この記事の対象読者
私は「順番を保ったロックオブジェクト」を作ることにしました。その奮闘記を見たい人向けです。
本文
順番を保障したロックオブジェクトとは
std::mutex mtx;
void add_value(int value){
std::lock_guard<std::mutex> lock(mtx);
// 何か作業をする。
}
上記のコードは1つのスレッドだけ通すコードです。後から来たスレッドはstd::lock_guard<std::mutex> lock(mtx)
で待たされる事になります。
この時、最初に待たされたスレッドが、次にmutexの所有権を持てそうな気しますが、実際は、次にmutexを所有できるスレッドの順番は保障されていません。 Windowsでは、CriticalSection
がXPまでは、順番が保障されていましたが、現在は保障されていません。
ですから、その機能が欲しい場合、手作りするしかなさそうです。webで検索してもそういうのは見つかりませんでした。
デモコード
プログラムの事前説明
このプログラムは、順番を保証できない同期オブジェクトで作った、ロックする順番を保障するプログラムを作る為のテストプログラムです。Ordered Lockクラスと命名しました。
この記事で使うソースコードへのリンク
GitHubへのリンクはここです。Visual Studio 2022用に設定されたslnファイルもあります。
TestProjectをスタートアッププロジェクトに設定し、ソリューションエクスプローラーからTestOrderedLockStdMutex.cppを選択し、プロパティの設定で全般->ビルドから除外項目をいいえに設定し、TestOrderedLockStdMutex.cpp以外ははいに設定し、ターゲットCPUをx64に設定し、F5
を押下すると実行できます。
また、記事中でいくつかのソースファイルを使いますが、その都度そのファイルを全般->ビルドから除外 の設定を変更して、一つのファイルをビルド対象にしていただければ、ビルド可能になります。
ソースコードの中にはデバッグ用のライブラリ、また、表示のギャップ時間を計測するクラスOrCout
も含んでいます。本質ではない為、今回は説明を割愛いたします。
この記事で紹介しているソースコードは、公開した時点から変更を加えている事があります。そのため、元の記事とは異なる結果を得る場合があります。また、ソースコードを機能別にディレクトリを分ける等の、改善を行う可能性があります。
コードの解説
下記にサンプルコードを記載します。その後、番号のコメントが付けられているところの、解説を順次行います。実行結果は、各々が確認してもらえればと思います。
TestOrderedLockStdMutex.cpp
#include <Windows.h>
#include <memory>
#include <iostream>
#include <sstream>
#include <thread>
#include <iomanip>
#include <mutex>
#include <crtdbg.h>
#include "../CommonLib/defSTRINGIZE.h"
#include "../CommonLib/OrderedCout.h"
#pragma comment(lib, "../CommonLib/" STRINGIZE($CONFIGURATION) "/CommonLib-" STRINGIZE($CONFIGURATION) ".lib")
#pragma once
using namespace std;
HANDLE hEvStart_; // 1
mutex mtx1, mtx2, mtx3;// 2
void doWorkInOrder(OrderedCOut& COut, int num, int i){ // 3
mtx1.lock(); // 4
mtx2.lock();
mtx3.lock();
// ここで作業を行う
COut.Push("thread" + to_string(num) + " " + to_string(i) + "\n"); // 5
mtx1.unlock(); // 6
Sleep(1); // 7
mtx2.unlock();
Sleep(1); // 7
mtx3.unlock();
}
void threadFunc(OrderedCOut& OrCOut, int num){ // 8
WaitForSingleObject(hEvStart_, INFINITE); // 9
for( int i = 0; i < 5; ++i ){
doWorkInOrder(ref(OrCOut), num, i);
}
}
int main(){ // 10
{
OrderedCOut OrCout; // 11
unique_ptr<remove_pointer_t<HANDLE>, decltype(CloseHandle)*> hEvStart{ [&](){
if( !(hEvStart_ = CreateEvent(NULL, TRUE, FALSE, NULL)) ){
throw std::exception("CreateEvent");
} return hEvStart_; }(), CloseHandle }; // 12
OrCout.Trigger(true); // 13
thread t1(threadFunc, ref(OrCout), 1); // 14
thread t2(threadFunc, ref(OrCout), 2); // 14
thread t3(threadFunc, ref(OrCout), 3); // 14
Sleep(200); // 15
SetEvent(hEvStart_); // 16
t1.join(); // 17
t2.join(); // 17
t3.join(); // 17
OrCout.StopTimer();// 18
stringstream ss;
ss << "Main:total elapsed time: "
<< fixed << setprecision(3)
<< OrCout.TotalTime()
<< " msec" << endl;
OrCout.Push(ss.str()); // 19
OrCout.MessageFlush(); // 20
}
_CrtDumpMemoryLeaks();
return 0;
}
コメント番号部分の解説
1. HANDLE hEvStart_;
各スレッドが同時に開始できるように、スレッドをスタート地点で待たせる役目を負います。
2. mutex mtx1, mtx2, mtx3;
ミューテックスを定義しています。
3. 4. 5. 6. 7.void doWorkInOrder(int num, int i)
複数のスレッドで実行される関数本体です。
void doWorkInOrder(OrderedCOut& COut, int num, int i){ // 3
mtx1.lock(); // 4
mtx2.lock();
mtx3.lock();
COut.Push("thread" + to_string(num) + " " + to_string(i) + "\n"); // 5
mtx1.unlock(); // 6
Sleep(1); // 7
mtx2.unlock(); // 6
Sleep(1); // 7
mtx3.unlock(); // 6
}
スレッドの中で行う実作業です。スレッドナンバーと、そのスレッドの「実行回数ー1」を、表示します。
この3つのmutexで3スレッドを順序を保ってロックする仕組みを作っています。Sleep(1)
に関しましては
後で、実験を行いますので、取り合えず入れておきます。
8. 9. 15. 16. threadFunc(OrderedCOut& OrCOut, int num)
void threadFunc(OrderedCOut& OrCOut, int num){ // 8
WaitForSingleObject(hEvStart_, INFINITE); // 9
for( int i = 0; i < 5; ++i ){
doWorkInOrder(ref(OrCOut), num, i);
}
}
スレッドプロシージャです。
WaitForSingleObject(hEvStart_, INFINITE); // 9
で、一旦全てのスレッドを揃えます。
その後、
Sleep(200); // 15
SetEvent(hEvStart_); // 16
で、一斉にスタートさせます。
10. int main()
メイン関数です。
11. OrderedCOut OrCout;
9. メイン関数を定義します。
10. 一斉にスタートをする為のイベントオブジェクトを作ります。
11. 3つスレッドを作成します。
12. Sleep(200)
一斉スタートできる状態まで待ちます。
13. SetEvent(hEvStart_)
スタートします。
14. スレッドの終了処理をします。
15. 計測タイマーをストップします。
16. OrCout.Push(ss.str())
トータルタイムを表示します。
実行結果
コンソール
thread3 0 0.000 msec
thread1 0 30.511 msec
thread2 0 31.942 msec
thread3 1 31.996 msec
thread1 1 29.947 msec
thread2 1 32.058 msec
thread3 2 31.856 msec
thread1 2 32.350 msec
thread2 2 31.641 msec
thread3 3 32.167 msec
thread1 3 31.929 msec
thread2 3 31.936 msec
thread3 4 32.000 msec
thread1 4 32.298 msec
thread2 4 14.946 msec
Main:total elapsed time: 427.582 msec
コンソールの表示について
thread2 0 0.000 msec
thread1 0 29.258 msec
thread3 0 32.056 msec
thread2 1 31.962 msec
thread1 1 32.043 msec
thread3 1 31.990 msec
...
と言った感じでthread1、thread2及びthread3が順番に実行され、番号がインクリメントされていますので、想定通りの表示が出来ています。
Sleep(1); // 7 について
これを入れないと次のように表示されます。
thread2 0 0.000 msec
thread2 1 0.014 msec
thread2 2 0.547 msec
thread2 3 0.480 msec
thread2 4 0.568 msec
thread3 0 0.558 msec
thread3 1 0.487 msec
thread3 2 0.861 msec
thread3 3 0.909 msec
thread3 4 0.537 msec
thread1 0 0.538 msec
thread1 1 0.576 msec
thread1 2 0.592 msec
thread1 3 0.670 msec
thread1 4 0.599 msec
Main:total elapsed time: 7.941 msec
スレッドが順序を持って実行できなくなっています。同期オブジェクトと言えども、この短い間では、同期出来ていないようです。他のスレッドはタイムスライスで、違うコードを実行している可能性があると言えるでしょう。
まとめ
- 順序を維持したlockオブジェクトを作る事にした
- mutexを何重にもネストさせる方法を選んだ
- 実験の結果、理論通りに動かない事が判り、
Sleep
を入れると理論通りに動く事が判った - 考えられることは、他のmutexを待っているスレッドが、タイムスライスにより、別のコードを実行していて、所有権を取得できるタイミングで、抽選に参加出来ないものと思われる
- spinの存在意義はここにあるのではと考えられる
- spinについては、また、別の機会で、実験で確かめたい
終わりに
「[C++][Windows][mutex]順番を保障したlockクラスを作る1」の解説は以上となります。この記事が皆様の閃きや発想のきっかけになりましたら幸いです。
また、ご意見、ご感想、ご質問など、お待ちしております。