インフラ・ストリーミングチーム インターンの八谷です。
本記事では、ガベージコレクション(GC)への負荷を低減することを目的として、Go言語でのメモリ確保時にOSから直接mmap領域を確保する手法と、make関数でランタイムから領域を確保する場合でのGC負荷などの比較を行います。結果としては、mmap領域を直接確保することでGCが走査するオブジェクト数を削減でき、それに伴ってアプリケーションの停止(Stop-The-World)時間を短縮できることがわかりました。
前提知識1: Goにおけるメモリ管理
Goのランタイムでは、起動時や必要に応じてOSから64MB単位の領域をあらかじめ確保し、それを管理しています。Goでメモリ領域の確保を行う際、一般的にはmakeやnew関数、&T{}といった表現を用いますが、この時にはランタイムの管理下の領域から必要なサイズが切り出され、アプリケーションに割り当てられます。割り当ての際には、まずmcacheと呼ばれるスレッド固有のキャッシュ領域を確認します。ここで領域が不足するとより上位の共有領域(mcentral)、さらに上位の領域(mheap)へと割り当ての要求が委譲されていきます。この階層が上がるにつれ、他スレッドとの競合を防ぐためのロック確保やシステムコールによるオーバーヘッドが増大していきます。
つまり、割り当ての頻度やサイズによっては遅延が発生するわけですが、基本的には、アプリケーションのランタイムがすでに確保しているメモリ領域を高速に確保できることがメリットと言えます。ちなみに、ランタイムが確保している領域は実際にはmmap領域なのですが、Goの上ではランタイムがアプリケーションに割り当てる領域をHeapと呼んでいます。
Goランタイムのメモリ管理の詳細についてはMemory Allocation in Goなどをご参照ください。
前提知識2: GoのGC
GoのGCは、ランタイムが管理するメモリ領域についてガベージコレクションを行います。Goの1.25までは典型的なTri-color Mark & Sweep方式のGCが標準でしたが、1.26からはGreenTeaと呼ばれるGCが新たに標準となりました。ここでは両方について簡単に説明します。
Tri-color Mark & Sweep
Mark & Sweepでは二つのフェーズに分けてGCを行います。
- Markフェーズ: 全てのルートオブジェクトから参照を辿り、生存しているオブジェクトにマークをつける。
- Sweepフェーズ: マークがつかなかった領域を未使用として再利用可能にする。
Tri-color Mark & Sweepではその名の通り、各オブジェクトの状態をMarkフェーズで3つに分けます。
- 白色: 参照がないまたは未走査。GC候補
- 灰色: 参照があるが、子オブジェクトの走査が完了していない
- 黒色: 参照があり、子オブジェクトの走査が完了している
Markフェーズでは最初全てのオブジェクトが白色で始まり、灰色のオブジェクトがなくなった時点でSweepフェーズに移行します。
ルートオブジェクトのMark時、Mark終了時のmcacheのフラッシュなどの際に微小なStop-The-World(STW)が発生します。ただ、GoにおいてはGCはアプリケーションスレッドと並行で行われるため、GC実行の間ずっとSTWが発生するということはありません。
この手法ではオブジェクトの参照を深さ優先探索で辿りますが、参照されているオブジェクトが同じメモリページに存在するとは限りません。オブジェクト毎に異なるページを参照する必要がある場合、キャッシュのヒット率が低下しMarkフェーズで大量のメモリアクセスが発生します。実際に、GopherCon2025の発表では、従来のTri-color Mark & Sweep方式ではCPU時間の20%以上がGCに割かれ、さらにそのうち90%以上はMarkフェーズ、さらにMarkフェーズのうち35%以上はメモリIOに割かれているという計測結果が発表されています。
GreenTea
この問題を解決するために実装されたのがGreenTea GCです。GreenTea GCではオブジェクト毎にスキャンするのではなく、ページ単位のFIFOキューを管理し、各ページについて参照があると判明しているオブジェクトが溜まった状態でMark作業を行うことで、空間的局所性を活かしたGCを可能にしています。より詳細な手法については先に示したGopherCon2025の発表、またはgoの公式記事をご参照ください。
MirrativにおけるGC負荷軽減のモチベーション
Mirrativでは、ライブ映像のストリーミングについて、サーバーを含めたパイプラインをGoで記述しています。特に配信された映像と音声を最初に受信するオリジンサーバーでは、AVCやAACのストリームをMPEG-TSやfMP4などのコンテナに詰めてHLSセグメントを生成するため、比較的大きなサイズのメモリ領域を用いた処理が発生します。フレーム毎に処理を行うのであれば一度確保したメモリ領域を使い回すのが好ましいですが、確保するサイズが大きいかつフレーム毎に確保が必要ではないケースでは、都度メモリ領域を確保するような処理を行っています。こういった場合に、ランタイムからメモリ領域を毎度確保していると、GCが走査するオブジェクト数が膨大になっていきます。オブジェクト数が膨大になるとGCの負荷が増大し、主に以下二つの事象でアプリケーションに影響を及ぼします。
アプリケーションのgoroutineによるGCアシスト
Goのランタイムは、GCを行うためのgoroutineを複数生成してGCを行っているわけですが、走査するオブジェクト数が増大すると単一スレッドでは処理しきれなくなります。すると、Goでは、アプリケーションが生成したgoroutineのリソースを用いてGCを行います。これにより、本来アプリケーションの処理に使われるはずだったgoroutineのリソースがGCによって奪われる形になり、アプリケーションのスループットが低下します。

MarkフェーズにおけるSTWの増加
並行Mark & SweepのGCにおいてもMarkフェーズでSTWが発生します。 オブジェクト数が少なければこれは無視できる程度の時間ですが、頻繁なメモリ割り当てによってオブジェクト数が膨大になるとGCの実行回数も増加し、それに伴いSTWの時間も無視できない長さになります。さらに、大きなサイズのメモリ割り当てが続くと、通常より高頻度でGCが実行される場合があります。この場合、前のGCのSweepフェーズの残り分はアプリケーションを止めて一気にSweepするためここでもSTWが発生します。
具体的なGCにおけるSTWのタイミングはGoランタイムのソースコードにコメントとして記載されていますが、総じて、オブジェクト数が増えるにつれGCアシストとSTWが増えるという問題があります。
mmap領域をOSから直接確保する
この問題に対してMirrativでは、ある程度高頻度に大きなサイズのメモリ領域を確保する必要のある処理では、ランタイムからメモリを確保するのではなく、OSから直接mmap領域を確保しています。GCによるメモリ解放を待つことなく自らメモリ確保と解放を繰り返すことで、メモリ使用量とGCの負荷を抑えています。GCの負荷が低減することによりアプリケーションスレッドによるGCアシストの頻度も減り、結果的にアプリケーションのスループットが向上することも期待できます。
一方でトレードオフは存在します。自らメモリ確保と解放を行うことの問題は、GCが生まれる前の古典的なメモリ管理の問題に帰着します。単一のメモリ領域を複数のスレッドが参照していたり、非同期処理で用いている場合にはプログラマーが注意深く例外処理を書きメモリの解放を行う必要があります。また、先にも述べたとおりランタイムがすでに確保している領域を利用するmake関数などと比較すると、OSからの直接確保ではシステムコールの影響で確保にかかる時間は長くなります。
具体的には以下のようなmmapの確保と解放のメソッドを実装し、オリジンサーバーの複数箇所で使用しています。
func Alloc(size int64) ([]byte, error) { data, err := unix.Mmap(-1, 0, int(size), mmapPerm, mmapFlag) if err != nil { return nil, errors.WithStack(err) } return data, nil } func Unmap(buffer []byte) error { if err := unix.Munmap(buffer); err != nil { return errors.WithStack(err) } return nil }
GC負荷、GCアシスト、STWの比較
make関数でランタイムからメモリ領域を確保する場合と、OSから直接mmap領域を確保する場合でGC負荷、GCアシスト、STWの長さがどれくらい変わるのかをstatsvizを用いて比較します。
実験環境
今回は、MirrativのオリジンサーバーのプロセスをMacbook Pro M4 Max上に立てたUbuntu24.04のコンテナで実行します。そのサーバーに対して以下のffmpegコマンドで50並列のライブ配信を開始します。
ffmpeg -re -f lavfi -i "testsrc=size=1280x720:rate=30" -f lavfi -i "sine=frequency=1000:sample_rate=44100" -c:v libx264 -profile:v baseline -pix_fmt yuv420p -preset ultrafast -s 1280x720 -r 30 -g 60 -b:v 1500k -c:a aac -b:a 96k -ac 2 -f flv
実験開始から5分同じ負荷をかけ続け、CPU使用率、メモリ使用量が高止まりしたタイミングでstatsvizによる可視化をはじめます。以降で示すグラフは、GCアシストのグラフをのぞいて、実験開始後5分から10分までの5分間のグラフです。各サンプルの青い罫線が潰れて少し図が見づらくなっていますがご了承ください。
比較する実装
オリジンサーバーのパイプラインでは、映像・音声ストリームを多重化したのち、MPEG-TSおよびfMP4形式のHLSセグメントを生成するシリアライザーにおいて、mmapを用いたOSからの直接メモリ確保を実装しています。この二つの部分をmake関数によるランタイムからのメモリ確保に変更し、元の実装との比較を行います。先に紹介した二つのGC方式での違いも測るため、Go1.24(Tri-color Mark & Sweep)と1.26(GreenTea)でそれぞれ実験を行います。
比較結果
GCにかかる負荷
まずGCにかかる負荷の比較です。Go 1.24でmmapとmakeを用いた場合のメモリ使用量を以下に示します。

オレンジの線はGCが認識しているメモリの総使用量、そして緑は参照が確認されている(生きている)オブジェクトのメモリ総量です。また赤色の線はGC実行後に達成されているべきメモリ使用量の目標値です。
メモリの総使用量について、makeを用いた場合は500MB前後であるのに対して、mmapを用いた場合安定して400MB前後に抑えられていることが分かります。
Go 1.26ではどうでしょうか。

mmapについては同様に400MB前後ですが、makeを用いた場合1.26だと500MBには届かない、470MBあたりで安定しているのが確認でき、わずかですがメモリが太るのを抑えられていることが分かります。これはGCに変更があったことと、1.26からsliceの初期のアロケーション方式が変わったことも影響していると考えられます。
GCアシスト
続いてアプリケーションが生成したgoroutineによるGCアシストの頻度を比較します。GCアシストの比較については5分間のグラフではスパイクによって差が確認できなくなってしまうため、同じ環境で再度実験し、1分間のグラフで比較を行います。

青い線がアプリケーションのgoroutineが秒間でGCアシストに費やした時間です。makeを用いた場合頻繁に50msを超え、時たま大きなスパイクがあります。これはこのスナップショットにおける局所的なものではなく、毎分コンスタントにスパイクが発生しています。一方、mmapではそれほど大きなスパイクはなく、基本的に50msを下回るアシストの秒数に収まっています。
1.26の場合も見てみましょう。(縦軸が3倍ほど違うので注意ください)

makeを用いた場合では大きなスパイクは発生していますが、1.24と比べると頻度は低くなっています。また、赤線はpause、つまりSTWを指しますが、このスパイクの頻度も1.26で減っていることが分かります。
STW
最後に、実験中に発生したSTWの長さと頻度をヒートマップで比較します。

makeを用いた場合1msを超えるSTWが発生しているのに対して、mmapを用いた場合では1msに届くSTWはほとんど発生していません。また、頻度についても、両グラフ右側の凡例を見ると分かる通り、makeを用いた場合の頻度がmmapのそれを上回る結果となっています。
1.26の場合が以下です。

makeを用いた場合では1.24に比べると若干、1msを超えるSTWが減っています。依然としてmmapを用いた場合の方がSTWの長さ、頻度ともに小さいですが、より大きなスケールでは1.26に更新することで大幅にSTWが削減されることも期待できそうです。
おわりに
Mirrativのライブ配信サーバーにおけるGC負荷削減のため、OSから直接mmap領域を確保する手法の紹介と、従来のランタイムからのメモリ確保とのGC負荷、STWの比較を行いました。結果として、GCが認識するメモリ使用量は100MBほど削減され、STWはほとんどが100μs以下に止まる程度に改善できることが分かりました。
Mirrativでは長らくmmapを直接確保する手法を用いてきましたが、Goのバージョンアップに伴い、ランタイムからメモリ領域を確保してもさほど性能に差が出なくなっているのではないかという疑問から今回の調査に至りました。GCの性能やメモリ割り当て手法の進化によって、元々の課題であったアプリケーション性能の低下は解決されているかもしれないと考え比較を行いましたが、結果的には新しいバージョンでも、部分的にはmmapを直接確保し続ける方が効率が良いという結論を得ました。