文章原文:https://yq.aliyun.com/article...本文相比原文前言BlockingI/O,event有一些修改驱动的特征。非阻塞I/O的简单解释就是:代码以单线程的方式执行,Node在遇到I/O操作时会开启一个新的线程来执行I/O操作。主线程代码继续执行。事件驱动简单的解释就是:事件生成器发布一个事件,事件订阅者接收到事件后执行一定的代码。但是非阻塞I/O和事件驱动是如何实现的呢?它们和Node.js的单线程有什么关系?Node.js结构Node标准库:该层是Node.js提供的标准库,包含了各种API接口,用JavaScript编写,在源代码lib目录下的Nodebindings:该层为上层提供调用底层的C/C++,数据交换等,在node.cc中实现了C/C++的底层:V8:著名的GoogleJavaScriptVM,这也是Node.js使用的js的原因,它为js提供运行环境。libuv:为Node.js提供跨平台、线程池、事件池、异步I/O等能力。C-ares:提供异步处理DNS相关的能力。http_parser,OpenSSL,zlib等:提供http解析,SSL,数据压缩等其他能力。LibuvLibuv(docs,GitHub)是Node.js的关键组件,它为上层js提供了统一的API调用,以及与平台兼容的区别,隐藏了底层实现(它来自libev,然而libev只能运行在类Unix系统上。为了让Node.js能够在Windows/Unix-like系统上运行,创建了libuv)NetworkI/O:NetworkI/OTCP:TransmissionControlProtocolUDP:UserDatagramProtocolUserDatagramProtocolTTY:大概是控制台的意思terminalPipe:进程间通信管道FileI/O:文件读写DNSOps:DNS解析Usercode:提供线程运行用户代码,获取循环通知uv_io_t:未找到相关信息epoll:Linux下多路复用使用增强版IO接口的select/pollkqueue:UNIX上高效的IO多路复用技术eventports:提供事件端口IOCP:一种高效处理很多很多clients进行数据交换的模型ThreadPool:可以看到线程池,这是一个开发者-友好的工具集,包括定时器、非阻塞网络I/O、异步文件系统访问、子进程等功能。它封装了Libev、Libeio和IOCP,保证了跨平台的通用性。整个过程的一个例子下面以文件操作为例来说明Node.js的整个执行过程constfs=require("fs")fs.open("./test.txt","w",(err,数据)=>{//TODO});整个代码的调用过程大致可以描述为:lib/fs.js->src/node_file.cc->uv_fs具体来说,当我们调用fs.open时,Node.js在C/C++层面调用了Open函数通过process.binding,然后通过它调用Libuv中的具体方法uv_fs_open,最后的执行结果通过callback传回,完成流程。图中可以看到平台判断的过程。需要注意的是,这一步是在编译时确定的,而不是在运行时确定的。一般情况下,我们在Javascript中调用的方法,最终都会通过process.binding传递到C/C++层面,最后由它们来执行真正的操作。这就是Node.js与操作系统交互的方式。通过这个过程,我们可以发现,其实Node.js虽然说是使用Javascript,但在开发的时候只是使用Javascript语法来编写程序。在实际的执行过程中,Javascript是由V8解释,然后C/C++执行真正的系统调用,所以不用太担心Javascript的执行效率。由此可见,Node.js不是一种语言,而是一个平台。异步、非阻塞I/O根据上面的铺垫,我们可以知道,真正执行系统操作的是Libuv层,而Libuv本身是异步和事件驱动的,所以,当我们调用一个I/O操作时,libuv启动一个线程来执行这个I/O操作,执行完成后传回给JavaScript进行后续操作。这里的I/O包括文件I/O和网络I/O,两者的实现是不一样的。文件I/O、DNS等操作由线程池(ThreadPool)实现,而网络I/O(包括TCP、UDP、TTY等)则由epoll、IOCP、kqueue来实现。一个异步I/O流程大致如下:发起I/O调用开发者通过JavaScript调用Node内置模块,将参数和回调函数传递给内置模块。Node内置模块会将传入的参数和回调函数封装成一个request对象。将这个请求对象推入I/O线程池中执行。JavaScript发起的异步调用结束,主线程执行后续代码。回调I/O操作完成后,会将结果保存在request对象的result属性中,并发送操作完成的通知。每次事件循环检查是否有完成的I/O操作,如果有则将request对象添加到I/O观察者队列中,然后在做事件处理的时候。在处理I/O观察者事件时,会取出request对象中封装的回调函数,执行回调函数,将结果作为参数完成Javascript回调的目的。从这里可以看出,我们一直对Node.js的单线程存在误解。其实它的单线程是指自身Javascript运行环境的单线程。Node.js没有在执行Javascript时创建新线程的能力。最终的实际操作是通过Libuv及其事件循环进行的。这就是为什么Javascript这种单线程语言在Node.js中可以实现异步操作,而且两者并不冲突。事件驱动当我们写了很多事件处理函数时,Libuv是如何执行这些回调的呢?这里指的是我们之前提到的uv_run,先看它的执行流程图:在uv_run函数中,会维护一系列的监视器(观察者队列):structuv_stream_suv_stream_t;typedefstructuv_tcp_suv_tcp_t;typedefstructuv_udp_suv_udp_t;typedefstructuv_pipe_suv_pipe_t;typedefstructuv_tty_suv_tty_t;typedefstructuv_poll_suv_poll_t;typedefstructuv_timer_suv_timer_t;typedefstructuv_prepare_suv_prepare_t;typedefstructuv_check_suv_check_t;typedefstructuv_idle_suv_idle_t;typedefstructuv_async_suv_async_t;typedefstructuv_process_suv_process_t;typedefstructuv_fs_event_suv_fs_event_t;typedefstructuv_fs_poll_suv_fs_poll_t;typedefstructuv_signal_suv_signal_t;这些监视器都有对应着一种异步操作,它们通过uv_TYPE_start,来注册事件监听以及correspondingcallback.uv_run在执行过程中,会不断的检查这些队列中是否有pendingevents,有则触发,这里只会执行一个callback,避免调用多个callback时发生竞争,因为Javascript是单线程的,无法处理这种情况。在上图中,事件驱动的I/O操作表达的很清楚。除了我们经常提到的I/O操作,图中还表达了一种情况,定时器(timer)。它与其他两个不同的是,它不单独创建新的线程,而是直接在事件循环中完成。事件循环除了维护那些观察者队列外,还维护了一个time字段,在初始化的时候会赋值为0,这个值会在每个周期更新。所有与时间相关的操作都会与这个值进行比较,以决定是否执行。图中定时器相关的流程如下:更新当前循环的时间字段,即当前循环下的“now”;检查循环中是否还有需要处理的任务(handlers/requests),如果没有,则不需要循环,即是否存活。检查注册的定时器,如果某个定时器中指定的时间晚于当前时间,则说明该定时器超时,然后执行其对应的回调函数。执行一次`I/O轮询(即阻塞线程等待I/O事件发生),如果下一个定时器超时时还没有完成I/O,则停止等待并执行下一个定时器的回调。如果发生I/O事件,则执行相应的回调;由于在执行回调的过程中可能有另一个定时器超时,所以再次检查定时器并在此处执行回调。
