1. 线程概念

1.1 啥是线程
-
以前的进程:我们可以将其理解为"内部只有一个线程的进程"。这并没有改变进程的定义,只是让我们对进程的理解更加丰富了。
-
进程 :强调独占 和隔离。不同进程间的资源是相互独立的,通信需要特殊机制(IPC)。
-
线程 :强调共享 和协作。同一进程内的多个线程天然共享大部分资源,通信非常便捷。
-
进程是资源分配的基本单位 ,线程是CPU调度的基本单位。
1.2 Linux 用进程模拟线程
1.2.1 为什么要这么设计?
-
复用代码,降低复杂度 :Linux内核本身是一个非常复杂的系统,进程管理(
task_struct)的代码已经非常成熟和健壮。如果为线程重新设计一套全新的管理结构(TCB,线程控制块),不仅工作量大,而且容易引入新的Bug。复用现有代码是内核开发的一个核心原则。 -
调度器无需修改 :Linux的进程调度器(CFS等)是为调度
task_struct设计的。如果将线程也实现为task_struct,那么调度器就可以零成本地支持线程调度,无需任何改动。 -
轻量级:这种实现方式让线程比传统意义上的进程更"轻量"。因为它们共享了大量资源,创建、销毁和切换的成本更低。
1.2.2 其他平台(如Windows)也是这样吗?
- 不是 。Windows有自己独立的、原生的线程实现。Windows内核中明确区分了
EPROCESS(进程对象)和ETHREAD(线程对象),它们的管理方式与Linux截然不同。Linux的"用进程模拟线程"是其独有的设计哲学,体现了"一切皆文件"、"机制与策略分离"的思想。
1.3 Linux下的具体实现:轻量级进程(LWP)
-
PCB (Process Control Block) vs TCB (Thread Control Block):
-
从概念上讲,进程和线程在内核中都需要被管理,即"先描述,再组织"。
-
但在Linux中,没有单独的TCB结构 。线程在内核中的描述符,就是**
task_struct**。 -
这个被用来描述线程的
task_struct,在Linux中被称为轻量级进程(Light Weight Process, LWP)。
-
-
如何实现共享?
-
一个传统的进程,内部只有一个执行流,其
task_struct指向一个独立的mm_struct(内存描述符)。 -
当我们创建一个线程时,Linux内核会创建一个新的
task_struct(LWP)。 -
这个新的
task_struct并不会创建自己的mm_struct,而是直接指向原来进程的mm_struct。 -
这样,多个
task_struct(LWP)就共享了同一个地址空间(mm_struct),从而实现了线程的资源共享。
-
结论:Linux线程,就是轻量级进程,或者说,就是用轻量级进程模拟实现的。
2. 资源划分 - 虚拟内存与页表
线程共享资源的本质,是共享了地址空间。而地址空间的管理,离不开虚拟内存和页表
2.1 为啥要虚拟内存和页表
-
隔离
每个进程有独立的虚拟地址空间,A 进程崩溃不影响 B 进程,安全性提高。
-
保护
页表里可以标记权限(读/写/执行、用户/内核),防止程序非法访问其他进程或内核内存。
-
简化编程
每个进程都以为自己独占从 0 开始的连续内存,编译器、链接器无需操心物理内存布局。
-
高效利用物理内存
-
物理页框可以非连续分配,避免外部碎片。
-
支持按需分页(用到才分配物理页)。
-
支持共享(多个进程的虚拟页映射到同一物理页,如共享库)。
-
支持写时拷贝(fork 时只复制页表,物理页延迟复制)。
-
支持交换(将不常用页换出到磁盘)。
-
-
页表是实现上述功能的硬件映射机制
-
由 MMU 根据页表完成虚拟地址 → 物理地址的转换。
-
多级页表节省了页表本身占用的内存(只用实际需要的页表项)。
-
2.2 物理内存管理:以页(Page)为单位

-
页框/页帧 :物理内存被操作系统划分为大小固定的块,通常为4KB。这些块称为页框。
-
页:磁盘(或可执行文件)中的数据也被划分为同样大小的4KB块,这些块称为页。
-
为什么是4KB? 这是硬件和操作系统共同决定的,它平衡了管理开销和I/O效率。
4GB / 4KB = 1,048,576,即一个32位系统有大约100万个物理页框。
2.2.2 操作系统如何管理物理页框?
操作系统需要知道每个物理页框的当前状态(是否被使用、是否脏了、被哪个进程使用等)。为此,内核为每一个物理页框都创建了一个**struct page**对象。
-
数据结构 :
struct page存在于<linux/mm_types.h>中,它包含了页框的状态标志(flags)、用于LRU(最近最少使用)链表的指针(lru)等信息。 -
关键点 :
struct page中不直接存储该页框的物理地址。 -
为什么? 因为物理地址可以通过计算得到!所有的
struct page对象被存放在一个全局数组mem_map[]中。数组的下标(index)就是该物理页框的编号。-
物理页框的起始地址 =
mem_map数组的起始物理地址 +index * 4KB -
这种设计极大地提高了管理效率,将对物理内存的管理转化为对数组的管理。
-
c
/* include/linux/mm_types.h */
struct page {
unsigned long flags; /* 页状态:脏、锁定、活动等 */
struct list_head lru; /* LRU链表,用于页面回收 */
struct address_space *mapping; /* 所属地址空间(文件或匿名) */
pgoff_t index; /* 在地址空间中的偏移(以页为单位) */
atomic_t _mapcount; /* 被页表映射的次数,-1表示空闲 */
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* 内核虚拟地址(高端内存为NULL) */
#endif
/* 其他字段用于slab分配器、私有数据等,一般场景不用深究 */
};
| 字段 | 作用 |
|---|---|
flags |
记录页的状态(如 PG_locked、PG_dirty、PG_active),定义在 <linux/page-flags.h> |
lru |
将页挂入 LRU(最近最少使用)链表,供内存回收算法使用 |
mapping |
指向页所属的地址空间:文件页指向文件的 address_space,匿名页指向 anon_vma |
index |
页在地址空间中的页偏移量,用于定位页内数据在文件或匿名映射中的位置 |
_mapcount |
该页在页表中被映射的次数;为 -1 时表示没有被任何页表引用,可以回收 |
virtual |
该页在内核空间的虚拟地址(仅当低端内存时有效;高端内存需临时映射) |
2.3 虚拟地址到物理地址的转换:页表
用户程序操作的是虚拟地址,CPU执行指令时,需要的是物理地址。这个转换过程由MMU(内存管理单元) 硬件完成,转换的依据就是页表。
2.3.1 为什么不用单张页表?
一个32位系统的虚拟地址空间是4GB,如果用一张巨大的页表来映射,这个页表本身就会占用4GB / 4KB * 4字节 = 4MB的内存(假设每个页表项4字节)。如果每个进程都维护这样一张页表,内存开销将无法接受。
2.3.2 多级页表(以二级页表为例)

Linux采用多级页表来节省内存。
-
虚拟地址结构:一个32位虚拟地址被划分为三部分:
-
10位 :页目录索引(
[0, 1023]) -
10位 :页表索引(
[0, 1023]) -
12位 :页内偏移(
[0, 4095])
-
-
转换过程:
-
CR3寄存器 :CPU中的CR3寄存器存放着当前进程的页目录的物理地址。当进程切换时,CR3的值也会被切换。
-
第一步:查页目录 。MMU取出虚拟地址的高10位,作为索引,在CR3指向的页目录中找到对应的页表项 。这个页表项里存放着页表的物理地址。
-
第二步:查页表 。MMU取出虚拟地址的中间10位,作为索引,在第一步找到的页表中找到对应的页表项 。这个页表项里存放着物理页框的起始物理地址。
-
第三步:计算物理地址。MMU将找到的物理页框起始地址,加上虚拟地址的低12位(页内偏移),得到最终的物理地址。
-
第四步:访问物理内存。MMU拿着这个物理地址,通过内存总线去访问物理内存中的具体字节。
-
-
节省内存的原理:
- 一个进程并不需要所有的页表。比如,如果一个进程只使用了地址空间的底部和顶部,那么只需要创建映射底部区域的页表和映射顶部区域的页表,中间的页目录项可以指向空的页表或标记为无效,这样就节省了大量内存。
2.4 页表项(PTE)的内容
页表项不仅包含物理页框地址,还包含一系列重要的标志位:
-
物理地址:映射到的物理页框的起始地址。
-
是否命中 :
Present位。如果为1,表示该虚拟页已映射到物理页框,可以直接访问;如果为0,则访问该地址会触发缺页中断。 -
RWX权限:Read/Write/eXecute权限位,控制对该页的访问权限。
-
U/K权限:User/Supervisor权限位,区分该页是用户空间还是内核空间,防止用户程序访问内核内存。
2.5 TLB(快表/缓存)

- 当CPU给MMU传新虚拟地址之后,MMU先去问TLB那边有没有,如果有就直接那物理地址发到总线给内存。但如果TLB那没有(Cache Miss),这时候MMU就去问页表,在页表中找到之后MMU除了把地址发到总线传给内存,还把这条映射关系给TLB,让他记录一下刷新缓存
2.6 缺页异常(Page Fault)
当 CPU 发送给 MMU 的虚拟地址在 TLB 和 页表 中都找不到对应的物理页时,就会触发缺页异常 。这是一种由硬件中断触发、可由软件逻辑纠正的错误。

2.6.1 触发条件
-
目标内存页在物理内存中不存在对应的物理页
-
或者存在但不具备所需权限(如写只读页、执行非执行页)
此时 CPU 无法获取数据,暂停当前操作,触发缺页中断,进程从用户态切换到内核态 ,由内核的 Page Fault Handler 进行处理。
2.6.2 缺页中断的分类与处理
| 类型 | 别名 | 说明 | 处理方式 |
|---|---|---|---|
| Hard Page Fault | Major Page Fault (硬缺页错误 / 主要缺页错误) | 物理内存中不存在对应的物理页 | 需要从磁盘设备读取页面到物理内存,再由 MMU 建立虚拟地址到物理地址的映射 |
| Soft Page Fault | Minor Page Fault (软缺页错误 / 次要缺页错误) | 物理内存中存在对应的物理页(如其他进程已加载) | 仅需在当前进程的页表中建立映射,无需磁盘 I/O,常见于多进程共享内存区域 |
| Invalid Page Fault | 无效缺页错误 | 访问了非法地址,如越界、空指针解引用等 | 无法修复,内核直接终止进程,常见错误为 Segmentation Fault |
2.7 内存申请与写时拷贝
-
申请内存(如malloc):
-
程序调用
malloc,库函数在用户空间进行内存管理。如果堆空间不够,会调用brk或mmap系统调用向内核申请内存。 -
内核收到请求后,并不会立即分配物理内存。它只会在进程的
vm_area_struct(虚拟内存区域)中记录:从虚拟地址A到虚拟地址B这一段区域被分配了。 -
真正分配物理内存的时机,是当程序第一次访问这些虚拟地址时。此时MMU发现页表项中的
Present位为0,触发缺页中断。 -
缺页中断处理程序会去查找
vm_area_struct,判断这个地址是否合法。如果合法,就会去物理内存的mem_map数组中找一个空闲的struct page(空闲页框),建立该虚拟页到该物理页框的映射(填充页表项),然后重新执行引发中断的指令。
-
-
写时拷贝(COW):
-
当使用
fork()创建子进程时,为了效率,子进程不会复制父进程的物理内存。 -
子进程会复制父进程的页表,但将这些页表项标记为只读。父进程和子进程的页表都指向同一块物理内存。
-
如果父子进程都不修改这些内存,那么就一直共享。
-
如果其中一个进程(比如子进程)试图写入 一个共享页,MMU会因为"只读"权限而触发一个页错误。
-
内核的缺页中断处理程序发现这是一个COW场景,就会重新分配一个新的物理页框 ,将原来页框的内容拷贝到新页框,并更新子进程的页表项,使其指向新页框,同时赋予可读可写权限。然后重新执行写入指令
-
3. 线程共享的本质
现在,我们可以将线程和内存管理知识结合起来,揭示线程共享的本质。
3.1 资源划分 = 地址空间划分 = 页表划分
-
虚拟地址是资源的代表。一个进程或线程能访问哪些资源(内存),完全由它"看到"的虚拟地址范围决定。
-
地址空间的划分 :
mm_struct结构体描述了一个完整的地址空间,而其中的vm_area_struct链表则精确地划分了地址空间中各个区域的用途(代码段、数据段、堆、栈、共享库等)。 -
页表是地图:页表是虚拟地址到物理地址的映射图,它决定了哪些虚拟地址是有效的,以及它们指向哪块物理内存。
结论 :线程进行资源划分,本质上是划分地址空间,获得一定范围的合法虚拟地址。而获得合法虚拟地址的过程,在底层就是对页表进行配置的过程。
3.2 资源共享 = 地址空间共享 = 页表条目共享
-
共享
mm_struct:当我们创建一个新线程时,新的task_struct(LWP)被创建,但它的mm_struct指针直接指向了主进程(或其他线程)的mm_struct。 -
共享页表 :因为
mm_struct是共享的,所以所有线程的页表也是共享的(都是指向同一份页目录和页表)。这意味着:-
如果一个线程在共享内存区(如堆)分配了一个对象,其他线程可以通过相应的虚拟地址立即访问到它。
-
如果父进程的代码段被映射到物理内存,所有子线程也都能看到同样的代码。
-
-
部分独占的资源:
-
栈 :每个线程必须有自己独立的栈,否则函数调用就会混乱。在共享的地址空间中,如何实现独立栈?操作系统在创建线程时,会在共享的
mm_struct中划分出一块独立的虚拟地址范围作为该线程的栈。虽然这些栈在同一个地址空间中,但它们的地址范围是不重叠的,所以每个线程的栈是独立的。 -
寄存器 :每个线程有自己独立的寄存器上下文,这是线程切换时需要保存和恢复的内容。其中最重要的就是CR3寄存器。
-
3.3 为什么线程切换比进程切换更轻量?
-
进程切换 :需要保存和恢复整个CPU上下文,最关键的是需要切换CR3寄存器,使其指向新进程的页目录。切换CR3会使得CPU的TLB(快表,用于缓存地址转换结果)全部失效,导致后续访问内存时TLB未命中,性能开销较大。
- 进程切换不仅需要切换寄存器,还会导致TLB和各级CPU Cache失效。当被切换出去的进程再次运行时,其指令和数据需要重新加载到Cache和TLB中,这个重新加载的过程是一个巨大的性能开销。
-
线程切换(同一进程内) :由于线程共享地址空间,它们也共享页表。因此,线程切换时不需要切换CR3寄存器!这意味着TLB中的内容依然有效,大大减少了地址转换的开销。这就是线程切换比进程切换更"轻量"的核心原因。
总结 :执行流看到的资源,本质是它拥有的合法虚拟地址。线程间资源共享,本质上就是虚拟地址的共享,也就是页表条目(PTE)的共享。
3.4 线程的特性总结
-
独立调度:线程是操作系统调度的基本单位。每个线程都拥有一组独立的寄存器(即线程的上下文),这背后体现了"线程是被独立调度的"这一事实。
-
独立栈结构:每个线程都有自己的栈空间,用于保存函数调用链、局部变量等,保证线程执行的独立性。线程是一个"动态"的概念,是进程中一条独立的执行流。
-
数据缓存:对于用户数据,可以利用CPU的Cache进行缓存,这对开发者是"透明"的。
3.5 线程异常
- 单个线程如果出现除零错误,野指针问题导致线程崩溃,进程也随之崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也随即退出
3.6 多线程程序的特性与挑战
-
时间片分配:操作系统将CPU时间片等分给进程内的每个线程,它们轮流获得执行机会。
-
健壮性问题:如果一个线程因为段错误、除零等异常而崩溃,整个进程都会崩溃。因为线程共享进程的地址空间,一个线程的严重错误会污染整个进程的内存空间。这降低了程序的健壮性,要求编写多线程程序时,必须对共享资源、同步等问题有更深入、更全面的考虑。
-
输出混杂:多个线程同时向同一个输出设备(如终端)打印信息时,如果不加同步,消息可能会交错混杂在一起,如示例所示。
3.7 关于内存越界
-
int array[10]; for(int i = 0; i <= 10; i++) array[i] = 0;- 这里假如变量i 的虚拟地址等于array10 (这里是不小心越界了)的地址,就会导致死循环,到10时把i修改成0
-
越界了不一定会立即崩溃 。因为
array[10]这个地址可能仍然在当前线程的合法虚拟地址范围内(比如刚好是栈上的一个对齐空隙,或者属于堆上另一块已分配的内存)。 -
只有当越界访问的虚拟地址是未映射的 (页表项
Present位为0),或者权限不匹配的 (比如尝试写入只读的代码段),才会触发缺页异常,操作系统发现这是一个非法访问,最终发送SIGSEGV信号导致进程崩溃。 -
这提醒我们,内存越界是非常隐蔽且危险的Bug,因为它可能在很长时间内都不会导致程序崩溃,但会悄悄破坏其他线程的数据,造成难以调试的随机问题。
4. 线程控制
4.1 轻量级进程(LWP)
在Linux中,通过ps -aL命令可以观察到线程的LWP(Light Weight Process,轻量级进程)。
bash
whb@bite:~$ ps -aL
PID LWP TTY TIME CMD
902075 902075 pts/0 00:00:00 test_thread
902075 902076 pts/0 00:00:00 test_thread
-
PID (Process ID):进程ID,表示这个线程所属的进程。
-
LWP (Light Weight Process) :轻量级进程ID。在Linux内核中,CPU调度器调度的最小单位就是LWP,而不是进程的PID。
-
结论 :Linux内核并没有真正意义上的"线程"概念,它只有"轻量级进程(LWP)"。用户所感知的"线程",在Linux底层是通过LWP来模拟实现的。因此,Linux中不存在真正意义上的线程 ,只有用轻量级进程模拟的线程。这种实现方式被称为用户级线程。
4.2 pthread 原生线程库
-
问题 :为什么在Linux上写多线程程序需要链接
pthread库(-lpthread)? -
答案:
-
Linux内核只提供创建"轻量级进程(LWP)"的系统调用,例如
clone()。 -
为了让开发者能够更方便地使用"线程"这个概念,社区开发了
pthread(POSIX thread)库。 -
pthread库的作用 :它在用户层对内核提供的clone等系统调用进行了封装。当用户调用pthread_create时,库内部会调用clone并传递合适的标志(flags),创建一个新的LWP。这个LWP与调用进程共享虚拟内存空间、文件描述符等资源,从而在用户层面表现为一个"线程"。 -
结论 :Linux的线程实现,本质是在用户层实现的,因此被称为用户级线程 。
pthread库也被称为原生线程库(Native POSIX Thread Library, NPTL)。
-
4.2.1 使用pthread库却未链接
例:
cpp
#include <pthread.h>
#include <iostream>
#include <unistd.h>
void *threadrun(void *args) {
std::string name = (const char*)args;
while(true) {
std::cout << "我是新线程: name: " << name << std::endl;
sleep(1);
}
return nullptr;
}
int main() {
pthread_t tid;
// 创建线程
pthread_create(&tid, nullptr, threadrun, (void*)"thread-1");
while(true) {
std::cout << "我是主线程..." << std::endl;
sleep(1);
}
return 0;
}
编译错误:
bash
g++ -o test_thread TestThread.cc
/usr/bin/ld: in function `main':
TestThread.cc:(.text+0x107): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status
错误原因 :pthread_create 函数在 pthread 库中实现,而 g++ 默认不链接该库。
解决方案 :编译时需要显式链接 pthread 库。
bash
g++ -o test_thread TestThread.cc -lpthread
4.2.2 使用 C++11 标准库thread却未链接
例:
cpp
#include <iostream>
#include <thread>
#include <unistd.h>
void hello() {
while (true) {
std::cout << "新线程: hello world, pid: " << getpid() << std::endl;
sleep(1);
}
}
int main() {
std::thread t(hello);
while (true) {
std::cout << "我是主线程..." << ", pid: " << getpid() << std::endl;
sleep(1);
}
t.join();
return 0;
}
编译错误:
bash
g++ -o test_thread TestThread.cc -std=c++11
/usr/bin/ld: /tmp/cctmzPsU.o: in function `std::thread::thread<void (&)(), , void>(void (&)())':
TestThread.cc:(.text._ZNSt6threadC2IRFvvEJEvEEOT0_[_ZNSt6threadC5IRFvvEJEvEEOT0_]+0x33): undefined reference to `pthread_create'
collect2: error: ld returned 1 exit status
错误原因 :即使使用了C++11的 std::thread,在Linux平台下,其底层实现依然依赖于 pthread 库。因此,编译时仍然需要链接 pthread 库。
解决方案:
bash
g++ -o test_thread TestThread.cc -std=c++11 -lpthread
关于语言跨平台性 :
C++标准库(如 std::thread)为了保证跨平台,其实现方式是在不同操作系统上封装各自的原生线程接口。
-
Linux :封装
pthread库。 -
Windows :封装
CreateThread等Windows API。 -
这是通过条件编译等技术,在库代码内部对不同平台进行适配,对用户提供了统一的接口,即所谓"大力出奇迹"
4.3 Linux线程控制核心接口
4.3.1 线程创建:pthread_create
c
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine) (void *),
void *arg);
-
参数:
-
thread:输出型参数 。用于返回新创建的线程ID(pthread_t类型,通常是一个无符号长整型)。 -
attr:线程属性。通常设置为nullptr,表示使用默认属性。 -
start_routine:函数指针 。新线程创建后将要执行的函数入口。该函数类型为void* (void*)。 -
arg:传递给start_routine函数的参数。
-
-
返回值:
-
成功:返回
0。 -
失败:返回一个非零的错误码(errno),并且
thread指向的内容是未定义的
-
4.3.2 线程等待pthread_join
线程创建后,类似于进程,也需要被等待,否则会产生类似于"僵尸进程"的内存泄漏问题。
c
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
-
功能 :阻塞调用线程(通常是主线程),直到指定的
thread线程终止。 -
参数:
-
thread:要等待的线程ID。 -
retval:输出型参数 ,二级指针。用于接收被等待线程的退出码(即start_routine的返回值)。如果不需要,可以设置为nullptr。
-
-
返回值 :成功返回
0,失败返回错误码。
4.3.3 线程ID获取:pthread_self
c
#include <pthread.h>
pthread_t pthread_self(void);
-
功能:获取调用线程自身的线程ID。
-
返回值:返回调用线程的ID。
-
注意 :
pthread_t类型的线程ID是一个不透明的类型,通常是一个结构体指针或无符号长整型。它用于在pthread库层面标识线程,不要将其与内核调度的LWP混淆。我们不应该直接暴露LWP的概念给用户。
4.3.4 线程终止:pthread_exit
c
#include <pthread.h>
void pthread_exit(void *retval);
-
功能:终止调用它的线程。
-
retval是一个指针,指向线程的返回值。这个返回值可以被同一进程中的其他线程通过pthread_join获取。 -
调用
pthread_exit后,当前线程立即终止,其后续代码(如下例中的std::cout)将不会被执行。
c
void* routine(void* args) {
Task *t = static_cast<Task*>(args);
sleep(100);
Result *res = new Result(t->Execute());
sleep(1);
// return res; // 1. 正常返回,等同于 pthread_exit(res)
// exit(13); // 2. 进程退出,所有线程都将终止
pthread_exit(res); // 3. 仅当前线程退出,并返回 res
std::cout << "haha,新线程不应该看到这里" << std::endl; // 此行永不执行
}
-
return res:在线程函数中,return等同于隐式调用了pthread_exit,返回值会被传递。
-
exit(13):这是进程级别的退出。一旦调用,整个进程(包括所有线程)都会终止,是一种更"激烈"的退出方式。 -
pthread_exit(res):这是线程级别的优雅退出,仅终止当前线程,并将res作为返回值。
4.3.5 线程取消:pthread_cancel
c
#include <pthread.h>
int pthread_cancel(pthread_t thread);
-
功能:向一个指定线程发送取消请求
-
调用时机 :主线程或其他线程调用
pthread_cancel时,必须确保目标线程已经启动。否则,取消请求可能无效或产生未定义行为。 -
并发不确定性 :主线程和新创建的线程谁先运行是不确定的,由操作系统的调度器决定。这可能导致竞争条件,因此在编写代码时必须考虑线程的同步问题。
4.3.6 线程分离:pthread_detach
c
#include <pthread.h>
int pthread_detach(pthread_t thread);
-
功能:将一个线程设置为分离状态。
-
一个线程可以是可结合的 或分离的。
-
可结合线程 :默认状态。当它终止时,其资源(如线程控制块TCB、栈空间)不会被自动回收。必须由另一个线程(通常是主线程)对其调用
pthread_join来回收资源并获取其返回值。 -
分离线程 :当它终止时,其所有资源会被系统自动回收。不需要、也不能对它调用
pthread_join。尝试join一个已分离的线程会出错。
5. 线程在库中的管理
5.1 为啥线程在库中
用户级线程库 pthread:
-
操作系统不会直接提供线程接口给程序员。我们使用的
pthread_create等接口,是由用户态的原生线程库(Native POSIX Thread Library, NPTL)提供的。 -
这个库(通常是
libpthread.so.0)是一个用户级别的库,它封装了底层的轻量级进程接口。 -
通过
ldd命令可以查看其位置:
c
ldd ./your_program
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
-
libpthread.so本身是一个动态库:-
它是ELF格式的可执行文件,但在运行时被动态链接到用户进程的地址空间中。
-
这意味着,
libpthread.so的代码和数据会通过动态链接 和地址重定向,被加载到内存,并映射到当前进程的地址空间中。进程的代码区可以访问到库内部的函数和数据。
-
既然线程的概念是在用户库中维护的,那么库就必须对它们进行管理。其核心原则是:先描述,再组织。
5.2 线程控制块 (TCB)
库内部会为每一个创建的线程维护一个结构体,通常称为 struct pthread 或 struct tcb(线程控制块)。这个结构体描述了线程的所有属性:
-
线程状态:运行、就绪、阻塞、终止等。
-
线程ID (
pthread_t):用户库层面的线程标识符。 -
线程独立的栈结构:每个线程必须有自己的栈空间,用于保存局部变量、函数调用信息等。
-
线程栈大小:可以配置,默认通常为8MB。
-
优先级、时间片:调度相关的信息。
-
joinable状态:标记该线程是可结合的(需要被join)还是已分离的。 -
LWP(Light Weight Process):与内核中轻量级进程的PID相关联的ID。 -
tid:线程ID,与LWP关联。 -
上下文:用于线程切换时保存和恢复CPU寄存器状态。
5.3 线程栈与存储布局
每个线程都必须有自己独立的栈空间。这些栈空间和TCB都位于进程地址空间的**共享区(或mmap映射区域)**中。


5.4 线程的创建与 clone 系统调用
当我们在用户态调用 pthread_create 时,库内部会做以下事情:
-
在共享区分配空间 :为该线程分配一个
struct pthread(TCB) 和独立的线程栈。 -
初始化TCB:填充线程的各种属性(状态、栈指针、优先级等)。
-
调用系统调用
clone:clone是Linux特有的系统调用,它类似于fork,但可以精确控制父子进程(或线程)之间共享哪些资源。
c
int clone(int (*fn)(void *), void *stack, int flags, void *arg, ...);
-
-
fn:新线程要执行的函数指针,即routine函数。 -
stack:新线程使用的栈地址,就是我们刚刚在共享区分配的线程栈。 -
flags:关键参数,通过指定CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND等标志,告诉内核新的LWP与调用者共享内存空间、文件系统、文件描述符、信号处理表等。正是这些标志,使得新的LWP看起来像一个"线程"。 -
arg:传递给fn的参数。
-
-
内核创建LWP :
clone系统调用在内核中创建一个新的task_struct(LWP),并根据flags设置其资源与父LWP共享。这个新的task_struct拥有了一个在内核中唯一的PID(我们称之为LWP ID)。
总结 :pthread_create 在用户空间创建TCB和栈,并通过 clone 系统调用在内核空间创建一个与其关联的轻量级进程。这就是"用户线程 vs LWP"的联动关系。
5.5 用户线程与LWP的映射模型:1:1
-
NPTL (Native POSIX Thread Library) 采用的模型是 1:1。
-
含义 :每一个用户态的线程(由
pthread_create创建),都直接对应一个内核态的轻量级进程(LWP)。 -
优点:实现简单,多个线程可以真正并行地在多核CPU上运行(因为内核调度器调度的是LWP)。一个线程阻塞(如I/O)不会影响其他线程。
-
结论:Linux的线程实现,就是用户态线程库与内核态轻量级进程的完美结合。
5.6 pthread_join与内存泄露
pthread_join 的必要性:
-
前面提到,可结合的线程终止后,其资源(TCB、栈)不会自动释放。
-
调用
pthread_join有两个目的:-
等待:阻塞调用线程,直到目标线程终止。
-
回收资源:释放目标线程占用的TCB和栈等资源。
-
获取返回值 :通过第二个参数
void **retval获取目标线程的返回值。
-
c
void *ret = nullptr;
// 等待 tid 线程结束,并将其返回值存入 ret
pthread_join(tid, &ret);
// 假设 routine 返回了 (void*)10
// 则 ret 的值就是 (void*)10
- 重要 :如果一个可结合的线程终止了,但没有其他线程对它调用
pthread_join,那么它的资源就无法被回收,这将导致内存泄漏(确切地说是线程资源泄漏)。
5.7 总结
-
Linux无真线程:Linux内核只提供轻量级进程,通过共享资源来模拟线程。
-
线程在库中 :线程的概念、管理(TCB、栈)都是在用户态的
libpthread库中实现的。 -
库映射到进程 :
libpthread.so作为动态库,被映射到进程地址空间的共享区(mmap区域)。 -
创建线程 = 用户态准备 + 内核态
clone:pthread_create在共享区分配TCB和栈,然后调用clone系统调用创建对应的LWP。 -
模型为1:1:每个用户线程对应一个内核LWP。
-
线程必须被管理:
-
可结合的线程终止后,必须由另一个线程调用
pthread_join来回收其资源。 -
可以调用
pthread_detach将线程设为分离状态,使其终止时自动回收资源。
-
-
线程有独立栈:每个线程在共享区拥有自己独立的栈空间,确保函数调用和局部变量互不干扰。
-
线程ID (
pthread_t):是用户库中TCB的标识符,不同于内核中的LWP ID。 -
取消和退出:
-
pthread_exit:退出当前线程。 -
exit:退出整个进程。 -
pthread_cancel:请求取消另一个线程。
-