深入了解 Node.js 与 epoll 的关系

Posted by Leo Eatle on 2020-07-21

根据我之前的文章我们可以知道 Node 主要就是两部分组成,除了各种封装了系统原生接口的 js 代码和 v8 引擎外,就是对于实现事件循环模型最重要的库,Libuv。

Node 以异步 I/O 解决网络并发问题著称,其核心概念就是通过异步事件来暂时“接下”所有的网络请求,通过不断循环处理事件来逐一异步地解决各个网络请求,对于低 CPU、高 I/O 的处理,即使是单线程,也有极高的处理效率。

什么是 I/O 操作

I/O操作包括了

  • TCP / UDP
  • 标准输入输出
  • 文件读写
  • DNS
  • 管道(进程通信

这些都是处理网络请求中比较耗时但事实上跟“主流程”并无关联的部分,在 Linux 中引入了文件描述符来统一这种操作,比如打开一个文件使用 Linux 的 open 函数,就会返回一个数字,这就是文件描述符fd,对应当前进程中唯一的一个文件(即 Linux 常说的,一切皆文件),

在异步 I/O 出现之前,Linux 是如何处理这种I/O操作呢?事实上,每次进行这种I/O操作的时候,进程就会进入阻塞阶段,等到 I/O 完成之后,这个进程才会继续进行,这个时候还会等待cpu分配时间片,才会继续后面的操作。

显然,这种方式无法并行地处理多个网络请求。这就是 I/O 操作阻塞了进程

我们可以通过开线程来专门执行 I/O 操作,但弊端就是开很多线程造成上下文切换的性能浪费。有没有非阻塞的方式呢。

epoll 的诞生

在早期 Linux 解决网络并发问题的时候,其实就已经有针对网络并发提供几个批量、非阻塞的I/O操作接口,做到单线程批量执行I/O操作,比如selectpoll

select是怎么做的呢,很简单,批量监听每一个fd,每当一个fd进入就绪状态的时候,就可以解除阻塞,继续执行下去对应的后续操作。

但它的弊端是fd_set最大数字是1024,超过这数字还是得多线程解决。另外,内核对其的实现是轮询,性能比较浪费。

poll的改进点是去掉了最大1024的限制,改用数组实现,但其实本质还是轮询。

接下来到了现在的主角epoll

epoll有点像弄了个中间层,它自己有个epoll_fd,然后再把实际的 fd 绑定到它们,然后开始监听,这个时候监听的实际上是epoll_fdepoll_fd传入的 fd 会在内核中维护一个红黑树,当 I/O 操作完成时,会以 O(LogN) 的效率定位到 fd 避免轮询。返回给用户态的 fd 都是真实可读、可写的,不再需要用户态自己去轮询查状态了。( epoll 的具体优化可以参考这个链接)

epoll有个缺点,只能支持网络产生的pipe操作,无法监听文件类型的fd

Libuv 如何解决 epoll 的缺陷

epoll 是 Libuv 构建 event-loop 的主体,对于可以用 epoll 监听的 fd 使用原生方式监听。不能用原生方式监听的,会用一个工作线程进行处理,并使用 pipe 与主线程通信,而 epoll 会监听这个 pipe 产生的 fd。

当然,epoll 只是在 Linux 上才实现的一个功能,在 windows 或者其他平台上,Libuv 可能会选择其他方式来完成它的事件循环机制。

参考资料

https://www.jianshu.com/p/548ef6a267ba
https://zhuanlan.zhihu.com/p/74119491
https://juejin.im/post/6844904093459152903