likes
comments
collection
share

好激动,离nodejs事件循环更近一步

作者站长头像
站长
· 阅读数 14

杰克-逊の黑豹,恰饭了啦 []( ̄▽ ̄)

前言

无数次,nodejs事件循环都在折磨我,尽管它并不影响我使用nodejs。但是,面试总会被问到,感觉每次回答都有点心虚,回去查漏补缺吧,效果也不显著。到头来,我似乎知道事件循环,又不知道事件循环。倒不是说,为了应付面试,我必须把它搞懂。而是说,这东西勾起我的探索欲,不把它搞清楚,我就心里难受

原因何在呢?

我对事件循环的理解都是隔着黑盒。

要么看nodejs官方文档介绍;

要么看别人的总结;

要么透过代码输出的次序,揣摩事件循环。

nodejs官方文档介绍得很好,可令人抓狂。那感觉就像,好不容易说到故事高潮了,然后不讲了。你读后,从理论上似乎明白事件循环是怎么一回事儿,追问到细节时,又一无所知。唉,这个感觉太让人煎熬了😭。

一年前,我下载nodejs源码,想通过阅读代码的方式搞懂,可没有坚持一星期就白白了。

后来,我尝试通过调试搞懂它,又不知道怎么调试node源码,被难住了,又白白了。

最近,我痛定思痛,拒绝做行动上的矮子,逼迫自己去调试,有了非常开心的结果。带着很激动的心情,写下此文。诚实地说,我对nodejs事件循环的理解更近一步,而非精通掌握。希望这篇文章可以帮助更多被nodejs事件循环困扰无数次的朋友。

如何调试node源码

我在网上搜索很多,没有找到合适的文章告知调试方法。无奈之下,我让自己镇定下来,硬着头皮看看node源码文档,从README.md,找到了调试方法,方法就记录在node源码的BUILDING.md文档中。

调试的思路非常简单,仅仅需要两步。

第一步,按照BUILDING.md,手动编译node。作为阉割版M1 Pro芯片的MacBook Pro14用户,编译node的时候,第一次听到风扇嗡嗡狂转。编译好的node二进制文件位于out/Debug/node

$ ./configure --debug
$ make -j4

第二步,使用 lldb 开始调试。test.js是我用来测试、随意编写的一个文件。在Mac环境下,我用的是lldb工具,可能你的情况不同,要用gdb工具。

$ lldb ./out/Debug/node test.js

test.js内容参考:

console.log('A');

process.nextTick(() => {
    console.log('B');
});

process.nextTick(async () => {
    console.log('C');
});

Promise.resolve().then(() => {
    console.log('D');
});

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

setImmediate(() => {
    console.log('F');
});

来到lldb调试界面,就是lldb如何使用的范畴了,不是本文重点,但是为了读者自己实践方便,还是给出一些基本操作:

  1. 执行b main,再执行 r, node程序就会运行,在入口函数 main 停下。lldb给出的调试信息,会告知main函数所在的文件名、行位置、列位置。
  2. 执行n,就是step over单步调试。
  3. 执行s,就是step into单步调试。
  4. 执行c,就是程序继续执行,遇到下一个断点停下。
  5. 执行list 10, 终端会输出当前被调试文件第10行开头的一部分代码。
  6. 执行p env,可查看源码里的变量env的值。
  7. 执行frame info,可查看当前提顿在哪一行代码。
  8. 执行variable,可查看当前所有栈变量。

这个方式可以看到node启动后都做了什么,让你亲眼看到事件循环。

省略源码的一些无关代码,只需要看带注释的代码即可。

// src/node_main_instance.cc

int NodeMainInstance::Run(const EnvSerializeInfo* env_info) {
 // ...
 
 int exit_code = 0;

 DeleteFnPtr<Environment, FreeEnvironment> env = CreateMainEnvironment(&exit_code, env_info);
  
 CHECK_NOT_NULL(env);

 {
    Context::Scope context_scope(env->context());

    if (exit_code == 0) {
        // 执行js脚本
        LoadEnvironment(env.get(), StartExecutionCallback{});

        // 进入事件循环
        exit_code = SpinEventLoop(env.get()).FromMaybe(1);
    }
    ResetStdio();
 }

 // ...

 return exit_code;
}
// src/api/embed_helpers.cc

Maybe<int> SpinEventLoop(Environment* env) {
  CHECK_NOT_NULL(env);
  MultiIsolatePlatform* platform = GetMultiIsolatePlatform(env);
  CHECK_NOT_NULL(platform);

  Isolate* isolate = env->isolate();
  HandleScope handle_scope(isolate);
  Context::Scope context_scope(env->context());
  SealHandleScope seal(isolate);

  if (env->is_stopping()) return Nothing<int>();

  env->set_trace_sync_io(env->options()->trace_sync_io);

  {
    bool more;
    env->performance_state()->Mark(
      node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_START);

    do {
      if (env->is_stopping()) break;

      // nodjs官方文档里说的 timers pending idle&prepare poll 
      // check close 等 phase,都在 uv_run 函数中!
      uv_run(env->event_loop(), UV_RUN_DEFAULT);

      if (env->is_stopping()) break;
      platform->DrainTasks(isolate);

      more = uv_loop_alive(env->event_loop());
      if (more && !env->is_stopping()) continue;
      if (EmitProcessBeforeExit(env).IsNothing()) break;

      more = uv_loop_alive(env->event_loop());
    } while (more == true && !env->is_stopping());

    env->performance_state()->Mark(node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_EXIT);

  }

  if (env->is_stopping()) return Nothing<int>();
  env->set_trace_sync_io(false);
  env->PrintInfoForSnapshotIfDebug();
  env->VerifyNoStrongBaseObjects();

  return EmitProcessExit(env);
}
// deps/uv/src/unix/core.c

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

  r = uv__loop_alive(loop);

  if (!r) uv__update_time(loop);

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop);
    
    // timers phase
    uv__run_timers(loop);
    
    // pending phase
    ran_pending = uv__run_pending(loop);
    
    // idle phase
    uv__run_idle(loop);
    
    // prepare phase
    uv__run_prepare(loop);

    timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);
    
    // poll phase
    uv__io_poll(loop, timeout);
    
    uv__metrics_update_idle_time(loop);

    // check phase
    uv__run_check(loop);

    // close phase
    uv__run_closing_handles(loop);

    if (mode == UV_RUN_ONCE) {
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop);

    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}

调试node源码,能让你理解node执行的骨架,真真正正看到事件循环,但无法让你看到宏任务和微任务是怎么加入到队列,又是如何从队列取出执行。想要知道这个,还需要从调试js文件入手。接下来,就以上文的test.js为例,说一下调试思路。

调试js文件

在vscode中,往.vscode/launch.json加入如下配置:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "debug node js with internals",
      "program": "${file}",
      "skipFiles": []
    }
  ]
}

process.nextTick Promise.resolve setTimeout setImmediate所在行打上断点,然后开启vscode的调试即可。对函数调用,使用 step in 调试,从setImmediate函数返回后,不要使用continue,继续采用单步调试,你会发现大宝藏。调试过程,非常无脑,非常简单,读者可以尝试尝试。

// test.js

console.log('A');

process.nextTick(() => {
    console.log('B');
});

process.nextTick(async () => {
    console.log('C');
});

Promise.resolve().then(() => {
    console.log('D');
});

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

setImmediate(() => {
    console.log('F');
});

接下来,说说调试后的结论。

当执行node test.js时,入口文件不是test.js,而是nodejs源码的lib/internal/main/run_main_module.js

然后是lib/internal/modules/run_main.js

然后是lib/internal/modules/cjs/loader.js。该文件中,会require我们的test.jstest.js因此得以执行。终端打印出A

执行之后,会跳转到processTicksAndRejections函数。这个函数位于lib/internal/process/task_queues.js。这个时候,process.nextTick注册的回调函数执行,之后执行Promise.resolve().then注册的回调函数。

之后,进入事件循环。

接下来,会跳转到processTimers函数,setTimeout注册的回调函数执行。

执行后,跳转到processImmediate函数,setImmediate注册的回调函数执行。

processTimersprocessImmediate函数都定义在lib/internal/timers.js

最终,程序结束。

这部分讲述起来非常复杂,一篇文章说不清楚,放在以后的一个文章里细聊。接下来,说一个补充的要点,给下一篇文章热热身。

js函数和事件循环运行时

lib/internal/下面的js文件,有些函数被完整定义,有些函数使用internalBinding函数引入,而不是require引入。比如:

// lib/internal/process/task_queues.js

const {
  tickInfo,
  runMicrotasks,
  setTickCallback,
  enqueueMicrotask
} = internalBinding('task_queue');

为什么会这样呢?

因为像runMicrotasks,是在cpp端定义,js端跨语言引用。

如果你开发过node addon的话,你就会很熟悉。开发node addon的时候,函数使用cpp或者Rust语言编写的,生成.node文件后,nodejs引入它,然后在js文件里调用这些函数。

V8引擎提供了这样的工具,帮助我们实现在cpp端定义的函数,可以被js端直接使用。但这属于具体技术实现细节,究竟怎么实现,并不影响我们理解nodejs事件循环。我们只需知道,js端调用这些函数的时候,就会陷入到cpp的一个函数里执行。

这种机制非常重要。因为事件循环使用cpp开发,相当于一个运行时,js端需要与之交互,才能去做宏任务、微任务的执行,而且像setTimeout也需要从运行时获取时间参数,计算任务的超时时刻。这个机制,会在下一篇相关的文章用到,是理解下一篇文章的基础。

最后

你是如何学习nodejs事件循环的,有没有哪些困惑?欢迎留言分享。

转载自:https://juejin.cn/post/7288165091979591739
评论
请登录