最も単純なロックフリーキューには、比較交換さえ要りません。各位置の書き手を1つだけにします。tail を書くのはプロデューサだけ、head を書くのはコンシューマだけです。相手側の位置を古い値で読んでも常に安全なので、リングバッファはロックなしで2つのスレッド間にティックを渡せます。探索器は291通りのスケジュールでそれを証明し、2つ目のプロデューサを加えると1,072通り中458通りで壊れることを示します。
位置ごとに書き手が1つの円
リングバッファは、増える一方の2つのカウンタを持ちます。プロデューサはスロット tail % capacity に書いてから tail を増やして公開し、コンシューマはスロット head % capacity を読んでから head を増やして解放します。tail - head == capacity なら満杯、head == tail なら空です。なぜロックなしで安全なのでしょう。tail を書くのはプロデューサだけなので、手元の変数にコピーを持てます。空きを調べるために head を読み、その値が古くても、古い head は本物より小さいので、プロデューサはバッファを実際より埋まっていると思い、せいぜい要素を捨てるだけで、未読のスロットを上書きすることはありません。コンシューマが読む古い tail も小さいので、せいぜいバッファが空に見えるだけです。これが単一ライターの原則です。
def initial_state(): return {"head": 0, "tail": 0, "pushed": (), "popped": ()}def ring_producer(items, capacity): def thread(shared): tail = 0 for item in items: yield head = shared.get("head", 0) if tail - head < capacity: yield shared["slot%d" % (tail % capacity)] = item yield tail += 1 shared["tail"] = tail shared["pushed"] = shared.get("pushed", ()) + (item,) return threaddef ring_consumer(attempts, capacity): def thread(shared): head = 0 for _ in range(attempts): yield tail = shared.get("tail", 0) if head != tail: yield item = shared.get("slot%d" % (head % capacity)) yield head += 1 shared["head"] = head shared["popped"] = shared.get("popped", ()) + (item,) return threadtwo = lambda shared: [ring_producer((7, 8), 2)(shared), ring_consumer(2, 2)(shared)]for prefix in ([0, 0, 0], [0, 0, 0, 1, 1, 1], [0, 0, 0, 0, 0, 0, 1, 1, 1, 1]): print(prefix, run_prefix(two, prefix, initial_state())[0])
[0, 0, 0] {'head': 0, 'tail': 1, 'pushed': (7,), 'popped': (), 'slot0': 7}
[0, 0, 0, 1, 1, 1] {'head': 1, 'tail': 1, 'pushed': (7,), 'popped': (7,), 'slot0': 7}
[0, 0, 0, 0, 0, 0, 1, 1, 1, 1] {'head': 1, 'tail': 2, 'pushed': (7, 8), 'popped': (7,), 'slot0': 7, 'slot1': 8}プロデューサの3手番で最初のティックが入ります。head を読み、要素を slot0 に格納し、tail: 1 を公開します。コンシューマの3手番のあとは head: 1、popped: (7,) です。最後の行ではプロデューサが両方の要素を入れ(slot0: 7、slot1: 8)、コンシューマが最初の1つを取りました。スレッド1は試行が1回残っているのでまだ生きています。
全スケジュールで不変条件を確かめる
キューは何を約束すべきでしょうか。2つあります。取り出した要素は入れた要素と同じ順序で欠けなく並ぶこと。つまり popped は pushed の接頭辞です(FIFO性)。そして入れていないものは取り出されないこと:len(popped) <= len(pushed)。スレッドは操作を終える手番そのものの中で入れたもの・取ったものを記録するので、violations は任意の最終状態で両方を確かめられます。verify は explore_all で全スケジュールの下でプログラム全体を実行し、各不変条件を壊すスケジュールを数えます。全スケジュールで broken が空なら、そのサイズのプログラムについては証明です。
def violations(final): pushed, popped = tuple(final.get("pushed", ())), tuple(final.get("popped", ())) broken = [] if popped != pushed[: len(popped)]: broken.append("order") if len(popped) > len(pushed): broken.append("count") return sorted(broken)def verify(producers, attempts, capacity): def make_threads(shared): return [ring_producer(items, capacity)(shared) for items in producers] + [ring_consumer(attempts, capacity)(shared)] outcomes = explore_all(make_threads, initial_state()) broken = {} pushed = popped = 0 for key, count in outcomes.items(): final = dict(key) pushed = max(pushed, len(final.get("pushed", ()))) popped = max(popped, len(final.get("popped", ()))) for name in violations(final): broken[name] = broken.get(name, 0) + count return {"schedules": sum(outcomes.values()), "broken": dict(sorted(broken.items())), "pushed": pushed, "popped": popped}print(violations({"pushed": (7, 8), "popped": (8,)}))print(verify([(7, 8)], 2, 2))print(verify([(7, 8)], 2, 1))
['order']
{'schedules': 101, 'broken': {}, 'pushed': 2, 'popped': 2}
{'schedules': 25, 'broken': {}, 'pushed': 2, 'popped': 2}2つの要素を入れるプロデューサを2スロットのバッファに置き、2回試行するコンシューマが読むと、スケジュールは101通りで、不変条件を壊すものはありません。スロットが1つだけの場合、コンシューマが遅いとプロデューサは要素を捨てなければなりませんが、それでも正しく、25通りで違反なしです。(7, 8) が入った後に取り出しが (8,) なら、予想どおり順序違反です。
共有変数ごとに、それを書くスレッドはちょうど1つで、読むだけのスレッドはいくつでもよい。書き手が1つなら、その変数に競合はなく、古い値を見た読み手も、その古い値が安全側にしかずれないなら安全です。
壊れる条件:2つ目のプロデューサ
安全性の議論は1つの事実に頼っています。tail の書き手が1人であることです。2つ目のプロデューサを加えると、2つのスレッドがそれぞれ自分の tail の考えを持ちます。両方が同じ head を読み、同じスロットを計算し、2回目の書き込みが1回目を上書きします。探索器はそれを見つけます。2つのプロデューサ(各1ティック)の1,072通りのスケジュールのうち458通りで、popped が pushed の接頭辞になりません。対策はこのリングへのパッチではなく別の構造、複数プロデューサのキューで、後のトピックで比較交換を使って作ります。
print(verify([(7,), (8,)], 2, 2)){'schedules': 1072, 'broken': {'order': 458}, 'pushed': 2, 'popped': 1}このマシンはすべての操作を順番どおりに実行します。実際のプロセッサでは、プロデューサは tail をreleaseストアで公開し、コンシューマはacquireロードで読まなければなりません。そうしないと、コンシューマが要素より先に新しい tail を見てしまいます(前のトピックの古いデータ)。また head と tail は別のキャッシュラインに置きます。同じラインだと2つのスレッドがラインを奪い合います(フォルスシェアリング)。
ticks = [int(round(float(c) * 100)) for c in load_bars()["close"].iloc[:4]]print(ticks)print("one producer: ", verify([ticks[:2]], 3, 2))print("two producers:", verify([ticks[:1], ticks[1:2]], 2, 2))
[9369, 9305, 9354, 9349]
one producer: {'schedules': 291, 'broken': {}, 'pushed': 2, 'popped': 2}
two producers: {'schedules': 1072, 'broken': {'order': 458}, 'pushed': 2, 'popped': 1}このレッスンは2つの数に尽きます。位置ごとに書き手が1人なら291通りで正しく、同じ位置に書き手が2人いると1,072通りの43%で誤ります。探索器は推測したのではなく、すべてのインターリーブを実行しました。
これから作るもの
ラボは講義の順に進みます。空のリング、プロデューサとコンシューマのスレッド、2つの不変条件の検査、探索器を動かす検証関数、そしてSPYの終値をプロデューサ1つと2つでリングに通す研究です。
- 関数 · 問い前提
- initial_state()空のリングはどんな状態か?単純な算術
- ring_producer(items, capacity)プロデューサはどう入れるか?単純な算術
- ring_consumer(attempts, capacity)コンシューマはどう取り出すか?単純な算術
- violations(final)この状態はどの不変条件を壊すか?単純な算術
- verify(producers, attempts, capacity)リングはすべてのスケジュールで正しいか?explore_allviolations
- study(bars)2つ目のプロデューサで何が壊れるか?verify
次のトピック:単一ライターの技にも限界があり、比較交換にも限界があります。ABA問題とタグ付きポインタでは、比較交換でロックフリーなスタックを作り、下でスタックが変わっているのに交換が成功してしまうスケジュールを示し、バージョンタグで直します。
ラボ:採点される内容
6タスク · 100点- 空のリング5点
- プロデューサ20点
- コンシューマ20点
- 不変条件10点
- 全スケジュールで検証する15点
- SPYの表での研究30点