このページの本文へ

Goならわかるシステムプログラミング 第14回

Go言語によるプログラマー視点のシステムプログラミング

Go言語で知るプロセス(2)

2017年03月29日 09時00分更新

文● 渋川よしき、編集● 鹿野桂一郎

  • この記事をはてなブックマークに追加
  • 本文印刷

前回の記事では、プログラムの実行単位であるプロセスについて、さまざまな属性やリソースをGo言語の視点から紹介しました。 今回は、Go言語のプログラムから、他のプロセスを実行したり属性を変更したりする方法を紹介します。

Go言語のプログラムから他のプロセスを扱うときは、プロセスを表す構造体を利用します。 そのための構造体には次の2種類があります。

  • osパッケージのos.Process: 低レベルな構造体
  • os/execパッケージのexec.Cmd: 少し高機能な構造体。内部でos.Processを持つ

まず高機能で実用的なexec.Cmdの使い方を説明してから、os.Processの使い方を簡単に紹介します。 その後、プロセスに関する便利なGo言語のライブラリを紹介します。

exec.Cmdによるプロセスの起動

exec.Cmd構造体は次の2つの関数で作ることができます。

  • exec.Command(名前, 引数...)
  • exec.CommandContext(コンテキスト, 名前, 引数...)

両者の違いは、引数としてコンテキストを取れるかどうかです。 コンテキストは、依存関係が複雑なときでもタイムアウトやキャンセルをきちんと行うための仕組みで、Go 1.7から標準で利用できるようになりました。 exec.CommandContextにコンテキストとして渡した処理が、exec.Cmdが表すプロセスの終了前に完了した場合、そのプロセスはos.Process.Kill()メソッドを使って強制終了されます。

以降の説明ではコンテキストを利用しないexec.Command()を使います。 まずは次のサンプルプログラムでexec.Cmd構造体の使い方を見てみましょう。

package main
 
import (
    "fmt"
    "os"
    "os/exec"
)
 
func main() {
    if len(os.Args) == 1 {
        return
    }
    cmd := exec.Command(os.Args[1], os.Args[2:]...)
    err := cmd.Run()
    if err != nil {
        panic(err)
    }
    state := cmd.ProcessState
    fmt.Printf("%s\n", state.String())
    fmt.Printf("  Pid: %d\n", state.Pid())
    fmt.Printf("  System: %v\n", state.SystemTime())
    fmt.Printf("  User: %v\n", state.UserTime())
}

上記は、引数として外部プログラムを指定すると、その外部プログラムの実行にかかった時間を表示するプログラムです。 UNIX系のOSに備わっているtimeコマンドと似た動作ですが、 実処理時間は表示せず、システム時間(カーネル内で行われた処理の時間)とユーザー時間(プロセス内で消費された時間)を表示します。

実行結果を下記に示します。

$ go run time.go sleep 1
exit status 0
 Pid: 42442
 System: 1.638ms
 User: 580µs

上記のサンプルプログラムでは、引数として渡された外部プログラムを指定してexec.Command()を呼び出し、 そのプロセスを表すexec.Cmd構造体のRun()メソッドを呼び出しています。 exec.Cmdには、プロセスの実行を制御するメソッドとして、Run()だけでなく下記のようなものが用意されています。

exec.Cmdの実行制御用メソッド
メソッド 説明
Start() error 実行を開始する
Wait() error 終了を待つ
Run() error Start()Wait()
Output() ([]byte, error) Run()実行後に標準出力の結果を返す
CombinedOutput() ([]byte, error) Run()実行後に標準出力、標準エラー出力の結果を返す

さらに上記のサンプルプログラムでは、実行した外部プログラムの実行にかかった時間を表示するために、 ProcessStateというexec.Cmd構造体のメンバーを利用しています。 この構造体メンバーは外部プログラムに対応するプロセスの終了ステータスを表しており、ほかにも下記のようなメンバーメソッドが提供されています。

state := cmd.ProcessState
// 終了コードと状態を文字列で返す
fmt.Printf("%s\n", state.String())
// 子プロセスのプロセスID
fmt.Printf("  Pid: %d\n", state.Pid())
// 終了しているかどうか
fmt.Printf("  Exited: %v\n", state.Exited())
// 正常終了か?
fmt.Printf("  Success: %v\n", state.Success())
// カーネル内で消費された時間
fmt.Printf("  System: %v\n", state.SystemTime())
// ユーザーランドで消費された時間
fmt.Printf("  User: %v\n", state.UserTime())

なお、消費された時間は実際の経過時間(Wall-clock time)ではありません。 スリープした時間はカウントされませんし、マルチスレッドで8コアの性能を100%引き出せばユーザーランドでは8倍速で時間がカウントされます。

exec.Cmd構造体では、構造体の作成から実行までの間にプロセスの実行に関する情報を変更するためのメンバーも提供されています。

プロセスの実行に関する情報を変更するためのexec.Cmdのメンバー変数
変数 種類 説明
Env []string 入力 環境変数。セットされないときは親プロセスを引き継ぐ。
Dir string 入力 実行時のディレクトリ。セットされないと親プロセスと同じ。
ExtraFiles []*os.File 入力 子プロセスに追加で渡すファイル。3以降のファイルディスクリプタで参照できる。追加のファイルは、子プロセスからはos.NewFile()を使って開ける。
SysProcAttr *syscall.SysProcAttr 入力 OS固有の設定

よく使うのはEnvDirあたりでしょう。 環境変数については、exec.Cmd構造体のメンバーで設定せず、親プロセス内でos.Setenv()してからexec.Command()を実行しても子プロセスに情報を伝達できます。

exec.Cmd構造体には、Sys()というメンバーもあります。 これはsyscall.WaitStatusのオブジェクトを返し、このオブジェクトはExitStatus()という終了ステータスコードを返すメソッドを持っています。 さらに、POSIX系OS限定ですが、シグナルを受信したかどうかの情報も持っています。

また、より低レベルな構造体であるos.Processのインスタンスも、Processというexec.Cmd構造体のメンバーとして利用可能です。

リアルタイムな入出力

実行制御のメソッドのうち、Output()CombinedOutput()は、子プロセスが出力した内容を返します。 プログラムが一瞬で完了する場合にはこれでも問題はありませんが、数10秒以上かかるコマンドを実行して途中経過が分からないのは不親切です。

実行を開始する前に下記の表に示すメソッドを使うことで、子プロセスとリアルタイムに通信を行うためのパイプが取得できます。 このパイプはexec.Cmd構造体が子プロセス終了時に閉じるため、呼び出し側では閉じる必要はありません。 なお、一度プロセスの実行をスタートするとこれらのメソッドの呼び出しはエラーになるので注意してください。

exec.Cmdとリアルタイムの入出力を行うためのメソッド
メソッド 説明
StdinPipe() (io.WriteCloser, error) 子プロセスの標準入力につながるパイプを取得
StdoutPipe() (io.ReadCloser, error) 子プロセスの標準出力につながるパイプを取得
StderrPipe() (io.ReadCloser, error) 子プロセスの標準エラー出力につながるパイプを取得

実験してみる前に、1秒に1つずつ数値を出力するプログラムを子プロセス用に作ってみましょう。

package main
 
import (
    "fmt"
    "time"
)
 
func main() {
    for i := 0; i < 10; i++ {
        fmt.Println(i)
        time.Sleep(time.Second)
    }
}

次のようにビルドします。

# Windows
$ go build -o count.exe count.go ⏎
 
# Windows以外
$ go build -o count count.go

このcountプログラムを起動し、標準出力に(stdout)というプリフィックスを付けつつリアルタイムでリダイレクトするサンプルを下記に示します。

package main
 
import (
    "bufio"
    "fmt"
    "os/exec"
)
 
func main() {
    count := exec.Command("./count")
    stdout, _ := count.StdoutPipe()
    go func() {
        scanner := bufio.NewScanner(stdout)
        for scanner.Scan() {
            fmt.Printf("(stdout) %s\n", scanner.Text())
        }
    }()
    err := count.Run()
    if err != nil {
        panic(err)
    }
}

標準出力と標準エラー出力を同時にダンプするときは、sync.Mutexなどを使って同時に書き込まないようにしたほうがよいでしょう。

os.Processによるプロセスの起動・操作

os.Processは低レベルなAPIです。 指定したコマンドを実行できるほか、すでに起動中のプロセスのIDを指定して作成できます。

  • os.StartProcess(コマンド, 引数, オプション)
  • os.FindProcess(プロセスID)

os.StartProcess()を使って実行ファイルを指定する場合は、exec.Command()と異なり、PATH環境変数を見て実行ファイルを探すことはしません。 そのため、絶対パスや相対パスなどで実行ファイルを直接指定する必要があります。 exec.Command()の場合には、内部で使っているexec.LookPath()を使うことで、探索して実行が可能です。

os.StartProcess()を使うときは、Wait()メソッドを呼び出すことで、子プロセスが終了するのを待てます。 Wait()メソッドは、os.ProcessState構造体のインスタンスを返すので、これを使って終了状態を取ることができます。

一方、os.FindProcess()を使って実行中のプロセスにアタッチして作ったos.Processオブジェクトは、Wait()メソッドを呼び出すことができず、終了状態を取得できません。 Kill()メソッドを呼ぶか、次回以降に説明するシグナルを送る以外、できることはありません。

os.Processのインスタンスはexec.Cmdにも含まれています。 したがって、exec.Command()で作ったプロセスに対しても、Kill()メソッド呼んだりシグナルを送ったりできます。

プロセスに関する便利なGo言語のライブラリ

プロセスの出力に色付けをする

OSに備わっている、cmd.exeやbashやPowerShellなどが動いている黒い画面(白いこともありますが)のことを、擬似端末(Pseudo Terminal)と呼びます。 正確には、GUIのターミナルアプリケーションがやりとりするOS側のデバイス(これもPOSIXではファイルとして抽象化されている)が擬似端末です。 プロセスから疑似端末に文字列を出力する場合に、出力文字列の色を変えたいときがありますが、そのためにはANSIエスケープシーケンスという仕組みを使います。

疑似端末への文字列出力時のエスケープシーケンスは、WindowsとPOSIX系OSでは互換性がない部分のひとつです。 Go言語の標準ライブラリでも、デフォルトではエスケープシーケンスの互換性の面倒は見てくれません。 そのため、ライブラリによっては、環境に応じて自前でエスケープシーケンスを出し分けています。 そのようなライブラリの一例としては、プログレスバーライブラリのgithub.com/cheggaaa/pbが挙げられます。

エスケープシーケンスの環境差を吸収するために数多くのライブラリのバックエンドで使われている仕組みとして、mattnさん作のgo-colorableというパッケージもあります。 このパッケージは、POSIX系OSのエスケープシーケンスが出力されるときに、Windows環境ではWindows用のエスケープシーケンスに変換するフィルタとして動作します。

エスケープシーケンスは、疑似端末への出力では意味がありますが、リダイレクトでファイルに結果を保存するときには不要な情報です。 行儀の良いアプリケーションでは、自分の標準出力がつながっている先が擬似端末かどうかを判定し、エスケープシーケンスを出し分けるのがよいでしょう1。 その判断にはisatty()というC言語の関数が使われますが、この関数の内部ではファイルディスクリプタの詳細情報を取得するioctl()というシステムコールが使われていて、擬似端末情報を取得できるかどうかで判定しています。 Go言語実装としては、やはりmattnさん作のgo-isattyというパッケージがあります。

go-colorableとgo-isattyを利用したサンプルを見てみましょう。 下記のコードは、接続先がターミナルのときはエスケープシーケンスを表示し、そうでないときはエスケープシーケンスを除外するフィルタ(colorable.NonColorable)を使い分ける例です。 このコードを実行すると古い昔のソフトウェアの名前を表示します2が、リダイレクトしてファイルに落とすとエスケープシーケンスが出力されないことが確認できます。

package main
 
import (
    "fmt"
    "github.com/mattn/go-colorable"
    "github.com/mattn/go-isatty"
    "io"
    "os"
)
 
var data = "\033[34m\033[47m\033[4mB\033[31me\n\033[24m\033[30mOS\033[49m\033[m\n"
 
func main() {
    var stdOut io.Writer
    if isatty.IsTerminal(os.Stdout.Fd()) {
        stdOut = colorable.NewColorableStdout()
    } else {
        stdOut = colorable.NewNonColorable(os.Stdout)
    }
    fmt.Fprintln(stdOut, data)
}

裏でこれらのライブラリも使いつつ、接続先が疑似端末のときだけ色付けを行うgithub.com/fatih/colorパッケージも便利です3

外部プロセスに対して自分が疑似端末だと詐称する

先ほどの例は、「自分がつながっている先が擬似端末かどうかでエスケープシーケンスを出力するかどうか決める」というものでした。 Cmd.StdinPipe()を使うと、子プロセスにおけるisatty()で「端末ではない」と判定されてしまうため、行儀の良いプログラムで疑似端末への出力なのにエスケープシーケンスが抑制されてしまうことがあります。 これでは、複数の子プロセスを並行して実行し、その間に子プロセスの出力をバッファにためておいて、終了したらまとめて出力することで各プロセスの出力が混ざらないように制御したいが、色情報は残したい、といった用途のときに困ってしまいます。 プログラムによっては--colorなどのオプションを使って色情報を強制出力できますが、そのような機能を提供していないプロセスのために、自分が疑似端末であると詐称する方法があります。

自分が疑似端末であると詐称するには、POSIX系OSではgithub.com/kr/ptyパッケージ、Windowsではgithub.com/iamacarpet/go-winptyパッケージを使います。

それぞれのパッケージの使い方を紹介するために、下記のプログラムをcheckという名前でビルドしておいてください。 このcheckプログラムを別のプログラムから読み込み、その際に上記のパッケージを使ってcheckプログラムが疑似端末につながっているものと思いこませてみます。

package main
 
import (
    "fmt"
    "github.com/mattn/go-colorable"
    "github.com/mattn/go-isatty"
    "io"
    "os"
)
 
func main() {
    var out io.Writer
    if isatty.IsTerminal(os.Stdout.Fd()) {
        out = colorable.NewColorableStdout()
    } else {
        out = colorable.NewNonColorable(os.Stdout)
    }
    if isatty.IsTerminal(os.Stdin.Fd()) {
        fmt.Fprintln(out, "stdin: terminal")
    } else {
        fmt.Println("stdin: pipe")
    }
    if isatty.IsTerminal(os.Stdout.Fd()) {
        fmt.Fprintln(out, "stdout: terminal")
    } else {
        fmt.Println("stdout: pipe")
    }
    if isatty.IsTerminal(os.Stderr.Fd()) {
        fmt.Fprintln(out, "stderr: terminal")
    } else {
        fmt.Println("stderr: pipe")
    }
}

POSIX系OSでは下記のようにgithub.com/kr/ptyを使います。

package main
 
import (
    "github.com/kr/pty"
    "io"
    "os"
    "os/exec"
)
 
func main() {
    cmd := exec.Command("./check")
    stdpty, stdtty, _ := pty.Open()
    defer stdtty.Close()
    cmd.Stdin = stdpty
    cmd.Stdout = stdpty
    errpty, errtty, _ := pty.Open()
    defer errtty.Close()
    cmd.Stderr = errtty
    go func() {
        io.Copy(os.Stdout, stdpty)
    }()
    go func() {
        io.Copy(os.Stderr, errpty)
    }()
    err := cmd.Run()
    if err != nil {
        panic(err)
    }
}

このプログラムを実行すると、checkプログラムが疑似端末につながっていると判定されていることがわかります。

$ go run pty.go ⏎
stdin: terminal
stdout: terminal
stderr: terminal

Windowsでは、github.com/iamacarpet/go-winptyを利用します。 このパッケージは、疑似端末をエミュレートするwinptyというソフトウェアのラッパーなので、次のリポジトリのreleasesからmsvc20015バイナリをダウンロードして実行フォルダと同じ場所にwinpty.dllwinpty-agent.exeをおいてください。

そして、これらのファイルの場所を、下記のようにwinpty.Open()の第一引数に指定して使います(空白文字列を指定するとワークフォルダを探しに行きます)。

package main
 
import (
    "github.com/iamacarpet/go-winpty"
    "io"
    "os"
)
 
func main() {
    pty, err := winpty.Open("", "check.exe")
    if err != nil {
        panic(err)
    }
    defer pty.Close()
    go func() {
        io.Copy(os.Stdout, pty.StdOut)
    }()
    // press any key to exit
    buffer := make([]byte, 1)
    for {
        _, err = os.Stdin.Read(buffer)
        if err == io.EOF || buffer[0] == '\n' {
            break
        }
    }
}

(注意点として、このパッケージでは疑似端末のような「終了しないコマンド」をサブコマンドとして実行することを前提としており、子プロセスの終了を待つメソッドが提供されていません。 そのため、上記のコードではEnterキーが押されたら終了するようにしています。)

上記のコードを実行すると次のような結果が表示されます。

$ go run winpty.go ⏎
stdin: terminal
stdout: terminal
stderr: terminal

winpty.Open()を見て「おや?」と思った方もいるかもしれません。 他のライブラリのように引数の配列を受け取ることはできず、このメソッドには引数も含めたコマンドラインを指定する必要があります。 これについては後述の内部実装のところで触れます。

OS固有のプロセス起動オプション

OS固有の設定はPOSIX系OSとWindowsで大きく異なります。また、POSIX系OSの中でも、LinuxとBSD系OSで多少の違いがあります。

共通のものとしては、子プロセスのルートディレクトリ(Chroot)、子プロセスのユーザID、グループID、補助グループIDを含むCredential構造体、デバッガAPIのptraceの有効化フラグ(Ptrace)、セッションIDやプロセスグループIDの初期化フラグ、疑似ターミナル関連の設定、フォアグラウンド動作をさせるかどうかなどです。詳しくはリファレンスを参照してください。

Windowsでは、ウインドウを隠すかどうかのフラグ(HideWindow)が設定できます。

もっと低レベルのAPIのためのフラグもあります。 Linuxはcloneunshareというシステムコールで子プロセスを作ります。 WindowsはCreateProcess()APIを使ってプロセスを作ります。 これらのAPIのためのフラグ情報を持たせることもできます。

Go言語では触れることのない世界

Goでのプログラミングではあまり触れることがないプロセスの世界もあります。

fork/exec

C言語やスクリプト言語でシステムコールを呼ぶサンプルでは、高い頻度でforkexecが登場します。 これはサブプロセスを起動するためのPOSIX系OSのお作法です。

まず、forkは現在のプロセスを2つに分身させます。 2つの分身とも、fork関数を呼び出した直後から実行が再開されます。 唯一の違いはfork関数の返り値で、これが子供のプロセスIDか0かで、親か子のどちらの文脈で実行しているかが判断できます。 以下のコードは、forkしたプロセスのプロセスIDを見て条件分岐をするPythonの例です。

# Pythonのコード例
import os
 
pid = os.fork()
if pid == 0:
    # 子供のプロセス
else:
    # 親のプロセス

その後は、exec属のシステムコールを使います。よく使われるのがexecveという関数です。 これは新しいプログラムを読み込んだ上で、親プロセスが用意したコマンドライン引数と環境変数を渡して実行を開始します。

forkには、「複数のスレッドが存在しているときでも、forkを呼び出したスレッド以外はコピーされない」という落とし穴があります。 ロックしてトランザクションで守られたデータを修正しているスレッドがあったとすると、このロックをかけたスレッドが突如なくなって、ロックが外されなくなります。 forkexecの間では、例外などが発生するシステムコールも使えません4

Go言語のランタイムでは、多数のOSスレッドが、その時々の待ちタスクとなっているgoroutineにアタッチして実行されるようになっています。 ガベージコレクタ、システムコールの待ちなど、多くのタスクを並列実行するために、たくさんのスレッドが利用されています。 各処理がどのスレッドで実行されているかを把握する必要はありませんし、ブラックボックス化されているので実際のOSスレッドを制御する機能はほとんど提供されていません。 そのため、Go言語のランタイムでは、forkをカジュアルに利用するようなコードを気軽に動かすことはできません。

ただし、外部プロセスで実行される関数を使うときは、Go言語でもforkexecveが利用されています。 その際には、関数呼び出しによって親プロセス内で余計なスレッド切り替え処理が起きないようにしたり、Go言語独自のスタックメモリ管理だったものをOS標準のスタックメモリ管理に戻したりといった、細々とした調整が施されています。 外部プロセスの起動では、forkexecの呼び出しの間でできることが限られることから、この2つの呼び出しをまとめたsyscall.forkExec()関数が使われています。

forkと並行処理

forkは、ネイティブスレッドが扱いやすい言語や、Go言語のようにスレッドの活用がランタイムに組み込まれている言語以外では、マルチコアCPUの性能向上を活かす上で強力な武器となります。

スクリプト言語では、インタプリタ内部のデータの競合が起きないようにGIL(グローバルインタプリタロック)やGVL(ジャイアントVMロック)と呼ばれる機構があり、同時に動けるスレッド数が厳しく制限されて複数コアの性能が生かせないケースがあります。 このときにforkで複数のプロセスを作り、ワーカーとして並行に実行させることがよくあります。 たとえばPythonには、これを効率よく行うためのmultiprocessingパッケージがあります。Node.jsにもclusterモジュールがあります。 ウェブサーバーのApacheでも、事前にforkしておくことで並行で処理を行うPreforkが一番最初に導入され、広く使われています。

親子のプロセスが作られるときは、どちらかのプロセスでメモリを変更するまではメモリの実体をコピーしない「コピーオンライト」でメモリが共有されます( 第12回の記事mmapで少し紹介しました)。そのため、子プロセス生成時に瞬時にメモリ消費量が大きく増えることはありません。 しかし、この仕様と言語のランタイムの相性が良くないことがあります。 たとえば、Rubyにはガベージコレクタ用のフラグがObjectの内部構造体にあったことから、GCが走るタイミングでフラグの書き換えが発生して早期にメモリコピーが発生してしまうという問題がありました。 この動作はRuby 2.0から修正され、フラグの保存領域が別の領域に分離されてマルチコアでの動作が改善されました。

Pythonは参照カウントで不要なオブジェクトの判断を行っていますが、これもコピーを発生させてしまう要因となります。 Instagramの技術ブログ5では、このコピーオンライトの問題を回避するためにGCを停止させて処理速度を向上させる方法のレポートが紹介されています(プロセス全体をきちんと監視して外部から適切にリセットすることが前提です)。

デーモン化

デーモン(daemon)は、POSIX系のOSで動き続けるサーバープロセスなどのバックグラウンドプロセスを作るための機能です。 普通のプログラムはシェルのプロセスの子供になってまうので、ログアウトしたりシェルを閉じたりするだけで終了してしまいます。 そのような場合でも終了しないように、下記のような特別な細工が施されたプロセスがデーモンです。

  • セッションID、グループIDを新しいものにして既存のセッションとグループから独立
  • カレントのディレクトリはルートに移動
  • フォークしてからブートプロセスのinitを親に設定し、実際の親はすぐに終了
  • 標準入出力も起動時のものから切り離される(通常は/dev/nullに設定される)

デーモンの作成では、セッションとグループから独立後にforkを呼び出し、標準出力などを切り離すといった処理が必要になります。 C言語ではプロセス関連のシステムコールを個別に呼び出してデーモン化させることもできますし、daemon()といった一発でデーモン化ができる関数も提供されています。

しかし、forkが必要なところからも分かる通り、Go言語自身ではデーモン化が積極的にサポートされていません。 とはいえ、syscall以下の機能を駆使することでデーモン化は可能です。そのようなパッケージも探せばいくつも出てきます。 現在では、通常のプログラムとして作ったうえで、launchctlやsystemd、daemontoolsといった仕組みで起動することによりデーモン化する方法が一般的でしょう。 この方法であれば、管理方法も他の常駐プログラムと同じように扱えるというメリットもあります。

Windowsではgolang.org/x/sys/windows/svcパッケージを使ってWindowsサービスを作れますが、nssmというツールを使うこともできます。 このあたりは次の情報が役立ちます。

子プロセスの内部実装

Unix系のOSには次のようなシステムコールがあります。

  • fork
  • vfork
  • rfork (BSD)
  • clone (Linux)

forkは親を複製して子プロセスを作ると紹介しました。 コピーオンライトでメモリの実態はコピーしないとしても、メモリブロックの管理テーブルなどの資源はコピーしておく必要がありますし、共有されるファイルディスクリプタのテーブル、シグナルのテーブルなどコピーが必要なデータもあります。 その後のコードで利用されないのであれば余計なタスクは減らしたほうが効率的です。 vforkはそのための代替手法の1つで、メモリブロックのコピーを行いません。 その代わり、競合が起きないように、子プロセスが終了するかexecを実行するまで親プロセスを停止させます。 rforkはBSD系OSで使えるシステムコールで、呼び出す側で各資源をコピーするかどうかを細かく条件を設定できます。

Linuxカーネル内ではスレッド・プロセスをまとめてタスクという言葉で扱っていて、実際、スレッドとプロセスの実装上の差はほぼありません。 スレッドは、親のプロセスと同じメモリ空間を共有するプロセスのように動作します。 Linuxではこのようなメモリの共有もフラグで制御できる、より柔軟なclone システムコールを内部で使っています。 カーネル内部では、 forkvforkも、スレッド生成も、最終的にはcloneシステムコールと同じロジックで処理されます。

なお、Go言語のsyscallパッケージでは、Linux上はclone()、他のPOSIX系OSではforkを使います。

Windows上では、CreateProcess()というWindows APIを使います。 Windows上ではforkに相当するAPIはサポートされていません。 CreateProcessは実行ファイルからプログラムを読み込み、実行ファイル用のメインスレッドを作成して子プロセスを実行します。 Goの内部実装のsyscall.forkExecを1つで行う関数となっています。

POSIX系OSではコマンドライン引数(と環境変数)は1項目ごとに1つのC文字列(NULL終端)としてプロセスに渡されますが、CreateProcess、Windowsの実行ファイルのエントリーポイントであるWinMain()は、POSIX系OSとは異なり、引数を1つの文字列で扱います。 先ほど紹介したgo-winptyも、このWindowsに忠実なAPIになっています。 Windowsだけで使えるsyscall.EscapeArg()を使ってスペースなどが含まれたパスをほぼ正しい文字列にまとめることができます。 次のコードはsyscallパッケージ内のexec_windows.goで実際に使われているコードです。

func makeCmdLine(args []string) string {
    var s string
    for _, v := range args {
        if s != "" {
            s += " "
        }
        s += EscapeArg(v)
    }
    return s
}

Windowsのコマンドライン引数の処理は各プログラムが行うため、コマンドによってエスケープの仕方が異なることがあります6。 ダブルクオーテーション(")のエスケープも、\"とバックスラッシュを使う場合と、""とする場合とがあります。 Microsoft社製のプログラムでも、CMD.exeとPowerShellとでは異なるようです。その上で提供されているPOSIX環境の場合はさらに複雑です7

それ以外にも、シェルでワイルドカード(?*)を使ったときの動作も、POSIX系OSとWindowsとで動作が異なります。 POSIX系OSでは、シェル側で展開を行い、マッチするファイルのリストがプログラム側に渡ってきますが、Windowsの場合はワイルドカードの文字列が渡されるため、呼び出されたプログラム側でワイルドカードを解釈してファイルを探し出す必要があります。 これには、 第11回「ファイルシステムと、その上のGo言語の関数たち(2)」 で紹介した、パターンにマッチするファイル名を取得するパッケージが使えます。

まとめと次回予告

今回は、他のプロセスをGo言語のプログラムから起動する方法を紹介しました。 単に起動するだけなら特に難しいことはありませんが、プロセスの起動オプションはたくさんあり、さまざまなケースに対応できるようになっています。

さらにちょっとした応用として、子プロセスの標準出力をリアルタイムで読み込む方法や、プロセスの出力に疑似端末上で色を付ける方法、疑似端末につながっていると詐称する方法なども紹介しました。

最後に、Go言語の話からちょっとはずれて、プロセスのforkと、それによる並行処理やデーモン化について簡単に説明しました。 また、プロセスの内部実装についても概説しました。

次回は、プロセス間の通信に利用するシグナルについて紹介します。

脚注

  1. 技術評論社刊行の「みんなのGo」では、これにプラスして、疑似端末では出力をバッファリングすることで高速化を行うことも用途の1つとして挙げられています。
  2. 株式会社ACCESSの登録商標です。
  3. 色付けやカーソル移動に特化すればこちらのほうが高機能です: https://github.com/morikuni/aec
  4. 革命の日々!: 「マルチスレッドプログラムはforkしたらexecするまでの間はasync-signal-safe な関数しか呼んではいけない」: http://mkosaki.blog46.fc2.com/blog-entry-886.html
  5. https://engineering.instagram.com/dismissing-python-garbage-collection-at-instagram-4dca40b29172
  6. http://stackoverflow.com/questions/562038/escaping-double-quotes-in-batch-script/31413730#31413730
  7. CygwinやMSYSのコマンドは、プロセスがCygwinやMSYSのシステムコールから作られたかどうかでコマンドライン引数の文字列のパースの挙動を変えているそうです。

カテゴリートップへ

この連載の記事
ピックアップ