C++线程池学习 Day08
目标:明白锁竞争的后果、工作窃取算法的核心思想
我们回头审视一下线程池构造函数的死循环部分:
for(;;) {std::function<void()>task;{std::unique_lock<std::mutex>lock(this->queue_mutex);this->condition.wait(lock,[this](){return this->stop || !this->tasks.empty();});if (this->stop && this->tasks.empty()) {return;}task=std::move(this->tasks.front());this->tasks.pop();}task();}
创建线程池后,线程池中的线程开始竞争唯一的锁queue_mutex(unique_lock构造函数那一行,实际上就是在抢锁)。如果有线程抢到了锁,那么就会进入到condition.wait()中。此时还没有调用enqueue函数,那么tasks队列就为空,谓词返回false,这个线程解锁,阻塞在条件变量处,被os挂起,避免消耗CPU资源。如果我们一直不调用enqueue函数,那么最终所有线程池中的线程都会阻塞在条件变量处,这个时候如果再调用enqueue,此时锁空闲,enqueue函数不需要竞争就可以拿到锁。enqueue推送任务后,释放锁,调用notify_one(),唤醒任意一个线程,然后这个线程也能很轻松地获取锁
也就是说,如果我们在主线程中创建了线程池,调用time.sleep(N),再调用enqueue函数,就能进入到上面所说的这种低竞争状态。但是这违背了我们使用线程池的初衷,线程池是为了并发执行任务,而且我们也无法确定睡眠时间该设置多久
所以,一般情况是:当一部分线程阻塞在条件变量、一部分线程阻塞在unique_lock构造函数处抢锁时,enqueue函数就会被调用。enqueue函数也是锁的竞争者,因为它要操作全局队列。
我们会发现有四拨势力在竞争锁:
1.从未抢到过锁的线程
2.enqueue函数
3.已经执行完任务,回到死循环开头竞争锁的线程
4.被os挂起后,又被notify_one()唤醒的线程(前提是enqueue函数已经被调用过)
这会导致激烈的锁竞争
锁竞争的后果
1️⃣ 上下文切换:用户态与内存态的切换
当线程尝试获取一个已被持有的锁时,它不会在原地傻等。这会触发一次系统调用,请求操作系统的帮助
操作系统会将这个线程的状态从“运行”或“就绪”改为“阻塞”,并将其从CPU上移走,挂入一个等待该锁的队列中
代价:一次上下文切换。这需要保存当前线程的寄存器状态、内存页表等,然后加载另一个线程的状态。这个过程需要数百上千个CPU时钟周期,并且会使CPU缓存大量失效,因为新线程需要的数据和代码可能不在当前缓存里
比喻:工人们为了抢工具,不得不频繁找经理(操作系统)调解。每次调解都要花时间重新安排工作
2️⃣ 缓存乒乓
这是现代多核CPU上锁竞争的致命性能杀手
前置知识:
1.每个CPU核心都有自己独享的高速缓存,用于加速对内存的访问
2.缓存一致性协议:这个协议保证了所有核心看到的内存数据是一致的
锁竞争时会发生: 1.核心A上的线程T1拿到了锁,表示锁状态的变量和被保护的数据会被加载到核心A的缓存中,状态可能是Exclusive(独占)
2.核心B上的线程T2也尝试获取锁,它需要读取锁的状态
3.缓存一致性协议发现核心B的缓存中没有最新数据(或者已经失效),于是发起一次缓存一致性通信
4.核心A必须将其缓存中的相关缓存行的状态降级(比如从Exclusive变为shared),或者直接将数据冲刷到内存,并通知核心B“数据无效了”
5.核心B现在可以从主内存(或者核心A的缓存)读取最新的锁状态数据到自己的缓存中
6.如果T2发现锁仍被持有,它可能会循环尝试(自旋)或进入阻塞。但无论如何,3-5的缓存同步通信已经发生了一次
7.更麻烦的是,如果还有核心C、D上的线程在竞争这把锁,它们会重复步骤3-5
这个过程就像几个乒乓球在多个CPU核心的缓存之间弹来弹去。大量的系统总线带宽和CPU周期被用于在核心间传递缓存行和同步消息(你的数据无效了,我的数据改动了),而不是执行有意义的计算指令
比喻:工人们(CPU核心)抢一份“工具使用许可”(锁状态),每次有人想问“工具空闲了吗?”,都需要打电话(缓存一致性通信)给其它工人确认,这个过程就把在工作的工人的工作打断了
简单地说,就是 大量CPU周期用于 为同步缓存数据而进行的通信 而不是计算
所以为了提高性能,我们需要: 1.尽可能避免共享数据,从而减少锁的使用
2.如果必须共享,尽量缩短持有时间,或使用更细粒度的锁、无锁数据结构来减少竞争范围
工作窃取算法,可以有效缓解因只有一个全局队列导致的激烈的锁竞争
其核心思想是:用分散协作取代集中竞争
1.每个线程都有自己的任务队列:不再使用一个全局队列,而是为每个线程分配一个专属的双端队列(通常是线程安全的无锁队列或使用细粒度锁的队列)。线程执行自己产生的任务时,只操作自己的队列,减少了竞争
2.缓存局部性:当一个线程接收一个新任务时,它通常会将这个新任务推入自己的本地队列的队尾。当它需要执行时,也从自己的本地队列的队尾pop出来。这有利于缓存局部性,因为刚产生的任务很可能还驻留在CPU缓存中,执行起来更快
3.窃取:既然拿任务是后进先出,那为什么不用栈而是用双端队列?因为双端队列可以让别的线程来窃取任务。如果一个线程自己的任务队列空了,它会随机选择另一个线程,尝试从那个线程的队列头部头一个任务来执行。也就是说,越在队头的任务就越容易被窃取,越在队尾的任务就越容易被自己执行
❓追问:怎么理解有利于缓存局部性?
答:当一个线程正在执行函数A时:
1.指令缓存:正在执行A的机器指令已经被加载到CPU核心的指令缓存中
2.数据缓存:函数A所操作的数据(局部变量、堆对象等)也已经被加载到数据缓存中
3.产生新任务:现在函数A创建了一个新任务B,创建任务B的动作本身就是由函数A的代码执行的
4.任务相关的代码和数据都是“新鲜”的
任务B可能捕获了A的一些局部变量,这些变量刚刚被A修改过,极大概率还驻留在当前核心的数据缓存中
任务B的可执行代码很可能和函数A的代码在同一个内存页甚至同一个缓存行中,因此也在指令缓存中
5.如果该线程立即从自己队列的队尾pop出这个刚push的任务B来执行,它需要访问的指令和数据很大概率还在缓存里,访问速度很快
之所以旧任务很可能不在缓存里,是由缓存本身有限的大小和缓存一致性协议共同决定的
CPU缓存(L1、L2、L3)非常快,但也非常小。如果线程的工作集(一个线程执行时所需要访问的指令和数据综合)大小超过了缓存容量,那么当需要加载新数据时,缓存就必须淘汰掉一些旧的、被认为近期不太可能使用的数据,以便为新数据腾出空间。被淘汰的数据就被写回主内存或者直接丢弃
比喻:书桌上堆满了书,你就需要把一些你认为最近不会用到的书放回到书柜里
另外,缓存不是完全任意的存储,它被组织成很多行。当新数据要来时,它必须被放在一个特定的缓存行里。如果那个位置被占了,就必须根据一种策略来决定覆盖谁。常见的策略是我们熟知的LRU或它的近似算法
一个很久以前被推入队列的任务,自从被创建后就再也没有被线程访问过。在LRU策略下,它是最佳的被淘汰候选者,因为它是”最近最少使用“的
总结:有利于缓存局部性,是指让CPU在短时间内集中、反复地访问一小块内存区域,从而使得这些数据几乎一直驻留在高速缓存中,避免昂贵的内存访问
比喻:送快递要一个片区一个片区地送,这样就不用每次都回中央仓库拿货了。只有当它的送货小车空了,它才会和同事说:“把你片区最早要送的快递给我吧,反正你也没开始送,都得从仓库现取”。这样既帮了同事,又避免了频繁交叉派单导致的缓存乒乓
而让窃取者从队首窃取,这是一种负载均衡和缓存友好地这种。因为旧任务已经冷了,在任何核心的缓存中都不存在,在哪个CPU上执行都得从主内存中加载,开销差不多,不如让空闲的核心做
❓追问:为什么说频繁交叉派单导致缓存乒乓?
答:
比喻概念 | 对应的计算机硬件 | 作用 |
---|---|---|
多个快递小哥 | 多个CPU核心 | 并行处理任务 |
小哥的电动小车 | CPU核心的私有缓存(L1, L2) | 暂存货物,访问极快 |
区域中转站 | 共享的L3缓存 | 比小车大,比仓库快,核心间共享 |
中央仓库 | 主内存(RAM) | 存储所有货物,访问很慢 |
派单员 | 缓存一致性协议(如MESI) | 管理所有小哥的货物清单,确保大家不会送错货 |
派单一单 | 执行一个任务 | 核心工作 |
回仓库取货 | 缓存未命中(Cache Miss),从内存加载 | 速度慢,耗时 |
打电话同步清单 | 缓存一致性通信 | 核心间通过总线发消息,协调缓存数据 |
现在,我们模拟一下全局队列线程池的工作方式,也就是“频繁交叉派单”:
场景:一个派单员(全局队列)负责给所有小哥(CPU核心)派单
派单:派单员拿起一个任务单(任务A),看了一眼,呼叫小哥1:“小哥1,去城东送个快递。”
取货:小哥1收到指令,但他的小车上没有这个货。他必须开车回中央仓库(访问内存)取到任务A的货物。这很慢
再次派单:派单员完全不管小哥1到哪了,立刻拿起下一个任务单(任务B),呼叫小哥2:“小哥2,去城西送个快递。”
再次取货:小哥2的小车上也没有货,他也必须开车回中央仓库取货
问题爆发:这个任务单本身(也就是任务队列的锁和队列头指针)就像是一份所有小哥都要看的“当前待派任务总清单”
小哥1送完货回来了,想看看清单上下一个任务是什么,他需要打电话问派单员(尝试获取锁)
同时,小哥2也送完货回来了,也想看清单,他也打电话问派单员
派单员(锁)一次只能接一个电话。他接了小哥1的电话,把清单给他看。小哥1看了清单,拿了下一个任务(比如任务C)
就在小哥1看清单的这一刻,派单员必须立刻打电话通知所有其他小哥:“小哥1正在修改清单,你们手里的清单副本都作废了(缓存失效)!等他用完再问我拿最新的!”
小哥1用完清单,刚挂电话。小哥2和小哥3的电话立刻就同时打进来了,又开始抢着问派单员要清单看
这就是“乒乓”:所有小哥的时间和精神,都浪费在不停地“打电话-通知清单作废-再打电话”这个循环上了。真正的送货时间反而只占一小部分。这些协调通信的电话,就是缓存一致性通信,它消耗的总线带宽和CPU周期就是巨大的性能开销
现在,我们看看工作窃取的工作方式:
场景:每个小哥(线程)负责一个固定的片区(本地队列),他有一份自己片区的专属任务清单。
工作:小哥1只处理自己片区的单子。他刚从仓库取的货(任务A)还在小车上(缓存是热的),他立刻就能送下一个自己片区的单子(任务B),完全不用打电话问任何人(无锁操作),效率极高。
窃取(偶尔发生):小哥2自己片区没单了,他决定“窃取”一下。他随机选了小哥1,打电话问他:“你片区最老的那个单子(从队首偷),你还没开始送吧?如果没送,给我吧?”
即使有这个电话,它的频率也远低于全局派单模式
他偷的是“最老的”单子,这意味着小哥1很可能还没动这个货(数据是冷的),货物很可能还在中央仓库,而不是在小哥1的小车上。因此,这次窃取不会引发“你的货物清单作废了”这种昂贵的同步通知
所以,“频繁交叉派单”导致“缓存乒乓”的原因是:
它让一份需要被频繁读写的关键共享数据(锁和队列头)成为所有工作单元(CPU核心)的焦点。 对这份数据的任何一次访问和修改,都会触发整个系统所有参与者昂贵的协调同步动作,从而耗尽系统资源。
而工作窃取通过数据所有权局部化,消除了绝大多数不必要的共享访问,只在万不得已时(窃取)才进行一点代价很小的共享,从而根本性地解决了这个问题