Android学习总结之协程对比优缺点(协程一)
进程是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单位;
线程是进程中的一个执行单元,是 CPU 调度和分派的基本单位;
而协程是一种比线程更加轻量级的并发编程方式,它可以在一个线程中实现多个任务的并发执行。
协程比线程使用资源更少的原因
- 栈空间小:线程的栈空间一般为几 MB,而协程的栈空间通常只有几 KB,大大减少了内存的占用。
- 创建和销毁开销低:线程的创建和销毁需要操作系统内核的参与,涉及到系统调用,开销较大;而协程的创建和销毁在用户态完成,开销较小。
- 上下文切换开销小:线程的上下文切换需要保存和恢复寄存器、栈指针等信息,并且涉及到用户态和内核态的转换;而协程的上下文切换只需要保存和恢复少量的寄存器信息,开销较小。
进程、线程、协程对比表
维度 | 进程(Process) | 线程(Thread) | 协程(Coroutine) |
---|---|---|---|
定义 | 程序在操作系统中的一次执行实例,是资源分配的基本单位。 | 进程内的执行单元,是 CPU 调度的基本单位。 | 用户态轻量级 “线程”,由协程库管理,可在同一线程内协作式调度。 |
调度单位 | 操作系统内核调度 | 操作系统内核调度 | 协程库(用户态调度,无需内核参与) |
上下文切换 | 内核态切换,开销极大(涉及内存地址空间、文件描述符等)。 | 内核态切换,开销较大(涉及寄存器、栈指针等)。 | 用户态切换,开销极小(仅保存协程状态到堆,无需内核干预)。 |
内存占用 | 独立地址空间(通常数 MB 到 GB 级)。 | 共享进程内存空间,每个线程栈默认约 1 MB。 | 共享线程内存,每个协程仅需几个 KB(无独立栈,共享调用栈)。 |
并发性 | 进程间并发,由操作系统控制。 | 线程间并发,由操作系统控制(抢占式多任务)。 | 协程间并发,由协程库控制(协作式多任务,需主动挂起)。 |
创建开销 | 高(需分配独立内存、文件句柄等资源)。 | 中(需分配线程栈、寄存器上下文)。 | 极低(仅创建协程对象,复用线程资源)。 |
切换开销 | 最高(涉及内核态上下文和地址空间切换)。 | 较高(内核态线程上下文切换)。 | 最低(用户态协程状态保存 / 恢复,无内核参与)。 |
资源隔离 | 完全隔离(地址空间、文件描述符等)。 | 部分隔离(共享进程内存,独立栈、寄存器)。 | 不隔离(共享线程内存,通过协程作用域管理生命周期)。 |
执行控制权 | 操作系统完全控制(不可预测抢占)。 | 操作系统完全控制(不可预测抢占)。 | 协程主动控制(通过 suspend 挂起,协作式恢复)。 |
典型用途 | 独立程序运行(如浏览器、IDE 等独立进程)。 | 多任务处理(如网络请求、文件读写等异步操作)。 | 高并发轻量任务(如海量 I/O 操作、事件驱动逻辑)。 |
代表技术 | Linux 进程、Windows 进程。 | Java 线程、POSIX 线程(pthread)。 | Kotlin 协程、Go Goroutine、Python asyncio 协程。 |
生命周期 | 由操作系统管理(创建 / 销毁开销大)。 | 由操作系统管理(依赖进程生命周期)。 | 由协程库管理(绑定作用域,如 Android 的 lifecycleScope )。 |
上下文保存位置 | 硬盘或内存(进程切换时保存完整状态)。 | 内存(线程栈和寄存器状态)。 | 堆(协程状态封装为 Continuation 对象)。 |
阻塞影响 | 进程阻塞不影响其他进程。 | 线程阻塞会占用 CPU 时间片,影响同进程内其他线程。 | 协程阻塞不阻塞线程,可释放线程执行其他协程。 |
核心差异总结
-
调度层级:
- 进程和线程由 操作系统内核 调度,属于 内核态并发;
- 协程由 协程库 调度,属于 用户态并发,依赖于线程但更轻量。
-
资源开销:
- 进程:资源隔离性最强,但创建和切换开销最大;
- 线程:共享进程资源,开销中等;
- 协程:几乎无额外资源开销,可在单线程内运行数万个协程。
-
控制方式:
- 进程 / 线程:由操作系统强制抢占,不可预测;
- 协程:通过
suspend
主动挂起,协作式恢复,适合细粒度异步控制。
-
适用场景:
- 进程:适合完全隔离的独立任务;
- 线程:适合 CPU 密集型或需要系统级并发的任务;
- 协程:适合 I/O 密集型、高并发轻量任务(如网络请求、UI 异步更新)。
关于 suspend
关键字
概念解释
在 Kotlin 协程中,suspend
关键字用于修饰函数,表明这个函数是一个挂起函数。挂起函数只能在协程内部或者另一个挂起函数中被调用。当调用挂起函数时,协程会暂停执行,直到挂起函数的操作完成,之后再恢复协程的执行。
深入源码理解
从源码层面来看,Kotlin 编译器会对挂起函数进行特殊处理,将其转换为带有状态机的代码。下面结合代码示例深入讲解:
// 使用 suspend 关键字修饰函数,表明这是一个挂起函数
suspend fun getDataFromNetwork(): String {// delay 是 Kotlin 协程库提供的一个挂起函数,用于模拟耗时操作,这里暂停 1000 毫秒delay(1000) return "Data from network"
}// 在协程作用域中启动一个新的协程
GlobalScope.launch {// 调用挂起函数,协程会在此处挂起,等待 getDataFromNetwork 函数执行完成val result = getDataFromNetwork() // 当挂起函数执行完成,协程恢复执行,打印结果println(result)
}
在编译时,getDataFromNetwork
函数会被转换为一个状态机。以下是简化的状态机代码示例,用于说明其工作原理:
// 定义一个密封类来表示状态机的不同状态
sealed class GetDataFromNetworkState {// 初始状态object Initial : GetDataFromNetworkState() // 等待延迟完成的状态object WaitingForDelay : GetDataFromNetworkState() // 任务完成的状态object Finished : GetDataFromNetworkState()
}// 模拟编译器转换后的挂起函数
fun getDataFromNetwork(state: GetDataFromNetworkState = GetDataFromNetworkState.Initial): Any? {var currentState = statewhile (true) {when (currentState) {// 初始状态,开始执行挂起函数is GetDataFromNetworkState.Initial -> {// 将状态更新为等待延迟完成currentState = GetDataFromNetworkState.WaitingForDelay // 调用 suspendCoroutine 函数挂起协程,等待延迟完成return suspendCoroutine<Unit> { continuation -> // 模拟延迟 1000 毫秒Thread.sleep(1000) // 延迟完成后,恢复协程执行continuation.resume(Unit) }}// 等待延迟完成的状态is GetDataFromNetworkState.WaitingForDelay -> {// 将状态更新为任务完成currentState = GetDataFromNetworkState.Finished // 返回函数的结果return "Data from network" }// 任务完成的状态,结束状态机is GetDataFromNetworkState.Finished -> {return null}}}
}
协程和多线程 / 线程池的区别
1. 资源消耗
- 协程:
协程是轻量级的,一个线程可以容纳多个协程。从源码角度看,Kotlin 协程使用Continuation
接口来管理协程的状态。Continuation
本质上是一个回调接口,它保存了协程的上下文和状态。当协程挂起时,只需要保存当前的Continuation
对象,而不需要像线程那样保存整个线程栈。以下是suspendCoroutine
函数的简化示例:
// 定义一个挂起函数,用于挂起协程并执行指定的代码块
suspend fun <T> suspendCoroutine(block: (Continuation<T>) -> Unit): T =// 调用 suspendCoroutineUninterceptedOrReturn 函数,传入一个 lambda 表达式suspendCoroutineUninterceptedOrReturn { c ->// 创建一个 SafeContinuation 对象,用于保存协程的状态val safe = SafeContinuation(c.intercepted()) // 执行传入的代码块,将 SafeContinuation 对象作为参数传递block(safe) // 获取协程的结果,如果协程还未完成,会抛出异常safe.getOrThrow() }
这里的 SafeContinuation
就是用于保存协程状态的对象,它的创建和销毁开销很小。
- 多线程 / 线程池:
线程在创建时,操作系统会为其分配一定的栈空间,通常是几兆字节。线程的创建和销毁涉及到操作系统的内核调用,开销较大。线程池虽然可以复用线程,但每个线程仍然需要占用固定的栈空间,当线程数量较多时,会占用大量的系统内存。以下是 Java 中创建线程的示例:
// 创建一个新的线程对象
Thread thread = new Thread(() -> {// 线程执行的代码System.out.println("Thread is running");
});
// 启动线程
thread.start();
2. 并发控制
- 协程:
协程是协作式的并发,由开发者控制协程的挂起和恢复。在 Kotlin 协程中,suspend
函数就是控制协程挂起的关键。当协程遇到suspend
函数时,会调用Continuation
的resumeWith
方法将控制权让出。以下是delay
函数的简化示例:
// 定义一个挂起函数,用于暂停协程指定的时间
suspend fun delay(timeMillis: Long) {// 如果延迟时间小于等于 0,直接返回if (timeMillis <= 0) return // 调用 suspendCancellableCoroutine 函数挂起协程,并在指定时间后恢复return suspendCancellableCoroutine sc@ { cont: CancellableContinuation<Unit> ->// 调用协程上下文的 delay 调度器,在指定时间后恢复协程cont.context.delay.scheduleResumeAfterDelay(timeMillis, cont) }
}
- 多线程 / 线程池:
多线程是抢占式的并发,由操作系统调度。线程之间竞争 CPU 资源,为了保证线程安全,需要使用同步机制,如synchronized
关键字或Lock
接口。以下是使用ReentrantLock
实现线程同步的示例:
import java.util.concurrent.locks.ReentrantLock;// 定义一个计数器类
class Counter {// 计数器的值private int count = 0; // 创建一个 ReentrantLock 对象,用于线程同步private final ReentrantLock lock = new ReentrantLock(); // 增加计数器的值public void increment() {// 获取锁lock.lock(); try {// 增加计数器的值count++; } finally {// 释放锁lock.unlock(); }}// 获取计数器的值public int getCount() {// 获取锁lock.lock(); try {// 返回计数器的值return count; } finally {// 释放锁lock.unlock(); }}
}
3. 代码编写
- 协程:
协程可以以同步的方式编写异步代码,避免了回调地狱。例如,使用withContext
函数切换线程:
// 定义一个挂起函数,用于在 IO 线程中获取数据
suspend fun fetchData() = withContext(Dispatchers.IO) {// 模拟网络请求,暂停 1000 毫秒delay(1000) // 返回获取到的数据"Data from network"
}
withContext
函数内部会挂起协程,切换到指定的线程池执行任务,任务完成后再恢复协程。
- 多线程 / 线程池:
多线程编程需要使用回调、Future
、Handler
等机制来处理异步操作。以下是使用ExecutorService
和Future
来执行异步任务的示例:
import java.util.concurrent.*;public class Main {public static void main(String[] args) {// 创建一个单线程的线程池ExecutorService executor = Executors.newSingleThreadExecutor(); // 提交一个任务到线程池,并返回一个 Future 对象Future<String> future = executor.submit(() -> {// 模拟网络请求,暂停 1000 毫秒Thread.sleep(1000); // 返回获取到的数据return "Data from network"; });try {// 获取任务的结果,如果任务还未完成,会阻塞当前线程String result = future.get(); // 打印任务的结果System.out.println(result); } catch (InterruptedException | ExecutionException e) {// 处理异常e.printStackTrace(); } finally {// 关闭线程池executor.shutdown(); }}
}
这种方式代码逻辑比较复杂,容易出现嵌套和回调地狱的问题。
综上所述,协程在资源消耗、并发控制和代码编写方面都具有明显的优势,尤其适合处理大量的异步任务。在 Android 开发中,合理使用协程可以提高应用的性能和可维护性。