Nodejs的运行原理-libuv篇
前言
这应该是Nodejs的运行原理的第7篇分享,这篇过后,短时间内不会再分享Nodejs的运行原理,会停更一段时间,PS:不是不更,而是会开挖新的坑,最近有在研究RPG Maker MV,区块链,云计算,可能会更新一些相关文章,或者相关教学。
回到正题,异步编程的难点在于请求与响应不是按顺序发生的。以http server 为例,异步编程赋予了server 高并发的品质,而且他可以以很小的资源代价,不断地接受和处理请求。但是快速处理请求不表示快速地返回请求=>高并发不等同于快速反馈。
在Nodejs中,libuv则为异步编程的实现提供了可能。libuv为builtin modules 提供了API,这些API用来支撑请求和数据的返回的异步处理方式。
这一篇分享,我们主要讨论libuv的运行原理,从两个角度出发:
1) libuv的架构
2)案例,从细节的角度看libuv是如何对待不同I/O请求,按照不同的方式来完成异步请求和数据返回的。
Libuv的架构
从左往右可分为两部分,Network I/O的相关请求,另一部分File I/O,DNS Ops和User Code组成。
上图展示了libuv细节的流程,图中代码很简单,包括2个部分:
1. server.listen()是用来创建TCP server时,通常放在最后一步执行的代码。主要指定服务器工作的端口以及回调函数。
2. fs.open()是用异步的方式打开一个文件。
选择两个示例很简单,因为libuv架构图可视:libuv对 Network I/O和 File I/O采用不同的机制。
上图右半部分,主要分成两个部分:
1. 主线程:主线程也是node启动时执行的现成。node启动时,会完成一系列的初始化动作,启动V8 engine,进入下一个循环。
2. 线程池:线程池的数量可以通过环境变量UV_THREADPOOL_SIZE配置,最大不超过128个,默认为4个。
Network I/O
V8 engine执行从server.listen() 开始,调用builtin module Tcp_wrap 的过程。
在创建TCP链接的过程中,libuv直接参与Tcp_wrap.cc函数中的 TCPWrap::listen() 调用uv_listen()开始到执行uv_io_start()结束。看起来很短暂的过程,其实是类似linux kernel的中断处理机制。
uv_io_start()负载将handle插入到处理的water queue中。这样的好处是请求能够立即得到处理。中断处理机制里面的下半部分与数据处理操作相似,交由主线程去完成处理。
代码逻辑很简单,查看loop中是否包含handle,如果有遍历default loop。
File I/O
这里我们研究一下 File I/O。
同Network I/O一样,我们的应用所依赖的fs模块,后面有一个builtin module Node_file.cc作为支撑。 Node_file.cc包含了各种我们常用的文件操作的接口,例如open, read, write, chmod,chown等。但同时,它们都支持异步模式。 我们通过Node_file.cc中的Open()函数来研究一下具体的实现细节。
如果你用类似source insight之类的代码阅读工具跟踪一下代码调用顺序,会很容易发现对于异步模式,Open()函数会在一系列辅助操作之后,进入函数uv_fs_open(),并且传入了一个FSReqWrap的对象。
FSReqWrap(),从名字可以看得出来,这是一个wrap,且是与FS相关的请求。也就是说,它基于某一个现成的机制来实现与FS相关的请求操作。这个现成的机制就是ReqWrap。好吧,它也是个wrap。乘你还没疯的时候,看一下图6吧。这里完整展示了FSReqWrap类继承关系。
除了FSReqWrap,还有其它Wrap,例如PipeConnectWrap,TCPConnectWrap等等。每个Wrap均为一种请求类型服务。 但是这些wrap,都是node自身的行为,而与libuv相关的是什么呢?上图中表示出了FSReqWrap关键的数据结构 uv_fs_s req__。
让我们把目光回到uv_fs_open()。在调用这个函数时, req__作为其一个重要的参数被传递进去。而在uv_fs_open()内部,req__则被添加到work queue的末尾中去。图3 thread pool中的thread会去领取这些request进行处理。 每个request很像一个粘贴板,它将event loop, work queue,每个请求的处理函数(work()),以及请求结束处理函数(done())绑定在一起。绑定的操作在uv__work_submit()中完成。 例如对于这里的req__,绑定在它身上的work()为uv__fs_work(), done()为uv__fs_done()。
这里有一个比较有意思的问题值得额外看一下。我们的thread pool是在什么时候建立的呢?
答案是:在第一次异步调用uv__work_submit()时。
每个thead的入口函数是 Threadpool.c中的worker()。工作逻辑比较简单,依次取出work queue中的请求,执行绑定在该请求上的work()函数。 前面我们提到的绑定在请求上的done()函数在哪里执行呢?这也是一个比较有意思的操作。libuv通过uv_async_send()通知event loop去执行相应的callback函数,也即我们绑定在request上的done()函数。uv__work_done()用于完成这样的操作。
uv_async_send()与主线程之间通过PIPE通信。
我在这一小节以一个FSReqWrap以及Open()函数为例,描述了libuv处理这种File I/O请求时所涉及的各种操作:
- 建立thread pool(只建立一次)
- 在每个请求req__上绑定与其相关的event loop, work queue, work(), done()
- thread worker()用来处理work queue里面的每个请求,并执行work()
- 通过uv_async_send()通知event loop执行done()