C# 进程调用实战:Process 异步读取输出 + 超时取消 + 进程树终止

一、为什么要在 C# 里调用外部脚本

在真实项目里,总有些活不适合用 C# 重写一遍:现成的 Python 脚本做 Excel 解析、Shell 脚本批量下载网元日志、Java 打的 jar 包做校验……最省事的做法就是”让进程跑起来,把结果读回来”。

听起来很简单,但真正落地时会踩到四个坑:

  1. Process.Start() 后直接 WaitForExit(),同步阻塞线程,一台机器上并发跑几个脚本就把线程池占满;
  2. 输出量一大就死锁——子进程写满了重定向缓冲区,父进程又在等它退出;
  3. 脚本卡死,没有超时;
  4. 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);
}

三个细节值得说:

  1. 跨平台选可执行文件:OperatingSystem.IsWindows() 是编译期就能被 JIT 优化掉的判断,比 RuntimeInformation.IsOSPlatform 更轻;
  2. Path.GetFullPath + WorkingDirectory 设为脚本所在目录:脚本里写 open("config.json") 这种相对路径时,相对的是进程当前目录而不是脚本目录。把工作目录设成脚本目录,脚本才能”自带依赖”地跑起来;
  3. 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。