はじめに
筆者は今後絶対に身につけるべきプログラマーのスキルを、並列プログラミングだと考えています。その背景については『インテル Parallel Studioを使って並列化プログラミングを試してみた』を参照してください。
今回は、以前紹介できなかった並列プログラミングのデバッグと、デバッグツールについて紹介します。
並列プログラミングのデバッグ
並列プログラミングを行う際には、従来の逐次プログラミングの発想では思いつかないエラーが発生します。例えば次のようなエラーが発生します。
- 実行するたびに計算結果が違う。
- 不定期に予想もつかないエラーが発生する。
- システムがフリーズする。
こういった並列プログラミング特有のエラーは直すのが非常に困難です。なぜならば、この種のエラーは再現が困難であり、思いもよらないコードが原因となっているからです。
この様な状況下でよく行われることは、シングルステップなどのデバッガの機能を使用することです。しかし、厄介なことにデバッガがプログラムに介入することにより、並列性があるプログラムは挙動が変わります。従って、バグを突き止めるのは困難な作業となります。かといって、開発者の勘でエラーの原因に目星をつけてデバッグするのは非常に非効率的で、並列プログラミングをしていると専用のデバッガがすぐに欲しくなります。
そこで筆者はParallel Inspector(パラレル・インスペクター)を試すことにしました。
Parallel Inspectorの概要
Parallel Inspectorは、Parallel Studioに付属しているデバッガで、メモリーエラーやスレッド化エラーを検出することにより、前項で述べた様な並列プログラミング時に発生するさまざまなバグを取り除く手助けをしてくれる心強いツールです。並列プログラミングで発生するエラーも普通のプログラムと同じで発生箇所が分かれば怖くありません。エラーの発生箇所さえ検出できれば、大半の問題をクリアーしたと言えるでしょう。
どんな製品も試さないと分かりません。筆者は簡単なプログラムを書いて試してみました。次項でその様子をお伝えします。
Parallel Inspectorのメモリエラー解析機能は従来の直列プログラミングでも有効に働きます。そのため、並列化を行う前にシングルスレッドでの動作を検証し、安全なコードかどうかを事前に確認することもできます。
並列プログラミングのサンプル
まずはこの記事のサンプルプログラム「ParallelSample」をダウンロードしてください。このプログラムは、生産者(Procedure)が商品を製造して在庫数を増やし、消費者(Consumer)が商品を購入して在庫数を減らすというシンプルなものです。
#include <stdlib.h> #include <time.h> #include <limits.h> #include <iostream> #include <windows.h> #include <winnt.h> #include <tchar.h> #include <StrSafe.h> #include <process.h> using namespace std; int stockCount = 10; //在庫数 //商品の生産を行う関数 DWORD WINAPI Producer( PVOID pvParam ) { //増加数を読み出す int point = PtrToUlong(pvParam); //処理を行うために在庫数を読み出す //※DBかファイルから値を読み出していると仮定してください int tmp = stockCount; //商品の生産処理を行います(処理時間はランダム) time_t seed; time( &seed ); srand( seed % UINT_MAX ); Sleep( ( rand() / 1000 ) * ( rand() / 1000 ) ); //在庫数を反映させます stockCount = tmp + point; return 0; } //商品の販売を行う関数 DWORD WINAPI Consumer( PVOID pvParam ) { //増加数を読み出す int point = PtrToUlong(pvParam); //処理を行うために在庫数を読み出す //※DBかファイルから値を読み出していると仮定してください int tmp = stockCount; //商品の売り上げ処理を行います(処理時間はランダム) time_t seed; time( &seed ); srand( seed % UINT_MAX ); Sleep( ( rand() / 1000 ) * ( rand() / 1000 ) ); //在庫数を反映させます stockCount = tmp - point; return 0; } typedef unsigned (__stdcall *PTHREAD_START) (void *); int _tmain(int argc, _TCHAR* argv[]) { //初期処理 _tsetlocale( LC_ALL, _T("") ); int strCount = 20; TCHAR* buffer = ( TCHAR* ) malloc( strCount * sizeof( TCHAR ) ); StringCchCopy ( buffer, strCount, _T( "これから並列処理の実験を開始します・・・" ) ); _tprintf( buffer ); _tprintf( _T( "\n" ) ); /*--------------------------------------------------------------------------- 商品を生産してから商品を販売します。 このサイクルを任意の回数繰り返しながら、在庫数の推移を確認します。 -----------------------------------------------------------------------------*/ int x = 10; //増加数 DWORD dwThreadID; HANDLE hThreads[2]; cout << "初期在庫数:" << stockCount << endl; cout << "在庫の増減数:" << x << endl; cout << "****************************************" << endl; for ( int i = 0; i < 10; i++) { //処理前の在庫数を表示 cout << "処理前の在庫数:" << stockCount << endl; //生産者スレッド開始 hThreads[0] = ( HANDLE ) _beginthreadex ( ( void * ) NULL, ( unsigned ) 0, ( PTHREAD_START ) Producer, ( void * ) (PVOID) (INT_PTR) x, ( unsigned ) 0, ( unsigned * ) &dwThreadID ); //消費者スレッド開始 hThreads[1] = ( HANDLE ) _beginthreadex ( ( void * ) NULL, ( unsigned ) 0, ( PTHREAD_START ) Consumer, ( void * ) (PVOID) (INT_PTR) x, ( unsigned ) 0, ( unsigned * ) &dwThreadID ); //生産者と消費者の処理が終わるのを待つ WaitForMultipleObjects(2, hThreads, TRUE, INFINITE); //処理後の在庫数を表示 cout << "処理後の在庫数:" << stockCount << endl; //念のためにスレッドを閉じる CloseHandle( hThreads[0] ); CloseHandle( hThreads[1] ); } cout << "****************************************" << endl; cout << "最終在庫数:" << stockCount << endl; //終了処理 strCount = 4; buffer = ( TCHAR* ) malloc( strCount * sizeof( TCHAR ) ); StringCchCopy ( buffer, strCount, _T( "実験終了" ) ); _tprintf( buffer ); _tprintf( _T( "\n" ) ); cout << "Enterキーを押して終了してください。"; getchar(); return 0; }
このサンプルプログラムはParallel Inspectorの機能を試すために、あらかじめありがちなバグを埋め込んでいます。何度か実行してみてください。すると、最終在庫数が毎回違うことが確認できます。本来、増減数が同じで生産者と消費者が同じ数なのですから、初期在庫数と最終在庫数が同数になるはずです。また、よく確認すればメッセージがおかしいことも分かります。Parallel Inspectorを使ってこのエラーの原因を探ります。
メモリーエラーの検出
まずは[ツール]-[Parallel Inspector]-[Inspector Memory Errors]を選択してください。
ダイアログが表示されますので、このダイアログで最後までつまみを下げてから[Run Analyze]ボタンをクリックしてください。
クリック後、解析処理が始まりますのでしばらくお待ちください。解析が終了したらメモリエラーのイベントログが表示されるので[Interpret Result]をクリックします。
するとメモリエラーに関するコードが詳細に表示されます。この詳細なレポートを見ればエラー解決の糸口になります。さらに、Overview画面でソースへ直接移動できるので大変効率よくデバッグを行えます。このサンプルプログラムでも問題箇所が分かれば解決は簡単です。
この問題の場合、Problem列にメモリリークが表示されていますのでメモリの確保と解放が正しく行われていない事が分かります。その事からfree
のプログラムが足りない事が分かりますし、文字の表示がおかしいという論理エラーも移動先のコードをよく読めば 文字数にNull文字の分が考慮されていない(1文字少ない)ことが原因だと直ぐに分かります。
スレッド化エラーの検出
次はParallel Inspectorのスレッド化エラー解析機能を試してみます。
[ツール]-[メニュー]-[Parallel Inspector]-[Inspector Threading Errors]をクリックしてください。表示されるダイアログで、最後までつまみを下げてから[Run Analyze]ボタンをクリックします。
クリック後、解析処理が始まりますのでしばらくお待ちください。解析が終了したらスレッド化エラーのイベントログが表示されるので[Interpret Result]をクリックします。スレッド化エラーもメモリエラー解析結果と同じく詳細な情報が表示されます。
今回はOverview画面のProblem列に注目してください。このサンプルプログラムの問題点がData raceだと的確に指摘しています。
さらに問題のコードが読み込みと書き込みに分けて表示され、大変分かりやすくなっています。
これで、このサンプルプログラムが毎回違う結果を出す原因は、変数stockCount(在庫数)を2つのスレッドで読み書きしていることだと分かります。並列プログラミングでは同時に2つ以上のスレッドが1つのデータを操作すると結果が予測できません。それをParallel Inspectorは検出したのです。
これさえ分かれば問題を解決したのも同然です。この短いサンプルプログラムでは効果が分かりにくいかもしれませんが、実務では数十万行以上のC/C++コードでデバッグすることが十分にありえます。そんな大量のコードの中から問題部分を探すのは大変骨が折れる作業です。それを自動かつ正確に検出できるのですから大変便利なツールです。
まとめ
今回の記事では並列プログラミング時のデバッグ作業が大変であり、その作業を助けるツールが必須であることを解説しました。また、よいツールとしてParallel Inspectorがあることを示し、簡単なサンプルコードを解析して大変便利なツールであることを確認しました。
今回は駆け足で解説しましたので、まだまだParallel Inspectorの機能を紹介しきれていません。読者の方はぜひ実際に体験版を入手してお試しください。