abbshr.github.io icon indicating copy to clipboard operation
abbshr.github.io copied to clipboard

Node学习笔记2

Open abbshr opened this issue 10 years ago • 0 comments

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观察者每次之调用队列头中的回调函数。

abbshr avatar Mar 29 '14 12:03 abbshr