从源码解读 Node 服务原理

675 阅读10分钟

很多同学或多或少都使用过 Node 创建 HTTP Server 处理 HTTP 请求,可能是简易的博客,或者已经是负载千万级请求的大型服务。但是我们可能并没有深入了解过 Node 创建 HTTP Server 的过程,希望借这篇文章,让大家对 Node 更加了解。

先上流程图,帮助大家更容易的理解源码

初探

我们先看一个简单的创建 HTTP Server 的例子,基本的过程可以分为两步

  • 使用 createServer 获取 server 对象
  • 调用 server.listen 开启监听服务
const http = require('http')

// 创建 server 对象
const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('响应内容');
});

// 开始监听 3000 端口的请求
server.listen(3000)

这个过程是非常简单,下面我们会根据这个过程,结合源码,开始分析 Node 创建 HTTP Server 的具体内部过程。

在此之前,为了更好的理解代码,我们需要了解一些基本的概念:

fd - 文件描述符

文件描述符(File descriptor)是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的,该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。

handle - 句柄

句柄(handle)是 Windows 操作系统用来标识被应用程序所建立或使用的对象的整数。其本质相当于带有引用计数的智能指针。当一个应用程序要引用其他系统(如数据库、操作系统)所管理的内存块或对象时,可以使用句柄。Unix 系统的文件描述符基本上也属于句柄。

本文中的 handle 可以理解为相关对象的引用。

文中用 ... 符合表示略去了部分和本文讨论内容关联性较低的,不影响主要逻辑的代码,如参数处理、属性赋值等。

http.createServer

createServer 是个工厂方法,返回了 _http_server 模块中的 Server 类的实例,而 Server 是从 _http_server 文件导出的

const {
  Server,
} = require('_http_server');

// http.createServer
function createServer(opts, requestListener) {
  return new Server(opts, requestListener);
}

_http_server

_http_server 模块的 Server 类中可以看出,http.Server 是继承于 net.Server

function Server(options, requestListener) {
  // 可以不使用 new 直接调用 http.Server()
  if (!(this instanceof Server)) return new Server(options, requestListener);
  
  // 参数适配
  // ...

	// 继承
  net.Server.call(this, { allowHalfOpen: true });

  if (requestListener) {
    this.on('request', requestListener);
  }

	// ...
  this.on('connection', connectionListener);
	// ...
}

// http.Server 继承自 net.Server
ObjectSetPrototypeOf(Server.prototype, net.Server.prototype);
ObjectSetPrototypeOf(Server, net.Server);

...

这里的继承关系也比较好理解:Node 中的 net.Server 是用于创建 TCP 或 IPC 服务器的模块。我们都知道,HTTP 是应用层协议,而 TCP 是传输层协议。HTTP 通过 TCP 传输数据,并进行再次的解析。Node 中的 HTTP 模块基于 TCP 模块做了再封装,实现了不同的解析处理逻辑,即出现了我们看到的继承关系。

类似的,net.Server 继承了 EventEmitter 类,拥有许多事件触发器,包含一些属性信息,感兴趣的同学可以自行查阅。

至此,我们可以看到,createServer 只是 net.Server 的实例化过程,并没有创建服务监听,而是由 server.listen 方法实现。

server.listen

当创建完成 server 实例后,通常需要调用 server.listen 方法启动服务,开始处理请求,如 Koa 的 app.listenlisten 方法支持多种使用方式,下面我们一一分析

1. server.listen(handle[, backlog][, callback])

第一种是不太常见的用法,Node 允许我们启动一个服务器,监听已经绑定到端口、Unix 域套接字或 Windows 命名管道的,给定的 handle 上的连接。

handle 对象可以是服务器、套接字(任何具有底层 _handle 成员的东西),也可以是具有 fd(文件描述符) 属性的对象,如我们通过 createServer 创建的 Server 对象。

当识别到是 handle 对象之后,就会调用 listenInCluster 方法,从方法的名字,我们可以猜测到这个就是启动服务监听的方法:

// handle 是具有 _handle 属性的对象
if (options instanceof TCP) {
  this._handle = options;
  this[async_id_symbol] = this._handle.getAsyncId();
  listenInCluster(this, null, -1, -1, backlogFromArgs);
  return this;
}

// 当 handle 是具有 fd 属性的对象
if (typeof options.fd === "number" && options.fd >= 0) {
  listenInCluster(this, null, null, null, backlogFromArgs, options.fd);
  return this;
}

2. server.listen([port[, host[, backlog]]][, callback])

第二种是我们常见的监听端口,Node 允许我们创建一个服务器,监听给定的 host 上的端口,host 可以是 IP 地址,或者域名链接,当 host 是域名链接时,Node 会先使用 dns.lookup 获取 IP 地址。最后,检验完端口合法后,同样是调用了 listenInCluster方法,源码🔗

3. [server.listen(path[, backlog][, callback])](http://nodejs.cn/s/yW8Zc1)

第三种,Node 允许启动一个 IPC 服务器监听指定的 IPC 路径,即 Windows 上的命名管道 IPC以及 其他类 Unix 系统中的 Unix Domain Socket。

这里的 path 参数是识别 IPC 连接的路径。 在 Unix 系统上,参数 path 表现为文件系统路径名,在 Windows 上,path 必须是以 \\?\pipe\\\.\pipe\ 为入口。

然后,同样是调用了 listenInCluster 方法,源码🔗

还有一种调用方法 server.listen(options[, callback]) 是端口和 IPC 路径的另外一种调用方式,这里就不多说了。

最后就是对不符合上述所有条件的异常情况,抛出错误。

小结

至此,我们可以看到,server.listen 方法对不同的调用方式做了解析,并调用了 listenInCluster 方法。

listenInCluster

首先,我们要对 clsuter 做一个简单的介绍。

我们都知道 JavaScript 是单线程运行的,一个线程只会在一个 CPU 核心上运行。而现代的处理都是多核心的,为了充分利用多核,就需要启用多个 Node.js 进程去处理负载任务。

Node 提供的 cluster 模块解决了这个问题 ,我们可以使用 cluster 创建多个进程,并且同时监听同一个端口,而不会发生冲突,是不是很神奇?不要着急,下面我们就会解密这个神奇的 cluster 模块。

先看一个 cluster 的简单用法:

const cluster = require('cluster');
const http = require('http');

if (cluster.isMaster) {
  // 衍生工作进程。
  for (let i = 0; i < 4; i++) {
    cluster.fork();
  }
} else {
  // 工作进程可以共享任何 TCP 连接。
  // 在本例子中,共享的是 HTTP 服务器。
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('你好世界\n');
  }).listen(8000);
}

基于 cluster 的用法,负责启动其他进程的叫做 master 进程,不做具体的工作,只负责启动其他进程。其他被启动的进程则叫 worker 进程,它们接收请求,并对外提供服务。

listenInCluster 方法主要做了一件事:区分 master 进程(cluster.isMaster)和 worker 进程,采用不同的处理策略:

  • master 进程:直接调用 server._listen 启动监听
  • worker进程:使用 clsuter._getServer 处理传入的 server 对象,修改 server._handle 再调用了 server._listen 启动监听
function listenInCluster(...) {
  // 引入 cluster 模块
  if (cluster === undefined) cluster = require('cluster');

  // master 进程
  if (cluster.isMaster || exclusive) {
    server._listen2(address, port, addressType, backlog, fd, flags);
    return;
  }

  // 非 master 进程,即通过 cluster 启动的子进程
  const serverQuery = {
    address: address,
    port: port,
    addressType: addressType,
    fd: fd,
    flags,
  };
  
  // 调用 cluster 的方法处理
  cluster._getServer(server, serverQuery, listenOnMasterHandle);

  function listenOnMasterHandle(err, handle) {
    // ...
    server._handle = handle;
    server._listen2(address, port, addressType, backlog, fd, flags);
  }
}

master 进程

我们先看 master 进程的处理方法 server._listen2server._listen2setupListenHandle 的别名。

setupListenHandle 主要是负责根据 server 监听连接的不同类型,调用 createServerHandle 方法获取 handle 对象,并调用 handle.listen 方法开启监听。

function setupListenHandle(address, port, addressType, backlog, fd, flags) {
  // 如果是 handle 对象,需要创一个 handle 对象
  if (this._handle) {
    // do nothing
  } else {
    let rval = null;
    // 在 host 和 port 省略,且没有指定 fd 的情况下
    // 如果 IPv6 可用,服务器将会接收基于未指定的 IPv6 地址 (::) 的连接
    // 否则接收基于未指定的 IPv4 地址 (0.0.0.0) 的连接。
    if (!address && typeof fd !== 'number') {
      rval = createServerHandle(DEFAULT_IPV6_ADDR, port, 6, fd, flags);

      if (typeof rval === 'number') {
        rval = null;
        address = DEFAULT_IPV4_ADDR;
        addressType = 4;
      } else {
        address = DEFAULT_IPV6_ADDR;
        addressType = 6;
      }
    }
    
    // fd 或 IPC
    if (rval === null)
      rval = createServerHandle(address, port, addressType, fd, flags);

    // 如果 createServerHandle 返回的是数字,则表明出现了错误,进程退出
    if (typeof rval === 'number') {
      const error = uvExceptionWithHostPort(rval, 'listen', address, port);
      process.nextTick(emitErrorNT, this, error);
      return;
    }

    this._handle = rval;
  }
   
  ...

  // 开始监听
  const err = this._handle.listen(backlog || 511);

  ...
  // 触发 listening 方法
}

createServerHandle 负责调用 C++tcp_warp.ccpipe_wrap 模块创建 PIPETCP 服务。PIPETCP 对象都拥有 listen 方法,listen 方法是对 uvlib 中的 [uv_listen](http://docs.libuv.org/en/v1.x/stream.html?highlight=uv_listen#c.uv_listen) 方法的封装,与 Linux 中的 [listen(2)](https://man7.org/linux/man-pages/man2/listen.2.html) 类似。可以调用系统能力,开始监听传入的连接,并在收到新连接后回调请求信息。

PIPE 是对 Unix 上的流文件(包括 socket,pipes)以及 Windows 上的命名管道的抽象封装,TCP 就是对 TCP 服务的封装。

function createServerHandle(address, port, addressType, fd, flags) {
  // ...
  let isTCP = false;
  // 当 fd 选项存在时
  if (typeof fd === 'number' && fd >= 0) {
    try {
      handle = createHandle(fd, true);
    } catch (e) {
      debug('listen invalid fd=%d:', fd, e.message);
      // uvlib 中的错误码,表示非法的参数,是个负数
      return UV_EINVAL;
    }
    ...
  } else if (port === -1 && addressType === -1) {
    // 当 port 和 address 不存在时,即监听 Socket 或 IPC 等
    // 创建 Pipe Server
    handle = new Pipe(PipeConstants.SERVER);
    ...
  } else {
    // 创建 TCB SERVER
    handle = new TCP(TCPConstants.SERVER);
    isTCP = true;
  }
  // ...
  return handle;
}

小结

master 进程的 server.listen 处理逻辑较为简单,可以概括为直接调用 libuv ,使用系统能力,开启监听服务。

worker 进程

如果当前进程不是 master 进程,事情就会变得复杂许多。

listenInCluster 方法会调用 cluster 模块导出的 _getServer 方法,cluster 模块会通过当前进程是否包含 NODE_UNIQUE_ID 判断当前进程是否子进程,分别使用 childmaster 文件的导出变量,相应的处理方法也会有所不同

const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master';

module.exports = require(`internal/cluster/${childOrMaster}`);

我们所说的 worker 进程,没有 NODE_UNIQUE_ID 环境变量,会使用 child 模块导出的 _getServer 方法。

worker 进程的 _getServer 方法主要做了以下两件事情:

  • 通过发送 internalMessage,即进程间通信的方式,向 master 进程传递消息,调用 queryServe,注册当前 worker 进程的信息。若 master 进程是第一次接收到监听此端口/fdworker,则起一个内部 TCP 服务器,来承担监听该端口/fd 的职责,随后在 master 中记录下该 worker
  • 如果是轮训监听(RoundRobinHandle),就修改掉 worker 进程中的 net.Server 实例的 listen 方法里监听端口/fd的部分,使其不再承担监听职责。
// obj 是 net.Server 或 Socket 的实例
cluster._getServer = function(obj, options, cb) {
  let address = options.address;
  // ...
  // const indexesKey = ...;
  // indexes 为 Map 对象
  indexes.set(indexesKey, index);

  const message = {
    act: 'queryServer',
    index,
    data: null,
    ...options
  };

  message.address = address;

  // 发送 internalMessage 通知 Master 进程
  // 接受 Master 进程的回调
  send(message, (reply, handle) => {
    if (typeof obj._setServerData === 'function')
      obj._setServerData(reply.data);

    if (handle)
      // 关闭连接时,移除 handle 避免内存泄漏
      shared(reply, handle, indexesKey, cb);  // Shared listen socket.
    else
      // 伪造了 listen 等方法
      rr(reply, indexesKey, cb);              // Round-robin.
  });

  // ...
};

master 中的 queryServer 接收到到消息后,会根据不同的条件(平台、协议等)分别创建 RoundRobinHandleSharedHandle ,即 cluster 两种分发处理连接的方法。

同时 master 进程会将监听端口、地址等信息组成的 key 作为唯一标志,记录 handle 和对应 worker 的信息。

function queryServer(worker, message) {
  // ...
  const key = `${message.address}:${message.port}:${message.addressType}:` +
              `${message.fd}:${message.index}`;
  let handle = handles.get(key);

  if (handle === undefined) {
    let address = message.address;
    ...
    let constructor = RoundRobinHandle;
    if (schedulingPolicy !== SCHED_RR ||
        message.addressType === 'udp4' ||
        message.addressType === 'udp6') {
      constructor = SharedHandle;
    }

    handle = new constructor(key, address, message);
    handles.set(key, handle);
  }

  // ...
  handle.add(worker, (errno, reply, handle) => {
    const { data } = handles.get(key);
    // ...
    send(worker, {
      errno,
      key,
      ack: message.seq,
      data,
      ...reply
    }, handle);
  });
}

RoundRobinHandle

RoundRobinHandle(也是除 Windows 外所有平台的默认方法)的处理模式为:由 master 进程负责监听端口,接收新连接后再将连接循环分发给 worker 进程,即将请求放到一个队列中,从空闲的 worker 池中分出一个处理请求,处理完成后在放回 worker 池中,以此类推

function RoundRobinHandle(key, address, { port, fd, flags }) {
  this.key = key;
  this.all = new Map();
  this.free = new Map();
  this.handles = [];
  this.handle = null;
  // 创建 Server
  this.server = net.createServer(assert.fail);

  // 开启监听,多种情况,省略
  // this.server.listen(...)

  this.server.once('listening', () => {
    this.handle = this.server._handle;
    // 收到请求,分发处理
    this.handle.onconnection = (err, handle) => this.distribute(err, handle);
    this.server._handle = null;
    this.server = null;
  });
}

// ...

RoundRobinHandle.prototype.distribute = function(err, handle) {
  this.handles.push(handle);
  const [ workerEntry ] = this.free;

  if (ArrayIsArray(workerEntry)) {
    const [ workerId, worker ] = workerEntry;
    this.free.delete(workerId);
    this.handoff(worker);
  }
};

RoundRobinHandle.prototype.handoff = function(worker) {
  if (!this.all.has(worker.id)) {
    return;  // Worker is closing (or has closed) the server.
  }

  const handle = this.handles.shift();

  if (handle === undefined) {
    this.free.set(worker.id, worker);  // Add to ready queue again.
    return;
  }

  const message = { act: 'newconn', key: this.key };

  sendHelper(worker.process, message, handle, (reply) => {
    if (reply.accepted)
      handle.close();
    else
      this.distribute(0, handle);

    this.handoff(worker);
  });
};

SharedHandle

SharedHandle 的处理模式为:master 进程创建监听服务器 ,再将服务器的 handle 发送 worker 进程,由 worker 进程负责直接接收连接

function SharedHandle(key, address, { port, addressType, fd, flags }) {
  this.key = key;
  this.workers = new Map();
  this.handle = null;
  this.errno = 0;

  let rval;
  if (addressType === 'udp4' || addressType === 'udp6')
    rval = dgram._createSocketHandle(address, port, addressType, fd, flags);
  else
    rval = net._createServerHandle(address, port, addressType, fd, flags);

  if (typeof rval === 'number')
    this.errno = rval;
  else
    this.handle = rval;
}

// 添加存储 worker 信息
SharedHandle.prototype.add = function(worker, send) {
  assert(!this.workers.has(worker.id));
  this.workers.set(worker.id, worker);
  // 向 worker 进程发送 handle
  send(this.errno, null, this.handle);
};
// ..

PS:Windows 之所以不采用 RoundRobinHandle 的原因是因为性能原因。从理论上来说,第二种方法应该是效率最佳的。 但在实际情况下,由于操作系统调度机制的难以捉摸,会使分发变得不稳定,可能会出现八个进程中有两个分担了 70% 的负载。相比而言,轮训的方法会更加高效。

小结

worker 进程中,每个 worker 不再独立开启监听服务,而是由 master 进程开启一个统一的监听服务,接受请求连接,再将请求转发给 worker 进程处理。

总结

在不同的情况下,Node 创建 HTTP Server 的流程是不一致的。当进程为 master 进程时,Node 会直接通过 libuv 调用系统能力开启监听。当进程为 child 进程(worker 进程)时,Node 会使用 master 进程开启间监听,并通过轮训或共享 Handle 的方式将连接分发给 child 进程处理。

最后,写文章不容易,如果大家喜欢的话,欢迎一键三联~