为何 C# 中 fork 后立即调用 exec,子进程仍因 SIGSEGV 退出?
结论
应把 forkpty 和子进程中的 exec 放在同一次原生调用中,避免子进程返回 C# 或重新进入 CoreCLR。更稳妥的做法是让独立的原生辅助进程负责创建 PTY 和启动 shell。
即使 C# 源码在 forkpty 后紧接着调用 execvp,子进程在两者之间执行的也可能远不止几条赋值指令。forkpty 返回后,P/Invoke 还可能进行运行时转换、线程状态检查等处理,此时子进程继承的运行时状态未必还能正常使用。
现有日志支持沿这个方向排查。不过,目前缺少匹配的符号和完整的 P/Invoke 声明,还无法确定此次 SIGSEGV 的具体触发点,也无法断言这是某个 .NET 版本的已知缺陷。
为什么还没进入 if 就可能崩溃
fork 不会复制其他线程的执行
fork 后,子进程只保留调用线程,其他线程不会在子进程中继续运行,但进程内存中的锁、线程记录和运行时数据仍会被继承。
例如,另一线程在 fork 时持有的锁,在子进程中仍处于已锁定状态,却没有对应线程来释放它。运行时也可能保留着指向其他线程状态的记录。
因此,多线程进程在 fork 后、成功执行 exec 前,子进程应只调用 async-signal-safe 函数。即使应用没有主动创建线程,也不能认为 .NET 进程中只有一个线程。
把 fork 理解为“浅拷贝”也不准确。Linux 通常通过写时复制保留地址空间;需要关注的是,这些复制过来的运行时状态能否在只剩一个线程的子进程中继续使用。
原生函数返回后,P/Invoke 调用还没有结束
调用路径大致如下:
C# 调用
→ P/Invoke 转换及运行时处理
→ forkpty
→ P/Invoke 返回路径及运行时处理
→ C# 判断 childPID
→ 下一次 P/Invoke
→ execvp
返回路径上具体会执行哪些处理,取决于运行时、生成的代码和声明方式。原生函数执行完毕,并不代表子进程已经安全返回托管代码。
因此,以下两种尝试都可能无效:
- 在
if中加入sleep,子进程仍可能在执行到它之前崩溃。 - 预热
execvp,仍无法排除forkpty返回路径上的问题。
预热至多能提前完成部分入口解析或代码生成,无法消除后续的运行时转换,也无法修复子进程继承的状态。
[SuppressGCTransition] 有严格的适用条件,不能用来绕过这个问题,也不能作为 forkpty 这类调用的通用修复手段。
TLS、信号和反汇编分别能说明什么
看到 TLS 访问,不代表 TLS 被 fork 破坏
调用线程的 TLS 内容会随地址空间继承。子进程获得新的线程标识,不会因此让所有 TLS 地址立即失效。
但 TLS 中的运行时记录可能包含线程标识、锁或其他需要协调的数据。判断时需要检查具体访问了什么,仅凭 __tls_get_addr 一类调用无法认定 TLS 已损坏。
nexti 后出现在另一段代码,不一定是 push 跳转了
push %rbp 本身没有切换调用栈并跳转到任意地址的语义。
调试器恢复执行后,期间可能发生信号递送、进入信号处理器,或由调试器切换当前线程、进程等情况。异常展开信息不足时,bt 显示的栈帧也可能产生误导。
需要结合停止原因、当前 inferior、线程和信号信息来判断。仅凭前后两次 $pc,无法还原中间发生了什么。
屏蔽信号后崩溃位置发生变化,说明信号处理或执行时序值得检查,还不足以证明已经找到根因。屏蔽运行时信号也不应作为正式修复方案。
SharpLab 的结果不是现场 Linux 反汇编
给出的调用使用 rcx、rdx、r8、r9 传递参数,符合 Windows x64 调用约定。Linux x86-64 的 System V ABI 通常使用 rdi、rsi、rdx、rcx 等寄存器。
这份结果有助于理解控制流,但不能代表现场进程的实际调用路径。两个连续的 call 也不足以证明中间没有运行时处理。
同样,不能假定所有 P/Invoke 入口在首次调用后,都会被替换成完全绕过运行时的直接调用。
如何调整实现
将子进程分支留在原生代码中
C# 只调用一个启动函数。该函数在父进程中返回 PID 和 PTY master FD;子进程直接执行 execve,失败时调用 _exit,不再返回 C#。
Linux 原生封装示例:
// pty_spawn.c
#define _DEFAULT_SOURCE
#include <pty.h>
#include <sys/types.h>
#include <unistd.h>
extern char **environ;
int pty_spawn_bash(int *master_fd)
{
struct winsize size = {
.ws_row = 25,
.ws_col = 80,
.ws_xpixel = 640,
.ws_ypixel = 400
};
char *const argv[] = {
"/usr/bin/bash",
NULL
};
pid_t pid = forkpty(master_fd, NULL, NULL, &size);
if (pid == -1)
return -1;
if (pid == 0) {
execve(argv[0], argv, environ);
// execve 只有失败时才会返回。
// 子进程不得返回托管代码。
_exit(127);
}
return (int)pid;
}
编译命令:
cc -O2 -Wall -Wextra -fPIC -shared \
pty_spawn.c -o libptyspawn.so -lutil
C# 端:
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
internal static class Native
{
[DllImport(
"libptyspawn.so",
EntryPoint = "pty_spawn_bash",
CallingConvention = CallingConvention.Cdecl,
SetLastError = true)]
internal static extern int SpawnBash(out int masterFd);
}
int childPid = Native.SpawnBash(out int masterFd);
if (childPid == -1)
{
int error = Marshal.GetLastPInvokeError();
throw new Win32Exception(error);
}
Console.WriteLine($"Child PID is: {childPid}, master FD is: {masterFd}");
这样可以省去子进程中的两次托管边界跨越,并由本机编译器处理 winsize 的布局。示例使用默认终端属性;如果需要自定义 termios,也可以在原生侧配置。
不过,这个封装仍无法保证:在任意多线程宿主中,所有原生库、信号处理器以及 forkpty 内部操作都适合在 fork 后执行。生产实现还需要处理以下事项:
- 通过错误管道将
execve失败时的errno传回父进程;返回 PID 只代表子进程已创建。 - 清理不应继承的 FD,并设置适当的 close-on-exec 标志。
- 按启动策略处理子进程的信号掩码和信号处置。
- 在父进程中关闭 master FD,并通过
waitpid回收子进程。
子进程失败时应调用 _exit,避免执行 exit 的缓冲区刷新和退出处理器。
独立辅助进程能进一步缩小风险
对于长期运行的终端模拟器,可以让 C# 启动一个尚未加载 CoreCLR 的原生辅助程序,再由它执行 forkpty 和 execve。
辅助程序可以通过 Unix domain socket 的 SCM_RIGHTS 将 master FD 传给 C#,也可以自行转发 PTY 数据。
还可以评估 posix_spawn,但需要检查目标平台的接口和扩展,确认它能否满足建立 PTY 会话、设置控制终端等需求。仅重定向标准输入输出管道,并不等于建立了控制终端。
如何进一步定位这次崩溃
先检查互操作声明
题目未给出 P/Invoke 声明以及 termios、winsize 的定义,还需要排除 ABI 错误。
尤其是 termios,从其他平台复制来的字段、数组长度和布局,未必适用于当前 Linux 环境。布局错误可能破坏内存,直到函数返回时才表现为崩溃。
建议先使用原生封装和默认终端属性,再逐步恢复自定义配置。
跟踪子进程和真正的 execve
可以先在 GDB 中设置:
set follow-fork-mode child
set detach-on-fork on
catch fork
break execve
handle SIGSEGV stop print pass
run
停止后检查:
info inferiors
info threads
info registers
p $_siginfo
x/16i $pc
info proc mappings
thread apply all bt
$_siginfo 可用时,可以提供信号原因和故障地址。逐指令观察可以使用 stepi,同时留意信号和线程切换。
CoreCLR 可能通过 SIGSEGV 实现部分异常处理。GDB 捕获到一次该信号,不代表进程最终会因此退出;需要区分运行时能够处理的异常和导致进程终止的故障。
也可以执行:
strace -ff -o trace ./YourApp
检查子进程是否实际发出了 execve。如果没有,只能说明故障发生在成功进入该系统调用之前,还无法据此确定哪段运行时代码出了问题。
使用与现场二进制匹配的符号
最后一份栈中出现了 libcoreclr.so 和 libclrjit.so,说明故障涉及运行时或 JIT 路径,但仅凭无符号地址无法识别具体函数。
应记录实际加载文件的路径和 ELF build ID,并使用与该构建匹配的符号。如果运行时来自发行版软件包,应检查对应包的调试符号,不能假定微软符号服务器覆盖所有发行版构建。
扩大 /proc/self/coredump_filter 主要影响转储包含哪些内存映射,不会自动补齐原生调试符号、托管方法名称或 JIT 展开信息,也不代表转储已经包含“一切”。分析托管栈通常还需要版本匹配的 DAC、SOS 等诊断组件。fork 后的状态出现异常时,这些工具也可能无法完整工作。
原代码还需要修正的地方
execvp 成功后不会返回。失败时,原代码会继续执行下面的 Console.WriteLine,使子进程重新进入托管运行时。这个失败路径必须改为先在原生代码中报告错误,再调用 _exit。
预热调用中的 argv == NULL 也不是正常的参数构造方式。正常调用应提供以空指针结尾的参数数组。这次返回 -1,只能证明该次调用失败并返回,不能证明所有后续运行时路径都已准备完毕。
父进程直接调用 execvp 能成功,说明它可以在完整的运行时状态下完成进程替换。这并未验证 fork 后子进程继续执行托管代码是否安全。
备注:内容仅供参考。
