Javascript 单线程指的是在一个浏览器进程中只存在一个 Javascript 执行线程,所以任务需要顺序排列等待执行,而不能像 Java 等多线程语言一样并发执行。但是这种单线程模型在处理耗时的异步任务是会出现较长时间的线程阻塞,导致后续的任务不能被及时处理。所以在 Javascript 中存在异步的处理方式用于处理这种情况,不过严格来说所谓的异步,本质上还是借助于多线程的宿主实现的,并发 Javascript 语言本身特性。我想尝试着总结一下在不同的宿主环境下,Javascript 的异步实现机制。
但凡“ 即是单线程又是异步 ”的语言都有一个共同的特点:它们是 event-driven 的,所以 Javascript 异步的实现也与其事件机制关系密切。
在浏览器端:
浏览器端的 Javascript 实现了两个很重要的异步 API,它们分别是 定时器 和 AJAX请求,它们具体都是怎么工作的呢?
定时器
定时器比如 setTimeout 被执行时,由浏览器的定时器线程执行的定时计数,而并不是 Javascript 执行线程负责计数,可以想象如果是 Javascript 执行线程负责计数,那必定会造成执行线程的阻塞。定时器线程在定时时间触发延时事件并将延时事件推入 Javascript 事件队列。当 Javascript 主线程同步代码执行完毕时,会去轮询该事件队列,取出最开始事件的处理函数推入主线程中被执行。
解释至此我们可以知道,为什么会说 Javascript 的定时器是不完全准时触发的呢?因为 Javascript 事件队列中的任务是被顺序取出执行的,如果在定时任务之前还存在其它的任务,或者主线程中的同步任务还没有被执行完毕,则定时任务会等到这之前的任务全部执行完毕之后,即主线程空闲出来了,才会被取出执行。
一个关于定时器的例子
我们希望在 500 ms 之后触发定时事件,然而上面这一段代码在 chrome 中执行的结果定时事件却是在 1000 ms 后才被触发。因为我们在定时器的下面写了一个空循环,在还不到 1000 ms 时 Javascript 主线程不会处于空闲状态,主线程同步代码在还没有执行完毕时,Javascript 不会去取出事件队列中的回调执行。
再看一个例子:
function a()
{
setTimeout(function(){console.log(1);},0);
console.log(2);
}
a();
运行这段脚本可以看到:先打印2后打印1,我们在setTimeout里面指定了0ms,希望能立即执行,但是实际上没有效果。
想要理解上面的2段代码,我们得了解一下javascript中setTimeout的实现原理。
JavaScript是单线程执行的,无法同时执行多段代码。当某一段代码正在执行的时候,所有后续的任务都必须等待,形成一个队列。一旦当前任务执行完毕,再从队列中取出下一个任务,这也常被称为 “阻塞式执行”。所以一次鼠标点击,或是计时器到达时间点,或是Ajax请求完成触发了回调函数,这些事件处理程序或回调函数都不会立即运行,而是立即排队,一旦线程有空闲就执行。假如当前 JavaScript线程正在执行一段很耗时的代码,此时发生了一次鼠标点击,那么事件处理程序就被阻塞,用户也无法立即看到反馈,事件处理程序会被放入任务队列,直到前面的代码结束以后才会开始执行。如果代码中设定了一个 setTimeout,那么浏览器便会在合适的时间,将代码插入任务队列,如果这个时间设为 0,就代表立即插入队列,但不是立即执行,仍然要等待前面代码执行完毕。所以 setTimeout 并不能保证执行的时间,是否及时执行取决于 JavaScript 线程是拥挤还是空闲。
也就是说setTimeout只能保证在指定的时间过后将任务(需要执行的函数)插入队列等候,并不保证这个任务在什么时候执行。执行javascript的线程会在空闲的时候,自行从队列中取出任务然后执行它。javascript通过这种队列机制,给我们制造一个异步执行的假象。
AJAX
AJAX 请求和定时器类似,同样是委托浏览器线程代为执行耗时任务,这里是借由浏览器的HTTP请求线程发起对服务器的请求,在请求得到响应之后触发请求完成事件,将回调函数推入事件队列等待执行。
req.send()方法是 AJAX 向服务器发生数据,它是一个异步任务,而 req.onreadystatechange()属于事件回调,只有在主线程同步代码执行完毕之后才会被从事件队列中取出执行,所以它是在 req.send()方法前面还是后面无关紧要,因为不管处于哪个位置,它都不会被立即执行。
这让我们想起似乎我们在给 DOM 元素绑定交互事件的时候也是这样,我们不需要去关心在文件的哪个区域声明我们的事件监听函数。其实原理是类似的,当用户点击一个绑定点击处理函数的 DOM 元素时,会有一个点击事件排入事件队列,该点击事件也需要等到当前所有正在运行的代码结束之后(可能还要等待其它此前已排队的事件也依次结束),才会执行。
NodeJS端:
NodeJS 的异步实现和浏览器端实现有所不同。在 NodeJS 中 Libuv 为 Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力,是 Node.js 如此强大的关键。Libuv 为上层的 Node.js 提供了统一的 API 调用,使其不用考虑平台差距,隐藏了底层实现。Libuv 本身就是异步和事件驱动的,所以,当我们将 I/O 操作的请求传达给 Libuv 之后,Libuv 开启线程来执行这次 I/O 调用,并在执行完成后,传回给 Javascript 进行后续处理。
总结来说,一个异步 I/O 的大致实现流程如下:
发起 I/O 调用
1 用户通过 Javascript 代码调用 NodeJS 核心模块,将参数和回调传入核心模块
2 NodeJS 核心模块将传入参数和回调封装为一个请求对象
3 将这个请求对象推入到I/O线程池中等待执行
4 Javascript 发起的异步调用结束,Javascript 线程继续执行后续操作
执行回调
1 异步任务完成之后,会将结果存放在请求对象的 result 属性上,并发出操作完成通知
2 每次事件循环时会检查 I/O 线程池中是否存在已经完成的 I/O 操作,如果有就将请求事件加入到I/O观察者队列当中(事件队列),之后当作事件处理
3 处理I/O观察者事件时,会将之前封装在请求对象中的回调函数取出,并将 result 参数传入执行,以完成 Javascript 回调的目的
我们知道 NodeJS 非常适合开发 IO 密集型应用,但并不适合开发 CPU(计算) 密集型应用。为什么会这样呢?因为 NodeJS 异步的天性,在处理并发 IO 的时候不会阻塞主线程,实际上这种异步是借助于多线程实现的,IO 任务完成之后排队等待主线程执行。因为 NodeJS 主线程执行同步代码的速度非常之快,所以完全可以 hold 得住大规模的并发请求。但这也有存在例外,如果 NodeJS 主线程在执行同步任务的时候遇到一些计算量非常大,或者执行循环太久,等非常耗时的操作的时候,就会导致后续的代码以及事件队列里已完成的 IO 任务迟迟得不到执行,严重拖垮 NodeJS 的性能。