今回はクリティカルセクションについてのお話です。
日本語では「きわどい領域」なんて訳されたりします。
複数のスレッドが、同じ変数の値を書き換える時に、
それぞれのスレッドが勝手に変数の値を操作すると、
うまくいかない状況が多々あります。
まずは、うまくいっていないサンプルをお目にかけます。
今回のサンプルは2つあります。
まず、最初にバグありバージョンのサンプルです。
// sample16_bug.cpp
#include <OS.h> // スレッド生成と時間情報のために必要
#include <iostream.h> // カウンター表示のため(cout)に必要
// スレッドによるカウンタークラス
class ThreadAutoCounter
{
public:
ThreadAutoCounter(uint64 count);
status_t StartCounter1(); // カウンター1のスタート
status_t StartCounter2(); // カウンター2のスタート
status_t StopCounter1(); // カウンター1のストップ
status_t StopCounter2(); // カウンター2のストップ
void PrintCount(); // カウンター内容を標準出力に表示
private:
static int32 CounterFunc(void* data); // スレッド用関数
thread_id m_tid1; // カウンター1番のスレッドID
thread_id m_tid2; // カウンター2番のスレッドID
uint64 m_count; // カウンターの値
};
// コンストラクタは、カウンターの初期値を与える
ThreadAutoCounter::ThreadAutoCounter(uint64 count)
:m_count(count)
{
}
// カウンター1をスタートさせる関数
status_t ThreadAutoCounter::StartCounter1()
{
m_tid1 = spawn_thread(ThreadAutoCounter::CounterFunc,
"CounterThread",B_NORMAL_PRIORITY,
this);
return resume_thread(m_tid1);
}
// カウンター2をスタートさせる関数
status_t ThreadAutoCounter::StartCounter2()
{
m_tid2 = spawn_thread(ThreadAutoCounter::CounterFunc,
"CounterThread",B_NORMAL_PRIORITY,
this);
return resume_thread(m_tid2);
}
status_t ThreadAutoCounter::StopCounter1()
{
return kill_thread(m_tid1);
}
status_t ThreadAutoCounter::StopCounter2()
{
return kill_thread(m_tid2);
}
// カウンターを表示する関数
void ThreadAutoCounter::PrintCount()
{
cout << "Counter:" << m_count << endl;
}
// カウンターを増やすスレッド用関数
int32 ThreadAutoCounter::CounterFunc(void* data)
{
// 自分自身のクラスへキャスト
ThreadAutoCounter* counter;
counter = reinterpret_cast<ThreadAutoCounter*>(data);
// 100000までカウント
for (int i=0;i<100000;i++)
{
// 一回ローカル変数にコピーしてから1を足す
uint64 count = counter->m_count;
count++;
counter->m_count = count;
// counter->m_count++; // 前回のコード
}
return B_OK; // スレッドの正常終了時にはB_OKを返す
}
int main()
{
ThreadAutoCounter counter(0); // カウンターの初期値は0
counter.StartCounter1(); // カウンター1をスタート
counter.StartCounter2(); // カウンター2をスタート
bigtime_t start_time;
start_time = system_time(); // OSの起動時間をマイクロ秒で取得
// 10秒間(10000000マイクロ秒)カウンターの内容を表示し続ける
while (system_time() - start_time < 10000000)
{
counter.PrintCount(); // カウンターの内容を表示
}
counter.StopCounter1(); // カウンター1スレッドを破棄
counter.StopCounter2(); // カウンター2スレッドを破棄
return 0;
}
実行結果を見る前に、このサンプルが何をするのか解説しておきます。
このサンプルでは、カウンタスレッド関数内で、カウンタを100000回増やすようにしており、
そのカウンタスレッドを2つ作成しています。
今回は、クリティカルセクションを分かりやすくするために、カウントをするコード部分を変更しました。
// 100000までカウント
for (int i=0;i<100000;i++)
{
// 一回ローカル変数にコピーしてから1を足す
uint64 count = counter->m_count;
count++;
counter->m_count = count;
// counter->m_count++; // 前回のコード
}
さらに、クリティカルセクションが起きやすいように、スレッドの優先度を上げています。
m_tid1 = spawn_thread(ThreadAutoCounter::CounterFunc, "CounterThread",B_NORMAL_PRIORITY, this); m_tid2 = spawn_thread(ThreadAutoCounter::CounterFunc, "CounterThread",B_NORMAL_PRIORITY, this);
100000回カウントするスレッドが2つあるわけですから、
カウンタは100000 × 2 = 200000で、それ以上カウントを起こさなくなるはずです。
しかし、実行結果は以下のように、200000より少ない数字でカウントをやめてしまいます
(まれに200000ちゃんとカウントされる場合もある)。
これは、なぜなのでしょうか?
カウントの途中で次のようなことが発生しているからです。
仮に今カウンタの値が50000であると仮定しまして、2つあるスレッドの片方をカウンタ1、もう一つをカウンタ2とします。
カウンタ1が、m_countから50000という値を持ってくる |
↓ |
カウンタ1がローカルの変数を1足す |
↓ |
カウンタ1がローカル変数の値をm_countに書き戻す前に カウンタ2がm_countから50000という値を持ってくる |
↓ |
カウンタ1が50001という値をm_countに書き戻す |
↓ |
カウンタ2がローカル変数の値に1足す |
↓ |
カウンタ2が50001という値をm_countに書き戻す |
つまり、カウンタ1と2がそれぞれ1回づつ値を足しているにもかかわらず、m_countの値は1しか足されていません。
従って、カウンタ1と2が100000回カウントしても、合計が200000にならないことがあるのです。
ここで、m_countに1足す一連の処理のことをきわどい領域、クリティカルセクションという言い方をします。
このような動作は、毎回決まった所で起きるのではなく、「かなり」気まぐれで起きたりするので、デバッグが非常に困難です。
ちなみに、カウンタの値をローカル変数に格納せずに、いきなりm_countを足せばいいと思われる方もいると思いますが、
残念ながらそれでも問題の根本的な解決にはなっていません。
メモリ上の変数の値を足すときは、メモリからレジスタに変数の値をフェッチして(取ってきて)から、レジスタ上の値に対して演算をして、
レジスタからメモリ上に値を書き戻すのが普通です。
C++(C言語)のコード上では1行の処理も、アセンブラレベルで見れば1命令でないことがほとんどです。
このサンプルの場合、m_countをいきなり足しても、
スレッドの優先度がB_NORMAL_PRIORITYの場合は、きちんと200000回カウントされることが多いですが、
スレッドの優先度をB_REAL_TIME_PRIORITY(一番高い)にすると、200000回カウントされることはほとんどなくなります。
さて、じゃあどうすればいいのかというと、セマフォと呼ばれるものを用います。
セマフォとは、「今からクリティカルセクションに入りますよ」という宣言するためのものです。
複数のスレッドが同じセマフォを利用して、クリティカルセクションを実行中のスレッドは1つだけにしよう、というのが狙いです。
今回はサポートキットに、セマフォを使いやすくしたBLockerというクラスがあるのでそれを利用することにします。
カーネルキットレベルの生のセマフォについては次回やる予定です。
さて、BLockerを使用して、エラーをなくしたサンプルコード全部です。
// sample16.cpp
#include <OS.h> // スレッド生成と時間情報のために必要
#include <iostream.h> // カウンター表示のため(cout)に必要
#include <Locker.h> // BLockerを使うために必要
// スレッドによるカウンタークラス
class ThreadAutoCounter
{
public:
ThreadAutoCounter(uint64 count);
~ThreadAutoCounter();
status_t StartCounter1(); // カウンター1のスタート
status_t StartCounter2(); // カウンター2のスタート
status_t StopCounter1(); // カウンター1のストップ
status_t StopCounter2(); // カウンター2のストップ
void PrintCount(); // カウンター内容を標準出力に表示
private:
static int32 CounterFunc(void* data); // スレッド用関数
thread_id m_tid1; // カウンター1番のスレッドID
thread_id m_tid2; // カウンター2番のスレッドID
uint64 m_count; // カウンターの値
BLocker* m_locker; // セマフォのかわりのBLockerクラス
};
// コンストラクタは、カウンターの初期値を与え、BLockerを構築する
ThreadAutoCounter::ThreadAutoCounter(uint64 count)
:m_count(count)
{
m_locker = new BLocker();
}
// デストラクタでは、BLockerを破棄
ThreadAutoCounter::~ThreadAutoCounter()
{
delete m_locker;
}
// カウンター1をスタートさせる関数
status_t ThreadAutoCounter::StartCounter1()
{
m_tid1 = spawn_thread(ThreadAutoCounter::CounterFunc,
"CounterThread",B_NORMAL_PRIORITY,
this);
return resume_thread(m_tid1);
}
// カウンター2をスタートさせる関数
status_t ThreadAutoCounter::StartCounter2()
{
m_tid2 = spawn_thread(ThreadAutoCounter::CounterFunc,
"CounterThread",B_NORMAL_PRIORITY,
this);
return resume_thread(m_tid2);
}
status_t ThreadAutoCounter::StopCounter1()
{
return kill_thread(m_tid1);
}
status_t ThreadAutoCounter::StopCounter2()
{
return kill_thread(m_tid2);
}
// カウンターを表示する関数
void ThreadAutoCounter::PrintCount()
{
cout << "Counter:" << m_count << endl;
}
// カウンターを増やすスレッド用関数
int32 ThreadAutoCounter::CounterFunc(void* data)
{
// 自分自身のクラスへキャスト
ThreadAutoCounter* counter;
counter = reinterpret_cast<ThreadAutoCounter*>(data);
// 100000までカウント
for (int i=0;i<100000;i++)
{
counter->m_locker->Lock(); // クリティカルセクションに入ることを宣言
// 一回ローカル変数にコピーしてから1を足す
uint64 count = counter->m_count;
count++;
counter->m_count = count;
counter->m_locker->Unlock(); // クリティカルセクションから抜けたと宣言
// counter->m_count++; // 前回のコード
}
return B_OK; // スレッドの正常終了時にはB_OKを返す
}
int main()
{
ThreadAutoCounter counter(0); // カウンターの初期値は0
counter.StartCounter1(); // カウンター1をスタート
counter.StartCounter2(); // カウンター2をスタート
bigtime_t start_time;
start_time = system_time(); // OSの起動時間をマイクロ秒で取得
// 10秒間(10000000マイクロ秒)カウンターの内容を表示し続ける
while (system_time() - start_time < 10000000)
{
counter.PrintCount(); // カウンターの内容を表示
}
counter.StopCounter1(); // カウンター1スレッドを破棄
counter.StopCounter2(); // カウンター2スレッドを破棄
return 0;
}
これの実行結果はちゃんと200000までカウントされます。
変更点についての解説です。
counter->m_locker->Lock(); // クリティカルセクションに入ることを宣言 // 一回ローカル変数にコピーしてから1を足す uint64 count = counter->m_count; count++; counter->m_count = count; counter->m_locker->Unlock(); // クリティカルセクションから抜けたと宣言
先程説明したクリティカルセクションの部分をBLocker::Lock()関数と、BLocker::Unlock()関数で囲んでいます。
仮に、同時にクリティカルセクションに入ろうとしている2つのスレッドを仮定しましょう。
時間的にわずかに先にクリティカルセクションに入るスレッドをスレッドA、後のをスレッドBとします。
スレッドAがBLocker::Lock()関数を呼んだら、BLockerがスレッドAがLock()関数を呼んだと記憶して、
スレッドAをクリティカルセクションに通過させます。
次に、スレッドBがBLocker::Lock()関数を呼ぶと、BLockerがすでにスレッドAが通過していることを覚えていて、
BLocker::Lock()関数はスレッドBに対しては、その場所を通過させないように働きます。
そのまま、スレッドBが立ち往生している間に、スレッドAはクリティカルセクションを通過して、BLocker::Unlock()関数を呼びます。
そうすると、BLockerはスレッドAがクリティカルセクションを通過したと認識しますので、
スレッドBに対してクリティカルセクションに入ることを許可します。
つまり別々の2つ以上のスレッドが、BLocker::Lock()関数を同時に通過することは不可能なわけです。
ちなみに、同じスレッドがBLocker::Lock()関数を複数回呼んでも、BLockerはそのスレッドの実行を妨げたりしません。
しかし、他のスレッドにBLocker::Lock()関数を通過させるには、
あるスレッドがLock()関数を呼んだ回数だけUnlock()関数を呼ぶ必要があります。
例えば、先程の例でスレッドAがBLocker::Lock()関数を3回呼んだら、Unlock()関数も3回呼び出さないと、
スレッドBがBLocker::Lock()関数を通過することができません。
スレッド間の同期の取り方については以上で説明を終えます。
しかし、スレッドで非常に多く同期を取っていると、マルチスレッドの同時実行性という性質が失われて、
速度的に、単一のスレッドで実行しているのと変わらない結果が出てしまいます。
しかし、きっちり同期を取らないと資源(変数)の破壊ということに繋がるわけで。
ここがまたマルチスレッドプログラミングの難しさでもあるわけです。
今回のクリティカルセクションの説明はいかがいかがでしたでしょうか?
次回はカーネルキットの生のセマフォを使用して、今回のサンプルと同じ機能を実装しようと思います。
|
|||
|
|