.NET 中 fork() 后,为什么子进程调用 exec() 会导致父进程退出?
子进程成功调用 exec() 后,不会因此直接终止父进程。现有信息还不足以确定父进程退出的根因,需要先确认退出的是哪个 PID,以及是什么操作触发了退出。实现时,应将 fork() 到 execve() 之间的子进程路径留在原生代码中,避免在这段路径里反复返回 .NET 托管运行时。
为什么简单的 P/Invoke 仍然存在问题
多线程进程调用 fork() 后,子进程只保留调用线程,但会继承整个地址空间,其中可能包含其他线程持有的锁。在执行新程序之前,子进程只能依赖 async-signal-safe 操作。Linux fork(2) 手册说明了这项限制。
因此,即使预先封送参数,避免显式分配和异常,也不足以证明托管子进程路径安全。close()、dup2() 本身满足要求,也不能据此保证调用前后的托管执行和互操作转换同样安全。
不能仅凭 execvp() 属于 exec 家族,就认为它与 execve() 有相同的安全保证。对于受限的子进程路径,宜在创建子进程前准备好可执行文件的绝对路径、argv 和 envp,然后使用 execve()。signal-safety(7)列出了相关函数的保证范围。
这些约束说明现有实现需要调整,但单凭它们还无法解释观察到的父进程退出。
先确认父进程是否真的退出
先在调试器外运行,再用系统调用跟踪区分父子进程。以下命令假定当前目录中有 QBX.dll:
strace -ff -tt -o qbx.trace -e trace=process,signal dotnet ./QBX.dll
重点检查各 PID 对应的跟踪文件:
- 创建子进程的调用返回了哪个 PID。
execve()实际发生在哪个 PID 中,是否成功。- 父进程是否执行了
exit_group(0),或者收到了终止信号。 - 子进程执行新程序后,父进程是否仍在运行。
调试器显示退出码 0,不能证明退出由信号触发,也不能证明 exec() 自动结束了父进程。如果跟踪显示父进程执行了 exit_group(0),应继续检查父进程的主方法返回、显式退出调用和生命周期管理逻辑;如果异常只出现在调试环境中,则继续检查调试器的进程跟踪行为。
还要核对 fork() 的返回值分支和 P/Invoke 声明。Linux 上 fork() 返回的 pid_t 是有符号类型:-1 表示失败,0 表示子进程,正数表示父进程中的子进程 PID。若分支或声明错误,可能实际在父进程中执行了 exec()。成功的 execve() 会替换调用进程自身,并且不会返回。execve(2) 手册
区分信号屏蔽字与信号处理函数
关于运行时内部管道的猜测有技术依据,但需要结合信号记录确认,不能直接认定它是这次故障的原因。
这里涉及两项不同的配置:
- 信号屏蔽字:控制线程当前阻塞哪些信号,由
pthread_sigmask()等接口操作。 - 信号处理方式:控制信号使用自定义处理函数、
SIG_DFL还是SIG_IGN,由sigaction()等接口操作。
所查阅的 dotnet/runtime main 分支中,相关原生路径会在创建子进程前屏蔽信号。子进程重置自定义信号处理函数后恢复屏蔽字,父进程也会恢复自己的屏蔽字。恢复屏蔽字与重新安装 .NET 的处理函数是两回事。源码注释明确提到了避免子进程向继承的管道写入通知、唤醒父进程处理线程的问题。pal_process.c 源码
排查时应对照实际安装的 .NET 版本。复制某一段信号代码,无法代替对完整原生启动路径的检查,也不应在父进程中随意清除运行时的信号处理函数。
如何调整 PTY 启动实现
可以采用以下两种结构。
通过一次 P/Invoke 调用完整的原生启动函数
在进入受限路径前准备参数和资源,由一个原生函数完成创建子进程、设置 FD、执行目标程序和错误回传。只有父进程分支返回 C#;子进程成功时执行新程序,失败时使用 _exit() 结束。
下面仅展示分支结构,不是完整的 PTY 启动实现:
pid_t pid = fork();
if (pid == -1) {
return -1; /* parent reports the creation error */
}
if (pid == 0) {
/* Native-only child setup:
configure signals and PTY file descriptors;
report setup failures through a prepared error pipe. */
execve(path, argv, envp);
/* execve returned: report errno through the error pipe. */
_exit(127);
}
return pid; /* only the parent returns to managed code */
可以为错误管道的子进程写端设置 FD_CLOEXEC,父进程关闭自己的写端。这样,execve() 成功时写端会关闭;失败时子进程可以先写回 errno,再退出。父进程还应结合错误协议和子进程状态判断结果,因为单独收到 EOF 也可能意味着子进程提前结束。
使用独立的原生 helper
由 .NET 启动一个原生 helper,让 helper 负责 PTY 和目标进程的创建,通过约定的通道与 .NET 通信。这种结构可以减少从多线程托管宿主直接创建子进程时需要处理的运行时继承状态。
如果目标程序需要交互式终端,还要按需求处理 session、控制终端和进程组。仅调用 openpty() 并映射三个标准 FD,并不意味着所有终端行为都已配置完成。
FD 和失败路径还要检查什么
dup2(slaveFd, STDIN_FILENO) 本身就能替换已打开的目标 FD,通常不需要先关闭三个标准 FD。预先关闭还可能在 slaveFd 恰好为 0、1 或 2 时破坏源描述符。
父进程应关闭不使用的 slave FD,子进程应关闭不使用的 master FD,并在完成映射后谨慎关闭多余描述符。还要确认标准 FD 的 FD_CLOEXEC 状态,避免执行目标程序时意外失去输入输出。
子进程失败路径应使用原生 _exit()。将 exec() 替换为 Environment.Exit() 后父进程没有退出,只能说明两条路径的表现不同,不能据此认定继承后的托管运行时是安全的。
要确定这次故障的具体原因,仍需要实际的 P/Invoke 声明、父子分支代码、.NET 版本,以及父进程退出前的系统调用记录。
备注:内容仅供参考。
