Linux操作系统之线程(三)

目录

前言:

一、前文补充

二、线程的优缺点

三、运行顺序问题

四、时间片分配

五、共享资源问题

六、线程回收

总结:


前言:

昨天我们已经为大家梳理出了分页式存储结构,帮助大家贯通了虚拟内存地址,物理地址,页表之间的联系。

今天,我们将会继续来学我我们的线程的有关内容。

一、前文补充

我们上文曾经说过,为了节省空间资源,大多数情况下页表只会有部分映射关系。

所以有时候我们进行查页表工作的时候会不命中,导致发生软中断,执行新加载内存逻辑,最后命中。我们把这个过程叫做缺页中断。

那现在就有了一个新的问题交给我们。我们应该如何判断区分出是缺页中断了,还是访问虚拟地址越界了呢?

答案是,我们会进行页号合法化检查。操作系统在处理中断或者异常的时候,首先检查触发事件的虚拟地址的页号是否合法。如果页号合法但是页面不在内存中,则为缺页中断,如果页号不合法,就是越界访问。

再者就是,操作系统还会进行内存映射检查,操作系统检测出发事件的虚拟地址是否在当前进程的映射范围,如果在范围内,但是页面不在,就是缺页中断,如果不在范围内,就是越界访问。

所以我们可以知道,线程资源划分的真相就是,只要将虚拟地址空间进行划分,进程资源就天然被划分好了。


二、线程的优缺点

线程具有许多优点,我们以前在创建进程时,总是需要独立的资源分配和管理,但是线程时共享的系统资源。所以创建一个新线程的代价要比创建一个新进程小得多。

正因为如此,与进程之间的切换相比,线程之间的切换需要操作系统做的工作也少了很多。

上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别就是当你改变虚拟内存空间的时候,处理的页表缓存TLB(快表)会被全部刷新,这导致内存的访问在一段时间内会变得相当低效。但是在线程的切换中,不会出现这个问题。

线程占用的资源也比进程要少,在计算密集型应用时,为了能在多处理器系统上云霄,将计算分解到多个线程中实现。
但线程也并不是完美的。
它具有相当的性能损失,如果计算密集型线程的数量比可用的处理器多,那么就会造成较大的性能损失,指的是额外的同步和调度开销。
并且线程之间是缺少保护的,我们编写多进程时不经代码难度会提高,还需要更加全面深入的考虑全局变量之类的共享资源造成的问题(后文会讲)

一个程序里,我们一般推荐的线程个数:CPU核数*CPU物理个数


三、运行顺序问题

与进程一样,在Linux系统中,新创建的线程(通过pthread_create)与主线程(main函数所在线程)之间的运行顺序也是不确定的,具体由操作系统的线程调度器决定。

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>

void* thread_func(void* arg) 
{
    printf("新线程运行\n");
    return NULL;
}

int main() 
{
    pthread_t tid;
    pthread_create(&tid, NULL, thread_func, NULL);
    printf("主线程运行\n");
    pthread_join(tid, NULL);  // 等待新线程结束
    return 0;
}

对于这个代码,运行结果不是绝对的情况,如:

连续运行两次同一个程序,打印的顺序是不一样的。

所以,如果我们需要要求多个线程必须按照一定顺序,不能乱来的话,就需要依靠我们后面讲的同步机制来确保关键操作的顺序性。(互斥锁等)


四、时间片分配

我们之前讲进程时说过时间片的概念,在讲内存的时候也说过,时间片本质上就是一个倒着的计数。

那线程呢?如何分配多个线程之间的运行时间问题呢?

我们必须要先知道,线程创建出来,是要对进程的时间片进行瓜分的。

线程是 CPU 调度的基本单位,同一进程的多个线程共享该进程的时间片,由操作系统在线程间进一步细分。

若进程获得 10ms 的时间片,其内部的 2 个线程可能以 5ms + 5ms 或其他比例分配(取决于调度算法)。


五、共享资源问题

线程相较于进程,最大的区别就是线程之间的资源是共享的。所以会由此引发一系列问题。

其中最常见的就是我们在执行打印的时候。在我们没有进行任何保护的情况下,显示器文件就是共享资源。

所以,就会出现一些打印错乱。

cpp 复制代码
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>


void* print_thread(void* arg) 
{
    for (int i = 0; i < 10; i++)
     {
        printf("Thread %ld: %d\n", (long)arg, i);  // 不加锁,直接写入 stdout
    }
    return NULL;
}

int main() 
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, print_thread, (void*)1);
    pthread_create(&t2, NULL, print_thread, (void*)2);
    pthread_join(t1, NULL);//该函数我们后面会讲
    pthread_join(t2, NULL);
    return 0;
}

可以观察结果:

这个的原因跟我们之前说的运行顺序还是有点类似的。

但这也证实了一点:显示器文件(如 stdout)是共享资源,我们必须通过一定手段对它进行保护


其实进程内的函数是被所有线程共享的 ,所以如果函数涉及共享数据(如全局变量、静态变量、文件操作等),就可能引发**线程安全问题。**多个执行流,执行的同一个函数,不就造成了重入吗?

特别是不可重入函数在多线程环境下可能导致数据竞争或逻辑错误。

我们创建多个线程,可以调用同一个函数:

cpp 复制代码
void print_hello() 
{
    printf("Hello from thread!\n");
}

void* func(void* arg) 
{
    print_hello();  // 所有线程调用同一个函数
    return NULL;
}

int main() 
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, func, NULL);
    pthread_create(&t2, NULL, func, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

可以看见,我们并没有给我们的线程提供print_hello的函数地址,只是在func中调用的该函数。

但实际上两个线程还是都能调用该函数:


全局变量在线程内部也是共享的 。

cpp 复制代码
int counter = 0;  // 全局变量,共享数据

void test() 
{
    counter++; 
    printf("Counter: %d\n", counter);
}

void* func(void* arg) 
{
    for (int i = 0; i < 10; i++) 
    {
        test();
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, func, NULL);
    pthread_create(&t2, NULL, func, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Final counter: %d\n", counter);
    return 0;
}

可以看见,线程不像进程,调用了两次test函数,那么counter的值就会加2*10.


我们再次强调,所有线程共享进程的内存空间(代码段、数据段、堆、文件描述符等)。

那如果我们有一个线程之间出现异常 ,其他线程会怎么办呢?

答案是会直接崩溃掉。

cpp 复制代码
void* test1(void* arg) 
{
    while (1) 
    {
        printf("Normal thread running...\n");
        sleep(1);
    }
    return NULL;
}

void* test2(void* arg) 
{
    sleep(2);  // 让正常线程先运行
    printf("Crash thread will cause SIGSEGV!\n");
    int* ptr = NULL;
    *ptr = 42;  // 故意解引用空指针,触发段错误
    return NULL;
}

int main() 
{
    pthread_t t1, t2;
    pthread_create(&t1, NULL, test1, NULL);
    pthread_create(&t2, NULL, test2, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

我们用test2故意创造异常,可以看见,我们正常运行的死循环程序最终还是终止了。


六、线程回收

关于线程,也是要被主线程回收的。

我们通常使用pthread_join函数来进行线程的回收:

理由如同进程一样,是为了避免僵尸进程的问题出现。

对于线程,我们无法像进程一样ps直接看到线程的"僵尸"问题,但是这个的确是存在的。再者就是,我们主线程,也需要知道分支线程的执行结果完成的怎么样。

pthread_join的第一个参数就是我们一开始创建的pthread_t,线程id,负责回收该线程。

他的第二个参数是一个void**的二级指针,是一个输出型参数,我们传递空的二级指针进去,最后的出来的二级指针的内容被修改了:为我们需要的void*类型。

为什么是二级指针呢?

这是为了方便我们传回参数,pthread_join需要返回线程的 void* 值!!!

我们都知道pthread_create的返回值是一个void*的指针。为了获取,修改这个返回值,我们必须传递一个二级指针进去。

那为什么返回值类型是void*呢?

这是因为,如果是void类型,就没有固定的大小,就不会给他分配空间。如果不是void或者void*,就不方便我们传递一下自定义的参数类型。

如果我们返回的是一个指针,指针是有固定的大小的,我们就可以对其分配空间。最重要的就是这个void*类型的指针可以强制转化为任意类型的指针,并且他们的大小是一样的。

这意味着我们可以轻松访问一个大小非常大的自定义的结构体类型(用八字节的该结构体类型指针),结构体类型指针由void*强制类型转化而来。


总结:

今天由于时间原因,我们不能更多的了解线程有关的概念,因为作者今天本人有事,直到晚上九点才回到家。缺少的内容,明天我们会补充。

希望今天的内容能够帮助大家。

相关推荐
研究是为了理解1 小时前
Linux 阻塞等待框架
linux
小白的程序空间2 小时前
Anaconda Prompt中删除库虚拟环境【保姆级教程】
linux·开发语言·python
努力自学的小夏2 小时前
RK3568 Linux驱动学习——SDK安装编译
linux·arm开发·笔记·学习
basketball6162 小时前
Linux C 信号操作
linux·c语言·开发语言
网易独家音乐人Mike Zhou4 小时前
【Linux应用】在PC的Linux环境下通过chroot运行ARM虚拟机镜像img文件(需要依赖qemu-aarch64、不需要重新安装iso)
linux·c语言·stm32·mcu·物联网·嵌入式·iot
Ronin3054 小时前
【Linux系统】进程控制
linux·运维·服务器·ubuntu
-曾牛5 小时前
Linux搭建LAMP环境(CentOS 7 与 Ubuntu 双系统教程)
linux·运维·ubuntu·网络安全·渗透测试·centos·云计算运维
小嵌同学5 小时前
Linux 内存管理(2):了解内存回收机制
linux·运维·arm开发·驱动开发·嵌入式
绵绵细雨中的乡音5 小时前
消息队列与信号量:System V 进程间通信的基础
linux