短時間でできる汎用の同期オブジェクト


ダン チョウ

はじめに

Windows(R) 95、Windows NT(R)、Windows CE などの、Microsoft Win32(R)ベースのオペレーティング システムはみな、マルチタスク、マルチスレッドの環境を提供します。複数のスレッドが同一のデータ群にアクセスしたり、同時にリソースを共有したりする場合は、データへのアクセスをコントロールするために、一種の同期機能が必要になります。幸い、Win32アプリケーション プログラミング インターフェイス(API)には、広範囲に渡る同期オブジェクトがあり、共有リソースへのアクセスをコントロールできます。同期オブジェクトには、速度、機能、およびプロセスをまたいだ同期に関して、それぞれの長所と短所があります。また、Win32ベース オペレーティング システムの種類に応じて、サポートしている同期機能のレベルも異なります。しかし残念ながら、高速で、複数プロセスにまたがって動作し、リソース カウント機能があり、すべてのWin32プラットフォームで動作する同期オブジェクトは、今のところ存在しません。

本稿では、「メータード セクション(metered section)」と呼ばれる新しい同期オブジェクトを開発していきます。メータード セクションには、速度、プロセスをまたいだ同期、共有リソースに対する相互排他制御にとどまらないアクセス制御機能(リソース カウント機能)、現行のすべてのWin32プラットフォームをサポートするといった利点があります。排他アクセスにとどまらない、もっと融通のきくリソース カウント機能はどのような場合に必要なのでしょう。コンピュータに4つのモデムが接続されていて、それぞれが一度に1つのスレッドしか割り当てられないとしたらどうでしょう。排他アクセスしかサポートしない同期オブジェクトを使うとすると、1つの共有モデムに対するアクセスしか同期化できなくなります。リソース カウント機能のある同期オブジェクトを使用すれば、同期オブジェクトは共有リソースが4つあることを知ることができます。これにより、4つのスレッドがそれぞれ4つのうちのいずれかのモデムを同時に使用でき、既存の4つのスレッドのいずれかがモデムの使用を終えるまで、アクセスを要求する他のスレッドはブロックされます。このようにリソース カウント機能は排他アクセスのスーパーセットであり、共有リソースが1つだけの場合には、排他アクセス機能だけが利用されます。

本稿では、読者が同期化の概念について深く理解しているものと想定して話を進めていきます。

同期オブジェクトの要約

まず最初に、既存の同期オブジェクトを見ていき、それぞれの長所と短所を分析します。表1に、既存のWin32同期オブジェクトの一覧をまとめます。

名前 速度 プロセス間 リソース カウント機能 サポートされている
プラットフォーム
クリティカル セクション なし なし(排他アクセス) 95/NT/CE
ミューテックス あり なし(排他アクセス) 95/NT/CE
セマフォ あり あり 95/NT
イベント あり あり* 95/NT/CE
メータード セクション あり あり 95/NT/CE
*イベントをリソース カウント用に使用できますが、カウントは記録されません。

表1:同期オブジェクトの要約

クリティカル セクション

「クリティカル セクション」は、Win32の基本的な同期オブジェクトの1つです。単独のプロセス内のスレッド間で、共有データへの排他アクセスを同期化します。クリティカル セクションへのアクセス権を獲得するのに競合が生じない限り、クリティカル セクションのコードは完全にユーザー モードで実行されるため、速度は極めて高くなります。遅延が生じるユーザー モードとカーネル モードの間の切り替えが行われないからです。クリティカル セクションをめぐって競合が生じても、すべてがだいなしになるわけではありません。単にカーネル イベント オブジェクトを使った同期に戻るだけです。イベントはカーネル オブジェクトなので、競合があるときには、比較的高くつくカーネル モードへの切り替えを行う必要があります。しかし、いずれにしてもスレッドはブロックされるので、スレッドが実際にブロックされている時間に比べれば、切り替えにかかる時間は通常、それほど気にはなりません。クリティカル セクションの最大の短所は、名前付きのカーネル オブジェクトが関連付けられていないため、複数のプロセスにまたがってアクセスを同期化できないという点です。

ミューテックス

Win32では、ミューテックス(mutex)はカーネル オブジェクトです。ミューテックスは、カーネル オブジェクトとして実装されているので、クリティカル セクションの大きな短所が解決します。ミューテックスはプロセスにまたがって同期化ができるとはいえ、それは速度を犠牲にしてのことです。プロセスがミューテックスの待機関数(WaitForSingleObjectなど)を呼び出すたびに、比較的高価なユーザー モードとカーネル モードの間の切り替えが発生します。共有リソースをめぐる競合がなく、ミューテックスの待機と解放が何度も行われると、切り替えにかかる時間が蓄積してアプリケーションの実行時間も長くなります。クリティカル セクションと同様、ミューテックスは排他アクセスの同期化にのみ使用できます。

セマフォ

セマフォは、ミューテックスと非常によく似ています。ミューテックスと同様、セマフォはカーネル オブジェクトとして実装されます。プロセスにまたがって動作するという長所と、比較的低速であるという短所も同じです。ミューテックスと異なる点は、セマフォは共有データに対する排他アクセスが可能というだけにとどまらないという点です。セマフォを使って、リソース カウントができます。ミューテックスやクリティカル セクションでは、1つの共有リソースに対して一度に1つのスレッドしかアクセスできないのに対し、セマフォの場合は複数のスレッドが共有リソースにアクセスできます。なお、Windows CEは現時点ではセマフォをサポートしていないため、セマフォを多用するアプリケーションをWindows CEへ移植するのは非常に困難です。

イベント

イベントは、基本的なカーネル同期オブジェクトであり、これをもとに他の同期オブジェクトを作成できます。イベント自体はやや低速ですが、名前付きイベントを使うことにより、プロセスにまたがってアクセスを同期化できます。使い方によっては、イベントもリソース カウントができますが、イベント自身はカウントを記録しません。

メータード セクション

メータード セクションは、基本的にはクリティカル セクションを拡張したものです。メータード セクションは、クリティカル セクションをベースにしていますが、2つの重要な点が拡張されています。それは、複数のプロセスにまたがってスレッドを同期化する機能が追加されることと、セマフォ カーネル オブジェクトと同様のリソース カウント機能を備えていることです(名前の「メータード」の部分は、この機能を指しています)。これだけなら、メータード セクションはセマフォとあまり変わらないような印象を受けるかもしれませんが、速度を犠牲にすることなく両機能を拡張している点が大きく違います。

メータード セクションの開発目標は、クリティカル セクションの速度と、セマフォのプロセスをまたぐリソース カウント機能を併せ持つ同期化機能を実現することでした。第2の目標は、すべてのWin32プラットフォームとの互換性を持たせることでした。

メータード セクションの使用方法

一般的なWin32の同期オブジェクトを1つでも使ったことがあれば、メータード セクションの使い方はそれほど難しくありません。メータード セクションを扱う関数は5つあり、クリティカル セクションやセマフォを扱う関数と見た目も動作も似ています。

注:アプリケーションのプログラミングでメータード セクションを使う場合は、METERED_SECTION構造体へのポインタをオペーク型ポインタとして扱い、構造体の中身を直接変更してはなりません。アクセスはすべて、以下のメータード セクション用インターフェイスを通じて行ってください。

CreateMeteredSection

CreateMeteredSection関数は、名前付きまたは名前なしのメータード セクション オブジェクトを作成します。

サンプル コードAに、CreateMeteredSection関数プロトタイプを示します。

A
LPMETERED_SECTION CreateMeteredSection( LONG lInitialCount, // Initial count LONG lMaximumCount, // Maximum count LPCTSTR lpName // Pointer to metered section name );

パラメータ
lInitialCount

メータード セクション用に最初に用意しておく空きスロットの数(カウント)を指定します。この値は、ゼロ以上でlMaximumCountの値以下でなければなりません。カウントがゼロを超えているときはスロットは使用可能であり、ゼロのときは使用できるスロットはありません。EnterMeteredSection関数がメータード セクションを待っているスレッドを解放するたび、カウントは1ずつ減少します。 LeaveMeteredSection関数を呼び出すと、指定した数だけカウントは増加します。

lMaximumCount

メータード セクション用に使用できるスロットの最大数を指定します。値はゼロ以上でなければなりません。

lpName

メータード セクションの名前を指定するヌル終了文字列へのポインタ。名前の長さはMAX_METSECT_NAMELEN文字までに制限されており、バックスラッシュ(\)以外の任意の文字で構成できます。大文字と小文字は区別されます。

lpNameが既存の名前付きメータード セクションの名前と一致する場合、lInitialCountとlMaximumCountの2つのパラメータは、最初にそのメータード セクションを作成したプロセスによってすでに設定済みであるため無視されます。

lpNameがNULLの場合、メータード セクションは名前なしで作成されます。

戻り値
関数が成功した場合、メータード セクションを指すポインタが返されます。名前付きメータード セクションが以前の関数呼び出しによってすでに存在する場合、既存のメータード セクションを指すポインタが返されます。このとき、GetLastErrorを実行するとERROR_ALREADY_EXISTSが返されます。

関数が失敗した場合は、NULLが返されます。詳しいエラー情報が必要な場合は、GetLastErrorを呼び出してください。

OpenMeteredSection

OpenMeteredSection関数は、既存の名前付きメータード セクションを指すポインタを返します。

:この関数はWindows CEではサポートされていません。

サンプル コードBに、OpenMeteredSection関数プロトタイプを示します。

B
LPMETERED_SECTION OpenMeteredSection( LPCTSTR lpName // Pointer to metered section name );

パラメータ

lpName

オープンするメータード セクションを指定するヌル終了文字列へのポインタ。名前の大文字と小文字は区別されます。

戻り値
関数が成功した場合、メータード セクションを指すポインタが返されます。関数が失敗した場合は、NULLが返されます。詳しいエラー情報が必要な場合は、GetLastErrorを呼び出してください。

EnterMeteredSection

EnterMeteredSection関数は、次のどちらかが起きると戻ります。

  • 指定したメータード セクションに使用可能なスロットがあった
  • 時間切れのためタイムアウトした

サンプル コードCに、EnterMeteredSection関数プロトタイプを示します。

C
DWORD EnterMeteredSection( LPMETERED_SECTION lpMetSect, // Pointer to a metered section DWORD dwMilliseconds // Time-out interval in milliseconds );

パラメータ
lpMetSect

メータード セクションへのポインタ。

dwMilliseconds

タイムアウトまでの時間をミリ秒単位で指定します。この時間が経過すると、関数はメータード セクションに空のスロットがなくても戻ります。dwMillisecondsがゼロの場合、この関数はメータード セクションの状態を判定してすぐに戻ります。dwMillisecondsがINFINITEの場合、この関数はタイムアウトしません。

戻り値
関数が成功した場合、WAIT_OBJECT_0が返されます。関数がタイムアウトした場合は、WAIT_TIMEOUTが返されます。詳しいエラー情報が必要な場合は、GetLastErrorを呼び出してください。

備考

EnterMeteredSection関数は、指定したメータード セクションに使用可能なスロットがあるかどうか調べます。メータード セクションに使用可能なスロットがない場合、呼び出し側のスレッドは効率のよい待ち状態に入ります。スレッドは、スロットが空くのを待つ間、プロセッサ時間をほんの少しだけ消費します。

LeaveMeteredSection

LeaveMeteredSection関数は、指定した数だけメータード セクションの使用可能なスロット数を増やします。

サンプル コードDに、LeaveMeteredSection関数プロトタイプを示します。

D
BOOL LeaveMeteredSection( LPMETERED_SECTION lpMetSect, // Pointer to a metered section LONG lReleaseCount, // Amount to add to current count LPLONG lpPreviousCount // Address of previous count );

パラメータ
lpMetSect メータード セクションへのポインタ。CreateMeteredSection関数とOpenMeteredSection関数はこのポインタを返します。

lReleaseCount

解放するメータード セクションのスロットの数を指定します。値はゼロより大きくなければなりません。指定した数が大きすぎて、メータード セクションの作成時に指定された使用可能な最大スロット数を超えた場合、使用可能なスロット数は変わらず、関数はFALSE(偽)を返します。

lpPreviousCount

関数を実行した時点の空きスロット数を受け取るための、32ビットのLONG型変数へのポインタ。直前のカウントが必要ない場合、このパラメータはNULLでも構いません。

戻り値
関数が成功した場合、ゼロ以外の値が返されます。関数が失敗した場合は、ゼロが返されます。詳しいエラー情報が必要な場合は、GetLastErrorを呼び出してください。

CloseMeteredSection

CloseMeteredSection 関数は、指定されたポインタが指す、オープンされているメータード セクションをクローズします。

サンプル コードEに、CloseMeteredSection関数プロトタイプを示します。

E
void CloseMeteredSection( LPMETERED_SECTION lpMetSect // Pointer to the metered section // to close );

パラメータ
lpMetSect

メータード セクションを指すポインタを指定します。

戻り値
なし

メータード セクションの仕組み

サンプルコードFの2つの構造体が、メータード セクションの核となる部分を表しています。

F
typedef struct _METSECT_SHARED_INFO { BOOL fInitialized; // Is the metered section initialized? LONG lSpinLock; // Used to gain access to this structure LONG lThreadsWaiting; // Count of threads waiting LONG lAvailableCount; // Available resource count LONG lMaximumCount; // Maximum resource count } METSECT_SHARED_INFO, *LPMETSECT_SHARED_INFO; typedef struct _METERED_SECTION { HANDLE hEvent; // Handle to a kernel event object HANDLE hFileMap; // Handle to memory mapped file LPMETSECT_SHARED_INFO lpSharedInfo; // Pointer to mapped view of // the memory mapped file } METERED_SECTION, *LPMETERED_SECTION;

メータード セクションは実際には METERED_SECTION構造体を指すオペーク型ポインタです。構造体は3つのフィールドからなります。1つはイベント オブジェクトのハンドルです。2つめは、システム ページ ファイルがサポートするメモリ マップド ファイルのハンドルです。3つめはMETSECT_SHARED_INFO構造体の構造を持つファイルの構成マップを指すポインタです。構造体には、複数のプロセスの間で共有できる可能性のある情報が入っています。

共有情報は、1つのBOOLと4つのLONGからなります。fInitializedフラグは、メータード セクションが初期化されているかどうかを示します。lThreadsWaitingフィールドは、共有リソースへのアクセス権を待っているスレッドの数を示します。lAvailableCountフィールドは、使用可能なスロットの数(リソース数)を示します。lMaximumCountフィールドは、当該メータード セクションがアクセスを制御しているリソースの空きスロットの最大数を示します。lSpinLock フィールドは、lAvailableSlotsとlThreadsWaitingを、呼び出し側のスレッドからアトミックに変更できるように、METSECT_SHARED_INFO構造体自身に対するアクセスを同期化します。

最初にCreateMeteredSectionを呼び出してメータード セクションを作成する段階で、いくつかの処理が発生します。まずMETERED_SECTION用にメモリが割り当てられ、必要な情報が埋められます。つまり、自動リセット イベントが作成され、そのハンドルがhEventに割り当てられ、METSECT_SHARED_INFO構造体を保持するのに十分な大きさのメモリ マップド ファイルが作成され、そのハンドルがhFileMapに割り当てられ、そのファイルの構成マップを指すポインタがlpSharedInfoに割り当てられるということです。 名前付きメータード セクションを作成するためにCreateMeteredSectionに名前が渡された場合、イベント オブジェクトと、メモリ マップド ファイルは、両方とも名前付きオブジェクトとして作成されます。カーネル オブジェクトはみな同じ名前空間を使うので、イベント オブジェクトとメモリ マップド ファイルの名前が重複してはなりません。イベント オブジェクトの名前は、メータード セクションの名前の前にDKC_MSECT_EVT_を付けることによって決まります。メモリ マップド ファイルの名前も同様に、メータード セクションの名前の前にDKC_MSECT_MMF_を付けることによって決まります。Win32の他の名前付きカーネル オブジェクトと同様、メータード セクションの名前も大文字と小文字が区別されます。

以後のプロセスでCreateMeteredSectionまたはOpenMeteredSectionに同じ名前を渡すと、該当するメータード セクションへのポインタを受け取ります。指定されたメータード セクションは、上記のそれぞれの関数に応じて内部的にCreateEventまたはOpenEventを呼び出し、すでに作成されている名前付きイベント オブジェクトのハンドルを取得します。同様に、メータード セクションは、内部的にそれぞれCreateFileMappingまたはOpenFileMappingを呼び出して、すでに作成されているメモリ マップド ファイルのハンドルを取得します。新しいメータード セクションがファイルの構成マップを作成すると、最初のメータード セクションと新しいメータード セクションは、両方とも同じ共有データへのポインタを持つことになります。プロセス間で同じイベント オブジェクト、同じ共有情報を使用するので、名前付きメータード セクション オブジェクトを使ってプロセス間で同期をとることができるのです。メータード セクションに名前が付いていないと、イベント オブジェクトとメモリ マップド ファイルも名前なしで作成されます。この方法で作成されたメータード セクションは、同じプロセス内のスレッド間でしか、アクセスを同期化できません。同じプロセス内のスレッド間でアクセスを同期化する分には、メモリ マップド ファイルは必須ではありません。しかし、その作成と破棄のときを除けば、ほかに速度が低下する要素はないので、一貫性を保つためにメモリ マップド ファイルを使用しています。

スレッドは、メータード セクションに入る必要があるたびに、EnterMeteredSectionを呼び出します。この関数は、内部的には共有lSpinLockフィールドにアクセスしなければなりません。スピン ロックへのアクセス許可を取得するには、InterlockedExchangeを呼び出して、lSpinLockフィールドを繰り返し“1”に設定します(下記のGetMeteredSectionLock関数のコードに示します)。InterlockedExchange関数は、lSpinLockの直前の値を返します。返される値が“0”になり、誰もロックをかけていない状態になるまで、InterlockedExchangeを繰り返し呼び出します。InterlockedExchangeを呼び出すたびに、Sleep(0)を呼び出します。スピン ロックを取得するときに競合が発生した場合でも、sleepを呼び出しているので、呼び出し側のスレッドはタイム スライスの残りをInterlockedExchangeを無駄に呼び出して浪費する代わりに、残っているタイム スライスを放棄します。このスピン ロックの処理が、CPUの時間を無駄使いしているように思われるかもしれません。しかしこのようなケースでは、メータード セクションは、数命令分の時間だけスピン ロックを保持するように設計されているので、スピン ロックをめぐる競合は最小限であり、競合があってもスピン ロックは速やかに解放されます。サンプル コードGを参照してください。

G
void GetMeteredSectionLock(LPMETERED_SECTION lpMetSect) { // Spin and get access to the metered section lock while (InterlockedExchange(&;(lpMetSect->lpSharedInfo-> _ lSpinLock), 1) != 0) Sleep(0); }

EnterMeteredSectionを呼び出しているスレッドは、スピン ロックを獲得すると、METSECT_SHARED_INFO構造体の内容を変更できる状態となります。同じ構造体をほかのスレッドが同時に変更しているかどうかを心配する必要がありません(下記のEnterMeteredSectionのコードの抜粋を参照)。スレッドはlAvailableCountフィールドを調べ、使用可能な空きスロットがあれば使用可能カウントの値を1減じて、スピン ロックを解放し、共有リソースへのアクセス権を獲得します。使用可能なスロットがなければ、共有リソースに対する競合が発生しているということです。この場合、呼び出し側のスレッドはそのままでは無条件にブロックされるので、カーネル イベント オブジェクトを使用して同期を図ることになります。イベント オブジェクトはリセットされ、スピン ロックは解放され、呼び出し側のスレッドは合図があるまで、あるいはタイムアウトになるまで、イベント オブジェクトを待ちます。サンプル コードHを参照してください。

H
GetMeteredSectionLock(lpMetSect); // Get the spin lock // We have access to the metered section, everything we do now will be atomic if (lpMetSect->lpSharedInfo->lAvailableCount >= 1) { lpMetSect->lpSharedInfo->lAvailableCount*; ReleaseMeteredSectionLock(lpMetSect); return WAIT_OBJECT_0; // Grant access to the shared resource } // Couldn't get in. Wait on the event object lpMetSect->lpSharedInfo->lThreadsWaiting++; ResetEvent(lpMetSect->hEvent); ReleaseMeteredSectionLock(lpMetSect); // Release the spin lock if (WaitForSingleObject(lpMetSect->hEvent, dwMilliseconds) == WAIT_TIMEOUT) { return WAIT_TIMEOUT; }

メータード セクションに入ったスレッドは、共有リソースを解放する準備ができた段階でLeaveMeteredSectionを呼び出し、解放する共有リソースの数を渡します。洞察力の鋭い読者の方なら、1回のLeaveMeteredSectionの呼び出しで複数のリソースを解放できることに気づかれたでしょう。しかし、1回のEnterMeteredSectionでは1つのリソースへのアクセス権しか獲得できません。セマフォの規則に合わせるためにこうしました。LeaveMeteredSectionはMETSECT_SHARED_INFO構造体の中身を変更するので、スピン ロックへのアクセス権が必要です。スピン ロックを獲得したら、lAvailableCountフィールドの値は、解放したリソースの数だけ増やされます。この時点で最低でも1つの共有リソースが使用可能になるので、待ち状態のスレッドがあるかどうか、lThreadsWaitingがチェックされます。待ち状態のスレッドがなければ、スピン ロックは解放され、関数は戻ります。待ち状態のスレッドがあれば、利用可能なリソースと同じ数の待ち状態のスレッドを呼び起こすために、その回数分だけSetEventを使ってイベント オブジェクト(hEvent)が設定されます。その具体的な方法を下記のコードに示します。自動リセット イベントを使っているので、SetEventを1回呼び出すたびに、1つのスレッドが呼び起こされます。手動リセット イベントを使うとすると、1回のSetEventの呼び出しで待ち状態のすべてのスレッドが呼び起こされ、全部がメータード セクションをめぐって競合することになります。使用可能なリソースの数よりも、待ち状態のスレッドの数が多い場合は、すべてのスレッドを呼び起こしてしまうのは非効率的です。複数のスレッドが、結局はイベント オブジェクトを待つ状態に戻るだけなのに、リソースへのアクセス権を獲得しようとしてCPUサイクルを無駄に使うことになるからです。サンプル コードIを参照してください。

I
// Set the event the appropriate number of times lReleaseCount = min(lReleaseCount, lpMetSect->lpSharedInfo->lThreadsWaiting); if (lpMetSect->lpSharedInfo->lThreadsWaiting) { for (iCount=0; iCount < lReleaseCount ; iCount++) { lpMetSect->lpSharedInfo-A>lThreadsWaiting*; SetEvent(lpMetSect->hEvent); } }

アプリケーションは、メータード セクションが不要になったらCloseMeteredSectionを呼び出します。この関数は、単にMETSECT_SHARED_INFO構造体の構造マップを解放して、イベント オブジェクトとメモリ マップド ファイルのハンドルをクローズし、METERED_SECTION構造体に割り当てられたメモリを解放して、メータード セクションの後始末をします。

メータード セクションについてもう1つ重要なことは、メータード セクションの実装の中で使われているAPIは、OpenEventとOpenFileMappingを除き、すべてのWin32プラットフォームでサポートされていることです。OpenEventとOpenFileMappingは、Windows CEではサポートされていません。この2つのAPIを使う関数はOpenMeteredSectionだけなので、条件付きコンパイル(#ifdefを使う)を行えば、Windows CE向けにコンパイルするときにはこのコードだけ除外できます。Windows CEにこのAPIがないといっても、それほど大きな問題ではありません。OpenEventとOpenFileMappingは実際には予備的な関数です。CreateEventとCreateFileMappingはどちらも、それぞれに対応するカーネル オブジェクトを作成できるうえ、既存のオブジェクトを指す名前を指定された場合には、それらをオープンすることもできます。その結果として、CreateMeteredSectionを使って、同様に既存のメータード セクションをオープンすることができます。Windows CEはセマフォをサポートしていないので、メータード セクションはWindows CEにおいては、ほかのWin32プラットフォームよりもなおさら高い重要性を持っています。メータード セクションは、Windows CEにおいて、リソース カウント機能を完全にサポートしている唯一の同期オブジェクトなのです。

結論

Win32は、多様な機能をもつ同期オブジェクトを提供しています。本稿ではさらに、読者の都合に合わせて使える、もう1つの同期オブジェクトを紹介しました。自分の作成するアプリケーションで、現行のすべてのWin32プラットフォームで動作し、スレッドおよびプロセスにまたがってリソース カウントが可能な高速の同期オブジェクトが必要であれば、 メータード セクションがまさにあなたが探しているものだと言えます。
表示: