コーディング ツール
次期バージョンの Visual Studio で強化される並列処理のサポート
Stephen Toub および Hazim Shafi
この記事は、Visual C++、Visual Studio、および .NET Framework のプレリリース版に基づいています。ここに記載されているすべての情報は、変更される場合があります。
この記事では、次の内容について説明します。
- マネージ コードで並列処理を表現する
- ネイティブ コードで並列処理を表現する
- 並列アプリケーションをデバッグする
- 並列アプリケーションをプロファイリングする
|
この記事では、次のテクノロジを使用しています。
Visual Studio、Multithreading
|

目次
ローカル コンピュータ ストアに移行することで多くのソフトウェアのパフォーマンス ボトルネックが解決されたのは、それほど昔のことではありません。18 ~ 24 か月ごとに新しいコンピュータを購入する手段と動機を持つ消費者と企業は、コンピュータを新たに購入するたびに、計算集約的なアプリケーションの実行速度が以前の 2 倍になっていることに気付きました。
悲しいことに、このような古きよき時代は過ぎ去ってしまったようです。CPU 速度が指数関数的に向上した日々は過去のことです。その主な理由は、私たちが速さに慣れるのに近いペース、また、最近のソフトウェアの進歩による要求に近いペースでハードウェア メーカーが経済的かつ環境にやさしくクロック速度を拡張するのを妨害する厄介な物理学の法則にあります。一方、ムーアの法則で示されるトランジスタの到来によって、チップ メーカーは市販ハードウェアで使用可能なコアの数を指数関数的に拡大できます。現在、米国でシングルコア コンピュータが売られているのを見かけることはめったになく、デュアルコアが標準になっています。来年に販売される一般的な消費者コンピュータはクアッドコアになり、その後間もなく 8 コアが標準となるでしょう。
残念ながら、今日のソフトウェアの大多数は、このようなプロセッサ数の指数関数的な増加を自動的には利用しません。これを利用するには、ソフトウェアがその構成要素の安全で効果的な分割をサポートするような方法で記述されている必要があります。これらの個々の部分を並列に計算し、すべてのコアに分散できるようにして、マルチコアおよびメニーコア システムで保証されている潜在能力を実現する必要があります。
今日の卓越したプログラミング言語、フレームワーク、および開発環境でこのような並列処理を表現することは簡単ではありません。最も平凡なシステムを除いて、マルチスレッド プログラミングは今日信じられないほど難しく、エラーを引き起こしやすくなっています。並列パターンの実装は普及しておらず、よく知られた簡単なものではありません。同時実行を利用するようにアプリケーションを構築できる場合でも、最もよく知られた競合状態やデッドロックから、より不明瞭なキャッシュ コヒレンシーのオーバーヘッド、ロック コンボイ、優先順位の逆転にいたるまで、機能の正確性とパフォーマンスの問題が頻繁に発生します。
さらに、開発者がこのようなパターンを克服できたとしても、それにかかる時間と労力はビジネスに負担をかけ、最良の (そして最も待遇のよい) 開発者は、パフォーマンス中心の業界で競争力を維持するためのコストにすぎない同時実行の問題よりも、純利益に影響するビジネスの問題に集中することを期待されています。これらの問題が著しく重なり合い、開発者が並列アプリケーションのデバッグと分析に使用できるツールはせいぜい基本的なものでしかありません。
その結果、ソフトウェア開発のビッグ プレーヤーは、すべての開発者が採用できるように並列処理をはるかに簡単にする道に踏み出し、マイクロソフトはそれを先導しています。次期バージョンの Visual Studio の計画には、並列処理の堅牢な基盤が含まれ、コア プラットフォームとツール サポートの両方に、ネイティブ コードとマネージ コードに対する大幅な改良が等しく含まれています。
マネージ コードで並列処理を表現する
次の問題提起について考えます。赤ちゃんの誕生情報の一覧があり、特定範囲の年に特定の州で生まれた特定の名前の赤ちゃんを検索し、その結果を返します。次に、C# での典型的なソリューションがどのようになるかを示します。
IEnumerable<BabyInfo> Search(
IEnumerable<BabyInfo> babies, QueryInfo qi) {
foreach (var baby in babies) {
if (baby.Name == qi.Name && baby.State == qi.State &&
baby.Year >= qi.YearStart && baby.Year <= qi.YearEnd) {
yield return baby;
}
}
}
これは主に "厄介な並列" の問題です。理論上は使用可能なすべてのプロセッサに作業を分割することで処理の並列化を簡単に行うことができるようにして、個々の赤ちゃんを独立して分析できます。あいにく、この理論は常に実践できるわけではありません。
図 1 に、このソリューションの並列化の試行を示します。コード図の長さからわかるように、今日の Microsoft .NET Framework では、並列処理中心の問題の説明で示唆されるほどタスクは簡単ではありません。このコードをもってしても、ソリューションは理想からかけ離れています。手動パーティション化を採用して、複数のコアに処理を分散しています。スレッド間で調整する方法を理解するには、同期プリミティブの詳細な知識が必要です。列挙子での競合と、クリティカル領域の内部と外部で実行される作業の比率により、ロックのオーバーヘッドが実装の多くを占める可能性があります。他にも多くの問題があります。

図 1 ネイティブの手動並列処理
IEnumerable<BabyInfo> Search(
IEnumerable<BabyInfo> babies, QueryInfo qi) {
var results = new List<BabyInfo>();
int partitionsCount = Environment.ProcessorCount;
int remainingCount = partitionsCount;
var enumerator = babies.GetEnumerator();
try {
using (ManualResetEvent done = new ManualResetEvent(false)) {
for (int i = 0; i < partitionsCount; i++) {
ThreadPool.QueueUserWorkItem(delegate {
while(true) {
BabyInfo baby;
lock (enumerator) {
if (!enumerator.MoveNext()) break;
baby = enumerator.Current;
}
if (baby.Name == qi.Name &&
baby.State == qi.State &&
baby.Year >= qi.YearStart &&
baby.Year <= qi.YearEnd) {
lock(results) results.Add(baby);
}
}
if (Interlocked.Decrement(ref remainingCount) == 0)
done.Set();
});
}
done.WaitOne();
}
}
finally {
IDisposable d = enumerator as IDisposable;
if (d != null) d.Dispose();
}
return results;
}
このソリューションを見たときにさらにがっかりするのは、並列定型コードとメソッドのビジネス ロジックを実際に実装しているコードの比率です。開発者が差し迫った問題提起に集中できるようにすべての定型をシステムで処理できれば、はるかに有効になります。
IEnumerable<BabyInfo> Search(
IEnumerable<BabyInfo> babies, QueryInfo qi) {
var results = new ConcurrentStack<BabyInfo>();
Parallel.ForEach(babies, baby => {
if (baby.Name == qi.Name && baby.State == qi.State &&
baby.Year >= qi.YearStart && baby.Year <= qi.YearEnd) {
results.Push(baby);
}
});
return results;
}
鋭い読者は、この問題提起が LINQ-to-Objects を使用した実装にもかなり役立つことに気付くでしょう。年で並べ替えられた順序で赤ちゃんの情報を返すように元の問題提起を補強したと想像してください。この追加要件によって、並べ替えを並列で行う場合には特に、前の手動実装にかなりのコードが追加されます。LINQ では、そのソリューションを図 2 の先頭に示すようにコーディングできます。ここで、図 2 の下部で示すように AsParallel を babies 列挙に付加するだけで、LINQ クエリを自動的に並列処理できると想像してください。

図 2 PLINQ による並列処理
逐次
IEnumerable<BabyInfo> Search(
IEnumerable<BabyInfo> babies, QueryInfo qi) {
return from baby in babies
where baby.Name == qi.Name && baby.State == qi.State &&
baby.Year >= qi.YearStart && baby.Year <= qi.YearEnd
orderby baby.Year ascending
select baby;
}
並列
IEnumerable<BabyInfo> Search(
IEnumerable<BabyInfo> babies, QueryInfo qi) {
return from baby in babies.AsParallel()
where baby.Name == qi.Name && baby.State == qi.State &&
baby.Year >= qi.YearStart && baby.Year <= qi.YearEnd
orderby baby.Year ascending
select baby;
}
さいわいなことに、これらの構成要素はまったく架空のものではありません。一部の読者は、Parallel.ForEach を TPL (Task Parallel Library) の一部として認識している可能性があります。TPL は、2007 年 12 月から .NET Framework に対する Parallel Extensions の CTP (Community Technology Preview) の一部として正式に使用可能になっています。その他の読者は、AsParallel を PLINQ (Parallel LINQ) の一部として認識している可能性があります。これも Parallel Extensions CTP を通じて使用可能です。
次期バージョンの Visual Studio では、これらはコア .NET Framework の一部になり、.NET を使用しているすべての開発者が .NET 準拠の言語から使用できるようになります。逐次 LINQ 実装に並んで、Parallel.ForEach は mscorlib.dll の一部として、AsParallel は System.Core.dll の一部として使用できるようになります。
System.Threading および System.Threading.Tasks 名前空間を通じて公開される TPL は、2007 年 10 月発行の MSDN Magazine (msdn.microsoft.com/magazine/cc163340 を参照) で世界中に紹介されてからかなりの変更が加えられましたが、中核となる原理は変わりません。for ループを並列化するための Parallel.For、前の例で示した foreach ループを並列化するための Parallel.ForEach、ステートメントのセットを並列化するための Parallel.Invoke など、今日の逐次アプリケーションで使用される上位レベルの構成要素に代わるものが提供されます。これらのメソッドは、並列で実行するコードを表現するデリゲートをすべて受け入れるため、匿名メソッドとラムダ式をサポートする言語では特にうまく機能します。
たとえば、図 3 のコードは、Luke Hoban (マイクロソフトの F# のプログラム マネージャ) が F# で作成したレイ トレーサからの抜粋です。このコードは、Parallel.For メソッドを利用してシーンのレンダリングを並列化しています。2 つの実装の主な違いは 1 行に収まることに注意してください。元のコードのその行を次に示します。
for y = 0 to screenHeight - 1 do

図 3 F# から Parallel.For を使用する
逐次
member this.Render(scene, rgb : int[]) =
for y = 0 to screenHeight - 1 do
let stride = y * screenWidth
for x = 0 to screenWidth - 1 do
let color = TraceRay(
{Start = scene.Camera.Pos;
Dir = GetPoint x y scene.Camera },
scene, 0)
let intColor = color.ToInt ()
rgb.[x + stride] <- intColor
並列
member this.Render(scene, rgb : int[]) =
Parallel.For(0, screenHeight, fun y ->
let stride = y * screenWidth
for x = 0 to screenWidth - 1 do
let color = TraceRay(
{Start = scene.Camera.Pos;
Dir = GetPoint x y scene.Camera },
scene, 0)
let intColor = color.ToInt ()
rgb.[x + stride] <- intColor)
並列バージョンを次に示します。
Parallel.For(0, screenHeight, fun y ->
シーン レンダリングを構成する反復の分割および実行は、TPL によって自動的に処理されます。
別の例として、次の逐次コードはツリーの各ノードのデリゲートを実行します。
static void WalkTree<T>(Tree<T> tree, Action<T> handler) {
if (tree == null) return;
handler(tree.Data);
WalkTree(tree.Left, handler);
WalkTree(tree.Right, handler);
}
Parallel.Invoke を使用して、このツリー探索の実行を簡単に並列化できます。
static void WalkTree<T>(Tree<T> tree, Action<T> handler) {
if (tree == null) return;
Parallel.Invoke(
() => handler(tree.Data),
() => WalkTree(tree.Left, handler),
() => WalkTree(tree.Right, handler));
}
TPL におけるこれらの上位レベルの構成要素は、タスクベースの並列処理の基盤を形成する下位レベルの構成要素セットの上に構築されています。これには、実行をスケジュールできる個々の作業項目を表す Task クラス、将来の時点での計算値を表す Future<T> クラス、タスクと計算を将来実行するために使用されるスケジューラを表す TaskManager クラス、およびタスクを効果的に構成および管理するためのいくつかの型サポートが含まれます。これらのソリューションにより、非常に複雑な並列システムを、現在必要とされるよりもはるかに少ない労力で構築できます。
mscorlib に組み込まれている新しい同時実行スケジューラのおかげで、これらはより強力なハードウェアでより効率的に実行することもできます。このスケジューラは、前述の TaskManager クラスを通じてパブリックに表されます。TaskManager クラスにより、分離されたタスクに実行方法の個別ポリシーを付与する個々のスケジューラを作成できます。共有リソース マネージャは、スケジューラ インスタンス間を調整して、過剰をできるだけ制限する方法でシステムの処理リソースが効果的に利用されることを保証します。スケジューラは、コンピュータのキャッシュをより適切に利用するための再帰アルゴリズムの有効化など、きめ細かな並列処理を使用しているときに通常行われるソリューションの最適化でデザインされています。
.NET Framework は、その誕生以来、マルチスレッド開発をサポートしてきたことに注意してください。System.Threading 名前空間は、モニタ、ミューテックス、セマフォ、イベント、スレッドローカル記憶域など多数の低レベル同期プリミティブのうち、その多くをバージョン 1.0 から提供しています。C# や Visual Basic などの言語は、それぞれ lock キーワードおよび SyncLock キーワードなどの構成要素を通じてサポートを公開します。ただし、TPL などのライブラリで並列システムを効果的に作成するには、多くの場合に上位レベルの調整と同期タイプが必要です。
その結果、次期バージョンの .NET Framework にも、マルチスレッド アプリケーションの作成を簡単にする追加の構成要素で肉付けされた System.Threading 名前空間が見られます。たとえば、遅延初期化は、最初に必要になったときにのみデータを読み込んで初期化するためにアプリケーションでよく使用される手法です。通常この処理は、処理時間がより目立たない時点に処理を遅延させるため (たとえば、起動時間を向上させるために、起動時に行われるはずの作業をオフロードするため)、または遅延初期化したデータがめったに使用されず、一部のアプリケーション実行ではまったく必要ない場合に、処理を完全に排除するために行われます。どちらの場合も、この手法をマルチスレッド アプリケーションで実装する場合は、複数のスレッドがデータの初期化でしばしば競合するため、手動でコーディングするとエラーが発生しやすく反復的になる可能性があります。
この問題を解決するために、.NET Framework には、スレッドセーフ遅延初期化の最も一般的なパターンを簡単にする LazyInit<T> 型が含まれます。LazyInit<T> の Value プロパティにアクセスすると、初期化がまだ行われていない場合は初期化が強制的に行われ、初期化されたデータが返されます。アプリケーションは、複数のスレッドによる Value の取得が競合する場合でも、その正確さを維持します。
次に、LazyInit<T> を複数の方法で利用する Visual Basic コードを示します。
Private _d1 As LazyInit(Of MyData)
Private _d2 As LazyInit(Of MyData) = New LazyInit(Of MyData)( _
Function() MyData.Load())
Private Shared _d3 As LazyInit(Of MyData) = New LazyInit(Of MyData)( _
Function() MyData.Load(), LazyInitMode.EnsureSingleExecution)
Private _d4 As LazyInit(Of MyData) = New LazyInit(Of MyData)( _
Function() MyData.Load(), LazyInitMode.ThreadLocal)
_d1 変数は、遅延初期化するインスタンス メンバです。この例では、LazyInit<T> が Activator.CreateInstance を使用して MyData のインスタンスを作成できるようにする、パブリックの、パラメータなしコンストラクタが MyData 型にあることを想定します。
_d2 変数もインスタンス メンバです。ただし、この例では、変数をアクセス時に初期化するためのコードとして、開発者が提供するラムダ式を使用しています。
_d2 と同様に、_d3 変数もカスタム デリゲートで初期化されています。ただし、今回は変数が静的です (Visual Basic の Shared)。また開発者は、Value プロパティにアクセスする複数スレッドの競合がある場合でも、提供されるデリゲートが 1 回のみ実行されることを保証するために、既定の初期化モードをオーバーライドすることを選択しました (既定のモードでは、デリゲートを複数回実行できますが、1 つの結果のみが Value を通じてパブリックに公開されることが保証されます)。
最後に、_d4 変数では ThreadLocal モードを使用しています。ThreadStaticAttribute に精通している読者は、アクセスする各スレッドがデータのコピーを独自に取得することが保証される点で LazyInitMode.ThreadLocal が似ていることに注目してください。ただし、ThreadStatic は実行時の最適化により効率が上がる可能性が高くなりますが、LazyInit<T> は機能の点で ThreadStaticAttribute より優れています。たとえば、ThreadStaticAttribute は静的変数でのみ使用できますが、LazyInit<T> はスレッドローカルをインスタンス メンバとして持つことができます。
また、ThreadStaticAttribute は使用を試みる多くの開発者を悩ませる可能性があります。次のコードについて考えます。
[ThreadStatic]
private static MyData _d5 = new MyData();
アプリケーションの他のコードで _d5 変数が設定されておらず、_d5 にアクセスしたときにその値が null であった場合は驚くでしょうか。この状況は完全に可能です。ここでの問題は、C# コンパイラが _d5 の初期化を、含まれる型の静的コンストラクタに浮上させることです。型が必要になる直前にのみ静的コンストラクタが実行される場合 (遅延初期化の優れた例)、そのコンストラクタは 1 回のみ実行されます。このため、型に最初にアクセスするスレッドでは _d5 が初期化されていることが検出されますが、他のスレッドからは値が null に見えます。LazyInit<T> は ThreadLocal モードでこの状況に対処し、LazyInit<T> の Value プロパティが正しく初期化されていることを、このプロパティにアクセスするすべてのスレッドが検出できるようにします。
これは、次期バージョンの .NET Framework で使用可能になる新しいスレッド構成要素の一例にすぎません。他にも、CountdownEvent、Barrier、SpinWait などがあります。
また、新しいサポートには、共有データの操作を支援するスレッドセーフ コレクションの主なセットも含まれます。これらのコレクションは System.Collections.Concurrent 名前空間にあり (以前の CTP では System.Threading.Collections 名前空間にありました)、さまざまな状況に対処します。ConcurrentQueue<T> や (前の例で示した) ConcurrentStack<T> などの型は、汎用的な Queue<T> および Stack<T> のスレッドセーフでスケーラブルな代替型であり、BlockingCollection<T> などの型は、プロデューサ/コンシューマ シナリオのブロックを簡単に実装できるようにします。プロデューサ/コンシューマ シナリオでは、コンシューマ スレッドのホストによって消費されるデータ、および並列に実行できる一連のフィルタを通じてデータが流れるパイプライン パターンが複数のスレッドによって生成されます。
既に述べたように、PLINQ は次期バージョンの .NET Framework の中核部分です。大量のデータを処理する読み取り専用クエリの場合、PLINQ は単純で宣言的なパスを並列処理に提供します。前の赤ちゃんの名前の例で示したように AsParallel をデータ ソースに追加することで、開発者は .NET 標準クエリ演算子の PLINQ 実装に切り替えます (この切り替えは、開発者に並列処理サポートの選択を強制するために明示的に行われます。ほとんどの LINQ クエリが読み取り専用であっても、いくつかが変換を実行する場合に、このことが重要になります。これらの変換で PLINQ を使用する場合は、並列処理を安全に行うために変換をスレッドセーフにする必要があります)。
実際には、PLINQ は従来のデータ並列処理手法を使用して入力データを分割し、それを複数スレッドで処理し、データの生成方法と使用方法に適した方法でそれらのデータを再びマージします。PLINQ の詳細については、PLINQ を初めて詳細に説明した 2007 年 10 月発行の MSDN Magazine (msdn.microsoft.com/magazine/cc163329) を参照してください。
ネイティブ コードで並列処理を表現する
過去数年にわたって、数人の開発者から、ネイティブ C++ に対するマイクロソフトの傾倒について疑問を投げかけられました。その懸念は理解しますが、マイクロソフトの開発部門でのネイティブ コードに向けたあらゆる作業を見てきて、このような心配事は対処されていると確信を持って言うことができます。たとえば、Visual C++ チームは最近、Technical Report 1 (TR1) の完全サポート実装と、MFC に対する大幅な更新をリリースしました。どちらも、Visual C++ 2008 Feature Pack (go.microsoft.com/fwlink/?LinkId=124366 を参照) の一部として入手できます。同時実行に関して、ネイティブ C++ アプリケーションの並列処理のサポートは最も頻繁に要求される機能の 1 つであり、そのサポートの提供は、Visual C++ チームと並列コンピューティング プラットフォーム チーム両方の主要目標です。これは、次期バージョンの Visual Studio および Visual C++ で反映されます。
マネージ コードと同様に、きめ細かな並列処理と逐次アプリケーションから並列アプリケーションへの移行を可能にすることは、Visual C++ での同時実行サポートの中核です。興味をそそる単純な行列乗算の例について考えてみましょう。
void MatrixMult(int size, double** m1,
double** m2, double** result) {
for (int i = 0; i < size; i++) {
for (int j = 0; j < size; j++) {
result[i][j] = 0;
for (int k = 0; k < size; k++) {
result[i][j] += m1[i][k] * m2[k][j];
}
}
}
}
このメソッドの並列化の試みを図 4 に示します。

図 4 MatrixMult を並列化する
void MatrixMult(int size, double** m1, double** m2, double** result) {
int N = size;
int P = 2 * NUMPROCS;
int Chunk = N / P;
HANDLE hEvent = CreateEvent(NULL, TRUE, FALSE, NULL);
long counter = P;
for (int c = 0; c < P; c++) {
std::thread t ([&,c] {
for (int i = c * Chunk;
i < (c + 1 == P ? N : (c + 1) * Chunk);
i++) {
for (int j = 0; j < size; j++) {
result[i][j] = 0;
for (int k = 0; k < size; k++) {
result[i][j] += m1[i][k] * m2[k][j];
}
}
}
if (InterlockedDecrement(&counter) == 0) SetEvent(hEvent);
});
}
WaitForSingleObject(hEvent,INFINITE);
CloseHandle(hEvent);
}
このコードは、非同期パターンの開発を大幅に簡略化する C++0x std::thread 型やラムダ式のサポートなど、次期バージョンの Visual C++ に導入される新しい有用な機能を示しています。
ただし、これらの機能が追加されても、このソリューションはまだ不十分です。静的分割を使用して、すべての使用可能なプロセッサに作業が分割されますが、計算に不均等がある場合、または分割の処理速度に違いが生じる原因となるその他の何らかの問題がコンピュータで発生した場合には、多くのスレッドが、作業の担当部分の完了に必要な 1 つ以上のスレッドを待機する (また、支援を提供しない) ことになる可能性があります。
ソリューションにはイベントなどの同期プリミティブの知識が依然として必要ですが、ドキュメントを見ずに、これらの CreateEvent パラメータ NULL、TRUE、FALSE、および NULL が同期にどのように影響するかを言える読者は何人いるでしょうか。
ソリューションはエラーを起こしやすく、分割ステップで検出が難しい 1 つ違い (off-by-one) のエラーが発生する可能性が高くなります。明示的にインターロックされた操作は、未処理スレッドの数を追跡する必要があります。ソリューションでは、MatrixMult を呼び出して処理を行うスレッドを利用せずに、代わりにこれをブロックして貴重なシステム リソースを浪費する方を選びます。これはほんの一例に過ぎません。
より簡単なソリューションが必要であり、次期バージョンの Visual C++ ではこれが提供されます。
void MatrixMult(int size, double** m1, double** m2, double** result) {
parallel_for(0, size, 1, [&](int i) {
for (int j = 0; j < size; j++) {
result[i][j] = 0;
for (int k = 0; k < size; k++) {
result[i][j] += m1[i][k] * m2[k][j];
}
}
});
}
この例でもラムダ式を利用していますが、新しい parallel_for メソッドも使用しています。parallel_for メソッドは、Visual C++ ランタイム ライブラリで出荷される PPL (Parallel Pattern Library) の一部です。parallel_for は、parallel_for_each、parallel_accumulate、parallel_invoke などの他の関数と共に開発されているいくつかの上位レベル並列処理関数の 1 つです。これらのメソッドの多くは、標準テンプレート ライブラリ (STL) に相当する直接並列処理を提供し、最小限のコードの書き直しで逐次メソッドを並列メソッドで簡単に置換できるようにするため (当然、並列で実行される操作はスレッドセーフであると想定されます)、なじみやすいものです。
たとえば、ゲームの敵を表すオブジェクトの std::vector を反復し、能力と場所に基づいてそれぞれの敵のポジションを更新する for_each 呼び出しについて考えます。
for_each(enemies.begin(), enemies.end(), [&](AIEnemy& cur) {
cur.updatePosition();
})
問題のベクタに重複が含まれない限り、また、updatePosition メンバ関数がローカル オブジェクトのみを更新する限り、メソッド呼び出しの前に parallel_ を付加するだけでこのメソッドを並列化できます。
parallel_for_each(enemies.begin(), enemies.end(), [&](AIEnemy& cur) {
cur.updatePosition();
});
マネージ コードで System.Threading.Parallel クラスがタスクの並列性を処理するために下位レベルの型の上に構築される方法と同様に、ネイティブ ソリューションでも parallel_ メソッドが使用されます (ネイティブ ソリューションとマネージ ソリューションは似ているように感じられるため、開発者は一方の分野の知識をもう一方の分野に簡単に応用できます)。PPL の基盤として機能する 2 つの中核となる型は task_handle と task_group です。この両方を図 5 に示します。

図 5 task_handle と task_group
template <class _Func>
class task_handle {
public:
task_handle(const _Func& f);
};
class task_group {
public:
task_group ();
task_group(tr1::function<void(exception_ptr)> Fn);
~task_group ();
template <class _Func> void run(const _Func& Fn);
template <class _Func> void run(task_handle<_Func>& t);
void cancel();
task_group_status wait();
void copy_exception(exception_ptr);
};
これらの構成要素がどのように使用されるかを示す例として、今月号の MSDN Magazine (msdn.microsoft.com/magazine/cc872852 で入手できます) の David Callahan の記事で示されているクイックソートの実装について考えます。David のソリューションは、典型的なクイックソート パターンに従っています。並べ替えるデータの長さが十分に小さい場合にはオーバーヘッドの少ない並べ替えを使用し、データを 2 等分し、クイックソートを再帰的に使用して各部分を並べ替えます。彼が示すソリューションでは parallel_invoke を使用していますが、parallel_invoke は task_group の上に構築され、これを直接使用する実装は次のようになります。
template<class T>
void QuickSort(T * data, int length, T* scratch) {
if(length < THRESHOLD) InsertionSort(data, length);
else {
int mid = Partition(data[0], data, length, scratch, true);
task_group g;
g.run([&]{QuickSort(data, mid);});
g.run([&]{QuickSort(data+mid, length-mid);});
g.wait();
}
}
実装を有効にするために必要な定型は非常にわずかでした。task_group を単純に作成し、各再帰呼び出しを別々のタスクとして実行し、両方のタスクの完了を待機します。待機操作は実際のカーネル待機ではない可能性があり、スレッドのリソースを浪費する代わりに、スケジュールされたタスクを現在のスレッドにインライン化することを選択しています。
その他にも中核となる型が提供されています。たとえば、前に示した task_group 型は、関連性のない複数のスレッドでタスクを同時に実行するようにスケジュールできるという点でスレッドセーフです。親子関係にあるタスクのみが同じグループに対して同時にスケジュールできる構造化並列処理の場合、structured_task_group 型は同じ機能に対して最適化されたサポートを提供します。
マネージ側と同様に、タスクは基礎となる効率的なスケジューラで実行されます。スケジューラでは、work-stealing アルゴリズムを使用して、きめ細かい操作の効率的な実行を可能にすること、リソースの効率的な再利用を可能にする協調ブロッキングをサポートすること、その操作に対してスケジューラが採用するポリシーを詳細に制御することなどを実現します。また、スケジューラには、きめ細かなタスク実行よりもプロセス間メッセージングに焦点を当てたプリミティブの組み込みサポートがあります。
次期バージョンの Visual C++ で提供される優れた同時実行機能の 1 つは、メッセージングと非同期エージェントのサポートです。これにより、開発者には、大まかなコンポーネントを定義してそれらを対話させることができる並列アプリケーションを作成するアプローチが提供されます。この対話はプロセス内メッセージング インフラストラクチャを通じて実行されるため、アプリケーションは遅延に耐えることができ、コンポーネントの作成をサポートし、スレッドの安全性のためのメカニズムとして分離を利用できます。
コア サポートは、いくつかのメッセージング関数 (send、receive、asend、および try_receive) と、送受信をサポートする ISource および ITarget インターフェイスを中心としています。ライブラリには、unbounded_buffer、overwrite_buffer、single_assignment、choice、call、transform、timer など、これらのインターフェイスを実装するいくつかのメッセージング ブロックが含まれます。
また、システムは、開発されるシステムに必要であるために実装されるカスタム メッセージング ブロックを促進するようにデザインされました。メッセージング ブロックでは、メッセージを格納する機能と、他のメッセージ ブロックにメッセージを転送する機能がサポートされます。ブロックは相互にリンクでき、メッセージが 1 つのブロックに到着した場合、そのブロックはセマンティクスに適した方法でそれらのメッセージを処理してから、ネットワーク内でリンクされているブロックに結果のメッセージを転送します。
たとえば、図 6 のコードは、2 つの unbounded_buffer を示しています。どちらも join ブロックにリンクされ、join ブロックは transform ブロックにリンクされ、transform ブロックは call ブロックにリンクされています。データが両方の unbounded_buffer に到着した場合、そのデータは join ブロックを通じて transform ブロックに渡され、transform ブロックは開発者が提供する関数を使用してデータを操作し、結果を call ブロックに送信できます。call ブロックは、別の開発者提供メソッドにそのデータを提供します (ここで示すメッセージング コードのサンプルでは、現在指定されている構文を使用していますが、この構文は初期プレビュー リリースで使用可能な構文とは若干異なる場合があります)。

図 6 受け渡しメッセージング ブロック
unbounded_buffer<int> unboundedBuf1;
unbounded_buffer<int> unboundedBuf2;
join<int,int> join1;
transform<tr1::tuple<int,int>,int>
transform1([](tr1::tuple<int,int>& input){
return tr1::get<0>(input) * tr1::get<1>(input);
};
call<int> call1([](int& input){
cout << "result: " << input << endl;
};
unboundedBuf1.link_target(join1);
unboundedBuf2.link_target(join1);
join1.link_target(transform1);
transform1.link_target(call1);
また、エージェントベースのアプリケーションでは、新しい agent_task クラスを利用できます。このクラスは実際には、メッセージを送受信することで他のタスクと対話することを目的としたスケジューラからの専用スレッドです。agent_task は、プロデューサ/コンシューマ デザインでかなり役立つ場合があります。
たとえば、図 7 の上部のコードについて考えます。このコードは、ログ ファイルからテキスト行を連続的に読み取って処理する単純なログ ファイル パーサーです。その処理は、図の下部に示すように、1 つのスレッドをファイルの読み取り専用とし、別のスレッドをそのコンテンツの処理専用とすることで、ファイルの読み取りの I/O と重ねることができます。unbounded_buffer は、リーダーからパーサーに送信されるメッセージを格納するために作成され、パーサー エージェントは、バッファから継続的に受信して結果のメッセージを処理するために作成されます。一方、逐次の例のように、ParseFile メソッドはファイルを開いて行を読み取りますが、各行を直接解析する代わりに、その行を使用するパーサー エージェントの unbounded_buffer に各行を単純に送信します。ファイルを完全に読み取ると、終了メッセージをパーサー エージェントに送信し、エージェントの完了を待機します。

図 7 エージェントでファイルを解析する
逐次
HRESULT LogChunkFileParser::ParseFile() {
HRESULT hr = S_OK;
WCHAR wszLine[MAX_LINE] = {0};
hr = reader->OpenFile(pFileName);
if (SUCCEEDED(hr)){
while(!reader->EndOfFile()){
hr = reader->ReadLine(wszLine, MAX_LINE);
if (SUCCEEDED(hr)){
Parse(wszLine);
}
}
}
return S_OK;
}
並列
HRESULT LogChunkFileParser::ParseFile() {
unbounded_buffer<AgentMessage> msgBuffer =
new unbounded_buffer<AgentMessage>();
agent_task* pParserAgent = agent_task::start([&] {
AgentMessage msg;
while((msg = receive(msgBuffer))->type != EXIT) {
Parse(msg->pCurrentLine);
delete msg->pCurrentLine;
}
});
HRESULT hr = reader->OpenFile(pFileName);
if (SUCCEEDED(hr)){
while(!reader->EndOfFile()) {
WCHAR* wszLine = new WCHAR[MAX_LINE];
hr = reader->ReadLine(wszLine, MAX_LINE);
if (SUCCEEDED(hr)) {
send(msgBuffer, AgentMessage(wszLine));
}
}
send(msgBuffer, AgentMessage(EXIT));
hr = agent_task::wait_for_completion(pParserAgent);
}
return hr;
};
ご覧のとおり、ネイティブ コード開発者とマネージ コード開発者の両方にとって、開発プラットフォームに役立つ機能が多数あります。ただし、これらの新しい構成要素のすべてが万能薬ではないということを指摘しておきます。このサポートの大部分は、開発者がアプリケーションで並列処理をより簡単に表現できるようにすること、そのために以前に必要だった定型を取り除くこと、および逐次プログラミングの構成要素を並列プログラミングの構成要素で置換するのを支援することに焦点を当てています。parallel_for や AsParallel などの構成要素によって、自動的にコードが同時実行コンテキストで安全に実行されるようになるわけではありません。競合条件、デッドロックなどはまだ発生する可能性があり、並列アプリケーションの開発をはるかに簡単にすることで、このような問題がより一般的になる可能性があります。
並列アプリケーションをデバッグする
並列プログラミングの課題は、プログラミング モデルに限りません。ツール サポートは、並列プログラミングのプラットフォームを成功させる重要な要素です。その理由は、並列アプリケーションのデバッグとパフォーマンス チューニングで開発者が直面する複雑さが増しているためです。このため、Visual Studio では、レガシ プログラミング モデルと新しいプログラミング モデルの両方で、並列プログラミングのツール サポートの改善に投資しています。
Visual Studio 2008 のツールがまだマルチスレッドのサポートを提供していないということではありません。実際に、Visual Studio 2008 はマルチスレッド アプリケーションの開発に役立つ有用な機能をいくつか備えています。
たとえば、並列アプリケーションのデバッグ ツールについて興味深いジレンマは、複数の実行コンテキストに関連するときのブレークポイント セマンティクスの性質です。具体的な問題は、すべての実行コンテキストを停止するか、ブレークポイントが検出されたコンテキストのみ停止するかということです。既定の動作では、通常はブレークポイントですべてのスレッドが停止しますが、他のスレッドがブレークポイントにあるときでも、スレッドなどの他の 1 つ以上の実行コンテキストが処理を続行できるようにすることが望ましい場合があります。すべてをブロックするデバッガ実装では競合条件が明らかにならない状況で、このような機能が非常に役立つと想像できます。デバッガは、ユーザーの必要に応じてスレッドを停止および再開する機能を提供します。最初は、ブレークポイントにヒットするとすべてのスレッドが停止します。この時点で、ユーザーはデバッグ セッションに進む前にスレッドのサブセットを再開することを選択できます。ユーザーは、必要に応じてスレッドを停止することもできます。
ただし、並列アプリケーションを開発およびデバッグする一方で、生産性を向上させるために行う必要のあることが他にも数多くあります。TPL や PPL によって提供されるようなプログラミング モデルでは、パフォーマンスの優れた並列アプリケーションの開発に対する複雑さのしきい値が低くなります。これらのプログラミング モデルに対する共通のテーマは、分割作業の詳細を抽象化し、それらをスケジュールし、適切な順序制約を満たすために必要な同期を追加するランタイム システムの存在です。実際に、これらのモデルの主な目標は、開発者がスレッド レベルでの並列処理について考える必要をなくし、きめ細かいタスク レベル以上で並列処理について考えるようにすることです。
また、自動並列処理を実行するランタイムがあると、定型コードをすべて作成する必要はなくなるため開発者の負担は軽減されますが、スレッドなどの実行の抽象化にマップされる多くの仮想プロセッサ間に分割される何千ものタスクに作業を分割する方法が、ランタイムによって選択されることにもなります。開発者の観点からは、個々のタスクの実行をデバッグする必要がある場合に、コードの特定の部分に導かれる実行パスをトレースすることは、不可能とは言わないまでも困難な場合があります。これにより、プログラミング モデルとデバッガの間に二極分化が作成されます。現在、これはスレッドに焦点を当てています。
次期バージョンの Visual Studio の 2 つの新機能である MultiStack ビューとタスク一覧ビューは、並列プログラミング ランタイムを利用するアプリケーションのデバッグの負担を軽くすることで、この困難に対処することを目的としています。
図 8 に示すように、MultiStack ビューは、子タスクのスタックフレームが親にリンクされる論理呼び出しスタックを、すべてのタスク (または選択したタスク セット) についてグラフ化します。ビューは共通スタック プレフィックスを単一のノードにまとめ、プレフィックスを共有するタスクの数でノードにタグを付けます。このため、グラフはタスクのセットの実行状態のグローバル ビューを表示します。ユーザーは、ブレークポイントで実行中タスクのコール スタックをたどり、エディタでメソッドの特定のインスタンスを表示するように切り替えることができます。
図 8 MultiStack (クリックすると画像が拡大表示されます)
図 9 のタスク一覧は、ブレークポイントでランタイム システムが認識しているすべてのタスク、それぞれの実行状態、および現在実行されているメソッドを、引数およびその値の一覧と共に示しています。一覧は任意の列で並べ替え、グループ化、または検索でき、開発者はタスク間をすばやく切り替えたり、他のさまざまなデバッガ機能を使用して実行状態をさらに調べたりできます。
図 9 タスク一覧 (クリックすると画像が拡大表示されます)
特に共有メモリ プラットフォームにおける並列プログラミングでもう 1 つの課題となる領域は、通常の式評価機能を効率的に提供しながら、複数の実行コンテキスト内のメモリ コンテンツを同時に検証する機能です。次期バージョンの Visual Studio には、このニーズに対処する MultiWatch ビューが含まれます。この機能により、開発者は変数名と式をウォッチ ウィンドウに追加できます。ウォッチ ウィンドウでは、各デバッガ ステップまたはブレークポイントで、またシステムのすべてのスレッドまたはタスクについて式が評価されます。これにより、問題の領域と発見しにくいバグの原因を見つけるために、スレッド間でのデータの不整合をはるかに簡単に特定できるようになります。
並列アプリケーションをプロファイリングする
並列処理の本質は (応答性かスループットかにかかわらず) パフォーマンスであるため、パフォーマンス分析ツールの優れたセットがプラットフォームの成功に不可欠です。コンピュータ処理中心の操作の並列処理を活用することは難しい場合がありますが、複雑なクライアント アプリケーションの並列処理タスクよりも確実に難易度は低くなります。複雑さの追加部分は、リッチ クライアント アプリケーションにはディスクやネットワーク I/O などのさまざまなボトルネックのフェーズが含まれるという事実によるものです。
開発者の観点からは、このようなアプリケーションの並列化の鍵は、その動作を十分に理解して並列処理の機会を識別することです。これを行った後、開発者はジョブを実行するために使用可能なプログラミング パターンと抽象化の多くを適用できます。このフェーズで覚えておく必要のある重要なポイントは、同時実行を、コンピュータ処理の高速化だけでなく遅延の隠蔽の手法としても利用できることです。アプリケーションが作成された後は、パフォーマンスに優れたソリューションを実現するために理解し、測定し、チューニングする必要のある、非効率性に関する追加のソース (同期など) があります。
Visual Studio 2008 のパフォーマンス分析ツールでは、マネージ アプリケーションとネイティブ アプリケーションの開発者のチューニング作業を支援するために非常に多くの機能が提供されます。既存のプロファイラは、サンプリング ベースおよびインストルメンテーション ベースのプロファイリングをサポートします。サンプル プロファイリングは、ユーザー定義の間隔でアプリケーション プログラムをインターセプトし、スレッドごとにコール スタックをキャプチャし、実行を継続します。分析時には、すべてのコール スタックが収集されて関連付けられ、大部分のサンプルが取得された関数がアプリケーション パスと共に特定されます。
この手法の 1 つの欠点は、同期呼び出しや I/O 呼び出しによってアプリケーションのスレッドがブロックされている時間が考慮されないことです。このため、サンプル プロファイリングは CPU バウンドのアプリケーションにのみお勧めします。
予定どおりの間隔でサンプリングする以外に、Visual Studio プロファイラでは、ページ フォールトやマイクロプロセッサ パフォーマンス モニタ カウンタ (たとえば、L2 キャッシュ ミス) などの他の重要なイベント時のサンプリングを実行することもできます。これらの機能は、キャッシュやメモリ ページの局所性など、アプリケーションの動作の重要な側面を理解するために非常に貴重なツールになる場合があります (このトピックの詳細な説明については、msdn.microsoft.com/magazine/cc872851 にある MSDN Magazine 今月号の「.NET の問題」のコラムを参照してください)。
インストルメンテーションベースのプロファイリングは、名前が示すように、ターゲット アプリケーションまたはモジュール内の各関数のコードを補強することで動作します。このようなインストルメンテーションは、他のメソッドへの呼び出しサイトを含め、通常は関数の入り口と出口のポイントに挿入されます。このインストルメンテーション レベルにより、プロファイラはアプリケーションの実行ですべての遅延を考慮できますが、実行時間とトレース ロギングの非常に高いオーバーヘッドという犠牲を払うことになります。これらのオーバーヘッドは、実行を大幅に混乱させ、データの分析に必要な時間が増えることがあります。このようなオーバーヘッドを削減するために、ユーザーは Visual Studio プロファイラを使用して、関数またはモジュール (DLL) の細分化レベルでアプリケーションを部分的にインストルメント化できます。また、プロファイラは、実行時にプログラマがパフォーマンス データ コレクションを調整できるようにする API を提供します。
並列プログラマの観点から見ると、現在のプロファイラには、新しい並列プログラミング ランタイムの上位レベル サポートなどの望ましいいくつかの機能と、同期などの一般的な並列パフォーマンスの問題に対処する特定のシナリオが不足しています。次期バージョンの Visual Studio では、プロファイラはマルチスレッド アプリケーション開発者のニーズに対処するツールで補強されています。
並列開発に不可欠なステップは、同時実行の程度と、アプリケーションで並列処理を使用してパフォーマンスが向上する可能性を理解することです。これは、並列処理の実装前とチューニング フェーズの両方で重要になります。
並列処理のターゲットであるアプリケーションを検証するときに、並列処理の候補となるアプリケーションのフェーズを判断する必要があります。通常、これらのフェーズは CPU バウンドですが、現在のところ、このような実行のフェーズを簡単に判断し、アプリケーション コードにマップするツールはありません。
図 10 に示すように、次期リリースの Visual Studio には同時実行分析のサポートが含まれます。x 軸が時間を示し、y 軸がシステムのコアの数を示すグラフには、対象のアプリケーションの時間によってコアの利用率がどのように変化するかが示されます (緑色で示されています)。ユーザーはこのビューを使用して、CPU バウンドであるアプリケーションのフェーズ、その相対的な期間、およびシステム コア利用率のレベルを判断し、そのフェーズにズームインして、これらのフェーズ中にアプリケーションで実行されていた内容を判断し、可能性のある並列処理の形式とそれらの利用方法の検討を開始できます。パフォーマンス チューニング フェーズ中に、このビューでは、期待される同時実行が実際に実行時に実現されているかどうかを確認できます。
図 10 同時実行分析 (クリックすると拡大画像が表示されます)
また、基本的なタイミング測定を行って、前の実行または逐次実行と比較した速度の向上を推定できます。最後に、同時実行ビューでは、問題のアプリケーションとシステムの他のプロセス、またはカーネルとの間の相互作用についても確認できます。ビューでは、アイドル時間は黒で表示され、他の非カーネル プロセスは黄色で表示されます。カーネル アクティビティ (この例では最小) は赤で表示されます。
開発者は、同時実行ビューを使用して対象のフェーズを識別した後、図 11 に示す [Thread Blocking] (スレッド ブロック) ビューを使用してアプリケーションの動作をさらに分析できます。このビューは、アプリケーション内の各スレッドの動作に関する豊富な情報を提供します。
図 11 スレッドの実行とブロックの分析 (クリックすると拡大画像が表示されます)
最初に、各スレッドの実行を分類した棒グラフが生成され、各スレッドの有効期間のうち、コードの実行またはブロックに費やされた部分が示されます。ブロック遅延は、I/O や同期などの各種カテゴリにさらに分類されます。
2 番目に、x 軸に時間を表示し、y 軸にスレッドおよび物理ディスク I/O をレーンとして表示するタイムライン ビューが提供されます。ディスク I/O の場合、画面には、ディスク上でその時点に行われた読み取りと書き込み、およびアクセスされたファイルがツールヒントを使用して表示されます。スレッドの場合は、スレッドの実行時刻、ブロックされた時刻、および対象の遅延カテゴリが色を使用して示されます。
3 番目に、コール スタック分析により、開発者はタイムライン ビューのブロック セグメントにマウスのカーソルを合わせて、各スレッドがブロックされたときにそのスレッドで何が行われていたかを詳細に理解できます。図 11 のスクリーンショットは、EnterCriticalSection の呼び出しにより約 83 ms の長さになっている同期ブロック セグメントの上にマウス カーソルが置かれた場合の例を示しています。ブロックがクリティカル セクションの取得を試行する原因となった行番号を含むコール スタックも表示されます。このような詳細情報は、開発者が非効率性の根本的な原因を調べ、それに対処するために不可欠です。その他の機能としては、詳細な実行、ブロック、およびディスク I/O 統計と、下側のタブで使用可能なレポートがあります。
並列パフォーマンス分析の別の重要な側面は、キャッシュ ワーキング セットとの対話によるスレッドの移行です。図 12 に、コア実行およびスレッド移行のビューを示します。このビューでは、各スレッドに一意の色が関連付けられ、システムのプロセッサ コアでスレッドの実行がどのようにスケジュールされているかが表示されます。スレッド移行イベントは、コア間での色の移り変わりによってグラフィカルに示されます。関心のある各スレッド移行イベントについて、アプリケーション フェーズでそのイベントを特定し、関連するスレッド上にマウス カーソルを合わせることでスレッド ID をメモし、スレッド ブロック ビューに切り替えることができます。スレッド ブロック ビューは、スレッド移行の前のブロック イベントの性質を示し、アプリケーションでイベントが発生したタイミングを示すことができます。開発者は、ブロック イベントのそのクラスを削減するか、アプリケーション実行環境で許容されると思われる場合はアフィニティを実施することで、この種類の問題を最適化するかどうかを決定できます。
図 12 コア実行とスレッド移行 (クリックすると拡大画像が表示されます)
今後の展望
この記事の執筆時点では、前述の同時実行サポートをすべて含む次期バージョンの Visual Studio のリリース日はまだ発表されていません。しかし、間もなくマネージ開発者とネイティブ開発者は同じように、マルチコア システムが提供する必要のあるすべてのコンピュータ処理能力を効率的に利用するアプリケーションを効果的に開発できるようになります。このようなシステムは企業と家庭ですぐに標準になるため、今こそこのようなサポートについて学習を始める時期です。
この記事を支援してくれた Rick Molloy、David Callahan、Paul Maybee の各氏に感謝の意を表します。
Stephen Toub は、マイクロソフトの並列コンピューティング プラットフォーム チームのシニア プログラム マネージャ リードであり、同時実行のプログラミング モデルに関する作業に集中しています。また、MSDN Magazine の寄稿編集者でもあります。
Hazim Shafi は、マイクロソフトの並列コンピューティング プラットフォーム チームでパフォーマンス ツールを担当している主席アーキテクトです。マイクロソフトで並列プログラミングに関するコースの講師も務めています。