如何在 Go 中正确读取子进程的标准输出流

本文详解 go 语言中使用 `os/exec` 启动子进程并实时、逐行读取其标准输出的完整实践,涵盖管道初始化、错误处理、标准错误重定向、goroutine 同步等关键要点。

在 Go 中通过 exec.Command 启动外部命令并读取其输出是常见需求,但若未正确处理管道、错误流或生命周期同步,极易出现“程序卡住”“无输出”或“数据丢失”等问题。你提供的代码看似逻辑清晰,却始终无法触发 scanner.Scan(),根本原因在于三个被忽略的关键环节:错误未检查、stderr 被静默丢弃、以及 cmd.Wait() 过早调用引发竞态

✅ 正确做法:四步闭环处理

1. 始终检查 StdoutPipe() 错误

cmd.StdoutPipe() 可能失败(例如命令未设置 cmd.Stdout = nil 时重复调用),必须显式校验:

out, err := cmd.StdoutPipe()
if err != nil {
    log.Fatal("Failed to get stdout pipe:", err)
}

2. 捕获并诊断 stderr(尤其对 pocketsphinx)

pocketsphinx_continuous 在缺少必要参数(如 -hmm、-dict)时不会向 stdout 输出任何内容,而是将错误直接写入 stderr。而你的代码完全忽略了 stderr,导致“看似运行但无响应”。务必同时捕获 stderr 进行调试:

stderr, err := cmd.StderrPipe()
if err != nil {
    log.Fatal("Failed to get stderr pipe:", err)
}

// 启动 goroutine 实时打印 stderr(开发阶段强烈推荐)
go func() {
    scanner := bufio.NewScanner(stderr)
    for scanner.Scan() {
        log.Printf("[ERR] %s", scanner.Text())
    }
}()
? 提示:生产环境可将 stderr 重定向至日志文件,但开发阶段务必实时查看——这是定位 pocketsphinx 类工具启动失败的首要线索。

3. 启动命令前确保参数完整

pocketsphinx_continuous 是一个严格依赖声学模型与词典的语音识别引擎。以下是最小可用命令示例(路径需按实际调整):

cmd := exec.Command(
    "/usr/local/bin/pocketsphinx_continuous",
    "-inmic", "yes",
    "-hmm", "/usr/local/share/pocketsphinx/model/en-us/en-us",
    "-dict", "/usr/local/share/pocketsphinx/model/en-us/cmudict-en-us.dict",
    "-lm", "/usr/local/share/pocketsphinx/model/en-us/en-us.lm.bin",
)

缺少任一模型参数,进程会立即退出,stdout 为空,stderr 报错(如 FATAL_ERROR: "acmod.c", line 142: Failed to open model definition)。

4. 正确同步 goroutine 与进程生命周期

defer cmd.Wait() 在 main() 返回前才执行,但此时 readStuff goroutine 可能尚未结束,导致 cmd.Wait() 提前阻塞或子进程被意外终止。必须等待扫描完成后再调用 Wait()

func readStuff(scanner *bufio.Scanner, done chan<- bool) {
    defer close(done) // 通知主协程扫描结束
    for scanner.Scan() {
        fmt.Println("→", scanner.Text())
    }
    if err := scanner.Err(); err != nil {
        log.Printf("Scanner error: %v", err)
    }
}

// 主函数中:
done := make(chan bool)
go readStuff(scanner, done)
<-done // 阻塞等待扫描完成
if err := cmd.Wait(); err != nil {
    log.Printf("Command finished with error: %v", err)
}

✅ 完整可运行示例(含健壮性增强)

package main

import (
    "bufio"
    "log"
    "os/exec"
    "time"
)

func main() {
    cmd := exec.Command(
        "/usr/local/bin/pocketsphinx_continuous",
        "-inmic", "yes",
        "-hmm", "/usr/local/share/pocketsphinx/model/en-us/en-us",
        "-dict", "/usr/local/share/pocketsphinx/model/en-us/cmudict-en-us.dict",
        "-lm", "/usr/local/share/pocketsphinx/model/en-us/en-us.lm.bin",
    )

    stdout, err := cmd.StdoutPipe()
    if err != nil {
        log.Fatal("StdoutPipe failed:", err)
    }

    stderr, err := cmd.StderrPipe()
    if err != nil {
        log.Fatal("StderrPipe failed:", err)
    }

    // 启动 stderr 监听(调试关键!)
    go func() {
        scanner := bufio.NewScanner(stderr)
        for scanner.Scan() {
            log.Printf("[SPHINX-ERR] %s", scanner.Text())
        }
    }()

    if err := cmd.Start(); err != nil {
        log.Fatal("Cmd start failed:", err)
    }

    // 启动 stdout 处理
    done := make(chan bool)
    go func() {
        defer close(done)
        scanner := bufio.NewScanner(stdout)
        for scanner.Scan() {
            log.Printf("[SPHINX-OUT] %s", scanner.Text())
        }
        if err := scanner.Err(); err != nil {
            log.Printf("Scanner error: %v", err)
        }
    }()

    // 等待处理完成(或设超时避免永久阻塞)
    select {
    case <-done:
        log.Println("Output processing completed.")
    case <-time.After(30 * time.Second):
        log.Println("Timeout waiting for output; terminating...")
        cmd.Process.Kill()
    }

    if err := cmd.Wait(); err != nil {
        log.Printf("Process exited with error: %v", err)
    }
}

⚠️ 注意事项总结

  • 永远不要忽略 StdoutPipe()/StderrPipe() 的返回错误
  • pocketsphinx_continuous 必须提供完整的模型路径参数,否则静默失败
  • cmd.Wait() 必须在所有 stdout/stderr 读取完成后调用,否则引发竞态或数据截断
  • 为防死锁,建议对 readStuff 设置超时机制(如 time.After)
  • 若需更高性能(如处理高吞吐音频流),可考虑 io.Copy + bytes.Buffer 或直接使用 io.ReadCloser 配合 bufio.Reader.ReadLine()。

遵循以上原则,即可稳定、可靠地从任意子进程(不限于 pocketsphinx)中流式读取标准输出。