OSのデバッグ:メモリ割り当てチュートリアル







他の多くの調査と同様に、すべてはバグレポートから始まりました。



レポートの名前は非常にシンプルでした:「HTTPに接続するとき、iter_contentはゆっくりと大きなチャンクで機能します。」 類似の名前には、2つの理由で頭の中にサイレンがすぐに含まれていました。 まず、ここで「遅い」の意味を判断するのはかなり困難です。 なんて遅い 「大きなサイズ」はどのくらいですか? 第二に、記述されたものが本当に真剣に明らかにされた場合、我々はすでにそれを知っているでしょう。 iter_content



メソッドは長い間使用されてiter_content



、一般ユーザーモードで大幅に速度が低下した場合、そのような情報は渡されませんでした。



私はすぐにレポートに目を通しました。 著者はいくつかの詳細を提供しましたが、これを書いています:「これは100%のプロセッサ負荷につながり、ネットワーク帯域幅を1 Mb / s未満に減らします。」 それができないので、私はこのフレーズをつかみました。 最小限の処理で簡単なダウンロードが遅くなることはありません!



ただし、拒否前のバグの報告はすべて調査に値します。 レポートの作成者と話をして、バグの兆候のシナリオを復元することができました:PyOpenSSLでリクエストを使用し、次のコードを実行すると、プロセッサが完全にロードされ、ネットワーク帯域幅が最小に低下します。



 import requests https = requests.get("https://az792536.vo.msecnd.net/vms/VMBuild_20161102/VirtualBox/MSEdge/MSEdge.Win10_preview.VirtualBox.zip", stream=True) for content in https.iter_content(100 * 2 ** 20): # 100MB pass
      
      





これは、リクエストスタックを明確に指しているため、 非常に再現性の高いスクリプトです。 ユーザーが指定したコードはここでは実行されません。リクエストライブラリの一部またはその依存関係の1つです。 このユーザーが愚かな低パフォーマンスコードを書いた可能性は低いです。 本当のフィクション。 パブリックURLの使用はさらに素晴らしいです。 スクリプトを実行できました ! そして、これを行った後、バグに遭遇しました。 実行ごとに。



別の素敵な詳細がありました:



10 MBでは、プロセッサの負荷がわずかに増加することも、スループットに影響することもありません。 1 GBでは、100 MBの場合のようにプロセッサに100%の負荷がかかりますが、スループットは100 MBで1 Mb / sとは対照的に100 Kb / s未満に低下します。


これは非常に興味深い点です。チャンクサイズのリテラル値がワークロードに影響することを示唆しています 。 これはPyOpenSSLを使用している場合にのみ発生すること、およびほとんどの場合スタックが上記のコードを処理することを考慮すると、問題が明らかになります。



 File "/home/user/.local/lib/python2.7/site-packages/OpenSSL/SSL.py", line 1299, in recv buf = _ffi.new("char[]", bufsiz)
      
      





調査の結果、FFI.newに関するFFI.new



標準的な動作は、 ゼロ化されたメモリを返すことです。 これは、割り当てられたメモリのサイズに応じて冗長性が直線的に増加することを意味しました。より大きなボリュームは、ゼロに長くリセットする必要がありました。 したがって、大規模なボリュームの割り当てに不適切な動作が関連付けられます。 これらのバッファーのゼロ化を無効にするCFFIの機能を利用して、問題はなくなりました1 。 解決しましたよね?



間違った。



本当のバグ



冗談は別として、これで問題を本当に解決できました。 しかし、数日後、彼らは私に非常に思慮深い質問をしました。 なぜ記憶が活発にリセットされたのですか? 問題の本質を理解するために、POSIXシステムでのメモリ割り当てについて説明します。



mallocとcallocとvmalloc、ああ!



多くのプログラマは、オペレーティングシステムからメモリを要求する標準的な方法を知っています。 標準Cライブラリのmalloc



関数がこのメカニズムに関与しています(手動検索でman 3 malloc



と入力することにより、OSのドキュメントを読むことができます)。 この関数は、1つの引数-割り当てるメモリのバイト数を取ります。 標準Cライブラリは、いくつかの異なる方法のいずれかを使用してメモリを割り当てますが、何らかの方法で、 少なくとも要求した量と同じ大きさのメモリセクションへのポインタを返します。



デフォルトでは、 malloc



初期化されていないメモリを返します 。 つまり、標準のCライブラリは、 既にあるデータを変更することなく、ボリュームを割り当ててすぐにプログラムに渡します。 つまり、 malloc



を使用するmalloc



、プログラムは既にデータを書き込んだバッファーを返します。 これは、Cなどのメモリに安全でない言語のバグの一般的な原因です。一般的に、初期化されていないメモリからの読み取りは非常に危険です。



ただし、 malloc



は、マニュアルの同じページに記載されている友人calloc



ます。 主な違いは、カウンターとサイズの2つの引数を使用することです。 malloc



を使用して、標準Cライブラリを要求します:「少なくともn



バイトを割り当ててください。」 そしてcalloc



を呼び出すときcalloc



彼女に尋ねます:「サイズm



バイトのn



オブジェクトに十分なメモリを割り当ててください。」 明らかに、 calloc



呼び出す主な目的は、オブジェクト配列にヒープを安全に割り当てることでした2



ただし、 calloc



は、メモリに配列を配置するという本来の目的に関連する副作用があります。 それはマニュアルで非常に控えめに言及されています。



割り当てられたメモリはゼロバイトで埋められます。


これは、 calloc



宛先と連動します。 たとえば、値の配列をメモリに配置する場合、多くの場合、初期状態を初期状態にすると非常に便利です。 一部の最新のメモリセーフ言語では、これは既に配列と構造を作成する際の標準的な動作になっています。 Goで構造を初期化すると、デフォルトでは、すべてのメンバーがいわゆる「ゼロ」値に削減されます。これは「すべてがゼロにリセットされた場合の値」に相当します。 これは、すべてのGo構造がcalloc



3を使用してメモリ内に配置されるという約束と見なすことができます。



この動作は、 malloc



が初期化されていないメモリを返し、 calloc



が初期化されたメモリを返すことを意味します。 もしそうなら、そして上記の厳しい約束に照らしてさえ、オペレーティングシステムは割り当てられたメモリを最適化できます。 実際、多くの最新のオペレーティングシステムがこれを行っています。



カロックを使用



もちろん、 calloc



を実装する最も簡単な方法は、次のような記述です。



 void *calloc(size_t count, size_t size) { assert(!multiplication_would_overflow(count, size)); size_t allocation_size = count * size; void *allocation = malloc(allocation_size); memset(allocation, 0, allocation_size); return allocation; }
      
      





このような関数のコストは、割り当てられたメモリのサイズに対してほぼ線形に変化します。バイトが多いほど、すべてをリセットするのに費用がかかります。 現在、ほとんどのOSには、実際にmemset



最適化されたパスが記述れた標準Cライブラリが含まれています(通常、特別なプロセッサベクトル命令が使用され、1つの命令で一度に多数のバイトをリセットできます)。 ただし、この手順のコストは直線的に異なります。



大量のボリュームを割り当てるために、OSは仮想メモリに関連する別のトリックを使用します。



仮想メモリ



ここでは、仮想メモリの構造と動作全体を分析しませんが、それについて読むことを強くお勧めします(このトピックは非常に興味深いです!)。 要するに、仮想メモリは、利用可能なメモリに関するプロセスに対するOSカーネルの嘘です。 実行された各プロセスは、彼と彼だけに属するメモリの独自のアイデアを持っています。 このビューは、物理メモリに間接的にマップされます。



その結果、OSはあらゆる種類のトリッキーなトリックをスクロールできます。 ほとんどの場合、メモリに表示される特殊ファイル(メモリマップファイル)を提供します。 これらは、メモリの内容をディスクにダウンロードしたり、メモリ内のメモリを表示したりするために使用されます。 後者の場合、プログラムはOSに次のように尋ねます。「nバイトのメモリを割り当てて、ディスク上のファイルに保存してください。メモリに書き込むときにすべての書き込みがこのファイルに行われ、メモリから読み取るときにデータがそれから読み取られるでしょう。」



カーネルレベルでは、次のように機能します。プロセスがそのようなメモリから読み取ろうとすると、プロセッサはメモリが存在しないことを通知し、プロセスを一時停止し、「ページフォールト」をスローします。 カーネルは、実際のデータをメモリに入れて、アプリケーションが読み取れるようにします。 その後、プロセスが一時停止され、魔法のように表示されたデータが適切な場所で検出されます。 プロセスの観点から見ると、すべてが一時停止することなく即座に発生しました。



このメカニズムを使用して、他の微妙なトリックを実行できます。 その1つは、非常に大量のメモリの「無料」割り当てです。 または、より正確には、 割り当てられたサイズではなく、このメモリの使用度に比例する値を作成します



歴史的に、実行時にまともなメモリチャンクを必要とする多くのプログラムは、起動時に大きなバッファを作成し、ライフサイクル中にプログラム内で割り当てることができます。 これは、プログラムが仮想メモリを使用しない環境向けに作成されたためです。 プログラムはすぐにある程度のメモリを使用する必要があったため、後でメモリが不足することはありませんでした。 しかし、仮想メモリの導入後、この動作は不要になりました。各プログラムは、他のプログラムから口を破ることなく、必要なだけのメモリを割り当てることができます4



アプリケーションの起動時に非常に高いコストを回避するために、オペレーティングシステムはアプリケーションに横たわり始めました。 ほとんどのオペレーティングシステムでは、1回の呼び出しで128 KB以上を割り当てようとすると、標準Cライブラリは、要求されたボリュームをカバーする完全に新しい仮想メモリページをOSに直接要求します。 しかし、主なこと:そのような選択にはほとんど費用がかかりません 。 結局のところ、実際には、OSは何もしません。仮想メモリスキームを再構成するだけです。 したがって、 malloc



を使用する場合malloc



コストは悲惨です。



メモリはプロセスに「割り当てられた」ものではなく、アプリケーションが実際に使用しようとするとすぐに、メモリページでエラーが発生します。 ここでは、メモリエラーやマップされたメモリファイルの場合と同様に、OSが介入し、目的のページを見つけて、プロセスがアクセスしている場所に配置します。 唯一の違いは、仮想メモリがファイルではなく物理メモリによって提供されることです。



その結果、 malloc(1024 * 1024 * 1024)



を呼び出して1 GBのメモリを割り当てると、実際にはメモリがプロセスに割り当てられないため、これはほぼ瞬時に発生します。 しかし、プログラムは瞬時に多くのギガバイトを「割り当てる」ことができますが、実際にはこれはすぐには起こりません。



しかし、さらに驚くべきことは、同じ最適化がcalloc



で利用できることです。 OSは、いわゆる「ゼロページ」にまったく新しいページを表示できます。これは読み取り専用のメモリページであり、そこからゼロのみが読み取られます。 当初、このマッピングはコピーオンライトです。プロセスがこの新しいメモリにデータを書き込もうとすると、カーネルが介入し、すべてのゼロを新しいページにコピーしてから、書き込みを許可します。



OS側のこのトリックのおかげで、 calloc



は、大きなボリュームを割り当てるときにmalloc



と同じことを実行して、仮想メモリの新しいページを要求できます。 これは、メモリの使用が開始されるまで無料で発生します。 このような最適化は、 calloc(1024 * 1024 * 1024, 1)



がメモリをゼロで埋めることを約束するという事実にもかかわらず、 calloc(1024 * 1024 * 1024, 1)



のコストcalloc(1024 * 1024 * 1024, 1)



が同じ量のメモリに対してmalloc



を呼び出すことに等しいことを意味します。 賢い!



バグに戻る



CFFIがcalloc



使用した場合、メモリがリセットされたのはなぜですか?



はじめに: calloc



常に使用されcalloc



わけでcalloc



ません。 しかし、この場合、 calloc



を使用してスローダウンを直接再現できるのではないかと疑ったため、プログラムを再度投げました。



 #include <stdlib.h> #define ALLOCATION_SIZE (100 * 1024 * 1024) int main (int argc, char *argv[]) { for (int i = 0; i < 10000; i++) { void *temp = calloc(ALLOCATION_SIZE, 1); free(temp); } return 0; }
      
      





calloc



1万回呼び出すことで100 MBを割り当てて解放する非常に単純なCプログラム。 その後、出口が実行されます。 次は2つのオプション5です。



  1. calloc



    は、仮想メモリで上記のトリックを使用できます。 この場合、プログラムは迅速に動作するはずです。割り当てられたメモリは実際には使用されず、ページに分割されず、ページがダーティになりません。 OSは割り当てについて嘘をついていますが、私たちは彼女の手をつかまないので、すべてが正常に動作します。
  2. calloc



    は、 malloc



    を描画し、 memset



    を使用してメモリを手動でリセットできます。 これは非常にゆっくりと行う必要があります。合計で、 テラバイトのメモリをリセットする必要があります(各100 MBの1万サイクル)。これは非常に困難です。


これは、最初のオプションを使用するための標準OSのしきい値を大きく超えているため、このような動作が期待できます。 確かに、Linuxはまさにそれを行います。GCCを使用してコードをコンパイルして実行すると、非常に高速に実行され、数ページのエラーが発生し、メモリの負荷はほとんど生じません。 しかし、同じプログラムをMacOSで実行すると、 非常に長く実行されます。約8分かかりました



さらに、 ALLOCATION_SIZE



を増やすと(たとえば、 1000 * 1024 * 1024



)、MacOSでは、このプログラムはほぼ瞬時に動作します! 一体何?



ここで何が起こっていますか?



詳細な分析



MacOSにはsample



ユーティリティ( man 1 sample



参照)があり、ステータスを記録することで、実行されているプロセスについて多くを知ることができます。 コードの場合、 sample



はこれを生成します。



 Sampling process 57844 for 10 seconds with 1 millisecond of run time between samples Sampling completed, processing symbols... Sample analysis of process 57844 written to file /tmp/a.out_2016-12-05_153352_8Lp9.sample.txt Analysis of sampling a.out (pid 57844) every 1 millisecond Process: a.out [57844] Path: /Users/cory/tmp/a.out Load Address: 0x10a279000 Identifier: a.out Version: 0 Code Type: X86-64 Parent Process: zsh [1021] Date/Time: 2016-12-05 15:33:52.123 +0000 Launch Time: 2016-12-05 15:33:42.352 +0000 OS Version: Mac OS X 10.12.2 (16C53a) Report Version: 7 Analysis Tool: /usr/bin/sample ---- Call graph: 3668 Thread_7796221 DispatchQueue_1: com.apple.main-thread (serial) 3668 start (in libdyld.dylib) + 1 [0x7fffca829255] 3444 main (in a.out) + 61 [0x10a279f5d] + 3444 calloc (in libsystem_malloc.dylib) + 30 [0x7fffca9addd7] + 3444 malloc_zone_calloc (in libsystem_malloc.dylib) + 87 [0x7fffca9ad496] + 3444 szone_malloc_should_clear (in libsystem_malloc.dylib) + 365 [0x7fffca9ab4a7] + 3227 large_malloc (in libsystem_malloc.dylib) + 989 [0x7fffca9afe47] + ! 3227 _platform_bzero$VARIANT$Haswel (in libsystem_platform.dylib) + 41 [0x7fffcaa3abc9] + 217 large_malloc (in libsystem_malloc.dylib) + 961 [0x7fffca9afe2b] + 217 madvise (in libsystem_kernel.dylib) + 10 [0x7fffca958f32] 221 main (in a.out) + 74 [0x10a279f6a] + 217 free_large (in libsystem_malloc.dylib) + 538 [0x7fffca9b0481] + ! 217 madvise (in libsystem_kernel.dylib) + 10 [0x7fffca958f32] + 4 free_large (in libsystem_malloc.dylib) + 119 [0x7fffca9b02de] + 4 madvise (in libsystem_kernel.dylib) + 10 [0x7fffca958f32] 3 main (in a.out) + 61 [0x10a279f5d] Total number in stack (recursive counted multiple, when >=5): Sort by top of stack, same collapsed (when >= 5): _platform_bzero$VARIANT$Haswell (in libsystem_platform.dylib) 3227 madvise (in libsystem_kernel.dylib) 438
      
      





ここでは、 _platform_bzero$VARIANT$Haswell



メソッドに多くの時間が浪費されていることが_platform_bzero$VARIANT$Haswell



ます。 バッファをゼロにするために使用されます。 つまり、MacOSはそれらをリセットします。 なんで?



リリース後しばらくして、AppleはOSのコアコードのほとんどを公開しています。 そして、このプログラムがlibsystem_malloc



多くの時間を費やしていることがlibsystem_malloc



ます。 私はopensource.apple.comに行き、必要なソースコードでlibmalloc-116アーカイブをダウンロードし、調査を始めました。



すべての魔法はlarge_mallocで発生するようです。 このブランチは、127 Kbを超えるメモリを割り当てるために必要です。仮想メモリでトリックを使用します。 では、なぜすべてが私たちにとってゆっくりと機能するのでしょうか?



事実、Appleは洗練されすぎているようです。 large_malloc



large_malloc



のコードCONFIG_LARGE_CACHE



#define



定数の背後に隠されています。 基本的に、このコードはすべて、プログラムに割り当てられた大量のメモリのページの「空きリスト」になります。 MacOSが隣接バッファーを127 KBからLARGE_CACHE_SIZE_ENTRY_LIMIT



(約125 MB)に割り当てる場合、 libsystem_malloc



は、別のメモリー割り当てプロセスが使用できる場合、これらのページを再度使用しようとします。 これにより、Darwinカーネルからページを要求する必要がなくなり、コンテキストの切り替えとシステムコールを節約できます。原則として、重要な節約になります。



ただし、これは、バイトをリセットする必要がある場合のcalloc



場合です。 また、MacOSが再利用可能なページを見つけ、それがcalloc



から呼び出された場合、 メモリはリセットされます。 すべて。 そして毎回。



これには独自の理由があります。ゼロ化されたページは、特に控えめな鉄の場合、限られたリソースです(Apple Watchを見てください)。 そのため、ページを再利用できる場合、これにより大幅に節約できます。



ただし、ページキャッシュは、 calloc



を使用してメモリのゼロページを提供する利点を完全に奪います。 ダーティページに対してのみ行われた場合、これはそれほど悪いことではありません。 アプリケーションがヌル可能ページに書き込む場合、おそらく無効化されません。 しかし、MacOSはこれを無条件に行います。 これは、メモリにまったく触れずにalloc



free



calloc



を呼び出した場合でも、2番目のcalloc



は最初の呼び出し中に割り当てられたページを使用し、物理メモリでサポートされないことを意味します。 したがって、OS すでにリセットされているにもかかわらず、リセットするためにこのメモリをすべてロード(ページイン)する必要があります。 これは、大容量の割り当てに関しては、仮想メモリベースの配布ツールを使用して回避したいものです。未使用のメモリ使用済みの「空きリスト」ページになります。



その結果、MacOSでは、他のオペレーティングシステムが127 Kbから始まるO(1)動作を示すにもかかわらず、割り当てられたメモリのサイズに応じてcalloc



のコストが最大125 MBまで直線的に増加します。 125 MBを超えると、MacOSはページのキャッシュを停止し、速度が魔法のように上昇します。



Pythonプログラムからこのようなバグを見つけることは期待していなかったので、いくつか質問がありました。 たとえば、すでにリセットされたメモリをリセットすると、プロセッササイクルがいくつ失われますか? OSが無意味にメモリを無効にできるように、アプリケーションが使用していない(および使用しない)メモリを強制的にロード(ページイン)するのに何回のコンテキストスイッチが必要ですか?



これはすべて、古いことわざの妥当性を確認しているようです:すべての抽象化にはリークがあります( すべての抽象化はリークが多い )。 Pythonでプログラミングしているからといって、忘れることはできません。 プログラムは、メモリとそれを制御するあらゆる種類のトリックを使用するマシンで実行されます。 , , . , .



Radar 29508271 . , .



結論



  1. , ? , CFFI : , , , . char



    OpenSSL, , OpenSSL . , OpenSSL . , OpenSSL , . . ) , OpenSSL, , ) . ( ) : OpenSSL , , . .
  2. «». C-, , : type *array = malloc(number_of_elements * size_of_element)



    . , : number_of_elements size_of_element



    , . calloc



    , . , .
  3. , « » — «». runtime Go .
  4. , , .
  5. , : !



All Articles