【原创】 [ 探索epoll的内置Leader-Follower支持以及线程安全问题, epoll可以更高效! ] - xmpp? - 博客园
【原创】 [ 探索epoll的内置Leader-Follower支持以及线程安全问题, epoll可以更高效! ]
最近在探索借助epoll做为reactor, 设计高效的服务端的方法.
常见的基于epoll的编程方式主要为单线程的事件循环, 用于一些非阻塞的业务逻辑开发是比较高效并且简单易懂的.
但实际开发业务的时候, 往往面临着查数据库, 访问磁盘, 通过网络访问其他主机的需求, 耗时往往较长, 所以单线程的epoll并不能轻松的适用, 往往需要做一些额外的设计与构思才能得到解决.
解决此类慢处理的服务端架构主要以leader-follower架构以及half-sync-half-async为主, 通过多线程的并发能力来满足同时执行多个慢处理业务逻辑. 其中, leader-follower因为较half-sync-half-async而言减少了从异步层 与 同步层间必要的内存拷贝, 并且half-sync-half-async的异步层是单线程实现, 是很容易达到单核CPU瓶颈的, 所以基本完败于leader-follower.
公司的UB框架使用的应该是简化版的leader-follower模型, 即线程池共享同一个epoll set, 而epoll set中只注册了监听socket, 从而保证同一个时刻只由leader线程accept得到connected socket并使用带超时的阻塞read接口先后读取nshead header与nshead body, 所以UB的设计是不适合暴露给外网的, 很容易受到攻击而影响服务.
正统的leader-follower应当由线程池共享同一个epoll set, 并将所有的socket注册到该Epoll set中, 然后由leader负责epoll_wait获取一个event socket并将其从epoll set中暂时移除, 之后Leader便可转换为follower并处理event socket, 并在处理完成后将event socket重新注册回epoll set以便继续监监听.
可以看到, leader-follower按照程序设计方法, 需要使用mutex+cond实现leader-follower职责间的转换, 在恢复event socket的时候, 因为epoll set被多线程共享的原因, 涉及到epoll_ctl的线程安全问题, 简单的例子: 假设A线程希望恢复socket1的事件注册, 但此时B线程正在epoll_wait, 那么A线程是否可以线程安全的使用epoll_ctl恢复socket1的事件.
幸运的是, epoll内置了leader-follower的选项支持, 可以避免mutex+cond的使用, 并且保障了epoll_ctl的线程安全性, 同时保证了epoll_wait是线程安全的并且会负载均衡事件到多个调用者, 不会引发惊群问题.
为了做到这一点, 只需要为每一个event socket设置选项: EPOLLONESHOT即可.
EPOLLONESHOT
Sets the One-Shot behaviour for the associated file descriptor. It means that after an event is pulled out with
epoll_wait(2) the associated file descriptor is internally disabled and no other events will be reported by the epoll
interface. The user must call epoll_ctl(2) with EPOLL_CTL_MOD to re-enable the file descriptor with a new event mask.
更关键的说明需要man epoll, 其中:
On the contrary, when used as a Level Triggered interface, epoll is by all means a faster poll(2), and can be used wherever
the latter is used since it shares the same semantics. Since even with the Edge Triggered epoll multiple events can be gen-
erated up on receival of multiple chunks of data, the caller has the option to specify the EPOLLONESHOT flag, to tell epoll
to disable the associated file descriptor after the receival of an event with epoll_wait(2). When the EPOLLONESHOT flag is
specified, it is caller responsibility to rearm the file descriptor using epoll_ctl(2) with EPOLL_CTL_MOD.
有了这个机制的保障, 在实现leader-follower的时候, 只要创建多个线程, 所有线程epoll_wait同一个epoll set, 并且保证epoll set中每个注册的event socket都携带了EPOLLONESHOT选项即可.
最初, epoll set里只有监听socket. 当多个线程调用epoll_wait时, 每个线程均会得到一批不同的event socekt. 这是因为在epoll_wait返回之前会为你设置发生event的socket的注册事件为0, 即其他线程不可能再次检测到该socket的事件, 相当于epoll_wait将poll与epoll_ctl(DEL)做了原子化, 这是浑然天成的leader-follower, 代码与单线程相比几乎一模一样. 在读写并处理完event socket后, 需要使用epoll_ctl(MOD)恢复event socket到epoll set中, 而epoll_ctl保证这是线程安全的, 并且如果socket有事件会立即通知某epoll_wait返回.
有了上面的理论基础, 我试着开发了一个多线程基于epoll的server, 测试了长连接的qps, 只跑echo服务, 发现可以达到15万, 但出现了大面积线程D状态, 继续提高压力qps也没有提升, 程序的系统调用也仅仅是read/write/accept/close/epoll_ctl/epoll_wait, 程序是无内存分配的, 并且每个核心的idle还剩余60多.
在猜测尝试了一些方法后, 包括单线程跑epoll, 竟然发现qps也是15万, 但单核CPU可以跑满, 怀疑了一顿网卡之后, 发现测试是单机的, 根本没走网卡, 所以不是网卡软中断过高的问题.
根据epoll_ctl/epoll_wait线程安全这个设计, 我猜测多个线程访问同一个epoll set是有锁的, 从而导致了程序瓶颈, 于是我创建了多个epoll set, 每个epoll set作为一个组, 组内有若干worker线程共享该epoll set, 从而减小了锁的面积.
但这样设计, 需要考虑到监听socket怎么处理, 因为每个组都有自己的epoll set, 不能将监听socket注册给多个epoll set, 那样会有惊群问题. 于是, 我独立创建了一个监听线程, 并创建了一个独立的epoll set跑事件循环来专门做accept, 而因为epoll_ctl的线程安全特性, 所以监听线程采用了round robin轮转的向每个组的epoll set进行conntected socket的事件注册.
通过以上的优化, 惊喜的发现服务端的qps达到了近40万, 而cpu idle也终于从60榨到了不到10%.
唯一的缺点就是round robin的分发策略可能因为客户端主动断开行为导致各个组内的负载不均, 希望找到一种无锁并且代价低的解决方案, (PS: 多线程server, 对业务数据并发访问的同步问题是个老问题)暂时就到这里了.
考虑到推广百度云盘, 代码放在这里了:http://pan.baidu.com/share/link?shareid=332303&uk=2686094642