线程
概述
- 一个线程不仅仅是一段正在执行的代码,而是下面几部分的集合
CPU执行现场- 内核调度信息
- 用户态线程环境
- 线程私有数据
Windows和Linux
Linux和Windows的整体模型非常相似- 同一进程内的线程共享进程资源,但每个线程拥有独立的执行现场、栈、调度状态、
TLS、信号状态和部分运行库状态
- 同一进程内的线程共享进程资源,但每个线程拥有独立的执行现场、栈、调度状态、
- 不过
Linux有一个很有特色的地方- 在
Linux内核看来,进程和线程本质上都是可调度任务,主要区别是它们共享了多少资源
- 在
Windows线程
Windows 为每个线程维护了什么
| 层次 | 主要内容 | 用途 |
CPU 执行上下文 |
寄存器、指令地址、栈指针 | 线程切换后继续执行 |
| 内核线程对象 | 状态、优先级、时间片、等待信息 | 调度线程 |
| 用户态线程环境 | TEB、错误码、TLS 槽位等 |
Win32 运行环境 |
| 程序自己的线程私有数据 | thread_local、TLS/FLS 数据 |
每个线程保存独立状态 |
CPU 执行上下文
- 线程被切换出去时,
Windows必须保存它的执行现场,否则下次不知道从哪里继续 - 其中包含:
- 指令指针:下一条执行什么指令
- 栈指针:当前线程栈的位置
- 通用寄存器
- 浮点和
SIMD寄存器 - 调试寄存器
- 处理器状态标志
上下文切换
- 注意,寄存器并不是永远属于某个线程
- 线程正在运行时使用
CPU的真实寄存器 - 被切换出去时,寄存器值会被保存到该线程的上下文记录中
- 线程正在运行时使用
|
1 2 3 4 5 6 7 8 9 10 11 |
线程 A 执行到函数 func() 中间 ↓ 时间片用完 ↓ Windows 保存线程 A 的寄存器 ↓ 切换到线程 B ↓ 之后恢复线程 A 的寄存器 ↓ 线程 A 从 func() 中断的位置继续执行 |
每个线程都有自己的线程栈
- 同一进程中的线程共享代码和堆,但每个线程都有独立栈
- 如果两个线程同时调用
func(),两个线程分别拥有自己的value:
|
1 2 3 4 |
void func() { int value = 10; } |
|
1 2 3 4 5 6 7 |
进程 ├── 堆:线程共享 ├── 全局变量:线程共享 ├── 线程 A 栈 │ └── value = 10 └── 线程 B 栈 └── value = 10 |
- 线程栈中通常保存:
- 局部变量
- 函数参数
- 返回地址
- 保存的寄存器
- 临时对象
- 异常处理相关信息
Windows线程栈通常会先保留一块较大的虚拟地址空间,只提交实际使用的部分- 接近边界时,通过保护页逐步增长
- 栈大小也可以在创建线程或链接程序时配置
TEB:线程的用户态档案
Windows在用户态为每个线程维护一个重要结构:
|
1 |
TEB:Thread Environment Block |
TEB中包含或关联的信息包括:- 当前线程栈的范围
TLS槽位- 前线程的
Win32 last-error - 线程
ID等客户端信息 - 异常处理、调试及运行库相关状态
- 指向进程环境块
PEB的指针
- 访问
TEB- 在
x64 Windows中,通常可以通过GS段相关机制快速访问当前线程的TEB - 在
x86中传统上使用FS
- 在
GetLastError 为什么线程独立
- 因为错误码保存在当前线程相关的环境中,而不是一个进程级全局变量里
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
DWORD WINAPI thread_a(void*) { SetLastError(100); // 当前线程的 last-error 是 100 return 0; } DWORD WINAPI thread_b(void*) { SetLastError(200); // 当前线程的 last-error 是 200 return 0; } |
线程本地存储 TLS
Thread Local Storage- 它解决的问题是:
- 使用同一个变量标识,但让每个线程得到一份独立的数据
- 普通全局变量:
- 所有线程访问的是同一个对象
|
1 |
int g_value = 0; |
|
1 2 3 |
线程 A ──┐ ├── g_value 线程 B ──┘ |
- 线程局部变量:
- 每个线程拥有一份
|
1 |
thread_local int tls_value = 0; |
|
1 2 3 |
线程 A ── tls_value(A) 线程 B ── tls_value(B) 线程 C ── tls_value(C) |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <iostream> #include <thread> thread_local int counter = 0; void work() { ++counter; ++counter; std::cout << std::this_thread::get_id() << ": " << counter << '\n'; } int main() { std::thread t1(work); std::thread t2(work); t1.join(); t2.join(); } |
TLS 底层
- 先用一个简化模型理解
- 程序保存的是同一个索引
tls_index = 3
- 程序保存的是同一个索引
|
1 2 3 4 5 6 7 8 9 10 |
程序申请一个 TLS 索引,比如索引 3 线程 A 的 TLS 数组: [空, 空, 空, 数据A, ...] 线程 B 的 TLS 数组: [空, 空, 空, 数据B, ...] 线程 C 的 TLS 数组: [空, 空, 空, 数据C, ...] |
- 但是使用该索引访问当前线程的
TLS区域时,每个线程取到不同数据
Windows 原生 TLS API
api
|
1 2 3 4 |
DWORD TlsAlloc(); BOOL TlsSetValue(DWORD index, LPVOID value); LPVOID TlsGetValue(DWORD index); BOOL TlsFree(DWORD index); |
- 示例
TlsAlloc()分配的是全进程通用的“槽位编号”TlsSetValue()设置的是当前线程在该槽位中的值TlsGetValue()读取的是当前线程对应槽位中的值TlsFree()释放槽位编号
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
#include <Windows.h> #include <iostream> DWORD g_tls_index = TLS_OUT_OF_INDEXES; DWORD WINAPI worker(LPVOID) { int* value = new int(100); if (!TlsSetValue(g_tls_index, value)) { delete value; return 1; } auto* current = static_cast<int*>(TlsGetValue(g_tls_index)); if (current != nullptr) { std::cout << *current << '\n'; } delete current; TlsSetValue(g_tls_index, nullptr); return 0; } int main() { g_tls_index = TlsAlloc(); if (g_tls_index == TLS_OUT_OF_INDEXES) { return 1; } HANDLE thread = CreateThread( nullptr, 0, worker, nullptr, 0, nullptr); if (thread == nullptr) { TlsFree(g_tls_index); return 1; } WaitForSingleObject(thread, INFINITE); CloseHandle(thread); TlsFree(g_tls_index); } |
现代 C++ 推荐 thread_local
- 示例
|
1 |
thread_local SomeType object; |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#include <iostream> #include <thread> class Context { public: Context() { std::cout << "construct\n"; } ~Context() { std::cout << "destroy\n"; } int request_count = 0; }; thread_local Context context; void process_request() { ++context.request_count; } |
- 它比直接使用
TlsAlloc()更自然:- 类型安全
- 支持构造函数
- 支持析构函数
- 不需要手动分配
TLS索引 - 不需要把指针转换成
void* - 编译器和运行库负责线程生命周期集成
- 要记住的是:
- 不是“整个程序只有一个
context”,而是: - 每个使用它的线程各自拥有一个
Context对象 - 其初始化通常与线程首次按规则需要该对象相关,具体初始化细节还取决于对象种类和实现
- 线程结束时,正常情况下会执行相应的线程局部对象析构
- 不是“整个程序只有一个
|
1 |
thread_local Context context; |
TLS 不等于线程安全
- 此时安全
temp_buffer不需要因为其他线程而加锁shared_result仍然存在数据竞争
|
1 2 3 4 5 6 7 8 9 |
thread_local std::vector<int> temp_buffer; std::vector<int> shared_result; void work() { temp_buffer.push_back(1); // 每线程独立 shared_result.push_back(1); // 仍然是共享的 } |
- 此时不安全
- 虽然
ptr本身是每线程独立的,但所有指针都指向同一个global_object:
- 虽然
|
1 |
thread_local SomeObject* ptr = &global_object; |
|
1 2 3 |
线程 A 的 ptr ──┐ 线程 B 的 ptr ──┼── global_object 线程 C 的 ptr ──┘ |
- 因此判断线程安全时,要看最终指向和修改的数据是否共享,不能只看指针变量是不是
TLS
TLS 和 FLS 的区别
Windows还有FLS(Fiber Local Storage)
|
1 2 3 4 |
FlsAlloc FlsSetValue FlsGetValue FlsFree |
TLS针对线程,FLS可以针对Fiber(纤程)- 如果这些
Fiber都在同一线程运行:TLS:它们看到的是同一个线程级值FLS:每个Fiber可以看到自己的值
FLS还支持清理回调
|
1 |
DWORD index = FlsAlloc(cleanup_callback); |
Windows纤程
概述
- 运行在线程内部、由应用程序自己切换的轻量级执行单元
- 线程由
Windows内核调度;纤程主要由程序主动调度
|
1 2 3 4 5 6 7 8 |
进程 ├── 线程 A │ ├── 主纤程 │ ├── 纤程 1 │ └── 纤程 2 └── 线程 B ├── 主纤程 └── 纤程 3 |
纤程的基本 API
|
1 2 3 4 5 |
ConvertThreadToFiber CreateFiber SwitchToFiber DeleteFiber ConvertFiberToThread |
|
1 2 3 4 5 |
1. 把当前线程转换为纤程 2. 创建其他纤程 3. 在纤程之间主动切换 4. 删除创建的纤程 5. 必要时把主纤程转换回普通线程 |
纤程拥有自己的什么
- 每个纤程都需要能够暂停并在将来恢复,所以它拥有独立的:
- 栈
- 栈指针
- 指令执行位置
- 需要保存的寄存器上下文
FLS(Fiber Local Storage)数据- 与纤程切换相关的用户态状态
同一线程中的纤程共享:
- 所属线程
- 线程
ID TEB中大量线程级状态TLS(Thread Local Storage)Win32 last-error等线程状态- 线程优先级
CPU时间片
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
线程 ├── 线程级运行环境 ├── TLS ├── last-error │ ├── Fiber A │ ├── 独立栈 │ ├── 执行位置 │ └── FLS │ └── Fiber B ├── 独立栈 ├── 执行位置 └── FLS |
线程切换和纤程切换
- 线程切换
- 线程切换由操作系统调度器决定
- 它通常会进入内核调度路径,而且可能涉及:
调度器决策
优先级
时间片
CPU核心迁移
缓存影响
|
1 2 3 4 5 6 7 |
线程 A 正在运行 ↓ 时间片耗尽、主动等待、被抢占 Windows 内核保存 A 的上下文 ↓ Windows 恢复线程 B 的上下文 ↓ 线程 B 运行 |
- 纤程切换
- 纤程通常主动调用
|
1 |
SwitchToFiber(other_fiber); |
|
1 2 3 4 5 6 7 |
Fiber A 正在运行 ↓ 主动调用 SwitchToFiber 保存 Fiber A 的执行上下文 ↓ 恢复 Fiber B 的执行上下文 ↓ Fiber B 从上次暂停的位置继续运行 |
纤程最大的危险:阻塞整个线程
- 假设
Fiber A调用阻塞式recv:
|
1 2 3 4 |
void WINAPI FiberA(void*) { recv(socket, buffer, size, 0); // 阻塞 } |
- 如果数据迟迟不来:
- 因为
Windows等待的是线程,不知道还想运行同一线程里的其他纤程
- 因为
|
1 2 3 4 5 |
Fiber A 阻塞 ↓ 承载它的线程阻塞 ↓ 该线程上的 Fiber B、Fiber C 也无法执行 |
- 所以纤程调度器通常需要搭配:
- 非阻塞
I/O IOCPOverlapped I/O- 定时器
- 等待完成后重新把纤程放进可运行队列
- 非阻塞
|
1 2 3 4 5 6 7 8 9 10 11 |
Fiber 调用异步读 ↓ I/O 尚未完成 ↓ Fiber 主动切回调度器 ↓ 线程运行其他 Fiber ↓ IOCP 收到完成通知 ↓ 原 Fiber 重新进入可运行队列 |
不能随便从纤程入口函数返回
Windows Fiber的入口函数不是普通线程函数- 纤程过程返回可能导致承载线程退出,而不是自动回到创建它的纤程
|
1 2 3 4 5 |
void WINAPI WorkerFiber(void*) { // ... return; // 危险 } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 更稳妥的设计 void WINAPI WorkerFiber(void*) { // 执行工作 finished = true; SwitchToFiber(scheduler_fiber); // 不再被调度 } // 然后由调度器确认该纤程不在运行后: DeleteFiber(worker_fiber); |
纤程与 C++20协程的区别
| 对比项 | Windows Fiber |
C++20 coroutine |
| 抽象层次 | Windows 平台 API |
C++ 语言机制 |
| 是否独立栈 | 是,有栈 | 通常无独立调用栈 |
| 暂停方式 | SwitchToFiber |
co_await、co_yield |
| 切换单位 | 保存和恢复栈上下文 | 保存协程帧和状态 |
| 平台性 | Windows 特有 |
跨平台语言标准 |
| 调度器 | 程序自行设计 | 程序或库自行设计 |
| 内存开销 | 通常更大 | 通常更小 |
| 普通深层函数能否直接让出 | 可以切换整个栈 | 通常需要协程调用链配合 |
Fiber是有栈协作式执行:- 当
func2()切走时,整条调用栈都被保留
- 当
|
1 2 3 4 5 |
Fiber 栈 main() └── func1() └── func2() └── SwitchToFiber() |
C++20协程通常是无栈协程- 能够暂停的是协程函数本身,不能从任意普通深层函数中透明地暂停整个调用栈
示例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
#include <Windows.h> #include <iostream> LPVOID g_main_fiber = nullptr; LPVOID g_worker_fiber = nullptr; void WINAPI WorkerFiber(void*) { std::cout << "worker: 第一次运行\n"; SwitchToFiber(g_main_fiber); // 下次切换回来时,从这里继续 std::cout << "worker: 第二次运行\n"; SwitchToFiber(g_main_fiber); // 不应直接从纤程入口函数返回 } int main() { g_main_fiber = ConvertThreadToFiber(nullptr); if (g_main_fiber == nullptr) { std::cerr << "ConvertThreadToFiber failed: " << GetLastError() << '\n'; return 1; } g_worker_fiber = CreateFiber( 0, // 使用默认栈大小 WorkerFiber, nullptr); if (g_worker_fiber == nullptr) { std::cerr << "CreateFiber failed: " << GetLastError() << '\n'; ConvertFiberToThread(); return 1; } std::cout << "main: 切换到 worker\n"; SwitchToFiber(g_worker_fiber); std::cout << "main: worker 第一次让出\n"; SwitchToFiber(g_worker_fiber); std::cout << "main: worker 第二次让出\n"; DeleteFiber(g_worker_fiber); if (!ConvertFiberToThread()) { std::cerr << "ConvertFiberToThread failed: " << GetLastError() << '\n'; return 1; } } |
Linux线程
整体对比
| 内容 | Windows |
Linux |
| 内核调度对象 | Thread |
Task,即 task_struct |
| 用户态线程库 | Win32、C++ runtime |
POSIX Threads,通常是 NPTL |
| 创建线程 | CreateThread |
pthread_create,底层使用 clone/clone3 |
| 线程执行现场 | 独立 | 独立 |
| 用户栈 | 独立 | 独立 |
| 内核栈 | 独立 | 独立 |
TLS |
thread_local、TlsAlloc |
thread_local、__thread、pthread_key_create |
TLS 快速定位 |
TEB、段寄存器机制 |
TCB、线程指针、段寄存器等 |
| 错误状态 | GetLastError() |
errno |
线程 ID |
Windows thread ID |
TID |
进程 ID |
Process ID |
TGID,一般由 getpid() 返回 |
| 线程级信号屏蔽 | Windows 模型不同 |
每线程信号掩码 |
Fiber 本地存储 |
FLS |
没有统一等价的内核 Fiber API |
Linux 内核怎样看待进程和线程
Linux内核为每个可调度任务维护一个类似下面的结构- 无论它在用户看来是“进程”还是“线程”,内核调度器看到的主要都是
task
- 无论它在用户看来是“进程”还是“线程”,内核调度器看到的主要都是
|
1 |
task_struct |
- 例如,一个进程有三个线程:
- 这三个任务各自可以被调度,但共享同一个进程级资源集合
|
1 2 3 4 5 6 |
用户视角: 进程 1000 ├── 主线程 ├── 工作线程 A └── 工作线程 B |
|
1 2 3 4 5 |
// Linux 内核视角更接近 task 1000 ──┐ task 1001 ──┼── 共享地址空间、文件表等资源 task 1002 ──┘ |
- 创建线程时,本质上是在告诉内核:
- 创建一个新的可调度任务,但让它与当前任务共享地址空间、文件描述符、信号处理方式等资源
- 概念上可能使用以下共享标志:
|
1 2 3 4 5 |
CLONE_VM // 共享虚拟地址空间 CLONE_FILES // 共享文件描述符表 CLONE_FS // 共享文件系统相关状态 CLONE_SIGHAND // 共享信号处理方式 CLONE_THREAD // 属于同一个线程组 |
CPU 执行上下文
- 每个线程拥有自己的(这与
Windows基本一致):- 指令指针
- 栈指针
- 通用寄存器
- 浮点和向量寄存器
CPU状态- 调试相关状态
|
1 2 3 4 5 6 7 |
线程 A 运行 ↓ 保存 A 的寄存器现场 ↓ 恢复 B 的寄存器现场 ↓ 线程 B 继续运行 |
每个线程都有自己的线程栈
|
1 2 3 4 |
void work() { int value = 10; } |
|
1 2 3 4 5 |
线程 A 用户栈 └── value = 10 线程 B 用户栈 └── value = 10 |
- 主线程的栈通常由程序加载和启动过程建立
pthread_create()创建的线程栈通常由pthread运行库负责分配和管理
- 可以设置线程栈大小
- 默认情况下,线程栈一般也会配置保护页,用于检测栈越界,但具体默认大小和布局受系统、架构及资源限制影响
|
1 2 3 4 5 6 7 8 9 |
pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setstacksize(&attr, 1024 * 1024); pthread_t thread; pthread_create(&thread, &attr, worker, nullptr); pthread_attr_destroy(&attr); |
内核栈
- 除了用户态栈,每个线程还有独立的内核栈
- 当线程执行系统调用:
CPU从用户态进入内核态,内核需要一块可靠的栈来执行系统调用代码
|
1 |
read(fd, buffer, size); |
|
1 2 3 4 5 6 |
线程 ├── 用户态执行 │ └── 用户栈 │ └── 内核态执行 └── 内核栈 |
- 内核栈由内核管理,普通程序不能直接访问
Windows内核线程同样有相应的内核执行栈概念
调度状态
- 每个
Linux线程是独立调度单位,因此拥有自己的:- 运行、睡眠、停止等状态
- 调度策略
- 调度优先级
CPU亲和性CPU使用时间- 当前运行或最近运行的
CPU - 调度统计信息
- 例如可以只设置某个线程的
CPU亲和性- 这表示该线程被限制到指定
CPU集合,并不代表整个进程的所有线程都会自动使用完全相同的设置
- 这表示该线程被限制到指定
|
1 2 3 4 5 6 7 8 9 |
cpu_set_t set; CPU_ZERO(&set); CPU_SET(2, &set); pthread_setaffinity_np( pthread_self(), sizeof(set), &set); |
Linux 的线程 ID
Linux中常见两类ID
|
1 2 |
getpid(); // 线程组 ID,通常被当作进程 ID gettid(); // 当前线程在内核中的 TID |
|
1 2 3 4 5 |
线程组 ID / getpid() = 1000 主线程:gettid() = 1000 线程 A:gettid() = 1001 线程 B:gettid() = 1002 |
- 所以(同一进程的不同线程):
getpid():通常相同gettid():不同pthread_self():不同,但类型和表示由pthread实现决定
Linux 线程本地存储 TLS
Linux中常见三种线程本地存储方式:C++thread_localGCC/Clang__threadPOSIXpthread_key_create
- 代
C++首选thread_local
|
1 |
thread_local |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <iostream> #include <thread> thread_local int counter = 0; void work() { ++counter; ++counter; std::cout << std::this_thread::get_id() << ": " << counter << '\n'; } int main() { std::thread t1(work); std::thread t2(work); t1.join(); t2.join(); } |
GCC/Clang的__thread- 它提供线程局部存储,但能力比
C++thread_local更受限制,特别是涉及需要动态初始化和析构的C++对象时
- 它提供线程局部存储,但能力比
|
1 2 3 |
// 这是编译器扩展: __thread int value = 0; |
|
1 2 3 4 |
// 简单 POD 数据可以使用: __thread int error_code; __thread char buffer[4096]; |
POSIX pthread TLS- 它在抽象上与
Windows的TlsAlloc()很相似
- 它在抽象上与
|
1 2 3 4 |
pthread_key_create pthread_setspecific pthread_getspecific pthread_key_delete |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
#include <pthread.h> #include <iostream> pthread_key_t g_key; void destroy_value(void* data) { delete static_cast<int*>(data); } void* worker(void*) { auto* value = new int(100); int result = pthread_setspecific(g_key, value); if (result != 0) { delete value; return nullptr; } auto* current = static_cast<int*>(pthread_getspecific(g_key)); if (current != nullptr) { std::cout << *current << '\n'; } // 正常退出时,由 key 的析构函数清理 return nullptr; } int main() { // int result = pthread_key_create( &g_key, destroy_value); if (result != 0) { return 1; } pthread_t thread; result = pthread_create( &thread, nullptr, worker, nullptr); if (result != 0) { pthread_key_delete(g_key); return 1; } pthread_join(thread, nullptr); pthread_key_delete(g_key); } |
pthread_key_create(&g_key, destroy_value);- 创建的是进程范围可用的
TLS键 - 每个线程在这个键下拥有不同的值:
- 创建的是进程范围可用的
|
1 2 3 4 5 |
g_key 线程 A → int(100) 线程 B → int(200) 线程 C → nullptr |
Linux TLS 的底层模型
- 先使用这个简化模型
|
1 2 3 4 5 6 7 |
程序中的 TLS 变量:counter 线程 A TLS 区域: └── counter = 1 线程 B TLS 区域: └── counter = 8 |
Linux可执行文件和动态库可以包含TLS模板- 创建线程时,运行库会为该线程准备相应的
TLS区域 - 在
x86-64 Linux中,通常通过FS段基址快速定位当前线程相关结构 - 在其他架构上则可能使用专门的线程指针寄存器或架构约定
- 创建线程时,运行库会为该线程准备相应的
errno 为什么线程独立
- 每个线程拥有独立的
errno:
|
1 2 |
线程 A errno = EAGAIN 线程 B errno = ENOENT |
Linux 每线程的信号状态
Linux信号机制中,既有进程级共享状态,也有线程级状态- 每个线程拥有自己的信号屏蔽集合
|
1 2 3 4 5 6 |
sigset_t set; sigemptyset(&set); sigaddset(&set, SIGINT); pthread_sigmask(SIG_BLOCK, &set, nullptr); |
|
1 2 3 4 5 6 7 8 |
// 因此可以设计成: // 这是多线程服务器中很常见的设计 工作线程: 阻塞 SIGINT、SIGTERM 专用信号线程: 使用 sigwait 等待 SIGINT、SIGTERM |
- 信号处理方式通常是进程共享的
- 通常在同一线程组中共享
- 信号处理函数设置:进程范围共享
- 信号屏蔽集合:每线程独立
|
1 |
sigaction(SIGINT, &action, nullptr); |
- 待处理信号既可能是线程级,也可能是进程级
Linux可以有:- 定向发送给某个线程的信号
- 发送给整个进程的信号
|
1 2 3 4 5 |
// 将信号定向到某个 pthread pthread_kill(thread, SIGUSR1); // 向进程/线程组发送信号,内核会按照规则选择一个可以接收它的线程 kill(getpid(), SIGTERM); |
|
1 2 3 |
// 每个线程还可以有自己的备用信号栈: sigaltstack(...) |
文件描述符是共享还是独立
- 同一进程的
pthread通常共享文件描述符表- 如果线程
A打开文件,把fd交给线程B:
- 如果线程
|
1 |
int fd = open(...); |
|
1 2 3 |
线程 A ──┐ ├── 同一个打开文件描述 线程 B ──┘ |
地址空间、堆和全局变量共享
- 同一进程的线程共享:
- 虚拟地址空间
- 代码段
- 数据段
- 全局变量
- 普通静态变量
- 堆
- 动态库映射
mmap映射- 文件描述符表
- 当前工作目录等文件系统上下文
- 信号处理函数设置
|
1 2 3 4 5 6 |
int global_value = 0; void work() { ++global_value; } |
|
1 2 3 |
// 多个线程同时执行存在数据竞争,需要同步: std::atomic<int> global_value{0}; |
|
1 2 3 4 5 6 7 8 9 |
// 但如果 TLS 中存的是共享对象指针,底层对象仍然共享 GlobalObject object; thread_local GlobalObject* ptr = &object; 线程 A 的 ptr ──┐ 线程 B 的 ptr ──┼── object 线程 C 的 ptr ──┘ |
pthread 取消状态也是线程级的
POSIX线程可以设置自己的取消状态:
|
1 2 3 4 5 6 7 |
pthread_setcancelstate( PTHREAD_CANCEL_DISABLE, nullptr); pthread_setcanceltype( PTHREAD_CANCEL_DEFERRED, nullptr); |
- 其他线程可以请求取消:
- 但这不是粗暴地立即“杀掉线程”。默认的延迟取消通常要等目标线程运行到取消点
|
1 |
pthread_cancel(thread); |
- 另外可以注册清理逻辑:
|
1 2 3 4 5 |
pthread_cleanup_push(cleanup, resource); // 可能被取消的代码 pthread_cleanup_pop(1); |
线程名称也是线程级的
Linux可以给线程设置名称
|
1 2 3 |
pthread_setname_np( pthread_self(), "io-worker"); |
/proc 如何体现线程
- 假设进程
PID是1000,内部有三个线程:
|
1 2 3 4 5 6 |
/proc/1000/task/ ├── 1000 ├── 1001 └── 1002 /proc/<PID>/task/<TID>/ |
Linux 有没有 Windows TEB 的对应物
- 没有完全一一对应、公开稳定的单个结构
- 可以近似理解为几个部分共同承担
|
1 2 3 4 5 6 |
Windows TEB ≈ Linux 用户态线程控制块 TCB + pthread 运行库线程描述结构 + TLS 区域 + 内核中的 task_struct |
TLS 在线程池中的陷阱
- 这点在
Linux和Windows完全一样
线程退出时会发生什么
- 正常线程退出时,用户态运行库通常需要处理:
- C++
thread_local对象析构 pthread TLS key的析构回调pthread清理处理程序- 线程栈释放
- 线程描述结构清理
- 与
pthread_join相关的退出值和同步 - 内核任务退出
- C++
最重要的 Linux 线程模型
Linux内核把线程视为共享了资源的可调度task- 每个线程都有独立的执行现场、用户栈、内核栈、
TLS、TID和调度状态 - 同一进程中的线程通常共享地址空间、堆、全局变量和文件描述符表
errno是线程局部的,但失败后仍然必须立即保存;TLS也只隔离变量本身,不会让它指向的共享对象自动线程安全
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
Linux 进程 / 线程组 │ ├── 共享资源 │ ├── 地址空间 │ ├── 堆和全局变量 │ ├── 文件描述符表 │ ├── 动态库映射 │ └── 信号处理方式 │ ├── 线程 A / task A │ ├── CPU 执行现场 │ ├── 用户栈与内核栈 │ ├── TLS 与 errno │ ├── TID │ ├── 调度状态 │ └── 信号掩码 │ └── 线程 B / task B ├── CPU 执行现场 ├── 用户栈与内核栈 ├── TLS 与 errno ├── TID ├── 调度状态 └── 信号掩码 |
其他
POSIX 线程
- 创建过程
|
1 2 3 4 5 6 7 |
std::thread ↓ 通常使用 pthread ↓ 通常通过 glibc NPTL ↓ 创建 Linux 内核线程/task |
- 为什么叫“
POSIX线程”而不直接叫“Linux线程”- 因为
pthread API不只存在于Linux: LinuxFreeBSDmacOS- 其他
Unix-like系统 - 它们都可以提供(但底层内核实现不一定相同)
- 因为
|
1 |
pthread_create() |
- 总结
POSIX线程是用户态标准和编程接口- 通过
NPTL实现后,每个pthread通常对应一个真正的Linux内核调度任务
NPTL
Native POSIX Thread Library,原生POSIX线程库- 是现代
Linux上glibc实现POSIX线程接口的主要方案
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
std::thread ↓ C++标准库实现 pthread_create / pthread_join pthread_mutex / pthread_cond ↓ POSIX API glibc中的NPTL ├── 管理线程栈和TCB ├── 管理TLS ├── 使用原子操作 ├── 使用futex ├── 使用信号机制 └── 调用Linux底层线程创建机制 ↓ Linux内核 ├── task_struct ├── TID和线程组 ├── 调度 ├── 内核栈 ├── 信号投递 └── futex等待与唤醒 |
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ 51CTO:Linux C++网络编程三08/16
- ♥ Linux 进程创建&&控制&&终止03/28
- ♥ Linux 高性能服务器编程:TCP二11/24
- ♥ 打包_7z生成自解压打包exe07/11
- ♥ Windows 高级调试 _ 内存破坏03/21
- ♥ Windows 核心编程 _ 内核对象:线程同步二07/30