📝 C# 调用 Python 脚本正确姿势:解决 WaitForExit 死锁与退出检测问题

最近在做一个工具,需要在 C# 里调用嵌入的 Python 脚本,把 YOLO 的 ONNX 模型转成别的格式。脚本执行完全没问题,但我一直收不到 Process.Exited 事件,甚至直接 WaitForExit() 会卡死,界面永远不会提示“执行完成”。

经过多次排查,发现这是 .NET 调用外部进程时的经典坑:标准输出缓冲区没读完,导致进程无法退出。这篇博客记录一下完整的排查过程和最终的解决方案。


1️⃣ 问题重现

原始代码大概是这样:

var psi = new ProcessStartInfo
{
    FileName = "cmd.exe",
    Arguments = $"/c conda run -n yolo python \"{scriptPath}\" \"{onnxPath}\"",
    UseShellExecute = false,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    CreateNoWindow = true,
    StandardOutputEncoding = Encoding.UTF8,
    StandardErrorEncoding = Encoding.UTF8
};

using var process = new Process { StartInfo = psi };

process.OutputDataReceived += (s, e) => { if (e.Data != null) AppendLog(e.Data); };
process.ErrorDataReceived  += (s, e) => { if (e.Data != null) AppendLog("❌ " + e.Data); };

process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();

process.WaitForExit(); // ❌ 这里卡死
AppendLog("✅ Python 执行完成");

运行后:

  • Python 脚本实际已经执行完
  • 转换文件生成成功
  • 但是 WaitForExit() 永远不返回,UI 卡死

2️⃣ 关键原因:标准输出缓冲区阻塞

在 .NET 里,如果同时:

  • RedirectStandardOutput = true
  • 又调用了 WaitForExit()

必须保证先把标准输出读完,否则进程会阻塞在写输出,永远无法退出。

这其实是经典的死锁场景:

  1. 子进程 Python 写输出 → 缓冲区满
  2. 父进程没及时读 → 子进程卡在写操作
  3. 父进程等子进程退出 → 形成死锁

3️⃣ 正确解决方式

✅ 核心原则

  • BeginOutputReadLine() 异步读取输出
  • 监听到 e.Data == null 表示输出流结束
  • 确保输出流、错误流都读完再 WaitForExit()

改进后的代码

private async Task RunPythonAsync(string envName, string scriptPath, string onnxPath)
{
    var psi = new ProcessStartInfo
    {
        FileName = "cmd.exe",
        Arguments = $"/c conda run -n {envName} python \"{scriptPath}\" \"{onnxPath}\"",
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true,
        StandardOutputEncoding = Encoding.UTF8,
        StandardErrorEncoding = Encoding.UTF8
    };

    using var process = new Process { StartInfo = psi };

    var outputTcs = new TaskCompletionSource();
    var errorTcs = new TaskCompletionSource();

    process.OutputDataReceived += (s, e) =>
    {
        if (e.Data == null) outputTcs.TrySetResult();
        else AppendLog(e.Data);
    };
    process.ErrorDataReceived += (s, e) =>
    {
        if (e.Data == null) errorTcs.TrySetResult();
        else AppendLog("❌ " + e.Data);
    };

    process.Start();
    process.BeginOutputReadLine();
    process.BeginErrorReadLine();

    // ✅ 等待读取完成
    await Task.WhenAll(outputTcs.Task, errorTcs.Task);

    // ✅ 再等待进程退出
    process.WaitForExit();

    AppendLog("✅ Python 执行完成");
}

这样写可以保证:

  • 所有标准输出、标准错误都被读完
  • 不会因为缓冲区阻塞而死锁
  • 退出检测准确可靠

4️⃣ 经验总结

  1. 不要只依赖 Exited 事件
    Exited 有可能因为进程没完全退出而不触发,尤其是 cmd.exe + 批处理嵌套调用的场景。

  2. 必须读干净输出流
    标准输出缓冲区是有限的,不读就会卡死。

  3. 推荐异步等待
    Task.WhenAll 等待读取完成,可以避免阻塞 UI 线程。

  4. 优先用 conda run
    直接执行 conda run -n env python ...,避免在子进程里 conda activate,命令更干净,退出更可控。


5️⃣ 小封装:通用运行工具

如果你需要经常调用外部进程,可以封装一个通用工具方法:

public static async Task<int> RunProcessAsync(
    string fileName, string arguments, 
    Action<string>? onOutput = null, 
    Action<string>? onError = null)
{
    var psi = new ProcessStartInfo
    {
        FileName = fileName,
        Arguments = arguments,
        UseShellExecute = false,
        RedirectStandardOutput = true,
        RedirectStandardError = true,
        CreateNoWindow = true,
        StandardOutputEncoding = Encoding.UTF8,
        StandardErrorEncoding = Encoding.UTF8
    };

    using var process = new Process { StartInfo = psi };

    var outputTcs = new TaskCompletionSource();
    var errorTcs = new TaskCompletionSource();

    process.OutputDataReceived += (s, e) =>
    {
        if (e.Data == null) outputTcs.TrySetResult();
        else onOutput?.Invoke(e.Data);
    };
    process.ErrorDataReceived += (s, e) =>
    {
        if (e.Data == null) errorTcs.TrySetResult();
        else onError?.Invoke(e.Data);
    };

    process.Start();
    process.BeginOutputReadLine();
    process.BeginErrorReadLine();

    await Task.WhenAll(outputTcs.Task, errorTcs.Task);
    process.WaitForExit();

    return process.ExitCode;
}

以后调用更简洁:

await RunProcessAsync("cmd.exe", $"/c conda run -n yolo python script.py file.onnx",
    onOutput: msg => AppendLog(msg),
    onError:  msg => AppendLog("❌ " + msg));

TaskCompletionSource 用法可以参考这篇文章! 【SuperSocket 】利用 TaskCompletionSource 在 SuperSocket 中实现跨模块异步处理客户端消息

🎯 总结

  • 死锁根因:输出缓冲区没读完,子进程被阻塞,父进程一直等
  • 解决方案:用异步读取流,先读完流再 WaitForExit
  • 最佳实践:写一个 RunProcessAsync 工具,封装好所有坑,调用更优雅

这样就能稳定地在 C# 中调用 Python,不会再遇到卡死和无法检测退出的问题。

更多推荐