SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

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

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

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


  • X ポスト
  • このエントリーをはてなブックマークに追加

スレッドプール

 今まではThreadクラスのStartメソッドを使ってスレッドを起動する方法を紹介してきました。しかし、このようにスレッドを作成するには少なからぬリソースが必要なため、スレッドの作成と廃棄を何回も繰り返すことは効率的ではありません。「スレッドプール」は、すでに作成された複数のスレッドをプールして使いまわすことにより、このような無駄を減らし、複数のスレッドを効率的に使う機能を提供します。

 理屈はさておき、早速スレッドプールを使ったサンプルを「threadpool_01」を見てください。

「VB.NET/threadpool_01/Class1.vb」 (VB.NET)
Class Class1
    'エントリポイント
    Public Shared Sub Main()
        Console.WriteLine("スタート")

        'メソッドをスレッドプールのキューに追加する
        System.Threading.ThreadPool.QueueUserWorkItem( _
            New System.Threading.WaitCallback( _
            AddressOf DoSomething))

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

        Console.ReadLine()
    End Sub

    'スレッドで実行するメソッド
    Private Shared Sub DoSomething(ByVal obj As Object)
        '何らかの処理があるものとする
        System.Threading.Thread.Sleep(10000)

        Console.WriteLine("終わりました")
    End Sub
End Class
「C#/threadpool_01/Class1.cs」 (C#)
class Class1
{
    //エントリポイント
    public static void Main()
    {
        Console.WriteLine("スタート");

        //メソッドをスレッドプールのキューに追加する
        System.Threading.ThreadPool.QueueUserWorkItem(
            new System.Threading.WaitCallback(DoSomething));

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

        Console.ReadLine();
    }

    //スレッドで実行するメソッド
    private static void DoSomething(object obj)
    {
        //何らかの処理があるものとする
        System.Threading.Thread.Sleep(10000);

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

 このアプリケーションを実行すると、

スタート
Enterキーを押してください
終わりました

 の順番で出力されます。「Enterキーを押してください」と表示されてすぐに[Enter]キーを押すと、アプリケーションはすぐに終了します。このことから、スレッドプールのスレッドはバックグラウンドスレッドであることが分かります。

 このコードでは、ThreadPoolクラスのQueueUserWorkItemメソッドにより、スレッドプールのキューにDoSomethingメソッドを追加しています。キューに置かれたメソッドは、スレッドプールで管理されたスレッドにより効率的に処理されていきます。

引数の指定

 QueueUserWorkItemメソッドの特徴の一つは、指定するメソッドがWaitCallbackデリゲートによってラップされるため、メソッドに引数(状態オブジェクト)を渡すことができることです。この特徴を生かせば、スレッドにデータを渡すことは言うまでもなく、結果を取得することもできます。

 サンプル「threadpool_02」では、その一例を紹介しています。

「VB.NET/threadpool_02/Class1.vb」 (VB.NET)
'状態オブジェクトのためのクラス
Class TaskInfo
    'メソッドに渡すデータ
    Public StringValue As String
    'メソッドからの返り値
    Public Result As String

    'コンストラクタ
    Public Sub New(ByVal str As String)
        StringValue = str
        Result = Nothing
    End Sub
End Class

Class Class1
    'エントリポイント
    Public Shared Sub Main()
        'TaskInfoオブジェクトを作成する
        Dim ti As New TaskInfo("こんにちは")

        'メソッドをスレッドプールのキューに追加する
        'この時、状態オブジェクトも指定する
        System.Threading.ThreadPool.QueueUserWorkItem( _
            New System.Threading.WaitCallback( _
            AddressOf WriteData), ti)

        '待機する
        System.Threading.Thread.Sleep(3000)

        '結果を表示
        Console.WriteLine(ti.Result)

        Console.ReadLine()
    End Sub

    'スレッドで実行するメソッド
    Private Shared Sub WriteData(ByVal state As Object)
        '渡されたデータを取得
        Dim ti As TaskInfo = CType(state, TaskInfo)

        '取得したデータを表示
        Console.WriteLine(ti.StringValue)

        '結果を格納
        ti.Result = "さようなら"
    End Sub
End Class
「C#/threadpool_02/Class1.cs」 (C#)
//状態オブジェクトのためのクラス
class TaskInfo
{
    //メソッドに渡すデータ
    public string StringValue;
    //メソッドからの返り値
    public string Result;

    //コンストラクタ
    public TaskInfo(string str)
    {
        StringValue = str;
        Result = null;
    }
}

class Class1
{
    //エントリポイント
    public static void Main()
    {
        //TaskInfoオブジェクトを作成する
        TaskInfo ti = new TaskInfo("こんにちは");

        //メソッドをスレッドプールのキューに追加する
        //この時、状態オブジェクトも指定する
        System.Threading.ThreadPool.QueueUserWorkItem(
            new System.Threading.WaitCallback(WriteData), ti);

        //待機する
        System.Threading.Thread.Sleep(3000);

        //結果を表示
        Console.WriteLine(ti.Result);

        Console.ReadLine();
    }

    //スレッドで実行するメソッド
    private static void WriteData(object state)
    {
        //渡されたデータを取得
        TaskInfo ti = (TaskInfo) state;

        //取得したデータを表示
        Console.WriteLine(ti.StringValue);

        //結果を格納
        ti.Result = "さようなら";
    }
}

 ここではスレッドにデータを渡して結果を取得するために、パラメータと戻り値として機能するフィールドを定義したクラス「TaskInfo」を作成し、そのオブジェクトを状態オブジェクトとしてメソッドに渡しています。

 ThreadPool.QueueUserWorkItemメソッドによりスレッドプールを使う手順をまとめると、次のようになります。

  1. WaitCallbackデリゲートと同じシグネチャ(パラメータや戻り値の型などの定義)のメソッドを作成し、別スレッドで実行したい仕事をこの中に記述する。
  2. データの授受を行う場合は、そのためのクラスを作成する。
  3. WaitCallbackデリゲートオブジェクトを作成し、ThreadPool.QueueUserWorkItemメソッドに渡す。データの授受を行う場合は、状態オブジェクトを作成し、これもQueueUserWorkItemメソッドに渡す。

スレッドプールの制限

 ThreadPoolオブジェクトは一つのプロセスに一つだけ存在します。スレッドプールが同時にアクティブにできるワーカースレッドの数は決まっており、既定では、1つのシステムプロセッサにつき25個となっています(注参照)。これ以上の数のメソッドをスレッドプールのキューに追加することもできますが、この場合キューに置かれたメソッドは別のメソッドが終了するまで実行されません。

 この制限数は変更できますが、その方法はかなり大変です。「C# Corner - Changing the default limit of 25 threads of ThreadPool class」でその方法が紹介されています。

 スレッドプール内のワーカースレッドの最大数はThreadPool.GetMaxThreadsメソッドで、使用できるワーカースレッドの数はThreadPool.GetAvailableThreadsメソッドでそれぞれ取得できます。

 サンプル「threadpool_03」を実行すると、スレッドプールで使用できるワーカースレッドの数が減っていき、最後には0になる様子が分かります。

「VB.NET/threadpool_03/Class1.vb」 (VB.NET)
Class Class1
    'エントリポイント
    Public Shared Sub Main()
        'スレッドプール内のワーカースレッドの最大数を表示
        Dim workers, ios As Integer
        System.Threading.ThreadPool.GetMaxThreads(workers, ios)
        Console.WriteLine("ワーカースレッドの最大数:{0}", workers)

        Dim i As Integer
        For i = 0 To 49
            'メソッドをスレッドプールのキューに追加する
            System.Threading.ThreadPool.QueueUserWorkItem( _
                New System.Threading.WaitCallback( _
                AddressOf DoSomething))
        Next i

        Console.ReadLine()
    End Sub

    'スレッドで実行するメソッド
    Private Shared Sub DoSomething(ByVal obj As Object)
        'スレッドプールで使用できるワーカースレッドの数を表示
        Dim workers, ios As Integer
        System.Threading.ThreadPool.GetAvailableThreads(workers, ios)
        Console.WriteLine _
            ("使用できるワーカースレッドの数:{0}", workers)

        '何らかの処理があるものとする
        System.Threading.Thread.Sleep(10000)
    End Sub
End Class
「C#/threadpool_03/Class1.cs」 (C#)
class Class1
{
    //エントリポイント
    public static void Main()
    {
        //スレッドプール内のワーカースレッドの最大数を表示
        int workers, ios;
        System.Threading.ThreadPool.
            GetMaxThreads(out workers, out ios);
        Console.WriteLine("ワーカースレッドの最大数:{0}", workers);

        for (int i = 0; i < 50; i++)
        {
            //メソッドをスレッドプールのキューに追加する
            System.Threading.ThreadPool.QueueUserWorkItem(
                new System.Threading.WaitCallback(DoSomething));
        }

        Console.ReadLine();
    }

    //スレッドで実行するメソッド
    private static void DoSomething(object obj)
    {
        //スレッドプールで使用できるワーカースレッドの数を表示
        int workers, ios;
        System.Threading.ThreadPool.
            GetAvailableThreads(out workers, out ios);
        Console.WriteLine
            ("使用できるワーカースレッドの数:{0}", workers);

        //何らかの処理があるものとする
        System.Threading.Thread.Sleep(10000);
    }
}

 スレッドプールにはこのような制限があるため、すべてのスレッドを使い切ってしまうと処理が滞るだけでなく、デッドロックの危険性も生じます。例えば、あるスレッドが別のメソッドの開始と終了を待つが、スレッドプールがいっぱいのためにそのメソッドが実行されないという場合にデッドロックとなります。

 また、.NET Frameworkではスレッドプールをデリゲートによる非同期呼び出しスレッドタイマ、System.Netソケット接続、非同期I/O完了などでも利用しています(非同期メソッドの解説で一部を紹介しています)。そのため、知らないうちにスレッドプールを予想以上に使用している恐れがあります。

 以上のような理由から、短時間で終わるメソッドの実行にはスレッドプールを積極的に活用し、時間のかかるメソッドの実行にはThread.Startを使うのが無難です。

 一般的には、Thread.Startよりもスレッドプール(非同期デリゲートを含む)を使った方法の支持率が圧倒的に高く、中には「Thread.Startは使うな」と言う人さえいます。しかし逆にThread.Startを好むという人もおり、結局のところは、それぞれの長所と短所を理解して使い分けるのが最もよい方法ということになりそうです。

 参考資料8では、スレッドプールの使用を推奨し、特にサーバーサイドコードではスレッドプールを使用すべきとしています。

非同期デリゲート

 .NET Frameworkには、デリゲートを使ってメソッドを非同期的に呼び出す簡単な方法が用意されています。この「非同期デリゲート」を使用すれば、基本的にすべてのメソッドを非同期的に呼び出すことができます。しかも、パラメータや戻り値の受け渡しも簡単に行えます。

 この非同期呼び出しにはスレッドプールが使われます。よって今まで紹介してきたマルチスレッドプログラミングやスレッドプールを扱う際の注意事項は、非同期デリゲートを使用する場合にも留意する必要があります。

 サンプル「delegate_01」では、非同期デリゲートを使った簡単な例を示します。このサンプルでは、WriteStringメソッドを非同期的に呼び出しています。

「VB.NET/delegate_01/Class1.vb」 (VB.NET)
Class Class1
    '非同期で呼び出すメソッドと同じシグネチャのデリゲート
    Delegate Function WriteStringAsyncDelegate( _
        ByVal msg As String, ByVal sleepTime As Integer) As Integer

    'エントリポイント
    Public Shared Sub Main()
        'デリゲートオブジェクトの作成
        Dim dlgt As New WriteStringAsyncDelegate _
            (AddressOf WriteString)
        '非同期呼び出しを開始
        Dim ar As IAsyncResult = _
            dlgt.BeginInvoke("こんにちは", 5000, Nothing, Nothing)

        Console.WriteLine("WriteStringメソッドの終了を待機中...")

        '非同期処理の終了を待ち、結果を取得
        Dim ret As Integer = dlgt.EndInvoke(ar)

        Console.WriteLine("結果:{0}", ret)
        Console.ReadLine()
    End Sub

    '非同期で呼び出すメソッド
    Private Shared Function WriteString(ByVal msg As String, _
        ByVal sleepTime As Integer) As Integer
        System.Threading.Thread.Sleep(sleepTime)
        Console.WriteLine(msg)

        Return msg.Length
    End Function
End Class
「C#/delegate_01/Class1.cs」 (C#)
class Class1
{
    //非同期で呼び出すメソッドと同じシグネチャのデリゲート
    private delegate int WriteStringAsyncDelegate
        (string msg, int sleepTime);

    //エントリポイント
    public static void Main()
    {
        //デリゲートオブジェクトの作成
        WriteStringAsyncDelegate dlgt = 
            new WriteStringAsyncDelegate(WriteString);
        //非同期呼び出しを開始
        IAsyncResult ar = 
            dlgt.BeginInvoke("こんにちは", 5000, null, null);

        Console.WriteLine("WriteStringメソッドの終了を待機中...");

        //非同期処理の終了を待ち、結果を取得
        int ret = dlgt.EndInvoke(ar);

        Console.WriteLine("結果:{0}", ret);
        Console.ReadLine();
    }

    //非同期で呼び出すメソッド
    private static int WriteString(string msg, int sleepTime)
    {
        System.Threading.Thread.Sleep(sleepTime);
        Console.WriteLine(msg);

        return msg.Length;
    }
}

 このプログラムを実行すると、

「WriteStringメソッドの終了を待機中...」と出力
(5秒間待機)
「こんにちは」と出力
「結果:5」と出力

 となります。

 WriteStringメソッドを非同期で呼び出すために、まずWriteStringメソッドと同じシグネチャのデリゲート「WriteStringAsyncDelegate」を定義します。すると、適切なシグネチャを持つBeginInvokeメソッドとEndInvokeメソッドがこのデリゲートに自動的に定義されます。

 このBeginInvokeメソッドを呼び出すことにより、WriteStringメソッドの非同期呼び出しを開始できます。ここではBeginInvokeの引数は4つとなり、その内2つはWriteStringメソッドに渡す引数を指定します。残りの2つ(上記の例ではnullが指定されている)に関しては説明を省略します。

 結果を取得するためには、BeginInvokeが返すIAsyncResultオブジェクトを指定して、EndInvokeメソッドを呼び出します。EndInvokeメソッドはWriteStringメソッドが終了するまでスレッドをブロックします。なお、EndInvokeメソッドは必ず呼び出さなくてはいけません。

メソッドの終了を知る

 EndInvokeメソッドを呼び出すとスレッドがブロックされますので、非同期処理が終了したタイミングでEndInvokeを呼び出したいところです。ここからは、非同期的に呼び出したメソッドがいつ終わったか知る方法について説明します。MSDNライブラリの「非同期プログラミングの概要」では、次の4つの方法が紹介されています。

  1. EndInvokeメソッドで呼び出しが終了するまでブロックする。
  2. 待機ハンドルを使って待機する。
  3. IAsyncResult.IsCompletedプロパティがTrueになるまで待つ。
  4. 呼び出しが終了したときにコールバックメソッドが実行されるようにする。

 1.は、サンプル「delegate_01」で紹介した方法です。2.はサンプル「delegate_02」で、3.はサンプル「delegate_03」でその例を示すこととし、ここでは触れません。4.の方法のみを紹介します。

コールバックメソッドの使用

 コールバックメソッドを使った例が、サンプル「delegate_04」です。これが通常もっともお勧めできる方法です。

「VB.NET/delegate_04/Class1.vb」 (VB.NET)
Class Class1
    '非同期で呼び出すメソッドと同じシグネチャのデリゲート
    Delegate Function WriteStringAsyncDelegate( _
        ByVal msg As String, ByVal sleepTime As Integer) As Integer

    'エントリポイント
    Public Shared Sub Main()
        'デリゲートオブジェクトの作成
        Dim dlgt As New WriteStringAsyncDelegate _
            (AddressOf WriteString)
        '非同期呼び出しを開始
        'コールバックメソッドを指定する
        'デリゲートオブジェクトをコールバックメソッドから
        '  IAsyncResult.AsyncStateで取得できるようにする
        Dim ar As IAsyncResult = dlgt.BeginInvoke("こんにちは", _
            1000, New AsyncCallback(AddressOf CallbackMethod), dlgt)

        Console.ReadLine()
    End Sub

    '非同期で呼び出すメソッド
    Private Shared Function WriteString(ByVal msg As String, _
        ByVal sleepTime As Integer) As Integer
        System.Threading.Thread.Sleep(sleepTime)
        Console.WriteLine(msg)

        Return msg.Length
    End Function

    'コールバックメソッド
    Private Shared Sub CallbackMethod(ByVal ar As IAsyncResult)
        'デリゲートオブジェクトの取得
        Dim dlgt As WriteStringAsyncDelegate = _
            CType(ar.AsyncState, WriteStringAsyncDelegate)

        'EndInvokeを呼び出し、結果を取得
        Dim ret As Integer = dlgt.EndInvoke(ar)

        '結果を表示
        Console.WriteLine("結果:{0}", ret)
    End Sub
End Class
「C#/delegate_04/Class1.cs」 (C#)
class Class1
{
    //非同期で呼び出すメソッドと同じシグネチャのデリゲート
    private delegate int WriteStringAsyncDelegate
        (string msg, int sleepTime);

    //エントリポイント
    public static void Main()
    {
        //デリゲートオブジェクトの作成
        WriteStringAsyncDelegate dlgt = 
            new WriteStringAsyncDelegate(WriteString);
        //非同期呼び出しを開始
        //コールバックメソッドを指定する
        //デリゲートオブジェクトをコールバックメソッドから
        //  IAsyncResult.AsyncStateで取得できるようにする
        IAsyncResult ar = 
            dlgt.BeginInvoke("こんにちは", 1000,
            new AsyncCallback(CallbackMethod), dlgt);

        Console.ReadLine();
    }

    //非同期で呼び出すメソッド
    private static int WriteString(string msg, int sleepTime)
    {
        System.Threading.Thread.Sleep(sleepTime);
        Console.WriteLine(msg);

        return msg.Length;
    }

    //コールバックメソッド
    private static void CallbackMethod(IAsyncResult ar)
    {
        //デリゲートオブジェクトの取得
        WriteStringAsyncDelegate dlgt =
            (WriteStringAsyncDelegate) ar.AsyncState;

        //EndInvokeを呼び出し、結果を取得
        int ret = dlgt.EndInvoke(ar);

        //結果を表示
        Console.WriteLine("結果:{0}", ret);
    }
}

 コールバックメソッドは、非同期処理が終了すると実行されます。つまりこの例では、WriteStringメソッドが終了すると、CallbackMethodメソッドが呼び出されます。

 この例では状態オブジェクトとして、WriteStringAsyncDelegateデリゲートオブジェクト(dlgt)を指定しています。これを使うことにより、CallbackMethodでは適切なデリゲートオブジェクトのEndInvokeメソッドを呼び出すことができます。

 状態オブジェクトに別の値を指定したいときは、スレッドプールの例のように、状態オブジェクトのためのクラスを用意し、そのフィールドにデリゲートオブジェクトやその他の値を保存するようにします。

 コールバックメソッドは、AsyncCallbackデリゲートと同じシグネチャである必要があります。また、コールバックメソッドはスレッドプールのスレッドで実行されることに注意してください。

 非同期デリゲートを使用して非同期処理を行う手順をまとめると、次のようになります。

  1. 非同期で呼び出したいメソッドと同じシグネチャのデリゲートを宣言する。
  2. デリゲートオブジェクトを作成する。
  3. メソッドへの引数を指定して、デリゲートオブジェクトのBeginInvokeメソッドを呼び出す。コールバックメソッドを使用するときは、AsyncCallbackと状態オブジェクトも指定する。この時、状態オブジェクトとしてデリゲートオブジェクトを指定しておくと、コールバックメソッドからEndInvokeメソッドを呼び出す時に楽である。
  4. BeginInvokeメソッドが返すIAsyncResultオブジェクトを指定して、EndInvokeメソッドを呼び出し、メソッドからの戻り値を取得する。

非同期メソッド

 .NET Frameworkには、「Begin...」で始まるメソッドと、それと対になる「End...」で始めるメソッドが幾つかあります。このうち、例えば、

  • Stream.BeginRead
  • BeginWrite、Socket.BeginReceive
  • WebRequest.BeginGetResponse
  • MessageQueue.BeginReceive

 などのメソッドは、非同期操作で使用されます。このような非同期操作で使用されるメソッドはスレッドプールが使用され、終了時に呼び出されるコールバックメソッドはスレッドプールのスレッドで実行されます。つまり、「非同期デリゲート」とほぼ同じと使い方をし、注意点も同様です。

 また、次のようなイベントのイベントハンドラなども、スレッドプールのスレッドで呼び出されます。

  • EventLogクラスのEntryWrittenイベント
  • FileSystemWatcherクラスのChangedCreatedDeletedRenamedイベント
  • MessageQueueクラスのReceiveCompletedPeekCompletedイベント
  • ProcessクラスのExitedイベント
  • System.Timers.TimerクラスのElapsedイベント

 しかし、これらのクラスではSynchronizingObjectオブジェクトにフォーム(またはコントロール)を設定することにより、そのフォームを作成したスレッドで呼び出されるようになります(つまり、記事冒頭で紹介したマーシャリングが行われます)。これらのクラスはVS.NETの[ツールボックス]の[コンポーネント]内にあり、ここからフォームに貼り付けたときは、SynchronizingObjectプロパティとして自動的にそのフォームが設定されますので、スレッドプールを意識することはないでしょう。

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

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
.NET Frameworkにおけるマルチスレッドプログラミングの基本連載記事一覧

もっと読む

この記事の著者

どぼん!(ドボン!)

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

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/139 2006/07/11 14:37

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング