根据我之前的文章我们可以知道 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操作,比如select
和poll
select
是怎么做的呢,很简单,批量监听每一个fd,每当一个fd进入就绪状态的时候,就可以解除阻塞,继续执行下去对应的后续操作。
但它的弊端是fd_set
最大数字是1024,超过这数字还是得多线程解决。另外,内核对其的实现是轮询,性能比较浪费。
poll
的改进点是去掉了最大1024的限制,改用数组实现,但其实本质还是轮询。
接下来到了现在的主角epoll
epoll
有点像弄了个中间层,它自己有个epoll_fd
,然后再把实际的 fd 绑定到它们,然后开始监听,这个时候监听的实际上是epoll_fd
。epoll_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