ニートが学ぶ非同期処理 ロックとデッドロック

はいどーもこんにちは、非同期処理をガンガン進めて行きたいニートです。
本日はロック機能について学んでみようと思います。
ちなみに今回は書籍ではなく、この記事を参考に勉強してますよ。
ちょっと古いですが、めちゃんこわかりやすかったので、第一回から全部読もうと思ってます。

なぜロックが必要なのか

マルチスレッドプログラミングでなぜロックが必要なのかと言えば、それはズバリ「データの整合性を保つため」でしょう。
言い換えれば、ロックしなければデータの整合性が破壊される可能性があります。

例えば以下のコードを実行したとき、コンソールログに出力される値は4になったり5になったり6になったりと変化します。
これは先ほど述べたようにデータの整合性が保たれていないためです。

なぜデータの整合性が破壊されるのか

上記のコードがなぜデータの整合性を保てないのかと言えば、スレッド間でメモリを共有しているためです。
つまり同じデータ(先の例で言えばNumプロパティが指すメモリ)をスレッド間で共有しているためです。
1つのスレッドがNumプロパティにアクセスして値を書き換えるとき(Num++の部分)、これはおおよそ以下の3つの手順に分解できます。

1.メモリから演算用の領域に値をコピー
2.演算用の領域で値に1を加算
3.演算用の領域からメモリに値を戻す(コピーする)

Numプロパティの現在値が0のとき、Aというスレッドで上記で言う1の作業(メモリから演算用の領域に値をコピー)を終えたとき、メモリの値はまだ0です。
このとき、BというスレッドもNumプロパティにアクセスし、メモリから演算用の領域に値をコピーしたとします(つまり1の作業)。
するとどうなるかと言えば、以下の通りです(順番はこの通りとは限らない)。

1.スレッドAで0という値に1を加算
2.スレッドAはNumプロパティに値を戻す
3.Numプロパティの値は1になる
3.スレッドBでも0という値に1を加算
4.スレッドBはNumプロパティに値を戻す
5.Numプロパティの値は1になる

「Num++」という処理を二回おこなったため、本来なら結果は「2」となってほしいのに、整合性が破壊されたため結果が「1」になってしまっています。
おまけに、必ずしも毎回「1」になるわけではなく、「2」になる場合もあるので厄介です。
これではデバッグが困難ですし、プログラムとしても使い物になりません。

ロックする

値を安全に書いたり読んだりするためにはロックを使う必要があります(他の方法も色々ありますが、ここではロックのみに焦点を当てます)。
というわけで先ほどのコードをロックしたバージョンに書き直してみます。

lockステートメントを用いることで、対象のリソース(今回なら_lockObj)をロックすることができます。
スレッドAが_lockObjをロックしているときに、スレッドBが_lockObjをロックすることはできません。
つまりスレッドBは_lockObjをロックする手前で処理が一時停止することになります。
こうすると何が嬉しいかと言えば、複数のスレッドが「Num++」の処理を同時に行うことがなくなるため、先ほどの述べたような「データの整合性が破壊される」ということがなくなります。
lockステートメントを無事抜けたら、lock状態から解放されたことになります(つまり別スレッドがアクセス可能になる)。

ここで注意しておきたいのが、「lockステートメントは_lockObjを保護しているわけではない」という点です。
もしもlockステートメントが_lockObjを保護しているなら、Numプロパティは全然関係ないですよね。
今回の場合はあくまでも「_lockObjをロックする。もし別スレッドで_lockObjがロックされていたら、そこで処理を一時停止する」という処理をするだけです。
つまりNumプロパティを別の場所でも使っていて、その値が_lockObjによってロックされていなければ、複数のスレッドが同時にNumプロパティの値を変更する可能性は依然としてあります。
言い換えるなら、適切にlockしなければ「データの整合性が破壊される」可能性があるということです。
このへんがマルチスレッドプログラミングの面倒なところですね……。

デッドロックが起きる場合

lockをする際に注意しなければいけないのが、デッドロックです。
リソースをお互いに待ち続けることによって発生するデッドロックが最も有名でしょう。
例えば以下のようなコードでデッドロックが発生します。

これを実行すると、ほとんどの環境では

・最初に「Dowork2開始」と表示される
・次の行に「DoWork1開始」と表示される
・あとは何も起こらない

となると思います。

なぜこんなことが起こるのか?
まずDoWork1はメインスレッドとは別のスレッドで起動させようとしています(起動に若干の時間がかかるので、まだ中身は実行されない)。
次にDoWork2の処理に移ります。
このときDoWork2の中では「_lockObj2」でリソースをロックします。
その後、メインスレッドを100ミリ秒スリープさせます。
100ミリ秒スリープしている間に別スレッドの起動が完了し、DoWork1の方に進みます。
このときDoWork1では「_lockObj1」でリソースをロックします。
そしてDoWork2と同じく別スレッドを100ミリ秒スリープさせます。

ここまでの状態ではまだデッドロックは発生していません。
問題はここからです。
まずDoWork2が先にスリープ状態から戻ります。
すると_lockObj1にロックをかけようとするのですが、すでにDoWork1の中で(つまり別スレッドで)_lockObj1にロックをかけているため、ロックをかけられません。
DoWork2はここでいったん処理を止めます。

次はDoWork1のスリープが解除され、_lockObj2にロックをかけようとします。
しかしこちらもDoWork2の中で(つまりメインスレッドで)_lockObj2にロックをかけているため、ロックをかけられません。
DoWork1もここでいったん処理を止めます。

するとどうなるか?
お互いがお互いにロック状態の解放を待って停止しているため、永遠にロックが解除されません。
つまり「DoWorkXの内側lock」という文字列に到達することがありません。
DoWork2はメインスレッドで実行されていますので、Mainメソッド内の「ここに到達しない」というコンソールログの出力処理にも到達しないことになります(なぜならメインスレッドの処理はDoWork2の途中で止められているから)。

これがデッドロックです。

他のデッドロックのパターン

上述した「リソースをお互いに待ち続ける場合」のデッドロック以外にもデッドロックは起きます。
例えば「スレッドプールを無駄遣い」してもデッドロックが起きるようです。
これについては僕もまだ正確には理解できていないので長々とは書きませんが、このパターンのデッドロックを避けるには以下の方法を取るとよいようです(参照:http://www.atmarkit.co.jp/ait/articles/0506/15/news114.html)。

・スレッドプールで実行させるリクエストの中に、さらにスレッドプールを使用して実行されるようなコードを含めない
・スレッドプールを利用する場合は、その各処理が比較的短時間で終了するように注意する

特に再帰処理でスレッドプールを利用するとかは最悪な気がしますね。
ちなみにですが、どんな方法がスレッドプールを使うのかは僕の記事第一回目が参考になるかも。

終わりに

本記事ではロックと、ロックによって発生するデッドロックについて学びました。
以前もこの辺はかる~く勉強していたのですが、当時はぶっちゃけあんまり理解できていませんでしたw
ですが今回はかなり「なるほどこういうことか!」という感覚を持って読み進められたので、これは大きな前進だなと思っています。

ほなそんな感じでまた。

おまけ

Timerを利用したスレッドプールの非同期処理も見つけたのでメモします。
なんでこんな場所かというと、挟む場所が特になかったからですw

例えば1秒ごとに現在の時間を取得したい場合、以下のようになります。

めちゃ簡単ですね。
たったこれだけで、スレッドプールのスレッドで、指定したメソッドが実行されます。
覚えておいたらどこかで使えるかも。

フォローする