
自律是普通人最大的武器
| 系列专栏 | 直达链接 |
|---|---|
| 🐧 Linux | 进入专栏 → |
| 🔤 C语言 | 进入专栏 → |
| 🔧 C++ | 进入专栏 → |
| 🐬 MySQL | 进入专栏 → |
| 🧩 算法刷题 | 进入专栏 → |
🏠 博客主页:虚无的纽扣
目录
- 引言
- [1、pthread 线程库](#1、pthread 线程库)
- 2、线程控制
- 3、理解线程库
-
- [管理块与线程 ID](#管理块与线程 ID)
- [独立栈与 clone](#独立栈与 clone)
- 线程局部存储
- 结语
引言
上一篇我们搞清楚了线程是什么:Linux 里没有真正的线程,只有用进程模拟出来的轻量级进程,多条执行流共享同一张地址空间,资源的划分本质是划分地址空间、划分虚拟地址范围,再往下数就是页表的划分。但概念归概念,真要写一个多线程程序,问题马上就来了:线程怎么创建?一个线程跑完了,谁来收它?不想要它了,能不能中途取消?它退出之后,结果又是怎么交回来的?
这一篇我们就把这些问题全部过一遍,顺带回答一个更本质的问题:Linux 内核里只有轻量级进程,那用户手里的"线程"到底是从哪来的?答案就藏在 pthread 线程库里。读完这一篇,你会对"线程是库维护的概念"这句话有全新的认识。
1、pthread 线程库
先解决一个前置问题:Linux 系统里并不存在真正意义上的线程,它的线程概念是用轻量级进程模拟的,内核里只有轻量级进程。所以 Linux 只会提供创建轻量级进程的系统调用,而不会提供所谓"创建线程"的系统调用。但用户只认线程。所有操作系统教材讲的都是线程,没有哪本书教用户去创建"轻量级进程",操作系统和用户之间就出现了概念上的鸿沟。为了填平这道鸿沟,也为了把轻量级进程的细节封装起来,系统在用户层提供了一层软件层,就是 pthread 线程库。它的核心工作,就是把创建轻量级进程的底层细节包装起来,给用户提供一批创建线程的接口。
所以 Linux 的线程实现是在用户层完成的,我们称之为用户级线程。C++11 的多线程,在 Linux 下本质也是封装了 pthread 库;所有语言的多线程,底层上几乎都是封装了 pthread 库。这也是为什么与线程有关的函数绝大多数都以 pthread_ 开头,而且编译的时候要带上 -lpthread 选项,显式链接这个第三方库。
2、线程控制
创建与等待
创建线程用的是 pthread_create。第一个参数是输出型参数,表示线程 id;第二个参数是线程的属性,大多数情况下设置为 nullptr,使用默认属性就行;第三个参数是一个返回值为 void*、参数也为 void* 的函数指针,也就是新线程启动后要执行的入口函数;第四个参数是传给这个函数指针的参数,没有就设为 nullptr。
c
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
线程创建好之后,默认情况下新线程要由主线程来等待------等待用的是 pthread_join------否则它的资源不会被释放,效果类似僵尸进程。当然也可以选择不等它、让它分离后自己释放,这个后面单独讲。
c
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
再说说传参和返回值。线程入口函数的返回值类型必须是 void*,这个返回值最终会被 pthread_join 拿到------pthread_join 的第二个参数就是用来获取新线程返回值的。子线程在返回时,可以选择返回一个强转为 void* 类型的整数,近似看作子线程的退出码(严谨的写法是先转成 intptr_t 再转 void*,避免 64 位下的指针截断问题)。给线程传递的参数和返回值可以是任意类型,包括自定义对象。另外要注意,main 函数结束代表主线程结束,一般也代表整个进程结束;而新线程对应的入口函数运行结束,只代表当前线程运行结束。
终止线程
终止一个线程有三种方式。第一种最简单:线程的入口函数进行 return,就是线程终止。但注意线程不能用 exit 终止,因为 exit 是终止进程的,一个线程调了 exit,整个进程都会被带走。第二种是 pthread_exit,专门用来终止当前线程。
c
#include <pthread.h>
void pthread_exit(void *retval);
第三种是 pthread_cancel,由别的线程来取消它,一般是主线程取消新线程。
c
#include <pthread.h>
int pthread_cancel(pthread_t thread);
取消的时候一定要保证新线程已经启动。线程如果被取消,join 拿到的返回值就是宏 PTHREAD_CANCELED(即 (void*)-1),注意它不是一个普通的整数返回值。
线程分离
线程默认是要被等待的。但如果主线程不想再关心新线程,而是希望它结束的时候自己释放资源,可以把新线程设置为分离状态。分离之后,新线程结束时会自动释放,主线程不需要再 join,硬要 join 会失败并返回非 0 错误码。线程分离可以由主线程分离新线程,也可以由新线程把自己分离。
c
#include <pthread.h>
int pthread_detach(pthread_t thread);
pthread_t pthread_self(void);
如果是主线程分离新线程,pthread_detach 的参数就填创建新线程时获得的线程 ID;如果是新线程自己分离自己,参数就用 pthread_self() 的返回值------这个接口可以让线程获得自己的线程 id,然后自己把自己分离。还要注意一点:被分离的线程依旧在进程地址空间里,进程的所有资源它照样可以访问、可以操作,变的只是"主线程不再等待它"这一件事。
3、理解线程库
管理块与线程 ID
线程 ID 是 pthread 库给每个线程定义的进程内唯一标识,由 pthread 库维护。由于每个进程有自己独立的内存空间,所以这个 id 的作用域是进程级的,而不是系统级的。
线程的概念既然是在库中维护的,库内部就一定会存在多个被创建好的线程------那库要不要管理线程?肯定要。所以在 pthread 库里也存在类似进程 PCB 一样的结构体------线程控制块 TCB(Thread Control Block),里面描述了线程应该有的属性:线程状态、线程 id、线程独立的栈结构和大小等等。就像当初讲过的 fopen,它内部会帮我们 malloc 一个 FILE 类型的结构体;pthread_create 也一样,它内部会自动帮我们 new 一个 TCB 类型的结构体对象。
那内核那边呢?内核中的结构体叫 LWP,即轻量级进程,这才是系统眼中的线程。用 ps -aL 命令就能看到,同一个进程的每条执行流 PID 相同,LWP 各不相同。

库为我们创建的 TCB 里保存的是用户级的信息,比如前面列出的那些;而跟系统调度相关的属性,如优先级、时间片等,则会被写进内核的 LWP 里。pthread_create 其实会在库中帮我们创建一大块管理当前线程的管理块,管理块由三部分构成------其实不止三部分,只是这三部分最重要:struct pthread 就是我们所说的 TCB,此外还有线程局部存储和线程自己独立的栈。

现在可以回答一个关键问题了:pthread_create 返回的那个线程 ID,其实并不是系统当中对应 LWP 的编号,有兴趣可以自己验证一下。那这个 tid 到底是什么?它就是线程在库中对应的管理块的虚拟地址!
理解了这一点,join 的机制也就通了。struct pthread 结构体里有一个属性 void* ret,线程执行结束进行 return 的时候,就会把返回值写进这个 ret 里。那为什么线程还需要 join?因为线程运行结束只是入口函数运行结束了,线程管理块还没有释放。join 时传入的第一个参数就是该线程的 tid,也就是管理块的地址,从管理块里把 ret 的值用第二个参数带出来,就拿到了线程退出时的退出信息,然后再把该线程对应的管理块全部释放掉------既得到了返回结果,又解决了内存泄漏问题。
独立栈与 clone
管理块里的线程栈,和内核里的 LWP 是怎么对上的?先看栈:每个线程都必须有自己独立的栈空间,这个栈空间就在每一个线程自己的管理块当中。主线程用的是整个虚拟地址空间里的栈,新线程用的是自己管理块里的线程栈,这样各条执行流函数调用产生的临时数据才不会互相踩踏。
创建过程则是两步联动。我们在自己的代码里调用 pthread_create,第一步会跳转到 pthread 库中创建一个线程管理块,再把管理块的起始地址传回来,这就是我们拿到的线程 ID;第二步,pthread_create 内部会调用系统调用,在内核中创建一个轻量级进程,并告诉系统新线程执行什么方法、栈在哪里。这个系统调用叫做 clone,专门用于创建轻量级进程。
c
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...
/* pid_t *parent_tid, void *tls, pid_t *child_tid */ );
第一个参数是新线程对应执行函数的入口地址,第二个参数就是该线程独占的栈结构------注意传的是栈区的最高地址(栈顶),因为栈是从高地址往低地址长的。将来 CPU 调度到新线程的时候,就会到对应的函数里执行里面的方法,并把执行方法产生的数据入栈------入到该线程自己的栈结构里。到这一步,内核数据和用户数据就在一定程度上联动起来了:库里的管理块记录用户级信息,内核里的 LWP 承担调度属性,clone 把两者绑在一起。
线程分离在这个模型里也就好理解了:它决定的其实就是线程执行结束时是否直接释放。TCB 中有一个标记,记录当前线程是否还需要被 join:需要的话,线程执行完之后管理块不会直接释放,等着 join 来收;不需要的话,线程执行完毕后,线程库检查到这个标记,就自动把管理块释放掉。所谓 pthread_detach,改的其实就是这个标记。
线程局部存储
最后补一个小特性。如果一个全局变量被 __thread 修饰,这个变量就叫做线程的局部存储。当一个变量同时被两个线程访问时,如果它是线程局部存储,就会在两个线程的局部存储区里分别拷贝一份,两个线程访问时各自访问自己的那份,不会再看到同一份变量。它的用途也很直白:当线程想要使用一个全局变量,又不想让这个全局变量被其他线程看到时,就可以把它修饰成线程局部存储。这其实是"线程独占资源"里最典型的一类------静态的私有数据不用自己传参维护,编译器帮我们按线程各存一份。
结语
到这里,线程的两篇就写完了。回顾一下:上篇讲的是线程的概念------Linux 用进程模拟线程,资源划分本质是划分页表;这一篇讲的是线程的控制------pthread 库在用户层把轻量级进程封装成线程,管理块、独立栈、clone 联动,构成了完整的用户级线程体系。你会发现之前学的进程知识在这里几乎全部复用了:fopen 的思想、PCB 的管理思路、僵尸进程的等待问题,换了个名字又出现了一遍。下一篇我们进入线程的重点话题:多个线程共享数据之后,怎么保证它们不打架------线程同步和互斥。