#80645likely_accept
sync: add new type Counter, a sharded counter
ステータス変更: active likely_accept
要約
AIによる要約であり、誤りを含む場合があります。
概要
sync パッケージに、ジェネリック型 sync.Counter[T] を追加する提案です。CPU(GOMAXPROCS の P)ごとに値を分割して保持する「シャード化カウンタ」で、書き込みが多く読み取りが少ないメトリクス収集のような用途で、多数のコアからの同時更新でも性能が落ちにくくなります。
ステータス変更
active → likely_accept
10月14日の Proposal Review では、委員会全体がこの提案を支持しました。@aclements は「何年も前に欲しかった」と述べています。レビューでは次の点を確認しています。
- 対象型を
~float64 | ~int64 | ~uint64に絞るのは妥当で、必要になれば後から増やせる。32ビット型は桁あふれしやすく、32ビット環境では多コアになることも少ないため、対象外とした Valueが一貫したスナップショットを返さない設計は正しい選択である- 未解決だったのは「1つのシャードだけが型の上限を超えたら、合計は正しく wrap-around するのか」という点だけだった。これは 10月29日に、剰余演算の性質上どのシャードで wrap しても合計結果は変わらない、と確認された
この確認を受けて、同日に likely accept と宣言されました。
技術的背景
現状の問題点
現在、並行に更新するカウンタには atomic.Int64 が広く使われています。しかし多数のコアが同じ変数を頻繁に更新すると、その値を含むキャッシュライン(CPU キャッシュの管理単位)がコア間で奪い合いになります。そのため、CPU 数が増えるほど性能が下がります。NUMA(コアごとにメモリへの距離が異なる構成)のマシンでは、この問題がさらに大きくなります。
また、標準ライブラリには float64 用の atomic Add がありません。浮動小数点のカウンタは CAS(compare-and-swap)ループなどで自作する必要があります。
汎用の per-CPU シャード値(#18802)は長年議論されてきましたが、どのケースでも破綻しない汎用 API の設計が難しく、結論が出ていません。
提案された解決策
type Counter[T ~float64 | ~int64 | ~uint64] struct{ /* unexported */ }
func (c *Counter[T]) Add(delta T) // 高速。happens-before 関係を作らない。overflow は wrap
func (c *Counter[T]) Value() T // 全シャードの合計。O(GOMAXPROCS)。同時書き込み中は近似値
プロトタイプ実装(CL 743460)の仕組みは次のとおりです。
Add: goroutine を現在の P に一時的に固定(pin)し、その P 専用のシャードを「読み取り、加算、atomic store」の順で更新します。atomic Add より高速です。Value: 全シャードを atomic load して合計します。データ競合(data race)や値の一部だけが書き換わった状態の読み取り(word tearing)は起きません。ただし、ある時点のスナップショットにはなりません。- 格納方法: シャードは P ごとのキャッシュライン境界に揃えたブロックに、複数のカウンタ分をまとめて格納します。これで偽共有(false sharing)を防ぎ、メモリも節約します。
- GOMAXPROCS の変化: P が増えればシャードを追加します。P が減っても、値を失わないよう既存のシャードは残します。
これによって何ができるようになるか
- メトリクス収集: リクエスト数、送受信バイト数、エラー数などを、ホットパスで気軽に
Addできます。提案者は Prometheus の Go クライアントを fork してこのカウンタに置き換える実験を行い、良好なベンチマーク結果を得ています。 - float64 の累積値: 処理時間の合計などを、CAS ループを自作せずに並行加算できます。
- fork-join 型の並列処理での集計: 並列区間ではカウントだけを行い、join 後に
Valueを読めば正確な値が得られます。
88コアのマシンでのベンチマークでは、Add 99%・Value 1% の混合ワークロードで、P 数が増えるほどスループットが向上しました。一方、単一 goroutine でのAddや、Valueの呼び出しは atomic より遅くなります。
コード例
// Before: 単一 atomic。多コアではキャッシュラインの奪い合いがボトルネックになる
var requests atomic.Int64
var latencyBits atomic.Uint64 // float64 は CAS ループで自作が必要
func handle(d time.Duration) {
requests.Add(1)
for {
old := latencyBits.Load()
nv := math.Float64bits(math.Float64frombits(old) + d.Seconds())
if latencyBits.CompareAndSwap(old, nv) {
break
}
}
}
// After: sync.Counter(ゼロ値のまま使える)
var (
requests sync.Counter[int64]
totalLatency sync.Counter[float64]
)
func handle(d time.Duration) {
requests.Add(1) // 自分の P のシャードだけを更新
totalLatency.Add(d.Seconds())
}
func report() {
fmt.Println(requests.Value(), totalLatency.Value()) // 近似値(同時書き込み中)
}
議論のハイライト
- 汎用シャード値(#18802)ではなく専用型にした理由: 提案者によると、汎用の per-CPU 値は API の設計とエッジケースの扱いが難しく、さらに汎用実装で作ったカウンタは専用カウンタの性能に届きませんでした。
- @balasanjay の反論: 汎用シャード値でも約200ns/op を約8ns/op に改善でき、専用型でさらに約2ns/op にする差は
syncに一回限りの型を足すほどの価値がない、と指摘しました。グラフが GOMAXPROCS で割った値になっていた誤りも指摘され、提案者が修正しています。 - スナップショットを提供しない設計:
Addの速度を最優先し、メモリ順序の保証と一貫性を意図的に犠牲にしています。委員会もこの選択を支持しました。 - P の ID や pin 機能を公開しない設計: 公開するとデッドロックなどの落とし穴が増えるため、ランタイム内部に隠しています。
- API の更新: 型制約を
float64 | int64 | uint64から、名前付き型も使える~float64 | ~int64 | ~uint64に広げました。シャードごとの overflow は、整数では剰余演算により合計に影響しません。float64 では加算順序が変わるため精度が多少変わる可能性がありますが、浮動小数点の再結合では一般に起きることとして許容されました。