https://wenfh2020.com/2021/09/29/nginx-thundering-herd/
本文将通过测试,重现 nginx(1.20.1) 的惊群现象,并深入 Linux (5.0.1) 内核源码,剖析惊群原因。
在不配置 nginx 处理惊群特性的情况下,通过 strace
命令观察 nginx 的系统调用日志。
在 ubuntu 14.04 系统,由简单的 telnet 测试可见:有的进程被唤醒后获取资源失败——惊群现象发生了!
先不配置 accept_mutex,reuseport 等特性。
1 |
telnet 127.0.0.1 80 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
# strace -f -s 512 -o /tmp/nginx.log /usr/local/nginx/sbin/nginx
# 1. pid 2. syscall
...
# 主进程。
# 79979 进程启动加载 nginx。nginx 主进程 socket -> bind -> listen。
79979 socket(PF_INET, SOCK_STREAM, IPPROTO_IP) = 6
79979 bind(6, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
79979 listen(6, 511) = 0
# (79979)进程 clone 子进程(79980) 作为 nginx 的主进程。
79979 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f9e6f67fa10) = 79980
# nginx 主进程 fork 两个子进程作为工作进程:79981,79982。
79980 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f9e6f67fa10) = 79981
79980 clone( <unfinished ...>
79980 <... clone resumed> child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f9e6f67fa10) = 79982
...
# 子进程 79981
79980 clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f9e6f67fa10) = 79981
79981 epoll_create(512 <unfinished ...>
79981 <... epoll_create resumed> ) = 8
# 子进程 epoll 监控共享的 listen socket ---> 6。
79981 epoll_ctl(8, EPOLL_CTL_ADD, 6, {EPOLLIN|EPOLLRDHUP, {u32=1868849168, u64=140318450409488}} <unfinished ...>
# 子进程 79981 被唤醒。
79981 epoll_wait(8, }, 512, -1) = 1
79981 epoll_wait(8, <unfinished ...>
79981 accept4(6, <unfinished ...>
# accept4 成功获取一个链接资源。
79981 <... accept4 resumed> {sa_family=AF_INET, sin_port=htons(58960), sin_addr=inet_addr("127.0.0.1")}, [16], SOCK_NONBLOCK) = 10
79981 epoll_ctl(8, EPOLL_CTL_ADD, 10, {EPOLLIN|EPOLLRDHUP|EPOLLET, {u32=1868849616, u64=140318450409936}}) = 0
79981 epoll_wait(8, }, 512, 60000) = 1
79981 recvfrom(10, "", 1024, 0, NULL, NULL) = 0
79981 close(10) = 0
...
# 子进程 79982
79980 <... clone resumed> child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f9e6f67fa10) = 79982
79982 epoll_create(512 <unfinished ...>
79982 <... epoll_create resumed> ) = 10
79982 epoll_wait(10, <unfinished ...>
# 子进程 epoll 监控共享的 listen socket ---> 6。
79982 epoll_ctl(10, EPOLL_CTL_ADD, 6, {EPOLLIN|EPOLLRDHUP, {u32=1868849168, u64=140318450409488}}) = 0
79982 epoll_wait(10, <unfinished ...>
# 子进程被唤醒后,accept4 获取链接资源失败,返回 EAGAIN。
79982 accept4(6, <unfinished ...>
79982 <... accept4 resumed> 0x7ffe70c17e40, [112], SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
79982 epoll_wait(10, <unfinished ...>
...
|
1 2 3 4 |
79982 epoll_wait(10, <unfinished ...>
# 子进程被唤醒后,accept4 获取链接资源失败,返回 EAGAIN。
79982 accept4(6, <unfinished ...>
79982 <... accept4 resumed> 0x7ffe70c17e40, [112], SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
|
惊群现象出现,有的子进程被唤醒但是并没有 accept 到链接资源。原因:
两个子进程通过 epoll_ctl 添加关注了主进程创建的 socket,当该 listen socket 没有资源时,子进程都通过 epoll_wait 进入了阻塞睡眠状态。也就是子进程分别往 socket.wq 等待队列添加了各自的等待事件。
因为添加的方式是 add_wait_queue
,而不是 add_wait_queue_exclusive,add_wait_queue 并没有设置 WQ_FLAG_EXCLUSIVE
排它唤醒标识,所以当 listen socket 的资源到来时,内核通过 __wake_up_common
去唤醒两个子进程去 accept 获取资源。
如果只有一个链接资源,那么 nginx 的两个子进程被唤醒,当然只有一个子进程能成功,另外一个则无功而返。
1 2 3 4 5 6 |
/* include/linux/net.h*/
struct socket {
...
struct socket_wq *wq; /* socket 等待队列。 */
...
};
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/* fs/eventpoll.c */
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead, poll_table *pt) {
struct epitem *epi = ep_item_from_epqueue(pt);
struct eppoll_entry *pwq;
if (epi->nwait >= 0 && (pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL)) {
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
pwq->whead = whead;
pwq->base = epi;
if (epi->event.events & EPOLLEXCLUSIVE)
add_wait_queue_exclusive(whead, &pwq->wait);
else
/* 因为 nginx 默认情况下在 Linux 4.5 版本以下内核是没有开启 EPOLLEXCLUSIVE 特性的,
* 所以调用的是没有设置排它性属性的函数 add_wait_queue。 */
add_wait_queue(whead, &pwq->wait);
...
}
...
}
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
/* kernel/sched/wait.c
* This is the callback that is passed to the wait queue wakeup
* mechanism. It is called by the stored file descriptors when they
* have events to report. */
static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, int wake_flags, void *key,
wait_queue_entry_t *bookmark) {
wait_queue_entry_t *curr, *next;
int cnt = 0;
...
/* 遍历等待队列,调用唤醒函数去唤醒进程。 */
list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
unsigned flags = curr->flags;
int ret;
...
/* 调用进程唤醒回调函数:ep_poll_callback。*/
ret = curr->func(curr, mode, wake_flags, key);
if (ret < 0)
break;
/* 检测 WQ_FLAG_EXCLUSIVE 属性,是否只唤醒一个进。*/
if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
...
}
...
}
|
先捋一捋这个通知唤醒的工作流程:tcp 产生链接资源后唤醒阻塞等待的子进程去 accept 获取。
tcp 协议的链接是通过三次握手实现的,而完整的链接资源是服务端在第三次握手中产生的,服务端会将新的链接资源存储在 listen socket 的完全队列中。
nginx 作为高性能服务程序,在 Linux 系统,它处理网络事件时,一般会采用 epoll 事件驱动。它通过 epoll_wait
等待事件,当通过 epoll_ctl 关注的 tcp listen socket 产生事件时,阻塞等待的 epoll_wait 被唤醒去 accept 链接资源。
通过下图,了解一下服务端 tcp 的第三次握手和 epoll 内核的等待唤醒工作流程。
当 listen socket 有链接资源时,内核通过 __wake_up_common 调用 epoll 的 ep_poll_callback 唤醒函数,唤醒进程。
就绪队列
:eventpoll.rdllist,然后唤醒 eventpoll.wq 里通过 epoll_wait 等待的进程,处理 eventpoll.rdllist 上的事件数据。【注意】
有了 socket.wq 为啥还要有 eventpoll.wq 啊?因为 listen socket 能被多个进程共享,epoll 实例也能被多个进程共享!
添加等待事件流程:
epoll_ctl -> listen socket -> add_wait_queue <+ep_poll_callback+> -> socket.wq ==> epoll_wait -> eventpoll.wq
唤醒流程:
tcp_v4_rcv -> socket.wq -> __wake_up_common -> ep_poll_callback -> eventpoll.wq -> wake_up_locked -> epoll_wait -> accept
客户端主动链接服务端,TCP 三次握手成功后,服务端产生新的 tcp 链接资源,内核将唤醒 socket.wq 上的等待进程,通过 accept 从 listen socket 上的 全链接队列
中获取 TCP 链接资源。
参考:《[内核源码] 网络协议栈 - tcp 三次握手状态》 《[内核源码] 网络协议栈 - listen (tcp)》
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
/* include/net/sock.h */
struct sock {
...
void (*sk_data_ready)(struct sock *sk);
...
}
static int inet_create(struct net *net, struct socket *sock, int protocol, int kern) {
struct sock *sk;
...
sock_init_data(sock, sk);
...
}
/* net/core/sock.c */
void sock_init_data(struct socket *sock, struct sock *sk) {
sk_init_common(sk);
...
sk->sk_data_ready = sock_def_readable;
...
}
/* 三次握手,服务端第三次握手。 */
int tcp_v4_rcv(struct sk_buff *skb) {
process:
...
if (sk->sk_state == TCP_NEW_SYN_RECV) {
...
else if (tcp_child_process(sk, nsk, skb)) {
...
}
...
}
}
/* parent 参数是 listen socket 的网络对象指针。 */
int tcp_child_process(struct sock *parent, struct sock *child,
struct sk_buff *skb) {
int ret = 0;
int state = child->sk_state;
...
tcp_segs_in(tcp_sk(child), skb);
if (!sock_owned_by_user(child)) {
ret = tcp_rcv_state_process(child, skb);
/* Wakeup parent, send SIGIO */
if (state == TCP_SYN_RECV && child->sk_state != state)
/* 唤醒 */
parent->sk_data_ready(parent);
}
...
}
/* sk_data_ready */
static void sock_def_wakeup(struct sock *sk) {
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wq))
wake_up_interruptible_all(&wq->wait);
rcu_read_unlock();
}
/* 调用 __wake_up_sync_key 函数,将 nr_exclusive 唤醒进程/线程的个数设置为 1. */
|