程式設計師調程式碼訪談:Russ Cox

Janzou發表於2015-01-12

『程式設計師調程式碼訪談』是 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執行了如下程式碼:

同時,為了等待確認,執行緒R執行了如下程式碼:

這是一個標準的鎖和條件變數的程式碼片段: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)。同時,使用這種方法的鎖被稱為自旋鎖

一個簡單的自旋鎖的實現是這樣的:

自旋鎖的核心是位欄位,它通過0和1兩個值來指示解鎖和鎖定。atomic_cmp_and_setatomic_set使用特殊的機器指令來對lk->bit進行原子操作。

如果鎖從未保持很長時間,自旋才有意義。因此執行緒B的自旋迴圈只執行少數幾次。如果鎖能保持更長的時間,自旋會持續浪費CPU並與作業系統排程進行糟糕地互動。

其次,更普遍的方法是維持一個想要獲得鎖的執行緒佇列。在這種方法中,當執行緒B發現鎖已經在被持有狀態時,它將自身增加到佇列中,並使用作業系統原語來休眠。當執行緒A最終釋放鎖時,它檢查佇列,發現執行緒B,並且使用一個作業系統原語來喚醒執行緒B。這種方法被稱為佇列,使用這種方法的鎖被稱為佇列鎖。當鎖能保持很長一段時間時,佇列比自旋更有效。

佇列鎖的佇列需要自己的鎖,這幾乎總是一個自旋鎖。在我使用的庫中,qlockqunlock的實現如下:

佇列鎖的核心是owner欄位。如果owner的值為nil ,鎖被解鎖;否則owner會記錄持有鎖的執行緒。通過持有自旋鎖lk->spinlk->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)spinunlockatomic_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後來回憶說,

“By June 1949, people had begun to realize that it was not so easy to get a program right as had at one time appeared. … It was on one of my journeys between the EDSAC room and the punching equipment that the realization came over me with full force that a good part of the remainder of my life was going to be spent in finding errors in my own programs.” (Wilkes, p. 145)

“到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.”

相關文章