Linux网络 高级IO
文章目录
- IO的基本概念
- IO是什么
- 操作系统的IO过程
- 何为高效IO
- 五种IO模型
- 同步与异步
- 阻塞与非阻塞
- 高级IO
- select
- 函数用法
- select的优缺点
- poll
- poll的定位
- 函数用法
- poll的特点
- epoll
- epoll的定位
- epoll的用法
- epoll的原理
- epoll的工作模式
- epoll的线程安全问题
- 参考
IO的基本概念
IO是什么
I/O(input/output)即输入/输出,在冯诺依曼体系结构中,将数据从输入设备拷贝到内存就是输入,将数据从内存拷贝到输出设备就是输出。也就是说,IO的本质就是外设的访问问题。例如,文件的读写操作是一种IO,文件IO对应的外设是磁盘,对网络进行的读写操作也是一种IO,网络IO对应的外设是网卡。
操作系统的IO过程
从操作系统的角度来看,输入就是操作系统将数据从外设拷贝到内存的过程,输出则是将数据拷贝到外设。
那么操作系统就一定要通过某种方法得知特定外设上是否有数据就绪,因为并不是操作系统想要从外设读取数据时外设上就一定有数据。但操作系统不会主动去检测外设上是否有数据就绪,这种做法很低效,因为可能大部分情况下外设中都是没有数据的,因此操作系统所做的大部分检测工作其实都是徒劳的。
事实上,外设等是通过中断的方式通知操作系统当前外设上有数据就绪的,当某个外设上面有数据就绪时,该外设就会向CPU中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给CPU。每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表被称为中断向量表,当CPU收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表执行该中断信号对应的中断处理程序,处理完毕后再返回原被暂停的程序继续运行。
注意,很多地方会提到“CPU不直接和外设交互”,这是指的在数据层面上的不直接交互。外设其实是可以直接将某些控制信号发送给CPU中的某些控制器的。
何为高效IO
拆分IO的过程会发现,IO的过程主要分为两步:等和拷贝,等就是等待IO条件就绪(等的本质就是阻塞的过程),拷贝就是当IO条件就绪后将数据拷贝到内存或外设。
但在实际的应用场景中"等"消耗的时间往往比"拷贝"消耗的时间多,因此要让IO变得高效,最核心的办法就是尽量减少"等"的时间。例如在recv的时候,我们大部分时间是在等,少部分时间是真正在做拷贝。
所以,提高IO效率的途径就在于减少单位时间IO事件等待的比重。
五种IO模型
在操作系统中,有五种常见的IO模型:阻塞式IO、非阻塞式IO、信号驱动IO、IO多路复用(多路转接 )、异步IO。其中前4种IO模型都是同步IO的模型,只有最后一种是异步IO。下面通过一个钓鱼的例子来理解这五种IO模型:
钓鱼的过程也可以分为"等"和"拷贝"两个步骤,这里的"等"指的是等鱼上钩,"拷贝"指的是当鱼上钩后将鱼从河里"拷贝"到鱼桶中,这个场景下假设鱼咬钩之后不会逃走。
- 拿着一个鱼竿,将鱼钩抛入水中后就死死的盯着浮漂,除此之外什么也不做,当有鱼上钩后就挥动鱼竿将鱼钓上来。这种就是阻塞式IO。
- 拿着一个鱼竿,将鱼钩抛入水中后就去做别的事情去了,然后定期观察浮漂,若有鱼上钩则挥动鱼竿将鱼钓上来,否则就继续去做其他事情。这种就是非阻塞式IO。
- 拿着一个鱼竿,将鱼钩抛入水中后在鱼竿顶部绑一个铃铛,然后去做别的事情,当铃铛响了就挥动鱼竿将鱼钓上来,否则就不管鱼竿。这种就是信号驱动IO。
- 拿着一百个鱼竿,将这一百个鱼竿抛入水中后就定期观察这100个鱼竿的浮漂,如果某个鱼竿有鱼上钩则挥动对应的鱼竿将鱼钓上来。这种就是IO多路复用。
- 花钱雇佣了一个员工用来钓鱼,其本身去做别的事情,当员工钓鱼结束之后,将钓到的鱼交给他。这种就是异步IO。
通过这里的钓鱼例子可以看到发现,阻塞IO、非阻塞IO、信号驱动IO本质上并没有直接提高IO的效率。但非阻塞IO和信号驱动IO提高了整体做事的效率,因为他们在IO的等待期间去做了别的事情。而IO多路复用则是切实的提高了IO的效率,因为多路复用的单位事件内获取到IO事件响应的概率最大,IO事件等待的占比就小,所以其效率就高。即多路复用是将"等"的时间进行重叠,减少了"等"的时间。
阻塞IO、非阻塞IO、信号驱动IO、IO多路复用之所以都是同步IO,是因为它们都有一个共同点,就是IO事件发生后,不论应用程序是如何接受到IO就绪信号的,IO的读写操作仍然需要由应用程序显式地执行,我们把这类IO统称为同步IO。其中,虽然信号驱动IO的信号的产生是异步的,但信号驱动IO也是同步IO的一种,因为判断一个IO过程是同步的还是异步的,本质就是看当前进程或线程是否需要参与IO过程。
如下是这五种IO模型的介绍:
- 阻塞式IO
阻塞式IO的优点是实现简单,编程方便,不需要额外的处理逻辑。 的缺点是效率低,用户进程在等待数据的过程中无法执行其他任务,浪费了CPU资源。阻塞式IO比较适合数据量小,响应时间短的场景。
- 非阻塞式IO
非阻塞式IO的优点是用户进程不会被阻塞,可以继续执行其他任务,提高了CPU的利用率。缺点是用户进程需要不断地询问内核数据是否就绪,这样会导致CPU占用率非常高,而且实现复杂,编程困难。非阻塞式IO比较适合数据量大,响应时间长的场景。
- 信号驱动IO
信号驱动IO的优点在于它允许应用程序在数据准备好时得到立即通知,而不需要像阻塞IO那样一直等待。缺点在于它的复杂性和编程难度高,信号处理函数需要在非常有限的时间内完成工作,不能阻塞或执行复杂的操作。- IO多路复用
IO多路复用的优点是可以集中管理多个文件描述符,减少系统调用和轮询的开销,提高了系统的并发性和可伸缩性。缺点是需要额外的系统调用来管理文件描述符,而且在epoll系统调用时和数据拷贝阶段仍然是阻塞的。IO多路复用比较适合网络编程,比如服务器端的并发处理。
- 异步IO
异步IO的优点是用户进程不需要等待数据的拷贝,而是在数据的拷贝完成后才被通知,这样可以最大程度地减少用户进程的阻塞时间,提高了IO的效率和性能。缺点是实现非常复杂,编程难度高,而且不是所有的操作系统和文件系统都支持异步IO。异步IO比较适合对IO的吞吐量和延迟有严格要求的场景。
同步与异步
所谓同步,简单来说就是由调用者主动等待这个调用的结果,即谁调用谁返回。异步则是相反,当一个异步过程调用发出后,调用者不会立刻得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。
注意,这里的同步指的是同步通信(同步IO),在进程和线程的概念种也有一组关于同步的概念:同步和异步。这里的同步通信(同步IO)和线程之间的同步是两个概念,并不是一个东西。同步通信是指由调用者主动等待调用的结果,而线程同步是指多个线程按照规定的先后顺序执行。
阻塞与非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态。阻塞调用是指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
要区分好阻塞和同步,在同步操作中,调用者(通常是线程或进程)会被阻塞,直到操作完成。同步强调的是谁调用谁返回,阻塞强调的是等。
在Linux下,文件描述符,在被创建时默认都是阻塞IO,我们可以通过 fcntl 接口将其设置为非阻塞的,接口定义如下:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
cmd的值不同,后面追加的参数也不相同,cmd的类型如下:
F_DUPFD — 复制一个现有的描述符
F_GETFD/F_SETFD — 获得/设置文件描述符标记
F_GETFL/F_SETFL — 获得/设置文件状态标记
F_GETOWN/F_SETOWN — 获得/设置异步I/O所有权
F_GETLK/F_SETLK/F_SETLKW — 获得/设置记录锁
用法示例如下:
void SetNoBlock(int fd)
{
//获取文件描述符的标记位
int fl = fcntl(fd, F_GETFL);
//设置,添加O_NONBLOCK到文件描述符的标记位中
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
对于read、recv等接口来说,如果是非阻塞等待,那么在数据没有准备好的情况下,返回值会按照出错返回,即返回-1。所以在这种场景下我们就需要区分是返回值为-1,是因为非阻塞导致的,还是真正的读取失败导致的。
这时我们可以通过返回值为-1时设置的errno来判断,当errno为EAGAIN或EWOULDBLOCK(11)时,就表示是因为非阻塞式IO而返回-1的。参考:
除此之外,当errno为EINTR的情况,表示收到信号而导致返回值为-1的情况的情况,参考:
高级IO
非阻塞IO,记录锁,系统V流机制,IO多路复用,readv和writev函数、存储映射IO(mmap),统称为高级IO。本文主要是基于Linux网络编程的场景,着重介绍select、poll、epoll这三个IO多路复用接口的使用,暂不涉及其它的高级IO。
select
函数用法
在Linux下,select系统调用是用来让我们的程序监视多个文件描述符的状态变化的,程序会停在select处阻塞等待,直到被监视的文件描述符有一个或多个发生了状态改变。
函数定义如下:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds 参数
表示需要监视的最大的文件描述符的值+1,例如,假设要等待的fd为1、3、5、7、9,则nfds就应该被设为10。之所以nfds要为max_fd+1是因为select的底层采用的是位图的形式来管理的fd的,而位图是从0开始的,所以要管理的fd中,假设最大的fd为n,那么就需要n+1大小的位图。
- readfds、writefds、exceptfds 参数
这三个参数是用于设置读、写、异常事件监听的文件描述符集合,即每一个集合对应一种事件监听。例如readfds参数就表示需要监听哪些fd的读事件。其中fd_set是一种位图类型,我们不能直接对其操作,需要用Linux提供的接口进行操作:
void FD_CLR(int fd, fd_set *set); // 从set中移除fd
int FD_ISSET(int fd, fd_set *set); // 判断fd是否在set中
void FD_SET(int fd, fd_set *set); // 将fd添加到set中
void FD_ZERO(fd_set *set); // 清空set,移除全部fd
fd_set本质上就是位图,所以所谓的移除添加操作其实就是将对应位置上的置0或置1的过程。
关键的,这三个参数是输入输出型的参数,在输入时用于告诉内核,要监听读写异常事件的fd有哪些。在输出时,是内核告诉用户,哪些fd被设置,即对应事件的哪些fd已经就绪了。
注意,如果我们对一个fd既考虑读又考虑写,一般不要把他们同时添加到读集合与写集合中,而是要分步添加。
- timeout 参数
timeout是一个timeval类型的参数,用于指定select的等待时间。大致可以分为三种:
NULL:则表示select()没有timeout,select将一直被阻塞,直到有事件就绪。
等于0:仅检测描述符集合的状态,然后立即返回,不等待外部事件的发生。
大于0的时间:表示等待的时间,如果在这个时间内有事件就绪则返回,没有就一直等到事件结束再返回。
- 返回值
大于0,表示等待的多个fd中,已经就绪的fd个数;等于0,表示超时且没有一个就绪;返回-1则表示出错了。
select的优缺点
select特点:
- 可监控的文件描述符个数取决与 sizeof(fd_set) 的值,每一个bit都表示一个文件描述符。假设sizeof(fd_set)=128,那么所能支持的最大文件描述符是的个数为128*8=1024
- 将fd加入select监控集的同时,还要再使用一个数组管理放到select监控集中的fd。一是用于在select 返回后,array作为源数据和fd_set进行FD_ISSET判断。二是select返回后会把以前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入,扫描array的同时取得fd最大值maxfd,用于select的第一个参数。
- select可以在没有多线程的情况下管理多个连接。
select缺点:
- 每次调用select,都需要手动设置fd集合,从接口使用角度来说也非常不便。
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
- select支持的文件描述符数量有限。
poll
poll的定位
poll也是Linux中内置的一种IO多路复用的接口,它和select的功能上是一样的,都是负责多个fd的IO等待,且一次可以等待多个fd。只是poll针对select的缺点做了些调整和改进。
相较于select的缺点来说,poll改善了如下方面:
1、将输入和输出参数分离,不需要每次都对参数进行重置;
2、解决select等待fd的数量上限问题。
函数用法
函数定义
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数说明:
- fds 和 nfds
fds是一个struct pollfd*类型的参数,是一个struct pollfd类型的数组。nfds表示这个数组的长度。同select类似,fds参数也是一个输入输出型参数。
struct pollfd的类型定义如下:
struct pollfd
{
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
其中fd就是对应的文件描述符。events作为输入,表示有哪些请求事件。revents作为输出,表示有哪些事件就绪了。event和events的取值如下:
事件 | 描述 | 是否可作为输入 | 是否可作为输出 |
---|---|---|---|
POLLIN | 数据可读(包括普通数据和优先数据) | 是 | 是 |
POLLRDNORM | 普通数据可读 | 是 | 是 |
POLLRDBAND | 优先级带数据可读(Linux 不支持) | 是 | 是 |
POLLPRI | 高优先级数据可读,比如TCP 带外数据 | 是 | 是 |
POLLOUT | 数据(包括普通数据和优先数据)可写 | 是 | 是 |
POLLWRNORM | 普通数据可写 | 是 | 是 |
POLLWRBAND | 优先级带数据可写 | 是 | 是 |
POLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作 | 是 | 是 |
POLLERR | 错误 | 否 | 是 |
POLLHUP | 挂起 | 否 | 是 |
POLLNVAL | 文件描述符没被打开 | 否 | 是 |
- timeout
timeout表示poll函数的超时时间,单位是毫秒(ms)。同select的类似,timeout大于0表示最多等待的时间;等于0表示不等待,立即返回;小于0表示阻塞式等待。
- 返回值
大于0,表示等待的多个fd中,已经就绪的fd个数;等于0,表示超时且没有一个就绪;返回-1则表示出错了。
poll的特点
poll的特点:
- 不同于select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现。
- pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式,接口使用比select更方便。
- poll并没有最大数量限制,其内存是使用链表管理的 (但是数量过大后性能也是会下降的)
poll的缺点 :
- poll中监听的文件描述符数目增多时和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
- 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中,资源耗费过大
- 同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长, 其效率也会线性下降。
epoll
epoll的定位
按照man手册的说法:epoll是为处理大批量句柄而作了改进的poll。它是在2.5.44内核中被引进的(epoll(4) is a new API introduced in Linux kernel 2.5.44)。它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
也就是说,可以把epoll看作是poll的升级版。
epoll的用法
与select、poll不同的是,epoll操作并不是只有一个接口,而是有三个:epoll_create、epoll_ctl、epoll_wait,如下是这三个接口的介绍。
-
epoll_create
用途:创建epoll函数组使用的文件描述符(epoll_create创建的fd需要用close接口关闭)
函数定义:#include <sys/epoll.h> int epoll_create (int size);
参数:自从linux2.6.8之后,size参数就是被忽略的,在实际的底层处理中是没有用到这个值的,只要保证大于0即可
返回值:返回一个epoll文件描述符,失败返回-1并设置errno -
epoll_ctl
用途:控制epoll实例对文件描述符的读写事件监听进行操作
函数定义:#include <sys/epoll.h> int epoll_ctl (int epfd, int op, int fd, struct epoll_event *event);
参数:
epfd:要操作的epoll fd,即epoll_create返回的epoll实例
op:操作类型
fd:要操作的文件描述符
event:指定的操作事件返回值:成功返回0,失败则返回-1并设置errno
常见的操作类型如下:(参数op)EPOLL_CTL_ADD:注册新的fd到epfd中
EPOLL_CTL_MOD:修改已经注册的fd的监听事件
EPOLL_CTL_DEL:从epfd中删除一个fdstruct epoll_event结构的定义如下:
struct epoll_event { uint32_t events; epoll_data_t data; };
如下是几个常见的event定义:
事件类型 | 说明 |
---|---|
EPOLLIN | 数据可读 |
EPOLLPRI | 高优先级数据可读 |
EPOLLOUT | 数据可写 |
EPOLLRDNORM | 普通数据可读 |
EPOLLRDBAND | 优先级带数据可读 |
EPOLLWRNORM | 普通数据可写 |
EPOLLWRBAND | 优先级带数据可写 |
EPOLLERR | 表示对应的文件描述符发生错误 |
EPOLLHUP | 表示对应的文件描述符被挂断 |
EPOLLRDHUP | TCP连接被对方关闭,或者对方关闭了写操作 |
EPOLLONESHOT | 将EPOLL设置为只监听一次事件 |
EPOLLET | 将EPOLL设为边缘触发(Edge Triggered)模式 |
-
epoll_wait
用途:等待一组文件描述符的事件,收集在epoll监控的事件中已经就绪的事件
函数定义:#include <sys/epoll.h> int epoll_wait (int epfd, struct epoll_event *events, int maxevents, int timeout);
参数:
epfd:epoll实例的fd
events:用于存放文件描述返回的事件。epoll将会把发生的事件赋值到events数组中,这个数组需要我们自己维护,且events参数不可以为空。
maxevents:监听事件的最大个数,即events的最大长度
timeout:超时时间,单位是ms,同poll的timeout,大于0表示最多等待的时间;等于0表示不等待,立即返回;小于0表示阻塞式等待返回值:成功返回就绪的文件描述符个数,失败则返回-1并设置errno
epoll的原理
epoll 在内核中使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 fd 通过 epoll_ctl() 函数加入内核中的红黑树里。红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。与select/poll不同的是,epoll可以保存所有待检测的 fd,所以只需要传入一个待检测的 fd,就可以在O(logn)的时间内完成操作,减少了内核和用户空间大量的数据拷贝和内存分配。
同时,epoll 在内核里维护了一个链表用来记录就绪事件,当某个 fd 上的事件就绪时,内核会自动将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
所以现在再来看epoll的三个接口,可以这样理解:epoll_create,就是创建一个一整套的epoll模型(内核中的红黑树和链表等),并统一由一个epfd进行管理。epoll_ctl,本质是对底层红黑树进行增删改。epoll_wait,本质就是等就绪队列数据然后拷贝就绪队列中数据的过程。而至于事件的监听操作以及将事件放到就绪队列中的操作,就是由内核自动去做的了。
epoll的工作模式
epoll有2种工作方式:水平触发(LT)和边缘触发(ET),epoll默认状态下就是LT的工作模式。LT和ET是epoll给用户提供的一种通知事件就绪的策略。
- 水平触发(level-triggered,LT):底层只要有数据就绪,epoll就一直通知的策略,直到内核缓冲区数据被读完才结束。
- 边缘触发(edge-triggered,ET):底层有数据就绪时就通知一次,之后便不再通知,直到下一次有新的数据就绪时数才通知。即使通知之后没有从内核中读取任何数据,也依然只通知一次。
一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,如上下文的切换、数据拷贝等。
使用 ET 模式的 epoll,需要将文件描述设置为非阻塞。这个不是接口上的要求,而是 “工程实践” 上的要求。这是因为当被监控的文件描述符上有可读写事件发生时,内核只会通知一次事件就绪。如果这次没有把数据全部读写完(如读写缓冲区太小),它就不会再通知了,所以为了能够保证只通知一次就把数据读完,就要循环的进行读取,保证能将数据读取完毕,但如果是阻塞式的循环,如果没有足够的数据可读,读取操作就会阻塞住,直到有足够的数据可读为止,所以有需要设计成非阻塞的。
简单来说就是,ET模式下要保证一次性把缓冲区中的数据全部取走,所以就需要循环式的读取,而为了防止读取过程中发送阻塞,就又需要用非阻塞IO来读取。即epoll在使用ET模式时,要非阻塞的对数据进行循环读取。
epoll的线程安全问题
epoll 本身是线程安全的,也就是说,多个线程可以同时调用 epoll 的相关函数(如 epoll_ctl 和 epoll_wait)而不会导致数据竞争或不一致。至于为什么,简单来说就是epoll是通过锁来保证线程安全的, epoll中粒度最小的自旋锁ep->lock(spinlock)用来保护就绪的队列, 互斥锁ep->mtx用来保护epoll的重要数据结构红黑树
参考
- 9.2 I/O 多路复用:select/poll/epoll
- Linux高级IO
- Linux网络——高级IO
- ET模式需要将文件设置为非阻塞的原因
- 为什么epoll是线程安全?