はじめに
並列処理とか結構前から一般的になってきていますが、
たまにSyncLockを用いた複数スレッド間の処理同期を行っているのを見かけます。
SyncLock…便利なんですけどね。
使い方を誤るとデッドロック起きちゃいますよね。
先日もあるエンドユーザーから
「たまに画面が反応しなくなって1回パソコンを強制終了かけないと使えなくなっちゃうんだけど」
というお問い合わせを頂きまして。
(不幸にも、タイトルバーのないリサイズ不可の画面全体に広がるアプリケーションだった!)
他人の書いたソースコードを確認していくのが億劫な人は結構多いはず(私もそのクチです)。
※ビジネス上用いられるものは結構レガシーなソースであることは多いですし。
いやレガシーなのが悪いというわけではないんですけれども…
というわけで以下できるだけ簡略化したサンプルコードとともに、誤った例をば。
※実際はもっとマシです。デッドロックは稀にしか発生しませんでしたので念のため。
サンプルコード
コードはMainFormという名前のFormにMessageTextBoxという名前のTextBoxを貼り付けただけのものです。
TextBoxはMultiLineをTrueへ。
ソリューションにプロジェクト追加してFormにコードを貼り付けてビルドして実行すると、
そのうちデッドロックしてアプリケーションを強制終了することになります。
まあ、見ただけで危険性は感じられるかとは思いますが…
Imports System.ComponentModel
Imports System.Threading
''' <summary>
''' 複数スレッドで同一オブジェクトに対するSyncLock利用時のデッドロックサンプル
''' </summary>
Public Class MainForm
''' <summary>
''' メインスレッドとワーカースレッドとでロック対象となるオブジェクト
''' </summary>
Private ReadOnly LockHandler As New Object()
''' <summary>
''' 1秒間隔でSyncLockして画面へログ出力の委譲を試みるワーカー
''' </summary>
Private WithEvents IntervalLockWorker As New BackgroundWorker() With {
.WorkerSupportsCancellation = True
}
''' <summary>
''' 0.5秒間隔でSyncLockして画面へログ出力するMain Threadで動作するタイマー
''' </summary>
Private WithEvents IntervalLockTimer As New Windows.Forms.Timer() With {
.Interval = 500
}
''' <summary>
''' Formロード
''' </summary>
''' <param name="sender"></param>
''' <param name="e"></param>
Private Sub MainForm_Load(sender As Object, e As EventArgs) Handles MyBase.Load
' 定期的にバックグラウンドでロックさせます。
Me.IntervalLockWorker.RunWorkerAsync()
' タイマーも開始させます。
Me.IntervalLockTimer.Start()
End Sub
''' <summary>
''' 画面のTextBoxへ引数で指定された文字列をログとして追加する
''' </summary>
''' <param name="message"></param>
Private Sub AppendLog(message As String)
Me.MessageTextBox.Text = String.Format(
"{0}{1}{2}{3}{4}",
Now.ToString("yyyy-MM-dd HH:mm:ss.fff"),
vbTab,
message,
vbNewLine,
Me.MessageTextBox.Text
)
End Sub
''' <summary>
''' バックグランドで1秒ごとにSyncLockしつつUI更新を試みるDoWorkイベント
''' </summary>
''' <param name="sender"></param>
''' <param name="e"></param>
Private Sub IntervalLockWorker_DoWork(sender As Object, e As DoWorkEventArgs) Handles IntervalLockWorker.DoWork
While (True)
SyncLock Me.LockHandler
If (Me.IntervalLockWorker.CancellationPending) Then
' キャンセル処理は決して到達しないダミーです。
' 無限ループって怖いね。
e.Cancel = True
Return
' NOTREACHED
End If
' ロック中にMain ThreadへUI更新を要求します。
' Main Threadがロック解除待ちなら、ここでデッドロック!!!(当たり前だよなぁ? )
Me.Invoke(New Action(Of String)(AddressOf Me.AppendLog), "IntervalLockWorker_DoWork")
Thread.Sleep(1000)
End SyncLock
End While
End Sub
''' <summary>
''' Main Threadで一定間隔でSyncLockしつつUI更新を試みるTimerイベント
''' </summary>
''' <param name="sender"></param>
''' <param name="e"></param>
Private Sub IntervalLockTimer_Tick(sender As Object, e As EventArgs) Handles IntervalLockTimer.Tick
SyncLock Me.LockHandler
' IntervalLockWorkerがLock中なら、Lock解除後に以下のステップが実行される。
Call Me.AppendLog("IntervalLockTimer_Tick")
End SyncLock
End Sub
End Class
【およそ33秒後にデッドロックが起きる例】※17:17:13頃に発生します。
解説
はもはや不要かな。
- タイマーの500ミリ秒間隔で発生するTickイベントがLockHandlerをSyncLockしながらUI更新(Main Thread)
- BackGroundで1000ミリ秒間隔でLockHandlerをSyncLockしながらUI更新(Main Threadへ更新委譲)
- 2.のSyncLock中に1.のSyncLockが起きるとMain ThreadがUI更新を試みるもLockHandlerのロック解除待ちでデッドロック(MeをSyncLockする場合でも同じ結果に)
お互いがお互いを待ち続けるという若干の哀愁を感じずにはいられない不具合なのでした。
解決策
IntervalLockTimer_Tickイベント内のSyncLockを行わないようにするだけ。
ただし、場合によってはそれだけでは解決できないことがあるので、実現しなければならない要件とともに要確認、ご注意を。
そもそもInvokeを別スレッドから行うのはSyncLockの外に出すというのも一案かもしれません。
※BeginInvokeを行うと上記サンプルコードではデッドロックは起きません。
…が、別な理由でデッドロックを誘発しているソースコードもあるかも…?
とかくマルチスレッドでのリソースアクセス管理は要注意ですよね!
ともあれ、今回は特段SyncLockを行う必要がないことが判明し、
Main ThreadのSyncLockを削除するので問題は解決したのでした。
教訓
道具は使い方をしっかり確認して、正しく使いましょう。
追記
ソースコードを貼り付けて動かすのも面倒な人もいるかもしれないので実際の挙動をgifとして貼り付け。