読み込み中...

【C++】マルチスレッド入門!10のサンプルコードで徹底解説

C++でのマルチスレッドプログラミングを解説するイメージ C++
この記事は約22分で読めます。

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

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

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

本記事のサンプルコードを活用して機能追加、目的を達成できるように作ってありますので、是非ご活用ください。

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

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

はじめに

C++でのマルチスレッドプログラミングは、現代のソフトウェア開発において非常に重要な要素です。

この記事を読むことで、マルチスレッドプログラミングの基本から応用までを理解し、C++言語を使用して効率的なプログラムを作成する方法を学ぶことができます。

マルチスレッドプログラミングは、プログラムのパフォーマンスを向上させ、リソースを最適に利用するための鍵となります。

初心者から上級者まで、本記事を通じてC++におけるマルチスレッドプログラミングの世界への理解を深めていただきたいと思います。

●C++とマルチスレッドプログラミングの基本

C++は、そのパワフルな機能と効率性で広く使用されているプログラミング言語です。

特に、マルチスレッドプログラミングにおいては、C++の豊富なライブラリと機能が大きな強みとなります。

マルチスレッドプログラミングを用いることで、複数の処理を同時に実行させることが可能となり、プログラムの効率と応答性が大幅に向上します。

C++11からは、標準ライブラリにスレッド関連の機能が組み込まれ、よりアクセスしやすくなりました。

これにより、C++を使ったマルチスレッドプログラミングが以前よりも容易に実現できるようになりました。

○C++におけるマルチスレッドの概要

C++におけるマルチスレッドプログラミングは、スレッドの作成、管理、同期など、多くの側面を含みます。

スレッドはプログラム内で独立した実行パスを持つもので、プログラムの主要な実行単位です。

C++では、std::threadライブラリを使用してスレッドを作成し、複数のタスクを並行して実行させることができます。

これにより、CPUリソースを効率的に活用し、プログラムのパフォーマンスを最適化することが可能になります。

○マルチスレッドプログラミングの利点と課題

マルチスレッドプログラミングは多くの利点を提供しますが、同時にいくつかの課題も存在します。

最大の利点は、プログラムのパフォーマンスと応答性の向上です。

複数のスレッドを使用することで、CPUの複数のコアを活用し、タスクを並行して実行させることが可能になります。

これにより、プログラムはより迅速に動作し、ユーザーにとって快適な経験を提供することができます。

しかしながら、マルチスレッドプログラミングは複雑さも増します。

スレッド間でのデータ共有や同期の問題、競合状態やデッドロックのリスクなど、様々な課題に直面することがあります。

これらの課題に対処するためには、適切な同期メカニズムの理解と使用が重要となります。

●C++におけるスレッドの作成と管理

C++におけるスレッドの作成と管理は、マルチスレッドプログラミングの核となる部分です。

C++11以降、標準ライブラリにはstd::threadクラスが導入されました。

これにより、スレッドをより簡単に作成し、制御することが可能になりました。

スレッドを効果的に管理することは、プログラムのパフォーマンスを最大限に引き出し、リソースの効率的な使用を実現するために不可欠です。

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

C++でスレッドを作成する基本的な方法は、std::threadクラスを使用することです。

#include <iostream>
#include <thread>

void helloFunction() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t(helloFunction);
    t.join();
    return 0;
}

このコードでは、helloFunctionという関数を新しいスレッドで実行しています。

std::threadクラスのコンストラクタにこの関数を渡すことで、スレッドが作成されます。

t.join()は、メインスレッドが新しいスレッドの終了を待つために使用されます。

これにより、プログラムがスレッドの完了前に終了するのを防ぎます。

○サンプルコード2:スレッドの終了と結合

スレッドの終了と結合は、マルチスレッドプログラムにおいて重要な概念です。

スレッドの終了を適切に管理することで、リソースのリークや予期せぬ動作を防ぐことができます。

#include <iostream>
#include <thread>
#include <vector>

void worker(int id) {
    std::cout << "Worker " << id << " is running" << std::endl;
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 5; ++i) {
        threads.push_back(std::thread(worker, i));
    }

    for (auto& t : threads) {
        t.join();
    }
    return 0;
}

この例では、5つのスレッドを作成し、それぞれに異なる作業を割り当てています。

各スレッドはworker関数を実行し、終了後にメインスレッドによって結合されます。

join()関数は、各スレッドが完了するまでメインスレッドの実行をブロックします。

これにより、すべてのスレッドが適切に終了することが保証されます。

●マルチスレッドの同期とデータ共有

マルチスレッドプログラミングでは、異なるスレッド間でデータを共有する際に発生する問題を適切に管理する必要があります。

スレッド間の同期を行うことで、データ競合やレースコンディションを防ぎ、プログラムの正確性と信頼性を保証します。

C++においては、std::mutexや条件変数などの同期メカニズムが提供されており、これらを利用することでスレッド間の安全なデータ共有が可能となります。

○サンプルコード3:mutexを使用したデータ保護

std::mutexは、スレッド間でのデータアクセスを排他制御するために使用されます。

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

int sharedData = 0;
std::mutex mtx;

void incrementSharedData() {
    mtx.lock();
    ++sharedData;
    std::cout << "Data incremented to " << sharedData << std::endl;
    mtx.unlock();
}

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

    t1.join();
    t2.join();
    return 0;
}

このコードでは、sharedDataという共有データに対して、2つのスレッドから同時にアクセスが行われます。

std::mutexを使ってデータへのアクセスを制御し、同時に2つのスレッドがデータを変更することを防いでいます。

これにより、データ競合を避けることができます。

○サンプルコード4:条件変数を用いたスレッド同期

条件変数は、特定の条件が満たされるまでスレッドの実行を待機させるために使用されます。

#include <iostream>
#include <mutex>
#include <condition_variable>
#include <thread>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void worker() {
    std::unique_lock<std::mutex> lock(mtx);
    while (!ready) {
        cv.wait(lock);
    }
    std::cout << "Worker thread is processing data" << std::endl;
}

int main() {
    std::thread t(worker);

    {
        std::lock_guard<std::mutex> lock(mtx);
        ready = true;
    }
    cv.notify_one();

    t.join();
    return 0;
}

このコードでは、workerスレッドがready変数がtrueになるまで待機します。

メインスレッドでは、readytrueに設定し、条件変数を通じてworkerスレッドに通知します。

これにより、workerスレッドは条件が満たされたときにのみ実行を再開します。

条件変数を使用することで、スレッド間の同期を効率的に行うことができます。

●マルチスレッドプログラミングの応用

マルチスレッドプログラミングの応用は、その可能性を最大限に引き出すことで、より高度なプログラミング技術を実現します。

C++においては、スレッドプールの実装や非同期タスクの処理など、様々な応用技術が存在します。

これらを用いることで、リソースの効率的な利用やプログラムのレスポンスの向上が期待できます。

○サンプルコード5:スレッドプールの実装

スレッドプールは、事前にスレッドのセットを作成し、必要に応じてそれらを再利用する技術です。

これにより、スレッドの作成と破棄に伴うオーバーヘッドを削減し、リソースの効率的な管理が可能になります。

#include <iostream>
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>

class ThreadPool {
private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queueMutex;
    std::condition_variable condition;
    bool stop;

public:
    ThreadPool(size_t threads) : stop(false) {
        for(size_t i = 0; i < threads; ++i)
            workers.emplace_back([this] {
                while(true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(this->queueMutex);
                        this->condition.wait(lock, [this]{ return this->stop || !this->tasks.empty(); });
                        if(this->stop && this->tasks.empty())
                            return;
                        task = std::move(this->tasks.front());
                        this->tasks.pop();
                    }
                    task();
                }
            });
    }

    template<class F, class... Args>
    auto enqueue(F&& f, Args&&... args) 
        -> std::future<typename std::result_of<F(Args...)>::type> {
        using return_type = typename std::result_of<F(Args...)>::type;
        auto task = std::make_shared< std::packaged_task<return_type()> >(
            std::bind(std::forward<F>(f), std::forward<Args>(args)...)
        );
        std::future<return_type> res = task->get_future();
        {
            std::unique_lock<std::mutex> lock(queueMutex);
            if(stop)
                throw std::runtime_error("enqueue on stopped ThreadPool");
            tasks.emplace([task](){ (*task)(); });
        }
        condition.notify_one();
        return res;
    }

    ~ThreadPool() {
        {
            std::unique_lock<std::mutex> lock(queueMutex);
            stop = true;
        }
        condition.notify_all();
        for(std::thread &worker: workers)
            worker.join();
    }
};

int main() {
    ThreadPool pool(4);
    pool.enqueue([] {
        std::cout << "Hello from thread!" << std::endl;
    });
    return 0;
}

このコードでは、スレッドプールを作成し、タスクをキューに追加することで、スレッドが利用可能になると自動的にそのタスクを実行します。

スレッドプールを使用することで、スレッドの生成と破棄のコストを抑えるとともに、リソースの管理を効率化できます。

○サンプルコード6:非同期タスクの処理

非同期タスクの処理は、スレッドを使ってバックグラウンドでタスクを実行する技術です。

C++11以降ではstd::asyncを用いて非同期処理を簡単に行うことができます。

#include <iostream>
#include <future>

int main() {
    std::future<void> ftr = std::async([]{
        std::cout << "Hello from async task!" << std::endl;
    });
    ftr.wait();
    return 0;
}

このコードでは、std::asyncを使用して非同期タスクを生成し、その完了を待っています。

std::asyncは、指定された関数を別スレッドで実行し、その結果をstd::futureオブジェクトで返します。

非同期処理を利用することで、プログラムの応答性を向上させることができます。

●マルチスレッドプログラミングのエラー処理とデバッグ

マルチスレッドプログラミングにおいて、エラー処理とデバッグは特に重要です。

複数のスレッドが同時に実行されるため、デバッグが複雑になることがあります。

また、スレッド間の不適切なデータアクセスや同期の問題によって予期せぬエラーが発生することもあります。

これらの問題に効果的に対処するためには、適切なエラー処理機構とデバッグ技術が必要です。

○サンプルコード7:スレッドの例外処理

マルチスレッド環境における例外処理は、スレッドごとに例外を捕捉し適切に処理することが重要です。

#include <iostream>
#include <thread>
#include <stdexcept>

void threadFunction() {
    try {
        // 何かの処理を行う
        throw std::runtime_error("Error in thread");
    } catch (const std::exception& e) {
        std::cerr << "Exception caught in thread: " << e.what() << std::endl;
        // 適切なエラー処理を行う
    }
}

int main() {
    std::thread t(threadFunction);
    t.join();
    return 0;
}

このコードでは、スレッド内で例外が発生した場合、そのスレッド内で例外を捕捉し、適切に処理しています。

このように各スレッドで例外を適切に処理することで、プログラム全体の安定性を保つことができます。

○サンプルコード8:デバッグとパフォーマンスチューニング

マルチスレッドプログラムのデバッグとパフォーマンスチューニングは、プログラムの効率性と信頼性を確保するために重要です。

デバッグでは、スレッド間の相互作用やタイミングの問題に注目する必要があります。

また、パフォーマンスチューニングでは、スレッドの過剰な使用を避け、リソースの効率的な利用を目指す必要があります。

デバッグツールやプロファイラを使用して、スレッドの動作を監視し、パフォーマンスのボトルネックを特定することが効果的です。

#include <iostream>
#include <thread>
#include <chrono>

void heavyTask() {
    // 時間のかかる処理を模擬
    std::this_thread::sleep_for(std::chrono::seconds(1));
}

int main() {
    auto start = std::chrono::high_resolution_clock::now();

    std::thread t1(heavyTask);
    std::thread t2(heavyTask);

    t1.join();
    t2.join();

    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double, std::milli> elapsed = end - start;
    std::cout << "Elapsed time: " << elapsed.count() << " ms\n";

    return 0;
}

このコードでは、2つのスレッドが重いタスクを実行し、その処理にかかった時間を計測しています。

パフォーマンス測定を行うことで、プログラムの最適化点を見つけることができます。

●マルチスレッドプログラミングの高度なテクニック

マルチスレッドプログラミングにおける高度なテクニックは、プログラムの効率性とスケーラビリティを大幅に向上させることができます。

特に、アトミック操作とメモリモデル、ロックフリー処理、並行アルゴリズムの実装は、マルチスレッドプログラムの性能を最大限に引き出すために重要です。

これらのテクニックを適切に使用することで、データの整合性を保ちつつ、スレッドの競合を最小限に抑えることができます。

○サンプルコード9:アトミック操作とメモリモデル

アトミック操作は、複数のスレッドによるデータアクセスを安全に行うための重要な手法です。

#include <iostream>
#include <atomic>
#include <thread>

std::atomic<int> count(0);

void increment() {
    for (int i = 0; i < 100; ++i) {
        count++;
    }
}

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

    t1.join();
    t2.join();

    std::cout << "Count: " << count << std::endl;
    return 0;
}

このコードでは、std::atomicを使用して、複数のスレッドから安全に共有変数countをインクリメントしています。

アトミック操作を使用することで、スレッド間でのデータ競合を防ぎ、データの整合性を保つことができます。

○サンプルコード10:ロックフリー処理と並行アルゴリズム

ロックフリー処理は、パフォーマンスの向上を目指して、ロックを使用せずにスレッド間の競合を管理する高度なテクニックです。

#include <iostream>
#include <atomic>
#include <thread>

std::atomic<bool> ready(false);
std::atomic<int> data(0);

void producer() {
    data.store(100, std::memory_order_relaxed);
    ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
        // busy-wait
    }
    std::cout << "Consumed data: " << data.load(std::memory_order_relaxed) << std::endl;
}

int main() {
    std::thread producerThread(producer);
    std::thread consumerThread(consumer);

    producerThread.join();
    consumerThread.join();
    return 0;
}

このコードでは、プロデューサスレッドがデータを生成し、コンシューマスレッドがそのデータを消費します。

std::atomicとメモリオーダを使用することで、ロックを使用せずにデータの整合性を保つことができます。

ロックフリー処理は、デッドロックのリスクを低減し、システムのスループットを向上させることができます。

●マルチスレッドプログラミングの注意点とベストプラクティス

マルチスレッドプログラミングにおいては、多くの注意点があります。

特に重要なのは、スレッドセーフな設計を心がけることと、パフォーマンスと安全性のバランスを適切に取ることです。

これらのポイントを遵守することで、効率的かつ安全にマルチスレッドプログラムを実行することが可能になります。

○スレッドセーフな設計の重要性

スレッドセーフな設計は、複数のスレッドが同時に実行される環境でのデータ競合やレースコンディションを避けるために不可欠です。

スレッドセーフなコードは、各スレッドが共有リソースにアクセスする際にデータの整合性を保つように設計されています。

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

class SafeCounter {
private:
    int value;
    std::mutex mtx;

public:
    SafeCounter() : value(0) {}

    void increment() {
        std::lock_guard<std::mutex> lock(mtx);
        ++value;
    }

    int getValue() {
        std::lock_guard<std::mutex> lock(mtx);
        return value;
    }
};

int main() {
    SafeCounter counter;

    std::thread t1([&counter] { for (int i = 0; i < 100; ++i) counter.increment(); });
    std::thread t2([&counter] { for (int i = 0; i < 100; ++i) counter.increment(); });

    t1.join();
    t2.join();

    std::cout << "Final value: " << counter.getValue() << std::endl;
    return 0;
}

このコードでは、SafeCounterクラス内でmutexを使用して、共有リソースへのアクセスを安全に行っています。

このような設計により、マルチスレッド環境でもデータの整合性を保つことができます。

○マルチスレッド環境におけるパフォーマンスと安全性のバランス

マルチスレッドプログラムでは、パフォーマンスと安全性のバランスを取ることが非常に重要です。

過剰な同期はパフォーマンスの低下を招く一方で、不十分な同期はデータの整合性の問題を引き起こす可能性があります。

そのため、プログラムの要件に応じて、最適な同期レベルを選択する必要があります。

例えば、読み取り専用のデータや、スレッド間で共有されないデータに対しては、同期のコストを避けることができます。

一方で、書き込みを伴う共有データに対しては、適切なロック機構を利用することが重要です。

まとめ

この記事では、C++を用いたマルチスレッドプログラミングの基本から応用、さらには高度なテクニックまでを幅広く解説しました。

10個のサンプルコードを通じて、スレッドの作成、同期、データ共有、エラー処理、そして最適化の方法を紹介してきました。

これらの知識を活用することで、読者はマルチスレッドプログラミングの複雑さを理解し、より効率的で安全なコードを書くことができるようになります。

マルチスレッドプログラミングは、その性能とスケーラビリティの面で大きな利点を提供しますが、同時に注意深い設計と実装が求められます。