マルチスレッド開発では、この要件が頻繁に発生します
。Task1、Task2、およびその他のタスクは並行して実行され、Task3はすべての実行が完了した後に実行されます。
Kotlinでこれを実現する方法はたくさんありますが、そのリストを次に示します。
- Thread.join
- 同期
- ReentrantLock
- BlockingQueue
- CountDownLatch
- CyclicBarrier
- 場合
- 未来
- CompletableFuture
- Rxjava
- コルーチン
タスクモック
Task3は、Task1とTask2から返された結果に基づいて文字列を連結し、各タスクはスリープを通じて時間のかかる操作をシミュレートします
val task1: () -> String = {
sleep(2000)
"Hello".also {
println("task1 finished: $it") }
}
val task2: () -> String = {
sleep(2000)
"World".also {
println("task2 finished: $it") }
}
val task3: (String, String) -> String = {
p1, p2 ->
sleep(2000)
"$p1 $p2".also {
println("task3 finished: $it") }
}
Thread.join()
KotlinはJavaと互換性があるため、すべてのJavaツールを使用できます。この場合の最も簡単な方法は、Threadのjoinメソッドを使用して同期を実現することです。
@Test
fun test_join() {
lateinit var s1: String
lateinit var s2: String
val t1 = Thread {
s1 = task1() }
val t2 = Thread {
s2 = task2() }
t1.start()
t2.start()
t1.join()
t2.join()
task3(s1, s2)
}
同期
synchronized
同期にロックを使用する
@Test
fun test_synchrnoized() {
lateinit var s1: String
lateinit var s2: String
Thread {
synchronized(Unit) {
s1 = task1()
}
}.start()
s2 = task2()
synchronized(Unit) {
task3(s1, s2)
}
}
ただし、タスクが3つ以上ある場合は、synchrnoizedを使用する方が扱いにくいため、複数の並列タスクの結果を同期するには、n個のロックを宣言してn個の同期をネストする必要があります。
ReentrantLock
Lockは、JUCが提供するスレッドロックであり、同期の使用を置き換えることができます。
@Test
fun test_ReentrantLock() {
lateinit var s1: String
lateinit var s2: String
val lock = ReentrantLock()
Thread {
lock.lock()
s1 = task1()
lock.unlock()
}.start()
s2 = task2()
lock.lock()
task3(s1, s2)
lock.unlock()
}
さらにタスクがある場合、ネストされた同期の問題は発生しませんが、さまざまなタスクを管理するために複数のロックを作成する必要があります。
BlockingQueue
ブロッキングキューの内部もロックによって実現されるため、同期ロックの効果も実現できます。
@Test
fun test_blockingQueue() {
lateinit var s1: String
lateinit var s2: String
val queue = SynchronousQueue<Unit>()
Thread {
s1 = task1()
queue.put(Unit)
}.start()
s2 = task2()
queue.take()
task3(s1, s2)
}
ブロッキングキューは、生産/消費タスクモデルでより多く使用されます。この場合は、数を補うためだけです。
CountDownLatch
JUCのほとんどのロックはAQS
、排他ロックや共有ロックなどの実装に基づいています。たとえば、ReentrantLockは排他ロックです。
対照的に、他のスレッドによって実行された操作が完了するまでスレッドが待機できるようにするCountDownLatchなど、共有ロックはこの場合により適しています。
@Test
fun test_countdownlatch() {
lateinit var s1: String
lateinit var s2: String
val cd = CountDownLatch(2)
Thread() {
s1 = task1()
cd.countDown()
}.start()
Thread() {
s2 = task2()
cd.countDown()
}.start()
cd.await()
task3(s1, s2)
}
共有ロックの利点は、タスクごとに個別のロックを作成する必要がなく、さらに多くの並列タスクを簡単に作成できることです。
CyclicBarrier
CyclicBarrierは、JUCが提供するもう1つの共有ロックメカニズムであり、スレッドのグループが同期ポイントに到達してから一緒に実行し続けることを可能にします。スレッドのいずれかが同期ポイントに到達しない場合、他の到着スレッドはブロックされます。
CountDownLatchとの違いは、CountDownLatchは1回限りですが、CyclicBarrierはリセットして再利用できるため、Cyclicの名前はこれに由来し、リサイクルできます。
@Test
fun test_CyclicBarrier() {
lateinit var s1: String
lateinit var s2: String
val cb = CyclicBarrier(3)
Thread {
s1 = task1()
cb.await()
}.start()
Thread() {
s2 = task1()
cb.await()
}.start()
cb.await()
task3(s1, s2)
}
場合
内部AQSは、スピンロックを介して同期を実現し、スピンロックは、CASを介したスレッドブロッキングのオーバーヘッドを回避します。
したがって、CASベースのアトミッククラスカウントを使用して、スレッドの安全性を確保し、ロックのない操作を実現できます。
@Test
fun test_cas() {
lateinit var s1: String
lateinit var s2: String
val cas = AtomicInteger(2)
Thread {
s1 = task1()
cas.getAndDecrement()
}.start()
Thread {
s2 = task2()
cas.getAndDecrement()
}.start()
while (cas.get() != 0) {
}
task3(s1, s2)
}
while
サイクリングのアイドリングは無駄に思えるかもしれませんが、スピンロックの本質はこれであるため、CPUを集中的に使用する短いタスクの同期にのみ使用されます。
揮発性
さらに、CASを見ると、多くの人がvolatileを使用すると、ロックフリー操作を実現するためにスレッドセーフになる可能性があると考えるかもしれません。次のようになります。
@Test
fun test_Volatile() {
lateinit var s1: String
lateinit var s2: String
Thread {
s1 = task1()
cnt--
}.start()
Thread {
s2 = task2()
cnt--
}.start()
while (cnt != 0) {
}
task3(s1, s2)
}
この書き方は間違っていることに注意してください。
揮発性は可視性を保証できますが、原子性を保証することcnt--
はできません。スレッドセーフではなく、ロック操作が必要です。
未来
上記のロック操作またはロックフリー操作があるかどうかに関係なく、2つの変数を定義して結果s1
をs2
記録することは非常に不便です。
Java 1.5以降、CallableとFutureが提供され、タスクの実行後にタスクの実行結果を取得できます。
@Test
fun test_future() {
val future1 = FutureTask(Callable(task1))
val future2 = FutureTask(Callable(task2))
Executors.newCachedThreadPool().execute(future1)
Executors.newCachedThreadPool().execute(future2)
task3(future1.get(), future2.get())
}
パスfuture.get()
、結果が同期的に返されるのを待つことができます。これは非常に便利です。
CompletableFuture
future.get()は便利ですが、スレッドをブロックします。
Java 8では、CompletableFutureクラスが導入されました。これは、Futureインターフェースを実装し、CompletionStageインターフェースも実装します。CompletableFutureは、複数のCompletionStagesに対してさまざまな論理的組み合わせを実行して、複雑な非同期プログラミングを実現できるさまざまなメソッドを提供します。これらのメソッドは、コールバックを介した同期によって引き起こされるスレッドのブロックを効果的に回避できます。
@Test
fun test_CompletableFuture() {
CompletableFuture.supplyAsync(task1)
.thenCombine(CompletableFuture.supplyAsync(task2)) {
p1, p2 ->
task3(p1, p2)
}.join()
}
RxJava
RxJavaが提供するさまざまな演算子とスレッド切り替え機能も、ニーズの達成に役立ちます。
zip
演算子は2つのObservableの結果を組み合わせることができます。subscribeOnは非同期スレッドを実行するために使用されます
@Test
fun test_Rxjava() {
Observable.zip(
Observable.fromCallable(Callable(task1))
.subscribeOn(Schedulers.newThread()),
Observable.fromCallable(Callable(task2))
.subscribeOn(Schedulers.newThread()),
BiFunction(task3)
).test().awaitTerminalEvent()
}
コルーチン
私は以前にたくさん話しましたが、それらはすべてJavaツールです。
最後のCoroutineはついにこの記事のタイトルに値する。
@Test
fun test_coroutine() {
runBlocking {
val c1 = async(Dispatchers.IO) {
task1()
}
val c2 = async(Dispatchers.IO) {
task2()
}
task3(c1.await(), c2.await())
}
}
書くのはとても快適で、以前のツールの利点を兼ね備えていると言えます。
総括する
この記事では、フェンネルビーンズの4つの書き方をすべて列挙していますが、乱用を助長するものではありません。
結論として、Kotlinで最もエレガントで最良の並列タスク実装方法は、最初のプッシュコルーチンです!