线程概念与控制

重点:

1. 深刻理解线程

2. 深刻理解虚拟地址空间

3. 了解线程概念,理解线程与进程区别与联系。

4. 学会线程控制,线程创建,线程终⽌,线程等待。

5. 了解线程分离与线程安全概念。

6. 掌握线程与进程地址空间布局

7. 理解LWP和原⽣线程库封装关系

0速查

1. 线程入口函数写法

cpp 复制代码
写法:

void *thread_run(void *arg)
{
// 线程执行逻辑
return NULL;
}

函数类型:

void *(*start_routine)(void *)

 参数是 void *arg,返回值是 void *。这个函数地址就是 pthread_create 的第三个参数。

2. pthread_create

cpp 复制代码
功能: 创建一个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);

参数:
thread:输出型参数,返回线程 ID
attr:设置线程属性,传 NULL 表示默认属性
start_routine:线程启动后要执行的函数
arg:传给线程函数的参数

返回值: 成功返回 0;失败返回错误码。
cpp 复制代码
例:
pthread_t tid;
int ret = pthread_create(&tid, NULL, thread_run, NULL);
if (ret != 0)
{
fprintf(stderr, "pthread_create error: %s\n", strerror(ret));
}

要记: pthread 系列函数一般直接看返回值,不是只看 errno。

3. pthread_self

cpp 复制代码
功能: 获取当前线程自己的 ID
原型:
pthread_t pthread_self(void);

写法:
pthread_t tid = pthread_self();
printf("tid = %lu\n", (unsigned long)tid);

记: pthread_t 是 pthread 库层面的线程 ID;对 Linux 当前 NPTL 实现来说,本质上像进程地址空间中的一个地址。

4. pthread_exit

cpp 复制代码
功能: 线程主动终止
原型:
void pthread_exit(void *value_ptr);

参数:
value_ptr:线程退出时带出去的返回值;不要指向局部变量

写法:
int *p = (int *)malloc(sizeof(int));
*p = 100;
pthread_exit((void *)p);

记: pthread_exit 或 return 返回的指针,必须指向全局区或 malloc 出来的内存,不能指向线程函数栈上的局部变量。

5. pthread_cancel

cpp 复制代码
功能: 取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);

参数:
thread:目标线程 ID

返回值: 成功返回 0;失败返回错误码。

写法:
pthread_cancel(tid);

6. pthread_join

cpp 复制代码
功能: 等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);

参数:
thread:线程 ID
value_ptr:接收线程返回值;不关心可传 NULL

返回值: 成功返回 0;失败返回错误码。

写法:
void *ret = NULL;
pthread_join(tid, &ret);

记:
线程 return → ret 里是线程函数返回值
线程 pthread_exit(x) → ret 里是 x
线程被 pthread_cancel → ret == PTHREAD_CANCELED

验证写法:
if (ret == PTHREAD_CANCELED)
{
printf("thread canceled\n");
}

7. pthread_detach

cpp 复制代码
功能: 分离线程,线程退出时自动释放资源
原型:
int pthread_detach(pthread_t thread);

参数:
thread:目标线程 ID

写法:
pthread_detach(tid);
pthread_detach(pthread_self());

记:
默认新线程是 joinable
joinable 线程退出后需要 pthread_join
detached 线程退出后自动释放资源
joinable 和 detached 冲突,不能同时存在。

8. 三种线程结束方式

方式 1:return

cpp 复制代码
void *thread1(void *arg)
{
int *p = (int *)malloc(sizeof(int));
*p = 1;
return (void *)p;
}

方式 2:pthread_exit

cpp 复制代码
void *thread2(void *arg)
{
int *p = (int *)malloc(sizeof(int));
*p = 2;
pthread_exit((void *)p);
}

方式 3:pthread_cancel

cpp 复制代码
void *thread3(void *arg)
{
while (1)
{
sleep(1);
}
return NULL;
}

第一章 Linux线程概念

1-1 什么是线程

• 在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是"⼀个进程内部 的控制序列"

• ⼀切进程⾄少都有⼀个执⾏线程

进程是资源的容器;线程是执行的载体

也就是:

  • 进程负责"有啥资源"
  • 线程负责"谁来跑这些代码"

所以一个进程里可以有多个线程,大家共享同一份进程资源,但各自有自己的执行现场。

• 线程在进程内部运⾏,本质是在进程地址空间内运⾏ 。线程不是独立拥有一整套资源,它是"借住"在进程里的。

比如一个进程有:

  • 代码段
  • 全局数据区
  • 打开的文件
  • 映射区

那么这个进程里的多个线程,通常都能访问这些东西。所以线程的核心特点就是:共享资源、并发执行。

• 透过进程虚拟地址空间,可以看到进程的⼤部分资源,将进程资源合理分配给每个执⾏流,就形 成了线程执⾏流

• 在Linux系统中,在CPU眼中,看到的PCB都要⽐传统的进程更加轻量化

在 Linux 系统中,CPU 眼中看到的 PCB 比传统进程更加轻量化。配图里把多个 task_struct 指向同一个虚拟内存 mm_struct,这就是 Linux 对线程/轻量级进程的典型处理方式。

即:多个执行流共享同一份地址空间,但内核里仍然用类似 task_struct 的结构管理它们

理解:

  • 在 Linux 内核里,并没有"线程"这种和"进程"完全不同的第二套实现
  • Linux 更常见的做法是:线程本质上也是一种 task
  • 只是这些 task 之间共享更多资源,比如地址空间、文件描述符表等

所以常说:Linux 线程 = 共享资源的轻量级进程

1-2 分⻚式存储管理

虚拟地址和页表的由来

外部碎片问题:

如果没有虚拟内存和分页机制,那么每个程序在物理内存上都必须占连续空间。程序1和程序2的代码段、数据段、栈段都得直接映射到物理内存的连续区域。

这样会有什么问题

程序大小不一样,运行和退出时间不同,于是物理内存会被切成很多大小不一的碎片。比如:

  • 程序 A 占 10MB
  • 程序 B 占 20MB
  • 程序 C 占 8MB

后来 B 退出了,中间就空出 20MB;再来个 25MB 的程序,明明总空闲内存可能够,但没有一块足够大的连续空间给它。这就是外部碎片问题。

**我们希望操作系统提供给⽤⼾的空间必须是连续的,但是物理内存最好不要连续。**此时虚 拟内存和分⻚便出现了,如下图所⽰:

把物理内存按照⼀个固定的⻓度的⻚框进⾏分割,叫做物理⻚。每个⻚框包含⼀个物理⻚ (page)。⼀个⻚的⼤⼩等于⻚框的⼤⼩。⼤多数32 位 体系结构⽀持 4KB 的⻚,⽽ 64 位 体系结构一般会支持 8KB 的⻚。区分:

  • 页框:物理内存上的存储单元(偏物理概念)
  • :虚拟内存中的数据块,可以被装入任意页框,也可以在磁盘上(偏逻辑概念)

有了这种机制,CPU便并⾮是直接访问物理内存地址,⽽是通过虚拟地址空间来间接的访问物理内存 地址。所谓的虚拟地址空间,是操作系统为每⼀个正在执⾏的进程分配的⼀个逻辑地址,在32位机 上,其范围从0~4G-1。

意味着:

  • 每个进程都"觉得"自己有 4GB 地址可用
  • 但这并不等于真的占了 4GB 物理内存
  • 真正用了哪些页,由页表决定

页表的作用

页表就是记录:虚拟页 → 物理页框 的映射关系。

CPU 访问地址时,不直接拿虚拟地址去读物理内存,而是先经过 MMU 查页表,找到真实物理地址,再访问内存。

总结⼀下,其思想是将虚拟内存下的逻辑地址空间分为若⼲⻚,将物理内存空间分为若⼲⻚框,通过 ⻚表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。这样就解决了使⽤连续的物理内存 造成的碎⽚问题。

物理内存管理

一个 4GB 物理内存、4KB 页框的例子说明:一共会有 4GB / 4KB = 1048576 个页框。操作系统必须把这么多物理页管理起来,所以内核里有 struct page 这样的结构来描述物理页。

本想介绍struct page 的部分源码,但由于篇幅原因,没必要,知道就行了。这里解释一下flags_mapcountvirtual 等字段。

1)为什么要有 struct page

因为操作系统要知道:

  • 这个物理页在不在用
  • 被谁映射了
  • 是不是脏页
  • 有没有锁
  • 是否属于 slab
  • 是否在 swap cache 中

内存管理不是只管"分不分配",而是要对每个物理页进行状态跟踪。

2)几个重要字段怎么理解

flags:表示页的状态位。

比如:

  • 是否锁定
  • 是否是脏页
  • 是否已更新
  • 是否在伙伴系统中

这本质上是"页的标签集合"。

_mapcount表示有多少页表项映射到这个物理页。

说白了就是:这个物理页被引用了多少次。如果一个物理页被多个虚拟地址映射,比如共享内存、写时拷贝等场景,这个值就很有意义。

virtual表示页在内核虚拟地址空间中的映射地址。 某些高端内存可能没有永久映射,所以这里可能是 NULL

面试:Linux 使用 struct page 对物理页进行管理,每个物理页都对应一个描述结构,其中保存页状态、映射计数等信息,以支持页分配、回收、共享映射等机制。

⻚表

**页表中的每一个表项,指向一个物理页的开始地址。**在32位系统中,虚拟内存的最大空间是4GB,这是每一个用户程序都拥有的虚拟内存空间。既然需要让4GB的虚拟内存全部可用,那么页表中就需要能够表示这所有的4GB空间,那么就一共需要4GB/4KB = 1048576个表项。如下图所示:

单级页表很大

算一遍你就明白了:

  • 4GB 虚拟空间
  • 每页 4KB
  • 页数 = 4GB / 4KB = 1,048,576
  • 每个页表项 4B
  • 总页表大小 = 1,048,576 × 4B = 4MB

4MB 看起来不大,但问题在于:

  • 对每个进程都要有页表
  • 而且单级页表往往要求连续空间
  • 很多进程并不会把 4GB 地址空间都用满

这就很浪费。

结论:

  • 虚拟地址空间是连续的
  • 物理页的映射可以是随机离散的
  • 单级页表虽然能解决映射问题,但页表本身太大、太笨重

这也就是后面多级页表出现的原因。

页目录结构

解决需要⼤容量⻚表的最好⽅法是:把⻚表看成普通的⽂件,对它进⾏离散分配,对⻚表再分⻚, 由此形成多级⻚表的思想

以把这个单⼀⻚表拆分成 1024 个体积更⼩的映射表。如下图所⽰。这样⼀ 来,1024(每个表中的表项个数)*1024(表的个数),仍然可以覆盖 4GB 的物理内存空间。

到⽬前为⽌,每⼀个⻚框都被⼀个⻚表中的⼀个表项来指向了,那么这 1024个页表也需要被管理起来。管理⻚表的表称之为⻚⽬录表,形成⼆级⻚表。

  • 页目录表中的每个项,指向一个页表
  • 页表中的每个项,指向一个物理页
  • CR3 指向页目录表的起始地址

所以访问内存时要先找页目录,再找页表,再定位具体页内偏移。

和线程的关系:

线程共享的是进程地址空间,而地址空间的底层实现就是这套页表体系。所以:

  • 同一进程的线程,共享同一套页目录 / 页表体系
  • 不同进程的线程,通常对应不同地址空间,因此切换时页表基址会变

这也是为什么线程切换成本通常比进程切换低。

两级页表的地址转换

在 32 位、4KB 页的场景下,虚拟地址低 12 位是页内偏移,高 20 位用于查页表,再拆成 10 位一级页号 + 10 位二级页号。MMU 先通过 CR3 找页目录,再找页表,最后和页内偏移组合成物理地址。

下⾯以⼀个逻辑地址为例。将逻辑地址( 0000000000,0000000001,11111111111 )转换为物 理地址的过程:

1. 在32位处理器中,采⽤4KB的⻚⼤⼩,则虚拟地址中低12位为⻚偏移,剩下⾼20位给⻚表,分成 两级,每个级别占10个bit(10+10)。

32 位虚拟地址:

  • 高 10 位:页目录索引
  • 中 10 位:页表索引
  • 低 12 位:页内偏移

因为:

  • 一个页 4KB = 2^12
  • 所以偏移需要 12 位
  • 剩下 20 位再拆成两级,各 10 位

2. CR3 寄存器 读取⻚⽬录起始地址,再根据⼀级⻚号查⻚⽬录表,找到下⼀级⻚表在物理内存中 存放位置。

四步:

  1. 从 CR3 拿到页目录表地址
  2. 用一级页号找到页表地址
  3. 用二级页号找到物理页框地址
  4. 加上 12 位页内偏移,得到最终物理地址

3. 根据⼆级⻚号查表,找到最终想要访问的内存块号。

4. 结合⻚内偏移量得到物理地址。

  1. 注:⼀个物理⻚的地址⼀定是 4KB 对⻬的(最后的 12 位全部为 0 ),所以其实只需要记录物理 ⻚地址的⾼20位即可。

6. 以上其实就是MMU的⼯作流程。MMU(MemoryManageUnit)是⼀种硬件电路,其速度很快, 主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀。

总结⼀下:单级⻚表对连续内存要求⾼,于是引⼊了多级⻚表,但是多级⻚表也是⼀把双 刃剑,在减少连续存储要求且减少存储空间的同时降低了查询效率。

有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。 了新武器,江湖⼈称快表的 TLB (其实,就是缓存)。MMU 为了提升效率,会先查 TLB(快表)。如果 TLB 命中,直接得到物理地址;如果没命中,再去查页表,查到后再把映射关系写回 TLB。

可以把 TLB 理解为:**页表映射关系的缓存。**这点非常非常关键,后面线程和进程切换时:

  • 进程切换通常会影响页表、TLB
  • 线程切换由于共享地址空间,TLB 影响相对更小

这就是线程切换更轻量的根本原因之一。

缺页异常

如果 CPU 给 MMU 一个虚拟地址,TLB 和页表都没法完成有效转换,或者权限不满足,就会产生缺页异常。CPU 会陷入内核,由 Page Fault Handler 处理。

缺页本质是:当前虚拟地址对应的物理页还没准备好,或者当前访问不合法。

三种缺页

硬缺页(Major / Hard Page Fault)

物理内存里根本没有对应页,需要从磁盘读进来,再建立映射。这很慢,因为涉及 IO。

软缺页(Minor / Soft Page Fault)

物理页其实已经在内存里了,只是当前进程 / 线程的页表里还没有建立映射。只需补上映射,不需要磁盘 IO。

无效缺页(Invalid Page Fault)

比如:

  • 访问越界地址
  • 空指针解引用
  • 没权限却硬访问

这类通常会导致段错误,比如 Segmentation Fault

理解:

因为线程共享同一个地址空间,所以:

  • 一个线程访问某地址导致缺页,本质是在这个进程地址空间里出问题
  • 如果是非法访问,往往不是"只影响自己",而是可能把整个进程搞挂
  • 这就是后面即将讲的:线程出异常,进程也可能一起崩

你看,前后就串起来了。

1-3 线程的优点

  • 创建成本小
  • 切换成本低
  • 占用资源更少
  • 能利用多核并行
  • 能让 IO 等待和计算并行
  • 适合计算密集型和 IO 密集型应用的优化

1-4 线程的缺点

  • 性能损失
  • 健壮性降低
  • 缺乏访问控制
  • 编程难度提高

1-5 线程异常

  • 单个线程如果出现除零、野指针等崩溃,进程也会随着崩溃

因为线程共享:

  • 地址空间
  • 代码
  • 数据
  • 资源

而异常通常是进程级处理的。比如一个线程空指针解引用,内核给这个进程发 SIGSEGV,默认处理就是终止进程。

  • 因为线程是进程的执行分支,线程异常会触发信号机制,最终终止进程,进程终止后所有线程都退出

面试常问

问:线程崩溃会不会只影响自己?

通常回答:

不一定。在线程共享进程资源的模型下,像段错误、非法内存访问这类致命异常通常会导致整个进程终止,因此进程内所有线程都会退出。

1-6 线程用途

  • 合理使用多线程,可以提高 CPU 密集型程序执行效率
  • 合理使用多线程,可以提高 IO 密集型程序的用户体验

1)CPU 密集型场景

比如:

  • 图像处理
  • 数值计算
  • 搜索排序
  • 编译任务

前提是任务可拆分,且核数足够。

2)IO 密集型场景

比如:

  • 聊天服务器
  • Web 服务
  • 文件上传下载
  • GUI 程序后台任务

一个线程等 IO,另一个线程继续响应业务或界面。

第二章 Linux 进程 VS 线程

2-1 进程和线程

  • 进程是资源分配的基本单位
  • 线程是调度的基本单位

这是面试高频原话。

1)资源分配的基本单位

为什么是进程?因为通常资源挂在进程上:

  • 地址空间
  • 打开的文件
  • 信号处理方式
  • 工作目录
  • 身份信息

2)调度的基本单位

为什么是线程?因为真正上 CPU 跑的,是具体的执行流。调度器决定"下一刻哪个执行流占 CPU",这个执行流就是线程。

3)线程自己也有私有数据

线程私有部分:

  • 线程 ID
  • 一组寄存器
  • errno
  • 信号屏蔽字
  • 调度优先级

要能理解这些为什么必须私有:

寄存器必须私有: 否则线程一切换,执行现场就乱了。

栈必须私有: 函数调用、局部变量、返回地址都靠栈保存。

errno 必须私有: 不然线程 A 调库函数出错,线程 B 一覆盖,A 就读错了。

信号屏蔽字、调度优先级私有: 因为线程可能有不同的调度和信号处理需求。

2-2 进程的多个线程共享

进程和线程的关系:

同一进程的多个线程共享同一地址空间,因此共享:

  • 代码段
  • 数据段
  • 文件描述符表
  • 信号处理方式
  • 当前工作目录
  • 用户 ID 和组 ID

1)共享代码段

一个函数定义一次,所有线程都能调用。

2)共享全局 / 静态数据

全局变量天生就是同一份。这也是线程安全问题的根源之一。

3)共享堆

malloc 出来的内存,理论上进程内所有线程都能访问。

4)共享文件描述符表

一个线程打开的文件,另一个线程也能用那个 fd。

5)共享当前工作目录、身份信息

因为这些本来就是进程级概念。

2-3 关于进程线程的问题

如何看待之前学习的单进程?------具有一个线程执行流的进程。

这句话非常适合你用来"升级旧知识"。以前你学的单进程程序,其实不是"没有线程",而是:

**只有一个线程的进程。**重新理解成:

  • 一个进程
  • 一个主线程
  • 这个主线程独占执行流

第三章 Linux 线程控制

这一章是最落地、最像面试和写代码会直接用到的一章。前两章已经知道了:

  • 线程是进程里的执行流
  • 线程共享进程的大部分资源
  • Linux 里线程和轻量级进程关系很深

那这一章开始,正式进入**"怎么创建线程、怎么结束线程、怎么回收线程、怎么让线程自己释放资源"**。也就是从"理解概念"进入"会写代码、会分析行为"。包含 5 个小节:POSIX 线程库、创建线程、线程终止、线程等待、分离线程。

3-1 POSIX 线程库

  • 线程相关函数大多以 pthread_ 开头
  • 需要包含头文件 <pthread.h>
  • 链接线程库时要使用 -lpthread 选项
1)本质

Linux 里我们平常写多线程,不是直接操作内核线程,而是通过 pthread 库来操作。

也就是说平时写的:

cpp 复制代码
pthread_create(...)
pthread_join(...)
pthread_exit(...)

这些都不是"CPU 原生命令",也不是你直接在操纵 task_struct

而是:

  • 你调用 用户层线程库接口
  • 线程库再去调用底层系统能力
  • 最终让内核创建 / 管理轻量级进程(LWP)

要建立一个意识:

平时写线程代码,首先面对的是 POSIX 线程编程接口 ,也就是 pthread 这一套 API。

2)POSIX 是什么

POSIX 可以先简单理解成:**一套可移植的操作系统接口标准。**它规定了很多系统编程接口的行为,比如:

  • 文件操作
  • 进程控制
  • 信号
  • 线程

所以 pthread 不是 Linux 独创的随意接口,而是遵循 POSIX 标准的线程接口

3)链接时要加 -lpthread

你写了头文件,只是告诉编译器"这些函数长什么样";但真正链接的时候,还得把线程库代码链接进来。

gcc test.c -lpthread:把 pthread 库链接进最终可执行程序。

把"头文件"和"链接库"分清:

  • #include <pthread.h>:解决声明问题
  • -lpthread:解决实现问题

Linux 下常用 POSIX 线程库进行多线程编程,线程相关函数大多以 pthread_ 开头,使用时需要包含 <pthread.h>,并在链接阶段链接线程库。

3-2创建线程

cpp 复制代码
功能:创建⼀个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
 (*start_routine)(void*), void *arg);
参数:
 thread:返回线程ID
 attr:设置线程的属性,attr为NULL表⽰使⽤默认属性
start_routine:是个函数地址,线程启动后要执⾏的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
参数 void *(*start_routine)(void *)

这是最容易看晕的地方。它表示:**线程启动后要执行的函数地址。**并且这个函数的签名必须长这样:

void *func(void *arg)

也就是说:

  • 参数只能收一个 void *
  • 返回值也是一个 void *

理解设计意图:

  • 参数统一用 void *,方便传任意类型地址
  • 返回值统一用 void *,方便线程退出时带回结果

所以线程入口函数必须长得像这样:

cpp 复制代码
void *rout(void *arg)
{
// 线程执行逻辑
return NULL;
}
返回值为什么不是 -1

强调错误检查。传统很多函数是:

  • 成功返回 0
  • 失败返回 -1
  • 再设置全局 errno

但 pthread 系列函数不一样:出错时不会设置全局 errno,而是直接把错误码作为返回值返回。

这点非常重要。

cpp 复制代码
if (pthread_create(...) == -1)
这是错的。
cpp 复制代码
正确思路是:

int ret = pthread_create(...);
if (ret != 0) {
// ret 本身就是错误码
}

检查 pthread 函数是否出错,优先看返回值,不要指望全局 errno。

pthread_self() 是什么
cpp 复制代码
pthread_t pthread_self(void);

它的意思是:获取"当前调用这个函数的线程"的线程标识。


比如在线程函数里调用:

pthread_self()

拿到的就是当前这个线程自己的 pthread_t。

这个函数很常用,因为很多线程内部逻辑需要知道"我是谁"。

pthread_tLWP 到底什么关系
  • pthread_self 得到的是 pthread 库维护的、进程内唯一标识
  • 它的作用域是进程级
  • 内核真正识别的是系统全局唯一的线程 ID
  • ps -aL 可以看到线程信息,其中 LWP 才是真正的线程 ID

pthread_self 得到的这个值,实际上是虚拟地址空间上的一个地址,通过这个地址可以找到线程的基本信息,比如线程 ID、线程栈、寄存器等属性。

这句话你得慢慢消化。先区分两种"线程ID"

第一种:库层线程 ID

  • 类型:pthread_t
  • 用途:pthread 库操作线程时使用
  • 作用域:进程内

第二种:内核层线程 ID

  • 你在 ps -aL 里看到的是 LWP
  • 这是内核调度真正识别的轻量级进程 ID

所以别把二者混为一谈。

为什么主线程的 PID 和某个 LWP 相同

ps -aL 示例中,有一行 PID == LWP,说明那就是主线程。

这可以这样理解:

  • 整个进程有一个进程 ID(PID)
  • 线程也有自己的轻量级进程 ID(LWP)
  • 主线程通常那个 LWP 和 PID 相同
  • 其他线程的 LWP 不同,但 PID 一样,因为它们属于同一进程
其他线程的栈在哪里
  • 主线程的栈在通常的栈区
  • 其他线程的栈在共享区 / 映射区里
必须掌握的面试点

问:pthread_create 返回什么?

答:

成功返回 0,失败返回错误码,不通过全局 errno 报错。

问:线程入口函数为什么必须是 void *(*)(void *)

答:

因为 pthread 接口把线程参数和返回结果都统一抽象成 void *,便于传递任意类型地址和线程退出结果。

问:pthread_t 是不是内核线程 ID?

答:

不是。 pthread_t 属于 pthread 库层面的线程标识,而 ps -aL 看到的 LWP 才是真正内核层线程 ID。

3-3 线程终止

如果只想终止某个线程,而不是整个进程,有三种方式:

  1. 线程函数 return
  2. 线程自己调用 pthread_exit
  3. 一个线程调用 pthread_cancel 终止另一个线程
第一种方式:线程函数直接 return

线程函数 return 只结束该线程;main return 结束整个进程。

第二种方式:pthread_exit

主动让当前线程结束,并带出一个退出结果。

原型:

cpp 复制代码
void pthread_exit(void *value_ptr);
注意:
value_ptr 不要指向局部变量
它没有返回值
线程结束时无法返回给它自己这个调用者
为什么 value_ptr 不能指向局部变量

这是一个超级高频坑。

比如你这样写:

cpp 复制代码
void *worker(void *arg)
{
int x = 10;
pthread_exit(&x); // 错误
}

为什么错?因为 x 在当前线程栈上。线程一退出,这个栈上的局部变量就失效了。其他线程之后再通过 pthread_join 拿这个指针,拿到的是悬空地址。

pthread_exitreturn 返回的指针,必须指向全局区或者 malloc 分配的内存,不能指向线程函数栈上的空间。

所以正确做法通常是:

  • 返回全局对象地址
  • 或返回堆上 malloc 出来的地址

第三种方式:pthread_cancel

原型:

cpp 复制代码
int pthread_cancel(pthread_t thread);

含义是:一个线程请求取消同一进程中的另一个线程。

注意这个措辞是"请求取消",你先不要把它粗暴理解成"立刻一刀砍死"。先掌握这个接口的用途即可:

  • 参数是目标线程 ID
  • 成功返回 0
  • 失败返回错误码
三种线程终止方式对比记忆
方式一:线程函数 return

最自然,函数跑完就退出。

方式二:pthread_exit

更明确地表达"我现在主动结束,并带出退出值"。

方式三:pthread_cancel

其他线程干预当前线程,让它结束。

3-4 线程等待

为什么需要线程等待:

  • 已退出线程的空间没有被释放
  • 新创建的线程不会复用刚退出线程的地址空间
cpp 复制代码
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数:
 thread:线程ID
 value_ptr:它指向⼀个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

1)为什么一定要有 pthread_join

这是这一章最重要的理解点之一。很多人以为:线程函数结束了,线程不就没了吗?

线程虽然退出了,但它占用的一些资源并没有立刻释放。

所以如果你不回收它,这些资源会残留在进程地址空间里。你可以类比进程里的"僵尸进程"思路来理解,虽然线程不是完全同一种机制,但那个"执行流已经结束,资源信息还需要被回收 "的味道很像。所以 pthread_join 的作用有两个:

  • 等待目标线程结束
  • 回收目标线程相关资源,并拿到它的退出结果
pthread_join 会发生什么

调用该函数的线程将挂起等待,直到 thread 对应线程终止。

也就是说,谁调用 pthread_join,谁就阻塞。例如主线程写:

cpp 复制代码
pthread_join(tid, &ret);

那么主线程会停在这里,直到 tid 对应线程结束。这个行为直接影响并发执行流程。

不同退出方式,对应拿到的结果有什么不同
情况一:目标线程通过 return 退出

value_ptr 里存放的是线程函数的返回值。

情况二:目标线程被别人 pthread_cancel

value_ptr 里存放的是常量 PTHREAD_CANCELED

情况三:目标线程自己调用 pthread_exit

value_ptr 里存放的是传给 pthread_exit 的参数。

情况四:你不关心返回值

可以直接传 NULL


样例代码

三个线程函数:

  • thread1return 一个堆上整数 1
  • thread2pthread_exit 一个堆上整数 2
  • thread3:一直循环,后面被 pthread_cancel

主线程分别对它们 pthread_create + pthread_join

运行结果显示:

  • thread1 回收后得到 1
  • thread2 回收后得到 2
  • thread3 被取消后得到 PTHREAD_CANCELED
这一节最容易错的点
错误一:线程退出了就不用管了

错。没 join,资源还在。

错误二:返回局部变量地址

错。线程栈失效后就是野指针。

错误三:把 pthread_join 当成普通查询函数

错。它会阻塞调用者,直到目标线程结束。

3-5 分离线程

  • 默认新线程是 joinable
  • 线程退出后需要 pthread_join
  • 否则无法释放资源,可能造成系统泄漏
  • 如果不关心线程返回值,可以让系统在线程退出时自动释放资源

接口:

cpp 复制代码
int pthread_detach(pthread_t thread);
1)什么叫 joinable

默认情况下,新线程是"可等待"的,也就是 joinable

意思就是:

  • 线程退出后,不会立刻把所有资源都自动清掉
  • 需要另一个线程来 pthread_join
  • 这样既能拿退出结果,也能完成资源回收

这是默认模型。


2)为什么会有 detach

因为并不是所有线程你都关心它的返回值。例如有些后台线程:

  • 写日志
  • 发心跳
  • 后台清理
  • 异步处理一些不需要主线程拿结果的任务

这种线程如果你还专门去 join,反而麻烦。如果不关心线程返回值,join 就成了一种负担,这时可以告诉系统:线程退出时自动释放资源。这就是 detach 的意义。


3)pthread_detach 到底干了什么

它的本质是把线程改成:**分离状态(detached)**一旦这个线程将来退出:

  • 系统自动回收线程资源
  • 你不能再用 pthread_join 等它拿结果了

所以 detach 的核心代价是:

省掉了手动 join,但也放弃了 join 取回退出值的能力。


4)线程可以怎么分离
方式一:线程组内其他线程分离目标线程

pthread_detach(tid);

方式二:线程自己分离自己

pthread_detach(pthread_self());

后者很常见,表示:我这个线程从一开始就声明:别等我了,我结束时自己自动回收。


5)为什么 joinabledetached 冲突

joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的。

很好理解。因为:

  • joinable:表示将来要有人来 join 我
  • detached:表示将来不用 join,我自己退出自己清理

这两种资源管理策略互相排斥。所以一旦线程 detach 了,就不要再想着 pthread_join 它。


6)课件示例

示例里,线程函数一开始就:

pthread_detach(pthread_self());

然后主线程:

  • 创建线程
  • sleep(1),确保子线程先完成分离
  • 再执行 pthread_join(tid, NULL)

结果是:

  • pthread_join 失败
  • 程序打印 "pthread wait failed"

这个例子就是专门证明:

一个已经分离的线程,不能再 join。

为什么要 sleep(1)?:"很重要,要让线程先分离,再等待。"

因为如果你不等一下,主线程也许还没等子线程 detach 成功,就先跑到 join 了,那实验现象就不稳定了。


7)什么时候该用 detach
适合 join 的线程
  • 你关心线程执行结果
  • 你需要知道它什么时候结束
  • 你需要严格控制线程生命周期
适合 detach 的线程
  • 你不关心返回值
  • 你只想让它干完活自己消失
  • 它更像后台任务

8)小结

默认线程是 joinable;不 join 就可能资源不回收;detach 后自动回收,但不能再 join。

第三章总结

真正要掌握的,不是把几个函数名背下来,而是把线程生命周期串起来:

第一阶段:创建

pthread_create 创建线程,指定入口函数和参数;成功返回 0,失败返回错误码,不靠全局 errno

第二阶段:运行

主线程和新线程并发执行;pthread_self() 拿到的是 pthread 库层线程标识,不等于 ps -aL 里的 LWP。

第三阶段:终止

线程可以通过 returnpthread_exitpthread_cancel 三种典型方式结束。主线程从 main 返回则意味着整个进程退出。

第四阶段:回收

默认线程是 joinable,需要 pthread_join,否则资源不会自动完全释放。pthread_join 还能拿到线程退出值。

第五阶段:分离

不关心返回值时,可以 pthread_detach,让线程退出后自动回收资源;但分离后不能再 join

第四章 线程 ID 及进程地址空间布局

第 3 章你已经知道了:

  • pthread_create 能创建线程
  • pthread_self() 能拿到线程"ID"
  • ps -aL 能看到 LWP
  • 主线程和子线程的栈位置不一样

但你当时很容易只是"记住现象",没真正吃透。这一章就是专门把这些问题掰开:

  1. pthread_t 到底是什么
  2. 它和 LWP 到底什么关系
  3. 为什么说 pthread_t 本质上像一个地址
  4. 线程在进程地址空间里到底怎么摆放
  5. 主线程栈和子线程栈为什么不一样

4-1 pthread_create 产生的线程 ID,到底是不是"真正线程 ID"

  • pthread_create 会产生一个线程 ID,存到第一个参数指向的位置
  • 但这个线程 ID,和前面说的"线程 ID"不是一回事
  • 前面讲的线程 ID 属于进程调度范畴
  • pthread_create 第一个参数里的那个 ID,属于 NPTL 线程库范畴

这句话特别重要。先说结论

Linux 里至少有两层"线程标识":

第一层:内核调度层线程 ID

这是内核真正认识的线程号。

也就是用:ps -aL看到的那个 LWP。这个东西是内核调度器拿来区分线程的,因为线程本质上是轻量级进程,是操作系统调度的最小单位之一。前面讲的线程 ID 是"进程调度的范畴"。

第二层:pthread 库层线程 ID

这就是 pthread_t。这是线程库给你暴露出来的"句柄",供你在用户态代码里操作线程:

  • pthread_join
  • pthread_cancel
  • pthread_detach
  • pthread_self

这些 API 都围绕 pthread_t 工作。线程库后续操作,就是根据这个 ID 来操作线程。


4-2 为什么 pthread_t 不是 LWP

这个问题你必须真懂,不然一到面试就开始混。

1)内核视角

内核要调度线程,它需要的是一个系统级、全局可识别 的标识。这个标识体现在 LWP 上。

你可以理解成:

对内核来说,线程首先是一个被调度的执行实体。

所以它关心的是:

  • 这个线程的内核 task 信息
  • 调度状态
  • 寄存器上下文
  • 内核栈
  • 轻量级进程 ID
2)线程库视角

pthread 库给用户提供 API,它不希望你直接操纵内核内部对象。所以库自己维护了一套线程描述信息。

于是它给你一个 pthread_t,本质上像个"引用/句柄/定位入口"。你拿着它,pthread 库就能找到这个线程对应的线程控制信息,然后进一步完成:

  • 等待
  • 分离
  • 取消
  • 取返回值

所以:

pthread_t 更接近"库内部线程描述符的引用"
LWP 更接近"内核真正调度的线程号"


4-3 pthread_t 为什么会"本质上是一个地址"

对 Linux 目前的 NPTL 实现而言,pthread_t 类型的线程 ID,本质就是一个进程地址空间上的一个地址。

1)别把"地址"理解成随便一个变量地址

这里不是说:"线程 ID 就等于某个普通整型变量的地址"。

而是说:

pthread 库内部会为线程维护一个描述线程的数据结构,pthread_t 在当前实现里,本质上就是这个描述对象的地址或者可由此定位到它。

你可以把它想成:pthread_t tid;这个 tid 在当前实现下,很像是指向某个线程描述块的入口

2)为什么设计成地址很合理

因为线程库后续要操作线程,必须找到线程相关的数据:

  • 线程的属性
  • 线程栈
  • 线程局部存储
  • 退出结果
  • 分离状态
  • 对应内核线程信息

如果 pthread_t 本身就能定位到这个线程描述结构,那线程库做事就方便了。

所以前面说,pthread_self() 得到的值,实际上是虚拟地址空间上的一个地址,通过这个地址,可以找到线程的基本信息,比如线程 ID、线程栈、寄存器等属性。

这就连起来了:

  • 第 3 章讲现象
  • 第 4 章讲本质

4-4 进程地址空间里,线程到底怎么分布

  • 这部分必须慢慢讲。
先说整个进程地址空间的大致布局一个典型进程的虚拟地址空间,大致理解成:
  • 低地址:代码段
  • 往上:已初始化数据段
  • 再往上:未初始化数据段
  • 再往上:堆(向上增长)
  • 再往上:文件映射区 / 共享区 / mmap
  • 高地址附近:主线程栈(向下增长)
  • 更高:内核空间

这个结构不是线程专属,是整个进程本来就有的虚拟地址空间布局。

线程加入后,发生了什么

当一个进程只有主线程时:

  • 主线程就在 main 那条执行流上运行
  • 主线程栈就是平常函数调用用的那个栈

当你 pthread_create 创建子线程时:

  • 进程不会重新生成一个新地址空间
  • 而是在同一个进程地址空间里,再给子线程准备一块独立的线程运行区域

这块区域通常就在:

  • mmap 区 / 文件映射区 / 共享区

里面会放子线程自己的那套东西。

4-5 主线程栈和子线程栈,为什么位置不一样

这是面试和理解源码都很关键的点。

主线程栈

主线程本质上是进程启动时就天然存在的那条执行流。它的栈就是大家熟悉的进程栈,也就是 main 及其调用链用的那块栈空间。

  • 对 Linux 进程或者主线程,简单理解就是 main 函数的栈空间
  • 它在 fork 时会被继承,写时拷贝
  • 而且它可以动态增长
  • 超出上限才会栈溢出报段错误

所以主线程栈的特点是:

  • 传统
  • 天然
  • 靠近高地址
  • 向下增长
  • 具备一定动态扩展特性
子线程栈
  • 子线程的栈不再是那种普通意义上动态向下生长的进程栈
  • 而是事先固定下来的
  • 一般由 pthread_create 在文件映射区/共享区中创建
  • 底层使用 mmap 分配出来

也就是说:

子线程栈不是"系统默认送你的那块主栈",而是 pthread 库额外给你映射出的一块内存。

这块内存的特点是:

  • 固定大小
  • 一般在 mmap
  • 属于线程私有使用
  • 不是主线程那种天然进程栈

默认大小一般是 8MB。所以你以后要形成一个正确画面:

  • 主线程栈:原生进程栈
  • 子线程栈 :pthread 库 mmap 出来的独立栈

4-6 为什么说子线程栈"原则上私有,但实际上别人也能访问"

  • 子线程的 stack 是在进程地址空间中 map 出来的一块内存区域
  • 原则上是线程私有的
  • 但是同一个进程的所有线程共享地址空间
  • 如果愿意,其他线程还是可以访问到的,所以一定要注意
理解

"私有"说的是设计语义和使用约定

  • 这块栈是给某个线程自己函数调用、局部变量、返回地址使用的
  • 正常程序设计里,别的线程不该碰它

但"共享地址空间"意味着:

  • 只要拿到了那个地址
  • 理论上别的线程也可以读写那块内存

因为线程之间没有像进程那样的地址空间隔离。所以真正准确的话是:

线程栈是"逻辑上私有",不是"物理上不可见"。

这也是多线程危险的来源之一。你乱传指针、越界写内存、拿着别的线程栈上地址乱用,都可能把程序弄崩。

第 3 章你看到的是接口层现象
  • pthread_create
  • pthread_self
  • pthread_join
  • pthread_detach
第 4 章你看到的是地址空间和实现层本质
  • pthread_t 不等于 LWP
  • pthread_t 在当前 NPTL 下本质像一个地址
  • 主线程栈在普通栈区
  • 子线程栈在 mmap / 共享区
  • 同一个进程所有线程共享虚拟地址空间,但每个线程保留自己那份栈和寄存器信息

第五章 线程封装

怎么把前面学的 pthread 接口,封装成一个像样的 C++ 类。

给了一个 Thread.hppmain.cc 的例子,核心目的是让你把:

  • 创建线程
  • 启动线程
  • join / detach
  • 线程命名

这些功能对象化。

待完成


第六章 附录:源码阅读,理解线程

待整理

1)pthread_create 背后的主流程图

pthread_create -> 分配 pd/stack -> 保存 start_routine/arg -> 返回 pthread_t(pd) -> create_thread -> clone

2)两句最关键源码

这两句非常值得摘:

  • pd->start_routine = start_routine;
  • *newthread = (pthread_t)pd;

这两句直接对应:

  • 用户函数被保存到线程描述对象里
  • pthread_t 本质上像个地址

3)线程栈怎么来的

  • 主线程栈:进程原生栈,可动态增长
  • 子线程栈:pthread_create 底层通过 mmap 在映射区分配,通常固定大小,默认一般是 8M 左右,不能像主线程栈那样自然扩展。

4)线程库和内核分工

  • 线程库负责:属性、tcb、线程栈、API 封装
  • 内核负责:通过 clone 真正创建和调度线程
    这正是第六章最后想让你明白的。
相关推荐
H_老邪10 分钟前
Linux 与 Docker 常用命令
linux·运维·服务器·docker
大尚来也10 分钟前
PHP 反序列化漏洞深度解析:从原理利用到 allowed_classes 防御实战
android·开发语言·php
雕刻刀13 分钟前
ERROR: Failed to build ‘natten‘ when getting requirements to build wheel
开发语言·python
qq_4160187213 分钟前
高性能密码学库
开发语言·c++·算法
小碗羊肉22 分钟前
【从零开始学Java | 第十八篇】BigInteger
java·开发语言·新手入门
宵时待雨24 分钟前
C++笔记归纳14:AVL树
开发语言·数据结构·c++·笔记·算法
执笔画流年呀39 分钟前
PriorityQueue(堆)续集
java·开发语言
山川行42 分钟前
关于《项目C语言》专栏的总结
c语言·开发语言·数据结构·vscode·python·算法·visual studio code
呜喵王阿尔萨斯43 分钟前
C and C++ code
c语言·开发语言·c++
旺仔.2911 小时前
顺序容器:deque双端队列 详解
c++