マルチスレッドプログラミングをやってみた

自己紹介

初めまして、新人エンジニアの勘右衛門です。今回の記事では、マルチスレッドプログラムがパソコン内部でどんな風に処理されて、シングルスレッドよりどのくらい早くなるのか調べていこうと思います。

そもそもマルチスレッドプログラムってなに?

名称 内容
スレッド プログラムで実行している処理の流れのこと。
マルチスレッドプログラム スレッドを複数同時に実行しているプログラムのこと。

上記の表で示した通り、マルチスレッドプログラムはスレッドを複数同時に実行しているプログラムのことです。この説明だと、スレッドが複数同時に行われれば、そりゃ早くなるよねと最初は思いました。ですが、マルチスレッドプログラムは同時に実行することもあれば、しない場合もあります。

マルチスレッドプログラムの処理フロー

マルチスレッドプログラムの処理フローを解析する前に、CPUについて簡単にお話します。CPUはプログラムを処理している装置なのですが、実際に処理しているのはCPUコアと呼ばれる装置です。そして、1つのCPUコアにプログラムの複数スレッドを同時に処理させることを並行処理といいます。

また、1つのプログラムの複数スレッドを複数のCPUコアで実行することを並列処理といいます。こちらは、本当の意味で同時にスレッドを実行してます。

マルチスレッドは、この並行処理か並列処理、または、両方で実行されます。スレッドを並列・並行で実行するかは、主にOSのスケジューラが決めています。

並行処理による高速化って、どういうこと?

上の図だけだと、並行処理で処理速度がなぜ上がるのかがわからないと思います。スレッドの切り替えで発生するオーバーヘッドのせいで、シングルスレッドよりも遅くなりそうというのが、自分が最初に思ったことです。結論を書くと、マルチスレッドプログラムの並行処理はスレッドで発生するCPUの待ち時間を別スレッドに切り替えて、CPUをずっと動く状態にすることで処理速度(CPUのパフォーマンス)が向上しています。例えば、ファイル読み込みなどでCPUが待たされている間に、別のスレッドを動かすことでCPUを有効活用できます。

データ競合

マルチスレッドプログラムは、処理速度を向上させるのに非常に有用なプログラムです。ただし、よく考えて処理を書かないとめちゃくちゃバグります。例えば、マルチスレッドプログラムは別々のスレッドで共有の変数を読み書きしようとすると、意図しない結果を生むことがあります。これをデータ競合と呼びます。

int a = 0;

void Increment()
{
    for (int i = 0; i < 100000; ++i)
    {
        a += 1;
    }
}

int main()
{
    std::thread t1(Increment);
    std::thread t2(Increment);

    // スレッドの処理が終わるまで待つ
    t1.join();
    t2.join();

    std::cout << a << std::endl;  // 200000になることを期待するが、106385と表示された

    return 0;
}

そして、データ競合はスレッドが同時に変数に読み書きをしないように制御することで解決できます。この制御のことを排他制御(ミューテックス)と呼びます。

int a = 0;
std::mutex mtx;   // 排他制御をするためのstlが提供するクラス

void Increment()
{
    // ミューテックスをロック
    // 他のスレッドで共通のミューテックスがロック中なら、ロックが解除されるまで待つ
    mtx.lock();
    for (int i = 0; i < 100000; ++i)
    {
        a += 1;
    }
    mtx.unlock();   // ミューテックスのロックを解除
}

int main()
{
    std::thread t1(Increment);
    std::thread t2(Increment);

    // スレッドの処理が終わるまで待つ
    t1.join();  
    t2.join();

    std::cout << a << std::endl;  //200000と表示される

    return 0;
}

ただ、ミューテックスを使用するうえで2つ気を付けないといけないことがあります。

1つ目が排他制御をしている部分は逐次処理になるため、排他制御をする範囲はなるべく小さくすることです。極端な例でいうと、スレッド1とスレッド2で処理したい関数をすべて排他制御にすると、処理したい関数をシングルスレッドで2回呼び出すのと大して変わりません。

std::mutex mtx;   // 排他制御をするためのstlが提供するクラス

void Test()
{
    // ミューテックスをロック
    // 他のスレッドで共通のミューテックスがロック中なら、ロックが解除されるまで待つ
    mtx.lock();

    // 重い処理

    mtx.unlock();   // ミューテックスのロックを解除
}

int main()
{
    std::thread t1(Test);
    std::thread t2(Test);

    // マルチスレッドで処理しても、Test関数を2回呼び出すのと変わらない
    t1.join();  
    t2.join();

    return 0;
}

2つ目は、デッドロックと呼ばれる処理です。これは、スレッド同士がミューテックスのロックが解除されるまで待つことで発生します。

int a = 0;
std::mutex mutex1;
std::mutex mutex2;

void Increment()
{
    mutex1.lock();
    for (int i = 0; i < 100000; ++i)
    {
         // デッドロックになるよう待ち時間を入れる
    }
    mutex2.lock();
    for (int i = 0; i < 100000; ++i)
    {
        a += 1;
    }
    mutex1.unlock();
    mutex2.unlock();
}

void Decrement()
{
    mutex2.lock();
    for (int i = 0; i < 100000; ++i)
    {
         // デッドロックになるよう待ち時間を入れる
    }
    mutex1.lock();
    for (int i = 0; i < 100000; ++i)
    {
        a -= 1;
    }
    mutex2.unlock();
    mutex1.unlock();
}

int main()
{
     std::thread t1(Increment);
     std::thread t2(Decrement);

     // // スレッドの処理が終わるまで待つが、いつまでたってもスレッドが終わらない
     t1.join(); 
     t2.join();

     std::cout << a << std::endl;

    return 0;
}

こちらは補足になるのですが、サンプルコードだとミューテックスがどこでロックやアンロックをしているのかわかりずらいです。また、アンロックするのを忘れるとデッドロックになるので、以下の方法で対策したりします。

std::mutex mtx;
void Test()
{
    {
        std::lock_guard <std::mutex> lock(mtx);    // mtxがロックされる
        
        // 処理
        
    }   // lockが破棄されたタイミングでmtxがアンロックされる
}

今回はstd::lock_guardを用いた方法を紹介をしましたが、ほかにもstd::unique_lockなどがございます。

実際にマルチスレッドプログラムを試してみよう

今回は、C++で78MBの動画を100個コピーした際の平均時間を比較してみようと思います。C++はマルチスレッドプログラム用のstlを提供していて、今回の記事で紹介したスレッドとミューテックスに該当するstd::threadstd::mutexを使用してコードを書きました。

実行環境

名前 内容
CPU Intel(R) Core(TM) Ultra 7 265KF 3.90 GHz
RAM 64GB
コア数 20

実行結果

スレッド数 平均時間
シングルスレッド 8.05秒
マルチスレッド(スレッド数2) 4.85秒
マルチスレッド(スレッド数5) 2.52秒
マルチスレッド(スレッド数10) 2.24秒
マルチスレッド(スレッド数20) 2.71秒

考察

実行結果から、シングルスレッドよりもマルチスレッドの処理速度のほうが圧倒的に早いことは一目瞭然です。ただ、スレッドを増やしていくと処理速度の上昇率がどんどん小さくなっています。そして、スレッド数を20にするとスレッド数10よりも遅くなっています。これは、CPUの稼働率が最大に近づいたこと、ディスクI/Oの処理限界が原因かもしれません。

まとめ

今回は、マルチスレッドの基礎部分の勉強をしました。マルチスレッドプログラムは処理速度が劇的に上がるのですが、実装難易度も劇的に上がって、テストコードがよく止まってしまいました。そして、考察がかなりふわっとした内容になっているため、機会があれば実証のためのブログを書かせてもらおうと思います。最後までお付き合いいただきありがとうございました。