【SpringBoot】並列・非同期処理の実装方法

SpringBootアプリケーションでの並列処理・非同期処理について、実装方法をご紹介します!

並列処理・非同期処理を実装すると、複数の処理をいっぺんに実行することができるので全体の処理速度の向上が見込めます!

ひらべー
ひらべー

こんなケースではオススメ!

・依存し合わない処理が直列実行されている

・IOに時間がかかる

一方、直列処理に比べると実装に癖があったり、可読性が下がるデメリットはあるので、注意も必要です。

ひらべー
ひらべー

こんなケースではイマイチかも…

・チームの開発スキルが成熟していない

・処理が軽く並列で処理しても処理速度がそれほど変わらない

・CPUやメモリを多く使用する(並列処理するとリソースが逼迫する)

ざっくりとメリデメを把握いただいたところで、具体的にみていきましょう!

この記事では、実装方法を網羅的に紹介するのではなく、僕が思う最も実装コストの低い方法をご紹介します!

実装方法

SpringBootでは@Asyncアノテーションを付与することで、簡単に非同期処理を実装することができます!

通常の同期処理と非同期処理で実装と動作をみていきましょう。

同期処理

@Component
public class SampleService {
    public String sample(int num) throws InterruptedException {
        System.out.println("start greeting %s.".formatted(num));
        TimeUnit.SECONDS.sleep(5);
        System.out.println("end greeting %s.".formatted(num));
        return "result %s".formatted(num);
    }
}

このクラスを以下のように呼び出してみます。※SpringBootTestのテストクラスで実装

    @Autowired
    private SampleService sampleService;

    @Test
    void test_sample() throws Exception {
        var start = System.currentTimeMillis();

        var result1 = sampleService.sample(1); // 5秒かかる
        System.out.println(result1);

        var result2 = sampleService.sample(2); // 5秒かかる
        System.out.println(result2);

        var end = System.currentTimeMillis();

        System.out.printf("time: %s ms%n", end - start);
    }

順に処理をするので 5秒 + 5秒 でトータル10秒かかります。

start 1.
end 1.
result 1
start 2.
end 2.
result 2
time: 10012 ms

非同期処理

それでは上記のSampleServiceクラスを非同期処理ができるように書き換えたFutureSampleServiceを実装します。

@Component
public class FutureSampleService {
    @Async // @Asyncアノテーションを付与する(privateのような内部で呼び出すメソッドに付与しても意味ないので注意)
    public CompletableFuture<String> sample(int num) throws InterruptedException {
        System.out.println("start greeting %s.".formatted(num));
        TimeUnit.SECONDS.sleep(5);
        System.out.println("end greeting %s.".formatted(num));
        
        // 戻り値は、「CompletableFuture.completedFuture」でwrapする
        return CompletableFuture.completedFuture("result %s".formatted(num));
    }
}

次に呼び出し元の実装も修正します。
※結果の取得は、この順でなくても良いですが、「メソッド実行」→「join」→「利用」としておくとシンプルで見通しが良いかなと思います。

    @Autowired
    FutureSampleService futureSampleService;

    @Test
    void test_future_sample() throws Exception {
        var start = System.currentTimeMillis();

        // 並列で実行したい処理をすべて呼び出す
        var resultFuture1 = futureSampleService.sample(1);
        var resultFuture2 = futureSampleService.sample(2);
        // 結果をjoinで取得する
        var result1 = resultFuture1.join();
        var result2 = resultFuture2.join();
        // 結果を利用する(以降は直列処理と一緒)
        System.out.println(result1);
        System.out.println(result2);

        var end = System.currentTimeMillis();
        System.out.printf("time: %s ms%n", end - start);
    }

最後に、@Asyncでの非同期処理を有効にするために、@EnableAsyncをmainクラスに付与します。

@EnableAsync // 付与するのは@ConfigurationのついたクラスでもOK
@SpringBootApplication
public class SampleApplication {

	public static void main(String[] args) {
		SpringApplication.run(SampleApplication.class, args);
	}
}

実行してみると、、、

start 2.
start 1.
end 2.
end 1.
result 1
result 2
time: 5011 ms

非同期な処理を並列で実行したことで、1つのメソッド処理時間分で2つのメソッドを実行できました🎉

非同期処理(なんちゃって編)

非同期処理の実装時にはメソッドの呼び出し順が重要になってきます。

メソッドの呼び出し順を入れ替えて実行してみます。

    @Test
    void test_future_sample_ng() throws Exception {
        var start = System.currentTimeMillis();
        
        var resultFuture1 = sampleService.sample(1);
        var result1 = resultFuture1.join(); // joinを呼び出すと、処理が終わるまでブロックする
        System.out.println(result1);
        
        var resultFuture2 = sampleService.sample(2);
        var result2 = resultFuture2.join();
        System.out.println(result2);

        var end = System.currentTimeMillis();
        System.out.printf("time: %s ms%n", end - start);
    }
start 1.
end 1.
result 1
start 2.
end 2.
result 2
time: 10020 ms

と、同期処理と同じ結果になってしまいました。。。

非同期処理の恩恵を受けるには、「非同期処理を呼び出し、join()を呼び出す前に別の非同期処理を呼び出す」ことが重要です。

例外処理

まずは非同期処理側の例外スローの実装です。

通常のthrow句ではなく、CompletableFuture.failedFutureに例外インスタンスを設定します。

※RuntimeExceptionを継承した例外であれば、throw句でもOKです。

    @Async
    public CompletableFuture<String> error(int num) {
        return CompletableFuture.failedFuture(new CustomException("error %s".formatted(num)));
    }

    class CustomException extends Exception {
        public CustomException(String message) {
            super(message);
        }
    }

呼び出し元では、join()実行時に例外処理を行います。

    @Test
    void test_future_sample_exception() {

        var resultFuture1 = futureSampleService.error(1);
        var resultFuture2 = futureSampleService.error(2);

        // try - catchで例外処理
        try {
            resultFuture1.join();
        } catch (CompletionException ex) { // 例外はすべてCompletionExceptionにwrapされる
            System.out.println(ex.getCause().getMessage());
        }

        // メソッドチェーンで例外処理
        var result = resultFuture2.exceptionally(ex -> {
            // 例外はすべてCompletionExceptionにwrapされる
            if (ex instanceof CompletionException completionException) {
                return completionException.getCause().getMessage();
            } else {
                return "unexpected";
            }
        }).join();
        System.out.println(result);
    }
ひらべー
ひらべー

呼び出し元で例外処理を行うと結構ごちゃつくので、
非同期処理の中で例外処理を押し込める方がスッキリするのかも?

単体テスト

最後に、JUnit5を用いた単体テストの実装サンプルです!

    @Test
    void test() throws InterruptedException {
        var actual1 = futureSampleService.sample(1);
        assertThat(actual1.join()).isEqualTo("result 1");

        var actual2 = futureSampleService.error(2);
        var completionException = assertThrows(CompletionException.class, actual2::join);
        assertThat(completionException.getCause().getMessage()).isEqualTo("error 2");
    }

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です