在 pre-forking 服务器中收割 children

Reaping children in a pre-forking server

Programming Language Examples Alike CookbookSockets 章节中,“Pre-Forking 服务器”部分使用如下 SIGCHLD 处理程序:

module PidSet = Set.Make(struct type t = int let compare = compare end)
let children = ref PidSet.empty

(* takes care of dead children *)
let rec reaper _ =
  Sys.set_signal Sys.sigchld (Sys.Signal_handle reaper);
  match Unix.wait ()
  with (pid, _) -> children := PidSet.remove pid !children

(* ... *)

let () =
  (* ... *)
  Sys.set_signal Sys.sigchld (Sys.Signal_handle reaper);
  (* ... *)

reaper函数看起来不对,但我不太确定。我担心两个或更多 children 碰巧退出 near-simultaneously 的情况。 reaper 函数可能只 运行 一次,因为信号在 Unix 中没有排队。结果只有一个 child 被收割,而其他 children 仍然是僵尸。

我查看了“分叉服务器”部分的另一个 reaper 函数:

let rec reaper signal =
  try while true do ignore (Unix.waitpid [Unix.WNOHANG] (-1)) done
  with Unix.Unix_error (Unix.ECHILD, _, _) -> ();
  Sys.set_signal Sys.sigchld (Sys.Signal_handle reaper)

然而,这似乎也是错误的,因为 Unix.waitpid [Unix.WNOHANG] (-1) 可能永远不会导致任何 Unix.Unix_error,导致大量循环,其中 Unix.waitpid [Unix.WNOHANG] (-1) 总是 returns 一个 pid 值0.

这是我为“Pre-Forking 服务器”部分编写正确的 SIGCHLD 处理程序以获取所有已终止 children 的尝试:

let rec reaper _ =
  try
    while true do
      let (pid, _) = Unix.waitpid [Unix.WNOHANG] (-1) in
      if pid > 0 then
        children := PidSet.remove pid !children
      else
        raise Not_found  (* Exit loop. *)
    done
  with Not_found -> ();
  Sys.set_signal Sys.sigchld (Sys.Signal_handle reaper)

这是正确的吗?

这是我用了很多年的版本。此函数设置为 Sys.sigchld 处理程序 Sys.set_signal Sys.sigchld (Sys.Signal_handle reap).

let rec reap n = match Unix.waitpid [Unix.WNOHANG] (-1) with
  | 0,_ -> ()
  | _   -> reap n
  | exception Unix.Unix_error (Unix.ECHILD,_,_) -> ()
  | exception _ -> (* log it *) ()

注意,当waitpidreturns一个child时,该函数递归调用自己,直到它耗尽所有完成children,然后再次等待信号。

ECHILD 异常被隐藏了,因为它通常发生在你试图成熟一个已经收获的 child 时(这发生在 OCaml 中,请参见下面的 P.P.S 注释)。在实际应用程序中,我记录了其余的异常,只是为了确保系统的行为符合我的预期(各种 Unices 有不同的行为,上面的代码仅在 Linux 和 macOS (Darwin) 上进行了全面测试) .

关于你的实现,它非常相似,除了你没有明确捕获异常,所以它们可能出现在你代码的随机部分(信号处理程序在内存分配期间被异步调用,所以最好不要允许异常跳出它们)。我不知道为什么需要一个 children 集,可能是 application-specific,但你不需要这个来保持你的进程 table 没有僵尸,因为这样集已经存在于内核中。

P.S。重要的是,对于低于 4.13 的 OCaml 版本,如果您的服务器应用程序未执行任何分配或阻塞调用,例如,如果它正在执行 while true; do () donelet rec loop () = loop (),则不会调用任何信号处理程序。对于更现代的 OCaml 版本(从 4.13 及更高版本开始的任何版本),这不再适用,感谢 @octachron 的澄清。

P.P.S.,以及关于 OCaml 信号处理的另一个警告,

The reaper function might only run once because signals are not queued in Unix.

这在一般情况下是正确的,除了在 OCaml 中信号确实是排队的。当信号到达时,它被排队等待直到下一个同步点,即下一个分配或阻塞系统调用。一旦发生,OCaml 会为队列中待处理的每个信号调用信号处理程序。当然,无法保证不会丢失任何信号,因此保守假设仍然有效 - 我们无法确定每个 child 是否收到一个信号。但是可能发生的是,我们可以收到一个 child 的信号,我们已经在 reap 信号处理程序的前一个触发器中获得了该信号。这就是为什么我 ECHILD 明确地保持沉默。

P.P.P.S.,

In my reaper function, I call Sys.set_signal Sys.sigchld (Sys.Signal_handle reaper) again. Is that necessary? I notice that your implementation does not have it.

您不需要重新安装信号处理程序。一旦安装,它将一直存在,直到重新安装。1 虽然,重新安装它不会真正伤害我建议不要使用递归函数,它不会真正递归地调用自身,而是通过信号处理程序。它违反了单一功能单一职责原则,因为你的函数现在有两个正交的职责——一个是获取 children,另一个是将自己安装为信号处理程序。最好让信号处理程序完成它的工作并在服务器初始化过程中单独安装一次。


1) 在 POSIX-compliant 系统中,或者实际上如果 sigactionsigprocmask 可用,OCaml 使用 sigaction 安装信号并确保信号处理程序保持连接状态。因此可以保证在 Linux 和 macOS 上您不需要重新安装信号。当检测到 BSD 语义时(并且未检测到 POSIX 信号),将使用 signal 函数,但它仍将保持处理程序附加(因为它是 BSD 语义)。系统仍然有可能缺少 POSIX-compliant 信号系统和 BSD 信号,即纯 System V,但对于这样的系统,我们的代码很可能会有许多其他问题,就像大多数 Unix 函数一样将未实现,包括 waitpid。话虽如此,你可以重装信号,不会有什么坏处,但我不会这样做。