月度文章——Event Loop

前言

JS是一门单线程的语言,如果没有异步操作的话,一个很耗时的操作,就可以堵塞整个进程。而出现异步操作之后,就会有数据通信之间的问题,而event loop很好的解决了这个问题。

Event Loop

什么是Event loop?这是我们第一个需要知道的问题。 在html官方标准中是这么介绍的。

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

为了协调事件,用户交互,脚本运行,页面渲染,网络请求等,用户代理必须使用本节描述event loop。有两种event loop,一种是browsing contexts,另一种是workers.

  • browsing contexts:基于浏览器上下文的event loop
  • workers:基于Web Worker中的event loop

在标准文档中可以看到两种task,一种就叫task,还有一种叫Microtask。以前刚接触EL的时候,听说过macrotask,标准文档中未提及,不做探讨。

一、task

An event loop has one or more task queues. A task queue is an ordered list of tasks

规范中指出一个事件循环有一个或者多个任务队列,一个任务队列中堆放这有序的任务。这里我们列举几个典型的任务源:

  • DOM操作
  • 用户交互(点击事件等操作)
  • 网络请求(ajax)
  • script代码(脚本任务)
  • setTimeout/setInterval
  • I/O(node中)
  • setImmediate(nodejs中)

二、microtask

Each event loop has a microtask queue. A microtask is a task that is originally to be queued on the microtask queue rather than a task queue. There are two kinds of microtasks: solitary callback microtasks, and compound microtasks.

规范中也指出,每一个event loop只有一个微任务队列,微任务通常只排列在微任务队列上,而不是任务队列。这里有两种微任务:回调微任务和复合微任务。举几个典型的微任务:

  • promise
  • promise的回调(catch和then)
  • process.nextTick(node中)
  • MutationObserver(新特性,自己没用过)

三、event loop运行机制

在写这个之前,先写几条条总结出来的规律:

  • 执行顺序:task > microtask
  • task一次只执行一个
  • microtask队列清空之后才会执行下一个task

用伪代码表示为:

一个任务,清空微任务栈,一个任务,清空微任务栈,...

关于整个运行过程,可以参见规范第8章

四、example

setTimeout(()=>{
    console.log('timer1')

    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)

setTimeout(()=>{
    console.log('timer2')

    Promise.resolve().then(function() {
        console.log('promise2')
    })
    setTimeout(() => {
    	console.log('timer3')
    }, 0)
}, 0)

Promise.resolve().then(function() {
    console.log('promise3')
})

console.log('start')
复制代码
  • 运行的过程
    • 循环一

      1、将脚本任务被放入到task队列。

      2、从task中取出一个任务运行,运行的结果是将set1和set2放入到task中,将promise.then放入到microtask中,输出start。

      3、检查microtask checkpoint,看microtask队列中是否有任务。

      4、运行microtask中所有的任务,输出promise3。

      5、清空microtask队列之后,进入下一个循环。

    • 循环二

      1、从task中在取出一个set1任务,运行的结果是输出timer1,将promise.then放入到microtask队列中。

      2、检查microtask checkpoint,看microtask队列中是否有任务。

      3、运行microtask中所有的任务,输出promise1。

      4、清空microtask队列之后,进入下一个循环。

    • 循环三

      1、从task中在取出一个set2任务,运行的结果是输出timer2,将promise.then放入到microtask队列中,将set3放入到task队列中。

      2、检查microtask checkpoint,看microtask队列中是否有任务。

      3、运行microtask中所有的任务,输出promise2。

      4、清空microtask队列之后,进入下一个循环。

    • 循环四

      1、从task中在取出一个set3任务,运行的结果是输出timer3

      2、检查microtask checkpoint,看microtask队列中没有任务,进入下一个循环。

    • 循环五

      检测task队列和microtask队列都为空,WorkerGlobalScope对象中closing标志位为true,销毁event loop。

  • 输出的结果
start
promise3
timer1
promise1
timer2
promise2
timer3
复制代码

node中的Event Loop

我们先来看一下node的架构。

nodejs架构

node的异步是通过底层的libuv来实现的。

一、libuv是什么

libuv enforces an asynchronous, event-driven style of programming. Its core job is to provide an event loop and callback based notifications of I/O and other activities. libuv offers core utilities like timers, non-blocking networking support, asynchronous file system access, child processes and more.

libuv使用异步和事件驱动的编程风格。它的核心工作是提供一个event-loop,还有基于I/O和其它事件通知的回调函数。libuv还提供了一些核心工具,例如定时器,非阻塞的网络支持,异步文件系统访问,子进程等。

二、libuv中的event loop

在node的官方doc中,将El分成了六个阶段,我们可以看一下下面的图:

当node开始运行的时候,它会初始化一个event loop,而每个event loop都包含以下六个阶段:

  • timers:这个阶段执行setTimeout和setInterval的回调。
  • pending callbacks:执行被推迟到下一个iteration的 I/O 回调。
  • idle,prepare:仅供内部使用。
  • poll:这个过程比较复杂,留到下面讲。
  • check:调用setimmediation()回调函数。
  • close callback:一些close回调,比如socket.on('close', ...)。

每一个阶段都有一个回调的FIFO队列,当EL运行到一个指定阶段的时候,node将会执行这个队列,当队列中所有的回调都执行完或者执行的回调数上限的时候,EL会跳到下一个阶段。以上所有阶段不包含process.nextTick()。

三、poll阶段

poll阶段主要有两个功能:

1、计算poll阶段堵塞和轮询的时间还有多长。

2、处理poll阶段中的事件。

当EL进入到poll阶段的时候,如果代码中没有设定的timers,那么会发生一下两种情况:

  • 如果poll队列不是空的,将执行poll阶段里面的cb,直到cb为空,或者执行的cb达到执行上限。

  • 如果poll为空的情况,又回有两种情况发生:

    • 如果代码中已经有setImmediate()的回调,那么会进入check阶段,执行check阶段的回调
    • 如果代码中没有setImmediate()的回调,那么将会阻塞在这个阶段。

曾经poll阶段是空的,EL会检查是否有到期的timers,如果有一个或者多个已经到达,那么会直接跳到timers阶段执行timers的回调。

用一张图表示:

node-EL-poll.png

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});
复制代码

输出结果:

106ms have passed since I was scheduled
复制代码

整个过程就是:

1、进入timers阶段,发现没有到期的timers跳过

2、进入pending callbacks阶段,发现没有异步I/O

3、进入idle,prepare阶段,无内部使用的异步函数,跳过

4、到poll阶段,堵塞在这里,fs.readFile完成,callback加入poll队列,整个过程花费6ms,然后进入while循环,设定100ms的定时器,在这个100ms之中,poll处于空闲状态,轮询检查定时器,当100ms到的时候,跳转到timers阶段,输出结果。

整个的EL运行过程源码注释版:

//deps/uv/src/unix/core.c
int uv_run(uv_loop_t *loop, uv_run_mode mode) {
	int timeout;
	int r;
	int ran_pending;
	//uv__loop_alive返回的是event loop中是否还有待处理的handle或者request
	//以及closing_handles是否为NULL,如果均没有,则返回0
	r = uv__loop_alive(loop);
	//更新当前event loop的时间戳,单位是ms
	if (!r)
    	uv__update_time(loop);
	while (r != 0 && loop->stop_flag == 0) {
    	//使用Linux下的高精度Timer hrtime更新loop->time,即event loop的时间戳
    	uv__update_time(loop);
    	//执行判断当前loop->time下有无到期的Timer,显然在同一个loop里面timer拥有最高的优先级
    	uv__run_timers(loop);
    	//判断当前的pending_queue是否有事件待处理,并且一次将&loop->pending_queue中的uv__io_t对应的cb全部拿出来执行
    	ran_pending = uv__run_pending(loop);
    	//实现在loop-watcher.c文件中,一次将&loop->idle_handles中的idle_cd全部执行完毕(如果存在的话)
    	uv__run_idle(loop);
    	//实现在loop-watcher.c文件中,一次将&loop->prepare_handles中的prepare_cb全部执行完毕(如果存在的话)
    	uv__run_prepare(loop);

    	timeout = 0;
    	//如果是UV_RUN_ONCE的模式,并且pending_queue队列为空,或者采用UV_RUN_DEFAULT(在一个loop中处理所有事件),则将timeout参数置为
    	//最近的一个定时器的超时时间,防止在uv_io_poll中阻塞住无法进入超时的timer中
    	if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
        	timeout = uv_backend_timeout(loop);
    	//进入I/O处理的函数(重点分析的部分),此处挂载timeout是为了防止在uv_io_poll中陷入阻塞无法执行timers;并且对于mode为
    	//UV_RUN_NOWAIT类型的uv_run执行,timeout为0可以保证其立即跳出uv__io_poll,达到了非阻塞调用的效果
    	uv__io_poll(loop, timeout);
    	//实现在loop-watcher.c文件中,一次将&loop->check_handles中的check_cb全部执行完毕(如果存在的话)
    	uv__run_check(loop);
    	//执行结束时的资源释放,loop->closing_handles指针指向NULL
    	uv__run_closing_handles(loop);

    	if (mode == UV_RUN_ONCE) {
        	//如果是UV_RUN_ONCE模式,继续更新当前event loop的时间戳
        	uv__update_time(loop);
        	//执行timers,判断是否有已经到期的timer
        	uv__run_timers(loop);
    	}
    	r = uv__loop_alive(loop);
    	//在UV_RUN_ONCE和UV_RUN_NOWAIT模式中,跳出当前的循环
    	if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
        	break;
		}
		
	//标记当前的stop_flag为0,表示当前的loop执行完毕
	if (loop->stop_flag != 0)
    	loop->stop_flag = 0;
	//返回r的值
	return r;
}
复制代码

可以结合上面的六个过程看一下。

四、setImmediate() vs setTimeout()

两者是用法是相似的,而setImmediate进入的是check阶段,而setTimeout进入的是timer的阶段。

而在node的docs中举了个例子,如下:

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

setImmediate(() => {
  console.log('immediate');
});
复制代码

而在执行的时候会不同的时候,两者的触发顺序是不同的:

$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
复制代码

而将其放在i/o中执行,两者的顺序是固定的:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
复制代码

输出的结果:

$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout
复制代码
  • 具体原因:

在node中,计时器的时间是精确到秒的,所以setTimeout(cb, 0) === setTimeout(cb, 1)。 EL初始化是需要耗时的,但是hrtime这个值精确到纳秒级别,所以整个脚本运行会发生以下两种情况:

1、loop准备时间超过1ms,那么loop->time >=1,就会发生uv_run_timers

2、loop准备时间小于1ms,那么loop->time<1,uv_run_timers不生效,就会直接到后面的check阶段去。

而如果有fs的情况下,直接走的是uv__io_poll,触发回调之后,直接走check,在走timer阶段。

五、process.nextTick()

process.nextTick()在node中不参与任何阶段,但是每当切换阶段的时候,需要清空process.nextTick()队列中的回调。

看一个例子:

var fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
    process.nextTick(()=>{
      console.log('nextTick3');
    })
  });
  process.nextTick(()=>{
    console.log('nextTick1');
  })
  process.nextTick(()=>{
    console.log('nextTick2');
  })
});
复制代码

输出结果:

nextTick1
nextTick2
setImmediate
nextTick3
setTimeout
复制代码

整个循环过程:

循环一:

1、进来的时候,直接进入poll阶段,执行回调。

2、挂载setTimeout,挂载setImmediate,将process.nextTick推进nextTick队列中

3、先执行nextTick队列,输出nextTick1和nextTick2。

4、进入check阶段,执行setImmediate回调,输出setImmediate。

5、在执行nextTick队列,输出nextTick3。

循环二:

1、进入timer阶段,有到期的定时器,输出setTimeout。

参考博文

The Node.js Event Loop, Timers, and process.nextTick()

html官方标准

Node.js Event Loop 的理解 Timers,process.nextTick()

猜你喜欢

转载自juejin.im/post/5bd989705188257f6c43e3fd