毕业项目是一个扇出:一个生产者发布 SPY 行情,多个消费者各自读到全部,全程没有任何锁。你建过的东西在这里汇合:只有生产者才会移动的尾指针、每个消费者一个私有游标因此互不争抢、决定读者能看到什么的写入顺序,以及把“看起来能用”变成“对每一种调度都成立”的探索器。
设计:一个日志,各有一个游标
这个数据流是一个只会增长的日志。位置 p 存放 tick<p>,tail 表示已发布多少个位置。只有生产者写行情和尾指针,所以生产者这一侧没有任何争用。每个消费者拥有一个游标,即它下一个要读的位置,别人都不写它。这正是扇出的全部窍门:消费者从不写别人会读的东西,所以多加一个消费者不会给其他人增加任何代价。唯一共享的事实是尾指针,唯一的问题是生产者以什么顺序写行情和移动尾指针。先写数据再移动,看到新尾指针的读者就能找到数据;先移动再写,读者就可能看到一个还没写好的位置。
def publish(shared, tick, safe=True): if safe: yield pos = shared["tail"] shared["tick%d" % pos] = tick shared["put%d" % pos] = tick yield shared["tail"] = pos + 1 else: yield pos = shared["tail"] shared["tail"] = pos + 1 yield shared["tick%d" % pos] = tick shared["put%d" % pos] = tickfor safe in (True, False): shared, _ = run_prefix(lambda sh: [publish(sh, 9369, safe)], [0], make_feed(1)) print("safe " if safe else "unsafe", "after one turn:", {k: v for k, v in shared.items() if k in ("tail", "tick0")})
safe after one turn: {'tail': 0, 'tick0': 9369}
unsafe after one turn: {'tail': 1}消费者:先读尾指针,再读自己的游标
一次轮询分两轮。第一轮读尾指针。第二轮读消费者自己的游标,若游标落后于尾指针,就读行情、推进游标并记录读到的内容。位置不会在它脚下变化,因为只有这个消费者写自己的游标。可能出错的是行情的内容:在尾指针已移动、数据尚未到达之间进来的读者什么也读不到。轮询把这记为 None,让检查器能看见。
def poll(shared, c): yield tail = shared["tail"] yield pos = shared["cur%d" % c] if pos >= tail: return None value = shared.get("tick%d" % pos) shared["cur%d" % c] = pos + 1 shared["got%d_%d" % (c, pos)] = value return valueready = dict(make_feed(1), tail=1)shared, alive = run_prefix(lambda sh: [poll(sh, 0)], [0, 0], ready)print("tail is 1 but tick0 was never written:", {k: v for k, v in shared.items() if k.startswith(("cur", "got"))})
tail is 1 but tick0 was never written: {'cur0': 1, 'got0_0': None}什么也没找到的轮询会立刻返回 None,而不是空转到行情出现。这让每个线程的程序保持有限,探索器才能结束;这同时也是无锁的契约:没有线程需要依赖另一个线程的进展才能完成自己的调用。
三个不变量,然后是每一种调度
对这个数据流来说“正确”是什么意思?关于最终状态的三条陈述。撕裂(torn):消费者收到的每条行情都等于放在该位置的行情(缺失的行情被收成 None 就算撕裂)。空洞(gap):每个消费者收到的位置是 0、1、2、……没有空洞。越界(overrun):没有游标超过尾指针。verify 探索一个生产者加若干消费者的每一种调度,并数出破坏每条不变量的调度数。探索器就是证明:它不是抽样那一次幸运的运行,而是把所有运行都访问一遍。
def violations(final): puts = {int(key[3:]): value for key, value in final.items() if key.startswith("put")} gots = {} for key, value in final.items(): if key.startswith("got"): c, p = key[3:].split("_") gots[(int(c), int(p))] = value broken = [] if any(value != puts.get(p) for (c, p), value in gots.items()): broken.append("torn") for c in {c for c, _ in gots}: if sorted(p for cc, p in gots if cc == c) != list(range(sum(1 for cc, _ in gots if cc == c))): broken.append("gap") break if any(key.startswith("cur") and value > final["tail"] for key, value in final.items()): broken.append("overrun") return sorted(broken)def verify(ticks, polls, safe=True): outcomes = explore_all(feed_threads(ticks, polls, safe), make_feed(len(polls))) broken = {} delivered = 0 for key, count in outcomes.items(): final = dict(key) delivered = max(delivered, sum(1 for k in final if k.startswith("got"))) for name in violations(final): broken[name] = broken.get(name, 0) + count return {"schedules": sum(outcomes.values()), "broken": dict(sorted(broken.items())), "delivered": delivered}ticks = [9369, 9305]print("safe ", verify(ticks, (2, 1), True))print("unsafe", verify(ticks, (2, 1), False))
safe {'schedules': 3150, 'broken': {}, 'delivered': 3}
unsafe {'schedules': 3150, 'broken': {'torn': 869}, 'delivered': 3}同一个程序、3,150 种调度,只把两行的顺序对调一下,结果就从“没有破坏”变成了 869 种破坏。其中大多数是几千次测试运行也几乎碰不到的调度。这就是用探索代替测试的理由,也是整个技能浓缩成一张表:一个小到可以枚举的模型、一个检查不变量的检查器,以及对每一种调度的裁决。
你要实现什么
实验沿着本讲走:空的数据流、两种顺序的生产者、消费者的轮询、三个不变量的检查器、探索全部调度的验证器、各消费者按序收到行情的平静运行回放,以及在 SPY 收盘价上的研究。
- 函数 · 问题依赖
- make_feed(consumers)共享什么?简单算术
- publish(shared, tick, safe)先写数据还是先移尾指针简单算术
- poll(shared, c)消费者 c 的下一条行情简单算术
- violations(final)哪些不变量被破坏了?简单算术
- verify(ticks, polls, safe)数据流在所有调度下都正确吗?explore_allviolations
- drain(ticks, polls)每个消费者收到什么?run_prefix
- study(bars)SPY 上的安全、不安全与平静verifydrain
无锁技能到此完成。你现在能构建各个部件(原子操作、compare-and-swap、栈和队列),点出各种危险(竞态、ABA、回收),最重要的是能把设计对每一种调度逐一检查,而不是相信一次碰巧通过的运行。下一步是在真实硬件上沿用同样的纪律,也就是 C++ 原子操作的主题。
实验:评分内容
7 个任务 · 100 分- 空的数据流5 分
- 生产者15 分
- 消费者20 分
- 不变量15 分
- 验证器15 分
- 平静的运行10 分
- 基于 SPY 表的研究20 分