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

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

このコミットは、Go言語の標準ライブラリnetパッケージにおけるAcceptシステムコールとsyscall.ForkLockの相互作用に関する潜在的なデッドロック問題を解決します。具体的には、Acceptがブロッキングモードになった場合にForkLockを保持し続けることで、fork+exec操作が不可能になるリスクを排除するための変更です。

コミット

commit 18441e8adeab78b32507fefc84be495873928f8c
Author: Russ Cox <rsc@golang.org>
Date:   Thu Feb 7 22:45:12 2013 -0500

    net: do not use RLock around Accept
    
    It might be non-blocking, but it also might be blocking.
    Cannot take the chance, as Accept might block indefinitely
    and make it impossible to acquire ForkLock exclusively
    (during fork+exec).
    
    Fixes #4737.
    
    R=golang-dev, dave, iant, mikioh.mikioh
    CC=golang-dev
    https://golang.org/cl/7309050

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

https://github.com/golang/go/commit/18441e8adeab78b32507fefc84be495873928f8c

元コミット内容

net: do not use RLock around Accept

Accept呼び出しの周囲でRLockを使用しないようにする。

Acceptはノンブロッキングであるかもしれないが、ブロッキングである可能性もある。 Acceptが無限にブロックし、ForkLockを排他的に取得することを不可能にする(fork+exec中)リスクを冒すことはできない。

Fixes #4737.

変更の背景

このコミットは、Go言語のnetパッケージがソケットのAccept操作を行う際に発生しうるデッドロックの問題を解決するために導入されました。具体的には、Goプログラムが新しいプロセスをforkしてexecする際に使用されるsyscall.ForkLockと、ネットワーク接続を受け入れるsyscall.Acceptシステムコールの間の競合状態が問題でした。

以前の実装では、syscall.Acceptの呼び出しの前後でsyscall.ForkLockの読み取りロック(RLock)が取得されていました。これは、Acceptがノンブロッキングモードで動作することを前提としていました。しかし、Goのnetパッケージは、ファイルディスクリプタ(FD)がFileメソッドを介してos.Fileに変換されると、そのFDがブロッキングモードに戻される可能性があります。もしAcceptがブロッキングモードのFDに対して呼び出され、かつ接続が来るまで無限にブロックした場合、ForkLockの読み取りロックが解放されなくなります。

syscall.ForkLockは、fork+exec操作中に排他的な書き込みロック(Lock)を必要とします。もしAcceptが読み取りロックを保持したままブロックすると、fork+exec操作がForkLockの書き込みロックを取得できなくなり、結果としてプログラム全体がデッドロックに陥る可能性がありました。これは、特にサーバーアプリケーションで、新しいプロセスを起動する(例えば、外部コマンドを実行する)際に問題となります。

この問題はGo issue #4737として報告され、このコミットはその修正として提案されました。

前提知識の解説

このコミットの理解には、以下のGo言語およびUnixシステムプログラミングの概念が不可欠です。

  1. syscall.Accept: Unix系システムにおけるaccept(2)システムコールのGo言語ラッパーです。これは、ソケット上で保留中の接続を受け入れ、新しいソケットディスクリプタを返します。この新しいディスクリプタは、確立された接続を介した通信に使用されます。acceptはデフォルトでブロッキング操作であり、接続が来るまで呼び出し元をブロックします。

  2. ファイルディスクリプタ (File Descriptor, FD): Unix系システムにおいて、ファイルやソケットなどのI/Oリソースを識別するために使用される整数値です。FDは、ブロッキングモードまたはノンブロッキングモードで設定できます。

    • ブロッキングモード: I/O操作(読み取り、書き込み、接続受け入れなど)が完了するまで、呼び出し元のスレッドをブロックします。
    • ノンブロッキングモード: I/O操作がすぐに完了しない場合、エラー(例: EAGAINEWOULDBLOCK)を返してすぐに戻ります。これにより、単一のスレッドで複数のI/O操作を同時に管理できます(通常はepoll, kqueue, selectなどのI/O多重化メカニズムと組み合わせて使用)。
  3. syscall.ForkLock: Go言語のsyscallパッケージで提供されるグローバルなsync.RWMutex(読み書きミューテックス)です。これは、fork(2)システムコールが安全に実行されることを保証するために使用されます。forkは、現在のプロセスのメモリ空間を複製して新しいプロセスを作成しますが、この際にミューテックスなどの共有リソースの状態が不整合になる可能性があります。

    • forkを呼び出す前には、ForkLockの書き込みロック(Lock())が取得されます。これにより、他のゴルーチンがForkLockの読み取りロックや書き込みロックを取得できなくなり、fork中に共有リソースが変更されるのを防ぎます。
    • execを呼び出す前には、ForkLockの書き込みロックが解放されます。
    • ForkLockの読み取りロック(RLock())は、fork中に問題を引き起こす可能性のあるシステムコール(例えば、ファイルディスクリプタを操作するシステムコール)を呼び出す際に取得されることがあります。これは、forkが進行中にこれらのシステムコールが呼び出されるのを防ぐためです。
  4. fork+exec: Unix系システムで新しいプログラムを実行するための一般的なパターンです。

    • fork(): 現在のプロセスのコピーを作成します。子プロセスは親プロセスのメモリ空間、ファイルディスクリプタなどを継承します。
    • exec(): 現在のプロセスを新しいプログラムで置き換えます。これにより、子プロセスは新しいプログラムを実行します。 この操作は、Go言語でos/execパッケージを使用して外部コマンドを実行する際などに内部的に使用されます。
  5. CloseOnExec: ファイルディスクリプタに設定できるフラグの一つで、execシステムコールが実行されたときにそのFDが自動的に閉じられるようにします。これにより、子プロセスに不要なFDが継承されるのを防ぎ、リソースリークやセキュリティリスクを軽減します。

技術的詳細

このコミットの核心は、syscall.Acceptの呼び出しがブロッキングモードになる可能性を考慮し、その周囲からsyscall.ForkLockの読み取りロック(RLock/RUnlock)を削除することです。

Goのnetパッケージは、通常、ソケットをノンブロッキングモードで設定し、I/O多重化(epollなど)を使用して効率的に多数の接続を処理します。しかし、netFD構造体のdup()メソッドや、ソケットのファイルディスクリプタをos.Fileに変換するFile()メソッドが呼び出されると、そのFDはブロッキングモードに戻される可能性があります。

元のコードでは、syscall.Acceptがノンブロッキングであるという「おそらく」という前提のもとでForkLock.RLock()が使用されていました。しかし、もし何らかの理由でFDがブロッキングモードに戻り、かつAcceptが接続を待って無限にブロックした場合、ForkLockの読み取りロックが解放されません。

syscall.ForkLockは、fork+exec操作中に排他的な書き込みロック(Lock())を必要とします。もしAcceptが読み取りロックを保持したままブロックすると、fork+exec操作は書き込みロックを取得できず、デッドロックが発生します。これは、Goプログラムが外部コマンドを実行しようとしたときに、ネットワーク接続の受け入れがブロックされているためにハングアップするという形で現れます。

このコミットでは、このリスクを完全に排除するために、syscall.Acceptの呼び出しの前後からForkLock.RLock()ForkLock.RUnlock()を削除します。これにより、Acceptがブロッキングモードで動作しても、ForkLockを保持し続けることはなくなり、fork+exec操作がブロックされる可能性がなくなります。

この変更の副作用として、fd_unix.godup()メソッド内のコメントが更新されています。syscall.SetNonblock(ns, false)(ノンブロッキングを解除し、ブロッキングモードに戻す)が呼び出されると、古いFDもブロッキングモードになることが明記されています。これにより、I/Oがスレッドをブロックするようになりますが、epollサーバーを使用しないだけで、機能的には問題なく動作します。これは、AcceptForkLockを保持しないことのトレードオフとして許容されます。

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

このコミットでは、以下の3つのファイルが変更されています。

  1. src/pkg/net/fd_unix.go
  2. src/pkg/net/sock_cloexec.go
  3. src/pkg/net/sys_cloexec.go

src/pkg/net/fd_unix.go

--- a/src/pkg/net/fd_unix.go
+++ b/src/pkg/net/fd_unix.go
@@ -661,6 +661,9 @@ func (fd *netFD) dup() (f *os.File, err error) {
 	syscall.ForkLock.RUnlock()
 
 	// We want blocking mode for the new fd, hence the double negative.
+	// This also puts the old fd into blocking mode, meaning that
+	// I/O will block the thread instead of letting us use the epoll server.
+	// Everything will still work, just with more threads.
 	if err = syscall.SetNonblock(ns, false); err != nil {
 		return nil, &OpError{"setnonblock", fd.net, fd.laddr, err}
 	}

src/pkg/net/sock_cloexec.go

--- a/src/pkg/net/sock_cloexec.go
+++ b/src/pkg/net/sock_cloexec.go
@@ -50,14 +50,14 @@ func accept(fd int) (int, syscall.Sockaddr, error) {
 	}
 
 	// See ../syscall/exec_unix.go for description of ForkLock.
-	// It is okay to hold the lock across syscall.Accept
+	// It is probably okay to hold the lock across syscall.Accept
 	// because we have put fd.sysfd into non-blocking mode.
-	syscall.ForkLock.RLock()
+	// However, a call to the File method will put it back into
+	// blocking mode. We can't take that risk, so no use of ForkLock here.
 	nfd, sa, err = syscall.Accept(fd)
 	if err == nil {
 		syscall.CloseOnExec(nfd)
 	}
-	syscall.ForkLock.RUnlock()
 	if err != nil {
 		return -1, nil, err
 	}

src/pkg/net/sys_cloexec.go

--- a/src/pkg/net/sys_cloexec.go
+++ b/src/pkg/net/sys_cloexec.go
@@ -35,14 +35,14 @@ func sysSocket(f, t, p int) (int, error) {\n // descriptor as nonblocking and close-on-exec.\n func accept(fd int) (int, syscall.Sockaddr, error) {\n 	// See ../syscall/exec_unix.go for description of ForkLock.\n-	// It is okay to hold the lock across syscall.Accept\n+	// It is probably okay to hold the lock across syscall.Accept\n 	// because we have put fd.sysfd into non-blocking mode.\n-	syscall.ForkLock.RLock()
+	// However, a call to the File method will put it back into\n+	// blocking mode. We can't take that risk, so no use of ForkLock here.\n 	nfd, sa, err := syscall.Accept(fd)
 	if err == nil {\n 		syscall.CloseOnExec(nfd)\n 	}\n-	syscall.ForkLock.RUnlock()
 	if err != nil {\n 		return -1, nil, err\n 	}\

コアとなるコードの解説

src/pkg/net/fd_unix.go の変更

netFD.dup() メソッドは、既存のネットワークファイルディスクリプタを複製し、新しいos.Fileを返す役割を担っています。このメソッド内で、複製されたFDをブロッキングモードに設定するsyscall.SetNonblock(ns, false)の呼び出しがあります。

変更点:

  • コメントが追加されました。このコメントは、syscall.SetNonblock(ns, false)が新しいFDだけでなく、元のFDもブロッキングモードに戻す可能性があることを明確にしています。これにより、I/O操作がepollなどのノンブロッキングI/O多重化メカニズムではなく、スレッドをブロックするようになることを説明しています。これは、AcceptForkLockを保持しないようにするためのトレードオフとして許容される動作です。

src/pkg/net/sock_cloexec.go および src/pkg/net/sys_cloexec.go の変更

これら2つのファイルは、それぞれ異なるコンテキスト(sock_cloexec.goはソケット操作全般、sys_cloexec.goはシステムコールレベルの操作)でaccept関数を定義していますが、本質的に同じ変更が加えられています。

変更点:

  • syscall.ForkLock.RLock() の呼び出しが削除されました。
  • syscall.ForkLock.RUnlock() の呼び出しが削除されました。
  • コメントが更新されました。以前のコメントでは「fd.sysfdをノンブロッキングモードに設定しているので、syscall.Acceptの前後でロックを保持しても問題ない」とされていましたが、新しいコメントでは「しかし、Fileメソッドの呼び出しにより、ブロッキングモードに戻る可能性がある。そのリスクは冒せないので、ここではForkLockを使用しない」と変更されています。

これらの変更により、syscall.Acceptがブロッキングモードで動作した場合でも、syscall.ForkLockを保持し続けることがなくなり、fork+exec操作がデッドロックする可能性が排除されました。これは、Goのネットワークスタックの堅牢性を高める重要な修正です。

関連リンク

参考にした情報源リンク

  • Go issue #4737の議論
  • Go言語のsyscallパッケージのドキュメント
  • Unix accept(2) manページ
  • Unix fork(2) manページ
  • Unix exec(3) manページ
  • Go言語のsync.RWMutexのドキュメント
  • Go言語のnetパッケージのソースコード
  • Go言語のos/execパッケージのソースコード
  • Go言語のsyscall/exec_unix.goのソースコード