マルチスレッドプログラミングのよくある問題と落とし穴

Maker.io Staff | 2022年5月/16日

Common Problems and Pitfalls with Multi-Thread Programming

アプリケーションにマルチスレッド処理を導入する場合、プログラマは並行処理するコンピュータプログラムで一般的に発生しうる多くの問題に注意を払わなければなりません。例えば、プログラマは、これらの並行タスクがCPU上で実行する順序を決めたい場合があります。さらに、データの整合性の問題を防ぐために、共有変数やファイルにアクセスできるプロセス数を制限したい場合もあります。

並行処理のプログラムによくある問題については、以下をお読みください。

マルチスレッドプログラミングの主な問題点

一般に、並行アルゴリズムを開発する最初のステップは、元のプログラムを、スレッドがそれぞれできるだけ独立して問題解決できるような小さなチャンクに分割することです。

しかし、実際には、並行プログラムのスレッドやプロセスは、コンピュータの限られた共有リソースをめぐって競合します。さらに、ハードディスクに1ファイル構成の場合など、さらに不足しているリソースもあります。すべてのスレッドがデータの一貫性を気にすることなくファイルを読むことができる一方で、ファイルに書き込むことができるのは1つのスレッドのみです。さらに、多くのアルゴリズムでは、スレッドが関連するステップを実行する順番があらかじめ厳密に決められています。プログラムに複数のスレッドが含まれる場合、そのうちのいくつかは、アルゴリズムの次のステップの作業に進む前に、他のスレッドの終了を待つ必要があるかもしれません。

したがって、マルチスレッドプログラミングの2大問題は、同時アクセス問題の管理と、すべてのスレッドが与えられたシーケンスを正しい順序で実行することの保証です。

なぜプログラマがスレッドの同期について心配する必要があるのか

前回の記事でも述べたように、OSのスケジューラがユーザープログラムのスレッドを任意の順番で実行することは、初期には保証されていません。OSは、例えばシステムソフトウェアの注意を必要とするコールバックイベントなどにより、特定のスレッドを実行の途中で一時停止し、後で再開することがあります。その記事では、プロセス内のすべてのスレッドが同じヒープ領域を共有することについても触れています。したがって、すべてのスレッドが、いつでも、どのような順序でも、グローバルプログラム変数にアクセスし、変更することができます。この方法はスレッド間のデータ交換に実用的ですが、共有メモリ領域は正しく管理されないとすぐに問題が発生する可能性があります。

簡単な例として、あなたの銀行が正しく実装されていない決済アルゴリズムを使用しているとします。あなたがオンラインで商品を注文すると、その会社は商品を発送すると同時にあなたの口座に請求します。銀行にとっては不運ですが、あなたが食料品を買いに出かけているときに起こったとします。チェックアウト端末にカードを通すと、地元の店舗のキャッシュレジスタが銀行から口座残高を取得します。その瞬間、オンラインショップがあなたの銀行に支払いを要求し、決済が完了したとします。しかし、地元の小売店の端末は少し遅く、支払い処理に数秒かかります。すると、食料品店のレジ端末は、先に取得していた古い残高を使って銀行に支払い要求をします。そうすることで、数秒前にオンラインショップから送信されたトランザクションをレジ端末が上書きしてしまうのです。

このフローチャートは、上記の銀行の例を示しています。同時進行の両プロセス(オンラインショップと
食料品店の端末)は、銀行から口座残高を取得します。しかし、食料品店の端末がネット通販の正しい
取引結果を上書きしてしまい、銀行口座の残高に誤りが生じてしまいます。

プログラムの同期性および一貫性を確立する方法

共有メモリの領域が2つの異なるスレッドによって同時に変更されないように保護する機構は、すべて同期機構と呼ばれます。なお、この説明は同一プロセス内の2つのスレッドの共有メモリ空間に限定されるものではありません。それどころか、他の共有メモリ領域、例えばHDD上のテキストファイルなどにも適用されます。このような仕組みがない場合、スレッドが他のスレッドの計算結果を上書きしたり破損したりすることで、誤った結果を導く可能性があります。一般に、プログラマが並行アプリケーションにおいてさまざまなレベルの一貫性を実現するためには、2つの広範なコンセプトが役に立ちます。

条件同期(Condition synchronization)によって、2つ以上のスレッドがコードを実行する際に、特定の順序に従うことを保証します。例えば、スレッドAがタスクXを完了してから、スレッドBがタスクYを実行する、と定義することができます。

排他制御(Mutual exclusion、mutex)により、プログラムのクリティカルな領域には常に1つのスレッドしか入れないように強制することで、データの一貫性を確保することができます。したがって、排他制御は、仮説の銀行の例で説明したようなデータの一貫性の問題を回避するのに役立ちます。

並行プログラムにおけるデッドロックとは?

競合状態や同期の問題は、開発者が条件同期や排他制御を使用する際に注意を払わないと、プログラムにデッドロックを引き起こす可能性があります。デッドロックは、2つ以上の同時実行スレッドやプロセスが互いに進行をブロックした場合に発生します。通常は、解決されることのない循環依存関係を待ち続けるためです。


Common Problems and Pitfalls with Multi-Thread Programming

この例では、スレッドAがファイルXのアクセス権を持ち、スレッドBがファイルYをロックし、スレッドCがファイルZを使用しています。どのスレッドも、他のスレッドの1つが現在保持しているファイルをリリースするまで、進行することはできません。しかし、どれかのスレッドが進展しない限り、それは起こりません。そのため、どのスレッドも進行することができず、永遠にブロックされ続けるのです。

次のようなシナリオを想像してください。食料品店のシリアル売り場で、あなたが買おうとしているシリアルのすぐ前に誰かが立っています。そこであなたは、彼らが立ち去るのを待つことにし、待つ間、棚に置いてある他のシリアルを見ることにしました。 しかし別の人が、あなたが今見ているシリアルを取って立ち去るのを待ってることを、あなたは知りません。 したがって、シリアルの前に立っている2人がシリアルを取って立ち去らないと、誰も前に進めません。このシナリオは、デッドロックの非常に単純な例です。

残念ながら、このジレンマの解決方法は複数存在しますが、万能の解決策はありません。しかし、並行プログラミングでは、通常、デッドロックの可能性を何としても回避することが最良の方法です。デッドロックの復旧は、通常、オペレーティングシステム次第です。それでも、プログラマは、ウォッチドッグスレッドなどのカスタム機構を実装して、他のスレッドが一定期間何も進行しない状況を検出できます。そうすることで、ウォッチドッグスレッドで必要に応じてスレッドを再開することができます。

まとめ

スレッドはいくつかのリソースを共有し、同じハードウェア上で実行されます。したがって、オペレーティングシステムは、各スレッドがある時点で必ず実行されるように、スレッドをスケジュールする必要があります。しかし、スレッドが特定の順序でコードを実行する保証はありません。もし、あなたのアルゴリズムが特定の実行順序を守るスレッドに依存しているなら、条件同期を確保する必要があります。

同様に、異なるスレッドを使用してデータにアクセスすることも、すぐに問題になります。通常、データの読み取りは問題ありません。しかし、スレッドが共有リソースやメモリロケーション(例えば、HDD上のファイル)に情報を書き込み始めた時点で、プログラマはアクセスされる大切なリソースは常に1つのスレッドしか書き込みできないようにしなければなりません。そうしないと、他のスレッドが書き込んだデータを上書きしてしまい、結果に破損が生じる可能性があります。




オリジナル・ソース(English)