一文浅析 Node.js 单线程高并发原理

一文浅析 Node.js 单线程高并发原理

Node 并非是真正意义上的单线程,它是主线程 "单线程",通过事件驱动模型把 I/O 和计算进行分离。

它也有一个线程池(基于 C/C++ 实现的 Libuv 库)专门负责执行那些耗时较长的 I/O 操作任务(如网络请求、文件读写等),任务执行完成后会通知主线程。

而对于 CPU 计算型任务,都是由主线程完成的。

Node 的重要优势就是把 I/O 操作放到主线程之外,从而让主线程腾出手去处理更多请求。

因此 Node 擅长执行 I/O 密集型任务,不善于执行 CPU 密集型任务。

不知大家在接触 Node 时是否有考虑过以下几个问题:

  • Node 真的是单线程吗?
  • 如果是单线程,它是如何处理高并发请求?
  • Nodes 事件驱动是如何实现的?
  • 为什么浏览器中运行的 Javascript 能与操作系统进行底层交互?

Node 架构与运行机制

在解答上面的问题之前我们先看看 NodeJS 的架构概览图:

NodeJS 架构概览图
  • Node Standard Library:由 Javascript 编写的 NodeJS 的标准库,即 API。
  • Node Bindings:这一层包括了 C/C++ Bindings(胶水代码),向下封装了 V8 和 Libuv 接口,向上提供了基础 API 接口,是连接Javascript 和 C++ 的桥梁。
  • V8:Google 推出的 Javascript VM,它为 Javascript 提供了在非浏览器端运行的环境。
  • Libuv:是专门为Node.js开发的一个封装库,提供跨平台的异步I/O能力,负责node运行时的线程池调度。
  • C-ares:提供了异步处理 DNS 相关的能力。
  • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其它能力。

NodeJS 的运行机制如下图:

NodeJS 运行机制
  1. V8 引擎解析应用 Javascript 脚本代码
  2. 通过 Node Bindings 调用 C/C++ 库
  3. 执行到当前事件时,会把事件放在调用堆栈处理
  4. 堆栈中的任何 I/O 请求都会交给 Libuv 处理,Libuv 维护着一个线程池,里面是一些工作线程,请求会调用这些线程来完成任务,这些线程则调用底层 C/C++ 库
  5. 请求处理完成后,Libuv 再把结果返回事件队列等待主线程执行
  6. 期间,主线程继续执行其它任务

跨操作系统交互

举个简单的例子,我们想要打开一个文件,并进行一些操作,可以写下面这样一段代码:

var fs = require('fs');
fs.open('./test.txt', "w", function(err, fd) {    //..do something});

这段代码的调用过程大致可描述为:lib/fs.js → src/node_file.cc → uv_fs

lib/fs.js

async function open(path, flags, mode) {  
  mode = modeNum(mode, 0o666);  
  path = getPathFromURL(path);
  validatePath(path);
  validateUint32(mode, 'mode');
  return new FileHandle(
    await binding.openFileHandle(pathModule.toNamespacedPath(path),stringToFlags(flags),mode, kUsePromises));
}

src/node_file.cc

static void Open(const FunctionCallbackInfo& args) {  
  Environment* env = Environment::GetCurrent(args);  
  const int argc = args.Length();  
  if (req_wrap_async != nullptr) {  
    AsyncCall(env, req_wrap_async, args, "open", UTF8,AfterInteger,uv_fs_open, *path, flags, mode);
  } else {
    CHECK_EQ(argc, 5);    
    FSReqWrapSync req_wrap_sync;    
    FS_SYNC_TRACE_BEGIN(open);    
    int result = SyncCall(env, args[4], &req_wrap_sync,"open",uv_fs_open, *path, flags, mode);    
    FS_SYNC_TRACE_END(open);
    args.GetReturnValue().Set(result);
  }
}

uv_fs

dstfd = uv_fs_open(NULL,&fs_req,req->new_path,dst_flags,statsbuf.st_mode,NULL);
uv_fs_req_cleanup(&fs_req);

大致流程如下图:

Node与操作系统交互流程

当我们调用 fs.open 时,Node 通过 process.binding 调用 C/C++ 层面的 Open 函数,然后通过它调用 Libuv 中的具体方法 uv_fs_open,最后执行的结果通过回调的方式传回,完成流程。

主线程"单线程"

在传统 web 服务模型中,大多数都采用多线程来解决并发问题,因为 I/O 是阻塞的,单线程就意味着用户要等待,显然这是不合理的,所以创建多个线程来响应用户请求。

Node 的单线程指的是主线程是"单线程",主线程按照编码顺序一步步执行程序代码,如果中途遇到同步代码阻塞,后续的程序代码就会被卡住。

我们可以创建一个简单的 Web 服务器来验证一下:

var http = require('http');

function sleep (time) {
  var _exit = Date.now() + time * 1000;
  while (Date.now() < _exit) {
  }
}

http.createServer(function (req, res) {
  sleep(10);
  res.end('server sleep 10s');
}).listen(8080);

通过浏览器快速连续请求访问两次 http://localhost:8080 ,可发现第一次请求在约 10s 后得到响应,而第二次请求在约 20s 后得到响应。

这是因为 Javascript 是解析性语言,代码按照编码顺序一行一行被压进 stack 里面执行。当主线程接收到 request 请后,程序被压进同步执行的 sleep 代码块(模拟业务处理)。如果在这 10s 内有第二个 request 进来就会被压进 stack 里面等待第一个请求执行后再处理下一个请求。如下面堆栈图:

主线程"单线程"

这也验证了 Node 中主线程是"单线程"。

事件驱动机制

既然 Node 主线程是"单线程",那为何能同时处理万级并发而不造成阻塞呢?这就是我们常说的 Node 基于事件驱动机制。

事件驱动机制

每个 Node.js 进程只有一个主线程在执行程序代码,形成一个执行栈(Execution Context Stack)。

除了主线程之外,还维护着一个 "事件队列" (Event Queue) ,当用户的网络请求或其它 I/O 异步操作到来时,Node 会把它放到 Event Queue 之中,此时并不会执行它,代码也不会阻塞,会继续往下走,直到主线程代码执行完毕。

主线程代码执行完成后,通过 Event Loop(事件循环机制)开始到 Event Queue 开头取出事件并对每个事件从线程池中分配一个线程去执行,直到事件队列中所有事件都执行完毕。

当有事件执行完毕后,会通知主线程执行回调方法,线程归还回线程池。

因此 Node.js 本质上的异步操作还是由线程池完成的,它将所有的阻塞操作都交给了内部的线程池去实现,本身只负责不断的往返调度,从而实现异步非阻塞 I/O,这便是 Node 单线程和事件驱动的精髓之处了。

Event Loop的执行顺序

Node.js 每次事件循环都包含了 6 个阶段,对应到 Libuv 源码中的实现,如下图所示:

Event Loop的执行顺序
  • timers 阶段:执行 setTimeout() 和 setInterval() 中到期的回调。
  • I/O callbacks 阶段:上一轮循环中有少数的 I/O 回调会被延迟到这一轮的这一阶段执行
  • idle, prepare 阶段:队列的移动,仅内部使用
  • poll 阶段:最为重要的阶段,执行 I/O 回调,在适当的条件下会阻塞在这个阶段
  • check 阶段:执行 setImmediate() 的回调
  • close callbacks 阶段:执行 socketclose 事件回调

**核心函数 uv_run 源码 **

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;  
  int r;  
  int ran_pending; 
  r = uv__loop_alive(loop); 
  //检查loop中是否有异步任务,如果没有直接就结束 
  if (!r)
    uv__update_time(loop);
  //事件循环其实就是一个大while
  while (r != 0 && loop->stop_flag == 0) { 
    //更新事件阶段
    uv__update_time(loop); 
    //处理timer回调
    uv__run_timers(loop); 
    //处理异步任务回调 
    ran_pending = uv__run_pending(loop);
    //node内部处理阶段
    uv__run_idle(loop);
    uv__run_prepare(loop);    
    // 这里先记住 timeout 是一个时间
    // uv_backend_timeout计算完毕后,传递给 uv__io_poll
    // 如果timeout = 0,则 uv__io_poll 会直接跳过
    timeout = 0;    
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);
    uv__io_poll(loop, timeout);    
    //就是跑 setImmediate
    uv__run_check(loop);    
    //关闭文件描述符等操作
    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;
}

Event loop 就是从事件队列里面不停循环的读取事件,驱动了所有的异步回调函数的执行,Event Loop 总共 6 个阶段,每个阶段都有一个任务队列,当所有阶段被顺序执行一次后,Event Loop 完成了一个 tick。

推荐阅读更多精彩内容