スレッドを扱う私の「パラダイム」

マルチスレッドアプリケーションの記述を学んだとき、この分野の多くの文献と参考情報を読み直しました。 しかし、理論と実践の間には大きなギャップがあります。 私はたくさんの円錐形を満たしましたが、それでも時々自分の流れから頭に乗ります。 私自身のために、私は厳密に従おうとするいくつかのルールのセットを開発しました。これは、マルチスレッドコードの記述に大いに役立ちます。



スレッド同期に関連するエラーはデバッグが非常に難しいため、ここで最も効果的な方法は、これらのエラーを防ぐことです。 このため、さまざまな抽象化レベルでさまざまなプログラミングパラダイムが使用されます。 下位レベルの抽象化は、同期オブジェクト(クリティカルセクション、ミューテックス、セマフォ)で動作すると見なされます。 一番上のものは、Futures and promises、STM(ソフトウェアトランザクションメモリ)、非同期メッセージ交換などのプログラミングパラダイムです。 多くの場合、上位レベルの抽象化は常に下位レベルに基づいています。



この記事では、低レベルの抽象化でコードを書くスタイルを共有します。 私はデルフィストなので、すべての例はDelphiで行われますが、以下のすべては他のプログラミング言語にも当てはまります(もちろん同期オブジェクトを扱うことができます)



スレッドセーフオブジェクト



最初のルールは、スレッド間でスレッドセーフなオブジェクトでのみ動作することです。 これは最も単純で、最も論理的で理解可能なルールです。 ただし、ここでもいくつかの機能があります。 オブジェクトは完全にスレッドセーフである必要があります。つまり、すべてのパブリックメソッド(コンストラクターとデストラクターを除く)を同期する必要があります。 コンストラクタとデストラクタは、オブジェクトの外部で常に同期する必要があります。 スレッドを使用する初期段階の間違いの1つ-デザイナーとデストラクタの同期を忘れていました。 また、コンストラクターでデルファイに問題がない場合(コンストラクターが既に機能している場合にのみオブジェクトへのポインターを取得します)、デストラクターに注意する必要があります。 破壊の同期は非常に滑りやすいトピックであり、実装の最適な方法については説明できません(マルチスレッドプログラミングの天才ではありませんが、ただ学ぶだけです)。 私自身は、TThreadクラスのデストラクタを使用してこのような同期を実行しようとしていますが、これはスレッドの存続期間中に存在するオブジェクトにのみ当てはまります。



ロック



説明


もう1つの一般的な問題はデッドロックです。 これは同期中に発生する最も一般的な問題であるという事実にもかかわらず、明らかなルールはありません。 スレッドが一度に複数の同期を実行しない場合、デッドロックは発生しません。 ここでは、同期という言葉で、リソースのブロックとリソースの待機の両方を意味します。 したがって、ミューテックスでの停止、ミューテックスのクローズ、セマフォの入力、クリティカルセクションの入力、またはメッセージ(SendMessage)の送信はすべて同期です。 実際、スレッドAがリソースを期待し、同時に単一のリソースをブロックしていない場合、誰もそれを期待しないため、相互のブロッキングはありません。





この状態を理解し、厳守することが、デッドロックが発生しない鍵です。 私が話していることの例を見てみましょう。 いくつかのクラスがあるとしましょう:

TMyObj = class private FCS: TCriticalSection; FA: Integer; FB: Integer; public property A: Integer read GetA write SetA; property B: Integer read GetB write SetB; function DoSomething: Integer; //...  end;
      
      





スレッドセーフオブジェクトを用意する必要があることに続いて、ゲッターとセッターを介してクリティカルセクションを持つプロパティAとBを実装しました。

 function TMyObj.GetA: Integer; begin FCS.Enter; try Result := FA; finally FCS.Leave; end; end; function TMyObj.GetB: Integer; begin FCS.Enter; try Result := FB; finally FCS.Leave; end; end; procedure TMyObj.SetA(const Value: Integer); begin FCS.Enter; try FA := Value; finally FCS.Leave; end; end; procedure TMyObj.SetB(const Value: Integer); begin FCS.Enter; try FB := Value; finally FCS.Leave; end; end;
      
      





DoSomething関数がAとBで次のように機能するとします。

 function TMyObj.DoSomething: Integer; begin Result := SendMessage(SomeHandle, WM_MYMESSAGE, A mod 3, B mod 4); end;
      
      





ちょっと、しかし、私たちはAとBのために1つの重要なセクションを使用します、経験の浅い作家は言うでしょう。 そしてすぐにこの作品を「最適化」します:

 function TMyObj.DoSomething: Integer; begin FCS.Enter; try Result := SendMessage(SomeHandle, WM_MYMESSAGE, FA mod 3, FB mod 4); finally FCS.Leave; end; end;
      
      





そしてそれは間違いです。 ここで、WM_MYMESSAGEハンドラーのフィールドAまたはBにアクセスしようとすると、デッドロックが発生します。 コードの量が少なく、データが単純であるため、このデッドロックは明らかです。 しかし、コードが巨大になると、たくさんの関係と依存関係が現れると、些細なことではなくなります。 ルールによると、一度に1つの同期のみを処理するには、上記のコードを次のように「最適化」できます。

 function TMyObj.DoSomething: Integer; var k, n: Integer; begin FCS.Enter; try k := FA mod 3; n := FB mod 4; finally FCS.Leave; end; Result := SendMessage(SomeHandle, WM_MYMESSAGE, k, n); end;
      
      





したがって、常に、新しい同期を呼び出す前に、他の同期オブジェクトを解放する必要があります。 コードの精神:

 FCS1.Enter; try //bla bla bla FCS2.Enter; try //bla bla bla finally FCS2.Leave; end; //bla bla bla finally FCS1.Leave; end;
      
      





ほとんどの場合、マルチスレッド化されたコードと見なすことができます。 あなたはすでにそれを書き換える方法を想像していると思います:

 FCS1.Enter; try //bla bla bla //bla bla bla //  / ,       FCS2 finally FCS1.Leave; end; FCS2.Enter; try //   / //bla bla bla finally FCS2.Leave; end;
      
      





このアプローチは、パフォーマンスに影響する可能性のあるデータをコピーする必要があることを示しています。 ただし、ほとんどの場合、データボリュームは大きくないため、コピーを許可できます。 コピーせずにアプローチを取るには、 3回 4回考えてください。



診断


コンパイルレベルでは、これは診断できません。 ただし、リアルタイムで診断を実行することは可能です。 これを行うには、各スレッドの現在の同期オブジェクトを保存する必要があります。 Delphiの診断ツールの実装例を次に示します。

 procedure InitSyncObject; procedure PushSyncObject(handle: Cardinal); overload; procedure PushSyncObject(obj: TObject); overload; procedure PopSyncObject; implementation threadvar syncobj: Cardinal; synccnt: Cardinal; procedure InitSyncObject; begin syncobj := 0; synccnt := 0; end; procedure PushSyncObject(handle: Cardinal); begin if handle = 0 then raise EProgrammerNotFound.Create('   '); if (syncobj <> 0) and (handle <> syncobj) then raise EProgrammerNotFound.Create('       '); syncobj := handle; inc(synccnt); end; procedure PushSyncObject(obj: TObject); begin PushSyncObject(Cardinal(obj)); end; procedure PopSyncObject; begin if (syncobj = 0) or (synccnt = 0) then raise EProgrammerNotFound.Create('   '); Dec(synccnt); if synccnt = 0 then syncobj := 0; end;
      
      





新しいスレッドを開始するときにInitSyncObjectを呼び出します。

同期オブジェクトをキャプチャする前にPushThreadObjectを呼び出し、同期オブジェクトを解放した後にPopThreadObjectを呼び出します。

これらの関数を使用するために、SyncObjs.pasモジュールのコードを新しいモジュール、たとえばSyncObjsDbg.pasにコピーすることをお勧めします。 同期オブジェクトの基本クラスがあります。

  TSynchroObject = class(TObject) public procedure Acquire; virtual; procedure Release; virtual; end;
      
      





AcquireでPushSyncObject(Self)への呼び出しを追加し、Release PopSyncObjectで追加します。 また、これらの関数のWaitForメソッドをTHandleObjectでフレーム化することを忘れないでください。 さらに、TThread.Synchronizeメソッドを使用する場合、呼び出しの前にTThreadオブジェクトを保存し、それを抽出した後(PopSyncObject)、SendMessageまたはWaitFor関数APIを使用する場合、呼び出しの前にハンドルを保存し(PushSyncObject)、抽出します(PopSyncObject)。

これで、2番目の同期オブジェクトをキャプチャしようとすると、例外が発生し、モジュール(SyncObjs / SyncObjsDbg)は定義によって変更できます。



悪いコード


悪いコードを例に取りましょう... Classes.pasモジュールのTThreadListクラス

  TThreadList = class private FList: TList; FLock: TRTLCriticalSection; FDuplicates: TDuplicates; public constructor Create; destructor Destroy; override; procedure Add(Item: Pointer); procedure Clear; function LockList: TList; procedure Remove(Item: Pointer); inline; procedure RemoveItem(Item: Pointer; Direction: TList.TDirection); procedure UnlockList; inline; property Duplicates: TDuplicates read FDuplicates write FDuplicates; end;
      
      





クリティカルセクションを介してアクセスするスレッドセーフクラスのように見えますが、何が悪いのでしょうか。 ただし、悪い点は、LockListメソッドとUnlockListメソッドを使用できることです。 LockList呼び出しとUnlockList呼び出しのペア間で同期がある場合、上記の規則に違反します。 したがって、いくつかのロック/ロック解除機能を公開するのは良くありません。そのような機能は非常に慎重に使用する必要があります。



ところで、MicrosoftのさまざまなAPIは、多くの場合、Enumインターフェイスを返します。 なぜ彼らはこれをしているのですか? 結局のところ、たとえばCount関数を使用して数量を取得し、GetItem関数を使用してループで要素をインデックスで取得する方がはるかに便利です。 ただし、この場合、ループ内で作業している間は誰もリストを変更できないように、さらに2つのロック/ロック解除関数を作成する必要があります。 さらに、内部同期を実行するLock / Unlockの間に突然API関数を呼び出すと、簡単にデッドロックを取得できます。 したがって、すべてはEnumインターフェイスを介して行われます。 このようなインターフェイスを受信すると、オブジェクトのリストが作成され、それらの参照カウントが増加します。 これは、少なくとも列挙型インターフェイスが存在するまで、列挙型インターフェイスの単一のオブジェクトが破棄されないことを意味します。列挙型で作業している間、全員が内部リストにアクセスでき、このリストを変更することもできます。



おそらく十分



プレビューボタンを押して、結果のボリュームを確認し、今のところ十分であることに気付きました。 次の記事では、TThread Delphiクラスについて説明し、スレッドを作成および操作する際に従う規則を示します。



All Articles