当前位置: 首页 > news >正文

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),从内存加载速度慢,耗时
打电话同步清单缓存一致性通信核心间通过总线发消息,协调缓存数据

现在,我们模拟一下全局队列线程池的工作方式,也就是“频繁交叉派单”:

  1. 场景:一个派单员(全局队列)负责给所有小哥(CPU核心)派单

  2. 派单:派单员拿起一个任务单(任务A),看了一眼,呼叫小哥1:“小哥1,去城东送个快递。”

  3. 取货:小哥1收到指令,但他的小车上没有这个货。他必须开车回中央仓库(访问内存)取到任务A的货物。这很慢

  4. 再次派单:派单员完全不管小哥1到哪了,立刻拿起下一个任务单(任务B),呼叫小哥2:“小哥2,去城西送个快递。”

  5. 再次取货:小哥2的小车上也没有货,他也必须开车回中央仓库取货

  6. 问题爆发:这个任务单本身(也就是任务队列的锁和队列头指针)就像是一份所有小哥都要看的“当前待派任务总清单”

    小哥1送完货回来了,想看看清单上下一个任务是什么,他需要打电话问派单员(尝试获取锁)

    同时,小哥2也送完货回来了,也想看清单,他也打电话问派单员

    派单员(锁)一次只能接一个电话。他接了小哥1的电话,把清单给他看。小哥1看了清单,拿了下一个任务(比如任务C)

    就在小哥1看清单的这一刻,派单员必须立刻打电话通知所有其他小哥:“小哥1正在修改清单,你们手里的清单副本都作废了(缓存失效)!等他用完再问我拿最新的!”

    小哥1用完清单,刚挂电话。小哥2和小哥3的电话立刻就同时打进来了,又开始抢着问派单员要清单看

  7. 这就是“乒乓”:所有小哥的时间和精神,都浪费在不停地“打电话-通知清单作废-再打电话”这个循环上了。真正的送货时间反而只占一小部分。这些协调通信的电话,就是缓存一致性通信,它消耗的总线带宽和CPU周期就是巨大的性能开销

现在,我们看看工作窃取的工作方式:

  1. 场景:每个小哥(线程)负责一个固定的片区(本地队列),他有一份自己片区的专属任务清单。

  2. 工作:小哥1只处理自己片区的单子。他刚从仓库取的货(任务A)还在小车上(缓存是热的),他立刻就能送下一个自己片区的单子(任务B),完全不用打电话问任何人(无锁操作),效率极高。

  3. 窃取(偶尔发生):小哥2自己片区没单了,他决定“窃取”一下。他随机选了小哥1,打电话问他:“你片区最老的那个单子(从队首偷),你还没开始送吧?如果没送,给我吧?”

    即使有这个电话,它的频率也远低于全局派单模式

    他偷的是“最老的”单子,这意味着小哥1很可能还没动这个货(数据是冷的),货物很可能还在中央仓库,而不是在小哥1的小车上。因此,这次窃取不会引发“你的货物清单作废了”这种昂贵的同步通知

所以,“频繁交叉派单”导致“缓存乒乓”的原因是:

它让一份需要被频繁读写的关键共享数据(锁和队列头)成为所有工作单元(CPU核心)的焦点。 对这份数据的任何一次访问和修改,都会触发整个系统所有参与者昂贵的协调同步动作,从而耗尽系统资源。

而工作窃取通过数据所有权局部化,消除了绝大多数不必要的共享访问,只在万不得已时(窃取)才进行一点代价很小的共享,从而根本性地解决了这个问题

http://www.dtcms.com/a/393706.html

相关文章:

  • VALUER倾角传感器坐标系的选择
  • 解决 win+R 运行处以及文件资源管理器处无法使用 wt、wsl 命令打开终端
  • R语言 生物分析 CEL 文件是 **Affymetrix 基因芯片的原始扫描文件**,全称 **Cell Intensity File**。
  • Apache Spark Shuffle 文件丢失问题排查与解决方案实践指南
  • xtuoj 0x05-C 项链
  • STM32F429I-DISC1【读取板载运动传感器数据】
  • 【Kafka面试精讲 Day 21】Kafka Connect数据集成
  • 2025数据资产管理平台深度分析:技术特性、与选型逻辑
  • RabbitMQ Java 解决消息丢失、重复和积压问题
  • 深入解析 Spring AI 系列:解析请求参数处理
  • OpenLayers地图交互 -- 章节五:捕捉交互详解
  • 阿瓦隆1566HA-448T矿机深度解析:性能、效率与冷却技术
  • 平替confluence,推荐一款国产开源免费的知识管理工具 - sward
  • 【开源】基于STM32的智能垃圾桶
  • RuoYi-Cloud问题:访问https的网关地址,实际是访问http的文件服务
  • HttpClientFactory vs new HttpClient:.NET Core HTTP 客户端的正确打开方式
  • MySQL数据库(七)—— 基于主主复制与 Keepalived 非抢占模式的高可用方案
  • 如何提高Java并发编程的实战能力?
  • JavaWeb 课堂笔记 —— 17 SpringBootWeb案例 部门管理
  • java设计模式四,原型模式
  • 【NOIP 2024 T2】遗失的赋值
  • TypeScript学习笔记1
  • Android普通应用切到后台后,多长时间会被系统回收
  • 【Elasticsearch面试精讲 Day 21】地理位置搜索与空间查询
  • 【Android】View 的滑动
  • 【深度学习的优化理论】如何理解OT与欧几里得距离均值的区别
  • 【Android】Room数据库的基本使用
  • 项目:仿muduo库的高并发服务器
  • Oracle普通用户报错ORA-31603处理
  • 网络安全期末大论文