[インデックス 13162] ファイルの概要
このコミットは、Go言語の標準ライブラリである net/rpc
パッケージにおける競合状態(race condition)を修正するものです。具体的には、クライアントがリクエストの書き込みに部分的に失敗した場合に発生する可能性のある問題に対処し、サーバーからの応答がすでに保留されていない呼び出し(call)に関連付けられることを防ぎ、そのような応答を適切に破棄することで堅牢性を向上させています。
コミット
commit 161f50574a9a17e43f4fad88dae57201b5bc3af8
Author: Alexey Borzenkov <snaury@gmail.com>
Date: Thu May 24 16:07:08 2012 -0700
net/rpc: fix race condition when request write partially fails
When client fails to write a request is sends caller that error,
however server might have failed to read that request in the mean
time and replied with that error. When client then reads the
response the call would no longer be pending, so call will be nil
Handle this gracefully by discarding such server responses
R=golang-dev, r
CC=golang-dev, rsc
https://golang.org/cl/5956051
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/161f50574a9a17e43f4fad88dae57201b5bc3af8
元コミット内容
net/rpc: fix race condition when request write partially fails
When client fails to write a request is sends caller that error,
however server might have failed to read that request in the mean
time and replied with that error. When client then reads the
response the call would no longer be pending, so call will be nil
Handle this gracefully by discarding such server responses
R=golang-dev, r
CC=golang-dev, rsc
https://golang.org/cl/5956051
変更の背景
この変更は、Go言語の net/rpc
パッケージにおいて、クライアントがRPCリクエストを送信する際に発生する可能性のある特定の競合状態を解決するために行われました。
問題のシナリオは以下の通りです。
- クライアントがRPCリクエストをサーバーに書き込もうとします。
- 書き込み処理が部分的に失敗し、クライアント側でエラーが発生します。この時点で、クライアントは呼び出し元にエラーを返します。
- しかし、その間にサーバーは部分的に書き込まれたリクエストを読み取ろうとし、それが不完全であるためにエラーを検出して、そのエラー応答をクライアントに返してしまう可能性があります。
- クライアントがサーバーからのこの応答を読み取った際、すでにクライアント側では元の呼び出し(
call
オブジェクト)がエラー処理のためにpending
リストから削除されているため、対応するcall
オブジェクトが見つからずnil
になってしまいます。 - 結果として、
nil
のcall
オブジェクトに対して操作を行おうとすると、パニック(panic)や予期せぬ動作を引き起こす可能性がありました。
この競合状態は、クライアントとサーバー間の非同期的なエラー処理と、pending
リストからの call
オブジェクトの削除タイミングのずれによって引き起こされていました。このコミットは、このような状況でサーバーからの応答を適切に破棄することで、システム全体の堅牢性と安定性を向上させることを目的としています。
前提知識の解説
RPC (Remote Procedure Call)
RPC(Remote Procedure Call)は、ネットワーク上の異なるアドレス空間にあるプロセス間で、あたかもローカルな手続き(関数やメソッド)を呼び出すかのように通信を行うための技術です。クライアントはリモートのサーバーにある手続きを呼び出し、サーバーはその手続きを実行して結果をクライアントに返します。
Go言語の net/rpc
パッケージは、このRPCメカニズムをGoプログラムで簡単に実装するための機能を提供します。クライアントとサーバーは、エンコーディング(通常は gob
エンコーディングがデフォルトですが、jsonrpc
など他のエンコーディングも利用可能)を通じてデータを交換します。
競合状態 (Race Condition)
競合状態とは、複数のプロセスやスレッドが共有リソース(この場合は client.pending
マップや call
オブジェクト)に同時にアクセスし、そのアクセス順序によってプログラムの実行結果が変わってしまう状態を指します。競合状態は、デバッグが困難なバグの一般的な原因となります。
今回のケースでは、クライアントの send
ゴルーチンがリクエスト書き込みエラーで call
を pending
から削除するのと、input
ゴルーチンがサーバーからの応答を処理しようとするタイミングが競合していました。
Go言語の net/rpc
パッケージ
net/rpc
パッケージは、GoプログラムでRPCサーバーとクライアントを構築するための基本的な機能を提供します。
rpc.Client
: リモートのRPCサーバーと通信するためのクライアントを表します。rpc.Call
: RPC呼び出しの情報を保持する構造体です。これには、サービスメソッド名、引数、応答、エラー、および呼び出しが完了したときに通知されるDone
チャネルが含まれます。client.pending
:rpc.Client
内部で管理されるマップで、現在サーバーからの応答を待っている保留中のRPC呼び出し(Call
オブジェクト)をシーケンス番号(seq
)をキーとして保持します。client.send
メソッド: クライアントからサーバーへリクエストを送信する役割を担います。client.input
メソッド: サーバーからの応答を読み取り、対応するCall
オブジェクトに結果をディスパッチする役割を担います。
技術的詳細
この競合状態は、net/rpc
クライアントの send
メソッドと input
メソッドが非同期に動作することに起因していました。
競合状態の発生メカニズム:
send
メソッドでのエラー: クライアントのsend
メソッドがclient.codec.WriteRequest
でエラーを検出します。これは、ネットワークの問題や部分的な書き込みなどによって発生する可能性があります。call
の削除:send
メソッドはエラーを検出すると、client.pending
マップから対応するcall
オブジェクトをシーケンス番号seq
を使って削除し、call.Error
にエラーを設定してcall.done()
を呼び出します。これにより、呼び出し元にはエラーが通知されます。input
メソッドでの応答受信: ほぼ同時に、サーバーは部分的に受信したリクエストに対してエラー応答を生成し、クライアントに送信します。クライアントのinput
メソッドはこの応答を受信します。call
の不在:input
メソッドが応答のシーケンス番号seq
を使ってclient.pending
マップからcall
オブジェクトを取得しようとすると、send
メソッドがすでにそれを削除しているため、call
はnil
になります。nil
ポインタ参照:input
メソッドは、nil
であるcall
オブジェクトに対してresponse.Error
のチェックやReadResponseBody
の呼び出し、最終的なcall.done()
を行おうとします。これがパニックを引き起こす可能性がありました。
修正アプローチ:
このコミットの修正は、send
メソッドと input
メソッドの両方で、client.pending
から call
オブジェクトを取得した後に、その call
が nil
でないことを明示的に確認するロジックを追加することで、この競合状態を解消しています。
send
メソッドでは、delete(client.pending, seq)
の後にcall = client.pending[seq]
を再度実行し、call
がnil
でない場合にのみエラー処理とcall.done()
を行います。これにより、input
がすでにcall
を処理してpending
から削除している場合でも安全になります。input
メソッドでは、call = client.pending[seq]
の後にif call == nil
のチェックを追加し、call
がnil
の場合はその応答を破棄(つまり、call
に関連する処理を行わない)するように変更しています。これにより、すでにsend
側で処理が完了している呼び出しに対するサーバーからの遅延応答を安全に無視できます。
この修正により、net/rpc
クライアントは、リクエスト書き込みエラーとサーバーからの応答が同時に発生するようなエッジケースにおいても、より堅牢に動作するようになります。
コアとなるコードの変更箇所
diff --git a/src/pkg/net/rpc/client.go b/src/pkg/net/rpc/client.go
index db2da8e441..e19bd484bd 100644
--- a/src/pkg/net/rpc/client.go
+++ b/src/pkg/net/rpc/client.go
@@ -88,10 +88,13 @@ func (client *Client) send(call *Call) {
err := client.codec.WriteRequest(&client.request, call.Args)
if err != nil {
client.mutex.Lock()
+ call = client.pending[seq]
delete(client.pending, seq)
client.mutex.Unlock()
- call.Error = err
- call.done()
+ if call != nil {
+ call.Error = err
+ call.done()
+ }
}
}
@@ -113,22 +116,26 @@ func (client *Client) input() {
delete(client.pending, seq)
client.mutex.Unlock()
- if response.Error == "" {
- err = client.codec.ReadResponseBody(call.Reply)
- if err != nil {
- call.Error = errors.New("reading body " + err.Error())
- }
- } else {
+ if call == nil || response.Error != "" {
// We've got an error response. Give this to the request;
// any subsequent requests will get the ReadResponseBody
// error if there is one.
- call.Error = ServerError(response.Error)
+ if call != nil {
+ call.Error = ServerError(response.Error)
+ }
err = client.codec.ReadResponseBody(nil)
if err != nil {
err = errors.New("reading error body: " + err.Error())
}
+ } else if response.Error == "" {
+ err = client.codec.ReadResponseBody(call.Reply)
+ if err != nil {
+ call.Error = errors.New("reading body " + err.Error())
+ }
}
- call.done()
+ if call != nil {
+ call.done()
+ }
}
// Terminate pending calls.
client.sending.Lock()
コアとなるコードの解説
client.send
メソッドの変更
err := client.codec.WriteRequest(&client.request, call.Args)
if err != nil {
client.mutex.Lock()
+ call = client.pending[seq] // ここで再度 call を取得
delete(client.pending, seq)
client.mutex.Unlock()
- call.Error = err
- call.done()
+ if call != nil { // call が nil でない場合のみ処理
+ call.Error = err
+ call.done()
+ }
}
client.send
メソッドは、RPCリクエストをサーバーに書き込む役割を担います。client.codec.WriteRequest
がエラーを返した場合、クライアントは client.pending
マップから対応する call
を削除し、エラーを call.Error
に設定して call.done()
を呼び出すことで、呼び出し元にエラーを通知します。
変更点では、delete(client.pending, seq)
の直前に call = client.pending[seq]
が追加されています。これは、delete
を行う前に、input
ゴルーチンがすでにこの call
を処理して pending
から削除している可能性を考慮しています。もし input
が先に処理を終えていれば、ここで call
は nil
になります。
その後の if call != nil
チェックは、call
が有効なオブジェクトである場合にのみ、エラーの設定と call.done()
の呼び出しを行うようにします。これにより、call
がすでに nil
になっている場合に nil
ポインタ参照を防ぎます。
client.input
メソッドの変更
delete(client.pending, seq)
client.mutex.Unlock()
- if response.Error == "" {
- err = client.codec.ReadResponseBody(call.Reply)
- if err != nil {
- call.Error = errors.New("reading body " + err.Error())
- }
- } else {
+ if call == nil || response.Error != "" { // call が nil の場合、またはエラー応答の場合
// We've got an error response. Give this to the request;
// any subsequent requests will get the ReadResponseBody
// error if there is one.
- call.Error = ServerError(response.Error)
+ if call != nil { // call が nil でない場合のみエラーを設定
+ call.Error = ServerError(response.Error)
+ }
err = client.codec.ReadResponseBody(nil)
if err != nil {
err = errors.New("reading error body: " + err.Error())
}
+ } else if response.Error == "" { // 正常応答の場合
+ err = client.codec.ReadResponseBody(call.Reply)
+ if err != nil {
+ call.Error = errors.New("reading body " + err.Error())
+ }
}
- call.done()
+ if call != nil { // call が nil でない場合のみ done() を呼び出し
+ call.done()
+ }
client.input
メソッドは、サーバーからの応答を読み取り、対応する call
オブジェクトに結果をディスパッチします。
変更前は、client.pending
から call
を取得した後、call
が nil
である可能性を考慮せずに処理を進めていました。
変更点では、まず if call == nil || response.Error != ""
という条件が追加されています。
call == nil
: これは、send
ゴルーチンがすでにcall
をpending
から削除している(つまり、クライアント側でエラー処理が完了している)場合に発生します。この場合、サーバーからの応答はもはや関連性がなく、破棄されるべきです。response.Error != ""
: サーバーからの応答自体がエラーを示している場合です。
この条件ブロック内では、if call != nil
のチェックが追加され、call
が有効な場合にのみ call.Error = ServerError(response.Error)
が実行されます。これにより、nil
の call
に対してエラーを設定しようとするのを防ぎます。
また、正常応答を処理する else if response.Error == ""
ブロックが追加され、コードの構造がより明確になりました。
最後に、if call != nil { call.done() }
というチェックが追加されています。これは、call
が有効な場合にのみ call.done()
を呼び出すことを保証します。これにより、call
がすでに nil
になっている場合に nil
ポインタ参照を防ぎ、競合状態によって call
が無効になった場合でも安全に処理を終了できるようになります。
これらの変更により、send
と input
の両方で call
オブジェクトの有効性を確認するようになり、非同期処理における競合状態が適切にハンドリングされるようになりました。
関連リンク
参考にした情報源リンク
- Go
net/rpc
package documentation: https://pkg.go.dev/net/rpc - Understanding Race Conditions: https://en.wikipedia.org/wiki/Race_condition
- Remote Procedure Call (RPC): https://en.wikipedia.org/wiki/Remote_procedure_call
- Go Concurrency Patterns: https://go.dev/blog/concurrency-patterns
- Go
sync.Mutex
documentation: https://pkg.go.dev/sync#Mutex