WaitHandleを使ってスレッドの排他処理を管理する方法をまとめました。
- 排他処理の必要性
- EventWaitHandleとManualResetEvent
- AutoResetEventでスレッド間の排他処理を行う
- ManualResetEventSlim
- CountdownEvent
- Barrier
- 参考
Unity2019.3.3
.Net Standard 2.0
※C#の話題ですがUnityで実行確認しています
排他処理の必要性
いま下記のようなスクリプトを実行してみます。
using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { [SerializeField] private int _test; private async void Start() { var tasks = new List<Task>(); for (var i = 0; i < 10; i++) { tasks.Add(SomeTask()); } await Task.WhenAll(tasks); Debug.Log("completed"); } private Task SomeTask() { return Task.Run(() => { for (var i = 0; i < 10; i++) { var value = _test; var random = new System.Random(Thread.CurrentThread.ManagedThreadId + i); Thread.Sleep(random.Next(1, 10) * 100); value++; _test = value; } }); } }
このスクリプトでは_test
の値をローカル変数に格納し、0.1~1秒後にそれをインクリメントして_test
に詰めなおします。
もし単一のスレッドで実行すれば_test
は順次インクリメントされるはずですが、
上記では複数のスレッドが同時に_test
を読み書きするため、値が増えたり減ったりしてしまいます。
あるスレッドが_test
の値を読み書きしている間に他のスレッドが処理を行わないようにするためには排他処理を行う必要があります。
EventWaitHandleとManualResetEvent
このようなスレッドの排他処理を行うにはまずEventWaitHandleの挙動を理解する必要があります。
EventWaitHandleは以下のようにして使用します。
using System.Threading; using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { [SerializeField] private int _test; // EventWaitHandleを生成 private EventWaitHandle _waitHandle = new EventWaitHandle(false, EventResetMode.ManualReset); private async void Start() { await SomeTask(); Debug.Log("completed"); _waitHandle.Dispose(); } private void Update() { if (Input.GetKeyDown(KeyCode.A)) { // シグナル状態にする _waitHandle.Set(); } } private Task SomeTask() { return Task.Run(() => { for (var i = 0; i < 10; i++) { // シグナル状態になるまでスレッドを待機 _waitHandle.WaitOne(); // 非シグナル状態にする _waitHandle.Reset(); _test++; } }); } }
WaitHandle.WaitOne()
を呼ぶと、現在のWaitHandleが「非シグナル状態」だったら、「シグナル状態」になるまでそのスレッドが待機状態になります。
そしてこの「シグナル状態」にはWaitHandle.Set()
を呼ぶことで入ります。
またシグナル状態のときにWaitHandle.Reset()
を呼ぶと非シグナル状態に戻ります。
さらにシグナルの初期状態はEventWaitHandle
のコンストラクタの第一引数で指定することができます。
これらを踏まえて上記のスクリプトを見てみると、Aキーが押されるたびに_test
がインクリメントされる処理になっていることがわかります。
また、EventWaitHandleの代わりにManualResetEventを使っても同じ挙動となります。
// private EventWaitHandle _waitHandle = new EventWaitHandle(false, EventResetMode.ManualReset); private EventWaitHandle _waitHandle = new ManualResetEvent(false);
AutoResetEventでスレッド間の排他処理を行う
さて前節ではEventWaitHandleのコンストラクタの第二引数にEventResetMode.ManualReset
を与えました。
この代わりに下記のようにEventResetMode.AutoResetを与えると、
WaitHandle.WaitOne()`で待機中のスレッドが一つ解放された時点で自動的に非シグナル状態になります。
これを利用すると以下のようにマルチスレッドの排他制御が行えます。
using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { [SerializeField] private int _test; // EventWaitHandleを生成 // 第一引数=シグナルの初期状態はtrue、第二引数はAutoResetにする private static EventWaitHandle _waitHandle = new EventWaitHandle(true, EventResetMode.AutoReset); private async void Start() { var tasks = new List<Task>(); for (var i = 0; i < 10; i++) { tasks.Add(SomeTask()); } await Task.WhenAll(tasks); Debug.Log("completed"); _waitHandle.Dispose(); } private Task SomeTask() { return Task.Run(() => { // シグナル状態になるまでスレッドを待機 _waitHandle.WaitOne(); for (var i = 0; i < 10; i++) { var value = _test; var random = new System.Random(Thread.CurrentThread.ManagedThreadId + i); Thread.Sleep(random.Next(1, 10) * 100); value++; _test = value; } // シグナル状態にする // 一つだけ待機中のスレッドを解放したらすぐ非シグナル状態にする(EventResetMode.AutoResetなので) _waitHandle.Set(); }); } }
上記では、_test
変数を読む前にWaitHandle.WaitOne()
でスレッドを待機させ、
_test
に値を代入し終わった後にWaitHandle.Set()
でスレッドを一つだけ解放させています。
またEventWaitHandleのコンストラクタでシグナルを初期状態でtrueにしています。
これによりいずれか一つのスレッドのみが変数を読み書きしている状態が実現され、_test
は綺麗にインクリメントされていきます。
なお、EventWaitHandleの代わりにAutoResetEventを使っても同じ挙動となります。
// private EventWaitHandle _waitHandle = new EventWaitHandle(true, EventResetMode.AutoReset); private EventWaitHandle _waitHandle = new AutoResetEvent(true);
ManualResetEventSlim
その他のWaitHandleとして、ManualResetEventの軽量版のManualResetEventSlimというクラスも用意されています。
使い方はほぼ同じなので以下に実装例だけ示します。
using System.Threading; using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { [SerializeField] private int _test; // ManualResetEventSlimを使う private ManualResetEventSlim _waitHandle = new ManualResetEventSlim(false); private async void Start() { await SomeTask(); Debug.Log("completed"); _waitHandle.Dispose(); } private void Update() { if (Input.GetKeyDown(KeyCode.A)) { // シグナル状態にする _waitHandle.Set(); } } private Task SomeTask() { return Task.Run(() => { for (var i = 0; i < 10; i++) { // シグナル状態になるまでスレッドを待機 _waitHandle.Wait(); // 非シグナル状態にする _waitHandle.Reset(); _test++; } }); } }
CountdownEvent
CountdownEventは、カウントがゼロになったときだけスレッドを解放します。
カウントはCountdownEvent.Signal()
を呼ぶことで減らすことができます。
using System.Threading; using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { [SerializeField] private int _test; private CountdownEvent _countdownEvent = new CountdownEvent(5); private async void Start() { await SomeTask(); Debug.Log("completed"); _countdownEvent.Dispose(); } private void Update() { if (Input.GetKeyDown(KeyCode.A)) { // カウントダウンを減らす _countdownEvent.Signal(); } } private Task SomeTask() { return Task.Run(() => { for (var i = 0; i < 10; i++) { // シグナル状態になるまでスレッドを待機 _countdownEvent.Wait(); // 非シグナル状態にする(カウントを初期状態に戻す) _countdownEvent.Reset(); _test++; } }); } }
上記の例では5回Aキーを叩くごとに一回だけ_test
がインクリメントされていきます。
Barrier
Barrierを使うとすべてのスレッドが当該地点に来るまで待つことができます。
using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { [SerializeField] private int[] _tests; private static Barrier _barrier = new Barrier(10); private async void Start() { _tests = new int[10]; var tasks = new List<Task>(); for (var i = 0; i < 10; i++) { tasks.Add(SomeTask(i)); } await Task.WhenAll(tasks); Debug.Log("completed"); _barrier.Dispose(); } private Task SomeTask(int threadIndex) { return Task.Run(() => { for (var i = 0; i < 10; i++) { var value = _tests[threadIndex]; var random = new System.Random(Thread.CurrentThread.ManagedThreadId); var sleepTime = random.Next(1, 10) * 200; Thread.Sleep(sleepTime); value++; _tests[threadIndex] = value; // 他のスレッドがここに到達するまで待機 _barrier.SignalAndWait(); } }); } }
上記のスクリプトでは、すべてのスレッドでインクリメントが終わってから次の数値へのインクリメントが始まる挙動になります。