os/exec 實現了golang調用shell或者其他OS中已存在的命令的方法. 本文主要是閱讀內部實現后的一些總結.
如果要運行ls -rlt
,代碼如下:
package main import ( "fmt" "log" "os/exec" ) func main() { cmd := exec.Command("ls", "-rlt") stdoutStderr, err := cmd.CombinedOutput() if err != nil { log.Fatal(err) } fmt.Printf("%sn", stdoutStderr) }
如果要運行ls -rlt /root/*.go
, 使用cmd := exec.Command("ls", "-rlt", "/root/*.go")
是錯誤的.
因為底層是直接使用系統調用execve
的.它並不會向Shell那樣解析通配符. 變通方案為golang執行bash命令, 如:
package main import ( "fmt" "log" "os/exec" ) func main() { cmd := exec.Command("bash", "-c","ls -rlt /root/*.go") stdoutStderr, err := cmd.CombinedOutput() if err != nil { log.Fatal(err) } fmt.Printf("%sn", stdoutStderr) }
源碼分析
一. os/exec是高階庫,大概的調用關系如下:
+----------------+
| (*Cmd).Start() |
+----------------+
|
v
+-------------------------------------------------------------+
| os.StartProcess(name string, argv []string, attr *ProcAttr) |
+-------------------------------------------------------------+
|
v
+-------------------------------------------+
| syscall.StartProcess(name, argv, sysattr) |
+-------------------------------------------+
二. (*Cmd).Start()主要處理如何與創建后的通信. 比如如何將一個文檔內容作為子進程的標准輸入, 如何獲取子進程的標准輸出.
這里主要是通過pipe實現, 如下是處理子進程標准輸入的具體代碼注釋.
// 該函數返回子進程標准輸入對應的文檔信息. 在fork/exec后子進程里面將其對應的文檔描述符設置為0 func (c *Cmd) stdin() (f *os.File, err error) { // 如果沒有定義的標准輸入來源, 則默認是/dev/null if c.Stdin == nil { f, err = os.Open(os.DevNull) if err != nil { return } c.closeAfterStart = append(c.closeAfterStart, f) return } // 如果定義子進程的標准輸入為父進程已打開的文檔, 則直接返回 if f, ok := c.Stdin.(*os.File); ok { return f, nil } // 如果是其他的,比如實現了io.Reader的一段字符串, 則通過pipe從父進程傳入子進程 // 創建pipe, 成功execve后,在父進程里關閉讀. 從父進程寫, 從子進程讀. // 一旦父進程獲取子進程的結果, 即子進程運行結束, 在父進程里關閉寫. pr, pw, err := os.Pipe() if err != nil { return } c.closeAfterStart = append(c.closeAfterStart, pr) c.closeAfterWait = append(c.closeAfterWait, pw) // 通過goroutine將c.Stdin的數據寫入到pipe的寫端 c.goroutine = append(c.goroutine, func() error { _, err := io.Copy(pw, c.Stdin) if skip := skipStdinCopyError; skip != nil && skip(err) { err = nil } if err1 := pw.Close(); err == nil { err = err1 } return err }) return pr, nil }
三. golang里使用os.OpenFile
打開的文檔默認是`close-on-exec”
除非它被指定為子進程的標准輸入,標准輸出或者標准錯誤輸出, 否則在子進程里會被close掉.
file_unix.go
里是打開文檔的邏輯:
// openFileNolog is the Unix implementation of OpenFile. // Changes here should be reflected in openFdAt, if relevant. func openFileNolog(name string, flag int, perm FileMode) (*File, error) { setSticky := false if !supportsCreateWithStickyBit && flag&O_CREATE != 0 && perm&ModeSticky != 0 { if _, err := Stat(name); IsNotExist(err) { setSticky = true } } var r int for { var e error r, e = syscall.Open(name, flag|syscall.O_CLOEXEC, syscallMode(perm)) if e == nil { break }
如果要讓子進程繼承指定的文檔, 需要使用
func main() { a, _ := os.Create("abc") cmd := exec.Command("ls", "-rlt") cmd.ExtraFiles = append(cmd.ExtraFiles, a) stdoutStderr, err := cmd.CombinedOutput() if err != nil { log.Fatal(err) } fmt.Printf("%sn", stdoutStderr) }
四. 當父進程內存特別大的時候, fork/exec的性能非常差, golang使用clone系統調優並大幅優化性能. 代碼如下:
locked = true switch { case runtime.GOARCH == "amd64" && sys.Cloneflags&CLONE_NEWUSER == 0: r1, err1 = rawVforkSyscall(SYS_CLONE, uintptr(SIGCHLD|CLONE_VFORK|CLONE_VM)|sys.Cloneflags) case runtime.GOARCH == "s390x": r1, _, err1 = RawSyscall6(SYS_CLONE, 0, uintptr(SIGCHLD)|sys.Cloneflags, 0, 0, 0, 0) default: r1, _, err1 = RawSyscall6(SYS_CLONE, uintptr(SIGCHLD)|sys.Cloneflags, 0, 0, 0, 0, 0) }
網上有很多關於討論該性能的文章:
https://zhuanlan.zhihu.com/p/47940999
https://about.gitlab.com/2018/01/23/how-a-fix-in-go-19-sped-up-our-gitaly-service-by-30x/
https://github.com/golang/go/issues/5838
五. 父進程使用pipe來探測在創建子進程execve
時是否有異常.
在 syscall/exec_unix.go
中. 如果execve
成功,則該pipe因close-on-exec在子進程里自動關閉.
// Acquire the fork lock so that no other threads // create new fds that are not yet close-on-exec // before we fork. ForkLock.Lock() // Allocate child status pipe close on exec. if err = forkExecPipe(p[:]); err != nil { goto error } // Kick off child. pid, err1 = forkAndExecInChild(argv0p, argvp, envvp, chroot, dir, attr, sys, p[1]) if err1 != 0 { err = Errno(err1) goto error } ForkLock.Unlock() // Read child error status from pipe. Close(p[1]) n, err = readlen(p[0], (*byte)(unsafe.Pointer(&err1)), int(unsafe.Sizeof(err1))) Close(p[0])
六. 當子進程運行完后, 使用系統調用wait4
回收資源, 可獲取exit code
,信號
和rusage
使用量等信息.
七. 有超時機制, 如下例子是子進程在5分鍾沒有運行時也返回. 不會長時間阻塞進程.
package main import ( "context" "os/exec" "time" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() if err := exec.CommandContext(ctx, "sleep", "5").Run(); err != nil { // This will fail after 100 milliseconds. The 5 second sleep // will be interrupted. } }
具體是使用context
庫實現超時機制. 一旦時間達到,就給子進程發送kill
信號,強制中止它.
if c.ctx != nil { c.waitDone = make(chan struct{}) go func() { select { case <-c.ctx.Done(): c.Process.Kill() case <-c.waitDone: } }() }
八. 假設調用一個腳本A, A有會調用B. 如果此時golang進程超時kill掉A, 那么B就變為pid為1的進程的子進程.
有時這並不是我們所希望的.因為真正導致長時間沒返回結果的可能是B進程.所有更希望將A和B同時殺掉.
在傳統的C代碼里,我們通常fork
進程后運行setsid
來解決. 對應golang的代碼為:
func main() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "sleep", "5") cmd.SysProcAttr.Setsid = true if err := cmd.Run(); err != nil { // This will fail after 100 milliseconds. The 5 second sleep // will be interrupted. } }