部分图来自网络和黑马程序员
IO
IO分为两个阶段:数据准备(数据读取到内核缓冲区)+数据拷贝(从内核缓冲区拷贝到用户空间)
例如,在下图中两个主机的通信中,程序A/B从TCP接收缓冲区读取数据时,需要通过系统调用先将数据(如果有)读取到内核缓冲区,再拷贝到程序A/B的用户空间;发送数据同理。
上面的接收和发送数据过程中,如果数据还未到达,那程序A/B会怎么办呢?这与其采用的IO模型有关。
阻塞IO:进程在两阶段都会处于阻塞状态
在阻塞I/O模型中(Blocking I/O),当一个进程发起I/O操作(如读或写),它会一直等待该操作完成。在此期间,进程会被阻塞,无法执行该进程的其他任务( CPU时间切换给其他进程),直到数据可用并传输完毕。
特点:简单易用,但可能导致资源利用率低下,特别是在高并发场景下。
如,服务端监听多个socket(客户端)。当调用recvfrom从网卡获取数据时,若数据还未准备好,则服务端需要阻塞等待,在此过程中,监听的其他socket没法得到处理。
非阻塞IO:数据准备阶段不阻塞,数据拷贝阶段会阻塞
非阻塞I/O模式下(Non-blocking I/O),进程发起I/O操作后立即返回,无论数据是否准备好。如果数据不可用,调用会立即返回一个错误码或特殊值,进程可以继续执行其他任务,然后再次检查数据是否准备就绪。
特点:避免了线程长时间阻塞,但可能需要不断地轮询检查数据状态,导致CPU占用率偏高。
如,服务端线程监听多个socket(客户端)。当调用recvfrom从网卡获取某socket发送的数据,若数据未准备好,则服务端线程需要轮询,在此过程中,其他socket没法得到处理。
IO多路复用:数据拷贝阶段是阻塞的
文件描述符(File Descriptor) :简称FD,是一个从0开始递增的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket) 。
I0多路复用(Multiplexing I/O):允许单个进程监视多个描述符(文件描述符、套接字等),等待其中任何一个变为可读、可写状态。这意味着进程可以同时等待多个I/O事件,而无需为每个描述符单独阻塞。
服务端进程调用监听函数(select、epoll等)同时监听多个socket,并等待数据(阻塞等或非阻塞都可以,取决于给定的超时时间)。当一个或多个被监听的socket就绪,则返回readable。然后反复调用recvfrom从网卡获取那些就绪的socket发送的数据。
即相比之前的逐个等待socket的方式,IO多路复用可以集体等待,且专心处理有事件的socket,把时间花在刀刃上。
select
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout)
。用于实现I/O多路复用,它允许一个进程同时监控多个文件描述符(如套接字、管道、终端等)的可读、可写或异常状态。
不足:
- 有上限。最多监听1024个fd(猜测,且fd最大值为1023?因为数组只有1024个bit)
- 两次拷贝。需要将要监听的fd_set拷贝到内核,select结束后,又要从内核中拷贝回来
- 两次遍历。内核中需要遍历一次以设置就绪状态,用户空间中需要遍历一次,以判断哪个fd已就绪
poll
与select对比:
select模式中的fd_ set大小固定为1024, 而pollfd在内核中采用链表,理论上无上限,但
- 还是需要两次拷贝和两次遍历。
- 监听FD越多,每次遍历消耗时间也越久,性能反而会下降。
epoll
相较于select
和poll
有着显著的性能优势。epoll
通过维护一个内部文件描述符集合并使用事件驱动的方式工作,仅关注实际活跃的连接,从而大大减少了在高并发环境下所需的系统调用次数和上下文切换。
epoll
主要涉及以下三个系统调用:
-
epoll_create(): 在内核中创建一个
epoll
文件描述符,用于跟踪感兴趣的文件描述符集合。int epoll_create(int size);
size
参数在现代Linux内核中被忽略,但通常传入1作为占位符。
-
epoll_ctl(): 控制
epoll
文件描述符,可以添加、修改或删除对特定文件描述符的监听事件。并且是使用红黑树来管理监听的socket,增删改查时间复杂度是O(logn)
。int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd
: 由epoll_create
返回的文件描述符。op
: 操作类型,如EPOLL_CTL_ADD
(添加)、EPOLL_CTL_MOD
(修改)、EPOLL_CTL_DEL
(删除)。fd
: 要监听的文件描述符。event
: 一个epoll_event
结构体,包含要监听的事件类型(如EPOLLIN
,EPOLLOUT
)和用户数据指针。事件触发就加入就绪链表,事件驱动。
epoll_event
结构体struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
events
: 位掩码,表示关注的事件类型。data
: 用户自定义数据,可以存储指向结构体或其他数据的指针,以便在事件触发时使用。
-
epoll_wait(): 等待一个或多个
epoll
事件的发生,当任何注册的事件准备就绪时返回。int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epfd
: 同上。events
: 用于接收就绪事件的数组。maxevents
: 该数组能容纳的最大事件数。timeout
: 等待超时时间,单位为毫秒,与select
类似。
总体结构:
优势:
- 红黑树效率高。基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常高,性能不会随监听的FD数量增多而下降。
- 一次添加,无需拷贝。每个FD只需要执行一次epoll_ctl添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间。
- 一次拷贝(且不是全部拷贝),且无需遍历。内核会将就绪链表中的FD直接拷贝到用户空间的指定位置,用户进程无需遍历就能得到就绪的FD。
epoll的触发模式
当FD有数据可读时,我们调用epoll_wait就可以得到通知。但是事件通知的模式有两种:
- Level Triggered: 水平触发,简称LT。当FD有数据可读时,会重复通知多次,直至数据处理完成。是Epoll的默认模式,select/poll 只有水平触发模式。
- Edge Triggered: 垂直触发,简称ET。当FD有数据可读时,只会被通知一次,不管数据是否处理完成。
将就绪节点拷贝到用户空间前,会将其从就绪链表中断开,拷贝完后,再根据触发模式采取动作。
-
LT模式中,内核会将其重新加入就绪链表,所以后续还会触发epoll_wait
- 重复通知、重复读对效率有影响
- 惊群。多个进程监听了同一个socket,他就绪时,所有进程都会被通知(唤醒),然而通常只有一两个进程获得了该事件,并进行处理;其他进程在发现获取事件失败后,又继续进入了等待状态;然后再次被唤醒,循环往复。这在一定程度上降低了系统性能。
-
ET模式,内核会将其直接删除
- 需要自己手动将就绪节点添加回去
- 或手动把数据一次读完。要使用非阻塞地读,防止读不到数据时阻塞进程。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 系统调用的次数。
IO多路复用-web流程
异步IO
异步I/O(Asynchronous I/O,简称AIO)是一种I/O处理模式,它允许应用程序发起一个或多个I/O操作后立即返回,继续执行后续代码,而无需等待I/O操作完成。当I/O操作实际完成时,系统会通过预先设定的机制(如回调函数、事件通知、信号等)通知应用程序,从而实现非阻塞的并发处理。
简单来说,发起一个IO操作后直接返回执行其他代码,等到IO(数据准备+数据拷贝)完成后,会收到通知,再去处理。
- 应用程序在发起I/O操作后不会被阻塞,提高了程序的响应性和并发能力。
- 因为程序无需等待I/O操作,CPU可以处理其他任务,提高了整体系统吞吐量。
- 异步I/O编程模型相对同步I/O更加复杂,需要处理回调逻辑和潜在的并发问题。
信号驱动IO
信号驱动I/O(Signal-Driven I/O)结合了信号(signals)机制与I/O操作,实现非阻塞式的输入输出处理。这种模型允许应用程序在有数据可读或可写时通过信号的形式得到通知,而无需主动轮询或阻塞等待。
- 注册信号处理器:首先,应用程序需要注册一个信号处理器(signal handler),这个处理器专门用于处理特定的信号,比如
SIGIO
。这个信号会在相关的I/O事件(如数据可读或可写)发生时被发送给进程。 - 等待信号:应用程序继续执行其他任务,不再直接等待I/O操作完成。当I/O事件发生时,内核会发送一个信号(通常是
SIGIO
)给进程,而不是让进程阻塞。 - 信号处理器响应:应用程序中的信号处理器被调用,它通常会快速处理信号,然后通过某种方式(如设置一个标志或者将任务加入到事件队列)通知主循环,表明有I/O操作就绪,需要进一步处理。
- 优点:信号驱动I/O避免了持续轮询的开销,同时也避免了在
select
或poll
调用中等待的阻塞,提高了程序的响应性。 - 局限:信号处理函数的执行受到限制,不能执行复杂的操作,且信号处理过程中应当尽量快速完成,因为它会中断正常的程序执行流。此外,信号是全局的,可能与其他信号处理逻辑冲突,增加了编程的复杂性。