资讯详情

资讯详情

Linux系统篇43——线程(八) 线程的数据不一致问题,从 ticket-- 的非原子性说起

本文收录于「流浪」的系列专栏Linux系统⚙️C数据结构与算法PythonLangChain LangGraph️MySQL 数据库Git 工具计算机网络AI大厂面试、八股学习筑基专栏 博客主页流浪 原创首发于 CSDN前言线程七把库和内核的分工拆完线程的控制面到此收官。控制的下一层是共享——多个线程同时动一份全局数据事故说来就来。本篇拿抢票当现场把 ticket 减到负数的全过程一帧一帧拆开一次减减的三步、切换时的上下文、判断和修改的分离。一、线程共享大部分资源数据不一致问题就来了1.1 共享是把好处和坑一起领走篇36 讲过线程比进程轻根子在共享篇38 拆过私有和共享的清单共享的地址空间、全局变量、堆、fd 表——大部分资源跟着进程走私有的一组寄存器、栈、errno 等——跟着执行流走好处是通信零成本坑也埋在同一个地方一个全局变量直接读写不用像进程那样搭管道——这是好处多个线程同时动一份数据你算到一半的值被别人覆盖别人读到的可能是你没写完的旧值并发越大坑越多——线程的卖点就是并发好处多大、代价出场多频1.2 解决的方向同步和互斥两个词先立在这是线程篇后半程的主线互斥管「同一时间只许一个线程进」同步管「多个线程按约定的顺序动」药方怎么开得先把病灶看准——本篇把 ticket 的发病过程完整拆一遍。1.3 抢票场景一张票被卖出去两次经典场景全局变量 ticket 记录余票初值 100多个线程同时跑这段逻辑#includestdio.h#includeunistd.h#includepthread.hintticket100;void*route(void*mes){char*id(char*)mes;while(true){if(ticket0){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 到 ticket100在寄存器里算出 99还没执行写回切走时保存的上下文里有两样要紧的东西ebx: 99——算到一半的结果PC——程序计数器指向下一条要执行的指令也就是那条没来得及执行的写回指令带着这些上下文旧线程被放入整个系统的等待队列-这两样东西一存一取就是断点续跑的全部家当ebx 保住「算到哪了」PC 保住「下一步该干嘛」没有它们切回来的线程就是失忆的——不知道接着干什么有了它们续跑天衣无缝根本意识不到自己已经沉睡了一轮、外面的世界换了人间问题恰恰埋在这份无缝里3.2 新线程减到 1调度器切回旧线程CPU 继续选下一个线程新线程同样做 ticket–它从内存里读 ticket因为上一个线程算完的值还没写回ticket 还是 100新线程成功执行一个完整周期ticket 变为 99继续一路往下减假设新线程已经把 ticket 减到 1调度器把旧线程切了回来恢复上下文ebx 装回 99PC 指回那条写回指令旧线程接着它被打断的地方继续执行把 99 写回 ticket结果ticket 由 1 弹回 99新线程几十次的扣减凭空消失——一张票可能被卖出去很多次数据不一致问题就这么造成了回头看谁都没做错旧线程没做错——它只是把三步里的最后一步执行完新线程也没做错——它读到的确实是当时的内存错的是两个执行流的步骤交错了把整个过程拉成时间线更清楚时刻旧线程新线程内存里的 tickett1load 读到 100还没上场100t2算出 99还没写回被切走保存 ebx99、PC—100t3等待队列里排队load 读到 100100t4—一路减到 11t5调度器切回恢复上下文—1t6把 99 写回—99弹回t4 到 t6 之间发生了什么内存里明明已经是 1一个「过期」的 99 把它盖掉了扣减丢失、余票回涨全是这一格的锅四、if(ticket0) 也拦不住ticket 减到负数4.1 判断也是一步运算也会被切走有人会想不是有 if(ticket0) 挡着吗挡不住除了算术运算CPU 还要做的一步运算是逻辑判断if 的条件求值同样由 CPU 执行同样占指令周期同样可能在「判断完、还没减」的间隙里被切走if 不是站在门口的保安它自己也是要排队的客人求值一次条件本身就要消耗指令周期判断和减减是两段独立的操作——中间的缝隙足够调度器塞进别的线程当进行 if(ticket0) 判断时假设此时 ticket 1判断为真只代表判断那一刻有票不代表轮到减的时候还有票4.2 三个线程接力ticket 变成 -2把三个线程排进这个缝隙1. 线程 A 判真还没减被切走ticket 1A 判断 1 0 为真正要进减减——被切走2. 线程 B 同样判真同样被切走B 回来一看ticket 还是 1判断为真也进了抢票流程——又被切走3. 线程 C 正常走完ticket 归零C 判断为真一口气走完减减ticket 0票卖完了4. A 和 B 依次唤醒闭眼各减一次A 唤醒——它的判断早就做完了恢复上下文直接执行减减ticket -1B 唤醒——同样直接减ticket -2时刻线程 A线程 B线程 C内存里的 tickett1判断 1 0 为真还没减被切走——1t2—判断 1 0 为真被切走—1t3——判断为真一路减完0t4唤醒直接减——-1t5—唤醒直接减—-2负数就这么来的if 拦的是「判断那一刻」拦不住「判断之后」判真的人可以排着队攒一堆票早被后到的人拿光了判断和修改不作为整体保护起来——超卖只是时间问题5 如何避免——加锁#includestdio.h#includeunistd.h#includepthread.hpthread_mutex_t lockPTHREAD_MUTEX_INITIALIZER;intticket100;void*route(void*mes){char*id(char*)mes;while(true){pthread_mutex_lock(lock);if(ticket0){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(ticket0) 已经判断了为什么票还能减成负数答推导判断是逻辑运算减减是算术运算两段操作中间可以被切走。ticket1 时 A、B 依次判真后被切走C 正常减到 0A、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 系统篇持续更新。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →