CMU 15-213 CSAPP:并发编程与同步的艺术(Concurrent Programming)
写在前面这是本系列系统级编程学习笔记的终章。当我们的 Web 服务器终于能够通过网络响应一个客户端之后新的问题接踵而至如果同时有一万个人访问怎么办并发编程Concurrent Programming是现代计算机科学的王冠但它也是无数隐蔽 Bug 的万恶之源。这篇笔记我们将探讨进程、线程、事件驱动的恩怨情仇以及伟大的计算机先驱们是如何利用“信号量”来约束这些狂暴的并发洪流的。Lec 23 Concurrent Programming并发编程是很困难的只要涉及到并发程序的执行顺序就成了一个玄学取决于操作系统的调度策略。常见的灾难包括竞争 (Races):程序的正确性居然依赖于哪个线程跑得快。举例谁能抢到飞机上的最后一个座位死锁 (Deadlock):资源分配不当导致所有人都卡死。举例十字路口四个方向的车互不相让彻底堵死。活锁 / 饥饿 / 公平性 (Livelock / Starvation):某个倒霉的线程因为优先级太低永远得不到 CPU 时间。举例你在排队但总是有 VIP 插队到你前面。Iterative Servers 的基本流 (串行服务器的致命缺陷)上一篇笔记中我们写了一个简单的 Echo Server。它的逻辑是接收一个连接 - 读数据 - 写回数据 - 断开 - 处理下一个。致命缺陷如果 Client 1 连上了但是它去上厕所了迟迟不发数据Server 就会死死阻塞在read上。此时如果有其他几万个 Client 想要连接全都会被拒绝。解决方案必须使用并发服务器 (Concurrent Servers)同时拉起多个逻辑流来服务不同的客户端。书写并发服务器的方法 (The 3 Approaches)如何实现并发业界有三大流派基于进程 (Process-based):内核负责管理每个客户端分配一个独立的进程。基于事件 (Event-based):程序员手工在用户态调度所有客户端都在一个进程/线程里利用 I/O 多路复用如select/epoll。基于线程 (Thread-based):进程和事件的折中方案。内核调度但所有线程共享同一个地址空间。基于进程 (Process-based)Pros (优势):绝对安全。由于每个进程有独立的地址空间一个客户端崩溃绝对不会影响其他客户端。不共享全局变量没有加锁的烦恼。Cons (劣势):太重了进程创建 (fork) 和上下文切换的开销极大。且进程间通信IPC如管道、共享内存编码非常复杂。基于事件 (Event-based)这就是大名鼎鼎的Node.js, Nginx, Redis背后的核心架构Pros (优势):性能怪兽。没有进程/线程切换的开销代码都在一个线程里不需要加锁调试可以用 GDB 一直单步走下去。Cons (劣势):陷入“回调地狱”Callback Hell编码极度反人类。此外最致命的是无法利用多核 CPU因为自始至终只有一个线程在跑必须额外部署多进程架构来弥补。线程 v.s. 进程 (Threads vs. Processes)线程是操作系统的轻量级调度单位。相似点:都有自己的逻辑控制流都能被内核并发调度上下文切换。不同点:线程共享所有的代码段、数据段、堆和打开的文件描述符它们只独占极小的栈。性能差距:创建一个线程的代价远小于进程通常便宜一倍以上。这也是为什么现代 Web 服务器如 Tomcat/Go大量依赖多线程/协程并发。总结并发的方式没有银弹。进程隔离好但太笨重。事件极速且轻量但吃不到多核红利代码难写。线程开销适中但“数据共享太容易了”导致极其容易写出数据竞争Data Race的 Bug极难调试Synchronization Basics (同步的基础)并发最可怕的就是多个线程同时去读写同一个共享变量。为了理解它我们需要引入“轨迹图”模型。临界区与不安全区 (Critical Sections Unsafe Regions)临界区:操作共享变量的那几行代码。不安全区:想象一个二维坐标系X 轴是线程 1 的进度Y 轴是线程 2 的进度。如果它们的临界区在时间上重叠了这块相交的区域就是“不安全区”。一旦操作系统的调度导致程序轨迹穿过了不安全区数据就一定会被覆盖/错乱。我们的目标是通过某种机制迫使程序的运行轨迹绕过这片“雷区”。如何保证安全路径我们需要实施互斥 (Mutual Exclusion)当一个线程在临界区时其他线程绝对进不来。主流解决方案Semaphores (信号量)- 由荷兰计算机科学巨擘 Edsger Dijkstra (迪杰斯特拉) 提出。Mutex (互斥锁) 和 Condition Variables (条件变量)。信号量 (Semaphores)信号量本质上就是一个具有非负值的全局整数s只能通过两个特殊的原子操作来修改它P(s) 操作:(荷兰语 Proberen, 尝试/等待)。如果s 0则s s - 1并立刻返回。如果s 0线程就会挂起休眠直到s变为非零。V(s) 操作:(荷兰语 Verhogen, 增加/释放)。将s s 1。如果有其他线程正因为P操作在苦苦等待就唤醒其中一个。核心机制P 和 V 操作必须由硬件/操作系统保证是绝对原子的不可分割否则信号量自己也会产生数据竞争C 语言中的信号量操作 (POSIX)sem_init: 初始化信号量。如果是互斥锁Mutex初始值设为1如果是资源计数设为对应的数量。sem_wait: 对应 P 操作等待/减1。sem_post: 对应 V 操作释放/加1。Summary并发程序的开发者必须清楚地知道哪些变量是被共享的。只要是共享的就必须通过信号量或互斥锁包裹起来进行保护。Synchronization Advanced (同步的进阶)经典模型Readers-Writers 问题在数据库或缓存中常常面临这样的场景许多线程只想“读”数据少数线程想要“写”数据。由于读操作不改变数据允许多个读者同时进入是安全的但写者进入时必须绝对独占。第一类读者优先:只要还有一个读者在里面后续来的写者全部在门外排队。极端情况如果读者源源不断地到来写者将被活活饿死Starvation。第二类写者优先:只要有一个写者在排队后续新来的读者不准进入等里面的读者出来后写者立刻进去修改。极端情况如果写者太多读者会饿死。工程上往往需要更加复杂的读写锁如pthread_rwlock_t来平衡这两者。线程安全 (Thread Safety) 的四个深渊在多线程环境中调用的函数必须是线程安全的有四大类函数是绝对危险的不保护共享变量的函数。(如多个线程直接count)。在多次调用间保存状态的函数。(如依赖全局状态的伪随机数发生器rand()下一次结果依赖上一次)。返回指向静态变量指针的函数。(如经典的ctime极其危险)。内部调用了其他线程不安全函数的函数。(毒树之果)。第三类危险返回指向静态变量的指针以 C 语言标准库的ctime()为例。它会把时间计算好存放在函数内部的一个static字符数组中然后返回这个数组的指针。如果线程 A 调了ctime拿到了指针还没来得及打印线程 B 瞬间又调了一次ctime。因为它们共享那个static内存线程 A 打印出的将是线程 B 的时间解决方案Lock-and-copy (加锁复制):我们用一个包装函数Wrapper在调用原版ctime前加锁拿到结果后迅速strcpy拷贝到线程自己私有的malloc内存中再释放锁。重写为可重入函数 (Reentrant):现代的做法比如ctime_r。强迫调用者自己分配好一块内存地址传进去函数只负责把结果往那个传进来的私有地址里写彻底杜绝全局状态。Thread Level Parallelism (线程级并行)当我们迈过了同步与线程安全的这道天堑前方的道路豁然开朗。线程级并行TLP不仅是为了解决“多客户端响应”的问题更是压榨现代多核 CPU 算力的终极钥匙。把一个计算密集型大任务如图像渲染、矩阵乘法切分成小块派发给多个线程同时跑在不同的物理核心上这才是计算机系统性能起飞的最后一块拼图。全系列完结撒花 从汇编指令到内存堆栈从系统调用到网络 Socket再到最终的多线程并发。这不仅仅是 CSAPP 的落幕更是你作为一名顶级系统程序员觉醒的开端。Keep coding!