Help us understand the problem. What is going on with this article?

C/C++によるマルチスレッドプログラミング入門

More than 1 year has passed since last update.

はじめに

この記事はマルチスレッドプログラミング未経験者orこれから始めていく人向けの記事です。
すでにマルチスレッドでゴリゴリコードを書いてる人が読んでも得るものはないと思います。
また筆者はプログラマとしては半人前もいいとこなので誤り等ありましたら遠慮なく指摘していただけると助かります。

マルチスレッドとは

まずマルチスレッドでないプログラム、シングルスレッドのプログラムを見てみましょう。
シングルスレッド.jpg
図のようにシングルスレッドの場合は処理を上から順番に実行していきます。
ループ等で上に戻ることもありますが、基本的に別の処理が同時並行して行われるということはありません。
処理1を実行し、処理1が終われば処理2を行い、処理2が終われば処理3を行う…
といったように処理を順次実行していきます。
入門書に書かれているようなプログラムは大抵がシングルスレッドだと思います。

これがマルチスレッドになると以下のようになります。
マルチスレッド.jpg
上図のように1つのプロセスの中で複数のスレッドが動いており複数の処理を同時並行して行うことが可能になります。この説明だけだと使いどころがわからないのでもうちょっと具体的な例を出しましょう。
例えばユーザー操作を受け付けて、その操作を受けて実行する処理に時間がかかるようなプログラムで威力を発揮します。
比較.jpg
上図のようにマルチスレッドの場合、時間のかかる操作を別スレッドで実行することでユーザー操作の受付を中断することなくプログラムを動かすことが可能になります。
普段使うようなソフト(ブラウザとかメディアプレーヤーとか)はまずマルチスレッドで動いてると言ってもいいでしょう。

スレッドの生成方法

スレッドの生成方法について軽く触れておきます。
詳細については既に色々な記事があると思うのでそちらを参照ということで・・・。

・C++11

C++11からはstd::threadというクラスが標準ライブラリとして実装されています。
各OSのシステムコールよりはこちらの方が簡単に利用できるのでサンプルとかを動かす場合はこちらを使えばいいと思います。

・Linux
pthread系の関数を使います。
pthread_create サンプル
みたいな感じでググれば使い方とかが出てくると思います。

・Windows
Windows APIを使います。
_beginthread サンプル
CreateThread サンプル
こちらもこんな感じでググれば使い方とか出てくると思います。

スレッド間通信

スレッド間の通信方法についてです。
スレッド間でやり取りをすることをメッセージを送受信するとか言ったりします。
メッセージ.jpg
図の丸で囲ってるとこがメッセージを送信しているところです。
メッセージを送信する、と書くとそういう用途のシステムコールがありそうな雰囲気です。
しかしC/C++にはスレッド間通信の標準ライブラリやOS固有のシステムコールといったものはありません。

スレッド間通信の簡単なソースコードとシーケンスを例示しましょう。
スレッド間通信.png

#include <thread>
#include <cstdio>
#include <cstdint>

uint32_t end_flag_;
uint32_t msg_;

void ThreadA()
{
    int32_t input = 0;
    while(end_flag_){
        printf("数字を入力してください\n");
        printf("0...スレッドBにメッセージを送信します\n");
        printf("1...プロセスを終了します\n");
        scanf("%d", &input);

        switch(input){
            case 0:
                msg_ = 1;
                break;
            case 1:
                end_flag_ = 0;
                break;
            default :
                printf("0か1を入力してください\n");
                break;
        }
    }
    printf("スレッドA終了\n");
}

void ThreadB()
{
    while(end_flag_){
        if(msg_){
            printf("スレッドAからメッセージを受信しました\n");
            msg_ = 0;
        }
    }
    printf("スレッドB終了\n");
}

int main()
{
    msg_ = 0;
    end_flag_ = 1;

    std::thread th_a(ThreadA);
    std::thread th_b(ThreadB);

    th_a.join();
    th_b.join();

    return 0;
}

上図のようにメッセージ通信とはいっても、実は単純なことでスレッドAとスレッドBの両方のスレッドから見えている変数に値を入れたりそれをチェックしたりしているだけです。
この2つのスレッドで共有している変数についてはサンプルのように単純なフラグだったり、キューを実装したりなど通信方法はそれぞれの好みに応じて実装できます。
しかしこの共有変数の扱いには注意が必要です。サンプルプログラムも動きはしてますがあまりお行儀がいいプログラムとは言えません。次項で解説します。

排他制御

sample1.png

#include <thread>
#include <cstdio>
#include <cstdint>

uint32_t count_;

void ThreadA()
{
    for(int i=0; i<100000; ++i){
        ++count_;
    }
}

void ThreadB()
{
    for (int i = 0; i<100000; ++i) {
        ++count_;
    }
}

int main()
{
    count_ = 0;

    std::thread th_a(ThreadA);
    std::thread th_b(ThreadB);

    th_a.join();
    th_b.join();

    printf("count_ : %d\n", count_ );


    return 0;
}

上記プログラムは、スレッドAで100000回共有変数に1を足す、スレッドBで100000回共有変数に1を足すプログラムになります。スレッドA、スレッドB終了後に共有変数の値を出力すると200000が表示されそうですが、値はプログラムを実行するごとに異なることになると思います。

なぜこのようなことが起きるのかは以下の2点を押さる必要があります。
2018/07/23コメントよりご指摘をいただいたので修正。
シングルコア・シングルスレッドのCPUの場合は以下の動きをしています。

1.マルチスレッドの実際の動き(シングルコア・シングルスレッドのCPU)

並行処理.png

図のように1つのスレッドで少し処理を行い、別のスレッドに切り替えてそのスレッドで少し処理を行い、また別のスレッドに切り替える...といったことを短い時間のなかで繰り返すことによって、あたかも同時並行して処理を行っているようにみせています。

2.変数を書き換えるコードの動き

a = a + 1;
++a;

加算処理は一行で記載できますが、実際は次の様な動きをしています。
1.変数aの値を読み出す
2.読みだした値に1を足す
3.変数aの値を書き換える

そしてマルチスレッドの場合はこの1、2、3のそれぞれの個所で処理が別のスレッドに移る場合があります。

この2点を考慮してより詳細なシーケンスを描くと以下のようになります。
競合詳細.png

スレッドAで共有変数の値を読み出したが書き換える前にスレッドBに処理が移ってしまい、
スレッドBでは書き換える前の値を読みだしているので、加算処理が一つ握りつぶされてしまった形になってしまいました。

2018/07/23コメントよりご指摘をいただきましたので追記
上記の動きはシングルコア・シングルスレッドのCPUの話になります。
スマホでもマルチコア・マルチスレッドのCPUが当たり前の時代でこの説明は化石すぎるのでマルチコア・マルチスレッドCPUの動きも記載します。

マルチコアのCPUの場合は、異なるコアに割り当てられたスレッドは見せかけではなく本当に同時並行して行われます。
また、プロセスのスレッド数がコア数以下であればスレッド生成用のライブラリは各スレッドが別プロセッサ上に割り当ててくれることを保証してくれるそうです。
別プロセッサ上に割り当ててくれるのははSolarisでの話で他のOSの場合ではそういった保証はなさそうです。

マルチコアCPUの場合は変数を書き換える際の挙動は以下のようになります。
マルチコア.png

原因としてはシングルコアの時と同様で、あるスレッドが変数に対して読み出してから書き込むまでの間に別のスレッドが同じ変数を読み出しているため加算処理が一つ握りつぶされたという形になっています。

CPUのコア数に関わらず、複数のスレッドから読み書きされる変数やオブジェクトにはこの問題が付きまといます。
それを防ぐために排他制御が必要になります。

セマフォとミューテックス

排他制御にはセマフォかミューテックスを利用するのが一般的です。
スレッド間通信での排他制御の場合はセマフォもミューテックスも
行うことは本質的に同じなのでここではミューテックスを用いて説明します。

・C++11

C++11からはstd::mutex

・Linux
pthread_mutex系関数

・Windows
Windows API

こちらについても詳細な使い方については各自で調べていただくということで…。
やっぱり楽なのはC++11のstd::mutexで以下のサンプルプログラムでも使っています。

ミューテックスを用いる場合のシーケンスとプログラムは以下のようになります。
排他制御.png

#include <cstdint>
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx_; // 排他制御用ミューテックス
uint32_t count_;

void add_count()
{
    // count_を加算する前にミューテックスを取得する
    std::lock_guard<std::mutex> lock(mtx_);
    ++count_;
}

void ThreadA()
{
    for(int i=0; i<100000; ++i){
        add_count();
    }
}

void ThreadB()
{
    for (int i = 0; i<100000; ++i) {
        add_count();
    }
}

int main()
{
    count_ = 0;

    std::thread th_a(ThreadA);
    std::thread th_b(ThreadB);

    th_a.join();
    th_b.join();

    std::cout << "count_ : " << count_ << std::endl;

    return 0;
}

共有変数の書き込みをする際、必ずミューテックスを取得するようにします。
既に他のスレッドが所有しているミューテックスを、所有権を持っていないスレッドから取得しようとするとそのスレッドは停止します。
そのため、ミューテックスを所有しているスレッド以外からの変数の読み書きを防ぐことができます。

排他制御が必要な変数

基本的に複数スレッドから読み書きされる変数の場合は排他制御が必要になります。
例外はイミュータブルな変数、std::atomicのように既に排他制御が実施されているような変数くらいでしょうか。
複数のスレッドから参照されているクラスのインスタンスのメンバ変数にももちろん排他制御は必要です。

このあたりの排他の考え方についてはC/C++以外の言語についても同じだと思います。
それぞれの言語にミューテックスやセマフォ、あるいはそれと似たような機構があるはずです。

最後に

排他制御を怠ると再現性が低いかつ発見が難しいバグを生むのでめんどくさがらず行いましょう。
また、関数仕様にもスレッドセーフか否かをしっかり記載しておけばこのあたりのバグを減らせるのではないかなと思います。

追記

その2も投稿しました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした