Linux| |高级IO&&select,poll,epoll

IO模型

1. 五种模型

1.1 阻塞IO

在内核将数据准备好之前,系统调用会一直等待,默认都是阻塞方式

1.2 非阻塞IO

如果内核还未将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码

【注意】:对于非阻塞IO一般都需要程序员采用循环的方式尝试读写文件描述符,这个过程称为轮询。这对于CPU来说是较大的浪费。

1.3 信号驱动IO

内核将数据包准备好的时候,使用SIGIO信号通知应用程序进行IO操作

所以对于应用程序来说,就必须要自己先有一个对于SIGIO信号的处理程序,这样当内核将数据准备好了之后,通知应用程序,应用程序才可以来读取数据

1.4 IO多路转接

对于多路转接是对于阻塞式IO进行了改进,也就是对于一个应用程序来说可以同时等待套接字接口中的任何一个变成就绪状态,而不是在是一个套接字接口了。

也就是对于能够同时等待多个文件描述符的就绪状态

1.5 异步IO

由内核在将数据拷贝完成时,通知应用程序。(而信号驱动是告诉应用程序何时可以开始靠拷贝数据)【关键在于数据是否从内核态已经拷贝到用户态了】

对于异步IO在开始的时候会采用一个aio_read的系统调用,对于该调用之后,应用程序就不在关心数据有没有准备好,这个应用程序就可以直接去做自己的事情去了,在数据准备好了之后就会直接自己从内核态拷贝到用户态。拷贝到了用户态之后,就会给应用进程发送一个在aio_read中的信号,这个时候应用进程就直接对这个数据报进行处理就好了。

但是但对于信号驱动IO来说,开始的时候,是应用进程调用sigaction调用来建立一个SIGIO的信号处理程序,并且希望从系统当中提取数据。当数据准备好了之后,系统就会给应用进程发送一个SIGIO信号,仅仅是告诉数据准备好了,但是还是需要系统调用recvfroom系统调用来完成对于数据从内核态拷贝到用户态的一个过程。然后才能对于该数据报进行处理

1.6 小结

对于任何的IO过程中,都包含这两个步骤:第一个就是等待,第二个就是拷贝

在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间,让IO更高效,最核心的办法就是让等待的时间可以尽量的少

2. 高级IO的重要概念

2.1 同步与异步

关心的是消息通信机制

同步(与同步与互斥中的同步完全不一样)

发起一个调用获取结果,没有获取结果之前一直等待

【注意】:

同步与异步中的同步指的是一个进程发起一个调用的时候对其返回结果是采用何种方式来获得的

同步与互斥中的同步是对于多个线程来访问同一个临界资源的时候,要对于线程来采用一直机制,按照一定的顺序来访问这个临界资源,防止同一个线程对于临界资源的一直访问,而其他的线程却得不到对于临界资源的访问权限

异步

发起一个调用获取结果,立即返回,等到被调用者将结果搞定了通过信号或者通过回调函数通知发起者

2.2 阻塞与非阻塞

关注在等待调用结果(消息,返回值)时的状态

阻塞

发起一个调用结果,没有结果则挂起等待

非阻塞

发起一个调用获取结果,如果没有结果则立即返回(不会挂起等待)

2.3 对于这四种概念的一个例子

背景:去饭店吃饭

同步:一个人去买饭,要了一份饭之后就自己一直在看老板做饭,等到饭做好了,自己就立马知道了

异步:去买饭,要了一份饭之后,就坐到了桌子旁,不关心饭何时做好,等到饭好了之后让老板通知一下

阻塞:去买饭,要了饭之后在等饭的这段时间内啥都不做,就等着饭何时做好

非阻塞:去买饭,要了饭在等饭的这段时间自己可以做着其他的事情,比如玩手机

对于同步和异步,关心的是结果是自己来知道的,还是别人通知的

对于阻塞和非阻塞,关心的是在等结果的这段时间内,自己有没有利用这段时间做其他的事情还是一直在啥都不做的在等结果的产生

所以对于同步异步与阻塞非阻塞这些概念是针对不同时间段所产生的行为,所以这些概念可以组合起来

同步阻塞:去买饭,要了饭之后就自己一直在看老板做饭等待着饭何时好,并且在等饭的这段时间自己啥也不做,就是一直看老板做饭

同步非阻塞:去买饭,要了饭之后自己就在哪看着老板做饭等待着饭好,在等饭的这段时间,自己有可能也在做着其他的事情,比如玩手机

异步阻塞:去买饭,要了饭之后,自己就不管饭什么时候好了,但是在等饭的这段时间,自己啥都不干就是在等饭

异步非阻塞:去买饭,要了饭之后,自己也不管这个饭什么时候好了,等着好了之后让老板通知自己,但是在等饭的这段时间,自己也在做这其他的事情

3. 非阻塞IO

3.1 fcntl

对于一个文件描述符默认的都是阻塞IO

函数原型:

#include <unistd.h>
#include <fcntl.h>
​
int fcntl(int fd, int cmd, .../* arg */);

cmd命令:对于传入的cmd的值不同,后面追加的参数也不相同

fcntl函数的5种功能:

  • 复制一个现有的描述符(cmd = F_DUPFD)

  • 获得/设置文件描述符标记(cmd = F_GETFD或F_SETFD)

  • 获得/设置文件状态标记(cmd = F_GETFL或F_SETFL)

  • 获得/设置异步I/O所有权(cmd = F_GETOWN或F_SETOWN)

  • 获得/设置记录锁(cmd = F_GETLK,F_SETLK,F_SETLKW)

此处我们只是用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞

3.2 实现函数SetNoBlock

基于fcntl系统调用,我们实现一个SetNoBlock函数,将文件描述符设置为非阻塞

void SetNoBlock(int fd)
{
  int oldfd = fcntl(fd, F_GETFL);
  if (oldfd < 0)
  {
    std::cerr << "fcntl error!" << std::endl;
    exit(1);
  }
​
  //使用oldfd和O_NONBLOCK相或是为了保留对于该描述符以前的特性,仅仅只是增加了非阻塞的属性
  fcntl(fd, F_SETFL, oldfd | O_NONBLOCK);
}
  • 使用F_GETFL将当前文件描述符的属性取出来(这是一个位图)

  • 然后再使用F_SETFL将文件描述符设置回去,设置回去的同时,加上一个O_NONBLOCK参数

3.3 轮询的方式读取标准输入

#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>
​
void SetNoBlock(int fd)
{
  int oldfd = fcntl(fd, F_GETFL);
  if (oldfd < 0)
  {
    std::cerr << "fcntl error!" << std::endl;
    exit(1);
  }
​
  //使用oldfd和O_NONBLOCK相或是为了保留对于该描述符以前的特性,仅仅只是增加了非阻塞的属性
  fcntl(fd, F_SETFL, oldfd | O_NONBLOCK);
}
​
int main()
{
  SetNoBlock(0);
  while (1)
  {
    char buf[1024] = { 0 };
    ssize_t ret = read(0, buf, sizeof(buf) - 1);
    if (ret < 0)
    {
      std::cerr << "read error!" <<std::endl;
      sleep(1);
      continue;
    }
​
    std::cout << buf << std::endl;
  }
  return 0;
}

4. I/O多路转接之select

4.1初识select

系统提供select函数来实现多路复用输入/输出模型

  • select系统调用是用来让我们的程序监视多个文件描述符状态改变

  • 程序会停在select这里等待,直到被监视的文件描述符有一个或者多个发生了状态改变

4.2 select函数原型

#incldue <sys/select.h>
​
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* excepfds, struct timeval* timeout);

参数解释:

  • 参数nfds是需要监视的最大的文件描述符值+1

  • readfds,writefds,excrpfds分别对应需要检测的可读文件描述符的集合,可写的文件描述符的集合以及异常文件描述符的集合

  • 参数time_out为结构timeval,用来设置select()的等待时间的

参数timeout的取值:

  • NULL:则表示select()没有tiemout,select将一直被阻塞,直到某个文件描述符发生了改变

  • 0:仅检测描述符结合的状态,然后立即返回,并不等待外部时间的发生

  • 特定的时间值:如果在指定的时间没有事件发生,select()将超时返回

    • 对于timeval结构体里面有两个变量

    • 一个是tv_sec秒

    • 一个是tv_usec微秒

对于fd_set结构体:

这个在系统内部就是一个结构体,更严格地说就是一个位图,利用位图中对应的为来表示要监视的文件描述符

提供了一组fd_set的接口,来比较方便的操作位图:

void FD_CLR(int fd, fd_set* set); //用来清空set中fd的位
int FD_ISSET(int fd, fd_set* set);//用来测试set中fd为是否为真
void FD_SET(int fd, fd_set* set);//用来设置set中相关的fd位
void FD_ZERO(int fd, fd_set* set);//用来清除set的全部位

关于timeval结构体:

timeval结构用于描述一段时间长度,如果在这个时间内,需要监听的描述符没有事件发生则函数返回,返回值为0

函数返回值:

  • 执行成功则返回文件描述词状态以改变的个数

  • 如果返回0表示在描述状态改变前已超过timeval时间,没有返回

  • 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds,exceptfds和timeout的值变成不可测

错误值可能为:

  • EBADF文件描述符为无效或者文件已经关闭

  • EINTR此调用被信号中断

  • EINVAL参数n为负值

  • ENOMEM核心内存不足

4.3 理解select执行过程

理解select关键在于理解fd_set,为说明方便,取fd_set长度为1字节,fd_set中的每一bit可以对应一个文件描述符fd。则一字节长的fd_set最答可以对应8个fd

  1. 执行fd_set set;FD_ZERO(&set);则set用位表示为00000000

  2. 若fd = 5,执行FD_SET(fd, &set);后fd_set变为00010000(第5位置为1)

  3. 若在加入fd=2,fd=1,则set变为00010011

  4. 执行select(6, &set, 0, 0, NULL)阻塞等待

  5. 若fd=1,fd=2上都发生可读事件,则select返回,此时set变为00000011。

【注意】:没有事件发生的fd=5被清空了

4.4 socoket就绪条件

读就绪

  • socket内核中,接收缓存区中的字节数,大于等于低水位标志SO_RCVLOWAT,此时可以无阻塞的读该文件描述符,并且返回值大于0

  • socketTCP通信中,对端关闭连接,此时对该socket读,则返回0

  • 监听的socket上有新的连接请求

  • socket上有未处理的错误

写就绪

  • socket内核中,发送缓存区中的可用字节数大于等于低水线,SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0

  • socket的写操作被关闭(close或者shutdown),对一个写操作被关闭的socket进行写操作,会触发SIGIPE信号

  • socket使用非阻塞connect连接成功或失败之后

  • socket上未读取的错误

异常就绪

  • socket上收到带外数据

4.5 select特点

  • 可监控的文件描述符的个数取决于sizeof(fd_set)的值。

  • 将fd加入select监控集的同时,还要使用一个数据结构array保存放到select监控集中的fd

    • 一是用于在select返回之后,array作为源数据和fd_set进行FD_ISSET判断

    • 而是用select返回后会把之前加入的但并无事件发生的fd清空,则每次开始select前都要重新从array取得fd逐一加入到,扫描arra的同时取得最大值maxfd,用于select的第一个参数

【备注】:fd_set的大小可以调整,可能涉及到重新编译内核

4.6 select的缺点

  • 每次调用select,都要时候手动设置fd集合

  • 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

  • 每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

  • select支持的文件描述符数量太小

5. IO多路转之poll

5.1 poll函数接口

#include <poll.h>
​
int poll(struct pollfd* fds, nfds_t nfds, int timeout);
​
//pollfd的结构
struct pollfd
{
    int fd;
    short events;
    short revents;
}

参数说明:

  • fds是一个poll系统调用监听的结构列表,每一个元素中,包含了三部分内容,文件描述符,监听的事件集合,返回的事件集合

  • nfds表示fds数组的长度

  • timeout表示poll函数的超时时间,单位是毫秒(ms)

返回结果:

  • 返回值小于0,表示出错

  • 返回值等于0,表示poll函数超时

  • 返回值大于0,表示poll由于监听的文件描述符就绪而返回

poll的优点:

  • pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式

  • poll并没有最大的数量限制(但是数量过大后性能也是会下降的)

poll的缺点:

  • 和select函数一样,poll返回后,需要轮询pollfd来获取机修的描述符

  • 每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中

  • 同时连接的大量客户端在同一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降

6. I/O多路转接之epoll

6.1 epoll初识

为了处理大量句柄而做了改进的poll

6.2 epoll的相关系统调用

epoll有3个相关的系统调用

epoll_create

int epoll_create(int size);
  • 创建一个epoll的句柄

    • 自从linux2.6.8之后,size参数是被忽略的

    • 用完之后,必须调用close()关闭

epoll_create

int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

epoll的事件注册函数:

  • 他不同于select()是在监听事件是告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型

  • 第一个参数是epoll_create()的返回值(epoll的句柄)

  • 第二个参数表示动作,用三个宏来表示

  • 第三个参数是需要监听的fd

  • 第四个参数是告诉内核需要监听什么事

第二个参数的取值

  • EPOLL_CTL_ADD:注册新的fd到epfd中

  • EPOLL_CTL_DEL:修改应注册的fd的监听事件

  • EPOLL_CTL_MOD:从epfd中删除一个fd

struct epoll_event结构如下

typedef union epoll_data {
               void        *ptr;
               int          fd;
               uint32_t     u32;
               uint64_t     u64;
           } epoll_data_t;
​
           struct epoll_event {
               uint32_t     events;      /* Epoll events */
               epoll_data_t data;        /* User data variable *
/
           };

events可以是以下几个宏的集合:

  • EPOLLIN : 表示对应的文件描述符可以读 (包括对端SOCKET正常关闭);

  • EPOLLOUT : 表示对应的文件描述符可以写;

  • EPOLLPRI : 表示对应的文件描述符有紧急的数据可读 (这里应该表示有带外数据到来);

  • EPOLLERR : 表示对应的文件描述符发生错误;

  • EPOLLHUP : 表示对应的文件描述符被挂断;

  • EPOLLET : 将EPOLL设为边缘触发(Edge Triggered)模式, 这是相对于水平触发(Level Triggered)来说的.

  • EPOLLONESHOT:只监听一次事件, 当监听完这次事件之后, 如果还需要继续监听这个socket的话, 需要再次把这个socket加入到EPOLL队列里

epoll_wait:

int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

收集在epoll监控的事件中已经发送的事件.

  • 参数events是分配好的epoll_event结构体数组.

  • epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).

  • maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.

  • 参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).

  • 如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败.

6. 3 epoll工作原理

插图:epoll工作原理

  • 当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关

  • 这个结构体中有着一个红黑树结点和一个双链表

  • 红黑树结点用于存放通过epoll_ctl方法向epoll对象中添加进来的事件,对于重复的事件可以通过红黑树高效的识别出来(红黑树的插入效率为lgn,n为树的高度)

  • 对于所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说当响应的事件发生时会调用这个回调方法

  • 这个回调方法在内核中叫做ep_poll_callback,他会将发生的是啊金添加的到rdlist双链表中

  • 在epoll中,对于每一个事件都会建立一个epitem结构体

struct epitem{
    struct rb_node rbn;//红黑树节点
    struct list_head rdllink;//双向链表节点
    struct epoll_filefd ffd; //事件句柄信息
    struct eventpoll *ep; //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
}
  • 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否epitem元素即可

  • 如果rdlist不为空,这把发生的事件复制到用户态,同时将事件数量返回给会用,这个操作的时间复杂度是O(1)

总结一下, epoll的使用过程就是三部曲:

  • 调用epoll_create创建一个epoll句柄;

  • 调用epoll_ctl, 将要监控的文件描述符进行注册;

  • 调用epoll_wait, 等待文件描述符就绪

6.4 epoll的优点(和 select 的缺点对应)

  • 接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开

  • 数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)

  • 事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中,

  • epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.

  • 没有数量限制:文件描述符数目无上限

6.5 容易认识的误区

有人说,epoll使用的是内存映射机制

内存映射机制是内核直接将就绪队列通过mmap的方式映射到用户态,避免了拷贝内存这样额外性能开销这种说法不准确,我们定义的是 struct epoll_event是我们在用户空间中分配好的内存,势必还是需要将内核的数据拷贝到这个用户空间的内存中的,这个拷贝过程还是存在的

6.6 epoll的工作方式

两种工作方式:

水平触发(LT)和边缘触发(ET)

  • 水平触发就是对于缓存区的数据没有读完的话,每次等待的时候都会进入到就绪状态,再次读取数据,直到将数据都读取完

  • 边缘触发就是对于不管这次就绪之后缓存区的数据读没读完,只有等到下一次客户端再发数据才会进入到等待状态

假如有这样一个例子:

  • 我们已经把一个tcp socket添加到epoll描述符

  • 这个时候socket的另一端被写入2KB数据

  • 调用epoll_wait,并且他会返回,说明他已经准备好读取操作

  • 然后调用read,只读取1KB的数据

  • 继续调用epoll wait......

水平触发Level Tiggered工作模式

epoll默认状态下就是LT工作模式:

  • 当epoll检测到socket上事件就绪的时候,可以不立刻进行处理,或者只处理一部分

  • 如上面的例子,虽然只读了1K的数据,缓冲区中还剩下1K数据,在第二次调用epoll_wait时,epoll_wait仍然会立刻返回并通知socket读事件就绪

  • 直到缓冲区上所有的数据都被处理完,epoll_wait才不会立刻返回

  • 支持阻塞读写和非阻塞读写

    • 对于阻塞读写,也就是每次只读取最大的数据,要读取多次才可以读完

    • 对于非阻塞读写,一次就要将缓冲区中的数据全部读完

边缘触发Edge Triggered工作模式

如果我们在第一步将socket添加到epoll描述符的时候使用了EPOLLET标志,epoll进入ET工作模式

  • 当epoll检测到socket上事件上就绪是,必须立刻处理

  • 如上面的例子,虽虽然只读了1K的数据,缓存区还剩1K的数据,在第二个调用epoll_wait时,epoll_wait不会在返回了

  • 也就是说,ET模式下,文件描述符上的事件机修后,只有一次处理机会

  • ET的性能比LT性能更高(epoll_wait返回的次数少了很多),Nginx默认采用ET模式使用epoll

  • 只支持非阻塞的读写

select和poll其实也是工作在LT模式下,epoll既可以支持LT,也可以支持ET

LT是epoll的默认行为。使用ET能够减少epoll的触发次数,但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完。

相当于一个文件描述符就绪之后,不会反复被提示就绪,看起来就比LT更高效一些。但是在LT情况下如果也能做到每次就绪的文件描述符都能立刻处理,不让这个就绪被重复提示的话,其实性能也是一样的。(这也就是对于LT使用非阻塞式的文件描述符,在每次文件来的时候,都将其一次读取完成,也就只需要就绪一次了)

另一方面就是ET的代码复杂度高了

6.7 理解ET模式和非阻塞文件描述符

使用ET模式的epoll,需要将这个文件描述符设置为非阻塞。这个不是接口上的要求,而是“工程实践”上的要求。

例:假如是这样客户端发过来一个消息,是10个字节,然后该文件描述符就绪了,客户端就会从接收缓存区中读取数据,但是对于接收缓存区来来说,每次最多只能读取2字节,所以就会有八个字节,如果是ET模式的话,那么只能到下次客户端发送消息的时候,文件描述符才能就绪,但是下一次客户端直接退出给服务器发送的消息是0字节,但是接受缓存区还有上一次没有读完的8字节的数据,然后服务器就有接收缓存区中读取2字节的数据,就会认为读取数据,成功,不会断开这个链接,但是对于客户端来说,最后一次应该发送0字节的数据,服务器读到0字节也就会提出,此时没有读到0字节的数据,读的是以前的数据,这样的话,服务器就不会断开这个链接,就会导致浪费服务器的资源。

但是如果文件描述符是非阻塞式的,就不会产生这样的问题,就会一次把数据读取完毕,不会导致,浪费服务器的资源。

所以对于ET模式的epoll必须要将1文件描述符设置为非阻塞式的

假设这样的一个场景:服务器收到一个10K的请求,会向客户端返回一个应答数据,如果客户端收到不应答,不会发送第二个10K请求

如果服务器端写的代码是阻塞式的read,并且一次只read1K的数据的话(read不能保证一次就把所有的数据都读出来)剩下的9K的数据就会呆着缓存区中

此时由于epoll是ET模式。并不会认为文件描述符机修,epoll_wait就不会再次返回,剩下的9K数据会一直在缓存区中,直到下一次客户端再给服务器写数据,epoll_wait才能返回

问题来了:

  • 服务器只读到1K数据,要10K读完才能给客户端返回响应数据

  • 客户端要读到服务器的响应,才会发送下一个请求

  • 客户端发送了下一个请求,epoll_wait才会返回,才能去缓存区中读取剩余的数据

所以为了解决上述的问题(read不一定能一下完整的请求读完),于是就可以使用非阻塞轮询的方式来读缓存区,保证一定能把完整的请求都读出来

而如果是LT没这个问题,只要缓存区中的数据没有读完,就能够让epoll_wait返回文件描述符就绪

6.8 epoll的使用场景

epoll的高性能,是由一定的特定场景的,如果场景选择的不适宜epoll的性能可能适得其反

  • 对于多连接,且多连接中只有一部分比较活跃时,比较适合使用epoll

例如:典型的一个需要处理上万个客户端的服务器,例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根据需求和场景特点来决定使用哪种IO模型.

6.9 epoll中的惊群问题

产生原因:

对于服务器来说肯定不是单线程或者单进程的,所以为了提高服务器的性能就会采取这样的方法让多个线程同时监控文件描述符。

那么就会出现这样的两种情况:

一是,让不同的线程或者进程监控不同的文件描述符这样就有可能因为文件描述符的部分活跃,导致有的线程或者进程的文件描述符一直是就绪状态对于另外一个几乎没有就绪状态的文件描述符,这样就会导致服务器的性能还是没有提高

二是:让所有的线程或者进程都监控一样多的文件描述符,这样的话,当一个文件描述符就绪的话,操作系统不知道将其中的那个线程或者进程去做这个事情,就会让几个线程或者进程都给唤醒,而实际上只有一个进程或者线程会成功,其他的线程或者进程都会失败,且错误码是EAGAIN。这种现象就是惊群效应。

惊群效应肯定会带来资源的消耗和性能的影响

解决方法:

多线程环境下解决惊群的方法:

这种情况,不建议让多个线程同时在epoll_wait监听的socket,而是让其中一个线程epoll_wait监听的socket,当有新的链接请求进来之后,由epoll_wait的线程调用accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样就可以避免了由于多线程环境下的epoll_wait惊群效应问题。

多进程下解决惊群的方法:

目前很多开源软件,如lighttpd,nginx等都采用master/workers的模式提高软件的吞吐能力及并发能力,在nginx中甚至还采用了负载均衡的技术,在某个子进程的处理能力达到一定负载之后,由其他负载较轻的子进程负责epoll_wait的调用,那么nginx和Lighttpd是如何避免epoll_wait的惊群效用的。

lighttpd的解决思路是无视惊群效应,仍然采用master/workers模式,每个子进程仍然管自己在监听的socket上调用epoll_wait,当有新的链接请求发生时,操作系统仍然只是唤醒其中部分的子进程来处理该事件,仍然只有一个子进程能够成功处理此事件,那么其他被惊醒的子进程捕获EAGAIN错误,并无视。

nginx的解决思路:在同一时刻,永远都只有一个子进程在监听的socket上epoll_wait,其做法是,创建一个全局的pthread_mutex_t,在子进程进行epoll_wait前,则先获取锁。

7. 对于select,poll和epoll的比较

【知识点】:

缺点:

  1. select能监控的最大描述符个数是有限的

  2. 每次都需要将描述符集合从用户态拷贝到内核态

,当描述符太多时,效率下降

  1. select并不会直接告诉我们哪一个描述符就绪了,而是将未就绪的描述符从集合中移除,因此需要我们遍历所有的描述符是否在集合中,才能知道到底是哪一个就绪,如果描述符太多则效率下降

  2. 因为select每次都会修改监控集合的内容,因此每次调用select之前需要重新添加描述符到集合中

  3. 因为以上的原因,因此diamante编写稍显复杂(看情况)

这个是一个高并发的服务器,而对于多进程和多线程就是高并行的服务器

优点:

  1. 跨平台(windows下也有select)

多路复用和多线程/多进程的对比

在资源足够的情况下,多线程/多进程可以并行,但是多路复用同一时间仅能接收一个请求只能并发

但是多路复用省资源。可以用多个线程都使用多路复用

多路复用技术用于有大量连接但是同一时间只有少量活跃连接的情况

epoll的优缺点:优点:

  1. 文件描述符数目无上限

select:描述符有上限 描述符多了性能降低 (轮询判断)需要轮询找出据需的描述符,效率低 select编码比较纠结 select需要每次重新添加描述符到集合,并且每次需要从用户态拷贝到内核态

poll:描述符无上限 描述符多了性能降低 (轮询判断)

需要轮询找出据需的描述符,效率低 编码相对简单,并且每次需要从用户态拷贝到内核态

epoll:描述符无上限 描述符多了性能不会降低(基于事件触发回调)拿到的就是就绪的,不需要无谓轮询 编码相对简单,但是事件仅需定义一次,并且向内核拷贝一次即可

select跨平台

poll和epoll只能在Linux下使用

对于多路转接 来说都是仅适用于有大量连接,但是同一时间仅有少量活跃的情况,

水平触发/边缘触发

边缘触发:每次新数据到来提醒一次,如果数据没有全部读取,那么epoll将不会再次触发事件,直到下一次新数据到来。

因此每次需要每次将缓存区数据全部读取,因为全部读取一不小心就会导致recv阻塞(缓存区没有数据还一直在读取就会阻塞),所以当socket是边缘触发时,需要将socket描述符设置为非阻塞模式

水平触发:只要满足触发条件就会提醒(只要缓存区有数据,就会触发)epoll 默认水平触发

猜你喜欢

转载自blog.csdn.net/qq_40399012/article/details/86657188