IO多路复用(multiplexing)的三种方式-select

2020-06-11  本文已影响0人  kummerwu

1、IO多路复用(multiplexing)相关概念

在介绍select、poll、epoll之前,首先介绍一下Linux操作系统中基础的概念:

1.1、用户空间 / 内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操作系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

1.2、进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的,并且进程切换是非常耗费资源的。

1.3、进程阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

我们知道传统的BIO(Blocking)操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务。而 I/O 多路复用就是为了解决这个问题而出现的。
这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),Reactor的设计模式就是基于NIO。
I/O多路复用实际上就是用select, poll, epoll技术等监听多个io对象,当io对象有变化(有数据)的时候就通知用户进程。好处就是单个进程可以处理多个socket。

1.4、文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

1.5、缓存I/O

缓存I/O又称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

2、IO多路复用(multiplexing)解决什么问题

2.1、一个现实生活中的问题

假设你是一个机场的空管, 你需要管理到你机场的所有的航线, 包括进港,出港, 有些航班需要放到停机坪等待,有些航班需要去登机口接乘客。

你会怎么做?

最简单的做法,就是你去招一大批空管员,然后每人盯一架飞机, 从进港,接客,排位,出港,航线监控,直至交接给下一个空港,全程监控。

那么问题就来了:

现实上我们的空管同时管几十架飞机稀松平常的事情, 他们怎么做的呢?
他们用这个东西

这个东西叫flight progress strip. 每一个块代表一个航班,不同的槽代表不同的状态,然后一个空管员可以管理一组这样的块(一组航班),而他的工作,就是在航班信息有新的更新的时候,把对应的块放到不同的槽子里面。

这个东西现在还没有淘汰哦,只是变成电子的了而已。。

是不是觉得一下子效率高了很多,一个空管塔里可以调度的航线可以是前一种方法的几倍到几十倍。

如果你把每一个航线当成一个Sock(I/O 流), 空管当成你的服务端Sock管理代码的话.

第一种方法就是最传统的多进程并发模型 (每进来一个新的I/O流会分配一个新的进程管理。)
第二种方法就是I/O多路复用 (单个线程,通过记录跟踪每个I/O流(sock)的状态,来同时管理多个I/O流 。)

其实“I/O多路复用”这个坑爹翻译可能是这个概念在中文里面如此难理解的原因。所谓的I/O多路复用在英文中其实叫 I/O multiplexing. 如果你搜索multiplexing啥意思,基本上都会出这个图:

image

于是大部分人都直接联想到"一根网线,多个sock复用" 这个概念,包括上面的几个回答, 其实不管你用多进程还是I/O多路复用, 网线都只有一根好伐。多个Sock复用一根网线这个功能是在内核+驱动层实现的

重要的事情再说一遍: I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态(对应空管塔里面的Fight progress strip槽)来同时管理多个I/O流.
发明它的原因,是尽量多的提高服务器的吞吐能力。

是不是听起来好拗口,看个图就懂了.

在同一个线程里面, 通过拨开关的方式,来同时传输多个I/O流, (学过EE的人现在可以站出来义正严辞说这个叫“时分复用”了)。

什么,你还没有搞懂“一个请求到来了,nginx使用epoll接收请求的过程是怎样的”, 多看看这个图就了解了。提醒下,ngnix会有很多链接进来, epoll会把他们都监视起来,然后像拨开关一样,谁有数据就拨向谁,然后调用相应的代码处理。

了解这个基本的概念以后,其他的就很好解释了。

1.2 IO多路复用的历史

select, poll, epoll 都是I/O多路复用的具体的实现,之所以有这三个鬼存在,其实是他们出现是有先后顺序的。

I/O多路复用这个概念被提出来以后, select是第一个实现 (1983 左右在BSD里面实现的)。

select

select 被实现以后,很快就暴露出了很多问题。

“If a file descriptor being monitored by select() is closed in another thread, the result is unspecified”
霸不霸气

poll

于是14年以后(1997年)一帮人又实现了poll, poll 修复了select的很多问题,比如

其实拖14年那么久也不是效率问题, 而是那个时代的硬件实在太弱,一台服务器处理1千多个链接简直就是神一样的存在了,select很长段时间已经满足需求。

但是poll仍然不是线程安全的, 这就意味着,不管服务器有多强悍,你也只能在一个线程里面处理一组I/O流。你当然可以那多进程来配合了,不过然后你就有了多进程的各种问题。

select/poll的几大缺点
1、每次调用select/poll,都需要把fd集合用户态拷贝到内核态,这个开销在fd很多时会很大
2、同时每次调用select/poll都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
3、针对select支持的文件描述符数量太小了,默认是1024
4.select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
5.select的触发方式是水平触发。(个人理解:如交易系统每笔交易会触发一次,一次就是把所有fd集合从用户态拷贝到内核态,所有表示select触发频率也很高)

epoll

于是5年以后, 在2002, 大神 Davide Libenzi 实现了epoll.

epoll 可以说是I/O 多路复用最新的一个实现,epoll 修复了poll 和select绝大部分问题, 比如:

epoll 当年的patch,现在还在,下面链接可以看得到:
/dev/epoll Home Page

贴一张霸气的图,看看当年神一样的性能(测试代码都是死链了, 如果有人可以刨坟找出来,可以研究下细节怎么测的).

横轴Dead connections 就是链接数的意思,叫这个名字只是它的测试工具叫deadcon. 纵轴是每秒处理请求的数量,你可以看到,epoll每秒处理请求的数量基本不会随着链接变多而下降的。poll 和/dev/poll 就很惨了。

可是epoll 有个致命的缺点。。只有linux支持。比如BSD上面对应的实现是kqueue。

其实有些国内知名厂商把epoll从安卓里面裁掉这种脑残的事情我会主动告诉你嘛。什么,你说没人用安卓做服务器,尼玛你是看不起p2p软件了啦。

而ngnix 的设计原则里面, 它会使用目标平台上面最高效的I/O多路复用模型咯,所以才会有这个设置。一般情况下,如果可能的话,尽量都用epoll/kqueue吧。

详细的在这里:
Connection processing methods

PS: 上面所有这些比较分析,都建立在大并发下面,如果你的并发数太少,用哪个,其实都没有区别。 如果像是在欧朋数据中心里面的转码服务器那种动不动就是几万几十万的并发,不用epoll我可以直接去撞墙了

3、解决IO多路复用(multiplexing)的三种方法-select,poll,epoll

3.1、Select

我们先分析一下select函数

int select(int maxfdp1,
               fd_set *readset,
               fd_set *writeset,
               fd_set *exceptset,
               const struct timeval *timeout);

【参数说明】

【返回值】

【select运行机制】

select()的机制中提供一种fd_set的数据结构,实际上是一个long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。

从流程上来看,使用select函数进行IO请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。用户可以注册多个socket,然后不断地调用select读取被激活的socket,即可达到在同一个线程内同时处理多个IO请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

【select机制的问题】

代码示例

#include <sys/select.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
 
int main(int argc, char *argv[])
{
    fd_set rfds;
    struct timeval tv;
    int ret;
    int fd;
    
    ret = mkfifo("test_fifo", 0666); // 创建有名管道
    if(ret != 0){
        perror("mkfifo:");
    }
    
    fd = open("test_fifo", O_RDWR); // 读写方式打开管道
    if(fd < 0){
        perror("open fifo");
        return -1;
    }
    
    ret = 0;
    
    while(1){
        // 这部分内容,要放在while(1)里面
        FD_ZERO(&rfds);     // 清空
        FD_SET(0, &rfds);   // 标准输入描述符 0 加入集合
        FD_SET(fd, &rfds);  // 有名管道描述符 fd 加入集合
        
        // 超时设置
        tv.tv_sec = 1;
        tv.tv_usec = 0;
        
        // 监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
        // 没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
        // FD_SETSIZE 为 <sys/select.h> 的宏定义,值为 1024
        ret = select(FD_SETSIZE, &rfds, NULL, NULL, NULL);
        //ret = select(FD_SETSIZE, &rfds, NULL, NULL, &tv);
 
        if(ret == -1){ // 出错
            perror("select()");
        }else if(ret > 0){ // 准备就绪的文件描述符
        
            char buf[100] = {0};
            if( FD_ISSET(0, &rfds) ){ // 标准输入
                read(0, buf, sizeof(buf));
                printf("stdin buf = %s\n", buf);
                
            }else if( FD_ISSET(fd, &rfds) ){ // 有名管道
                read(fd, buf, sizeof(buf));
                printf("fifo buf = %s\n", buf);
            }
            
        }else if(0 == ret){ // 超时
            printf("time out\n");
        }
    
    }
    
    return 0;
}

POLL

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。也就是说,poll只解决了上面的问题3,并没有解决问题1,2的性能开销问题。

【函数原型:】

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

typedef struct pollfd {
        int fd;                         // 需要被检测或选择的文件描述符
        short events;                   // 对文件描述符fd上感兴趣的事件
        short revents;                  // 文件描述符fd上当前实际发生的事件
} pollfd_t;

poll改变了文件描述符集合的描述方式,使用了pollfd结构而不是select的fd_set结构,使得poll支持的文件描述符集合限制远大于select的1024

【参数说明】

【返回值】

int 函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错;

代码示例

#include <poll.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
 
int main(int argc, char *argv[])
{
 
    int ret;
    int fd;
    struct pollfd fds[2]; // 监视文件描述符结构体,2 个元素
    
    ret = mkfifo("test_fifo", 0666); // 创建有名管道
    if(ret != 0){
        perror("mkfifo:");
    }
    
    fd = open("test_fifo", O_RDWR); // 读写方式打开管道
    if(fd < 0){
        perror("open fifo");
        return -1;
    }
    
    ret = 0;
    
    fds[0].fd = 0;   // 标准输入
    fds[1].fd = fd;  // 有名管道
        
    fds[0].events = POLLIN; // 普通或优先级带数据可读
    fds[1].events = POLLIN; // 普通或优先级带数据可读
    
    while(1){
    
        // 监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
        // 没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
        ret = poll(fds, 2, -1);
        //ret = poll(&fd, 2, 1000);
 
        if(ret == -1){ // 出错
            perror("poll()");
        }else if(ret > 0){ // 准备就绪的文件描述符
        
            char buf[100] = {0};
            if( ( fds[0].revents & POLLIN ) ==  POLLIN ){ // 标准输入
                read(0, buf, sizeof(buf));
                printf("stdin buf = %s\n", buf);
                
            }else if( ( fds[1].revents & POLLIN ) ==  POLLIN ){ // 有名管道
                read(fd, buf, sizeof(buf));
                printf("fifo buf = %s\n", buf);
            }
            
        }else if(0 == ret){ // 超时
            printf("time out\n");
        }
    
    }
    
    return 0;
}

Epoll

epoll在Linux2.6内核正式提出,是基于事件驱动的I/O方式,相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

Linux中提供的epoll相关函数如下:

int epoll_create(int size);
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);
  1. epoll_create 函数创建一个epoll句柄,参数size表明内核要监听的描述符数量。调用成功时返回一个epoll句柄描述符,失败时返回-1。

  2. epoll_ctl 函数注册要监听的事件类型。四个参数解释如下:

struct epoll_event {
    __uint32_t events;  /* Epoll events */
    epoll_data_t data;  /* User data variable */
};

typedef union epoll_data {
    void *ptr;
    int fd;
    __uint32_t u32;
    __uint64_t u64;
} epoll_data_t;
  1. epoll_wait 函数等待事件的就绪,成功时返回就绪的事件数目,调用失败时返回 -1,等待超时返回 0。

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

LT和ET原本应该是用于脉冲信号的,可能用它来解释更加形象。Level和Edge指的就是触发点,Level为只要处于水平,那么就一直触发,而Edge则为上升沿和下降沿的时候触发。比如:0->1 就是Edge,1->1 就是Level。

ET模式很大程度上减少了epoll事件的触发次数,因此效率比LT模式下高。

代码示例

#include <sys/epoll.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
 
int main(int argc, char *argv[])
{
 
    int ret;
    int fd;
    
    ret = mkfifo("test_fifo", 0666); // 创建有名管道
    if(ret != 0){
        perror("mkfifo:");
    }
    
    fd = open("test_fifo", O_RDWR); // 读写方式打开管道
    if(fd < 0){
        perror("open fifo");
        return -1;
    }
    
    ret = 0;
    struct epoll_event event;   // 告诉内核要监听什么事件
    struct epoll_event wait_event;
    
    
    int epfd = epoll_create(10); // 创建一个 epoll 的句柄,参数要大于 0, 没有太大意义
    if( -1 == epfd ){
        perror ("epoll_create");
        return -1;
    }
    
    event.data.fd = 0;     // 标准输入
    event.events = EPOLLIN; // 表示对应的文件描述符可以读
    
    // 事件注册函数,将标准输入描述符 0 加入监听事件
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, 0, &event);
    if(-1 == ret){
        perror("epoll_ctl");
        return -1;
    }
    
    event.data.fd = fd;     // 有名管道
    event.events = EPOLLIN; // 表示对应的文件描述符可以读
    
    // 事件注册函数,将有名管道描述符 fd 加入监听事件
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
    if(-1 == ret){
        perror("epoll_ctl");
        return -1;
    }
    
    ret = 0;
    
    while(1){
        
    
        // 监视并等待多个文件(标准输入,有名管道)描述符的属性变化(是否可读)
        // 没有属性变化,这个函数会阻塞,直到有变化才往下执行,这里没有设置超时
        ret = epoll_wait(epfd, &wait_event, 2, -1);
        //ret = epoll_wait(epfd, &wait_event, 2, 1000);
        
        if(ret == -1){ // 出错
            close(epfd);
            perror("epoll");
        }else if(ret > 0){ // 准备就绪的文件描述符
        
            char buf[100] = {0};
            
            if( ( 0 == wait_event.data.fd ) 
            && ( EPOLLIN == wait_event.events & EPOLLIN ) ){ // 标准输入
            
                read(0, buf, sizeof(buf));
                printf("stdin buf = %s\n", buf);
                
            }else if( ( fd == wait_event.data.fd ) 
            && ( EPOLLIN == wait_event.events & EPOLLIN ) ){ // 有名管道
            
                read(fd, buf, sizeof(buf));
                printf("fifo buf = %s\n", buf);
                
            }
            
        }else if(0 == ret){ // 超时
            printf("time out\n");
        }
    
    }
    
    close(epfd);
    
    return 0;
}

总结

一张图总结一下select,poll,epoll的区别:

比较项 select poll epoll
操作方式 遍历 遍历 回调
底层实现 数组 链表 哈希表
IO效率 每次调用都进行线性遍历,时间复杂度为O(n) 每次调用都进行线性遍历,时间复杂度为O(n) 事件通知方式, 每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到readyList里面,时间复杂度O(1)
最大连接数 1024(x86)或2048(x64) 无上限 无上限
fd拷贝 每次调用select,都需要把fd集合从用户态拷贝到内核态 每次调用poll,都需要把fd集合从用户态拷贝到内核态 调用epoll_ctl时拷贝进内核并保存,之后每次epoll_wait不拷贝

epoll是Linux目前大规模网络并发程序开发的首选模型。在绝大多数情况下性能远超select和poll。目前流行的高性能web服务器Nginx正式依赖于epoll提供的高效网络套接字轮询服务。但是,在并发连接不高的情况下,多线程+阻塞I/O方式可能性能更好。

附录

一句话
reactor:反应器,有数据来了你反应给我,我去读
proactor:代理人,有数据来了你代理我读好,然后再通知我

1、标准定义两种I/O多路复用模式:Reactor和Proactor
一般地,I/O多路复用机制都依赖于一个事件多路分离器(Event Demultiplexer)。分离器对象可将来自事件源的I/O事件分离出来,并分发到对应的read/write事件处理器(Event Handler)。开发人员预先注册需要处理的事件及其事件处理器(或回调函数);事件分离器负责将请求事件传递给事件处理器。

两个与事件分离器有关的模式是Reactor和Proactor。
Reactor模式采用同步IO,而Proactor采用异步IO。

在Reactor中,事件分离器负责等待文件描述符或socket为读写操作准备就绪,然后将就绪事件传递给对应的处理器,最后由处理器负责完成实际的读写工作。

而在Proactor模式中,处理器--或者兼任处理器的事件分离器,只负责发起异步读写操作。IO操作本身由操作系统来完成。传递给操作系统的参数需要包括用户定义的数据缓冲区地址和数据大小,操作系统才能从中得到写出操作所需数据,或写入从socket读到的数据。事件分离器捕获IO操作完成事件,然后将事件传递给对应处理器。比如,在windows上,处理器发起一个异步IO操作,再由事件分离器等待IOCompletion事件。典型的异步模式实现,都建立在操作系统支持异步API的基础之上,我们将这种实现称为“系统级”异步或“真”异步,因为应用程序完全依赖操作系统执行真正的IO工作。

举个例子,将有助于理解Reactor与Proactor二者的差异,以读操作为例(类操作类似)。
在Reactor中实现读:

在Proactor中实现读:

可以看出

通俗理解使用Proactor框架和Reactor框架都可以极大的简化网络应用的开发,但它们的重点却不同。

在我看来,他们都是用于派发/分离IO操作事件的。这里所谓的IO事件也就是诸如read/write的IO操作。"派发/分离"就是将单独的IO事件通知到上层模块。两个模式不同的地方在于,Proactor用于异步IO,而Reactor用于同步IO。部分参考自>http://www.cnblogs.com/dawen/archive/2011/05/18/2050358.html

上一篇下一篇

猜你喜欢

热点阅读