精华 Node.js挖掘之六:两条腿走路,一个例子浅析libuv架构
发布于 5 年前 作者 LanceHBZhang 11623 次浏览 最后一次编辑是 4 年前 来自 分享

本文所做的研究基于Node.js v0.12.4 Linux版本。 Node.js挖掘进行到第6篇。

因为Node.js挖掘系列,在网络上认识了不少朋友。大家的鼓励,促使我有了动力和激情继续挖掘Node。额,不是基情。 社交,有了互联网的催化,发生了一系列不可思议的事情。六度人脉关系理论说地球上所有的人都可以通过六层以内的熟人链和任何其他人联系起来,有了互联网,其实一层就够了。

异步编程的难点源自于请求和响应不是顺序发生的。而异步编程的魅力却也在于请求和响应不是顺序发生。 以Web服务器为例,异步编程赋予了Node的高并发的品质,而且它能以很小的资源消耗为代价,不断地快速接受请求和处理请求。 但是快速处理请求不表示能快速地返回请求数据。也就是说,Node在处理后面一个请求时,其实第一个请求的处理结果也许还没有拿到,也没有反馈给数据请求端。这是另外一个真相:高并发不等于快速反馈。

1.jpg

图1:Node.js架构图

对于Node,如果说JavaScript的函数式编程方式使得其异步编程的思想对程序员展现得更自然,那么它背后的功臣libuv,则为异步编程的实现提供了可能。 图1是Node.js的架构图,黄色部分为libuv模块。从中可以看到libuv在整体体系结构中所处的位置。libuv为builtin modules提供了若干API,用来支撑其请求和数据的返回的异步处理方式。 在Node中,libuv是以源代码的形式被包含在目录node-v0.12.4/deps/uv里。在编译时,生成静态可链接文件node-v0.12.4/out/Release/ libuv.a,并最终包含在可执行文件node中。libuv最初主要的目的是为node而开发的,但目前为止,做为一个独立的库,也被其它产品使用,如Luvit, Julia,pyuv等等。

这一篇我们讨论这个node背后的功臣: libuv,讨论libuv是如何实现帮助JavaScript完成请求以及异步响应的。 我们的讨论从两个角度展开。一个角度是libuv官方提供的架构图,而另一个角度是以一个示例,从细节的角度看libuv是如何对不同的I/O请求,按照不同的方式来完成异步请求和数据返回的。

1. libuv架构

图2是从libuv官网上下载的架构图。这应该是最能体现libuv设计理念的架构图了。

1.jpg.png

图2:libuv架构

图2从左往右分为两部分,一部分是与Network I/O相关的请求,而另外一部分是由 File I/O, DNS Ops以及User code组成的请求。

从图中可以看出,对于Network I/O和以File I/O为代表的另一类请求,异步处理的底层支撑机制是完全不一样的。 对于Network I/O相关的请求, 根据OS平台不同,分别使用Linux上的epoll,OSX和BSD类OS上的kqueue,SunOS上的event ports以及Windows上的IOCP机制。 而对于File I/O为代表的请求,则使用thread pool。利用thread pool的方式实现异步请求处理,在各类OS上都能获得很好的支持。

http://blog.libtorrent.org/2012/10/asynchronous-disk-io/ 这个链接详细描述了对于disk I/O,libuv团队为什么要选择 thread pool的机制。基本上原因不外乎编码和维护复杂度太高、可支持的API太少且质量堪忧、技术支持较弱,而用thread pool则很好地避开了这些问题。这是一篇很有意思的文章,从中可以看出libuv团队当时为实现跨平台异步兼容,所做的各种研究和辛苦尝试。

这也就是所谓的“两条腿走路”,在下一节,我会详细举例介绍这两条腿。

2. 以一个常用的例子,从另一个角度浅析libuv架构

diagrams.jpg

图3:函数调用流程总体图

图3是一个带有示例程序的,主要目的是用来展现libuv 细节的流程示意图。 图中的示例代码非常简单,包括2个部分:

  1. server.listen()是我们在利用node建立一个TCP 服务器时,通常放在最后一步需要执行的代码。主要作用是指定服务器工作的端口以及callback函数等。用过express或者restify的同学应该会比较熟悉这行代码。
  2. fs.open()则是用来以异步的方式打开一个文件。

选取这两个做示例的原因很简单,因为第1节介绍到,libuv对于Network I/O和File I/O分成两个不同的实现机制,那么我们这里也当然需要从两个不同的函数调用示例入手,来挖掘一下libuv是如何分两条腿走路的。

图中的右半部分分成两个主要的部分:

  1. 主线程:主线程也就是node起来的时候执行代码的线程。node起来的时候,在做完一系列初始化动作,启动V8执行src\node.js等工作后,会进入到一个循环。 循环做的工作比较简单,不断地判断default loop是否有需要处理的事情。default loop处理的目标是handle。handle是代表相对长生命周期的对象,比如一个TCP 连接。
  2. 线程池:线程池的数量可以通过环境变量UV_THREADPOOL_SIZE配置,但最大数量不超过128个,默认是4个。 线程池里面的线程处理的目标是request。request代表相对短生命周期的对象,比如一个文件的I/O请求。request可以基于handle,比如基于TCP连接的数据发送和接受请求,也可以独立于handle被处理。

2.1 Network I/O

我在Node.js挖掘之四的第1节详细介绍过从V8执行 server.listen()开始,到调用node的builtin module Tcp_wrap的过程。这个过程有几个参与者:我们的应用代码,Native modules以及builtin module。挖掘之四没有把整个过程讲完,在这个过程中还少了一位参与者: libuv。

对于建立TCP连接的过程,libuv的直接参与从 Tcp_wrap.cc中的函数TCPWrap::Listen()调用 uv_listen()开始到执行uv__io_start()结束。这中间包括一些其它相关的操作,例如调用socket listen(),设置callback 函数等等。但事实就是这样,确实很简短。

这看起来很简短的过程,其实很类似Linux kernel中断处理机制里面的上半部和下半部机制。上半部负责快速响应硬件中断,而下半部则用来处理耗时较长的数据操作。 uv__io_start()负责将handle插入到待处理的water queue里面,这样的好处是请求能立即得到处理。这样,排在类似server.listen()后面的应用代码能继续得到执行。而与中断处理机制里面的下半部相似的数据处理操作,则交由主线程去完成。

1.jpg.png

图4:主线程循环处理default loop代码

图4描述的是在主线程里面,循环处理default loop的代码。代码逻辑很简单,判断loop里面是否有需要处理的handle,如果有遍历default loop。 而图5描述的是遍历一次loop所需完成的主要工作。与我们的主题相关的部分,我用红框高亮出来了。可以看到,对于I/O epoll仅仅是default loop其中的一步而已,它还有很多其它工作要做,例如在2.2节将要介绍的每个请求结束的处理函数也在这里完成。

I/O epoll主要由uv__io_poll()完成。该函数根据不同的底层实现机制,分为4个同名,但不同的实现。关于如何利用epoll来实现事件的查询,以及如何调用相关的callback函数,已经超出了本篇主题。后续挖掘文章会详细介绍。

1.jpg

图5: Network I/O loop执行细节

2.2 File I/O

看完Network 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类继承关系。

1.jpg.png

图6:FSReqWrap类继承图和关键数据结构

除了FSReqWrap,还有其它Wrap,例如PipeConnectWrap,TCPConnectWrap等等。每个Wrap均为一种请求类型服务。 但是这些wrap,都是node自身的行为,而与libuv相关的是什么呢?图6中表示出了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请求时所涉及的各种操作:

  1. 建立thread pool(只建立一次)
  2. 在每个请求req__上绑定与其相关的event loop, work queue, work(), done()
  3. thread worker()用来处理work queue里面的每个请求,并执行work()
  4. 通过uv_async_send()通知event loop执行done()

libuv所做的工作当然不止于此,很多的细节限于篇幅和主题,没有讨论到。后续的挖掘文章会以相应的主题详细介绍细节。

转载本文请注明作者和出处,请勿用于任何商业用途。如需帮助,请QQ联系作者:229848501

16 回复

虽然没理解懂,但是还是要顶起来

@1340641314 我就喜欢像你这种看贴会回复的同学 :)

@LanceHBZhang 请多多指教以后,刚学nodejs,

libuv在整个node.js架构中位置靠后,属于成功男人的“背后的女人”。 我在研究libuv代码时,发现libuv team真是不容易。 其实,看懂别人的代码和自己能做出来完全是两码事。 工程师的成长可以分为几个阶段:工,匠,大师 。 老实说,我们的水平真是在“工”的阶段,距离“匠”还差得很远,更不用说“大师”

之前对这种重量级别的源码有畏惧感,最近认真地反复看了之前你写的几期的文章之后,也把nodejs源码下载下来捣鼓起来了,收获非常大。期待后续更精彩的文章,最好能单独讲解一下libuv,然后再结合nodejs说明。再次表示感谢你的精彩分享。

@showen 看到文章能帮助到同行,非常开心。关于libuv挖掘的后续安排,我还在思考。主要是Node要挖的东西挺多,不能把笔墨全放在libuv上。

不懂,awesome!

感谢分享. 也准备读读源码

统一回复表示看不懂的同学: 我写这一系列挖掘文章,初衷是希望能提供一个像地图一样的工具,帮助不希望停留在仅仅会用Node的阶段的同学。 当年鼓起勇气研究Linux kernel源码的时候,因为方法不当,如同不会游泳的人掉进了大海,完全迷失,差点因为溺水而放弃,说到底,没有找对方法。 当你们想要多跨一步,想要一窥源码究竟的时候,这样一份地图类工具能帮你们梳理各个模块的关系,让你们不至于在源码的世界里迷失方向。 还是那句话,先走大道,再看小路。

这么好的文章。我一口气看了(1-6),楼主好人呀,虽然一下子吸收不了好多,,至少看见了森林,以后慢慢去找树,支持楼主。持续关注,希望以后看到更多的精彩文章(贪心一下。。哈哈。)

请问下楼主在network IO中,请求是如何实现出队列和实现请求的?是否有像thread pool 一样的独立于主线程的thread 来进行处理?我想应该是有的,还请楼主赐教

回到顶部