【I/O模型】什么是IO多路复用?
什么是IO多路复用
什么是IO多路复用:单线程或单进程同时检测若干文件描述符是否可以执行IO操作的能力。
使用场景:
应用程序需要处理来自多条事件流中的事件,比如web服务器入nginx,需要同时处理来自N个客户端的事件。
逻辑控制流在时间上的重叠叫做并发。
传统方法是使用多线程或多进程来处理,但是资源开销成本很大:
1.线程/进程创建成本
2.CPU切换不同线程/或进程成本
3.多线程资源竞争
而IO多路复用是一种可以在单线程/进程处理多个事件流的方法。所以IO多路复用解决的本质问题是在用更少的资源完成更多的事。
I/O模型
目前Linux系统中提供了5种IO处理模型
1.阻塞IO
2.非阻塞IO模型
3.IO多路复用
4.信号驱动IO
5.异步IO
阻塞I/O
最常用的简单的IO模型。阻塞IO意味着当我们发起一次IO操作后一直要等待成功或失败之后才返回,在这期间程序不能做其他的事情。阻塞IO操作只能对单个文件描述符进行操作,比如:read或write
非阻塞I/O
在发起IO时,通常对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO一般采用for循环轮询的方式,要么操作成功,要么当IO阻塞时返回错误EWOULDBLOCK/EAGAIN,这种轮询方式的操作会浪费很多不必要的CPU资源,也只能对单个描述符进行操作
信号驱动I/O
信号驱动IO时利用信号机制,内核告知应用程序文件描述符的相关事件。
但是信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,以下事件都会导致SIGIO信号的产生:
1.TCP连接建立
2.一方断开TCP连接请求
3.断开TCP连接请求完成
4.TCP连接半关闭
5.数据已到达TCP Socket
6.数据已发送出去(如:写Buffer有空余空间)
以上情况都会产生SIGIO信号,所以没有办法在SIGIO对应的信号处理函数中区分不同的事件,SIGIO只应该在IO事件单一情况下使用,比如用来监听端口的socket,因为只有客户端发起的新连接的时候才会产生SIGIO信号。
异步I/O
异步IO和信号驱动IO差不多,相比信号驱动IO需要在程序中完成数据从用户态到内核态的拷贝,异步IO可以把拷贝这一步也完成后才通知应用程序,比如:aio_read,aio_write
同步IO和异步IO
1.同步IO和是指程序会一直阻塞到IO操作如read、write完成
2.异步IO指的是IO操作不会阻塞当前程序的继续执行
所以阻塞IO,非阻塞IO都是同步IO,因为文件操作符可用时我们还是需要阻塞的读或写,同理IO多路复用和信号驱动IO也是同步IO,只有异步IO是完全完成了数据的拷贝之后才通知程序进行处理,没有阻塞数据的读写过程。
目前的I/O模型方案
各平台I/O复用方案
Linux:select、poll、epoll
MacOS/FreeBSD:kqueue
Windows:IOCP
常见的软件IO多路复用方案
redis:Linux下epoll(状态持续通知),没有epoll的低版本内核使用select
nginx:Linux下epoll (状态变化通知),没有epoll的低版本内核使用select
Linux下IO复用使用方法
主要介绍Linux下的解决方案:
select
相关接口和宏
#include <sys/select.h>
#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 *timerout);
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);
void FD_CLR(int fd, fd_set *set);
int FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);
参数说明
select调用会阻塞到有文件描述符可以进行IO操作或被信号打断或着超时才会返回。
nfds:是所有加入文件描述符集的最大那个值还要加1。比如我们的文件描述符为1、4、5,那么maxfdp1就应该设置为6。maxfdp1存在的目的是为提高效率,使函数不必检查fd_set的所有1024 bits。
select:将监听的文件描述符分为3组,每一组监听不同的需要进行的IO操作。
readfds:是需要进行读操作的文件描述符
writefds:是需要进行写操作的文件描述符
exceptfds:是需要进行异常事件处理的描述符。
timerout:timeval结构体的指针,timeval指定了秒数和微秒数。
readfds,writefds,exceptfds,timerout这四个参数可以用NULL来表示对应的事件不需要监听。
timeval有三种可能:
1.timeval=NULL(阻塞式:直到有一个fd位被置为1,函数才返回)
2.timeval所指向的结构设为非零时间(等待固定时间:有一个fd位被置为1或者时间耗尽,函数均返回)
3.timeval所指向的结构,时间设为0(非阻塞:函数检查完每个fd后立即返回)
FD_XX系列函数是用来操作文件描述符组和文件描述符的关系。
FD_ZERO用来清空文件描述符组:
fd_set writefds;
FD_ZERO(&writefds);
FD_SET添加一个文件描述符到组中,FD_CLR对应将一个文件描述符移出组中
FD_SET(fd, &writefds);
FD_CLR(fd, &writefds);
FD_ISSET检测一个文件描述符是否在组中,我们用这个来检测一次select调用之后有哪些文件描述符可以进行IO操作
if (FD_ISSET(fd, &readfds)){
/* fd可读 */
}
select可以同时监听的文件描述符数量是通过FS_SETSIZE来限制的,在linux系统中,此值为1024,这个值可以修改,但随着监听的文件描述符数量增加,select效率会降低。
使用流程
1.先调用宏FD_ZERO将指定的fd_set清零,
2.然后调用宏FD_SET将需要测试的fd加入fd_set,
3.调用函数select测试fd_set中的所有fd,并根据状态修改fd_set对应位,
4.宏FD_ISSET检查某个fd在函数select调用后,相应位是否仍然为1。
示例代码
实现客户端写,服务端读并把小写转大写,再写回客户端的功能
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <ctype.h>
#define SERV_PORT 6666
int main(int argc, char *argv[])
{
int listenfd, connfd;
char buf[BUFSIZ], str[INET_ADDRSTRLEN];
struct sockaddr_in clie_addr, serv_addr;
socklen_t clie_addr_len;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //SO_REUSEADDR允许重用本地地址端口
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(SERV_PORT);
bind(listenfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
listen(listenfd, 128);
fd_set rset, allset;
int ret, maxfd = 0;
maxfd = listenfd;
FD_ZERO(&allset); //集合全部设为0
FD_SET(listenfd, &allset);
while (1)
{
rset = allset;
ret = select(maxfd + 1, &rset, NULL, NULL, NULL); //返回有事件发生的个数
if (FD_ISSET(listenfd, &rset))
{
clie_addr_len = sizeof(clie_addr);
connfd = accept(listenfd, (struct sockaddr *)&clie_addr, &clie_addr_len);
printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)), ntohs(clie_addr.sin_port));
FD_SET(connfd, &allset); //将一个集合中的事件“加入”
if (maxfd < connfd)
maxfd = connfd;
if (ret == 1)
continue; //说明select只返回一个,并且只是listenfd,无需后续执行
}
int n = 0;//read读到的字节数
for (int i = listenfd + 1; i < maxfd + 1; ++i)
{
if (FD_ISSET(i, &rset)) //判断文件描述符是否在集合中
{
n = read(i, buf, sizeof(buf));
if (n == 0)
{
close(i);
FD_CLR(i, &allset); //将一个集合中的事件“拿走”
}
else if (n == -1)
{
}
else
for (int j = 0; j < n; ++j)
buf[j] = toupper(buf[j]);
write(i, buf, n);
write(STDOUT_FILENO, buf, n);
}
}
}
close(listenfd);
return 0;
}
poll
poll简介
poll是一种I/O多路复用机制,它可以同时监视多个文件描述符,当其中任意一个文件描述符就绪时,就会通知程序进行相应的读写操作。
poll机制与select机制类似,但是poll没有最大文件描述符数量的限制,因此在文件描述符数量较大时,poll的效率会更高。
poll机制的使用需要调用系统调用poll()函数,该函数会阻塞进程直到有文件描述符就绪或者超时。
poll()函数的参数是一个pollfd结构体数组,每个结构体中包含了一个文件描述符和该文件描述符所关注的事件类型。
poll实现原理
- 用户将想要监听的socket文件绑定struct pollfd对象,并注册监听事件至struct pollfd对象events成员,监听多个socket文件使用struct pollfd数组。
- 用户通过struct pollfd数组注册poll事件至poll_list链表,poll_list链表单个元素可以存储固定数量的struct pollfd对象。
- poll系统调用采用轮询方式获取socket事件信息,一次poll调用需完成整个poll_list链表轮询工作,轮询socket的过程中会创建socket等待队列项,并加入socket等待队列(用于socket唤醒进程)。如果检测到socket处于就绪状态,将socket事件保存在struct pollfd对象的revents成员。
- poll系统调用完成一次轮询后,如果检测到有socket处于就绪状态,则将poll_list链表所有的struct pollfd通过copy_to_user拷贝至用户struct pollfd数组。如果未检测到有socket处于就绪状态,根据超时时间确定是否返回或者阻塞进程。
- socket检测到读,写,异常事件后,会通过注册到socket等待队列的回调函数poll_wake将进程唤醒,唤醒的进程将再次轮询poll_list链表。
poll相关函数定义
#include <poll.h>
// 函数原型
int poll(struct pollfd *fds,nfds_t nfds,int timeout);
函数功能:
poll函数是Linux系统中的一种I/O多路复用机制,它可以同时监视多个文件描述符。
参数说明:
fds:监听事件结构体数组。
nfds:监听事件结构体数组长度。
timeout:
等于-1:一直阻塞。
等于0:立即返回。
大于0:等待超时时间,单位毫秒。
返回值:
成功:返回检测到的文件描述符数量。
失败:返回-1,设置errno。
超时:返回0。
poll事件定义
#define POLLIN 0x0001
#define POLLPRI 0x0002
#define POLLOUT 0x0004
#define POLLERR 0x0008
#define POLLHUP 0x0010
#define POLLNVAL 0x0020
#define POLLRDNORM 0x0040
#define POLLRDBAND 0x0080
#define POLLWRNORM 0x0100
#define POLLWRBAND 0x0200
#define POLLMSG 0x0400
#define POLLREMOVE 0x1000
#define POLLRDHUP 0x2000
#define POLLFREE 0x4000
#define POLL_BUSY_LOOP 0x8000
pollfd结构体
和select用三组文件描述符不同,poll只有一个pollfd数组,数组的每一个元素都表示一个需要监听IO操作事件的文件描述符。events参数是需要关心的事件,revents是所有内核监测到的事件。
// pollfd结构体
struct pollfd
{
int fd; // 监听文件描述符。
short events; // 监听事件集合,用于注册监听事件。
short revents; // 返回事件集合,用于存储返回事件。
};
poll编程模型
epoll
epoll相关函数定义
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
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);
int epoll_pwait(int epfd,struct epoll_event *events,
int maxevents,int timeout,
const sigset_t *sigmask);
epoll_create和epoll_create1用于创建一个epoll实例,而epoll_ctl用于往epoll实例中增删改要检测的文件描述符,epoll_wait则用于阻塞的等待可以执行IO操作的文件描述符直到超时。
不同I/O多路复用方案优缺点
poll vs select
poll相比select的优势:
1.poll传参对用户更友好,不需要和select一样计算nfds(值最大的文件描述符+1),而且不需要分开三组传入参数
2.poll性能比select稍好,因为select每个bit位都检测,假设有个值为1000的文件描述符,select会从第一位开始检测一直检测到1000个bit位,但poll检测的是一个数组
3.select的时间参数在返回的时候各个系统的处理方式不统一,如果希望程序可移植性好,需要每次调用select都初始化时间参数。
select相比poll的优势:
1.支持select的系统更多,兼容更强大,有些unix系统不支持Poll
2.select提供及高度更高(miscrosecond)的超时时间,而poll只提供ms的精度
总体而言:select和poll基本一致
epoll vs poll&select
epoll相比于select&poll的优势:
1.在需要同时监听的文件描述符数量增加时,select&poll是O(n)复杂度,epoll是O(1),在N很小的情况下,差距不会特别的大,但是如果在N很大前提下,一次O(n)的循环比O(1)要慢很多,所以高性能网络服务器都会选择epoll进行IO多路复用
2.epoll内部用一个文件描述符挂载需要监听的文件描述符,这个epoll的文件描述符可以在多个线程/进程中共享,所以epoll的使用场景比select/poll要多。
参考文章:
https://zhuanlan.zhihu.com/p/115220699
https://zhuanlan.zhihu.com/p/668363501