[TOC]

​ 我们常说的IO,指的是文件的输入和输出。在操作系统层面,IO就是:将文件磁盘中通过内核空间,将文件拷贝到用户空间里面,涉及三个位置:磁盘、内核空间、用户空间。本文主要参考了《Linux高性能服务器编程》。

一、5种IO模型

​ 在操作系统中,我们将I/O操作描述为:

数据内核缓冲区读入用户缓冲区

或将数据用户缓冲区写入内核缓冲区

​ 我们通常所说的阻塞I/O实际上指的是阻塞的文件描述符,非阻塞I/O指的是非阻塞的文件描述符,依次类推

​ 操作系统中共存在5种I/O模型,大体上可以分为同步I/O和异步I/O,两者的区别是:

同步I/O要求用户代码自行执行I/O操作,即同步I/O向应用程序通知的是I/O就绪事件,都是在I/O时间发生之后,由应用程序来完成读写操作。

异步I/O则由内核代码自动执行I/O操作,即异步I/O向应用程序通知的是I/O完成事件,异步I/O总是立即返回,不论是否阻塞,因为真正的读写操作已经由内核接管。

​ 其中同步I/O可以进一步划分为阻塞I/O和非阻塞I/O,但是非阻塞I/O一般不会单独使用,需要结合一些具体的I/O通知机制,例如I/O复用机制、信号驱动I/O机制。

​ 所以在Linux学习资料中一般会介绍阻塞I/O非阻塞I/OI/O复用信号I/O异步I/O的5种I/O模型。

1、阻塞IO

阻塞I/O执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生为止。

​ 【常见优点】在阻塞等待过程中不消耗CPU资源,提高程序性能。

​ 【常见缺点】阻塞I/O只能串行,不适合大量并发场景,例如socket默认创建出来是阻塞I/O,这样socket server每连接一个新的socket client都必须使用一个新的进程/线程去处理它的读写问题,那么client足够多时不然会造成性能的下降。例如经典的C10K问题,意思是使用在一台服务器上维护1w个连接,需要建立1w个进程或者线程。那么如果维护1亿用户在线,则需要1w台服务器。

​ 【应用场景】适合串行场景,例如各种编程语言提供的wait()、pause()、sleep()等函数。

​ 【编码场景】:

// 先读取鼠标设备的输入
memset(buf,0,sizeof(buf));
fd = open("/dev/input/mouse0",O_RDONLY|O_NONBLOCK);//获取鼠标设备文件描述符
ret = read(fd,buf,5);//read设置为阻塞,有数据才返回
printf("鼠标读出的内容是:[%s]\n",buf);
// 再读取键盘设备的标准输入
memset(buf,0,sizeof(buf));
ret = read(0,buf,5);//read设置为阻塞,有数据才返回
printf("键盘读出的内容是:[%s]\n",buf);

2、非阻塞IO

非阻塞I/O也叫并发式IO,执行的系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1,和出错的情况一样。此时程序员必须根据errno来区分这两种情况。

​ 【常见优点】适合用于并发场景

​ 【常见缺点】非阻塞I/O需要不断检查,可能会消耗大量CPU资源

​ 【应用场景】非阻塞I/O适用于并发场景,但一般不会单独使用,要和其他I/O通知机制一起使用,比如I/O复用和SIGIO信号。

​ 【编码场景】:

......
while(1) // 不断检查
{
    // 立即读取鼠标设备的输入
    memset(buf,0,sizeof(buf));
    fd = open("/dev/input/mouse0",O_RDONLY|O_NONBLOCK);//获取鼠标设备文件描述符
    ret = read(fd,buf,5);//read设置为非阻塞,没有数据也返回
    if(ret > 0){
        printf("鼠标读出的内容是:[%s]\n",buf);
    }
    // 立即读取键盘设备的标准输入
    memset(buf,0,sizeof(buf));
    ret = read(0,buf,5);//read设置为非阻塞,没有数据也返回
    if(ret > 0){
        printf("键盘读出的内容是:[%s]\n",buf);
    }
}
......

3、IO复用

​ 阻塞I/O需要多进程/线程才能用于并发场景,普通的非阻塞I/O需要在应用程序中不断检查才能用于并发场景,两者在并发场景中都不高效。I/O复用是并发场景中最常使用的I/O通知机制。I/O复用的原理是:

应用程序通过I/O复用函数向内核程序注册一组事件集合;

内核程序不断轮询事件集合,通过I/O复用函数把其中就绪的事件通知给应用程序。

​ 本质上IO复用将普通的非阻塞I/O在应用程序中的轮询通过I/O复用函数交给了内核程序,所以其可以在一个进程/线程中同时处理多个I/O操作。

​ Linux上常用的I/O复用函数是select、poll和epoll_wait。

​ 需要指出的是,I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们可以在同一个线程/进程中具有同时监听多个I/O事件的能力,所以IO复用可以解决阻塞I/O不能用于大量并发的缺陷。

​ 【常见优点】非常适合用于并发场景

​ 【常见缺点】实现复杂。

​ 【应用场景】网络socket。

3、信号IO

​ SIGIO信号也可以辅助非阻塞I/O报告I/O事件。我们可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到SIGIO信号。这样,当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发,也就可以在该信号处理函数中对目标文件描述符执行非阻塞I/O操作了。

​ 目前在linux程序开发中很少被用到,Linux内核某个IO事件ready,通过kill出一个signal,应用程序在signal IO上绑定处理函数。

//异步通知函数,绑定SIGIO信号,在函数内处理异步通知事件
void func(int sig){
    char buf[200] = {0};
    if(sig != SIGIO)return;
    //读取鼠标
    read(mousefd,buf,5);//read默认是阻塞的
    printf("鼠标读出的内容是:[%s]\n",buf);
}
int main(void){
        char buf[200];
		//获取鼠标设备文件描述符
        mousefd = open("/dev/input/mouse0",O_RDONLY);
        //注册异步通知(把鼠标的文件描述符设置为可以接受异步IO)
        int flag = fcntl(mousefd,F_GETFL);
        flag |= O_ASYNC;
        fcntl(mousefd,F_SETFL,flag);
		fcntl(mousefd,F_SETOWN,getpid());//把异步IO事件的接收进程设置为当前进程
        //注册当前进程的SIGIO信号捕捉函数
        signal(SIGIO,func);
    	// 执行其他程序	
    	while(1){	
            ....
        }
        return 0;
}

5、异步IO

​ 异步IO就是操作系统用软件实现的一套中断系统。 ​ 异步IO的工作方法:我们当前进程注册一个异步IO事件(使用signal注册一个信号SIGIO的处理函数),然后当前进程可以正常处理自己的事情,当异步事件发生后当前进程会收到一个SIGIO信号从而执行绑定的处理函数去处理这个异步事件。

​ 异步 I/O 与信号 I/O 的区别在于,异步 I/O 的信号是*通知应用进程 I/O 完成*,而信号驱动 I/O 的信号是*通知应用进程可以开始 I/O*

二、IO复用

1、select

​ select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件。 它把1000个fd加入到fd_set(文件描述符集合),通过select监控fd_set里的fd是否有变化。如果有一个fd满足读写事件,就会依次查看每个文件描述符,那些发生变化的描述符在fd_set对应位设为1,表示socket可读或者可写。Select通过轮询的方式监听,对监听的FD数量 t通过FD_SETSIZE限制。

#include<sys/select.h>
int select(intnfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,struct timeval*timeout);

​ 1)nfds参数指定被监听的文件描述符的总数。它通常被设置为select监听的所有文件描述符中的最大值加1,因为文件描述符是从0开始计数的。

2)readfds、writefds和exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符。select调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。这3个参数是fd_set结构指针类型。

​ 【优点】非常适合用于并发场景

​ 【缺点】效率低,性能不太好,不能解决大量并发请求的问题。两个问题:

1、select初始化时,要告诉内核,关注1000个fd, 每次初始化都需要重新关注1000个fd。前期准备阶段长。 2、select返回之后,要扫描1000个fd。 后期扫描维护成本大,CPU开销大。

2、poll

​ poll()是一个系统调用函数,和select类似,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。用法如下:

(1)定义fds数组

假设我们要监控5个socket描述符,其中1个是服务端socket描述符,4个是客户端socket描述符。那么首先定义一个数组:

pollfd fds[5];//要监控的文件描述符数组
// 设置第一个为服务端socket描述符:listenfd
fds[0].fd=listenfd; 
fds[0].events=POLLIN|POLLERR; //监控可读、错误类型的事件
fds[0].revents=0; 
// 初始化其余的客户端socket描述符,其还没连接客户端
for(int i=1;i<=USER_LIMIT;++i) {
    fds[i].fd=-1; 
    fds[i].events=0; 
}

其中每个元素是一个pollfd结构体类型,其定义如下:

struct pollfd{
	int fd;			//文件描述符
	short events;	//等待的事件
	short revents;	//实际发生的事件
};

(2)调用poll函数

接下来就可以调用poll函数,该函数会在内核程序中注册一系列事件表,阻塞等待某一个事件发生,一旦有事件发生就会返回,由于我们要多次监控时间,所以要放入while(1)循环中。

while(1) {
	// 系统调用
    ret=poll(fds,5,-1);//阻塞等待 
    if(ret<0) {
        printf("poll failure\n"); 
        break; 
    }
    // 处理各类事件
}

poll函数原型如下:

#include<poll.h>
int poll(struct pollfd*fds,nfds_t nfds,int timeout);

1)fds参数是一个pollfd结构类型的数组头地址,

2)nfds参数用来指定第一个参数数组元素个数

3)timeout参数指定等待的毫秒数,无论 I/O 是否准备好,poll() 都会返回

(3)处理各类事件

接下来继续在while(1)循环中遍历fds数组,看哪个事件发生就处理某个事件:

int user_counter=0; 
for(int i=0;i<5;++i) {
     // 服务端的可读事件:处理客户端连接请求
     if((fds[i].fd==listenfd)&&(fds[i].revents&POLLIN)) {
         int connfd=accept(listenfd,(struct sockaddr*)NULL,NULL);
         setnonblocking(connfd);//设置connfd为非阻塞模式
         user_counter++;
         fds[user_counter].fd=connfd; 
         fds[user_counter].events=POLLIN|POLLRDHUP|POLLERR; //可读、关闭、错误
         fds[user_counter].revents=0;
     }
     // 客户端的关闭事件
     else if(fds[i].revents&POLLRDHUP) {
         close(fds[i].fd);//关闭该socket
         fds[i]=fds[user_counter]; //清除
         i--; 
         user_counter--;
     }
     // 客户端的可读事件
     else if(fds[i].revents&POLLIN){
         // 接收该客户端的消息到缓存中
         int connfd=fds[i].fd; 
         recv(connfd,users[connfd].buf,BUFFER_SIZE-1,0); 
         ...
         // 遍历所有的其余客户端,设置其可写事件
         for(int j=1;j<=user_counter;++j) {
             if(fds[j].fd==connfd) {continue; }
             fds[j].events|=~POLLIN; //去除其可读事件
             fds[j].events|=POLLOUT; //注册其可写事件
             users[fds[j].fd].write_buf=users[connfd].buf; 
         }
     }
     // 客户端的可写事件
     else if(fds[i].revents&POLLOUT){
         // 将缓存中的消息发送出去
         int connfd=fds[i].fd; 
         ret=send(connfd,users[connfd].write_buf,strlen(users[connfd].write_buf),0); 
         users[connfd].write_buf=NULL; 
         /*写完数据后需要重新注册fds[i]上的可读事件*/ 
         fds[i].events|=~POLLOUT; 
         fds[i].events|=POLLIN; 
     }
   	 // 服务端或者客户端的错误事件
     else if(fds[i].revents&POLLERR){
         int connfd=fds[i].fd; 
         char errors[100]; 
         memset(errors,'\0',100); 
         socklen_t length=sizeof(errors); 
         // 获取错误消息
         if(getsockopt(connfd,SOL_SOCKET,SO_ERROR,&errors, &length)<0) {
             printf("get socket option failed\n"); 
         }
     }

3、epoll

​ epoll是Linux特有的IO复用函数 ,其实现和使用与select或者poll有较大差异,其性能也更加高效。epoll机制需要多个系统调用。

​ epoll对文件描述符的操作有两种模式:LT模式(Level Trigger,电平触发)ET模式(Edge Trigger,边沿触发)。LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。当往epoll内核事件表中注册

一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式。

LT:epoll的默认工作模式。epoll_wait每个事件会通知多次,应用程序可以不立即处理

ET:epoll的高效工作模式。epoll_wait每个事件只会通知一次,应用程序必须要立即处理

​ ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。

(1)创建epoll模型

主要代码如下:

int epollfd=epoll_create(5); 
assert(epollfd!=-1); 

其中的关键API如下:

#include<sys/epoll.h>
int epoll_create(int size);

size参数现在并不起作用,只是给内核一个提示,告诉它事件表需要多大。

该函数返回一个epoll文件描述符,用来唯一指定要访问的内核事件表

(2)操作epoll模型

主要代码如下:

addfd(epollfd,listenfd,true);//监控listenfd
/*将文件描述符fd上的EPOLLIN注册到epollfd指示的epoll内核事件表中*/ 
void addfd(int epollfd,int fd,bool enable_et) {
    epoll_event event; 
    event.data.fd=fd;//要监控的listenfd
    event.events=EPOLLIN;// 可读事件
    if(enable_et) {event.events|=EPOLLET; }//指定是否对fd启用ET模式
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&event); //注册epoll事件
    setnonblocking(fd); //设为非阻塞模式
}
/*将文件描述符设置成非阻塞的*/
int setnonblocking(int fd) {
    int old_option=fcntl(fd,F_GETFL); 
    int new_option=old_option|O_NONBLOCK; 
    fcntl(fd,F_SETFL,new_option); 
    return old_option; 
}

其中的关键API如下:

#include<sys/epoll.h>
int epoll_ctl(int epfd,int op,int fd,struct epoll_event*event)

fd参数是要操作的文件描述符,op参数则指定操作类型。操作类型有如下3种:

EPOLL_CTL_ADD,往事件表中注册fd上的事件。

EPOLL_CTL_MOD,修改fd上的注册事件。

EPOLL_CTL_DEL,删除fd上的注册事件。

​ event参数指定事件,它是epoll_event结构指针类型。epoll_event的定义如下:

结构体
struct epoll_event{
	__uint32_t events;/*epoll事件*/
	epoll_data_t data;/*用户数据*/
};
// 联合体
typedef union epoll_data {
    void*ptr;
    int fd;//目标文件描述符
    uint32_t u32;
    uint64_t u64;
 }epoll_data_t;

epoll_ctl成功时返回0,失败则返回-1并设置errno。

(3)检测epoll事件

主要代码如下:

epoll_event events[MAX_EVENT_NUMBER];//存储监听到的事件
while (1)
{
    int ret=epoll_wait(epollfd,events,MAX_EVENT_NUMBER,-1); // 阻塞监听事件
    if(ret<0) {
        printf("epoll_wait failure\n"); 
        break; 
    }
    lt(events,ret,epollfd,listenfd);/*使用LT模式*/ 
    //et(events,ret,epollfd,listenfd);/*使用ET模式*/ 
}

其中的关键API如下:

#include<sys/epoll.h>
int epoll_wait(int epfd,struct epoll_event*events,int maxevents,int timeout);

​ epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到它的第二个参数events指向的数组中。这个数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。

​ epoll获取事件的时候,无须遍历整个被侦听的描述符集,只要遍历那些被内核I/O事件异步唤醒而加入就绪队列的描述符集合。

(4)LT模式和ET模式

LT模式:

/*LT模式的工作流程*/ 
void lt(epoll_event*events,int number,int epollfd,int listenfd) {
    char buf[BUFFER_SIZE]; 
    for(int i=0;i<number;i++) {
        int sockfd=events[i].data.fd; 
        if(sockfd==listenfd) {
            struct sockaddr_in client_address; 
            socklen_t client_addrlength=sizeof(client_address); 
            int connfd=accept(listenfd,(struct sockaddr*)&client_address, &client_addrlength); 
            addfd(epollfd,connfd,false);
            /*对connfd禁用ET模式*/ 
        }else if(events[i].events &EPOLLIN){
            /*只要socket读缓存中还有未读出的数据,这段代码就被触发*/ 
            printf("event trigger once\n"); 
            memset(buf,'\0',BUFFER_SIZE); 
            int ret=recv(sockfd,buf,BUFFER_SIZE-1,0); 
            if(ret<=0) {
                close(sockfd); 
                continue; 
            }
            printf("get%d bytes of content:%s\n",ret,buf); 
        }else {
            printf("something else happened\n"); 
        }
    }
}

ET模式:

#include <sys/epoll.h>
/*ET模式的工作流程*/ 
void et(epoll_event*events,int number,int epollfd,int listenfd) {
    char buf[BUFFER_SIZE]; 
    for(int i=0;i<number;i++) {
        int sockfd=events[i].data.fd; 
        if(sockfd==listenfd) {
            struct sockaddr_in client_address; 
            socklen_t client_addrlength=sizeof(client_address); 
            int connfd=accept(listenfd,(struct sockaddr*)&client_address,& client_addrlength); 
            addfd(epollfd,connfd,true);/*对connfd开启ET模式*/ 
        }
        else if(events[i].events&EPOLLIN) {
            /*这段代码不会被重复触发,所以循环读取数据,以确保把socket读缓存中的所有 数据读出*/ 
            printf("event trigger once\n"); 
            while(1) {
                memset(buf,'\0',BUFFER_SIZE); 
                int ret=recv(sockfd,buf,BUFFER_SIZE-1,0); 
                if(ret<0) {
                    /*对于非阻塞IO,下面的条件成立表示数据已经全部读取完毕。
                    此后,epoll就能再次触 发sockfd上的EPOLLIN事件,以驱动下一次读操作*/ 
                    if((errno==EAGAIN)||(errno==EWOULDBLOCK)) {
                        printf("read later\n");
                        break; 
                    }
                    close(sockfd); 
                    break; 
                }else if(ret==0) {
                    close(sockfd); 
                }else {
                    printf("get%d bytes of content:%s\n",ret,buf); 
                }
            }
        }
        else {
            printf("something else happened\n"); 
        }
    }
}

​ 总结:epoll是几乎是大规模并行网络程序设计的代名词,一个线程里可以处理大量的tcp连接,cpu消耗也比较低。很多框架模型,nginx, nodejs, 底层均使用epoll实现。