JavaScript的Event Loop机制

Event Loop(事件循环)是JavaScript Runtime最重要的机制之一,它很好地解决了单线程JS带来的性能问题,但增加了JS运行环境的复杂性。很多接触JavaScript不久的人可能会纳闷它的一些怪异行为,甚至会写出一些“难以解决”的bug,这些问题可能就是不理解Event Loop导致的。但要彻底理解Event Loop也不是件很容易的事,本文会尽量详细地介绍其涉及到的概念,希望能解释得清楚。

:1. 本文适合有一定JS基础的人阅读;2. 以下讨论的是浏览器中的Event Loop机制,Node.js中的Event Loop机制有所不同;3. 本文有误之处还请指出,感谢。

单线程JavaScript

JS最大的特点估计就是单线程了(single-threaded),也就是说,JS在同一时间只能干一件事。那为什么不设计成多线程(并发)呢?其实这是由JS的用途决定的,因为JS最初运行在浏览器上,如果有多个线程,其中线程A在某个DOM上添加了些内容,线程B却直接删了这个DOM,那么浏览器该以哪个线程为标准?是否要引入锁机制?这又大大增加了复杂性,而浏览数上没有那么多复杂的业务场景必须运行多个线程,所以单线程就是最适合的运行模式了。

同步&异步

单线程确定下来了,于是让JavaScript去运行一些简单的代码:

// 让两个数相乘
function multiply(m, n) {
  return m * n;
}
// 对一个数进行平方
function square(a) {
  return multiply(a, a);
}
// 输出一个数的平方值
function log(b) {
  const result = square(b);
  console.log(result);
}
log(3); // 9

嗯,马上输出了9,没毛病。

上面代码中,multiply和square函数执行时就能立即拿到函数的返回值,并且只有当multiply执行完成后才执行square,square执行完成之后才会执行console.log,这种行为称为同步(synchronous),代码总是按照顺序执行,只有当上一个操作执行完成并返回之后才会执行下一个操作。

但是考虑一个问题:总有些东西让代码运行起来很慢,我们称之为阻塞(blocking),比如一个ajax去服务端拿数据,网络请求,是阻塞;一个readFile去读取本地文件,IO操作,也是阻塞;setTimeout延迟执行,毫无疑问是阻塞......这些阻塞短之几百毫秒,长之四五秒甚至更久!试想,如果单线程的JS在遇到这些阻塞时,只会傻傻地等待这些阻塞操作完成再继续执行后面的代码,期间干不了任何事,对于用户来说是多么痛苦的一件事!比如我在上面代码中的log函数中加入一段阻塞:

function log(b) {
  const result = square(b);
  // 将平方结果写到后台服务器的log日志中,假设花了3秒钟时间
  postAjaxSync('/calc/log', { reuslt }); 
  console.log(result);
}

现在再执行会发现,开始执行到输出结果,花了三秒多钟!但是用户并不关心这个结果到底有没有写入后台服务器,他们只想马上得到结果!
为了解决上面同步代码的阻塞问题,JS引入了异步概念:

如果在函数返回的时候,调用者还不能立即得到预期结果,而是需要在将来通过一定的手段得到(一般通过回调函数的形式),那么这个函数就是异步的(asynchronous)。

现在我们将log函数中的阻塞代码改成非阻塞的异步形式:

function log(b) {
  const result = square(b);
  // 这里用异步的ajax方法,假设ajax请求也花了3秒
  postAjaxAsync('/calc/log', { reuslt }, function(res) {
    console.log('结果已写入后台服务器!');
  }); 
  console.log(result);
}

此时执行上面代码,控制台上会立即输出结果9,并且3秒多钟之后再输出“结果已写入后台服务器!”,其中postAjaxAsync的第三个参数就是回调函数。

Event Loop涉及到的一些概念

上面介绍了JS的单线程和同步、异步的概念,但是要理解Event Loop,还需要再理解JS运行时的一些结构。

1. 调用栈(也称为“执行栈”)

当JS调用一个函数时,会产生一个这个函数对应的执行上下文(context),这个执行上下文中存放了这个函数的作用域、上层作用域的指向、函数的参数、函数中声明的变量等一系列东西,这个函数被压入栈中形成一个栈帧;当JS从一个函数调用下一个函数时,会为下个函数再创建一个新的栈帧并进入这个栈帧,当前这个栈帧称为“当前帧”,而上个函数所对应的栈帧称为“调用帧”......这个包含了许多个栈帧的栈结构我们称之为调用栈(Call Stack),如下图所示:

1.jpg

很懵逼?没事,我们回头看之前的那几个函数的调用行为,就很好理解:

// 让两个数相乘
function multiply(m, n) {
  return m * n;
}
// 对一个数进行平方
function square(a) {
  return multiply(a, a);
}
// 输出一个数的平方值
function log(b) {
  const result = square(b);
  console.log(result);
}
log(3); // 9

此时,这几个函数在调用栈中的位置以及压入时机如下:


2.png

3.png
2. Web APIs

JS运行在浏览器中是单线程的,我们强调了JS在同一时间只能做同一件事,但是为什么JS在请求ajax时,我们能先执行下面的代码,等ajax请求完成之后再执行ajax的回调函数呢?是不是自相矛盾了?这里需要解释下:JS运行时是绝对的单线程,而我们能在请求ajax的过程中先执行别的代码的原因在于,浏览器不仅仅只有JS运行时,浏览器不是单线程的!浏览器提供了一些api供开发者调用,这些都是有效的线程,比如setTimeout,ajax(XMLHttpRequest),DOM(document)等。

3. 宏任务(Macro Task)与微任务(Micro Task)
  • 宏任务:当前调用栈中执行的代码称为宏任务,浏览器会在一个宏任务执行完成后,在下一个宏任务开始执行之前,对页面进行重新渲染。主代码块(所有同步代码)、setTimeout、setInterval等都属于宏任务;
  • 微任务:当前调用栈的所有宏任务执行完,在下一轮宏任务开始之前需要执行的任务。Promise.then catch finally、new MutationObserver()等属于微任务。

宏任务中的事件放在宏任务队列(Macrotask Queue)中,由事件触发线程维护;微任务的事件放在微任务队列(Microtask Queue)中,由js引擎线程维护。
至此我已经把解释Event Loop需要用到的元素都介绍了一遍了,我们可以建立一个简单的Event Loop模型了:


3.jpg

下面是对这张图的运行过程的具体解释:


  1. JS引擎检查宏任务队列中是否有任务在排队,若有,取出第一个任务推入调用栈中执行。若无,跳到第4步。
  2. 调用栈中的任务遇到异步操作时(比如setTimeout、ajax等Web API),调用异步函数,此时浏览器会有另一个线程去执行异步操作(建立网络请求等),然后JS引擎继续执行调用栈中的代码,当异步操作完成时,会将异步的回调函数排队进入宏任务队列;
  3. 调用栈中的任务遇到Promise.then等微任务时,会去微任务队列进行排队;
  4. 当调用栈中的代码全部执行完毕,调用栈为空时,JS引擎会去检查微任务队列中是否有任务在排队,若有,则按照先进先出的顺序依次取出并执行,直到微任务队列为空;
  5. 事件循环一轮完毕,回到第1步继续下一轮循环。

举个栗子,我们将上面介绍调用栈时的代码加入一些异步代码:

// 让两个数相乘
function multiply(m, n) {
  return m * n;
}
// 对一个数进行平方
function square(a) {
  return multiply(a, a);
}
// 输出一个数的平方值
function log(b) {
  const result = square(b);
  // 异步ajax请求服务器写入日志
  postAjaxAsync('/calc/log', { reuslt }, function ajaxResp(res) {
    output('结果已写入后台服务器!');
  }); 
  Promise.resolve().then(function result() => {
    console.log(result);
  });
}
log(3); 

上面代码执行过程中,调用栈的状态如下所示:

  • 前面几步同步函数的调用简单说明如下:


    6.png
  • 重点来了,当上面执行到第6步时,由于是个ajax异步请求,所以此时浏览器将ajax放入另一个线程中去执行:


    7.png
  • ajax交给另一个线程了,此时JS线程又可以往下执行了,但是这里又遇到了一个Promise,这时需要将then中的方法排队进入微任务队列:


    image.png
  • log函数执行完毕,出栈,此时调用栈中已处于空状态,JS引擎检查微任务队列中是否有任务在排队,发现有个result函数,则取出推入调用栈中并执行:


    image.png
  • result函数执行完成,此时调用栈和微任务队列都清空了。当ajax请求完毕,会触发回调函数,将其推入宏任务队列:


    image.png
  • JS引擎检查宏任务队列,如果有任务在排队,则按照先进先出原则取出第一个任务,放入调用栈中,于是从第一步开始又开始一轮循环:


    image.png

注:

  1. 宏任务队列可以有多个,不同的任务源维护其各自的任务队列,比如setTimeout和setInterval有各自的一个任务队列
  2. 微任务队列仅有一个
  3. 异步任务不会一开始就往队列中插入回调任务,而是会等异步操作完成后再将回调任务排队进队列中

好了,完成上面的过程的讲解,我们再来看一道题:

console.log(1);

setTimeout(() => {
  console.log(2)
}, 0);

Promise.resolve().then(() => {
  console.log(3);
  Promise.resolve().then(() => {
    console.log(4);
  }).then(() => {
    console.log(5);
  });
}).then(() => {
  console.log(6);
});

Promise.resolve().then(() => {
  console.log(7);
}).then(() => {
  console.log(8);
});

console.log(9);

根据上述分析,请给出打印顺序?
答案:1、9、3、7、4、6、8、5、2

Web Worker

这一块内容跟Event Loop完全没关系,就是补充一点:随着应用复杂度的日益提高,单线程的JS有时会显得力不从心了,所以HTML5规范里引入了Web Worker,用于实现JS的多线程操作,但Web Worker子线程完全受主线程控制,无法操作DOM,所以本质上,JS还是单线程的。


参考

https://html.spec.whatwg.org/multipage/webappapis.html#microtask-queue
https://www.youtube.com/watch?v=8aGhZQkoFbQ

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335