POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Cory Benfield

本記事は、原著者の許諾のもとに翻訳・掲載しております。

追記:やあやあ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 です。 calloc malloc との最大の違いは、前者が 二つ の引数(要素数とサイズ)を受け取るという点です。 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. calloc malloc を使い、ゼロ埋めは 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は、 有無を言わさず この処理を行うのです。たとえば calloc free calloc をこの順に呼んでその間メモリに何も手を加えなかったとしましょう。二度目の 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. コンパイル時の最適化をオフにしていることが前提です。最適化をかけたら間違いなく、このプログラム全体がなかったことにされてしまうでしょうね!
監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。