C++におけるメモリ管理 (パート5)
この記事では、C++における(9) 並行性とメモリと(10) メモリ管理のベストプラクティスについて説明したいと思います。C++におけるメモリ管理の最後の記事でございます。これは C++におけるメモリ管理 (パート4)の続きです。パート4に興味があってお読みになりたい方は、以下のリンクをご参照ください。
(9.i) スレッドセーフティとデータ競合
スレッドセーフ性: スレッドセーフ性は、複数のスレッドが同時に実行される場合でも、プログラムが正しく動作することを保証するプログラムの基本的な特性です。スレッドセーフなプログラムは、データ競合などの一般的な問題を回避し、スレッドの実行順序に関係なく、結果が一貫性があり予測可能であることを保証します。
データ競合: データ競合は、適切な同期メカニズムが設けられていない状態で、2つ以上のスレッドが共有データに同時にアクセスし、変更を行う場合に発生します。データ競合は、マルチスレッドプログラミングにおける最も一般的で問題のある事象の一つです。メモリアクセスの順序が保証されていないため、予測不可能な誤った挙動を引き起こす可能性があります。
スレッドセーフ性が不足しているため、データ競合が発生する例 :
#include <iostream>
#include <thread>
int shared_data = 0;
void incrementSharedData(int iterations)
{
for (int i = 0; i < iterations; ++i)
{
++shared_data; // 同期がなく、データ競合の可能性があります
}
}
int main()
{
const int num_iterations = 1000000;
std::thread t1(incrementSharedData, num_iterations);
std::thread t2(incrementSharedData, num_iterations);
t1.join();
t2.join();
std::cout << "共有データ: " << shared_data << std::endl;
return 0;
}
この例では、2つのスレッドがshared_data変数を同時に増分しようとする場合、同時にアクセスできないことを保護する同期メカニズムが存在しません。その結果、shared_dataを同時に増分しようとするとデータ競合の可能性があり、予測不可能で正しくない結果につながることがあります。
(9.ii) 共有メモリの管理のための同期プリミティブ
C++は、共有メモリを効果的に管理するためのさまざまな同期プリミティブを提供しています。最も一般的に使用されるもののうち、2つはミューテックスと条件変数です。
ミューテックス: ミューテックス(相互排他の略)は、複数のスレッドが同時に実行するのを防ぎ、コードのクリティカルセクションを保護することができます。
データ競合からスレッドセーフ性を実現するために、mutex(ミューテックス)を使用する例 :
#include <iostream>
#include <thread>
#include <mutex>
int shared_data = 0;
std::mutex mtx;
void incrementSharedData(int iterations)
{
for (int i = 0; i < iterations; ++i)
{
std::lock_guard<std::mutex> lock(mtx); // ミューテックスをロック
++shared_data;
}
}
int main()
{
const int num_iterations = 1000000;
std::thread t1(incrementSharedData, num_iterations);
std::thread t2(incrementSharedData, num_iterations);
t1.join();
t2.join();
std::cout << "共有データ: " << shared_data << std::endl;
return 0;
}
この修正されたコードでは、mtx という名前の std::mutex を使用して、shared_data へのアクセスと増分が行われるクリティカルセクションを保護しています。std::lock_guard はミューテックスを自動的にロックおよびアンロックし、1つのスレッドしか一度に shared_data にアクセスできないようにします。これにより、スレッドセーフ性が確保され、データ競合が防がれます。
条件変数: 条件変数は、特定の条件が満たされるのを待ってからスレッドが進行する必要がある高度な同期シナリオで使用されます。条件変数は、スレッド間での通知と待機を管理するための同期メカニズムです。通常、ある条件が満たされるまでスレッドが待機し、他のスレッドが条件を満たしたときに通知されます。以下は条件変数の具体的な例です。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool data_ready = false;
void producer()
{
std::this_thread::sleep_for(std::chrono::seconds(2)); // シミュレーションのための待機
{
std::lock_guard<std::mutex> lock(mtx);
data_ready = true;
}
cv.notify_one(); // 条件を満たしたことを通知
}
void consumer()
{
std::cout << "Consumer is waiting for data..." << std::endl;
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [] { return data_ready; }); // 条件が満たされるまで待機
std::cout << "Consumer received the data." << std::endl;
}
int main()
{
std::thread producer_thread(producer);
std::thread consumer_thread(consumer);
producer_thread.join();
consumer_thread.join();
return 0;
}
この例では、条件変数 cv を使用して、producer_threadとconsumer_threadの間でデータの通知と待機を実現しています。
producer 関数は、データを生成し、data_ready フラグを設定し、cv.notify_one() で条件を満たしたことを通知します。
consumer 関数は、データが到着するまで待機し、cv.wait() を使用して待機中に自動的にミューテックスを解放します。条件が満たされると、コンシューマースレッドは待機を終了し、データを処理します。
(※例で使っているstd::unique_lock と std::lock_guardについて説明したいと思います。std::unique_lock と std::lock_guard は、C++ のスレッドセーフなプログラミングにおいてミューテックス(Mutex)を使用する際に役立つヘルパークラスです。これらのクラスは、ミューテックスをロック(ロックを取得)およびアンロック(ロックを解放)するために使用されます。
1. std::lock_guard:
std::lock_guard は、スコープ内でミューテックスをロックし、そのスコープから抜ける際に自動的にミューテックスをアンロックします。これにより、ミューテックスのアンロックを忘れることなく、安全にスレッドセーフなプログラムを作成できます。
std::lock_guard の主な利点は、コードがクリーンで単純であることです。ロックとアンロックの操作がスコープ内に含まれており、プログラマーが手動でこれらの操作を行う必要がありません。
2. std::unique_lock:
std::unique_lock は、std::lock_guard と同様にスコープ内でミューテックスをロックおよびアンロックできますが、より柔軟性があります。例えば、std::unique_lock を使うと、ミューテックスの手動でのアンロックと再ロックが可能です。これは、ある条件が満たされたときにミューテックスを解放し、再びロックする場合に便利です。
std::unique_lock は、条件変数と組み合わせて使用する場合に特に役立ちます。条件変数を待機中にミューテックスを解放する必要がある場合、std::unique_lock はその用途に適しています。
std::lock_guard はミューテックスのロックとアンロックを自動的に行うシンプルな方法を提供し、std::unique_lock はより高度な制御を可能にします。)
(9.iii) メモリバリアとアトミック操作
メモリバリア : メモリバリア(またはメモリフェンス)は、マルチスレッドプログラミングにおいて、異なるスレッドによってメモリ操作がどの順序で見られるかを制御するために使用されます。これにより、共有変数へのメモリアクセスや変更が所望の順序で観察され、データ競合などの問題を防ぎ、スレッドがメモリに一貫したビューを持つことが確保されます。C++では、std::atomic_thread_fence 関数を使用してメモリバリアを作成することができます。
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> shared_data(0);
void writer1()
{
// 100ミリ秒の遅延を導入してからshared_dataの値を42に設定
shared_data.store(42, std::memory_order_relaxed);
std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 仕事をシミュレート
std::atomic_thread_fence(std::memory_order_release); // リリースメモリバリアを設定
}
void writer2()
{
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // 仕事をシミュレート
shared_data.store(17, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_release); // リリースメモリバリアを設定
}
void reader1()
{
std::atomic_thread_fence(std::memory_order_acquire); // アクイアメモリバリアを設定してから
int value = shared_data.load(std::memory_order_relaxed);
std::cout << "Reader 1: Shared Data = " << value << std::endl;
}
void reader2()
{
std::atomic_thread_fence(std::memory_order_acquire); // アクイアメモリバリアを設定してから
int value = shared_data.load(std::memory_order_relaxed);
std::cout << "Reader 2: Shared Data = " << value << std::endl;
}
int main()
{
std::thread writer_thread1(writer1);
std::thread writer_thread2(writer2);
std::thread reader_thread1(reader1);
std::thread reader_thread2(reader2);
writer_thread1.join();
writer_thread2.join();
reader_thread1.join();
reader_thread2.join();
return 0;
}
この例では:
writer1 は、shared_data の値を 42 に設定する前に 100 ミリ秒の遅延し、writer2 は値を 17 に設定する前に 50 ミリ秒遅延します。
各 writer スレッドの後に std::atomic_thread_fence(std::memory_order_release) が使用され、リリースメモリバリアが正しく設定されます。
リーダースレッドは、shared_data の値をロードする前に std::atomic_thread_fence(std::memory_order_acquire) を実行します。
追加の遅延とメモリバリアがあるため、リーダースレッドは最初の writer1 によって設定された値をより多く見る可能性が高くなります。ただし、実行の正確な順序は依然として異なる可能性があり、時折、リーダースレッドが 2番目の writer2 の値を読み取る出力が表示される可能性もあります。
(※例で使っているmemory_order_relaxed、memory_order_release、memory_order_acquireについて説明したいと思います。
メモリ順序(memory_order_relaxed、memory_order_release、memory_order_acquire)は、マルチスレッドプログラムにおけるメモリアクセスの可視性と順序を制御するために、アトミック操作と共に使用されます。これらは、他のスレッドによってメモリの読み書きがどのように認識されるかを決定し、正しい同期を確保する上で重要な役割を果たします。
1. memory_order_relaxed:このメモリ順序は、最も緩やかな保証を提供します。メモリアクセスに特定の順序を課しません。つまり、最も制約のないメモリ順序です。 memory_order_relaxedを持つ操作は、データ依存のルールを破らない限り、コンパイラやハードウェアによって並べ替えられる可能性があります。特定の順序の保証を必要としない場合に適しています。
2. memory_order_release:このメモリ順序は、現在のスレッド内の直前のメモリ書き込みが、リリース操作を実行する前に他のスレッドから見えることを確保したい場合に使用されます。 これにより、リリース操作の前に現在のスレッド内で行われたメモリ書き込みが、それ以降に再配置されることを防ぎます。これにより、以前の書き込みがリリース操作の前に完了することが保証されます。
3. memory_order_acquire:このメモリ順序は、アクワイア操作が完了した後に現在のスレッドで行われるすべての後続のメモリ読み取りが行われることを確保したい場合に使用されます。 アクワイア操作の後に表示されるメモリ読み取りがそれより前に再配置されることを防ぎます。これにより、アクワイア操作の後に後続の読み取りが行われることが保証されます。)
アトミック操作 : アトミック操作は、他のスレッドからの割り込みなしに実行が保証される操作です。これはマルチスレッド環境で共有データを安全に操作する方法を提供します。C++では、アトミックデータと操作を扱うために、<atomic> ヘッダからアトミック型と操作を使用できます。
std::atomicおよびアトミック操作を使用した例:
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<int> shared_variable(0);
void incrementSharedVariable(int iterations)
{
for (int i = 0; i < iterations; ++i)
{
shared_variable.fetch_add(1, std::memory_order_relaxed);
}
}
int main()
{
const int num_iterations = 1000000;
std::thread t1(incrementSharedVariable, num_iterations);
std::thread t2(incrementSharedVariable, num_iterations);
t1.join();
t2.join();
std::cout << "Shared Variable: " << shared_variable.load(std::memory_order_relaxed) << std::endl;
return 0;
}
この例では:
std::atomic<int> 型の shared_variable という共有変数を定義し、これに対してアトミックなアクセスと変更を保証します。
incrementSharedVariable 関数は std::memory_order_relaxed とともに fetch_add 操作を使用して、shared_variable を1ずつ増やします。リラックスしたメモリ順序は厳格なメモリ順序の制約を課さず、性能向上のための再配置を許可します。
main 関数では、t1 と t2 という2つのスレッドを作成し、それぞれが incrementSharedVariable を呼び出して共有変数を100万回ずつ増やします。
スレッドを結合した後、shared_variable の値を std::memory_order_relaxed とともに load で読み込み、最終結果を表示します。
この例では、std::atomic は t1 と t2 による共有変数への同時の更新がデータ競合や未定義の動作を引き起こさないことを保証します。fetch_add の使用により、他のスレッドからの干渉なしに増加がアトミックに実行されることが保証されます。
このプログラムは "Shared Variable: 2000000" という期待される結果を表示し、アトミック操作が両方のスレッドからの増加を正常に同期させたことを示しています。std::memory_order_relaxed の使用は、厳格な順序が必要でない場合に適しており、メモリ順序の制約が最小限であることを示しています。
(10) メモリ管理のベストプラクティス
(10.i)確立されたC++ベストプラクティスに従う
1. スマートポインタの使用:C++11では、std::unique_ptrやstd::shared_ptrなどのスマートポインタが導入されました。これらは、リソースが不要になったときにそれらを解放することを確実にするため、メモリ管理を自動化するのに役立ちます。
2. RAII(リソースの取得は初期化):これはC++の基本的なイディオムの一つです。メモリ割り当てやファイルハンドルなどのリソースは、オブジェクトの寿命に結びつけるべきです。オブジェクトがスコープを抜けると、そのデストラクタが自動的に呼び出され、リソースが解放されます。
3. 手動のメモリ管理を避ける:newやdeleteの使用を最小限に抑えてメモリを自動的に管理するstd::vectorやstd::stringなどのコンテナを検討して、より安全で便利なメモリ管理を採用しましょう。
4. C++標準ライブラリの使用:C++標準ライブラリはさまざまなデータ構造とアルゴリズムを提供しています。これらのリソースを活用することは、安全かつ効率的なメモリ管理を確保するのに役立ちます。
(10.ii) メモリ管理の決定を文書化する
メモリ管理に関する決定を文書化することは、コードの信頼性を高め、開発者間でのコミュニケーションを円滑にするために非常に重要です。
1. コメントの使用:コード内に友好的なコメントを追加しましょう。特定のメモリ管理テクニックを選択した理由とその利点について説明します。これにより、単なる「何」だけでなく「なぜ」も明確になります。
2. 所有権と責任の定義:コメントで所有権と責任を明確に定義します。メモリの所有権を移転する場合、明示的に文書化しましょう。たとえば、メモリの所有権を関数に渡して解放を行わせる場合、その所有権の移転を説明するコメントを残します。
3. エラーハンドリング計画:メモリの割り当てが失敗した場合に備え、コメントでエラーハンドリング計画を説明します。メモリ割り当ての問題に対応するため、プログラムがどのように正しく対応すべきかを説明します。適切に文書化されたエラーハンドリング戦略は、コードの強靭性を確保します。
(10.iii) 継続的なテストとプロファイリング
1. 自動テストの実施:メモリ管理に焦点を当てたユニットテストや統合テストを実装します。Google Testのようなテストフレームワークを使用して、メモリ関連の動作を検証できます。
2. メモリプロファイリング:メモリプロファイリングは、アプリケーションのメモリ使用状況を分析し、メモリ関連の問題、メモリリーク、過度なメモリ消費、効率の悪いメモリ管理などを特定するために使用される技術です。メモリプロファイリングツールは、アプリケーションがメモリをどのように使用し、メモリフットプリントを最適化するための洞察を得るために使用されます。Valgrind、AddressSanitizer、macOSのInstrumentsなどのメモリプロファイリングツールを使用して、メモリリークやその他の問題を特定できます。
3. パフォーマンスプロファイリング:パフォーマンスプロファイリング、またはコードプロファイリングとしても知られるこの技術は、コンピュータープログラムのランタイム動作とパフォーマンスを分析するために使用される手法です。これは、実行時間、リソース使用状況、およびボトルネックを含む、アプリケーションのパフォーマンスのさまざまな側面に洞察を提供します。gprof、perf、gperftoolsなどのパフォーマンスプロファイリングツールを使用して、メモリ使用に関連するパフォーマンスボトルネックを特定できます。
この記事では(9) 並行性とメモリと(10) メモリ管理のベストプラクティスについて議論しました。C++におけるメモリ管理の記事シリーズが今回で終了となります。このシリーズをお読みいただき、誠にありがとうございます。
エンジニアファーストの会社 株式会社CRE-CO
su_myat_phyu
この記事が気に入ったらサポートをしてみませんか?