编程笔记

lifelong learning & practice makes perfect

翻译|理解并发、并行,以JS为例


直到现在,我才意识到并发和并行实际上是不同的概念,因为有些人经常互换使用它们。我在阅读《Clojure for the Brave and True》这本书的第 9 章时才了解到事实并非如此。

这让我想要更多地了解与并发和并行相关的概念,尤其是关于我最熟悉的编程语言:JavaScript。所以这篇文章基本上是我在这个学习过程中所做的笔记的集合。

顺序、并发和并行

在生活中执行任务时,我们以顺序、并发或并行的方式执行它们。这同样适用于计算。

顺序执行基本上是指任务一个接一个地完成,没有任何重叠。例如,如果一个人先看手机,完成手机上的工作,然后才切换到另一个任务,例如吃汤,他们就是在顺序工作。这种方法的问题是,有时你的任务会被阻塞,例如,当你用手机向朋友请求某件事时,如果你在朋友回答之前不切换到其他任务,你基本上会浪费时间。因此,不时地使用不同的多任务处理形式可以帮助节省时间。并发和并行是实现多任务处理的方法。但是,两者之间存在细微但重要的差异。

并发就像通过在子任务之间交替(也称为交错)来处理多个任务,而并行就像同时执行多个任务。例如,如果一个人看手机,放下手机舀一口汤,然后在放下勺子后回到手机,他们就是在并发工作。相反,如果一个人一边用一只手发短信,一边用另一只手吃饭,他们就是在并行工作。在这两种情况下,他们都在进行多任务处理,但他们在多任务处理的方式上存在细微的差异。

线程

在上面的类比中,我将吃汤和使用手机称为不同的任务,每个任务都由子任务组成(例如,吃汤,你需要拿着勺子,然后把它放到汤里,然后把它放到嘴里,等等……)。

同样,在编程的上下文中,子任务可以被认为是进程中较大的一组指令的各个段。同时操作不同子任务的传统方法是创建不同的内核线程。这些线程有点像单独的工作人员,每个工作人员处理他们特定的任务,同时能够处理同一组指令以及资源。

你的线程是并行运行还是并发运行实际上取决于你的硬件。如果你的 CPU 的核心数多于同时运行的线程数,则每个线程都可以分配给不同的核心,从而允许它们并行运行。但是,如果你的 CPU 的核心数少于线程数,则操作系统将开始在线程之间交错。

当涉及到内核线程时,开发人员的体验保持不变,无论任务是实际并发处理还是并行处理,都没有太大区别。开发人员使用线程来提高性能并避免阻塞。但是,操作系统会根据可用资源最终决定如何处理这些线程。只要开发人员使用线程,无论它们是并发运行还是并行运行都无关紧要;在这两种情况下,来自不同线程的指令的执行顺序都是不可预测的。因此,开发人员应警惕可能因两个不同线程操作相同数据而发生的潜在问题(如竞争条件、死锁、活锁等)!

生成进程、I/O 通知

除了使用线程之外,还有其他方法可以实现并发/并行,例如,虽然不如线程高效,但生成多个进程是另一种方法。由于 CPU 并行和并发地运行不同的进程,因此你可以使用多个进程进行多任务处理。这里的缺点是,每个进程都有其自己分配的内存空间,并且它们默认不共享它们的内存空间,就像线程一样。因此,如果你需要不同的进程在相同的状态下运行,你可能需要某种 IPC 机制,如共享内存段、管道、消息队列,甚至是数据库。

内核还实现了它们自己的 I/O 事件通知机制,这在构建你不想在执行某些任务时被阻塞的程序时也很有帮助。

我不想深入探讨太多细节,因为我对它了解不多,但关键思想是,内核线程不是你可以实现并发的唯一操作系统特定方法。

NodeJS,用户空间并发的示例

编程语言通常提供自己的并发机制,以简化与使用操作系统 API(系统调用)相关的复杂性。这意味着编译器或解释器可以将你的高级代码转换为操作系统理解的低级系统调用,这样你就不必考虑太多。

Node.js 就是这个概念的一个很好的例子。尽管你的 JavaScript 程序在单线程环境中以顺序执行流程运行,但诸如 IO 操作之类的阻塞任务会委托给 Node.js 工作线程。因此,NodeJS 在幕后使用线程来管理这些阻塞任务,而不会向开发人员透露管理它们的复杂性。

它的工作原理如下:阻塞操作,如写入文件或从文件读取,或发送网络请求,通常使用 Node.js 提供的内置函数来处理。当调用这些函数时,你通常会传递回调函数作为参数,以便 Node.js 工作线程在完成其任务时可以执行你提供的回调函数。

对 NodeJS 并发在幕后的工作原理有了一点了解后,我们现在可以开始通过检查某些情况/情境来实践这个理论。

考虑以下代码(感谢我的朋友 Onur 提出这个例子);

1
2
3
4
5
6
7
8
9
10
11
setTimeout(() => {
while (true) {
console.log("a");
}
}, 1000);

setTimeout(() => {
while (true) {
console.log("b");
}
}, 1000);

在这里,如果你运行这个程序,你屏幕上唯一会遇到的就是“a”s。这是因为 NodeJS 解释器会继续执行当前回调,只要还有可用的指令。

一旦主代码中的所有指令都执行完毕,NodeJS 运行时环境就会开始调用回调函数。你也可以将你编写的主代码视为默认被调用的回调。在上面的示例中,第一个 setTimeout 使用提供的回调函数执行,第二个 setTimeout 使用提供的回调函数执行。1 秒过后,它开始刷屏“a”s。你永远不会看到“b”s,因为一旦调用了第一个回调,它就会用它丑陋的 while 循环永远支配着主线程!因此,永远不会调用第二个回调。

这有一些重要的影响。首先,它减少了诸如竞争条件之类的问题的可能性,尽管它们仍然可能发生,尤其是与 C 之类的多线程语言相比。为什么?在类似 C 的语言中,CPU 在指令级别交错线程,而在这里,它主要发生在回调级别。只要你避免使用依赖于带有嵌套回调的 async 函数的复杂逻辑,就可以确定执行流程保持不中断,基本上是顺序的。

如果编程逻辑包含许多 基于异步回调的函数(如 fs.readFile()setTimeout()setImmediate(),甚至是 Promise.then()),则竞争条件很容易开始发生。

这也适用于 await 的使用,因为你可以将 await 语句视为将当前范围内的剩余代码包装到一个回调函数中的简写,该回调函数在等待的 Promise 被解析后运行。

考虑下面提供的 testtest2 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const
{scheduler} = require('node:timers/promises'),

test = async () => {
let x = 0

const foo = async () => {
let y = x
await scheduler.wait(100)
x = y + 1
}

await Promise.all([foo(), foo(), foo()])
console.log(x) // 返回 1,而不是 3
},

test2 = async () => {
let x = 0

const foo = async () => {
await scheduler.wait(100)
let y = x
x = y + 1
}

await Promise.all([foo(), foo(), foo()])
console.log(x) // 返回 3
},

main = () => {
test()
test2()
}

main()

test() 记录 1 的原因是,当调用 foo 函数时,它们一旦遇到 await scheduler.wait(100),它们就会立即完成。因为在幕后,使用 await scheduler.wait(100) 会评估如下内容:

1
2
3
scheduler.wait(100).then(() => {
x = y + 1
})

因此,第一个 foo 函数完成其工作,现在由回调函数继续执行业务,但由于它只会在 100 毫秒后被调用,因此 NodeJS 解释器不会闲置,而是继续按顺序执行第二个和第三个 foo 函数。它们还在来自第一个 foo 的回调被触发之前将 y 变量设置为 x 的值,并使用回调函数调用 scheduler.wait。因此,当回调最终被执行时,它们都使用 x 的先前值更新 x,因此我们得到 1,而不是 3。

为什么在运行 test2() 时会记录出 3?因为 await 运行的位置不同,它评估为如下内容:

1
2
3
4
scheduler.wait(100).then(() => {
let y = x
x = y + 1
})

一旦调用此回调函数,就没有任何东西可以在其间交错

因此,不会发生竞争条件。

总结

这里的核心思想是,实现“并发”的方法不止一种,你实现它的方式也会影响很多事情,比如你的程序的性能、你可能会遇到的问题,或者要注意哪些事情等等。

在处理应该并发/并行工作的程序时,请尽量注意。事情可能会很快出错。

附录

2024-09-18:这篇文章受到了我意想不到的关注。它在 HackerNews 上获得了 99 个赞,并在首页上出现了一段时间。几天后,我的朋友 Carlo 告诉我,我的文章被刊登在 Bytes 时事通讯的 第 323 期中,这是一份拥有超过 20 万订阅者的 JavaScript 时事通讯。

我还收到了几条表达对文章赞赏的信息,甚至收到了我博客 GitHub 存储库的第一个拉取请求。感谢每一位抽出时间阅读并提供反馈的人。

在 HackerNews 的讨论中,@duped 和 @donatj 推荐了 Rob Pike 的 并发不是并行。这是一个非常好的演讲,所以我也想在这里为任何对该主题进一步感兴趣的人提及它。

原文

欢迎关注我的其它发布渠道