把所有调度跑一遍可以证明一个小程序,但代价是交错的总数。真实的工具有两条捷径。竞态检测器只观察一次运行,就报告在别的调度下可能发生竞争的变量。模型检验器则记住见过的状态。两个各 10 步的线程,是 184,756 种调度对 121 个状态。
一条轨迹与一个问题
竞态检测器不运行线程,它读的是一条轨迹:每一次对共享数据的读和写,以及每一次对锁的获取和释放。我们的程序变成这类操作的列表,interleave 把一个程序和一个调度变成该次运行的轨迹。两个线程各自读写 x 且不用锁,就得到下面的四个事件。在这个调度里线程 0 在线程 1 开始之前就结束了,所以测试会看到正确的结果。而检测器的问题不同:这两个线程有没有可能重叠?
def interleave(programs, schedule): position = [0] * len(programs) trace = [] for tid in schedule: kind, name = programs[tid][position[tid]] position[tid] += 1 trace.append((tid, kind, name)) return traceunlocked = [[("read", "x"), ("write", "x")], [("read", "x"), ("write", "x")]]trace = interleave(unlocked, [0, 0, 1, 1])print(trace)
[(0, 'read', 'x'), (0, 'write', 'x'), (1, 'read', 'x'), (1, 'write', 'x')]
Lockset:哪把锁保护这个变量?
第一个检测器靠一条纪律:每个共享变量都应该被某把在每次访问时都被持有的锁保护。lockset规则跟踪每个线程持有的锁,并对每个变量取其各次访问时持有的锁集合的交集。如果至少两个线程碰过这个变量,其中一个写过它,而交集为空,就说明没有任何一把锁保护它:报告一个潜在竞态。没加锁的计数器会被标出,加锁的不会。这条规则代价低,但会误报。在下面的交接里,线程 0 写 x,然后通过锁把它交给线程 1。写入时没持有任何锁,所以规则抱怨,尽管这些访问实际上是有序的。
def lockset_races(trace): held = {} accesses = {} for tid, kind, name in trace: locks = held.setdefault(tid, set()) if kind == "acquire": locks.add(name) elif kind == "release": locks.discard(name) else: accesses.setdefault(name, []).append((tid, kind == "write", frozenset(locks))) racy = [] for name, history in accesses.items(): threads = {tid for tid, _, _ in history} writes = any(is_write for _, is_write, _ in history) common = frozenset.intersection(*(locks for _, _, locks in history)) if len(threads) >= 2 and writes and not common: racy.append(name) return sorted(racy)locked = [[("acquire", "m"), ("read", "x"), ("write", "x"), ("release", "m")]] * 2guarded = interleave(locked, [0, 0, 0, 0, 1, 1, 1, 1])handoff = [(0, "write", "x"), (0, "acquire", "m"), (0, "release", "m"), (1, "acquire", "m"), (1, "release", "m"), (1, "read", "x")]print("unlocked:", lockset_races(trace))print("locked: ", lockset_races(guarded))print("handoff: ", lockset_races(handoff))
unlocked: ['x'] locked: [] handoff: ['x']
当两个线程访问某变量、其中一个写它、且所有访问时没有共同持有的锁时,就报告该变量。它能从一次运行里找出缺失的加锁纪律,但当顺序来自共同锁之外的东西时会误报。
happens-before:什么真的可能重叠
第二个检测器问的是准确的问题:从第一次访问到第二次访问,有没有一条有序链?边有两种:线程内部的顺序,以及同一把锁的“释放后获取”这一对。happens-before关系是这两者的闭包。每个线程一个向量时钟,记录该线程见过什么;释放时把时钟存进锁,获取时和它逐项取最大。如果另一个线程较早的冲突访问的纪元是当前时钟没见过的,这次访问就是竞态。在交接中,释放与获取把写和读连了起来,所以没有竞态。它只报告真正的竞态,但只限于它观察到的那些访问。
def hb_races(trace): clocks = {} lock_clocks = {} history = {} racy = set() for tid, kind, name in trace: clock = clocks.setdefault(tid, {tid: 1}) if kind == "acquire": for u, value in lock_clocks.get(name, {}).items(): clock[u] = max(clock.get(u, 0), value) elif kind == "release": lock_clocks[name] = dict(clock) clock[tid] += 1 else: is_write = kind == "write" for u, was_write, epoch in history.get(name, []): if u != tid and (is_write or was_write) and clock.get(u, 0) < epoch: racy.add(name) history.setdefault(name, []).append((tid, is_write, clock[tid])) return sorted(racy)print("unlocked:", hb_races(trace))print("locked: ", hb_races(guarded))print("handoff: ", hb_races(handoff))
unlocked: ['x'] locked: [] handoff: []
ThreadSanitizer 这样的真实工具使用 happens-before,因为它在所见轨迹上不会误报。lockset 规则是找出“从没人保护过的变量”的更便宜办法,也是不错的第一道检查。对于正确性根本不靠锁的无锁结构,两者都替代不了模型检验器。
空间有多大?
模型检验器访问同一个程序,但它记住状态。在我们的程序里,状态就是每个线程走到了哪一步,所以线程各有 n 步时共有 (n1 + 1) × (n2 + 1) × ... 个状态,不管有多少种调度通向它们。下面的计数线程给出真实数据下的数字,把调度数和状态数并排摆出。
def count_states(lengths): total = 1 for n in lengths: total *= n + 1 return totaldef counter_programs(ups, locked): programs = [] for count in ups: program = [] for _ in range(count): program += ([("acquire", "m")] if locked else []) + [("read", "x"), ("write", "x")] + ([("release", "m")] if locked else []) programs.append(program) return programsreturns = [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, lock in (("unlocked", False), ("locked", True)): programs = counter_programs(ups, lock) lengths = [len(p) for p in programs] trace = interleave(programs, serial_schedule(lengths)) print(name, len(trace), "events", lockset_races(trace), hb_races(trace), count_schedules(lengths), "schedules,", count_states(lengths), "states")
unlocked 8 events ['x'] ['x'] 28 schedules, 21 states locked 16 events [] [] 1820 schedules, 65 states
一条串行轨迹就足以让两个检测器找出缺失的锁,而加锁的程序是干净的。模型检验器相对暴力枚举的优势就是 1,820 与 65 之间的倍数,而且无上限地增长:两个各 10 步的线程是 184,756 对 121。所以模型检验能覆盖简单枚举永远够不着的程序,而竞态检测器的代价只是对一次运行扫一遍。
第一次到达某个状态时记住它,之后绝不重复展开。许多调度会到达同一个状态,所以代价就从调度数降到了状态数。
你要实现什么
实验沿着本讲走:一次运行的轨迹、lockset 规则、用向量时钟实现的 happens-before 规则、状态数、计数线程的程序,以及对 SPY 上涨日的两个检测器和两种空间大小的研究。
- 函数 · 问题依赖
- interleave(programs, schedule)发生了什么,顺序如何?简单算术
- lockset_races(trace)哪些变量没有共同的锁?简单算术
- hb_races(trace)哪些变量有无序的访问?简单算术
- count_states(lengths)需要记住多少个状态?简单算术
- counter_programs(ups, locked)计数线程做什么?简单算术
- study(bars)两个检测器在 SPY 上一致吗?counter_programsinterleavelockset_raceshb_racescount_states
下一个主题:无锁代码还有一种危险,检测器和检验器都没法事后补救:一个线程释放了另一个线程还在读的节点。危险指针与纪元回收会构建两种经典的安全释放内存的办法,并用探索器展示:凡是你没有防住的调度,都会出现释放后使用。
实验:评分内容
6 个任务 · 100 分- 一次运行的轨迹10 分
- lockset 规则25 分
- 用向量时钟实现 happens-before30 分
- 模型检验器的状态5 分
- 计数线程5 分
- 基于 SPY 表的研究25 分