程序员调代码访谈:Russ Cox
12 Jan 2015 Translation本文由@Jan_zou翻译,原文出处:debuggers.co
『程序员调代码访谈』是 Karim Hamidou 发起的一个程序员访谈系列,受访者分享他们遇到的最难/最有意思的Bug,以及如何解决。
本文的受访者是 Russ Cox,写过内核代码、网络服务器、文件系统和一点图形代码。他目前是GO语言的主要开发者之一。
你是谁?
我是一个程序员。
我写程序。我在贝尔实验室的Plan 9上大约工作了十年,写内核代码、网络服务器、文件系统和一点图形代码。现在,我在谷歌工作,在那里我是GO语言的首席开发者之一。Go语言已经被证明是一个很好的通用语言,但其最初的设计目标是并发网络服务器,这种类型的程序我们曾为Plan 9和谷歌写过。
我也写与程序相关的东西。我最知名的文章是关于实现正则表达式,把一个zip文件放入自身里面,和在二维码中制造图片。我使用Go语言来实现上面这三个。
你见过的最有趣的bug是什么?
对我而言,最有趣的bug是那些能揭示程序工作方式根本而微妙地方的误解。一个好的bug就像一个好的科学实验:通过它,你会学到一些关于你正在探索的虚拟世界的意想不到的东西。
大约十年前,我工作使用联网的服务器,它使用线程来协调锁和条件变量。这个服务器是Plan 9的一部分,是用C语言写的。内部的malloc偶尔会崩溃,这通常意味着因为写后释放(write-after-free)错误引起的某种内存损坏。有一天,当大部分服务器的基准被禁用,我很幸运能有这样的崩溃重复发生。服务器大多数被禁用给了我在隔离bug方面有一个良好的开端。同时,重复性有可能使代码被一块块地削减,直到有一部分有非常清晰的关联。
在客户端最近断开之后,有问题的代码被清理。在服务器中,由两个线程共享每个客户端的数据结构:线程R从客户端连接中进行读取,线程W向其中写数据。线程R注意到断开在读取的信息中是EOF标识,它通知线程W,等待与线程W的确认,然后再释放每个客户端的数据结构。
要确认断开连接,线程W运行了如下代码:
qlock(&conn->lk);
conn->writer_done = 1;
qsignal(&conn->writer_ack);
qunlock(&conn->lk);
thread_exit();
同时,为了等待确认,线程R运行了如下代码:
qlock(&conn->lk);
while(!conn->writer_done)
qwait(&conn->writer_ack);
// The writer is done, and so are we:
// free the connection.
free(conn);
这是一个标准的锁和条件变量的代码片段:qwait
被定义为解除锁(here, conn->lk)
,等待,然后在返回前重新获得锁。线程R一旦观测到writer_done
被设置,它就知道线程W已经结束,因此线程R就能释放每个连接的数据结构。
线程R不调用qunlock(&conn->lk)
。我的理由是,在释放前调用qunlock
会发送混乱的信息:qunlock
建议协同另一个线程来使用conn
,但只有在没有其他线程使用conn
时,释放才是安全的。线程W是其他线程,它已经结束了。但为什么当我在free(conn)
前加入qunlock(&conn->lk)
时崩溃停止。为什么会这样呢?
要回答这个问题,我们必须来看看锁是如何实现的。
从概念上讲,一个锁的核心是具有解锁和锁定两个标记的变量。要获得一个锁,在一个原子操作中,一个线程检查其变量是否被标记为未锁定,如果是这样,则将其标记为锁定。因为是原子操作,如果两个(或更多)线程来试图获得这个锁,只有一个能成功。这线程(姑且称为线程A)现在持有了这个锁。争夺该锁的另一个线程(线程B)看到变量被标记为锁定,现在必须决定该怎么做。
首先,最简单的办法就是再不断的一次次尝试。最终,线程A将解除锁定(通过将变量标记为未锁定),此时线程B的原子操作会成功。这种方法被称为自旋(spinning)。同时,使用这种方法的锁被称为自旋锁。
一个简单的自旋锁的实现是这样的:
struct SpinLock
{
int bit;
};
void
spinlock(SpinLock *lk)
{
for(;;) {
if(atomic_cmp_and_set(&lk->bit, 0, 1))
return;
}
}
void
spinunlock(SpinLock *lk)
{
atomic_set(&lk->bit, 0);
}
自旋锁的核心是位字段,它通过0和1两个值来指示解锁和锁定。atomic_cmp_and_set
和atomic_set
使用特殊的机器指令来对lk->bit
进行原子操作。
如果锁从未保持很长时间,自旋才有意义。因此线程B的自旋循环只执行少数几次。如果锁能保持更长的时间,自旋会持续浪费CPU并与操作系统调度进行糟糕地交互。
其次,更普遍的方法是维持一个想要获得锁的线程队列。在这种方法中,当线程B发现锁已经在被持有状态时,它将自身增加到队列中,并使用操作系统原语来休眠。当线程A最终释放锁时,它检查队列,发现线程B,并且使用一个操作系统原语来唤醒线程B。这种方法被称为队列,使用这种方法的锁被称为队列锁。当锁能保持很长一段时间时,队列比自旋更有效。
队列锁的队列需要自己的锁,这几乎总是一个自旋锁。在我使用的库中,qlock
和qunlock
的实现如下:
struct QLock
{
SpinLock spin;
Thread *owner;
ThreadQueue queue;
};
void
qlock(QLock *lk)
{
spinlock(&lk->spin);
if(lk->owner == nil) {
lk->owner = current_thread();
spinunlock(&lk->spin);
return;
}
push(&lk->queue, current_thread());
spinunlock(&lk->spin);
os_sleep();
}
void
qunlock(QLock *lk)
{
Thread *t;
spinlock(&lk->spin);
t = pop(&lk->queue);
lk->owner = t;
if(t != nil)
os_wakeup(t);
spinunlock(&lk->spin);
}
队列锁的核心是owner
字段。如果owner
的值为nil
,锁被解锁;否则owner
会记录持有锁的线程。通过持有自旋锁lk->spin
,lk->owner
被执行为原子操作。
回到之前提到的bug。
崩溃代码中的锁就是队列锁。线程R和线程W之间的确认协议在线程W调用qunlock
和线程R调用qlock
之间设置了一场竞争(无论是在代码中显示调用还是在qwait
内部隐式调用)。其中哪个调用会先发生呢?
如果首先发生的线程W调用qunlock
,那么线程R调用qlock
发现锁未被锁定,则锁定它。这样一切都进行得没有问题。
如果首先发生的是线程R调用qlock
,它发现线程W持有锁,因此它将现线程R添加到队列中,并让线程R休眠。然后线程W执行调用qunlock
。它将owner
设为线程R,唤醒线程R,并将自旋锁解锁。当线程W解锁自旋锁时,线程R可能已经开始运行,线程R可能已经调用了free(conn)
。spinunlock
的atomic_set
指令将conn->lk.spin.bit
写为零。这就是写后释放,且如果存储分配器不想这里有零,这个零就会导致崩溃(或内存泄露,或一些其他的行为)。
但是,这是服务器的代码错误还是qunlock
错误?
此处的根本误解在队列锁API的定义中。队列锁需要在释放前被解锁?或者队列锁需要在锁定时支持被释放?我写过队列锁的程序,将它作为一个跨平台的库模拟Plan 9的一部分。当时我写qunlock
时,我还没有遇到这个问题。
- 如果队列锁必须在未锁定时才能释放,那么
qunlock
的实现是正确的,服务器的代码必须改变。如果线程R在释放前调用qunlock
,那么线程R调用qunlock
中的spinlock
必须等待线程W调用qunlock
中的spinunlock
执行。因此,随着线程R的调用释放,线程W将真的会结束。 - 如果队列锁能在锁定时被释放,那么服务器的代码是正确的,
qunlock
必须改变:os_wakeup
放弃对lk
的控制,且必须延迟到执行spinunlock
后。
Plan 9文档中队列锁的部分没有直接解决这个问题,但这种释放锁定的队列锁的实现是没有害处的。因为我曾经使用我的库来运行修改Plan 9软件,我改变了锁的实现,在执行spinunlock
后调用os_wakeup
。两年后,当修复一个不同的bug时,我改变了服务器的实现来以防万一地调用qunlock
。POSIX(Portable Operating System Interface)标准定义的pthread_mutex_destroy函数对于相同的设计问题给出了不同的答案:“销毁一个未锁定的初始化互斥量是安全的。试图销毁一个锁定的互斥量会导致不确定的行为。”
我们学到了什么?
对于在释放前不调用qunlock
,我给出的理由做出了一个隐含的假设,那两者是独立的。在看过内部的实现后,我们可以知道为什么这两者相互关联,以及为什么API可以指定你必须在销毁它前解除锁定,正如POSIX所做的。创建一个“抽象泄露“,这是涉及影响API实现的一个示例。
让这个bug有趣的地方是,它是由手动内存管理和并发性之间的复杂交互所引起的。显然,一个程序必须在释放前停止使用资源。但一个并发程序必须在释放前停止所有线程使用资源。在很好的一天,这可能需要记录或多方协调来跟踪哪个线程仍在使用资源。在糟糕的一天,这可能需要读取锁的实现来了解在不同线程间进行操作的确切顺序。
在现代计算机的客户端、服务器和云中,并发是大多数程序的一个基本问题。在那个世界中,选择垃圾收集而不是手动内存管理来消除泄露抽象的来源,使程序更简单、更容易解释。
其他补充的内容?
在文章的开始,我提到好的bug帮助你学到一些关于你正在探索的虚拟世界的意想不到的东西。这对Maurice Wilkes和他的团队更是如此,他创造了第一个实用的存储程序的计算机EDSAC。他们在EDSAC上运行的第一个程序(打印平方数)运行正确,但第二个没有:1949年5月7日的日志上写着“素数表尝试程序不正确”。那是一个星期六,这使得它成为第一个工作在一个错误程序上的周末。
他们学到了什么?Wilkes后来回忆说,
“到1949年6月,人们才开始意识到,让程序每次都运行正确不是那么容易。……在EDSAC和打孔设备间检查,让我突然意识到我余生的大部分时间都要花在寻找我程序中的错误。“(Wilkes,第145页)
想要了解更多有关这早期的历史,请看Brian Hayes的“The Discovery of Debugging”和Martin Campbell-Kelly的“The Airy Tape: An Early Chapter in the History of Debugging”