前面的文章中提到,由于并发系统的不确定性,导致对系统的测试和调试是非常困难的。下面以共享缓存区为例来介绍如何在并发场景下应用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+