🙌

Ktor のハンドラーを書くときに runBlocking するな

2024/06/01に公開

TL;DR

  • Ktor で、リクエストを受け付けたら postgres につないで pg_sleep(3) するだけのアプリを作った
  • pg_sleep(3)runBlocking で囲うか withContext で囲うかによって、大量リクエストをした場合の実行時間に差が出る
  • withContext を使え

確認方法

以下のような Ktor のハンドラーを用意してアプリを動かす。
※ Ktor 2.3.11
※ Exposed 0.51.0

これで http://localhost:8080/runblocking/3http://localhost:8080/withcontext/3 ができる。

fun Application.configureRouting() {
    Database.connect("jdbc:postgresql://localhost:5432/postgres", "org.postgresql.Driver", "postgres", "postgres")

    routing {
        get("/runblocking/{second}") {
            val second = call.parameters["second"]!!.toLong()
            runBlocking(Dispatchers.IO) {
                transaction {
                    exec("SELECT pg_sleep($second)")
                }
            }
            call.respond(HttpStatusCode.OK)
        }
        get("withcontext/{second}") {
            val second = call.parameters["second"]!!.toLong()
            withContext(Dispatchers.IO) {
                transaction {
                    exec("SELECT pg_sleep($second)")
                }
            }
            call.respond(HttpStatusCode.OK)
        }
    }
}

アプリを起動したら100並列でリクエストを投げたいのでシェルスクリプトを書く。

以下みたいな感じで withcontext.sh も書く。

#!/usr/bin/env bash

URL="http://localhost:8080/runblocking/3"

for i in {1..100}
do
  curl -s "$URL" &
done
wait

echo "done"

できたら time を使って時間を測りながら実行してみる。

chmod +x ./runblocking.sh
time ./runblocking.sh

実行結果

$ time ./runblocking.sh
done
./runblocking.sh  0.65s user 1.17s system 4% cpu 40.620 total
$ time ./withcontext.sh
done
./withcontext.sh  0.62s user 1.10s system 23% cpu 7.322 total

なぜこうなるか

については ChatGPT (GPT-4o) に書かせてみた。

なぜこうなるか
KotlinのrunBlockingとwithContextは、どちらも非同期のコードを同期的に扱うために使用されますが、それぞれ異なる動作をします。

runBlocking
runBlockingは、非同期コードをブロックして実行するために使用されます。つまり、runBlockingで囲まれたコードは、呼び出し元のスレッドをブロックし、指定されたコンテキストでコルーチンを実行します。そのため、この方法では、他のコルーチンや非同期処理をブロックする可能性があります。つまり、サーバーのスレッドプールを占有してしまい、スループットや応答性に悪影響を与える可能性があります。

withContext
一方、withContextは、非同期コードを別のコンテキストで実行するために使用されますが、現在のスレッドをブロックすることはありません。つまり、指定されたコンテキストでコードを実行し、その後に呼び出し元のコンテキストに戻ります。そのため、withContextは、非同期処理を実行する際にメインのスレッドプールを占有することなく、より効率的にリソースを利用できます。

どちらを使うべきか
runBlockingとwithContextの選択は、そのコンテキストでどのような動作が必要かに依存します。一般的には、非同期処理を行う場合にはwithContextを使用することが推奨されます。なぜなら、withContextは呼び出し元のスレッドをブロックせずに非同期処理を実行するため、スループットや応答性を損なうことなく、リソースを効率的に利用できるからです。

ただし、特定のシナリオでは、一時的に同期処理が必要な場合があります。そのような場合にはrunBlockingを使用しても問題ありませんが、その使用には慎重さが必要です。runBlockingを誤用すると、サーバーのスレッドプールが枯渇し、応答性の低下やリソースの無駄遣いが発生する可能性があります。

したがって、Ktorでサーバーサイドアプリを書く際には、通常は非同期処理にwithContextを使用することが適切です。これにより、リソースの効率的な利用とアプリケーションの応答性の向上が実現できます。

結論
KotlinのrunBlockingとwithContextは、非同期処理を同期的に扱うための方法として利用されますが、それぞれ異なる動作をします。Ktorのサーバーサイドアプリを書く際には、通常は非同期処理にwithContextを使用することが推奨されます。これにより、リソースの効率的な利用とアプリケーションの応答性の向上が実現できます。

以上が、runBlockingとwithContextの選択についての詳細です。ご質問があればお気軽にどうぞ。

とのこと。

そもそも Coroutine とは、みたいなところはとりあえず下記を参照。個人的には超分かりやすかった。

https://speakerdeck.com/chimerast/kotlin-coroutines-to-ktor-http-client-dezuo-rusukerusurutasukushi-xing

Discussion