I/O多路复用(又被称为“事件驱动”),首先要理解的是,操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一个通知。这样当配合非阻塞的socket使用时,只有当系统通知我哪个描述符可读了,我才去执行read操作,可以保证每次read都能读到有效数据而不做纯返回-1和EAGAIN(read发现输入缓冲中没数据可读时返回-1,并在errno中保存EAGAIN常量)的无用功。写操作类似。操作系统的这个功能通过select/poll/epoll/kqueue之类的系统调用函数来使用,这些函数都可以同时监视多个描述符的读写就绪状况,这样,多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫 I/O多路复用,这里的“复用”指的是复用同一个线程。
select 编程模型就是:一个连接来了,就必须遍历所有已经注册的文件描述符,来找到那个需要处理信息的文件描述符,如果已经注册了几万个文件描述符,那会因为遍历这些已经注册的文件描述符,导致cpu爆炸。
通过改进「IO多路复用」模型,进一步的优化,发明了一个叫做epoll的方法。
从常用的IO操作谈起,比如read和write,通常IO操作都是阻塞I/O的,也就是说当调用read时,如果没有数据收到,那么线程或者进程就会被挂起,直到收到数据。
这样,当服务器需要处理1000个连接的的时候,而且只有很少连接忙碌的,那么会需要1000个线程或进程来处理1000个连接,而1000个线程大部分是被阻塞起来的。由于CPU的核数或超线程数一般都不大,比如4,8,16,32,64,128,比如4个核要跑1000个线程,那么每个线程的时间槽非常短,而线程切换非常频繁。这样是有问题的:
- 线程是有内存开销的,1个线程可能需要512K(或2M)存放栈,那么1000个线程就要512M(或2G)内存。
- 线程的切换,或者说上下文切换是有CPU开销的,当大量时间花在上下文切换的时候,分配给真正的操作的CPU就要少很多。
那么,我们就要引入非阻塞I/O的概念,非阻塞IO很简单,通过fcntl(POSIX)或ioctl(Unix)设为非阻塞模式,这时,当你调用read时,如果有数据收到,就返回数据,如果没有数据收到,就立刻返回一个错误,如EWOULDBLOCK。这样是不会阻塞线程了,但是你还是要不断的轮询来读取或写入。
于是,我们需要引入IO多路复用的概念。多路复用是指使用一个线程来检查多个文件描述符(Socket)的就绪状态,比如调用select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪,则返回,否则阻塞直到超时。得到就绪状态后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如使用线程池)。
这样在处理1000个连接时,只需要1个线程监控就绪状态,对就绪的每个连接开一个线程处理就可以了,这样需要的线程数大大减少,减少了内存开销和上下文切换的CPU开销。
使用select函数的方式如下图所示:
select()函数
#include
#include
int select(int maxfd, fd_set *readset, fd_set *writeset, fd_set *exceptset,
const struct timeval *timeout);
//返回:若有就绪描述符则为其数目,若超时则返回0,出错返回-1
参数介绍:
maxfd指定被监听的文件描述符的总数,通常被设置为select监听的所有文件描述符中的最大值加1;
- 三个参数readset,writeset,exceptset指定我们要让内核测试读、写和异常条件的描述符。
select使用描述符集,通常是一个整数数组,其中每个整数中的每一位对应一个描述符。举例来说,假设使用32位整数,那么该数组的第一个元素对应于描述符0~31,第二个元素对应于描述符32~63,以此类推。所有这些实现细节都与应用程序无关,它们隐藏在名为fd_set的数据类型和以下四个宏中:
void FD_ZERO(fd_set *fdset); //将fd_set变量的所有位初始化为0
void FD_SET(int fd, fd_set *fdset); //在参数fdset指向的变量中注册文件描述符fd的信息
void FD_CLR(int fd, fd_set *fdset); //从参数fdset指向的变量中清除文件描述符fd的信息
int FD_ISSET(int fd, fd_set *fdset); //若参数fdset指向的变量中包含文件描述符fd的信息,则返回真
我们分配一个fd_set数据类型的描述符集,并用这些宏设置或测试该集合中的每一位,也可以用C语言中的赋值语句把它赋值成另外一个描述符集。
注意:前面所讨论的每个描述符占用整数数组中的一位的方法仅仅是select函数的可能实现之一。
readfds、writefds、exceptfds分别指向可读、可写和异常等对应的文件描述符集合,select调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪,即select返回的时候结果是在这三个参数里面的,在调用select之前我们把监视描述符设置为1,当返回的时候未就绪的会变成0,而就绪的就为1(除了就绪的,其他的1都变成0,也就是还为1的那些是就绪的)。它们都是fd_set结构指针类型,fd_set结构体仅包含一个整形数组,该数组的每个元素的每一位标记一个文件描述符。
timeout用来设置select函数的超时时间,告知内核等待所指定描述符中的任何一个就绪可花多长时间,它是一个timeval结构类型的指针
若给timeout的两个参数都传0,则select立即返回;若给timeout传递NULL,则select一直阻塞,直到某个文件描述符就绪。有三种可能:
- 永远等待下去:仅在有一个描述符准备好I/O时才返回,将其设为空指针
- 等待一段固定时间:在有一个描述符准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
- 根本不等待:检查描述符后立即返回,这就是轮询。为此,该参数必须指向一个timeval结构,但是其中的值必须设置为0
系统调用介绍:select成功时返回就绪(可读、可写和异常)文件描述符的总数。
select返回套接字的“就绪”条件
- 满足下列四个条件之一的任何一个时,一个套接字准备好读:
- 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对于这样的套接字执行读操作不会阻塞并将返回一个大于0的值(也就是返回准备好读入的数据)。我们使用SO_RECVLOWAT套接字选项设置套接字的低水位标记。对于TCP和UDP套接字而言,其默认值为1
- 该连接的读半部关闭(也就是接收了FIN的TCP连接)。对这样的套接字的读操作将不阻塞并返回0(也就是返回EOF)
- 该套接字是一个监听套接字且已完成的连接数不为0。对这样的套接字accept通常不会阻塞
- 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置为确切的错误条件。这些待处理错误也可以通过SO_ERROR套接字选项调用getsockopt获取并清除。
- 下列四个条件的任何一个满足时,一个套接字准备好写:
- 该套接字发送缓冲区中的可用字节数大于等于套接字发送缓冲区低水位标记的当前大小,并且或该套接字已连接,或者该套接字不需要连接(如UDP套接字)。这意味着如果我们把这样的套接字设置成非阻塞的,写操作将不阻塞并返回一个正值(如由传输层接收的字节数)。我们使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记。对于TCP和UDP而言,默认值为2048
- 该连接的写半部关闭。对这样的套接字的写操作将产生SIGPIPE信号
- 使用非阻塞式connect套接字已建立连接,或者connect已经已失败告终
- 其上有一个套接字错误待处理。对这样的套接字的写操作将不阻塞并返回-1(也就是返回一个错误),同时把errno设置为确切的错误条件。这些待处理错误也可以通过SO_ERROR套接字选项调用getsockopt获取并清除。
- 如果一个套接字存在带外数据或者仍处于带外标记,那么它有异常条件待处理。
- 注意:当某个套接字上发生错误时,它将由select标记为既可读又可写
- 接收低水位标记和发送低水位标记的目的在于:允许应用进程控制在select可读或可写条件之前有多少数据可读或有多大空间可用于写。
- 任何UDP套接字只要其发送低水位标记小于等于发送缓冲区大小(默认应该总是这种关系)就总是可写的,这是因为UDP套接字不需要连接。
调用select之前一定需要先将fd_set初始化,然后设置自己关心的描述符,当返回的时候调用FD_ISSET()
来查看哪些是就绪的。
select的缺点:
- 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024)
- 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
- select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
- select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理
。这样所带来的缺点是:
1、select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。
一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看
。32位机默认是1024个。64位机默认是2048.
2、对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。
当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询
,这正是epoll与kqueue做的。
3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
epoll()函数
select和poll函数是当关心的描述符如果有就绪事件发生,返回之后它们是不清楚哪个描述符发生了什么事的,必须去一个一个轮询,那么当描述符数量较多的时候效率很明显会下降,而epoll函数是通过为每个描述符注册一个callback回调函数,当描述符有就绪事件发生的时候,就会直接调用callback函数。不过当描述符较多的时候并且很多描述符都会激活的情况epoll的效率不一定比select/poll会高,select/poll适合那种描述符就绪状态变化频率较少的场景
另外epoll使用mmap用户空间映射到进程虚拟地址空间,加速了从内核空间到用户空间的消息传递。
epoll还区分是边缘触发(Edge Triggered)和水平触发(Level Triggered)。对于前者,只有在状态变化的时候才得到通知,即使缓冲区内还有未处理的数据也是得不到通知的。而后者是只要缓冲区有数据,就会一直有通知。
- epoll是Linux特有的I/O复用函数。它在实现和使用上与select、poll有很大的差异。
- 首先,epoll使用一组函数来完成任务,而不是单个函数。
- 其次,epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。
- 但epoll需要使用一个额外的文件描述符,来唯一标识内核中的这个事件表
epoll文件描述符使用如下方式创建:
#include
int epoll_create(int size);//创建保存文件描述符的空间,即epoll例程
size参数完全不起作用,只是给内核一个提示,告诉它事件表需要多大。该函数返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。成功时返回epoll文件描述符,失败时返回-1。
下面的函数用来操作epoll的内核事件表:
#include
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//返回:若成功返回0,失败返回-1,并置errno
参数epfd是用于注册监视对象的epoll例程的文件描述符,fd参数是要操作的文件描述符,op参数则指定操作类型。操作类型有以下三类:
- EPOLL_CTL_ADD, 将文件描述符注册到epoll例程
- EPOLL_CTL_MOD, 修改fd上的注册事件
- EPOLL_CTL_DEL, 从epoll例程中删除文件描述符
event指定监视对象关注的事件,它是epoll_event结构指针类型,epoll_event的定义如下:
strcut epoll_event{
__uint32_t events; //epoll事件
epoll_data_t data; //用户数据
};
epoll系列系统调用的主要接口是epoll_wait函数,它在一段超时时间内等待一组文件描述符上的事件,其原型如下:
#include
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//返回:若成功返回就绪的文件描述符个数,失败时返回-1,并置errnoo
- maxevents参数指定最多监听多少个事件,它必须大于0
event_wait函数如果检测到事件,就将所有就绪事件从内核事件表(由epfd参数指定)中复制到它的第二个参数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肯定就绪,直接处理
}
- LT和ET模式
- LT(Level Trigger,电平/条件触发)模式:是默认工作模式,在这种模式下的epoll相当于一个效率较高的poll。当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件。
- ET(Edge Trigger,边沿/边缘触发)模式。对于ET工作模式下的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。
- ET模式在很大程度上降低了同一个epoll事件被重复触发的次数。因此效率要比LT模式高。
- 每个使用ET模式的文件描述符都应该是非阻塞的。如果文件描述符是阻塞的,那么读或写操作将会因为没有后续的时间而一直处于阻塞状态(饥渴状态)
- EPOLLONESHOT事件
- 即使使用ET模式,一个socket上的某个事件还是可能被触发多次。这在并发程序中引起一个问题。比如一个线程(或进程)在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket上又有新数据可读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是出现了两个线程同时操作一个socket的场面。这当然不是我们期望的。我们期望的是一个socket连接在任一时刻都只被一个线程处理。
- 对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上的EPOLLONESHOT事件。这样,当一个线程在处理某个socket时,其他线程时不可能有机会操作该socket的。但反过来思考,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket.
Linux内核具体的epoll机制实现思路。
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关。eventpoll结构体如下所示:
struct eventpoll{
....
/*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/
struct rb_root rbr;
/*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/
struct list_head rdlist;
....
};
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过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不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
epoll数据结构示意图
从上面的讲解可知:通过红黑树和双链表数据结构,并结合回调机制,造就了epoll的高效。
OK,讲解完了Epoll的机理,我们便能很容易掌握epoll的用法了。一句话描述就是:三步曲。
第一步:epoll_create()系统调用。此调用返回一个句柄,之后所有的使用都依靠这个句柄来标识。
第二步:epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改感兴趣的事件,返回0标识成功,返回-1表示失败。
第三部:epoll_wait()系统调用。通过此调用收集收集在epoll监控中已经发生的事件。
epoll_create : 创建红黑树和就绪列表,实际他创建了一个专属于epoll文件系统的一个文件
epoll_ctl :向红黑树添加socket句柄,向内核注册回调函数,用于当中断事件来临时,向准备就绪列表插入数据
epoll_wait :返回准备就绪列表的数据
shutdown函数
(1)close把描述符的引用计数减一,仅在该计数变为0时才关闭套接字。使用shutdown可以不管引用计数就激发TCP的正常连接终止
(2)close终止读和写两个方向的数据传送。而shutdown却可以单方向的关闭套接字的一半。比如当我们给服务器的数据发送关闭,我们可以直接关闭写这一半,即使服务器这时候还是有数据要发给我们的话我们仍然可以读连接。既然TCP连接是全双工的,有时候我们需要告知对端我们已经完成了数据发送,即使对端有数据要发送给我们,此时可使用shutdonwn函数
#include
int shutdown(int sockfd, int howto);
成功时返回0,出错时返回-1
参数howto的值:
SHUT_RD 关闭连接的读一半,套接字不再有数据可接收,而且套接字接收缓冲区中的现有数据都被丢弃。进程不能再对这样的套接字调用任何读函数。对一个TCP套接字这样调用shutdown函数后,由该套接字接收的来自对端的任何数据都被确认,然后悄然丢弃。
SHUT_WR 关闭连接的写一半,对于TCP套接字,这称为半关闭。当前留在套接字发送缓冲区中的数据将被发送掉,后跟TCP的正常连接终止序列。
SHUT_RDWR 连接的读半部和写半部都关闭
三组I/O复用函数的比较:
epoll适用于连接数量多,但活动连接少(因为若活动连接数多,会频繁调用回调函数)。
select原理概述
调用select时,会发生以下事情:
- 从用户空间拷贝fd_set到内核空间;
- 注册回调函数__pollwait;
- 遍历所有fd,对全部指定设备做一次poll(这里的poll是一个文件操作,它有两个参数,一个是文件fd本身,一个是当设备尚未就绪时调用的回调函数__pollwait,这个函数把设备自己特有的等待队列传给内核,让内核把当前的进程挂载到其中);
- 当设备就绪时,设备就会唤醒在自己特有等待队列中的【所有】节点,于是当前进程就获取到了完成的信号。poll文件操作返回的是一组标准的掩码,其中的各个位指示当前的不同的就绪状态(全0为没有任何事件触发),根据mask可对fd_set赋值;
- 如果所有设备返回的掩码都没有显示任何的事件触发,就去掉回调函数的函数指针,进入有限时的睡眠状态,再恢复和不断做poll,再作有限时的睡眠,直到其中一个设备有事件触发为止。
- 只要有事件触发,系统调用返回,将fd_set从内核空间拷贝到用户空间,回到用户态,用户就可以对相关的fd作进一步的读或者写操作了。
epoll原理概述
调用epoll_create时,做了以下事情:
- 内核帮我们在epoll文件系统里建了个file结点;
- 在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket;
- 建立一个list链表,用于存储准备就绪的事件。
调用epoll_ctl时,做了以下事情:
- 把socket放到epoll文件系统里file对象对应的红黑树上;
- 给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。
调用epoll_wait时,做了以下事情:
观察list链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。
总结如下:
一颗红黑树,一张准备就绪句柄链表,少量的内核cache,解决了大并发下的socket处理问题。
执行epoll_create时,创建了红黑树和就绪链表;
执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据;
执行epoll_wait时立刻返回准备就绪链表里的数据即可。
两种模式的区别:
LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时重复返回这个句柄,而ET模式仅在第一次返回。
两种模式的实现:
当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait检查这些socket,如果是LT模式,并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表。所以,LT模式的句柄,只要它上面还有事件,epoll_wait每次都会返回。
对比
select缺点:
- 最大并发数限制:使用32个整数的32位,即32*32=1024来标识fd,虽然可修改,但是有以下第二点的瓶颈;
- 效率低:每次都会线性扫描整个fd_set,集合越大速度越慢;
- 内核/用户空间内存拷贝问题。
epoll的提升:
- 本身没有最大并发连接的限制,仅受系统中进程能打开的最大文件数目限制;
- 效率提升:只有活跃的socket才会主动的去调用callback函数;
- 省去不必要的内存拷贝:epoll通过内核与用户空间mmap同一块内存实现。
当然,以上的优缺点仅仅是特定场景下的情况:高并发,且任一时间只有少数socket是活跃的。
如果在并发量低,socket都比较活跃的情况下,select就不见得比epoll慢了(就像我们常常说快排比插入排序快,但是在特定情况下这并不成立)。