比較交換は「値はまだ読んだ値のままか」を問えますが、「変更されてまた元に戻ったか」は問えません。ロックフリーなスタックでは、この死角がABA問題です。スタックについての古い認識のまま交換が成功し、同じ値が2回返されます。スタックをかき回すスレッドに対するpop1回の3,021通りのスケジュールのうち、21通りが通常のスタックを壊し、タグ付きのスタックを壊すものは1つもありません。
比較交換によるスタック
ロックフリースタックは、ノードを共有辞書に持ちます。ノード i は値 val{i} と、下のノードへのリンク next{i} を持ち、head が先頭を指します(空なら-1)。pushでは、head を読み、新しいノードのリンクをそれに向け、head を読んだ値から新しいノードへ交換します。交換に失敗したら誰かが head を変えたのでやり直します。popでは、head を読み、先頭ノードのリンクと値を読み、head を読んだノードからそのリンクへ交換します。勝ったpopは値を popped に記録します。スタックの各操作はジェネレータで、スレッドは yield from で実行し、戻り値も受け取れます。
def make_stack(values, tagged=False): top = 0 if values else -1 shared = {"head": (top, 0) if tagged else top, "popped": ()} for i, value in enumerate(values): shared["val%d" % i] = value shared["next%d" % i] = i + 1 if i + 1 < len(values) else -1 return shareddef push(shared, node, value, tagged=False): shared["val%d" % node] = value while True: yield old = shared["head"] yield shared["next%d" % node] = old[0] if tagged else old yield new = (node, old[1] + 1) if tagged else node if compare_and_swap(shared, "head", old, new): returndef pop(shared, tagged=False): while True: yield old = shared["head"] idx = old[0] if tagged else old if idx == -1: return None yield nxt = shared["next%d" % idx] value = shared["val%d" % idx] yield new = (nxt, old[1] + 1) if tagged else nxt if compare_and_swap(shared, "head", old, new): shared["popped"] = shared["popped"] + (value,) return valuedef alone(shared): def thread(): for _ in range(3): print("popped", (yield from pop(shared))) return [thread()]final, alive = run_prefix(alone, [0] * 20, make_stack([10, 20]))print(final["head"], final["popped"])
popped 10 popped 20 popped None -1 (10, 20)
単独では普通のスタックで、popは10、20、そして None を返し、head は -1 に戻ります。問題は、popの3回の読み出しと交換の間に別のスレッドが動いたときに何が起きるかです。
ABAスケジュール
罠はこうです。被害者スレッドがpopを始め、head = 0(ノードA)とリンク next0 = 1(ノードB)を読み、交換の前に止められます。次に、かき回すスレッドがAをpopし、Bをpopし、ノード0を新しい値99でpushし直すので、head は再び0になり、その下には何もありません。被害者が目を覚まします。比較交換が問うのは head がまだ0かどうかだけで、確かに0なので交換は成功し、もうスタックにないノード1を head にしてしまいます。スケジュール [0, 0]、かき回す側の9手番、そして被害者の順に追い、conserved で確かめます。取り出した値と残った値の合計は、入れた値とちょうど一致しなければなりません。
def walk(shared, tagged=False): values = [] idx = shared["head"][0] if tagged else shared["head"] while idx != -1 and len(values) < 10: values.append(shared["val%d" % idx]) idx = shared["next%d" % idx] return valuesdef conserved(shared, expected, tagged=False): return sorted(list(shared["popped"]) + walk(shared, tagged)) == sorted(expected)def aba_program(tagged=False): def make_threads(shared): def victim(): yield from pop(shared, tagged) def churner(): yield from pop(shared, tagged) yield from pop(shared, tagged) yield from push(shared, 0, 99, tagged) return [victim(), churner()] return make_threadsprefix = [0, 0] + [1] * 9 + [0]final, alive = run_prefix(aba_program(), prefix, make_stack([10, 20]))print(final)print("still on the stack:", walk(final), "| conserved:", conserved(final, [10, 20, 99]))
{'head': 1, 'popped': (10, 20, 10), 'val0': 99, 'next0': -1, 'val1': 20, 'next1': -1}
still on the stack: [20] | conserved: False被害者は、かき回す側がすでに取り出した10を返し(popped: (10, 20, 10))、head はノード1ですが値20も取り出し済みなので、20はスタックにも popped にもあり、新しい値99は失われています。conserved は False です。失われた更新、重複した値、存在すべきでないノード。すべて、比較した値に対しては「正しい」交換が引き起こしたものです。
値がAとして読まれ、Bに変わり、比較交換が確かめる前にAに戻る現象。交換はAを見て成功しますが、値の周りの状態は変わっています。ノードが解放されてそのアドレスが再利用されるときに起きます。
変更を数えるタグ
対策は、head に自分の履歴を持たせることです。タグ付きポインタ版では head は (ノード, タグ) の組で、pushやpopが成功するたびに (新しいノード, タグ + 1) に交換します。比較交換は両方のフィールドを比べるので、かき回す側が残した head (0, 3) は、被害者が読んだ (0, 0) とは別物です。被害者の交換は失敗し、リトライを1回数えて本当のスタックを読み直し、99をpopします。同じスケジュールでも、タグがあれば正しく終わります。
final, alive = run_prefix(aba_program(True), prefix, make_stack([10, 20], True))print(final["head"], "victim alive:", alive == [0])final, alive = run_prefix(aba_program(True), prefix + [0] * 6, make_stack([10, 20], True))print(final["popped"], walk(final, True), "| conserved:", conserved(final, [10, 20, 99], True))
(0, 3) victim alive: True (10, 20, 99) [] | conserved: True
64ビットポインタはすべてのビットを必要とすることはまれなので、タグを上位16ビットに置いたり、倍幅の比較交換でポインタとカウンタを一緒に交換したりできます。一周したタグは理論上は繰り返しうるので、もう1つの対策である安全なメモリ回収は、誰かがまだ読むかもしれないノードの再利用を防ぎます。これは後のトピックで扱います。
values = [int(round(float(c) * 100)) for c in load_bars()["close"].iloc[:2]]for tagged in (False, True): outcomes = explore_all(aba_program(tagged), make_stack(values, tagged)) corrupt = sum(n for key, n in outcomes.items() if not conserved(dict(key), values + [99], tagged)) print("tagged" if tagged else "plain ", sum(outcomes.values()), "schedules,", corrupt, "corrupt")
plain 3021 schedules, 21 corrupt tagged 3021 schedules, 0 corrupt
3,021通り中21通りは1%未満です。実機のストレステストは何日走っても1つも踏まず、本番で1回だけ失敗するかもしれません。探索器はそのすべてを列挙し、タグはそのすべてを取り除きます。
これから作るもの
ラボは講義の順に進みます。共有辞書の中のスタック、比較交換ループによるpushとpop(通常とタグ付き)、スタックの走査、保存則の検査、被害者とかき回すスレッドのABAプログラム、そしてSPYの終値2つでの両スタックの全スケジュール研究です。
- 関数 · 問い前提
- make_stack(values, tagged)共有スタックはどんな形か?単純な算術
- push(shared, node, value, tagged)ノードはどう積むか?単純な算術
- pop(shared, tagged)値はどう取り出すか?単純な算術
- walk(shared, tagged)スタックには何があるか?単純な算術
- conserved(shared, expected, tagged)失われたり重複したりしたものはあるか?walk
- aba_program(tagged)ABAの罠はどんな形か?poppush
- study(bars)タグは何通りを救うか?make_stackaba_programconserved
次のトピック:スタックには先頭が1つしかありませんが、キューには両端があり、それぞれに多くのスレッドが来ます。複数プロデューサ・複数コンシューマのキューでは、プロデューサとコンシューマが fetch-and-add でチケットを取り、各スロットが連番を持つ有界キューを作り、全スケジュールで探索器で確かめます。
ラボ:採点される内容
6タスク · 100点- 共有スタック10点
- push15点
- pop20点
- 走査と検査20点
- ABAプログラム10点
- SPYの表での研究25点