abbshr.github.io
abbshr.github.io copied to clipboard
Node学习笔记2
Chapter 1:事件如何被监听?
看完libuv对watchers和事件循环的描述之后,突然发现我一直以来忽略了一个问题:事件是通过什么方式被监听的?
无论是线程还是进程都和我们生物不同,他们不会自发感知外界环境的改变。所以对于这个问题,第一印象往往是:轮询。也就是用一个 while(true) 循环不断询问外部是否有什么新鲜事。
可问题是,我们从来没见过系统内核进程因为监听一個socket而导致CPU狂转、系统挂掉吧。还有,浏览器中监听JavaScript事件是常事,它也没让系统变卡顿啊。
单从这一点来看,轮询事件的产生并非上策!除了轮询,还有什么方法能做到事件的监听呢?或许我们可以从操作系统的底层——计算机硬件工作流程中找到答案。
操作系统在与外设进行交互是典型的事件监听:CPU与设备控制器之间有一条中断请求线,设备控制器会在外设I/O结束时通过电信号向CPU发送中断请求,CPU在原子指令过后检查中断线的状态位判断I/O是否结束,如果结束的话就跳转到内存特定进程位置(中断向量)调度中断处理程序。
我们先来简单分析一下底层的事件监听模型。所谓事件是由源发出,就是一个电信号(或脉冲信号)。进程虽然做不到监听,但硬件CPU却可以,它能接收到电信号的变化。最后CPU对事件做出反应,也就是调度处理进程。
没错,事件监听还可以靠中断来实现。
Chapter 2:基本I/O方式
阻塞I/O、非阻塞I/O、同步I/O、异步I/O是操作系统的几大I/O模式。
我们往往会认为阻塞I/O与同步I/O等同,非阻塞I/O与异步I/O等同。其实这种观点是不准确的,这里科普一下他们的细微区别。
阻塞I/O,即进程/线程在做I/O操作时,被CPU调度到阻塞队列,等待I/O操作的结束,然后进程再被调度回来,处理I/O结果。在等待期间,进程除了休眠无法做任何事情,不过他不占用CPU时间片,这时CPU可以先调度其他进程,当I/O完成时以事件形式通知CPU。
非阻塞I/O与上述相反。进程不会一直等待到I/O操作结束,当I/O请求发出时,进程会立马从系统调用返回,这时进程可以继续工作,也就是CPU不必将其调度到阻塞队列了。但此时进程很可能还没有得到I/O结果,所以要通过轮询来检验I/O是否操作结束。虽说进程没有被阻塞,不过CPU的时间片被白白占用。
同步I/O,就是进程先等待I/O结果,再继续处理其他任务。所以说,同步I/O由阻塞I/O实现。
而异步I/O与非阻塞I/O的差别就是:前者的I/O调用在不阻塞进程的前提下完整的执行,后者的I/O调用为了不阻塞进程会立刻返回,即便是没有得到I/O最终结果。
Chapter 3:Node中的事件循环机制
上面提到的阻塞和非阻塞是针对_进程_而言的,和_CPU_的阻塞正好相反,这点必须要认清。
libuv在Linux平台上使用了Linux的_epoll_机制。epoll是Linux平台的I/O事件通知工具,主要用来处理大量的文件句柄。
libuv的事件循环特性就是由epoll提供的,先介绍一下epoll。
epoll的函数在头文件sys/epoll.h中。用epoll编写程序会用到两个数据结构:
/* 保存触发事件的某个文件描述符相关的数据 */
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
和
/* 用于注册所感兴趣的事件和回传所发生待处理的事件 */
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
其中结构体epoll_event的events成员是表示感兴趣的事件和被触发的事件,可能的取值为:
EPOLLIN:表示对应的文件描述符可以读; EPOLLOUT:表示对应的文件描述符可以写; EPOLLPRI:表示对应的文件描述符有紧急的数据可读; EPOLLERR:表示对应的文件描述符发生错误; EPOLLHUP:表示对应的文件描述符被挂断; EPOLLET:表示对应的文件描述符有事件发生;
epoll提供的API有如下几个函数:
int epoll_create(int size)
创建一个epoll实例,并返回一个引用该实例的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event)
在给定文件描述符增加、删除、修改事件。
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout)
等待I/O事件,并阻塞调用线程。 最后一个timeout参数表示epoll_wait的超时条件,为0时表示马上返回,为-1时表示函数会一直等下去直到有事件返回,为任意正整数时表示等这么长的时间,如果一直没有事件,则会返回。
对于这几个函数的使用,man手册里给出一个很有代表性的例子:
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;
/* Set up listening socket, 'listen_sock' (socket(),
bind(), listen()) */
epollfd = epoll_create(10);
if (epollfd == -1) {
perror("epoll_create");
exit(EXIT_FAILURE);
}
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}
/* 这里相当于事件循环的开始,epoll先阻塞进程,等待指定事件到来 */
for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_pwait");
exit(EXIT_FAILURE);
}
/* 一旦事件触发,继续事件循环,这里获取事件触发时的数据 */
for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
conn_sock = accept(listen_sock,
(struct sockaddr *) &local, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
do_use_fd(events[n].data.fd);
}
}
}
然后我们回过头看看Node(或者说libuv)内部是如何实现事件循环、事件监听、异步回调的。
libuv负责从操作系统那里收集事件或监视其他资源的事件,而用户可以注册在某个事件发生时要调用的回调函数。
监视器(Watchers)是 libuv 用户用于监视特定事件的工具。他们都是以 uv_TYPE_t
命名的抽象结构体,这个类型表明了监视器的用途。
下面是所有的监视器(也称作事件处理器)列表:
typedef struct uv_loop_s uv_loop_t;
typedef struct uv_err_s uv_err_t;
typedef struct uv_handle_s uv_handle_t;
typedef struct uv_stream_s uv_stream_t;
typedef struct uv_tcp_s uv_tcp_t;
typedef struct uv_udp_s uv_udp_t;
typedef struct uv_pipe_s uv_pipe_t;
typedef struct uv_tty_s uv_tty_t;
typedef struct uv_poll_s uv_poll_t;
typedef struct uv_timer_s uv_timer_t;
typedef struct uv_prepare_s uv_prepare_t;
typedef struct uv_check_s uv_check_t;
typedef struct uv_idle_s uv_idle_t;
typedef struct uv_async_s uv_async_t;
typedef struct uv_process_s uv_process_t;
typedef struct uv_fs_event_s uv_fs_event_t;
typedef struct uv_fs_poll_s uv_fs_poll_t;
typedef struct uv_signal_s uv_signal_t;
监视器是通过调用uv_TYPE_init(uv_TYPE_t*)
函数来创建。
Note:如上所示,有些监视器初始化函数要用事件循环作为第一个参数。
让监视器监听事件则调用:uv_TYPE_start(uv_TYPE_t*, callback)
而停止监听则调用:uv_TYPE_stop(uv_TYPE_t*)
回调函数是当监视器感兴趣的事件发生时,由 libuv 调用的函数。应用程序指定的逻辑一般会在回调函数中的实现。
只要有活动的监视器,事件循环就会一直运行。没有活动的事件监视器, uv_run() 退出。
ex:
#include <stdio.h>
#include <uv.h>
int64_t counter = 0;
void wait_for_a_while(uv_idle_t* handle, int status) {
counter++;
if (counter >= 10e6)
uv_idle_stop(handle);
}
int main() {
uv_idle_t idler;
uv_idle_init(uv_default_loop(), &idler);
uv_idle_start(&idler, wait_for_a_while);
printf("Idling...\n");
uv_run(uv_default_loop(), UV_RUN_DEFAULT);
return 0;
}
系统运行中会在监视器启动时给事件循环引用计数加 1,而在监视器停止时给事件循环引用减 1。也可以手动修改处理器引用计数:
void uv_ref(uv_handle_t*);
void uv_unref(uv_handle_t*);
使用这些函数可让事件循环在监视器处于活动状态下退出,或让事件循环使用自定义对象来维持其活动状态。
在笔记1中介绍了Node主线程与libuv I/O线程、事件循环的协作关系,这里我们总结一下Node的工作原理。
在Node启动时,主线程内先初始化一些必要的Watchers,比如I/O Watchcers,然后解析js文件,调用相应的libuv函数,最后执行libuv的事件循环函数,先检查watchers队列是否有到来的事件,有就在当前线程中处理,没有阻塞主线程,等待事件唤醒(epoll实现)。
对于js文件中调用libuv函数的语句,将会执行相应函数,利用epoll机制开启一系列I/O线程,设置watchers的回调函数,调用底层API,并进入阻塞态等待调用结束。系统调用结束,返回结果,I/O线程将返回结果赋给watchers回调函数的参数。同时向epoll机制提交状态。
主线程中epoll将再次激活事件循环,从阻塞处向下执行:调用watchers的回调函数。直到再次阻塞在epollwait那里(如果程序并没有设置listen,事件循环在下次检测不到新的事件时就退出循环(引用计数减1),结束程序,例如“文件读取”。如果http上调用了listen函数,将会不断的检测事件的到来~,即引用计数保持为1)。
Chapter 4:Watchers的优先级
上面讲的情况都是I/O,可是事件循环处理的事件不仅仅是I/O事件,还包括process.nextTick
产生的Idle事件,计时器的定时事件,setImmediate
的check事件。
而我们发现了一个优先级顺序:idle观察者 > I/O观察者 > check观察者。也就是说事件循环每次都是按这个顺序来依次检查watchers的。
进一步的实验我们将会发现:idle观察者和I/O观察者将会在一次事件循环中调用队列中的所有回调函数,而check观察者每次之调用队列头中的回调函数。