PMPの流儀

PMPの流儀

エンジニアのページ

MENU

C++クラスによるマルチスレッドのデザインパターン

C++によるマルチスレッドプログラムのデザインパターンについて紹介します。マルチスレッドはうまく使えば便利ですが、理解が不完全だと簡単にアクセス違反などの例外が発生します。クラスによるカプセル化によりマルチスレッドの複雑さを限定的にすることで見通しが良くできます。
f:id:ruruucky:20211117213701p:plain

マルチスレッドの使う場面

一番効果を感じるのはウインドウ描画があるアプリケーションで、ネットワークや裏で重たい処理を行うケースです。 ボタンが押されると、そのハンドラ関数がコールされるのですが、ハンドラが抜けないと処理が戻りません。ハンドラ関数で時間を使っている間、画面操作が一切きかなくなります。このような場合に、裏でワークスレッドを起動して、重い処理はスレッドに任せるのが効率的です。

あとは大量の計算処理があるときに、依存関係がない計算処理を複数のスレッドに分割させて処理させるのは有効です。

マルチスレッドの基本

関数を指定して新しいスレッドを起動する事ができます。

#include <thread>

void thread_fnc()
{
    // スレッドはこの関数が終了するまで動く
}

void f()
{
    std::thread th(thread_fnc);     // 関数を指定してスレッド起動

    // スレッドを起動すると、メインスレッドそのまま次の作業ができる

    th.join();                    // スレッドの終了待ち
}

C++のクラスへの適用

マルチスレッド処理をクラス内に閉じ込めることにより、マルチスレッドの複雑さを限定的にできます。これにより、このクラスの利用者は単純にコールするだけで済みます。

一般的なスレッドの解説では、クラスへの組み込みまで解説されていないことが多いです。障壁になるのはスレッド用の関数は静的(static)な関数である点です。static属性をつけた関数はメンバー変数にアクセスする事はできません。

このままではクラス内にスレッドを導入できないのですが、以下の方法で実現できます。

class Sample
{
    volatile int x;                           // スレッドが使用する変数は必ず volatile をつける。

    static void thread_fnc(void* param)      // スレッド用関数は static でなければならない
    {
        Smaple* const that = (Sample*)param;   // インスタンスのthisポインタを引数でもらう。thisは使えないのでthatとしています。
        that->x++;                   // that経由で、メンバー変数にアクセスできる。
        that->f();                       // that経由で、メンバー関数をコールできる
    }

    void f()
    {
        this->x++;
    }
public:
    void Run()
    {
        this->x = 10;
        std::thread th(fnc, this);      // スレッド起動。ここでthisポインタを渡す
        th.join();

        printf("%d\n", this->x);
    }
}

g()
{
     Sample obj;
     obj.Run();
}

スレッド関数起動時にthisポインタを渡すのがポイントです。スレッド用関数の引数でthisポインタをもらえれば、それ経由でインスタンスのメンバー変数や関数にアクセスが可能となります。
メインスレッドはthis, ワークスレッド内ではthat 経由で記載することで、一貫した書き方ができます。他のオート変数などとの区別になりますし、"this->"と打つと、VisualStudio のインテリセンスにひっかかるので、メンバー変数や関数を一覧から入力でき便利という効果もあります。

複数のスレッドから使用する変数は必ず volatile 属性をつけます。コンパイラの最適化による不具合を防止するためです。

例えば…こんなソースは書きませんが、分かりやすい例として。。。 f()関数は通常の感覚では、while文では抜けられなくなります。コンパイラは最適化によりwhile(1) にしてしまうでしょう。
しかし、これがマルチスレッドで外部からxの値が書き換わるとしたら、while( this->x ) として評価しなければなりません。
コンパイラに最適化を防止するために、volatile 属性をつけます。

volatile int x;

f()
{
    this->x = 1;
    while(this->x);
}

g()
{
    that->x = 0;
}

排他処理

マルチスレッド設計は複雑です。複数のスレッドがどの関数を同時に走るかを厳密に考慮しなければなりません。
簡単な例ですが、このスレッド用関数を同時に10個のスレッドで実行すると、xの値は100000にはなりません。

int x;

void thread_fnc()
{
    for(int i=0; i<10000; i++) {
        x++;
    }
}

x++の個所は、

(1). xを読み込み
(2). xに1を足す

の2step に分かれます。

2つのスレッドがこういうタイミングで動くかもしれません。

スレッド1 (1). xを読み込み
スレッド2 (1). xを読み込み
スレッド1 (2). xに1を足す
スレッド2 (2). xに1を足す

まったく同じ計算を2重にやってるだけですね。この重複した回数分だけ不足するわけです。

これを防止するために登場するのがMutexです。Mutex を使用する事により、実行を1つのスレッドのみに限定する事ができます。

int x;
std::mutex mtx;

void thread_fnc()
{
    for(int i=0; i<10000; i++) {
        mtx.lock();
        x++;
        mtx.unlock();
    }
}

lock()とunlock()の期間が排他期間です。同一コードだけでなく、異なる関数間でも使用できます。

このMutexもクラスの中に閉じ込めてしまうのが良いです。

class Sample
{
    std::mutex  mtx;
    volatile int x;                           // スレッドが使用する変数は必ず volatile をつける。

    static void thread_fnc(void* param)      // スレッド用関数は static でなければならない
    {
        Smaple* const that = (Sample*)param;   // インスタンスのthisポインタを引数でもらう。thisは使えないのでthatとしています。
        mtx.lock();
        //
            that->x++;                   // that経由で、メンバー変数にアクセスできる。
            that->f();                       // that経由で、メンバー関数をコールできる
        //
        mtx.leave();
    }

    void f()
    {
        this->x++;
    }
public:
    void Run()
    {
        this->x = 10;
        std::thread th(fnc, this);      // スレッド起動。ここでthisポインタを渡す
        mtx.lock();
        //
            this->x += 10;
            this->f();
        //
        mtx.leave();
        th.join();

        printf("%d\n", this->x);
    }
}

g()
{
     Sample obj;
     obj.Run();
}