libuv 框架初窥
libuv 的设计框架,从网络 I/O 看,在 linux(unix)平台它可以通过网络的底层 epoll 作为异步的 I/O 处理,它的中间有一层抽象层 uv__io_t;而在 Windows 平台可以通过 IOCP...
解读
从官方的文档可以找到一个设计框架的图片:libuv/docs/src/static/architecture.png

从这张图片可以看出 libuv 的设计框架(从上往下看,从左往右分成网络 IO 与文件 IO 等操作),从网络 I/O 看,在 linux(unix)平台它可以通过网络的底层 epoll 作为异步的 I/O 处理,在 OSX 上可以使用 kqueue 等,它的中间有一层抽象层 uv__io_t;而在 Windows 平台可以通过 IOCP 作为异步的处理;对应文件 I/O 操作,它不像网络 I/O,libuv 没有特定平台的异步 IO 原语(primitives)可以依赖,所以 libuv 是在线程池中执行阻塞(同步)IO 来实现异步的操作。
在操作系统中,程序运行的空间分为内核空间和用户空间,用户空间所有对 io 操作的代码(如文件的读写、socket 的收发等)都会通过系统调用进入内核空间完成实际的操作。
而且我们都知道 CPU 的速度远远快于硬盘、网络等 I/O。在一个线程中,CPU 执行代码的速度极快,然而, 一旦遇到 I/O 操作,如读写文件、发送网络数据时,就需要等待 I/O 操作完成,才能继续进行下一步操作,这种情况称为同步 I/O。
在某个应用程序运行时,假设需要读写某个文件,此时就发生了 I/O 操作,在 I/O 操作的过程中,系统会将当前线程挂起,而其他需要 CPU 执行的代码就无法被当前线程执行了,这就是同步 I/O 操作,因为一个 IO 操作就阻塞了当前线程,导致其他代码无法执行,所以我们可以使用多线程或者多进程来并发执行代码,当某个线程 / 进程被挂起后,不会影响其他线程或进程。
多线程和多进程虽然解决了这种并发的问题,但是系统不能无上限地增加线程 / 进程。由于系统切换线程 / 进程的开销也很大,所以,一旦线程 / 进程数量过多,CPU 的时间就花在线程 / 进程切换上了,真正运行代码的时间就少了,这样子的结果也导致系统性能严重下降。
多线程和多进程只是解决这一问题的一种方法,另一种解决 I/O 问题的方法是异步 I/O。
当程序需要对 I/O 进行操作时,它只发出 I/O 操作的指令,并不等待 I/O 操作的结果,然后就去执行其他代码了。一段时间后,当 I/O 返回结果时,再通知 CPU 进行处理。这样子用户空间中的程序不需要等待内核空间中的 I/O 完成实际操作,就可执行其他任务,提高 CPU 的利用率。
简单来说就是,用户不需要等待内核完成实际对 io 的读写操作就直接返回了。
大家可以思考一下,如何去实现异步 I/O 模型,思考明白了,就表示对 libuv 框架有一定了解了,或者说看完这篇文章后,你能知道为什么 libuv 是这样子设计的,那就可以了。
Handles 就是句柄,Requests 是请求,句柄代表着一个可用的资源(或者说是一个对象),比如一个 TCP 连接,在整个 TCP 连接的生命周期中,句柄都是可用的,当断开连接后,句柄也应该随之释放。而请求则表示一个操作的开始,比如 TCP 请求建立连接、TCP 请求读取数据、发送数据等,这个请求是期望得到应答的,当产生应答的时候,请求就完成了。还有很重要的一点是:这些请求是可以在句柄上操作的,比如在某个 TCP 连接中请求发送数据、读取数据;当然也有不在句柄上的请求。
事件循环是 libuv 的核心部分,它的主要职责是对 I/O 进行轮询,然后基于不同的事件源调度它们的回调,它是 libuv 中建立所有 I/O 操作的内容,所有网络 I/O 工作在非阻塞套接字上,这些循环依赖平台上的最佳机制进行轮询,比如在 linux 平台上使用 epoll,在 OSX 上使用 kqueue,在 Windows 上使用 IOCP 等机制,当这些网络 I/O 有数据到达的时候,libuv 将通过回调函数去执行相应的操作,比如读取数据、写入数据等。
对于文件 I/O 的操作,由于平台并未对文件 I/O 提供轮询机制,libuv 通过线程池的方式阻塞他们,每个 I/O 将对应产生一个线程,并在线程中进行阻塞,当有数据可操作的时候解除阻塞,进而进行回调处理,因此 libuv 将维护一个线程池,线程池中可能创建了多个线程并阻塞它们。
接下来放一张官方的图片吧,它在libuv/docs/src/static/loop_iteration.png路径下:

这张图很明确的表示了 libuv 中所有 I/O 的事件循环处理的过程,其实就是uv_run()函数执行的过程,它内部是一个 while 循环。
-
首先判断循环是否是处于活动状态,它主要是通过当前是否存在处于 alive 活动状态的句柄,如果句柄都没有了,那循环也就没有意义了,如果不存在则直接退出。
-
开始倒计时,主要是维护所有句柄中的定时器,当某个句柄超时了,就会告知应用层已经超时了,就退出去或者重新加入循环中。
-
调用待处理的回调函数,如果有待处理的回调函数,则去处理它,如果没有就接着往下运行。
-
运行空闲句柄,反正它这个线程都默认会有空闲句柄的,这个空闲句柄会在每次循环中被执行。
-
运行准备句柄回调处理,这个名字有点奇怪,我是跟着官方手册来学习的,也不知道怎么去翻译它,简单来说就是在某个 I/O 要阻塞前,有需要的话就去运行一下他的回调函数,举个例子吧,比如我要从某个文件读取数据,如果我在读取数据进入阻塞状态之前想打印一个信息,那么就可以通过这个准备句柄的回调函数去处理这个打印信息。
-
计算轮询超时,在阻塞 I/O 之前,循环会计算阻塞的时间,并将这个 I/O 进入阻塞状态(如果可以的话,阻塞超时为 0 则表示不阻塞),这些是计算超时的规则:
- 如果使用该
UV_RUN_NOWAIT模式运行循环,则超时为 0。 - 如果要停止循环(uv_stop() 被调用),则超时为 0。
- 如果没有活动的句柄或请求,则超时为 0。
- 如果有任何空闲的句柄处于活动状态,则超时为 0。
- 如果有任何要关闭的句柄,则超时为 0。
- 如果以上情况均不匹配,则采用最接近的定时器超时,或者如果没有活动的定时器,则为无穷大。
- 如果使用该
I/O 循环作为事件循环迭代的一部分,事件循环将会被阻塞在 I/O 循环上(例如:linux 上的 epoll_pwait() 调用),直到该套接字有 I/O 事件发生时唤醒这个线程,调用关联的回调函数,然后便可以在 handles 上进行读、写或其他想要进行的操作 requests。这也直接避免了时间循环一直工作导致占用 CPU 的问题。
-
检查句柄的回调,其实当程序能执行到这一步,就表明 I/O 已经退出阻塞状态了,那么有可能是可读 / 写数据,也有可能超时了,此时 libuv 将在这里检查句柄的回调,如果有可读可写的操作就调用他们对应的回调,当超时了就调用超时的处理。
-
如果通过调用 uv_close() 函数关闭了句柄,则会调用 close 将这个 I/O 关闭。
-
在超时后更新下一次的循环时间,前提是通过
UV_RUN_DEFAULT模式去运行这个循环,关于运行的模式在后续会讲解到,目前有 3 中模式,UV_RUN_NOWAIT、UV_RUN_ONCE、UV_RUN_DEFAULT。