异步和事件循环

JavaScript是一种同步的、阻塞的、单线程的语言。

在开启文章的主题前,我必须要说一下并行和并发这两个概念。

并行和并发

并行 Parallel

  • do multiple things at once. (在同一时刻做很多操作)

对于 JavaScript 语言来说,并行可谓"只能远观而不可亵玩焉。。。" 为什么这么说呢,一般地,并行都是伴随着多线程的(或者说多线程是实现并行的最有效方法)。

并发 Concurrent

  • do multiple things, not at once. (做很多操作但不在同一时刻)

如图:

parallel_concurrent.png

异步 Asynchronous

有了以上的知识点,我想开始谈一下异步,因为异步在某种情况下能够体现并行和并发。

异步其实是一种描述语言(编程方法)。不过在说异步之前,还是要说一下同步(Synchronous),因为JS是单线程的,主要的代码在主线程(Main thread) 中运行,而同步的最大特点就是必须要等待上一个“任务”完成才能继续执行下一个“任务”,而这等待就意味着阻塞。

看以下代码:

//Synchronous
console.log("script start");
let before = new Date();
function syncRequest1(){
    console.log("request1 send!");
    // some computation 模拟阻塞,需要运行1s
    let bf = new Date();
    while(true){
        if(new Date() - bf === 1000){
            console.log("response1!");
            break;
        }
    }
}
function syncRequest2(){
    console.log("request2 send!");
    let bf = new Date();
    // some computation 模拟阻塞,需要运行2s
    while(true){
        if(new Date() - bf === 2000){
            console.log("response2!");
            console.log("同步代码耗时"+ (new Date() - before) + "ms");
            break;
        }
    }
}
syncRequest1();
syncRequest2();
console.log("script end");

运行结果:

sync_code.gif

显然地, syncRequest2() 必须得等到 syncRequest1() 执行完毕后才能继续执行,而这些单纯的单线程任务完全可以利用一个带有 Call Stack , Heap执行模型 就可以完成,似乎还不需要队列,看着是这样的。

sync_arc.png

代码从开头运行,遇到 syncRequest1 就进栈,然后必须要等到函数执行完毕,才能出栈,继续调用 syncRequest2函数。

sync.png

这种阻塞的模式显然是不明智的,因此我要开始说异步这个概念了。还是以代码为开篇:

//Asynchronous
console.log("script start");
let before = new Date();
function asyncRequest1(){
    console.log("request1 send");
    //compute
    setTimeout(() => {
        console.log("response1!");
    },1000); // 模拟运行,需要1s
}

function asyncRequest2(){
    console.log("request2 send");
    setTimeout(() => {
        console.log("response2!");
        console.log("耗时"+ (new Date() - before) + "ms");
    },2000); // 模拟运行,需要2s
}
asyncRequest1();
asyncRequest2();
console.log("script end");

让我们来看下结果!

async_code.gif

居然只是耗时 2000ms ! 而不是 1000+2000=3000ms ,这是为什么呢?原因很简单,就是异步。

看一下定义:异步指的是让 CPU 暂时搁置当前请求的响应,处理下一个请求,当通过轮询或其他方式得到回调通知后,开始运行。

意思是异步不会阻塞,异步任务(在这里就是setTimeout() 的回调函数)会“挂”起来,其他主线程的任务继续执行,而异步任务什么时候完成对于当前主线程的其他任务来说不可见,至于什么时候完成,肯定是在未来的某个时刻完成。说实话,这些其实还是很抽象,直接来图吧:

async_arc.png

在这里我不会先说代码的执行顺序问题,因为这会关系到后面要说的事件循环(JS的执行机制),因此我要先说的是代码中关键的 setTimeout() 函数。

setTimeout()

来看一下!

//同步
(function(){
  let before = new Date();
  //模拟一些阻塞计算
  console.log("开始计算,阻塞中!!!");
  while(true){
    if(new Date() - before === 2000){
      console.log("计算完了!");
      break;
    }
  }
  console.log("上面终于执行完了,该我了!");
})();
//运行结果
// 开始计算,阻塞中!!!
// 计算完了! (2s后)
// 上面终于执行完了,该我了!

//异步
(function(){
  //setTimeout设置一个1s后打印 "1000ms later..."的任务
  console.log("开始计算!");
  setTimeout(() => {
    let before = new Date();
    //将阻塞代码放到这里
    while(true){
      if(new Date() - before === 2000){
        console.log("计算完了!");
        break;
      }
    }
  },0);
  console.log("hhh,运行顺畅无阻!")
})();
//运行结果
// 开始计算!
// hhh,运行顺畅无阻!
// 计算完了! (2s后)

这段代码的关键是为什么 setTimeout() 中的任务不会立即执行而导致主线程阻塞,因为 setTimeout()函数本身就被设计是异步的,这是很关键的;因此,这些异步是怎么样实现的的?

我们都知道,JavaScript是单线程、同步、阻塞的语言,一次只能执行一个操作;但是对我们理解很重要的是浏览器不是单线程的,浏览器是多线程的!!Web浏览器定义了一些函数和API,这些函数和API就是一些其他线程,使得允许我们当某些事件发生时不是按照同步方式,而是异步地调用函数。

因此,浏览器的多线程赋予了setTimeout() (或者XMLHTTPRequest, DOM事件)的异步特性,这些都是一些对应的线程被挂起(异步特性),等待被推到消息队列(事件循环模型)。

所以说,setTimeout() 的定时器计算并不是主线程在计算,而是浏览器的计时器线程在计算。这种感觉是超纲了,是浏览器的实现问题,接下来我不会继续说这个,是时候将注意力放在 执行循序 上了。

事件循环 Event loop

当加入了异步任务后,之前的那个同步模型(上面那个栈、堆)明显解决不了,因此浏览器JS引擎实现了事件循环这个模型去负责执行代码、收集和处理事件以及执行队列中的子任务。

在这里我推荐看这个视频:What the heck is the event loop anyway? 来学习事件循环。

关于异步代码何时推到调用栈上,检查调用栈是否为空这些操作在原本的同步模型已经管理不了,所以浏览器添加了消息队列(微任务和宏任务队列)这种数据结构来管理,从而构成了所谓的事件循环。

如图:

event_loop.png

总体上:

  • JS分为同步任务和异步任务

  • 同步任务都在主线程上执行,形成一个执行栈

  • 一旦执行栈中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行

任务分类

  • 宏任务 Macrotask (task)

    • script

    • setTimeout(), setInterval()

    • MessageChannel

    • postMessage

    • requestAnimationFrame()

    • I/O

    • Ajax

    • UI rendering

  • 微任务 Microtask (jobs)

    • Promise.then

    • queryMicrotask()

    • MutationObserver()

可以看到浏览器实现的是宏任务,而JavaScript自己实现的则是微任务。

执行循环

这里强烈推荐看Tasks, microtasks, queues and schedules这篇文章,可以对着视频看 Jake Archibald: In The Loop。是同一个作者,贼强。

为此,我特地扒了他那个很直观的程序😂:

{{< snippet >}}

这个程序很直观的将执行步骤展现给我们,无论是宏任务还是微任务,亦或者是同步任务。

把这些总结为一张图:

macro_micro_exe.png

这就是我对异步和事件循环所有理解了,个人理解有限,见谅。对于关于线程的概念,浏览器中的各种线程,可以查看参考的链接自行阅读。

Reference参考: