クリティカルセクション

スレッド間の排他制御

マルチスレッドプログラムでは各スレッドの処理は独立していますが、同じプログラム(プロセス)中で動作するのでメモリ領域は共有しています。
複数のスレッドから同じデータにアクセスすると、問題が発生する可能性があります。

主にグローバル変数や静的変数(static変数)が複数のスレッドでメモリ領域を共有します。
その他、ポインタ経由で他のスレッドが管理するメモリ領域にアクセスすることもあります。
引数それ自体や動的な(staticではない)ローカル変数などは関数単位で独立しているので問題は起こりません。

例えばある変数(中身は「0」)にアクセスする度に数値をインクリメントする処理を考えます。


int number = 0;

number++;	//インクリメント

インクリメントは

  • 変数の値を読み取る(0←number)
  • インクリメント(0→1)
  • 変数に値を書き込む(1→number)

という手順で処理されます。
たった一行のコードですが、読み取りと書き込みは同時に行われるわけではなく、わずかに時間差があります。
シングルスレッドの場合はメインスレッド以外からのアクセスはないので、何度繰り返しても正常に動作します。

マルチスレッドの場合、各スレッドは独立しているため、どういったタイミングで変数へのアクセスが行われるかはわかりません。
例えば

  • スレッドAが変数の値を読み取る(0←number)
  • スレッドAがインクリメント(0→1)

このタイミングで別のスレッドBで同じ処理が発生すると、

  • スレッドBが変数の値を読み取る(0←number)
  • スレッドAが変数に値を書き込む(1→number)
  • スレッドBがインクリメント(0→1)
  • スレッドBが変数に値を書き込む(1→number)

という処理が行われる可能性があります。
変数に二回アクセスがあったため、最終的な値は「2」が正しいはずですが、スレッドAが変数に書き込む前にスレッドBが変数から値を読み取っているため、どちらも「0」が取得されてしまい、結果として値は「1」になってしまうのです。
このような、並行処理によるデータの不整合の「危険」がある箇所をクリティカルセクションといいます。

同時に発生する可能性がある処理が読み取りのみである場合は問題は起こりません。

排他制御

共有データの整合性を保つには、クリティカルセクションにスレッドがアクセスしている間は他のスレッドからはアクセスできないように制限する必要があります。
これを排他制御といいます。

CRITICAL_SECTION型

排他制御にはCRITICAL_SECTION型の変数を使用します。
この型の変数はクリティカルセクションオブジェクトといいます。

クリティカルセクションオブジェクトは特定のスレッドが「所有」することができ、所有権は同時にひとつのスレッドのみが持つことが出来ます。
あるスレッドが所有しているクリティカルセクションオブジェクトを別のスレッドが所有しようとすると、所有可能になるまでスレッドは待機状態になります。
所有者スレッドが所有権を解放すると、待機中のスレッドが所有権を取得し処理を再開します。

これを利用してクリティカルセクションにアクセスするスレッドを待機させることで、データの整合性を確保することができます。

CRITICAL_SECTION型は内部的には構造体で定義されていますが、プログラマは構造体のメンバにアクセスすることはありません。
クリティカルセクションオブジェクトは専用の操作関数以外による操作は禁止で、単純なコピー操作もしてはなりません。

InitializeCriticalSection関数

CRITICAL_SECTION型変数は最初にInitializeCriticalSection関数を使用して初期化する必要があります。

void InitializeCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
);
クリティカルセクションオブジェクトlpCriticalSectionを初期化する。

引数はポインタなので注意してください。

一度初期化されたクリティカルセクションオブジェクトを、削除する前に再度初期化することはできません。

EnterCriticalSection関数

スレッドがクリティカルセクションオブジェクトの所有権を得るにはEnterCriticalSection関数を使用します。

void EnterCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
);
クリティカルセクションオブジェクトlpCriticalSectionの所有権を要求し、所有権を得るまでスレッドを待機する。

この関数の呼び出し元スレッドがクリティカルセクションオブジェクトの所有権を要求します。
どのスレッドも所有していない場合は、そのまま所有権を得て処理を続行します。
別のスレッドが所有している場合は、所有権が解放されて所有権を得られるまでスレッドを待機します。

呼び出し元スレッドが既にクリティカルセクションオブジェクトの所有権を持っている場合、処理はそのまま続行されます。
ただし後述するLeaveCriticalSection関数(所有権の解放)は、クリティカルセクションに入った回数だけ実行する必要があります。

TryEnterCriticalSection関数

クリティカルセクションオブジェクトの所有権を得るにはTryEnterCriticalSection関数を使用することもできます。

BOOL TryEnterCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
);
クリティカルセクションオブジェクトlpCriticalSectionの所有権を要求しする。
所有権を得られた場合は0以外を返す。
他のスレッドが所有権を持っている場合は0を返す。

こちらはクリティカルセクションオブジェクトの所有権を得られなかった場合、待機することなくすぐに制御を返します。

LeaveCriticalSection関数

スレッドが持つクリティカルセクションオブジェクトの所有権を解放するにはLeaveCriticalSection関数を使用します。

void LeaveCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
);
クリティカルセクションオブジェクトlpCriticalSectionの所有権を解放する。

自身が所有権を持っていないクリティカルセクションオブジェクトに対してこの関数を実行することはできません。
(スレッドが無限待機=停止する可能性がある)

DeleteCriticalSection関数

不要になったクリティカルセクションオブジェクトはDeleteCriticalSection関数で削除します。

void DeleteCriticalSection(
 LPCRITICAL_SECTION lpCriticalSection
);
クリティカルセクションオブジェクトlpCriticalSectionを削除する。

削除したCRITICAL_SECTION型変数は、InitializeCriticalSection関数で再度初期化する以外の操作をしてはいけません。
(再初期化後は他の関数で使用できます)

スレッドが所有している状態のクリティカルセクションオブジェクトを削除してはなりません。
(未定義の動作)

サンプルコード


#include <windows.h>
#include <strsafe.h>
#include <process.h>

//関数プロトタイプ宣言
ATOM MyRegisterClass(HINSTANCE);
BOOL InitInstance(HINSTANCE, int);
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
DWORD WINAPI ThreadProc1(LPVOID);
DWORD WINAPI ThreadProc2(LPVOID);

//ウィンドウの生成等は省略

#define BUFFERSIZE 11

unsigned int counter1, counter2;
CRITICAL_SECTION cs;	//クリティカルセクションオブジェクト

//排他制御なし
DWORD WINAPI ThreadProc1(LPVOID lpParameter)
{
	WCHAR buf[BUFFERSIZE];
	unsigned int count = 0;
	
	while (count++ < 100)
	{
		StringCchPrintf(buf, BUFFERSIZE, L"%u", ++counter1);
		SetWindowText((HWND)lpParameter, buf);
		Sleep(10);
	}
	return 0;
}

//排他制御あり
DWORD WINAPI ThreadProc2(LPVOID lpParameter)
{
	WCHAR buf[BUFFERSIZE];
	unsigned int count = 0;

	//クリティカルセクションオブジェクトの所有権を要求
	//所有権を得られるまで待機
	EnterCriticalSection(&cs);
	while (count++ < 100)
	{
		StringCchPrintf(buf, BUFFERSIZE, L"%u", ++counter2);
		SetWindowText((HWND)lpParameter, buf);
		Sleep(10);
	}
	//クリティカルセクションオブジェクトの所有権を解放
	LeaveCriticalSection(&cs);
	return 0;
}

#define IDC_BUTTON1 100
#define IDC_BUTTON2 101

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{	
	static HWND hStatic1, hStatic2;
	HANDLE hThread;

	switch (message)
	{
	case WM_CREATE: //ウィンドウの作成
		//クリティカルセクションオブジェクトの初期化
		InitializeCriticalSection(&cs);

		CreateWindow(L"BUTTON", L"排他制御なし",
			WS_CHILD | WS_VISIBLE,
			10, 10,
			120, 25,
			hWnd, (HMENU)IDC_BUTTON1, hInst, NULL);
		CreateWindow(L"BUTTON", L"排他制御あり",
			WS_CHILD | WS_VISIBLE,
			140, 10,
			120, 25,
			hWnd, (HMENU)IDC_BUTTON2, hInst, NULL);

		hStatic1 = CreateWindow(L"STATIC", L"0",
			WS_CHILD | WS_VISIBLE,
			10, 40,
			120, 25,
			hWnd, (HMENU)-1, hInst, NULL);
		hStatic2 = CreateWindow(L"STATIC", L"0",
			WS_CHILD | WS_VISIBLE,
			140, 40,
			120, 25,
			hWnd, (HMENU)-1, hInst, NULL);
		break;

	case WM_COMMAND:
		switch (LOWORD(wParam))
		{
		case IDC_BUTTON1:
			hThread = (HANDLE)_beginthreadex(
				NULL, 0,
				(LPTHREAD_START_ROUTINE)ThreadProc1,
				hStatic1, 0, NULL);
			if (hThread != NULL)
				CloseHandle(hThread);
			break;

		case IDC_BUTTON2:
			hThread = (HANDLE)_beginthreadex(
				NULL, 0,
				(LPTHREAD_START_ROUTINE)ThreadProc2,
				hStatic2, 0, NULL);
			if (hThread != NULL)
				CloseHandle(hThread);
			break;
		}
		break;

	case WM_DESTROY: //ウィンドウの破棄
		//クリティカルセクションオブジェクトの破棄
		DeleteCriticalSection(&cs);
		PostQuitMessage(0);
		break;

	default:
		return DefWindowProc(hWnd, message, wParam, lParam);
	}
	return 0;
}

ボタンクリックでスレッドを作成し、ボタン下の数値を100増加させるコードです。
クリティカルセクションのサンプルコード

どちらもスレッドの終了を待たずに新しいスレッドを作成します。
「排他制御なし」のほうは、カウント処理中にさらにボタンをクリックすると、画像のように中途半端な数値になることがあります。
これはカウントアップ処理中のグローバル変数に対して別スレッドが処理途中の値を読み取っているからです。
「排他制御あり」のほうは他のスレッドがクリティカルセクションに入っている(処理している)場合は直前で待機するので、必ず100ずつカウントされます。

デッドロック

排他制御を行う上で気を付けないといけないのはデッドロックと呼ばれる現象です。
これはクリティカルセクションが2つ以上あるマルチスレッドプログラムで、スレッド同士が互いの所有しているクリティカルセクションオブジェクトの解放待ちになることです。
互いが相手の持っている(ロックしている)オブジェクトが解放されるまで処理を進められず、スレッドが停止してしまいます。
デッドロックの概念図

わざとデッドロックを発生させるサンプルコードです。


#include <windows.h>
#include <strsafe.h>
#include <process.h>

//ウィンドウの生成等は省略

CRITICAL_SECTION cs1, cs2;

DWORD WINAPI ThreadProc1(LPVOID lpParameter)
{
	EnterCriticalSection(&cs1);
	{
		//わざとデッドロックを発生させる
		Sleep(1000);

		EnterCriticalSection(&cs2);
		{
			//何か処理...
		}
		LeaveCriticalSection(&cs2);

		//何か処理...
	}
	LeaveCriticalSection(&cs1);

	return 0;
}

DWORD WINAPI ThreadProc2(LPVOID lpParameter)
{
	EnterCriticalSection(&cs2);
	{
		//わざとデッドロックを発生させる
		Sleep(1000);

		EnterCriticalSection(&cs1);
		{
			//何か処理...
		}
		LeaveCriticalSection(&cs1);

		//何か処理...
	}
	LeaveCriticalSection(&cs2);
	
	return 0;
}

#define BUFFERSIZE 16

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
	static HANDLE hThread1, hThread2;
	static WCHAR txt1[BUFFERSIZE];
	static WCHAR txt2[BUFFERSIZE];

	HDC hdc;
	PAINTSTRUCT ps;
	DWORD dwExitCode;

	switch (message)
	{
	case WM_CREATE: //ウィンドウの作成
		//クリティカルセクションの初期化
		InitializeCriticalSection(&cs1);
		InitializeCriticalSection(&cs2);

		//スレッドを二つ作成
		hThread1 = (HANDLE)_beginthreadex(
			NULL, 0,
			(LPTHREAD_START_ROUTINE)ThreadProc1,
			NULL, 0, NULL);
		hThread2 = (HANDLE)_beginthreadex(
			NULL, 0,
			(LPTHREAD_START_ROUTINE)ThreadProc2,
			NULL, 0, NULL);

		//0.1秒ごとにスレッドの状態を取得
		SetTimer(hWnd, 1, 100, NULL);
		break;

	case WM_TIMER: //タイマー
		//スレッドの終了状態の取得
		GetExitCodeThread(hThread1, &dwExitCode);
		StringCchPrintf(txt1, BUFFERSIZE,
			dwExitCode == STILL_ACTIVE ? L"Thread1 active" : L"Thread1 end");
		GetExitCodeThread(hThread2, &dwExitCode);
		StringCchPrintf(txt2, BUFFERSIZE,
			dwExitCode == STILL_ACTIVE ? L"Thread2 active" : L"Thread2 end");
		InvalidateRect(hWnd, NULL, TRUE);
		break;

	case WM_PAINT:
		hdc = BeginPaint(hWnd, &ps);

		TextOut(hdc, 10, 10, txt1, lstrlen(txt1));
		TextOut(hdc, 10, 30, txt2, lstrlen(txt2));

		EndPaint(hWnd, &ps);
		break;

	case WM_DESTROY: //ウィンドウの破棄
		//クリティカルセクションの破棄
		DeleteCriticalSection(&cs1);
		DeleteCriticalSection(&cs2);
		PostQuitMessage(0);
		break;

	default:
		return DefWindowProc(hWnd, message, wParam, lParam);
	}
	return 0;
}

このサンプルコードはふたつのスレッドを作成し、タイマーで0.1秒ごとにスレッドの終了状態をチェックします。
しかしデッドロックが発生しているため、いつまで経ってもスレッドが終了することはありません。
デッドロックの例

最初にクリティカルセクションに入った後、Sleep関数で待機することで「相手が必要とするクリティカルセクションオブジェクトを、互いに所有している状態」を意図的に作っています。
こうなるとそれ以上処理を続けることができなくなり、スレッドは停止します。

これを回避するには、

  1. クリティカルセクションの数を1つに限定する
  2. クリティカルセクションに入る(ロックする)順番を統一する

などが考えらえます。
1の方法ならばデッドロックは起こらないのでまず検討すべき方法です。
2の方法は、今回で言えばすべてのスレッドで「cs1→cs2」の順にクリティカルセクションに入るように、プログラム全体で統一することです。
そうすればデッドロックは発生しないので、どうしても複数のクリティカルセクションが必要な場合はこちらの方法を採用します。

なお、デッドロックを防ぐ方法ではありませんが、クリティカルセクション内の処理はできるだけ短時間になるように心がけましょう。
長くクリティカルセクションオブジェクトを占有すると、他スレッドはずっと解放待ちになりパフォーマンスに影響が出ます。