September 2009

Volume 24 Number 09

並列デバッグ - Visual Studio 2010 におけるタスクベースの並列アプリケーションのデバッグ

Daniel Moth | September 2009

サンプル コードのダウンロード

どの CPU または GPU ハードウェア メーカーに聞いても、将来はメニーコアの時代になると言うでしょう。プロセッサのコア速度は、この 40 年間のような急速なペースで向上していません。むしろ、新しいコンピューターは、コアを増やして製造されるようになっています。その結果、長年アプリケーション開発者が "無料" で手に入れてきたパフォーマンスの向上は、過去のものとなりました。再び、進化し続けるハードウェアの "無料のランチ" をごちそうになり、新しいパフォーマンスを活かした機能でアプリケーションを強化するには、並列処理を行って複数のコアを利用する必要があります。

Visual Studio 2010 で提供される Visual C++ 10 および Microsoft .NET Framework 4 では、新たに導入されるライブラリおよびランタイムによって、コード ベースで並列処理を表現するプロセスが大幅に簡略化されます。また、並列アプリケーションのパフォーマンス分析やデバッグを行う新しいツールもサポートされます。この記事では、Visual Studio 2010 でのデバッグのサポートについて説明します。その大半は、タスクベースのプログラミング モデルに重点を置いています。

タスクベースのプログラミングの必要性

アプリケーションに並列処理を導入するのは、複数のコアを利用するためです。単一の順次処理の作業では、1 度に 1 つのコアでしか実行されません。アプリケーションで複数のコアを使用するには、複数のスレッドで並列処理されるように、作業は複数の部品で構成される必要があります。したがって、作業が 1 部品で構成されているのであれば、複数コアでの実行による並列処理でパフォーマンスを向上できるように、作業を複数のユニットに分割する必要があります。

最も簡単な方法は、静的分割です。つまり、作業を決められたサイズの決められた数のユニットに分割します。もちろん、これを実行するハードウェア構成ごとにコードを作成しなければならないという状況は望ましくありません。また、あらかじめユニット数を決めておくと、今より大きく、性能のよいコンピューターで実行される場合、アプリケーションのスケーラビリティが損なわれます。代わりに、コンピューターの詳細に基づき、実行時に動的にユニット数を決めることができます。たとえば、1 コアに付き 1 ユニットとして作業を分割します。この場合、必要な処理時間という点ですべてのユニットのサイズが同じであり、1 ユニットに付き 1 スレッドを使用すれば、コンピューターの性能を極限まで利用できます。

ただし、この方法でもまだ十分とはいえません。実際のワークロードをこのような形で分割できることはほとんどありません。同じコンピューターで同時に実行されていて、このコンピューターのリソースを使用している可能性がある他のワークロードなど、外部要因を考慮した場合は特に、各ユニットの処理時間が同じになることは保証できません。この場合、1 コアに付き 1 ユニットの分割では、作業が不均等に分散されることになると思われます。つまり、あるスレッドが別のスレッドよりも先にユニットの処理が完了し、結果として負荷が不均等になります。一部のコアは他のコアが完了するまでアイドル状態で待機します。この問題を解決するためには、作業を細かく分割し、ワークロードをできる限り小さいユニットに分けて、コンピューターのすべてのリソースが、処理が完全に終わるまで、ワークロードの処理に参加できるようにします。

作業の 1 ユニットの実行にオーバーヘッドが伴わない場合は、お話したばかりのソリューションが理想的ですが、オーバーヘッドが発生しない操作は実際にはまずありません。従来、このような作業ユニットを実行するメカニズムとして、スレッドが使われてきました。作業ユニットごとにスレッドを作成して、実行を終えたら、スレッドを破棄します。残念ながら、スレッドは比較的負荷が高いため、この方法でスレッドを使用して発生するオーバーヘッドを考えると、説明したような細かい分割は利用できません。必要なのは、オーバーヘッドを最小限に抑えて、このように分割されたユニットを実行できる負荷の低いメカニズム (罪悪感をそれほど持たずに細かく分割できるメカニズム) です。このような方法を取る場合、1 ユニットに付き 1 スレッドを作成するのではなく、スケジューラを利用できます。スケジューラにより管理対象のスレッドで各ユニットが実行されるようにして、ユニット数を最小限に抑えながらスループットの最大化を図ります。

これが、まさにスレッド プールに当たります。これは、スケジュールされているすべての作業項目のスレッド管理のコストが償却されるため、個々の作業項目関連のオーバーヘッドを最小限に抑えることができます。Windows では、このようなスレッド プールには Kernel32.dll から公開される QueueUserWorkItem 関数を使用してアクセスします (Windows Vista には、新しいスレッド プール機能も導入されています)。.NET Framework 4 では、このようなプールには、System.Threading.ThreadPool クラスを使用してアクセスします。

上記の API では比較的わずかなオーバーヘッドしか伴わない分割が可能になりますが、これらの API は主に、"開始したら後は忘れてよい (fire and forget)" 作業を対象としています。たとえば、.NET Framework 4 ThreadPool クラスには、例外処理、作業の取り消し、作業完了までの管理、作業完了の通知受け取りなどについての一貫性のあるメカニズムがありません。この間隙を埋めるのが、マネージ コードにもネイティブ コードにも対応する "タスクベースの" プログラミングを考えて用意された .NET Framework 4 と Visual C++ 10 の新しい API です。タスクは、作業や実行管理のためのさまざまな機能を公開しながらも、基盤のスケジューラによって効率的に実行できる作業ユニットです。Visual C++ 10 では、これらの API は Concurrency::task_group 型および Concurrency::task_handle 型にまとめられています。.NET Framework 4 では、新しい System.Threading.Tasks.Task クラスにまとめられています。


図 1 [Parallel Stacks] (並列スタック) ウィンドウ


図 2 [Parallel Tasks] (並列タスク) ウィンドウ

現在の Visual Studio でのデバッグ

ソフトウェア開発の歴史で繰り返し証明されてきているのは、プログラミング モデルは規範的なデバッグ サポートから大きな恩恵を受けていることです。Visual Studio 2010 ではこの点を踏まえ、タスクベースの並列プログラミングに役立つ 2 つの新しいデバッグ ツール ウィンドウを提供しています。これらの新機能を説明する前に、お膳立てとして、現在の Visual Studio のデバッグ機能を確認しましょう。

(この記事の残りの部分では、説明のために .NET のタスクベースの型を使用しますが、このデバッグ サポートの説明は、ネイティブ コードにも当てはまります)。

Visual Studio でのプロセスのデバッグの最初の作業は、もちろん、デバッガーをアタッチすることです。この処理は、Visual Studio で開かれているプロジェクトで F5 キーを押す (または、[デバッグ] メニューの [デバッグ開始] をクリックする) と既定で実行されます。または、[デバッグ] メニューの [プロセスにアタッチ] をクリックして、デバッガーをプロセスに手動でアタッチすることもできます。デバッガーがアタッチされたら、次はデバッガーに割り込みます。これには複数の方法があります。具体的には、ユーザー定義のブレーク ポイントに到達する、手動で割り込む ([デバッグ] の [1 つのプロセスがブレークするとき、他のプロセスもブレークする])、これを要求するプロセスを利用する (たとえば、マネージ コードでは、System.Diagnostics.Debugger.Break メソッドへの呼び出し)、または例外をスローすることもできます。

図 3 素数の検索

//C#



static void Main(string[] args)

{

var primes =

from n in Enumerable.Range(1,10000000)

.AsParallel()

.AsOrdered()

.WithMergeOptions(ParallelMergeOptions.NotBuffered)

where IsPrime(n)

select n;

foreach (var prime in primes) Console.Write(prime + “, ”);

}

public static bool IsPrime(int numberToTest) // WARNING: Buggy!

{

// 2 is a weird prime: it’s even. Test for it explicitly.

if (numberToTest == 2) return true;

// Anything that’s less than 2 or that’s even is not prime

if (numberToTest < 2 || (numberToTest & 1) == 0) return false;

// Test all odd numbers less than the sqrt of the target number.

// If the target is divisible by any of them, it’s not prime.

// We don’t test evens, because if the target is divisible

// by an even, the target is also even, which we already checked for.

int upperBound = (int)Math.Sqrt(numberToTest);

for (int i = 3; i < upperBound; i += 2)

{

if ((numberToTest % i) == 0) return false;

}

// It’s prime!

return true;

}

'Visual Basic



Shared Sub Main(ByVal args() As String)

        Dim primes = From n In Enumerable.Range(1, 10000000).AsParallel().AsOrdered().WithMergeOptions(ParallelMergeOptions.NotBuffered)

                     Where IsPrime(n)

                     Select n

        For Each prime In primes

            Console.Write(prime + ", ")

        Next prime

    End Sub

    Public Shared Function IsPrime(ByVal numberToTest As Integer) As Boolean ' WARNING: Buggy!

        ' 2 is a weird prime: it’s even. Test for it explicitly.

        If numberToTest = 2 Then

            Return True

        End If

        ' Anything that’s less than 2 or that’s even is not prime

        If numberToTest < 2 OrElse (numberToTest And 1) = 0 Then

            Return False

        End If

        ' Test all odd numbers less than the sqrt of the target number.

        ' If the target is divisible by any of them, it’s not prime.

        ' We don’t test evens, because if the target is divisible

        ' by an even, the target is also even, which we already checked for.

        Dim upperBound = CInt(Fix(Math.Sqrt(numberToTest)))

        For i = 3 To upperBound - 1 Step 2

            If (numberToTest Mod i) = 0 Then

                Return False

            End If

        Next i

        ' It’s prime!

        Return True

    End Function

デバッガーへの割り込みを処理したら、アプリケーション内のすべてのスレッドが停止します。その時点では、実行を続行するまでは、どのコードも実行されません (デバッガー自体が使用するるスレッドは除きます)。このように実行を停止することで、その時点でのアプリケーションの状態を調査できます。アプリケーションの状態を調査するときに、通常は頭の中でどのような状態であるかを予想しています。さまざまなデバッガー ウィンドウを使用して、予想と現実との差を確認できます。

Visual Studio で開発者が基本的に使用するデバッグ ウィンドウは、[スレッド] ウィンドウ、[呼び出し履歴] ウィンドウ、[変数] ウィンドウ ([ローカル]、[自動変数]、[ウォッチ]) です。[スレッド] ウィンドウには、プロセス内のすべてのスレッドが、現在のスレッドのスレッド ID とスレッドの優先順位、インジケーション (黄色の矢印) などの情報と合わせて一覧表示されます。これは既定では、デバッガーがプロセスに割り込んだときに実行されていたスレッドです。スレッドに関しておそらく最も重要な情報は、デバッガが実行を停止した時点で、実行していた場所です。これは、呼び出し履歴フレームの Location (場所) 列に表示されています。この列の上にカーソルを合わせると、これも同じぐらい重要な呼び出し履歴が表示されます。これは、スレッドが現在の場所にたどり着く前に実行していた系列またはメソッド呼び出しです。

現在のスレッドの呼び出し履歴が表示される [呼び出し履歴] ウィンドウには、相互作用の機会も含め、呼び出し履歴についてさらにかなり詳しい情報が表示されます。

[呼び出し履歴] ウィンドウに別のスレッドの呼び出し履歴を表示するには、[スレッド] ウィンドウで目的のスレッドをダブルクリックして、そのスレッドを現在のスレッドにします。現在実行中のメソッド (呼び出し履歴の一番上) は、黄色の矢印によって示され、"最上位フレーム"、"リーフ フレーム"、または "アクティブ スタック フレーム" と呼ばれます。これは、デバッガーから離れて、アプリケーションの実行を継続するときに、スレッドが実行を継続する起点となるメソッドです。既定では、アクティブ スタック フレームは、現在のスタック フレームでもあります。つまり、次に説明する変数の検査を行うメソッドです。


図 4 条件付きブレークポイントの設定

アプリケーションの変数値の検査には、変数ウィンドウが使用されます。ローカル メソッドの変数は通常、[ローカル] ウィンドウと [自動変数] ウィンドウに表示されます。グローバル状態 (メソッドで宣言されていない変数) については、[ウォッチ] ウィンドウに追加して検査できます。.Visual Studio 2005 のときから、目的の変数の上にマウス ポインターを置くことで状態を検査し、ポップアップ表示されたデータチップを確認する開発者が増えてきました (データチップは、"クイック ウォッチ" ウィンドウへのショートカットと考えられます)。変数の値は、変数が現在のスタック フレーム (これは、前述のとおり、既定では現在のスレッドのアクティブ スタック フレームです) のスコープにある場合にしか表示できないことに注意してください。

スレッドの呼び出し履歴で、以前にスコープ内にあった変数を検査するには、[呼び出し履歴] ウィンドウで検査対象のスタック フレームをダブルクリックして、現在のスタック フレームを変更する必要があります。この時点で、この新しい現在のスタック フレームには、柄の部分がカーブしている緑の矢印が表示されます (アクティブ スタック フレームは黄色の矢印のままです)。また、別のスレッドの変数を検査する場合は、[スレッド] ウィンドウで現在のスレッドを変更し、[呼び出し履歴] ウィンドウでそのスレッドの呼び出し履歴の現在のフレームを切り替える必要があります。

つまり、デバッガーからプロセスに割り込むと、1 つのスレッドで実行中のメソッドのスコープにある変数を非常に簡単に検査できます。ただし、すべてのスレッドの実行中の場所を完全に把握するには、各スレッドをダブル クリックして現在のスレッドにして、各スレッドの呼び出し履歴を個別に検査し、[呼び出し履歴] ウィンドウを確認して、頭の中で全体像を作成する必要があります。さらに、さまざまなスレッドのさまざまなスタック フレームの変数を検査するには、再び 2 段階の処理を行う必要があります。つまり、スレッドを切り替えて、フレームを切り替えます。

並列スタック

アプリケーションが使用するスレッドが多くなると (より処理リソースが多いコンピューターが使われるようになっているので、これが一般的になるでしょう)、ある時点でこれらのスレッドが実行されている場所をまとめて確認できるビューが必要になります。これが、Visual Studio 2010 で提供される並列スタック ツール ウィンドウです。

画面の実際の状態を維持しながら、並列シナリオにおいて特に興味深いメソッドを示すために、このウィンドウは、呼び出し履歴セグメントと同じノードに結合されています。これらは、スレッドのルートでは一般的なものです。たとえば、図 1 では、1 つのビューに 3 つのスレッドの呼び出し履歴が表示されています。図では、Main から A そして B へ移行している 1 つのスレッドと、同じ外部コードから始まり、A に移行している他の 2 つのスレッドが表示されています。そのうちの 1 つは、さらに B、そして外部コードへと移り、もう一方は C から AnonymousMethod へと移っています。AnonymousMethod もアクティブ スタック フレームで、現在のスレッドに属しています。ズーム、鳥瞰図ビュー、フラグによるスレッドのフィルター、既に [呼び出し履歴] ウィンドウで利用できる機能とほとんど同じ機能など、他にもさまざまな機能がこのウィンドウではサポートされています。


図 5 [Freeze All Threads But This] (これ以外のすべてのスレッドを凍結) をクリック


図 6 スタック フレームの結合

アプリケーションがスレッドではなくタスクを作成する場合、タスク中心のビューに切り替えることができます。このビューでは、タスクを実行していないスレッドの呼び出し履歴が省略されています。また、スレッドの呼び出し履歴は、タスクの実際の呼び出し履歴に合わせて短くなっています。つまり、単一スレッドの呼び出し履歴には、分割して個別に表示したい 2、3 のタスクのみが保持される可能性があります。[Parallel Stacks] (並列スタック) ウィンドウの特別な機能を使用すると、1 つのメソッドを中心に図を展開して、そのメソッドのコンテキストでの呼び出し元と呼び出し先を明確に把握できます。

図 7 依存関係があるタスクベースのコード

    //C#
    
    
    
    static void Main(string[] args) // WARNING: Buggy!
    
    {
    
    var task1a = Task.Factory.StartNew(Step1a);
    
    var task1b = Task.Factory.StartNew(Step1b);
    
    var task1c = Task.Factory.StartNew(Step1c);
    
    Task.WaitAll(task1a, task1b, task1c);
    
    var task2a = Task.Factory.StartNew(Step2a);
    
    var task2b = Task.Factory.StartNew(Step2b);
    
    var task2c = Task.Factory.StartNew(Step2c);
    
    Task.WaitAll(task1a, task1b, task1c);
    
    var task3a = Task.Factory.StartNew(Step3a);
    
    var task3b = Task.Factory.StartNew(Step3b);
    
    var task3c = Task.Factory.StartNew(Step3c);
    
    Task.WaitAll(task3a, task3b, task3c);
    
    }

    ‘Visual Basic
    
    
    
    Shared Sub Main(ByVal args() As String) ' WARNING: Buggy!
    
            Dim task1a = Task.Factory.StartNew(Step1a)
    
            Dim task1b = Task.Factory.StartNew(Step1b)
    
            Dim task1c = Task.Factory.StartNew(Step1c)
    
            Task.WaitAll(task1a, task1b, task1c)
    
            Dim task2a = Task.Factory.StartNew(Step2a)
    
            Dim task2b = Task.Factory.StartNew(Step2b)
    
            Dim task2c = Task.Factory.StartNew(Step2c)
    
            Task.WaitAll(task1a, task1b, task1c)
    
            Dim task3a = Task.Factory.StartNew(Step3a)
    
            Dim task3b = Task.Factory.StartNew(Step3b)
    
            Dim task3c = Task.Factory.StartNew(Step3c)
    
            Task.WaitAll(task3a, task3b, task3c)
    
    End Sub

並列タスク

[Parallel Stacks] (並列スタック) ウィンドウで実際の呼び出し履歴を確認するだけでなく、もう 1 つ別の新しいデバッガー ウィンドウでも、タスク ID、タスクに割り当てられているスレッド、現在の場所、作成時にタスクに渡されたエントリ ポイント(デリゲート) など、タスクのその他の情報を確認できます。このウィンドウは、[Parallel Tasks] (並列タスク) ウィンドウと呼ばれ、現在のタスク (現在のスレッドで実行中の最上位タスク)、現在のタスクの切り替え機能、タスクのフラグ、スレッドの凍結と解凍など、[スレッド] ウィンドウと同様の機能を公開します。


図 8 [Parallel Tasks] (並列タスク) ウィンドウを使用して依存関係の問題を調査

おそらく、開発者にとって最も価値があるのは、[Status] (状態) 列です。[Status] (状態) 列の情報から、実行中のタスクと (別のタスクまたは同期プリミティブを) 待機中のタスク、またはデッドロック状態のタスク (循環待機チェーンが検出された、特殊な待機状態にあるタスク) とを見分けることができます。[Parallel Tasks] (並列タスク) ウィンドウにも、スケジュールされたタスクが表示されます。これらは、まだ実行されていませんが、スレッドにより実行されるために、キューで待機しているタスクです。この場合の例を図 2 に示します。[Parallel Stacks] (並列スタック) ウィンドウと [Parallel Tasks] (並列タスク) ウィンドウの詳細については、ブログ記事 (danielmoth.com/Blog/labels/ParallelComputing.html) および MSDN ドキュメント (msdn.microsoft.com/dd554943(VS.100).aspx) を参照してください。

図 9 デッドロックが発生するコード

    //C#
    
    
    
    static void Main(string[] args)
    
    {
    
    int transfersCompleted = 0;
    
    Watchdog.BreakIfRepeats(() => transfersCompleted, 500);
    
    BankAccount a = new BankAccount { Balance = 1000 };
    
    BankAccount b = new BankAccount { Balance = 1000 };
    
    while (true)
    
    {
    
    Parallel.Invoke(
    
    () => Transfer(a, b, 100),
    
    () => Transfer(b, a, 100));
    
    transfersCompleted += 2;
    
    }
    
    }
    
    class BankAccount { public int Balance; }
    
    static void Transfer(BankAccount one, BankAccount two, int amount)
    
    {
    
    lock (one) // WARNING: Buggy!
    
    {
    
    lock (two)
    
    {
    
    one.Balance -= amount;
    
    two.Balance += amount;
    
    }
    
    }
    
    }

    ‘Visual Basic
    
    
    
    Shared Sub Main(ByVal args() As String)
    
    Dim transfersCompleted = 0
    
    Watchdog.BreakIfRepeats(Function() transfersCompleted, 500)
    
    Dim a = New BankAccount With {.Balance = 1000}
    
    Dim b = New BankAccount With {.Balance = 1000}
    
    Do
    
    Parallel.Invoke(Sub() Transfer(a, b, 100), Sub() Transfer(b, a, 100))
    
    transfersCompleted += 2
    
    Loop
    
    End Sub
    
    Friend Class BankAccount
    
        Public Balance As Integer
    
    End Class
    
    Shared Sub Transfer(ByVal one As BankAccount, ByVal two As BankAccount, ByVal amount As Integer)
    
    SyncLock one ' WARNING: Buggy!
    
    SyncLock two
    
    one.Balance -= amount
    
    two.Balance += amount
    
    End SyncLock
    
    End SyncLock
    
    End Sub

バグを見つける

新しいツール機能を理解する最もよい方法は、実際の動作を確認することです。そのために、いくつかバグのあるコード スニペットを作成してあります。ここでは、新しいツール ウィンドウを使用して、コードに潜むエラーを見つけます。

シングル ステップ

まず、図 3 のコードを見てみましょう。このコードの目的は、1 ~ 10,000,000 にある素数を出力し、この作業を並列処理することです (並列処理のサポートは並列 LINQ によって提供されます。詳細については、blogs.msdn.com/pfxteam and msdn.microsoft.com/dd460688(VS.100).aspx を参照してください)。コードを実行して、出力された最初のいくつかの数値を見るとわかりますが、IsPrime の実装にバグがあります。

2, 3, 5, 7, 9, 11, 13, 15, 17, 19, 23, 25, ...

これらの数値のほとんどは素数ですが、9、15、および 25 は違います。これが単一スレッド アプリケーションであったなら、簡単にコードをステップ実行して、誤った結果になった理由を見つけられます。しかし、シングル ステップを実行 (たとえば [デバッグ] をクリックし、[<対象> にステップ イン] をクリック) した場合、複数スレッド プログラムでは、プログラムのすべてのスレッドがステップ実行の対象になり得ます。つまり、ステップ実行中に複数のスレッド間を行き来する可能性があり、制御フローとプログラムの現在の位置についての診断情報を把握しづらくなります。この作業を行う場合、デバッガーのいくつかの機能を利用できます。まず 1 つは、条件付きブレークポイントを設定することです。

図 4 のように、ブレークポイント (この例では IsPrime メソッドの最初の行) を設定し、特定の条件に一致した場合にのみデバッガーに割り込むことを指定します。この例では、誤った "素数" の 1 つが評価されることが条件です。

(問題の値のいずれかではなく) 問題の値の特定の 1 つに行き当たったときに、デバッガーに割り込むように設定することもできましたが、ここではテストに使用する値を PLINQ が処理する順序を推測できません。そこで、問題の値のいずれかを検出するようにデバッガーに指示して、割り込みまでの待ち時間ができる限り短くて済むようにしています。

デバッガーに割り込んだら、現在のスレッドのみをシングル ステップ実行するように指示する必要があります。それには、デバッガーの機能であるスレッドの凍結と解凍を利用して、凍結したスレッドが解凍されるまで実行されないようにします。新しい [Parallel Tasks] (並列タスク) ウィンドウでは、簡単に続行を許可するスレッドを見つけて (黄色の矢印のアイコンが目印)、他のすべてのスレッドは (コンテキスト メニューを介して) 凍結できます (図 5 参照)。

関係のないスレッドを凍結できたら、バグのある IsPrime をシングル ステップ実行できるようになります。numberToTest==25 をデバッグすることで、容易に処理の誤りを把握できます。テストを行うループには、upperBound 値を含めなければなりませんが、ループの条件が less-than-or-equals 演算子ではなく less-than 演算子となっているため、この値が除外されています。つまり、25 の平方根は 5 で、25 は 5 で割り切れるのですが、5 がテストされないために、25 が誤って素数に分類されています。

[Parallel Stacks] (並列スタック) ウィンドウも、割り込み時にプログラムの状態を確認できる、便利な統合ビューを提供しています。図 6 は、アプリケーションを再び実行した後の現在状態を示しており、今回はデバッガーの "すべてのプロセスをブレークする" 機能を使用して明示的に割り込んでいます。

PLINQ は IsPrime を複数のタスクで実行し、これらすべてのタスクの numberToTest 値がポップアップ ウィンドウに表示されます。この例では、Task 1 が numberToTest==8431901 を処理し、Task 2 が numberToTest==8431607 を処理しています。

依存関係の問題

図 7 のコードは、並列アプリケーションでは一般的なパターンを示しています。このコードは、並列で実行できるように複数の操作 (step1a、step1b、step1c、これらはすべてフォーム "void StepXx()" のメソッド) に分岐し、その後、結合されてます。その後、アプリケーションは再び分岐しますが、この操作の中で悪影響を及ぼす問題に依存しているため (データを共有配列に書き込むなど)、前の操作が完了している必要があるコードがあります。

残念ながら、このコードにはバグがあり、このコードを作成した開発者は、タスクの 3 つ目のセットによって誤った結果が実行されるのを目にすることになります。開発者は先行するタスクがすべて完了するのを待っていても、何か誤りがあり、先に実行されたステップの中に実際には完了しないと思われます。コードをデバッグするには、最後の WaitAll 呼び出しにブレークポイントを設定して、[Parallel Tasks] (並列タスク) ウィンドウを使用してプログラムの現在状態を確認する必要があります (図 8 参照)。

予想したとおり、[Parallel Tasks] (並列タスク) ウィンドウには、Step 3 のタスクが既にスケジュールされていますが、Step2c のタスクがまだ実行中であることが示されています。2 つ目の Task.WaitAll 呼び出しを見直せば、理由が分かります。答えは、入力ミスです。task2 のサブコードではなく、task1a、task1b、および task1c が待機.の対象になっています。

デッドロック

図 9 は、ロックの順序に注意を払わなかったことによる、典型的なデッドロック シナリオの例です。メインのコードは、銀行口座間の送金を延々と実行します。Transfer メソッドは、複数のスレッドから同時に呼び出せるように、スレッドセーフにする必要があります。このため、これに渡される BankAccount オブジェクトを内部でロックしています。単純に 1 つ目をロックし、次に 2 つ目をロックしています。残念ながら、このコードを実行するとわかりますが、この動作ではデッドロックにつながる可能性があります。最終的には、まったく送金が処理されていないことを検出して、デバッガーが割り込みます (割り込みは、一定の期間が過ぎても新たに送金が完了していないことをデバッガーが検出すると、Debugger.Break を発行するコードを使用して実行されます。このコードは、この記事に付属のダウンロード コードに含まれています)。


図 10 [Parallel Tasks] (並列タスク) ウィンドウに表示されたデッドロック情報

 


図 11 デッドロックを表示する [Parallel Stacks] (並列スタック) ウィンドウ

 


図 12 [Parallel Stacks] (並列スタック) ウィンドウのメソッド ビュー

デバッガーで作業をすると、直ちにデッドロックがあることを示すアイコンが表示されます (図 10 参照)。また、この図からは、Waiting-Deadlocked 状態をマウスでポイントすると、正確に何が待機されていて、どのスレッドが保護リソースを保持しているかについて詳細な情報が提供されることがわかります。[Thread Assignment] (スレッドの割り当て) 列を見ると、Task 2 が Task 1 に保持されているリソースを待機していること、また Task 1 をマウスでポイントするとこの逆の状態になっていることが分かります。

この情報は [Parallel Stacks] (並列スタック) ツール ウィンドウからも推測できます。図 11 は、[Parallel Stacks] (並列スタック) ウィンドウのタスク ビューです。ここでは、2 つのタスクが表示されていますが、そのどちらも Monitor.Enter への呼び出しをブロックしています (図 9 の lock ステートメントのため)。また、図 12 は、[Parallel Stacks] (並列スタック) ウィンドウのメソッド ビューを示しています (対応するツール バー ボタンからアクセス)。Transfer メソッドをよく見ると、Transfer には現在 2 つのタスクがあり、どちらも Monitor.Enter への呼び出しを保持していることが容易にわかります。枠をマウスでポイントすると、両方のタスクのデッドロック状態について、詳細が表示されます。

図 13 ロック コンボイの作成

    //C#
    
    
    
    static void Main(string[] args) // WARNING: Buggy!
    
    {
    
    object obj = new object();
    
    Enumerable.Range(1, 10).Select(i =>
    
    {
    
    var t = new Thread(() =>
    
    {
    
    while (true)
    
    {
    
    DoWork();
    
    lock (obj) DoProtectedWork();
    
    }
    
    }) { Name = “Demo “ + i };
    
    t.Start();
    
    return t;
    
    }).ToList().ForEach(t => t.Join());
    
    }

    ‘Visual Basic
    
    
    
    Public Shared Sub Run()
    
            'Watchdog.BreakIn(3000);
    
             Dim obj As New Object()
    
             Enumerable.Range(1, 10).Select(Function(i)
    
            Dim t = New Thread(Sub()
    
                        Do
    
                            DoWork() 
    
                        SyncLock obj   
    
                        DoProtectedWork()                                                                       
    
                        End SyncLock
    
                    Loop
    
                     End Sub)                                                                     
    
                t.Name = "Demo" & i
    
                t.Start()
    
                Return t
    
                        End Function).ToList().ForEach(Sub(t)      
    
                                        t.Join()                                                                      
    
                                         End Sub) 
    
     End Sub

ロック コンボイ

ロック コンボイは、同じ保護領域に対して、複数のスレッドが繰り返し競合すると発生する可能性があります (Wikipedia にロック コンボイについてわかりやすくまとめられた記事があります。en.wikipedia.org/wiki/Lock_convoy)。図 13 のコードは、ロック コンボイの典型的な例です。保護領域の外で、複数のスレッドが繰り返し少しずつ作業をしていますが、保護領域内で他の作業をするためにロックをかけています。この領域の内外での作業量の割合によりますが、パフォーマンスの問題が発生する可能性があります。このような問題は、Visual Studio 2010 に付属の同時実行プロファイラーのようなツールを使用して、プログラムを実行するとわかりますが、[Parallel Stacks] (並列スタック) ウィンドウなどのデバッグ ツールを使用して実行しても、確認できます。

図 14 [Parallel Stacks] (並列スタック) ウィンドウに表示されたロック コンボイ

図 14図 13のコードの実行を示しています。コードは、この実行開始の数秒後に割り込まれました。この図の上部で、9 つのスレッドが現在監視を待機しているために、ブロックされているのがわかります。どのスレッドも、ある 1 つのスレッドにより DoProtectedWork が終了されるのを待っています。終了されれば、9 つのスレッドのうちの 1 つが、保護領域に入ることができるためです。

まとめ

この記事では、Visual Studio 2010 デバッガ ツール ウィンドウを使用して、タスク ベースのコードのバグを見つける作業を簡素化できる例を紹介しました。マネージ コードとネイティブ コードのタスクベースの API は、この記事の簡単な例でお見せしたものよりも豊富です。これらについては、.NET Framework 4 と Visual C++ 10 でさらにお試しになることをお勧めします。ツールについては、この記事の 2 つの新しいデバッガー ウィンドウのほかに、新しい並列パフォーマンス アナライザーが Visual Studio の既存のプロファイラーに統合されています。

これらのすべての機能を試すには、Visual Studio 2010 (msdn.microsoft.com/dd582936.aspx) をダウンロードしてください。

Daniel Mothは、マイクロソフトの並列コンピューティング プラットフォーム チームで仕事をしています。彼のブログは、danielmoth.com/Blogからご覧になれます。
Stephen Toubは、マイクロソフトの並列コンピューティング プラットフォーム チームで仕事をしています。また、MSDN Magazine の寄稿編集者でもあります。