不用休眠的 Kotlin 并发:深入对比 delay() 和 sleep()
不用休眠的 Kotlin 并发:深入对比 delay() 和 sleep()
在Kotlin协程中,delay()和sleep()是两个经常被混淆的函数。虽然它们都涉及到时间延迟,但它们的工作机制和使用场景却大不相同。本文将深入解析这两个函数的区别,并通过代码示例和运行结果帮助读者更好地理解它们的工作原理。
毫无疑问,Kotlin语言中的协程Coroutine极大地帮助了开发者更加容易地处理异步编程。该特性中封装的诸多高效API,可以确保开发者花费更小的精力去完成并发任务。一般来说,开发者了解一下如何使用这些API就足够了!
可就JVM的角度而言,协程一定程度上减少了“回调地狱”的问题,切实地改进了异步处理的编码方式。
相信包括笔者在内的很多开发者常常会好奇协程的背后到底是如何做到的。所以,本文将以delay()
为切入点,带开发者剖析下协程的背后原理。
1. delay()
干啥用的?
使用过协程的开发者大概率对delay()
并不陌生,anyway,先来看下官方针对该函数的描述:
“delay()
用来延迟协程一段时间,但不阻塞线程,并且能在指定的时间后恢复协程的执行。”
来看一段在task1执行2000ms后执行task2的示例代码:
scope.launch {
doTask1()
delay(2000)
doTask2()
}
代码很简单,但需要再次提醒一些关于delay()
的重要特点:
- 它不会阻塞当前运行的线程
- 但它允许其他协程在同线程运行
- 当延迟的时间到了,协程会被恢复并继续执行
很多开发者常常会将delay()
和Java语言的sleep()
进行比较。可事实上,这两个函数用作完全不同的场景,只是命名上看起来有点相似而已。。。
2. sleep()
呢?
sleep()
则是Java语言中标准的多线程处理API:促使当前执行的线程进入休眠,并持续指定的一段时间。
“该方法一般用来告知CPU让出处理时间给App的其他线程或者其他App的线程。”
如果在协程里使用该函数,它会导致当前运行的线程被阻塞,同时也会导致该线程的其他协程被阻塞,直到指定的阻塞时间完成。
为了解更多的细节,让我们通过示例进一步地对比sleep()
和delay()
两者。
3. 对比delay()
和sleep()
假使我们想在单线程(就比如Android开发里的主线程)里执行并发任务。
看一下如下的代码片段:分别启动两个协程,并各自调用了1000ms的delay()
或sleep()
。
比较:
协程的启动时间:
调用
delay()
代码里的两个协程在同一时间(05:48:58)执行调用
sleep()
代码里的第2个协程相隔了1s后执行协程的结束时间:
调用
delay()
代码里的2个协程一共花了1045ms调用
sleep()
代码里的2个协程则一共花了2044ms
这也印证了上面提到的特性差异:delay()
只是挂起协程、同时允许其他协程复用该协程,而sleep()
则在一段时间内直接阻塞了整个线程。
事实上,delay()
还具备其他神奇的特点,再来看看下面的代码示例:
- 先定义了一个最大创建2个线程的线程池context示例
- 当第1个协程启动并执行一个task之后,调用
delay()
挂起1000ms,接着再执行一个task - 在第1个协程执行的同时,启动第2个协程兵执行耗时task
通过查看task里打印的log,我们惊讶地发现:delay
函数执行前,它运行在Duet-1线程。但当delay
完成后,它却恢复到了另一个线程:Duet-2。
这是为什么?
原来是因为原线程正在忙于处理第2个协程启动的耗时task,所以delay
之后它只能恢复到另一个线程。
这就有意思了,看看官方文档的描述。。。
“协程可以挂起一个thread并且恢复到另一个thread!”
既然感受到了delay()
的魔力,我们就来了解下它背后的工作原理。
4. 剖析delay()
原理
delay()
会先在协程上下文里找到Delay
的实现,接着执行具体的延时处理。
public suspend fun delay(timeMillis: Long) {
if (timeMillis <= 0) return
return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->
if (timeMillis < Long.MAX_VALUE) {
cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont)
}
}
}
Delay
是interface类型,其定义了延时之后调度协程的方法scheduleResumeAfterDelay()
等。开发者直接调用的delay()
、withTimeout()
正是Delay
接口提供的支持。
public interface Delay {
public fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>)
public fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle =
DefaultDelay.invokeOnTimeout(timeMillis, block, context)
}
事实上,Delay
接口由运行协程的各CoroutineDispatcher
实现。我们知道CoroutineDispatcher
是抽象类,Dispatchers
类会利用线程相关API来实现它。
比如:
Dispatchers.Default
、Dispatchers.IO
使用java.util.concurrent包下的Executor API来实现Dispatchers.Main
使用Android平台上特有的Handler API来实现
接着,各Dispatcher还需要实现Delay
接口,主要就是实现scheduleResumeAfterDelay()
,去返回指定ms之后执行协程的Continuation
实例。
如下是ExecutorCoroutineDispatcherImpl
类实现该方法的具体代码:
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
(executor as? ScheduledExecutorService)?.scheduleBlock(
ResumeUndispatchedRunnable(this, continuation),
continuation.context,
timeMillis
)
// Other implementation
}
可以看到:它借助了Java包ScheduledExecutorService
的schedule()
来调度了Continuation的恢复。
我们再来看下Android平台Dispatcher即HandlerDispatcher
又是如何实现的该方法。
override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
val block = Runnable {
with(continuation) { resumeUndispatched(Unit) }
}
handler.postDelayed(block, timeMillis.coerceAtMost(MAX_DELAY))
// Other implementation
}
它直截了当地使用了Handler的postDelayed()
post了Continuation恢复的Runnable对象。这也解释了delay()
没有阻塞线程的原因。
假使你在Android主线程的协程里执行了delay()
逻辑,其效果等同于调用了Handler的右侧代码。
这种实现非常有趣:在Android平台上调用delay()
,实际上相当于通过Handler post一个delayed runnable;而在JVM平台上则是利用Executor API这种类似的思路。
但如果还是同样的业务逻辑,将delay()
换成sleep()
,那么效果将大相径庭。可以说,delay()
和sleep()
是完全不同的两种API,不要搞混了。
讲到这里,我们能感受到协程的优雅奇妙:用简单的同步代码写出异步逻辑,切实地帮助开发者免受“回调地狱”的困扰。
希望本文能帮你了解到Kotlin协程里delay()
的用法和工作原理,并理解和sleep()
的明显差异,感谢阅读😃。