C# 进程调用实战:Process 异步读取输出 + 超时取消 + 进程树终止
一、为什么要在 C# 里调用外部脚本
在真实项目里,总有些活不适合用 C# 重写一遍:现成的 Python 脚本做 Excel 解析、Shell 脚本批量下载网元日志、Java 打的 jar 包做校验……最省事的做法就是”让进程跑起来,把结果读回来”。
听起来很简单,但真正落地时会踩到四个坑:
Process.Start()后直接WaitForExit(),同步阻塞线程,一台机器上并发跑几个脚本就把线程池占满;- 输出量一大就死锁——子进程写满了重定向缓冲区,父进程又在等它退出;
- 脚本卡死,没有超时;
- Python 脚本自己又起了子进程,只杀父进程留下孤儿进程。
下面这套 ScriptHelper
就是围绕这四个问题设计的,代码不多,但每一行都有理由。
二、先把结果建模:ScriptResult
调用方真正关心的不是 Process
对象,而是四个值:退出码、标准输出、标准错误、耗时。
public class ScriptResult
{
public int ExitCode { get; set; }
public string Output { get; set; } = string.Empty;
public string Error { get; set; } = string.Empty;
public long DurationMilliseconds { get; set; }
public bool Success => ExitCode == 0;
}
把 Success
定义成计算属性而不是字段,是为了避免”退出码改了、Success
忘了改”这种不一致。上层判断就只用看一个字段。
三、核心方法:一次执行,三个保障
所有脚本类型(Python / Shell / Java / dotnet)最终都收敛到同一个私有方法:
private static async Task<ScriptResult> RunAsync(
string fileName, IEnumerable<string> args, string workingDir, int timeoutSeconds = 300)
{
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = fileName,
WorkingDirectory = workingDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.OutputDataReceived += (s, e) =>
{
if (e.Data != null) outputBuilder.AppendLine(e.Data);
};
process.ErrorDataReceived += (s, e) =>
{
if (e.Data != null) errorBuilder.AppendLine(e.Data);
};
var sw = Stopwatch.StartNew();
try
{
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
try
{
await process.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
try
{
if (!process.HasExited)
{
process.Kill(true); // 杀子进程树
}
}
catch { }
throw;
}
}
finally
{
sw.Stop();
}
return new ScriptResult
{
ExitCode = process.ExitCode,
Output = outputBuilder.ToString(),
Error = errorBuilder.ToString(),
DurationMilliseconds = sw.ElapsedMilliseconds
};
}
3.1 为什么要 BeginOutputReadLine 而不是 ReadToEnd
这是最经典的死锁场景。重定向后的管道有容量上限(约 4KB 级别)。如果父进程先等子进程退出、子进程又在拼命写输出,缓冲区写满后子进程阻塞、父进程永远等不到退出——双方互等。
BeginOutputReadLine()
注册异步回调,由线程池在后台持续把管道抽干,主线程才能安全地等退出。注意它必须和
OutputDataReceived 事件配对使用;如果只挂了事件不调用
Begin*ReadLine,事件永远不会触发。
另外,e.Data == null
是流结束的哨兵值,判空即可,不要试图把它 append 进去。
3.2 每个流一个 StringBuilder
StringBuilder 不是线程安全的。这里刻意让 stdout 和
stderr
各用一个实例,两个异步回调永远不会写同一个对象,省掉了加锁。同一路流内部
.NET 保证顺序回调,所以输出顺序不会乱。
3.3 超时:CancellationTokenSource 比 Thread.Abort 优雅
.NET Core 之后没有 Thread.Abort
了(也不该用)。标准做法是给 WaitForExitAsync
传一个到期就取消的 Token:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
await process.WaitForExitAsync(cts.Token);
超时后抛 OperationCanceledException,进入 catch
里处理进程。默认 300
秒对大多数脚本够用,特殊场景(比如拉网元日志)可以按需传入更大的值。
3.4 Kill(true):别留孤儿进程
process.Kill(true) 的
entireProcessTree: true 是关键。Python 脚本里用
subprocess 起的子进程、Shell 脚本里 &
拉起的后台任务,只杀父进程的话,子进程会变孤儿继续跑,端口占着、文件锁着,下一次执行直接报错。
Kill 本身是异步的,紧接着 throw
让调用方感知超时即可;catch { }
包住是为了防止”进程刚好在这一瞬间自己退出了”导致抛
InvalidOperationException,掩盖真正的超时异常。
3.5 退出码只能在退出后读
process.ExitCode 在进程未退出时读取会抛异常。所以它放在
return 那一行——此时 WaitForExitAsync
已经返回,进程必然已结束。这也是为什么不能把 ExitCode
提前塞进 ScriptResult。
四、异步输出刷新的一个坑
WaitForExitAsync
返回时,进程确实退出了,但”异步事件回调有没有把最后几行数据 append
完”并不百分之百有保证。稳妥的写法是在
await WaitForExitAsync 之后再补一次同步等待:
await process.WaitForExitAsync(cts.Token);
// 保证 OutputDataReceived / ErrorDataReceived 的回调全部执行完毕
process.WaitForExit();
无参的 WaitForExit()
会等所有重定向的异步事件处理完才返回。对”脚本最后一行是 JSON
结果”这种场景,少了这一句就可能拿到被截断的结果。上面示例为了保持与项目一致没有加,属于可以增强的点,正式项目建议补上。
五、编码:中文乱码的真正原因
在 Windows 上跑 Python 或 git-bash,输出编码默认跟系统代码页走(简体中文环境是 GBK/936)。这时候如果强行设置:
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
而脚本实际吐的是 GBK 字节,解码出来就是一串问号——不是脚本错了,是解码方式不对。项目里这两行是被注释掉的,让 .NET 使用控制台默认编码,反而能正确读中文。
如果确实需要 UTF-8(比如脚本本身是跨平台部署的),正确的顺序是让两端都统一:
- 脚本侧:设置
PYTHONIOENCODING=utf-8,或在 Shell 脚本开头chcp 65001; - 进程侧:
StandardOutputEncoding = Encoding.UTF8。
一边改一边不改,乱码必然出现。
六、上层封装:把差异收敛到一行
有了 RunAsync,不同脚本类型只剩”可执行文件 +
参数前缀”的差别:
public static async Task<ScriptResult> RunShellAsync(string scriptPath, string[] args = null, int timeoutSeconds = 300)
{
string fileName = OperatingSystem.IsWindows() ? "C:\\Program Files\\Git\\git-bash.exe" : "/bin/bash";
scriptPath = Path.GetFullPath(scriptPath);
var arguments = new[] { scriptPath }.Concat(args ?? []);
string workingDir = Path.GetDirectoryName(scriptPath);
return await RunAsync(fileName, arguments, workingDir, timeoutSeconds);
}
public static async Task<ScriptResult> RunPythonAsync(string scriptPath, string[] args = null, int timeoutSeconds = 300)
{
string fileName = OperatingSystem.IsWindows() ? "python.exe" : "python3";
scriptPath = Path.GetFullPath(scriptPath);
var arguments = new[] { scriptPath }.Concat(args ?? []);
string workingDir = Path.GetDirectoryName(scriptPath);
return await RunAsync(fileName, arguments, workingDir, timeoutSeconds);
}
public static async Task<ScriptResult> RunJavaAsync(string jarPath, string[] args = null, int timeoutSeconds = 300)
{
var arguments = new[] { "-jar", Path.GetFullPath(jarPath) }.Concat(args ?? []);
return await RunAsync("java", arguments, Path.GetDirectoryName(jarPath), timeoutSeconds);
}
三个细节值得说:
- 跨平台选可执行文件:
OperatingSystem.IsWindows()是编译期就能被 JIT 优化掉的判断,比RuntimeInformation.IsOSPlatform更轻; Path.GetFullPath+WorkingDirectory设为脚本所在目录:脚本里写open("config.json")这种相对路径时,相对的是进程当前目录而不是脚本目录。把工作目录设成脚本目录,脚本才能”自带依赖”地跑起来;ArgumentList而不是拼字符串:参数里出现空格、引号、中文时,手拼命令行极易被拆错甚至被注入。ArgumentList由运行时按平台规则转义,是把参数当”参数”而不是”命令片段”的正确姿势。
调用端于是就变得非常短:
var result = await ScriptHelper.RunPythonAsync(_consistencyCheckScriptPath, args);
if (!result.Success)
{
string error = !string.IsNullOrWhiteSpace(result.Error) ? result.Error : result.Output;
return JsonDataResult.Failure(error).WithExtension("id", sessionId);
}
Error 为空时回退到
Output,是因为很多脚本把错误信息打到 stdout 里(Python 的
print 而非 sys.stderr),只看
Error
会拿到空字符串,用户看到的就是”失败了但没有任何原因”。
七、整体执行时序
sequenceDiagram
participant Svc as 业务服务
participant H as ScriptHelper
participant P as 子进程
participant T as 线程池
Svc->>H: RunPythonAsync(脚本, 参数)
H->>P: Start()
H->>T: BeginOutputReadLine / BeginErrorReadLine
T-->>H: OutputDataReceived(逐行追加)
T-->>H: ErrorDataReceived(逐行追加)
H->>H: WaitForExitAsync(超时 Token)
alt 正常退出
P-->>H: 退出码 0
H-->>Svc: ScriptResult(Success=true)
else 超时
H->>P: Kill(entireProcessTree: true)
H-->>Svc: 抛 OperationCanceledException
end
八、上线前的检查清单
- 重定向输出必须配
Begin*ReadLine,否则大输出必死锁; - 超时用
CancellationTokenSource,不要用Thread.Sleep轮询HasExited; - 杀进程一定用
Kill(true),尤其是会再起子进程的 Python / Shell 脚本; ExitCode只在进程退出后读;- 中文输出乱码先查脚本侧编码,再动
StandardOutputEncoding; - 参数一律走
ArgumentList,不要拼字符串; - 工作目录设成脚本目录,避免相对路径失效;
- 日志里记录耗时和退出码,出问题才有据可查。
九、总结
调用外部脚本本质上是在做两件事:把进程的生命周期管住(启动、读输出、超时、杀树),把结果结构化成调用方能直接判断的对象。
Process
这个类本身不难,难的是它那些”不写就出事”的细节:异步读输出防死锁、超时后杀进程树、退出后才读退出码、编码要两端对齐。把这几条固定在一个
ScriptHelper
里,业务代码就只需要关心一件事——result.Success 是 true 还是
false。