一、概述
很早前刚了解协程的时候,写了第一篇文章,只是关于协程的简单的一些重要概念,使用方式。这次就想根据自己的成长的了解,说说深入一点的东西。
二、协程 & 线程
很早前,我听朋友将起,协程实现原理是类似线程池,通过线程池管理的方式,避免了线程创建和销毁来带的资源上的开销,毕竟线程的创建和销毁是通过Android系统底层调度实现的。我当时不置可否,甚至已经就认为是对的。
协程既然作为轻量级的并发模型,那绝不仅仅是用户态切换线程这个过程那么简单。
线程
我们知道线程是有自己的栈空间和上下文的,因为应用分配的内存空间是有限的,因此可以管理的线程数量也是有限的。
线程的上下文切换是基于内核实现的,需要依赖操作系统的调度。
协程
而协程不同,它共享线程的栈空间,并且创建和销毁协程的开销非常小,因此可以在线程中创建成百上千个协程,这点很重要。
协程的上下文切换是在用户态完成,保存的数据信息也非常的少。
字节码分析
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
delay(1000L)
println("World!")
}
println("Hello,")
}
//编译字节码
public final class MainKt$main$1 extends ContinuationImpl {
int label;
@Override
public Object invokeSuspend(Object result) {
switch (label) {
case 0:
label = 1;
if (delay(1000L, this) == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
break;
case 1:
println("World!");
return Unit.INSTANCE;
}
return Unit.INSTANCE;
}
}
可以看到,协程在转译成字节码文件的时候,继承了一个ContinuationImpl的类, 并实现它的方法。当执行挂起函数delay的时候,会置label为0,并且返回COROUTINE_SUSPENDED挂起协程,将控制权返回调用者。当挂起结束,会置label为1,并从挂起点恢复执行。
ContinuationImpl是一个状态机类,而label就是管理的状态变量,用于跟踪协程的当前状态,每个挂起点都会对应一个状态。挂起函数的调用会被转换成对Continuation的调用。
挂起函数
这里的挂起函数指的是两个东西,一个是具体的挂起函数,比如delay,明确当前函数会被挂起,并在特定下恢复。另一个是suspend关键字。
suspend用来修饰一个函数是挂起函数,但他只是标记的作用,表示当前函数可以被挂起,并且提供协程域,可以进行协程相关的操作。但是即使是修饰,函数也可能不执行任何挂起操作。
suspend fun fetchData(): String {
// 这里可以调用其他挂起函数
return "data"
}
suspend fun fetchData(): String {
delay(1000) // 挂起协程 1 秒钟
return "data"
}
并发模型
协程可以让异步操作像同步一样执行。这就是协程挂起的特性,和阻塞不同,它不会阻塞所在线程的执行。
Thread thread = new Thread(() -> {
try {
System.out.println("Thread started");
Thread.sleep(1000); // 阻塞操作
System.out.println("Thread resumed");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
我们可以看到,sleep的阻塞函数,让线程进入阻塞状态,直到1秒后恢复执行,这段时间内,线程无法执行其他任务。
import kotlinx.coroutines.*
fun main() = runBlocking {
launch {
println("Coroutine started")
delay(1000) // 挂起操作
println("Coroutine resumed")
}
println("Main function")
}
delay的挂起操作,虽然会让当前协程执行暂停1秒后恢复执行,为什么是挂起,而不是阻塞呢,就是delay的挂起,对于它所在的线程并不会阻塞,Main function依旧会执行打印,然后打印started, 并暂停1秒打印resumed。
dispatcher
调度器,负责协程的上下文切换的对象。它决定了协程在哪个线程或线程池中运行,Dispatcher的实现依赖于底层的线程池和任务队列机制,以实现线程间的切换和任务调度。
常见的Dispatcher类型:
- Dispatchers.Default:使用共享的后台线程池,适用于CPU密集型任务。
- Dispatchers.IO:使用共享的线程池,适用于I/O密集型任务,如文件操作和网络请求。
- Dispatchers.Main:在主线程上执行,通常用于更新UI(在Android开发中常用)。
object MainDispatcher : CoroutineDispatcher() {
private val mainHandler = Handler(Looper.getMainLooper())
override fun dispatch(context: CoroutineContext, block: Runnable) {
mainHandler.post(block)
}
}
调度器设置的主线程的调度器,就是依赖Android的Handler和Looper来完成的切换。
object DefaultDispatcher : CoroutineDispatcher() {
private val executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors())
override fun dispatch(context: CoroutineContext, block: Runnable) {
executor.execute(block)
}
}
object IODispatcher : CoroutineDispatcher() {
private val executor = Executors.newCachedThreadPool()
override fun dispatch(context: CoroutineContext, block: Runnable) {
executor.execute(block)
}
}
而Default 和 IO都是使用一个共享的线程池,它们的调度原理都是通过创建一个新的Continuation,并使用新的上下文,提交任务到新的调度器来实现的切换。
suspend fun <T> withContext(context: CoroutineContext, block: suspend () -> T): T {
// 获取当前协程的 Continuation
val continuation = suspendCoroutineUninterceptedOrReturn<T> { cont ->
// 创建一个新的 Continuation,使用新的上下文
val newContinuation = cont.intercepted().withContext(context)
// 提交任务到新的调度器
context[ContinuationInterceptor]?.dispatch(context, Runnable {
newContinuation.resumeWith(Result.success(block()))
})
COROUTINE_SUSPENDED
}
return continuation
}
resumeWith是Continuation接口的一部分,用于恢复挂起的协程,还会根据协程的上下文(调度器)决定在哪个线程恢复执行,这个之前没有提到。它接收一个Result对象作为参数,表示协程的结果或异常。
Result可以保存协程的结果和异常,这也就是为什么使用Async的方式,可以通过try…catch的方式捕获deferred.await的异常。协程的异常虽然会被保存在Result中,并传播到外层中抛出,但是异常不会自动传播给到父协程中去处理。
父协程:在已有的协程内部启动一个协程,外部的协程就是父协程,内部的就是子协程。
异常
刚刚有提到在协程中的异常问题,和线程一样,你无法协程外部去捕获协程内部的异常,但是async可以通过wait的方式来捕获。
如果父协程有两个子协程,其中一个协程出现异常,父协程和其他子协程都会被取消。因为默认的CoroutineScope和Job会将异常传播给父协程的Job,导致父协程和其他子协程被取消,但异常不会直接抛出到父协程的代码中。
fun main() = runBlocking {
val parentJob = launch {
val child1 = launch {
println("Child 1 is running")
delay(1000)
println("Child 1 completed")
}
val child2 = launch {
println("Child 2 is running")
throw RuntimeException("Error in Child 2")
}
try {
joinAll(child1, child2)
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
}
parentJob.join()
println("Parent job completed")
}
print:
Child 1 is running
Child 2 is running
Caught exception: Error in Child 2
Parent job completed
但是这有一个例外,就是当你使用的是supervisorScope或 SupervisorJob的时候,异常不会传播到父协程,因此其他的协程都可以正常完成。
fun main() = runBlocking {
supervisorScope {
val child1 = launch {
println("Child 1 is running")
delay(1000)
println("Child 1 completed")
}
val child2 = launch {
println("Child 2 is running")
throw RuntimeException("Error in Child 2")
}
try {
joinAll(child1, child2)
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
}
println("Parent job completed")
}
print:
Child 1 is running
Child 2 is running
Child 1 completed
Parent job completed
除了之前提到的async#wait的方式可以让父协程捕获子协程的异常,还可以通过supervisorScope
fun main() = runBlocking {
supervisorScope {
try {
val childJob = launch {
println("Child is running")
throw RuntimeException("Error in child coroutine")
}
childJob.join() // 等待子协程完成,并捕获异常
} catch (e: Exception) {
println("Caught exception: ${e.message}")
}
}
println("Parent job completed")
}
join是一个挂起函数,可以让异常传播到Job中,等挂起点恢复,再被父协程捕获。
三、总结
这次总结没什么好讲的了,也算是深入的了解了一下协程和调度器的原理。之后希望能够对协程还有更进一步的认识。