Grokking Concurrency
Date: 2025-06-01
并行的限制
程序中无法并行的部分,决定了整体性能的上限。
Amdahl 定律
一个程序中,假设其中 Lock / Unlock 是串行(不可并行),只占 30% 时间;
-
你把其余的 70% 并行了,最多也只能加速约 1 / (0.3 + 0.7/N);
-
即使 N=100,也不是 100 倍加速,可能只有 3 倍左右。
-
任务中无法并行的部分就是瓶颈,再多线程、再多 CPU 也没用;
-
可并行度越小,增加资源的收益越差。
虽然Amdahl 定律 展现了一个令人失望的结果! 但我们仍然需要乐观
乐观视角
Gustafson 定律
Amdahl 假设任务总量是固定的,这在现实中不常见,反过来看:既然加速比有瓶颈,那我们不如做更多的任务!
核心思想: 并行不是为了加速固定的任务,而是为了让我们能处理更大的问题规模、更复杂的数据量。
你银行系统如果每天只能处理 1 万笔转账(串行),用了并发系统之后,不是把 1 万笔处理更快 —— 而是你能处理 100 万笔!
并发和并行
Rob Pike说过的一句经典的话
“Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.”
并发: 你能同时接收很多个转账请求,每个请求你都安排好流程、排队、处理顺序;
并行: 你有多个处理器,多个请求可以真的在同一时刻一起处理(比如一个核心处理用户 A 的转账,另一个处理用户 B 的转账)。
并发是编程模型,Go 语言提供了 goroutine + channel,容易表达并发逻辑;是否能并行,是操作系统 + CPU 的事。
进程和线程
为什么说没有线程的进程是不存在的?
现代操作系统的进程实现方式。从技术角度看,进程至少包含一个执行线程(即主线程)
进程类比为: 拥有者资源 线程类比为: 执行资源的人
线程(工人) 进程(建筑)
所以说 即使你拥有资源但是无法执行 也就相当于没有拥有!
谁又去管理 线程和进程呢?
也就是操作系统,操作系统_使用PCB和TCB_去管理
操作系统用PCB记录进程的资源(内存、文件等)。
用TCB记录线程的执行状态(寄存器、栈等)。
PCB必须关联至少一个TCB,否则进程无法被调度执行。
线程的优缺点
优点:
-
相比于进程,拥有更少的内存开销
-
更少的通信开销:因为 线程间使用相同的地址空间 直接读写其进程的共享地址空间 一个线程所做 立刻对所有线程可见
缺点:
- 需要同步: 因为操作系统对于进程的隔离做的非常好,通常 一个进程的崩溃 不会影响其他进程 但是对于线程来说 一个线程很可能会受到影响
问题:
为什么一个进程崩溃 常常不会影响另一个进程 (如何两个进程需要通信呢?) 一个线程的崩溃常常会影响所有线程使用的相同共享资源?
即使两个进程通信(如管道、socket、共享内存):
进程 A 崩溃不会导致进程 B 崩溃,最多导致 B 的通信读/写出错(比如管道断了、socket 断开)。
OS 保证隔离,通信机制只是连接而非绑定生死。
而:
所有线程共享同一个进程的 地址空间、堆、全局变量、文件描述符等资源。
一个线程写坏共享数据、释放了不该释放的资源、非法访问内存,会破坏整个进程的状态。
OS 管线程是以“进程整体”看待的,某线程崩溃时,整个进程可能被 OS 杀掉(例如段错误信号)。
进程间通信
通信是并发系统的核心: 我们应该保证进程间通信的高效执行,这样才能享受并发带来的性能提升
“进程内线程A 与 进程内线程B 的通信,只不过它们各自受进程隔离保护” 这是形象的理解进程间通信的本质
IPC
一旦决定应用程序从IPC中收益,必须决定在系统上使用哪一种可用的IPC方法!
**NOTE: ** 无论是进程还是线程 都可以将他们视为线程 每个进程都至少有一个线程 实际上通信只发生在线程之间
共享内存IPC
最简单的方式: 共享内存, 一个或者多个任务 通过公共内存进行通信, 这些内存在他们的虚拟地址空间中出现,它们就好像在阅读自己的内存空间,这样的更改通常会直接反应在其它进程或线程中,无需于操作系统交互
实例:
- 创建共享空间
- 生产者向共享空间写入数据
- 消费者向共享空间读取数据
优点:
提供了最快且资源消耗最少的通信方式(操作系统参与分配共享内存 但 并不参与任务间通信)实现了更高的速度 和 更少的数据
缺点:
-
不一定是最安全的, 操作系统不会为共享内存提供接口和保护,如果两个人一起访问了同一块空间呢?(保护共享内存重新设计代码)
-
仅用于本地机器 无法拓展多台机器, 在分布式系统中会出现问题: 多个数据无法容纳在一台机器上
消息传递IPC
(通常由操作系统支持) 每个任务由唯一名称标识, 任务通过向命名任务发送和接收消息进行交互 操作系统建立通信通道, 适当提供系统调用
优点:
操作系统管理通道,提供接口发送和接收数据 避免冲突
另一方面 : 通信成本较高 想要传输信息 必须通过系统调用 将信息从任务的用户空间复制到操作系统管道,然后再复制回接收任务的地址空间
消息传递 IPC也就是:用户空间数据走系统调用到内核,内核中转再送到目标进程用户空间,性能开销高但安全可控。
可以轻松部署到多台机器的分布式系统
NOTE: Go中的通过通信来共享内存!
很多技术都可以实现消息传递 类似的思想!
同样!也是Linux中的重要思想
管道
最简单的进程间通信(任务之间的的单向数据流 一端数据写入端 一端数据读取端 )双向通信必须创建两个管道
类比: 水管 再水流中 一段放入鸭子 从另一侧流出
代码: 调用写入端方法发送数据, 调用读取方 读取传入的数据
管道是一个临时对象,任何一方被丢弃则管道被关闭
**管道本质:**文件描述符
单向字节流:数据没有明确的消息边界,读端需自行解析(如按\n分隔)。
半双工限制:若需双向通信,必须创建两个管道。
亲缘限制:无名管道仅限父子进程;命名管道(FIFO)虽可跨进程,但仍需文件路径协调。
局限:
-
如果多个消费者进程同时读取同一个管道,数据可能被任意一个进程抢走(无法定向投递)。
-
如果多个生产者写入同一个管道,数据可能混杂(需额外同步机制)。
**NOTE: ** Go中channel 被称为 goroutine之间通信的管道
无名管道
无名管道的作用范围:
子进程-父进程-同一进程中的线程 相关任务共享文件描述符 无名管道在任务使用完毕后消失
命名管道
解决了无名管道(pipe())的局限性允许无亲缘关系的进程通过读写该文件进行数据交换。
跨进程通信需求
客户端和服务器的通信(如数据库服务监听 FIFO 请求)。
创建一个命名管道: mkfifo myfifo
这将 在当前目录下生成一个特殊文件:
echo "hello from writer" > myfifo
(这会阻塞,直到有进程读)
在终端2 cat myfifo
文件描述符
Linux中规定每一个文件对应一个索引,这样要操作文件的时候,我们直接找到索引就可以对其进行操作了。
**大体思想:**内核为了高效管理这些已经被打开的文件所创建的_索引_,其是一个非负整数(通常是小整数),用于指代被_打开的文件_
0 1 2 是linux 固定的三个文件描述符 剩下的文件也就是依次增加 4 5 ...
每一个连接、管道、文件都占一个 fd 所以一个进程会有很多的文件描述符
Shell 命令执行(如 ls | grep "txt" 就是 fork() + pipe() 实现的)。 这期间发生了什么?
- Shell 调用 pipe()
它就是内核中一段缓冲区(队列),写进去的字节可以从读端读出来。
- Shell 调用 fork() 两次(或一次+循环):
第一次 fork 出 ls 子进程
第二次 fork 出 grep 子进程
dup重定向 -> 把进程的标准输入(fd 0)、标准输出(fd 1)、标准错误(fd 2)重新指到你想让它流向的地方,比如 pipe、文件、socket。
消息队列
-
传输的是结构化消息,每条消息包含: 类型字段(msg_type,用于区分消息类别)。 数据部分(长度固定或可变)。
-
多进程可同时读写,通过_消息类型(mtype_)区分目标
生产者通过 mtype 标记消息类别。
消费者通过 mtype 选择性接收,互不干扰。
-
显式创建(msgget())和销毁(msgctl(IPC_RMID)),即使进程退出仍存在,除非主动删除。
-
并且 消息持久化在队列中,即使消费者未启动,生产者仍可发送消息。
多进程协作怎么体现?
进程A(生产者1):发送类型为1的消息(任务A)。
进程B(生产者2):发送类型为2的消息(任务B)。
进程C(消费者1):只接收类型1的消息。
进程D(消费者2):只接收类型2的消息。
**消息队列模式:**生产者 → 队列 → 消费者
UDSs
同一系统上线程使用的UNIX域套接字
一个线程向套接字写入消息,另一个线程从套接字接收消息( 序列化 = 对象 → 字节流/文本)
问题:
IP 地址 决定了数据包送到哪台计算机。 为什么不是MAC地址+端口号?
MAC地址是链路层概念,只能在同一局域网内定位设备(如你的电脑和路由器)。
IP地址是网络层概念,可以在全球互联网范围内定位主机。
每一跳的MAC地址都会变化,但源IP(192.168.1.2)和目标IP(180.101.49.12)始终不变
回答:
Socket 不直接用 MAC,是因为它处在更高层(传输层 + 应用层)。
MAC 地址仍然存在,只是由操作系统自动处理。
程序员只关心 (IP, Port),而 MAC 是链路层的“底层细节”。
多级并发硬件
指令级并行
一次执行多个指令,让一条指令执行的同时,下一条指令也开始执行。 ➤ 例子:流水线(pipeline)、乱序执行(out-of-order execution)。
位级并行
一次处理多个位的数据。 ➤ 例子:以前 8 位处理器升级成 16/32/64 位,能一次处理更多的数据位,效率提升。
对称多处理器架构(SMP)
有两个或更多的相同的处理机(处理器)共享同一主存,由一个操作系统控制
优缺点
优点是并发度很高,但是由于系统总线的带宽是有限的,故处理器数目受限,且性能受限。
思想 | 说明 -- | -- 对称性 | 所有 CPU 平等地访问内存与 I/O,无主从之分。 共享性 | 共享同一内存空间,便于线程/进程通信。 可扩展性(中等) | 可以添加更多 CPU,但随着数量增加总线可能成为瓶颈。实际用处
场景 | SMP 的优势 -- | -- 多线程应用(Web服务器) | 各 CPU 并行处理请求,响应快 数据库(PostgreSQL等) | 并行查询,提高吞吐量 操作系统调度 | 系统把进程平均调度到多个 CPU 上 虚拟化(如 KVM) | 每个虚拟机分配一个核心运行更流畅 多核编程(Go 并发等) | 利用 SMP 启动多个 goroutine,映射到多个核心并行执行缺点和瓶颈:
共享内存导致竞争:多个 CPU 同时访问共享数据,需加锁或同步。
总线带宽瓶颈:CPU 越多,共享总线的压力越大。
缓存一致性问题
SMP架构
所有 CPU 共享同一个内存、I/O 设备,硬件设计容易实现,降低成本,
现有问题:
总线瓶颈:多个 CPU 竞争同一个总线访问内存,扩展性受限
缓存一致性问题:多个 CPU 缓存共享数据,需要一致性协议(如 MESI) 怎么解决的
SMP 架构中,每个 CPU 有自己的 L1/L2/L3 缓存。
多个 CPU 同时读写 共享内存的同一地址,每个 CPU 缓存了这块数据。
某个 CPU 修改了数据,别的 CPU 的缓存仍然是旧值 ⇒ 缓存不一致。
现代解决
MESI 协议
M (Modified) | 缓存已被修改,和内存不一致 E (Exclusive) | 缓存和内存一致,其他 CPU 没有该数据 S (Shared) | 缓存和内存一致,多个 CPU 都有副本 I (Invalid) | 缓存无效,需要从内存或其他 CPU 获取
伪共享
CPU 缓存行(Cache Line):现代 CPU 读取内存时,并不是逐字节读取,而是以 缓存行(通常 64 字节) 为单位加载。
伪共享问题:如果两个线程(Thread A 和 Thread B)分别修改 同一个缓存行里的不同变量,即使它们_逻辑上不冲突_,也会导致缓存行频繁失效,触发 缓存一致性协议(如 MESI) 的额外同步开销,降低性能。
如何理解
以 缓存行(通常 64 字节) 为单位加载,什么是,修改同一个缓存行里的不用变量
缓存行:
缓存行 = CPU 缓存中最小的传输/对齐单位 大小通常是 64 字节(也有 32、128,看架构)也就是: CPU 并不会每次只加载 8 字节,而是以一整块 64 字节(cache line)为单位。
当 CPU 想读取 a 时,它会加载从 0x1000 开始的 64 字节内存块 到缓存。
这 64 字节中包含了 a 和 b。
一次读/写内存,CPU 不是按字节,而是按“缓存行”来加载的
例如:你访问内存地址 0x1000,CPU 会一次性加载 0x1000 ~ 0x103F(64 字节)到缓存中
为什么这么设计?
局部性原理(Locality):程序访问内存通常是“连着访问”,一次多取一点更高效
提高内存带宽利用率,降低缓存 miss
示例:
c
如果变量落在同一个缓存行内,会发生什么?
虽然这些变量本身没有关系(不同用途、不同线程在用),但是:
因为在同一个 cache line 里
一个 CPU 修改了它自己的变量 ⇒ 整个 cache line 被标记“已修改”
其他 CPU 的该 cache line 就变成 Invalid,必须重新从内存加载 ⇒ 频繁的 cache line 失效和同步
加深理解:
因为go的调度器可以将 不同的goruntine调度到cpu0和cpu1
缓存行一致性协议就会出现:
goroutine1 在 CPU0 上运行:
执行 counter.a++,需要修改 a。 CPU0 将包含 a 和 b 的缓存行加载到自己的缓存,并标记为 Exclusive。 修改 a 后,缓存行状态变为 Modified。
goroutine2 在 CPU1 上运行:
执行 counter.b++,需要修改 b。 因为 b 和 a 在同一个缓存行,CPU1 需要访问这个缓存行。 但 CPU0 的缓存行已标记为 Modified,CPU1 必须通过总线通信,要求 CPU0 将缓存行写回主内存(或直接传递给 CPU1)。 这导致 CPU0 的缓存行变为 Invalid 或 Shared,CPU1 获得缓存行并标记为 Exclusive,然后修改 b,状态变为 Modified。
反复竞争: 如果 goroutine1 再次尝试修改 a,它会发现自己的缓存行已失效(因为 CPU1 拿走了控制权),需要重新从 CPU1 或主内存获取缓存行。 这会导致频繁的缓存失效和总线通信,显著降低性能。
处理伪共享
为什么同步机制可以避免伪共享?
互斥访问:mu.Lock() 确保 goroutine1 和 goroutine2 不会同时修改 counter.a 和 counter.b。
缓存行为:
当 goroutine1 获取锁并运行在 CPU0 上时,它加载包含 a 和 b 的缓存行到 CPU0 的缓存,状态为 Exclusive 或 Modified。 goroutine2 在尝试获取锁时会被阻塞,无法同时访问缓存行,因此不会触发 CPU1 的缓存加载或修改。 只有当 goroutine1 释放锁(mu.Unlock())后,goroutine2 才能获取锁并加载缓存行到 CPU1。
放弃共享内存架构
也就是 分布式内存架构: ,不要多线程竞争数据,而是让数据主动迁移,或使用消息通信来代替共享状态。也就是 让某个线程“拥有”这块数据,其他线程需要时请求它来处理数据并返回结果。
不共享状态,就不需要锁,程序就简单且高性能。
并行计算机分类
Flynn 并行计算机分类(按指令流和数据流)
SISD | Single Instruction, Single Data | 单处理器,串行执行 | 传统单核 CPU SIMD | Single Instruction, Multiple Data | 一条指令处理多个数据(数据级并行) | GPU、向量处理器(如 AVX) MISD | Multiple Instruction, Single Data | 理论上存在,几乎无实际用途 | 容错系统(很罕见) MIMD | Multiple Instruction, Multiple Data | 多处理器,各自独立运行程序(主流) | 多核 CPU、分布式集群
按内存架构分类
SMP | 对称多处理器,共享主内存 | 多核 CPU,桌面服务器 NUMA | 非统一内存访问,每个 CPU 有本地内存 | 大型多核服务器 分布式内存 | 每个节点独立内存,通过网络通信 | 集群(HPC、分布式系统)
按通信方式分类
共享内存 | 通过共享变量通信 | 多线程程序、Go channel 消息传递 | 通过显式消息通信 | 分布式系统(gRPC、MPI、微服务)
CPU架构
cpu的时钟频率为什么时钟频率的快慢决定执行指令的多少?
时钟频率越高,单位时间内“干活的机会”越多,理论上能执行更多指令
典型的 MIMD:每个核干自己的活,处理不同数据,互不干扰。现代 CPU 之所以“基于 MIMD”,是因为它们具备多个核心,每个核心能独立执行不同的程序(指令)并处理不同的数据。
GPU架构
SIMD 更适合 GPU: “一条指令,多份数据” 也就是:一个指令流,作用于多个数据。所有执行单元同时做同一件事,但每个操作的数据不同。 数据量大、操作统一。 因为矩阵单元的大多数操作彼此独立且相似,可以并行化,也就是大规模的并行处理
并发编程步骤
拆分程序为小型 ,独立任务,找出大任务中可以独立完成的部分,任务之间尽量无依赖、无共享状态
任务不要太大,避免单个任务阻塞整体
任务不要太小,避免调度开销过大
拆分并发任务,就是找出可以独立执行的“小任务”,用合适工具调度它们并行执行。
进程
也就是 :
程序在磁盘,静止不动
进程在内存,是程序运行时的动态表现
操作系统把每个运行的程序包装成一个进程,不同进程之间:
拥有独立的内存空间
互不干扰,一方崩溃不影响另一方
通过特定方式(IPC)通信 以后会继续了解!
进程内部
进程的内部 由于 进程读取和写入的数据在内存中,所以进程可以看到或访问的内存是运行中的进程的一部分
进程的内存空间主要包括:
代码段(Text Segment):存储可执行文件的机器指令,只读。 数据段(Data Segment):存储初始化和未初始化的全局/静态变量。 堆(Heap):动态分配的内存(如malloc分配)。 栈(Stack):存储局部变量、函数调用信息。 环境变量和命令行参数:存储进程的环境信息。
PCB
PCB是操作系统用来存储和管理每个进程元信息的数据结构
PCB 存储 进程ID 进程状态 可执行文件 程序还会访问磁盘 网络资源 等 必须包含进程打开的文件列表
启动一个进程是一个非常繁重的事情 这就是为什么通常被称为重量级进程
进程状态
进程从开始到执行到销毁 一定是有一定的状态去区分和管理的! 整个对于进程的状态的管理全部交给操作系统,进程附带的许多资源必须被创建或者释放,这将引入很大的额外延迟