目录
前言:
昨天我们已经为大家梳理出了分页式存储结构,帮助大家贯通了虚拟内存地址,物理地址,页表之间的联系。
今天,我们将会继续来学我我们的线程的有关内容。
一、前文补充
我们上文曾经说过,为了节省空间资源,大多数情况下页表只会有部分映射关系。
所以有时候我们进行查页表工作的时候会不命中,导致发生软中断,执行新加载内存逻辑,最后命中。我们把这个过程叫做缺页中断。
那现在就有了一个新的问题交给我们。我们应该如何判断区分出是缺页中断了,还是访问虚拟地址越界了呢?
答案是,我们会进行页号合法化检查。操作系统在处理中断或者异常的时候,首先检查触发事件的虚拟地址的页号是否合法。如果页号合法但是页面不在内存中,则为缺页中断,如果页号不合法,就是越界访问。
再者就是,操作系统还会进行内存映射检查,操作系统检测出发事件的虚拟地址是否在当前进程的映射范围,如果在范围内,但是页面不在,就是缺页中断,如果不在范围内,就是越界访问。
所以我们可以知道,线程资源划分的真相就是,只要将虚拟地址空间进行划分,进程资源就天然被划分好了。
二、线程的优缺点
线程具有许多优点,我们以前在创建进程时,总是需要独立的资源分配和管理,但是线程时共享的系统资源。所以创建一个新线程的代价要比创建一个新进程小得多。
正因为如此,与进程之间的切换相比,线程之间的切换需要操作系统做的工作也少了很多。
上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别就是当你改变虚拟内存空间的时候,处理的页表缓存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*强制类型转化而来。
总结:
今天由于时间原因,我们不能更多的了解线程有关的概念,因为作者今天本人有事,直到晚上九点才回到家。缺少的内容,明天我们会补充。
希望今天的内容能够帮助大家。