【C#】EventWaitHandleを使ってスレッドの排他処理を管理する方法まとめ

WaitHandleを使ってスレッドの排他処理を管理する方法をまとめました。

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を読み書きするため、値が増えたり減ったりしてしまいます。

f:id:halya_11:20200518220121g:plain

あるスレッドが_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がインクリメントされる処理になっていることがわかります。

f:id:halya_11:20200518223431g:plain

また、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は綺麗にインクリメントされていきます。

f:id:halya_11:20200518224410g:plain

なお、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();
            }
        });
    }
}

上記のスクリプトでは、すべてのスレッドでインクリメントが終わってから次の数値へのインクリメントが始まる挙動になります。

f:id:halya_11:20200518231725g:plain

参考

docs.microsoft.com