Sherlock的程序人生

多路复用

1、说明

socket编程的demo中使用的都是最基本的,但是一般不会真正用在项目中的代码。而实际项目中,需要面临复杂多变的需求环境,比如有多个socket连接,或者服务需要监听的时候,可能有很多socket连接进来。面对这种情况,最直接最简单的想法是,一个socket连接创建一个线程去处理。当然,在socket连接数较少的情况下,这种方式无可厚非,但是如果连接数量较大,就会出现意外情况。

我们都知道,linux下一个线程默认所占的内存是8M(可以使用ulimit -s查看),那么加入,1000个socket连接,建立1000线程,光线程的开销就高达8G多,更遑论其他业务还要使用内存了。

而且,在很多情况下,socket建立连接之后,并不是要一直通信,而是间隔通信,那么占用一个独立的线程来“照顾”这个连接显得很不明智。

针对这种情况,就需要采用多路复用机制,所谓多路复用,就是一个进程见识多个socket描述符,一旦某个socket描述符就绪(可读写或者异常)了,就会通知应用程序,进行相应的处理。

1.1、多路复用的几种机制

目前的多路复用机制有三种,select、poll 和 epoll。这三种机制各有优劣

2、函数简介

2.1、select

头文件和函数声明:

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

FD_ZERO(int fd, fd_set* fds)   // 清空集合
FD_SET(int fd, fd_set* fds)    // 将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds)  // 判断指定描述符是否在集合中 
FD_CLR(int fd, fd_set* fds)    // 将给定的描述符从文件中删除

描述:

监听多个文件描述符的属性变化。

函数返回后,需要便利fd_set来找到就绪的描述符

参数说明:

nfds: 需要监听的描述符的范围,一般是最大描述符+1,比如,现在需要监听 0/1/2/3/4/5 这几个描述符,则参数设置为6,在linux下,值最大是1024

readfds: 监听到的可读的描述符的set,所有可读的描述符都会存储到这里

writefds: 监听到的可写的描述符的set

exceptfds: 监听到的异常的描述符set

timeout: select 方法的超时时间,这个参数决定 select 的运行机制,可能有三种值

  1. NULL,设置为空指针,则select阻塞运行
  2. 0,select 非阻塞运行,机制变为轮询,注意,不是参数传递0,参数传递0表示NULL
  3. >0,在指定的时间内阻塞,但有事件或者超时之后返回,返回值为有事件的描述符数量

返回值:

select 返回有事件的描述符数量,可以在对应的set中找到具体的描述符,错误则返回-1

优点: 跨平台

缺点:

  1. 描述符的数量受到限制,比如linux下最大1024;
  2. 每次调用,都需要将set从用户态复制到内核态,这在描述符多时,开销很大;
  3. 采用便利轮询的方式,多了无意义的损耗,比如,只需要监听99的描述符,但是内核会遍历0~100的描述符;

2.2、poll

头文件和函数声明:

#include <poll.h>

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

struct pollfd {
    int fd; 	   /* 描述符 */
    short events;  /* 需要监听的事件 */
    short revents; /* 实际发生的事件 */
};

函数描述: 监听多个文件描述符的属性变化,和select类似,但是有很大区别,使用一个 pollfd 指针来替代 select 的三个set的功能

参数描述:

fd: 结构体指针,可以传入多个结构体,每个结构体都是一个被监听的描述符

nfds: 指定传入的结构体的数量

timeout: 超时时间,单位毫秒,-1 表示阻塞

返回值: 返回有事件的描述符数量,函数返回后,需要轮询来找到发生事件的描述符,错误则返回-1

pollfd 结构体:

fd: 表示描述符

events: 需要监听的事件掩码,取值如下

revents: 实际发生的事件掩码,取值如下

宏定义 可作events的值 可作revents的值 说明
POLLIN y y 数据可读
POLLRDNORM y y 普通数据可读
POLLRDBAND y y 优先数据可读
POLLPRI y y 紧迫带数据可读
POLLOUT y y 数据可写,不会阻塞
POLLWRNORM y y 普通数据可写,不会阻塞
POLLWRBAND y y 优先级带数据可写,不会阻塞
POLLMSGSIGPOLL y y 消息可用

非法事件

宏定义 可作events的值 可作revents的值 说明
POLLERR y 发生错误
POLLHUP y 发生挂起
POLLNVAL y 描述不是打开的文件

POLLIN | POLLPRI 等价于 select 的读事件,而 POLLIN 等价于 POLLRDNORM | POLLRDBAND

POLLOUT | POLLWRBAND 等价于 select 的写事件,而 POLLOUT 等价于 POLLWRNORM

这些事件不是互斥的,可以同时设置

优缺点:

和 select 相比,poll 没有了数量的限制,但是数量太大也会影响效率

poll 同样有着将传入的描述符从用户态复制到内核态的缺点,开销随着描述符数量的增大而线性增大

2.3、epoll

epoll是后来提出的,作为 select 和 poll 的增强版本

epoll 的操作需要三个接口,头文件和声明如下:

#include <sys/epoll.h> 

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);  

2.3.1、epoll_create

int epoll_create(int size); 

创建一个 epoll 专用的描述符,size 为监听的数目

size 参数并没有限制 epoll 监听的描述符的最大限制,而是作为内部分配数据结构的一个建议,linux自动2.6.8版本之后,该参数被忽略,只要大于0就行

返回一个描述符,使用结束时,需要close(),错误则返回-1

2.3.2、epoll_ctl

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

epoll 的事件注册,告诉 epoll 要监听哪些事件

返回0表示注册成功,-1表示失败

参数:

epfd: epoll 专用的描述符,由epoll_create() 返回

op: 该函数的作用,即注册(EPOLL_CTL_ADD)、修改(EPOLL_CTL_MOD)和删除(EPOLL_CTL_DEL)

fd: 需要监听的描述符

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 */  
};  

epoll_event 中的 events 可以是以下宏定义的集合

宏定义 说明
EPOLLIN 表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭)
EPOLLOUT 表示对应的文件描述符可以写
EPOLLPRI 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR 表示对应的文件描述符发生错误
EPOLLHUP 表示对应的文件描述符被挂断
EPOLLET 将 EPOLL 设为边缘触发(Edge Trigger)模式,这是相对于水平触发(Level Trigger)来说的
EPOLLONESHOT 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里

2.3.3、epoll_wait

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

等待 epoll 监听下的 IO 事件

参数:

epfd: epoll 描述符

events: epoll 会把发生的事件赋值到events中

maxevents: 表明这个 events 的大小

timeout: 超时时间,单位毫秒,-1表示阻塞

返回值: 返回需要处理的事件数目,0表示超时未有事件,-1表示失败

2.4、其他方法

FD_ZERO(fd_set *fdset);	//清空一个描述符集合
FD_SET(fd_set *fdset, int fd);	//添加fd到描述符集合中
FD_CLR(fd_set *fdset, int fd);	//从描述符集合中删除一个fd
FD_ISSET(int fd,fd_set *fdset);	//检查fd是否在描述符集合中

3、epoll

epoll 的工作模式有两种:LT(level trigger) 和 ET(edge trigger),默认LT模式,区别如下:

LT模式: 当 epoll_wait 检测到描述符事件发生,并通知应用程序,应用程序可以不利己处理该事件,下次调用 epoll_wait 时,还是会通知此事件

ET模式: 当 epoll_wait 检测到描述符事件发生,并通知应用程序,应用程序必须立即处理该事件。如果不处理,则下次调用时,不会再次通知此事件

3.1、LT模式和ET模式

LT模式:

默认模式,支持阻塞和非阻塞方式,内核会通知事件发生,若果不做任何操作,则内核下次还是会通知,编程不易出错,select/poll都是这种模式

ET模式:

告诉你工作模式,只支持非阻塞。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

3.2、优缺点

  1. 监听数量不受限制,理论上上限是最大可以打开的文件数目,这个数目一般远大于2048,linux上可以使用 cat /proc/sys/fs/file-max 命令查看。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
  2. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。
  3. 文件描述符只需要复制一次到内核,不需要每一次调用函数都进行文件描述符的内核复制

如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。

posted @ 2020-11-01 22:42  sherlock_lin  阅读(2162)  评论(0编辑  收藏  举报