处理并发之一:LINUX Epoll 机制介绍
Epoll 可是当前在 Linux 下开发大规模并发网络程序的热门人选,Epoll 在 Linux2.6 内核中正式引入,和 select 相似,其实都 I/O 多路复用技术而已,并没有什么神秘的。
其实在 Linux 下设计并发网络程序,向来不缺少方法,比如典型的 Apache 模型(Process Per Connection,简称 PPC),TPC(Thread Per Connection)模型,以及 select 模型和 poll 模型,那为何还要再引入 Epoll 这个东东呢?那还是有得说说的…
常用模型的缺点
如果不摆出来其他模型的缺点,怎么能对比出 Epoll 的优点呢。
PPC/TPC 模型
这两种模型思想类似,就是让每一个到来的连接一边自己做事去,别再来烦我。只是 PPC 是为它开了一个进程,而 TPC 开了一个线程。可是别烦我是有代价的,它要时间和空间啊,连接多了之后,那么多的进程 / 线程切换,这开销就上来了;因此这类模型能接受的最大连接数都不会高,一般在几百个左右。
select模型
最大并发数限制,因为一个进程所打开的 FD(文件描述符)是有限制的,由 FD_SETSIZE 设置,默认值是 1024/2048,因此 Select 模型的最大并发数就被相应限制了。自己改改这个FD_SETSIZE?想法虽好,可是先看看下面吧…
效率问题,select 每次调用都会线性扫描全部的 FD 集合,这样效率就会呈现线性下降,把FD_SETSIZE 改大的后果就是,大家都慢慢来,什么?都超时了??!!
内核 / 用户空间 内存拷贝问题,如何让内核把 FD 消息通知给用户空间呢?在这个问题上 select 采取了内存拷贝方法。
poll 模型
基本上效率和 select 是相同的,select 缺点的 2 和 3 它都没有改掉。
Epoll 的提升
把其他模型逐个批判了一下,再来看看 Epoll 的改进之处吧,其实把 select 的缺点反过来那就是 Epoll 的优点了。
Epoll 没有最大并发连接的限制,上限是最大可以打开文件的数目,这个数字一般远大于 2048, 一般来说这个数目和系统内存关系很大,具体数目可以
cat /proc/sys/fs/file-max
察看。效率提升,Epoll 最大的优点就在于它只管你 “活跃” 的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll 的效率就会远远高于 select 和 poll。
内存拷贝,Epoll 在这点上使用了 “共享内存”,这个内存拷贝也省略了。
Epoll为什么高效
Epoll 的高效和其数据结构的设计是密不可分的,这个下面就会提到。
首先回忆一下 select 模型,当有 I/O 事件到来时,select 通知应用程序有事件到了快去处理,而应用程序必须轮询所有的 FD 集合,测试每个 FD 是否有事件发生,并处理事件;
代码像下面这样:
1 | int res = select(maxfd+1, &readfds, NULL, NULL, 120); |
Epoll 不仅会告诉应用程序有 I/0 事件到来,还会告诉应用程序相关的信息,这些信息是应用程序填充的,因此根据这些信息应用程序就能直接定位到事件,而不必遍历整个 FD 集合。
1 | intres = epoll_wait(epfd, events, 20, 120); |
Epoll 关键数据结构
前面提到 Epoll 速度快和其数据结构密不可分,其关键数据结构就是:
1 | struct epoll_event { |
结构体 epoll_event
被用于注册所感兴趣的事件和回传所发生待处理的事件.
其中 epoll_data
联合体用来保存触发事件的某个文件描述符相关的数据.
例如一个 client 连接到服务器,服务器通过调用 accept 函数可以得到于这个 client 对应的 socket 文件描述符,可以把这文件描述符赋给 epoll_data 的 fd 字段以便后面的读写操作在这个文件描述符上进行。epoll_event 结构体的 events 字段是表示感兴趣的事件和被触发的事件可能的取值为:
- EPOLLIN :表示对应的文件描述符可以读;
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET:表示对应的文件描述符有事件发生;
ET 和 LT 模式
LT(level triggered) 是缺省的工作方式,并且同时支持 block 和 no-block socket. 在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的 select/poll 都是这种模型的代表。
ET (edge-triggered) 是高速工作方式,只支持 no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个 EWOULDBLOCK 错误)。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在 TCP 协议中,ET 模式的加速效用仍需要更多的 benchmark 确认。
ET 和 LT 的区别在于 LT 事件不会丢弃,而是只要读 buffer 里面有数据可以让用户读,则不断的通知你。而 ET 则只在事件发生之时通知。可以简单理解为 LT 是水平触发,而 ET 则为边缘触发。
ET 模式仅当状态发生变化的时候才获得通知,这里所谓的状态的变化并不包括缓冲区中还有未处理的数据,也就是说,如果要采用 ET 模式,需要一直 read/write
直到出错为止, 很多人反映为什么采用 ET 模式只接收了一部分数据就再也得不到通知了, 大多因为这样; 而 LT 模式是只要有数据没有处理就会一直通知下去的.
使用 Epoll
既然 Epoll 相比 select 这么好,那么用起来如何呢?会不会很繁琐啊…先看看下面的三个函数吧,就知道 Epoll 的易用了。
1 | int epoll_create(int size); |
生成一个 Epoll 专用的文件描述符,其实是申请一个内核空间,用来存放你想关注的 socket fd 上是否发生以及发生了什么事件。size 就是你在这个 Epoll fd 上能关注的最大 socket fd 数,大小自定,只要内存足够。
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
- EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;
- EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;
- EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
第三个参数是需要监听的 fd,第四个参数是告诉内核需要监听什么事
1 | int epoll_wait(int epfd,struct epoll_event * events,int maxevents,int timeout); |
等待 I/O 事件的发生;参数说明:
- epfd: 由
epoll_create()
生成的 Epoll 专用的文件描述符; - epoll_event: 用于回传代处理事件的数组;
- maxevents: 每次能处理的事件数;
- timeout: 等待 I/O 事件发生的超时值;
- 返回发生事件数。
测试程序
首先对服务端和客户端做下说明:
我想实现的是客户端和服务端并发的程序,客户端通过配置并发数,说明有多少个用户去连接服务端。
客户端会发送消息:”Client: i send message Hello Server!”,其中
i
表示哪一个客户端;收到消息:”Recv Server Msg Content:%s\n”。
例如:
1 | 发送:Client: 1 send message "Hello Server!" |
例如:
1 | 发送:Hello, client fd: 6 |
备注:这里在接收到消息后,直接打印出消息,如果需要对消息进行处理(如果消息处理比较占用时间,不能立即返回,可以将该消息放入一个队列中,然后开启一个线程从队列中取消息进行处理,这样的话不会因为消息处理而阻塞 epoll)。libenent 好像对这种有 2 中处理方式,一个就是回调,要求回调函数,不占用太多的时间,基本能立即返回,另一种好像也是一个队列实现的,这个还需要研究。
服务端代码说明:
服务端在绑定监听后,开启了一个线程,用于负责接收客户端连接,加入到 epoll 中,这样只要 accept 到客户端的连接,就将其 add EPOLLIN 到 epoll 中,然后进入循环调用 epoll_wait,监听到读事件,接收数据,并将事件修改为 EPOLLOUT;反之监听到写事件,发送数据,并将事件修改为EPOLLIN。
服务器代码:
1 | //cepollserver.h |
1 |
|
客户端代码:
说明:测试是两个并发进行测试,每一个客户端都是一个长连接。代码中在连接服务器(ConnectToServer)时将用户 ID 和 socketid 关联起来。用户 ID 和 socketid 是一一对应的关系。
1 |
|
1 |
|
服务器主程序:
1 |
|
客户端主程序:
1 |
|