1. 序章

このチュートリアルでは、並行プログラムのテストの基本について説明します。 主に、スレッドベースの同時実行性とそれがテストで提示する問題に焦点を当てます。

また、これらの問題のいくつかを解決し、Javaでマルチスレッドコードを効果的にテストする方法についても理解します。

2. 並行プログラミング

並行プログラミングとは、大きな計算をより小さな比較的独立した計算に分解するプログラミングを指します。

この演習の目的は、これらの小さな計算を同時に、場合によっては並行して実行することです。 これを達成する方法はいくつかありますが、目的は常にプログラムをより高速に実行することです。

2.1. スレッドと並行プログラミング

プロセッサがこれまで以上に多くのコアをパックしているため、並行プログラミングはそれらを効率的に利用するための最前線にあります。 ただし、並行プログラムは、の設計、作成、テスト、および保守がはるかに難しいという事実が残っています。 したがって、結局のところ、同時プログラムの効果的で自動化されたテストケースを作成できれば、これらの問題の大部分を解決できます。

では、並行コードのテストを作成するのが非常に難しいのはなぜですか? それを理解するには、プログラムで並行性を実現する方法を理解する必要があります。 最も一般的な並行プログラミング手法の1つは、スレッドの使用です。

現在、スレッドはネイティブにすることができます。その場合、スレッドは基盤となるオペレーティングシステムによってスケジュールされます。 ランタイムによって直接スケジュールされるグリーンスレッドと呼ばれるものを使用することもできます。

2.2. 並行プログラムのテストの難しさ

使用するスレッドの種類に関係なく、使用を困難にするのはスレッド通信です。 スレッドを含むがスレッド通信を含まないプログラムを実際に作成できた場合、これ以上のことはありません。 より現実的には、スレッドは通常通信する必要があります。 これを実現するには、共有メモリとメッセージパッシングの2つの方法があります。

並行プログラミングに関連する問題の大部分は、共有メモリでネイティブスレッドを使用することから発生します。 同じ理由で、このようなプログラムのテストは困難です。 共有メモリにアクセスできる複数のスレッドは、通常、相互排除が必要です。 これは通常、ロックを使用した保護メカニズムによって実現されます。

ただし、これでも、競合状態、ライブロック、デッドロック、スレッドの枯渇などの多くの問題が発生する可能性があります。 さらに、ネイティブスレッドの場合のスレッドスケジューリングは完全に非決定的であるため、これらの問題は断続的に発生します。

したがって、これらの問題を決定論的な方法で検出できる同時プログラムの効果的なテストを作成することは、確かに課題です。

2.3. スレッドインターリーブの構造

ネイティブスレッドは、オペレーティングシステムによって予期せずスケジュールされる可能性があることを私たちは知っています。 これらのスレッドが共有データにアクセスして変更する場合、興味深いスレッドインターリーブが発生します。 これらのインターリーブの一部は完全に受け入れられる場合がありますが、他のインターリーブは最終データを望ましくない状態のままにする場合があります。

例を見てみましょう。 スレッドごとにインクリメントされるグローバルカウンターがあるとします。 処理が終了するまでに、このカウンターの状態を、実行されたスレッドの数と完全に同じにする必要があります。

private int counter;
public void increment() {
    counter++;
}

さて、Javaでプリミティブ整数をインクリメントすることは、アトミック操作ではありません。 これは、値を読み取り、値を増やし、最後に保存することで構成されます。 複数のスレッドが同じ操作を実行している間、多くの可能なインターリーブが発生する可能性があります。

この特定のインターリーブは完全に許容できる結果を生成しますが、これはどうですか?

これは私たちが期待したものではありません。 ここで、これよりもはるかに複雑なコードを実行している何百ものスレッドを想像してみてください。 これにより、スレッドがインターリーブする想像を絶する方法が生まれます。

この問題を回避するコードを作成する方法はいくつかありますが、それはこのチュートリアルの主題ではありません。 ロックを使用した同期は一般的なものの1つですが、競合状態に関連する問題があります。

3. マルチスレッドコードのテスト

マルチスレッドコードをテストする際の基本的な課題を理解したので、それらを克服する方法を見ていきます。 簡単なユースケースを作成し、並行性に関連するできるだけ多くの問題をシミュレートしようとします。

おそらく何でも数える単純なクラスを定義することから始めましょう:

public class MyCounter {
    private int count;
    public void increment() {
        int temp = count;
        count = temp + 1;
    }
    // Getter for count
}

これは一見無害なコードですが、スレッドセーフではないことを理解するのは難しくありません。 このクラスで並行プログラムを作成した場合、それは間違いなく欠陥があります。 ここでのテストの目的は、そのような欠陥を特定することです。

3.1. 非並行部品のテスト

経験則として、コードを同時動作から分離してテストすることを常にお勧めします。 これは、同時実行に関連しないコードに他の欠陥がないことを合理的に確認するためです。 それをどのように行うことができるか見てみましょう:

@Test
public void testCounter() {
    MyCounter counter = new MyCounter();
    for (int i = 0; i < 500; i++) {
        counter.increment();
    }
    assertEquals(500, counter.getCount());
}

ここでは何も起こりませんが、このテストにより、少なくとも同時実行性がない場合でも機能するという確信が得られます。

3.2. 並行性を使用したテストの最初の試み

今度は並行セットアップで、同じコードをもう一度テストしてみましょう。 複数のスレッドを使用してこのクラスの同じインスタンスにアクセスし、その動作を確認します。

@Test
public void testCounterWithConcurrency() throws InterruptedException {
    int numberOfThreads = 10;
    ExecutorService service = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);
    MyCounter counter = new MyCounter();
    for (int i = 0; i < numberOfThreads; i++) {
        service.execute(() -> {
            counter.increment();
            latch.countDown();
        });
    }
    latch.await();
    assertEquals(numberOfThreads, counter.getCount());
}

複数のスレッドで共有データを操作しようとしているため、このテストは妥当です。 スレッドの数を10のように少なく保つと、ほとんど常に通過することに気付くでしょう。 興味深いことに、スレッドの数をたとえば100に増やし始めると、ほとんどの場合、テストが失敗し始めることがわかります

3.3. 並行性を使用したテストのより良い試み

前のテストでは、コードがスレッドセーフではないことが明らかになりましたが、このテストには問題があります。 基になるスレッドが非決定論的な方法でインターリーブするため、このテストは決定論的ではありません。 私たちのプログラムでは、このテストに頼ることはできません。

必要なのは、スレッドのインターリーブを制御して、はるかに少ないスレッドで決定論的な方法で同時実行の問題を明らかにできるようにする方法です。 まず、テストしているコードを少し調整します。

public synchronized void increment() throws InterruptedException {
    int temp = count;
    wait(100);
    count = temp + 1;
}

ここでは、メソッド synchronized を作成し、メソッド内の2つのステップの間に待機を導入しました。 synchronized キーワードは、一度に1つのスレッドのみが count 変数を変更できることを保証し、待機により、各スレッドの実行間に遅延が発生します。

テストするコードを必ずしも変更する必要はないことに注意してください。 ただし、スレッドスケジューリングに影響を与える方法は多くないため、これに頼っています。

後のセクションで、コードを変更せずにこれを行う方法を説明します。

ここで、前に行ったのと同じようにこのコードをテストしてみましょう。

@Test
public void testSummationWithConcurrency() throws InterruptedException {
    int numberOfThreads = 2;
    ExecutorService service = Executors.newFixedThreadPool(10);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);
    MyCounter counter = new MyCounter();
    for (int i = 0; i < numberOfThreads; i++) {
        service.submit(() -> {
            try {
                counter.increment();
            } catch (InterruptedException e) {
                // Handle exception
            }
            latch.countDown();
        });
    }
    latch.await();
    assertEquals(numberOfThreads, counter.getCount());
}

ここでは、これを2つのスレッドだけで実行しており、欠落していた欠陥を取得できる可能性があります。 ここで行ったことは、特定のスレッドインターリーブを実現することです。これは、影響を与える可能性があることがわかっています。 デモンストレーションには適していますが、これは実用的な目的には役立たない場合があります

4. 利用可能なテストツール

スレッドの数が増えると、それらがインターリーブする可能性のある方法の数は指数関数的に増えます。 そのようなインターリーブをすべて把握してテストすることは不可能です。 私たちは、私たちのために同じまたは同様の努力をするためにツールに頼らなければなりません。 幸いなことに、私たちの生活を楽にするために利用できるものがいくつかあります。

並行コードをテストするために利用できるツールには、大きく分けて2つのカテゴリがあります。 1つ目は、多くのスレッドを使用する並行コードにかなり高いストレスをかけることを可能にします。 ストレスはまれなインターリーブの可能性を高め、したがって、欠陥を見つける可能性を高めます。

2つ目は、特定のスレッドインターリーブをシミュレートできるため、より確実に欠陥を見つけるのに役立ちます。

4.1. 時は飛ぶ

tempus-fugit Javaライブラリは、並行コードを簡単に記述およびテストするのに役立ちます。 ここでは、このライブラリのテスト部分に焦点を当てます。 複数のスレッドを持つコードにストレスをかけると、並行性に関連する欠陥を見つける可能性が高くなることを以前に見ました。

自分でストレスを生成するユーティリティを作成することはできますが、tempus-fugitは同じことを実現するための便利な方法を提供します。

以前にストレスを生成しようとしたのと同じコードを再検討し、tempus-fugitを使用して同じことを実現する方法を理解しましょう。

public class MyCounterTests {
    @Rule
    public ConcurrentRule concurrently = new ConcurrentRule();
    @Rule
    public RepeatingRule rule = new RepeatingRule();
    private static MyCounter counter = new MyCounter();
	
    @Test
    @Concurrent(count = 10)
    @Repeating(repetition = 10)
    public void runsMultipleTimes() {
        counter.increment();
    }

    @AfterClass
    public static void annotatedTestRunsMultipleTimes() throws InterruptedException {
        assertEquals(counter.getCount(), 100);
    }
}

ここでは、tempus-fugitから入手できる2つのRuleを使用しています。 これらのルールはテストをインターセプトし、繰り返しや同時実行などの目的の動作を適用するのに役立ちます。 したがって、事実上、10個の異なるスレッドからそれぞれ10回テスト対象の操作を繰り返しています。

繰り返しと並行性を増やすと、並行性に関連する欠陥を検出する可能性が高くなります。

4.2. スレッドウィーバー

Thread Weaver は、本質的にマルチスレッドコードをテストするためのJavaフレームワークです。 以前、スレッドのインターリーブはまったく予測できないことを確認しました。そのため、定期的なテストで特定の欠陥を見つけることはできません。 効果的に必要なのは、インターリーブを制御し、可能なすべてのインターリーブをテストする方法です。 これは、以前の試みでは非常に複雑な作業であることが証明されています。

ここで、スレッドウィーバーがどのように役立つかを見てみましょう。 Thread Weaverを使用すると、方法を気にすることなく、2つの別々のスレッドの実行をさまざまな方法でインターリーブできます。 また、スレッドのインターリーブ方法をきめ細かく制御できる可能性もあります。

以前の素朴な試みをどのように改善できるか見てみましょう。

public class MyCounterTests {
    private MyCounter counter;

    @ThreadedBefore
    public void before() {
        counter = new MyCounter();
    }
    @ThreadedMain
    public void mainThread() {
        counter.increment();
    }
    @ThreadedSecondary
    public void secondThread() {
        counter.increment();
    }
    @ThreadedAfter
    public void after() {
        assertEquals(2, counter.getCount());
    }

    @Test
    public void testCounter() {
        new AnnotatedTestRunner().runTests(this.getClass(), MyCounter.class);
    }
}

ここでは、カウンターをインクリメントしようとする2つのスレッドを定義しました。 スレッドウィーバーは、考えられるすべてのインターリーブシナリオで、これらのスレッドを使用してこのテストを実行しようとします。 おそらくインターリーブの1つで、欠陥が発生します。これは、コードで非常に明白です。

4.3. MultithreadedTC

MultithreadedTC は、同時アプリケーションをテストするためのさらに別のフレームワークです。 複数のスレッドのアクティビティのシーケンスを細かく制御するために使用されるメトロノームを備えています。 スレッドの特定のインターリーブを実行するテストケースをサポートします。 したがって、理想的には、すべての重要なインターリーブを個別のスレッドで決定論的にテストできる必要があります。

現在、この機能豊富なライブラリの完全な紹介は、このチュートリアルの範囲を超えています。 しかし、実行中のスレッド間で可能なインターリーブを提供するテストをすばやく設定する方法は確かにわかります。

MultithreadedTCを使用してコードをより決定論的にテストする方法を見てみましょう。

public class MyTests extends MultithreadedTestCase {
    private MyCounter counter;
    @Override
    public void initialize() {
        counter = new MyCounter();
    }
    public void thread1() throws InterruptedException {
        counter.increment();
    }
    public void thread2() throws InterruptedException {
        counter.increment();
    }
    @Override
    public void finish() {
        assertEquals(2, counter.getCount());
    }

    @Test
    public void testCounter() throws Throwable {
        TestFramework.runManyTimes(new MyTests(), 1000);
    }
}

ここでは、共有カウンターを操作してインクリメントする2つのスレッドを設定しています。 MultithreadedTCは、失敗したものを検出するまで、最大1,000の異なるインターリーブに対してこれらのスレッドでこのテストを実行するように構成しました。

4.4. Java jcstress

OpenJDKは、OpenJDKプロジェクトで作業するための開発者ツールを提供するコードツールプロジェクトを維持しています。 このプロジェクトには、 Java同時実行ストレステスト(jcstress)を含むいくつかの便利なツールがあります。 これは、Javaでの並行性サポートの正確さを調査するための実験的なハーネスおよび一連のテストとして開発されています。

これは実験的なツールですが、これを利用して並行コードを分析し、それに関連する欠陥に資金を提供するテストを作成することもできます。 このチュートリアルでこれまで使用してきたコードをテストする方法を見てみましょう。 概念は、使用法の観点からは非常に似ています。

@JCStressTest
@Outcome(id = "1", expect = ACCEPTABLE_INTERESTING, desc = "One update lost.")
@Outcome(id = "2", expect = ACCEPTABLE, desc = "Both updates.")
@State
public class MyCounterTests {
 
    private MyCounter counter;
 
    @Actor
    public void actor1() {
        counter.increment();
    }
 
    @Actor
    public void actor2() {
        counter.increment();
    }
 
    @Arbiter
    public void arbiter(I_Result r) {
        r.r1 = counter.getCount();
    }
}

ここでは、クラスに注釈 State を付けました。これは、複数のスレッドによって変更されたデータを保持していることを示しています。 また、アノテーション Actor を使用しています。これは、さまざまなスレッドによって実行されたアクションを保持するメソッドをマークします。

最後に、アノテーション Arbiter でマークされたメソッドがあります。これは、基本的に、すべてのActorが状態にアクセスした後にのみ状態にアクセスします。 また、注釈結果を使用して期待値を定義しました。

全体として、セットアップは非常にシンプルで直感的です。 これは、フレームワークによって提供されるテストハーネスを使用して実行できます。このハーネスは、 JCStressTest で注釈が付けられたすべてのクラスを検索し、それらを数回の反復で実行して、可能なすべてのインターリーブを取得します。

5. 並行性の問題を検出する他の方法

並行コードのテストを作成することは困難ですが、可能です。 課題とそれを克服するための一般的な方法のいくつかを見てきました。 ただし、テストだけでは同時実行性の問題をすべて特定できない場合があります。特に、テストをさらに作成するための増分コストがメリットを上回り始めた場合はなおさらです。

したがって、妥当な数の自動テストとともに、他の手法を使用して並行性の問題を特定できます。 これにより、自動テストの複雑さを深く理解することなく、並行性の問題を見つける可能性が高まります。 このセクションでは、これらのいくつかについて説明します。

5.1. 静的解析

静的分析は、実際にプログラムを実行せずにプログラムを分析することを指します。 さて、そのような分析は何ができるでしょうか? これについては説明しますが、最初に、動的分析との対比を理解しましょう。 これまでに作成した単体テストは、テストするプログラムを実際に実行して実行する必要があります。 これが、これらが主に動的分析と呼ばれるものの一部である理由です。

静的分析は、動的分析に代わるものではないことに注意してください。 ただし、コードを実行するずっと前に、コード構造を調べて、考えられる欠陥を特定するための非常に貴重なツールを提供します。 静的分析は、経験と理解をもってキュレーションされた多数のテンプレートを利用します。

コードを調べて、私たちがキュレートしたベストプラクティスやルールと比較することはかなり可能ですが、大規模なプログラムには妥当ではないことを認めなければなりません。 ただし、この分析を実行するために利用できるツールがいくつかあります。 それらはかなり成熟しており、人気のあるプログラミング言語のほとんどに膨大なルールがあります。

Java用の一般的な静的分析ツールはFindBugsです。 FindBugsは「バグパターン」のインスタンスを探します。 バグパターンは、多くの場合エラーとなるコードイディオムです。 これは、言語機能の難しさ、メソッドの誤解、不変条件の誤解など、いくつかの理由で発生する可能性があります。

FindBugsは、実際にバイトコードを実行せずに、バグパターンの発生についてJavaバイトコードを検査します。 これは非常に使いやすく、実行も高速です。 FindBugsは、条件、デザイン、重複コードなど、多くのカテゴリに属するバグを報告します。

また、並行性に関連する欠陥も含まれます。 ただし、FindBugsは誤検知を報告する可能性があることに注意する必要があります。 これらは実際には少ないですが、手動分析と相関させる必要があります。

5.2. モデル検査

モデル検査は、システムの有限状態モデルが特定の仕様を満たしているかどうかをチェックする方法です。 さて、この定義はあまりにも学術的に聞こえるかもしれませんが、しばらくの間それを我慢してください!

通常、計算問題は有限状態マシンとして表すことができます。 これはそれ自体が広大な領域ですが、有限の状態セットと、明確に定義された開始状態と終了状態を持つそれらの間の遷移のルールを備えたモデルを提供します。

ここで、仕様は、モデルが正しいと見なされるためにモデルがどのように動作するかを定義します。 基本的に、この仕様は、モデルが表すシステムのすべての要件を保持します。 仕様を取得する方法の1つは、アミール・プヌーリによって開発された時相論理式を使用することです。

モデル検査を手動で実行することは論理的には可能ですが、それは非常に非現実的です。 幸いなことに、ここで私たちを助けるために利用できる多くのツールがあります。 Javaで利用できるそのようなツールの1つは、 Java PathFinder (JPF)です。 JPFは、NASAでの長年の経験と研究によって開発されました。

具体的には、JPFはJavaバイトコードのモデルチェッカーです。 考えられるすべての方法でプログラムを実行し、それによって、考えられるすべての実行パスに沿ってデッドロックや未処理の例外などのプロパティ違反をチェックします。 したがって、任意のプログラムの並行性に関連する欠陥を見つけるのに非常に役立つことがわかります。

6. 後付け

今のところ、マルチスレッドコードに関連する複雑さをできるだけ避けることが最善であることは私たちにとって驚くべきことではありません。 テストと保守が容易な、より単純な設計のプログラムを開発することが、私たちの主な目的であるはずです。 現代のアプリケーションでは並行プログラミングがしばしば必要であることに同意する必要があります。

ただし、いくつかのベストプラクティスと原則を採用しながら、私たちの生活を楽にする並行プログラムを開発することができます。 このセクションでは、これらのベストプラクティスのいくつかについて説明しますが、このリストは完全ではないことに注意してください。

6.1. 複雑さを軽減する

複雑さは、並行要素がなくてもプログラムのテストを困難にする要因です。 これは、並行性に直面して悪化するだけです。 よりシンプルで小さなプログラムが推論しやすく、したがって効果的にテストするの理由を理解するのは難しくありません。 ほんの数例を挙げると、SRP(単一責任パターン)やKISS(Keep It Stupid Simple)など、ここで役立ついくつかの最良のパターンがあります。

現在、これらは並行コードのテストを直接作成する問題に対処していませんが、作業を簡単に試みることができます。

6.2. 不可分操作を検討する

アトミック操作は、互いに完全に独立して実行される操作です。 したがって、インターリーブの予測とテストの難しさを簡単に回避できます。 コンペアアンドスワップは、そのような広く使用されているアトミック命令の1つです。 簡単に言えば、メモリ位置の内容を特定の値と比較し、それらが同じである場合にのみ、そのメモリ位置の内容を変更します。

最新のマイクロプロセッサのほとんどは、この命令のいくつかの変形を提供します。 Javaは、AtomicIntegerAtomicBooleanなどのさまざまなアトミッククラスを提供し、その下にあるコンペアアンドスワップ命令の利点を提供します。

6.3. 不変性を受け入れる

マルチスレッドプログラミングでは、変更可能な共有データは常にエラーの余地を残します。 不変性は、インスタンス化後にデータ構造を変更できない状態を指します。 これは、同時プログラムのために天国で行われた試合です。 オブジェクトの作成後にオブジェクトの状態を変更できない場合、競合するスレッドはそれらの相互排除を申請する必要はありません。 これにより、並行プログラムの作成とテストが大幅に簡素化されます。

ただし、不変性を選択する自由が常にあるとは限らないことに注意してください。ただし、可能な場合はそれを選択する必要があります。

6.4. 共有メモリを避ける

マルチスレッドプログラミングに関連する問題のほとんどは、競合するスレッド間でメモリを共有しているという事実に起因する可能性があります。 もし私たちがそれらを取り除くことができたらどうでしょう! まあ、スレッドが通信するためのメカニズムがまだ必要です。

この可能性を提供する並行アプリケーション用の代替デザインパターンがあります。 人気のあるものの1つは、同時実行の基本単位としてアクターを規定するアクターモデルです。 このモデルでは、アクターはメッセージを送信することによって相互に対話します。

Akkaは、アクターモデルを活用してより優れた同時実行プリミティブを提供するScalaで記述されたフレームワークです。

7. 結論

このチュートリアルでは、並行プログラミングに関連するいくつかの基本事項について説明しました。 特にJavaでのマルチスレッド同時実行について詳しく説明しました。 特に共有データを使用してこのようなコードをテストする際に、このコードが提示する課題を経験しました。 さらに、並行コードをテストするために利用できるいくつかのツールと手法を試しました。

また、自動テスト以外のツールや手法など、同時実行の問題を回避する他の方法についても説明しました。 最後に、並行プログラミングに関連するプログラミングのベストプラクティスをいくつか紹介しました。

この記事のソースコードは、GitHubにあります。