3つのカウンタは、どのスケジュールでも正しい答えを出しますが、同じではありません。最悪のタイミングで1つのスレッドを止めると、スピンロックは全員を止め、比較交換のカウンタは誰かを通し、fetch-and-add のカウンタはどのスレッドも1手番で終えさせます。これが進行保証の梯子の3段です。
止められた1つのスレッド
実際のOSは、いつでもスレッドをコアから外せます。タイムスライスが終わる、ページフォルトでディスクへ送られる、割り込みが来る、などです。良い並行構造はこれに耐えるべきです。この問いを敵対者で正確にします。敵対者は好きな prefix の手番を実行し、その後1つのスレッドを永久に止め、他の全員に budget ラウンドを与えます。止められていないスレッドで終わっていないものが残るなら、その構造は止められます。スピンロックはこのテストに最も単純な形で落ちます。敵対者がロックを持つスレッドを止めると、他のスレッドは予算が尽きるまで空回りします。
def locked_increments(k): def thread(shared): for _ in range(k): while True: yield if not shared.get("lock", 0): shared["lock"] = 1 break yield value = shared.get("x", 0) yield shared["x"] = value + 1 yield shared["lock"] = 0 return threaddef progress_without(make_threads, prefix, paused, budget=50): n = len(make_threads({})) allowed = [tid for tid in range(n) if tid not in paused] _, alive = run_prefix(make_threads, list(prefix) + allowed * budget) return [tid for tid in allowed if tid not in alive]lk = lambda shared: [locked_increments(1)(shared), locked_increments(1)(shared)]print(run_prefix(lk, [0]))print("thread 0 paused holding the lock:", progress_without(lk, [0], [0]))print("thread 0 finished first: ", progress_without(lk, [0, 0, 0, 0], [0]))
({'lock': 1}, [0, 1])
thread 0 paused holding the lock: []
thread 0 finished first: [1]1手番のあと、スレッド0がロックを持ちます('lock': 1)。そこで止めると、スレッド1は50ラウンドたっても何も終えません([])。先にスレッド0に4手番を実行させるとロックは再び空き、スレッド1は終わります([1])。違いは敵対者がどこを突くかだけです。ロックは、クリティカルセクション内の一時停止を、プログラム全体の一時停止に変えてしまいます。
ロックフリー:誰かは必ず終わる
is_blocking は敵対者を自動化します。数手番の接頭辞をすべて試し、生きているスレッドを順に止めて、他のスレッドがまだ終わるかを progress_without に尋ねます。スピンロックでは、1手番のあとに答えが「はい」になります。比較交換のカウンタでは決してそうなりません。読み出しと交換の間で止められたスレッドは値を変えないままなので、他のスレッドの交換は成功します。これがロックフリーの定義です。1つのスレッドが永久に止まっても、システム全体は必ず前進します。
def is_blocking(make_threads, depth=3, budget=50): def visit(prefix): _, alive = run_prefix(make_threads, prefix) for paused in alive: finished = progress_without(make_threads, prefix, [paused], budget) if len(finished) < len([tid for tid in range(len(make_threads({}))) if tid != paused]): return True if len(prefix) < depth: return any(visit(prefix + [tid]) for tid in alive) return False return visit([])cs = lambda shared: [cas_increments(1)(shared), cas_increments(1)(shared)]at = lambda shared: [atomic_increments(1)(shared), atomic_increments(1)(shared)]print("spin lock:", is_blocking(lk))print("compare-and-swap:", is_blocking(cs))print("fetch-and-add:", is_blocking(at))
spin lock: True compare-and-swap: False fetch-and-add: False
比較交換のカウンタも fetch-and-add のカウンタも、ブロックしません。梯子に3段目があるのは、ロックフリーでも1つのスレッドにとっては悪い日があり得るからです。すべての競争に負けることがあります。
ウェイトフリー:全員が終わる
ウェイトフリーな操作には、自分の手番数に対する上限があり、どの隣人もそれを破れません。worst_own_turns はそれを測ります。すべての完成したスケジュールでスレッドが取る手番数の最大値です。そして classify が決定的な問いを立てます。隣のスレッドの増分が1回のときと3回のときで、スレッド0の最悪ケースは同じか。同じならスレッド0はウェイトフリーです。増えるなら、隣人がスレッド0により多くリトライさせられるので、構造はロックフリーにとどまります。
def worst_own_turns(make_threads, tid): worst = 0 def visit(prefix): nonlocal worst _, alive = run_prefix(make_threads, prefix) if not alive: worst = max(worst, prefix.count(tid)) return for other in alive: visit(prefix + [other]) visit([]) return worstdef classify(factory, others=(1, 3)): def program(m): return lambda shared: [factory(1)(shared), factory(m)(shared)] if is_blocking(program(others[0])): return "blocking" light, busy = worst_own_turns(program(others[0]), 0), worst_own_turns(program(others[1]), 0) return "wait-free" if light == busy else "lock-free"for neighbour in (1, 2, 3): cas = lambda shared: [cas_increments(1)(shared), cas_increments(neighbour)(shared)] atomic = lambda shared: [atomic_increments(1)(shared), atomic_increments(neighbour)(shared)] print(neighbour, "increments next door: CAS", worst_own_turns(cas, 0), "turns, fetch-and-add", worst_own_turns(atomic, 0))print([classify(f) for f in (locked_increments, cas_increments, atomic_increments)])
1 increments next door: CAS 4 turns, fetch-and-add 1 2 increments next door: CAS 6 turns, fetch-and-add 1 3 increments next door: CAS 8 turns, fetch-and-add 1 ['blocking', 'lock-free', 'wait-free']
ウェイトフリーは最も強く、設計は最も高価ですが、fetch-and-add のように手が届く場合もあります。ロックフリーはキューやスタックの現実的な目標です。ロックは、保持者が止まっても困らない場所なら構いませんが、止まってはならない経路には決して使えません。
returns = [float(r) for r in load_bars()["close"].pct_change().dropna()][:6]ups = [sum(1 for r in returns[start::2] if r > 0) for start in (0, 1)]for name, factory in (("locked", locked_increments), ("cas", cas_increments), ("atomic", atomic_increments)): program = lambda shared, f=factory: [f(k)(shared) for k in ups] blocking = is_blocking(program) print(name, classify(factory), blocking, None if blocking else worst_own_turns(program, 0))
locked blocking True None cas lock-free False 8 atomic wait-free False 1
棒グラフを読みましょう。fetch-and-add のスレッドは隣がどれだけ忙しくても1手番ですが、比較交換のスレッドは隣の増分が増えるにつれて4、6、8手番になります。隣の増分が1回増えるごとに、スレッド0はもう1回競争に負けて読み出しと交換をやり直すことがあります。この増え方こそが、ロックフリーとウェイトフリーを分けるものです。
ブロッキング:1つのスレッドを止めると他も止まりうる。ロックフリー:必ずどれかのスレッドが終わる。ウェイトフリー:どのスレッドも自分の有限の手番数で終わる。この並びで、各段は一つ前の段を含意します。
これから作るもの
ラボは講義の順に進みます。スピンロックのカウンタ、一部のスレッドを止めた実行、1つのスレッドを止めると他が止まるかのテスト、スレッド自身の手番数の最悪ケース、カウンタの分類、そしてSPYの上昇日に対する3つのカウンタの研究です。
- 関数 · 問い前提
- locked_increments(k)ロックを待つカウンタは?単純な算術
- progress_without(make_threads, prefix, paused, budget)一部が止まったとき誰が終わるか?run_prefix
- is_blocking(make_threads, depth, budget)1つのスレッドを止めると全員が止まるか?progress_without
- worst_own_turns(make_threads, tid)スレッドは最大何手番必要か?run_prefix
- classify(factory, others)ブロッキング、ロックフリー、ウェイトフリーのどれか?is_blockingworst_own_turns
- study(bars)SPYで3つのカウンタはどこに位置するか?classifyis_blockingworst_own_turns
次のトピック:キューが何を約束できるかが分かりました。単一プロデューサ・単一コンシューマのリングバッファでは、書き手1つと読み手1つ、固定メモリ、アトミックなRead-Modify-Writeなしという最も単純で実用的なロックフリーキューを作り、ティックを失ったり並べ替えたりしないことを探索器で確かめます。
ラボ:採点される内容
6タスク · 100点- スピンロックのカウンタ10点
- スレッドを止めて実行する20点
- 1つの停止で全員が止まるか20点
- スレッドの最悪ケース15点
- 保証に名前を付ける10点
- SPYの表での研究25点