By东关

惊群问题 | 复现 | 解决
2021-07-23

前言

我们知道,像 Nginx、Workerman 都是单 Master 多 Worker 的进程模型。

Master 进程用于创建监听套接字、创建 Worker 进程及管理 Worker 进程。

Worker 进程是由 Master 进程通过 fork 系统调用派生出来的,所以会自动继承 Master 进程的监听套接字,每个 Worker 进程都可以独立地接收并处理来自客户端的连接。

由于多个 Worker 进程都在等待同一个套接字上的事件,就会出现标题所说的惊群问题。

什么是惊群问题

惊群问题又称惊群效应,当多个进程等待同一个事件,事件发生后内核会唤醒所有等待中的进程,但是只有一个进程能够获得 CPU 执行权对事件进行处理,其他的进程都是被无效唤醒的,随后会再次陷入阻塞状态,等待下一次事件发生时被唤醒。

举个例子,你们寝室几个人都在一边睡觉一边等外卖,外卖到了的时候,快递小哥嗷一嗓子把你们几个人都叫醒了,但是他只送了一个人的外卖,其它人骂骂咧咧的又躺下了,下次外卖来的时候,又会把这几个人都吵醒。

这里的室友表示进程,外卖小哥表示操作系统,外卖就是等待的事件。

惊群问题带来的问题

由于每次事件发生会唤醒所有进程,所以操作系统会对多个进程频繁地做无效的调度,让 CPU 大部分时间都浪费在了上下文切换上面,而不是让真正需要工作的进程运行,导致系统性能大打折扣。

发生惊群问题的时机

通过上面的介绍可以知道,惊群问题主要发生在 socketaccept 和 socketselect 两个函数的调用上。

下面我们通过两个例子复现这两个系统调用的惊群。

socket_accept 函数

PHP 中的 socket_accept 函数是 accept 系统调用的一层包装。函数原型如下:

  1. socket_accept(Socket $socket): Socket|false

该函数接收监听套接字上的新连接,一旦接收成功,就会返回一个新的套接字(连接套接字)用于与客户端进行通信。如果没有待处理的连接,socket_accept 函数将阻塞,直到有新的连接出现。

  1. // 创建 TCP 套接字
  2. $server_socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
  3. // 将套接字绑定到指定的主机地址和端口上
  4. socket_bind($server_socket, "0.0.0.0", 8080);
  5. // 设置为监听套接字
  6. socket_listen($server_socket);
  7. for ($i = 0; $i < 5; $i++) {
  8. $pid = pcntl_fork();
  9. if ($pid < 0) {
  10. exit('fork 失败');
  11. } else if ($pid == 0) {
  12. // 这里是子进程
  13. $pid = posix_getpid();
  14. printf("worker[%d] running\n", $pid);
  15. // while true 是为了处理完一个连接之后,可以继续处理下一个连接
  16. while (true) {
  17. // 由于我们刚刚创建的 $server 是阻塞 IO,
  18. // 所以代码运行到这的时候会阻塞住,会将 CPU 让出去,
  19. // 直到有客户端来连接
  20. $conn_socket = socket_accept($server_socket);
  21. if (!$conn_socket) {
  22. printf("worker[%d] 接收新连接失败,原因:%s\n", $pid, socket_last_error($conn_socket));
  23. continue;
  24. }
  25. // 获取客户端地址及端口号
  26. socket_getpeername($conn_socket, $address, $port);
  27. printf("worker[%d] 接收新连接成功:%s:%d\n", $pid, $address, $port);
  28. // 关闭客户端连接
  29. socket_close($conn_socket);
  30. }
  31. }
  32. // 这里是父进程
  33. }
  34. // 父进程等待子进程退出,回收资源
  35. while (true) {
  36. // 为待处理的信号调用信号处理程序。
  37. \pcntl_signal_dispatch();
  38. // 暂停当前进程的执行,直到一个子进程退出,或者直到一个信号被传递。
  39. $pid = \pcntl_wait($status, WUNTRACED);
  40. // 再次调用待处理信号的信号处理程序。
  41. \pcntl_signal_dispatch();
  42. if ($pid > 0) {
  43. printf("worker[%d] 退出\n", $pid);
  44. }
  45. }

上面的代码先创建了一个监听套接字 $serversocket,然后通过 pcntlfork 函数派生出 5 个子进程。在调用完 pcntl_fork 函数后,如果派生子进程成功,那么该函数会有两个返回值,在父进程中返回子进程的进程 ID,在子进程中返回 0;派生失败则返回 -1。

  • 父进程:调用 pcntl_wait 函数阻塞等待子进程退出,然后回收进程资源
  • 子进程:调用 socket_accept 函数并阻塞,直到有新连接需要处理。

将上面的代码保存为 accept.php,然后在 CLI 中执行 php accept.php 启动服务端程序,可以看到 1 个 master 进程和 5 个 worker 进程都已经处于运行状态:

执行 pstree-acp pid 查看一下进程树:

进程树的结构与我们服务启动的日志是一致的。

接下来我们执行 telnet0.0.0.08080 命令连接到服务端程序上,accept.php 输出:

咦,怎么回事,跟一开始说的不一样啊,这明明只有一个进程被唤醒然后处理了新连接!

莫慌,这是在预料之中的,因为在 Linux 2.6 后的版本中,Linux 已经修复了 accept 的惊群问题。

演示这一步主要是为后面的内容做铺垫。

socket_select 函数

跟 socketaccept 函数一样,socketselect 函数也是 select 系统调用的一层包装。

select 是最早的一种多路复用实现方式,性能相对于后面出现的 poll、epoll 要差很多,那么为什么这里要用 select 来做演示呢?

一是因为支持 select 的操作系统比较多,连 Windows 和 MacOS 也都支持 select 系统调用。二是截止目前 Linux 内核版本 4.4.0 依然没有解决 select 的惊群问题。

socket_select 接受套接字数组并阻塞等待它们有事件发生。函数原型如下:

  1. socket_select(
  2. array|null &$read,
  3. array|null &$write,
  4. array|null &$except,
  5. int|null $seconds,
  6. int $microseconds = 0
  7. ): int|false
  • $read 表示需要监听可读事件的套接字数组。
  • $write 表示需要监听可写事件的套接字数组。
  • $except 表示需要监听的异常事件套接字数组。
  • $seconds 和 $microseconds 组合起来表示 select 阻塞超时时间,$seconds 为 0 表示不等待,立即返回,设置为 null 表示一直阻塞等待,直到有事件发生。

当在函数超时前有事件发生时,返回值为发生事件的套接字数量,如果是函数超时,返回值为 0 ,有错误发生时返回 false。

socketselect 函数的示例程序与上面 socketaccept 函数的差不多,只不过需要将监听套接字设置为非阻塞,然后在 socketaccept 函数之前调用 socketselect 进行阻塞等待事件。

  1. // 创建 TCP 套接字
  2. $server_socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
  3. // 将套接字绑定到指定的主机地址和端口上
  4. socket_bind($server_socket, "0.0.0.0", 8080);
  5. // 设置为监听套接字
  6. socket_listen($server_socket);
  7. // 设置为非阻塞
  8. socket_set_nonblock($server_socket);
  9. printf("master[%d] running\n", posix_getpid());
  10. for ($i = 0; $i < 5; $i++) {
  11. $pid = pcntl_fork();
  12. if ($pid < 0) {
  13. exit('fork 失败');
  14. } else if ($pid == 0) {
  15. // 这里是子进程
  16. $pid = posix_getpid();
  17. printf("worker[%d] running\n", $pid);
  18. // while true 是为了处理完一个连接之后,可以继续处理下一个连接
  19. while (true) {
  20. // 将监听套接字放入可读事件的套接字数组中,
  21. // 表示我们需要等待监听套接字上的可读事件,
  22. // 监听套接字发生可读事件说明有客户端连接上来了。
  23. $reads = [$server_socket];
  24. // 可写事件和异常事件我们不关心,设置为空数组即可。
  25. $writes = $excepts = [];
  26. // 超时时间设置为 NULL,表示一直阻塞等待,直到有事件发生。
  27. $num = socket_select($reads, $writes, $excepts, NULL);
  28. printf("worker[%d] wakeup,num:%d\n", $pid, $num);
  29. $conn_socket = socket_accept($server_socket);
  30. if (!$conn_socket) {
  31. printf("worker[%d] 接收新连接失败\n", $pid);
  32. continue;
  33. }
  34. // 获取客户端地址及端口号
  35. socket_getpeername($conn_socket, $address, $port);
  36. printf("worker[%d] 接收新连接成功:%s:%d\n", $pid, $address, $port);
  37. // 关闭客户端连接
  38. socket_close($conn_socket);
  39. }
  40. }
  41. // 这里是父进程
  42. }
  43. // 父进程等待子进程退出,回收资源
  44. while (true) {
  45. // 为待处理的信号调用信号处理程序。
  46. \pcntl_signal_dispatch();
  47. // 暂停当前进程的执行,直到一个子进程退出,或者直到一个信号被传递。
  48. $pid = \pcntl_wait($status, WUNTRACED);
  49. // 再次调用待处理信号的信号处理程序。
  50. \pcntl_signal_dispatch();
  51. if ($pid > 0) {
  52. printf("worker[%d] 退出\n", $pid);
  53. }
  54. }

我们将上述代码保存为 select.php 并执行 phpselect.php 启动服务,然后使用 telnet127.0.0.18080 连接上去就会发现 5 个子进程都输出了 wakeup,但是只有一个进程 accept 成功了。

如何解决惊群问题

因为惊群问题主要是出在系统调用上,但是内核系统更新肯定没那么及时,而且不能保证所有操作系统都会修复这个问题。

所以解决方案可以分为两类:用户程序层面和内核程序层面,用户程序层面就是通过加锁解决问题,内核程序层面就是让内核程序提供一些机制,一劳永逸地解决这个问题。

用户程序:加锁

通过上面我们可以知道,惊群问题发生的前提是多个进程监听同一个套接字上的事件,所以我们只让一个进程去处理监听套接字就可以了。

Nginx 采用了自己实现的 accept 加锁机制,避免多个进程同时调用 accept。Nginx 多进程的锁在底层默认是通过 CPU 自旋锁实现的,如果操作系统不支持,就会采用文件锁。

Nginx 事件处理的入口函数使 ngxprocesseventsandtimers(),下面是简化后的加锁过程:

  1. // 是否开启 accept 锁,
  2. // 开启则需要抢锁,以防惊群,默认是关闭的。
  3. if (ngx_use_accept_mutex) {
  4. if (ngx_accept_disabled > 0) {
  5. // ngx_accept_disabled 的值是经过算法计算出来的,
  6. // 当值大于 0 时,说明此进程负载过高,不再接收新连接。
  7. ngx_accept_disabled--;
  8. } else {
  9. // 尝试抢 accept 锁,发生错误直接返回
  10. if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
  11. return;
  12. }
  13. if (ngx_accept_mutex_held) {
  14. // 抢到锁,设置事件处理标识,后续事件先暂存队列中。
  15. flags |= NGX_POST_EVENTS;
  16. } else {
  17. // 未抢到锁,修改阻塞等待时间,使得下一次抢锁不会等待太久
  18. if (timer == NGX_TIMER_INFINITE
  19. || timer > ngx_accept_mutex_delay)
  20. {
  21. timer = ngx_accept_mutex_delay;
  22. }
  23. }
  24. }
  25. }

在 ngxtrylockaccept_mutex 函数中,如果抢到了锁,Nginx 会把监听套接字的可读事件放入事件循环中,该进程有新连接进来的时候就可以 accept 了。

内核程序:从根源解决问题

在高本版的 Nginx 中 accept 锁默认是关闭的,如果开启了 accept 锁,那么在多个 worker 进程并行的情况下,对于 accept 函数的调用是串行的,效率不高。

所以最好的方式还是让内核程序解决惊群的问题,从问题的根源上去解决。

Linux 内核 3.9 及后续版本提供了新的套接字参数 SO_REUSEPORT,该参数允许多个进程绑定到同一个套接字上,内核在收到新的连接时,只会唤醒其中一个进程进行处理,内核中也会做负载均衡,避免某个进程负载过高。

对于 epoll 多路复用机制,Linux 内核 4.5+ 新增 EPOLLEXCLUSIVE 标志,这个标志会保证一个事件只会有一个阻塞在 epoll_wait 函数的进程被唤醒,避免了惊群问题。

在 Nginx 的 ngxeventprocessinit 函数中,可以看到 Nginx 是如何使用 SOREUSEPORT 和 EPOLLEXCLUSIVE 的。

  1. // Nginx 支持端口复用
  2. #if (NGX_HAVE_REUSEPORT)
  3. // 配置 listen 80 resuseport 时,支持多进程共用一个端口,
  4. // 此时可直接把监听套接字加入事件循环中,并监听可读事件。
  5. if (ls[i].reuseport) {
  6. if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
  7. return NGX_ERROR;
  8. }
  9. continue;
  10. }
  11. #endif
  12. // 打开 accept_mutex 锁之后,
  13. // 每个 worker 进程不能直接处理监听套接字,
  14. // 需要在 worker 进程抢到锁之后才能将监听套接字放入自己的事件循环中。
  15. if (ngx_use_accept_mutex) {
  16. continue;
  17. }
  18. // Nginx 支持 EPOLLEXCLUSIVE 标志
  19. #if (NGX_HAVE_EPOLLEXCLUSIVE)
  20. // 如果 nginx 使用的是 epoll 多路复用机制,并且 worker 进程大于 1,
  21. // 那么就将监听套接字加入自己的事件循环中,并且设置 EPOLLEXCLUSIVE 标志。
  22. if ((ngx_event_flags & NGX_USE_EPOLL_EVENT)
  23. && ccf->worker_processes > 1)
  24. {
  25. if (ngx_add_event(rev, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
  26. == NGX_ERROR)
  27. {
  28. return NGX_ERROR;
  29. }
  30. continue;
  31. }
  32. #endif
  33. // 未开启 accept_mutex 锁,未启动 resuseport 端口复用,不支持 EPOLLEXCLUSIVE 标志,
  34. // 此后监听套接字发生事件时会引发惊群问题。
  35. if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
  36. return NGX_ERROR;
  37. }

总结

通过本文我们了解到什么是惊群问题,以及对应的解决方式。在编写类似的多进程的应用时就可以避免这个问题,从而提高应用的性能。

你必须 登录 才能发表评论.

  • 还没有人留下脚印噢,快来踩踩叭