news 2026/6/9 2:07:14

一个 C++ 线程是怎么跑起来的

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
一个 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::cout<<"hello";});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是 glibc(GNU 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_struct

CPU 核 ──── 时间片轮转 ────▶ 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 (tid=12345) │ ← 🧬 真正的线程 │ - 用户栈 (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线程组 ID(TGID),同一进程的所有线程相同PID 列
tid这个 task 自己的唯一 IDLWP 列
$ ps -eLf | grep my_app UID PID LWP ... root 1000 1000 主线程 root 1000 1001 工作线程 root 1000 1002 调度线程

PID 全是一样的 1000——它们是同一个进程。LWP(Light Weight Process,就是 tid)各不相同。

💥 "线程崩溃"就是进程崩溃

⚠️ 操作系统层面没有"单个线程崩溃"这个概念。

std::terminate()最终调用abort(),给整个进程SIGABRT信号:

内核:给 PID=1000 的线程组发信号 → 遍历 task_struct 链表 → 找到所有 pid == 1000 的 task → 每个都收到 SIGABRT → 全部终止 💀

所有pid == 1000的 task 一起死,因为内核清楚地知道它们是同一个线程组。一个线程对象的析构错误 →std::terminateSIGABRT→ 整个线程组全灭 → 进程消失。

一个线程的 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 \| ...)
内存(堆、全局变量)📋 独立复制,互不影响🪢共享,改了一个线程全都看得见
pid(TGID)🆕 分配全新的👨‍👦继承创建者的
tid🟰 等于新 pid🆕 分配新的,各自不同
文件描述符📋 复制一份🪢共享
信号处理表📋 复制一份🪢共享

💡为什么进程的 tid 等于 pid?fork 出来的进程刚出生时只有一个线程——主线程。这个线程组的pid是新的,组里唯一的线程就把这个pid当成自己的tid。而 clone 出来的线程被塞进一个已经有主的线程组里:组的pid不变(继承),但新线程需要自己的tid来跟组里其他线程区分开。

所以整个系统的进程树是这样的:

第一个进程 (init, pid=1) │ 🍴 fork() ├── bash (pid=100) │ │ 🍴 fork() │ └── my_app (pid=1000) ← 🟦 进程,独立内存空间 │ │ 🧵 clone(CLONE_VM | CLONE_FS | ...) │ ├── 主线程 (tid=1000, pid=1000) ← 🟩 线程,pid 继承 │ ├── 工作线程 (tid=1001, pid=1000) ← 🟩 线程,共享内存 │ └── 调度线程 (tid=1002, pid=1000) ← 🟩 线程,共享内存
  • 🟦进程是 fork 出来的,独立内存空间,独立 pid
  • 🟩线程是 clone 出来的,共享内存空间,pid 继承父线程,tid 各自不同

七、💥 为什么对绑着线程的对象赋值会崩?

C++ 里std::threadoperator=有这么个规定:如果左操作数joinable()为 true(还绑着线程),直接调std::terminate()

std::threadt1(func1);t1=std::thread(func2);// t1 还绑着旧线程 → std::terminate() → 进程死 💀

为什么这么设计?如果允许这个赋值,旧线程的句柄就丢了,但那个线程还在内核里跑。这叫"静默丢失线程",后果很严重:

后果说明
💧 内存泄漏线程栈(8MB)永远不会释放
🔒 资源泄漏线程持有的锁永不释放 → 其他线程死锁;持有的 fd 永不 close
👻 无法终止没有句柄就没法通知它停,永远占着 CPU
💥 析构二次爆炸如果最终 thread 对象析构时还是 joinable,析构函数也会调std::terminate

C++ 委员会的选择是"与其让线程默默泄漏,不如直接崩给你看"——所以std::terminate就是直接abort(),进程瞬间死,会生成 coredump,让你知道出事了。

✅ 正确的做法是:先把旧的解绑,再绑新的

if(t1.joinable()){t1.join();// 或 detach()}t1=std::thread(func2);// 现在安全了

八、📌 关键要点

  1. 🎫std::thread不是线程,它只是存了一个线程 ID 的所有权句柄
  2. join()= 等线程结束 + 解绑,不回收资源
  3. 🪂detach()= 直接解绑,线程跑完后 OS 自动回收
  4. 🧹std::thread::~thread()= 如果是空壳就安静析构,如果还绑着线程就调std::terminate
  5. 🧬线程的本质是内核里的task_struct,跟进程的区别仅在于创建时是否共享地址空间
  6. 🏠操作系统区分线程归属:同一个进程的所有线程共享pid(TGID)和mm_struct
  7. 💥不存在"单线程崩溃"std::terminateSIGABRT→ 整个进程所有线程一起死
  8. 🍴fork()clone()底层是同一个系统调用,区别在于是否传CLONE_VM等共享 flag
  9. 调度器不区分线程和进程,统一按 task 调度,时间片轮转
  10. 🔑所有权是 thread 的核心语义——创建了就必须负责处置(join 或 detach),否则崩

📝 写于 2026 年 6 月,整理自一次 C++ 多线程的深入讨论。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/9 2:07:02

从传感器原理到算法优化:深度图稳定性问题的‘治本’与‘治标’思路全解析

从传感器原理到算法优化&#xff1a;深度图稳定性问题的‘治本’与‘治标’思路全解析 深度相机在机器人导航、三维重建和增强现实等领域的应用日益广泛&#xff0c;但获取稳定可靠的深度图始终是工程师面临的核心挑战。物体边缘的噪声、表面空洞和时序抖动等问题&#xff0c;往…

作者头像 李华
网站建设 2026/6/9 2:04:59

实测对比:国产大模型怎么搭配使用,成本最低、效果最好

没有哪个大模型是万能的。我试过用一个模型干所有事——写代码、写文案、分析数据&#xff0c;结果代码能跑但注释稀烂&#xff0c;文案能看但不出彩。后来换了策略&#xff0c;给每个任务配最合适的模型&#xff0c;效果上了一个台阶&#xff0c;成本反而降了。这几个月拿几个…

作者头像 李华
网站建设 2026/6/9 1:59:57

IMRNNs技术解析:动态嵌入调制在信息检索中的应用

1. IMRNNs技术解析&#xff1a;当信息检索遇上动态嵌入调制 在搜索引擎和问答系统的实际开发中&#xff0c;我们常遇到这样的困境&#xff1a;传统检索模型对复杂查询&#xff08;如需要多步推理的医疗问题&#xff09;的处理效果远不如人工筛选。去年我在优化一个法律咨询系统…

作者头像 李华
网站建设 2026/6/9 1:57:53

期末论文扎堆不用慌!百考通AI,一站式解决高校课业写作难题

每到期末复习周&#xff0c;高校学生都会陷入典型的双重压力困境&#xff1a;一方面要集中精力备战期末考试、应对随堂测验&#xff0c;另一方面各类公共课、专业课的结课论文、课程小作业集中扎堆。不同学科的论文标准各不相同&#xff0c;文科注重理论论述与案例支撑&#xf…

作者头像 李华
网站建设 2026/6/9 1:52:58

拒绝谈癌色变!国商联等离子舱颠覆你对健康管理的认知

在健康中国战略持续推进和大健康产业快速发展的时代背景下&#xff0c;科技创新正不断为人类健康事业注入新的动力。作为长期深耕生命科学领域的科研工作者&#xff0c;勾合山院士始终坚信&#xff0c;科研创新的最终价值在于服务社会、守护人民健康。面对癌症等重大疾病带来的…

作者头像 李华