当前位置: 首页 > 科技观察

Node.jsMicrotask处理(基于Node.jsV17)

时间:2023-03-12 01:08:22 科技观察

前言:Node.js事件循环是家常便饭,但是在Node.js的执行过程中,事件循环并不是全部,在事件循环之外,处理microtasks的处理也是核心节点,比如nextTick和Promise任务的处理。本文介绍Node.js中微任务处理的相关内容。关于Promise、nextTick、setTimeout、setImmediate的执行顺序,网上的文章很多,面试题也很多。通过这篇文章,让你从原理上理解它们,解决相关问题而不拘泥于死记硬背和记录。1事件循环本文不打算详细讲解事件循环,因为相关的文章已经很多了,过程本身也不是很复杂。事件循环本质上是消费者和生产者的模型。我们可以理解为事件循环的每一阶段都维护一个任务队列,然后在每一轮事件循环中消费这些任务,也就是执行回调,然后在回调中就可以生产任务,从而驱动运行整个事件循环。当事件循环中没有生产者时,系统将退出。而有的producer会hold住eventloop,让整个系统都不会退出,比如我们启动了一个TCPserver。事件循环处理Node.js中的大部分执行流程,但不是全部。2微任务在Node.js中,典型的微任务包括nexiTick和Promise。官网说nextTick任务会在继续事件循环之前处理。描述比较宏观。我们来看看具体的实现细节。微任务的处理时序分为两个时间点。1.对象销毁时定义C++InternalCallbackScope对象。2.主动调用JS函数runNextTicks。2.1InternalCallbackScope我们先来看看InternalCallbackScope。通常在需要处理微任务的地方定义一个InternalCallbackScope对象,然后执行一些其他代码,最后退出作用域。{InternalCallbackScope//somecode}//退出作用域,析构我们来看看InternalCallbackScope析构函数的逻辑。InternalCallbackScope::~InternalCallbackScope(){Close();}voidInternalCallbackScope::Close(){tick_callback->Call(context,process,0,nullptr);}将在析构函数中执行tick_callback函数。让我们看看这个函数是什么。staticvoidSetTickCallback(constFunctionCallbackInfo&args){Environment*env=Environment::GetCurrent(args);CHECK(args[0]->IsFunction());env->set_tick_callback_function(args[0].As<函数>());}tick_callback由SetTickCallback设置。setTickCallback(processTicksAndRejections);我们可以看到setTickCallback设置的函数是processTicksAndRejections。functionprocessTicksAndRejections(){lettock;do{while(tock=queue.shift()){constcallback=tock.callback;callback();}runMicrotasks();}while(!queue.isEmpty()||processPromiseRejections());}processTicksAndRejections是处理微任务的函数,包括tick和Promise任务。现在我们已经看到了InternalCallbackScope对象的逻辑。那么我们来看看这个对象用在什么地方。第一个地方是Node.js初始化的时候,执行完用户JS之后,进入事件循环之前。看一下相关代码。我们看到在Node.js初始化的时候,执行完用户JS之后,会在进入事件循环之前处理一个microtask,所以如果我们在自己的初始化JS中调用nextTick,这个时候就会处理。第二个地方是每次从C、C++层执行JS层回调。MaybeLocalAsyncWrap::MakeCallback(constLocalcb,intargc,Local*argv){ProviderTypeprovider=provider_type();async_contextcontext{get_async_id(),get_trigger_async_id()};MaybeLocalret=InternalMakeCallback(env(),object(),object(),cb,argc,argv,context);returnret;}MakeCallback是C、C++层回调JS层的函数,在这个函数中调用了一个InternalMakeCallback。MaybeLocalInternalMakeCallback(Environment*env,Localresource,Localrecv,constLocalcallback,intargc,Localargv[],async_contextasyncContext){//定义InternalCallbackScopeInternalCallbackScope(env,resource,asyncContext,flags);//执行JS层回调callback->Call(context,recv,argc,argv);//处理microtaskscope.Close();}我们看到在InternalMakeCallback中定义了一个InternalCallbackScope,然后在callbackJS函数执行完毕后,会调用InternalCallbackScope对象的Close来处理微任务。以上是典型的处理时间。另外有些地方还定义了InternalCallbackScope对象,具体可以去源码中搜索。2.2runNextTicks刚刚介绍过,事件循环每次消费任务时,都会遍历各个stage的任务队列,然后逐一执行任务节点对应的回调。callback执行的时候会从C到C++层,再到JS层。执行完JS代码后,会再次回调到C++层。C++层会处理一个微任务,然后返回给C层。继续执行下一个任务节点的回调,以此类推。这似乎涵盖了所有情况,但是有两个地方比较特殊,那就是setTimeout和setImmediate。对于其他的任务,一个节点对应一个C、C++、JS的回调,所以如果一个微任务是在JS回调中产生的,返回到C++层就会处理。但是为了提高性能,Node.js的定时器和setImmediate被实现为一个底层节点来管理多个JS回调。这里以定时器为例,Node.js在底层使用一个Libuv定时器节点来管理JS层的所有定时器,维护JS层的所有定时器节点,然后设置Libuv定时器节点的??超时时间是JS层中过期最快的节点的时间,会出问题。即当一个定时器超时,Libuv从C和C++回调到JS层时,JS层会直接处理所有的超时节点,然后返回到C++层。这时候就有机会处理微任务了。这会导致setTimeout中产生的microtasks在macrotask(setTimeout回调)执行后没有得到处理。这超出了规范。所以这个地方需要特殊对待。我们来看一下相关代码。functionprocessTimers(now){nextExpiry=Infinity;letlist;letranAtLeastOneList=false;while(list=timerListQueue.peek()){if(list.expiry>now){nextExpiry=list.expiry;returnrefCount>0?nextExpiry:-nextExpiry;}//处理listOnTimeout最后一次回调产生的microtaskif(ranAtLeastOneList)runNextTicks();elseranAtLeastOneList=true;listOnTimeout(list,now);}return0;}functionlistOnTimeout(list,now){letranAtLeastOneTimer=false;lettimer;while(timer=L.peek(list)){//处理微任务if(ranAtLeastOneTimer)runNextTicks();elseranAtLeastOneTimer=true;//执行setTimeout回调timer._onTimeout();}}定时器的结构如下。Node.js在JS层维护了一棵树,每个节点管理一个链表。当处理一个超时事件时,它会遍历树的每个节点,然后遍历这个节点对应的队列中的每个节点。上面的代码是为了保证每次调用setTimeout回调时,都会处理一个microtask。同样的setImmediate任务类似。letranAtLeastOneImmediate=false;while(immediate!==null){if(ranAtLeastOneImmediate)runNextTicks();elseranAtLeastOneImmediate=true;immediate._onImmediate();immediate=immediate._idleNext;}上面的补偿处理保证了宏任务和微任务处理是正如预期的那样。