[インデックス 15866] ファイルの概要
このコミットは、Goランタイムにおけるネットワークポーリングメカニズム、特にLinuxのepoll
システムコールを使用する部分の重要なバグ修正を目的としています。具体的には、ファイルディスクリプタ(FD)が閉じられる際に、epoll
の監視セットから明示的に削除されないことによって発生する問題に対処しています。これにより、既に閉じられたFDに対するカーネルからの通知や、FDの再利用時にEEXIST
エラーが発生する可能性がありました。この変更は、close()
が呼び出される前にepoll
セットからFDを明示的に削除することで、これらの問題を解決します。
コミット
commit 44840786ae2a7a24d81df176494e0af5ba9764c4
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Thu Mar 21 12:54:19 2013 +0400
runtime: explicitly remove fd's from epoll waitset before close()
Fixes #5061.
Current code relies on the fact that fd's are automatically removed from epoll set when closed. However, it is not true. Underlying file description is removed from epoll set only when *all* fd's referring to it are closed.
There are 2 bad consequences:
1. Kernel delivers notifications on already closed fd's.
2. The following sequence of events leads to error:
- add fd1 to epoll
- dup fd1 = fd2
- close fd1 (not removed from epoll since we've dup'ed the fd)
- dup fd2 = fd1 (get the same fd as fd1)
- add fd1 to epoll = EEXIST
So, if fd can be potentially dup'ed of fork'ed, it's necessary to explicitly remove the fd from epoll set.
R=golang-dev, bradfitz, dave
CC=golang-dev
https://golang.org/cl/7870043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/44840786ae2a7a24d81df176494e0af5ba9764c4
元コミット内容
runtime: explicitly remove fd's from epoll waitset before close()
Fixes #5061.
Current code relies on the fact that fd's are automatically removed from epoll set when closed. However, it is not true. Underlying file description is removed from epoll set only when *all* fd's referring to it are closed.
There are 2 bad consequences:
1. Kernel delivers notifications on already closed fd's.
2. The following sequence of events leads to error:
- add fd1 to epoll
- dup fd1 = fd2
- close fd1 (not removed from epoll since we've dup'ed the fd)
- dup fd2 = fd1 (get the same fd as fd1)
- add fd1 to epoll = EEXIST
So, if fd can be potentially dup'ed of fork'ed, it's necessary to explicitly remove the fd from epoll set.
R=golang-dev, bradfitz, dave
CC=golang-dev
https://golang.org/cl/7870043
変更の背景
このコミットは、GoランタイムがネットワークI/Oを効率的に処理するために使用するepoll
(LinuxにおけるI/Oイベント通知メカニズム)の誤解釈と、それに伴う潜在的なバグを修正するために導入されました。
従来のGoランタイムのコードは、「ファイルディスクリプタ(FD)が閉じられると、自動的にepoll
の監視セットから削除される」という前提に立っていました。しかし、これは誤りであることが判明しました。実際には、基盤となるファイル記述(underlying file description)は、そのファイル記述を参照する全てのFDが閉じられた場合にのみ、epoll
セットから削除されます。
この誤解釈は、以下の2つの深刻な問題を引き起こしていました。
-
既に閉じられたFDに対するカーネル通知の配信: FDが閉じられたにもかかわらず、それが
epoll
セットから削除されていない場合、カーネルは引き続きそのFDに関連するイベント(例: データが利用可能になった、書き込み可能になった)を通知し続ける可能性があります。これは、Goランタイムが既に無効なFDに対して操作を試みる原因となり、予期せぬ動作やクラッシュにつながる可能性があります。 -
FDの再利用時の
EEXIST
エラー: より深刻な問題として、FDが複製(dup
システムコールなど)されたり、プロセスがフォークされたりするシナリオで発生する可能性のある競合状態がありました。コミットメッセージに示されている具体的なシーケンスは以下の通りです。fd1
をepoll
に追加する。fd1
を複製してfd2
を作成する(fd1
とfd2
は同じ基盤となるファイル記述を参照する)。fd1
を閉じる(しかし、fd2
がまだ存在するため、基盤となるファイル記述はepoll
セットから削除されない)。fd2
を複製して、たまたま以前のfd1
と同じ値の新しいFD(これもfd1
と呼ぶ)を取得する。- この新しい
fd1
をepoll
に追加しようとすると、EEXIST
エラーが発生する。これは、epoll
セットには既に同じ基盤となるファイル記述が登録されており、epoll_ctl(EPOLL_CTL_ADD)
が既に存在するFDを追加しようとした場合に返すエラーだからです。
これらの問題は、GoのネットワークI/O処理の信頼性と堅牢性を損なうものであり、特に高負荷な環境や、FDの再利用が頻繁に行われるアプリケーションにおいて顕著でした。このコミットは、close()
が呼び出される前にepoll
セットからFDを明示的に削除するepoll_ctl(EPOLL_CTL_DEL)
を導入することで、これらの根本的な問題を解決しています。
前提知識の解説
このコミットを理解するためには、以下の概念を把握しておく必要があります。
-
ファイルディスクリプタ (File Descriptor, FD): Unix系OSにおいて、プロセスがファイル、ソケット、パイプなどのI/Oリソースにアクセスするために使用する抽象的なハンドルです。整数値で表現されます。
-
epoll
: Linuxカーネルが提供するI/Oイベント通知メカニズムの一つで、多数のファイルディスクリプタに対するI/Oイベント(読み込み可能、書き込み可能など)を効率的に監視するために設計されています。特に、C10K問題(単一サーバーで1万以上の同時接続を処理する問題)を解決するために開発されました。epoll
は以下の主要なシステムコールで操作されます。epoll_create()
:epoll
インスタンスを作成し、そのFDを返します。epoll_ctl()
:epoll
インスタンスにFDを追加(EPOLL_CTL_ADD
)、変更(EPOLL_CTL_MOD
)、削除(EPOLL_CTL_DEL
)します。epoll_wait()
:epoll
インスタンスに登録されたFDで発生したイベントを待ち、イベントが発生したFDのリストを返します。
-
dup()
/dup2()
システムコール: 既存のファイルディスクリプタを複製するために使用されます。dup(oldfd)
は、oldfd
と同じ基盤となるファイル記述を参照する新しいFDを返します。dup2(oldfd, newfd)
は、newfd
が既に開いている場合はそれを閉じ、oldfd
と同じ基盤となるファイル記述を参照するようにnewfd
を再割り当てします。複製されたFDは、元のFDと同じファイルオフセット、ファイルステータスフラグなどを共有します。 -
Goの
netpoll
(ネットワークポーリング): Goランタイムは、ノンブロッキングI/Oとイベント駆動型プログラミングを組み合わせることで、多数の同時ネットワーク接続を効率的に処理します。このメカニズムは「ネットワークポーリング」と呼ばれ、Linuxではepoll
、macOS/BSDではkqueue
、WindowsではI/O Completion Ports (IOCP) など、OS固有のI/O多重化メカニズムを利用しています。 Goのnetpoll
は、ゴルーチンがネットワークI/O操作でブロックされることなく、他のゴルーチンが実行できるようにします。I/O操作が完了すると、netpoll
は対応するゴルーチンを再開可能にします。 -
PollDesc
構造体: Goランタイム内部で使用される構造体で、ネットワークI/O操作に関連するファイルディスクリプタの状態を管理します。これには、FD自体、そのFDで待機しているゴルーチン、タイマー、およびポーリングの状態などが含まれます。 -
sysfile.Close()
とpd.Close()
: Goのnet
パッケージにおけるnetFD
構造体は、OSのファイルディスクリプタ(sysfd
)と、Goランタイムのポーリングディスクリプタ(pd
、PollDesc
のインスタンス)をカプセル化しています。sysfile.Close()
: OSレベルでファイルディスクリプタを閉じる操作(close()
システムコールに相当)を実行します。pd.Close()
: Goランタイムのポーリングメカニズムから、このFDを登録解除する操作を実行します。このコミット以前は、epoll
ベースのシステムではpd.Close()
がepoll_ctl(EPOLL_CTL_DEL)
を呼び出すことを期待していましたが、実際にはそうではありませんでした。
技術的詳細
このコミットの核心は、epoll
の動作に関する正確な理解に基づいています。
epoll
は、監視対象のファイルディスクリプタを内部の「監視セット」(waitset)に保持します。epoll_ctl
システムコールを使って、このセットにFDを追加(EPOLL_CTL_ADD
)、変更(EPOLL_CTL_MOD
)、削除(EPOLL_CTL_DEL
)することができます。
コミットメッセージが指摘するように、close()
システムコールが呼び出されても、そのFDがepoll
セットから自動的に削除されるわけではありません。epoll
は、基盤となるファイル記述が参照されなくなったときにのみ、その記述に関連するイベント通知を停止します。これは、dup()
やfork()
によって複数のFDが同じ基盤となるファイル記述を参照している場合、それら全てのFDが閉じられるまで、epoll
セットからの自動削除は行われないことを意味します。
この挙動は、特にGoのようなランタイムがFDをプールしたり、再利用したりする可能性がある場合に問題となります。Goのnetpoll
は、ネットワーク接続が閉じられた際に、関連するPollDesc
をクリーンアップし、最終的にOSのFDを閉じます。しかし、epoll
セットからの明示的な削除が行われないと、以下のような問題が発生します。
- ゾンビイベント: 閉じられたFDに関連するイベントが
epoll_wait
によって報告され続ける。Goランタイムはこれらのイベントを処理しようとするが、FDは既に無効であるため、エラーや未定義の動作につながる。 - FDの衝突: 閉じられたFDの番号が再利用され、新しい接続に割り当てられた場合、その新しいFDを
epoll
セットに追加しようとすると、以前の(まだepoll
セットに残っている)同じ番号のFDとの衝突が発生し、EEXIST
エラーとなる。これは、epoll
がFD番号ではなく、基盤となるファイル記述を識別子として使用しているためです。
このコミットは、この問題を解決するために、epoll
を使用するシステム(Linux)において、FDが閉じられる直前にepoll_ctl(epfd, EPOLL_CTL_DEL, fd, &ev)
を呼び出して、epoll
セットからFDを明示的に削除するように変更しました。
kqueue
との比較:
興味深いのは、src/pkg/runtime/netpoll_kqueue.c
の変更です。kqueue
はmacOSやBSD系のOSで使用されるI/Oイベント通知メカニズムです。このファイルでは、runtime·netpollclose
関数が追加されていますが、その実装は単にreturn 0;
となっています。これは、kqueue
の動作がepoll
とは異なり、close()
システムコールが呼び出されると、そのFDに関連するkevent
(kqueue
のイベント)が自動的に削除されるためです。したがって、kqueue
においてはepoll
のような明示的な削除は不要であり、このコミットの変更は主にLinuxのepoll
に特化したものであることがわかります。
この修正により、Goランタイムはepoll
の正確なセマンティクスに準拠し、FDのライフサイクル管理がより堅牢になります。これにより、ネットワークI/Oにおける潜在的なバグや競合状態が解消され、Goアプリケーションの安定性が向上します。
コアとなるコードの変更箇所
このコミットでは、以下の5つのファイルが変更されています。
src/pkg/net/fd_unix.go
src/pkg/runtime/netpoll.goc
src/pkg/runtime/netpoll_epoll.c
src/pkg/runtime/netpoll_kqueue.c
src/pkg/runtime/runtime.h
コアとなるコードの解説
src/pkg/net/fd_unix.go
--- a/src/pkg/net/fd_unix.go
+++ b/src/pkg/net/fd_unix.go
@@ -124,8 +124,10 @@ func (fd *netFD) decref() {
fd.sysmu.Lock()
fd.sysref--
if fd.closing && fd.sysref == 0 && fd.sysfile != nil {
-\t\tfd.sysfile.Close()
+\t\t// Poller may want to unregister fd in readiness notification mechanism,
+\t\t// so this must be executed before sysfile.Close().
\t\tfd.pd.Close()
+\t\tfd.sysfile.Close()
\t\tfd.sysfile = nil
\t\tfd.sysfd = -1
\t}
このファイルは、Goのnet
パッケージにおけるUnix系システムでのファイルディスクリプタの管理を担当しています。
変更点としては、fd.sysfile.Close()
(OSレベルのFDクローズ)の呼び出しの前に、fd.pd.Close()
(Goランタイムのポーリングディスクリプタのクローズ)が呼び出されるように順序が変更されました。
コメントで「Poller may want to unregister fd in readiness notification mechanism, so this must be executed before sysfile.Close().」と説明されているように、ポーラー(epoll
など)が準備通知メカニズムからFDを登録解除する必要があるため、OSのFDを閉じる前にポーラー側のクリーンアップを行う必要があります。これにより、epoll
セットからの明示的な削除が、OSのFDが完全に無効になる前に行われることが保証されます。
src/pkg/runtime/netpoll.goc
--- a/src/pkg/runtime/netpoll.goc
+++ b/src/pkg/runtime/netpoll.goc
@@ -25,6 +25,7 @@ struct PollDesc
{
PollDesc* link; // in pollcache, protected by pollcache.Lock
Lock; // protectes the following fields
+\tint32 fd;
bool closing;
uintptr seq; // protects from stale timers and ready notifications
G* rg; // G waiting for read or READY (binary semaphore)
@@ -69,6 +70,7 @@ func runtime_pollOpen(fd int) (pd *PollDesc, errno int) {
\truntime·throw("runtime_pollOpen: blocked write on free descriptor");
if(pd->rg != nil && pd->rg != READY)
\truntime·throw("runtime_pollOpen: blocked read on free descriptor");
+\tpd->fd = fd;
pd->closing = false;
pd->seq++;
pd->rg = nil;
@@ -87,6 +89,7 @@ func runtime_pollClose(pd *PollDesc) {
\truntime·throw("runtime_pollClose: blocked write on closing descriptor");
if(pd->rg != nil && pd->rg != READY)
\truntime·throw("runtime_pollClose: blocked read on closing descriptor");
+\truntime·netpollclose(pd->fd);
runtime·lock(&pollcache);
pd->link = pollcache.first;
pollcache.first = pd;
このファイルは、Goランタイムのネットワークポーリングに関連するCgoコード(GoとCの混在コード)です。
PollDesc
構造体にint32 fd;
フィールドが追加されました。これにより、PollDesc
がどのファイルディスクリプタに関連付けられているかを直接保持できるようになります。runtime_pollOpen
関数内で、pd->fd = fd;
が追加され、PollDesc
がオープンされる際にそのFDが保存されるようになりました。runtime_pollClose
関数内で、runtime·netpollclose(pd->fd);
が追加されました。これは、PollDesc
がクローズされる際に、対応するOS固有のネットワークポーリングクローズ関数(netpoll_epoll.c
やnetpoll_kqueue.c
で定義される)を呼び出すためのものです。この呼び出しが、epoll
セットからの明示的な削除をトリガーします。
src/pkg/runtime/netpoll_epoll.c
--- a/src/pkg/runtime/netpoll_epoll.c
+++ b/src/pkg/runtime/netpoll_epoll.c
@@ -34,10 +34,22 @@ int32
runtime·netpollopen(int32 fd, PollDesc *pd)
{
EpollEvent ev;
+\tint32 res;
ev.events = EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET;
ev.data = (uint64)pd;
-\treturn runtime·epollctl(epfd, EPOLL_CTL_ADD, fd, &ev);\n+\tres = runtime·epollctl(epfd, EPOLL_CTL_ADD, fd, &ev);\n+\treturn -res;\n+}\n+\n+int32\n+runtime·netpollclose(int32 fd)\n+{\n+\tEpollEvent ev;\n+\tint32 res;\n+\n+\tres = runtime·epollctl(epfd, EPOLL_CTL_DEL, fd, &ev);\n+\treturn -res;\n }
// polls for ready network connections
このファイルは、Linuxのepoll
システムコールをGoランタイムから呼び出すためのCコードです。
runtime·netpollclose(int32 fd)
関数が新しく追加されました。この関数は、引数として与えられたfd
をepoll
セットから削除するために、runtime·epollctl(epfd, EPOLL_CTL_DEL, fd, &ev)
を呼び出します。これが、epoll
セットからの明示的な削除を行う主要な変更点です。runtime·netpollopen
の戻り値の処理が-res
に変更されていますが、これはエラーコードの慣習的な変換であり、機能的な変更ではありません。
src/pkg/runtime/netpoll_kqueue.c
--- a/src/pkg/runtime/netpoll_kqueue.c
+++ b/src/pkg/runtime/netpoll_kqueue.c
@@ -57,6 +57,15 @@ runtime·netpollopen(int32 fd, PollDesc *pd)
return 0;
}
+int32
+runtime·netpollclose(int32 fd)
+{
+\t// Don't need to unregister because calling close()
+\t// on fd will remove any kevents that reference the descriptor.
+\tUSED(fd);
+\treturn 0;
+}
+
// Polls for ready network connections.
// Returns list of goroutines that become runnable.
G*
このファイルは、macOS/BSDのkqueue
システムコールをGoランタイムから呼び出すためのCコードです。
runtime·netpollclose(int32 fd)
関数が新しく追加されました。しかし、その実装はreturn 0;
と非常にシンプルです。これは、コメントで説明されているように、「close()
を呼び出すと、そのディスクリプタを参照するkevent
が自動的に削除されるため、登録解除する必要がない」というkqueue
の特性によるものです。この変更は、netpoll.goc
で追加されたruntime·netpollclose
の呼び出しに対応するために、インターフェースを統一するためのものです。
src/pkg/runtime/runtime.h
--- a/src/pkg/runtime/runtime.h
+++ b/src/pkg/runtime/runtime.h
@@ -792,6 +792,7 @@ bool runtime·deltimer(Timer*);
G* runtime·netpoll(bool);
void runtime·netpollinit(void);
int32 runtime·netpollopen(int32, PollDesc*);
+int32 runtime·netpollclose(int32);
void runtime·netpollready(G**, PollDesc*, int32);
void runtime·crash(void);
このファイルは、Goランタイムの内部関数宣言を含むヘッダーファイルです。
int32 runtime·netpollclose(int32);
という新しい関数プロトタイプが追加されました。これは、netpoll.goc
から呼び出され、各OS固有のnetpoll_epoll.c
やnetpoll_kqueue.c
で実装されるruntime·netpollclose
関数の宣言です。
これらの変更により、Goランタイムはファイルディスクリプタのライフサイクル管理において、epoll
の正確なセマンティクスに準拠するようになり、特にFDの複製や再利用が関わるシナリオでの安定性と信頼性が大幅に向上しました。
関連リンク
- Go Issue: #5061
- Go Code Review: https://golang.org/cl/7870043
参考にした情報源リンク
epoll
man page:man 7 epoll
dup
man page:man 2 dup
kqueue
man page:man 2 kqueue
- The C10K problem: http://www.kegel.com/c10k.html
- Go runtime source code (for context and structure)