Lec 14 并发
[toc]
本节的学习目标:
- 学习两种并发方式
- 消息传递
- 共享内存
- 并发进程和线程,和时间片轮转(time slicing)
- 竞态条件的危险性
1. 并发的概念
并发意味着两个计算在同一时间发生,在现代编程中无处不在:
- 在一个网络内多台计算机一同工作
- 一台计算机内运行多个应用
- 一台计算机里面又有多核的处理器芯片
事实上,并发对于现代编程非常关键。
- Web网站,同时处理多个users请求
- 图形用户界面几乎总是需要不打扰用户的后台工作
- 例如,Visual Studio Code 会在您编辑 TypeScript 代码时对其进行编译。
并发编程在未来仍然很重要。处理器时钟速度不再提升。相反,每一代新芯片都会拥有更多核心。因此,在未来,为了提高计算速度,我们必须将计算拆分成多个并发部分。
2. 两种并发编程的模型
有两种比较通用的并发模型
2.1 共享内存
在共享内存并发模型中,并发模块通过读写内存中的共享对象进行交互。在右图中,A 和 B 是并发模块,可以访问同一共享内存空间。蓝色对象是 A 或 B 的私有对象(只有一个模块可以访问它),但橙色对象由 A 和 B 共享(两个模块都引用它)。

共享内存模型场景示例:
- A、B可能是同个计算机的两个处理器,共享同一个物理内存
- A、B可能是同个计算机的两个程序,共享者同一个文件系统的的文件,并且能够读写
- A、B可能是同个程序的两个线程,共享和同个对象
2.2 消息传递
在消息传递模型中,并发模块通过通信通道相互发送消息进行交互。模块发送消息,每个模块的传入消息排队等待处理。示例包括:

消息传递模型场景示例:
- A和B可能同个网络的两台计算机,通信通过网络连接
- A、B可能是浏览器和Web服务器,A打开对B的连接并请求一个网页,然后B将网页发送回A
- A、B可能是服务端/客户端的通信的瞬间的消息
- 可能是运行同个计算机的两个程序,输入输出都连接着一个通道,比如
ls | grep
3. 进程、线程、时间分片
3.1 进程
- 进程是一个正在运行的程序的实例,它与同一台机器上的其他进程是隔离的。特别是,它有自己专用的机器内存部分。
- 抽象地说,你可以将进程看作一台虚拟计算机。它让程序感觉像是独占了整台机器——就像刚创建了一台新的计算机,带有新的内存,仅用于运行该程序。
- 正如通过网络连接的计算机一样,进程之间通常不共享内存。一个进程根本无法访问另一个进程的内存或对象。在大多数操作系统上,进程之间共享内存是可能的,但需要特别的努力。相比之下,一个新进程自动准备好进行消息传递,因为它在创建时具有标准输入和输出流。
- 每当你在计算机上启动一个程序时,也会创建一个新的进程来包含正在运行的程序。
3.2 线程
- 线程是正在运行的程序中的一个控制点。可以将其视为正在运行的程序中的一个位置,加上引导到该位置的方法调用栈(这样线程在遇到返回语句时可以回到栈上)。
- 正如进程代表一个虚拟计算机一样,线程抽象代表一个虚拟处理器。创建一个新线程模拟在进程代表的虚拟计算机内部创建一个新的处理器。这个新的虚拟处理器运行相同的程序,并与进程中的其他线程共享相同的内存。
- 线程自动准备好共享内存,因为线程共享进程中的所有内存。需要额外的工作才能获得“线程本地”内存,这些内存仅对单个线程私有。
- 每当你运行一个 TypeScript/JavaScript 程序时,程序首先从一个线程开始,该线程开始执行你运行的文件作为其第一步。
3.3 时间片轮转(Time slicing)

当你的计算机中只有一个或两个处理器时,你如何拥有多个并发线程?当线程数多于处理器时,通过时间片轮转模拟并发,这意味着处理器在线程之间切换。右图显示了在只有两个实际处理器的机器上,如何对三个线程 T1、T2 和 T3 进行时间片轮转。在图中,时间向下进行,因此起初,处理器 P1 运行线程 T1,处理器 P2 运行线程 T2,然后处理器 P2 切换到运行线程 T3。线程 T2 简单地暂停,直到它的下一个时间片到来时在相同的处理器或另一个处理器上运行。图的最右部分显示了从每个线程的角度来看这种情况。线程有时在处理器上积极运行,有时则被挂起,等待下一个运行机会。
当线程在两个不同的处理器上同时运行时,称它们为并行运行,从而在软件中产生并行性。如果所有线程都在同一个处理器上运行,它们是并发的,但不是并行的。然而,即使没有并行性,并发仍然可能很复杂。在大多数系统中,时间片轮转是不可预测和非确定性的,这意味着一个线程可能在任何时间被暂停或恢复。
Python中线程
我们首先看Python中线程如何工作。Python的线程API类似其他面向对象语言,包括JAVA。你需要实例化一个Thread对象来开启一个新线程,并让其执行start(),新线程的代码是通过向Thread的构造器传递的。
from threading import Thread
def hello():
print("Hello from a thread!")
t = Thread(target=hello)
t.start()TypeScript的Worker
TypeScript没有用户线程,但有一种称为worker的抽象,有时候称为 Web Workers, 因为API首先出现在Web浏览器,并且Node也支持这个API
Worker是创建需要指定一个JS文件。例如,假设我们已经有了通过编译以下 TypeScript 代码创建hello-world.js文件:
// hello-world.ts (compiles to hello-world.js)
console.log('Hello from a worker!')然后我们可以启动一个worker执行它
let worker: Worker = new Worker('./hello-world.js')这段代码将立即开始为 Worker 创建一个新环境,开始执行 hello-world.js,并打印 hello 消息。它还将继续并发地执行启动 Worker 的代码。
请注意,new Worker 需要一个 JavaScript 文件(.js)。如果 Worker 代码是用 TypeScript 编写的,必须先将其编译为 JavaScript,然后才能启动 Worker。
Mozilla 和 Node 为 Worker 的文档都将其称为线程,但其行为更像是独立的进程:
- 与线程一样,Worker 可以使用
SharedArrayBuffer访问共享内存以共享二进制数据binary data。 - 但像进程一样,每个新 Worker 都在新的全局环境中运行其 JavaScript 文件。当导入一个模块时,它会加载该模块的私有副本;它不会与其他 Workers 共享模块。Workers 不能共享变量或常规对象的实例。
- 由于它们的共享内存能力非常有限,Workers 通常通过消息传递进行通信。在创建 Worker 的代码和 Worker 内部运行的代码之间会自动建立一个双向消息传递通道。
总的来说,我们可以将 Worker 视为轻量级进程。
共享内存的例子
这个例子的目的是为了说明并发编程的难度,因为它可能存在一些细微的错误。
想象一下,一家银行的自动取款机使用共享内存模型:一个通用的文件系统。银行账户的信息存储在一个文件中,所有自动取款机都可以读写该文件来更新账户信息。
为了说明可能出现的问题,我们将银行简化为一个账户,其余额以文本形式存储在名为 account 的文件中:

import fs from "node:fs"
export function readBalance(): number {
const fileData = fs.readFileSync('account.txt').toString()
return parseInt(fileData)
}
export function writeBalance(balance: number): void {
fs.writeFileSync('account.txt', balance.toString())
}两个操作 存钱(deposit) 和取钱(withdraw)都是更新做简单的加/减1刀
function deposit(): void {
let balance = readBalance();
balance = balance + 1;
writeBalance(balance);
}
function withdraw(): void {
let balance = readBalance();
balance = balance - 1;
writeBalance(balance);
}假设我们一天开始时账户中有 200 美元,那么一天结束时,无论有多少台自动取款机在运行,或者我们处理了多少笔交易,账户余额都应该保持不变,为 200 美元:
// start with a balance of 200
writeBalance(200);
for (let machine = 0; machine < NUMBER_OF_CASH_MACHINES; ++machine) {
new Worker('./cash-machine.js'); // runs the cash-machine code just above
}但是,如果我们运行这段代码,我们经常会发现,一天结束时的余额不是 200 美元。如果同时有多个工作进程运行——比如说,在同一台计算机的不同处理器上——那么一天结束时的余额可能不是 200 美元不变。为什么呢?
交错执行
假设两个取款机的工作线程A 和 B 同时进行 deposit操作,这个操作分为下面几条指令:
readBalance() return 200
add 1
wirteBalance(201)当 A 和 B 并发执行时,这些指令可能会交错执行,也就是说,A 的操作和 B 的操作可能被任意地穿插在一起。(如果 A 和 B 运行在不同的处理器上,有些操作甚至可能真正同时发生或重叠,但我们先只考虑交错执行的情况。)
| A | B |
|---|---|
| A readBalance() 返回 200 | |
| A add 1 | |
| A writeBalance(201) | |
| B readBalance() 返回 201 | |
| B add 1 | |
| B writeBalance(202) |
这种交错没问题 —— 最终余额是 202,说明 A 和 B 都成功存入了一美元。
但如果交错顺序如下:
| A | B |
|---|---|
| A readBalance() 返回 200 | |
| B readBalance() 返回 200 | |
| A add 1 | |
| B add 1 | |
| A writeBalance(201) | |
| B writeBalance(201) |
此时余额变成了 201 —— A 的一美元丢失了! 原因是:A 和 B 在同一时间读取了余额,都各自计算出了新的余额,然后同时写回,结果导致只保留了一次存款的结果,另一笔被覆盖掉了。
竞态竞争
竞争条件指的是:程序的正确性(即是否满足规范,以及是否保持其不变式)取决于并发计算 A 和 B 之间事件发生的相对时序。 当这种情况出现时,我们就说“A 和 B 存在竞争”。
有些事件的交错顺序可能没问题,它们的结果与单个、非并发的进程产生的结果是一致的; 但另外一些交错顺序则会产生错误的结果 —— 违反了规范或不变式。
消息传递例子
现在不仅是每个机器是一个模块,每个账户也是一个模块。模块通过给彼此发送消息来交互。过来的请求被放置在一个队列里面,一次只处理一条消息(比如,下图展示了账户1有3个pending中的请求),当等待其响应时, 发送方不会停止工作。 模块在等待别的模块响应时,仍然可以继续处理来自自身队列的其他请求 —— 它不会因为一次请求而阻塞整个模块的运行。 回复响应最终会作为另外一个消息返回

但不幸当然是,消息传递不会消除竞态条件的可能。假设每个账户支持get-balance和withdraw操作。两个用户,分别在机器A和B面前, 将同时尝试向同一用户取钱,他们首先检查余额,以确保提取的金额不会超过账户余额。

send get-balance message, and store the reply in local variable bal
if bal >= 1 then
send withdraw-1-dollar message问题再次在于交错,但这次是发送到银行账户的消息的交错,而不是 A 和 B 执行的指令的交错。如果账户初始余额为一美元,那么什么样的消息交错才能欺骗 A 和 B,让它们认为他们都可以提取一美元,从而透支账户?
在消息传递模型(message-passing model)里,接口(也就是可发送的操作消息)怎么设计非常关键。如果操作改成 withdraw-if-sufficient-funds(余额足够时再取钱),那么就把 “检查余额 + 扣钱” 这两步合并到一个原子操作里
并发测调试的困难
想通过测试找到竞态条件是非常困难的,即使测试发现了Bug,也很难将其定位到Bug位置。
并发Bug的可复现性(reproducibility)很差。很难让它们以相同的方式重复发生。指令或消息的交错顺序取决于事件的相对时序,而时序又强烈依赖于环境因素。延迟可能由其他正在运行的程序、网络流量、操作系统调度决策、处理器时钟速度的变化等引起——这些都是程序员几乎无法控制的因素。每次运行一个包含竞争条件的程序时,可能会得到不同的行为结果。
这些 bug 被称为 heisenbug(海森堡 bug),它们是非确定性的,难以重现的;而与之相对的是 bohrbug(玻尔 bug),它们在每次查看时都会重复出现。几乎所有顺序程序中的 bug 都是玻尔 bug。
一个海森堡 bug 甚至可能在你试图查看它时消失,比如通过 console.log 或调试器!原因是打印和调试操作比其他操作慢得多,通常慢 100 到 1000 倍,这会显著改变操作的时序和交错顺序。
这个问题在使用共享内存的线程中尤为明显,因为它们访问共享变量的速度非常快。但这种情况仅仅是被掩盖,并没有真正解决。程序中其他地方时序的变化可能会突然让这个 bug 重新出现。
在使用 TypeScript 的银行账户示例中,通过worker线程读取和写入共享文件,比较难看到这种效应,因为文件访问相对比打印慢。但海森堡 bug 现象依然存在,且如果你在断点处停止其中一个现金机并尝试逐步调试,问题仍然会显现——因为另一个现金机可以在没有干扰的情况下完成运行,而问题似乎消失了。
并发编程很难做到正确。这篇文章的部分目的是让你对并发编程中的这些 bug 感到警觉。在接下来的几篇阅读材料中,我们将看到一些原则性的方式来设计并发程序,以使其更安全,避免这些类型的 bug。