前端面试题 - 17. 说说对libuv的理解

352 阅读7分钟

本文翻译自libuv官方文档:docs.libuv.org/en/v1.x/des…

设计概述

Libuv是一个跨平台的的基于事件驱动的异步io库。最初是为Node.js编写的。它是围绕事件驱动的异步I/O模型设计的。

该库提供的不仅仅是对不同I/O轮询机制的简单抽象:handle和streams为sockets和其他实例提供了一个高级抽象。此外,libuv还提供了跨平台文件I/O和线程化功能。

以下是一张图表,说明了组成libuv的不同部分以及它们与什么子系统相关:

image.png

从图中可以看出,至少包含几个部分:

  1. 事件
    1. epoll - 事件通知,Linux
    2. kqueue - 事件通知,Mac OS X和FreeBSD
    3. events ports - 事件通知,Solaris
    4. IOCP - 事件通知,windows
  2. 线程池: 多线程,计算密集型操作或者I/O操作
  3. 各种I/O API
    1. 网络
    2. 文件
    3. DNS
    4. 用户代码

Handles and requests

libuv结合事件循环为用户提供了两个可供使用的抽象:句柄和请求。

句柄(Handle)表示能够在活动时执行某些操作持久的对象。一些例子:

  • 当活动时,prepare句柄在每个循环迭代中调用一次回调。
  • TCP服务器句柄,每次有新连接时都会调用它的连接回调。

请求(Request)代表(通常)短暂的操作。这些操作可以在句柄上执行:

  • 写入请求用于在句柄上写入数据;
  • standalone:getaddrinfo请求不需要句柄,它们直接在循环中运行。

I/O循环

image.png

从上图中我们大致了解到,Libuv分为几个阶段,然后在一个循环里不断执行每个阶段里的任务。下面我们具体看一下每个阶段。

  • 1 更新当前事件,在每次事件循环开始的时候,libuv会更新当前事件到变量中,这一轮循环的剩下操作可能使用这个变量获取当前事件,避免过多的系统调用影响性能。

  • 2 如果时间循环是处于alive状态,则开始处理事件循环的每个阶段。否则退出这个事件循环。alive状态是什么意思呢?如果有active和ref状态的handle,active状态的request或者closing状态的handle则认为事件循环是alive的(具体的后续会讲到)。

  • 3 timer阶段:判断最小堆中的节点哪个节点超时了,执行他的回调。

  • 4 pending阶段:执行pending回调。一般来说,所有的io回调(网络,文件,dns)都会在poll io阶段执行。但是有的情况下,poll io阶段的回调会延迟到下一次循环执行,那么这种回调就是在pending阶段执行的。

  • 5 idle阶段:如果节点处理avtive状态,每次事件循环都会被执行(idle不是说事件循环空闲的时候才执行)。

  • 6 prepare阶段:和idle阶段一样。

  • 7 poll io阶段:计算最长等待时间timeout,计算规则:

    • 如果时间循环是以UV_RUN_NOWAIT模式运行的,则timeout是0。
    • 如果时间循环即将退出(调用了uv_stop),则timeout是0。
    • 如果没有active状态的handle或者request,timeout是0。
    • 如果有dile阶段的队列里有节点,则timeout是0。
    • 如果有handle等待被关闭的(即调了uv_close),timeout是0。
    • 如果上面的都不满足,则取timer阶段中最快超时的节点作为timeout,如果没有则timeout等于-1,即永远阻塞,直到满足条件。
  • 8 poll io阶段:调用各平台提供的io多路复用接口,最多等待timeout时间。返回的时候,执行对应的回调。(比如linux下就是epoll模式)

  • 9 check阶段:和idle prepare一样。

  • 10 closing阶段:处理调用了uv_close函数的handle的回调。

  • 11 如果libuv是以UV_RUN_ONCE模式运行的,那事件循环即将退出。但是有一种情况是,poll io阶段的timeout的值是timer阶段的节点的值。并且poll io阶段是因为超时返回的,即没有任何事件发生,也没有执行任何io回调。这时候需要在执行一次timer阶段。因为有节点超时了。

  • 12 一轮事件循环结束,如果libuv以UV_RUN_NOWAIT 或 UV_RUN_ONCE模式运行的,则退出事件循环。如果是以UV_RUN_DEFAULT模式运行的并且状态是alive,则开始下一轮循环。否则退出事件循环。

重要的是,libuv使用线程池使异步文件I/O操作成为可能,但网络I/O总是在单个线程中执行,即每个循环的线程中执行。

文件I/O

与网络I/O不同,libuv没有可以依赖的特定于平台的文件I/O原语,因此当前的方法是在线程池中运行阻塞文件I/O操作。

有关跨平台文件I/O环境的全面解释,请查看本文。

libuv目前使用全局线程池,所有循环都可以在该池上排队工作。此池上当前正在运行3种类型的操作:

  • 文件系统操作
  • DNS函数(getaddrinfo和getnameinfo)
  • 用户通过uv_queue_work()指定的代码

补充知识

epoll

libuv在Linux上使用epoll作为事件通知机制。epoll是Linux提供的一种高效的I/O事件通知机制,能够有效地处理大量的I/O事件。 libuv的事件循环机制使用epoll来监听文件描述符上的事件。它的使用方式如下:

1.创建一个事件循环对象:

cCopy code
uv_loop_t* loop = uv_default_loop();

2.初始化一个文件描述符(socket,文件等)并将其添加到事件循环中:

cCopy code
int fd = ...; // 一些文件描述符
uv_poll_t handle;
uv_poll_init(loop, &handle, fd);

3.设置文件描述符上需要监听的事件:

cCopy code
uv_poll_start(&handle, UV_READABLE, callback);

这里的callback是一个回调函数,它将在文件描述符上有事件发生时被调用。第二个参数UV_READABLE表示需要监听的事件类型,这里是可读事件。 4.事件循环监听并处理事件:

cCopy code
uv_run(loop, UV_RUN_DEFAULT);

这里的UV_RUN_DEFAULT表示事件循环将一直运行直到没有待处理的事件。 当文件描述符上有事件发生时,回调函数将被调用。回调函数的定义如下:

cCopy code
void callback(uv_poll_t* handle, int status, int events) {
  if (status < 0) {
    // 错误处理
    return;
  }
  if (events & UV_READABLE) {
    // 文件描述符可读事件发生,进行读取操作
  }
  if (events & UV_WRITABLE) {
    // 文件描述符可写事件发生,进行写入操作
  }
}

这里的events参数表示发生的事件类型,可以是UV_READABLE、UV_WRITABLE等。根据不同的事件类型,回调函数可以进行相应的操作。 需要注意的是,epoll机制有一个最大并发连接数的限制,通常为1024或者65535。在使用epoll时,需要注意不要超过这个限制。可以使用libuv提供的线程池机制,在多个线程中监听不同的端口,来避免这个限制。

kqueue

libuv在Mac OS X和FreeBSD等系统上使用kqueue作为事件通知机制。kqueue是BSD系统提供的一种高效的I/O事件通知机制,能够有效地处理大量的I/O事件。 libuv的事件循环机制使用kqueue来监听文件描述符上的事件。它的使用方式如下: 1.创建一个事件循环对象:

cCopy code
uv_loop_t* loop = uv_default_loop();

2.初始化一个文件描述符(socket,文件等)并将其添加到事件循环中:

cCopy code
int fd = ...; // 一些文件描述符
uv_poll_t handle;
uv_poll_init(loop, &handle, fd);

3.设置文件描述符上需要监听的事件:

cCopy code
uv_poll_start(&handle, UV_READABLE, callback);

这里的callback是一个回调函数,它将在文件描述符上有事件发生时被调用。第二个参数UV_READABLE表示需要监听的事件类型,这里是可读事件。 4.事件循环监听并处理事件:

cCopy code
uv_run(loop, UV_RUN_DEFAULT);

这里的UV_RUN_DEFAULT表示事件循环将一直运行直到没有待处理的事件。 当文件描述符上有事件发生时,回调函数将被调用。回调函数的定义如下:

cCopy code
void callback(uv_poll_t* handle, int status, int events) {
  if (status < 0) {
    // 错误处理
    return;
  }
  if (events & UV_READABLE) {
    // 文件描述符可读事件发生,进行读取操作
  }
  if (events & UV_WRITABLE) {
    // 文件描述符可写事件发生,进行写入操作
  }
}

这里的events参数表示发生的事件类型,可以是UV_READABLE、UV_WRITABLE等。根据不同的事件类型,回调函数可以进行相应的操作。 需要注意的是,kqueue机制没有最大并发连接数的限制,可以处理大量的并发连接。但是在使用kqueue时,需要注意一些细节,比如文件描述符的使用和管理等。可以参考libuv的官方文档和示例程序来了解更多的细节。

events ports

libuv在Solaris上使用events ports作为事件通知机制。events ports是Solaris提供的一种高效的I/O事件通知机制,与kqueue和epoll类似,能够有效地处理大量的I/O事件。 libuv的事件循环机制使用events ports来监听文件描述符上的事件。它的使用方式如下: 1.创建一个事件循环对象:

cCopy code
uv_loop_t* loop = uv_default_loop();

2.初始化一个文件描述符(socket,文件等)并将其添加到事件循环中:

cCopy code
int fd = ...; // 一些文件描述符
uv_poll_t handle;
uv_poll_init(loop, &handle, fd);

3.设置文件描述符上需要监听的事件:

cCopy code
uv_poll_start(&handle, UV_READABLE, callback);

这里的callback是一个回调函数,它将在文件描述符上有事件发生时被调用。第二个参数UV_READABLE表示需要监听的事件类型,这里是可读事件。 4.事件循环监听并处理事件:

cCopy code
uv_run(loop, UV_RUN_DEFAULT);

这里的UV_RUN_DEFAULT表示事件循环将一直运行直到没有待处理的事件。 当文件描述符上有事件发生时,回调函数将被调用。回调函数的定义如下:

cCopy code
void callback(uv_poll_t* handle, int status, int events) {
  if (status < 0) {
    // 错误处理
    return;
  }
  if (events & UV_READABLE) {
    // 文件描述符可读事件发生,进行读取操作
  }
  if (events & UV_WRITABLE) {
    // 文件描述符可写事件发生,进行写入操作
  }
}

这里的events参数表示发生的事件类型,可以是UV_READABLE、UV_WRITABLE等。根据不同的事件类型,回调函数可以进行相应的操作。 需要注意的是,events ports机制只在Solaris上可用,其他操作系统不支持。在使用events ports时,需要注意一些细节,比如文件描述符的使用和管理等。可以参考libuv的官方文档和示例程序来了解更多的细节。

IOCP

libuv在Windows上使用IOCP(I/O 完成端口)作为事件通知机制。IOCP是Windows提供的一种高效的I/O事件通知机制,能够有效地处理大量的I/O事件。 libuv的事件循环机制使用IOCP来监听文件描述符上的事件。它的使用方式如下: 1.创建一个事件循环对象:

cCopy code
uv_loop_t* loop = uv_default_loop();

2.初始化一个文件描述符(socket,文件等)并将其添加到事件循环中:

cCopy code
uv_tcp_t* handle = (uv_tcp_t*)malloc(sizeof(uv_tcp_t));
uv_tcp_init(loop, handle);

3.连接到服务器端:

cCopy code
uv_tcp_connect_t* connect_req = (uv_tcp_connect_t*)malloc(sizeof(uv_tcp_connect_t));
uv_tcp_connect(connect_req, handle, (const struct sockaddr*)&addr, on_connect);

这里的on_connect是连接成功后的回调函数。 4.事件循环监听并处理事件:

cCopy code
uv_run(loop, UV_RUN_DEFAULT);

这里的UV_RUN_DEFAULT表示事件循环将一直运行直到没有待处理的事件。 当文件描述符上有事件发生时,回调函数将被调用。回调函数的定义如下:

cCopy code
void on_read(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
  if (nread < 0) {
    // 错误处理
    return;
  }
  if (nread == 0) {
    // 读取完成
    return;
  }
  // 进行读取操作
  ...
}

需要注意的是,IOCP机制只在Windows上可用,其他操作系统不支持。在使用IOCP时,需要注意一些细节,比如文件描述符的使用和管理等。可以参考libuv的官方文档和示例程序来了解更多的细节。

thread pool

libuv的线程池(thread pool)机制可以用来处理一些耗时的任务,如计算密集型操作或者I/O操作等。它可以将这些任务分配到多个线程中执行,从而提高程序的并发性和响应性。 libuv的线程池机制使用方式如下: 1.创建一个线程池对象:

cCopy code
uv_thread_pool_t* pool = (uv_thread_pool_t*)malloc(sizeof(uv_thread_pool_t));

2.初始化线程池对象并设置线程数目:

cCopy code
uv_thread_pool_create(pool, 4);

这里的4表示线程池中的线程数目为4。 3.将任务添加到线程池中:

cCopy code
uv_thread_pool_work_t* work = (uv_thread_pool_work_t*)malloc(sizeof(uv_thread_pool_work_t));
uv_thread_pool_work_init(work, task_func, on_complete, data);
uv_queue_work(pool, work, on_work_done);

这里的task_func是需要执行的任务函数,on_complete是任务完成后的回调函数,data是任务函数的参数。 4.等待任务完成:

cCopy code
uv_run(loop, UV_RUN_DEFAULT);

这里的loop是事件循环对象,需要保证线程池和事件循环在同一个线程中。 当任务完成后,回调函数将被调用。回调函数的定义如下:

cCopy code
void on_work_done(uv_work_t* req, int status) {
  if (status == 0) {
    // 任务执行成功
    ...
  } else {
    // 任务执行失败
    ...
  }
  // 释放工作请求对象
  free(req);
}

需要注意的是,线程池机制只适用于一些耗时的任务,如果任务执行时间很短,反而会因为线程切换等开销导致程序性能下降。另外,线程池中的任务并不是按照添加顺序执行的,而是按照线程池中线程的空闲情况来分配执行任务。因此,不能保证任务的执行顺序。可以参考libuv的官方文档和示例程序来了解更多的细节。

network I/O

libuv的网络I/O机制可以用于实现TCP/UDP等网络协议的通信。它使用事件驱动的方式来处理网络I/O事件,可以同时处理多个连接。libuv的网络I/O机制使用方式如下: 1.创建一个事件循环对象:

cCopy code
uv_loop_t* loop = uv_default_loop();

2.初始化一个TCP连接对象:

cCopy code
uv_tcp_t* handle = (uv_tcp_t*)malloc(sizeof(uv_tcp_t));
uv_tcp_init(loop, handle);

3.绑定TCP连接对象到一个IP地址和端口号:

cCopy code
struct sockaddr_in addr;
uv_ip4_addr("0.0.0.0", 8000, &addr);
uv_tcp_bind(handle, (const struct sockaddr*)&addr, 0);

这里的8000表示端口号,可以根据实际情况进行修改。 4.监听TCP连接请求:

cCopy code
uv_listen((uv_stream_t*)handle, SOMAXCONN, on_connection);

这里的SOMAXCONN表示TCP连接请求的最大队列长度,可以根据实际情况进行修改。on_connection是连接请求回调函数,当有连接请求时会被调用。 5.处理连接请求:

cCopy code
void on_connection(uv_stream_t* server, int status) {
  if (status == -1) {
    // 错误处理
    return;
  }
  uv_tcp_t* client = (uv_tcp_t*)malloc(sizeof(uv_tcp_t));
  uv_tcp_init(loop, client);
  uv_accept(server, (uv_stream_t*)client);
  uv_read_start((uv_stream_t*)client, on_alloc, on_read);
}

这里的on_alloc和on_read分别是数据读取的分配回调函数和读取回调函数。 6.分配读取缓冲区:

cCopy code
void on_alloc(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf) {
  buf->base = (char*)malloc(suggested_size);
  buf->len = suggested_size;
}

7.读取数据并处理:

cCopy code
void on_read(uv_stream_t* stream, ssize_t nread, const uv_buf_t* buf) {
  if (nread < 0) {
    // 错误处理
    return;
  }
  if (nread == 0) {
    // 读取完成
    return;
  }
  // 进行读取操作
  ...
}

需要注意的是,libuv的网络I/O机制可以处理多个连接请求,因此需要对每个连接请求分别处理。可以参考libuv的官方文档和示例程序来了解更多的细节。

file I/O

libuv的文件I/O机制可以用于实现文件读写等操作。它使用事件驱动的方式来处理文件I/O事件,可以异步地进行文件读写操作,从而提高程序的并发性和响应性。libuv的文件I/O机制使用方式如下: 1.创建一个事件循环对象:

cCopy code
uv_loop_t* loop = uv_default_loop();

2.初始化一个文件读取请求对象:

cCopy code
uv_fs_t* req = (uv_fs_t*)malloc(sizeof(uv_fs_t));

3.发起文件读取请求:

cCopy code
uv_fs_open(loop, req, filename, flags, mode, on_open);

这里的filename表示要读取的文件名,flags表示打开文件的方式(如只读、只写等),mode表示文件权限。on_open是文件打开回调函数,当文件打开成功时会被调用。 4.处理文件打开请求:

cCopy code
void on_open(uv_fs_t* req) {
  if (req->result == -1) {
    // 打开文件失败
    return;
  }
  // 文件打开成功,进行读取操作
  uv_fs_read(loop, req, req->result, buf, len, -1, on_read);
}

这里的buf表示读取缓冲区,len表示读取缓冲区的长度。 5.读取文件并处理:

cCopy code
void on_read(uv_fs_t* req) {
  if (req->result < 0) {
    // 读取文件失败
    return;
  }
  // 读取文件成功,进行处理
  ...
}

需要注意的是,libuv的文件I/O机制可以异步地进行文件读写操作,因此需要对每个读写请求分别处理。可以参考libuv的官方文档和示例程序来了解更多的细节。

uv__io_t

在libuv中,uv__io_t是用于管理底层I/O事件的结构体。它通常由libuv内部使用,用于底层I/O事件的注册、注销和处理。在一些特殊情况下,我们也可以使用uv__io_t来实现自定义的底层I/O事件。uv__io_t的使用方式如下: 1.创建一个uv__io_t对象:

cCopy code
uv__io_t* io_watcher = (uv__io_t*)malloc(sizeof(uv__io_t));

2.初始化uv__io_t对象:

cCopy code
uv__io_init(loop, io_watcher, io_fd, io_events);

这里的loop表示事件循环对象,io_fd表示底层I/O事件的文件描述符,io_events表示底层I/O事件的类型(如读、写等)。 3.注册uv__io_t对象到事件循环中:

cCopy code
uv__io_start(loop, io_watcher, io_cb, io_events);

这里的io_cb是底层I/O事件的回调函数,当底层I/O事件发生时会被调用。 4.处理底层I/O事件:

cCopy code
void io_cb(uv__io_t* watcher, int revents) {
  // 处理底层I/O事件
  ...
}

需要注意的是,使用uv__io_t需要对底层I/O事件的处理非常熟悉,因为它与操作系统的底层I/O事件直接相关。可以参考libuv的官方文档和示例程序来了解更多的细节。