事件循环是如何工作的

本文章总结自 Philip Roberts 在 JSConf EU 2014 发表的分享:What the heck is the event loop anyway?

Javascript 是一个单线程,无阻塞,异步,并发的语言。

为什么 Javascript 是单线程的

Javascript 是单线程的原因是 Javascript 主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来复杂的同步问题。例如两个线程同时操作一个 DOM,浏览器应该以哪个线程为准。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 Javascript 脚本创建多个线程,但是子线程完全收主线程控制,且不得操作 DOM。新标准并没有改变 Javascript 单线程的本质。

Javascript 如何实现异步

为了实现异步, Javascript 包含调用堆栈,事件循环,回调队列,一些其他的 API 等等,那么它们是怎么工作的。

下图是一个简单的 Javascript 运行环境视图,类似于 Chrome 里的 V8 引擎。

  • 堆(heap) :一个大部分非结构化的内存区域,对象在堆中被分配内存。
  • 栈(stack) : 函数调用形成了一个栈帧。

如果你克隆下来 V8 的源码,搜索抓取 setTimeoutDOM 或者 HTTP request 这些关键字,你会发现它们不在 V8 里面。这可能会令人困惑,因为这些是使用异步的最基本的东西,但是它们却不在 V8 引擎里。

实际上这些东西并不是由 V8 提供的,这些额外的东西是由浏览器提供的。浏览器提供了这些 WebAPIs,同时还提供了 回调堆栈(callback queue)事件循环(event loop)

让我们回到开头部分,Javascript 单线程就意味着,只有一个调用栈,一次只能处理一个任务,所有任务需要排队。假设现在有多个 function 待执行,这里模拟调用栈里的情况。

function multiply(a, b) {
  return a * b;
}
function square(n) {
  return multiply(n, n);
}
function printSquare(n) {
  const squared = square(n);
  console.log(squared);
}
printSquare(4);

因此在单线程的情况下,一旦有任何的任务很慢,就会拖累整个运行速度,那么到底什么东西会很耗费时间。

很多东西都会导致我们的执行变慢甚至阻塞,比如做一个 1,000,000 次的循环会很慢,请求一个图片会很慢。假设现在没有异步,Javascript 执行下面代码时,我们就要等待每一个请求完成后才能执行下一句代码,更麻烦的是,在 Javascript 被阻塞的期间,所有的 Event 事件也同样被阻塞,这意味我们与界面的交互动作必须直到请求结束才能被处理。

const foo = $.getSync('//foo.com');
const bar = $.getSync('//bar.com');
const qux = $.getSync('//qux.com');

console.log(foo);
console.log(bar);
console.log(qux);

这个问题最简单的解决方案就是异步回调

基本上所有会导致阻塞(blocking)的东西都会以异步方式执行,也就是执行异步代码,返回一个回调,晚一点再执行回调。下面这段代码,当执行到 setTimeout 的时候,会把 console.log('There') 推入一个队列,然后跳过这段先继续执行下面的代码 console.log('Here'),然后 5 秒钟后,再打印出 'There'。

console.log('Hi');

setTimeout(function () {
  console.log('There');
}, 5000);

console.log('Here');
Hi
Here
There

那么在这种情况下调用栈的情况是怎么样的呢?当代码执行到 setTimeoutsetTimeout 被推入调用栈执行,但是它的回调 console.log('There') 不会被推入调用栈,而是继续将后面的 console.log('Here') 推入调用栈执行,执行完毕后调用栈清空,5 秒钟后 console.log('There') 才被推入调用栈。

这个并发执行的过程依靠的就是事件循环的作用。

我们前面已经明确了一点就是 Javascript 是单线程的,它不能一边发送 ajax 请求一边执行其他代码,或者一边执行 setTimeout 一边执行其他代码。我们之所以能并发执行的原因是因为浏览器不仅仅是一个 Javascript 执行环境。还记得我们上面看到的那个浏览器环境视图,Javascript 执行环境只能一次做一件事,但是浏览器赋予了我们其他的东西,这些 WebAPIs 就是可以被调用的线程。现在再来看上面的代码是怎么实现异步的。

前面提到 setTimeout 是由浏览器提供的额外 API,它不存在于 V8 上,所以在执行到 setTimeout 的时候,浏览器就会替你对 callback 函数和它的延迟时间进行处理,它会为我们进行 timer 的倒计时,这时候我们就能把 setTimeout 从调用栈中踢出去,接着执行后面的代码。

5 秒倒计时结束后,WebAPI 并不能直接修改我们的代码,它不能把 callback 直接塞入调用栈里,就好像把回调随便塞入到代码里的某个位置一样。WebAPI 会把 callback 放入到一个任务队列里(task queue)。

最后终于轮了事件循环(event loop),也就是这篇文章的主题。

事件循环(event loop)只有一个简单的工作——查看调用栈(stack) 和任务队列(task queue),如果调用栈(stack)是空的,就从任务队列(task queue)取最前面的一个任务,推入到调用栈(stack)。

可以看到这时候调用栈(stack)已经空了,callback 位于任务队列(task queue)的顶端,事件循环(event loop)就开始运作,把 callback 塞入了调用栈(stack) 中,此时 callback 就出现在了调用栈(stack) 里,最后执行完结束。

setTimeout(cb, 0)

理解完 setTimeout 的运行过程,就可以解释一个奇怪的现象 —— setTimeout(cb, 0)

第一个问题是为什么要把代码包裹在 setTimeout(cb, 0) 里面,一般来说是因为我们想要让某些代码推迟到 stack 空了以后执行。

console.log('Hi');

setTimeout(function() {
  console.log('There');
}, 0);

console.log('Here');

这段代码类似于前面的例子,唯一的区别就是由于 setTimeout 延迟时间为 0,所以 callback 会被立即推入任务队列(task queue)。还记得前面提到 event loop 的工作吗,它会直到 stack 清空后才能把 callback 推入 stack。所以 stack 还是会先执行 console.log('Here') ,等全部代码执行完毕 stack 空了,现在 event loop 才能把 callback 推入 stack 执行。所以这也是为什么 setTimeout(cb, 0) 并不是立即执行的,实际上它至少需要等到下一次事件循环时钟。

这个 setTimeout(cb, 0) 的例子演示了如何延迟代码的执行直到 stack 清空。

以上就是 WebAPI 实现异步的方式,不管是 ajax 请求,还是浏览器事件,都是一样的。

浏览器渲染

在浏览器里大约 16.7ms 渲染一次屏幕,也就 60FPS,但是浏览器的渲染会因为各种原因被 Javascript 所限制。

实际上渲染就像 callback 一样,也必须等到 stack 清空后才能执行。唯一的区别是,渲染的优先级高于 task queue 里的 callback,因此每 16ms,浏览器就会把一次渲染推入渲染队列(render queue),等待 stack 清空来执行渲染。在 stack 忙碌期间,浏览器渲染是阻塞的,这个时候你无法选中屏幕上的文本或者点击按钮。但是在任务队列(task queue)每次往 stack 推入 callback 的间隙时间内,能够执行一次渲染。

人们总是说不要阻塞事件循环,这句话的实际意思是不要往 stack 里丢执行起来很慢的代码,因为这样会导致浏览器不能呈现出流畅的 UI。

还有一个比较常见的例子是 scroll 事件,浏览器里 scroll 事件触发得非常频繁,如果我们对 scroll 添加事件监听函数, 每次滚动,虽然没有阻塞 stack,但会往任务队列(task queue)导入大量的 callback,为了解决防抖动的问题我们最好能每几秒触发一次回调,或者等到用户停止滚动的时候触发。

P.S. 视频里 Philip Roberts 写的 loop 例子的在线地址:戳这里

results matching ""

    No results matching ""