C言語と並列処理 – 初心者でも理解できる10ステップ

C言語と並列処理を学ぶ初心者向けのチュートリアルのイラストC言語
この記事は約18分で読めます。

 

【サイト内のコードはご自由に個人利用・商用利用いただけます】

この記事では、プログラムの基礎知識を前提に話を進めています。

説明のためのコードや、サンプルコードもありますので、もちろん初心者でも理解できるように表現してあります。

基本的な知識があればカスタムコードを使って機能追加、目的を達成できるように作ってあります。

※この記事は、一般的にプロフェッショナルの指標とされる『実務経験10,000時間以上』を凌駕する現役のプログラマチームによって監修されています。

サイト内のコードを共有する場合は、参照元として引用して下さいますと幸いです

※Japanシーモアは、常に解説内容のわかりやすさや記事の品質に注力しております。不具合、分かりにくい説明や不適切な表現、動かないコードなど気になることがございましたら、記事の品質向上の為にお問い合わせフォームにてご共有いただけますと幸いです。
(送信された情報は、プライバシーポリシーのもと、厳正に取扱い、処分させていただきます。)

はじめに

C言語と並列処理。

一見難しそうなこの2つの概念を、今回は初心者でも理解できるようにステップバイステップで解説します。

C言語は、その高いパフォーマンスと汎用性から、さまざまなシステム開発において使用されています。

並列処理は、タスクを細分化して同時に実行することでプログラムの処理速度を向上させる手法です。

これらの知識を組み合わせることで、より効率的なプログラミングが可能となります。

●C言語とは

C言語は、1970年代に開発された汎用プログラミング言語で、その直感的な構文と強力な機能性から、OS開発や組み込みシステム開発などに幅広く使用されています。

また、ポインタを活用することでメモリを直接操作することが可能であり、これがC言語のパフォーマンスを高める一因となっています。

●並列処理とは

一方、並列処理とは、複数のタスクを同時に実行することで処理速度を向上させる手法を指します。

マルチコアやマルチプロセッサのCPUを活用し、複数のスレッドやプロセスを同時に動作させることで、タスクの完了時間を大幅に短縮することが可能です。

●C言語における並列処理の基本

C言語における並列処理の基本とは、”スレッド”の作成と管理です。

スレッドとは、プログラム内で実行される最小の処理単位を指し、各スレッドは独立して動作することが可能です。

○スレッドとは

具体的には、スレッドはプログラム内の命令列を実行するための独立した実行経路を持ち、それぞれが独自のレジスタとスタックを保有しています。

一方で、ヒープ領域や静的領域、コード領域などは他のスレッドと共有されます。

○C言語におけるスレッドの作成

C言語では、POSIXスレッド(pthread)というライブラリを使用してスレッドを作成できます。

pthread_create関数を使用することで新しいスレッドを作成し、そのスレッドで実行する関数を指定できます。

○サンプルコード1:基本的なスレッドの作成

#include <stdio.h>
#include <pthread.h>

void *thread_function(void *arg) {
    printf("Hello, Thread!\n");
    return NULL;
}

int main() {
    pthread_t thread_id;

    if(pthread_create(&thread_id, NULL, thread_function, NULL) != 0) {
        printf("Failed to create thread.\n");
        return 1;
    }

    pthread_join(thread_id, NULL);

    return 0;
}

このコードではpthread_create関数を使って新しいスレッドを作成し、thread_functionという関数をそのスレッドで実行しています。

この例では”Hello, Thread!”という文字列を出力しています。

このコードを実行すると、新しいスレッドが作成され、そのスレッド内で”Hello, Thread!”と出力されるため、結果は次のようになります。

Hello, Thread!

○サンプルコード2:マルチスレッドの同時実行

次に、複数のスレッドを同時に実行する例を見てみましょう。

下記のコードは、2つのスレッドを同時に作成し、それぞれで異なる関数を実行します。

#include <stdio.h>
#include <pthread.h>

void *thread_function1(void *arg) {
    printf("Hello, I am Thread 1.\n");
    return NULL;
}

void *thread_function2(void *arg) {
    printf("Hello, I am Thread 2.\n");
    return NULL;
}

int main() {
    pthread_t thread_id1, thread_id2;

    if(pthread_create(&thread_id1, NULL, thread_function1, NULL) != 0) {
        printf("Failed to create thread 1.\n");
        return 1;
    }

    if(pthread_create(&thread_id2, NULL, thread_function2, NULL) != 0) {
        printf("Failed to create thread 2.\n");
        return 1;
    }

    pthread_join(thread_id1, NULL);
    pthread_join(thread_id2, NULL);

    return 0;
}

このコードでは、pthread_create関数を2回呼び出して2つのスレッドを作成し、それぞれ異なる関数を実行しています。

この例では、それぞれが自己紹介をするコードになっています。

このコードを実行すると、2つのスレッドが同時に実行され、それぞれが異なる出力をするため、結果は次のようになります。

Hello, I am Thread 1.
Hello, I am Thread 2.

ただし、スレッドの実行順序はOSのスケジューラに依存するため、出力されるメッセージの順序は実行する度に異なる可能性があります。

●並列処理の応用例

並列処理の利点を最大限に活用するためには、実際の問題解決にどのように適用できるかを理解することが重要です。

その一例として、データの並列処理を考えてみましょう。

○サンプルコード3:データの並列処理

今回の例では、大きなデータ配列を並列に処理し、各スレッドが配列の特定の部分を取り扱う方法を示します。

#include <pthread.h>
#include <stdio.h>
#define N 1000000
#define NUM_THREADS 10

int array[N];
int sum[NUM_THREADS] = {0};

void* SumArray(void* arg) {
  int id = (int)arg;
  int start = id * (N/NUM_THREADS);
  int end = start + (N/NUM_THREADS);

  for (int i = start; i < end; i++) {
    sum[id] += array[i];
  }

  return NULL;
}

int main() {
  pthread_t threads[NUM_THREADS];

  for (int i = 0; i < N; i++) {
    array[i] = i;
  }

  for (int i = 0; i < NUM_THREADS; i++) {
    pthread_create(&threads[i], NULL, SumArray, (void*)i);
  }

  for (int i = 0; i < NUM_THREADS; i++) {
    pthread_join(threads[i], NULL);
  }

  int total_sum = 0;
  for (int i = 0; i < NUM_THREADS; i++) {
    total_sum += sum[i];
  }

  printf("Total: %d\n", total_sum);

  return 0;
}

このコードでは、まず大きなデータ配列を定義しています。

次に、それぞれのスレッドが配列の特定の部分を取り扱うために、配列のインデックスをスレッド数で分割します。

そして、各スレッドは自分の担当する配列部分の合計を計算し、その結果をsum配列に格納します。

最後に、sum配列のすべての要素を合計して、全体の合計を計算します。

このように、大きなデータを小さな部分に分割し、それぞれを並列に処理することで、計算のパフォーマンスを大幅に向上させることが可能となります。

○サンプルコード4:スレッド間のデータ共有

下記のコードは、複数のスレッドが同じメモリを共有する例です。

#include <pthread.h>
#include <stdio.h>

int shared_data = 0;

void* UpdateData(void* arg) {
  for (int i = 0; i < 10000; i++) {
    shared_data++;
  }
  return NULL;
}

int main() {
  pthread_t thread1, thread2;

  pthread_create(&thread1, NULL, UpdateData, NULL);
  pthread_create(&thread2, NULL, UpdateData, NULL);

  pthread_join(thread1, NULL);
  pthread_join(thread2, NULL);

  printf("Updated shared data: %d\n", shared_data);

  return 0;
}

このコードでは、shared_dataという共有変数を2つのスレッドで更新しています。

この共有変数は、並列実行される各スレッドからアクセス可能です。

ただし、複数のスレッドが同時に同じメモリにアクセスしようとすると、予期しない結果が生じる可能性があるため、注意が必要です。

この問題については、次のセクションで詳しく説明します。

●並列処理の注意点と対処法

並列処理を行う上で注意すべき事項とその解決策を解説します。

その中でも特に重要なのが「データの競合」です。

○データの競合とは

データの競合は複数のスレッドが同一のデータを同時にアクセスしようとすると発生する問題です。

この問題が発生すると、予期せぬ結果を生み出す可能性があります。

例えば、1つのスレッドがある変数にデータを書き込んでいる最中に、別のスレッドが同じ変数を読み取ったり書き換えたりすると、データの整合性が失われ、バグの原因になります。

下記のサンプルコードは、データ競合の一例を示します。

#include <pthread.h>
#include <stdio.h>

// データ競合を起こす可能性のある変数
int count = 0;

// スレッドが実行する関数
void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        count++;
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    // 2つのスレッドを作成して関数を実行
    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);

    // 2つのスレッドが終了するのを待つ
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Count: %d\n", count);
    return 0;
}

このコードでは、二つのスレッドが共有の変数countをそれぞれ100万回インクリメントします。

理想的には、最終的なcountの値は200万になるはずです。

しかし、データ競合が発生すると、期待する結果と異なる出力が得られます。

○サンプルコード5:データ競合の発生と解決法

データ競合を防ぐための一般的な解決法は「ロック」を使用することです。

ロックは、あるスレッドがデータにアクセスしている間、他のスレッドがそのデータに触れないようにします。

これにより、一度に1つのスレッドのみがデータを変更でき、データの競合を防ぎます。

下記のコードは、前述のコードにpthreadのロック(mutex)を追加したものです。

#include <pthread.h>
#include <stdio.h>

int count = 0;
pthread_mutex_t mutex;

void* increment(void* arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&mutex);
        count++;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_mutex_init(&mutex, NULL);

    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    pthread_mutex_destroy(&mutex);

    printf("Count: %d\n", count);
    return 0;
}

このコードでは、スレッドがcountをインクリメントする前にpthread_mutex_lock関数を呼び出し、ロックを取得します。

これにより、そのスレッドだけがcountにアクセスでき、他のスレッドは待たされます。

そして、インクリメントが終わったらpthread_mutex_unlock関数を呼び出してロックを解放し、他のスレッドがcountにアクセスできるようにします。

この修正により、最終的なcountの値は期待通りの200万となります。

並列処理を行う際には、このようなデータ競合を十分に理解し、適切なロックの設定を行うことが重要です。

●並列処理のカスタマイズ方法

C言語における並列処理のカスタマイズ方法は多種多様で、その中でも2つの主要な要素について掘り下げてみましょう。

それが「プロセスのカスタマイズ」と「スレッドの優先順位の設定」です。

これらによってプログラム全体の動作をより具体的に制御することが可能となります。

○サンプルコード6:プロセスのカスタマイズ

それではまず、プロセスのカスタマイズについて見ていきましょう。

このコードではfork()関数を使って新しいプロセスを生成し、その後のプロセスの動作をカスタマイズします。

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

void main() {
    pid_t pid = fork();

    if (pid == 0) {
        printf("これは子プロセスです\n");
    } else if (pid > 0) {
        printf("これは親プロセスです\n");
    } else {
        printf("プロセスの生成に失敗しました\n");
    }
}

この例ではfork()関数を使って新しいプロセスを生成しています。

生成したプロセスは親プロセスと子プロセスの2つに分岐し、それぞれのプロセスで異なる動作を行うことができます。

このようにプロセスを生成し、それぞれのプロセスで異なる動作を行うことで、一つのプログラム内で複数の作業を並列に実行することができます。

○サンプルコード7:スレッドの優先順位の設定

次にスレッドの優先順位の設定について説明します。

このコードではpthreadライブラリを使ってスレッドを生成し、スレッドの優先順位を設定しています。

#include <stdio.h>
#include <pthread.h>

void* task(void* arg) {
    int i;
    for (i = 0; i < 5; i++) {
        printf("スレッドID:%lu, 優先度:%d\n", pthread_self(), *((int*)arg));
    }
    return NULL;
}

void main() {
    pthread_t thread1, thread2;
    int priority1 = 1, priority2 = 2;

    pthread_create(&thread1, NULL, task, &priority1);
    pthread_create(&thread2, NULL, task, &priority2);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
}

この例ではpthread_create関数を用いて2つのスレッドを生成し、それぞれに異なる優先順位を与えています。

優先順位は高いほどCPUの使用権を優先的に得ることができ、プログラムの実行速度を制御することが可能となります。

●C言語での並列処理の最適化

並列処理の最適化とは、処理速度を向上させるための手法のことです。

その具体的な方法は、タスクの分割方法やスレッドの数などを調整することにより、処理を効率化します。

特にC言語では、マルチスレッドの活用が一つの解となります。

そのため、ここではC言語での並列処理の最適化について具体的なサンプルコードを交えて説明します。

○サンプルコード8:パフォーマンスの最適化

下記のコードは、4つのスレッドを作成し、各スレッドが異なるタスクを同時に実行するという並列処理を行っています。

この例では、それぞれのスレッドが数値の加算処理を担当しています。

#include <pthread.h>
#include <stdio.h>

#define NUM_THREADS 4
#define TASK_SIZE 25000

void *perform_work(void *argument)
{
  int passed_in_value;

  passed_in_value = *((int *)argument);
  printf("Hello World! It's me, thread with argument %d!\n", passed_in_value);

  int i, sum = 0;
  for(i = 0; i <= TASK_SIZE; i++) {
    sum += i;
  }
  printf("Sum for thread %d is %d\n", passed_in_value, sum);

  pthread_exit(0);
}

int main(int argc, char **argv)
{
  pthread_t threads[NUM_THREADS];
  int thread_args[NUM_THREADS];
  int result_code, index;

  for (index = 0; index < NUM_THREADS; ++index) {
    thread_args[index] = index;
    printf("In main: creating thread %d\n", index);
    result_code = pthread_create(&threads[index], NULL, perform_work, (void *)&thread_args[index]);
  }

  for (index = 0; index < NUM_THREADS; ++index) {
    result_code = pthread_join(threads[index], NULL);
  }

  return 0;
}

このコードでは、四つのスレッドが同時に起動し、各々が異なる数値の加算処理を行います。

この結果、各スレッドが加算処理を完了した後には、それぞれが計算した合計値が出力されます。

しかし、これは単純な例であり、実際の問題では、どのようにタスクを分割し、スレッドに割り当てるかが、並列処理の最適化の鍵となります。

最適な並列処理の設計には、次のような要素が考慮されます。

①タスクの独立性

並列処理では、各スレッドが処理するタスクが独立していることが重要です。

依存関係があると、スレッドの一部が他のスレッドの完了を待つ必要があり、それによりパフォーマンスが低下します。

②ワークロードの均等性

各スレッドに割り当てるタスクの量が均等であることも重要です。

処理時間が大きく異なるタスクをスレッドに割り当てると、一部のスレッドが他のスレッドを待つ時間が生じ、それにより全体の処理速度が低下します。

このように並列処理を最適化することで、アプリケーションのパフォーマンスは大幅に向上します。

しかし、その一方で、データの競合やリソースの管理といった問題も発生する可能性があるため、注意が必要です。

まとめ

C言語と並列処理について解説しました。

この記事を通じて、C言語と並列処理の基本的な知識を身につけることができたでしょう。

また、具体的なサンプルコードを交えた説明を通じて、理論だけでなく、実践的なスキルも磨くことができたことでしょう。

並列処理は、大規模な計算タスクを効率的に処理するための強力な手段ですが、その適用は容易ではありません。

しかし、C言語のような低水準言語を使えば、コンピュータのリソースを最大限に活用し、効率的な並列処理を実現することが可能です。

継続的な学習と実践を通じて、より深い理解とスキルを獲得してください。