
🎬 个人主页 :道尔柯南
❄专栏传送门 :《C语言》《C++》《Linux操作系统》
昙花一现,却等待了整个白昼;蝉鸣一夏,却蛰伏了好几个四季。
文章目录
- [从虚拟内存到 pthread:Linux 线程概念与控制全解析](#从虚拟内存到 pthread:Linux 线程概念与控制全解析)
-
- [1. 线程到底是什么?](#1. 线程到底是什么?)
- [2. 理解线程的前置知识:虚拟地址空间与分页](#2. 理解线程的前置知识:虚拟地址空间与分页)
-
- [2.1 没有虚拟内存会怎样?](#2.1 没有虚拟内存会怎样?)
- [2.2 页、页框、页表](#2.2 页、页框、页表)
- [2.3 MMU、TLB 与缺页异常](#2.3 MMU、TLB 与缺页异常)
- [2.4 malloc/new、写时拷贝与缺页](#2.4 malloc/new、写时拷贝与缺页)
- [3. Linux 进程 vs 线程:哪些共享,哪些私有?](#3. Linux 进程 vs 线程:哪些共享,哪些私有?)
- [4. 线程的优点、缺点与适用场景](#4. 线程的优点、缺点与适用场景)
-
- [4.1 优点](#4.1 优点)
- [4.2 缺点](#4.2 缺点)
- [4.3 适用场景](#4.3 适用场景)
- [5. POSIX 线程控制:pthread 系列](#5. POSIX 线程控制:pthread 系列)
-
- [5.1 创建线程](#5.1 创建线程)
- [5.2 线程终止](#5.2 线程终止)
- [5.3 线程等待](#5.3 线程等待)
- [5.4 线程分离](#5.4 线程分离)
- [5.5 取消与清理](#5.5 取消与清理)
- [6. 线程 ID 与进程地址空间布局](#6. 线程 ID 与进程地址空间布局)
- [7. 简单 C++ 线程封装](#7. 简单 C++ 线程封装)
- [8. 从 glibc/NPTL 源码看 pthread_create](#8. 从 glibc/NPTL 源码看 pthread_create)
- [9. 补充:线程安全与同步](#9. 补充:线程安全与同步)
-
- [9.1 竞态条件](#9.1 竞态条件)
- [9.2 死锁](#9.2 死锁)
- [9.3 可重入与线程局部存储](#9.3 可重入与线程局部存储)
- [9.4 信号与线程](#9.4 信号与线程)
- [10. 最佳实践总结](#10. 最佳实践总结)
- 结语
- 结尾
从虚拟内存到 pthread:Linux 线程概念与控制全解析
本文不是对课件的逐页复述,而是把"Linux 线程概念与控制"串成一条完整的技术路线:先理解虚拟地址空间和分页,再理解线程为什么轻量,然后落到 pthread 的创建、终止、等待、分离,最后补充 NPTL、clone、线程安全和工程实践。
1. 线程到底是什么?
在操作系统教材里,线程常被定义为:
线程是进程内部的一条执行流,是 CPU 调度的基本单位。
但在 Linux 里,这个定义要再往前走一步:Linux 内核并没有为"线程"单独设计一种和进程完全不同的数据结构 。无论是进程还是线程,内核里都对应 task_struct,只是它们共享资源的范围不同。
可以这样理解:
- 进程:资源分配的基本单位,拥有独立的虚拟地址空间、文件描述符表、信号处理方式等。
- 线程:调度的基本单位,同一进程内的多个线程共享地址空间和大部分进程资源。
- LWP:Light Weight Process,轻量级进程。Linux 内核视角下,线程更像是一个"共享了很多资源的进程"。
所以,CPU 眼中看到的线程 PCB,比传统进程更"轻量"。而用户态使用的 pthread 线程库,本质上是对内核 clone 系统调用的一层封装。
一句话:
Linux 用"共享资源的 task"实现线程,用 pthread 库给用户提供 POSIX 线程接口。
2. 理解线程的前置知识:虚拟地址空间与分页
要真正理解线程,不能只背"线程共享地址空间"。必须知道地址空间为什么能被共享,以及内核如何管理内存。
2.1 没有虚拟内存会怎样?
如果每个程序直接使用物理内存,那么程序的代码段、数据段、栈段必须连续存放。程序运行、退出后,物理内存会留下大量离散碎片。时间一长,内存利用率会变差。
虚拟内存和分页机制解决了这个问题:
- 给每个进程一个连续的虚拟地址空间;
- 物理内存按固定大小切成页框;
- 通过页表把虚拟页映射到任意可用的物理页框;
- 用户看到的是连续地址,物理内存可以是离散的。
2.2 页、页框、页表
- 页:虚拟内存中的数据块。
- 页框:物理内存中的存储区域。
- 页表:记录虚拟页到物理页框映射关系的数据结构。
常见页大小是 4KB。32 位系统虚拟地址空间为 4GB,因此需要:
text
4GB / 4KB = 1048576 个页表项
如果每个页表项 4 字节,单级页表就要 4MB。这 4MB 还必须连续存放,显然不太合理。
于是引入多级页表:
- 把页表再分页;
- 用页目录管理页表;
- 典型 32 位两级页表:页目录 1024 项,每个页表 1024 项;
- CR3 寄存器保存当前页目录物理地址;
- MMU 负责地址转换。
2.3 MMU、TLB 与缺页异常
MMU 是硬件内存管理单元。CPU 发出虚拟地址后,MMU 查页表得到物理地址。为了加速,现代 CPU 还有 TLB,即快表,缓存最近使用的页表项。
地址转换大致流程:
- CPU 给出虚拟地址;
- MMU 先查 TLB;
- TLB 命中,直接得到物理地址;
- TLB 未命中,查页表;
- 页表也没有,触发缺页异常;
- 内核缺页处理程序决定是分配物理页、从磁盘读取,还是判定为非法访问。
缺页异常常见类型:
| 类型 | 含义 |
|---|---|
| 硬缺页 / 主缺页 | 物理内存中没有对应页,需要从磁盘读入 |
| 软缺页 / 次缺页 | 物理页已存在,只需建立映射 |
| 无效缺页 | 地址非法,通常触发 SIGSEGV |
补充一个容易混淆的点:越界访问不一定立刻报错。如果地址仍落在合法 VMA 内,或者页号合法只是没有建立映射,可能先触发缺页;只有地址非法时才会段错误。
2.4 malloc/new、写时拷贝与缺页
malloc / new 通常只是申请虚拟地址空间,并不立即分配物理页。第一次读写时才可能触发缺页,由内核分配物理页。
fork 后的写时拷贝也是类似思想:
- 父子进程先共享物理页;
- 任一方写入时触发缺页;
- 内核复制一份新的物理页;
- 再修改页表映射。
所以,申请内存 ≠ 立即占用物理内存。理解这一点,对理解线程栈、共享内存和性能都很重要。
3. Linux 进程 vs 线程:哪些共享,哪些私有?
同一进程内的多个线程共享:
- 虚拟地址空间;
- 代码段、数据段、堆;
- 文件描述符表;
- 信号处理方式;
- 当前工作目录;
- 用户 ID、组 ID。
每个线程私有:
- 线程 ID;
- 一组寄存器,即线程上下文;
- 栈;
errno;- 信号屏蔽字;
- 调度优先级;
- 线程局部存储 TLS。
可以用表格总结:
| 资源 | 进程内线程是否共享 |
|---|---|
| 地址空间 | 共享 |
| 代码段、全局变量、堆 | 共享 |
| 文件描述符 | 共享 |
| 信号处理函数 | 共享 |
| 当前工作目录 | 共享 |
| 用户/组 ID | 共享 |
| 栈 | 私有 |
| 寄存器上下文 | 私有 |
| errno | 私有 |
| 信号屏蔽字 | 私有 |
| 调度优先级 | 私有 |
因此:
- 进程是资源分配的基本单位;
- 线程是调度的基本单位;
- 线程共享地址空间,所以通信方便,但也意味着缺乏保护。
4. 线程的优点、缺点与适用场景
4.1 优点
- 创建线程比创建进程开销小;
- 线程切换不需要切换地址空间,TLB 和 cache 更友好;
- 线程间共享数据方便;
- 能利用多核并行;
- 适合 I/O 密集场景,一个线程阻塞等待 I/O,其他线程继续计算。
4.2 缺点
- 同步复杂,容易竞态、死锁;
- 健壮性降低,一个线程崩溃可能导致整个进程退出;
- 缺乏访问控制,一个线程关闭 fd,会影响同进程其他线程;
- 调试困难,时序问题难以复现;
- 线程数量过多会增加调度和同步开销。
4.3 适用场景
- CPU 密集型:分解计算任务,利用多核;
- I/O 密集型:重叠等待,提高吞吐;
- 但线程不是越多越好,通常结合线程池、异步 I/O、协程等方案。
5. POSIX 线程控制:pthread 系列
Linux 下线程函数大多以 pthread_ 开头,需要包含 <pthread.h>,编译时链接 -lpthread 或 -pthread。
pthread 函数出错时通常不设置全局 errno,而是返回错误码。
5.1 创建线程
c
#include <pthread.h>
int pthread_create(
pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg
);
参数说明:
thread:返回线程 ID;attr:线程属性,NULL 表示默认;start_routine:线程入口函数;arg:传给入口函数的参数。
示例:
c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
void *worker(void *arg) {
printf("worker thread, pthread_self = %lu\n",
(unsigned long)pthread_self());
int *ret = malloc(sizeof(int));
*ret = 42;
return ret;
}
int main(void) {
pthread_t tid;
int err = pthread_create(&tid, NULL, worker, NULL);
if (err != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(err));
return 1;
}
void *retval = NULL;
pthread_join(tid, &retval);
printf("worker return: %d\n", *(int *)retval);
free(retval);
return 0;
}
5.2 线程终止
线程可以有三种终止方式:
- 从线程函数
return; - 调用
pthread_exit; - 被其他线程
pthread_cancel。
注意:
- 主线程从
main返回相当于调用exit,会终止整个进程; - 主线程调用
pthread_exit,其他线程可以继续运行; pthread_exit的参数不要指向线程栈上的局部变量,因为线程退出后栈可能失效。
5.3 线程等待
c
int pthread_join(pthread_t thread, void **value_ptr);
pthread_join 会阻塞调用线程,直到目标线程终止。
目标线程不同终止方式,value_ptr 得到的内容不同:
| 终止方式 | join 得到的内容 |
|---|---|
| return | 返回值 |
| pthread_exit | pthread_exit 参数 |
| pthread_cancel | PTHREAD_CANCELED |
如果不关心返回值,可以传 NULL。
线程退出后,如果既没有 join,也没有 detach,资源不会自动释放,可能造成泄漏。
5.4 线程分离
c
int pthread_detach(pthread_t thread);
默认创建的线程是 joinable 的。分离后,线程退出时系统自动回收资源。
也可以线程自己分离:
c
pthread_detach(pthread_self());
joinable 和 detached 是冲突的,一个线程不能同时两者都是。
5.5 取消与清理
pthread_cancel 只是发送取消请求,线程是否立即取消取决于取消状态和取消点。很多阻塞系统调用都是取消点。
如果线程持有锁或申请了资源,应使用清理处理程序:
c
pthread_cleanup_push(cleanup, arg);
/* ... */
pthread_cleanup_pop(1);
这样可以避免取消时资源泄漏。
6. 线程 ID 与进程地址空间布局
这里有两个"线程 ID":
| 类型 | 来源 | 作用域 | 说明 |
|---|---|---|---|
| 内核 tid / LWP | gettid()、ps -aL 的 LWP |
系统全局 | 内核调度使用 |
| pthread_t | pthread_self() |
进程内 | NPTL 中通常是 struct pthread * 地址 |
ps -aL 可以看到线程:
text
PID LWP TTY TIME CMD
2711838 2711838 pts/235 00:00:00 mythread
2711838 2711839 pts/235 00:00:00 mythread
其中主线程的 LWP 和 PID 相同。其他线程的 LWP 不同,但 PID 相同,因为 PID 实际是线程组 ID。
地址空间布局可以粗略理解为:
text
高地址
+------------------+
| 内核空间 |
+------------------+
| 主线程栈 | 向下增长
+------------------+
| mmap 区域 | 共享库、线程栈、TLS、TCB
+------------------+
| 堆 | 向上增长
+------------------+
| BSS |
| Data |
| Text |
+------------------+
低地址
子线程的栈通常由 mmap 分配在共享区,默认大小常见为 8MB,并且有 guard page。它不能像主线程栈那样动态增长,用尽就栈溢出。
7. 简单 C++ 线程封装
直接使用 pthread 比较底层,C++ 中可以做一层封装。核心思路:
- 用
std::function<void()>保存任务; pthread_create的入口函数是静态函数;- 通过
void*把当前对象传进去; - 提供
start、join、detach。
简化示例:
cpp
#include <pthread.h>
#include <functional>
#include <utility>
class Thread {
public:
explicit Thread(std::function<void()> func)
: func_(std::move(func)), tid_(0), joinable_(true) {}
bool Start() {
return pthread_create(&tid_, nullptr, Entry, this) == 0;
}
void Join() {
if (joinable_) {
pthread_join(tid_, nullptr);
joinable_ = false;
}
}
void Detach() {
if (joinable_) {
pthread_detach(tid_);
joinable_ = false;
}
}
~Thread() {
if (joinable_) {
pthread_detach(tid_);
}
}
private:
static void *Entry(void *arg) {
Thread *self = static_cast<Thread *>(arg);
self->func_();
return nullptr;
}
pthread_t tid_;
bool joinable_;
std::function<void()> func_;
};
补充:C++11 之后标准库提供了 std::thread,更推荐直接使用。C++20 还有 std::jthread,支持自动 join 和停止令牌。
线程名称也可以设置,方便调试:
cpp
pthread_setname_np(pthread_self(), "worker");
Linux 下线程名通常最多 16 个字符。
8. 从 glibc/NPTL 源码看 pthread_create
Linux 原生线程库 NPTL 采用 1:1 模型:一个用户态 pthread 对应一个内核调度实体 LWP。
pthread_create 的大致流程:
- 分配线程栈和 TCB;
- 初始化
struct pthread; - 设置入口函数和参数;
- 调用
clone创建内核线程; - 返回
pthread_t,本质是 TCB 地址。
glibc 中会使用 mmap 分配线程栈:
c
mem = mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
然后调用 clone,常见 flags 包括:
| clone flag | 作用 |
|---|---|
| CLONE_VM | 共享虚拟地址空间 |
| CLONE_FS | 共享文件系统信息 |
| CLONE_FILES | 共享文件描述符表 |
| CLONE_SIGHAND | 共享信号处理函数表 |
| CLONE_THREAD | 同一线程组 |
| CLONE_SETTLS | 设置 TLS |
| CLONE_PARENT_SETTID | 写入线程 ID |
| CLONE_CHILD_CLEARTID | 线程退出时清理并唤醒等待者 |
| CLONE_SYSVSEM | 共享 System V 信号量 undo |
内核中,线程组 leader 的 PID 就是 tgid,其他线程有各自 tid,但 tgid 相同。getpid() 返回 tgid,gettid() 返回内核 tid。
所以,pthread 库负责用户态线程管理,内核负责调度。两者通过 clone、futex 等机制协作。
9. 补充:线程安全与同步
线程共享地址空间,最大好处是通信方便,最大风险是数据竞争。
9.1 竞态条件
多个线程并发访问共享变量,且至少有一个写操作,最终结果依赖调度顺序,这就是竞态。
例如:
c
int count = 0;
void *worker(void *arg) {
for (int i = 0; i < 100000; i++) {
count++;
}
return NULL;
}
两个线程同时执行,count 很可能小于 200000。
解决方式:
- 互斥锁
pthread_mutex_t; - 原子操作;
- 读写锁;
- 条件变量;
- 信号量。
9.2 死锁
死锁四个必要条件:
- 互斥;
- 占有并等待;
- 不可抢占;
- 循环等待。
避免死锁的常见方法:
- 固定加锁顺序;
- 使用
std::lock_guard/std::unique_lock; - 尽量减少锁粒度;
- 避免在持锁时调用未知代码。
9.3 可重入与线程局部存储
可重入函数不依赖全局或静态可变状态,多线程调用安全。
errno 是线程私有的。C/C++ 中也可以用 TLS:
c
__thread int tls_var;
或 pthread 键:
c
pthread_key_t key;
pthread_key_create(&key, NULL);
pthread_setspecific(key, ptr);
9.4 信号与线程
信号处理方式在进程内共享,但信号屏蔽字是线程私有的。信号可以发送给特定线程,也可能由内核选择某个线程处理。一个线程收到 SIGSEGV,默认会终止整个进程。
10. 最佳实践总结
- 创建线程后,明确是
join还是detach,避免资源泄漏。 - 检查 pthread 函数返回值,不要只看
errno。 - 线程入口函数返回值如果指向堆内存,记得由 join 方释放。
- 子线程栈默认固定大小,注意栈溢出。
- 共享数据必须同步,优先使用 RAII 锁。
- 不要在线程回调中访问已销毁对象,封装时注意生命周期。
- 线程名、日志、
ps -aL、/proc/<pid>/task都是调试利器。 - 高并发场景优先考虑线程池、异步 I/O、协程,而不是无限创建线程。
- 理解 Linux 线程的本质:
task_struct+clone+ 共享资源 + NPTL 封装。 - 进程与线程不是替代关系,而是隔离性与通信效率之间的权衡。
结语
Linux 线程的核心可以压缩成一句话:
线程是共享进程地址空间和大部分资源的执行流;Linux 内核用 task_struct 统一表示,用 clone 实现资源共享,用 NPTL 封装成 pthread 接口。
理解虚拟地址空间、分页、页表、TLB 和缺页异常,才能真正理解线程为什么"轻量";理解 pthread 的创建、终止、等待、分离,才能写出正确的多线程程序;理解 clone、TCB、TLS 和线程栈布局,才能从"会用线程"走向"理解线程"。
结尾
uu们,本文的内容到这里就全部结束了,道尔在这里再次感谢您的阅读!
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 道尔柯南 C/C++ & Linux 底层探索者 | 一个正在努力学习的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日目标达成!" |
结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!