Those hidden corners in Golang

recover() は本当に使えるのか?

システムアラーム調査で、あるコンポーネントが panic を起こし、運転を再開しなかったことが判明した。panic のログは "fatal error: concurrent map writes" だった。当時、そのコンポーネントは手動で再起動するしかなかった。ソースコードを確認したところ、panic 発生箇所に対応する関数には既に recover() 関数が存在していた。ソースコードのロジックを抽象化すると以下の通りである。

package main
import (
"fmt"
)
func concurrentMapWrite() {
defer func() {
if err := recover(); err 。= nil {
fmt.Printf("Panic occurred due to %+v, Recovered in f", err)
}
}()
m := map[int]int{}
idx := 0
for {
go func() {
m[idx] = 1
}()
idx++
}
}

func main() {
concurrentMapWrite()
}

当時、recover が "fatal error: concurrent map writes" を捕捉できなかったと暫定的に判断した。仮説を検証するため、以下の一連の調査を行った。

defer で recover() を使用する

Go プログラムが期待通りに動作しない場合、ユーザーには「エラー」と「例外」としてフィードバックされることが多い。前者はコードロジックに問題がある場合に返されるエラーであり、プログラマが想定しているもので、プログラムの動作を損なうことはない。後者はコード内の予期しないエラーによって引き起こされ、プログラムが実行を継続できなくなる。適切に処理しないと、プログラムは異常終了し、非常に危険である。

プログラムの堅牢性を向上させるため、Go の recover と defer の仕組みを利用して、例外発生後もプログラムが実行を継続できるようにし、プログラムの偶発的な終了を回避し、ビジネスの安定稼働を確保する必要がある。

defer キーワードに含まれる内容は、所属する関数の return の前に実行される。recover 関数は goroutine を異常シナリオから回復させるために使用される。defer 内で呼び出された場合にのみ有効である。

使用方法は以下の通りである。

func div(x, y int) int {
return x / y
}
func f() {
defer func() {
if err := recover(); err 。= nil {
fmt.Printf("Panic occurred due to %+v, Recovered in f", err)
}
}()
fmt.Println(div(1, 0))
}

recover を組み合わせた defer は Java の try/catch キーワードに相当し、プログラムが例外によって中断されないようにする。Java の try-catch では、すべての例外の基底クラスである Exception を catch すれば、あらゆる種類の例外を捕捉できることが知られている。では、なぜ Go はそうではないのか?

回復不可能な panic

try-catch とは異なる。Go ではすべての例外が recover で捕捉できるわけではない。

・runtime.panic() でスローされた例外は、recover メソッドで捕捉できる。
・runtime.throw() または runtime.fatal() でスローされた例外は、recover メソッドで捕捉できない。

上記の実際のシナリオで遭遇した「マップへの同時書き込み」例外は runtime.fatal() を通じてスローされる。具体的なソースコードは runtime/map.go である。

マップはフラグビット h.flags を使用して、マップに同時書き込みが発生しているかどうかをチェックする。同時書き込みがある場合、fatal メソッドが呼び出される。このとき、エラーは "fatal error" となり、プログラムは強制終了する。

fatal メソッドのコメントから、このメソッドは throw メソッドと同等であるが、ユーザーレベルの例外のみをスローし、システムレベルの例外は runtime.throw によってスローされることがわかる。fatal メソッド内では fatalthrow メソッドも呼び出されている。このメソッドの注釈には「fatalthrow は回復不可能なランタイムスローを実装する」と明記されている。したがって、このメソッドがスローする例外は回復不可能な例外である。

「マップへの同時書き込み」が回復不可能な例外とされる理由は、Go がデータ競合を検出した時点で、マップの内部構造が既に破壊されているためである。実行を続行すると予期しない結果が生じる可能性があるため、プログラムは強制終了される。以下にその他の回復不可能な例外の種類を挙げる。

・メモリ不足

・マップへの同時書き込み

・スタックメモリ枯渇

・nil 関数を goroutine として起動しようとした場合

・すべての goroutine がスリープ状態 - デッドロック

・スレッド上限超過

スライスの容量拡張にはどのような落とし穴があるか?

開発プロセスにおいて、スライスをパラメータとして関数に渡し、関数内でスライスの内容を変更すると、対応する変更が実パラメータに同期的に更新されることが期待される。しかし、実際の開発テストでは、期待通りに動作するシナリオもあれば、そうでないシナリオもあることがわかった。

関数内でスライスの容量を拡張し、拡張後のスライスのサイズが元の容量を超えない場合、スライス内の既存の要素への変更は実パラメータのスライスに同期されるが、容量拡張は実パラメータのスライスに同期されない。関数内でこのスライスの容量を拡張し、拡張後のスライスのサイズが元の容量を超える場合、変更も容量拡張も実パラメータのスライスに同期されない。

容量拡張の仕組み

関数内でスライス引数の既存要素を更新すると実引数に影響する

スライス自体はポインタプロパティを保持しており、これによってベース配列の一部の要素のコレクションを指している。その構造は以下の通りである。

type slice struct {

Array unsafe. Pointer//Pointer to the underlying array

Len int//Length of the slice

Cap int//Slice capacity

}

Go の公式ドキュメントには、関数パラメータの受け渡し方法は値渡しのみであると記載されている。値渡しでは、関数呼び出し時に実パラメータのコピーが関数に渡される。スライスパラメータが関数に渡されると、その array、len、cap がコピーされる。したがって、関数内のスライスと実パラメータのスライスはベースとなるスライス配列を共有する。関数内のスライスの既存要素への変更は、実パラメータのスライスに同期される。

スライスの容量拡張戦略

スライスは append 関数で要素を追加することで動的に容量を拡張できる。拡張された要素はスライスの既存のストレージに格納される。ただし、スライスのストレージの上限はスライスの容量によって決まる。拡張する要素の数がスライスの容量を超えた場合、スライスはこれらの要素を収容するためにベース配列を拡張する必要がある。ここでは go/src/runtime/slice.go の growslice メソッドを通じて、Go 1.19.2 以降の容量拡張戦略を分析する。

容量拡張後の要素の総数がスライスの容量を超えた場合、容量拡張戦略は以下の通りである。

・threshold = 256

・容量拡張後の要素数が元の容量の 2 倍を超える場合、容量拡張後の要素数を新しい容量として直接割り当てる。超えない場合は以下を実行する。

・元の容量が threshold 未満の場合、元の容量の 2 倍を新しい容量として割り当てる。そうでない場合は以下を実行する。

・元の容量を基準に、毎回 (元の容量 + threshold * 3) / 4 ずつ増加させ、容量拡張後の要素数以上になるまで繰り返す。

拡張戦略が完了して新しい容量値が得られた後、この値に基づいてメモリが確保され、元の配列のデータと拡張されたデータが新しいメモリにコピーされる。これでスライスの動的拡張が完了する。数式は以下の通りである。

上記から、関数内でパラメータのスライスをリサイズし、リサイズ後の要素数が元のスライスの容量を超えた場合、ベース配列は別のメモリ領域に移行されるため、関数内でのパラメータスライスの既存要素の更新は実パラメータのスライスに影響しないことがわかる。

上記のシナリオは実際の開発・アプリケーションプロセスから抽象化した gRPC 通信プロセスであり、一般的なプロセスでもある。クライアント側はタイムアウト付きの context をサーバー側に渡す。サーバー側はタイムアウト内にリクエスト処理を完了し、レスポンスをクライアント側に返す必要がある。タイムアウトを超えると、接続は切断され、クライアント側はレスポンスを受け取れない。

しかし、実際の開発・アプリケーションでは、サーバー側の context がタイムアウトしても、リクエストのレスポンスが断続的にクライアント側に配信され、ある機能で予期しない状況が発生することがわかった。対応するインタラクションプロセスをコードで説明するため、ここに簡略化したサンプルコードを提示し、当時のインタラクションロジックを説明する。

gRPC のタイムアウト伝達プロセス

Go の gRPC 通信プロセスにおいて、タイムアウト情報は異なる通信エンドポイント間で伝達され、伝送媒体は HTTP/2 リクエストフレームである。リクエスト送信前に、gRPC クライアントは情報をさまざまなフレームにエンコードする。たとえば、Data Frame はリクエストのレスポンスペイロードを格納するために使用される。Header Frame にはパス情報など、goroutine 間で転送されるデータが格納される。タイムアウト情報は Header Frame に格納される。

クライアントのサーバーはタイムアウト情報を受け取ると、ヘッダーから gRPC のタイムアウトフィールドを取得し、タイムアウト情報に基づいて新しい context インスタンスを生成する。

gRPC のクライアント側では、context.Done() が継続的にチェックされ、context がタイムアウトしたかどうかが判断される。タイムアウトした場合、接続は切断される。ただし、context タイムアウトの検知漏れも存在する。たとえば、クライアント側の context がタイムアウトしているが、次のラウンドのチェックがまだ開始されていない状態で、同時にサーバー側がちょうどレスポンス情報を返した場合、クライアント側の context はタイムアウトしているが、サーバー側からのレスポンスが受信され処理される。より一般的なケースは select {case <-ctx; ...; case <-response; ...} であり、50% の確率で context タイムアウトが検出されない。詳細については、前述の gRPC の issue を参照されたい。

gRPC レスポンスのタイムアウトエラーを確実にする

前述のエラーシナリオでは、サーバー側の context がタイムアウトし、クライアント側にレスポンスが返された。このとき、クライアント側もタイムアウトして接続を切断することが期待されていたが、実際にはクライアント側のレスポンスを正常に受信してしまった。処理ロジックの問題により、当時のレスポンスにはタイムアウトエラーが含まれていなかったため、クライアント側はリクエストを受信した後、リクエストを再送した。再送後、context タイムアウトが検出され、最終的に接続が切断され、エラーが発生した。

したがって、アプリケーションプロセスにおいては、サーバー側の context タイムアウト時にレスポンスで返されるエラーメッセージが grpc.DeadlineExceeded であることを確実にする必要がある。これにより、クライアント側もタイムアウトを認識し、不要なロジックを回避できる。

Related Articles

Explore More Special Offers

  1. Short Message Service(SMS) & Mail Service

    50,000 email package starts as low as USD 1.99, 120 short messages start at only USD 1.00

phone お問い合わせ
Hi, I'm Alibaba Cloud AI Assistant!
I can help with questions and solutions.