前言
本章讨论的开发环境是Linux 网络io
同步I/O
在操作系统中,程序运行的空间分为内核空间和用户空间,用户空间所有对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 操作的结果,然后就去执行其他代码了。一段时间后,当I/O 返回结果时,再通知CPU 进行处理。这样子用户空间中的程序不需要等待内核空间中的I/O 完成实际操作,就可执行其他任务,提高CPU 的利用率。
简单来说就是,用户不需要等待内核完成实际对io 的读写操作就直接返回了。
阻塞I/O
在linux 中,默认情况下所有的socket 都是阻塞的,一个典型的读操作流程大概是这样:
当用户进程调用了read()/recvfrom() 等系统调用函数,它会进入内核空间中,当这个网络I/O 没有数据的时候,内核就要等待数据的到来,而在用户进程这边,整个进程会被阻塞,直到内核空间返回数据。当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程才解除阻塞的的状态,重新运行起来。
所以,阻塞I/O 的特点就是在IO 执行的两个阶段(用户空间与内核空间)都被阻塞了。
非阻塞I/O
linux 下,可以通过设置socket 使其变为非阻塞模式,这种情况下,当内核空间并无数据的时候,它会马上返回结果而不会阻塞,此时用户进程可以根据这个结果自由配置,比如继续请求数据,或者不再继续请求。当对一个非阻塞socket 执行读操作时,流程是这个样子:
当用户进程调用read()/recvfrom() 等系统调用函数时,如果内核空间中的数据还没有准备好,那么它并不会阻塞用户进程,而是立刻返回一个error。
对于应用进程来说,它发起一个read() 操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error 时,它就知道内核中的数据还没有准备好,那么它可以再次调用read()/recvfrom() 等函数。
当内核空间的数据准备好了,它就会将数据从内核空间中拷贝到用户空间,此时用户进程也就得到了数据。
所以,非阻塞I/O 的特点是用户进程需要不断的主动询问内核空间的数据准备好了没有。
多路复用I/O
多路复用I/O 就是我们说的select,poll,epoll 等操作,复用的好处就在于单个进程就可以同时处理多个网络连接的I/O,能实现这种功能的原理就是select、poll、epoll 等函数会不断的轮询它们所负责的所有socket ,当某个socket 有数据到达了,就通知用户进程。
一般来说I/O 复用多用于以下情况:
-
当客户处理多个描述符时。
-
服务器在高并发处理网络连接的时候。
-
服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O 复用。
-
如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O 复用。
-
如果一个服务器要处理多个服务或多个协议,一般要使用I/O 复用。
与多进程和多线程技术相比,I/O 多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。但select,poll,epoll 本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O 则无需自己负责进行读写,异步I/O 的实现会负责把数据从内核拷贝到用户空间。
select
用直白的话来介绍select:select 机制会监听它所负责的所有socket,当其中一个socket 或者多个socket 可读或者可写的时候,它就会返回,而如果所有的socket 都是不可读或者不可写的时候,这个进程就会被阻塞,直到超时或者socket 可读写,当select 函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select 整个处理过程如下
-
用户进程调用select() 函数,如果当前没有可读写的socket,则用户进程进入阻塞状态。
-
对于内核空间来说,它会从用户空间拷贝fd_set 到内核空间,然后在内核中遍历一遍所有的socket 描述符,如果没有满足条件的socket 描述符,内核将进行休眠,当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的内核进程,即在socket 可读写时唤醒,或者在超时后唤醒。
-
返回select() 函数的调用结果给用户进程,返回就绪socket 描述符的数目,超时返回0,出错返回-1。
-
注意,在select() 函数返回后还是需要轮询去找到就绪的socket 描述符的,此时用户进程才可以去操作socket 。
select 目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。
当然select 也有很多缺点:1. 单个进程能够监视的文件描述符的数量存在最大限制,在Linux 上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低。2. 需要维护一个用来存放大量fd 的数据结构,这样会使得用户空间和内核空间在
传递该结构时复制开销大。3. 每次在有socket 描述符活跃的时候,都需要遍历一遍所有的fd 找到该描述符,这会带来大量的时间消耗(时间复杂度是O(n),并且伴随着描述符越多,这开销呈线性增长)
对应内核来说,整个处理的流程如下:
select 函数原型:
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
参数说明:
-
maxfdp1 指定感兴趣的socket 描述符个数,它的值是套接字最大socket 描述符加1,socket描述符0、1、2⋯maxfdp1-1 均将被设置为感兴趣(即会查看他们是否可读、可写)。
-
readset:指定这个socket 描述符是可读的时候才返回。
-
writeset:指定这个socket 描述符是可写的时候才返回。
-
exceptset:指定这个socket 描述符是异常条件时候才返回。
-
timeout:指定了超时的时间,当超时了也会返回。
如果对某一个的条件不感兴趣,就可以把它设为空指针。
返回值:就绪socket 描述符的数目,超时返回0,出错返回-1。
select 的缺点
-
每次调用select ,都需要把fd 集合从用户态拷贝到内核态,这个开销在fd 很多时会很大。
-
同时每次调用select 都需要在内核遍历传递进来的所有fd ,这个开销在fd 很多时也很大。
-
每次在select() 函数返回后,都要通过遍历文件描述符来获取已经就绪的socket 。
-
select 支持的文件描述符数量太小了,默认是1024 。
poll
poll 的实现和select 非常相似,只是描述fd 集合的方式不同,poll 使用pollfd 结构而不是select 的fd_set 结构,poll 不限制socket 描述符的个数,因为它是使用链表维护这些socket描述符的,其他的都差不多和select() 函数一样,poll() 函数返回后,需要轮询pollfd来获取就绪的描述符,根据描述符的状态进行处理,但是poll 没有最大文件描述符数量的限制。poll 和select 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
函数原型:
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
epoll
epoll 的原理
其实相对于select 和poll 来说,epoll 更加灵活,但是核心的原理都是当socket 描述符就绪(可读、可写、出现异常),就会通知应用进程,告诉他哪个socket 描述符就绪,只是通知处理的方式不同而已。
epoll 使用一个epfd (epoll 文件描述符)管理多个socket 描述符,epoll 不限制socket 描述符的个数,将用户空间的socket 描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy 只需一次。当epoll 记录的socket 产生就绪的时候,epoll 会通过callback 的方式来激活这个fd,这样子在epoll_wait 便可以收到通知,告知应用层哪个socket 就绪了,这种通知的方式是可以直接得到那个socket 就绪的,因此相比于select 和poll ,它不需要遍历socket 列表,时间复杂度是O(1),不会因为记录的socket 增多而导致开销变大。
epoll 的操作模式
epoll 对socket 描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT 模式是默认模式,LT 模式与ET 模式的区别如下:
-
LT 模式:即水平出发模式,当epoll_wait 检测到socket描述符处于就绪时就通知应用程序,应用程序可以不立即处理它。下次调用epoll_wait 时,还会再次产生通知。
-
ET 模式:即边缘触发模式,当epoll_wait 检测到socket 描述符处于就绪时就通知应用程序,应用程序必须立即处理它。如果不处理,下次调用epoll_wait 时,不会再次产生通知。
ET 模式在很大程度上减少了epoll 事件被重复触发的次数,因此效率要比LT 模式高。epoll 工作在ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
epoll 的函数
epoll 只有epoll_create()、epoll_ctl()、epoll_wait() 3 个系统调用函数。
epoll_create()
int epoll_create(int size);
创建一个epoll 的epfd (epoll 文件描述符,或者称之为句柄),当创建好epoll 句柄后,它就是会占用一个fd 值,必须调用close() 关闭,否则可能导致fd 被耗尽,这也是为什么我们前面所讲的是:epoll 使用一个epfd 管理多个socket 描述符。
size 参数用来告诉内核这个监听的数目一共有多大,它其实是在内核申请一空间,用来存放用户想监听的socket fd 上是否可读可行或者其他异常,只要有足够的内存空间,size 可以随意设置大小,1G 的内存上能监听约10 万个端口。
epoll_ctl()
该函数用于控制某个epoll 文件描述符上的事件,可以注册事件,修改事件,以及删除事件。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
-
epdf:由epoll_create() 函数返回的epoll 文件描述符(句柄)。
-
op:op 是操作的选项,目前有以下三个选项:
-
EPOLL_CTL_ADD:注册要监听的目标socket 描述符fd 到epoll 句柄中。
-
EPOLL_CTL_MOD:修改epoll 句柄已经注册的fd 的监听事件。
-
EPOLL_CTL_DEL:从epoll 句柄删除已经注册的socket 描述符。
-
fd:指定监听的socket 描述符。
-
event:event 结构如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
-
events 可以是以下几个宏的集合:
∗ EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET 正常关闭)。
∗ EPOLLOUT:表示对应的文件描述符可以写。
∗ EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)。
∗ EPOLLERR:表示对应的文件描述符发生错误。
∗ EPOLLHUP:表示对应的文件描述符被挂断。
∗ EPOLLET:将EPOLL 设为边缘触发(Edge Triggered) 模式,这是相对于水平触发(Level Triggered) 来说的。
∗ EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket 的话,需要再次把这个socket 加入到EPOLL 队列里。
epoll_wait()
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
epoll_wait() 函数的作用就是等待监听的事件的发生,类似于调用select() 函数。
参数:- events:用来从内核得到事件的集合。
-
maxevents:告知内核这个events 有多大,这个maxevents 的值不能大于创建epoll_create()时的指定的size。
-
timeout:超时时间。
-
函数的返回值表示需要处理的事件数目,如返回0 表示已超时。
epoll 为什么更高效
-
当我们调用epoll_wait() 函数返回的不是实际的描述符,而是一个代表就绪描述符数量的值,这个时候需要去epoll 指定的一个数组中依次取得相应数量的socket 描述符即可,而不需要遍历扫描所有的socket 描述符,因此这里的时间复杂度是O(1)。
-
此外还使用了内存映射(mmap )技术,这样便彻底省掉了这些socket 描述符在系统调用时拷贝的开销(因为从用户空间到内核空间需要拷贝操作)。mmap 将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(不管是用户空间还是内核空间都是虚拟地址,最终要通过地址映射映射到物理地址),使得这块物理内存对内核和对用户均可见,减少用户态和内核态之间的数据交换,不需要依赖拷贝,这样子内核可以直接看到epoll 监听的socket 描述符,效率极高。
-
另一个本质的改进在于epoll 采用基于事件的就绪通知方式。在select/poll 中,进程只有在调用一定的方法后,内核才对所有监视的socket 描述符进行扫描,而epoll 事先通过epoll_ctl() 来注册一个socket 描述符,一旦检测到epoll 管理的socket 描述符就绪时,内核会采用类似callback 的回调机制,迅速激活这个socket 描述符,当进程调用epoll_wait() 时便可以得到通知,也就是说epoll 最大的优点就在于它只管就绪的socket 描述符,而跟socket 描述符的总数无关。
参考资料:Linux 基础与应用开发实战指南——基于STM32MP1 系列