|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +date: 2020-10-19 11:00:00 |
| 4 | +title: 聊聊 JavaScript 的并发、异步和事件循环 |
| 5 | +summary: JavaScript 作为天生的单线程语言,社区经常聊 JavaScript 就聊异步、聊 Event Loop,看起来它们好像难舍难分,实际上可能只有五毛钱的关系。本文把这些串起来讲讲,希望能给读者带来一些收获,如果能消除一些误解那就最好了。 |
| 6 | +--- |
| 7 | + |
| 8 | + |
| 9 | + |
| 10 | +> 本文作者:[Cody Chan](https://github.com/int64ago),题图来自 [Jake Archibald](https://jakearchibald.com/) |
| 11 | +
|
| 12 | +JavaScript 作为天生的单线程语言,社区经常聊 JavaScript 就聊异步、聊 Event Loop,看起来它们好像难舍难分,实际上可能只有五毛钱的关系。本文把这些串起来讲讲,希望能给读者带来一些收获,如果能消除一些误解那就最好了。 |
| 13 | + |
| 14 | +> 需要强调的是,这类纯技术学习除了 **SPEC** 和**源码**其它都不是严谨的途径,这篇文章也不例外。 |
| 15 | +
|
| 16 | +## 开端 |
| 17 | + |
| 18 | +网上经常充斥着所谓「前端八股文」,其中可能就有类似这样的题: |
| 19 | + |
| 20 | +```javascript |
| 21 | +console.log(1); |
| 22 | + |
| 23 | +setTimeout(() => console.log(2), 0); |
| 24 | + |
| 25 | +new Promise(resolve => { |
| 26 | + console.log(3); |
| 27 | + resolve(); |
| 28 | +}).then(() => console.log(4)); |
| 29 | + |
| 30 | +console.log(5); |
| 31 | +``` |
| 32 | + |
| 33 | +这篇文章并不是为了解决上面的题,上面的题只要对 Event Loop 有过基本了解就可以作答。 |
| 34 | + |
| 35 | +写这个文章的冲动来自于很久之前的一个疑惑:NodeJS 里既然有了 `fs.readFile()` 为什么还提供 `fs.readFileSync()`? |
| 36 | + |
| 37 | +## Engine 和 Runtime |
| 38 | + |
| 39 | +严格来说,JavaScript 跟其它语言一样,是很单纯的,只是一份 [SPEC](https://www.ecma-international.org/publications/standards/Ecma-262.htm)。我们现在看到的它的面貌很多是 Engine 和 Runtime 赋予的。 |
| 40 | + |
| 41 | +这里的 Engine 是指 [JavaScript 引擎](https://en.wikipedia.org/wiki/List_of_ECMAScript_engines),比如常见的 [V8](https://chromium.googlesource.com/v8/v8.git) 和 [SpiderMonkey](https://developer.mozilla.org/en-US/docs/Mozilla/Projects/SpiderMonkey) 等,它们主要工作就是翻译代码并执行(当然附带内存分配回收等)。下图是 V8 主要工作原理: |
| 42 | + |
| 43 | + |
| 44 | + |
| 45 | +> 可以通过 [这个](https://docs.google.com/presentation/d/1chhN90uB8yPaIhx_h2M3lPyxPgdPmkADqSNAoXYQiVE) 了解更多。 |
| 46 | +
|
| 47 | +而 Runtime 是指各种浏览器及 NodeJS,它们提供了各种接口模块,整合 Engine 并按事件驱动地方式调度等。 |
| 48 | + |
| 49 | +比如下面的代码: |
| 50 | + |
| 51 | +```javascript |
| 52 | +setTimeout(callback, ms); |
| 53 | +``` |
| 54 | + |
| 55 | +Engine 只是很纯粹地翻译执行,跟对待任何普通函数一样: |
| 56 | + |
| 57 | +```javascript |
| 58 | +myFun(arg1, arg2); |
| 59 | +``` |
| 60 | + |
| 61 | +Runtime 实现了 `setTimeout` 并把它放到了 `window` 或 `global` 上,至于里面的 `callback` 何时可以被执行的逻辑也是 Runtime 实现的,其实就是 Event Loop 机制。 |
| 62 | + |
| 63 | +> 部分参考自 [这里](https://medium.com/@sanderdebr/a-brief-explanation-of-the-javascript-engine-and-runtime-a0c27cb1a397),这些称呼在不同语境下也不太一样,知道怎么回事即可。 |
| 64 | +
|
| 65 | +## 并发 |
| 66 | + |
| 67 | +并发和多线程经常会同时出现,看起来 JavaScript 这种单线程语言在并发天然弱势,实则不然。 |
| 68 | + |
| 69 | +除了并发,还有个叫并行的概念,并行就是一般意义上多个任务同时进行,而并发是指多个任务**看起来像**是同时进行的。我们一般很少需要关心是否并行。 |
| 70 | + |
| 71 | +**高效处理并发的本质是充分利用 CPU。** |
| 72 | + |
| 73 | +### 充分利用单核 CPU |
| 74 | + |
| 75 | +对于 I/O 密集型应用,CPU 其实很闲的,可能大多时候就是无聊地等待。I/O 操作之间如果没有依赖,完全可以并发地发起指令,再并发地响应结果,中间等待的时间就可以省掉。 |
| 76 | + |
| 77 | +因为 CPU 处理的事足够简单,多线程干这个事表现就可能很糟糕,花 100ms 切上下文,结果 CPU 只用了 10ms 就又切走了。所以 JavaScript 选择了事件驱动的方式,也让它更擅长 I/O 密集型场景。 |
| 78 | + |
| 79 | + |
| 80 | + |
| 81 | +### 充分利用多核 CPU |
| 82 | + |
| 83 | +充分利用单核 CPU 是有上限的,充其量也仅仅是把 CPU 不必要的空闲时间(进程挂起)减为零。面对 CPU 密集型应用,就需要充分利用多核 CPU。 |
| 84 | + |
| 85 | + |
| 86 | + |
| 87 | +用户态进程是无法直接调度 CPU 的,所以如果要充分利用多 CPU,只需要在用户态开多个进程(线程),操作系统会自动帮调度。 |
| 88 | + |
| 89 | +拿 Chrome 为例,看浏览器的 Task Manager,会发现每个 Tab 以及每个扩展都是 [独立的进程](https://developers.google.com/web/updates/2018/09/inside-browser-part1#the_benefit_of_multi-process_architecture_in_chrome),当然我们还可以借助 [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers) 手动开多个线程。 |
| 90 | + |
| 91 | +NodeJS 的话方式就多了: |
| 92 | + |
| 93 | + - [Child process](https://nodejs.org/api/child_process.html):比较常用,可以 fork 一个子进程,也可以 spawn 执行系统命令; |
| 94 | + - [Worker threads](https://nodejs.org/api/worker_threads.html):这个更轻,如名字,可以认为更**像**线程,还可以通过 [ArrayBuffer](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) 等共享内存(数据); |
| 95 | + - [Cluster](https://nodejs.org/api/cluster.html):跟上面方案比起来 Cluster 更像是具体场景的解决方案,在作为 Web Server 提供服务时,如果 fork 多个进程,这就涉及到通信以及 bind 端口被占用等问题,而这些 Cluster 都帮你解决了,著名的 [PM2](https://github.com/Unitech/pm2) 以及 [EggJS](https://eggjs.org/en/index.html) 多进程模型都是基于此。 |
| 96 | + |
| 97 | +### 用户态并发 |
| 98 | + |
| 99 | +当然充分利用 CPU 也不是万事大吉,还要合理安排我们的任务! |
| 100 | + |
| 101 | +对于那些任务有相互依赖的情况,比如 B 依赖 A 的结果,我们一般是做完 A 再做 B,那如果是 B 部分依赖 A 呢?实际场景,A 是生产者且一直生产,B 是消费者且一直消费,这种单线程如何优雅实现呢? |
| 102 | + |
| 103 | +答案是协程,在 JavaScript 里即 [Generator 函数](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator)。实现上述过程的代码: |
| 104 | + |
| 105 | +```javascript |
| 106 | +function* consumer() { |
| 107 | + while(true) { console.log('consumer'); yield p; } |
| 108 | +} |
| 109 | + |
| 110 | +function* provider() { |
| 111 | + while(true) { console.log('provider'); yield c; } |
| 112 | +} |
| 113 | + |
| 114 | +var c = consumer(), p = provider(); |
| 115 | +var current = p, i = 0; |
| 116 | + |
| 117 | +do { current = current.next().value; } while(i++ < 10); |
| 118 | +``` |
| 119 | + |
| 120 | +所以 Generator(协程)作用只有一个,在用户态可以细粒度地控制任务的切换。至于使用 [co](https://github.com/tj/co) 包裹后达到同步的效果那是另一件事了,仅仅是因为 co 利用这个控制能力在异步 callback 回来时可以手动恢复到之前执行的位置继续执行。再深究的话你会发现即使 co 包裹后的 Generator 函数执行也是立即返回的,也就是 Generator 函数并不能真的让异步变同步,顶多是把逻辑上有顺序的代码在局部做到**看起来**同步。 |
| 121 | + |
| 122 | +JavaScript 因为自身限制,借助 Runtime 各种奇技淫巧还是比较完美地解决了并发问题,但是回头看,还是不如那些天然支持多线程的语言来的优雅。多线程处理并发更像 React 借助 [Virtual DOM](https://reactjs.org/docs/faq-internals.html) 处理 UI 渲染,关注的问题是收敛的,而 JavaScript 这一套方案下来,会有种不断打补丁的感觉。 |
| 123 | + |
| 124 | +## 异步 I/O |
| 125 | + |
| 126 | +我们说同步和异步时,大多时候说的是 I/O 操作,而 I/O 操作一般是慢的,因为 I/O 操作会跟外部设备打交道,比如文件读写操作硬盘、网络请求操作网卡等。 |
| 127 | + |
| 128 | +所谓同步就是进程进行 I/O 操作时**从用户态看**是被阻塞了的,要么是一直挂起等待内核(I/O 底层由内核驱动)准备数据,要么一直主动检查数据是否准备好。这里为了便于理解,可以认为一直在检查。 |
| 129 | + |
| 130 | + |
| 131 | + |
| 132 | +从社会分工经验看,这类无聊重复的轮询工作不应该分散在各个日常工作中(主线程),应该由其它工种(独立线程)批量做。注意,即使轮询工作交出去了,这部分工作也并没有凭空消失,哪有什么岁月静好,不过是有人替你负重前行罢了。 |
| 133 | + |
| 134 | +当然,这些操作系统早就提供好整套解决方案了,因为不同操作系统会不一样,为了跨平台,就出现了一些独立的库屏蔽这些差异,比如 NodeJS 重要组成部分的 [libuv](https://github.com/libuv/libuv)。 |
| 135 | + |
| 136 | +> 实际实践中并不是这么简单的,有时会结合 [线程池](https://en.wikipedia.org/wiki/Thread_pool),而且除了同步和异步,还有 [其它维度](https://notes.shichao.io/unp/ch6/#io-models)。 |
| 137 | +
|
| 138 | +## Event Loop |
| 139 | + |
| 140 | +上面提到的帮你负重前行的就是 Event Loop(及相关配套)。 |
| 141 | + |
| 142 | +这个展开说的话会需要非常长的篇幅,这里只是简单介绍。强烈建议看两个视频: |
| 143 | + |
| 144 | + - [In The Loop](https://www.youtube.com/watch?v=cCOL7MC4Pl0) ([国内地址](https://www.bilibili.com/video/BV1a4411F7t7)) |
| 145 | + - [What the heck is the event loop anyway?](https://www.youtube.com/watch?v=8aGhZQkoFbQ) ([国内地址](https://www.bilibili.com/video/BV1nx411L7aA)、[可视化 DEMO](http://latentflip.com/loupe)) |
| 146 | + |
| 147 | +如果没时间看可以参照下图: |
| 148 | + |
| 149 | + |
| 150 | + |
| 151 | +1. JavaScript 单线程,Engine 维护了一个栈用于执行进栈的任务; |
| 152 | +2. 执行任务的过程可能会调用一些 Runtime 提供的异步接口; |
| 153 | +3. Runtime 等待异步任务(如定时器、Promise、文件 I/O、网络请求等)完成后会把 callback 扔到 Task Queue(如定时器)或 Microtask Queue(如 Promise); |
| 154 | +4. JavaScript 主线程栈空了后 Microtask Queue 的任务会依次扔到栈里执行,直到清空,之后会取出一个 Task Queue 里可以执行的任务扔到栈里执行; |
| 155 | +5. 周而复始。 |
| 156 | + |
| 157 | +> 因为不同 Runtime 机制不太一样,上面仅仅是个大概。 |
| 158 | +
|
| 159 | +## 问题回顾 |
| 160 | + |
| 161 | +看下一开始的问题:NodeJS 里既然有了 `fs.readFile()`(异步)为什么还提供 `fs.readFileSync()`(同步)? |
| 162 | + |
| 163 | +看起来很明显,同步的方式在等待结果返回前会挂起当前线程,也就是期间无法继续执行栈里的指令,也无法响应其它异步任务回调回来的结果。所以通常不推荐同步的方式,但是以下情况还是可以考虑甚至推荐使用同步方式的: |
| 164 | + |
| 165 | + - 响应时间很短且可控; |
| 166 | + - 无并发诉求,比如 CLI 工具; |
| 167 | + - 通过其它方式开起来多个进程; |
| 168 | + - 对结果准确性要求很高(可能有人好奇为什么异步的结果准确性不高,考虑一个极端情况,在 I/O 完成响应,已经在 Task Queue 等待被处理期间文件被删除了,我们期望的是报错,但结果会被当做成功)。 |
| 169 | + |
| 170 | +## 总结 |
| 171 | + |
| 172 | +本文从一个问题出发,顺便带着回顾了 JavaScript 的并发、异步和事件循环,总结如下: |
| 173 | + |
| 174 | + - JavaScript 语言层面是单线程的,它和 Engine 以及 Runtime 共同构成了我们现在看到的样子; |
| 175 | + - JavaScript 使用异步来解决 I/O 的并发场景; |
| 176 | + - Runtime 通过 Web Worker、Child process 等方式可以创建多线程(进程)来充分利用多核 CPU; |
| 177 | + - Event Loop 是实现异步 I/O 的一种方案(不唯一)。 |
| 178 | + |
| 179 | +最后,抛个问题,如果 JavaScript 提供了语言层面的创建多线程的方式,又会是怎样一番景象呢? |
| 180 | + |
0 commit comments