std :: shared_ptrおよびカスタムアロケーター

リファクタリングが嫌いな人はいますか? 私たちは、古いコードをリファクタリングするときに、新しい何かを発見したか、重要であるが忘れられた何かを思い出したことを繰り返したと思います。 最近では、カスタムアロケータを使用する際にstd :: shared_ptrの作業に関する知識が少し更新されたので、それらをもう忘れてはならないと判断しました。 更新できたものはすべてこの記事で収集しました。







プロジェクトの1つでは、パフォーマンスの最適化が必要でした。 プロファイリングは、多数のnew / deleteステートメントの呼び出しと、それに対応するmalloc / freeの呼び出しを指しています。これにより、マルチスレッド環境で高価なロックが発生するだけでなく、最も予想外の瞬間にmalloc_consolidateなどの重い関数を呼び出すこともできます。 動的メモリを使用する多数の操作は、スマートポインターstd :: shared_ptrの集中的な作業が原因でした。







この方法でオブジェクトが作成されたクラスは多くありませんでした。 さらに、私は本当にアプリケーションを書き直したくありませんでした。 したがって、パターンを使用する可能性-オブジェクトプール-を調査することが決定されました。 つまり shared_ptrを使い続けますが、動的メモリの集中的な受信/解放を取り除くような方法でメモリ割り当てメカニズムをやり直してください。







mallocの標準実装を他のオプション(tcmalloc、jemalloc)に置き換えることは検討しませんでした。 経験から、標準の実装を置き換えることはパフォーマンスに根本的な影響を与えませんでしたが、変更はプログラム全体に影響を及ぼし、結果が生じる可能性がありました。







その後、アイデアは独自のメモリプールの使用と特別なアロケーターの実装に変換されました。 私の場合、オブジェクトプールよりもメモリプールを使用する利点は、呼び出しコードの透過性です。 アロケーターを使用する場合、オブジェクトは、対応するコンストラクター呼び出しで既に割り当てられたメモリに割り当てられ(配置演算子newが使用されます)、デストラクターへの明示的な呼び出しによってクリアされます。 つまり オブジェクトプールの特性である追加のアクションは、オブジェクトを初期化して(プールから受信したとき)、元の状態に戻す(プールに戻る前に)必要はありません。







次に、私が個人的に理解し、自分で整理したshared_ptrを使用するとき、メモリを操作することの興味深い機能を検討します。 テキストを詳細で過負荷にしないために、コードは単純化され、最も一般的な用語でのみ実際のプロジェクトに関連付けられます。 まず、アロケーターの実装ではなく、カスタムアロケーターを使用するときのstd :: shared_ptrの操作の原則に焦点を当てます。







ポインターを作成する現在のメカニズムは、std :: make_sharedを使用することでした。







auto ptr = std::make_shared<foo_struct>();
      
      





ご存知のように、このポインター作成方法により、ワーカーフレンドリーな方法でポインターを作成した場合に発生する可能性のあるメモリリークの問題が解消されます(ただし、場合によってはこのオプションが正当化されます。たとえば、deleterを渡す必要がある場合):







 auto ptr = std::shared_ptr<foo_struct>(new foo_struct);
      
      





メモリを操作する際の重要なアイデアは、制御ブロックが作成される順序でのstd :: shared_ptrです。 そして、これはポインターをスマートにする特別な構造であることを知っています。 そして彼女にとっては、適切に正直にメモリを割り当てる必要があります。







std :: shared_ptrを使用する際のメモリ使用量を完全に制御する機能は、std :: allocate_sharedを通じて提供されます。 std :: allocate_sharedを呼び出すときに、独自のアロケーターを渡すことができます。







 auto ptr = std::allocate_shared<foo_struct>(allocator);
      
      





new演算子とdelete演算子を再定義すると、例から必要なメモリ量が構造にどのように割り当てられるかを確認できます。







 struct foo_struct { foo_struct() { std::cout << "foo_struct()" << std::endl; } ~foo_struct() { std::cout << "~foo_struct()" << std::endl; } uint64_t value1 = 1; uint64_t value2 = 2; uint64_t value3 = 3; uint64_t value4 = 4; };
      
      





たとえば、最も単純なアロケーターを取り上げます。







 template <class T> struct custom_allocator { typedef T value_type; custom_allocator() noexcept {} template <class U> custom_allocator (const custom_allocator<U>&) noexcept {} T* allocate (std::size_t n) { return reinterpret_cast<T*>( ::operator new(n*sizeof(T))); } void deallocate (T* p, std::size_t n) { ::operator delete(p); } };
      
      





表示する
 ---- Construct shared ---- operator new: size = 32 p = 0x1742030 foo_struct() operator new: size = 24 p = 0x1742060 ~foo_struct() operator delete: p = 0x1742030 operator delete: p = 0x1742060 ---- Construct shared ----
      
      





 ---- Make shared ---- operator new: size = 48 p = 0x1742080 foo_struct() ~foo_struct() operator delete: p = 0x1742080 ---- Make shared ----
      
      





 ---- Allocate shared ---- operator new: size = 48 p = 0x1742080 foo_struct() ~foo_struct() operator delete: p = 0x1742080 ---- Allocate shared ----
      
      





shared_ptrで作業するときにstd :: make_sharedとカスタムアロケーターの両方を使用する重要な機能は、一見重要ではありません。アロケーターの1回の呼び出しでオブジェクト自体とコントロールユニットの両方にメモリを割り当てる機能です。 これはしばしば本に書かれていますが、実際にこれに出くわすまではメモリに保存されていません。







この側面が見落とされている場合、ポインターを作成するときのシステムの動作はかなり奇妙に見えます。 アロケーターを使用して、ポインターが指す特定のオブジェクトにメモリを割り当てる予定ですが、実際には、メモリ割り当ての要求には、オブジェクトが占有するよりも多くのボリュームが必要です。 そして、使用されているアロケータのタイプが元のアロケータと一致しません。







アロケーターに小さなデバッグ出力を追加することで、これを確認できます
 ---- Allocate shared ---- Allocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2> operator new: size = 48 p = 0x1742080 foo_struct() ~foo_struct() Deallocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2> operator delete: p = 0x1742080 ---- Allocate shared ----
      
      





foo_structクラスのオブジェクトではなく、メモリが割り当てられます。 より正確には、foo_structだけではありません。







std :: shared_ptr制御ブロックを呼び出すと、すべてが適切に配置されます。 ここで、アロケーターのコピーコンストラクターにもう少しデバッグ出力を追加すると、作成されるオブジェクトのタイプを確認できます。







見るために
 ---- Allocate shared ---- sizeof control_block_type: 48 sizeof foo_struct: 32 custom_allocator<T>::custom_allocator(const custom_allocator<U>&): T: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2> U: foo_struct Allocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2> operator new: size = 48 p = 0x1742080 foo_struct() ~foo_struct() custom_allocator<T>::custom_allocator(const custom_allocator<U>&): T: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2> U: foo_struct Deallocating: std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2> operator delete: p = 0x1742080 ---- Allocate shared ----
      
      





この場合、 アロケーターの再バインドがトリガーされます。 つまり あるタイプのアロケーターを別のタイプのアロケーターから取得する。 この「トリック」は、std :: shared_ptrだけでなく、std :: listやstd :: mapなどの標準ライブラリの他のクラスでも使用されます-実際に保存されるオブジェクトはユーザーのものとは異なります。 同時に、ソースアロケータから必要なオプションが作成され、必要な量のメモリが割り当てられます。







そのため、カスタムアロケーターを使用する場合、メモリはコントロールユニットとオブジェクト自体の両方に割り当てられます。 そして、これらすべてを一度に呼び出します。 これは、アロケーターを作成するときに考慮する必要があります。 特に、以前に固定長のブロックで割り当てられたメモリが使用される場合。 ここでの問題は、アロケータが動作しているときに本当に必要となるメモリブロックのサイズを正しく決定することです。







メモリブロックのサイズを決定する

これまでのところ、意図的に大きな値を使用するか、完全に移植性のない方法を使用するよりも良いものは見つかりませんでした。







 using control_block_type = std::_Sp_counted_ptr_inplace<foo_struct, custom_allocator<foo_struct>, (__gnu_cxx::_Lock_policy)2>; constexpr static size_t block_size = sizeof(control_block_type);
      
      





ところで、コンパイラのバージョンによって、制御ブロックのサイズは異なります。







この問題をよりエレガントな方法で解決する方法についてのヒントに感謝します。







結論として、代替アロケーターを使用した重要な結果は、オブジェクトを操作するための既存のコードとインターフェースを大幅に変更することなく最適化を実行できることでした。 そしてもちろん、プログラミング言語のさまざまな微妙な側面の記憶を定期的に更新することを忘れないでください!







githubのサンプルのソースコード。







ご清聴ありがとうございました!








All Articles