IO多路复用

首先我们要了解概念:
同步和异步:
描述的是用户线程与内核的交互方式:同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞和非阻塞:
描述的是用户线程调用内核IO操作的方式:阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

常用的IO模型有以下几种:

  1. 同步阻塞IO(Blocking IO):即传统的IO模型。


    同步阻塞
  2. 同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。


    同步非阻塞
  3. IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。


    IO多路复用

    本文重点分析IO多路复用:
    常用的IO多路复用方式有select,poll以及epoll,为什么会有三种方式是因为这三种方式出现的事件有前后,最开始select,poll为select的优化版本,epoll则是颠覆版本,性能最佳,目前解决IO复用基本上都是用的epoll。
    select,poll和epoll都是内核层面的,来让我们解开他们的面纱。
    对于服务器来说,accept是阻塞的,当有客户端传来连接并建立连接完成之后,accept才会完成,然后获取到已经建立连接的这个socket的文件描述符,后续的read和write就是根据此文件描述符来操作的,read和write可以设置为阻塞或者非阻塞,上面的同步非阻塞IO是开了多个线程,然后每个线程循环访问内核看是否有数据到来,可以发现没有访问都使用了一次read系统调用,这是非常占用资源的,而且开多线程也比较浪费资源,为解决这个问题,把这个循环访问放到内核态怎么样,这样既可以减少系统调用,也可以减少用户态太多线程的使用。我们先来看下select

select

select 是操作系统提供的系统调用函数,通过它,可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:


select

对应服务器端,要开两个线程,一个线程用来不断接受客户端的连接,并且把socket文件描述符放到一个list里面。

while(1) {
  connfd = accept(listenfd);
  fcntl(connfd, F_SETFL, O_NONBLOCK);
  fdlist.add(connfd);
}

然后另外开一个线程不用自己循环遍历,而是调用select系统调用,交给操作系统来遍历list中的文件描述符。
当select返回的时候,代表已经检测到有数据接收了,但是select不会返回具体哪一个文件描述符对应的socket收到数据了,而是返回收到数据的socket的文件描述符的数量,如上图所示,select系统调用返回2,然后用户态还要遍历刚刚的list

while(1) {
  nready = select(list);
  // 用户层依然要遍历,只不过少了很多无效的系统调用
  for(fd <-- fdlist) {
    if(fd != -1) {
      // 只读已就绪的文件描述符
      read(fd, buf);
      // 总共只有 nready 个已就绪描述符,不用过多遍历
      if(--nready == 0) break;
    }
  }
}

从上面的分析中我们能够看出select中的几个特点:

  1. 虽然用户不用遍历,但是需要将list数组拷贝到内核中,让内核来遍历,但是在高并发的场景下,数组的拷贝会消耗很多资源。
  2. 虽说是把遍历放到了内核态,但是select实际上还是遍历,对于内核来说是同步的过程,只是减少了系统调用切换上下文的开销。
  3. select系统调用返回的是可读文件描述符的个数,具体哪个可读还是得用户来遍历判断。
    select的优点:
    (1)可以一次等待多个套接字socket。
    (2)减少系统调用的使用,节省系统资源。
    (3)减少线程的使用,节省资源。
    select的缺点:
    (1)select支持监听的socket文件描述符数量太少,最多1024个。
    (2)每次select系统调用都会把文件描述符fd的数组从用户态拷贝到内核态,这个开销在fd很多时会很大。
    (3)每次select系统调用返回文件描述符的数量,用户态还要遍历fd数组,开销在fd很多时也很大。

针对select的缺点,poll增添了支持fd的数量,去掉了select只能监听1024个文件描述符的限制,但是没有解决核心问题,epoll应时而生

epoll

针对于上文所说的select的三个特点

  1. 虽然用户不用遍历,但是需要将list数组拷贝到内核中,让内核来遍历,但是在高并发的场景下,数组的拷贝会消耗很多资源。
  2. 虽说是把遍历放到了内核态,但是select实际上还是遍历,对于内核来说是同步的过程,只是减少了系统调用切换上下文的开销。
  3. select系统调用返回的是可读文件描述符的个数,具体哪个可读还是得用户来遍历判断。
    epoll针对这三点都进行了改进。
    1.内核中存储了一份文件描述符fd的集合,无需用户每次都从用户态传入,只需要告诉内核修改的部分就可以。
    2.内核中不再通过轮询的方式找到就绪的文件描述符fd,而是通过异步IO事件进行唤醒。
    3.内核会将有IO事件发生的文件描述符fd返回给用户,用户不需要自己进行遍历。

epoll提供了三个系统调用的入口:

第一步,创建一个 epoll 句柄
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(
  int epfd, int op, int fd, struct epoll_event *event);
第三步,类似发起了 select() 调用
int epoll_wait(
  int epfd, struct epoll_event *events, int max events, int timeout);

原理如下所示:

epoll

epoll有两种操作模式–LT(level trigger水平模式)ET(edge trigger边缘模式)
简单来讲,LT是epoll的默认操作模式,当epoll_wait函数检测到有事件发生并将通知应用程序,而应用程序不一定必须立即进行处理,这样epoll_wait函数再次检测到此事件的时候还会通知应用程序,直到事件被处理。

而ET模式,只要epoll_wait函数检测到事件发生,通知应用程序立即进行处理,后续的epoll_wait函数将不再检测此事件。因此ET模式在很大程度上降低了同一个事件被epoll触发的次数,因此效率比LT模式高。
epoll示例代码:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>

#define true                1
#define false               0
#define MAX_EVENT_NUMBER    1024
#define BUFFER_SIZE         10

/*将文件描述符设置为非阻塞*/
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;
}

/*将文件描述符fd上的EPOLLIN注册到epollfd指示的epoll内核事件表中。 参数enable_et 指定是否对fd采用ET模式*/
void addfd(int epollfd, int fd, int enable_et)
{
    struct epoll_event event;
    event.data.fd = fd;
    event.events  = EPOLLIN;
    if(enable_et){
        event.events |= EPOLLET;
    }
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event );
    setnonblocking(fd);
}
/*LT 模式的工作原理*/
void lt(struct epoll_event *events, int number, int epollfd, int listenfd)
{
    char buf[BUFFER_SIZE];
    int i = 0;
    for(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);
        }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 happen !!!\n");
        }
    }
}

/*ET 模式的工作流程*/
void et(struct epoll_event* events, int number, int epollfd, int listenfd)
{
    char buf[BUFFER_SIZE];
    int i=0;
    for(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上的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 happen\n");
        }
        
    }
    
}


int main(int argc, char *argv[])
{
    if(argc <= 2){
        printf("Usage: %s ip_address port_number\n", argv[0]);
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);
    
    int opt = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    
    ret = listen(listenfd, 5);
    assert(ret != -1);
    
    
    struct epoll_event events[MAX_EVENT_NUMBER];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    
    
    addfd(epollfd, listenfd, true);
    while(1){
        int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        if(ret<0){
            printf("epoll failure\n");
            break;
        }
        //lt(events, ret, epollfd, listenfd);
        et(events, ret, epollfd, listenfd);
    }
    close(listenfd);
    return 0;
}

一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO。
为了破这个局,程序员在用户态通过多线程来防止主线程卡死。
后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 IO。
但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。
后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用。
多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。
IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。

参考:
IO多路复用到底是不是异步的? - 知乎 (zhihu.com)
epoll经典代码示例_遇见你是我最美丽的意外-CSDN博客_epoll示例代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 219,110评论 6 508
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,443评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 165,474评论 0 356
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,881评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,902评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,698评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,418评论 3 419
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,332评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,796评论 1 316
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,968评论 3 337
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,110评论 1 351
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,792评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,455评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,003评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,130评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,348评论 3 373
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,047评论 2 355

推荐阅读更多精彩内容