编辑
2025-07-23
个人笔记
00

目录

并发编程的代价
1. 非确定性
2.编译器优化中的Determinism(确定性)
3.宽松内存模型
解决问题:互斥
锁——互斥的实现
实现互斥:硬件
提升性能
解决问题:同步
同步?
理解同步
实现同步
经典永流传——生产者-消费者问题
程序并行改造,同步——实现任意计算图
同步:信号量
互斥锁的拓展——信号量🚨
信号量的局限性
并发Bugs
What & From
源头
问题
解决并发BUG
并发编程,Let's Talking About Usage
并发编程的核心抽象
高性能计算中的并发编程
Web中的并发编程
数据中心的并发编程
协程
Go 和 Goroutine
CPU内的并行编程
实际的CPU(Instruction-Level Parallelism)
面对功耗墙
1. 让一条指令能处理更多的数据
2. 用更多更简单的处理器
GPU:Need More Threads

多线程程序的状态机模型

  • 新增的特殊系统调用:spawn()
    • 新增一个"状态机"
    • 独立的栈
    • 共享全局变量

[!hint]

  • 并发
    • 逻辑上的"同时执行"
    • 通过操作系统或者运行库(java)模拟的"轮流执行"
  • 并行
    • 真正意义上的"同时执行"
    • 共享内存的多个处理器

并发编程的代价

1. 非确定性

不同时读写,导致数据覆盖

2.编译器优化中的Determinism(确定性)

虚拟化给了进程Determinism

  • 除了系统调用外,没有东西能干涉程序的状态 因此:
  • 编译器:按照"syscall"优化程序代码 只要保证在"syscall"层面是一样的,可以随便改变语句 ==但是,编译器的优化是基于Determinism的== 失去了Determinism编译器优化后的程序,在多线程环境下无法正确运行
  • 解决方案
    1. 插入“不可优化”的代码块
    2. 标记变量为“不可优化”
    3. ==《操作系统》课程的推荐方法:不要玩火🔥==

3.宽松内存模型

为了更快的读取速度性能

  • 数据先写入CPU的cache,再同步给其他处理器

  • 允许读取到旧值

  • 现代处理器的“乱序执行”

解决问题:互斥

对于解决并发1+1 -> 阻止同时的sum++ 互斥

  • 锁?
  • 状态迁移
  • 实现资源独享
  • 并发变回单线

锁——互斥的实现

  • 悲观锁&乐观锁
    • 程序变快1/k
    • 并行总是能实现的 , 虽然不能并发 , 但是当数据量够大时大规模并行总能提升性能
特征乐观锁悲观锁
冲突假设假设冲突很少假设冲突频繁
加锁时机==操作前不加锁,提交时检查冲突====操作前立即加锁,阻塞其他线程==
性能无锁操作,冲突少时性能高有锁操作,冲突多时性能稳定
实现复杂度需要版本号、CAS 等机制依赖操作系统或数据库的锁机制
典型场景高并发、低冲突(如数据库更新)低并发、高冲突(如银行账户操作)

永远不要用不要用共享内存做互斥 数据有传播延迟,CPU核心有自己的内存(Cache),核心之间的内存共享做懒处理 编译器会优化

实现互斥:硬件

电脑是人造的,BYD我是土皇帝

把锁的层级提升到硬件,Atomos原子指令 通过Atomos,在硬件层面上实现一个类似下面锁代码:

c
void lock() { retry: if (can_go == ![✅]) { can_go = ![❌]; // then function returns } else { goto retry; } } void unlock() { can_go = ![✅]; }

==而对于那些使用原子指令实现的并发层序,都是硬件基于上面实现的锁==

[!example] 各个CPU架构上的Atomos

  • 一个 “不被打断” 的 load + 计算 + store
    • x86: Bus Lock (locked instruction)
    • RISC-V: LR/SC & A 扩展
      • 来自 MIPS: Load-Linked/Store-Conditional >(LL/SC)
    • arm: ldxr/stxr, stadd (store add) 指令

对于每一个临界变量都有自己的一把锁,自旋锁

提升性能

  • 性能问题——Scalability 可拓展性

    • 自旋锁的Scalability,一万个线程 >> CPU物理线程
      • “一核有难,八核围观”
        • 只有带锁的线程干活,其他无锁的线程都在空转
      • 应用程序不能关中断
        • 拿着锁的线程,被操作系统中断,切换到其他线程
        • 但是切换到的线程进行计算需要锁,拿不到锁导致空转
  • 解决——锁的控制交由操作系统控制

    硬件提供机制解决问题,操作系统再实现程序提高性能

    • 系统调用(SYSCALL_acquire / SYSCALL_release)
      • SYSCALL_acquire(&lk)
        • 成功:线程获得锁,直接返回。
        • 失败:将线程状态标记为“不可执行”(挂起),并切换到其他线程(上下文切换)。
      • SYSCALL_release(&lk)
        • 释放锁后,检查等待队列中是否有线程在等待该锁。
        • 若有,唤醒其中一个线程(如FIFO策略),使其进入“可运行”状态。
    • 实际使用中pthread_mutex_t lock最通用且性能平衡

解决问题:同步

互斥实现了原子性,保证了A->B OR B ->A 但是由于线程的先后顺序不确定,A->B的稳定性还没有保障

[!tip] 理解并发的方法

  • 线程 ->我们自己
  • 共享内存 ->物理空间

同步?

  • 什么是同步? 两个及以上的量,随时间变化,在这个过程中保持一定的相对关系
    • 多线程同步:多个事件、进程或系统在时间上时间协调一致,确保按预定顺序同时执行

对于互斥来说,只能做到分开


理解同步

理解同步:什么是事件(代码的执行)、什么是顺序

  • 同步点: 一个确定的状态,从而实现并发控制
  • 控制: 将发散的并发程序状态收束

实现同步

  • 老方法,古法同步(

    • 不断循环观测起跑Flag
    • 也是老问题,性能差
  • 性能优化

    • 每一次有线程完成了,就唤醒一下完成了的线程看看人齐了没有

经典永流传——生产者-消费者问题

任何同步问题都需要锁🔒 ==条件变量总是和一个互斥锁联合使用==

mutex_lock(🔒); while (!cond) { // cond 可以是任意的计算 cond_wait(&cv, 🔒); } assert(cond); // 此时 cond 成立且持有锁 lk mutex_unlock(🔒);
// 注意锁的使用 mutex_lock(🔒); cond = true; cond_broadcast(&cv); // 唤醒所有可能继续的线程 mutex_unlock(🔒);

万能的同步方法:条件变量

  • 保证了程序状态机的确定性,收束到我指定的状态

而使用条件变量,只需要回答一个问题: ”什么时候执行方法?“

  • Happens-Before
    • 也是一次生产一次消费
    • 改造单线程程序实现并行的关键
    • 有向无环图,Dependence(上一轮的结果交给下一轮用,同一轮的可以并行)

程序并行改造,同步——实现任意计算图

  • 节点想要执行,所有的依赖节点已经完成(可以用一把大锁同步一轮)

  • 实现一个任务的调度器Scheduler

    • 拿着所有的锁,控制所有的workers
    • Scheduler可以知道CPU的拥挤度
    • 管理一个任务队列(不是线程队列),队列内部实现一个拓扑排序。然后合理地(根据拓扑关系)在不同的Worker线程上,排队任务(同个Worker排队多个任务,轮次不同的任务可能要等待上一轮的结果)

同步:信号量

实现同步->Happens-Before->条件变量 实现互斥->互斥锁

[!question] 互斥锁实现同步? 我们通过mutex互斥锁,实现线程之间的互斥运行 实际上在这些互斥的线程中,也存在一种Happens-Before的关系(A to B / B to A)

而同步的实现是基于各个线程Happens-Before关系 因此,我们是否能借助Mutex来实现一个更高效的同步呢——信号量

互斥锁的拓展——信号量🚨

对上面的想法进行抽象,本质上是Release-Acquire实现了Happens-Before

  • Acquire = 等待信号🚨
  • Release = 发出信号🚨

信号🚨在这里可以视为一种资源许可

  • 共享的资源但是凭票(🚨) 进入

信号量的局限性

  • Faker!信号量 VS 条件变量,你们知道吗?

    • 信号量
      • 干净!优雅!完美!(并非完全)
      • 不太好表达二选一
      • count不总是能很好地代表同步条件
    • 条件变量
      • 万能:能实现任何anything~!)的同步条件
      • 丑陋:代码总感里有什么脏东西 (spin loop)
  • 更复杂的同步问题——复杂的条件变量?

[!example] 哲♂学家吃饭 ![[file-20250513195719748.png]] 哲♂学家们一起吃饭,他们有时会思考有时会吃饭。 吃饭的时候需要举起他们左右手的叉子,只有举起左右手的叉子后,他们才会开始吃饭。

  • 问题:吃了很久很久后,于是乎在某一个时刻,所有哲学家同时都举起左手的叉子,大家就都等待右手的叉子

并非完美,信号量不总是优雅

  • 数值型资源不能总是很好地表达同步条件

  • 引入调度者,统一管理全部资源(叉子)

    • 想要通过局部交互来控制全局是非常困难的事情
    • 作为拥有全部资源的调度者,获取到更多的信息,从而不止局限在附近的叉子,而是能全局地看待,更好地分配叉子(哲学家申请要叉子,调度者根据情况分配)

并发Bugs

  • T1线程改变了数据,干扰了T2的运行
  • 函数式编程,代码中的函数像是数学公式一样,就不会产生不确定性

并发编程困难的本质

  • 人类是Sequential的生物,我们只能用Sequential的方式去理解并发(原子性操作)

What & From

源头

  • Data Race 数据竞争 不同的线程==同时==访问同一内存,且至少有一个是写

  • Atomicity Violation 原子性违反 一个操作序列(多个相关操作) 在执行过程中被其他线程中断,导致部分操作完成而另一部分未完成,破坏整体逻辑一致性

    • 吃饭的时候站起来,被偷偷拿掉凳子了(
  • Order Violation 顺序违反 事情未按预定的顺序发生![[file-20250516185226498.png]]

问题

  • Deadlock 死锁 -> 数据竞争

    • 四个必要条件

    [!PDF|yellow] [[操作系统导论 ( etc.) (Z-Library).pdf#page=294&selection=10,0,24,20&color=yellow|操作系统导论 ( etc.) (Z-Library), p.294]]

    - 互斥:线程对于需要的资源进行互斥的访问(例如一个线程抢到锁)。 - 持有并等待:线程持有了资源(例如已将持有的锁),同时又在等待其他资源(例如,需要获得的锁)。 - 非抢占:线程获得的资源(例如锁),不能被抢占。 - 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的。如果这 4 个条件的任何一个没有满足,死锁就不会产生
  • 非死锁问题 -> 原子性/顺序违反

解决并发BUG

  • Lock Ordering

    • 任意时刻系统中的锁都是有限的
    • 给所有锁编号 (Lock Ordering)
      • 严格按照从小到大的顺序获得锁
      • 这个也容易检查,通过LockDoc(打印记录Lock)
  • 原子操作

[!check] 用好现代的程序工具 开发者社区已经为这些MillionDollorsMistake尽可能地提供解决方案


并发编程,Let's Talking About Usage

并发编程的核心抽象

  • 计算图模型
    • 在图中的节点上计算,计算结果彼此之间相互依赖(数字电路模拟、深度神经网络、最长公共子序列)
    • 计算图可能是动态
  • 实现计算图 要实现计算图模型,就需要实现同步条件uu的所有Predecessors都完成)
    • 条件变量
    • 信号量

然而,在计算图的实现上,原子性、信号量和条件变量虽然提供了基础的同步条件功能,但这并不是计算图优雅的实现方式(好比用汇编代码做开发......)

在实现这些同步条件的时候,我们代码中往往会充斥许多的干扰代码,而往往我们会在这里犯错(比如上错锁,或者忘记上锁)![[assets/并发编程/file-20250527131756842.png]]

然而,在长期的编程实践中,程序员们总结出了一定的规律,能为我们提供一些封装的方法功能实现计算图

高性能计算中的并发编程

Embarrassingly parallel(易并行)的计算(物理计算)

通常计算图容易静态切分

Web中的并发编程

Web2.0的到来,带来了并发的需求

  • event-based concurrency(动态计算图)

    • 禁止任何计算节点并行
    • 允许网络请求、sleep 在后台执行
      • 执行完后产生一个新的事件 (计算节点)
    • 假设网络访问占大部分时间;浏览器内计算只是小部分
    • 事件可以在浏览器里看到!
  • Callback Hell

  • Promise

  • “框架” 是驱动技术发展的原动力

  • 我们需要好的抽象来表达人类世界中的需求

    • 简单可靠,聚集大量行业开发者
      • AI 也许是这里的 “开发者”
    • 灵活通用,构造各种应用程序

数据中心的并发编程

不同于高性能计算,以海量分布式数据(存储)为中心

  • 实时的 “小数据处理”

    • 内容分发、用户认证、视频直播、弹幕……
  • 离线的 “大数据处理”

    • 内容索引、数据挖掘……
  • 数据中心里的并发问题

    • 高吞吐 (QPS) & 低延迟的事件处理
      • 处理事件可能需要读写持久存储或请求网络上的服务
        • 延迟不确定
      • 线程维护和上下文切换都会带来开销
    • 假设有数千/数万个请求同时到达服务器……
      • “Denial of Service, DoS”
        • 全国的小爱音箱在小米汽车发布会上同步瘫痪
  • Challenge: 高可靠、低延迟的多副本分布式存储和计算

    • 数据保持一致 (Consistency)
    • 服务时刻可用 (Availability)
    • 容忍机器离线 (Partition tolerance) 不可兼得的不可能三角

协程

和线程概念相同

  • 进程内独立堆栈、共享内存的状态机
    • 在同一个操作系统线程中执行
    • 可以由程序控制调度
    • 除了内存,不占用额外操作系统资源
  • 但 “一直执行”,直到 yield() 主动放弃处理器
    • yield() 是函数调用
      • 只需保存/恢复 non-volatile 的寄存器
      • (线程切换需要保存/恢复全部寄存器)
    • 但 sleep (I/O) 时,所有协程都 “卡住了”
      • 失去了并行

Go 和 Goroutine

小孩子才做选择,多处理器并行和轻量级并发我全都要!

  • Goroutine: 概念上是线程,实现是线程和协程的混合体

Goroutine 实现

  • 每个 CPU 上有一个 Go Worker Thread (协程调度器)
  • 协程执行 blocking API (sleep, read)
    • 偷偷调用 non-blocking 的版本
    • 成功 → 立即继续执行
    • 失败 → 立即 yield 到另一个需要 CPU 的 goroutine
      • 完全可以在单个服务器上启动 1,000,000 个 goroutine 共享处理器算力

CPU内的并行编程

什么是CPU? 无情的执行指令的机器(并非)

实际的CPU(Instruction-Level Parallelism)

  • CPU其实一直在并行编程 指令的执行也是一种计算图,对于没有数据依赖多条指令,CPU可以从PipeLine上预读多条指令,然后构建一个并行的计算图执行

  • 每个 CPU 核心都是一个编译器

    • 动态 (运行时) 数据流分析和指令调度
    • 服务器 CPU 上可能同时有上千条指令在执行
  • 这意味着什么?——用数字电路实现一个编译器

    • 能效&性能 -> 选择了性能,带来的巨量的发热(浪费了很多电路,进行很多的门电路翻转)

[!example] Dark Silicon “暗硅时代”

  • P=C⋅V2⋅f
  • “功耗墙”:纵使有更大的电路,热功耗限制了性能上限

面对功耗墙

P=CV2fP=C⋅V^2⋅f

  • 如何在降低 VV 和 ff 的同时,用面积换性能?

1. 让一条指令能处理更多的数据

  • SIMD (Single Instruction, Multiple Data)
    • “一条指令” 浪费的能量大致是定数
    • 处理的数据越多,浪费越少

2. 用更多更简单的处理器

  • 多处理器系统、异构多处理器
    • 同等面积,处理器越简单,数量越多
    • 大小核,小核的能效比更高
      • 更小、更简单的核心——甚至可以不需要有CPU指令集的计算能力
      • “领域专用加速器”
        • ISP: Image Signal Processing (手机相机)
        • GPU: Graphics Processing Unit (图形渲染)
        • DPU: Data Processing Unit (网络/数据处理)

[!summary] 向量指令 向量指令(Vector Instruction)是一种单指令多数据(Single Instruction, Multiple Data, SIMD)的指令形式,它通过一条指令同时对多个数据元素进行相同的操作,从而显著提升数据处理效率。这种指令集设计主要用于并行计算场景,尤其适合需要处理大规模数据的高性能计算、多媒体处理和机器学习等领域。

GPU:Need More Threads

[!info] 图形渲染原理——GPU的由来 计算屏幕上每个像素点要展示的RGB Value(光照的变化等......) ![[assets/并发编程/file-20250530163954871.png]] 而对于屏幕上的每个像素点(也就是Shader函数)都是可以并行的,我们就需要大量的线程实现并行(2,073,600个线程)

  • SIMT(Single instruction, multiple threads) ——GPU上的并行 单指令多线程:多个Cores共享一个PC,实现所有线程同步执行相同指令

  • 线程发散

    • 分支对于GPU执行指令的损耗是很大的,但是多线程重复指令GPU很擅长

[!summary] 线程发散 线程发散(Thread Divergence) 是指同一线程束(Warp/Wavefront)中的线程因条件分支(如if-else)进入不同的执行路径,导致GPU无法并行执行所有线程,从而降低效率的现象

  • CPU为了执行业务逻辑,要执行、加载很多离散的代码。 因此CPU更擅长处理线程发散,而对于要同时执行大量的相同指令,优化是很差的
  • 而线程发散对于GPU执行指令的损耗是很大的,但是多线程重复指令GPU很擅长

因此,当你的程序对内存的访问,符合正确的模式时,GPU才能发挥最大的带宽