嘿,大家好!今天我们深入研究下协程调度器。首先,让我们用通俗易懂的语言来解释一下现实生活中“调度器”的含义:安排人员或车辆去往目的地的人。
在 Kotlin 协程中,调度器是协程上下文的一部分,用于决定协程作用域将在哪个线程上运行。下面让我们详细分析一下这对我们的代码有哪些影响。
开始之前
在我们正式开始之前,提个小疑问,我们如何确定一个调度器可以并发多少任务?
各位稍微思考一下。
下面我给出我的答案:
suspend fun main() {
val dispather = newFixedThreadPoolContext(4,"Test")
val allTime = measureTimeMillis {
coroutineScope {
repeat(5) {
launch(dispather) {
Thread.sleep(1000) // 这里一定要Sleep
println("${Instant.now().atZone(ZoneId.systemDefault()).toLocalTime()}: End")
}
}
}
}
println("Done: $allTime")
}
// Ouptut
// 09:17:55.627706300: End
// 09:17:55.627706300: End
// 09:17:55.627706300: End
// 09:17:55.627706300: End
// 09:17:56.646634100: End
// Done: 2083
上述代码,我创建了一个 4 线程的调度器,那么理论上它可以并行运行 4 个任务。然后我们在内部,使用 repeat 创建了 5 个协程。通过打印我们就会发现,第五个任务比前四个任务晚了一秒左右,同时整个任务耗时花费了 2083 秒,接近两秒。如果我们只运行 4 个任务呢?
// Ouptut
09:21:45.080864300: End
09:21:45.080864300: End
09:21:45.080864300: End
09:21:45.080864300: End
Done: 1062
你看,整个任务耗费 1 秒左右。
那么我们就知道,该调度器可以并行运行 4 个任务。
Dispatchers.Default
Dispatchers.Default 是 Kotlin 协程中一个预定义的调度器。
如果你没有为协程作用域指定调度器,默认使用 Dispatchers.Default。这确保了即使你没有显式设置,协程也有一个标准的运行位置。
如果没有设置其他调度器,
runBlocking会设置自己的调度器;因此,在runBlocking内部,Dispatchers.Default不是默认调度器。
对于
viewModelScope,它使用的是Dispatchers.Main调度器。Android中大部分和LiveCycle相关的调度器都是Dispatchers.Main。
它是为 CPU 密集型任务设计的。它使用的线程池线程数量与你机器中的 CPU 核心数相同,最少为 2 个线程。从理论上讲,这种设置能最有效利用线程。
在初始化 Dispatchers.Default 时,源码中你会看到这样的代码
SchedulerCoroutineDispatcher(
CORE_POOL_SIZE, MAX_POOL_SIZE,
IDLE_WORKER_KEEP_ALIVE_NS, DEFAULT_SCHEDULER_NAME
)
//...
internal val CORE_POOL_SIZE = systemProp(
"kotlinx.coroutines.scheduler.core.pool.size",
AVAILABLE_PROCESSORS.coerceAtLeast(2),
minValue = CoroutineScheduler.MIN_SUPPORTED_POOL_SIZE
)
//...
internal val AVAILABLE_PROCESSORS = Runtime.getRuntime().availableProcessors()
因此,如果你有一台 8 核的机器,并且正在使用 Dispatchers.Default,那么你最多可以同时运行 8 个并行进程 —— 不能更多。我们可以使用上一节提到的方法确认 Dispatchers.Default 使用的线程数量是基于你的 CPU 核心数的。
查看源码会让我们更好地理解这一点。假设我有一个 12 核的处理器,但这并不能保证 Dispatchers.Default 会使用 12 个线程。在初始化时,它首先会检查可用的处理器数量,然后据此分配线程。
suspend fun main() {
val availableProcessors = Runtime.getRuntime().availableProcessors()
println("availableProcessors: $availableProcessors")
val allTime = measureTimeMillis {
coroutineScope {
repeat(availableProcessors + 1) {
launch(Dispatchers.Default) {
Thread.sleep(1000)
println("${Instant.now().atZone(ZoneId.systemDefault()).toLocalTime()}: End")
}
}
}
}
println("Done: $allTime")
}
输出如下:
// Ouptut
availableProcessors: 12
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:45.107062800: End
13:39:46.125232200: End
Done: 2063
作者的机器 CPU 是 i5-12400,其实这颗 CPU 是 6 核心的,但是因为 Intel 有超线程技术,能够模拟 12 线程,所以在获取 availableProcessors 的时候,返回的是 12。
运行结果可以看出,最后一个任务,也就是第 13 个任务,没有被并行的安排。必须等到前面的 12 个任务执行完成之后,它才能执行。
正是因为 Dispatchers.Default 的默认线程池与可用的 CPU 数量挂钩,在执行任务时能够减少线程往 CPU 上调度分配资源的开销,所以 Dispatchers.Default 适用于 CPU 密集型任务,不适用于阻塞任务。
那么,问题来了:假设出现一系列繁重的任务,它开始占用 Dispatchers.Default 的所有线程,导致使用同一个调度器的其他协程无法获取线程,必须排队等待。我们如何避免这种线程阻塞的情况发生呢?
别担心,Kotlin 协程为我们提供了解决办法 ——limitedParallelism。
该方法会创建当前调度器的一个视图,将并行度限制为给定值。生成的视图使用原始调度器来执行,并保证并行的协程不会超过 parallelism 个。
此方法不对视图的数量或并行度的总和加以限制,每个视图独立控制自身的并行度,并确保所有视图的有效并行度不会超过原始调度器的实际并行度。
让我们看看代码,了解一下具体是如何实现的:
suspend fun main() {
val availableProcessors = Runtime.getRuntime().availableProcessors()
println("availableProcessors: $availableProcessors")
val limit = 6
println("set limitedParallelism to $limit")
val dispatchers = Dispatchers.Default.limitedParallelism(limit)
val allTime = measureTimeMillis {
coroutineScope {
repeat(limit + 1) {
launch(dispatchers) {
Thread.sleep(1000)
println("${Instant.now().atZone(ZoneId.systemDefault()).toLocalTime()}: End")
}
}
}
}
println("Done: $allTime")
}
输出如下:
// Ouptut
availableProcessors: 12
set limitedParallelism to 6
13:52:50.854274200: End
13:52:50.854274200: End
13:52:50.854274200: End
13:52:50.854274200: End
13:52:50.854274200: End
13:52:50.854274200: End
13:52:51.873996600: End
Done: 2055
例子非常简单,直白易懂。假设你有 12 个可用的 CPU 核心,但你将限制设置为 6。这意味着一次最多只能运行 6 个任务,所以第 7 个任务必须等待,直到前面的某个任务完成。这就是为什么最终完成任务需要两秒左右的时间。
limitedParallelism 设置了一个上限来管理资源使用。
注意,新调度器 dispatchers 的任务依然会在 Dispatchers.Default 上面运行,只不过我们限定了它的并行度。
大师,我悟了!
我们可以给予超过 CPU 核心数的并行度,从而让 Dispatchers.Default 打破 CPU 核心数的并行限制!
suspend fun main() {
val availableProcessors = Runtime.getRuntime().availableProcessors()
println("availableProcessors: $availableProcessors")
val limit = availableProcessors * 2 // 尝试设置 2 倍的并行值
println("set limitedParallelism to $limit")
val dispatchers = Dispatchers.Default.limitedParallelism(limit)
val allTime = measureTimeMillis {
coroutineScope {
repeat(20) { // 期待并行完成 20 个任务,毕竟我给了 24 并行值,对吧!
launch(dispatchers) {
Thread.sleep(1000)
println("${Instant.now().atZone(ZoneId.systemDefault()).toLocalTime()}: End")
}
}
}
}
println("Done: $allTime")
}
输出如下:
// Ouptut
availableProcessors: 12
set limitedParallelism to 24
14:01:06.468199700: End
14:01:06.468706400: End
14:01:06.468199700: End
14:01:06.468199700: End
14:01:06.452998100: End
14:01:06.468706400: End
14:01:06.452998100: End
14:01:06.468706400: End
14:01:06.468199700: End
14:01:06.468706400: End
14:01:06.468706400: End
14:01:06.468706400: End
14:01:07.484704500: End
14:01:07.484704500: End
14:01:07.484704500: End
14:01:07.484704500: End
14:01:07.484704500: End
14:01:07.484704500: End
14:01:07.484704500: End
14:01:07.484704500: End
Done: 2058
你会发现,它竟然依然只能并行运行 12 个任务。
limitedParallelism在Dispatchers.Default中,它可以帮助你设置限制,但这个限制应该小于你的核心数量。如果你设置的限制大于核心数量,那么它将直接忽略该设置并使用实际的核心数量。
上面提到,Dispatchers.Default 适用于 CPU 密集型任务,为什么它不适用 IO 密集型呢?
Dispatchers.Default 有固定数量的线程。如果你将其用于阻塞操作(可能需要很长时间才能完成的任务),那么这些线程会被占用,这可能会导致其他也需要运行的协程因缺乏资源而无法运行。这可能会导致严重的延迟。
如果一个阻塞操作的运行时间比预期的要长,它可能会引发异常,从而导致应用程序崩溃和不稳定。这就是为什么避免将 Dispatchers.Default 用于任何可能长时间阻塞线程的操作。
所以,IO 密集型任务就应该···没错!就是 Dispatchers.IO。
Dispatchers.IO
IODispatcher 专为处理诸如读取文件、进行网络请求或访问数据库等阻塞式 I/O 操作而设计。它的线程数下限为 64 个,为并发 I/O 任务提供了充足的资源。
在下面的代码示例中,该任务大约需要 1 秒钟,因为 Dispatchers.IO 能够支持至少 64 个活动线程同时运行。这种高并行执行能力使其非常适合 I/O 操作,确保长时间运行的任务不会阻碍其他协程的运行。
通过这种设置,你可以运行多个阻塞式 I/O 任务,而无需担心会使其他协程因缺乏资源而无法运行或导致性能瓶颈。它非常适合那些需要等待外部资源的操作,但请记住,它并不适用于 CPU 密集型工作。
suspend fun main() {
val allTime = measureTimeMillis {
coroutineScope {
repeat(64) { // 你可以试试 65
launch(Dispatchers.IO) {
Thread.sleep(1000)
}
}
}
}
println("Done: $allTime")
}
// Output
// Done: 1053
正如你看到的那样,64 个任务可以并行运行。
我们一窥源码:
Dispatchers.IO 的线程池大小是“无限制”的,但最初的上限是 64 个线程。
Default 和 IO 调度器共享一个公共线程池。这种优化可以实现线程复用,而且通常无需进行线程调度。如果你在 Dispatchers.Default 上运行一个任务,然后切换到 IO 调度器,该任务很可能仍在同一线程上运行。关键的区别在于,此时线程数量限制适用的是 IO 调度器的限制,而不是 Dispatchers.Default 的限制。它们的限制是独立运作的,所以不会出现一方抢占另一方资源的情况。
我们看下示例代码:
suspend fun main(): Unit = coroutineScope {
launch(Dispatchers.Default) {
println(Thread.currentThread().name)
withContext(Dispatchers.IO) {
println(Thread.currentThread().name)
}
}
}
// Output
// DefaultDispatcher-worker-1
// DefaultDispatcher-worker-1
如你所见,它确实仍在同一线程上运行。
假设你将 Dispatchers.Default 和 Dispatchers.IO 的线程使用量都达到最大。那么,活动线程的总数将是它们各自限制数量之和。如果你在 Dispatchers.IO 中允许使用 64 个线程,而你的设备有 8 个核心,那么在共享线程池中就会有 72 个活动线程。这意味着我们实现了高效的线程复用,并且这两个调度器都具有很强的独立性。
唯一的问题在于,阻塞过多线程时就会出现麻烦。Dispatchers.IO 最多只能使用 64 个线程。如果有某个服务大量阻塞线程,那么可能会导致其他所有服务都得排队等待。为了解决这个问题,我们再次使用 limitedParallelism。
Dispatchers.IO 的行为与 Dispatchers.Default 有很大不同。当你为 Dispatchers.IO 设置 limitedParallelism 时,这个限制可以在两个方向上进行设置,既可以设置小于 64 的限制,也可以设置大于 64 的限制。因为它有一个无限制的线程池。
此外,当你设置一个大于 64 的值时,这与 Dispatchers.IO 本身的限制并无关联,这是一个全新的调度器,有额外的限制,并且它仍然像原始调度器一样是有限制的 。
suspend fun main() {
val dispatcher = Dispatchers.IO.limitedParallelism(72)
val allTime = measureTimeMillis {
coroutineScope {
repeat(72) {
launch(dispatcher) {
Thread.sleep(1000)
}
}
}
}
println("Done: $allTime")
}
// Output
// Done: 1050
从输出中可以看到,它创建了一个新的 LimitedDispatcher,新限制为 72,72 个任务花费了 1 秒钟——它们是并行的。
从概念上讲,存在一个无限的线程池,Dispatchers.Default和Dispatchers.IO 都会使用这个线程池,但它们对线程的访问权限都是有限的。
- 当我们在
Dispatchers.IO上使用limitedParallelism时,我们会创建一个新的调度器,它有一个独立的线程池(完全独立于Dispatchers.IO的限制),该调度器运行的任务会在新的调度器上运行。 - 当我们在
Dispatchers.Default调度器上使用limitedParallelism,我们会创建一个带有额外限制的调度器,这个调度器对于并行度会按照我们设定的值来,而任务依然是在Dispatchers.Default上运行的。
如图,在 Dispatchers.Default 上使用 limitedParallelism 会创建一个有额外限制的调度器。在 Dispatchers.IO 上使用 limitedParallelism 会创建一个独立于 Dispatchers.IO 的调度器。不过,它们都共享同一个无限线程池。
suspend fun main(): Unit = coroutineScope {
launch(Dispatchers.IO) {
println(Thread.currentThread().name)
}
launch(Dispatchers.Default) {
println(Thread.currentThread().name)
}
}
// Output
// DefaultDispatcher-worker-1
// DefaultDispatcher-worker-1
如果运气好的话,你会发现,线程名称是一样的。但至少有一点可以确认,它们都使用一个名为 DefaultDispatcher 线程池。
以上,便是对于 Dispatchers.Default 和 Dispatchers.IO 的完全讲解。