並行程序易於產生 bug 不是什麼秘密。編寫這種程序是一種挑戰,並且在編程過程中悄悄產生的 bug 不容易被發現。許多並行 bug 只有在系統測試、功能 測試時才能被發現或由用戶發現。到那時修復它們需要高昂的費用 -- 假設能夠 修復它們 -- 因為它們是如此難於調試。
在本文中,我們介紹了 ConTest,一種用於測試、調試和測量並行程序范圍 的工具。正如您將很快看到的,ConTest 不是單元測試的取代者,但它是處理並 行程序的單元測試故障的一種補充技術。
為什麼單元測試還不夠
當問任何 Java™ 開發者時,他們都會告訴您單元測試是一種好的實踐。 在單元測試上做適當的投入,隨後將得到回報。通過單元測試,能較早地發現 bug 並且能比不進行單元測試更容易地修復它們。但是普通的單元測試方法(即 使當徹底地進行了測試時)在查找並行 bug 方面不是很有效。這就是為什麼它 們能逃到程序的晚期 。
為什麼單元測試經常遺漏並行 bug?通常的說法是並行程序(和 bug)的問 題在於它們的不確定性。但是對於單元測試目的而言,荒謬性在於並行程序是非 常 確定的。下面的兩個示例解釋了這一點。
無修飾的 NamePrinter
第一個例子是一個類,該類除了打印由兩部分構成的名字之 外,什麼也不做。出於教學目的,我們把此任務分在三個線程中:一個線程打印 人名,一個線程打印空格,一個線程打印姓和一個新行。一個包括對鎖進行同步 和調用 wait() 和 notifyAll() 的成熟的同步協議能保證所有事情以正確的順 序發生。正如您在清單 1 中看到的,main() 充當單元測試,用名字 "Washington Irving" 調用此類:
清單 1. NamePrinter
public class NamePrinter {
private final String firstName;
private final String surName;
private final Object lock = new Object();
private boolean printedFirstName = false;
private boolean spaceRequested = false;
public NamePrinter(String firstName, String surName) {
this.firstName = firstName;
this.surName = surName;
}
public void print() {
new FirstNamePrinter().start();
new SpacePrinter().start();
new SurnamePrinter().start();
}
private class FirstNamePrinter extends Thread {
public void run() {
try {
synchronized (lock) {
while (firstName == null) {
lock.wait();
}
System.out.print(firstName);
printedFirstName = true;
spaceRequested = true;
lock.notifyAll();
}
} catch (InterruptedException e) {
assert (false);
}
}
}
private class SpacePrinter extends Thread {
public void run() {
try {
synchronized (lock) {
while ( ! spaceRequested) {
lock.wait();
}
System.out.print(' ');
spaceRequested = false;
lock.notifyAll();
}
} catch (InterruptedException e) {
assert (false);
}
}
}
private class SurnamePrinter extends Thread {
public void run() {
try {
synchronized(lock) {
while ( ! printedFirstName || spaceRequested || surName == null) {
lock.wait();
}
System.out.println(surName);
}
} catch (InterruptedException e) {
assert (false);
}
}
}
public static void main(String[] args) {
System.out.println();
new NamePrinter("Washington", "Irving").print();
}
}
如果您願意,您可以編譯和運行此類並且檢驗它是否像預期的那樣把名字打 印出來。 然後,把所有的同步協議刪除,如清單 2 所示:
清單 2. 無修飾的 NamePrinter
public class NakedNamePrinter {
private final String firstName;
private final String surName;
public NakedNamePrinter(String firstName, String surName) {
this.firstName = firstName;
this.surName = surName;
new FirstNamePrinter().start();
new SpacePrinter().start();
new SurnamePrinter().start();
}
private class FirstNamePrinter extends Thread {
public void run() {
System.out.print(firstName);
}
}
private class SpacePrinter extends Thread {
public void run() {
System.out.print(' ');
}
}
private class SurnamePrinter extends Thread {
public void run() {
System.out.println(surName);
}
}
public static void main(String[] args) {
System.out.println();
new NakedNamePrinter("Washington", "Irving");
}
}
這個步驟使類變得完全錯誤:它不再包含能保證事情以正確順序發生的指令 。但我們編譯和運行此類時會發生什麼情況呢?所有的事情都完全相 同!"Washington Irving" 以正確的順序打印出來。
此試驗的寓義是什麼?設想 NamePrinter 以及它的同步協議是並行類。您運 行單元測試 -- 也許很多次 -- 並且它每次都運行得很好。自然地,您認為可以 放心它是正確的。但是正如您剛才所看到的,在根本沒有同步協議的情況下輸出 同樣也是正確的,並且您可以安全地推斷在有很多錯誤的協議實現的情況下輸出 也是正確的。因此,當您認為 已經測試了您的協議時,您並沒有真正地 測試它 。
現在我們看一下另外的一個例子。
多bug的任務隊列
下面的類是一種常見的並行實用程序模型:任務隊列。它有一個能使任務入 隊的方法和另外一個使任務出隊的方法。在從隊列中刪除一個任務之前,work() 方法進行檢查以查看隊列是否為空,如果為空則等待。enqueue() 方法通知所有 等待的線程(如果有的話)。為了使此示例簡單,目標僅僅是字符串,任務是把 它們打印出來。再一次,main() 充當單元測試。順便說一下,此類有一個 bug 。
清單 3. PrintQueue
import java.util.*;
public class PrintQueue {
private LinkedList<String> queue = new LinkedList<String>();
private final Object lock = new Object();
public void enqueue(String str) {
synchronized (lock) {
queue.addLast(str);
lock.notifyAll();
}
}
public void work() {
String current;
synchronized(lock) {
if (queue.isEmpty()) {
try {
lock.wait();
} catch (InterruptedException e) {
assert (false);
}
}
current = queue.removeFirst();
}
System.out.println(current);
}
public static void main(String[] args) {
final PrintQueue pq = new PrintQueue();
Thread producer1 = new Thread() {
public void run() {
pq.enqueue("anemone");
pq.enqueue("tulip");
pq.enqueue("cyclamen");
}
};
Thread producer2 = new Thread() {
public void run() {
pq.enqueue("iris");
pq.enqueue("narcissus");
pq.enqueue("daffodil");
}
};
Thread consumer1 = new Thread() {
public void run() {
pq.work();
pq.work();
pq.work();
pq.work();
}
};
Thread consumer2 = new Thread() {
public void run() {
pq.work();
pq.work();
}
};
producer1.start();
consumer1.start();
consumer2.start();
producer2.start();
}
}
運行測試以後,所有看起來都正常。作為類的開發者,您很可能感到非常滿 意:此測試看起來很有用(兩個 producer、兩個 consumer 和它們之間的能試 驗 wait 的有趣順序),並且它能正確地運行。
但是這裡有一個我們提到的 bug。您看到了嗎?如果沒有看到,先等一下; 我們將很快捕獲它。
並行程序設計中的確定性
為什麼這兩個示例單元測試不能測試出並行 bug?雖然原則上線程調度程序 可以 在運行的中間切換線程並以不同的順序運行它們,但是它往往 不進行切換 。因為在單元測試中的並行任務通常很小同時也很少,在調度程序切換線程之前 它們通常一直運行到結束,除非強迫它(也就是通過 wait())。並且當它確實 執行了線程切換時,每次運行程序時它往往都在同一個位置進行切換。
像我們前面所說的一樣,問題在於程序是太確定的:您只是在很多交錯情況 的一種交錯(不同線程中命令的相對順序)中結束了測試。更多的交錯在什麼時 候試驗?當有更多的並行任務以及在並行類和協議之間有更復雜的相互影響時, 也就是當您運行系統測試和功能測試時 -- 或當整個產品在用戶的站點運行時, 這些地方將是暴露出 bug 的地方。
使用 ConTest 進行單元測試
當進行單元測試時需要 JVM 具有低的確定性,同時是更“模糊的”。這就是 要用到 ConTest 的地方。如果使用 ConTest 運行幾次 清單 2 的 NakedNamePrinter, 將得到各種結果,如清單 4 所示:
清單 4. 使用 ConTest 的無修飾的 NamePrinter
>Washington Irving (the expected result)
> WashingtonIrving (the space was printed first)
>Irving
Washington (surname + new-line printed first)
> Irving
Washington (space, surname, first name)
注意不需要得到像上面那樣順序的結果或相繼順序的結果;您可能在看到後 面的兩個結果之前先看到幾次前面的兩個結果。但是很快,您將看到所有的結果 。 ConTest 使各種交錯情況出現;由於隨機地選擇交錯,每次運行同一個測試 時都可能產生不同的結果。相比較的是,如果使用 ConTest 運行如 清單 1 所 示的 NamePrinter ,您將總是得到預期的結果。在此情況下,同步協議強制以 正確的順序執行,所以 ConTest 只是生成合法的 交錯。
如果您使用 ConTest 運行 PrintQueue,您將得到不同順序的結果,這些對 於單元測試來說可能是可接受的結果。但是運行幾次以後,第 24 行的 LinkedList.removeFirst() 會突然拋出 NoSuchElementException 。bug 潛藏 在如下的情形中:
啟動了兩個 consumer 線程,發現隊列是空的,執行 wait()。
一個 producer 把任務放入隊列中並通知兩個 consumer。
一個 consumer 獲得鎖,運行任務,並把隊列清空。然後它釋放鎖。
第二個 consumer 獲得鎖(因為通知了它所以它可以繼續向下進行)並試圖 運行任務,但是現在隊列是空的。
這雖然不是此單元測試的常見交錯,但上面的場景是合法的並且在更復雜地 使用類的時候可能發生這種情況。使用 ConTest 可以使它在單元測試中發生。 (順便問一下,您知道如何修復 bug 嗎?注意:用 notify() 取代 notifyAll () 能解決此情形中的問題,但是在其他情形中將會失敗!)
ConTest 的工作方式
ConTest 背後的基本原理是非常簡單的。instrumentation 階段轉換類文件 ,注入挑選的用來調用 ConTest 運行時函數的位置。在運行時,ConTest 有時 試圖在這些位置引起上下文轉換。 挑選的是線程的相對順序很可能影響結果的 那些位置:進入和退出 synchronized 塊的位置、訪問共享變量的位置等等。通 過調用諸如 yield() 或 sleep() 方法來嘗試上下文轉換。決定是隨機的以便在 每次運行時嘗試不同的交錯。使用試探法試圖顯示典型的 bug。
注意 ConTest 不知道實際是否已經顯示出 bug -- 它沒有預期程序將如何運 行的概念。是您,也就是用戶應該進行測試並且應該知道哪個測試結果將被認為 是正確的以及哪個測試結果表示 bug。ConTest 只是幫助顯示出 bug。另一方面 ,沒有錯誤警報:就 JVM 規則而言所有使用 ConTest 產生的交錯都是合法的。
正如您看到的一樣,通過多次運行同一個測試得到了多個值。實際上,我們 推薦整個晚上都反復運行它。然後您就可以很自信地認為所有可能的交錯都已經 執行過了。
ConTest 的特性
除了它的基本的方法之外,ConTest 在顯示並行 bug 方面引入了幾個主要特 性:
同步覆蓋:在單元測試中極力推薦測量代碼覆蓋,但是在測試並行程序時使 用它,代碼覆蓋容易產生誤導。在前兩個例子中,無修飾的 NamePrinter 和多 bug 的 Print Queue,給出的單元測試顯示完整的語句覆蓋(除了 InterruptedException 處理)沒有顯示出 bug。 同步覆蓋彌補了此缺陷:它測 量在 synchronized 塊之間存在多少競爭;也就是說,是否它們做了“有意義的 ”事情,您是否覆蓋了有趣的交錯。
死鎖預防: ConTest 可以分析是否以沖突的順序嵌套地擁有鎖,這表明有死 鎖的危險。此分析是在運行測試後離線地進行。
調試幫助:ConTest 可以生成一些對並行調試有用的運行時報告:關於鎖的 狀態的報告(哪個線程擁有哪個鎖,哪個線程處於等待狀態等等),當前的線程 的位置的報告和關於最後分配給變量和從變量讀取的值的報告。您也可以遠程進 行這些查詢;例如,您可以從不同的機器上查詢服務器(運行 ConTest)的狀態 。另一個對調試有用的特性可能是重放,它試圖重復一個給定運行的交錯(不能 保證,但是有很高的可能性)。
UDP 網絡混亂:ConTest 支持通過 UDP(數據報)套接字進行網絡通信的域 中的並行混亂的概念。 UDP 程序不能依靠網絡的可靠性;分組可能丟失或重新 排序,它依靠應用程序處理這些情況。與多線程相似,這帶來對測試的挑戰:在 正常環境中,分組往往是按正確的順序到達,實際上並沒有測試混亂處理功能。 ConTest 能夠模擬不利的網絡狀況,因此能夠運用此功能並顯示它的 bug。
挑戰與未來方向
ConTest 是為 Java 平台創建的。用於 pthread 庫的 C/C++ 版本的 ConTest 在 IBM 內部使用,但是不包含 Java 版的所有特性。出於兩種原因, 用 ConTest 操作 Java 代碼比操作 C/C++ 代碼簡單:同步是 Java 語言的一部 分,並且字節碼非常容易使用。我們正在開發用於其他庫的 ConTest,例如 MPI 庫。如果您想要使用 C/C++ 版的ConTest,請與作者聯系。硬實時軟件對於 ConTest 也是一個問題,因為工具是通過增加延遲而工作。為使用 ConTest,我 們正在研究與監視硬實時軟件相似的方法,但是在目前我們還不能確定如何克服 此問題。
至於將來的方向,我們正在研究發布一種 監聽器 體系結構,它將允許我們 在 ConTest 上應用基於監聽器的工具。使用監聽器體系結構將使創建原子數檢 查器、死鎖偵聽器和其他分析器以及嘗試不必寫入有關的基礎設施的新的延遲機 制成為可能。
結束語
ConTest 是用於測試、調試和測量並行程序的范圍的工具。它由位於以色列 海法市的 IBM Research 實驗室的研究人員開發,可以 從 alphaWorks 獲得 ConTest 的有限制的試用版。如果您有關於 ConTest 的更多問題,請聯系作者 。
本文配套源碼