Linux系统中的异步I/O问题
Linux系统中的异步I/O问题
系统编程中,I/O(输入/输出)模型决定了如何和数据进行交互,它对整个系统性能影响至关重要。I/O模型会直接影响如下几大方面:
- 吞吐量
- 机器资源利用率,例如:CPU使用率
- 系统的可拓展性
- 编程复杂性
I/O模型在网络编程中,是高性能服务器的基础,如果设计的不好,不管上层如何设计精巧都会在I/O层卡住,成为性能瓶颈。
本文将重点讨论Linux环境下的两种核心I/O模型:同步I/O模型和异步I/O模型,并结合相关的套接字函数进行阐述。
第一部分:同步I/O,那些我们熟悉的“阻塞”和“非阻塞”
同步I/O的通俗解释是:调用方发起I/O操作后,必须等待数据就绪才能继续执行。
核心子模型一:阻塞I/O(Blocking I/O)
在Linux系统调用socket()中,默认的模式就是阻塞模式。在这种模式下,调用recv(),read(),或者accept()函数时,如果数据没有准备好,进程就会被挂起进入阻塞状态,一直等到数据准备就绪,进程恢复运行。
这里的进程挂起对于多线程场景是线程挂起的意思。
这种方式有它的优点和缺点。优点是编程简单,逻辑直观,容易理解,但是缺点就是它的效率低下,特别对于高并发场景下,单个线程只能处理一个连接,这是无法充分利用CPU资源的。
核心子模型二:非阻塞I/O(Non-blocking I/O)
在Linux中,非阻塞I/O是通过fcntl()函数将套接字设置成非阻塞模式。设置生效后,如果数据没有就绪,调用方会立即得到函数返回的错误(通常是EAGAIN或EWOULDBLOCK),此时调用线程不会被挂起,从而可以处理其他事务。
这种方式下,优点是显而易见的,那就是线程不阻塞,这样可以同时处理更多的任务,大大地利用了CPU资源,但是缺点也是明显的,它需要有一种不断地轮训机制去检查I/O是否数据准备就绪,这种情况下,也会导致消耗CPU资源。
两种模式的小结
同步I/O的本质是发起I/O请求后,调用方必须等待数据拷贝完成。阻塞和非阻塞只是在等待数据就绪这个阶段采取的不同策略。
第二部分:异步I/O,让操作系统来帮你处理I/O
在第一部分我们看到了阻塞I/O的CPU利用率低,而非阻塞I/O的控制复杂,同时它的机制也会导致消耗大量CPU,为了更够更好解决这些问题,于是就有了操作系统级别的异步I/O。
它的流程是这样的,调用方发起I/O操作后,可以立即返回并继续执行其他任务,当I/O操作完成后,操作系统会通过某种机制(如回调函数)通知调用方。就像你点外卖后可以继续工作,等到外卖到了会收到通知。
这样下来,操作系统帮我们解决了非阻塞I/O的控制复杂和消耗CPU的问题。
这里介绍Linux中的异步I/O机制,不过它们是用于磁盘块设备读取使用的,不适用网络I/O,目的是阐述Linux异步I/O思想。异步I/O分为如下几个步骤:
步骤一:发起异步I/O请求
异步I/O发起的流程:
- 应用程序调用一个特殊的异步I/O请求函数(例如
aio_read(),或者io_submit())。 - 这个调用会立即把 I/O 请求(包括要读取的文件描述符、缓冲区地址、数据大小等信息)提交给内核。
- 这是最关键的一步。提交请求的函数会立即返回,而不是等待数据读写完成。这意味着应用程序可以继续执行其他任务,而不需要停下来。
LibAIO和Linux原生异步I/O函数
LibAIO是符合POSIX标准的,具有更好的跨平台能力,但是它实际上使用线程池模拟异步的,所以性能上并不理想,典型函数有:
aio_read()aio_write()aio_fsync()aio_error()aio_return()aio_suspend()aio_cancel()aio_listio()Linux原生异步I/O函数是一种更直接、更高效的方式与内核进行交互的方式,它的工作方式是直接把I/O请求提交到内核的事件队列,从而不依赖线程模拟,达到真正的内核级别的异步,但是它的缺点主要是在于它只能在Linux系统中运行,不具备跨平台能力,典型函数有:
io_setup()io_getevents()io_submit()io_cancel()io_destroy()
步骤二:内核接管并处理I/O
请求被提交,剩下的工作就由内核来完成,应用程序几乎不用再操心。
- 内核调度:内核接收到请求后,会将其放入一个队列中,并安排合适的时间进行处理。
- 硬件交互:内核会与硬件(如磁盘控制器)进行交互,启动数据的传输。这个过程是完全在后台进行的,通常不需要 CPU 的介入(通过 DMA,直接内存访问技术)。
- 数据拷贝:数据会直接从硬件设备被拷贝到应用程序指定的内存缓冲区中。这个拷贝过程是完全由内核负责的,不需要应用程序的参与。
步骤三: I/O操作完成并通知
数据拷贝完成后,内核需要以某种方式通知应用程序,告诉它“你的 I/O 任务已经完成了”。
通知机制:这就是异步 I/O 模型的精髓所在。通知机制可以有多种方式:
- 信号(Signal):内核可以向应用程序发送一个信号(例如
SIGIO)来表示 I/O 任务完成。应用程序需要提前注册一个信号处理函数来捕获这个信号。 - 回调函数(Callback Function):这是更常见的方式。在发起请求时,应用程序会提供一个回调函数的地址。当 I/O 完成后,内核会自动调用这个函数,并将结果(比如成功或失败)作为参数传递进去。
步骤四:应用程序处理结果
当应用程序收到完成通知后,就可以安全地处理数据了。
- 检查结果:应用程序接收到通知后,会检查操作的结果,例如数据读入的字节数或者是否发生了错误。
- 继续处理:现在,缓冲区中已经有了完整的数据,应用程序可以开始对数据进行解析、处理或响应了。
第三部分:I/O多路复用:同步世界的异步曙光
上面两个部分分别介绍了非阻塞I/O和异步I/O编程,可见非阻塞效率低下,而异步I/O编程又比较复杂。那么有没有一种折中的方案呢?答案是有的,那就是I/O多路复用(I/O Multiplexing)。
I/O多路复用的定义是一个线程可以同时监听多个socket文件描述符(file descriptor),当某个socket上有事件(数据可读、可写)发生时,内核会通知应用层。
这里只介绍几个核心API:
select():最早期的API,优点是跨平台,缺点是性能随socket数量线性下降,且有文件描述符数量的限制。poll():select()的改进版,解决了文件描述符数量的限制。epoll():Linux系统独有的高性能API。详细阐述其**边缘触发(ET)和水平触发(LT)**两种模式,并解释它为何能在大规模并发连接中表现出色(仅返回已就绪的描述符,不需要轮询所有)。
I/O多路复用本质上仍然是同步I/O(因为内核通知后,应用层还是需要自己发起read()或write()调用来拷贝数据),但它通过“等待多个文件描述符就绪”的机制,解决了阻塞I/O的效率问题和非阻塞I/O的轮询问题,实现了“单线程处理多连接”的高效模式。
第四部分:总结与选择
阻塞I/O、非阻塞I/O、I/O多路复用和异步I/O的选择需要考虑不同因素,需要综合考虑编程复杂度、性能、适用场景等维度,选择适合自己业务的方式。
一般性建议:
- 阻塞I/O:适用于客户端应用、连接数较少且对性能要求不高的服务器。
- I/O多路复用(尤其是
epoll):这是高并发服务器的首选,例如Web服务器、聊天室等,它在编程复杂度和性能之间取得了最佳平衡。 - 异步I/O:适用于对I/O性能有极致要求、且编程模型复杂性可以接受的场景,例如数据库、高性能存储系统等。
对于I/O模型技术的发展也在不断进步中,其中典型是Linux中io_uring的出现,它可以支持所有类型的I/O类型,包括网络I/O。