こんにちは、みんな
最近、バグについて二重に書きました. オンラインサービスがデッドロックしています. 幸い新しいサービスなので大きな影響はありません.
問題は Go の読み書きロックです. Java を書いているのであれば, 横切る必要はありません. この記事を読むべきです. この記事の焦点は Java と Go の読み書きロックの比較です.それを読むと、あなたはかすかな気持ちになるでしょう: Go の読み書きロックにバグはありますか?
トラブルシューティング
サーバーサービス(Go言語で実装)がhttpインターフェースを提供し、別のクライアントサービスがこのインターフェースを呼び出すという背景はシンプルかつ抽象的で、全体のアーキテクチャは非常にシンプルで、アーキテクチャ図を描かなくても理解できます。
これら 2 つのサービスは、しばらく問題なく実行されていましたが、ある日突然、サーバーを呼び出すクライアントのすべてのインターフェイスがタイムアウトになりました。
このような問題が発生した場合は、最初にログと監視を確認してください. クライアント側はタイムアウト ログでいっぱいであり、サーバー側のログは異常ではありません.クライアント側がサーバー側に到達しませんでした。
サーバーに手動でインターフェースを要求しましたが、カードの所有者は動かず、クライアントは除外されました。サーバーに問題があるに違いありません。
このようなスタックの問題は、実際には非常に簡単に確認できます. 基本的には直接 pprof を使用してコルーチンがスタックしている場所を確認することで結論を導き出すことができます (Java の jstack に似たツール) が、このサービスは pprof を有効にしないため、 pprof Repost を開くようにコードを変更し、次回問題が再発するのを待ちます。
幸いなことに、私は幸運で、2 日後に問題が発生しました.pprof を使用して、プログラムがスタックしている場所を確認します。
クラスタやサービスがトラフィックの少ないクラスタかどうかを判断するところに行き詰まっていることが判明した.このインタフェースはクラスタ名やサービス名のパラメータを受け付けて,クラスタやサービスがトラフィックの少ないクラスタかどうかを判断する. 、そして一連のことを行います。小さなトラフィック クラスターは、構成センターで構成されます。
このコードを抜粋しました(図は判定クラスター分岐、以下のコードはより単純なサービス分岐で説明、最下層は同じ)。空を避けるために、ここでプログラムのロジックを簡単に説明します。
- まず、小さなトラフィックの構成では、読み取り/書き込みロック (sync.RWMutex) と、メモリに保持する必要があるサービスのルール (scopesMap) を定義します。
- 構成が変更されたら、reset を呼び出して scopesMap を更新し、書き込みロックを使用し、その後のロジックを省略します。
- グレースケール サービスかどうかを判断するには、まず読み取りロックを追加して、ルールが存在するかどうかを確認します。
- サービスがルールに適合するかどうかを判断するために、別のロックを追加します。
このように要点を丸で囲んでおけば一目で問題点がわかるかもしれませんが、読み込みロックが2回追加されており、2回目は不要という間違いです。実際、読み取りロックを追加する 2 番目のコードを削除しても問題ありません。ここまでくれば、この記事を書く必要はありませんが、デッドロックが発生する理由を分析してみましょう。
なぜデッドロック
この結果を見て、私の最初の反応は Go のロックの問題でした重入性
。
Java に精通している学生は再入可能性をロックすることをよく知っています. 一部の読者がロックの再入可能性を理解していない場合に備えて, 私はそれを一文で要約します:
可重入锁
とも呼ばれる、再入場できる錠前です递归锁
。
ReentrantLock
Java には次のようなものがあります。ロックを繰り返しても問題はありません。
しかし、Go のロックは再入可能ではありません。
Go の実装の問題であるこの穴にも足を踏み入れました。必要に応じて、再入不可のロックを Java に実装することもできますが、ほとんどの Java では再入可能ロックの方が使いやすいため、再入可能ロックを使用しています。
Go が再入可能ロックを実装しない理由については、この記事「Why doesn't Go support reentrant locks?」を参照してください。」、その理由は、Go の設計者がリエントラント ロックは設計が悪いと考え、採用しなかったことに要約されます。しかし、この記事へのコメントはもっと刺激的だと思います。
そういえば、上記の問題は明らかに読み書きロックだとおっしゃるかもしれませんが ( sync.RWMutex
)、読み書きロックの特徴は何ですか?
- 読み取りと読み取りの間で相互に排他的ではない
- 読み取りと書き込み、書き込みと書き込みの間で相互に排他的
読み取りロックは相互に排他的ではないため、読み取りロックは 2 回追加できるため、読み取りロックは yes にする必要があります可重入
。デモテストを書きましょう:
案の定、読み取りロックを追加するロジックを見てみましょう。
フレーム化したコードを見てください。書き込みロックが待機している場合、読み取りロックは書き込みロックを待機する必要があります。
ロジックは何ですか?
コルーチンが既に読み取りロックを取得しており、別のコルーチンが書き込みロックを追加しようとしても、この時点では追加できないはずであり、問題はありません。読み取りロック コルーチンが再度読み取りロックを取得しようとすると、書き込みロックを待機する必要があり、これはデッドロックです!
検証のために、デモを作成しました。
このコードは①、②、③の順に実行されますが、②のライトロックは①のリードロックが解除されるのを待ち、③のリードロックはライトロックが解除されるのを待つ必要があります。パラグラフ②は解放されるべきで、結局デッドロックの論理です。
よくよく考えてみると、ここで最も物議をかもしているのは已经拿到读锁再次进入读锁需要等写锁
このロジックです。
これはJavaの場合ですか?試してみるデモを書いてください:
Javaには何の問題もありませんが、なぜですか? 迷ったらソースコードを見てみよう!しかし、Java のソース コードは長すぎて、この記事の焦点ではないため、いくつかの重要な結論だけを述べます。
- Java の ReentrantReadWriteLock はロックをサポートします
降级
が、できません升级
。つまり、書き込みロックを取得したスレッドは引き続き読み取りロックを取得できますが、読み取りロックを取得したスレッドは再度書き込みロックを取得できません。 - ReentrantReadWriteLock は公平ロックと不公平ロックの両方を実装します. 公平ロックの場合, 読み取りロックと書き込みロックを取得する前に, 同期キュー内のスレッドが私の前にキューに入れられているかどうかを確認する必要があります. 不公平ロックの場合: 書き込みロックは直接ただし、読み取りロックの取得には譲歩条件があります。現在の同期キュー head.next が書き込みロックを待機しており、再入可能でない場合は、解放して待機する必要があります。
Java の実装では、スレッドが読み取りロックを保持している場合、書き込みロックは当然待機する必要がありますが、読み取りロックを保持しているスレッドも再度読み取りロックに入ることができます。
Java と Go の読み書きロックの実装に一貫性がないことがわかりました。この矛盾がバグを書いた理由です。
これは合理的ですか
実装はさておき、考えてみましょう、これは合理的ですか?
- コルーチン (またはスレッド) が読み取りロックを取得しており、他のコルーチン (スレッド) は書き込みロックを取得するときに読み取りロックの解放を待機する必要があります
- このコルーチン (またはスレッド) は既に読み取りロックを所有しているのに、読み取りロックを再度取得するときに他の書き込みロックを待機する必要があるのはなぜでしょうか?
患者が列を作って診察を受ける場合も考えられます.最初の患者は医師に尋ね,入ってからドアを閉めます.いくら(理論的には)患者が中に入る権利があっても,後ろの患者は彼が来るまでドアを開けることができません.外。
しかし、Go の認識は、前の患者が、各文の後に誰かがドアの外で待っているかどうかを確認する必要があったことです. 彼が尋ねるのを待っているので、誰もが行き詰まり、誰も医者に診てもらうことさえ考えられません.
考えてみると、これは Go の BUG だと思いますか?
Go がこのように実装する理由
github で検索しようとしたところ、次の問題が見つかりました。
タイトルから、彼が私と同じ問題に遭遇したことがわかります。
スレッドが既に書き込みロックを持っている場合、読み取りロックはハングしないはずですか? #30657
誰かが言わなければならないことをチェックしてください:
ボスは、これはGoロックの原則と一致していないと言いました.Goロックはコルーチンやスレッド情報を知りませんが、コード呼び出しのシーケンスしか知りません.つまり、読み書きロックはアップグレードもダウングレードもできません.
Java のロックはホルダー (スレッド ID) を記録しますが、Go のロックはホルダーが誰であるかを認識していないため、読み取りロックが取得された後に再度読み取りロックが取得されます。その他 コルーチンなので一様に処理されます。
これは実際に Go ソース コードのコメントに反映されており、後で気付きました。
翻訳は次のとおりです。
コルーチンが読み取りロックを保持している場合、別のコルーチンが Lock を呼び出して書き込みロックを追加する可能性があり、前の読み取りロックが解放されるまで、コルーチンは読み取りロックを取得できません. これは、読み取りロックの再帰を防ぐためです. また、ロックが最終的に利用可能になるようにします。書き込みロック呼び出しをブロックすると、新しい読み取りロックが除外されます。
しかし、この警告は目立たないため、おそらく次のような影響があります。
このシーンは、製品とプログラマーに非常に似ています。
- プロダクト マネージャー: 私はこの機能を達成したいのですが、どのように達成するかは気にしません
- Go: これは私の設計原則に違反しています。この機能を受け入れないでください
- プロダクト マネージャー: 誰もが一歩下がってください。より安価なソリューションを見つけることができます。
そのため、プログラマーは読み書きロックに関するコメントを書きました。
やっと
このデッドロック ピットは、特に Java プログラマーが Go を作成する場合に非常に簡単に踏み込むことができます。
Go の設計者はより「妄想的」であり、「悪い」設計は実装しないと判断されていると考えています. たとえば、ロックの実装はスレッドとコルーチンの情報に依存すべきではありません. 再入可能 (再帰的) ロックは悪い設計です. ですから、このようなバグがあるように見えるデザインにも、いくつかの真実があります。
もちろん、それぞれの考えがありますが、Go の読み書きロックをこのように実装することは合理的だと思いますか?