C++は、その高いパフォーマンスと柔軟性から多くのアプリケーションで広く利用されています。その中でも、マルチスレッドプログラミングは、コンピュータの複数のコアを活用し、プログラムの処理速度を向上させるために非常に重要です。しかし、マルチスレッドプログラミングには、スレッド間の同期やデッドロックの回避など、多くの課題が伴います。本記事では、C++を使用してマルチスレッドプログラミングを行う際のパフォーマンス最適化の方法について詳しく解説します。基本的な概念から実践的なテクニックまでを網羅し、効率的で効果的なプログラムの構築をサポートします。
マルチスレッドプログラミングの基礎
マルチスレッドプログラミングは、プログラムを複数のスレッドに分割して同時に実行することで、コンピュータの複数のコアを活用し、パフォーマンスを向上させる技術です。C++では、標準ライブラリに含まれる<thread>
ヘッダを使用してスレッドを作成し、管理することができます。
スレッドの作成
C++でスレッドを作成する基本的な方法は、std::thread
クラスを使用することです。以下に簡単な例を示します。
#include <iostream>
#include <thread>
void printHello() {
std::cout << "Hello from thread!" << std::endl;
}
int main() {
std::thread t(printHello); // スレッドの作成
t.join(); // スレッドの終了を待つ
return 0;
}
この例では、printHello
関数を実行する新しいスレッドを作成し、t.join()
でそのスレッドの終了を待っています。
スレッドの管理
スレッドの管理には、以下のような操作が必要です。
- スレッドの開始: 新しいスレッドを作成して実行を開始します。
- スレッドの終了: スレッドが終了するのを待ちます(
join
)。 - デタッチ: スレッドをデタッチして、独立して実行させます(
detach
)。
デタッチの例を以下に示します。
#include <iostream>
#include <thread>
void printHello() {
std::cout << "Hello from thread!" << std::endl;
}
int main() {
std::thread t(printHello); // スレッドの作成
t.detach(); // スレッドをデタッチ
// メインスレッドが終了してもtスレッドは実行を続ける
std::this_thread::sleep_for(std::chrono::seconds(1)); // メインスレッドを1秒間停止
return 0;
}
デタッチしたスレッドは、メインスレッドが終了しても実行を続けますが、終了を待つ手段がなくなるため、リソース管理には注意が必要です。
スレッド同期の重要性と手法
マルチスレッドプログラミングでは、複数のスレッドが同じデータにアクセスすることがよくあります。この場合、データの整合性を保つためにスレッド間の同期が不可欠です。適切な同期が行われていないと、データ競合や不整合が発生し、プログラムの動作が予測不可能になります。
Mutexによる同期
Mutex(ミューテックス)は、複数のスレッドが同時に共有リソースにアクセスするのを防ぐための同期手段です。C++では、std::mutex
クラスを使用してミューテックスを実装します。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void printNumber(int n) {
std::lock_guard<std::mutex> lock(mtx);
std::cout << "Thread " << n << std::endl;
}
int main() {
std::thread t1(printNumber, 1);
std::thread t2(printNumber, 2);
t1.join();
t2.join();
return 0;
}
この例では、std::lock_guard
を使用してミューテックスを自動的にロックおよびアンロックします。これにより、複数のスレッドが同時にprintNumber
関数を実行することを防ぎます。
Semaphoreによる同期
Semaphore(セマフォ)は、指定された数のスレッドのみが共有リソースにアクセスできるように制御するための同期手段です。C++17以降では、std::counting_semaphore
が利用可能です。
#include <iostream>
#include <thread>
#include <semaphore>
std::counting_semaphore<2> sem(2);
void accessResource(int n) {
sem.acquire();
std::cout << "Thread " << n << " accessing resource" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
sem.release();
}
int main() {
std::thread t1(accessResource, 1);
std::thread t2(accessResource, 2);
std::thread t3(accessResource, 3);
t1.join();
t2.join();
t3.join();
return 0;
}
この例では、std::counting_semaphore
を使用して、最大2つのスレッドが同時にリソースにアクセスできるようにしています。セマフォのacquire
メソッドでリソースを取得し、release
メソッドでリソースを解放します。
デッドロックの回避方法
デッドロックは、複数のスレッドが互いにリソースを待ち続ける状態で、プログラムが停止してしまう問題です。デッドロックを避けるためには、いくつかのベストプラクティスを守る必要があります。
デッドロックの原因
デッドロックは以下の4つの条件が同時に満たされると発生します。
- 相互排他: 一度に1つのスレッドしかリソースを使用できない。
- 保持と待機: スレッドがリソースを保持しながら、他のリソースを待つ。
- 不可奪取: リソースを強制的に奪うことができない。
- 循環待機: スレッドが循環状にリソースを待っている。
これらの条件のどれかを避けることでデッドロックを防止できます。
ロックの順序を統一する
複数のロックを取得する場合、常に同じ順序でロックを取得することがデッドロックを避ける一つの方法です。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1;
std::mutex mtx2;
void task1() {
std::lock(mtx1, mtx2); // ロックを一度に取得
std::lock_guard<std::mutex> lg1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lg2(mtx2, std::adopt_lock);
std::cout << "Task 1" << std::endl;
}
void task2() {
std::lock(mtx1, mtx2); // ロックを一度に取得
std::lock_guard<std::mutex> lg1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lg2(mtx2, std::adopt_lock);
std::cout << "Task 2" << std::endl;
}
int main() {
std::thread t1(task1);
std::thread t2(task2);
t1.join();
t2.join();
return 0;
}
この例では、std::lock
を使用して複数のロックを同時に取得し、ロックの順序が一致するようにしています。
タイムアウト付きのロックを使用する
タイムアウト付きのロックを使用することで、一定時間待機してもロックが取得できない場合に処理を中断し、デッドロックを回避することができます。
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::timed_mutex tmtx;
void task(int id) {
while (!tmtx.try_lock_for(std::chrono::milliseconds(100))) {
std::cout << "Thread " << id << " waiting for lock" << std::endl;
}
std::cout << "Thread " << id << " acquired lock" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
tmtx.unlock();
}
int main() {
std::thread t1(task, 1);
std::thread t2(task, 2);
t1.join();
t2.join();
return 0;
}
この例では、std::timed_mutex
を使用して、タイムアウト付きのロックを実装しています。スレッドが一定時間ロックを取得できない場合、再試行を行います。
スレッドプールの活用
スレッドプールは、スレッドの作成と破棄のオーバーヘッドを削減し、効率的にタスクを実行するための手法です。事前に複数のスレッドを作成しておき、必要に応じてタスクを割り当てることで、パフォーマンスを向上させることができます。
スレッドプールの基本概念
スレッドプールは、一連のワーカースレッドとタスクキューから構成されます。タスクはキューに追加され、ワーカースレッドがキューからタスクを取り出して実行します。このアプローチにより、スレッドの作成と破棄のコストを削減し、タスク実行の効率を高めます。
シンプルなスレッドプールの実装
以下に、基本的なスレッドプールの実装例を示します。
#include <iostream>
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
class ThreadPool {
public:
ThreadPool(size_t numThreads);
~ThreadPool();
void enqueue(std::function<void()> task);
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queueMutex;
std::condition_variable condition;
bool stop;
void worker();
};
ThreadPool::ThreadPool(size_t numThreads) : stop(false) {
for (size_t i = 0; i < numThreads; ++i) {
workers.emplace_back(&ThreadPool::worker, this);
}
}
ThreadPool::~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queueMutex);
stop = true;
}
condition.notify_all();
for (std::thread &worker : workers) {
worker.join();
}
}
void ThreadPool::enqueue(std::function<void()> task) {
{
std::unique_lock<std::mutex> lock(queueMutex);
tasks.push(std::move(task));
}
condition.notify_one();
}
void ThreadPool::worker() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queueMutex);
condition.wait(lock, [this] { return stop || !tasks.empty(); });
if (stop && tasks.empty())
return;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
}
int main() {
ThreadPool pool(4);
for (int i = 0; i < 8; ++i) {
pool.enqueue([i] {
std::cout << "Task " << i << " is executing" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
});
}
std::this_thread::sleep_for(std::chrono::seconds(5));
return 0;
}
この例では、スレッドプールを作成し、タスクをキューに追加することで、複数のタスクを効率的に並行実行しています。ThreadPool
クラスは、スレッドの管理、タスクのキューイング、およびタスクの実行を行います。
スレッドプールの利点
- 効率の向上: スレッドの作成と破棄のオーバーヘッドが減少します。
- リソースの最適化: スレッド数を適切に管理することで、システムリソースの使用を最適化します。
- 柔軟性: タスクを簡単に追加・削除できるため、動的なタスク管理が可能です。
並列アルゴリズムの活用
C++17以降、標準ライブラリには並列アルゴリズムが導入され、マルチスレッド環境で効率的にタスクを分散処理するための強力な手段が提供されています。これにより、開発者は容易に並列処理を利用し、プログラムのパフォーマンスを向上させることができます。
並列アルゴリズムの基本概念
並列アルゴリズムは、複数のスレッドを使用して大規模なデータセットを並列に処理することを目的としています。C++の標準ライブラリでは、std::execution
名前空間を通じて並列実行ポリシーが提供されています。これにより、従来のシーケンシャルアルゴリズムを簡単に並列化できます。
並列アルゴリズムの使用例
以下に、std::for_each
を使用した並列アルゴリズムの例を示します。
#include <iostream>
#include <vector>
#include <algorithm>
#include <execution>
void printSquare(int n) {
std::cout << n * n << " ";
}
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::for_each(std::execution::par, numbers.begin(), numbers.end(), printSquare);
std::cout << std::endl;
return 0;
}
この例では、std::execution::par
ポリシーを指定することで、std::for_each
が並列に実行され、各要素の平方が計算されて出力されます。並列実行ポリシーには以下の3つが提供されています:
std::execution::seq
(シーケンシャル実行)std::execution::par
(並列実行)std::execution::par_unseq
(並列かつ非順序実行)
並列ソートの例
並列ソートアルゴリズムを使用すると、大規模なデータセットのソートを効率的に行うことができます。
#include <iostream>
#include <vector>
#include <algorithm>
#include <execution>
#include <random>
int main() {
std::vector<int> numbers(1000000);
std::mt19937 gen(42);
std::uniform_int_distribution<> dis(1, 1000000);
for (auto& n : numbers) {
n = dis(gen);
}
std::sort(std::execution::par, numbers.begin(), numbers.end());
for (size_t i = 0; i < 10; ++i) {
std::cout << numbers[i] << " ";
}
std::cout << std::endl;
return 0;
}
この例では、std::execution::par
ポリシーを使用して、100万個のランダムな整数を並列にソートしています。これにより、シーケンシャルソートに比べて大幅なパフォーマンス向上が期待できます。
並列アルゴリズムの利点
- パフォーマンスの向上: 大規模データの処理が高速化されます。
- 簡潔なコード: 明示的なスレッド管理が不要で、シンプルなコードで並列処理を実現できます。
- 柔軟性: 様々な並列実行ポリシーを選択でき、用途に応じた最適な実行方法を選べます。
メモリ管理とキャッシュの最適化
マルチスレッドプログラミングにおいて、効率的なメモリ管理とキャッシュの最適化は、パフォーマンスを大幅に向上させる重要な要素です。適切なメモリ管理を行うことで、スレッド間のデータ競合やキャッシュミスを減少させることができます。
メモリ管理の基本
マルチスレッドプログラミングでは、スレッド間で共有されるデータの管理が重要です。共有メモリへのアクセスを最小限にし、各スレッドが独立して作業できるようにすることで、データ競合を減らすことができます。
スレッドローカルストレージ
スレッドローカルストレージ(Thread Local Storage, TLS)は、各スレッドが独自の変数を持つことができるメカニズムです。C++では、thread_local
キーワードを使用してTLS変数を定義します。
#include <iostream>
#include <thread>
thread_local int threadLocalVar = 0;
void increment(int id) {
threadLocalVar++;
std::cout << "Thread " << id << ": " << threadLocalVar << std::endl;
}
int main() {
std::thread t1(increment, 1);
std::thread t2(increment, 2);
t1.join();
t2.join();
return 0;
}
この例では、各スレッドが独自のthreadLocalVar
を持ち、それぞれが独立して変数を操作します。
キャッシュの最適化
CPUキャッシュの効率的な利用は、メモリアクセス速度に大きな影響を与えます。キャッシュの最適化には、データの局所性を高めることが重要です。
データの局所性
データの局所性を高めるためには、関連するデータをメモリ上で近くに配置することが有効です。これにより、キャッシュミスを減らし、メモリアクセスの速度を向上させることができます。
False Sharingの回避
False Sharingは、異なるスレッドが同じキャッシュラインを共有することで発生するパフォーマンス低下の原因です。これを回避するためには、パディングを利用してキャッシュラインの共有を防ぎます。
#include <iostream>
#include <thread>
#include <vector>
struct alignas(64) PaddedData {
int value;
char padding[64 - sizeof(int)];
};
PaddedData data[2];
void increment(int index) {
for (int i = 0; i < 1000000; ++i) {
data[index].value++;
}
}
int main() {
std::thread t1(increment, 0);
std::thread t2(increment, 1);
t1.join();
t2.join();
std::cout << "Data[0]: " << data[0].value << std::endl;
std::cout << "Data[1]: " << data[1].value << std::endl;
return 0;
}
この例では、PaddedData
構造体を使用して、各スレッドが異なるキャッシュラインを使用するようにしています。これにより、False Sharingを防ぎ、パフォーマンスを向上させます。
メモリプールの活用
メモリプールを使用することで、動的メモリアロケーションのオーバーヘッドを削減し、メモリ管理を効率化できます。メモリプールは、頻繁に使用されるオブジェクトを事前に確保し、再利用するための手法です。
プロファイリングとパフォーマンス測定
マルチスレッドプログラミングにおいて、パフォーマンスを最適化するためには、ボトルネックを特定し、適切な対策を講じることが重要です。これを実現するために、プロファイリングとパフォーマンス測定ツールを活用します。
プロファイリングの基本
プロファイリングは、プログラムの実行時の動作を分析し、パフォーマンスのボトルネックを特定するための手法です。プロファイラを使用することで、CPU使用率、メモリ使用量、スレッドの競合状況などを詳細に分析できます。
プロファイリングツールの紹介
いくつかのプロファイリングツールが存在し、それぞれ異なる特性と利点を持っています。以下に、代表的なツールを紹介します。
Visual Studio Profiler
Visual Studioには、統合されたプロファイリングツールが含まれており、CPU使用率、メモリ使用量、スレッドのパフォーマンスを詳細に分析できます。
#include <iostream>
#include <thread>
#include <vector>
void task(int n) {
std::this_thread::sleep_for(std::chrono::milliseconds(n));
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(task, i * 100);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
このコードをVisual Studioで実行し、プロファイリングを行うことで、各スレッドの実行時間やリソースの使用状況を可視化できます。
Valgrind
Valgrindは、メモリリークやキャッシュミスの検出に優れたプロファイリングツールです。特にLinux環境で広く使用されています。
valgrind --tool=callgrind ./my_program
このコマンドを使用することで、my_program
の実行時の詳細なプロファイル情報を取得できます。
Intel VTune Profiler
Intel VTune Profilerは、高度なパフォーマンス解析ツールで、マルチスレッドプログラムの詳細なパフォーマンスデータを提供します。特にIntelプロセッサに最適化されたプログラムのプロファイリングに有効です。
パフォーマンス測定の方法
プロファイリング結果を元に、以下の手法を用いてパフォーマンスを測定し、最適化します。
CPU使用率の測定
CPU使用率の測定により、各スレッドのCPUリソース消費状況を把握します。高いCPU使用率が長時間続く場合、その部分がボトルネックである可能性があります。
スレッドの競合状態の分析
スレッド間の競合状態を分析することで、ロックの頻度や待ち時間を測定し、同期処理の最適化を図ります。
メモリ使用量の測定
メモリ使用量を測定し、メモリリークや不要なメモリアロケーションを特定します。これにより、メモリ管理の最適化が可能になります。
実践例: マルチスレッドによるソートアルゴリズム
ここでは、実際にマルチスレッドを使用してソートアルゴリズムを実装する方法を紹介します。並列処理を活用することで、大規模なデータセットのソート時間を大幅に短縮できます。
マルチスレッドによるクイックソートの実装
クイックソートは分割統治法に基づいたソートアルゴリズムで、並列処理との相性が良いです。以下に、マルチスレッドを使用したクイックソートの実装例を示します。
#include <iostream>
#include <vector>
#include <thread>
#include <algorithm>
#include <future>
// クイックソートのパーティション分割
int partition(std::vector<int>& arr, int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; ++j) {
if (arr[j] < pivot) {
++i;
std::swap(arr[i], arr[j]);
}
}
std::swap(arr[i + 1], arr[high]);
return i + 1;
}
// 並列クイックソート
void parallelQuickSort(std::vector<int>& arr, int low, int high, int depth = 0) {
if (low < high) {
int pi = partition(arr, low, high);
if (depth < 4) { // 再帰の深さを制限
auto leftFuture = std::async(std::launch::async, parallelQuickSort, std::ref(arr), low, pi - 1, depth + 1);
auto rightFuture = std::async(std::launch::async, parallelQuickSort, std::ref(arr), pi + 1, high, depth + 1);
leftFuture.get();
rightFuture.get();
} else {
parallelQuickSort(arr, low, pi - 1, depth + 1);
parallelQuickSort(arr, pi + 1, high, depth + 1);
}
}
}
int main() {
std::vector<int> arr = {10, 7, 8, 9, 1, 5};
int n = arr.size();
parallelQuickSort(arr, 0, n - 1);
std::cout << "Sorted array: ";
for (int num : arr) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
この例では、std::async
を使用して並列にクイックソートを実行しています。再帰の深さを制限することで、スレッドの過剰生成を防ぎ、効率的な並列ソートを実現しています。
パフォーマンスの測定と比較
シーケンシャルソートとマルチスレッドソートのパフォーマンスを比較するため、ソート時間を測定します。
#include <iostream>
#include <vector>
#include <algorithm>
#include <chrono>
void sequentialQuickSort(std::vector<int>& arr, int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
sequentialQuickSort(arr, low, pi - 1);
sequentialQuickSort(arr, pi + 1, high);
}
}
int main() {
std::vector<int> arr(100000);
std::generate(arr.begin(), arr.end(), std::rand);
// シーケンシャルソートの測定
auto start = std::chrono::high_resolution_clock::now();
sequentialQuickSort(arr, 0, arr.size() - 1);
auto end = std::chrono::high_resolution_clock::now();
std::chrono::duration<double> duration = end - start;
std::cout << "Sequential QuickSort: " << duration.count() << " seconds" << std::endl;
// マルチスレッドソートの測定
std::generate(arr.begin(), arr.end(), std::rand);
start = std::chrono::high_resolution_clock::now();
parallelQuickSort(arr, 0, arr.size() - 1);
end = std::chrono::high_resolution_clock::now();
duration = end - start;
std::cout << "Parallel QuickSort: " << duration.count() << " seconds" << std::endl;
return 0;
}
このコードでは、シーケンシャルクイックソートと並列クイックソートの実行時間を比較し、それぞれのパフォーマンスを測定しています。並列処理によるパフォーマンス向上を実際に確認できます。
ベストプラクティスとアンチパターン
マルチスレッドプログラミングを成功させるためには、効果的なベストプラクティスに従い、よくあるアンチパターンを避けることが重要です。ここでは、効率的なマルチスレッドプログラムを構築するためのベストプラクティスと、避けるべきアンチパターンについて説明します。
ベストプラクティス
最小限の共有データ
スレッド間で共有するデータを最小限に抑えることで、データ競合のリスクを減らし、ロックの必要性を最小化できます。可能な限り、各スレッドが独立して動作できるように設計しましょう。
#include <iostream>
#include <thread>
#include <vector>
void independentTask(int id) {
// 各スレッドが独自のデータを処理
std::cout << "Thread " << id << " is processing its own data" << std::endl;
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back(independentTask, i);
}
for (auto& t : threads) {
t.join();
}
return 0;
}
適切なロックの使用
必要な場合にのみロックを使用し、ロックの粒度を適切に設定することで、パフォーマンスを向上させます。長時間のロックは避け、短時間で済むように設計します。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void safeIncrement(int& counter) {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
int main() {
int counter = 0;
std::thread t1(safeIncrement, std::ref(counter));
std::thread t2(safeIncrement, std::ref(counter));
t1.join();
t2.join();
std::cout << "Counter: " << counter << std::endl;
return 0;
}
スレッドの適切な管理
スレッドの作成と破棄にはコストが伴うため、スレッドプールを活用することで、リソースの効率的な利用が可能になります。また、スレッドのデタッチやジョインを適切に管理し、リソースリークを防ぎましょう。
アンチパターン
過度なロック
過度にロックを使用することは、パフォーマンス低下の原因となります。不要なロックや、長時間ロックを保持することは避けるべきです。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void excessiveLockingTask() {
std::lock_guard<std::mutex> lock(mtx);
// 長時間ロックを保持する処理
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Task completed" << std::endl;
}
int main() {
std::thread t1(excessiveLockingTask);
std::thread t2(excessiveLockingTask);
t1.join();
t2.join();
return 0;
}
デッドロック
デッドロックは、複数のスレッドが互いにリソースを待ち続ける状態です。ロックの順序を統一するか、タイムアウト付きのロックを使用してデッドロックを回避します。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1;
std::mutex mtx2;
void deadlockTask1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx2);
std::cout << "Task 1 completed" << std::endl;
}
void deadlockTask2() {
std::lock_guard<std::mutex> lock1(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx1);
std::cout << "Task 2 completed" << std::endl;
}
int main() {
std::thread t1(deadlockTask1);
std::thread t2(deadlockTask2);
t1.join();
t2.join();
return 0;
}
この例では、mtx1
とmtx2
のロック順序が異なるため、デッドロックが発生します。ロック順序を統一することで、デッドロックを防止できます。
演習問題
マルチスレッドプログラミングの理解を深めるために、以下の演習問題に取り組んでください。これらの問題は、記事で紹介した概念や手法を実践する機会を提供します。
演習1: マルチスレッドによる配列の合計計算
与えられた整数配列の要素の合計をマルチスレッドで計算するプログラムを作成してください。スレッドを使用して配列を分割し、それぞれの部分を並行して計算します。
#include <iostream>
#include <vector>
#include <thread>
#include <numeric>
void partialSum(const std::vector<int>& arr, int start, int end, int& result) {
result = std::accumulate(arr.begin() + start, arr.begin() + end, 0);
}
int main() {
std::vector<int> arr(1000, 1); // 要素1000の配列、すべて1で初期化
int mid = arr.size() / 2;
int result1 = 0, result2 = 0;
std::thread t1(partialSum, std::cref(arr), 0, mid, std::ref(result1));
std::thread t2(partialSum, std::cref(arr), mid, arr.size(), std::ref(result2));
t1.join();
t2.join();
int total = result1 + result2;
std::cout << "Total sum: " << total << std::endl;
return 0;
}
演習2: スレッドプールの実装
スレッドプールを実装し、複数のタスクを並行して実行するプログラムを作成してください。タスクのキューイングとスレッドの再利用を行います。
#include <iostream>
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
class ThreadPool {
public:
ThreadPool(size_t numThreads);
~ThreadPool();
void enqueue(std::function<void()> task);
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queueMutex;
std::condition_variable condition;
bool stop;
void worker();
};
ThreadPool::ThreadPool(size_t numThreads) : stop(false) {
for (size_t i = 0; i < numThreads; ++i) {
workers.emplace_back(&ThreadPool::worker, this);
}
}
ThreadPool::~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queueMutex);
stop = true;
}
condition.notify_all();
for (std::thread &worker : workers) {
worker.join();
}
}
void ThreadPool::enqueue(std::function<void()> task) {
{
std::unique_lock<std::mutex> lock(queueMutex);
tasks.push(std::move(task));
}
condition.notify_one();
}
void ThreadPool::worker() {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queueMutex);
condition.wait(lock, [this] { return stop || !tasks.empty(); });
if (stop && tasks.empty())
return;
task = std::move(tasks.front());
tasks.pop();
}
task();
}
}
int main() {
ThreadPool pool(4);
for (int i = 0; i < 8; ++i) {
pool.enqueue([i] {
std::cout << "Task " << i << " is executing" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
});
}
std::this_thread::sleep_for(std::chrono::seconds(5));
return 0;
}
演習3: デッドロックを防ぐ
以下のプログラムにデッドロックが発生しています。このデッドロックを防ぐためにコードを修正してください。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx1;
std::mutex mtx2;
void task1() {
std::lock_guard<std::mutex> lock1(mtx1);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx2);
std::cout << "Task 1 completed" << std::endl;
}
void task2() {
std::lock_guard<std::mutex> lock1(mtx2);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::lock_guard<std::mutex> lock2(mtx1);
std::cout << "Task 2 completed" << std::endl;
}
int main() {
std::thread t1(task1);
std::thread t2(task2);
t1.join();
t2.join();
return 0;
}
このプログラムのデッドロックを防ぐには、ロックの順序を統一するか、std::lock
を使用してデッドロックを回避します。
void task1() {
std::lock(mtx1, mtx2);
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
std::cout << "Task 1 completed" << std::endl;
}
void task2() {
std::lock(mtx1, mtx2);
std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
std::cout << "Task 2 completed" << std::endl;
}
この修正により、デッドロックを防ぐことができます。
まとめ
本記事では、C++におけるマルチスレッドプログラミングとそのパフォーマンス最適化について詳細に解説しました。基本的なスレッドの作成方法から始まり、スレッド同期、デッドロック回避、スレッドプールの活用、並列アルゴリズムの利用、メモリ管理とキャッシュ最適化、そしてプロファイリングとパフォーマンス測定に至るまで、幅広いトピックをカバーしました。
これらの技術を理解し、実践することで、より効率的で高性能なマルチスレッドプログラムを開発できるようになります。ベストプラクティスを遵守し、アンチパターンを避けることで、安定した動作と最適なパフォーマンスを実現できるでしょう。演習問題を通じて、これらの知識を実際のコードに適用し、さらに深い理解を得ることができるはずです。
C++のマルチスレッドプログラミングは複雑で挑戦的な領域ですが、適切な技術とツールを使用することで、その利点を最大限に活用することが可能です。今後のプロジェクトにおいて、この記事で学んだ内容を役立ててください。
コメント