こんにちは、最近シェーダーをよく書いているバックエンドアークテクチャGの小林です。
先日業務内で map データ構造の罠にはまってしまった事例が観測されたため、改めて振り返ってみることにしました。
Go で、以下のようなコードで map の等値性を判定している箇所がありました。
package main
import (
"fmt"
)
func main() {
:= map[string]string{"foo": "hoge", "bar": "fuga", "baz": "piyo"}
m1 := map[string]string{"foo": "hoge", "bar": "fuga", "baz": "piyo"}
m2
for i := 0; i < 10; i++ {
.Printf("%t\n", isEquivalentMap(m1, m2))
fmt}
}
func isEquivalentMap(lhs, rhs map[string]string) bool {
return mapToString(lhs) == mapToString(rhs)
}
func mapToString(m map[string]string) string {
:= make([]byte, 0, 8192)
buf for k, v := range m {
:= fmt.Sprintf("(%s,%s)", k, v)
elm = append(buf, elm...)
buf }
return string(buf)
}
しかし、結果としてこのコードは結果として想定どおりに動作せず、不具合の原因になってしまいました。 この原因についてより深く知るために map の内部構造や実装について調査しましたので共有します。
一般的に map と呼ばれるデータ構造1においてキーに対応する値を発見するアルゴリズムはいくつか存在しますが、 大きく分けてハッシュ関数を使うものと木構造を使うものに分けられます。
ハッシュ関数を用いてキーに対応するハッシュ値を計算し、それを配列などに格納します。 平均計算量(厳密にはならし計算量)は挿入・削除・探索ともに O(1) です。
ハッシュ値を計算した際、違うキー同士でハッシュ値が衝突してしまうことがあります。 その際の処理としてはさらに次の 2 つに大別されます。
array[42], array[43], array[44]...
で最初に空いている部分に登録するarray[44]
以降で登録されるarray[42][0], array[42][1], array[42][2]...
で最初に空いている部分に登録するSwissTables など、現代のプロセッサに合わせてより最適化された アルゴリズムもありますが、ここで重要なのは 「ハッシュ値が衝突したキーの間の探索はあくまで線形探索」 ということです。
多くのスクリプト言語の map や、明示的に順序が保証されていない場合の map はこのタイプです (C++ の std::unordered_map なんかもそう)。
キーの順序によって決定される木構造を使って管理する方法です。具体的には赤黒木や B 木のようなバランス木が使われることが多いです。 平均計算量は挿入・削除・探索ともに O(log(n)) ですが、最悪計算量も同じく O(log(n)) です。 また、衝突のようなことは原理的に起きないほか、この方法だと内容を列挙した際に常にキーの順序で取り出すことができます。
.NET ならば SortedDictionary 、C++ では std::map 、Rust では BTreeMap などが該当します。
さて、ハッシュ関数を使う方法のところで「『平均計算量は』挿入・削除・探索ともに O(1)」と説明しました。 逆に、最悪計算量は O(n) となってしまうのがこの方法の欠点でもあります。
では、全てハッシュ値が衝突するようなキーで構成された map を作ったとしたら?
その時、ハッシュ関数は一切意味を為さなくなります。常に配列を線形探索してキーで比較するのと同じ状態になってしまうのです。 これを巨大な map で実行すればするほど、どんどんパフォーマンスは悪化します。
特に POST パラメーター等から map を構築する際は、構築が O(n^2) のオーダーとなってしまい、探索も列挙も全て線形になります。 このような攻撃を HashDoS 攻撃といいます。2011 年ごろにこの脆弱性が発表され、多くの言語が影響を受けていました。 類似の脆弱性自体は 1990 年 代から知られていたようで、 Perl や Ruby ではすでにいくらかの修正が施されていましたが、 本格的に対策がとられはじめたのは上記発表があってからのようです。
対策方法としては、「ハッシュ値が外部から予測できないハッシュ関数を使う」というものが主流です。 入力となる文字列と別に乱数の鍵を使ってハッシュ値を計算することで、その乱数を知らない攻撃者にはハッシュ値が衝突する複数の入力を 用意することができなくなります。
このようなハッシュ関数として代表的なものに SipHash があります。 これは暗号学的に安全かつ短いメッセージ(e.g. キーにする文字列)に対しても高速という特長があり、 SHA などを使うよりも 効率よく、かつ安全にハッシュ値を生成することができるものです。現在では様々な言語やアプリケーションでハッシュ値の生成に 使われているようです。
ところで、 map のキーや値、あるいはそのペアを列挙したときの順序はどうなるのでしょうか? 答えは 「実装による」 です。
例えば Rust の HashMap
では、以下のようなコードを実行すると生成するごとに同じキーでも違う順序で列挙されることがわかります。
use std::collections::HashMap;
fn main() {
for i in 1..=10 {
println!("Iteration #{}", i);
let mut hashmap: HashMap<i32, i32> = HashMap::new();
for i in 0..10 {
.insert(i, 10 - i);
hashmap}
for pair in hashmap {
print!("{:?} ", pair);
}
println!();
}
}
また、 Go では同じ map
に対しても列挙ごとに違う順序になります。
package main
import (
"fmt"
)
func main() {
:= map[string]string{
m "foo": "hoge",
"bar": "fuga",
"baz": "piyo",
"a": "あ",
"b": "い",
"c": "う",
"one": "eins",
"two": "zwei",
"three": "drei",
}
for i := 0; i < 10; i++ {
for k, v := range m {
.Printf("(%s, %s) ", k, v)
fmt}
.Printf("\n")
fmt}
}
冒頭のコードがうまく動かない原因がわかりましたね。毎回違う順序で列挙されていたので、構成される文字列も毎回違うものになってしまっていたのです。 Go で map の内容が一致しているかを正しく検査する方法として、 reflect.DeepEqual
を使う方法や、 1.12 以降 map の print 順序がソートされることを利用して `fmt.Sprintf` で文字列化して比較する方法などがあります。 もちろん、手動で全てのキーをソートしてからキー順に比較することもできます。
一方で、ハッシュ関数を使わない実装ではキーによる一意な順序を返すものが多いです。 また、 SipHash などによる内部的なハッシュ値とは関係なく、キーが挿入された順で列挙する実装もあります。 スクリプト言語の言語構造の map に多い傾向で、例えば Python では以下のコードは毎回同じ結果を返します。
= {
d 'foo': 42,
'bar': 'hoge',
'baz': 'https://example.com',
}
# 常に foo, bar, baz の順で表示される
for k, v in d.items():
print(k, v)
このように、言語や実装によって map の列挙順序は異なったものとなっています。 言語仕様などで明確に順序が保証されている場面以外では、順序に依存したコードを書かないようにしましょう。
基本的なデータ構造と侮るなかれ、意外なところで罠を踏まないように気を付けていきたいですね。
この記事を書くにあたって、アドバイスをくださった皆さんにこの場を借りてお礼申し上げます。
主要な言語の標準ライブラリ・言語構造の map について、どのような特性のものがあるのかをまとめました。 以下では map についてのみ記述しますが、特筆のない限りは対応する set 構造についても同じものとなっています。
std::map<Key, T>
はキーの順序(狭義の弱順序)に基づく。std::unordered_map<Key, T>
は有意な順序を返さない。
interface Map<K, V>
しか実装されない場合、 コレクション・ビューのイテレータが要素を返すときの順序 となる。interface SortedMap<K, V>
も実装される場合、キーの順序(自然順序付けあるいは Comparator
)に基づく。HashMap<K, V>
は、ハッシュ値の衝突が連続した場合、連結リストではなくバランス木によって配置されるようになる。HashDoS 耐性があると考えられる。Dictionary<TKey, TValue>
は列挙順序について未定義である。
SortedDictionary<TKey, TValue>
はキーの順序に基づく。OrderedDictionary
はキーの挿入順序に基づく。map
に同じループを回しても毎回違う順序で列挙される。HashMap<K, V>
は任意の順序を返す。
BTreeMap<K, V>
はキーの順序に基づく。dict
は 3.7 以降挿入順序に基づくことが保証されている。
OrderedDict
も挿入順序に基づくが、等値判定で挿入順序も一致することを要求する。dict
に限らず)ランダムになるようになった。Hash
は 1.9 以降挿入順序に基づくことが保証されている。$_POST
の最大パラメーター数を制限することで極端に性能が悪化しないように対応しているが、本質的な対応ではないという指摘もある。
json_decode
はこの制限の対象外となる。Map
は挿入順序に基づく。言語によって map・dictionary・hash・table など様々な呼称がありますが、この記事では map に統一します。↩︎
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。
合わせて読みたい
KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。