連載
» 2014年11月11日 17時33分 UPDATE

.NET TIPS:非同期:awaitを含むコードをロックするには?(SemaphoreSlim編)[C#、VB]

lock/SyncLockステートメントの代わりにSemaphoreSlimクラスを使い、await/Await演算子を含むコードで排他ロックを行う方法を解説する。

[山本康彦,BluewaterSoft/Microsoft MVP for Windows Platform Development]
.NET TIPS
Insider.NET

 

「.NET TIPS」のインデックス

連載目次

対象:.NET 4.5以降


 async修飾子/await演算子(VBではAsync修飾子/Await演算子、以降は「async/await」と略す)によって、非同期プログラミングは簡潔に記述できるようになった。ところが、複数スレッド間の排他ロックを実現するために使ってきたlockステートメント(VBではSyncLockステートメント)が、await演算子(VBではAwait演算子、以降は省略)を含むコードでは使えなくなってしまったのである。async/awaitを多用するコード(特にWindowsストアアプリやWindows Phoneアプリ)を書いていて困った経験を持つ人も多いだろう。await演算子を含むコードをロックするにはどうしたらよいのだろうか? 本稿では、SemaphoreSlimクラス(System.Threading名前空間)を使う方法を説明する。

従来のlock/SyncLockステートメントによる排他ロック

 従来は、lock/SyncLockステートメントを使ってスレッド間の排他ロックを簡潔に記述していた。まず、その方法を確認しておこう。

 次のコードは、Taskクラス(System.Threading.Tasks名前空間)を利用した非同期処理の例だ。排他ロックしていないため、2つの処理が同時に実行される。

class Program
{
  static void Main(string[] args)
  {
    var task1 = Task.Run(() => LongTimeMethod1("A")); // 1つ目の処理を別スレッドで開始
    System.Threading.Thread.Sleep(100); // ←結果が前後しないように入れてある(なくてもよい)
    var task2 = Task.Run(() => LongTimeMethod1("B")); // 2つ目の処理を別スレッドで開始
#if DEBUG
    Console.ReadKey();
#endif
  }

  // 実行に約1秒かかるメソッド(同期的に実行される通常のメソッド)
  static void LongTimeMethod1(string id)
  {
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id);
    System.Threading.Thread.Sleep(1000);
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id);
  }
}

Module Module1
  Sub Main()
    Dim task1 = Task.Run(Sub() LongTimeMethod1("A")) ' 1つ目の処理を別スレッドで開始
    System.Threading.Thread.Sleep(100) ' ←結果が前後しないように入れてある(なくてもよい)
    Dim task2 = Task.Run(Sub() LongTimeMethod1("B")) ' 2つ目の処理を別スレッドで開始
#If DEBUG Then
    Console.ReadKey()
#End If
  End Sub

  ' 実行に約1秒かかるメソッド(同期的に実行される通常のメソッド)
  Private Sub LongTimeMethod1(id As String)
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id)
    System.Threading.Thread.Sleep(1000)
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id)
  End Sub
End Module

Taskクラスを使って2つの非同期処理を実行するコード例(上:C#、下:VB)
LongTimeMethod1メソッドは、ほぼ同時に(Mainメソッド内で指定した約100ミリ秒だけずれて)処理を開始し、非同期に実行される。
なお、C#ではSystem名前空間とSystem.Threading.Tasks名前空間をインポートしておく必要がある(以下同じ)。

 これを実行してみると、次の画像のようになる。LongTimeMethod1メソッドが2つ同時に実行されている(=非同期処理)。

Taskクラスを使って2つの非同期処理を実行した例 Taskクラスを使って2つの非同期処理を実行した例

 非同期処理をするのが目的のコードなので、この結果は想定通りである。ところで、非同期処理の途中で共通のリソースにアクセスしなければならないことがある。非同期に実行されている複数の処理から同時に1つのリソースにアクセスできない(実行してしまうと何が起きるか分からない)ので、その部分は一度に1つの処理だけが行われるようにしたい。それがスレッド間の排他ロックだ。これは、次のコードのようにlock/SyncLockステートメントを使って記述できる。

static object lockObj = new object();

static void LongTimeMethod1(string id)
{
  lock (lockObj)
  {
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id);
    System.Threading.Thread.Sleep(1000);
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id);
  }
}

Private lockObj As Object = New Object()

Private Sub LongTimeMethod1(id As String)
  SyncLock lockObj
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id)
    System.Threading.Thread.Sleep(1000)
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id)
  End SyncLock
End Sub

lock/SyncLockステートメントを使って排他ロックするコード例(上:C#、下:VB)
改修したLongTimeMethod1メソッドのみを示す(Mainメソッドは前と同じ)。前のコードに対して、太字の部分を追加した。
一般的には、非同期処理の途中で必要最小限の範囲だけをロックする。ここではサンプルということで、メソッドの中身を全部ロックしている。

 これで次の画像のように、2つの非同期処理が排他的に動くようになる。

lock/SyncLockステートメントを使って排他ロックした例 lock/SyncLockステートメントを使って排他ロックした例

async/awaitを使った場合の問題点

 上記と同様なコードをasync/awaitを使って書いてみよう。まず、呼び出される側を、async/awaitを使って非同期に動作するメソッドにする(次のコード)。

class Program
{
  static void Main(string[] args)
  {
    var task3 = LongTimeMethod2Async("C"); // 1つ目の処理を開始(別スレッドで実行される)
    System.Threading.Thread.Sleep(100); // ←結果が前後しないように入れてある(なくてもよい)
    var task4 = LongTimeMethod2Async("D"); // 2つ目の処理を開始(別スレッドで実行される)
#if DEBUG
    Console.ReadKey();
#endif
  }

  // 実行に約1秒かかるメソッド(非同期的に実行されるメソッド)
  static async Task LongTimeMethod2Async(string id)
  {
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id);
    await Task.Delay(1000); // この行は別スレッドで実行される
    // これ以降は、元と同じスレッドで実行されるとは限らない
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id);
  }
}

Module Module1
  Sub Main()
    Dim task3 = LongTimeMethod2Async("C") ' 1つ目の処理を開始(別スレッドで実行される)
    System.Threading.Thread.Sleep(100) ' ←結果が前後しないように入れてある(なくてもよい)
    Dim task4 = LongTimeMethod2Async("D") ' 2つ目の処理を開始(別スレッドで実行される)
#If DEBUG Then
    Console.ReadKey()
#End If
  End Sub

  ' 実行に約1秒かかるメソッド(非同期的に実行されるメソッド)
  Private Async Function LongTimeMethod2Async(id As String) As task
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id)
    Await Task.Delay(1000) ' この行は別スレッドで実行される
    ' これ以降は、元と同じスレッドで実行されるとは限らない
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id)
  End Function
End Module

async/awaitを使ったメソッドを使って2つの非同期処理を実行するコード例(上:C#、下:VB)
ここで注目しておいてほしいのは、await演算子以降のコードが以前のスレッドとは異なるスレッドで実行される可能性がある、という点だ。

 これを実行してみると、次の画像のように2つの処理がやはり非同期実行される。

async/awaitを使ったメソッドを使って2つの非同期処理を実行した例 async/awaitを使ったメソッドを使って2つの非同期処理を実行した例

 では、また先ほどと同様に、lock/SyncLockステートメントを使って排他ロックするコードを書いてみよう(次のコード)。

// このコードはコンパイルできない!

static object lockObj = new object();

static async Task LongTimeMethod2Async(string id)
{
  lock (lockObj) // エラー「'await' 演算子は、lock ステートメント本体では使用できません」
  {
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id);
    await Task.Delay(1000); // この行は別スレッドで実行される
    // これ以降は、元と同じスレッドで実行されるとは限らない
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id);
  }
}

' このコードはコンパイルできない!

Private lockObj As Object = New Object()

Private Async Function LongTimeMethod2Async(id As String) As task
  SyncLock lockObj ' エラー「'Await' は、……略……'SyncLock' ステートメントの内部では使用できません」
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id)
    Await Task.Delay(1000) ' この行は別スレッドで実行される
    ' これ以降は、元と同じスレッドで実行されるとは限らない
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id)
  End SyncLock
End Function

async/awaitを使ったメソッドでlock/SyncLockステートメントを使って排他ロックするコード例(上:C#、下:VB)
先ほどと同様にlock/SyncLockステートメントを追加した。しかし、lock/SyncLockステートメントがビルドエラーになってしまう。

 ところが「lock/SyncLockステートメント内ではawait演算子を使えない」というビルドエラーになってしまう。なぜだろうか?

 lock/SyncLockステートメントは、そのスコープに入るときにロックを獲得し、スコープから抜けるときにロックを解放する。ただし、ロックを解放するには条件があって、ロックを獲得したときと同じスレッドで解放しなければならない(「スレッドアフィニティがある」という)。コード中にコメントしたように、await演算子以降は違うスレッドで実行される可能性がある(すなわち、違うスレッドでロックを解放する可能性がある)ために、コンパイラーがビルドエラーとしているのだ。

 なお、このようにスレッドアフィニティのあるロック機構は、lock/SyncLockステートメント(System.Threading名前空間のMonitorクラスを内部的に使用)の他に、MutexクラスやReaderWriterLockSlimクラス(ともにSystem.Threading名前空間)などがある。スレッドアフィニティのあるロック機構は、async/awaitを使っているコードでは利用できない。

await演算子を含むコードをロックするには?

 上述したlock/SyncLockステートメントが使えない理由が分かれば、対処法も理解できるだろう。スレッドアフィニティのないロック機構を利用すればよいのである。

 スレッドアフィニティのないロック機構としてSemaphoreSlimクラス(System.Threading名前空間)がある。これを使って排他ロックするコードは次のようになる。

static System.Threading.SemaphoreSlim _semaphore
  = new System.Threading.SemaphoreSlim(1, 1);

static async Task LongTimeMethod2Async(string id)
{
  await _semaphore.WaitAsync(); // ロックを取得する
  try
  {
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id);
    await Task.Delay(1000); // この行は別スレッドで実行される
    // これ以降は、元と同じスレッドで実行されるとは限らない
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id);
  }
  finally
  {
    _semaphore.Release(); // 違うスレッドでロックを解放してもOK
  }
}

Private _semaphore As System.Threading.SemaphoreSlim _
  = New System.Threading.SemaphoreSlim(1, 1)

Private Async Function LongTimeMethod2Async(id As String) As task
  Await _semaphore.WaitAsync() ' ロックを取得する
  Try
    Console.WriteLine("{0} - ({1}) START ", DateTime.Now.ToString("ss.fff"), id)
    Await Task.Delay(1000) ' この行は別スレッドで実行される
    ' これ以降は、元と同じスレッドで実行されるとは限らない
    Console.WriteLine("{0} - ({1}) END", DateTime.Now.ToString("ss.fff"), id)
  Finally
    _semaphore.Release() ' 違うスレッドで解放してもOK
  End Try
End Function

SemaphoreSlimクラスを使って排他ロックするコード例(上:C#、下:VB)
SemaphoreSlimクラスはスレッドアフィニティがないので、ロック中にスレッドが変わっても問題ない。

 これで、await演算子を含むコードでも排他ロックできた(次の画像)。

SemaphoreSlimクラスを使って排他ロックした例 SemaphoreSlimクラスを使って排他ロックした例

利用可能バージョン:.NET Framework 4.5以降
カテゴリ:クラスライブラリ 処理対象:非同期処理
使用ライブラリ:SemaphoreSlimクラス(System.Threading名前空間)


「.NET TIPS」のインデックス

.NET TIPS

Copyright© 1999-2017 Digital Advantage Corp. All Rights Reserved.

@IT Special

- PR -

TechTargetジャパン

この記事に関連するホワイトペーパー

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。