Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 14852] ファイルの概要

このコミットは、Go言語のsyscallパッケージにおいて、LinuxシステムコールPipe2の実装を追加し、それを用いてForkExec関数におけるパイプの作成処理を改善するものです。これにより、パイプ作成時のファイルディスクリプタのクローズオンエグゼック(close-on-exec)フラグの設定をより効率的かつアトミックに行えるようになり、競合状態のリスクを低減します。

コミット

commit e32d1154ec3176a81659720c1ca665e68ac95c42
Author: Georg Reinke <guelfey@gmail.com>
Date:   Thu Jan 10 17:04:55 2013 -0800

    syscall: implement Pipe2 on Linux and use it in ForkExec
    
    Fixes #2656.
    
    R=golang-dev, bradfitz, iant, minux.ma
    CC=golang-dev
    https://golang.org/cl/7062057

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/e32d1154ec3176a81659720c1ca665e68ac95c42

元コミット内容

syscall: implement Pipe2 on Linux and use it in ForkExec

このコミットは、Linux上でPipe2システムコールを実装し、ForkExec関数内でそれを使用するように変更します。これにより、Issue #2656で報告された問題が修正されます。

変更の背景

この変更の主な背景は、Go言語のsyscallパッケージがプロセスをフォークして新しいプログラムを実行する際に使用するForkExec関数における、パイプ作成の堅牢性と効率性の向上です。

従来のUnix系システムでは、プロセス間通信のためにパイプを作成する際には、まずpipe()システムコールを呼び出して2つのファイルディスクリプタ(読み込み側と書き込み側)を取得します。その後、これらのファイルディスクリプタが子プロセスに意図せず継承されるのを防ぐため、fcntl()システムコールとFD_CLOEXECフラグを使用して、それぞれのファイルディスクリプタに「クローズオンエグゼック(close-on-exec)」属性を設定する必要がありました。

この2段階の操作には、以下のような問題点がありました。

  1. 競合状態(Race Condition): pipe()呼び出しとfcntl()呼び出しの間に、別のスレッドがfork()を呼び出すと、FD_CLOEXECフラグが設定されていないパイプのファイルディスクリプタが子プロセスに継承されてしまう可能性がありました。これは、子プロセスが予期せぬファイルディスクリプタを保持することになり、セキュリティ上の脆弱性やデッドロックの原因となる可能性があります。
  2. 効率性: 2つのシステムコールを連続して呼び出すことは、1つのシステムコールで同等の操作を行うよりもオーバーヘッドが大きくなります。

Linuxカーネル2.6.27以降で導入されたpipe2()システムコールは、これらの問題を解決するために設計されました。pipe2()は、パイプを作成する際に、同時にO_CLOEXECなどのフラグをアトミックに設定できる機能を提供します。これにより、競合状態のリスクを排除し、システムコールの呼び出し回数を減らすことで効率を向上させることができます。

このコミットは、Go言語のsyscallパッケージがLinuxの新しい機能を利用できるようにすることで、より堅牢で効率的なプロセス管理を実現することを目的としています。特に、Issue #2656で報告された問題の修正が明記されており、これはおそらく従来のパイプ作成方法に起因するバグや不安定性に関連していると考えられます。

前提知識の解説

このコミットを理解するためには、以下の概念について理解しておく必要があります。

1. パイプ (Pipe)

パイプは、Unix/Linuxシステムにおけるプロセス間通信(IPC: Inter-Process Communication)の最も基本的なメカニズムの一つです。単方向のデータフローを提供し、一方のプロセスがパイプの書き込み端にデータを書き込み、もう一方のプロセスがパイプの読み込み端からデータを読み取ります。

  • pipe()システムコール:
    • int pipe(int pipefd[2]);
    • このシステムコールは、2つの新しいファイルディスクリプタを作成し、pipefd[0]に読み込み端、pipefd[1]に書き込み端を格納します。
    • 成功すると0を返し、失敗すると-1を返します。

2. ファイルディスクリプタ (File Descriptor, FD)

ファイルディスクリプタは、Unix/Linuxシステムにおいて、開かれたファイルやソケット、パイプなどのI/Oリソースを識別するためにカーネルがプロセスに割り当てる非負の整数です。

3. クローズオンエグゼック (Close-on-exec, CLOEXEC)

CLOEXECは、ファイルディスクリプタに設定できるフラグの一つです。このフラグが設定されたファイルディスクリプタは、execve()(または関連するexecファミリーの関数)システムコールによって新しいプログラムが実行される際に、自動的に閉じられます。

  • なぜ必要か?:
    • 子プロセスが親プロセスから不要なファイルディスクリプタを継承するのを防ぎます。
    • これにより、子プロセスが親プロセスのリソースに意図せずアクセスしたり、デッドロックを引き起こしたりするのを防ぎ、セキュリティと堅牢性を向上させます。
    • 特に、fork()exec()を組み合わせて新しいプロセスを起動する際に重要です。fork()は親プロセスのファイルディスクリプタをすべて子プロセスにコピーしますが、exec()の前にCLOEXECを設定することで、子プロセスが不要なFDを保持するのを防ぎます。

4. fcntl()システムコール

fcntl()(file control)システムコールは、開かれたファイルディスクリプタの属性を操作するために使用されます。

  • int fcntl(int fd, int cmd, ... /* arg */ );
  • cmd引数には様々な操作を指定できます。
    • F_SETFD: ファイルディスクリプタフラグを設定します。
    • FD_CLOEXEC: F_SETFDと共に使用され、ファイルディスクリプタにCLOEXECフラグを設定します。

5. pipe2()システムコール

pipe2()はLinux固有のシステムコールで、pipe()の拡張版です。

  • int pipe2(int pipefd[2], int flags);
  • pipe()と同様に2つのファイルディスクリプタを作成しますが、flags引数によってパイプ作成時に追加の属性をアトミックに設定できます。
  • O_CLOEXECフラグ: pipe2()flags引数にO_CLOEXECを指定することで、作成される両方のファイルディスクリプタにCLOEXECフラグをアトミックに設定できます。これにより、pipe()fcntl()を別々に呼び出す必要がなくなり、競合状態を回避できます。
  • 導入時期: Linuxカーネル2.6.27で導入されました。

6. ForkExec関数 (Go言語のsyscallパッケージ)

Go言語のsyscallパッケージは、オペレーティングシステムの低レベルなシステムコールへのインターフェースを提供します。ForkExec関数は、Unix系システムにおいて、新しいプロセスをフォークし、その子プロセスで指定されたプログラムを実行するために使用されるGo言語の関数です。内部的にはfork()execve()システムコールを組み合わせて利用します。

7. ENOSYSエラー

ENOSYSは、システムコールが実装されていないことを示すエラーコードです。このコミットの文脈では、古いLinuxカーネルでpipe2()が利用できない場合に返される可能性があります。

これらの前提知識を理解することで、このコミットがなぜ重要であり、どのような技術的課題を解決しようとしているのかが明確になります。

技術的詳細

このコミットの技術的詳細は、主にLinuxシステムコールpipe2()の導入と、それを用いたForkExecにおけるパイプ作成処理の改善に集約されます。

pipe2()の導入と利点

従来のpipe()システムコールでは、パイプを作成した後に、fcntl()システムコールを使って個別にファイルディスクリプタにFD_CLOEXECフラグを設定する必要がありました。この2段階の操作は、以下のような問題を引き起こす可能性がありました。

  1. 競合状態の発生: pipe()が呼び出されてファイルディスクリプタが作成された直後から、fcntl()FD_CLOEXECが設定されるまでの短い期間に、別のスレッドがfork()を呼び出すと、CLOEXECフラグが設定されていないファイルディスクリプタが子プロセスに継承されてしまう可能性があります。これは、子プロセスが意図しないファイルディスクリプタを保持することになり、予期せぬ動作やリソースリーク、セキュリティ上の問題を引き起こす可能性があります。
  2. 効率性の低下: 2つのシステムコール(pipe()fcntl()を2回)を呼び出すことは、1つのシステムコールで同等の操作を行うよりも、カーネルとユーザー空間間のコンテキストスイッチのオーバーヘッドが大きくなります。

Linuxカーネル2.6.27で導入されたpipe2()システムコールは、これらの問題を解決します。pipe2(pipefd, flags)は、パイプを作成する際に、flags引数を通じてO_CLOEXECなどのフラグをアトミックに設定できます。O_CLOEXECフラグをpipe2()に渡すことで、パイプの読み込み側と書き込み側の両方のファイルディスクリプタに、作成と同時にCLOEXEC属性が設定されます。これにより、競合状態のリスクが完全に排除され、システムコールの呼び出し回数が削減されるため、効率も向上します。

forkExecPipe関数の変更

このコミットでは、syscallパッケージ内のforkExecPipe関数が変更されています。この関数は、ForkExec内で子プロセスとの通信に使用されるパイプを作成する役割を担っています。

  • Linux固有の実装 (exec_linux.go):

    • Pipe2(p, O_CLOEXEC)を最初に試みます。これは、pipe2()システムコールを直接呼び出し、O_CLOEXECフラグを渡すことで、アトミックにCLOEXEC属性を持つパイプを作成しようとします。
    • もしPipe2ENOSYSエラー(システムコールが実装されていない)を返した場合、これは実行中のLinuxカーネルがpipe2()をサポートしていないことを意味します。この場合、従来のフォールバックメカニズムが実行されます。
    • フォールバックメカニズムでは、まずPipe(p)を呼び出してパイプを作成し、その後、fcntl(p[0], F_SETFD, FD_CLOEXEC)fcntl(p[1], F_SETFD, FD_CLOEXEC)を呼び出して、それぞれのファイルディスクリプタにFD_CLOEXECフラグを個別に設定します。このフォールバックにより、古いカーネルバージョンでもGoプログラムが正しく動作することが保証されます。
  • BSD系システムの実装 (exec_bsd.go):

    • BSD系システム(macOSなど)にはpipe2()に相当するシステムコールがないため、引き続き従来のPipe()fcntl()を組み合わせた方法が使用されます。このコミットでは、forkExecPipe関数が新しく導入され、Pipe()と2回のfcntl()呼び出しをカプセル化しています。これにより、コードの重複が避けられ、Linux版とのインターフェースの整合性が保たれます。

zsyscall_linux_*.goファイルの更新

zsyscall_linux_386.go, zsyscall_linux_amd64.go, zsyscall_linux_arm.goといったファイルは、Goのsyscallパッケージが各アーキテクチャのシステムコールを呼び出すための低レベルなラッパーを自動生成する際に使用されます。このコミットでは、pipe2システムコールに対応するエントリがこれらのファイルに追加されています。これにより、Goプログラムからpipe2()システムコールを直接呼び出すことが可能になります。

具体的には、RawSyscall(SYS_PIPE2, ...)を使用して、カーネルのpipe2システムコールを呼び出すためのGoの関数が定義されています。

全体的な影響

この変更により、Go言語で外部プロセスを起動する際のパイプ処理が、Linuxカーネルの新しい機能を利用してより安全かつ効率的になります。特に、競合状態のリスクが排除されることは、信頼性の高いアプリケーションを構築する上で非常に重要です。古いカーネルとの互換性も維持されているため、幅広い環境でこの改善の恩恵を受けることができます。

コアとなるコードの変更箇所

このコミットにおけるコアとなるコードの変更箇所は以下のファイルに集中しています。

  1. src/pkg/syscall/exec_linux.go: Linux固有のforkExecPipe関数の実装。pipe2システムコールを優先的に使用し、フォールバックロジックを含む。
  2. src/pkg/syscall/exec_bsd.go: BSD系システム(macOSなど)向けのforkExecPipe関数の実装。従来のpipefcntlを使用。
  3. src/pkg/syscall/exec_unix.go: forkExec関数内で、従来のパイプ作成ロジックをforkExecPipe関数呼び出しに置き換える。
  4. src/pkg/syscall/syscall_linux.go: Pipe2 Go関数(pipe2システムコールのラッパー)の宣言と実装。
  5. src/pkg/syscall/zsyscall_linux_386.go, src/pkg/syscall/zsyscall_linux_amd64.go, src/pkg/syscall/zsyscall_linux_arm.go: 各Linuxアーキテクチャ向けのpipe2システムコール呼び出しの低レベルな実装(自動生成される部分)。

以下に、特に重要な変更箇所を抜粋して示します。

src/pkg/syscall/exec_linux.go の変更

--- a/src/pkg/syscall/exec_linux.go
+++ b/src/pkg/syscall/exec_linux.go
@@ -233,3 +233,20 @@ childerror:
 	// and this shuts up the compiler.
 	panic("unreached")
 }
+
+// Try to open a pipe with O_CLOEXEC set on both file descriptors.
+func forkExecPipe(p []int) (err error) {
+	err = Pipe2(p, O_CLOEXEC)
+	// pipe2 was added in 2.6.27 and our minimum requirement is 2.6.23, so it
+	// might not be implemented.
+	if err == ENOSYS {
+		if err = Pipe(p); err != nil {
+			return
+		}
+		if _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil {
+			return
+		}
+		_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)
+	}
+	return
+}

src/pkg/syscall/exec_bsd.go の変更

--- a/src/pkg/syscall/exec_bsd.go
+++ b/src/pkg/syscall/exec_bsd.go
@@ -221,3 +221,17 @@ childerror:
 	// and this shuts up the compiler.\n \tpanic(\"unreached\")
 }\n+\n+// Try to open a pipe with O_CLOEXEC set on both file descriptors.
+func forkExecPipe(p []int) error {\n+\terr := Pipe(p)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\t_, err = fcntl(p[0], F_SETFD, FD_CLOEXEC)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\t_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)\n+\treturn err\n+}\n

src/pkg/syscall/exec_unix.go の変更

--- a/src/pkg/syscall/exec_unix.go
+++ b/src/pkg/syscall/exec_unix.go
@@ -183,13 +183,7 @@ func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error)\n \tForkLock.Lock()\n \n \t// Allocate child status pipe close on exec.\n-\tif err = Pipe(p[0:]); err != nil {\n-\t\tgoto error\n-\t}\n-\tif _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil {\n-\t\tgoto error\n-\t}\n-\tif _, err = fcntl(p[1], F_SETFD, FD_CLOEXEC); err != nil {\n+\tif err = forkExecPipe(p[:]); err != nil {\n \t\tgoto error\n \t}\n \n```

### `src/pkg/syscall/syscall_linux.go` の変更

```diff
--- a/src/pkg/syscall/syscall_linux.go
+++ b/src/pkg/syscall/syscall_linux.go
@@ -39,6 +39,18 @@ func Pipe(p []int) (err error) {\n \treturn\n }\n \n+//sysnb pipe2(p *[2]_C_int, flags int) (err error)\n+func Pipe2(p []int, flags int) (err error) {\n+\tif len(p) != 2 {\n+\t\treturn EINVAL\n+\t}\n+\tvar pp [2]_C_int\n+\terr = pipe2(&pp, flags)\n+\tp[0] = int(pp[0])\n+\tp[1] = int(pp[1])\n+\treturn\n+}\n+\n //sys\tutimes(path string, times *[2]Timeval) (err error)\n func Utimes(path string, tv []Timeval) (err error) {\n \tif len(tv) != 2 {\

src/pkg/syscall/zsyscall_linux_386.go (およびamd64, arm) の変更

--- a/src/pkg/syscall/zsyscall_linux_386.go
+++ b/src/pkg/syscall/zsyscall_linux_386.go
@@ -49,6 +49,16 @@ func pipe(p *[2]_C_int) (err error) {\n \n // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT\n \n+func pipe2(p *[2]_C_int, flags int) (err error) {\n+\t_, _, e1 := RawSyscall(SYS_PIPE2, uintptr(unsafe.Pointer(p)), uintptr(flags), 0)\n+\tif e1 != 0 {\n+\t\terr = e1\n+\t}\n+\treturn\n+}\n+\n // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT\n \n func utimes(path string, times *[2]Timeval) (err error) {\

コアとなるコードの解説

src/pkg/syscall/exec_linux.goforkExecPipe 関数

この関数は、Linuxシステムにおけるパイプ作成の主要なロジックを含んでいます。

func forkExecPipe(p []int) (err error) {
	err = Pipe2(p, O_CLOEXEC)
	// pipe2 was added in 2.6.27 and our minimum requirement is 2.6.23, so it
	// might not be implemented.
	if err == ENOSYS {
		if err = Pipe(p); err != nil {
			return
		}
		if _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil {
			return
		}
		_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)
	}
	return
}
  • err = Pipe2(p, O_CLOEXEC): まず、pipe2()システムコールをGoのラッパー関数Pipe2を通じて呼び出します。O_CLOEXECフラグを渡すことで、作成されるパイプの読み込み側と書き込み側の両方のファイルディスクリプタに、アトミックにCLOEXEC属性を設定しようとします。これが成功すれば、競合状態のリスクなしに効率的にパイプが作成されます。
  • if err == ENOSYS: Pipe2の呼び出しがENOSYSエラーを返した場合、これは実行中のLinuxカーネルがpipe2()システムコールをサポートしていないことを意味します(pipe2はLinux 2.6.27で導入され、Goの最小要件は2.6.23であるため、このような状況が発生する可能性があります)。
  • フォールバックロジック: ENOSYSの場合、従来のpipe()fcntl()を組み合わせた方法にフォールバックします。
    • if err = Pipe(p); err != nil: まずpipe()システムコールを呼び出してパイプを作成します。
    • if _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil: パイプの読み込み側ファイルディスクリプタp[0]FD_CLOEXECフラグを設定します。
    • _, err = fcntl(p[1], F_SETFD, FD_CLOEXEC): パイプの書き込み側ファイルディスクリプタp[1]FD_CLOEXECフラグを設定します。
    • このフォールバックにより、古いLinuxカーネルバージョンでもGoプログラムが正しく動作することが保証されます。

src/pkg/syscall/exec_bsd.goforkExecPipe 関数

BSD系システム(macOSなど)ではpipe2()に相当するシステムコールがないため、この関数は従来のpipe()fcntl()を組み合わせた方法をカプセル化しています。

func forkExecPipe(p []int) error {
	err := Pipe(p)
	if err != nil {
		return err
	}
	_, err = fcntl(p[0], F_SETFD, FD_CLOEXEC)
	if err != nil {
		return err
	}
	_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)
	return err
}
  • この関数は、Pipe()でパイプを作成し、その後、fcntl()を2回呼び出して両方のファイルディスクリプタにFD_CLOEXECフラグを設定します。これは、Linux版のフォールバックロジックと本質的に同じです。これにより、異なるOS間でのforkExecPipeのインターフェースの整合性が保たれます。

src/pkg/syscall/exec_unix.goforkExec 関数

このファイルは、Unix系システム全般に共通するforkExec関数のロジックを含んでいます。変更は非常にシンプルで、従来のパイプ作成とfcntl呼び出しの複数の行を、新しく導入されたforkExecPipe関数への単一の呼び出しに置き換えています。

	// Allocate child status pipe close on exec.
	if err = forkExecPipe(p[:]); err != nil {
		goto error
	}
  • これにより、forkExec関数のコードが簡潔になり、パイプ作成のOS固有の詳細はforkExecPipe関数に抽象化されます。

src/pkg/syscall/syscall_linux.goPipe2 関数

このファイルは、Go言語のsyscallパッケージが提供するLinux固有のシステムコールラッパーを定義しています。

//sysnb pipe2(p *[2]_C_int, flags int) (err error)
func Pipe2(p []int, flags int) (err error) {
	if len(p) != 2 {
		return EINVAL
	}
	var pp [2]_C_int
	err = pipe2(&pp, flags)
	p[0] = int(pp[0])
	p[1] = int(pp[1])
	return
}
  • //sysnb pipe2(p *[2]_C_int, flags int) (err error): これはGoのツールチェーンに対する指示で、pipe2という名前のシステムコールを、引数と戻り値の型に基づいてGoの関数として自動生成するように伝えます。sysnbは"system call, no blocking"を意味し、このシステムコールがブロックしないことを示唆します。
  • func Pipe2(p []int, flags int) (err error): ユーザーがGoコードから呼び出すPipe2関数です。
  • if len(p) != 2 { return EINVAL }: 引数pが2つの整数を保持するスライスであることを確認します。そうでない場合、EINVAL(無効な引数)エラーを返します。
  • var pp [2]_C_int: C言語のint型に対応するGoの型_C_intの2要素配列を宣言します。これは、システムコールがCスタイルの配列ポインタを期待するためです。
  • err = pipe2(&pp, flags): 低レベルのpipe2システムコールラッパー(後述のzsyscall_linux_*.goで定義される)を呼び出します。
  • p[0] = int(pp[0])p[1] = int(pp[1]): システムコールから返されたCスタイルの配列の値を、Goの[]intスライスにコピーします。

src/pkg/syscall/zsyscall_linux_386.go (およびamd64, arm) の pipe2 関数

これらのファイルは、Goのビルドプロセスによって自動生成される、特定のアーキテクチャ向けの低レベルなシステムコール呼び出しラッパーを含んでいます。

func pipe2(p *[2]_C_int, flags int) (err error) {
	_, _, e1 := RawSyscall(SYS_PIPE2, uintptr(unsafe.Pointer(p)), uintptr(flags), 0)
	if e1 != 0 {
		err = e1
	}
	return
}
  • RawSyscall(SYS_PIPE2, ...): これはGoのsyscallパッケージの最も低レベルな関数で、直接カーネルのシステムコールを呼び出します。
    • SYS_PIPE2: pipe2システムコールに対応するシステムコール番号です。
    • uintptr(unsafe.Pointer(p)): パイプのファイルディスクリプタを格納する配列pのアドレスを、システムコールが期待するuintptr型に変換して渡します。
    • uintptr(flags): flags引数を渡します。
  • e1: システムコールがエラーを返した場合、そのエラーコードがe1に格納されます。
  • if e1 != 0 { err = e1 }: エラーがあれば、それをGoのエラーとして返します。

これらの変更により、Go言語のsyscallパッケージは、Linuxの最新のシステムコール機能を活用し、より安全で効率的なプロセス間通信を実現できるようになりました。同時に、古いカーネルとの互換性も維持されています。

関連リンク

参考にした情報源リンク

[インデックス 14852] ファイルの概要

このコミットは、Go言語のsyscallパッケージにおいて、LinuxシステムコールPipe2の実装を追加し、それを用いてForkExec関数におけるパイプの作成処理を改善するものです。これにより、パイプ作成時のファイルディスクリプタのクローズオンエグゼック(close-on-exec)フラグの設定をより効率的かつアトミックに行えるようになり、競合状態のリスクを低減します。

コミット

commit e32d1154ec3176a81659720c1ca665e68ac95c42
Author: Georg Reinke <guelfey@gmail.com>
Date:   Thu Jan 10 17:04:55 2013 -0800

    syscall: implement Pipe2 on Linux and use it in ForkExec
    
    Fixes #2656.
    
    R=golang-dev, bradfitz, iant, minux.ma
    CC=golang-dev
    https://golang.org/cl/7062057

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/e32d1154ec3176a81659720c1ca665e68ac95c42

元コミット内容

syscall: implement Pipe2 on Linux and use it in ForkExec

このコミットは、Linux上でPipe2システムコールを実装し、ForkExec関数内でそれを使用するように変更します。これにより、Issue #2656で報告された問題が修正されます。

変更の背景

この変更の主な背景は、Go言語のsyscallパッケージがプロセスをフォークして新しいプログラムを実行する際に使用するForkExec関数における、パイプ作成の堅牢性と効率性の向上です。

従来のUnix系システムでは、プロセス間通信のためにパイプを作成する際には、まずpipe()システムコールを呼び出して2つのファイルディスクリプタ(読み込み側と書き込み側)を取得します。その後、これらのファイルディスクリプタが子プロセスに意図せず継承されるのを防ぐため、fcntl()システムコールとFD_CLOEXECフラグを使用して、それぞれのファイルディスクリプタに「クローズオンエグゼック(close-on-exec)」属性を設定する必要がありました。

この2段階の操作には、以下のような問題点がありました。

  1. 競合状態(Race Condition): pipe()呼び出しとfcntl()呼び出しの間に、別のスレッドがfork()を呼び出すと、FD_CLOEXECフラグが設定されていないパイプのファイルディスクリプタが子プロセスに継承されてしまう可能性がありました。これは、子プロセスが予期せぬファイルディスクリプタを保持することになり、セキュリティ上の脆弱性やデッドロックの原因となる可能性があります。
  2. 効率性: 2つのシステムコールを連続して呼び出すことは、1つのシステムコールで同等の操作を行うよりもオーバーヘッドが大きくなります。

Linuxカーネル2.6.27以降で導入されたpipe2()システムコールは、これらの問題を解決するために設計されました。pipe2()は、パイプを作成する際に、同時にO_CLOEXECなどのフラグをアトミックに設定できる機能を提供します。これにより、競合状態のリスクを排除し、システムコールの呼び出し回数を減らすことで効率を向上させることができます。

このコミットは、Go言語のsyscallパッケージがLinuxの新しい機能を利用できるようにすることで、より堅牢で効率的なプロセス管理を実現することを目的としています。特に、Issue #2656で報告された問題の修正が明記されており、これはおそらく従来のパイプ作成方法に起因するバグや不安定性に関連していると考えられます。

前提知識の解説

このコミットを理解するためには、以下の概念について理解しておく必要があります。

1. パイプ (Pipe)

パイプは、Unix/Linuxシステムにおけるプロセス間通信(IPC: Inter-Process Communication)の最も基本的なメカニズムの一つです。単方向のデータフローを提供し、一方のプロセスがパイプの書き込み端にデータを書き込み、もう一方のプロセスがパイプの読み込み端からデータを読み取ります。

  • pipe()システムコール:
    • int pipe(int pipefd[2]);
    • このシステムコールは、2つの新しいファイルディスクリプタを作成し、pipefd[0]に読み込み端、pipefd[1]に書き込み端を格納します。
    • 成功すると0を返し、失敗すると-1を返します。

2. ファイルディスクリプタ (File Descriptor, FD)

ファイルディスクリプタは、Unix/Linuxシステムにおいて、開かれたファイルやソケット、パイプなどのI/Oリソースを識別するためにカーネルがプロセスに割り当てる非負の整数です。

3. クローズオンエグゼック (Close-on-exec, CLOEXEC)

CLOEXECは、ファイルディスクリプタに設定できるフラグの一つです。このフラグが設定されたファイルディスクリプタは、execve()(または関連するexecファミリーの関数)システムコールによって新しいプログラムが実行される際に、自動的に閉じられます。

  • なぜ必要か?:
    • 子プロセスが親プロセスから不要なファイルディスクリプタを継承するのを防ぎます。
    • これにより、子プロセスが親プロセスのリソースに意図せずアクセスしたり、デッドロックを引き起こしたりするのを防ぎ、セキュリティと堅牢性を向上させます。
    • 特に、fork()exec()を組み合わせて新しいプロセスを起動する際に重要です。fork()は親プロセスのファイルディスクリプタをすべて子プロセスにコピーしますが、exec()の前にCLOEXECを設定することで、子プロセスが不要なFDを保持するのを防ぎます。

4. fcntl()システムコール

fcntl()(file control)システムコールは、開かれたファイルディスクリプタの属性を操作するために使用されます。

  • int fcntl(int fd, int cmd, ... /* arg */ );
  • cmd引数には様々な操作を指定できます。
    • F_SETFD: ファイルディスクリプタフラグを設定します。
    • FD_CLOEXEC: F_SETFDと共に使用され、ファイルディスクリプタにCLOEXECフラグを設定します。

5. pipe2()システムコール

pipe2()はLinux固有のシステムコールで、pipe()の拡張版です。

  • int pipe2(int pipefd[2], int flags);
  • pipe()と同様に2つのファイルディスクリプタを作成しますが、flags引数によってパイプ作成時に追加の属性をアトミックに設定できます。
  • O_CLOEXECフラグ: pipe2()flags引数にO_CLOEXECを指定することで、作成される両方のファイルディスクリプタにCLOEXECフラグをアトミックに設定できます。これにより、pipe()fcntl()を別々に呼び出す必要がなくなり、競合状態を回避できます。
  • 導入時期: Linuxカーネル2.6.27で導入されました。

6. ForkExec関数 (Go言語のsyscallパッケージ)

Go言語のsyscallパッケージは、オペレーティングシステムの低レベルなシステムコールへのインターフェースを提供します。ForkExec関数は、Unix系システムにおいて、新しいプロセスをフォークし、その子プロセスで指定されたプログラムを実行するために使用されるGo言語の関数です。内部的にはfork()execve()システムコールを組み合わせて利用します。

7. ENOSYSエラー

ENOSYSは、システムコールが実装されていないことを示すエラーコードです。このコミットの文脈では、古いLinuxカーネルでpipe2()が利用できない場合に返される可能性があります。

これらの前提知識を理解することで、このコミットがなぜ重要であり、どのような技術的課題を解決しようとしているのかが明確になります。

技術的詳細

このコミットの技術的詳細は、主にLinuxシステムコールpipe2()の導入と、それを用いたForkExecにおけるパイプ作成処理の改善に集約されます。

pipe2()の導入と利点

従来のpipe()システムコールでは、パイプを作成した後に、fcntl()システムコールを使って個別にファイルディスクリプタにFD_CLOEXECフラグを設定する必要がありました。この2段階の操作は、以下のような問題を引き起こす可能性がありました。

  1. 競合状態の発生: pipe()が呼び出されてファイルディスクリプタが作成された直後から、fcntl()FD_CLOEXECが設定されるまでの短い期間に、別のスレッドがfork()を呼び出すと、CLOEXECフラグが設定されていないファイルディスクリプタが子プロセスに継承されてしまう可能性があります。これは、子プロセスが意図しないファイルディスクリプタを保持することになり、予期せぬ動作やリソースリーク、セキュリティ上の問題を引き起こす可能性があります。
  2. 効率性の低下: 2つのシステムコール(pipe()fcntl()を2回)を呼び出すことは、1つのシステムコールで同等の操作を行うよりも、カーネルとユーザー空間間のコンテキストスイッチのオーバーヘッドが大きくなります。

Linuxカーネル2.6.27で導入されたpipe2()システムコールは、これらの問題を解決します。pipe2(pipefd, flags)は、パイプを作成する際に、flags引数を通じてO_CLOEXECなどのフラグをアトミックに設定できます。O_CLOEXECフラグをpipe2()に渡すことで、パイプの読み込み側と書き込み側の両方のファイルディスクリプタに、作成と同時にCLOEXEC属性が設定されます。これにより、競合状態のリスクが完全に排除され、システムコールの呼び出し回数が削減されるため、効率も向上します。

forkExecPipe関数の変更

このコミットでは、syscallパッケージ内のforkExecPipe関数が変更されています。この関数は、ForkExec内で子プロセスとの通信に使用されるパイプを作成する役割を担っています。

  • Linux固有の実装 (exec_linux.go):

    • Pipe2(p, O_CLOEXEC)を最初に試みます。これは、pipe2()システムコールを直接呼び出し、O_CLOEXECフラグを渡すことで、アトミックにCLOEXEC属性を持つパイプを作成しようとします。
    • もしPipe2ENOSYSエラー(システムコールが実装されていない)を返した場合、これは実行中のLinuxカーネルがpipe2()をサポートしていないことを意味します。この場合、従来のフォールバックメカニズムが実行されます。
    • フォールバックメカニズムでは、まずPipe(p)を呼び出してパイプを作成し、その後、fcntl(p[0], F_SETFD, FD_CLOEXEC)fcntl(p[1], F_SETFD, FD_CLOEXEC)を呼び出して、それぞれのファイルディスクリプタにFD_CLOEXECフラグを個別に設定します。このフォールバックにより、古いカーネルバージョンでもGoプログラムが正しく動作することが保証されます。
  • BSD系システムの実装 (exec_bsd.go):

    • BSD系システム(macOSなど)にはpipe2()に相当するシステムコールがないため、引き続き従来のPipe()fcntl()を組み合わせた方法が使用されます。このコミットでは、forkExecPipe関数が新しく導入され、Pipe()と2回のfcntl()呼び出しをカプセル化しています。これにより、コードの重複が避けられ、Linux版とのインターフェースの整合性が保たれます。

zsyscall_linux_*.goファイルの更新

zsyscall_linux_386.go, zsyscall_linux_amd64.go, zsyscall_linux_arm.goといったファイルは、Goのsyscallパッケージが各アーキテクチャのシステムコールを呼び出すための低レベルなラッパーを自動生成する際に使用されます。このコミットでは、pipe2システムコールに対応するエントリがこれらのファイルに追加されています。これにより、Goプログラムからpipe2()システムコールを直接呼び出すことが可能になります。

具体的には、RawSyscall(SYS_PIPE2, ...)を使用して、カーネルのpipe2システムコールを呼び出すためのGoの関数が定義されています。

全体的な影響

この変更により、Go言語で外部プロセスを起動する際のパイプ処理が、Linuxカーネルの新しい機能を利用してより安全かつ効率的になります。特に、競合状態のリスクが排除されることは、信頼性の高いアプリケーションを構築する上で非常に重要です。古いカーネルとの互換性も維持されているため、幅広い環境でこの改善の恩恵を受けることができます。

コアとなるコードの変更箇所

このコミットにおけるコアとなるコードの変更箇所は以下のファイルに集中しています。

  1. src/pkg/syscall/exec_linux.go: Linux固有のforkExecPipe関数の実装。pipe2システムコールを優先的に使用し、フォールバックロジックを含む。
  2. src/pkg/syscall/exec_bsd.go: BSD系システム(macOSなど)向けのforkExecPipe関数の実装。従来のpipefcntlを使用。
  3. src/pkg/syscall/exec_unix.go: forkExec関数内で、従来のパイプ作成ロジックをforkExecPipe関数呼び出しに置き換える。
  4. src/pkg/syscall/syscall_linux.go: Pipe2 Go関数(pipe2システムコールのラッパー)の宣言と実装。
  5. src/pkg/syscall/zsyscall_linux_386.go, src/pkg/syscall/zsyscall_linux_amd64.go, src/pkg/syscall/zsyscall_linux_arm.go: 各Linuxアーキテクチャ向けのpipe2システムコール呼び出しの低レベルな実装(自動生成される部分)。

以下に、特に重要な変更箇所を抜粋して示します。

src/pkg/syscall/exec_linux.go の変更

--- a/src/pkg/syscall/exec_linux.go
+++ b/src/pkg/syscall/exec_linux.go
@@ -233,3 +233,20 @@ childerror:
 	// and this shuts up the compiler.
 	panic("unreached")
 }
+
+// Try to open a pipe with O_CLOEXEC set on both file descriptors.
+func forkExecPipe(p []int) (err error) {
+	err = Pipe2(p, O_CLOEXEC)
+	// pipe2 was added in 2.6.27 and our minimum requirement is 2.6.23, so it
+	// might not be implemented.
+	if err == ENOSYS {
+		if err = Pipe(p); err != nil {
+			return
+		}
+		if _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil {
+			return
+		}
+		_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)
+	}
+	return
+}

src/pkg/syscall/exec_bsd.go の変更

--- a/src/pkg/syscall/exec_bsd.go
+++ b/src/pkg/syscall/exec_bsd.go
@@ -221,3 +221,17 @@ childerror:
 	// and this shuts up the compiler.\n \tpanic(\"unreached\")
 }\n+\n+// Try to open a pipe with O_CLOEXEC set on both file descriptors.
+func forkExecPipe(p []int) error {\n+\terr := Pipe(p)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\t_, err = fcntl(p[0], F_SETFD, FD_CLOEXEC)\n+\tif err != nil {\n+\t\treturn err\n+\t}\n+\t_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)\n+\treturn err\n+}\n

src/pkg/syscall/exec_unix.go の変更

--- a/src/pkg/syscall/exec_unix.go
+++ b/src/pkg/syscall/exec_unix.go
@@ -183,13 +183,7 @@ func forkExec(argv0 string, argv []string, attr *ProcAttr) (pid int, err error)\n \tForkLock.Lock()\n \n \t// Allocate child status pipe close on exec.\n-\tif err = Pipe(p[0:]); err != nil {\n-\t\tgoto error\n-\t}\n-\tif _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil {\n-\t\tgoto error\n-\t}\n-\tif _, err = fcntl(p[1], F_SETFD, FD_CLOEXEC); err != nil {\n+\tif err = forkExecPipe(p[:]); err != nil {\n \t\tgoto error\n \t}\n \n```

### `src/pkg/syscall/syscall_linux.go` の変更

```diff
--- a/src/pkg/syscall/syscall_linux.go
+++ b/src/pkg/syscall/syscall_linux.go
@@ -39,6 +39,18 @@ func Pipe(p []int) (err error) {\n \treturn\n }\n \n+//sysnb pipe2(p *[2]_C_int, flags int) (err error)\n+func Pipe2(p []int, flags int) (err error) {\n+\tif len(p) != 2 {\n+\t\treturn EINVAL\n+\t}\n+\tvar pp [2]_C_int\n+\terr = pipe2(&pp, flags)\n+\tp[0] = int(pp[0])\n+\tp[1] = int(pp[1])\n+\treturn\n+}\n+\n //sys\tutimes(path string, times *[2]Timeval) (err error)\n func Utimes(path string, tv []Timeval) (err error) {\n \tif len(tv) != 2 {\

src/pkg/syscall/zsyscall_linux_386.go (およびamd64, arm) の変更

--- a/src/pkg/syscall/zsyscall_linux_386.go
+++ b/src/pkg/syscall/zsyscall_linux_386.go
@@ -49,6 +49,16 @@ func pipe(p *[2]_C_int) (err error) {\n \n // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT\n \n+func pipe2(p *[2]_C_int, flags int) (err error) {\n+\t_, _, e1 := RawSyscall(SYS_PIPE2, uintptr(unsafe.Pointer(p)), uintptr(flags), 0)\n+\tif e1 != 0 {\n+\t\terr = e1\n+\t}\n+\treturn\n+}\n+\n // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT\n \n func utimes(path string, times *[2]Timeval) (err error) {\

コアとなるコードの解説

src/pkg/syscall/exec_linux.goforkExecPipe 関数

この関数は、Linuxシステムにおけるパイプ作成の主要なロジックを含んでいます。

func forkExecPipe(p []int) (err error) {
	err = Pipe2(p, O_CLOEXEC)
	// pipe2 was added in 2.6.27 and our minimum requirement is 2.6.23, so it
	// might not be implemented.
	if err == ENOSYS {
		if err = Pipe(p); err != nil {
			return
		}
		if _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil {
			return
		}
		_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)
	}
	return
}
  • err = Pipe2(p, O_CLOEXEC): まず、pipe2()システムコールをGoのラッパー関数Pipe2を通じて呼び出します。O_CLOEXECフラグを渡すことで、作成されるパイプの読み込み側と書き込み側の両方のファイルディスクリプタに、アトミックにCLOEXEC属性を設定しようとします。これが成功すれば、競合状態のリスクなしに効率的にパイプが作成されます。
  • if err == ENOSYS: Pipe2の呼び出しがENOSYSエラーを返した場合、これは実行中のLinuxカーネルがpipe2()システムコールをサポートしていないことを意味します(pipe2はLinux 2.6.27で導入され、Goの最小要件は2.6.23であるため、このような状況が発生する可能性があります)。
  • フォールバックロジック: ENOSYSの場合、従来のpipe()fcntl()を組み合わせた方法にフォールバックします。
    • if err = Pipe(p); err != nil: まずpipe()システムコールを呼び出してパイプを作成します。
    • if _, err = fcntl(p[0], F_SETFD, FD_CLOEXEC); err != nil: パイプの読み込み側ファイルディスクリプタp[0]FD_CLOEXECフラグを設定します。
    • _, err = fcntl(p[1], F_SETFD, FD_CLOEXEC): パイプの書き込み側ファイルディスクリプタp[1]FD_CLOEXECフラグを設定します。
    • このフォールバックにより、古いLinuxカーネルバージョンでもGoプログラムが正しく動作することが保証されます。

src/pkg/syscall/exec_bsd.goforkExecPipe 関数

BSD系システム(macOSなど)ではpipe2()に相当するシステムコールがないため、この関数は従来のpipe()fcntl()を組み合わせた方法をカプセル化しています。

func forkExecPipe(p []int) error {
	err := Pipe(p)
	if err != nil {
		return err
	}
	_, err = fcntl(p[0], F_SETFD, FD_CLOEXEC)
	if err != nil {
		return err
	}
	_, err = fcntl(p[1], F_SETFD, FD_CLOEXEC)
	return err
}
  • この関数は、Pipe()でパイプを作成し、その後、fcntl()を2回呼び出して両方のファイルディスクリプタにFD_CLOEXECフラグを設定します。これは、Linux版のフォールバックロジックと本質的に同じです。これにより、異なるOS間でのforkExecPipeのインターフェースの整合性が保たれます。

src/pkg/syscall/exec_unix.goforkExec 関数

このファイルは、Unix系システム全般に共通するforkExec関数のロジックを含んでいます。変更は非常にシンプルで、従来のパイプ作成とfcntl呼び出しの複数の行を、新しく導入されたforkExecPipe関数への単一の呼び出しに置き換えています。

	// Allocate child status pipe close on exec.
	if err = forkExecPipe(p[:]); err != nil {
		goto error
	}
  • これにより、forkExec関数のコードが簡潔になり、パイプ作成のOS固有の詳細はforkExecPipe関数に抽象化されます。

src/pkg/syscall/syscall_linux.goPipe2 関数

このファイルは、Go言語のsyscallパッケージが提供するLinux固有のシステムコールラッパーを定義しています。

//sysnb pipe2(p *[2]_C_int, flags int) (err error)
func Pipe2(p []int, flags int) (err error) {
	if len(p) != 2 {
		return EINVAL
	}
	var pp [2]_C_int
	err = pipe2(&pp, flags)
	p[0] = int(pp[0])
	p[1] = int(pp[1])
	return
}
  • //sysnb pipe2(p *[2]_C_int, flags int) (err error): これはGoのツールチェーンに対する指示で、pipe2という名前のシステムコールを、引数と戻り値の型に基づいてGoの関数として自動生成するように伝えます。sysnbは"system call, no blocking"を意味し、このシステムコールがブロックしないことを示唆します。
  • func Pipe2(p []int, flags int) (err error): ユーザーがGoコードから呼び出すPipe2関数です。
  • if len(p) != 2 { return EINVAL }: 引数pが2つの整数を保持するスライスであることを確認します。そうでない場合、EINVAL(無効な引数)エラーを返します。
  • var pp [2]_C_int: C言語のint型に対応するGoの型_C_intの2要素配列を宣言します。これは、システムコールがCスタイルの配列ポインタを期待するためです。
  • err = pipe2(&pp, flags): 低レベルのpipe2システムコールラッパー(後述のzsyscall_linux_*.goで定義される)を呼び出します。
  • p[0] = int(pp[0])p[1] = int(pp[1]): システムコールから返されたCスタイルの配列の値を、Goの[]intスライスにコピーします。

src/pkg/syscall/zsyscall_linux_386.go (およびamd64, arm) の pipe2 関数

これらのファイルは、Goのビルドプロセスによって自動生成される、特定のアーキテクチャ向けの低レベルなシステムコール呼び出しラッパーを含んでいます。

func pipe2(p *[2]_C_int, flags int) (err error) {
	_, _, e1 := RawSyscall(SYS_PIPE2, uintptr(unsafe.Pointer(p)), uintptr(flags), 0)
	if e1 != 0 {
		err = e1
	}
	return
}
  • RawSyscall(SYS_PIPE2, ...): これはGoのsyscallパッケージの最も低レベルな関数で、直接カーネルのシステムコールを呼び出します。
    • SYS_PIPE2: pipe2システムコールに対応するシステムコール番号です。
    • uintptr(unsafe.Pointer(p)): パイプのファイルディスクリプタを格納する配列pのアドレスを、システムコールが期待するuintptr型に変換して渡します。
    • uintptr(flags): flags引数を渡します。
  • e1: システムコールがエラーを返した場合、そのエラーコードがe1に格納されます。
  • if e1 != 0 { err = e1 }: エラーがあれば、それをGoのエラーとして返します。

これらの変更により、Go言語のsyscallパッケージは、Linuxの最新のシステムコール機能を活用し、より安全で効率的なプロセス間通信を実現できるようになりました。同時に、古いカーネルとの互換性も維持されています。

関連リンク

参考にした情報源リンク