阅读完需:约 37 分钟
1.刚学 Thread 的时候
现在接触 Kotlin 的开发者绝大多数都有 Java 基础,我们刚开始学习 Thread 的时候,一定都是这样干的:
val thread = object : Thread(){
override fun run() {
super.run()
//do what you want to do.
}
}
thread.start()
肯定有人忘了调用 start
,还特别纳闷为啥我开的线程不启动呢。说实话,这个线程的 start
的设计其实是很奇怪的,不过我理解设计者们,毕竟当年还有 stop
可以用,结果他们很快发现设计 stop
就是一个错误,因为不安全而在 JDK 1.1 就废弃,称得上是最短命的 API 了吧。
既然
stop
是错误,那么总是让初学者丢掉的start
是不是也是一个错误呢?
哈,有点儿跑题了。我们今天主要说 Kotlin。Kotlin 的设计者就很有想法,他们为线程提供了一个便捷的方法:
val myThread = thread {
//do what you want
}
这个 thread
方法有个参数 start
默认为 true
,换句话说,这样创造出来的线程默认就是启动的,除非你实在不想让它马上投入工作:
val myThread = thread(start = false) {
//do what you want
}
//later on ...
myThread.start()
这样看上去自然多了。接口设计就应该让默认值满足 80% 的需求嘛。
2. 协程的启动
说了这么多线程,原因嘛,毕竟大家对它是最熟悉的。协程的 API 设计其实也与之一脉相承,我们来看一段最简单的启动协程的方式:
GlobalScope.launch {
//do what you want
}
那么这段代码会怎么执行呢?我们说过,启动协程需要三样东西,分别是 上下文、启动模式、协程体,协程体 就好比 Thread.run
当中的代码,自不必说。
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext, // 上下文
start: CoroutineStart = CoroutineStart.DEFAULT, // 启动模式
block: suspend CoroutineScope.() -> Unit // 协程体
): Job
本文将为大家详细介绍 启动模式。在 Kotlin 协程当中,启动模式是一个枚举:
public enum class CoroutineStart {
DEFAULT,
LAZY,
@ExperimentalCoroutinesApi
ATOMIC,
@ExperimentalCoroutinesApi
UNDISPATCHED;
}
模式 | 功能 |
---|---|
DEFAULT | 立即执行协程体 |
ATOMIC | 立即执行协程体,但在开始运行之前无法取消 |
UNDISPATCHED | 立即在当前线程执行协程体,直到第一个 suspend 调用 |
LAZY | 只有在需要的情况下运行 |
2.1 DEFAULT
四个启动模式当中我们最常用的其实是 DEFAULT
和 LAZY
。
DEFAULT
是饿汉式启动,launch
调用后,会立即进入待调度状态,一旦调度器 OK 就可以开始执行。我们来看个简单的例子:
suspend fun main() {
log(1)
val job = GlobalScope.launch {
log(2)
}
log(3)
job.join()
log(4)
}
说明: main 函数 支持 suspend 是从 Kotlin 1.3 开始的。另外,main 函数省略参数也是 Kotlin 1.3 的特性。后面的示例没有特别说明都是直接运行在 suspend main 函数当中。
这段程序采用默认的启动模式,由于我们也没有指定调度器,因此调度器也是默认的,在 JVM 上,默认调度器的实现与其他语言的实现类似,它在后台专门会有一些线程处理异步任务,所以上述程序的运行结果可能是:
19:51:08:160 [main] 1
19:51:08:603 [main] 3
19:51:08:606 [DefaultDispatcher-worker-1] 2
19:51:08:624 [main] 4
也可能是:
20:19:06:367 [main] 1
20:19:06:541 [DefaultDispatcher-worker-1] 2
20:19:06:550 [main] 3
20:19:06:551 [main] 4
这取决于 CPU 对于当前线程与后台线程的调度顺序,不过不要担心,很快你就会发现这个例子当中 2 和 3 的输出顺序其实并没有那么重要。
JVM 上默认调度器的实现也许你已经猜到,没错,就是开了一个线程池,但区区几个线程足以调度成千上万个协程,而且每一个协程都有自己的调用栈,这与纯粹的开线程池去执行异步任务有本质的区别。
当然,我们说 Kotlin 是一门跨平台的语言,因此上述代码还可以运行在 JavaScript 环境中,例如 Nodejs。在 Nodejs 中,Kotlin 协程的默认调度器则并没有实现线程的切换,输出结果也会略有不同,这样似乎更符合 JavaScript 的执行逻辑。
2.2 LAZY
LAZY
是懒汉式启动,launch
后并不会有任何调度行为,协程体也自然不会进入执行状态,直到我们需要它执行的时候。这其实就有点儿费解了,什么叫我们需要它执行的时候呢?就是需要它的运行结果的时候, launch
调用后会返回一个 Job
实例,对于这种情况,我们可以:
- 调用
Job.start
,主动触发协程的调度执行 - 调用
Job.join
,隐式的触发协程的调度执行
所以这个所谓的”需要“,其实是一个很有趣的措辞,后面你还会看到我们也可以通过 await
来表达对 Deferred
的需要。这个行为与 Thread.join
不一样,后者如果没有启动的话,调用 join
不会有任何作用。
log(1)
val job = GlobalScope.launch(start = CoroutineStart.LAZY) {
log(2)
}
log(3)
job.start()
log(4)
基于此,对于上面的示例,输出的结果可能是:
14:56:28:374 [main] 1
14:56:28:493 [main] 3
14:56:28:511 [main] 4
14:56:28:516 [DefaultDispatcher-worker-1] 2
当然如果你运气够好,也可能出现 2 比 4 在前面的情况。而对于 join
,
...
log(3)
job.join()
log(4)
因为要等待协程执行完毕,因此输出的结果一定是:
14:47:45:963 [main] 1
14:47:46:054 [main] 3
14:47:46:069 [DefaultDispatcher-worker-1] 2
14:47:46:090 [main] 4
2.3 ATOMIC
ATOMIC
只有涉及 cancel 的时候才有意义,cancel 本身也是一个值得详细讨论的话题,在这里我们就简单认为 cancel 后协程会被取消掉,也就是不再执行了。那么调用 cancel 的时机不同,结果也是有差异的,例如协程调度之前、开始调度但尚未执行、已经开始执行、执行完毕等等。
为了搞清楚它与 DEFAULT
的区别,我们来看一段例子:
log(1)
val job = GlobalScope.launch(start = CoroutineStart.ATOMIC) {
log(2)
}
job.cancel()
log(3)
我们创建了协程后立即 cancel,但由于是 ATOMIC
模式,因此协程一定会被调度,因此 1、2、3 一定都会输出,只是 2 和 3 的顺序就难说了。
20:42:42:783 [main] 1
20:42:42:879 [main] 3
20:42:42:879 [DefaultDispatcher-worker-1] 2
对应的,如果是 DEFAULT
模式,在第一次调度该协程时如果 cancel 就已经调用,那么协程就会直接被 cancel 而不会有任何调用,当然也有可能协程开始时尚未被 cancel,那么它就可以正常启动了。所以前面的例子如果改用 DEFAULT
模式,那么 2 有可能会输出,也可能不会。
需要注意的是,cancel 调用一定会将该 job 的状态置为 cancelling,只不过ATOMIC
模式的协程在启动时无视了这一状态。为了证明这一点,我们可以让例子稍微复杂一些:
log(1)
val job = GlobalScope.launch(start = CoroutineStart.ATOMIC) {
log(2)
delay(1000)
log(3)
}
job.cancel()
log(4)
job.join()
我们在 2 和 3 之间加了一个 delay
,delay
会使得协程体的执行被挂起,1000ms 之后再次调度后面的部分,因此 3 会在 2 执行之后 1000ms 时输出。对于 ATOMIC
模式,我们已经讨论过它一定会被启动,实际上在遇到第一个挂起点之前,它的执行是不会停止的,而 delay
是一个 suspend 函数,这时我们的协程迎来了自己的第一个挂起点,恰好 delay
是支持 cancel 的,因此后面的 3 将不会被打印。
我们使用线程的时候,想要让线程里面的任务停止执行也会面临类似的问题,但遗憾的是线程中看上去与 cancel 相近的 stop 接口已经被废弃,因为存在一些安全的问题。不过随着我们不断地深入探讨,你就会发现协程的 cancel 某种意义上更像线程的 interrupt。
2.4 UNDISPATCHED
有了前面的基础,UNDISPATCHED
就很容易理解了。协程在这种模式下会直接开始在当前线程下执行,直到第一个挂起点,这听起来有点儿像前面的 ATOMIC
,不同之处在于 UNDISPATCHED
不经过任何调度器即开始执行协程体。当然遇到挂起点之后的执行就取决于挂起点本身的逻辑以及上下文当中的调度器了。
log(1)
val job = GlobalScope.launch(start = CoroutineStart.UNDISPATCHED) {
log(2)
delay(100)
log(3)
}
log(4)
job.join()
log(5)
我们还是以这样一个例子来认识下 UNDISPATCHED
模式,按照我们前面的讨论,协程启动后会立即在当前线程执行,因此 1、2 会连续在同一线程中执行,delay
是挂起点,因此 3 会等 100ms 后再次调度,这时候 4 执行,join
要求等待协程执行完,因此等 3 输出后再执行 5。以下是运行结果:
22:00:31:693 [main] 1
22:00:31:782 [main @coroutine#1] 2
22:00:31:800 [main] 4
22:00:31:914 [DefaultDispatcher-worker-1 @coroutine#1] 3
22:00:31:916 [DefaultDispatcher-worker-1 @coroutine#1] 5
方括号当中是线程名,我们发现协程执行时会修改线程名来让自己显得颇有存在感。运行结果看上去还有一个细节可能会让人困惑,
join
之后的 5 的线程与 3 一样,这是为什么?我们在前面提到我们的示例都运行在 suspend main 函数当中,所以 suspend main 函数会帮我们直接启动一个协程,而我们示例的协程都是它的子协程,所以这里 5 的调度取决于这个最外层的协程的调度规则了。
多种启动方式(以下ChatGPT内容)
在 Kotlin 协程中,启动协程的方式有以下几种:
-
GlobalScope.launch
:使用全局协程作用域启动协程,可以在任何地方使用,但需要注意协程的生命周期管理。 -
CoroutineScope.launch
:使用自定义的协程作用域启动协程,可以更好地控制协程的生命周期和资源使用。 -
runBlocking
:使用runBlocking
函数启动一个新的协程,并阻塞当前线程,直到协程执行完毕。 -
async
:使用async
函数启动一个新的协程,并返回一个Deferred
对象,可以使用await()
函数获取协程执行的结果。 -
withContext
:使用withContext
函数切换协程的上下文,并在当前协程中执行一个挂起函数,可以更好地控制协程的上下文切换。
需要注意的是,在使用协程时,需要根据具体的业务场景和代码逻辑来选择合适的方式。同时,在启动协程时,需要注意协程的生命周期管理和资源使用,以避免潜在的问题。
启动问题记录
1、GlobalScope.launch
在 for 循环里创建有什么后果
在使用GlobalScope.launch
创建协程时,应该谨慎地考虑在循环中使用它的影响。如果在循环中使用GlobalScope.launch
,则将为每个迭代创建一个新的协程。这可能会导致创建大量的协程,从而消耗大量的资源和内存,因为这些协程将会在后台运行,并且如果没有明确地取消它们,它们将继续运行直到完成。
此外,如果在循环中使用GlobalScope.launch
,则协程可能会相互干扰,导致不可预测的结果。例如,在多个协程同时访问和修改共享状态时,可能会出现竞争条件或死锁。
因此,在使用GlobalScope.launch
创建协程时,应该考虑在循环中使用的影响,并确保适当地控制协程的生命周期和资源使用,以避免潜在的问题。
2、如果for 循环在 GlobalScope.launch
里创建有什么后果
如果将for
循环放在GlobalScope.launch
内部,则在创建新协程时只会创建一个协程,该协程将执行整个for
循环。这意味着协程将按顺序依次执行循环中的每个迭代。这种情况下,协程之间不会相互干扰,因为每个协程都在单独的执行上下文中运行。
然而,如果for
循环本身执行的时间非常长,那么该协程可能会阻塞其他协程或线程的执行,这可能会导致性能问题或不必要的等待。在这种情况下,最好将for
循环拆分为多个较小的任务,并在每个任务之间暂停当前协程,以便让其他协程有机会执行。
总的来说,应该谨慎地考虑将循环放在协程内部的影响,并确保在创建协程时正确控制其生命周期和资源使用。
3、kotlin协程应该在for循环外还是在for循环内使用
Kotlin 协程可以在 for 循环内或外使用,具体取决于你的需求和代码逻辑。
如果在 for 循环内启动协程,那么每次循环迭代都会启动一个新的协程,这可能会导致大量的协程同时执行,从而导致资源过度消耗或性能问题。此外,在循环内使用协程时,还需要注意协程的生命周期和资源使用,以避免潜在的问题。
相比之下,在 for 循环外启动协程可以更好地控制协程的数量和生命周期。你可以使用 CoroutineScope.launch
函数或 async
函数在一个作用域内启动协程,并在需要时显式地取消协程。此外,你还可以使用协程的特性,如 withContext
或 runBlocking
,来等待所有协程完成执行,以便进行下一步操作。
需要注意的是,在使用协程时,需要根据具体的业务场景和代码逻辑来选择合适的方式。如果在 for 循环内使用协程,需要确保正确管理协程的生命周期和资源使用,以避免潜在的问题。如果在 for 循环外使用协程,需要合理控制协程的数量和生命周期,以避免资源过度消耗或性能问题。
GlobalScope.launch
GlobalScope.launch
是 Kotlin 协程库中用于启动协程的函数之一。它创建一个新的协程,并在全局范围内启动该协程,不受限于任何特定的作用域。这意味着,该协程的生命周期将与应用程序的生命周期相同,直到应用程序被关闭或该协程显式地被取消。
使用 GlobalScope.launch
函数可以创建并启动一个协程,而无需创建或保持对其父协程或作用域的引用。这使得 GlobalScope.launch
很方便,但同时也意味着该协程不受任何限制或约束。因此,在使用 GlobalScope.launch
时,需要确保该协程的行为和资源使用是可控的,以避免潜在的性能问题或资源泄漏。
总的来说,GlobalScope.launch
是一个方便的函数,可用于在 Kotlin 应用程序中启动协程。但是,需要谨慎使用它,并确保在协程的生命周期内正确管理其行为和资源使用。
在多个地方使用 GlobalScope.launch
会相互切换吗
GlobalScope.launch
函数启动的协程在全局范围内执行,与具体的作用域无关,因此在多个地方使用 GlobalScope.launch
启动协程不会相互切换。
如果在不同的代码段中分别使用 GlobalScope.launch
启动多个协程,则每个协程都是独立的,它们的执行不会相互干扰或切换。这些协程在全局范围内运行,因此它们可以访问全局的资源和状态,但同时也需要注意协程的行为和资源使用,以避免潜在的性能问题或资源泄漏。
需要注意的是,使用 GlobalScope.launch
函数启动的协程在应用程序的整个生命周期内都存在,直到应用程序被关闭或该协程被显式地取消。因此,在使用 GlobalScope.launch
时需要确保协程的生命周期正确管理,以避免资源泄漏或性能问题。建议在需要启动协程的作用域内使用 CoroutineScope.launch
函数来启动协程,以便更好地控制协程的生命周期和资源使用。
GlobalScope.launch
是在当前主线程中创建协程吗
不一定。使用 GlobalScope.launch
启动的协程并没有绑定到任何特定的线程或线程池中,因此它可以在任何线程上执行。具体来说,如果当前主线程空闲,那么该协程就可能在当前主线程中执行;但如果当前主线程正在忙于处理其他任务,那么该协程就可能在其他线程中执行,例如后台线程或线程池中的线程。
因此,使用 GlobalScope.launch
启动的协程的执行线程是不确定的,这取决于当前主线程的状态和可用资源。如果需要更精确地控制协程的执行线程,可以使用 CoroutineScope
来创建协程作用域,并在指定的线程或线程池中启动协程。
CoroutineScope.launch
CoroutineScope.launch
是 Kotlin 协程库中用于启动协程的函数之一。它创建一个新的协程,并在指定的作用域中启动该协程,协程的生命周期与该作用域相关联。当作用域被销毁时,该作用域内的所有协程都会被取消。
在使用 CoroutineScope.launch
启动协程时,需要确保正确管理协程的生命周期,以避免潜在的性能问题或资源泄漏。在协程执行完毕后,应该在协程作用域内显式地取消协程,以确保协程资源得到释放。可以使用 coroutineContext.cancel()
或 cancel()
函数来取消协程。
CoroutineScope.launch
使用的注意事项
- 每个协程作用域应该有一个明确的生命周期,应该在不再需要协程时取消它。如果没有明确的生命周期,可以使用
GlobalScope
,但是要注意它的生命周期可能比应用程序更长,如果不小心泄漏协程可能会导致内存泄漏。 - 协程使用的线程池是有限的资源,如果创建太多的协程,可能会导致线程池耗尽并阻塞其他协程的启动,甚至导致应用程序崩溃。因此,需要合理地管理协程数量和生命周期,以避免出现资源耗尽的情况。
- 在协程中可能会抛出异常,需要使用
try-catch
块或CoroutineExceptionHandler
处理异常,避免因为异常导致协程终止或应用程序崩溃。 - 在协程中可以使用
delay
函数等待一段时间,但是需要注意不要阻塞协程的执行,避免影响其他协程的运行。 - 在协程中可以使用
withContext
函数切换线程上下文,但是需要注意不要频繁切换线程上下文,避免影响协程的执行效率。
总之,在使用 CoroutineScope.launch 时需要注意协程的生命周期、运行线程、并发量控制、阻塞操作处理、异常处理等问题。
CoroutineScope.launch
没有显式的关闭,那么它会自动关闭吗
CoroutineScope.launch
创建的协程并不会自动关闭,需要在适当的时候显式地关闭它。如果没有正确地关闭协程,会导致协程继续执行直到应用程序结束,可能会出现资源泄漏、内存泄漏等问题。
一般来说,应该在创建协程的函数或类的生命周期结束时,调用协程作用域的 cancel
方法来关闭所有在该作用域中创建的协程。如果使用 GlobalScope.launch
创建协程,则需要在应用程序的适当时机手动取消协程,避免出现应用程序退出后协程仍然在执行的情况。
除了手动取消协程,还可以使用协程的 withTimeout
或 withTimeoutOrNull
函数设置超时时间,当协程执行时间超过设定的时间时,协程会自动取消。
总之,在使用协程时,应该注意协程的生命周期,及时关闭不再需要的协程,避免出现资源泄漏等问题。
在使用协程中,delay与Thread.sleep有什么区别
在使用协程中,delay
与 Thread.sleep
有以下几个区别:
-
delay
是一个挂起函数,它会暂停当前协程的执行,并在指定的时间后恢复协程的执行;而Thread.sleep
是一个阻塞函数,它会阻塞当前线程的执行,不会释放线程的资源。 - 在使用协程时,如果使用
Thread.sleep
来暂停协程的执行,那么协程所在的线程也会被阻塞,从而影响其他协程的执行;而使用delay
来暂停协程的执行,则不会影响其他协程的执行。 - 在使用协程时,
delay
可以更好地与协程的调度器配合使用,以实现更高效的协程调度和资源利用;而Thread.sleep
则无法与协程的调度器配合使用,容易导致资源浪费和性能问题。 - 在使用协程时,
delay
可以接受一个时间参数,并支持时间单位的选择,比如毫秒、秒等;而Thread.sleep
只能接受毫秒为单位的时间参数。
综上所述,delay
是一种更适合在协程中使用的暂停函数,它可以更好地配合协程的调度器,实现更高效的协程调度和资源利用。而 Thread.sleep
则更适合在传统的多线程环境中使用,不太适合在协程中使用。
为什么协程中delay可以切换协程
协程是一种基于线程的轻量级并发编程模型,它可以将一个线程分成多个逻辑上的执行单元,并在这些执行单元之间进行协作式调度,以实现高效、安全、可控的异步编程。
在协程中,delay
函数并不会阻塞当前线程或协程,而是将当前协程挂起一段时间后再恢复执行。在执行 delay
函数期间,协程会释放线程资源,并将控制权交还给调度器,让调度器可以切换到其他协程执行。当指定的时间到达后,协程会被唤醒,并继续执行其后续的操作。
通过 delay
函数的挂起和恢复机制,协程可以在执行过程中避免阻塞线程,同时还能让调度器在协程之间进行切换,从而实现更高效的并发编程。由于协程是轻量级的执行单元,所以可以创建大量的协程来并发执行任务,而不会像线程那样造成过多的上下文切换和线程资源的浪费。
在协程内还可以创建协程吗
是的,在协程内部仍然可以启动新的协程。Kotlin 协程使用了一个基于任务的模型,可以将一个复杂的异步操作分解为多个任务,并使用协程来管理这些任务之间的依赖关系和执行顺序。
在协程内部创建新的协程,实际上是创建了一个新的任务,并将其加入到当前协程所属的任务执行队列中。新创建的协程会继承父协程的协程上下文和作用域,因此可以共享父协程的资源和状态。
不过需要注意的是,在创建协程时,应该使用当前作用域的协程构建器来启动协程,以确保新协程的生命周期与当前作用域的协程生命周期一致。如果直接使用 GlobalScope
或其他顶层协程构建器来启动协程,则会导致新协程的生命周期无法被正确管理,可能会造成资源泄漏或其他问题。
在协程内使用async,是如何并行执行的
在协程内使用 async
函数可以实现并行执行多个耗时操作,并在所有操作完成后获取它们的结果。async
函数实际上是 launch
函数的变体,它可以启动一个新的协程并返回一个 Deferred
对象,该对象代表了一个异步计算的结果。通过 await
函数可以获取 Deferred
对象的结果。
在协程内使用 async
函数启动多个异步计算时,每个计算都会在自己的协程中执行,并发运行,互不干扰。这些协程可以在同一个线程或不同线程上执行,具体取决于当前协程所绑定的线程池或上下文。同时,每个计算的结果都可以通过 await
函数获取,而且获取结果的操作是非阻塞的,不会影响其他计算的执行。
在多个异步计算之间使用 async
函数并行执行可以显著提高程序的执行效率,尤其是在需要同时处理多个 I/O 操作或网络请求的场景下。但需要注意的是,使用 async
函数并不总是能够带来更好的性能,有时候可能会因为线程切换、内存占用等原因导致性能下降。因此,在实际使用中需要根据具体场景和需求进行权衡和选择。
如何理解协程中挂起和恢复机制
在协程中,挂起和恢复是协程调度器的核心机制之一,它是实现协程并发执行和协作式调度的重要手段。
当一个协程执行到需要等待某个操作完成的时候,如网络请求、文件读写等耗时操作,它会调用挂起函数(如 delay
、withContext
等),将当前协程挂起,释放占用的线程资源,并将控制权交还给调度器。此时,调度器会检查当前是否有其他就绪的协程需要执行,如果有,就将控制权切换到该协程上,让它继续执行。当被等待的操作完成后,协程会被恢复,重新获取线程资源,并继续执行其后续的操作。
在协程中,挂起和恢复机制的实现依赖于协程上下文和挂起函数的特性。协程上下文是一个包含了协程执行所需的环境和配置信息的对象,包括线程池、调度器、异常处理器等。挂起函数是一类能够将协程挂起并返回一个异步计算结果的函数,如 async
、withContext
、delay
等。不同的协程上下文和挂起函数会影响协程的挂起和恢复行为,从而影响协程的并发性、性能和可靠性等方面。
通过协程中的挂起和恢复机制,可以实现高效、安全、可控的异步编程,提高程序的并发性和响应性。同时,也需要注意协程上下文和挂起函数的选择和配置,以避免潜在的性能和可靠性问题。
协程和JDK19中loom的区别
协程和JDK 19中的loom都是解决并发编程问题的技术,但它们的实现方式和目标有所不同。
协程是基于轻量级线程的一种并发编程技术,它通过挂起和恢复来实现协作式调度,可以避免线程上下文切换和线程创建销毁的开销,提高程序的并发性和响应性。协程通过语言层面的支持,可以使用统一的、易于理解和调试的代码风格来编写异步、并发和并行程序,使得编程更加直观和高效。
JDK 19中的loom则是一种基于轻量级线程(Virtual Threads)的并发编程技术,它提供了一种更加轻量、高效、灵活的线程模型,使得线程的创建和切换成本更低,而且更加可控。loom通过引入新的线程池和协程支持,可以让开发者更加容易地编写高并发、高吞吐量的程序,并且在性能、可靠性和安全性等方面都有很大的提升。
协程和loom的区别主要在于实现方式和应用场景。协程是一种基于语言层面的轻量级线程实现,适用于异步编程、网络编程、事件驱动编程等场景;loom则是基于JVM层面的轻量级线程实现,适用于高吞吐量、低延迟、CPU密集型的任务处理场景。
总的来说,协程和loom都是非常有前途的并发编程技术,它们都有自己的优势和适用场景,可以在不同的应用场景中发挥重要作用。
纤程示意图
异步示意图
Reactive方法是另一种选择,实际上非常相似。虽然它摆脱了单线程 API 限制并提供了背压机制,但它仍然需要非阻塞代码。
因为 OS 线程很昂贵,Reactive 将它们池化,并在整个应用程序生命周期中重复使用它们。核心流程是从池中获取一个空闲线程,让它执行代码,然后将线程释放回池中。这就是它需要非阻塞代码的原因:如果代码阻塞,则不会释放正在执行的线程,并且池将在某一时刻耗尽。
响应式编程传播的思想不是为每个阻塞的并发任务创建一个线程,而是一个通常称为事件循环的专用线程查看分配给非响应式模型中的线程的所有任务,并处理它们中的每一个在同一个过程中。这种新范式通过采用异步和非阻塞的思维方式减少了上下文切换的问题。结果,提高了多核上的性能和资源利用率,并提供了一种更易于维护的方法来处理异步。
Kotlin 语言提供了 Reactive 方法的替代方案:协程。简而言之,当使用suspend关键字时,Kotlin 编译器会在字节码中生成一个有限状态机。好处是在协程块中调用的函数看起来像是按顺序执行的,尽管它们是并行执行的——或者更准确地说,有可能是,这取决于确切的范围。
Java19—Loom
Loom 项目旨在通过将 Java 线程与重量级内核线程分离,将轻量级线程(称为虚拟线程)引入 Java 平台。在这方面,Loom 项目和 Kotlin 协同程序追求相同的目标——它们都旨在为 JVM 带来轻量级线程。与协作调度的 Kotlin 协程相比,Loom 项目的线程保持抢占式调度。虚拟线程没有层级的概念。
Reactive 模型和 Kotlin 协程都在客户端代码和 JVM 线程之间添加了一个额外的抽象层。框架/库有责任动态地将一个映射到另一个。问题的症结在于 JVM 线程是操作系统线程的薄包装器:请记住,操作系统线程的创建成本很高,并且数量限制为几千个。
Loom 项目的目标实际上是将 JVM 线程与 OS 线程分离。
第一次意识到这个计划时,我的想法是创建一个额外的抽象,称为Fiber(线程,Project Loom,你明白了吗?)。一个Fiber责任是获得一个 OS 线程,让它运行代码,然后将其释放回池中,就像 Reactive 堆栈所做的那样。
当前的提案有一个巨大的不同:它没有引入一个新Fiber类,而是重新使用了一个 Java 开发人员非常熟悉的类—— java.lang.Thread!
因此,在新的 JVM 版本中,一些Thread对象可能是重量级的并映射到 OS 线程,而另一些可能是虚拟线程。
轻量级线程将称为Fibers
,协程逻辑将称为Continuation
。与 Kotlin 函数类似suspend,Continuations 将有一个入口点和一个与可以暂停执行的点相对应的屈服点。如果调用者恢复继续,控制将返回到最后一点。
当 Fiber 遇到阻塞调用时,它不会解除对本机线程的阻塞——但 Fiber 会暂停,直到此操作完成将执行切换到不同的 Fiber。纤程在线程池中运行,协同产生。在纤程之间切换执行是一种廉价操作。
反应式和异步编程通常会导致认知复杂性、上下文和控制的丢失。相反,Loom 的方法不是基于高级异步机制。它将以异步的性能成本提供同步操作的简单性。在 Fibers 中执行的阻塞执行将在后台使用非阻塞 I/O。对于开发人员来说,开发和维护高性能系统会容易得多,因为复杂性将“驻留在”JVM 级别,并在不同平台上一致地利用此功能。
参考几篇文章:
https://blog.softwaremill.com/will-project-loom-obliterate-java-futures-fb1a28508232
https://kt.academy/article/dispatcher-loom
https://unixism.net/loti/async_intro.html
https://itnext.io/kotlin-coroutines-vs-java-virtual-threads-a-good-story-but-just-that-91038c7d21eb
https://github.com/Kotlin/kotlinx.coroutines/issues/3606