JS 的异步和事件循环机制

异步编程是什么?为什么在 JavaScript 中这么重要?

在同步编程中,代码会按顺序自顶向下依次执行(条件语句和函数调用除外),如果遇到网络请求或者磁盘读写(I/O)这类耗时的任务,就会堵塞在这样的地方。

在异步编程中,JS 运行在事件循环(event loop)中。当需要执行一个阻塞操作(blocking operation)时,主线程发起一个(异步)请求,(工作线程就会去执行这个异步操作)同时主线程继续执行后面的代码。(工作线程执行完毕之后)就会发起响应,触发中断(interrupt),执行事件处理程序(event handler),执行完后主线程继续往后走。这样一来,一个程序线程就可以处理大量的并发操作了。

用户界面(user interface,UI)天然就是异步的,大部分时间它都在等待用户输入,从而中断事件循环,触发事件处理程序。Node.js 默认是异步的,采用它构建的服务端和用户界面的执行机制差不多,在事件循环中等待网络请求,然后一个接一个地处理这些请求。

异步在 JavaScript 中非常重要,因为它既适合编写 UI,在服务端也有上佳的性能表现。

JavaScript 的事件循环机制

我们先记住几个结论,后面再根据表现来理解这些结论

  • JavaScript 的特点是单线程,而这个线程中拥有唯一的一个事件循环
  • JavaScript 执行过程中,所有同步任务都在主线程上执行,形成一个执行栈(execution context stack),另外还依靠任务队列(task queue)来管理另外一些异步代码的执行。
  • 一个线程中事件循环是唯一的但事件队列可以拥有多个
  • 任务队列又分 marco-taskmicro-task
  • marco-task:setTimeoutsetInterval、I/O、UI rendering
  • micro-task: Promiseprocess.nextTick
  • 来自不同任务源的任务会进入到不同的任务队列中。但要注意 setTimeoutsetInterval是同源的
  • 事件循环的顺序,决定了 JavaScript 代码的执行顺序。它从 marco-task <script>(整体代码)开始第一次循环。全局执行上下文进入函数的调用栈。直到调用栈清空(只剩下全局执行上下文),然后执行所有 micro-task 。当所有可执行的 micro-task 执行完后。循环会再次从 marco-task 开始,执行队列中最先进入的任务,然后又去执行所有的 micro-task,并一直处于这样的循环中。

来分析一个实际的例子

setTimeout(function() {
  console.log('timeout1')
})

new Promise(function(resolve, reject) {
  console.log('promise1')
  for (var i = 0; i < 1000; i++) {
    i == 99 && resolve()
  }
  console.log('promise2')
}).then(function() {
  console.log('then1')
})

console.log('global1')

最开始,事件循环从宏任务队列开始。此时在宏任务队列中,只有一个 script (整理代码)任务。每一个任务的执行顺行,都依靠函数调用栈来搞定,而遇到任务源,则会先将任务分发到对应的队列中去。上面例子最开始时,任务队列看起来是这个样子:

在执行 script 任务时,首先遇到了 setTimeout。它是一个宏任务源,它的作用就是将任务分发到它对应的队列中。

setTimeout(function() {
    console.log('timeout1');
})

接着遇到了 Promise 实例。Promise 构造函数的第一个参数,是在 new 的时候执行,因此不会进入任何其他队列,而是直接在当前任务直接执行了。而后续的 .then 方法传入的函数则会被分发到 micro-task 的 Promise 队列中去。 因此,构造函数执行是,里面的参数进入函数调用执行栈执行。for 循环不进入任务队列,因此代码会依次执行,所以例子中的 promise1promise2会依次输出

script 任务继续往下执行,最后输出 global1。此时,全局任务就执行完毕了

在第一个 macro-task 任务,也就是 script 执行完毕之后,就开始执行所有可执行的微任务。这时,微任务队列中,只有 Promise 队列中的一个任务输出 then1的函数,函数会进入函数调用栈中执行,输出结果后第一轮的事件循环就结束了。

前面所说的随着 micro-task 任务队列中的任务执行完毕后,第一次循环也就结束 。事件循环就马上开始第二次的循环,依然还是从 marco-task 开始。

我们可以通过上图很直观的看到此时的 marco-task 只有 setTimeout队列中还有一个输出 timeout1的函数的任务等待执行,所以最后timeout1输出到屏幕上

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

这就是 JavaScript 事件队列的基础知识,我们在理解事件队列后当然就需要顺着思路来理解 Promise。由于 Promise 的知识比较重要,再新开一篇文章专门学习 Promise 的知识

参考链接