前面的文章中提到,由于并发系统的不确定性,导致对系统的测试和调试是非常困难的。下面以共享缓存区为例来介绍如何在并发场景下应用TLA+。
假设要设计一个共享缓冲区系统,包含三块内容:生产者、消费者、缓冲区。
系统主要的同步控制通过缓冲区来完成,生产者和消费者只负责简单的写入和读取。由此可见缓冲区就是系统设计的关键所在,其JAVA实现如下:
public class Buffer<E> { public final int capacity; private final E[] store; private int head, tail, size; @SuppressWarnings("unchecked") public Buffer (int capacity) { this.capacity = capacity; this.store = (E[])new Object[capacity]; } private int next (int x) { return (x + 1) % store.length; } public synchronized void put (E e) throws InterruptedException { // 同步写入 while (isFull()) // 满则等待 wait(); notify(); store[tail] = e; tail = next(tail); size++; } public synchronized E get () throws InterruptedException { // 同步读取 while (isEmpty()) // 空则等待 wait(); notify(); E e = store[head]; store[head] = null; // for GC head = next(head); size--; return e; } public synchronized boolean isFull () { // 判断已满 return size == capacity; } public synchronized boolean isEmpty () { // 判断为空 return size == 0; } }
上述代码完全满足设计需求,无论在实现逻辑上,亦或可读性上都非常清晰易懂,通过代码走查应该问题不大,就这样提交到master分支,并进入了生产环境中。
系统运行了一段时候后的某天深夜你被电话惊醒,被运维人员告知系统出了异常,需要马上解决。这时正式开始了痛苦且充满不确定的分布式系统调试过程。我们可能会这么干:
增加一些有助于调试定位的打印
public synchronized void put (E e) throws InterruptedException { String name = Thread.currentThread().getName(); while (isFull()) { logger.info(String.format("buffer full; %s waits", name)); waitSetSize++; wait(); logger.info(String.format("%s is notified", name)); } logger.info(String.format("%s successfully puts", name)); notify(); if (waitSetSize > 0) waitSetSize--; logger.fine(String.format("wait set size is %d", waitSetSize)); store[tail] = e; tail = next(tail); size++; lastChange = System.currentTimeMillis(); }
ok,做好了准备工作,开始调试。
在实验室照搬故障现场进行测试 几个小时过后,没有出现任何问题,效率太低这时只有放弃拷机测试。 开始考虑对系统进行简化,模拟构建些生产者和消费者,来提升测试效率。
在实验室通过简化和模拟来测试
以模拟生产者为例子:
class Producer extends Thread { public Producer (int n) { super("producer-"+n); } public void run () { String name = getName(); try { while (true) { sleep(rand.nextInt(sleepProd)); buffer.put(name); } } catch (InterruptedException e) { return; } } }
通过sleep随机时间来模拟业务过程,简化写入数据(仅写入生产者实例名称)。对于有经验的开发者,在共享资源竞争的情况下最容易出现的就是死锁问题。
long time = System.currentTimeMillis(); while (true) { Thread.sleep(60000); // check for deadlock every minute synchronized (buffer) { if (buffer.waitSetSize == threadCount) { logger.severe(String.format ("DEADLOCK after %d messages and %.1f seconds!", buffer.msgCount, (buffer.lastChange - time)/ 1e3)); for (Thread t : participants) t.interrupt(); return; } } }
在主函数中定期(每分钟)检查生产者和消费者是否存在死锁问题。
问题发生
在不断的尝试过程中,在缓冲区容量为2,生产者数量为3,消费者数量为2的场景下,终于出现了死锁。
java AnnotatedBuffer 2 3 2 3 2 01:35:39.047: DEADLOCK after 3970423422 messages and 1576335.9 seconds!
可以看下中间所经历的消息交互数量(近40亿次)和时间(大约18天)。这还是在模拟系统规模极小和业务速度极快的情况下。说不定过程中又放弃了。
当然也不是每次都会这么慢,这只是一种执行的可能性。
Spec中的注释已经很清晰了,下面就没有注释的内容进行解释:
主要设置了四块内容:
点击执行按钮后,很快就检查出了不变量NoDeadlock冲突,右下角Error-Trace窗口展示的就是该冲突所出现的过程。总共经过24步waitSet就包含了全部线程,你可以通过trace清楚的了解到错误发生的整个过程。整个系统涵盖的总状态数量为387。
感兴趣的读者可以将生产者设置为{p1,p2},然后再运行TLC,你会发现TLC检测通过了。
减少了一个生产者整个问题的状态成指数级下降。结果正确证明了系统是否异常跟buffer的设计关系也没有那么密切,而跟资源如何规划关系更紧密。通过泛化可以推导出以下结论:
2 * BufCapacity >= Producers + Consumers
问题:在内部培训时,有同学提出疑问:Spec的编写成本跟真实的buffer.java差不多,建模的性价比是否太低?
解答:其实这个问题得这样看当完成TLA+的spec和模型配置后,相当于实现了整个buffer系统(包括:生产者、消费者以及缓冲区)而非仅仅实现了buffer.java类。以较小的成本实现原型系统和对系统的全面状态检查才是TLA+的强大之处。
http://www.cs.unh.edu/~charpov/teaching-cs745_845-example.html
Previous
形式化规范语言TLA+