一、服务器网络模型和惊群
传统的服务器使用“listen-accept-创建通信socket”完成客户端的一次请求服务。在高并发服务模型中,服务器创建很多进程-单线程(比如apache mpm)或者n进程:m线程比例创建服务线程(比如nginx event)。机器上运行着不等数量的服务进程或线程。这些进程监听着同一个socket。这个socket是和客户端通信的唯一地址。服务器父子进程或者多线程模型都accept该socket,有几率同时调用accept。当一个请求进来,accept同时唤醒等待socket的多个进程,但是只有一个进程能accept到新的socket,其他进程accept不到任何东西,只好继续回到accept流程。这就是惊群效应。如果使用的是select/epoll accept,则把惊群提前到了select/epoll这一步,多个进程只有一个进程能acxept到连接,因为是非阻塞socket,其他进程返回EAGAIN。
二、accept惊群的解决
所有监听同一个socket的进程在内核中中都会被放在这个socket的wait queue中。当一个tcp socket有IO事件变化,都会产生一个wake_up_interruptible()。该系统调用会唤醒wait queue的所有进程。所以修复linux内核的办法是只唤醒一个进程,比如说替换wake函数为wake_one_interruptoble()。
2.1、改进版本accept reuse port
没有开启reuse选项的socket只有一个wait queue,假设在开启了socket REUSE_PORT选项,内核中为每个进程分配了单独的accept wait queue,每次唤醒wait queue只唤醒有请求的进程。协议栈将socket请求均匀分配给每个accept wait queue。reuse部分解决了惊群问题,但是本身存在一些缺点或bug,比如REUSE实现是根据客户端ip端口实现哈希,对同一个客户请求哈希到同一个服务器进程,但是没有实现一致性哈希。在进程数量扩展新的进程,由于缺少一致性哈希,当listen socket的数目发生变化(比如新服务上线、已存在服务终止)的时候,根据SO_REUSEPORT的路由算法,在客户端和服务端正在进行三次握手的阶段,最终的ACK可能不能正确送达到对应的socket,导致客户端连接发生Connection Reset,所以有些请求会握手失败。
三、select/epoll模型
在一个高并发的服务器模型中,每秒accept的连接数很多。accept成为一个占用cpu很高的系统调用。考虑使用多进程来accept。select由于可扩展性能比如epoll,select遍历所有socket,select对每次操作都是要循环遍历所有的fd,所以在高并发场景下,select性能差。在高并发场景epoll使用场景更多。
3.1、epoll的EPOLL_EXCLUSIVE选项
liunx 4.5内核在epoll已经新增了EPOLL_EXCLUSIVE选项,在多个进程同时监听同一个socket,只有一个被唤醒。
四、应用层解决
同一时间只让一个进程accept/select/epoll一个监听端口。这是应用层解决惊群的办法,伪代码如下
semop(...); // lock
epoll_wait(...);
accept(...);
semop(...); // unlock
... // manage the request
多进程使用sysv实现semop,多线程则使用mutex。比如说mpm模式下的httpd,nginx都是这种实现办法。但是这种办法sysv是个固定的内存大小。比如在终端敲入ipcs。ipcs是机器共享固定大小空间。所以一旦有很多进程分配忘了手动释放,有内存泄漏风险。
4.1、httpd
for (;;) {
accept_mutex_on ();
for (;;) {
fd_set accept_fds;
...
rc = select (last_socket 1, &accept_fds, NULL, NULL, NULL);
...
for (i = first_socket; i <= last_socket; i) {
if (FD_ISSET (i, &accept_fds)) {
new_connection = accept (i, NULL, NULL);
}
accept_mutex_off ();
process the new_connection;
}
}
4.2、nginx
void ngx_process_events_and_timers(ngx_cycle_t *cycle) { ...
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
...
if (ngx_accept_mutex_held) {
flags |= NGX_POST_EVENTS;
}
...
(void) ngx_process_events(cycle, timer, flags);
ngx_event_process_posted(cycle, &ngx_posted_accept_events);
if (ngx_accept_mutex_held) {
ngx_shmtx_unlock(&ngx_accept_mutex);
}
ngx_event_process_posted(cycle, &ngx_posted_events);
}
4.3、少量进程监听同一个socket
当然在低负债的环境下,也可以分配少量的进程,即使有惊群,影响也是不大的。
五、tornado/golang/其他
5.1、tornado
tornado使用IOLoop模块,在Python3,IOLoop是个asyncio event循环。Python 2则使用了epoll (Linux) or kqueue (BSD and Mac OS X) 否则选用select()。所以python tornado在面对惊群问题其实是没有解决的。所以就是系统不解决惊群问题丢给应用层解决,应用层不解决丢给用户解决。笔者在tornado模拟业务源站行为,曾经开启了几百个进程。模拟行为很纯粹,就是根据X-Flux头的指定的大小,返回给用户相应大小的2xx响应。该程序不涉及磁盘io,不涉及内存大量拷贝操作,本应是cpu运算型,但是发现客户端在压测tornado,并发度1w左右,却服务端cpu跑满,并且连接超时的概率竟然有百分之四五十。使用python分析程序发现epoll wait函数占用了40%左右的cpu时间。很显然就是遇到了惊群响应。后面用golang重新实现了服务器,就没有了惊群。
5.2、golang
为啥golang就没有惊群响应呢?笔者查看了一个关键包netFD的accept实现。
func (fd *netFD) accept() (netfd *netFD, err error) {
//在这里序列化accept,避免惊群效应
if err := fd.readLock(); err != nil {
return nil, er
}
defer fd.readUnlock()
......
for {
s, rsa, err = accept(fd.sysfd)
if err != nil {
if err == syscall.EAGAIN {
//tcp还没三次握手成功,阻塞读直到成功,同时调度控制权下放给gorontine
if err = fd.pd.WaitRead(); err == nil {
continue
}
} else if err == syscall.ECONNABORTED {
//被对端关闭
continue
}
}
break
}
netfd, err = newFD(s, fd.family, fd.sotype, fd.net)
......
//fd添加到epoll队列中
err = netfd.init()
......
lsa, _ := syscall.Getsockname(netfd.sysfd)
netfd.setAddr(netfd.addrFunc()(lsa), netfd.addrFunc()(rsa))
return netfd, nil
}
5.3 lighttpd及其他
linghttpd使用场景建议只有一个worker,多个worker会有些功能兼容上的问题,所以lighttpd官方其实也没有解决惊群问题。其他服务器tomcat、nodejs等等因为其实在高并发上会搭配apache或者nginx协同使用,所以研究意义不大。
六、总结
管中窥豹、惊群问题说大不大,但是如果碰到,可能是限制高并发性能的重要一个瓶颈,在探索惊群问题解决上,对各个服务器模型的分析以及内核层调研中整理了这些想法,希望对大家有所帮助。