(Linux与计算机网络)IO模型 5种IO模型当我们想要进行IO操作时不一定有数据让我们读取也不一定有空间放我们写入的数据所以IO操作除了要拷贝数据还要等待时机即等待数据或者空间就绪。IO效率高指的是单位时间内传输的数据量大。IO效率低下一般是由于等待的时间过长。单位时间内IO操作的等待时长越少IO就越高效。因此阻塞IO和非阻塞IO的IO效率是没有区别的只是后者可以利用等待的时间去做其它事提高了整理的效率但没有提高IO的效率。IO肯定需要等待时机但是不同的IO模型处理等待的方式不同。阻塞IO很简单就是一直检测是否就绪一直等非阻塞IO则是每次只检测一次若失败了就直接返回先去执行其它任务执行完了再检测如此循环。信号驱动IO直接连检测都不检测让内核通过信号通知收到信号再处理。多路复用比较特殊它一次检测多个IO通道哪个先好就先处理哪个。前面4种由进程亲自处理IO操作的IO模型都属于同步IO异步IO则是直接让内核去进行IO操作完成了再通知。阻塞式IO非常简单信号驱动IO和异步IO已经很少使用我们主要讲多路转接和非阻塞IO非阻塞IOLinux系统下套接字socket、管道pipe、命名管道FIFO等都是被视为文件管理的而用户通过文件描述符对其进行IO所以一个文件描述符就对应一个IO通道。我们知道用户通过文件描述符进行IO操作其底层就是通过file结构体进行IO。file结构体的成员中有一个文件状态标记记录了该文件是否使用阻塞IO。通过接口 fcntl 获取并修改文件状态标记再设置回去就可以将 对该文件的IO操作 设置为非阻塞从而进行非阻塞IO。fcntl接口如下第一个参数是要操作的文件的文件描述符传入op的值不同后面追加的参数就不同。表示获取和设置文件状态标记的op值分别为F_GETFL和F_SETFL。使用示例如下第3行调用fcntl时会获取文件状态标记是一个位图并通过返回值返回。//将fd对应的文件设置为非阻塞 void SetNoBlock(int fd) { int fl fcntl(fd, F_GETFL); //获取文件状态标记 if (fl 0) { perror(fcntl); return; } fcntl(fd, F_SETFL, fl | O_NONBLOCK);//增设非阻塞 }将文件设置为非阻塞后IO接口的使用也要有所调整。例如使用read读取非阻塞式的文件时在很多种情况下返回的都是-1如读取出错和数据未就绪为了区分read会修改全局变量errno即设置一个错误码通过错误码的值即可区分返回-1时的具体情况。在读取非阻塞式文件时要根据errno的值对各个情况分别处理。多路转接select基本用法多路转接的系统调用接口是select它会一次检测多个fd文件描述符在设定的时间内有fd就绪时就会返回通知调用方有哪些fd已经就绪了。第一个参数nfds是要关心的fd中最大的fd的值1用于底层遍历文件描述符表。最后一个参数timeout是用timeval结构体表示的时间如下图单位分别是秒和微秒。如果在timeout表示的时间内没有一个fd就绪select就会立即返回0表示超时。如果将timeout的两个成员设置为0即等待时间为0select会将每个fd检测一遍如果没有就绪就立即返回即非阻塞式检测如果为nullptr则select会一直等待直到有fd就绪即阻塞式检测。select的返回值有三种大于0时表示的是就绪的fd个数等于0表示的是超时即在timeout指定的时间内没有fd就绪小于0为报错。我们知道不同的IO操作等待的事件不同读取操作等待的是底层有数据这个被称为读事件就绪写入操作等待的是底层有空间它被称为写事件就绪。在select接口中第二个参数readfds就用于指定 要检测读事件就绪的fd第三个参数writefds则是指定 要检测写事件就绪的fd。而第四个参数比较特殊它用于指定 要检测异常状态的fd如TCP协议的socket收到紧急数据时就会进入异常状态这个暂时不管。这三个参数即是输入型参数也是输出型参数它们的类型都是fd_set*fd_set是位图结构。以readfds为例输入时位图第一位为1表示0号fd要检测读事件就绪为0则不管该fd第二位对应的是1号fd第三位对应2号以此类推如下图检测到有fd就绪select返回时位图的某一位为1就表示对应的文件描述符已就绪不过具体是哪一位需要我们自己遍历一遍位图才知道。其它两个的用法也是类似。fd_set不能直接用位操作修改要用系统提供的宏如下。void FD_CLR(int fd, fd_set *set); //移除set中对fd的标记即对应位设为0 int FD_ISSET(int fd, fd_set *set); //检测set中是否标记了fd void FD_SET(int fd, fd_set *set); //将fd加入位图set即对应位设为1 void FD_ZERO(fd_set *set); //清空set将位图所有位设为0注意事项使用select需要注意几点select会修改位图所以要用一个辅助数组存储所有要关心的文件描述符。每次调用select之前通过辅助数组更新select参数的位图。其次如果要通过select实现多路转接那么进行读取操作时默认读事件不就绪必须先由select等待就绪了再进行读取。其它多路转接的接口也是如此。简单来说就是所有要进行读取的文件描述符都要让select关心待其返回后再根据位图确认是哪个fd就绪再调用对应的处理函数进行读取。如TCP的accept获取连接底层要有连接才能获取并返回本质就是在等待读事件所以监听套接字也要让select关心。只要想读取就要用select确认就绪。在TCP通信中由于不需要等待用户发送数据即使是单进程selectserver也可以处理多个连接。写入则有些不同多路转接中一般默认写事件是就绪的在网络通信中就是默认发送缓冲区有空间不一定要设置进selet或其它多路转接的接口进行等待按需设置即可。这是因为发送缓冲区一般不会写满如果设置关心写事件那么基本每次调用多路转接的接口就会有写事件就绪。所以直接写入即可如果写满了再把写关心设置进epoll。这样一次可能无法完成写入需要循环写入并根据实际写入的大小删除或跳过缓冲中已经写入的部分。还要注意设置写事件关心后如果后面完成了发送要把去掉写事件关心。select有许多缺点最主要的有两点其一是由于fd_set的大小有限所以select能关心的fd数量每次等待的fd数量有限。最多1024个。其二是由于输入和输出位置重叠每次调用select前都需要重新更新位图。为了更高效地使用多路转接后来又引入了poll在poll之后还有更高级的epoll。pollpoll也是一个多路转接的接口如下第一个参数fds是数组用于设置要关心的fd和事件后面展开讲。第二个参数nfds则是数组fds中元素的个数。timeout用于表示超时时间单位ms,设为-1表示阻塞式等待。struct pollfd定义如下我们使用poll时在外部创建好struct pollfd数组为每一个要关心的fd初始化数组中的sruct pollfd完成后将数组传入poll的参数fds参数nfds填写数组中有效的pollfd数量即可。struct pollfd { int fd; /* 文件描述符 */ short events; /* 要关心的事件*/ short revents; /* poll返回时就绪的事件*/ };poll内部会根据数组中每一个pollfd的events确定该fd要关心的事件各个值对应的事件如下一般只需注意最上面的POLLIN和最下面的POLLOUT即可分别表示关心读事件和写事件。如果把short类型看成位图下表中不同事件的值只占位图中的一位。所以要关心一个fd的多个事件时就用按位或进行合并。poll返回时会将就绪事件的值设置进文件描述符对应的pollfd的revent成员中。我们在调用前初始化数组时不用管这个成员。不难发现相比于selectpoll将输入和输出的位置分离了调用前不需要什么重置操作更加方便。并且由于使用的是数组所以等待的fd个数没有上限但是当数量过大时性能仍然会下降。其次每次调用poll时需要把大量的pollfd结构从用户态拷贝到内核中poll是系统调用接口内核和用户的内存是相互隔离的。而且大量fd在同一时刻可能只有少数处于就绪状态随着要监视的描述符数量增长poll的效率也会不断下降。为此后来又引入了epoll。epollepoll底层维护了一个红黑树和一个就绪队列并且不再是使用单一的接口我们先了解主要的几个接口的大致用法。epoll_create创建epoll创建epoll使用的是epoll_create返回的是epoll结构本身的文件描述符用于后面管理epoll。参数size现在在epoll_create内部已经被忽略了不用管随便设置一个正整数即可。epoll_ctl控制epoll控制epoll就是操作其内部的fd的接口是epoll_ctl该接口成功返回0失败返回-1第一个参数传入前面epoll_create返回的epoll文件描述符第二个是操作选项有三种分别是增添fd的EPOLL_CTL_ADD、修改fd要关心的事件的EPOLL_CTL_MOD、移除关心的fd的EPOLL_CTL_DEL此时event可以为NULL。要注意用epoll_ctl移除对fd的关心时fd必须是有效的不能关闭fd后才调用epoll_ctl移除关心。第三个参数用于指定要操作的fd最后一个参数用于指定要操作的事件如读就绪、写就绪。struct epoll_event的定义如下event用于指定事件具体的值及其解释在后面。成员data是epoll_data联合体类型用来存储epoll_event的相关信息由用户自己设置epoll内部不会修改也不会使用最主要的就是存储epoll_event属于哪个fd。后面调用epoll_wait获取就绪的事件时只会返回epoll_event此时就需要通过data来确认是哪个fd在TCP通信中可能还需要保存客户端信息。需要存储多种信息时可以定义一个结构体把data设置为该结构体的指针。struct epoll_event { uint32_t events; /* 用于指定事件 */ epoll_data_t data; /*用户自定义的数据内核(epoll)不会修改 */ }; union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; };成员events用法如下知道最上面两个即可。宏值十六进制意义EPOLLIN0x001文件描述符可读包括对端关闭连接、有新数据到达EPOLLOUT0x004文件描述符可写发送缓冲区有空间可以写入数据EPOLLRDHUP0x2000对端关闭连接或半关闭TCP 收到 FIN需要 2.6.17 内核EPOLLPRI0x002有紧急数据可读如 TCP 带外数据EPOLLERR0x008错误发生默认监听无需显式添加EPOLLHUP0x010挂起对端主动关闭连接管道或 socket 被挂断默认监听EPOLLET0x80000000边缘触发模式Edge Triggered默认是水平触发 LTEPOLLONESHOT0x40000000一次性触发事件触发一次后自动从监听集合中移除epoll_wait等待事件等待事件的接口是epoll_wait第一个参数传入epoll自己的文件描述符第二个参数events是输出型参数需要用户创建数组并传入epoll_wait会在events内填写已就绪的文件描述符maxevents用于表示数组的大小它决定了一次最多能返回多少事件。timeout用于设置超时时间单位ms返回值有三种大于0时表示的是就绪的fd个数等于0表示的是超时即在timeout指定的时间内没有fd就绪小于0为报错。IO就绪时的通知机制在了解epoll底层之前我们需要先了解Linux读写事件就绪的通知机制。我们知道当文件可读或可写时需要通知上层进行相应的IO操作。但是要明确的是对普通文件的读写几乎不会阻塞想IO就可以IO不需要等待更不需要通知下面的“文件”指的是socket的接收、发送缓冲区、管道pipe、IO设备如键盘的缓冲区等被封装成文件的IO对象。在内核中每个文件都有自己的等待队列如果有进程要对该文件进行阻塞式IO对应的IO系统调用如select会创建一个节点里面存放回调方法将其接入该文件的等待队列然后进入阻塞等待此时进程就阻塞住了。文件就绪时内核就会唤醒对应的进程调用等待队列中节点的回调方法完成IO操作。前面提到可以通过修改file结构体的文件状态标记将对该文件的IO设置为非阻塞。但是如果参数指定要进行阻塞等待timeout ! 0即使文件本身是非阻塞模式select、epoll_wait等多路转接接口仍然会按照其 timeout 参数的要求阻塞等待事件。文件非阻塞模式不影响多路转接的阻塞行为。最后一个文件有多个等待队列例如读取和写入的等待队列就不是同一个。epoll模型epoll底层是一个红黑树和一个就绪队列红黑树中一个节点对应一个要关心的fd而就绪队列中的一个节点表示一个就绪的fd。调用epoll_create时就会创建eventpoll结构体下图右上角其内部包含了红黑树的根节点指针和就绪队列的头节点指针一开始没有关心的fd时两个数据结构都是空的。当我们调用epoll_ctl添加要关心的fd时其内部会为该fd创建一个结构体epitem将其成员rbn接入红黑树。当调用epoll_wait时其内部就是在检查就绪队列是否为空如果不为空就将就绪队列中的节点对应的epitem拷贝到用户从参数传入的缓冲区如果为空则直接阻塞。前面提到对文件进行阻塞式IO时需要对应的系统调用向其等待队列添加节点文件就绪时通过调用节点的回调方法唤醒进程进行IO。我们调用epoll_ctl添加关心的fd时其内部就会进行上述的操作比较特殊的是epoll_ctl在等待队列中注册的回调方法会唤醒进程如果文件就绪时进程被epoll_wait阻塞但不会进行IO毕竟epoll_ctl 就不是用来读写的。这个回调方法会将红黑树中对应节点的epitem的rdllink成员接入就绪队列。简单来说就是让epitem同时处于红黑树和就绪队列一开始只在红黑树里这个操作我们简称激活节点。那么怎么知道要激活哪个节点呢在调用epoll_ctl时其底层除了创建epitem结构体将其成员rbn设为红黑树的节点还会为该节点注册一个回调函数这个回调函数专门用于激活该节点将该回调函数接入对应的等待队列后文件就绪、调用回调时就会激活红黑树的对应节点。 这个红黑树就绪队列回调机制的模型就是epoll模型。这种将就绪的数据放入缓存队列的机制和生产者消费者相似不仅如此和生产者消费者模型一样epoll结构也是线程安全的。select和poll效率低下因为其原理是遍历一遍用户传入的fd向所有对应的等待队列注册回调文件就绪后还要将回调方法从等待队列中移除否则如果下次用户要关心的fd不同了进程会被不再关心的fd唤醒。并且每次调用都需要把用户传入的fd信息全部拷贝到内核的内存。因为select和poll是系统调用接口系统调用使用的是内核的内存与用户的内存是相互分隔的。而epoll只有用户调用epoll_clt增加要关心的节点时才会将fd信息拷贝到内核epoll_wait每次只需查看就绪队列不用遍历所有fd并且只有用户调用epoll_ctl删除fd时才会移除fd对应文件的等待队列的回调方法。epoll工作模式epoll有两种工作模式一种是水平触发LTLevel Triggerred一种是边缘触发ETEdge Triggered。在LT模式下用户调用epoll_wait获取就绪队列中的就绪事件后只有当用户处理完就绪事件内核才会将对应的事件从就绪队列中移除。例如当socket的接收缓冲区收到数据后内核调用等待队列的回调将epoll红黑树的对应epitem节点激活到就绪队列epoll_wait将就绪队列的节点对应的事件返回此时内核不会立即将就绪队列中的节点移除而是动态检查底层的接收缓冲区中的数据是否读取完毕读取完毕了才会移除否则不会。没有移除时用户再次调用epoll_wait由于就绪队列中仍有该事件的节点所以epoll_wait仍会返回该事件。那么此时在用户看来LT模式下就算一次没有把数据读取完下次epoll_wait还会返回该事件可以慢慢处理数据。如果是在ET模式下则相反epoll_wait将就绪队列中的事件返回后不管用户会不会处理完内核会立即将队列中对应的节点移除下次调用epoll_wait时epoll_wait就不会把该事件返回了这就迫使用户必须一次将数据读取完由于缓冲区中的数据可能会很多用户用于暂存数据的缓存不够所以必须循环读取。而写入操作相比与LT模式则没什么变化因为一次写入没有把数据写完是底层缓冲区的问题不是用户的问题缓冲区空间不够了才会一次写不完把缓冲区写满也算处理完写事件。需要补充的是epoll结构是在内核中维护内核随时可以操作就绪队列所以即使用户没有再调用epoll_wait或者其它的接口内核仍然可以选择是否移除就绪队列中的节点。使用ET模式需要注意要让epoll关心的所有fd必须设置为非阻塞。我们知道ET模式下是循环读取的如果文件是阻塞式的那么当缓冲区读完时用于读取数据的接口如read、recv就会阻塞住只要进程被IO接口阻塞了那就不是多路转接。ET与LT相比看起来没什么优势但它可以通过独特的机制让程序员必须一次处理完所有数据在TCP通信中这样可以高效利用缓冲区空间、提高发送量进而提高TCP的IO效率。