PMPの流儀

我が家の流儀

我が家で気になったことを夫婦で取り上げていきます

MENU

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

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

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

一番効果があるのはウインドウ描画があるアプリケーションで、ネットワークや裏で重い処理を行うケースです。

ボタンが押されるとハンドラ関数がコールされますが、ハンドラが抜けないと処理が戻りません。ハンドラ関数で時間を使っている間画面操作が一切きかなくなります。これはUIの描画およびハンドラ関数は同一スレッドで走るからです。どこかで滞るとUI全体に影響が出ます。ウインドウアプリケーションにおいてはハンドラ関数はできるだけ速く抜けることが肝要です。

このような場合に、裏でワークスレッドを起動して、重い処理はスレッドに任せるのが効率的です。

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

マルチスレッドの基本

関数を指定して新しいスレッドを起動する事ができます。 以前はプラットフォーム毎にAPIが異なっていましたが、2011年にリリースされたC++11にてstd::threadクラスが追加され標準化されました。

#include <thread>

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

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

    // thread_fnc()を別のスレッドで動かしながら
    // メインスレッドは並行して動く。

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

C++のクラスへの適用

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

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

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

class Sample
{
    // 複数スレッド間で共有する変数は volatile をつける
    volatile int x;
    
    // スレッド関数は static でなければならない
    static void thread_fnc(void* param)
    {
        // インスタンスのthisポインタを引数でもらう。
        // 変数名にthisは使えないのでthatとします。
        // that が書き換えられないようにポインタ側に const をつけておく。
        Smaple* const that = (Sample* const)param;

        // that経由で、メンバー変数やメンバー関数にアクセスできる。
        that->x++;
        that->f();
    }

    void f()
    {
        this->x++;
    }
public:
    void Run()
    {
        this->x = 10;

        // スレッド起動。ここでthisポインタを渡す
        std::thread th(fnc, this);
        th.join();
        this->f();
        printf("%d\n", this->x);
    }
}

g()
{
    // 利用する側はスレッドを意識せずに使えます。
    Sample obj;
    obj.Run();
}

スレッド関数起動時にthisポインタを渡すのがポイントです。static 関数でも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()の期間が排他期間です。同一コードだけでなく、異なる関数間でも使用できます。

先ほどのスレッドのクラスに適用します。

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

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

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

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

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

マルチスレッドをクラス内に閉じ込めることで、排他制御する範囲もクラス内で完結し影響範囲を限定化できました。