【总结】I/O多路复用及epoll的工作原理

目录

第一部分 epoll原理最直观讲解

第二部分  I/O多路复用之水平触发和边沿触发模式 

第三部分  epoll的具体使用方法

1.创建epoll实例:

2.修改epoll的兴趣列表:

2.事件等待:

4:LT模式与ET模式

5 EPOLLONESHOT事件


第一部分 epoll原理最直观讲解

转自知乎高分回答  https://www.zhihu.com/question/20122137/answer/14049112

首先我们来定义流的概念,一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。    不管是文件,还是套接字,还是管道,我们都可以把他们看作流。

之后我们来讨论I/O的操作,通过read,我们可以从流中读入数据;通过write,我们可以往流写入数据。现在假定一个情形,我们需要从流中读数据,但是流中还没有数据,(典型的例子为,客户端要从socket读如数据,但是服务器还没有把数据传回来),这时候该怎么办?

  • 阻塞:阻塞是个什么概念呢?比如某个时候你在等快递,但是你不知道快递什么时候过来,而且你没有别的事可以干(或者说接下来的事要等快递来了才能做);那么你可以去睡觉了,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。
  • 非阻塞忙轮询:接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他挂个电话:“你到了没?”

很明显一般人不会用第二种做法,不仅显很无脑,浪费话费不说,还占用了快递员大量的时间。

大部分程序也不会用第二种做法,因为第一种方法经济而简单,经济是指消耗很少的CPU时间,如果线程睡眠了,就掉出了系统的调度队列,暂时不会去瓜分CPU宝贵的时间片了。

为了了解阻塞是如何进行的,我们来讨论缓冲区,以及内核缓冲区,最终把I/O事件解释清楚。缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。

假设有一个管道,进程A为管道的写入方,B为管道的读出方。

  1. 一开始内核缓冲区是空的,B作为读出方,被阻塞着。然后首先A往管道写入,这时候内核缓冲区由空的状态变到非空状态,内核就会产生一个事件告诉B该醒来了,这个事件姑且称之为“缓冲区非空”。
  2. 但是“缓冲区非空”事件通知B后,B却还没有读出数据;且内核许诺了不能把写入管道中的数据丢掉这个时候,A写入的数据会滞留在内核缓冲区中,如果内核也缓冲区满了,B仍未开始读数据,最终内核缓冲区会被填满,这个时候会产生一个I/O事件,告诉进程A,你该等等(阻塞)了,我们把这个事件定义为“缓冲区满”。
  3. 假设后来B终于开始读数据了,于是内核的缓冲区空了出来,这时候内核会告诉A,内核缓冲区有空位了,你可以从长眠中醒来了,继续写数据了,我们把这个事件叫做“缓冲区非满”
  4. 也许事件Y1已经通知了A,但是A也没有数据写入了,而B继续读出数据,知道内核缓冲区空了。这个时候内核就告诉B,你需要阻塞了!,我们把这个时间定为“缓冲区空”。

这四个情形涵盖了四个I/O事件,缓冲区满,缓冲区空,缓冲区非空,缓冲区非满(注都是说的内核缓冲区,且这四个术语都是我生造的,仅为解释其原理而造)。这四个I/O事件是进行阻塞同步的根本。(如果不能理解“同步”是什么概念,请学习操作系统的锁,信号量,条件变量等任务同步方面的相关知识)。

    然后我们来说说阻塞I/O的缺点。但是阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。

    于是再来考虑非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论):

while true {
    for i in stream[]; {
        if i has data
            read until unavailable
    }
}

    我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了,但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。这里要补充一点,阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(后文介绍的select以及epoll)处理甚至直接忽略。

    为了避免CPU空转,可以引进了一个代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流(于是我们可以把“忙”字去掉了)。代码长这样:

while true {
    select(streams[])
    for i in streams[] {
        if i has data
            read until unavailable
    }
}

    于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。

    但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,没一次无差别轮询时间就越长。再次

说了这么多,终于能好好解释epoll了。  epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

    在讨论epoll的实现细节之前,先把epoll的相关操作列出:

epoll_create 创建一个epoll对象,一般epollfd = epoll_create()

epoll_ctl (epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件,比如:

epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//注册缓冲区非空事件,即有数据流入

epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//注册缓冲区非满事件,即流可以被写入

epoll_wait(epollfd,...)等待直到注册的事件发生

(注:当对一个非阻塞流的读写发生缓冲区满或缓冲区空,write/read会返回-1,并设置errno=EAGAIN。而epoll只关心缓冲区非满和缓冲区非空事件)。一个epoll模式的代码大概的样子是:

while true {
    active_stream[] = epoll_wait(epollfd)
    for i in active_stream[] {
        read or write till
    }
}

第二部分:I/O多路复用之水平触发和边沿触发模式 

转自链接:https://blog.csdn.net/fengxinlinux/article/details/75331567

多路I/O复用不管是select,poll还是epoll,其都是通过同时监听多个文件描述符,当有文件文件描述符处于就绪状态时,触发通知。

LT(Level Trigger,水平触发)模式和ET(Edge Trigger,边沿触发)模式是两种文件描述符准备就绪的通知模式。

水平触发通知:如果文件描述符上可以非阻塞地执行I/O系统调用,此时认为它已经就绪,触发通知。 
边沿触发通知:如果文件描述符自上次状态检查以来有了新的I/O活动(比如新的输入),此时需要触发通知。

  1. 当采用水平触发通知时,我们可以在任意时刻检查文件描述符的就绪状态。这表示当我们确定了文件描述符处于就绪状态时(比如存在有输入数据),就可以对其执行一些I/O操作,然后重复检查文件描述符,看看是否仍然处于就绪态(比如还有更多的输入数据),此时我们就能执行更多的I/O。举个例子,比如说我们采用epoll水平触发模式监听一个文件描述符的可读,当这个文件可读就绪时,epoll会触发一个通知,然后我们执行一次读取操作,但这次操作我们并没有把该文件描述符的数据全部读取完。当下一次调用epoll监听该文件描述符时,epoll还会再次触发通知,直到该事件被处理完。这就意味着,当epoll触发通知后,我们可以不立即处理该事件,当下次调用epoll监听时,然后会再次向应用程序通告此事件,此时我们再处理也不晚。 
  2. 与之相反的是,当我们采用边沿触发时,只有当I/O事件发生时我们才会收到通知。还是上个例子,如果这次我们采用epoll的边沿触发模式监听一个文件描述符的可读,当可读就绪时,epoll会触发一个通知,如果我们此时不立即处理该事件,当下次再调用epoll监听时,虽然该文件描述符的状态是可读的,但是此时epoll并不会再给应用程序发送通知。因为在边沿触发工作模式下,只有下一个新的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操作,直到相应的系统调用(比如read(),write())以错误码EAGAIN或EWOULDBLOCK的形式失败。
可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。

下表是I/O多路复用select,poll和epoll所支持的通知模型:

I/O模式 水平触发 边沿触发
select()  支持 不支持
poll() 支持 不支持
epoll() 支持 支持 

select和poll只支持LT工作模式,epoll的默认的工作模式是LT模式。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。

第三部分  epoll的具体使用方法

转自:https://blog.csdn.net/fengxinlinux/article/details/75452740

epoll()是Linux特有的I/O复用函数。它在实现和使用上与select,poll有很大的差异。 关于select和poll可以参考:I/O多路复用之select ,I/O多路复用之poll 

首先,epoll使用一组函数来完成任务,而不是单个函数。其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。相比于select,epoll最大的好处在于它不会随着监听fd数目的增长而降低效率。因为在内核中的select实现中,它是采用轮询来处理的,轮询的fd数目越多,自然耗时越多。并且,在linux/posix_types.h头文件有这样的声明:

#define __FD_SETSIZE 1024

表示select最多同时监听1024个fd,当然,可以通过修改头文件再重编译内核来扩大这个数目,但这并不治本。 
然而epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目。具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。我的系统的file-max值是392250。 

epoll在具有大量应用程序请求时能够获得较好的性能( 此时被监视的文件描述符数目非常大,与旧的 select 和 poll 系统调用完成操作所需 O(n) 不同, epoll能在O(1)时间内完成操作,所以性能相当高)。epoll的使用:

1.创建epoll实例:

由于epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,所以epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表。 这个文件描述符使用如下epoll_create函数来创建:

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

size参数现在并不起作用,只是给内核一个提示,告诉内核应该如何为内部数据结构划分初始大小。 该函数返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。 需要注意的是,这个文件描述符也会占用一个fd值,在linux下如果查看/proc/进程id/fd/,能够看到这个fd,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。 
ps: 如果我们使用dup()或类似的函数复制一个epoll文件描述符,那么被复制的描述符所指代的epoll兴趣列表和就绪列表同原始的epoll文件描述符相同。这一观点同样也适合于fork()调用之后的情况。此时子进程通过继承复制了父进程的epoll文件描述符,而这个复制的文件描述符所指向的epoll数据结构同原始的描述符相同。

2.修改epoll的兴趣列表:

下面的函数用来操作epoll的内核事件表:

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
  • epfd是创建epoll实例时返回的文件描述符
  • fd参数是要操作的文件描述符。
  • op参数则指定操作类型。操作类型有如下3种:

EPOLL_CTL_ADD,注册新的fd到epfd中;

EPOLL_CTL_MOD,修改已注册fd的事件;

EPOLL_CTL_DEL,从epfd中删除一个fd; 

  • event参数指定事件,它是epoll_event结构指针类型。epoll_event的定义如下:
struct epoll_event {
  __uint32_t events;  /* Epoll 事件 */
  epoll_data_t data;  /* 用户数据 */
};

其中events成员描述事件类型。epoll支持的事件类型和epoll基本相同。表示epoll事件类型的宏是在poll对应的宏前加上”E”,比如epoll的数据可读事件是EPOLLIN。但epoll有两个额外的事件类型——EPOLLET和EPOLLONESHOT。它们对于epoll的高效运作非常关键,我们一会将会具体讲解这两个事件类型。 
events事件类型:

事件描述
EPOLLIN    可读取非高优先级数据
EPOLLPRI    可读取高优先级数据
EPOLLOUT    普通数据可写
EPOLLHUP    出现挂断
EPOLLET    采用边沿触发事件通知
EPOLLONESHOT    在完成事件通知后禁用检查
EPOLLRDHUP    套接字对端关闭
EPOLLERR    有错误发生

    data成员用于存储用户数据,其类型epoll_data_t的定义如下:

   typedef union epoll_data {    
        void *ptr;    
        int fd;    
        __uint32_t u32;    
        __uint64_t u64;    
    } epoll_data_t;    

epoll_data_t是一个共用体,其4个成员中使用最多的是fd,它指定事件所从属的目标文件描述符。ptr成员可以用来指定与fd相关的用户数据。但由于epoll_data_t是一个共用体,我们不能同时使用其ptr成员和fd成员,因此,如果要将文件描述符和用户数据关联起来,以实现快速的数据访问,只能放弃使用epoll_data_t的fd成员,而在ptr指向的用户数据中包含fd。

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

2.事件等待:

epoll系列的系统调用的主要接口是epoll_wait函数。它在一段超时时间内等待一组文件描述符上的事件,其原型如下:

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

返回值:成功时返回就绪的文件描述符个数,失败时返回-1并设置errno。 
timeout参数:当timeout=-1时,阻塞直到有一个或多个文件描述符就绪。当timeout=0时,立即返回。当timeout值大于0时,等待timeout值的时间。 
maxevents参数:指定最多监听多少个事件,它必须大于0。 

epoll_wait函数如果检测到事件,就将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组中。数组events的空间由调用者负责申请,所包含的元素个数在参数maxevents中指定。 

与poll的使用一样,我们只需要将输出数组中的元素的events数据成员与我们关心的事件就行&操作,结果大于0,则代表该事件已就绪。 第二个参数events数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组参数那样既用于传入用户注册事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪文件描述符的效率。 
我们可以看一个简单的代码:

//比较poll和epoll在使用上的差别

//索引poll返回的就绪文件描述符
int ret=poll(fds,MAX_EVENT_NUMBER,-1);
//必须遍历所有已注册文件描述符并找到其中的就绪者
for(int i=0;i<MAX_EVENT_NUMBER;i++)
{
     if(fds[i].revents & POLLIN)  //判断第i个文件描述符是否就绪
     {  
         int sockfd=fds[i].fd;
         //处理sockfd.....
     }
}


//索引epoll返回的就绪文件描述符
int ret = epoll_wait(epollfd,events,MAX_EVENT_NUMBER,-1);
//仅需遍历就绪的ret个文件描述符
for(int i=0;i<ret;i++)
{
   int sockfd = events[i].data.fd;
   //sockfd肯定就绪,直接处理
}

4:LT模式与ET模式

epoll对文件描述符的操作有两种模式:LT(水平触发)模式和ET(边沿触发)模式。LT模式是默认的工作模式,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式。 

我们在这里只简单的介绍一下,主要介绍如何使用epoll的LT模式和ET模式。

水平触发通知(Level Trigger, LT):如果文件描述符上可以非阻塞地执行I/O系统调用(即有数据读或有空间写),此时认为它已经就绪,触发通知。 
边沿触发通知(Edge Trigger, ET):如果文件描述符自上次状态检查以来有了新的I/O活动(比如新的输入),此时需要触发通知。

二者的差异在于LT模式下只要某个socket处于readable/writable状态,无论什么时候进行epoll_wait都会返回该socket;而ET模式下只有某个socket从unreadable变为readable或从unwritable变为writable时,epoll_wait才会返回该socket,ET模式注重的是状态发生改变的时候才触发。下面两幅图清晰反映了二者区别。

对于采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。而对于采用ET工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这件事。可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。

当我们使用ET模式的epoll时,我们应该按照以下规则设计:

在接收到一个I/O事件通知后,立即处理该事件。程序在某个时刻应该在相应的文件描述符上尽可能多地执行I/O。
在ET模式下,在使用epoll_ctl注册文件描述符的事件时,应该把描述符设置为非阻塞的。因为程序采用循环来对文件描述符执行尽可能多的I/O,而文件描述符又被设置为可阻塞的,那么最终当没有更多的I/O可执行时,I/O系统调用就会阻塞。基于这个原因,每个被检查的文件描述符通常应该置为非阻塞模式,在得到I/O事件通知后重复执行I/O操作,直到相应的系统调用(比如read(),write())以错误码EAGAIN或EWOULDBLOCK的形式失败。

说了这么多,我们来看一个简单的两种工作模式的代码,很容易就明白了:

#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 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, bool enable_et )
{
    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( 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 );
        }
        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模式的工作流程
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 );
        }
        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" );
        }
    }
}

int main( int argc, char* argv[] )
{
    if( argc <= 2 )
    {
        printf( "usage: %s ip_address port_number\n", basename( 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( PF_INET, SOCK_STREAM, 0 );
    assert( listenfd >= 0 );

    ret = bind( listenfd, ( struct sockaddr* )&address, sizeof( address ) );
    assert( ret != -1 );

    ret = listen( listenfd, 5 );
    assert( ret != -1 );

    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 );     //使用LT模式
        //et( events, ret, epollfd, listenfd );    //使用ET模式
    }

    close( listenfd );
    return 0;
}

5 EPOLLONESHOT事件

即使我们使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程(或进程)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新的数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个socket的局面。这当然不是我们希望的,我们期望的是一个socket连接在任一时刻都只被一个线程处理。这一点可以使用epoll的EPOLLONESHOT事件实现。

对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其注册的一个可读,可写或者异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程是不可能有机会操作该socket的。让我们来看一个例子:

#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 MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 1024
struct fds
{
   int epollfd;
   int sockfd;
};

//设置为非阻塞文件描述符
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和EPOLLET事件注册到epollfd指示的epoll内核事件表中,参数oneshot指定是否注册fd上的EPOLLONESHOT事件
void addfd( int epollfd, int fd, bool oneshot )
{
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET;
    if( oneshot )
    {
        event.events |= EPOLLONESHOT;
    }
    epoll_ctl( epollfd, EPOLL_CTL_ADD, fd, &event );
    setnonblocking( fd );
}

//重置fd上的事件。这样操作之后,尽管fd上的EPOLLONESHOT事件被注册,但是操作系统仍然会触发fd上的EPOLLIN事件,且只触发一次
void reset_oneshot( int epollfd, int fd )
{
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
    epoll_ctl( epollfd, EPOLL_CTL_MOD, fd, &event );
}

void* worker( void* arg )
{
    int sockfd = ( (fds*)arg )->sockfd;
    int epollfd = ( (fds*)arg )->epollfd;
    printf( "start new thread to receive data on fd: %d\n", sockfd );
    char buf[ BUFFER_SIZE ];
    memset( buf, '\0', BUFFER_SIZE );
    //循环读取sockfd上的数据,直到遇到EAGAIN错误
    while( 1 )
    {
        int ret = recv( sockfd, buf, BUFFER_SIZE-1, 0 );
        if( ret == 0 )
        {
            close( sockfd );
            printf( "foreiner closed the connection\n" );
            break;
        }
        else if( ret < 0 )
        {
            if( errno == EAGAIN )
            {
                reset_oneshot( epollfd, sockfd );
                printf( "read later\n" );
                break;
            }
        }
        else
        {
            printf( "get content: %s\n", buf );
            //休眠5s,模拟数据处理过程
            sleep( 5 );
        }
    }
    printf( "end thread receiving data on fd: %d\n", sockfd );
}

int main( int argc, char* argv[] )
{
    if( argc <= 2 )
    {
        printf( "usage: %s ip_address port_number\n", basename( 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( PF_INET, SOCK_STREAM, 0 );
    assert( listenfd >= 0 );

    ret = bind( listenfd, ( struct sockaddr* )&address, sizeof( address ) );
    assert( ret != -1 );

    ret = listen( listenfd, 5 );
    assert( ret != -1 );

    epoll_event events[ MAX_EVENT_NUMBER ];
    int epollfd = epoll_create( 5 );
    assert( epollfd != -1 );
    addfd( epollfd, listenfd, false );

    while( 1 )
    {
        int ret = epoll_wait( epollfd, events, MAX_EVENT_NUMBER, -1 );
        if ( ret < 0 )
        {
            printf( "epoll failure\n" );
            break;
        }

        for ( int i = 0; i < ret; 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 );
                //对每个非监听文件描述符都注册EPOLLONESHOT事件
                addfd( epollfd, connfd, true );
            }
            else if ( events[i].events & EPOLLIN )
            {
                pthread_t thread;
                fds fds_for_new_worker;
                fds_for_new_worker.epollfd = epollfd;
                fds_for_new_worker.sockfd = sockfd;
                //新启动一个工作线程为sockfd服务
                pthread_create( &thread, NULL, worker, ( void* )&fds_for_new_worker );
            }
            else
            {
                printf( "something else happened \n" );
            }
        }
    }

    close( listenfd );
    return 0;
}

从工作线程函数worker来看,如果一个工作线程处理完某个socket上的一次请求(我们用休眠5s来模拟这个过程)之后,又接收到该socket上新的客户请求,则该线程将继续为这个socket服务。并且因为该socket上注册了EPOLLONESHOT事件,其他线程没有机会接触这个socket,如果工作线程等待5s后仍没有收到该socket上的下一批客户端数据,则它将放弃为该socket服务。同时,它调用reset_oneshot函数来重置该socket上的注册事件,这会使epoll有机会再次检测到该socket上的EPOLLIN事件,进而使得其他线程有机会为该socket服务。

epoll的优点:

  1. 支持一个进程打开大数目的socket描述符(FD)
  2. IO效率不随FD数目增加而线性下降。传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是”活跃”的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对”活跃”的socket进行操作。这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有”活跃”的socket才会主动的去调用 callback函数。
  3. 使用mmap加速内核与用户空间的消息传递 。这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。

需要我们注意的是,虽然epoll相比select和poll有了很多的改进,但是我们只能说通常使用epoll的效率比select和poll都高,但这并不是绝对的,要根据实际的应用情况来判断使用哪种I/O。 

如果已连接套接字数目不太大并且这些套接字一直处于活跃的状态,那么epoll会不停地调用callback函数可能会造成低效率,也就是说低于一次性遍历,此时epoll的效率就可能低于select和poll。总的来说,epoll在需要监听大量的文件描述符,但是这些文件描述符不是很活跃的情况下,是效率最高的。

第四部分   redis为什么这么快-----之多路复用技术

转自:http://www.syyong.com/db/Redis-why-the-use-of-single-process-and-single-threaded-way-so-fast.html

redis采用的是基于内存的采用的是单进程单线程模型的KV数据库,由C语言编写。官方提供的数据是可以达到100000+的qps。这个数据不比采用单进程多线程的同样基于内存的KV数据库Memcached差。Redis快的主要原因是:

  1. 完全基于内存
  2. 数据结构简单,对数据操作也简单
  3. 使用多路 I/O 复用模型

第一、二点不细讲,主要围绕第三点采用多路 I/O 复用技术来展开。

多路 I/O 复用模型是利用select、poll、epoll可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。

多路 I/O 复用模型

和Memcached不同,Redis并没有直接使用Libevent,而是自己完成了一个非常轻量级的对select、epoll、evport、kqueue这些通用的接口的实现。在不同的系统调用选用适合的接口,linux下默认是epoll。因为Libevent比较重更通用代码量也就很庞大,拥有很多Redis用不上的功能,Redis为了追求“轻巧”并且去除依赖,就选择自己去封装了一套。

1、单进程好处

  1. 代码更清晰,处理逻辑更简单
  2. 不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗
  3. 不存在多进程或者多线程导致的切换而消耗CPU

2、单进程单线程弊端

  1. 无法发挥多核CPU性能,不过可以通过在单机开多个Redis实例来完善;

3、其他一些优秀的开源软件采用的模型

  • 多进程单线程模型:Nginx
  • 单进程多线程模型:Memcached

 

 

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值