ConTestを使用したマルチスレッドユニットテスト

12570 ワード

パラレルテストが困難な理由とConTestアシストテストの使用方法
パラレルプログラミングはバグを生じやすいことはよく知られている.さらに深刻なのは、開発プロセスの末期にこれらのパラレルバグが深刻な損害をもたらすと、それらを発見し、デバッグすることが困難になることが多い.それらを徹底的にデバッグしても、従来のユニットテストの実践では、パラレルバグが漏れる可能性があります.本論文では,パラレル専門家のShmuel UrとYarden Nir‐Buchbinderは,パラレルバグがなぜこのようにキャプチャしにくいのかを説明し,IBM Researchの新しいソリューションを紹介した.
パラレルプログラムがバグを生成しやすいのは秘密ではありません.このプログラムの作成は挑戦であり,プログラミング中にこっそり発生したバグは容易に発見されない.多くのパラレルバグは、システムテスト、機能テスト時にのみ発見されるか、またはユーザーによって発見される.その場合、修復には高価な費用がかかります.修復できると仮定します.デバッグが難しいからです.
本稿では,並列プログラムの範囲をテスト,デバッグ,測定するためのツールであるConTestを紹介した.すぐにわかるように、ConTestはユニットテストの代替者ではありませんが、パラレルプログラムのユニットテスト障害を処理する補完技術です.
注意この文書にはサンプルパッケージが含まれています.ConTestがどのように動作するかに関する基本的な知識を理解すれば、自分でパッケージを使用してテストすることができます.

なぜユニットテストが足りないのか


どんなJavaに聞いても™ 開発者は、ユニットテストが良い実践であることを教えてくれます.ユニットテストに適切な投資を行い、その後、リターンが得られます.ユニットテストにより、バグを早期に発見することができ、ユニットテストを行わないよりも容易に修復することができる.しかし、通常のユニットテスト方法(完全にテストが行われた場合でも)は、パラレルバグの検索には有効ではありません.それがプログラムの末期に逃げられる理由です
なぜユニットテストでパラレルバグが頻繁に漏れているのですか?通常、パラレルプログラム(およびbug)と呼ばれる問題は、それらの不確実性にある.しかし、ユニットテストの目的では、パラレルプログラムが非常に決定されていることがでたらめである.次の2つの例では、この点を説明します.

修飾なしName Printer


1つ目の例は、2つの部分からなる名前を印刷する以外に何もしないクラスです.教育の目的で、私たちはこのタスクを3つのスレッドに分けました.1つのスレッドは人名を印刷し、1つのスレッドはスペースを印刷し、1つのスレッドは姓と新しい行を印刷します.ロックの同期および呼び出しを含む成熟した同期プロトコルは、すべてのことが正しい順序で起こることを保証することができる.リスト1でご覧のように、wait()はユニットテストとして機能し、名前「Washington Ir ving」で呼び出します.
リスト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.修飾なしName Printer
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およびその同期プロトコルがパラレルクラスであることを想定します.ユニットテストを何度も実行し、毎回よく実行します.自然と、安心して正しいと思います.しかし、先ほどご覧のように、同期プロトコルがまったくない場合でも出力は正しく、多くのエラープロトコルが実装されている場合でも出力が正しいと安全に推定できます.そのため、プロトコルをテストしたと思ったら、実際にテストしていません.
もう一つの例を見てみましょう.

マルチバグのタスクキュー


次のクラスは、一般的なパラレルユーティリティモデルです.タスクキューです.タスクをキューに入れる方法と、タスクをキューに出す方法があります.notifyAll()メソッドは、キューからタスクを削除する前に、キューが空であるかどうかを確認し、空である場合は待機します.main()メソッドは、待機中のすべてのスレッド(存在する場合)を通知します.この例を簡単にするために、ターゲットは文字列だけで、タスクはそれらを印刷することです.もう一度、work()はユニットテストとして機能する.ちなみに、このようなバグがあります.
リスト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();
   }
}

テストを実行した後、すべて正常に見えます.クラスの開発者として、このテストは便利に見えます(2つのproducer、2つのconsumerとそれらの間のenqueue()をテストできる興味深い順序)、正しく実行できます.
しかし、ここには私たちが言及したバグがあります.ご覧になりましたか.もし見なかったら、ちょっと待ってください.私たちはすぐにそれを捕獲します.
 


パラレルプログラミングにおける決定性


この2つのサンプルユニットテストでパラレルバグがテストされないのはなぜですか?スレッドスケジューラは、原則として、実行中にスレッドを切り替え、異なる順序で実行できますが、切り替えないことが多いです.ユニットテストでの並列タスクは通常小さく、かつ少ないため、スケジューラがスレッドを切り替える前に、強制しない限り、通常は終了まで実行されます(すなわち、main()を介します).また、スレッド切替が確実に実行されると、プログラムを実行するたびに同じ位置で切替されることが多い.
前に述べたように、問題はプログラムがあまりにも確定していることです.多くのインタリーブ状況のインタリーブ(異なるスレッドでのコマンドの相対順序)でテストを終了しただけです.もっと交錯するのはいつ実験しますか?パラレル・クラスとプロトコルの間により複雑な相互影響がある場合、つまりシステム・テストと機能テストを実行する場合、または製品全体がユーザーのサイトで実行される場合、これらの場所はバグが露出する場所です.
 

ユニットテストにConTestを使用


ユニットテストを行う場合、JVMの決定性が低く、より「ぼやけている」必要があります.これがConTestを使う場所です.ConTestを使用してインベントリ2waitを何回か実行すると、リスト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)

上記のような順序の結果や相次ぐ順序の結果を得る必要はないことに注意する.後の2つの結果を見る前に、前の2つの結果を何回か見ることができます.しかし、すぐにすべての結果が表示されます.ConTestは各種の交錯状況を出現させる.ランダムにインタリーブを選択するため、同じテストを実行するたびに異なる結果が発生する可能性があります.比較すると、ConTestを使用してインベントリ1に示すwait()を実行すると、常に予想される結果が得られます.この場合、同期プロトコルは正しい順序で強制的に実行されるため、ConTestは正当なインタリーブを生成するだけである.
ConTestを使用してNakedNamePrinterを実行すると、ユニットテストで許容できる異なる順序の結果が得られます.しかし、数回運転すると、24行目のNamePrinterが突然PrintQueueを投げ出す.バグは次のような状況に潜んでいます.
2つのconsumerスレッドが起動し、キューが空であることを発見し、LinkedList.removeFirst()を実行した.
1つのproducerはタスクをキューに入れ、2つのconsumerに通知します.
consumerはロックを取得し、タスクを実行し、キューを空にします.ロックを解除します
2番目のconsumerはロックを取得し(通知されたので下に進むことができます)、タスクを実行しようとしましたが、キューが空になりました.
これは、このユニットテストの一般的なインタリーブではありませんが、上記のシーンは合法的であり、クラスをより複雑に使用する場合に発生する可能性があります.ConTestを使用すると、ユニットテストで発生します.(ちなみに、バグの修復方法を知っていますか?注意:NoSuchElementExceptionの代わりにwait()を使用すると、この状況の問題を解決できますが、他の状況では失敗します!)
 


ConTestの働き方


ConTestの背後にある基本原理は非常に簡単です.Instrumentationフェーズでクラスファイルを変換し、ConTestランタイム関数を呼び出すために選択した場所を注入します.実行時、ConTestはこれらの場所でコンテキスト変換を起こそうとする場合があります.スレッドの相対的な順序が結果に影響を及ぼす可能性が高い場所を選択します.synchronizedブロックに入る場所、共有変数にアクセスする場所などです.コンテキスト変換は、notify()またはnotifyAll()のような方法を呼び出すことによって試みられる.実行するたびに異なるインタリーブを試行するためにランダムに決定します.プローブ法を用いて典型的なバグを表示しようとした.
注意ConTestは、実際にバグが表示されているかどうか分かりません.プログラムがどのように実行されるかという概念はありません.ユーザーがテストを行い、どのテスト結果が正しいと判断されるか、どのテスト結果がバグを示すかを知る必要があります.ConTestはバグを表示するのを助けるだけです.一方、エラー・アラートはありません.JVMルールでは、ConTestを使用して生成されたすべてのインタリーブが合法です.
ご覧のように、同じテストを複数回実行することで複数の値が得られます.実際には、一晩中繰り返し運転することをお勧めします.そして、可能なすべてのインタリーブが実行されたと自信を持って考えることができます.