CodeZine(コードジン)

特集ページ一覧

.NETマルチスレッドプログラミング 2:非同期デリゲートとスレッドプール

.NET Frameworkにおけるマルチスレッドプログラミングの基本

  • LINEで送る
  • このエントリーをはてなブックマークに追加
2005/08/10 12:00
目次

別スレッドとのデータの受け渡し

 ThreadPoolクラスや、非同期デリゲートを使ってメソッドを別のスレッドで処理する方法では、スレッドにデータを渡し、かつ結果を取得する方法が用意されています。しかし、Thread.Startメソッドでスレッドを開始した場合は、これが容易ではありません。なぜなら、Thread.Startメソッドに必要なThreadStartデリゲートには引数も戻り値もないからです。そこでこの場合は別の方法を考える必要があります。

 .NET Framework 2.0では、ThreadStartデリゲートだけでなく、引数を指定できるデリゲートも登場するようです。

 これに関してMSDNライブラリの「マルチスレッド プロシージャのパラメータと戻り値」では、別スレッドとして実行するメソッドはクラスにラップし、引数として機能するフィールドをそのクラスに定義するのが「最善の方法」であると述べられています。ここでは、この方法を紹介します。サンプル「parameter_01」でその具体例を示します。

「VB.NET/parameter_01/Class1.vb」 (VB.NET)
'マルチスレッドメソッドのためのクラス
Class ThreadInfo
    'スレッドが返す値
    Public ReturnValue As Integer
    'スレッドに渡す値
    Public sleepTime As Integer

    'インストラクタ
    Public Sub New(ByVal stime As Integer)
        Me.sleepTime = stime
    End Sub

    '別スレッドで実行するメソッド
    Public Sub MyThreadMethod()
        'sleepTimeミリ秒停止する
        System.Threading.Thread.Sleep(Me.sleepTime)

        'TickCountを返す
        Me.ReturnValue = System.Environment.TickCount
    End Sub
End Class

Class MainClass
    'エントリポイント
    Public Shared Sub Main()
        'ThreadInfoオブジェクトの作成
        'スレッドに渡すデータを設定
        Dim info As New ThreadInfo(1000)

        'Threadオブジェクトを作成する
        Dim t As New System.Threading.Thread( _
            New System.Threading.ThreadStart( _
            AddressOf info.MyThreadMethod))
        'スレッドを開始する
        t.Start()

        'スレッドtが終わるまで待機する
        t.Join()

        '結果を表示する
        Console.WriteLine(info.ReturnValue)

        Console.ReadLine()
    End Sub
End Class
「C#/parameter_01/Class1.cs」 (C#)
//マルチスレッドメソッドのためのクラス
class ThreadInfo
{
    //スレッドが返す値
    public int ReturnValue;
    //スレッドに渡す値
    public int sleepTime;

    //インストラクタ
    public ThreadInfo(int stime)
    {
        this.sleepTime = stime;
    }

    //別スレッドで実行するメソッド
    public void MyThreadMethod()
    {
        //sleepTimeミリ秒停止する
        System.Threading.Thread.Sleep(this.SleepTime);

        //TickCountを返す
        this.ReturnValue = System.Environment.TickCount;
    }
}

class MainClass
{
    //エントリポイント
    public static void Main()
    {
        //ThreadInfoオブジェクトの作成
        //スレッドに渡すデータを設定
        ThreadInfo info = new ThreadInfo(1000);

        //Threadオブジェクトを作成する
        System.Threading.Thread t = 
            new System.Threading.Thread(
            new System.Threading.ThreadStart(info.MyThreadMethod));
        //スレッドを開始する
        t.Start();

        //スレッドtが終わるまで待機する
        t.Join();

        //結果を表示する
        Console.WriteLine(info.ReturnValue);

        Console.ReadLine();
    }
}

 このサンプルでは、まずThreadInfoクラスを作成し、別スレッドで実行するメソッドとしてMyThreadMethodメソッドを定義します。またMyThreadMethodメソッドへの引数として機能するフィールドとしてsleepTimeを、戻り値として機能するフィールドにReturnValueを定義しています。

 スレッド開始前にThreadInfoオブジェクトのsleepTimeフィールドに引数となる値を設定します。結果の取得は、スレッドが確実に終了したところで、ReturnValueフィールドから取得します。

コールバックデリゲートの使用

 上記の方法ではJoinメソッドでスレッドの終了を待機していますが、コールバックメソッドを使うことにより、その必要がなくなります。また、戻り値をコールバックメソッドの引数として受け取ることができます。

 サンプル「parameter_02」は、サンプル「parameter_01」を書き換え、コールバックメソッドを使うようにしています。

「VB.NET/parameter_02/Class1.vb」 (VB.NET)
Class ThreadInfo
    'スレッドが結果を返すためのコールバックデリゲート
    Delegate Sub MyThreadCallback(ByVal returnValue As Integer)
    Private callbackDelegate As MyThreadCallback
    'スレッドに渡す値
    Public SleepTime As Integer

    'インストラクタ
    Public Sub New(ByVal stime As Integer, _
        ByVal callback As MyThreadCallback)
        Me.SleepTime = stime
        Me.callbackDelegate = callback
    End Sub

    '別スレッドで実行するメソッド
    Public Sub MyThreadMethod()
        'sleepTimeミリ秒停止する
        System.Threading.Thread.Sleep(Me.SleepTime)

        'コールバックデリゲートを実行して結果を返す
        If Not (Me.callbackDelegate Is Nothing) Then
            Me.callbackDelegate(System.Environment.TickCount)
        End If
    End Sub
End Class

Class MainClass
    'エントリポイント
    Public Shared Sub Main()
        'ThreadInfoオブジェクトの作成
        'スレッドにデータを渡す
        Dim info As New ThreadInfo(1000, _
            New ThreadInfo.MyThreadCallback( _
            AddressOf GetCallbackResult))

        'Threadオブジェクトを作成する
        Dim t As New System.Threading.Thread( _
            New System.Threading.ThreadStart( _
            AddressOf info.MyThreadMethod))
        'スレッドを開始する
        t.Start()

        Console.ReadLine()
    End Sub

    'スレッド終了時に呼び出されるコールバックメソッド
    Private Shared Sub GetCallbackResult(ByVal returnValue As Integer)
        '結果を表示する
        Console.WriteLine(returnValue)
    End Sub
End Class
「C#/parameter_02/Class1.cs」 (C#)
class ThreadInfo
{
    //スレッドが結果を返すためのコールバックデリゲート
    public delegate void MyThreadCallback(int returnValue);
    private MyThreadCallback callbackDelegate;
    //スレッドに渡す値
    public int SleepTime;

    //インストラクタ
    public ThreadInfo(int stime, MyThreadCallback callback)
    {
        this.SleepTime = stime;
        this.callbackDelegate = callback;
    }

    //別スレッドで実行するメソッド
    public void MyThreadMethod()
    {
        //sleepTimeミリ秒停止する
        System.Threading.Thread.Sleep(this.SleepTime);

        //コールバックデリゲートを実行して結果を返す
        if (this.callbackDelegate != null)
        {
            this.callbackDelegate(System.Environment.TickCount);
        }
    }
}

class MainClass
{
    //エントリポイント
    public static void Main()
    {
        //ThreadInfoオブジェクトの作成
        //スレッドにデータを渡す
        ThreadInfo info = new ThreadInfo(1000,
            new ThreadInfo.MyThreadCallback(GetCallbackResult));

        //Threadオブジェクトを作成する
        System.Threading.Thread t = 
            new System.Threading.Thread(
            new System.Threading.ThreadStart(info.MyThreadMethod));
        //スレッドを開始する
        t.Start();

        Console.ReadLine();
    }

    //スレッド終了時に呼び出されるコールバックメソッド
    private static void GetCallbackResult(int returnValue)
    {
        //結果を表示する
        Console.WriteLine(returnValue);
    }
}

 この例では、MyThreadMethodメソッドの最後で指定されたコールバックデリゲートを実行することにより、結果を返しています。なおコールバックメソッド(ここではGetCallbackResultメソッド)は、MyThreadMethodメソッドを実行したスレッドで実行されることに注意してください。

Interlockedクラス

 「スレッドの同期」で、競合状態について説明しました。そこで示した例は非常に分かりやすいものでしたが、整数のインクリメント「i++」のように一見他のスレッドが間に入り込む余地がなく、競合状態など起こらないだろうと思われる操作でさえも、競合状態は発生します。

 「i++」という操作は実際には、

 「変数iの値を取得する」→「1を足す」→「結果を変数iに格納する」

 という複数の操作で行われています。よって、現在iの値が0のときに、あるスレッドがiに1を足してiに格納する間に、別のスレッドがiに1を足すというケースも考えられます。この場合、「i++」が2回呼び出されたわけですから、iは2となるべきです。しかし実際には、はじめのスレッドで1つ足した結果をiに格納する前に別のスレッドがiを取得していますので、iは1にしかなりません。

 つまり、「i++」という操作でも次のような同期が必要です。

(VB.NET)
'インクリメントする
SyncLock syncCount
    i += 1
End SyncLock
(C#)
//インクリメントする
lock (syncObject)
{
    i++;
}

 このように一見一回の操作で行われているように見える操作も、実は何回かの操作によって行われていることがあります。しかし、分割不可能な操作として行われることが保障され、競合状態が発生する隙を与えない操作もあります。このように操作が分割されないことが保障されていることを「アトミック(atomic)」と呼びます。

 このようなアトミックな操作を行うメソッドが、Interlockedクラスに用意されています。InterlockedクラスのIncrementメソッドでは整数のインクリメント、Decrementメソッドでは整数のデクリメント、Exchangeメソッドでは変数への値の設定(変数の値の交換)、CompareExchangeメソッドでは変数の比較と値の設定を行います。

 つまり上記のコードは次のように書き換えることができます。

(VB.NET)
'インクリメントする
System.Threading.Interlocked.Increment(i)
(C#)
//インクリメントする
System.Threading.Interlocked.Increment(ref i);

 このようなInterlockedクラスのメソッドを使用する方が、lockを使用するよりも、安全面、パフォーマンス面で優れています。もし可能ならば、Interlockedの使用を積極的に検討してください。

スレッドの同期を行わずに複数のスレッドから同じフィールドにアクセスする

 「スレッドの同期」でマルチスレッドプログラミングの難しさを実感していただけたと思いますが、ここではこれよりもさらに難解な問題を紹介しなければなりません。

 まずはサンプル「volatile_01」をご覧ください。

「VB.NET/volatile_01/Class1.vb」 (VB.NET)
Class Class1
    Private Shared _canceled As Boolean = False

    'エントリポイント
    Public Shared Sub Main()
        'DoSomethingメソッドを別のスレッドで実行する
        'Threadオブジェクトを作成する
        Dim t As New System.Threading.Thread( _
            New System.Threading.ThreadStart(AddressOf DoSomething))
        'スレッドを開始する
        t.Start()

        Console.WriteLine("Enterキーを押してください")
        Console.ReadLine()

        '_canceledをtrueにする
        _canceled = True

        Console.WriteLine("終わりました")
        Console.ReadLine()
    End Sub

    'メソッド
    Private Shared Sub DoSomething()
        '_canceledがtrueになるまでループする
        While Not _canceled
            '何らかの作業があるものとする
            System.Threading.Thread.Sleep(100)
        End While
    End Sub
End Class
「C#/volatile_01/Class1.cs」 (C#)
class Class1
{
    private static bool _canceled = false;

    //エントリポイント
    public static void Main()
    {
        //DoSomethingメソッドを別のスレッドで実行する
        //Threadオブジェクトを作成する
        System.Threading.Thread t = 
            new System.Threading.Thread(
            new System.Threading.ThreadStart(DoSomething));
        //スレッドを開始する
        t.Start();

        Console.WriteLine("Enterキーを押してください");
        Console.ReadLine();

        //_canceledをtrueにする
        _canceled = true;

        Console.WriteLine("終わりました");
        Console.ReadLine();
    }

    //メソッド
    private static void DoSomething()
    {
        //_canceledがtrueになるまでループする
        while (!_canceled)
        {
            //何らかの作業があるものとする
            System.Threading.Thread.Sleep(100);
        }
    }
}

 このプログラムは、_canceledフィールドというフラッグを使って、別スレッドのループ処理を終了させるものです。普通ならば、メインスレッドで_canceledtrueとなったときにDoSomethingメソッドではループが終了するはずです。ところがそうはならずに、ループが永遠に回り続ける可能性があるのです。

 これは簡単に言うと、命令を並べ替える最適化が行われると、フィールドへの書き込みと読み取りの順番が変わってしまう可能性があるためですが、ここでは詳しくは論じません(詳しくは、下記のリンク先や参考資料などをご覧ください)。

 とにかく、このような問題はスレッドの同期を行わずに複数のスレッドから同じフィールドにアクセスする場合に起こりえます。この問題を解決するには、volatile修飾子を使うか、lockのような同期を行います。

 volatileと同様のことが、.NET Framework 1.1から追加されたThread.VolatileReadVolatileWriteメソッドでもできるようです。また同じく1.1から追加されたThread.MemoryBarrierを使う方法も考えられます。

 volatile修飾子を使用して書き換えると、サンプル「volatile_02」のようになります(VB.NETのサンプルはありません)。

「C#/volatile_02/Class1.cs」 (C#)
class Class1
{
    private static volatile bool _canceled = false;

    //エントリポイント
    public static void Main()
    {
        //DoSomethingメソッドを別のスレッドで実行する
        //Threadオブジェクトを作成する
        System.Threading.Thread t = 
            new System.Threading.Thread(
            new System.Threading.ThreadStart(DoSomething));
        //スレッドを開始する
        t.Start();

        Console.WriteLine("Enterキーを押してください");
        Console.ReadLine();

        //_canceledをtrueにする
        _canceled = true;

        Console.WriteLine("終わりました");
        Console.ReadLine();
    }

    //メソッド
    private static void DoSomething()
    {
        //_canceledがtrueになるまでループする
        while (!_canceled)
        {
            //何らかの作業があるものとする
            System.Threading.Thread.Sleep(100);
        }
    }
}

 _canceledフィールドを宣言するときにvolatile修飾子を付けるだけですから、簡単です。このようにvolatileを付けると、このフィールドの最適化が制限されます(詳しくは、MSDNライブラリを参照)。

 volatile修飾子は残念ながらVB.NETには用意されていません。また、volatileを付けられるフィールドは、次のようなものに限られています。

  • 参照型
  • ポインタ型(unsafeコンテキスト内)
  • sbytebyteshortushortintuintcharfloatboolの各型
  • 列挙型とbytesbyteshortushortint、またはuintの列挙基本型
 volatile修飾子は.NET Compact Frameworkでもサポートされていません。ただし、こちらのニュースグループの記事によると、.NET CF 1でビルドし、CFデバイスのみを対象とした場合は、すべてのフィールドはvolatileとして扱われるとのことです。また、.NET CF 2では、volatileがサポートされるようです。.NET CFでvolatileを指定したときにエラーを出さない方法は、Neil Cowburnの記事で紹介されています。

 volatileを使わずにlockを使ったサンプルが「volatile_03」です。

「VB.NET/volatile_03/Class1.vb」 (VB.NET)
Class Class1
    Private Shared _canceled As Boolean = False
    Private Shared _canceledSync As New Object

    'プロパティ
    Private Shared Property Canceled() As Boolean
        Get
            SyncLock _canceledSync
                Return _canceled
            End SyncLock
        End Get
        Set(ByVal Value As Boolean)
            SyncLock _canceledSync
                _canceled = Value
            End SyncLock
        End Set
    End Property

    'エントリポイント
    Public Shared Sub Main()
        'DoSomethingメソッドを別のスレッドで実行する
        'Threadオブジェクトを作成する
        Dim t As New System.Threading.Thread( _
            New System.Threading.ThreadStart(AddressOf DoSomething))
        'スレッドを開始する
        t.Start()

        Console.WriteLine("Enterキーを押してください")
        Console.ReadLine()

        '_canceledをtrueにする
        Canceled = True

        Console.WriteLine("終わりました")
        Console.ReadLine()
    End Sub

    'メソッド
    Private Shared Sub DoSomething()
        '_canceledがtrueになるまでループする
        While Not Canceled
            '何らかの作業があるものとする
            System.Threading.Thread.Sleep(100)
        End While
    End Sub
End Class
「C#/volatile_03/Class1.cs」 (C#)
class Class1
{
    private static bool _canceled = false;
    private static object _canceledSync = new object();

    //プロパティ
    private static bool Canceled
    {
        get
        {
            lock (_canceledSync)
            {
                return _canceled;
            }
        }
        set
        {
            lock (_canceledSync)
            {
                _canceled = value;
            }
        }
    }

    //エントリポイント
    public static void Main()
    {
        //DoSomethingメソッドを別のスレッドで実行する
        //Threadオブジェクトを作成する
        System.Threading.Thread t = 
            new System.Threading.Thread(
            new System.Threading.ThreadStart(DoSomething));
        //スレッドを開始する
        t.Start();

        Console.WriteLine("Enterキーを押してください");
        Console.ReadLine();

        //_canceledをtrueにする
        Canceled = true;

        Console.WriteLine("終わりました");
        Console.ReadLine();
    }

    //メソッド
    private static void DoSomething()
    {
        //_canceledがtrueになるまでループする
        while (!Canceled)
        {
            //何らかの作業があるものとする
            System.Threading.Thread.Sleep(100);
        }
    }
}

 ここでは、_canceledフィールドにCanceledプロパティを通してアクセスするようにして、Canceledプロパティ内でロックを行っています。

 volatileと比べlockを使用した方がパフォーマンス的には劣ります。しかし、「複数のスレッドから共有リソースへアクセスする場合は、lockを使用する」という考え方は非常にシンプルで、しかも確実です。

 参考資料8によると、volatileの使用は処理速度を落とすため、制限しろということです。しかし、volatileが必要な場面とそうでない場面との違いをはっきりと説明したドキュメントが見つからないため、volatileの厳密な使用法は非常に分かりにくいです(例えば、「スレッドプールや、非同期デリゲートなどを使った場合でもvolatileは必要か」など)。筆者自身この点に関して適切なアドバイスができないことをお許しください。

まとめ

 以上が、.NET Frameworkにおけるマルチスレッドプログラミングで必ず知っておくべきと、筆者が判断した事柄です。しかし、これ以降(パート3パート4)で紹介する事柄も基本的なものばかりですので、少なくとも頭の隅には入れておいてください。

参考資料

  1. Jon's Homepage! 『Multi-threading in .NET』
  2. MSDNライブラリ 『.NET Framework開発者ガイド-スレッド処理』
  3. MSDNライブラリ 『Visual Basic言語の概念-Visual Basic .NETにおけるマルチスレッド』
  4. MSDNライブラリ 『.NET Framework開発者ガイド-非同期呼び出しの組み込み』
  5. MSDNライブラリ 『コンポーネントのマルチスレッド』
  6. MSDNライブラリ 『スレッド処理のデザイン ガイドライン』
  7. MSDNライブラリ 『非同期プログラミングのガイドライン』
  8. MSDNライブラリ 『Chapter 4 - Architecture and Design Review of a .NET Application for Performance and Scalability』


  • LINEで送る
  • このエントリーをはてなブックマークに追加

バックナンバー

連載:.NET Frameworkにおけるマルチスレッドプログラミングの基本

著者プロフィール

  • どぼん!(ドボン!)

    DOBON.NET内で.NET Frameworkの機能を紹介したWebサイト.NET Tipsやメールマガジン「.NETプログラミング研究」の発行人。

あなたにオススメ

All contents copyright © 2005-2021 Shoeisha Co., Ltd. All rights reserved. ver.1.5