五种I/O模式
生若直木,不语斧凿ᝰ 2019-08-01 20:59:43 951 收藏 3
分类专栏: linux 文章标签: 同步异步 阻塞非阻塞 I/O复用 ET模式 LT模式
版权
linux
专栏收录该内容
10 篇文章0 订阅
订阅专栏
linux网络编程中,对于一次I/O访问,比如说执行一次read,首先是操作系统内核现将数据读到内核缓冲区,接着由程序从内核缓冲区读取;这两个阶段简单概括为数据拷贝到内核,程序从内核获取数据,基于这两个步骤linux细分出5中I/O复用模式。
阻塞 I/O(blocking IO)
非阻塞 I/O(nonblocking IO)
I/O 多路复用( IO multiplexing)
信号驱动 I/O( signal driven IO)
异步 I/O(asynchronous IO)
同步/异步是消息通信机制,也就是对于调用者如何获取结果的方式来讲的
同步:调用者调用方法之后不返回,直到得到结果后返回;
异步:调用者调用方法之后不管结果,直接返回,结果是被调用者通过状态或者通知或者回调函数来告诉调用者;
阻塞/非阻塞是对等待调用结果这段时间线程的状态来讲的
阻塞: 调用结果返回之前,当前线程被挂起;
非阻塞:调用结果返回之前,当前线程不被挂起,正常继续执行;
同步/异步和阻塞/非阻塞之间无关。
阻塞和挂起是不一样的,区别在于:
(1)挂起是一种主动行为,因此恢复也应该要主动完成。而阻塞是一种被动行为,是在等待事件或者资源任务的表现,你不知道它什么时候被阻塞,也不清楚它什么时候会恢复阻塞。挂起的进程可以理解为被操作系统抛弃的进程,几乎不管了。
(2)阻塞(pend)就是任务释放CPU,其他任务可以运行,一般在等待某种资源或者信号量的时候出现。挂起(suspend)不释放CPU,如果任务优先级高,就永远轮不到其他任务运行。一般挂起用于程序调试中的条件中断,当出现某个条件的情况下挂起,然后进行单步调试。类似sleep()和wait()都是让程序等待n秒的但是:
sleep()方法没有释放锁,而wait()方法释放了锁,使得其他线程可以使用同步控制块或者方法;
sleep()指线程被调用时,占着CPU不工作;
sleep(2000)表示:占用CPU,程序休眠2秒。
wait(2000)表示:不占用CPU,程序等待2秒。
阻塞IO: 在内核将数据准备好之前,系统调用会一直等待所有的套接字,默认的是阻塞方式。举个例子就是在write完成之后 read才能执行,否则被阻塞;
非阻塞IO: 每次客户询问内核是否有数据准备好,即文件描述符缓冲区是否就绪。当有数据报准备好时,就进行拷贝数据报的操 作。当没有数据报准备好时,也不阻塞程序,内核直接返回未准备就绪的信号,等待用户程序的下一个轮寻(轮询方式对cpu浪费严重,一般不使用)。
信号驱动IO模型:应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号 处理函数来获取数据报。
异步IO: 当应用程序调用aio_read时,内核一方面去取数据报内容返回,另一方面将程序控制权还给应用进程,应用进程继续处理其他事情,是一种非阻塞的状态。当内核中有数据报就绪时,由内核将数据报拷贝到应用程序中,返回aio_read中定义好的函数处理程序。
IO多路复用:
目前支持I/O多路复用的系统调用有 select、poll、epoll。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
使用场景:IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。
1)当客户处理多个描述符时(一般是交互式输入和网络套接口),必须使用I/O复用。
2)当一个客户同时处理多个套接口时,这种情况是可能的,但很少出现。
3)如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
4)如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
5)如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
epoll 跟 select 都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现。
select原理图:
select函数详解:
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
void FD_CLR(int fd, fd_set *set); // 清除set中的fd位
int FD_ISSET(int fd, fd_set *set); // 判断set中是否设置了文件描述符fd
void FD_SET(int fd, fd_set *set); // 在set中设置文件描述符fd
void FD_ZERO(fd_set *set); // 清空set中的所有位(在使用文件描述符集前,应该先清空一下)
//(注意FD_CLR和FD_ZERO的区别,一个是清除某一位,一个是清除所有位)
#include <sys/select.h>
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
各个参数的含义:
1)nfds参数指定被监听的文件描述符的总数。通常被设置为select监听的所有文件描述符中最大值加1;
2)readfds、writefds、exceptfds分别指向可读、可写和异常等事件对应的文件描述符集合。
这三个参数都是传入传出型参数,指的是在调用select之前,用户把关心的可读、可写、或异常的文件描述符通过 FD_SET函数
分别添加进readfds、writefds、exceptfds文件描述符集,select将对这些 "文件描述符集合" 中的文件描述符进行监听,
如果有就绪文件描述符,select会重置readfds、writefds、exceptfds文件描述符集来通知应用程序哪些文件描述符就绪。
这个特性导致select函数返回后,再次调用select之前,必须重置所关心的文件描述符,也就是三个文件描述符集已经不是之前传入的了。
3)timeout参数用来指定select函数的超时时间(下面讲select返回值时还会谈及)。
struct timeval
{
long tv_sec; //秒数
long tv_usec; //微秒数
};
select的返回情况:
1)如果指定timeout为NULL,select会永远等待下去,直到有一个文件描述符就绪,select返回;
2)如果timeout的指定时间为0,select根本不等待,立即返回;
3)如果指定一段固定时间,则在这一段时间内,如果有指定的文件描述符就绪,select函数返回,如果超过指定时间,select同样返回。
4)返回值情况:
a)超时时间内,如果文件描述符就绪,select返回就绪的文件描述符总数(包括可读、可写和异常),没有文件描述符就绪,select返回0;
b)select调用失败时,返回 -1并设置errno,如果收到信号,select返回 -1并设置errno为EINTR。
文件描述符的就绪条件:
在网络编程中,
1)下列情况下socket可读:
a) socket内核接收缓冲区的字节数大于或等于其低水位标记SO_RCVLOWAT;
b) socket通信的对方关闭连接,此时该socket可读,但是一旦读该socket,会立即返回0(可以用这个方法判断client端是否断开连接);
c) 监听socket上有新的连接请求;
d) socket上有未处理的错误。
2)下列情况下socket可写:
a) socket内核发送缓冲区的可用字节数大于或等于其低水位标记SO_SNDLOWAT;
b) socket的读端关闭,此时该socket可写,一旦对该socket进行操作,该进程会收到SIGPIPE信号;
c) socket使用connect连接成功之后;
d) socket上有未处理的错误。
select缺点:
select本质上是通过设置或者检查存放 fd 标志位的数据结构来进行下一步处理。这样所带来的缺点是:
select最大的缺陷就是单个进程所打开的文件描述符是有一定限制的,它由FD_SETSIZE设置,默认值是1024。32位机默认是1024个。64位机默认是2048.
对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是就绪的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
poll函数:
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
int ppoll(struct pollfd *fds, nfds_t nfds,
const struct timespec *timeout_ts, const sigset_t *sigmask);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
各个参数的含义:
1)第一个参数是指向一个结构数组的第一个元素的指针,
即 fds 是一个struct pollfd类型的数组,每个元素都是一个pollfd结构,用于指定测试某个给定描述符的条件。
比如用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds数组不会被清空;
struct pollfd
{
int fd; //指定要监听的文件描述符
short events; //指定监听fd上的什么事件
short revents; //fd上事件就绪后,用于保存实际发生的事件
};
待监听的事件由events成员指定,函数在相应的revents成员中返回该描述符的状态
(每个文件描述符都有两个事件,一个是传入型的events,一个是传出型的revents,从而避免使用传入传出型参数,注意与select的区别),
从而告知应用程序fd上实际发生了哪些事件。events和revents都可以是多个事件的按位或。
2)第二个参数是要监听的文件描述符的个数,也就是数组fds的元素个数; 即nfds记录数组fds中描述符的总数量;
3)第三个参数意义与select相同。即 timeout是调用poll函数阻塞的超时时间,单位毫秒;
timeout参数指定等待的毫秒数,无论I/O是否准备好,poll都会返回。
timeout指定为负数值表示无限超时,使poll()一直挂起直到一个指定事件发生;
timeout为0指示poll调用立即返回并列出准备好I/O的文件描述符,但并不等待其它的事件。这种情况下,poll()就像它的名字那样,一旦选举出来,立即返回。
poll的返回值情况: 与select相同。
返回值和错误代码
成功时,poll()返回结构体中revents域不为0的文件描述符个数;
如果在超时前没有任何事件发生,poll()返回0;
失败时,poll()返回-1,并设置errno为下列值之一:
EBADF 一个或多个结构体中指定的文件描述符无效。
EFAULTfds 指针指向的地址超出进程的地址空间。
EINTR 请求的事件之前产生一个信号,调用可以重新发起。
EINVALnfds 参数超出PLIMIT_NOFILE值。
ENOMEM 可用内存不足,无法完成请求。
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
poll事件类型:
epoll: 在Linux内核中申请了一个简易的文件系统,把原先的一个select或poll调用分成了3部分:
调用epoll_create建立一个epoll对象(在epoll文件系统中给这个句柄分配资源);
调用epoll_ctl向epoll对象中添加连接的套接字;
调用epoll_wait收集发生事件的连接。
int epoll_create(int size);
struct eventpoll {
...
/*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,
也就是这个epoll监控的事件*/
struct rb_root rbr;
/*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/
struct list_head rdllist;
...
};
在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传
来的socket外,还会再建立一个rdllist双向链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个rdllist双向链表里有没
有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
所有添加到epoll中的事件都会与设备(如网卡)驱动程序建立回调关系,也就是说相应事件的发生时会调用这里的回调方法。这个回调方法在内核中叫做ep_poll_callback,它会把这样的事件放到上面的rdllist双向链表中。对于每一个事件都会建立一个epitem结构体:
struct epitem {
...
//红黑树节点
struct rb_node rbn;
//双向链表节点
struct list_head rdllink;
//事件句柄等信息
struct epoll_filefd ffd;
//指向其所属的eventepoll对象
struct eventpoll *ep;
//期待的事件类型
struct epoll_event event;
...
}; // 这里包含每一个事件对应着的信息。
当调用epoll_wait检查是否有发生事件的连接时 ,只是检查eventpoll对象中的rdllist双向链表是否有epitem元素而已,
如果rdllist链表不为空,则这里的事件复制到用户态内存(使用共享内存提高效率)中,同时将事件数量返回给用户。因此
epoll_waitx效率非常高。epoll_ctl在向epoll对象中添加、修改、删除事件时,从rbr红黑树中查找事件也非常快,也就
是说epoll是非常高效的,它可以轻易地处理百万级别的并发连接,而select、poll只能处理千级别的并发连接。
epoll触发模式:ET模式(高速模式) LT模式(默认模式)
LT(水平触发)模式下,只要这个文件描述符还有数据可读或者说未及时处理,每次调用 epoll_wait都会返回它的事件,提醒用户程序去操作;)只要有数据都会触发,缓冲区剩余未读尽的数据会导致epoll_wait返回。
ET(边缘触发)模式下,在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN 为止,否则下次的 epoll_wait 不会返回余下的数据,会丢掉事件,也就是说如果事件不及时处理下次也就没机会处理了。如果ET模式不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。只有数据到来才触发,不管缓存区中是否还有数据,缓冲区剩余未读尽的数据不会导致epoll_wait返。
EPOLLET边缘触发模式:
如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLLET这种边缘触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你;这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。
三种多路IO方式对比:
从能够监听的事件类型: select只有三种,poll和epoll可以监听更多
从能够监听的文件描述符个数: select最多1024个,poll和epoll更多(由系统限制)
从返回的文件描述符:select和poll都会返回所有的关注的文件描述符(就绪的和未就绪的),epoll只会返回就绪的,所以用户程序检测就绪文件描述符的时间复杂度分别为O(n) O(1)
从函数参数: 每次select调用, 内核都会在线修改传递的参数,所以每次调用select都必须重新设置参数。poll(将用户关注的事件类型和内核修改的事件类型分离开表示)和epoll(由内核事件表维护用户关注的文件描述符上的事件类型)则不需要。
从调用时拷贝的文件描述: select和poll每次调用都需要将用户空间数据拷贝到内核空间,返回时,将内核修改的数据在拷贝到用户空间,epoll只会在调用epoll_ctl时拷贝一次,epoll_wait调用时,只从内核向用户拷贝就绪的文件描述符。
从内核实现角度: select和poll采用轮询的方式检测就绪的事件,epoll采用回调的方式。
从工作模式: select和poll只能工作在效率较低的LT模式下,epoll支持高效率的ET模式。
epoll的ET和LT的区别:
从使用上来看,LT模式下,同一个事件就绪后,如果应用程序并没有及时处理,下次还会再被通知给应用程序。ET模式则是如果就绪事件被通知给应用程序而其没有处理或者没有处理完成,则下次不会再次通知。
从内核实现上看: epoll的epoll_wait函数检测内核链表rdlist是否为空,不为空则将rdlist中的结点移动到txlist中(移动完成后,rdlist为空),接着遍历txlist中的每个结点,查看是否事件就绪,如果就绪就拷贝给epoll_wait调用时传递的用户数组上。对于LT模式,接下来会将还被关注的事件从txlist中拷贝回rdlist。对于ET模式,则不会被拷贝回rdlist。所以下次epoll_wait调用不会再直到之前就绪的事件。
借鉴:https://blog.csdn.net/u012861978/article/details/53224367
————————————————
版权声明:本文为CSDN博主「生若直木,不语斧凿ᝰ」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/zhanxiao5287/article/details/98091574