网络编程-五种IO模型
阻塞IO
阻塞IO即Blocking IO
针对阻塞IO执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生。系统调用某个函数,一直检查这个函数有没有返回,必须等返回才能进行下一步动作。
socket基础API中,可能被阻塞的系统调用包括accept, send, recv, connect。
注意阻塞和非阻塞的概念是应用于文件描述符(socket, fd)而非函数。
进程执行read(是一次系统调用),当内核数据未准备好,进程会阻塞,一直等待数据。
数据准备好后开始拷贝数据,从内核空间拷贝到用户空间。
拷贝完成后,进程才能执行read。
阻塞原理
操作系统为了支持多任务,实现了进程调度的功能,会把进程分为“运行”和“等待”等几种状态。运行状态是进程获得cpu使用权,正在执行代码的状态;等待状态是阻塞状态,比如程序运行到recv时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态。操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务。
当某进程A执行socket通信过程中的创建socket时,操作系统会创建一个socket对象,包含发送缓冲区,接收缓冲区,等待队列。当程序执行到recv时,操作系统会将进程A从工作队列移动到等待队列,cpu会继续执行工作队列中的进程,而进程A会被阻塞(不会往下执行代码,也不会占用cpu资源)。只有当接受数据后,操作系统才重新将进程A放回工作队列继续执行。
非阻塞IO
Non-Blocking IO
非阻塞等待,每隔一段时间就去检测IO事件是否就绪。没有就绪就可以做其他事。
非阻塞I/O执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据 errno 区分这两种情况,对于accept,recv 和 send,事件未发生时,errno 通常被设置成 EAGAIN。
显然只有在事件已经发生的情况下操作非阻塞IO,才能提高程序的效率
因此非阻塞IO通常与其他IO通知机制一起使用,比如IO复用和SIGNO信号
IO多路复用
IO multiplexing
IO复用是最常使用的IO通知机制。它指的是应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数把其中就绪的事件通知给应用程序。
IO复用函数本身也是阻塞的,但是能同时监听多个IO事件(同时阻塞多个IO操作),因此能提高程序效率
Linux下常用的三种IO复用函数是select/poll/epoll
IO复用流程可用以下图来表示:
程序阻塞于IO复用系统调用,等待套接字变成可读。当数据准备好,返回可读的文件描述符。然后用read读取数据,IO本身的读写操作是非阻塞的,读取数据还是需要从内核空间拷贝到用户空间。
select
主旨思想
- 首先构造一个关于文件描述符的列表fds(数组),将要监听的文件描述符添加到该列表中
- 调用一个系统函数select,监听该列表中的文件描述符,直到这些文件描述符中的一个或多个进行IO操作,函数返回,中断程序唤醒进程(将进程从等待队列移到工作队列)。
a. 函数是阻塞的
b. 函数对文件描述符的检测操作由内核完成 - 返回时,告诉进程有多少文件描述符要进行IO操作。
- 遍历fds,通过FD_ISSET判断具体收到数据的socket。
select 流程伪代码
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.size; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
select API
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- 参数
-
nfds:委托内核检测的最大文件描述符值+1(因为文件描述符是从0开始计数)
-
readfds:要检测的文件描述符的读的集合,对应的是对方发过来的数据,检测的是读缓冲区
是一个传入传出参数,传入要检测的(FD_SET将标志位置为1),根据FD_ISSET判断标志位是否为1,传出检测到的(用FD_SET置为1,无数据用FD_CLR置为0)
-
writefds:要检测的文件描述符的写的集合,检测内核写缓冲区是否有写空间,一般不检测
-
exceptfds:检测异常文件描述符的集合
-
timeout:设置的超时时间
-
- 返回值:
- -1:失败
- >0(n):检测的集合中有n个文件描述符发生变化
select描述补充
-
readfds、writefds、exceptfds参数分别指向可读、可写、异常事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。
-
fd_set是一个结构体,仅包含一个整型数组,数组的每一位标记一个文件描述符
位操作API(宏)
void FD_CLR(int fd, fd_set *set);
FD_CLR:将参数文件描述符fd对应的标志位设置为0
int FD_ISSET(int fd, fd_set *set);
FD_ISSET:判断fd对应的标志位是0还是1
void FD_SET(int fd, fd_set *set);
FD_SET:将参数文件描述符fd对应的标志位设置为1
缺点
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,将进程加入到等待队列,每次唤醒都需要从队列中移除
- 同时每次调用select,都需要在内核遍历传递进来的所有fd
- 支持的fd数量太小,默认1024(只能支持1024个客户端)(因为fd_set是128字节,即1024位,是整型数组,每一位是一个标志位,对应一个文件描述符)
- fd集合不能重用,每次都需要重置
poll
poll系统调用和select类似,在指定时间内轮询一定数量的文件描述符,是select的改进(解决了select缺点3、4)
select用128字节的文件描述符集合readfds标志要检测的文件描述符,在poll中换成了结构体数组,标志要委托检测的和实际发生的分开,使得文件描述符集合可以重用,并且没有128字节限制
API
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- 参数:
- fds:是一个struct pollfd结构体数组,是一个需要检测的文件描述符的集合
- nfds:指定被监听事件集合fds的大小,是第一个参数数组中最后一个有效元素的下标+1
- timeout:阻塞时长
- 返回值:
- -1:失败,永远阻塞,直到某事件发生
- 0:立即返回
- >0(n):n表示检测到集合中有n个文件描述符发生变化
pollfd结构体:
struct pollfd{
int fd; //委托内核检测的文件描述符
short events; //注册文件描述符的事件
short revents; //文件描述符实际发生的事件
};
epoll
select低效的原因是将“维护等待队列”和“阻塞进程”两个步骤合二为一,每次调用select都需要执行这两步。epoll把这两步分开,先用epoll_ctl维护等待队列,再调用epoll_wait阻塞进程
epoll原理
创建一个新的eventpoll对象。
int epfd = int epoll_create(int size);
epoll_create创建一个eventpoll对象,epfd文件描述符操作这个对象。参数size无意义。
这个对象中有两个比较重要的数据(还有一个等待列表存放等待的进程)
- 一个是需要检测的文件描述符的信息(红黑树)
- 一个是就绪列表,存放检测到数据发生改变的文件描述符信息,对应就绪的socket(双向链表)。当进程被唤醒后,只要获取就绪列表的内容,就能知道哪些socket收到数据
伪代码:
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = lfd;
struct eventpoll{
...
struct rb_root rbr;
struct list_head rdlist;
...
};
int lfd = socket(AF_INET, SOCK_STREAM, 0);
bind(lfd , ...);
listen(lfd , ...);
int cfd = accept(lfd, ...);
int epfd = epoll_create(...);
epoll_ctl(epfd, EPOLL_CTL_ADD,...); //将所有需要监听的socket添加到epfd中
while(1){
int ret = epoll_wait(epfd,...);
for(接收到数据的socket){
//处理
read(...)/recv(...)
}
}
- 创建epoll对象后,可以用epoll_ctl添加或删除所要监听的socket。以添加socket为例,如果通过epoll_ctl添加sock1、sock2和sock3的监视,内核会将eventpoll添加到这三个socket的等待队列中。
- 当某个socket收到数据后,中断程序会给eventpoll的“就绪列表rdlist”添加该socket引用。
eventpoll对象相当于是socket和进程之间的中介,socket的数据接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程状态。 - 假设计算机中正在运行进程A和进程B,在某时刻进程A运行到了epoll_wait语句。内核会将进程A放入eventpoll的等待队列中,阻塞进程。
- 当socket接收到数据,中断程序一方面修改rdlist,另一方面唤醒eventpoll等待队列中的进程,进程A再次进入运行状态。进程A只需要不断循环遍历rdlist,从而获取就绪的socket。
- 当程序执行到epoll_wait时,其实就是去遍历 rdlist。如果rdlist已经引用了socket,那么epoll_wait直接返回;如果rdlist为空,阻塞进程。
总结
首先epoll初始化用epoll_create创建一个event_poll对象,这个对象有就绪列表,红黑树,等待列表。就绪列表存放就绪的socket,红黑树存放所有正在监听的socket引用,等待列表放正在等待的进程。
每次accept到一个新连接,调用中断在文件系统中创建fd,这个fd里有接收缓存区,发送缓存区,等待列表,(ps:如果已有这个fd,则直接将这个socket加入这个fd的等待列表中,这里理解为fd对应的端口,计算机有65535个端口,所以epoll最多支持65535,而一个端口可能会有许多的连接,因为socket是IP:端口,IP不同,端口一样也是不同的socket,但是对应的端口fd是一个),同时在中断系统里注册一个监听回调函数。一旦某个socket发生了读写操作,中断程序会调用这个socket的回调函数,将这个socket的引用加入到event_poll的就绪队列中,在while程序里,一直都会有epoll_wait(A进程),一旦A进程会一直轮询就绪队列,一旦就绪队列非空,A进程获得其中的socket数据进入系统运行队列,由等待列表的下一个进程继续使用epoll_wait来轮询。
等待列表中的进程,系统每次有空闲进程,就将其放入等待列表中阻塞,epoll_wait有一个time_out参数,在这个等待时间time里,如果就绪队列有socket需要处理,就调用阻塞的进程运行,如果一直为空,当计时器到了,进程变为非阻塞继续去干活。
epoll_ctl()
epoll_ctl添加或删除所要监听的socket。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
- 参数
- epfd:epoll实例对应的文件描述符
- op:要进行的操作
- EPOLL_CTL_ADD 添加
- EPOLL_CTL_MOD 修改
- EPOLL_CTL_DEL 删除
- fd:要检测的文件描述符
- event:要检测的事件
epoll_wait()
等待数据
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
- 参数
- epfd:epoll实例对应的文件描述符
- events:保存发生变化的文件描述符的信息
- maxevents:第二个参数结构体数组的大小
- timeout:阻塞时间
- 0:不阻塞
- -1:阻塞,直到检测到fd数据发送变化,解除阻塞
- >0:阻塞的时长
- 返回值
- -1:失败
- >0:发生变化的文件描述符个数
两种工作模式
水平触发LT
边缘触发ET
信号驱动
Linux 用套接口进行信号驱动 IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO 信号,然后处理 IO 事件。