一个 C++ 线程是怎么跑起来的
从 std::thread 到 CPU 调度器 最近在看 C 多线程代码发现对std::thread的认知只停留在知道这函数干什么的层面完全不知道它和操作系统的关系是什么。这篇笔记记录我从上而下学习线程原理的过程。一、 join / joinable / detach 到底是什么意思我之前一直迷迷糊糊的——被 join 过了这种说法听着就别扭。其实这三个概念的核心是一个东西绑定关系。std::thread对象不是线程本身。它只是一个壳里面存了一个操作系统线程 ID。它和 OS 线程的关系是绑定——谁和谁绑在一起。publish_thread_ ──── 绑定 ────▶ [OS 线程在跑]这个绑定有三种状态状态joinable()含义 刚默认构造没关联任何线程false空壳 绑了一个正跑着的线程true绑定中✂️ 调过join()或detach()之后false绑定解除joinable()就是问一句话“你现在还绑着一个活着的线程吗”返回值true→ 绑着呢线程还在跑你得管它false→ 空壳没人可绑可以安全销毁或赋新值它不是这个线程能不能 join而是这个对象当前有没有绑着一个线程。所以严格来说叫thread_bound()更准确但 C 标准定死了叫joinable名字有误导性。⏳join()等它结束然后解绑做两件事先等那个 OS 线程跑完然后解除绑定。调用方线程 ────┬──────────────────▶ │ t.join() │ 卡住等 │ 线程 t ───▶ 干活干活干活 ──▶ 结束 │ ├── 等到了解绑继续走join()没有杀死线程的能力也没做任何资源回收。它只是等等到线程自己跑完。detach()不等了直接解绑做一件事直接解除绑定线程自己跑自己的。调用方 ──── detach(t) ──▶ 不管了继续走 线程 t ──▶ 后台继续跑 ──▶ 跑完了OS 自动回收detach 之后你再也管不了那个线程了——没有句柄不能等不能停。线程跑完后操作系统自己会回收线程栈和任务描述符不需要你操心。♻️ 那资源回收到底是谁干的分两层各管各的层面什么资源什么时候回收谁干的 OS 线程线程栈、TCB任务控制块线程函数 return 时内核自动回收 C 对象std::thread对象本身析构时C 运行时C 对象析构时要求它必须是空壳joinable() false否则析构函数也会调std::terminate。二、 std::thread 的类比 — “领号条”你去办事大厅取了个号std::threadt(func);// 取了一个号窗口开始处理你的业务✅ 你能做的事只有两件t.join()— 坐在大厅椅子上等这个号被叫到等线程结束叫到了你就可以走了号条作废解绑t.detach()— 把号条撕了走人窗口的业务还在办完但你不关心结果❌ 你不能的让窗口暂停让窗口加速把号条转让给别人std::thread是个所有权句柄不是控制器。它的语义跟unique_ptr一样——你拥有这个东西析构前必须处置掉要么等它干完 join要么放弃所有权 detach否则崩。除此之外你什么都做不了。 补充std::thread自己的析构函数干了什么~thread(){if(joinable()){// 还绑着线程std::terminate();// 崩进程死}// 否则啥也不干对象安静地释放掉}就两个分支要么是空壳 → 安静析构要么还绑着 → 直接崩。没有帮你 join 一下没有帮你 detach 一下。C 标准委员会的决定是析构一个还绑着线程的对象是编程错误不要替程序员做选择直接崩。三、 C 对象到底怎么让线程跑起来的这是最让我困惑的问题。我写std::thread t(func)操作系统怎么就多了一个线程一层层往下拆第 1 层你写的代码std::threadt([]{std::couthello;});t.join();C 层面创建了对象传了一个函数等它完。第 2 层std::thread 构造函数内部std::thread是标准库的类不是语法糖。构造函数里做的事大概是std::thread::thread(func) { 1. 调用 pthread_create(tid, attr, func, arg) // POSIX 线程 API 2. 把返回的线程 ID 存到成员变量里 3. 把 joinable 标记置为 true }所以std::thread底层调用的是 POSIX 标准的pthread_create。它是 C 语言的 API不是 C 特有的。第 3 层pthread_create 做了什么pthread_create是 glibcGNU C 库提供的。它内部pthread_create() { 1. 分配线程栈空间默认 8MB 2. 把用户传的函数指针和参数包装好 3. 调用 clone() 系统调用 4. 返回线程 ID }第 4 层clone() 系统调用 — 进入内核 这是最关键的一步。Linux 内核收到clone()调用后sys_clone() { 1. 创建一个新的 task_struct任务描述符 2. 新 task 跟父 task 共享内存地址空间 ↑ 这是线程和进程唯一的区别 ↑ 进程用 fork()不共享地址空间 ↑ 线程用 clone()共享地址空间 3. 分配一段内核栈不是用户栈用户栈在第二层已经分了 4. 设置新 task 的程序计数器PC指向包装函数 5. 把新 task 加入调度器的就绪队列 6. 返回新线程的 tid } Linux 内核里没有线程这个概念。内核只知道task_struct——所有可调度单元都叫 task不管是进程还是线程。线程和进程的区别仅仅是创建时是否共享地址空间。第 5 层CPU 调度器怎么让它跑 ⚡调度器手里有一个就绪队列里面全是等着跑的task_structCPU 核 ──── 时间片轮转 ────▶ task A (主线程) task B (新线程) │ │ ▼ ▼ 就绪队列里排队 就绪队列里排队 │ │ 调度器选中 调度器选中 到你了 到你了 │ │ 在 CPU 核上跑 10ms 在 CPU 核上跑 10ms │ │ 时间片到了 时间片到了 切下来回队列 切下来回队列调度器不关心一个 task 是线程还是进程——对它来说都一样都是分配时间片、切换上下文。整个系统里几十上百个 task靠调度器按优先级和时间片轮转分配 CPU 时间。四、️ 一张总览图把前面几层拼在一起用户态你的代码 ┌──────────────────────────────┐ │ std::thread t │ ← 所有权句柄领号条 │ - _M_id: 12345 │ ← 存的线程 ID │ - joinable: true │ ← 还绑着呢 └──────────┬───────────────────┘ │ pthread_create() │ clone() 系统调用 │ ▼ 内核态OS ┌──────────────────────────────┐ │ task_struct (tid12345) │ ← 真正的线程 │ - 用户栈 (8MB) │ │ - 内核栈 │ │ - 寄存器快照 │ │ - 调度优先级 │ │ - 状态: TASK_RUNNING │ └──────────┬───────────────────┘ │ ▼ CPU 调度器 ┌──────┐ │ CPU │ ← ⚡ 时间片轮转 │ 核 │ └──────┘std::thread用户态所有权句柄只管我绑着个线程这件事task_struct内核态任务描述符线程的真身⚡调度器决定哪个 task 在哪个 CPU 上跑多久五、 操作系统怎么知道哪些线程属于同一个进程每个线程在内核里都是一个独立的task_struct有自己唯一的 tid线程 ID。但它们属于同一个进程靠两个东西关联共享同一份mm_struct内存描述符进程虚拟地址空间只有一份 ├── task_struct (主线程) │ ├── tid: 1000 │ ├── pid: 1000 ← TGID线程组 ID │ └── mm ──────────┐ │ │ ├── task_struct (工作线程) │ ├── tid: 1001 ↘ │ ├── pid: 1000 ┌──────────────┐ │ └── mm ──────────▶│ mm_struct │ ← 所有线程共用同一份 │ │ 页表 │ ├── task_struct (调度线程)│ 堆 │ │ ├── tid: 1002 │ 代码段 │ │ ├── pid: 1000 └──────────────┘ │ └── mm ──────────┘创建线程时用clone()关键 flag 是CLONE_VM——不复制 mm_struct新旧 task 指向同一份。所有线程看到的堆、全局变量、代码段都是同一片内存。共享同一个 TGID线程组 ID每个task_struct有两个 ID字段含义ps看到的pid线程组 IDTGID同一进程的所有线程相同PID 列tid这个 task 自己的唯一 IDLWP 列$ ps -eLf | grep my_app UID PID LWP ... root 1000 1000 主线程 root 1000 1001 工作线程 root 1000 1002 调度线程PID 全是一样的 1000——它们是同一个进程。LWPLight Weight Process就是 tid各不相同。 线程崩溃就是进程崩溃⚠️ 操作系统层面没有单个线程崩溃这个概念。std::terminate()最终调用abort()给整个进程发SIGABRT信号内核给 PID1000 的线程组发信号 → 遍历 task_struct 链表 → 找到所有 pid 1000 的 task → 每个都收到 SIGABRT → 全部终止 所有pid 1000的 task 一起死因为内核清楚地知道它们是同一个线程组。一个线程对象的析构错误 →std::terminate→SIGABRT→ 整个线程组全灭 → 进程消失。一个线程的 bug 拖垮整个进程。六、 进程和线程的创建fork vs clone两者的底层其实都是clone()系统调用区别在于传的 flag// 创建进程 — fork()clone(SIGCHLD);// 什么都不共享全部复制// 创建线程 — pthread_create()clone(CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND,...);// 共享内存 共享文件系统 共享文件描述符 共享信号处理一张表看清区别 进程 (fork) 线程 (pthread)底层调用clone(SIGCHLD)clone(CLONE_VM \| CLONE_FS \| ...)内存堆、全局变量 独立复制互不影响共享改了一个线程全都看得见pidTGID 分配全新的继承创建者的tid 等于新 pid 分配新的各自不同文件描述符 复制一份共享信号处理表 复制一份共享为什么进程的 tid 等于 pidfork 出来的进程刚出生时只有一个线程——主线程。这个线程组的pid是新的组里唯一的线程就把这个pid当成自己的tid。而 clone 出来的线程被塞进一个已经有主的线程组里组的pid不变继承但新线程需要自己的tid来跟组里其他线程区分开。所以整个系统的进程树是这样的第一个进程 (init, pid1) │ fork() ├── bash (pid100) │ │ fork() │ └── my_app (pid1000) ← 进程独立内存空间 │ │ clone(CLONE_VM | CLONE_FS | ...) │ ├── 主线程 (tid1000, pid1000) ← 线程pid 继承 │ ├── 工作线程 (tid1001, pid1000) ← 线程共享内存 │ └── 调度线程 (tid1002, pid1000) ← 线程共享内存进程是 fork 出来的独立内存空间独立 pid线程是 clone 出来的共享内存空间pid 继承父线程tid 各自不同七、 为什么对绑着线程的对象赋值会崩C 里std::thread的operator有这么个规定如果左操作数joinable()为 true还绑着线程直接调std::terminate()。std::threadt1(func1);t1std::thread(func2);// t1 还绑着旧线程 → std::terminate() → 进程死 为什么这么设计如果允许这个赋值旧线程的句柄就丢了但那个线程还在内核里跑。这叫静默丢失线程后果很严重后果说明 内存泄漏线程栈8MB永远不会释放 资源泄漏线程持有的锁永不释放 → 其他线程死锁持有的 fd 永不 close 无法终止没有句柄就没法通知它停永远占着 CPU 析构二次爆炸如果最终 thread 对象析构时还是 joinable析构函数也会调std::terminateC 委员会的选择是与其让线程默默泄漏不如直接崩给你看——所以std::terminate就是直接abort()进程瞬间死会生成 coredump让你知道出事了。✅ 正确的做法是先把旧的解绑再绑新的。if(t1.joinable()){t1.join();// 或 detach()}t1std::thread(func2);// 现在安全了八、 关键要点std::thread不是线程它只是存了一个线程 ID 的所有权句柄⏳join() 等线程结束 解绑不回收资源detach() 直接解绑线程跑完后 OS 自动回收std::thread::~thread() 如果是空壳就安静析构如果还绑着线程就调std::terminate线程的本质是内核里的task_struct跟进程的区别仅在于创建时是否共享地址空间操作系统区分线程归属同一个进程的所有线程共享pidTGID和mm_struct不存在单线程崩溃std::terminate→SIGABRT→ 整个进程所有线程一起死fork()和clone()底层是同一个系统调用区别在于是否传CLONE_VM等共享 flag⚡调度器不区分线程和进程统一按 task 调度时间片轮转所有权是 thread 的核心语义——创建了就必须负责处置join 或 detach否则崩 写于 2026 年 6 月整理自一次 C 多线程的深入讨论。