📚 本文收录于「流浪」的系列专栏
| 🐧Linux系统 | ⚙️C++ |
| 📊数据结构与算法 | 🐍Python |
| 🔗LangChain & LangGraph | 🗄️MySQL 数据库 |
| 🌿Git 工具 | 🌐计算机网络 |
| 🤖AI | 💯大厂面试、八股 |
| 📚学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前言:线程(七)把库和内核的分工拆完,线程的控制面到此收官。控制的下一层是共享——多个线程同时动一份全局数据,事故说来就来。本篇拿抢票当现场,把 ticket 减到负数的全过程一帧一帧拆开:一次减减的三步、切换时的上下文、判断和修改的分离。
一、线程共享大部分资源,数据不一致问题就来了
1.1 共享是把好处和坑一起领走
篇36 讲过,线程比进程轻,根子在共享;篇38 拆过私有和共享的清单:
- 共享的:地址空间、全局变量、堆、fd 表——大部分资源跟着进程走
- 私有的:一组寄存器、栈、errno 等——跟着执行流走
好处是通信零成本,坑也埋在同一个地方:
- 一个全局变量直接读写,不用像进程那样搭管道——这是好处
- 多个线程同时动一份数据:
- 你算到一半的值被别人覆盖
- 别人读到的,可能是你没写完的旧值
- 并发越大,坑越多——线程的卖点就是并发,好处多大、代价出场多频
1.2 解决的方向,同步和互斥
两个词先立在这,是线程篇后半程的主线:
- 互斥:管「同一时间只许一个线程进」
- 同步:管「多个线程按约定的顺序动」
药方怎么开,得先把病灶看准——本篇把 ticket 的发病过程完整拆一遍。
1.3 抢票场景,一张票被卖出去两次
经典场景:全局变量 ticket 记录余票,初值 100,多个线程同时跑这段逻辑:
#include<stdio.h>#include<unistd.h>#include<pthread.h>intticket=100;void*route(void*mes){char*id=(char*)mes;while(true){if(ticket>0){usleep(1000);printf("%s tickkts:%d\n",id,ticket);ticket--;}else{break;}}returnnullptr;}intmain(){pthread_tt1,t2,t3,t4;pthread_create(&t1,NULL,route,(void*)"thread 1");pthread_create(&t2,NULL,route,(void*)"thread 2");pthread_create(&t3,NULL,route,(void*)"thread 3");pthread_create(&t4,NULL,route,(void*)"thread 4");pthread_join(t1,NULL);pthread_join(t2,NULL);pthread_join(t3,NULL);pthread_join(t4,NULL);return0;}朴素的正确性要求一句话:要么减,要么别减:
- 判断了有票、也确实减了——没问题
- 判断了没票、就不减——也没问题
- 糟糕的是第三种:
- 判断时有票,减的时候票已经没了,硬减
- ticket 被减成负数——一张票卖出去好几次
这套代码就是线上超卖的最小模型:
- 共享的库存数、多个并发请求、判断和扣减中间没有护栏
- 电商超卖、12306 多扣票:
- 规模可以放大千万倍
- 骨架就这么几行
要理解「硬减」怎么发生,得钻到 CPU 层面看两件事:
- ticket-- 不是原子的
- if 判断和减减也不是一体的
二、ticket-- 不是原子的
2.1 减减本质是算术运算,只能由 CPU 完成
先摆两个部件的分工:
- ticket 是内存中的一个变量
- 对变量做 --,本质是进行一次算术运算:
- 算术运算由冯诺依曼体系结构规定
- 只能由 CPU 来完成
- 内存是存储部件,负责存取数据,自己不会算数;运算器在 CPU 里,负责算
所以 ticket-- 必然在 CPU 和内存之间来回一趟:
- 数据从内存进 CPU,算完再从 CPU 回内存
- 这一趟走得越「碎」,中间被插入的机会就越多——接下来看它碎成几步
2.2 一次减减在 CPU 上走三步
这一趟分三步:
1. 将 ticket 从内存载入 CPU 的寄存器
- 寄存器是 CPU 内部的小仓库,参与这一步的主要是通用寄存器
- x86 上诸如 eax、ebx 这类——职责是存从内存读来的数据、承接运算的中间结果
2. 在指令周期中读取指令,进行计算
- CPU 在指令周期里读取指令,对寄存器里的值做减一
- 下一条读哪条指令,由 PC(程序计数器)指着:
- 专职记录执行位置
- 切走再切回时能接着跑,靠的就是它
- ticket-- 是 C 语言,最终会被翻译成汇编指令:
- 源码里的一行
- 到 CPU 那里是一条条独立的指令
3. 将计算完的值写回原来的内存
- 算完的新值从寄存器写回 ticket 所在的内存
- 写回完成,一次减减才算落账
2.3 切走可以发生在三步之间
CPU 进行运算,本质是在执行某个进程或线程的代码:
- 三步指令执行期间,这个线程随时可能被切走——时间片到、更高优先级就绪,调度器不挑时候
CPU 总没来得及将计算完的值写回内存时线程被切走,中间状态就留下来了:
- 内存里是旧值,寄存器里是新值,账对不上
这就是非原子性:
- 三步不是一体的,中间能被插入别人的执行
- 原子性反过来:
- 一个操作要么不做、要做就一口气做完
- 中间不留可被别人看到的中间状态
三、线程切换保存上下文,旧线程写回 99 覆盖现场
3.1 切走不是空手走的
篇12 讲进程切换时立过结论:
- 切走必保存上下文,寄存器的值就是上下文
- 线程同理——在线程切换的同时,也要保存上下文
现场大概是这样:
- 旧线程被切走的时机:
- load 到 ticket=100
- 在寄存器里算出 99
- 还没执行写回
- 切走时保存的上下文里有两样要紧的东西:
- ebx: 99——算到一半的结果
- PC——程序计数器,指向下一条要执行的指令,也就是那条没来得及执行的写回指令
- 带着这些上下文,旧线程被放入整个系统的等待队列
-
这两样东西一存一取,就是断点续跑的全部家当:
- ebx 保住「算到哪了」,PC 保住「下一步该干嘛」
- 没有它们:
- 切回来的线程就是失忆的——不知道接着干什么
- 有了它们:
- 续跑天衣无缝
- 根本意识不到自己已经沉睡了一轮、外面的世界换了人间
- 问题恰恰埋在这份无缝里
3.2 新线程减到 1,调度器切回旧线程
CPU 继续选下一个线程:
- 新线程同样做 ticket–,它从内存里读 ticket:
- 因为上一个线程算完的值还没写回,ticket 还是 100
- 新线程成功执行一个完整周期,ticket 变为 99,继续一路往下减
假设新线程已经把 ticket 减到 1,调度器把旧线程切了回来:
- 恢复上下文:
- ebx 装回 99
- PC 指回那条写回指令
- 旧线程接着它被打断的地方继续执行,把 99 写回 ticket
结果:
- ticket 由 1 弹回 99
- 新线程几十次的扣减凭空消失——一张票可能被卖出去很多次
- 数据不一致问题就这么造成了
回头看,谁都没做错:
- 旧线程没做错——它只是把三步里的最后一步执行完
- 新线程也没做错——它读到的确实是当时的内存
- 错的是两个执行流的步骤交错了
把整个过程拉成时间线更清楚:
| 时刻 | 旧线程 | 新线程 | 内存里的 ticket |
|---|---|---|---|
| t1 | load 读到 100 | 还没上场 | 100 |
| t2 | 算出 99,还没写回,被切走(保存 ebx=99、PC) | — | 100 |
| t3 | 等待队列里排队 | load 读到 100 | 100 |
| t4 | — | 一路减到 1 | 1 |
| t5 | 调度器切回,恢复上下文 | — | 1 |
| t6 | 把 99 写回 | — | 99(弹回) |
t4 到 t6 之间发生了什么:
- 内存里明明已经是 1,一个「过期」的 99 把它盖掉了
- 扣减丢失、余票回涨,全是这一格的锅
四、if(ticket>0) 也拦不住,ticket 减到负数
4.1 判断也是一步运算,也会被切走
有人会想:不是有 if(ticket>0) 挡着吗?挡不住:
- 除了算术运算,CPU 还要做的一步运算是逻辑判断
- if 的条件求值同样由 CPU 执行:
- 同样占指令周期
- 同样可能在「判断完、还没减」的间隙里被切走
if 不是站在门口的保安,它自己也是要排队的客人:
- 求值一次条件,本身就要消耗指令周期
- 判断和减减是两段独立的操作——中间的缝隙足够调度器塞进别的线程
当进行 if(ticket>0) 判断时,假设此时 ticket = 1:
- 判断为真只代表判断那一刻有票,不代表轮到减的时候还有票
4.2 三个线程接力,ticket 变成 -2
把三个线程排进这个缝隙:
1. 线程 A 判真,还没减,被切走
- ticket = 1,A 判断 1 > 0 为真,正要进减减——被切走
2. 线程 B 同样判真,同样被切走
- B 回来一看,ticket 还是 1,判断为真,也进了抢票流程——又被切走
3. 线程 C 正常走完,ticket 归零
- C 判断为真,一口气走完减减,ticket = 0,票卖完了
4. A 和 B 依次唤醒,闭眼各减一次
- A 唤醒——它的判断早就做完了,恢复上下文直接执行减减,ticket = -1
- B 唤醒——同样直接减,ticket = -2
| 时刻 | 线程 A | 线程 B | 线程 C | 内存里的 ticket |
|---|---|---|---|---|
| t1 | 判断 1 > 0 为真,还没减,被切走 | — | — | 1 |
| t2 | — | 判断 1 > 0 为真,被切走 | — | 1 |
| t3 | — | — | 判断为真,一路减完 | 0 |
| t4 | 唤醒,直接减 | — | — | -1 |
| t5 | — | 唤醒,直接减 | — | -2 |
负数就这么来的:
- if 拦的是「判断那一刻」,拦不住「判断之后」
- 判真的人可以排着队攒一堆,票早被后到的人拿光了
- 判断和修改不作为整体保护起来——超卖只是时间问题
5 如何避免——加锁
#include<stdio.h>#include<unistd.h>#include<pthread.h>pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;intticket=100;void*route(void*mes){char*id=(char*)mes;while(true){pthread_mutex_lock(&lock);if(ticket>0){usleep(1000);printf("%s tickkts:%d\n",id,ticket);ticket--;pthread_mutex_unlock(&lock);}else{pthread_mutex_unlock(&lock);break;}}returnnullptr;}intmain(){pthread_t t1,t2,t3,t4;pthread_create(&t1,NULL,route,(void*)"thread 1");pthread_create(&t2,NULL,route,(void*)"thread 2");pthread_create(&t3,NULL,route,(void*)"thread 3");pthread_create(&t4,NULL,route,(void*)"thread 4");pthread_join(t1,NULL);pthread_join(t2,NULL);pthread_join(t3,NULL);pthread_join(t4,NULL);return0;}五、线程安全问题和不可重入函数
5.1 全局资源没加保护,就是线程安全问题
把前面的现场收拢成定义:对于全局资源没有加保护所引发的并发问题,称为线程安全问题。
man 手册给线程安全函数的表述可以对照着读:
- 能被多个线程同时调用且结果照样正确的函数,才叫线程安全
- 不加保护地读写共享数据,正是它的反面
放到本篇里看,事故的根子就是它:
- ticket 就是那份全局资源
- 判断和减减的缝隙没人拦,三步之间也没人拦
- 保护缺位——前面两章的事故全是这一条的直接后果
5.2 这种函数,叫不可重入函数
对于这种函数,我们称之为不可重入函数:
- 函数内部访问了没加保护的全局资源
- 第二个执行流在第一个没跑完时进入,数据就错
- 抢票这段代码就是标准的不可重入
画面感一点:
- 第一个线程在函数里走到一半,函数里的全局变量正改到一半
- 第二个线程从同一扇门进来:
- 读走的是半新不旧的值
- 再把自己那轮修改叠上去
- 两份执行搅在同一锅数据里——出来的东西谁也没法保证
5.3 可重入和线程安全的关系,一句话甄别
两个词的关系是——可重入函数必定线程安全,线程安全的函数不一定是可重入的。
glibc 手册把两个维度标成独立的:
- MT-Safe(线程安全):管多线程并发进入
- AS-Safe(异步信号安全):管中断后重入
面试怎么答:
- 用互斥锁可以把一个不可重入函数改造成线程安全,但它对信号重入仍不安全
- 把两个词划等号是常见扣分点
- 分清「并发进入」和「中断后重入」两种场景再答
六、全篇总结
- 共享是坑的来源:
- 线程共享大部分资源,多个执行流同时动一份全局数据,就有各种情况的数据不一致问题
- 解决方向:互斥和同步
- ticket-- 不是原子的:
- 载入寄存器 → 指令周期计算 → 写回内存,三步
- 通用寄存器存数据、PC 指向指令
- 算完没写回被切走,中间状态就留下来了
- 切换保存上下文:
- 旧线程带着 ebx:99 和 PC 进等待队列
- 新线程读到的还是旧值
- 调度器切回后 99 写回,新线程的扣减凭空消失
- 判断和减减不一体:
- if 判真只管判断那一刻
- A/B/C 三线程接力推演,ticket 一路减到 -2
- 结果由切换时序决定:
- 每个线程单独看都没做错
- 交错方式不同,结局就不同——丢扣减、票回涨、减成负数,全看调度器把切换点放在哪
- 两个名词收口:
- 全局资源没加保护引发的并发问题叫线程安全问题
- 这种函数叫不可重入函数
- 可重入必线程安全,反过来不成立
七、文末面试题
7.1 推导题
1. ticket-- 在 CPU 上分哪三步?为什么三步间被切走就出问题?
答(推导):载入(内存到通用寄存器)、计算(指令周期内做减一)、写回(寄存器到内存)。三步是独立指令,切走可以插在任意两步之间——算完没写回时切走,寄存器里是新值、内存里是旧值,上下文保存的是新值,恢复后写回会覆盖别人已完成的扣减,账就错了。
2. 旧线程恢复后为什么写回的是 99 而不是最新值?
答(推导):切走时保存的上下文里 ebx 已经是 99——计算在切走前就完成了。恢复上下文就是把 ebx 装回 99、PC 指回写回指令,旧线程从断点继续,它不知道也不检查内存里现在是什么,照写 99。数据不一致的根源就是这份「过期的正确」。
3. if(ticket>0) 已经判断了,为什么票还能减成负数?
答(推导):判断是逻辑运算,减减是算术运算,两段操作中间可以被切走。ticket=1 时 A、B 依次判真后被切走,C 正常减到 0;A、B 唤醒后不再判断、直接执行减减,ticket 变 -1、-2。判断为真只代表判断那一刻有票,不代表执行修改时还有票——判真的人可以攒一堆,票被后到者拿光,轮到自己减时早已无票可减。
4. 什么是线程安全问题?
答(推导):对全局资源没有加保护所引发的并发问题。多个执行流不加约束地交错访问共享数据,执行结果依赖切换时序——本次是丢扣减,下次是超卖,错的还不重样。判定标准三条:有共享、有并发修改、还没保护,凑齐就是它的地盘。要结果确定,就得把对共享资源的访问保护起来。
5. 可重入函数和线程安全函数是什么关系?
答(推导):可重入必线程安全,线程安全不必可重入。可重入指的是函数被打断后再次进入(比如信号处理里重入)依然正确,要求不依赖未保护的共享状态;线程安全只承诺多线程同时调用结果正确,用锁就能做到——但锁在重入场景会死锁,所以加锁的线程安全函数恰恰不可重入。glibc 手册里 MT-Safe 和 AS-Safe 是两个独立维度,别混着答。
7.2 真题
1. i++ 是原子操作吗?为什么?
【真题·转述自 牛客讨论帖《i++是原子操作吗?为什么?》(Shopee、字节跳动员工回帖互证)】
答(推导 · 已对照面经,转述):不是。i++ 是多条指令的结合——先读取,再加,最后放回内存,和本篇 ticket-- 的三步一模一样。线程在这些步骤之间被切走,读到的就是旧值,两次自增可能只生效一次。回帖的共识口径与三步拆法一致。
2. 两个线程分别对同一个变量执行 100 次 ++,能得到的最大值和最小值分别是多少?
【真题·转述自 CSDN 博客《i++是原子操作吗?》(题库型,未标注具体公司)】
答(推导 · 已对照面经,转述):最大 200——每次 ++ 都完整走完三步再轮到另一个线程,零交错,两次 ++ 各落各的账。最小 2——两个线程全程读-改-写完全重叠:每次都是 A 读、B 读、A 写、B 写,两组 ++ 只落账一次;但第一步和最后一步总有落账的机会(谁先写谁落账,另一个随后覆盖自己的账),所以 100 组重叠后剩下 2。这道题把「交错方式决定结果」考到了极致,和本篇的负数推演同一个骨架。
💬结语:到这里,线程的问题面从「怎么用」翻到了「怎么不出错」:一次减减的三步、上下文保存的现场、判断与修改的缝隙,三个缝隙里任意一个都能把 ticket 送进负数。看懂事故,才知道锁要锁的到底是什么。评论区聊聊你第一次见到负数余票时的表情。如果这篇对你有帮助,点个赞再走,关注流浪,Linux 系统篇持续更新。