Golang中windows环境下c:= exec.CommandContext(ctx, ...) + c.Output()在上下文过期后进程被终止但未返回子进程终止的问题

Golang中windows环境下c:= exec.CommandContext(ctx, …) + c.Output()在上下文过期后进程被终止但未返回子进程终止的问题 大家好,社区!

这个问题发生在一个更复杂的程序中,但我写了一个简单的程序来复现它。

package main

import (
	"context"
	"fmt"
	"os/exec"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30000)*time.Millisecond)
	defer cancel()
	c := exec.CommandContext(ctx, "cmd.exe", "/c", "start", "/wait", "notepad.exe")
	_, err := c.Output()
	fmt.Println("end", "err", err)
}

复现步骤:

  • 运行程序并观察进程树(使用 Process Explorer 或类似工具)

预期行为:当上下文超时导致 cmd.exe 进程被终止时,c.Output() 调用应立即返回。

观察到的行为:程序在 c.Output() 调用处“永远”阻塞,直到我手动终止 notepad.exe 进程。

调试显示 Go 阻塞在 (os/exec_windows.go):

s, e := syscall.WaitForSingleObject(syscall.Handle(handle), syscall.INFINITE)

它在等待 cmd.exe 的进程句柄(即使该进程已被终止)。

我还在 Mac 上进行了测试(好奇非 Windows 操作系统上的行为),在 Mac 上,一旦父进程被终止,c.Output() 就会立即返回(子进程继续运行,但这不会导致 c.Output() 调用阻塞)。

提前感谢!

此致!


更多关于Golang中windows环境下c:= exec.CommandContext(ctx, ...) + c.Output()在上下文过期后进程被终止但未返回子进程终止的问题的实战教程也可以访问 https://www.itying.com/category-94-b0.html

5 回复

看起来你没有在返回的 Cmd 上调用 Run() 方法,正如这里所做的那样:exec package - os/exec - pkg.go.dev

更多关于Golang中windows环境下c:= exec.CommandContext(ctx, ...) + c.Output()在上下文过期后进程被终止但未返回子进程终止的问题的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


这个问题在这里得到了解答:

https://groups.google.com/g/golang-nuts/c/xEHZo6x45s4

祝好!

勘误

Go 在尝试从通道读取时被阻塞(exec\exec.go):

	for range c.goroutine {
		if err := <-c.errch; err != nil && copyError == nil {
			copyError = err
		}
	}

Output() 在内部调用 Run()(我需要捕获标准输出)。如果你指定一个 io.Writer 作为 cmd.Stdout,并且该写入器不满足 os.File,那么 Run 也存在同样的问题,因为在这种情况下会使用管道。看起来管道被子进程继承,并且直到子进程结束才会关闭(这会阻塞从管道读取并写入缓冲区的 goroutine)。

我找到了这个解决方案:

package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os/exec"
	"time"
)

type WriterWithReadFrom interface {
	io.Writer
	io.ReaderFrom
}

type ContextWrappedWriter struct{
	w WriterWithReadFrom
	c context.Context
}

type ReadFromResult struct{
	n int64
	err error
}

func (cww *ContextWrappedWriter) Write(p []byte) (n int, err error){
	return cww.Write(p)
}

func (cww *ContextWrappedWriter) ReadFrom(r io.Reader) (n int64, err error){
	if c, ok := r.(io.Closer); ok {
		ch := make(chan ReadFromResult, 1)
		go func() {
			n, err := cww.w.ReadFrom(r)
			ch <- ReadFromResult{n, err}
		}()

		closed := false
		for ;; {
			select {
			case res := <-ch:
				return res.n, res.err
			case <-cww.c.Done():
				if !closed{
					closed = true
					err := c.Close()
					if err != nil {
						return 0, fmt.Errorf("error closing reader: %v", err)
					}
				}
				time.Sleep(time.Second * 1)
			}
		}

	} else {
		return cww.w.ReadFrom(r)
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(30000)*time.Millisecond)
	defer cancel()
	var Stdout, Stderr bytes.Buffer

	c := exec.CommandContext(ctx, "cmd.exe", "/c", "start", "/wait", "notepad.exe")
	c.Stderr = &ContextWrappedWriter{&Stderr, ctx}
	c.Stdout = &ContextWrappedWriter{&Stdout, ctx}
	err := c.Run()
	fmt.Println("end", "err", err, "stdout", Stdout.String(), "stderr", Stderr.String())
}

这是一个典型的Windows进程树管理问题。在Windows上,exec.CommandContext创建的进程及其子进程构成了一个进程树,当父进程被终止时,子进程可能不会自动终止,导致Go的WaitForSingleObject无限期等待。

问题在于Windows的进程终止机制与Unix-like系统不同。在Windows上,终止父进程不会自动终止子进程,而Go的exec包在Windows上只等待直接创建的那个进程句柄。

以下是几种解决方案:

方案1:使用任务终止整个进程树(推荐)

package main

import (
	"context"
	"fmt"
	"os/exec"
	"syscall"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// 设置进程创建标志,允许创建独立的进程树
	c := exec.CommandContext(ctx, "cmd.exe", "/c", "start", "/wait", "notepad.exe")
	c.SysProcAttr = &syscall.SysProcAttr{
		CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
	}

	_, err := c.Output()
	fmt.Println("end", "err", err)
}

方案2:手动管理进程树终止

package main

import (
	"context"
	"fmt"
	"os/exec"
	"syscall"
	"time"
	"unsafe"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	c := exec.CommandContext(ctx, "cmd.exe", "/c", "start", "/wait", "notepad.exe")
	
	// 启动进程但不等待
	c.Start()
	
	// 等待上下文超时或进程结束
	done := make(chan error, 1)
	go func() {
		done <- c.Wait()
	}()
	
	select {
	case <-ctx.Done():
		// 上下文超时,强制终止整个进程树
		terminateProcessTree(c.Process.Pid)
		<-done // 清理等待
		fmt.Println("context timeout, process tree terminated")
	case err := <-done:
		fmt.Println("process completed", "err", err)
	}
}

func terminateProcessTree(pid int) {
	// 使用taskkill终止整个进程树
	cmd := exec.Command("taskkill", "/F", "/T", "/PID", fmt.Sprintf("%d", pid))
	cmd.Run()
}

方案3:使用Job Object限制进程树(最可靠)

package main

import (
	"context"
	"fmt"
	"os/exec"
	"syscall"
	"time"
	"unsafe"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	c := exec.CommandContext(ctx, "cmd.exe", "/c", "start", "/wait", "notepad.exe")
	
	// 创建Job Object来管理进程树
	jobHandle, err := createJobObject()
	if err != nil {
		fmt.Println("create job object failed:", err)
		return
	}
	defer syscall.CloseHandle(jobHandle)
	
	// 设置Job Object在父进程结束时终止所有子进程
	info := syscall.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
		BasicLimitInformation: syscall.JOBOBJECT_BASIC_LIMIT_INFORMATION{
			LimitFlags: syscall.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
		},
	}
	
	_, err = syscall.SetInformationJobObject(
		jobHandle,
		syscall.JobObjectExtendedLimitInformation,
		uintptr(unsafe.Pointer(&info)),
		uint32(unsafe.Sizeof(info)),
	)
	if err != nil {
		fmt.Println("set job object info failed:", err)
		return
	}
	
	c.SysProcAttr = &syscall.SysProcAttr{
		CreationFlags: syscall.CREATE_SUSPENDED,
	}
	
	c.Start()
	
	// 将进程分配到Job Object
	err = syscall.AssignProcessToJobObject(jobHandle, syscall.Handle(c.Process.Pid))
	if err != nil {
		fmt.Println("assign process to job failed:", err)
		c.Process.Kill()
		return
	}
	
	// 恢复进程执行
	syscall.ResumeThread(syscall.Handle(c.Process.Pid))
	
	_, err = c.Output()
	fmt.Println("end", "err", err)
}

func createJobObject() (syscall.Handle, error) {
	name, err := syscall.UTF16PtrFromString("GoExecJob")
	if err != nil {
		return 0, err
	}
	
	handle, err := syscall.CreateJobObject(nil, name)
	if err != nil {
		return 0, err
	}
	
	return handle, nil
}

方案4:使用更简单的命令结构

package main

import (
	"context"
	"fmt"
	"os/exec"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	
	// 避免使用start /wait,直接运行程序
	c := exec.CommandContext(ctx, "notepad.exe")
	
	_, err := c.Output()
	fmt.Println("end", "err", err)
}

关键点说明:

  1. Windows的进程管理与Unix-like系统不同,父进程终止不会自动终止子进程
  2. start /wait创建了一个新的进程树,导致Go只等待cmd.exe而忽略了notepad.exe
  3. 使用Job Object是Windows上管理进程树最可靠的方式
  4. 在可能的情况下,简化命令结构可以避免这类问题

方案3(Job Object)是最彻底的解决方案,它能确保整个进程树在上下文超时或被取消时完全终止。方案1和方案2在大多数情况下也能工作,但可能在某些边缘情况下不够可靠。

回到顶部