ロックフリーのスタックはブロックしませんが、取り出したノードを単純に解放することはできません。別のスレッドが少し前にヘッドを読み、そのノードの next を読もうとしているかもしれないからです。解放されたメモリは再利用されたりマップ解除されたりするので、その読み出しはuse-after-freeです。古典的な対策は2つあります。ハザードポインタは、使っているノードをスレッドが宣言します。エポックは、いつ入ったかを宣言します。どちらも、切り離したノードは誰も保持できなくなってから解放します。
すぐ解放する:危険なpop
モデルはABAトピックのスタックです。スレッドはヘッドを読み、ノードを読み、1回のcompare-and-swapでヘッドを入れ替えます。危険なpopは、入れ替えが成功した同じターンでノードを解放します。解放されたノードには誰も触れてはならず、read_node はスレッドがそれを読むたびにuse-after-freeを記録します。1回ずつpopする2スレッドのスケジュールはわずか20通りで、探索器は悪いものをすぐ見つけます。一方のスレッドがヘッドを読み、もう一方がそのノードをpopして解放し、最初のスレッドがそのあとで読むスケジュールです。
def pop_unsafe(shared, tid): while True: yield idx = shared["head"] if idx == -1: return None yield nxt, value = read_node(shared, idx) yield if compare_and_swap(shared, "head", idx, nxt): shared["popped"] = shared["popped"] + (value,) free_node(shared, idx) return valuedef two_pops(shared): return [pop_unsafe(shared, 0), pop_unsafe(shared, 1)]outcomes = explore_all(two_pops, make_heap([9369, 9305]))hits = sum(n for key, n in outcomes.items() if dict(key)["uaf"])print(sum(outcomes.values()), "schedules,", hits, "of them read a freed node")
20 schedules, 6 of them read a freed node
ハザードポインタ:宣言してから確認
ハザードポインタは、解放を対話に変えます。スレッドはノードを読む前に、その番号を自分の枠に書きます。それだけでは足りません。ヘッドを読んでから枠に書くまでの間にノードがpopされ解放されているかもしれないので、スレッドは検証します。ヘッドをもう一度読み、動いていたらやり直します。popが成功しても、スレッドはノードを解放せず、自分の退役リストに入れてスキャンします。全スレッドの枠を1回読み、どの枠にも名前がない退役ノードだけを解放します。リストに残ったノードは次回のスキャンを待ちます。
def pop_hazard(shared, tid): while True: yield idx = shared["head"] if idx == -1: shared["hp%d" % tid] = -1 return None yield shared["hp%d" % tid] = idx yield if shared["head"] != idx: continue yield nxt, value = read_node(shared, idx) yield if compare_and_swap(shared, "head", idx, nxt): shared["popped"] = shared["popped"] + (value,) shared["hp%d" % tid] = -1 shared["retired%d" % tid] = shared["retired%d" % tid] + (idx,) break yield hazards = tuple(shared["hp%d" % t] for t in range(shared["threads"])) yield keep = [] for node in shared["retired%d" % tid]: if node in hazards: keep.append(node) else: free_node(shared, node) shared["retired%d" % tid] = tuple(keep) return valuedef two_pops(shared): return [pop_hazard(shared, 0), pop_hazard(shared, 1)]outcomes = explore_all(two_pops, make_heap([9369, 9305]))hits = sum(n for key, n in outcomes.items() if dict(key)["uaf"])waiting = max(sum(len(dict(key)["retired%d" % t]) for t in (0, 1)) for key in outcomes)print(sum(outcomes.values()), "schedules,", hits, "read a freed node, up to", waiting, "node waiting at the end")
12122 schedules, 0 read a freed node, up to 1 node waiting at the end
スレッドごとの、読んでいる1つのノードの宣言。読む前に書き、読んだ後に検証します。退役ノードは、どの宣言にも名前がないときだけ解放されます。
エポック:入った時刻を宣言する
エポックベースの回収は宣言する量がずっと少なくなります。グローバルなエポックカウンタが1つあります。スレッドは操作に入るときに現在のエポックを自分の枠に書き、出るときに-1を書きます。個々のノードは宣言しません。エポック e で退役したノードを保持できるのはエポック e 以前に入ったスレッドだけなので、操作中の全スレッドが e より大きいエポックを宣言していれば解放して安全です。代償は、長く中に留まるスレッドが入って以降に退役した全ノードを引き止めることです。ハザードポインタが引き止めるのは名指しした1ノードだけです。
def pop_epoch(shared, tid): yield shared["ep%d" % tid] = shared["epoch"] while True: yield idx = shared["head"] if idx == -1: shared["ep%d" % tid] = -1 return None yield nxt, value = read_node(shared, idx) yield if compare_and_swap(shared, "head", idx, nxt): shared["popped"] = shared["popped"] + (value,) shared["ep%d" % tid] = -1 shared["retired%d" % tid] = shared["retired%d" % tid] + ((idx, shared["epoch"]),) shared["epoch"] = shared["epoch"] + 1 break announced = [] for t in range(shared["threads"]): yield if shared["ep%d" % t] != -1: announced.append(shared["ep%d" % t]) yield keep = [] for node, epoch in shared["retired%d" % tid]: if announced and epoch >= min(announced): keep.append((node, epoch)) else: free_node(shared, node) shared["retired%d" % tid] = tuple(keep) return valuedef two_pops(shared): return [pop_epoch(shared, 0), pop_epoch(shared, 1)]outcomes = explore_all(two_pops, make_heap([9369, 9305]))hits = sum(n for key, n in outcomes.items() if dict(key)["uaf"])waiting = max(sum(len(dict(key)["retired%d" % t]) for t in (0, 1)) for key in outcomes)print(sum(outcomes.values()), "schedules,", hits, "read a freed node, up to", waiting, "node waiting at the end")
9012 schedules, 0 read a freed node, up to 1 node waiting at the end
どちらの方式も、スケジュールによっては最後に退役ノードが待ったまま残ります。探索器はそれを pending として報告します。後のスキャンで解放されるので何も失われません。エポックの本当の危険は、操作の途中で止まったスレッドです。新しいエポックを宣言しないので、その後に退役したノードは決して解放されず、メモリが増え続けます。止まったスレッドが保持するのは1ノードだけなので、ハザードポインタは影響を受けません。
安全にはスケジュールの数という代償があります。宣言、検証、スキャンのどれもが、もう一方のスレッドが割り込める新しいステップだからです。20通りが12,122通りと9,012通りになりました。だからこそここで探索器が役に立ちます。方式がなぜ正しいかを知る必要はなく、そのすべてのスケジュールを確かめるだけです。
これから作るもの
ラボは講義の順に進みます。スレッドごとの枠を持つ共有状態、危険なpop、ハザードポインタのpop、エポックのpop、2つの不変条件(use-after-freeなし、欠損なし)の検査、方式の全スケジュールを探索する検証器、そしてSPYの終値での3方式の研究です。
- 関数 · 問い前提
- make_heap(values, threads)何が共有されるか?単純な算術
- pop_unsafe(shared, tid)popして即解放単純な算術
- pop_hazard(shared, tid)宣言、検証、退役、スキャン単純な算術
- pop_epoch(shared, tid)入り、エポック付きで退役、回収単純な算術
- violations(final)どの不変条件が壊れたか?単純な算術
- verify(scheme, values, pops)方式は全スケジュールで安全か?explore_allviolations
- study(bars)SPYのスタックで生き残る方式は?verify
次のトピック:これはスタックで、おもちゃのようなものでした。最終プロジェクトロックフリーなマーケットデータのファンアウトで、スキルの全体を使います。SPYのティックが1つのプロデューサから複数のコンシューマへロックフリーの構造を通って流れ、探索器がその不変条件をすべてのスケジュールで証明します。
ラボ:採点される内容
7タスク · 100点- 共有状態5点
- すぐ解放10点
- ハザードポインタ20点
- エポック20点
- 不変条件10点
- 検証器15点
- SPYの表での研究20点