彻底理解JavaScript同步|异步|事件循环(EventLoop)

JavaScript:彻底理解同步、异步和事件循环(Event Loop)

为什么JavaScript是单线程?

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,渲染DOM的线程和JavaScript执行的线程一定是互斥的。
 
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。

虽然JS是单线程的但是浏览器的内核是多线程的,在浏览器的内核中不同的异步操作由不同的浏览器内核模块调度执行,异步操作会将相关回调添加到任务队列中。而不同的异步操作添加到任务队列的时机也不同,如 onclick, setTimeout, ajax 处理的方式都不同,这些异步操作是由浏览器内核的 webcore 来执行的,webcore 包含上图中的3种 webAPI,分别是 DOM Binding、network、timer模块。

  • onclick由浏览器内核的DOM Binding模块来处理,当事件触发的时候,回调函数会立即添加到任务队列中。
  • setTimeout会由浏览器内核的timer模块来进行延时处理,当时间到达的时候,才会将回调函数添加到任务队列中。
  • ajax则会由浏览器内核的network模块来处理,在网络请求完成返回之后,才将回调添加到任务队列中。

同步和异步

假设存在一个函数A:
A(args...);
同步:如果在函数A返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的。例如:👇

1
2
Math.sqrt(2);
console.log('Hi');

  • 第一个函数返回时,就拿到了预期的返回值:2的平方根。
  • 第二个函数返回时,就看到了预期的效果:在控制台打印了一个字符串。

所以这两个函数都是同步的。

异步:如果在函数A返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。例如:👇

1
2
3
fs.readFile('foo.txt', 'utf8', function(err, data) {
console.log(data);
});

在上面的代码中,我们希望通过fs.readFile函数读取文件foo.txt中的内容,并打印出来。
但是在fs.readFile函数返回时,我们期望的结果并不会发生,而是要等到文件全部读取完成之后。如果文件很大的话可能要很长时间。

下面以AJAX请求为例,来看一下同步和异步的区别:

  • 异步AJAX:
    • 主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
  • AJAX线程:“好的,主线程。我马上去发,但可能要花点儿时间呢,你可以先去忙别的。”
  • 主线程::“谢谢,你拿到响应后告诉我一声啊。”
    (接着,主线程做其他事情去了。一顿饭的时间后,它收到了响应到达的通知。)

  • 同步AJAX:

    • 主线程:“你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
  • AJAX线程:“……”
  • 主线程::“喂,AJAX线程,你怎么不说话?”
  • AJAX线程:“……”
  • 主线程::“喂!喂喂喂!”
  • AJAX线程:“……”
  • (一炷香的时间后…)
  • 主线程::“喂!求你说句话吧!”
  • AJAX线程:“主线程,不好意思,我在工作的时候不能说话。你的请求已经发完了,拿到响应数据了,给你。”

正是由于JavaScript是单线程的,而异步容易实现非阻塞,所以在JavaScript中对于耗时的操作或者时间不确定的操作,使用异步就成了必然的选择。

异步过程的构成要素

从上文可以看出,异步函数实际上很快就调用完成了。但是后面还有工作线程执行异步任务、通知主线程、主线程调用回调函数等很多步骤。我们把整个过程叫做异步过程。异步函数的调用在整个异步过程中,只是一小部分。

总结一下,一个异步过程通常是这样的:

主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时工作线程执行异步任务;工作线程完成工作后,通知主线程;主线程收到通知后,执行一定的动作(调用回调函数)。

异步函数通常是这样的形式:A(args..., callbackFn)
它可以叫做异步过程的发起函数,或者叫做异步任务注册函数。args是这个函数需要的参数。callbackFn也是这个函数的参数,但是它比较特殊所以单独列出来。

所以,从主线程的角度看,一个异步过程包括下面两个要素:

  • 发起函数(或叫注册函数)A
  • 回调函数callbackFn

它们都是在主线程上调用的,其中注册函数用来发起异步过程,回调函数用来处理结果。

举个具体的例子:

1
setTimeout(fn, 1000);

其中的setTimeout就是异步过程的发起函数,fn是回调函数。
注意:前面说的形式A(args..., callbackFn)只是一种抽象的表示,并不代表回调函数一定要作为发起函数的参数,例如:👇

1
2
3
4
5
var xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.onload = xxx; // 添加回调函数
xhr.onerror = xxx; // 添加回调函数
xhr.send(); // 发起函数

发起函数和回调函数就是分离的,发起函数和回调函数位置互换也是不影响的。也就是说,指定回调函数的部分(onload和onerror),在send()方法的前面或后面无关紧要,因为它们属于执行栈的一部分,系统总是执行完它们,才会去读取“任务队列”。

消息队列和事件循环

上文讲到,异步过程中,工作线程在异步操作完成后需要通知主线程。那么这个通知机制是怎样实现的呢?答案是利用消息队列和事件循环。用一句话概括:

工作线程将消息放到消息队列,主线程通过事件循环过程去取消息。

  • 消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。
  • 事件循环:事件循环是指主线程重复从消息队列中取消息、执行的过程。

实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。伪代码表示类似

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

如果当前没有任何消息queue.waitForMessage 会等待同步消息到达。执行机制如下:

  1. 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
  2. 主线程之外,还存在一个“任务队列”(task queue)。只要异步任务有了运行结果,就在”任务队列”之中放置一个事件。
  3. 一旦“执行栈”中的所有同步任务执行完毕,系统就会读取“任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。
    processEvent&eventQueue
执行至完成

每一个消息完整的执行后,其它消息才会被执行。当你分析你的程序时,这点提供了一些优秀的特性,包括每当一个函数运行时,该函数占用的(线程)资源不能被抢占,并且在其他代码运行之前完全运行(且可以修改此函数操作的数据)。这与C语言不同,例如,如果函数在线程中运行,则可以在任何位置终止然后在另一个线程中运行其他代码。
 
这个模型的一个缺点在于当一个消息需要太长时间才能完成,Web应用无法处理用户的交互,例如点击或滚动。一个很好的做法是使消息处理缩短,如果可能,将一个消息裁剪成几个消息。

添加消息

在浏览器里,当一个事件出现且该对象(this)绑定了事件监听器,消息可能随时被添加。如果没有事件监听器,事件该会丢失。所以点击一个附带点击事件处理函数的元素会添加一个消息。其它事件亦然。

1
2
3
4
var button = document.getElement('#btn');
button.addEventListener('click', function(e) {
console.log();
});

从事件的角度来看,上述代码表示:在按钮上添加了一个鼠标单击事件的事件监听器;当用户点击按钮时,鼠标单击事件触发,事件监听器函数被调用

从异步过程的角度看,addEventListener函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数。事件触发时,表示异步任务完成,会将事件监听器函数封装成一条消息放到消息队列中,等待主线程执行

从生产者与消费者的角度看,异步过程是这样的:工作线程是生产者,主线程是消费者(只有一个消费者)。工作线程执行异步任务,执行完成后把对应的回调函数封装成一条消息放到消息队列中;主线程不断地从消息队列中取消息并执行,当消息队列空时主线程阻塞,直到消息队列再次非空

事件的概念实际上并不是必须的,事件机制实际上就是异步过程的通知机制。我觉得它的存在是为了编程接口对开发者更友好。

另一方面,所有的异步过程也都可以用事件来描述。例如:setTimeout可以看成对应一个时间到了!的事件。前文的setTimeout(fn, 1000);可以看成:

1
timer.addEventListener('timeout', 1000, fn);

调用 setTimeout 函数会在一个时间段过去后在队列中添加一个消息。这个时间段作为函数的第二个参数被传入。如果队列中没有其它消息,消息会被马上处理。但是,如果有其它消息,setTimeout消息必须等待其它消息处理完。因此第二个参数仅仅表示需要等待的最小时间,而非确切的时间。

零延迟

零延迟并不是意味着回调会立即执行。在零延迟调用 setTimeout 时,并不是过了给定的时间间隔后就马上执行回调函数。其等待的时间基于队列里正在等待的消息数量。在下面的例子中,”this is just a message” 将会在回调 (callback) 获得处理之前输出到控制台,这是因为延迟是要求运行时 (runtime) 处理请求所需的最小时间,但不是有所保证的时间。👇

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(function () {

console.log('this is the start');
setTimeout(function cb() {
console.log('this is a msg from call back');
});
console.log('this is just a message');
setTimeout(function cb1() {
console.log('this is a msg from call back1');
}, 0);
console.log('this is the end');

})();

// "this is the start"
// "this is just a message"
// "this is the end"
// "this is a msg from call back"
// "this is a msg from call back1"


扯远了,,那么,消息队列中放的消息具体是什么东西?消息的具体结构当然跟具体的实现有关,但是为了简单起见,我们可以认为:

消息就是注册异步任务时添加的回调函数。

再次以异步AJAX为例,假设存在如下的代码:

1
2
3
4
5
6
7
8
$.ajax('http://segmentfault.com', function(resp) {
console.log('我是响应:', resp);
});

// 其他代码
...
...
...

主线程在发起AJAX请求后,会继续执行其他代码。AJAX线程负责请求segmentfault.com,拿到响应后,它会把响应封装成一个JavaScript对象,然后构造一条消息:

1
2
3
4
// 消息队列中的消息就长这个样子
var message = function () {
callbackFn(response);
}

其中的callbackFn就是前面代码中得到成功响应时的回调函数。
 
主线程在执行完当前循环中的所有代码后,就会到消息队列取出这条消息(也就是message函数),并执行它。到此为止,就完成了工作线程对主线程的通知,回调函数也就得到了执行。如果一开始主线程就没有提供回调函数,AJAX线程在收到HTTP响应后,也就没必要通知主线程,从而也没必要往消息队列放消息。如图👇
event-loop

主线程从任务队列中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
为了更好地理解Event Loop,请看下图(转引自Philip Roberts的演讲《Help, I’m stuck in an event-loop》)。
eventLoop
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在”任务队列”中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。

 

永不阻塞

事件循环模型的一个非常有趣的特性是 JavaScript,与许多其他语言不同,它永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待IndexedDB查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,如用户输入。
 Rime
例外是存在的,如 alert或者同步 XHR,但应该尽量避免使用它们。注意,例外的例外也是存在的(但通常是实现错误而非其它原因)。


文章引自:

  1. https://segmentfault.com/a/1190000004322358
  2. http://www.ruanyifeng.com/blog/2014/10/event-loop.html
  3. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop