こんちには!
UPSIDERのWebチームでサーバサイドKotlinを書いているエンジニアのおかだです。
突然ですが、Kotlinコルーチン使ってますか? もちろん使ってますよね。
KotlinでWebアプリケーションを書くのであれば、リクエストごとにコルーチンを起動してハンドリングするでしょうし、その処理の中でDBにアクセスすることも多いだろうと思います。ですが、このありがちな処理を、ごく普通に書くだけで、わかりにくいバグを生む場合がある、そんな話を紹介します。
やってはならないことは単純。
アンチパターン
DBトランザクション内でsuspendする処理を、大量(DBコネクション総数を超える数)のコルーチンで並行実行してはならない
やってしまうとしたらバッチ処理ですかね。これをやってしまうと、コルーチンはコネクションを使い果たし、マイルドデッドロックとでも言うべき状態に陥ります。特に気をつけてほしいのは、これはスレッド数とはほぼ無関係だということです。スレッド数がコネクション数よりも少ないから大丈夫、とはならないのです*1。
どのような機制でこの問題が起きるのか。図で見ていきましょう。
DBコネクションが8つ、スレッドが3つ、コルーチンが20個、という状況を考えてみます。車がスレッド、円柱の下に生えてる触手みたいなやつがコネクションです。スレッドが車のようなものだとは全然思ってないですけどね。コルーチンとの関係性で言えば、コルーチンが乗ってる感あるかなと・・・。図にするのって難しい。
まず、スレッドの数と同じ3つのコルーチンが動き出します。トランザクションを開始してコネクションをプールから借りてきます。
さてここで、アンチパターンに記載したとおり、この処理はトランザクションの途中でサスペンドします。たとえば、DBに何か保存したあとにサスペンド関数でメール送信する、とか。
ここで重要なのは、トランザクションを維持するためには、コネクションはそれぞれのコルーチンが持ち続けなければならないということです。UPSIDERではORMとしてExposedを使っていますが、Exposedはこのように振る舞います。仮にExposedでなくとも、ロジカルに考えて、こうする以外の選択肢ってあまり思いつきません。
最初の3つのコルーチンがサスペンドしたことで、スレッドが空きました。というわけで、実行されるのをワクワクと待ち構えている次のコルーチンがスレッドを割り当てられて、処理を始めます。またコネクションが3つ消費されます。
そしてこのコルーチンたちもトランザクション途中でサスペンドします。
問題は次です。コルーチンがサスペンドして行った処理は(この3台の車とは別のスレッド上で行ったことにしておきますが)、それほど時間のかかる処理ではなく、すでに完了しているとします。つまり、左上で -_- みたいな顔をしている連中の一部は、スレッドが空くのを待っているのです。
ですが、彼らはスレッドを割り当ててもらえません。最初から待ち続けている左下のコルーチンが優先されます。このコルーチンのスケジューリングアルゴリズムは CoroutineDispatcher
に依存するので、変更できる可能性がないわけではないですが、これをうまくコントロールする CoroutineDispatcher
の実装を試みるのはおそらく無理筋です。
最終的には、コネクションはすべて、サスペンド中のコルーチンに持っていかれ、スレッド上にはコネクションを待つコルーチンが居座り続けることになります。
空くはずのないコネクションをどのくらいの間待ち続けるのかはコネクションプールの設定によりますが、仮に30秒だとすると、まだコネクションを得ていない12個のコルーチンがすべてコネクション取得を諦めてタイムアウトするまでに 30秒 * (12コルーチン / 3スレッド) = 120秒 かかります。そのあとようやく左上のコルーチンがスレッドを割り当てられ処理を再開、コネクションを解放していきます。
解決策
アンチパターンを構成する、トランザクション内サスペンドか、大量のコルーチン起動、どちらかを回避しましょう。
1. トランザクション内でサスペンドするのをやめる
Kotlinコルーチンのアドバンテージをすこし犠牲にしてしまうかもしれませんが。条件次第では大した代償なく対応できるかもしれません。チーム開発で、この内部処理ではサスペンド禁止、というのを周知し守り続けるのはなかなか難しいものがありそう。
2. 一度に起動するコルーチン数を制限する
先にスレッド待ちしているコルーチンがいなければ、サスペンドしているコルーチンがすぐにスレッドを獲得して処理再開し、コネクションを解放できます。コルーチン数を制限する方法については「Kotlinでコルーチンの並行処理数を制限する」という記事で触れていますので、そちらも合わせて読んでいただければと思います。
*1:スレッド数が逆にコルーチン数を上回るほど多い場合はこの問題は起きないのですが、そういうシチュエーションは普通なさそう