OSのデバッグ:メモリアロケーション講座

追記:やあやあHacker Newsさん!おひさしぶり!メモリ管理を深く掘り下げた私の投稿を読む邪魔はしたくないし、私の投稿のあら探しをする人たちを邪魔するつもりもありません。技術的なマル秘テクニックに注目するのもいいでしょう(いや、わかりますよ。楽しいですしね!)。でも、私たちはひとりぼっちでソフトウェアを書いているわけではないのです。だから、ためになる技術的なコンテンツ(私の記事もそうでありたいものです)を捜すだけじゃなくて、政治的な話題にも目を向けることを強くおすすめします。ソフトウェア開発者である私たちは、今後数十年にわたって社会を変える最前線に立つ特権を与えられています。私たちは、自分たちの仕事を社会全体に役立てられるようにするための情報や知識を必要としています。

そういう意味でみなさんには、Hacker Newsが「政治的」なコンテンツを検閲すると言い出したことに反対してもらいたいのです。このコンテンツのモデレートが難しいことはわかっています。でも、世界をよりよくするためには、私たちが世界にもたらしうる害を直視することによる不快感を受け入れる必要があるのではないでしょうか。みなさんにも考えてもらいたいのです。よりよい世界をつくるために、自分たちができることをしていく義務があるのかどうか、そしてあなたの考えに同意しない人たちと積極的にかかわっていくのかどうかを。また、価値のある別の視点をもたらしてくれる長期的な作品を提供する出版物を支援することも検討しましょう。よくわからない人は、まずはModel View Cultureや地元の調査ジャーナリズムを見てみるといいでしょう。欧米におけるファシズムの台頭のリスクについても何かしらのアクションが必要ではないでしょうか。あなたの雇用者や周囲のコミュニティが、前世紀半ばの恐怖を繰り返さないようにするためにも、それが大切です。

閑話休題。いつものコンテンツに戻りましょう。それでは、スタート!


多くの調査がそうであるのと同様に、そもそものはじまりはあるバグレポートでした。

バグレポートのタイトルは「HTTPS接続上でチャンクサイズが大きいときにiter_contentが遅い」というシンプルなものでした。このタイトルを見た私はちょっと警戒しました。その理由は二つあります。まず、定量的なてがかりがまったくないこと。「遅い」ってどういうこと?どんなふうに遅いの?「大きい」ってどのくらい?もうひとつは、もし深刻な影響が出るのであれば、これまでにも同様の報告がきているはずだということ。iter_contentメソッドは古くからあるものです。それなりに一般的な使いかたをしていて目に見えて遅くなるのなら、もっと早い段階で指摘されていたに違いありません。

最初の報告に対して元の報告者が補足しました。まだまだ詳細はわかりませんが、曰く「CPU使用率が100%になって、スループットも1MB/s未満になってしまう」のだとのこと。これが私の目を引きました。本当だとは思えなかったのです。データをダウンロードする最小限の処理だけでそんなに速度が低下するなんてあり得ない!

しかし、バグレポートを却下するならするで、その前にきちんと調査しなければいけません。元の報告者との何度かのやりとりを経て、バグを再現させることに成功しました。PyOpenSSLのRequestsを使った次のコードを実行すると、CPU使用率が100%に張り付いたうえにスループットが最小限に落ちてしまったのです。

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


これはすばらしい再現シナリオです。Requestsスタックに問題があるであろうことがはっきりわかるからです。ここには、ユーザーが書いたコードは一切ありません。すべてのコードはRequestsライブラリやその依存ライブラリに組み込まれているものであり、パフォーマンスが落ちるようなコードをユーザーが書いたわけではないのです。それだけでもすばらしいのですが、さらにすばらしい点があります。この再現シナリオは公開URLを使っているので、誰でも試せるのです。実際、私の環境で実行してもバグを再現させることができました。何度やっても結果は同じでした。

このバグにはさらに興味深い点がありました。

チャンクサイズが10MBだと、CPU負荷にもスループットにも何も影響がみられません。1GBになるとCPU負荷が100%になります。これは100MBでも同じでした。ただスループットは、1GBのときが100KB/s未満だったのに対して100MBのときは1MB/s程度でした。

これがおもしろいのは、チャンクサイズのリテラル値が作業負荷に影響を及ぼしているという点です。この情報と、PyOpenSSLでだけこのバグが発生するという事実やスタックが次の行に処理時間の大半を費やしていることなどから、問題がより明確になりました。

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


さらに調べたところ、CFFIのFFI.newが返すメモリがデフォルトでゼロ埋めされていることがわかりました。つまり、確保するメモリのサイズに比例したオーバーヘッドが発生するということです。確保するサイズが増えれば増えるほど、ゼロ埋めに必要な時間も増えます。したがって、大きなサイズの確保は好ましくないということになります。CFFIの機能を使ってバッファメモリのゼロ埋め処理を無効にしたところ、この現象は解決しました*1。これで一件落着……だと思いましたか?

残念でした。

ほんとうのバグ

冗談はさておき、これで確かに問題は解決しました。しかしその数日後、Nathaniel Smithから鋭い指摘がありました。「そもそも、なぜわざわざゼロ埋めしていたのでしょう?」この疑問を理解するには、POSIXシステムのメモリアロケーションについて少し掘り下げる必要があります。

mallocとcallocとvm_allocと

プログラマーの多くは、OSにメモリの確保を要求する標準的な方法を知っていることでしょう。Cの標準ライブラリ関数mallocを使うのです(man 3 mallocと打ち込めば、マニュアルを読めます)。この関数が受け取る引数は一つで、確保したいメモリのバイト数を指定します。C標準ライブラリはさまざまな戦略の中から一つを選んでメモリを確保し、要求されたメモリと少なくとも同じサイズ以上のメモリへのポインタを返します。

標準規格では、mallocが返すのは初期化していないメモリです。C標準ライブラリは、どこかに確保したメモリをそのまま呼び出し側に返します。その際に、もともとそこに書かれていた内容には一切手を加えません。つまり、mallocを普通に使っていると、すでにプログラム内で何かのデータを書き込んだバッファを受け取ることもあるということです。メモリセーフではないC言語などでは、この挙動がやっかいなバグを引き起こすことがありがちです。一般に、初期化していないメモリの内容を読み込むのは危険なことだとされています。

しかし、mallocにはお友達がいます。マニュアルで一緒に書かれているcallocです。callocmallocとの最大の違いは、前者が二つの引数(要素数とサイズ)を受け取るという点です。mallocはC標準ライブラリに「少なくともnバイトのメモリを確保して欲しい」と依頼するのに対して、callocはC標準ライブラリに「サイズがmバイトのオブジェクトをn個用意できるだけのメモリを確保して欲しい」と依頼するのです。calloc本来の狙いはもちろん、オブジェクトの配列用のヒープメモリを安全な方法で確保することです*2

しかしcallocには副作用があります。配列用のメモリを確保するという本来の目的に関連するもので、マニュアルにもひっそりと書かれています。

割り当てられたメモリには値ゼロのバイトがセットされます。

これは、callocの本来の目的に沿った挙動です。値の配列を確保しようとしたときには、配列がデフォルトの状態からはじまるのが役立つことが多いでしょう。最近のメモリセーフな言語の中には、配列や構造体を作るときのデフォルトの挙動をそのようにしているものもあります。たとえばGo言語の場合は、構造体を初期化するとすべてのメンバーがデフォルトの「ゼロ」値になります。これは「すべてがゼロにセットされていた場合の値」です。これは要するに、Go言語の構造体はすべてcallocを用いて確保されるものだと考えてかまわないということです*3

mallocが初期化していないメモリを返すのに対して、calloc初期化済みのメモリを返すということです。この厳密な約束があるので、OSは処理を最適化することができます。そして実際、最近のOSの大半は実際に最適化しているのです。

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に含まれるC標準ライブラリはmemset用の最適化されたパスを持っています(特別なCPUベクトル命令を用いて、個々の命令が大量のバイトをゼロクリアしているのが一般的です)。それにもかかわらず、そのコストは線形になります。

しかし、OSはこれ以外にも、大規模なメモリ確保のためのトリックを用意しています。仮想メモリを利用するものです。

仮想メモリ

仮想メモリそのものについての説明は残念ながらこの記事で扱う範囲を超えてしまうのですが、事前に仮想メモリについて調べておくことを強くおすすめします(とても興味深いものですよ!)ひとことで言うと「仮想メモリ」とは、OSのカーネルがプロセスに対してメモリについてのウソをつくようなものです。マシン上で動くそれぞれのプロセスには、それぞれのプロセスに属するメモリビューがあります。このメモリビューを、物理メモリへ間接的に「マップ」しています。

これでOSは、あらゆるトリックを活用できるようになります。よくあるトリックのひとつは、「メモリ」といいつつその実態はファイルで持たせるようなことです。これは、メモリをディスクにスワップアウトするときに使われます。また、ファイルのメモリマッピングにも使われます。ファイルのメモリマッピングの場合なら、プログラムからOSに対してこのように問い合わせます。「nバイトのメモリを確保して、このファイルをディスクに書き戻してください。私がメモリに何か書き込んだときにはそれをディスク上のファイルに書き込んで、メモリの内容を読み込んだときにはディスク上のファイルの内容を読み出してください」

カーネルレベルで見ると、プロセスがメモリを読み込もうとしたときにそのメモリが実際には存在しないことにCPUが気づくと、そのプロセスを一時停止して「ページフォルト」を発生させます。すると、OSのカーネルが動いてデータをメモリに持ち込み、アプリケーションから読めるようにします。元のプロセスの一時停止が解けるとプロセス側からは、ファイルの内容がそのメモリから読めるようになっています。

この仕組みを使ってその他のトリックを実行することもできます。そのひとつが、巨大な確保済みメモリを「解放」することです。より正確に言うと、メモリの解放にかかるコストが「確保された」メモリサイズではなく「実際に使われた」メモリサイズに比例するようにすることです。

わざわざそうする理由は、歴史的に、大量のメモリを必要とするプログラムの多くが「起動時に莫大なサイズのバッファを一括確保して、必要に応じてそこから内部的に切り出していく」方式をとっているからです。それらのプログラムは仮想メモリを使わない環境を想定して書かれているので、後でメモリが足りなくなることを防ぐためには事前にその使用権を確保しておく必要があったのです。しかし、仮想メモリがある今では、そんな対策は不要です。それぞれのプログラムが必要に応じてその場でメモリを確保できるし、メモリが足りなくなってしまう心配もありません*4

そこで、これらのアプリケーションの起動時のコストを下げるために、大規模メモリ割り当てをOS側でごまかすようになりました。大半のOSでは、128キロバイトを超えるメモリを1回の呼び出しで確保しようとすると、Cの標準ライブラリはそれをカバーできる仮想メモリページをOSに要求します。しかし、ここがポイントなのですが、仮想メモリページを確保するコストはゼロに等しいのです。OS側では、この時点で実際にメモリを確保することはありません。単に、仮想メモリマッピングの準備をするだけです。そのため、mallocを呼んだときの処理コストが劇的に低下します。

もちろん、まだメモリがプロセスに「マッピング」されてはいないので、アプリケーションがメモリを実際に使おうとするとページフォルトが発生します。この時点でOSが、メモリ内で実際のページを探して割り当てます。ちょうどメモリマップトファイルにおけるページフォルトの対応と同じです(仮想メモリをファイルに書き戻すかわりに物理メモリに書き戻します)。

これらのおかげで、最近のOSの多くは、たとえばmalloc(1024 * 1024 * 1024)で1ギガバイトのメモリを確保するのも一瞬でできてしまいます。実際には、プロセスにメモリを渡す操作は一切行われないからです。その結果として、起動時に数ギガバイトのメモリを確保するけれども実際にはほとんど使わないようなプログラムは極めて高速に立ち上がります。

さらに驚くべきことに、同じような最適化がcallocでも行えるのです。OSは、新しいページをいわゆる「ゼロページ」にマップできるからです。ゼロページは読み込み専用のメモリページで、全体がゼロになっています。このマッピングは最初にコピーオンライト方式で行われます。つまり、プロセスがそのメモリマップに実際に書き込もうとしたときにカーネルが介入して、すべてのゼロを新しいページにコピーしてからプロセスの書き込みを受け付けるのです。

OSがこのトリックを使えるので、callocで大規模なメモリを確保する際にもmallocと同様にできて、新しい仮想メモリページを要求します。これもまた、実際にメモリが使われるときまでは一切コストがかかりません。この最適化が意味するのは、calloc(1024 * 1024 * 1024, 1)のコストが(ゼロクリアするという約束があるにもかかわらず)mallocで同じサイズを確保するコストとまったく同じになるということです。すごいですね!

バグの話に戻りましょう

さてNathanielが言いたかったのはこういうことです。CFFIが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;
}


極めて単純なCのプログラムです。callocによる100MBのメモリ確保と解放を10000回繰り返してから終了します。このプログラムでは、次の二つのいずれかが起こるものと思われます*5

  1. callocが先述の仮想メモリのトリックを使う。この場合なら、今回のプログラムはあっという間に終了するでしょう。このプログラムで確保したメモリは実際には使われなく、ページインすることもないので、このページがダーティになることはありません。OSはメモリ確保の際にトリックを使い、プログラムがそのはったりを突くことはないので、すべてが丸く収まります。
  2. callocmallocを使い、ゼロ埋めはmemsetを用いて手動で行う。もしこちらなら、処理はとても遅くなるでしょう。トータルで1テラバイト(100MB×10000回)ものメモリをゼロクリアする必要があって、その手間が大きくなるからです。

一般的な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に大部分の時間を費やしているのが明白であることです。このメソッドは、バッファをゼロクリアするために使うものです。つまり、macOSではメモリをゼロクリアしているということになります。いったいなぜ?

ちょうど都合のいいことに、Appleは、リリースからある程度経過したOSのコア部分のコードの大部分をオープンソースにしています。このプログラムは処理時間の大半をlibsystem_mallocに費やしていることがわかったので、Appleのオープンソースのウェブページからlibmalloc-116のtarballをダウンロードしました。そして、ソースを探検してみたのです。

すべての鍵はlarge_mallocにあることがわかりました。これは127kBより大きなサイズのメモリを確保する際に使われるもので、最終的には先ほど説明した仮想メモリのトリックを利用しています。なのになぜ、実行速度が遅くなってしまうのでしょう?

ここで、Appleはよかれと思って気を回しすぎてしまったようです。large_mallocの中には、#defineで定義した定数CONFIG_LARGE_CACHEの背後に隠されたコードがあります。このコードは基本的に、プログラムに割り当てられた大きなメモリページの「フリーリスト」にあたります。macOSのプログラムが127kBからLARGE_CACHE_SIZE_ENTRY_LIMIT(ほぼ125MB)までの間の連続したメモリバッファを確保すると、libsystem_mallocは、もし別の場所で確保されたページが使えそうならそのページの再利用を試みます。これで、Darwinカーネルへのメモリページの要求を減らすことができて、コンテキストスイッチやシステムコールを減らせます。理屈の上では、これは大きな節約になるはずです。

しかしcallocの場合は必然的に、これらのバイトをゼロクリアする必要があります。そのため、macOSが再利用可能なページを見つけているときにcallocが呼び出されると、そのメモリをゼロクリアすることになります。全体を、毎回です。

これは完全に不合理だというわけでもありません。ゼロ埋めされたページは限られたリソースです。リソースに制約のあるハードウェアならなおさらでしょう(Apple Watchを見ながら)。つまり、もしページを再利用できるなら、それは本当に大きな節約になる可能性があるということです。

しかし、ページキャッシュは、callocを使ってゼロクリアしたメモリページを提供する最適化を台無しにしてしまいます。ページが「ダーティ」である場合は悪くありません。つまり、ゼロクリアしたページにアプリケーションからの書き込みがなされている場合(おそらくゼロではなくなっていると思われる場合)がそうです。しかしmacOSは、有無を言わさずこの処理を行うのです。たとえばcallocfreecallocをこの順に呼んでその間メモリに何も手を加えなかったとしましょう。二度目のcallocの呼び出しのときには最初の呼び出しで確保されたページを受け取ることでしょう。これは実際のメモリに書き戻されることがなかったものです。たとえすでにゼロになっているとしても、OSはこのページを改めてゼロで埋めようとします。これはまさに、仮想メモリベースのアロケータで大規模なメモリ割り当てをする際に避けようとしてきたことです。このメモリはまだ実際には使われておらず、「フリーリスト」で使われるようになるはずでした。

これらを踏まえると、macOSにおけるcallocのコストは、125MBまでは確保するサイズに比例してしまうということです。ほとんどのOSでは、127kBを超えるとO(1)のオーダーに落ち着くのにもかかわらずです。125MBを超えるとmacOSはページキャッシュをやめるので、その時点で急に処理速度が向上します。

ひとつのPythonのプログラムから見つかったまったく予期せぬバグであり、いろいろ気になることもあります。たとえば、すでにゼロになっているメモリを改めてゼロにするために、どれだけのCPUサイクルをムダにしているのでしょう?決して使われていなくてその必要がないメモリをページインさせるために、どれだけのコンテキストスイッチがムダになったのでしょう?

結局これは、古い格言を実証しているのだと思います。つまり「あらゆる抽象化は破綻する」ということです。自分はPythonプログラマーであるからといってそこから目を背けることはできません。深く掘り下げていけば、あなたが動かしているプログラムが動いているマシンにはメモリが積まれていて、トリックが効いているのです。いつの日か、自分の書いたプログラムがあり得ないくらいに遅くなることがあるかもしれません。そして、原因を見つけるためにOSのレベルまで潜り込んで、メモリの動きがどうなっているかを調べざるを得なくなるかもしれません。

このバグはRadar 29508271として登録されました。私が今までに見たなかでも最大級の奇妙なバグですね。

追記:この投稿の前のバージョンでは、OSのカーネルがアイドル時間にページをゼロクリアする処理について紹介しました。これは最近のOSでは使われておらず、かわりにコピーオンライト方式が採用されています。これもれっきとした最適化のひとつで、カーネルが使用済みページにゼロを書き込むために大量の時間を費やさずに済ませています。アプリケーションが実際にデータを書き込むページにだけゼロを書き込むようにしているのです。これには、CPUサイクルを節約するという効果もあります。アプリケーションが要求したメモリがまったくの未使用であったなら、そこにゼロを書き込むコストはかからないのです。すばらしいですね!


脚注

  1. 簡単に補足しましょう。「そんなことしてだいじょうぶなの?」と思った人も多いことでしょう。もちろんCFFIは、単にそうしたいからというだけの理由でバッファをゼロ埋めしているのではありません。初期化済みのメモリを使うほうがそうでないメモリを使うよりもずっといいからです。直接メモリを操作できるような言語を使っている場合はなおさらそうでしょう。つまり、何もしないよりはゼロ埋めするほうがずっと安全なのです。しかし今回の場合は、charの配列を作ったらすぐにOpenSSLに渡してデータをバッファに書き込めるようにします。さらに、OpenSSLに対してバッファの長さも伝えています。つまりOpenSSLは、バッファの最大長までのゼロ埋めされたメモリにすぐに書き込めるということです。戻されたバッファを受け取るときに、実際に何バイト書き込んだのかをOpenSSLが教えてくれます。私たちはそのバイト数ぶんだけをコピーして残りを捨ててしまえるのです。つまり、バッファ内の各バイトは「私たちがゼロを書き込んで、それをOpenSSLが上書きして、私たちがコピーした」「私たちがゼロを書き込んで、それ以降は一切使われることがなかった」のいずれかであることになります。どちらの場合も、最初のステップ(私たちがゼロを書き込む)は不要です。どうせ後からOpenSSLが上書きするのならそもそもゼロにしておく必要はないし、一切使わないのであれば中身が何であってもかまいません。一般論として、こういう使いかたをするバッファのゼロ埋めは不要なのです。
  2. 「安全な方法で」というからにはもう少し明確にしておかなければいけませんね。配列用にヒープメモリを確保しようとするCのコードの多くは、type *array = malloc(要素数 * 要素のサイズ)のような書きかたをしています。しかしこれは危険です。なぜなら、掛け算の結果がオーバーフローする可能性があるからです。要素数と要素のサイズを掛けた結果が大きくなりすぎて所定のビット数に収まらなくなっても何もエラーは発生せず、ただ黙って、本来必要なサイズよりもずっと少ないメモリを要求することになってしまいます。callocにはその心配がありません。要素数と要素サイズを掛けた結果のオーバーフローをチェックするコードが組み込まれていて、オーバーフローが発生した場合は適切なエラーを返すからです。
  3. 「〜が保証される」ではなく「〜と考えてかまわない」であることに注意。Goのランタイムを確認したわけではないので、ほんとうにそうなのかどうかは不明です。
  4. もちろん、お互いが物理メモリを食いつぶしてしまうこともありえるでしょうが、それはまた別の話。
  5. コンパイル時の最適化をオフにしていることが前提です。最適化をかけたら間違いなく、このプログラム全体がなかったことにされてしまうでしょうね!