本文翻译自libuv官方文档:docs.libuv.org/en/v1.x/des…
设计概述
Libuv是一个跨平台的的基于事件驱动的异步io库。最初是为Node.js编写的。它是围绕事件驱动的异步I/O模型设计的。
该库提供的不仅仅是对不同I/O轮询机制的简单抽象:handle和streams为sockets和其他实例提供了一个高级抽象。此外,libuv还提供了跨平台文件I/O和线程化功能。
以下是一张图表,说明了组成libuv的不同部分以及它们与什么子系统相关:
从图中可以看出,至少包含几个部分:
- 事件
- epoll - 事件通知,Linux
- kqueue - 事件通知,Mac OS X和FreeBSD
- events ports - 事件通知,Solaris
- IOCP - 事件通知,windows
- 线程池: 多线程,计算密集型操作或者I/O操作
- 各种I/O API
- 网络
- 文件
- DNS
- 用户代码
Handles and requests
libuv结合事件循环为用户提供了两个可供使用的抽象:句柄和请求。
句柄(Handle)表示能够在活动时执行某些操作持久的对象。一些例子:
- 当活动时,prepare句柄在每个循环迭代中调用一次回调。
- TCP服务器句柄,每次有新连接时都会调用它的连接回调。
请求(Request)代表(通常)短暂的操作。这些操作可以在句柄上执行:
- 写入请求用于在句柄上写入数据;
- standalone:getaddrinfo请求不需要句柄,它们直接在循环中运行。
I/O循环
从上图中我们大致了解到,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的官方文档和示例程序来了解更多的细节。