スレッドよりもコルーチンのパフォーマンス上の利点をテストするために、3つの単純なプログラムを作成しました。各プログラムは、多くの一般的な単純な計算を行います。すべてのプログラムは互いに別々に実行されました。実行時間に加えて、Visual VM
IDEプラグインを介してCPU使用率を測定しました。
最初のプログラムは、1000-threaded
プールを使用してすべての計算を行います。次のコードは、コンテキストが頻繁に変更されるため、他のコードと比較して最悪の結果(64326 ms
)を示しています。
val executor = Executors.newFixedThreadPool(1000)
time = generateSequence {
measureTimeMillis {
val comps = mutableListOf<Future<Int>>()
for (i in 1..1_000_000) {
comps += executor.submit<Int> { computation2(); 15 }
}
comps.map { it.get() }.sum()
}
}.take(100).sum()
println("Completed in $time ms")
executor.shutdownNow()
2番目のプログラムも同じロジックですが、1000-threaded
プールの代わりにn-threaded
プールのみを使用します(n
はマシンのコアの量に等しい)。はるかに優れた結果(43939 ms
)を示し、使用するスレッドの数も少ないため、これも優れています。
val executor2 = Executors.newFixedThreadPool(4)
time = generateSequence {
measureTimeMillis {
val comps = mutableListOf<Future<Int>>()
for (i in 1..1_000_000) {
comps += executor2.submit<Int> { computation2(); 15 }
}
comps.map { it.get() }.sum()
}
}.take(100).sum()
println("Completed in $time ms")
executor2.shutdownNow()
3番目のプログラムはコルーチンで作成され、結果に大きな変動(41784 ms
から81101 ms
へ)を示しています。私は非常に混乱していて、なぜそれらがそれほど異なるのか、なぜコルーチンがスレッドよりも遅いのか(小さな非同期計算がコルーチンのforteであることを考えると)がよくわかりません)。これがコードです:
time = generateSequence {
runBlocking {
measureTimeMillis {
val comps = mutableListOf<Deferred<Int>>()
for (i in 1..1_000_000) {
comps += async { computation2(); 15 }
}
comps.map { it.await() }.sum()
}
}
}.take(100).sum()
println("Completed in $time ms")
私は実際にこれらのコルーチンとそれらがコトリンにどのように実装されているかについてたくさん読んでいますが、実際には意図したとおりに機能しているとは思いません。ベンチマークを間違っていますか?それともコルーチンを間違って使用していますか?
あなたが問題を設定した方法では、コルーチンからの利益を期待するべきではありません。すべての場合において、分割できない計算ブロックをエグゼキュータに送信します。コルーチンの一時停止のアイデアを活用していません。実際には細かく切り分けられ、おそらく別のスレッドで実行される順次コードを記述できます。
コルーチンのほとんどのユースケースは、ブロックするコードを中心に展開します。つまり、スレッドを独占して応答を待つ以外に何もしないというシナリオを回避します。 CPUを集中的に使用するタスクをインターリーブするために使用することもできますが、これはより特殊なケースのシナリオです。
Roman ElizarovのKotlinConf 2017トーク のように、いくつかの連続したブロッキングステップを含む1,000,000タスクをベンチマークすることをお勧めします。
_suspend fun postItem(item: Item) {
val token = requestToken()
val post = createPost(token, item)
processPost(post)
}
_
ここで、requestToken()
、createPost()
およびprocessPost()
はすべてネットワーク呼び出しに関係しています。
これの2つの実装がある場合、1つは_suspend fun
_ sを使用し、もう1つは通常のブロッキング関数を使用します。次に例を示します。
_fun requestToken() {
Thread.sleep(1000)
return "token"
}
_
vs.
_suspend fun requestToken() {
delay(1000)
return "token"
}
_
最初のバージョンの1,000,000の同時呼び出しを実行するように設定することさえできないことがわかります。また、数値を_OutOfMemoryException: unable to create new native thread
_なしで実際に達成できる数まで下げると、コルーチンのパフォーマンス上の利点が明らかになります。
CPUにバインドされたタスクのコルーチンの考えられる利点を調査する場合は、それらを順次実行するか並列実行するかに関係なく、ユースケースが必要です。上記の例では、これは無関係な内部の詳細として扱われます。1つのバージョンでは1,000の同時タスクを実行し、もう1つのバージョンでは4つだけを使用するため、ほぼ順次実行されます。
Hazelcast Jet は、計算タスクが相互依存しているため、このようなユースケースの例です。1つの出力は別の出力です。この場合、完了するまでそれらのいくつかを実行することはできません。小さなスレッドプールで、実際にそれらをインターリーブして、バッファーされた出力が爆発しないようにする必要があります。コルーチンがある場合とない場合のこのようなシナリオを設定しようとすると、タスクと同じ数のスレッドを割り当てているか、または中断可能なコルーチンを使用していて、後者のアプローチが適していることに再び気付くでしょう。 Hazelcast Jetはコルーチンの精神を明白なJava APIで実装しています。これはその リファレンスマニュアル で説明されています)。そのアプローチはコルーチンプログラミングモデルの恩恵は大いにありますが、現在は純粋なJavaです。
開示:この投稿の著者は、Jetエンジニアリングチームに属しています。
コルーチンは、スレッドよりも高速になるようには設計されていません。これは、RAMの消費量を減らし、非同期呼び出しの構文を改善するためです。
コルーチンは軽量スレッドとして設計されています。 1,000,000の同時ルーチンを実行するときに、1,000,000のスレッドを作成する必要がないため、RAMの使用量が少なくなります。コルーチンは、スレッドの使用を最適化し、実行をより効率的にするのに役立ち、スレッドを気にする必要がなくなります。コルーチンは、ハンドラーにポストしてスレッドまたはスレッドプールで実行できる実行可能またはタスクと見なすことができます。