【C言語】排他制御について解説【Mutex】

排他制御解説ページのアイキャッチ

マルチスレッドなどの並列処理を行う際に必要になるのが排他制御などの同期です。

マルチスレッドは処理を並列実行することで高速化を行なったり、別々の処理を同時に実行したりする上では非常に有用ですが、排他制御を行わないと結果が意図したものにならない場合があります。

排他制御と聞くと難しそうなイメージを持つかもしれませんが、今回紹介する Mutex は排他制御の中では最も簡単なものです。

並列プログラミングを行うのであれば是非マスターしておきましょう!

MEMO

このページのプログラムで使用する Mutex は POSIX 仕様の pthread_mutex になります

pthread_mutex が利用できない環境の場合(特に Windows)は、そのままプログラムが利用できないので注意してください

ただし考え方はどの環境を利用している方でも参考になるはずです!

排他制御とは

マルチスレッドプログラミングにおける排他制御とは「複数のスレッド間で共有するデータへの同時アクセスによってデータの不整合が起こる場合に、その共有するデータへ同時にアクセスできるスレッド数を制限する」ことです。

排他制御のイメージ図

排他制御の効果は「データの不整合を防ぐことができる」ことです。

C言語入門者の方がまず学ぶのはシングルスレッドプログラミングですので、共有するデータへの同時アクセスが発生するようなことは基本的にありません。ですので、排他制御に関して意識する必要はありませんでした。

ですが、マルチスレッドプログラミングを行うと同時アクセスが発生する可能性があるので、同時アクセスによりデータの不整合が発生するような場合は排他制御を行う必要があります。

排他制御の必要性

続いて、排他制御の必要性について身近な例を用いて説明したいと思います。

スポンサーリンク

映画館の座席予約システムの例

思い浮かべていただきたいのが映画館の座席予約システムです。

スマホやパソコンから簡単に座席が予約できて便利ですよね。私はとにかく並ぶのが苦手なので、映画見るときは必ず利用しています。

実は、この映画館の座席予約システムにおいても「排他制御」が活躍しています。

排他制御なしの場合に発生する問題

例えば同時に別々の2人が同じ座席を予約しようとしたとしましょう。

排他制御の必要性1

もし、排他制御が行われておらず、2人とも座席が予約できてしまうと、映画館に行っていざ座席に座ろうとしても既に他の人が座席に座ってしまっているという状況が発生する可能性があります。

排他制御の必要性2

この場合、お客さんはせっかく座席を予約したのに映画が見られないことになります…。こんなシステムを採用している映画館は、きっとお客さんからのクレームが絶えない映画館でしょう…。

このケースでは、同じ座席の予約が同時に行われてしまうと競合が起こり、本来1人しか予約できない座席に対して2人以上が予約できてしまうという「データの不整合」が発生してしまっているのです。

排他制御でデータの不整合を防ぐ

こういったデータの不整合が発生しないように、普通の予約システムでは排他制御が行われ、同時に同じ座席の予約が行われないようになっています。

例えば特定の座席が予約されようとしている間は、他の人からその座席が予約できないように「その座席を選択できなくする」「予約しようとしても “他の人が予約中です” などの画面に遷移させて予約を中止する」などの対策が取られているはずです。

この例では、ある座席へアクセスできる “人数” を制限しましたが、こんな感じで「共有するデータへ同時にアクセスできる “スレッド数” を制限する」のがマルチスレッドにおける排他制御になります。

マルチスレッドを用いれば、同時に様々な処理を並列で実行できるプログラムが作成できるようになりますし、同時に処理ができることで、より便利なプログラムも作れるようになります。

ただし、データの不整合が発生すると、先ほどの映画館座席予約システムのようにむしろ不便になる可能性もあります。

そうならないように、マルチスレッドプログラミングを行う際は、必要に応じて排他制御を行う必要があります。

排他制御なしのプログラミング

まずは排他制御なしの場合にデータの不整合が発生するプログラム例を紹介したいと思います。

これを後ほど排他制御によってデータの不整合が発生しないようにしていきます。

プログラム例

下記は NUM_THREAD 個のスレッドで sum = sum + 1ADD_NUM / NUM_THREAD 回ずつ繰り返し、全スレッドの処理終了後に sum の値を表示するプログラムになります。

要は sum = sum + 1ADD_NUM 回実行するプログラムです。

sum の初期値が 0ADD_NUM が  100000 なので表示される期待値は当然 100000 ですね。

データの不整合が発生する例
#include <stdio.h>
#include <pthread.h>

#define NUM_THREAD 4
#define ADD_NUM 100000

/* 共有データ */
long long sum = 0;

/* スレッドに渡すデータ */
typedef struct thread_data {
    int addNum;
} THREAD_DATA;

void* add(void *arg){
    int i;
    THREAD_DATA *data = (THREAD_DATA*)arg;;

    /* addNum回 sum = sum + 1 を実行 */
    for(i = 0; i < data->addNum; i++){
        sum = sum + 1;
    }

    return NULL;
}

int main(void){
    pthread_t thread[NUM_THREAD];
    THREAD_DATA data[NUM_THREAD];
    int i;

    for(i = 0; i < NUM_THREAD; i++){
        /* ADD_NUMはNUM_THREADで割り切れることを前提としている */
        data[i].addNum = ADD_NUM / NUM_THREAD;
    }

    /* スレッドの開始 */
    for(i = 0; i < NUM_THREAD; i++){
        pthread_create(&thread[i], NULL, add, &data[i]);
    }

    /* スレッドの終了待ち */
    for(i = 0; i < NUM_THREAD; i++){
        pthread_join(thread[i], NULL);
    }

    /* 計算結果の表示 */
    printf("sum = %lld\n", sum);

    return 0;
}

pthread を利用しているため、リンク(コンパイル)時には -lpthread オプションを指定する必要があります。

実行して表示された値は下記のように 100000 よりも小さくなりました。

sum = 94918

スポンサーリンク

期待した結果にならない理由

なぜ期待した値にならなかったのでしょうか?

この理由について解説していきたいと思います。

ポイントは下記の処理です。

共有データへのアクセス
sum = sum + 1;

まず sum はグローバル変数であり、プログラム起動時に作成され、各スレッドで共有されるデータです。

各スレッドで行う処理は「sum = sum + 1ADD_NUM / NUM_THREAD 回繰り返す」なので、当然各スレッドは sum に対してアクセスすることになります。

さらに、スレッドは複数(4つ)なので、各スレッドから “同時に” sum にアクセスされる可能性があります。

さらに、先程の処理はC言語のソースコードとして1行で記載されていますが、細かく分けると下記の3つの処理に分解されます。

  1. sum の値を取得する
  2. sum の値に +1 する
  3. sum に 2. の計算結果を代入する

sum の 値が更新されるのは 3. の処理になります。

これらの処理を、例えば2つのスレッド(仮にスレッドAとスレッドBとします)が並列に実行することを考えてみましょう。

例えばスレッドAで 2. の処理を行っている間にスレッドBで 1. の処理が実行されたとします。

そうなると、スレッドBで取得される sum の値は、スレッドAで計算した結果が反映されておらず、スレッドAが取得した sum と同じ値が取得されることになります。

すると、スレッドAとスレッドBは同じ値に対して +1 して sum に格納することになります。

ここで期待している結果よりも “1 少なくなる” ことになります。

データの不整合が発生する様子

これが積もり積もって最終的に期待していた結果、 100000 よりも sum が小さい値になってしまうのです。

まさに「複数のスレッド間で共有するデータへの同時アクセスによってデータの不整合が起こる」様子が確認できると思います。

排他制御ありのプログラミング

では次は排他制御を実際に実装していく方法を解説していきたいと思います。

最終的には排他制御なしのプログラミングで紹介したプログラムをデータの不整合が起こらないように改善します。

排他制御を実現するにあたり、このページでは Mutex(より具体的には pthread_mutex)を利用したいと思います。

問題と解決方法

まずは排他制御なしのプログラミングで紹介したプログラムの問題点と解決方法を整理していきましょう。

先ほどのプログラムの問題は複数のスレッド間で共有されるデータ sum に同時にアクセスできてしまう点です。

より具体的には、下記の処理が複数のスレッドが同時に実行できてしまうところが問題です。

共有データへのアクセス
sum = sum + 1;

こういった複数のスレッドから同時に実行されるとデータの不整合が発生する一連の処理部分を「クリティカルセクション」と呼びます。 

クリティカルセクションを複数のスレッドから同時に実行されてしまうとデータの不整合が発生するのであれば、クリティカルセクションを複数のスレッドから実行できないようにしてやれば問題は解決できます。

要は、クリティカルセクションを同時に1つのスレッドからのみ実行可能にしてやれば良いです。

スポンサーリンク

Mutex による排他制御

このような制御を行うのに便利なのが Mutex(ミューテックス)です。

Mutex とはクリティカルセクションを1つのスレッドしか進入できない「鍵付きの個室」として扱うことで排他制御を実現する仕組みです。

Mutexのイメージ図

POSIX 仕様の Mutex である pthread_mutex の一般的な使用方法は下記のようになります。要はクリティカルセクションを pthread_mutex_lockpthread_mutex_unlock で囲います。

pthread_mutexによる排他制御
pthread_mutex_lock(&mutex);
/* クリティカルセクション */
pthread_mutex_unlock(&mutex);

pthread_mutex_lock は、それ以降の処理(クリティカルセクション)に鍵を掛ける(ロックする)関数になります。

pthread_mutex_lockで鍵を掛ける様子

鍵が掛かっている間、他のスレッドが pthread_mutex_lock を実行してもクリティカルセクションに進入することができません(Mutex のオプション等にもよりますが、pthread_mutex_lock を実行してもクリティカルセクションに進入できなかったスレッドは、基本的にこの鍵が開けられるまで待たされることになります)。

pthread_mutex_lockで待たされる様子

また pthread_mutex_unlockpthread_mutex_lock により掛けられた鍵を開ける(アンロックする)関数になります。

pthread_mutex_unlockで鍵を開ける様子

鍵が開けられるので、他のスレッドがクリティカルセクションに進入することができるようになります。

もし pthread_mutex_lock 関数で待たされているスレッドがあれば、そのスレッドが自動的にクリティカルセクションに進入し、そのスレッドの処理が再開されることになります。

こういった仕組みにより、pthread_mutex_lockpthread_mutex_unlock で囲ったクリティカルセクション内には同時に1つのスレッドしか進入できなくなります。

つまり、クリティカルセクション内の処理を同時に複数のスレッドで実行されることを防ぐことができます。

したがって、pthread_mutex_lockpthread_mutex_unlock の間に「共有データへの同時アクセスによりデータの不整合が発生する可能性のある処理」を実行させるようにすれば、データの不整合も起きなくなります。

排他制御を行うプログラム例

排他制御なしのプログラミングで紹介したプログラムを、Mutex を利用して排他制御するように改善したプログラムは下記のようになります。

Mutex やスレッド生成には POSIX 仕様の pthread を利用しています。

Mutexを用いた排他制御
#include <stdio.h>
#include <pthread.h>

#define NUM_THREAD 4
#define ADD_NUM 100000

/* 共有データ */
long long sum = 0;

/* Mutexオブジェクト */
pthread_mutex_t mutex;

/* スレッドに渡すデータ */
typedef struct thread_data {
    int addNum;
} THREAD_DATA;

void* add(void *arg){
    int i;
    THREAD_DATA *data = (THREAD_DATA*)arg;;

    /* addNum回 sum = sum + 1 を実行 */
    for(i = 0; i < data->addNum; i++){
        pthread_mutex_lock(&mutex);
        sum = sum + 1;
        pthread_mutex_unlock(&mutex);
    }

    return NULL;
}

int main(void){
    pthread_t thread[NUM_THREAD];
    THREAD_DATA data[NUM_THREAD];
    int i;

    /* Mutexオブジェクトの初期化 */
    pthread_mutex_init(&mutex, NULL);

    for(i = 0; i < NUM_THREAD; i++){
        /* ADD_NUMはNUM_THREADで割り切れることを前提としている */
        data[i].addNum = ADD_NUM / NUM_THREAD;
    }

    /* スレッドの開始 */
    for(i = 0; i < NUM_THREAD; i++){
        pthread_create(&thread[i], NULL, add, &data[i]);
    }

    /* スレッドの終了待ち */
    for(i = 0; i < NUM_THREAD; i++){
        pthread_join(thread[i], NULL);
    }

    /* Mutexオブジェクトの破棄 */
    pthread_mutex_destroy(&mutex);

    /* 計算結果の表示 */
    printf("sum = %lld\n", sum);

    return 0;
}

今回は、下記のように必ず結果が 100000 になります。

sum = 100000

プログラムの解説

排他制御なしのプログラムで紹介したプログラムとの一番大きな違いは、もともと下記のように複数スレッドで共有するデータ sum へアクセスする処理を、

共有データへのアクセス(排他制御なし)
sum = sum + 1;

下記のように pthread_mutex_lockpthread_mutex_unlock で囲うようにしたところです。

共有データへのアクセス(排他制御あり)
pthread_mutex_lock(&mutex);
sum = sum + 1;
pthread_mutex_unlock(&mutex);

これらで囲った部分を同時に処理できるのは1スレッドのみになるので、sum への同時アクセスが無くなりデータの不整合も起きなくなります。

Mutex は通常下記の手順で使用します。

  1. Mutex オブジェクトの生成
  2. Mutex オブジェクトの初期化
  3. Mutex を利用したロック
  4. Mutex を利用したアンロック
  5. Mutex オブジェクトの破棄

3. と 4. に関してはプログラム内で必要な回数実行することになります。

1. Mutex オブジェクトの生成

Mutex オブジェクトの生成は pthread_mutex_t 型変数の宣言により行うことができます。今回はグローバル変数として宣言しています。

Mutexオブジェクトの生成
/* Mutexオブジェクト */
pthread_mutex_t mutex;

この変数のアドレスが、pthread_mutex_lockpthread_mutex_unlock の引数となります。

この Mutex オブジェクトは同一プログラム内で複数生成して使用することもできます。

2. Mutex オブジェクトの初期化

Mutex オブジェクトの初期化は pthread_mutex_init で行います。上記プログラムでは main 関数の中で実行しています。

Mutexオブジェクトの初期化
/* Mutexオブジェクトの初期化 */
pthread_mutex_init(&mutex, NULL);

第1引数は Mutexオブジェクト(pthread_mutex_t 型変数)へのアドレス、第2引数には pthread_mutexattr_t 型変数へのアドレスを指定します。

pthread_mutexattr_t 型変数は Mutex オブジェクトの設定を行うための変数になります。今回は初期設定のままで良かったので、NULL を設定しています。

3. Mutex を利用したロック

Mutex による排他制御で解説したように、Mutex を利用したロックは pthread_mutex_lock を実行することで行います。

ロック
pthread_mutex_lock(&mutex);

第1引数には Mutex オブジェクト(pthread_mutex_t 型変数)へのアドレスを指定します。

pthread_mutex_lock によりロックされている間、同じ Mutex オブジェクトに対して pthread_mutex_lock を実行したスレッドは、他のスレッドによって pthread_mutex_unlock が実行されるまで pthread_mutex_lock 関数の中で待たされることになります。

4. Mutex を利用したアンロック

Mutex による排他制御で解説したように、Mutex を利用したアンロックは pthread_mutex_unlock を実行することで行います。

アンロック
pthread_mutex_unlock(&mutex);

第1引数には Mutex オブジェクト(pthread_mutex_t 型変数)へのアドレスを指定します。

pthread_mutex_lock はクリティカルセクションの前に実行するのに対し、pthread_mutex_unlock はクリティカルセクションの後に実行します。

これにより、pthread_mutex_lock 関数の中で待たされている他のスレッドがクリティカルセクションに進入できるようになり、自動的に処理が再開されることになります。

5. Mutex オブジェクトの破棄

Mutex が不要になったら最後に破棄を行います。Mutex オブジェクトの破棄は pthread_mutex_destroy で行います。上記プログラムでは main 関数の中で実行しています。

Mutexオブジェクトの破棄
/* Mutexオブジェクトの破棄 */
pthread_mutex_destroy(&mutex);

第1引数には Mutex オブジェクト(pthread_mutex_t 型変数)へのアドレスを指定します。

スポンサーリンク

共有データ

最初に排他制御とは「複数のスレッドで共有するデータへの同時アクセスによりデータの不整合が起こる場合に、その共有するデータへ同時にアクセスできるスレッド数を制限する」ことであると説明しました。

この説明の中に出てくる「複数のスレッドで共有するデータ」とは具体的にどんなデータであるかについて説明しておきたいと思います。

この共有データに複数スレッドから同時にアクセスされる可能性がある場合は、データの不整合が発生する可能性があります。不整合が発生する場合は排他制御を行う必要があります。

共有データの例

「複数のスレッドで共有するデータ」と聞くとパッとイメージがつかないかもしれませんが、結局各スレッドで実行するのは関数です。

つまり「複数のスレッドで共有するデータ」とは「いろんな関数からアクセス可能なデータ」と考えられます。

グローバル変数

「いろんな関数からアクセス可能なデータ」と聞いて一番最初に思い浮かぶのは「グローバル変数」ではないでしょうか?

グローバル変数は、同じファイル内に定義された関数であれば、どの関数からもアクセスすることが可能であり、どのスレッドからもアクセス可能なデータになります。

例えば下記は異なる関数から同じグローバル変数 a にアクセスする例になります。

同じグローバル変数へのアクセス
int a = 0;

void funcA(void) {
    a = a + 1;
}

void funcB(void) {
    a = a - 1;
}

今回紹介したプログラムでも、共有データとしてはグローバル変数を利用していました。

ファイル

あとは「ファイル」です。異なる関数からでも fopen 関数で同じファイル名のファイルを開くと、同じファイルにアクセスすることができます。

つまり、複数スレッドから同時に同じファイルにアクセス可能です。

例えば下記は異なる関数から同じファイル(test.txt)にアクセスする例になります。

同じファイルへのアクセス
void funcA(void) {

    FILE *fo;

    fo = fopen(“test.txt”, “w”);

    /* ファイルに対する処理 */
}

void funcB(void) {

    FILE *fo;

    fo = fopen(“test.txt”, “w”);

    /* ファイルに対する処理 */

}

同じファイルが同時に編集されてしまうとデータの不整合が起きてしまう(前にデータ保存した人の変更が上書きされてしまう等)ことはイメージつきやすいですよね?

アドレス

さらには「アドレス指定」する場合も注意が必要です。

この場合は、より正確に言うと「そのアドレスに存在するデータ」が共有データになります。

C言語ではアドレスさえ同一のものを指定してやれば、異なる関数からでも同一のデータにアクセスすることが可能です。

例えば下記は異なる関数から同じアドレス(main 関数で変数宣言した変数 x のアドレス)にアクセスする例になります。

同じアドレスへのアクセス
void funcA(int *a) {
    *a = *a + 1;
}

void funcB(int * a) {
    *a = *a - 1;
}

int main(void) {
    int x = 0;

    funcA(&x);
    funcB(&x);
    return 0;
}

「共有データ = 排他制御必要」とは限らない

勘違いしないでいただきたいのは、ここで挙げた共有データ全てに対して排他制御が必要というわけでは無いということです。

排他制御が必要なのは、共有データかつ、同時アクセスによりデータの不整合が起こる場合です。

例えば、データの読み込み(データの取得)目的でしか利用しない共有データであれば、複数スレッドから同時にアクセスされてもデータの不整合は起きません。

また、グローバル変数の配列も共有データですが、下の図のように各スレッドでアクセスする領域が重複しないように設計・実装してやれば同時アクセス自体を防ぐこともできます。

スレッドごとにアクセスする範囲を制限する様子

実際にこの考え方でマルチスレッド により画像の拡大縮小を行なっている例を下記で説明していますので、興味のある方は是非読んでみてください。

マルチスレッドで画像の拡大縮小を高速化

共有データでないデータの例

逆に共有データでないデータとしては「ローカル変数」が挙げられます。

ローカル変数とは関数内で変数宣言される変数で、これは関数が実行されるたびに毎回新たに用意されるデータです。

したがって、複数スレッドから同時に同じ関数が実行され、同じ名前の変数にアクセスしたとしても、データの実体としては異なりますので異なるデータにアクセスしていることになります。

なので、ローカル変数は共有データではないですし、排他制御をする必要はありません。

ただし、ポインタの場合は注意が必要です。特にポインタ変数の前に * 演算子をつけてポインタの指す先のデータにアクセスする場合は注意が必要です。

前述の通り、アドレス指定する場合はそのアドレスに存在するデータが共有データになり得ます。

ですので、ポインタ変数自体は異なるデータなので共有データで無いとしても、ポインタの指す先のデータは同一のデータであり、複数スレッド間で共有されるデータである可能性があります。

特にアドレス指定の場合は、そのアドレスに存在するデータが共有データかどうかを確認し、必要に応じて排他制御する必要があります。

スポンサーリンク

Mutex の使用例

最後に Mutex を使用して排他制御を行う実例をいくつか紹介したいと思います。

複数行で共有データへの読み込み・書き込みが行われる場合

排他制御なしのプログラミングで紹介したプログラムでは下記で共有データへの同時アクセスが発生してデータの不整合が発生していました。

データの不整合が発生するケースの例
sum = sum + 1;

で、これを排他制御ありのプログラミングで下記のように Mutex を利用することで、共有データへの同時アクセスを行わないようにしました。

排他制御の例
pthread_mutex_lock(&mutex);
sum = sum + 1;
pthread_mutex_unlock(&mutex);

では、共有データへのアクセスを行う処理を下記のように変更した場合、どのように Mutex を利用すれば良いでしょうか?

データの不整合が発生するケースの例
int a;
a = sum;
a = a + 1;
sum = a;

共有データへ同時アクセスさせないことを考えれば下記のように Mutex を利用することも考えられます。

間違った排他制御の例
int a;
pthread_mutex_lock(&mutex);
a = sum;
pthread_mutex_unlock(&mutex);
a = a + 1;
pthread_mutex_lock(&mutex);
sum = a;
pthread_mutex_unlock(&mutex);

しかし、これだとデータの不整合が起こり得ます。

結局期待した結果にならない理由で説明した現象と同じ現象が発生します。

データの不整合が発生する様子

確かに上記のように Mutex を利用することで共有データへの同時アクセスを防ぐことができます。しかし、排他制御を行う目的はあくまでも「データの不整合を防ぐこと」です。

今回取り上げている処理では「他のスレッドが計算結果を sum に格納する前に sum の値を取得してしまう」ことが原因でデータの不整合が発生しています。

なので、データの不整合を防ぐためには「他のスレッドが計算結果を sum に格納する前に sum の値を取得しない」ようにする必要があります。

つまり「sum の値の取得から sum の値の格納まで」の間に対して排他制御する必要があります。

これを実現するのが下記になります。

排他制御の例
int a;
pthread_mutex_lock(&mutex);
a = sum;
a = a + 1;
sum = a;
pthread_mutex_unlock(&mutex);

「共有データへの同時アクセス」が発生するかどうかは排他制御の必要性を見定めるのに良い目印になりますが、排他制御は「共有データへアクセスする処理のみに対して行えば良い」というわけではないので注意してください。

あくまでも重要なのは「データの不整合を防ぐ」ことです。これを考えて排他制御を実装する必要があります。

これは大体の場合、「共有データからのデータの読み込み〜共有データへのデータの書き込み」の間を排他することで実現できると思います。

複数箇所から共有データにアクセスされる場合

排他制御なしのプログラミング排他制御ありのプログラミングで紹介したプログラムでは、同じ関数から共有データにアクセスしていたので、排他制御は1箇所のみ行えば良かったのですが、複数の関数から同じ共有データにアクセスされる場合もあります。

例えば下記のように funcAfuncB から共有データ a にアクセスする例などが挙げられます。あるスレッドで funcA を、他のスレッドで funcB を同時に実行するような場合にデータの不整合が起こりえます(Mutex の初期化等の処理は省略しています)。

データの不整合が発生するケースの例
int a = 0;

void funcA(void) {
    a = a + 1;
}

void funcB(void) {
    a = a - 1;
}

このような場合、どのようにして排他制御を行えば良いでしょうか?

例えば下記のように関数ごとに異なった Mutex オブジェクトを用いて排他制御することが考えられます(Mutex の初期化等の処理は省略しています)。

間違った排他制御の例
int a = 0;

void funcA(void) {
    pthread_mutex_lock(&mutexA);
    a = a + 1;
    pthread_mutex_unlock(&mutexA);
}

void funcB(void) {
    pthread_mutex_lock(&mutexB);
    a = a - 1;
    pthread_mutex_unlock(&mutexB);
}

これは間違った排他制御になります。前述で Mutex とは「鍵付きの個室」と喩えましたが、Mutex オブジェクトが異なると「鍵付きの個室」も別のものとして扱われます。

なので、funcAmutexA をロックしている最中でも、mutexB はロックされていませんので funcBmutexB をロックし、クリティカルセクションに進入することができてしまいます。したがって排他制御しているにも関わらず、共有データ a に同時アクセスできることになります。

共有データ a への同時アクセスを防ぐためには、同じ Mutex オブジェクトを用いて排他制御を行う必要があります。

排他制御の例
int a = 0;

void funcA(void) {
    pthread_mutex_lock(&mutexA);
    a = a + 1;
    pthread_mutex_unlock(&mutexA);
}

void funcB(void) {
    pthread_mutex_lock(&mutexA);
    a = a - 1;
    pthread_mutex_unlock(&mutexA);
}

特に排他制御初心者の方は「同じ共有データへのアクセスを排他する場合、同じ Mutex を用いる」考え方で排他制御を行えば良いと思います。

スポンサーリンク

共有データが複数ある場合

ここまで紹介してきたプログラムでは、全て共有データが1つだけでした。もちろん共有データが複数の場合もあります。

データの不整合が発生するケースの例
int a = 0;
int b = 0;

void funcA(void) {
    a = a + 1;
}

void funcB(void) {
    a = a - 1;
}

void funcC(void) {
    b = b + 1;
}

void funcD(void) {
    b = b - 1;
}

共有データが2つなので、Mutex も2つ用意する必要があるようにも感じますが、実は下記のように1つの Mutex 排他制御は実現できます。

排他制御の例1
int a = 0;
int b = 0;

void funcA(void) {
    pthread_mutex_lock(&mutexA);
    a = a + 1;
    pthread_mutex_unlock(&mutexA);
}

void funcB(void) {
    pthread_mutex_lock(&mutexA);
    a = a - 1;
    pthread_mutex_unlock(&mutexA);
}

void funcC(void) {
    pthread_mutex_lock(&mutexA);
    b = b + 1;
    pthread_mutex_unlock(&mutexA);
}

void funcD(void) {
    pthread_mutex_lock(&mutexA);
    b = b - 1;
    pthread_mutex_unlock(&mutexA);
}

ただし、異なる共有データに同じ Mutex を用いるとプログラムの並列度が下がります。要は処理効率が低下します。

上記のプログラムであれば、変数 a にあるスレッドがアクセスしている間でも、変数 b は変数 a とは無関係の変数ですので、同時に変数 b に他のスレッドがアクセスしても問題ないはずです。

ですが、同じ Mutex で排他制御を行っているために、変数 a にアクセスしている間は変数 b にもアクセスできないようになってしまっています。余分に排他制御してしまっている感じですね。

ただし共有データへの同時アクセスは起こらないので、データを不整合を防ぐという目的は達成できています。

下記のように共有データごとに Mutex を用意してやれば、変数 a にアクセスしている間も変数 b にアクセスはできるようになり、この問題は解消されます(もちろん変数 a と変数 b それぞれに対しては排他制御が行われているためデータの不整合は起きません)。

排他制御の例2
int a = 0;
int b = 0;

void funcA(void) {
    pthread_mutex_lock(&mutexA);
    a = a + 1;
    pthread_mutex_unlock(&mutexA);
}

void funcB(void) {
    pthread_mutex_lock(&mutexA);
    a = a - 1;
    pthread_mutex_unlock(&mutexA);
}

void funcC(void) {
    pthread_mutex_lock(&mutexB);
    b = b + 1;
    pthread_mutex_unlock(&mutexB);
}

void funcD(void) {
    pthread_mutex_lock(&mutexB);
    b = b - 1;
    pthread_mutex_unlock(&mutexB);
}

ただし、このように使用する Mutex オブジェクトの数が増えるとデッドロックが発生する可能性が発生します。

デッドロックについては下記の Wikipedia などを見るとイメージがつくと思います。

https://ja.wikipedia.org/wiki/%E3%83%87%E3%83%83%E3%83%89%E3%83%AD%E3%83%83%E3%82%AF

基本的には共有データの数だけ Mutex オブジェクトを用意するという考え方で良いですが、特にクリティカルセクションの中で他のクリティカルセクションに進入するようなプログラムではデッドロックが起こらないように注意する必要があります。

このような場合は並列度を犠牲にして異なる共有データに対して同じ Mutex オブジェクトを用いるなどの対応を行う必要があります。

デッドロックに関してはいずれ私のサイトでも紹介したいと思います。

まとめ

このページでは特にマルチスレッドにおける「排他制御」について解説しました。

マルチスレッドを利用すれば、いろんな処理を並列に実行できるようになり、作成できるプログラムが広がります。

ただし、処理を並列に実行することにより、共有データへの同時アクセスによりデータの不整合が起こる可能性が発生します。このデータの不整合を防ぐ時に便利なのが「排他制御」です。

排他制御と聞くと難しそうに感じるかもしれませんが、このページで紹介した通り、データの不整合が生じる可能性のある一連の処理(クリティカルセクション)を Mutex のロックとアンロックで囲えば良いだけです。

ただし用意する Mutex の数が増えるとデッドロックが発生する可能性もありますので注意してください。

まずは排他制御を実際に利用して慣れていくのが良いと思います!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です