RubyでのGILの仕組み。 パート3. GILはコードスレッドを安全にしますか?





前の2つの部分の翻訳:

前編

第二部



これはJesse Storimerによる記事です 彼は、Unix fuワークショップ 、素晴らしいRubyハックを学び、サーバースタック開発スキルを向上させたいRuby開発者向けのオンライン教室で講演しています。 参加者の数は限られているため、空いている席がある間急いでください。 彼はまた、 「Unixプロセスの操作」「TCPソケットの操作」「Rubyのスレッドの操作」という本の著者でもあります。



RubyコミュニティのインタープリターのMRI実装には、GILに関する誤解がいくつかあります。 この記事の主な質問に対する答えを読まずに知りたい場合、GILはRubyコードをスレッドセーフにしません。



しかし、あなたは私の言葉を当たり前に受け取るべきではありません。



この一連の記事は、GILが技術レベルで何であるかを理解する試みから始まりました。 最初の部分では、MRI実装で使用されるCコードで競合状態の条件が現れる場所について説明します。 しかし、少なくともArray#<<



メソッドについては、GILがこれを回避したようです。



2番目の部分では、GILが実際にMRIのインラインメソッドのアトミックな実装を行うことを確認します。 言い換えれば、これは競合状態の発生を排除します。 ただし、これは、MRI自体の組み込み関数にのみ適用され、Rubyコードには適用されません。 したがって、「GILは、Rubyコードがスレッドセーフであるという保証を提供しますか?」という疑問が残りました。



私はすでにこの質問に答えました。 今、私はこれについての誤解を止めたいです。



もう一度レースの状態について



一部のデータが複数のストリームで共有されている場合、競合状態が発生する可能性があり、それらは同時にこのデータを処理しようとします。 これが同期なしで、たとえばブロックなしで発生すると、プログラムが予期しない動作を開始し、データが失われる可能性があります。



一歩後退して、競合状態がどのように発生するかを思い出しましょう。 この記事のこの部分では、次のRubyコード例を使用します。



 class Sheep def initialize @shorn = false end def shorn? @shorn end def shear! puts "shearing..." @shorn = true end end
      
      







このクラスには新しいものはありません。 羊は出生時にトリミングされません。 「shear!」メソッドはヘアカットを実行し、羊をすでに刈り込んだものとしてマークします。







 sheep = Sheep.new 5.times.map do Thread.new do unless sheep.shorn? sheep.shear! end end end.each(&:join)
      
      







このコードは、新しい羊オブジェクトを作成し、5つのスレッドを生成します。 それらはそれぞれ、ヒツジが切断されているかどうかをチェックし、切断されていない場合は、shear!メソッドを呼び出します。



以下は、MRI 2.0でこのコードを数回実行した結果です。



 $ ruby check_then_set.rb shearing... $ ruby check_then_set.rb shearing... shearing... $ ruby check_then_set.rb shearing... shearing...
      
      







時々、1匹の羊が2回刈られます!



GILにより、コードが複数のスレッドで「正常に動作する」ことができると確信している場合は、これで問題ありません。 GILはいかなる保証もいたしません。 スクリプトを最初に実行したときは期待した結果が得られますが、次回は結果が期待されていなかったことに注意してください。 この例を引き続き実行すると、さらにいくつかのオプションが表示されます。



これらの予期しない結果は、Rubyコードの競合状態の結果です。 実際、これはかなり一般的な設計エラーパターンであり、「check-then-set race condition」という独自の名前を持っています。 この場合、2つ以上のスレッドが特定の値をチェックし、最初の値に基づいて他の値を設定します。 アトミック性を保証するものが何もないため、2つのストリームが「値検証」フェーズを経て、両方が「新しい値の設定」フェーズを完了することは完全に可能です。



レースステータスの認識



これを修正する方法を見る前に、これを認識する方法を理解してほしい。 @brixenには、同時実行のコンテキストでインターリーブの用語を説明する義務があります。 これは本当に役に立ちます。



コンテキストの切り替えは、コードのどの行でも発生する可能性があることに注意してください。 あるスレッドから別のスレッドに切り替えるとき、プログラムが個別のブロックのセットに分割されていると想像してください。 この一連のブロックのセットは、インターリーブ用のセットです。



一方では、コードの各行の後にコンテキストの切り替えが発生する可能性があります! このような交互ブロックのセットには、それぞれに1行のコードが含まれます。 一方、ストリームの本文ではコンテキストの切り替えがまったく行われない可能性があります。 この場合、各交互ブロックに完全なストリームコードがあります。 これらの両極端の間には、プログラムを交互のブロックにスライスする方法に関する多くのオプションがあります。



これらの代替のいくつかは問題ありません。 すべてのコード行が競合状態になるわけではありません。 しかし、可能な代替ブロックのセットとしてプログラムを提示することは、競合状況がいつ発生するかを理解するのに役立ちます。 一連のグラフィカルなスキームを使用して、このコードを2つのスレッドで実行する方法を示します。





ダイアグラムを単純にするために、「shear!」メソッド呼び出しをそのコードに置き換えました。



このスキームを検討してください。 ストリームAの交互のブロックは赤で強調表示され、ブロックBは青で強調表示されます。



次に、コンテキスト切り替えをシミュレートすることで、このコードがどのように代替されるかを見てみましょう。 最も単純なケースでは、実行中にスレッドが中断されない場合、これは競合状態を引き起こさず、期待される結果が得られます。 次のようになります。







イベントの順序が順番に表示されるように、回路を整理しました。 GILは実行可能コードの周りのすべてを停止するため、2つのスレッドが実際に並行して動作することはできません。 この場合のイベントは、上から下に順番に進みます。



このローテーションでは、スレッドAはすべての作業を完了し、スケジューラはコンテキストをスレッドBに切り替えます。スレッドAはすでに正常に羊を切り取り、状態変数を更新しているため、スレッドBは何もしません。



しかし、必ずしもそれほど単純ではありません。 スケジューラはいつでもコンテキストを切り替えることができます。 今回は幸運でした。



予想外の結果をもたらす、より卑劣な例を見てみましょう。







この場合、問題の原因となった時点でコンテキストの切り替えが発生します。 ストリームAは状態をチェックし、カットを開始します。 その後、スケジューラはコンテキストを切り替え、スレッドBが実行を開始しますが、スレッドAは既に羊を刈っていますが、ステータスフラグを更新することができていないため、スレッドBはそれについて何も知りません。



ストリームBは状態をチェックし、羊が刈り込まれていないと判断し、再び刈り取ります。 その後、コンテキストはスレッドAに切り替わり、スレッドAが実行を完了します。 スレッドBはステータスフラグを設定しますが、割り込み時の状態のみを記憶するため、スレッドAはこれを再度行います。



羊が2回切断されたという事実は、これを処理するのに大きな問題とは思えないかもしれませんが、アカウントに置き換えて、不満の顧客を獲得するために各ヘアカットに料金を払うだけで十分です!



これらのことの非決定的な性質を示す別の例を共有します。







各スレッドが数回少し実行されるように、コンテキストスイッチを追加しました。 プログラムのどの行でもコンテキストの切り替えが可能であることを理解する必要があります。 これらの切り替えは、コードが実行されるたびに異なるタイミングで発生する可能性があるため、1回の反復で目的の結果を得ることができ、次の反復では予期しない結果を得ることができます。



レースの状態を考えるのは本当に良いことです。 マルチスレッドコードを記述するときは、プログラムをブロックに分割できることを考慮し、さまざまな代替の影響を考慮する必要があります。 それらの一部が誤った結果につながる可能性があると思われる場合は、アプローチを再考するか、ミューテックスを介して同期を開始する必要があります。



これはひどいです!



ミューテックスを追加するだけで、このコードをスレッドセーフにできることを伝えるのが適切だと思われます。 はい、あなたは本当にそれを行うことができますが、これがひどいアプローチであることを私のポイントを証明するために、特に次の例を用意しました。 このようなコードをマルチスレッド実行用に作成しないでください。



オブジェクトへのリンクを持つ複数のスレッドがあり、その変更を行うたびに、変更の途中でコンテキストを切り替えることの結果を防ぐために適切な場所にロックがない場合、問題が発生します。



ただし、コードをブロックせずに競合状態を回避できます。 キューを使用する1つのソリューションを次に示します。



 require 'thread' class Sheep # ... end sheep = Sheep.new sheep_queue = Queue.new sheep_queue << sheep 5.times.map do Thread.new do begin sheep = sheep_queue.pop(true) sheep.shear! rescue ThreadError # raised by Queue#pop in the threads # that don't pop the sheep end end end.each(&:join)
      
      







以前とまったく同じであるため、sheepクラスの実装を削除しました。 現在、1頭の羊と彼女の毛刈りのレースで異なるストリームを一緒に使用する代わりに、同期を提供するキューが登場しました。



このコードをMRIまたは他の実際の並列Ruby実装で実行すると、毎回予期される結果が生成されます。 このコードでは競合状態を解消しました。 すべてのスレッドが多かれ少なかれ同時にQueue#pop



を呼び出しますが、このコードは内部ミューテックスを使用して、一度に1つのスレッドのみが羊を取得できるようにします。



この1つのストリームが羊を取得するとすぐに、競合状態は消滅します。 たった1つのスレッドで、彼と競争することはもうありません!



ブロックする代わりにキューを使用することを提案する理由は、キューを誤って使用するのがより難しいためです。 ロックでは、ご存じのとおり、間違いを犯しやすいです。 正しく使用しないと、デッドロックやパフォーマンス低下などの新しい問題が発生します。 データ構造の使用は、抽象化の使用に似ています。 トリッキーなものをより制限しますが、よりシンプルなAPIを取得します。



遅延初期化



私は、遅延初期化が「check-then-set race condition」の別の形式であることをすぐに指摘します。 ||=



演算子は次のように展開されます。



 @logger ||= Logger.new #   if @logger == nil @logger = Logger.new end @logger
      
      







展開されたバージョンを見て、問題が発生する可能性のある場所を考えます。 複数のスレッドがあり、同期が行われていない場合、 @logger



が数回初期化される可能性は@logger



にあります。 もちろん、この場合@logger



2回初期化しても問題はありませんが、問題の原因となるコードに同様のバグがあります。



反射



最後に、あなた自身のためにいくつかの教訓を学んでほしい。



5人中4人のプログラマは、マルチスレッドプログラミングではすべてを正しく行うことは非常に難しいことに同意します。



最後に、GILが保証しているのは、MRIに組み込まれたメソッド実装がアトミックであることだけです(ただし、 落とし穴もあります )。 この振る舞いは時々助けになりますが、GILは実際にはRuby開発者向けの堅牢なAPIとしてではなく、MRI自体を内部的に保護するように設計されています。



したがって、GILはスレッドの安全性の問題を解決しません。 先ほど言ったように、マルチスレッドプログラムを正しく作成することは困難ですが、毎日複雑な問題を解決しています。 複雑な問題に対処するための1つのオプションは抽象化です。



たとえば、コードでHTTPリクエストを行う必要がある場合、ソケットを使用する必要があります。 しかし、それはかさばり、エラーが発生しやすいため、通常は直接使用しません。 代わりに、抽象化を使用します。 HTTPクライアントは、より制限されたシンプルなAPIを提供し、ソケットでの作業を隠し、不必要なエラーから私を救います。



正しいマルチスレッドを取得することが困難な場合は、直接使用しないでください。



「プログラムに新しいスレッドを追加した場合、おそらく5つの新しいバグを追加したでしょう。」 マイク・パーハム




ストリームの周囲にはますます抽象化が見られます。 Rubyコミュニティを獲得したアプローチはアクターモデルであり、 Celluloidの形式で最も一般的な実装が行われています。 並行処理プリミティブをRubyオブジェクトモデルに接続する優れた抽象化を提供します。 Celluloidは、コードがスレッドセーフであることや競合状態がないことを保証しませんが、この点に関するベストプラクティスが含まれています。 彼に チャンスを与えることを強く勧める。



私たちが話しているこれらの問題は、RubyやMRIに特有のものではありません。 これは、マルチコアプログラミングの世界における現実です。 デバイスのコアの数は増え続けているだけで、MRIはまだこれに対応していません。 いくつかの保証にもかかわらず、マルチスレッドプログラミングでGILを使用することは間違っているようです。 これはMRI成長病の一部です。 JRubyやRubinusなどの他の実装は実際に分散して動作し、GILはありません。



並行性の抽象化が組み込まれた多くの新しい言語があります。 少なくともまだ、Rubyにはありません。 抽象化のもう1つの利点は、実装を改善できると同時に、コードが変更されないことです。 たとえば、キュ​​ーの実装がロックの使用を取り除いた場合、コードは変更なしでメリットを享受します。



今のところ、Rubyプログラマーはこれらの問題を自分で解決する方法を学ぶべきです! 並行性について学習します。 競合状態の原因を知る。 コードを交互のブロックとして想像してください。これは問題の解決に役立ちます。



最後に、今日の並行性に関する作業のほとんどをよく説明する引用を追加します。



「一緒に仕事をしない、ステータスを共有する、ステータスを共有する」




同期にデータ構造を使用すると、これがサポートされます。 アクターモデルはこの考えをサポートしています。 Go、Erlangなどの言語の同時実行性の基礎になります。



Rubyは、他の言語で機能するものと、それを自分自身に追加する方法を監視する必要があります。 Ruby開発者として、今日から何かを始めることができます。いずれかのアプローチを試して、サポートしてください。 より多くの人々が参加することで、これらのアプローチはRubyの新しい標準になる可能性があります。



この記事の下書きを分析してくれたブライアン・シライに感謝します。



All Articles