深入理解并发、并行与 JavaScript
深入理解并发、并行与 JavaScript
赵洲洋直到现在,我一直不太清楚“并发”和“并行”究竟有何不同,因为很多人经常把这两个概念混用。直到我阅读了阮一峰的博客,我才意识到原来它们并不是完全相同的概念。
这让我想更深入地了解与“并发”和“并行”相关的知识,尤其是和我熟悉的编程语言——JavaScript——相关的内容。所以,这篇文章基本上是我在学习过程中总结的一些笔记。
顺序执行、并发和并行
在我们的日常生活中,任务的执行方式有时是顺序的,有时是并发的,也有时是并行的。这些概念同样适用于计算机科学。
顺序执行指的是任务一个接一个地完成,没有任何重叠。举个例子,假设你正在做饭。你先洗菜,做完后再开始炒菜,完全按照顺序进行。这种方式的问题在于,有时一个任务的进展可能会被阻塞,导致效率低下。比如,当你在等待菜煮熟时,如果不做其他事情,就会浪费很多时间。因此,在某些情况下,进行多任务处理可以提高效率。
并发和并行是实现多任务的一种方式,但它们之间有一些细微但重要的区别。
并发就像是通过不断切换多个任务来实现多任务处理,也就是“交替执行”。举个例子,假设你正在做饭,先把菜洗好放在一边,再开始炒菜。当菜炒的过程中,你去检查一下炖汤的火候,再回来翻炒一下菜。你并没有同时做这两件事,但你是在不同的任务之间切换,交替进行。
相比之下,并行指的是多个任务真正同时进行。比如你一只手在炒菜,另一只手在搅拌汤,这两个任务是并行进行的。
线程
在上面的例子中,我们提到做饭包括多个步骤(洗菜、炒菜、炖汤等),这些步骤可以看作是任务中的不同子任务。在编程中,子任务通常被看作是程序中一系列指令的组成部分。为了实现并行处理,传统的方式是创建多个线程。线程就像是独立的工作者,每个线程处理自己的任务,但它们可以共享同一组资源。
无论线程是并行执行还是并发执行,实际上都取决于硬件。如果你的 CPU 有多个核心,那么每个线程可以分配到不同的核心上,从而实现真正的并行执行。但如果 CPU 核心数少于线程数,操作系统就会在不同线程之间切换,从而实现并发执行。
对于开发者而言,线程的使用方式大致相同,无论是并发还是并行,都不需要过多担心底层的实现。开发者主要关注性能优化和避免阻塞。但是,操作系统会根据可用的硬件资源来决定如何调度这些线程。无论是并发还是并行,多个线程的执行顺序通常是不可预测的。因此,开发者需要注意并发编程中的潜在问题,如竞态条件、死锁、活锁等,因为多个线程可能会同时访问和修改同一份数据。
创建进程与 I/O 通知
除了使用线程,还有其他方式可以实现并发/并行。例如,虽然创建多个进程的效率不如线程,但也可以实现多任务并发处理。每个进程有自己的独立内存空间,默认情况下,进程之间不能直接共享内存。这就意味着,如果不同的进程需要共享数据,就必须使用某种进程间通信(IPC)机制,例如共享内存、管道、消息队列等。
操作系统还提供了 I/O 事件通知机制,可以帮助我们处理在执行时不希望被阻塞的任务。虽然这些机制的使用有一定的复杂性,但它们对于并发编程非常有用。
我不打算深入讨论这些技术细节,因为我自己对此了解不深。总的来说,内核线程并不是实现并发的唯一方式,操作系统的其他特性也能实现类似的效果。
Node.js:用户空间并发的示例
编程语言通常会提供自己的并发机制,以简化直接使用操作系统底层 API 的复杂性。通过这种方式,编译器或解释器会将高层的代码转换成操作系统可以理解的低级调用,从而减少开发者对底层细节的关注。
Node.js 就是这一思想的一个很好的例子。虽然 Node.js 程序运行在单线程的环境中,并且执行过程是顺序的,但对于像 I/O 操作这样的阻塞任务,Node.js 会将它们委派给 Worker Threads 来处理。开发者并不需要直接管理这些后台线程的复杂性。
那么 Node.js 是如何实现并发的呢?以文件读取、写入或网络请求为例,Node.js 会通过内置的异步 API 来处理这些 I/O 操作。当你调用这些函数时,通常会传入一个回调函数,Node.js 会在任务完成后通过 Worker Threads 来执行这个回调。
了解了 Node.js 的基本并发原理后,我们可以通过一个具体的示例来验证这一点。
考虑下面的代码:
1 | setTimeout(() => { |
如果你运行这个程序,会发现屏幕上只会打印 “a”。原因是 Node.js 解释器会一直执行当前的回调,只要还有指令可执行。
当第一个 setTimeout
被触发时,它开始执行回调并进入一个死循环输出 “a”。由于回调函数运行在主线程上,第二个 setTimeout
的回调无法在主线程空闲时被执行。因此,只有 “a” 会被持续输出,而 “b” 永远不会出现。
这也揭示了一个重要的影响:虽然 Node.js 是单线程的,但它通过事件循环和回调的方式减少了竞争条件的发生。然而,如果你的程序包含大量异步函数(如 fs.readFile()
、setTimeout()
、Promise.then()
),竞争条件仍然可能发生。
以下是一个简单的例子,展示了如何通过异步函数的顺序性来避免竞态条件:
1 | const |
在 test()
中,所有的 foo()
都在执行时遇到了 await
,所以它们会在等待时挂起并返回,导致 x
最终为 1。而在 test2()
中,由于 await
的位置不同,x
的更新操作没有发生竞态条件,因此最终结果是 3。
总结
这里的主要思想是,并没有单一的方式来实现“并发”。你选择的并发实现方式会影响到程序的性能、潜在问题、以及你需要特别注意的事项等。在编写需要并发或并行的程序时,请务必小心,处理不当可能会导致意外的错误。