
2025/12/17 4:11
I've been writing ring buffers wrong all these years (2016)
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
著者は、従来の実装よりもシンプルで効率的なリングバッファ設計―「2 つの 非マスク インデックスを自由に増加させ、基盤配列へアクセスする際だけマスクする」手法―が優れていると主張しますが、その採用はほとんど見られません。1 要素バッファで実験した結果、以下のことが判明しました。
- 一般的な「配列 + 2 インデックス(読み取り・書き込み)」パターンは full と empty の状態が衝突し、スロットを 1 つ無駄にします。
- 「配列 + インデックス + 長さ」を使う代替案は容量全体を利用できますが、リーダーとライタの両方が共有
フィールドを更新する必要があり、キャッシュ性能を低下させ、原子操作が必須になります。length
自由に増加するインデックス手法はこれらの問題を回避します:無駄なスロットを排除し、ロジックを簡素化し、パフォーマンスを高めます。
uint32_t read, write; static inline uint32_t mask(uint32_t v) { return v & (capacity-1); } void push(v) { assert(!full()); array[mask(write++)] = v; } int shift() { assert(!empty()); return array[mask(read++)]; } bool empty() { return read == write; } bool full() { return size() == capacity; } size_t size() { return write - read; }
この手法の要件は次のとおりです。
- 言語が 符号なし整数のラップアラウンド をサポートしていること(さもなければインデックスは膨大な値に増加します)。
は 2 の冪であること。capacity- 実際に使用できる最大容量は、インデックスタイプの範囲の半分以下であること(例:32 ビット符号なしの場合 ≤ 2³¹–1)。
このテクニックは少なくとも 2004 年(Andrew Morton)から存在しますが、多くのコードは依然として古い「マスク」スタイルに従っています。これは歴史的慣性やオーバーフロー処理への懸念によるものと考えられます。著者は、なぜ劣ったバージョンが残存しているのか疑問を投げかけ、もし言語が符号なしラップアラウンドの意味論を受容すれば、将来の設計は自由に増加する方法へ切り替わる可能性があると示唆しています。採用すると容量無駄が減少し、キャッシュ挙動が改善され、同時実行リングバッファコードが簡素化されます―システムプログラマ、OS 開発者、および高性能循環バッファに依存するすべてのソフトウェアにとって有益です。
本文
そこで私は、1 要素のリングバッファを実装しようとしていました。
ご存知の通り、これは非常に合理的なデータ構造です。
書くのは思ったよりも面倒でした――その理由について後ほど触れます。考えてみると、ずっと「間違った」方法でリングバッファを書いていたことに気づきました。そしてもっと良いやり方があるという結論に至りました。
配列 + 2 つのインデックス
リングバッファを使ってキューを実装する一般的な方法は二通りあります。
- 配列をバックエンドとして使用し、配列へのポインタとして read と write の 2 つのインデックスを保持します。
ヘッドから値を取り出す ときは
を使って配列にアクセスし、その後read
をインクリメントします。read
テールへ値を追加する ときは
を使って配列に書き込み、次にwrite
をインクリメントします。write
両方のインデックスは常に
0 … (capacity‑1) の範囲に収まります。これはインデックスが増えた後でマスクして行います。
uint32 read; uint32 write; mask(val) { return val & (array.capacity - 1); } inc(index) { return mask(index + 1); } push(val) { assert(!full()); array[write] = val; write = inc(write); } shift() { assert(!empty()); ret = array[read]; read = inc(read); return ret; } empty() { return read == write; } full() { return inc(write) == read; } size() { return mask(write - read); }
欠点
この表現は配列の 1 要素を無駄にします。
例えば配列が 4 要素なら、キューは最大で 3 要素しか保持できません。なぜかというと、空バッファでは
read == write が成り立ちますが、満杯のバッファ(容量 N)でも同じ状態になってしまいます。そのため「満杯」を判定するには 1 スロットを未使用にしておく 必要があります。
大きなバッファでは無駄は目立ちませんが、1 要素しかない配列だと 100 % のオーバーヘッドになり、ペイロードゼロです!
配列 + インデックス + 長さ
もう一つの方法は インデックス と 長さ(length)を保持します。
要素を取り出すときは
read を使い、read をインクリメントしながら length を減算します。追加するときは mask(read + length) に書き込み、length を増やします。
uint32 read; uint32 length; mask(val) { return val & (array.capacity - 1); } inc(index) { return mask(index + 1); } push(val) { assert(!full()); array[mask(read + length++)] = val; } shift() { assert(!empty()); --length; ret = array[read]; read = inc(read); return ret; } empty() { return length == 0; } full() { return length == array.capacity; } size() { return length; }
これなら全容量を使えますが、私はあまり好きではありません。
同時に読者と書き手がいる場合、両方のスレッドが
length を更新するためキャッシュ効率が落ち、原子操作が必要になります。
配列 + 2 つの 未マスク インデックス
「両方の表現のメリットを得て、第三の状態変数を持たない方法」はあります――それは インクリメント時にマスクしない で、配列へアクセスするときだけマスクすることです。符号なし整数のオーバーフローが自然にゼロに戻るようにします。
uint32 read; uint32 write; mask(val) { return val & (array.capacity - 1); } push(val) { assert(!full()); array[mask(write++)] = val; } shift() { assert(!empty()); return array[mask(read++)]; } empty() { return read == write; } full() { return size() == array.capacity; } size() { return write - read; }
無駄になっていたスロットが回収されます。インデックスの更新コードはシンプルで、状態判定も直感的です。
制限事項
- 言語が符号なし整数のオーバーフローをラップアラウンドで扱うこと。
は 2 のべき乗(モジュロ演算でも必要)。capacity- 最大容量はインデックス型の範囲の半分まで(例:32 ビット符号なしなら (2^{31}-1))。
これらは通常問題になりません。
なぜ人々は「劣る」バージョンを使い続けているのでしょうか?
- 慣習:教科書的手法が教えられ、再考せずに継承されます。
- 安全性への懸念:符号なしオーバーフローはバグだと恐れる人もおり、意図しないオーバーフローを避けるため明示的にマスクします。
- 非 2 のべき乗サイズ:容量が必ずしも 2 のべき乗でないケースでは、マスク版があればどんなサイズでも動作します。
- 歴史的慣性:未マスク手法は(例:Andrew Morton, 2004)既に知られていましたが、多くの実装は古いパターンを続けています。
結論
容量が 2 のべき乗で、符号なし整数オーバーフローを利用できる場合は 未マスクインデックス を使う方が簡潔かつ効率的です。
「マスク後に増分」バージョンが残っているのは主に慣習とオーバーフローバグへの不安によるもので、必ずしも優れた設計だからではありません。