☰
Linux系统篇43——线程(八) 线程的数据不一致问题,从 ticket-- 的非原子性说起
2026/10/5 6:52:59 网站建设 项目流程


📚 本文收录于「流浪」的系列专栏

🐧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
t1load 读到 100还没上场100
t2算出 99,还没写回,被切走(保存 ebx=99、PC)—100
t3等待队列里排队load 读到 100100
t4—一路减到 11
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 系统篇持续更新。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询