メインコンテンツへスキップ
#80645active

sync: add new type Counter, a sharded counter

新規提案

要約

AIによる要約であり、誤りを含む場合があります。

概要

syncパッケージに、論理CPU(P)ごとに分割(シャーディング)して値を持つカウンタ型sync.Counter[T]を追加する提案です。更新が多く読み取りが少ないメトリクス収集のようなカウンタで、コア数が多いマシンでも書き込み性能が落ちないようにすることが目的です。

ステータス変更

(新規) → active
2026-10-14のProposal Review Meetingで議論され、Proposalsプロジェクトのactive列に追加されました。今後は週次のレビュー会議で継続して検討されます。@aclementsの議事メモには、委員会がこの提案を支持していること(「何年も前に欲しかった」)と、「Valueが一貫したスナップショットを返さない設計は正しい選択」という見解が記録されています。#18802(汎用の per-CPU シャード値)は長年議論が続いて結論が出ていません。そこで、最も需要の大きいカウンタに範囲を絞ったことが、前に進められた理由と考えられます。

技術的背景

現状の問題点

現在は、複数のgoroutineから更新するカウンタにatomic.Int64を使うのが一般的です。しかしCPU数が増えると、全コアが同じキャッシュラインを奪い合う(キャッシュライン競合)ため、Addがボトルネックになります。NUMA(CPUごとにメモリまでの距離が異なる構成)の大規模マシンでは、この劣化がさらに目立ちます。また、標準ライブラリにはfloat64のアトミック加算がないため、CASループを自前で書く必要があります。

提案された解決策

type Counter[T ~float64 | ~int64 | ~uint64] struct{ /* unexported */ }
func (c *Counter[T]) Add(delta T)   // happens-before関係を確立しない。オーバーフローはラップアラウンド
func (c *Counter[T]) Value() T      // GOMAXPROCSに比例する時間がかかる。値は近似値
  • Add: goroutineを現在のPに固定(pin)し、そのP専用のシャードを読み出して加算し、アトミックにstoreします。アトミックなaddより高速です。
  • Value: 全シャードをアトミックにloadして合計します。並行して書き込みがある間は近似値になりますが、データ競合やワードの途中書き込み(word-tearing)は起きません。
  • シャードはP単位のキャッシュライン境界に揃えたブロックに複数のカウンタを詰め込み、偽共有(false sharing)を防ぎます。GOMAXPROCSが増えるとシャードを追加し、減っても値を失わないように既存のシャードは保持します。

これによって何ができるようになるか

  • メトリクスライブラリ: Prometheusクライアントのフォークで置き換えを試し、良好なベンチマーク結果が得られています。
  • 高スループットなサーバー: リクエスト数、バイト数、エラー数などをホットパスで気軽にカウントできます。
  • fork-join型の並列処理: 並列区間で集計し、joinの後で読めば正確な値になります。
  • float64の累積値(処理時間の合計など)も、ロックやCASループなしで扱えます。
    88コアのマシンで計測したベンチマークでは、単一goroutineからのAddはatomicより遅くなります。一方、並列でAddする場合と、Add 99%・Value 1%の混在ワークロードでは、コア数が増えるほどスループットが向上しました。

コード例

// Before: 単一のアトミック値。コア数が増えるとキャッシュライン競合で遅くなる
var requests atomic.Int64
func handle() { requests.Add(1) }
func report() int64 { return requests.Load() }
// float64はCASループを自前で書く必要がある
var latencyBits atomic.Uint64
func addLatency(d float64) {
	for {
		old := latencyBits.Load()
		nv := math.Float64bits(math.Float64frombits(old) + d)
		if latencyBits.CompareAndSwap(old, nv) {
			return
		}
	}
}
// After: sync.Counterを使う。ゼロ値のまま使える
var requests sync.Counter[int64]
var latency sync.Counter[float64]
func handle(d time.Duration) {
	requests.Add(1)
	latency.Add(d.Seconds())
}
func report() (int64, float64) { return requests.Value(), latency.Value() } // 近似値

議論のハイライト

  • 汎用シャード値(#18802)ではなく専用型にした理由: 提案者は「汎用のper-CPU実装で作ったカウンタは、専用カウンタの性能に届かない」と説明しています。これに対し@balasanjayは「汎用実装でも約200ns→8ns程度に改善でき、専用型でさらに8ns→2nsにするためだけにsyncへ追加する価値はあるのか」と疑問を出しました。
  • P IDやpinの公開は見送り: goroutine/P IDを露出させたり、Pへのpinを公開APIにしたりするとデッドロックなどの落とし穴が多く、Goの思想にも反するため、実装の内部にとどめています。
  • スナップショット読み取りの断念: スナップショットを保証するとAddの性能が損なわれます。メトリクス用途なら近似値で十分という判断で、委員会もこれを支持しました。
  • 委員会からの論点: 単一のシャードが型の範囲を超えたとき、合計値でもラップアラウンドの意味論が保たれるのかが確認事項として挙がりました。型の制約~float64 | ~int64 | ~uint64は妥当で、32ビット型は値がすぐ一周してしまうため不要とされ、必要になれば後から拡張できるとされています。
  • 当初公開されたベンチマークのグラフは数値がGOMAXPROCSで割られた誤ったものでしたが、@balasanjayの指摘を受けて修正済みです。

関連リンク