什么是事件轮询
大家都知道, JavaScript是单线程的, 那么nodejs是如何做到非阻塞呢,在nodejs内部使用了第三方库libuv,nodejs会把IO,文件读取等异步操作交由他处理,而nodejs主线程可以继续去处理其他的事情。libuv会开启不同的线程去处理这些延时操作,处理完后,会把异步操作的回调函数放到nodejs的轮询队列中,nodejs会在适当的时候处理轮询队列中的回调函数,从而实现非阻塞。所以,实际上nodejs在处理这些阻塞操作时,并不是单线程的。
事件轮询详解
下图是nodejs官网的事件轮询流程图
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
从图中可以看出, 事件轮询机制分为六个阶段,每个阶段都有一个 FIFO 队列来执行回调。虽然每个阶段都是特殊的,但通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作,然后在该阶段的队列中执行回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段。
事件轮询阶段详解
timers阶段
概述: timers阶段用来处理setTimeout() 和 setInterval() 的回调函数。
详解 :我们都使用过setTimeout(callback, delay), setInterval(callback, delay), 第一个参数是回调函数, 第二个参数是延迟时间(ms),即多久之后执行。但是在绝大多数情况下,它并不会绝对守时。因为操作系统调度或其它回调的运行可能会延迟它们。比如说, 当轮询进入到poll阶段的时候并且poll阶段的回调队列不为空, 如果timers此时已经有一个setTimeout达到了预设的延时时间, 系统也需要先处理完poll阶段的回调队列,才能去处理timers的回调队列。
pending callbacks阶段
概述: 这个阶段用来处理系统操作的回调函数
详解 :此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,这些错误的回调将被放到此阶段的回调函数。
idle prepare阶段
概述: 此阶段是仅供nodejs内部操作调用,我们不必讨论
poll阶段
概述: 这个阶段主要用来处理如IO操作,网络请求等异步操作
详解 :这各阶段会有不同的情况
1.当poll阶段的回调函数队列不为空的时候,则处理队列中的回调函数,直到队列为空或者达到系统处理的上限的时候,就跳过此阶段,处理下一阶段。
2.当进入poll阶段的时候,如果此阶段的回调队列为空,系统会在此阶段等待新的回调函数入队,再进行处理。如果一直等不到新的回调函数呢?咋办?阻塞在这里?一直等?不会的,在这个阶段会同时进行检测timers阶段是否已经有回调函数超时,如果有,则马上跳过poll阶段,进入下一个阶段。
那么在poll阶段是如何利用libuv库来处理io及文件读取等操作的呢?看下图
从图中可以看得出,libuv自身有一个EventQueue的队列,这个队列里面都是一些如文件读取,网络请求的操作,libuv依次去处理这个队列中的操作,libuv每当从EventQueue中拿到一个处理事件, 就会分配一个线程给它,让这个线程去处理这个事件,当这个线程处理完毕这个事件的时候,就会将结果返回到EventQueue队列, 当再次获取到该事件的时候, 发现已经不是IO操作了,就会把这个事件的回调函数放到poll阶段的队列中,再交由poll去处理回调函数。所以EventQueue在每次出队的时候都会进行判断该操作是否是IO操作,不是的话就直接返回给poll阶段的队列了。需要注意的是,libuv默认只开启了4个线程,你可以通过设置环境变量来修改线程数量。
check阶段
概述: 这个阶段用来处理setImmediate的回调函数
详解 :当poll阶段的回调队列为空的时候(或者达到系统执行的上限),就会进入到check阶段来处理setImmediate的回调函数。
close callbacks阶段
概述: 这个阶段用来处理如socket的close事件
详解 :顾名思义, 关闭回调函数, 如socket.on("close", () => {...})
process.nextTick及Promise
上面的讨论一直未提及process.nextTick和Promise的执行,原因是这两个函数比较特殊,它们不由libuv去管理,而且它们的优先级要高于事件轮询的每一个阶段。它们会在事件轮询的每一个阶段之间执行,注意是事件轮询的每一个阶段之间。process.nextTick会放在nextTickQueue, Promise会放在microTaskQueue,在每次事件轮询进入到下一个阶段的时候, 都会检查这两个队列是否为空,不为空则马上处理它们的回调。
注意因为process.nextTick会在事件轮询每个阶段之间执行, 如果递归调用nextTick, 就会导致轮询阻塞,所以尽量避免使用process.nextTick, 可以使用setImmediate代替。
有趣的事情
从process.nextTick和setImmediate的名字上来看,setImmediate应当是要先于process.nextTick执行的。但事实恰好相反,nodejs官网也给出了解释,这是nodejs历史原因,很难再去修改他们的名字!!