フューチャー技術ブログ

Go 1.26で変わるcgoの高速化 〜ランタイム刷新がもたらす30%の高速化とその舞台裏〜

Go1.26ブログ連載の7本目です。

はじめに

テクノロジーイノベーショングループの宮崎です。

Go1.26でcgoが高速化され、約30%速くなったと公表がありました。

これまでなんとなくでcgoを捉えていたので、これを機会にそもそもの仕組みやどのようにオーバーヘッドが短縮されたのか調べてみました。

そもそもcgoとは?

そもそもcgoとはGo言語とC言語の間の「橋渡し」を行うパッケージおよび機能のことで、GoのプログラムからC言語で書かれた関数を呼び出したり、逆にC言語からGoの関数を呼び出したりすることが可能になります。

cgoを利用してCのコードを呼び出すには、import "C"という特殊な行を追加し、その直上のコメント(プリアンブル)にCのコードを記述します。このようにすることでC.関数の形式でCのコードを呼び出すことができます。

Goの場合はランタイムが自動的にメモリ管理をしてくれますが、Cを呼び出す場合はメモリ解放を自力で行う必要があるなど、注意が必要です。

GoからCを呼び出す場合
package main

/*

#include <stdio.h>
#include <stdlib.h>

void hello_c(const char* name) {
printf("Hello, %s\n", name);
}
*/
import "C" // import Cの直上にコメントを配置する必要がある
import "unsafe"

func main() {
// Goの文字列をCの文字列型 (*C.char) に変換
name := C.CString("Go code")
defer C.free(unsafe.Pointer(name))

// Cの関数を呼び出す
C.hello_c(name)
}

また、CからGoを呼び出すこともできます。

CからGoを呼び出す場合: Go側コード
package main

import "C"
import "fmt"

//export Sum
func Sum(a, b C.int) C.int {
return a + b
}

func main() {}
CからGoを呼び出す場合: C側コード
#include <stdio.h>
#include "libsum.h" // Goのビルド時に自動生成されるファイル

int main() {
int a = 10;
int b = 20;

// Goで定義した関数を呼び出す
int result = Sum(a, b);

printf("result: %d", result);
return 0;
}

上記2ファイルを用意し、GoコードをCの共有ライブラリとしてビルド後、Cコードをコンパイルすることで実行バイナリが生成されます。

# -buildmode=c-shared を指定することで、Cから読める形式になる
# libsum.so(本体)と libsum.h(C用の定義)が生成される
go build -o libsum.so -buildmode=c-shared main.go

# Cコードのコンパイル
gcc -o main main.c ./libsum.so

# 実行
./main

何がオーバーヘッドなのか? 境界超えのコスト

このように、C言語資産を簡単に活用できる土台がcgoによって整っており、一見便利に思えますが、多くのエンジニアを悩ませてきた種でもあります。

それがcgoによるネイティブ連携のオーバーヘッドです。C言語の資産を活用しようとするたびに、GoランタイムとCの実行環境を往復する境界越えでオーバーヘッドが発生し、「cgoは遅いからGoのみで再実装すべき」という意見もありました。

Go 1.26はこの意見を過去のものにする画期的なリリースです。

ランタイムの抜本的な刷新により、cgo呼び出しに伴うベースラインのオーバーヘッドが平均約30%削減されました。特定の環境ではさらに顕著で、Apple M1(ARM64)では33.4%、AMD EPYCでは17.99%もの高速化を叩き出しています。

高速化の革新:_Psyscall状態の廃止

Go 1.26における高速化の核心は、Goのスケジューリングモデル(G-M-Pモデル)の心臓部にあたる、プロセッサ(P)の状態管理の再設計にあります。

G-M-Pモデルとは?

GoではOSから提供されるスレッドを直接扱うのではなく、より軽量なゴルーチンを効率良く管理するためにG-M-Pモデルというスケジューリングメカニズムを採用しています。

G・M・Pはそれぞれ以下のコンポーネントの頭文字です。

  • G (Goroutine)
    ゴルーチンそのものです。実行される関数やスタック情報、状態(待機中、実行中など)を保持しています。OSのスレッドに比べてメモリ消費が非常に少なく(数KB程度)、大量に生成できます
  • M (Machine / OS Thread)
    OSのスレッドです。実際にCPU上で計算を行う実体です。Gを実行するには、必ずこのMが必要です
  • P (Processor)
    論理プロセッサです。「GをMに割り当てるための権利(リソース)」と考えてください。通常、マシンのCPUコア数と同じ数が設定されます

昔のGo(初期)には「P」がなく、共通のグローバルなキューをすべてのMが奪い合っていました。これだと、スレッドが増えるほどロック競合が発生し、パフォーマンスが落ちるという弱点がありました。

そこで導入されたのが P(ローカルランキュー) です。

GMPの連携フローは以下になります。

  1. P が自分専用の「実行待ちGのリスト(Local Queue)」を持ちます
  2. M は P を1つ捕まえて、その中にある G を順番に実行します
  3. 共通のグローバルキューを見に行く頻度が減るため、高速に処理を回せます

このモデルでは以下の様に処理が偏ったときのリカバリー機能によって全体を効率化しています。

  • Work Stealing(奪取)
    あるMが自分の担当するPのGをすべて使い切って暇になったとき、他のPが持っているGの半分を盗んできて実行します。これにより、特定のコアだけ暇になるのを防ぎます
  • Hand-off(引き継ぎ)
    実行中のGがシステムコール(入出力待ちなど)でブロックされた場合、Mも一緒に止まってしまいます。その時、Pは別のM(空いているスレッド)を探して、残りのGたちを引き継がせます

要するに、ゴルーチンを成り立たせるコアロジックです。

スケジューラの事務手続きのボトルネック

G-M-Pモデルでは、通常 P は _Prunning(実行中)などの状態をとりますが、ゴルーチン(G)がシステムコールを呼び出すと、ランタイムは P の状態を_Psyscallに書き換えていました。

これはシステムコール中にPを他のスレッドが奪いやすくするための「目印」とすること目的でした。

これまでのGo(1.25以前)では、ゴルーチンがcgoを介してCコードを実行する際、担当するスレッド(M)は保持しているプロセッサ(P)の状態をわざわざ_Psyscallに変更していました。これはいわば「空港で手荷物がない乗客にも、わざわざ預け入れカウンターへの立ち寄りを強制する」ような、煩雑な手続きでした。この状態遷移には重いアトミック操作(CAS: Compare-And-Swap)が伴い、高並列環境で競合を引き起こす大きな要因となっていました。

ゴルーチン中心の監視への移行

Go 1.26では、このスケジューラの安全網であった _Psyscall 状態が廃止されました。

Pの状態をいちいち書き換えるのではなく、そのPの上で動いている ゴルーチンのステータス(_Gsyscall) を直接監視する仕組みに移行しました。これにより、短時間のcgo呼び出しであれば、MはPを手放す準備をすることなく保持し続けられることになります。システムモニター(sysmon)の挙動も刷新され、不要なPの奪取が抑制されることで、命令パスが劇的に短縮されました。

cgo高速化によるメリット

このオーバーヘッド削減は、特に「マイクロ秒未満の軽量なC関数」を頻繁に叩くライブラリにおいて、圧倒的なパワーを発揮します。

go-sqlite3 へのインパクト

代表例は go-sqlite3 です。

SQLiteのドライバーはC言語で作られており、各言語のドライバーはC言語の純正ドライバーを呼び出す構成になっています。これまではcgoのオーバーヘッド問題により、modernc.org/sqliteのようにピュアなGo言語に置き換えられたライブラリを使用することもありましたが、今回のアップデートでgo-sqlite3を使用も考えられるようになりました。

SQLiteのクエリ実行は、SQLの準備・ステップ実行・カラム値の取得など非常に小さなcgo呼び出しの連鎖で成り立っており、 従来の「100nsのC処理 + 50nsのオーバーヘッド = 150ns」という構成が、Go 1.26では「100ns + 35ns = 135ns」に短縮されます。

この全体で約10%の効率向上は、大量のレコードをスキャンするバッチ処理において無視できない累積的利益をもたらします。

重い処理(OpenCVなど)における真の価値

一方、OpenCV(GoCV)を用いた画像解析や機械学習の推論のように、C側でミリ秒単位の時間を要する処理では、ナノ秒単位の短縮は微々たるものです。

しかし、ここでの真の恩恵は速度ではなくスケジューリングの安定性です。

_Psyscall の廃止によって、sysmonによる不必要なPの奪取が防止されます。これにより、システム全体のジッターが抑制され、テイルレイテンシの改善に寄与します。
リアルタイム性が求められるシステムにおいて、このシステムの揺らぎの低減は、スループット向上に匹敵する価値があると考えられます。

まとめ

「cgoとは」という観点から実際に享受可能なメリットまで深ぼってみました。

原文ではThe baseline runtime overhead of cgo calls has been reduced by ~30%.の一行のみがさらっと記載されているのみでしたが、コードの書き換えを一切必要とせずアップグレードするだけで30%のオーバーヘッド削減することができ、素晴らしいアップデートでした。