kotlin协程-基础概念篇

种一颗树的最好时机是十年前,其次是现在。 学习也一样。 跟着霍老师的《深入理解Kotlin携程》学习一下协程。

协程是什么

当我们说到协程的时候,总会提到它是轻量级的用户态线程,能够挂起恢复 ,也就是允许函数在执行过程中主动让出控制权,并在之后从断点继续执行。 从这里我们可以看出协程的一些特点: 用户态控制: 协程的调度完全由程序自身(开发者)控制,而非操作系统内核,因此切换开销远小于线程。 协作式调度: 协程通过显式的挂起(如yield、await)和恢复操作交出执行权,同一时间只有一个协程在运行,不存在线程的抢占式调度冲突。 状态保存: 协程挂起时会保存自身的执行上下文(如局部变量、程序计数器等),恢复时可从挂起点继续执行,无需重新初始化。

为什么要有协程

其实这个问题和协程解决了什么问题是一样的,个人粗浅的简介,协程解决了如下两个大方面的问题:

异步编程的复杂性

这里复杂性是指我们写代码的复杂性和代码阅读的困难性。 当我们进行网络请求或者其他IO密集型的任务(文件读写、数据库操作)时,通常情况不会 傻傻地等待任务结束,浪费cpu资源,传统的解决方案就是回调函数(callback),但这导致了著名的"回调地狱"(Callback Hell),代码嵌套深,难以阅读和维护。 但协程通过"同步的方式写异步的代码",解决了这个问题。代码看起来是顺序执行的,但实际上是异步非阻塞的。 举个例子

javascript// 复制代码
httpRequest.get("url", function(response) {
    response.on("data", function(data) {
        // 处理数据,如果再嵌套其他异步操作...
    });
});

// 使用 Async/Await(假设语法上允许这么写,只是做个示例)
async function fetchData() {
    let response = await httpRequest.get("url"); // 挂起,等待结果,但不阻塞线程
    let data = await response.json(); // 再次挂起等待
    return process(data);
}

轻量级并发

那么它在哪?

核心比喻:公司与员工

  • 进程 :一个公司。拥有独立的办公场地、仓库、资金(对应:独立的内存空间、系统资源)。
  • 线程 :公司的正式员工
    • 每个员工的招聘和解雇(创建和销毁)需要经过HR和行政的复杂流程,成本高(系统调用,内核介入)。
    • 员工之间的工作切换(上下文切换)需要开会、交接任务,也很耗时(内核态切换,保存大量寄存器状态)。
    • 员工数量太多时,管理开销会变得巨大(系统调度器负载过重)。
  • 协程 :公司为了完成一个特定项目而临时组建的"团队"或"工作小组"
    • 创建和解散:经理一句话就能组建或解散,完全在公司内部完成,成本极低(用户态创建,无需内核参与)。
    • 工作切换:小组内的工作交接非常简单。比如,A说"我做到这里,先等你把数据给我",然后B就开始做。交接只在小组内部记录一下,不需要惊动全公司(用户态切换,保存少量上下文)。
    • 数量:一个公司可以轻松拥有成千上万个这样的临时小组,而正式员工的数量可能只有几百个。

"轻量"的具体体现(技术层面)

基于上面的比喻,我们来看具体"轻"在哪儿:

1. 创建和销毁的成本:极低
维度 线程 协程
操作者 操作系统内核 用户程序/运行时库
系统调用 需要(如 clone, pthread_create 不需要
内存分配 需要为栈、线程控制块等在内核/用户空间分配内存(通常为 MB 级别,如Linux默认8MB) 通常在堆上预分配或动态分配一小块内存(通常为 KB 级别,如2KB)
速度 慢(毫秒级,ms) 极快(纳秒级,ns,通常比线程快100-1000倍)

结论 :你可以轻松创建数万甚至数十万个协程,而创建同样数量的线程会直接耗尽你机器的内存和CPU资源。

2. 上下文切换的开销:极低

这是协程性能优势最关键的一点。

维度 线程切换 协程切换
切换者 操作系统内核(调度器) 用户程序/运行时库(协程调度器)
模式切换 需要从用户态 切换到内核态,再切回用户态。这个操作本身就有CPU开销。 完全在用户态完成,没有模式切换的开销。
保存的上下文 非常多。包括:通用寄存器、浮点寄存器、状态寄存器、栈指针、程序计数器、内存映射信息等。需要保存整个CPU现场。 非常少 。通常只保存必要的寄存器(如程序计数器、栈指针)和少量局部变量。
缓存影响 可能很大。线程被切换到不同CPU核心时,CPU缓存(L1/L2/L3)可能失效,导致性能下降。 很小。协程通常在同一个线程内切换,对CPU缓存友好。

结论:协程的切换就像是自己收拾一下桌面,准备做另一件事;而线程切换就像是整个公司开会,宣布换一个项目组来用这个办公室。

3. 栈内存管理:灵活且节省
  • 线程 :每个线程都有自己固定大小的栈内存(如8MB)。这块内存在线程创建时就被保留,即使线程只用了很少的栈空间(比如几十KB),这8MB也被占着。
  • 协程 :协程的栈通常是动态增长 的,或者是在堆上分配的一小块内存。开始时可能只有2KB,如果不够用,可以再申请。这使得数千个协程的总内存占用远小于同样数量的线程。
4. 调度方式:协作式 vs 抢占式
  • 线程(抢占式):操作系统调度器会在任何时候(通过时钟中断)强制暂停当前线程,切换到另一个线程。这保证了"公平",但增加了不确定性,并且切换时机不可预测。
  • 协程(协作式) :协程主动 通过 yieldawait 等关键字自愿 交出执行权。调度器只在协程挂起时才会进行切换。
    • "轻"在哪里?
      1. 无需复杂的同步原语:因为切换点是明确的,所以在协程内部,非挂起点的代码段是"原子性"的,不需要加锁来保护。这简化了编程,也避免了锁的开销。
      2. 切换时机可控:调度器只在协程"愿意"的时候才工作,没有强制中断带来的额外上下文保存开销。

总结表格

特性 线程 协程 "轻"在何处
创建/销毁 重量级,需内核介入 轻量级,用户态完成 无系统调用,内存占用小,速度快
上下文切换 重量级,需陷入内核 轻量级,完全在用户态 无模式切换,保存的上下文极少
栈内存 固定大小(MB级) 动态/小块堆内存(KB级) 内存利用率高,总占用小
调度 抢占式,由内核强制 协作式,由协程主动让出 切换时机明确,无需锁,开销可控
数量级 成百上千 成千上万 资源开销小,可创建数量多

小提醒

虽然经常听到"协程是轻量级的线程"这个说法,虽然直观,但容易引起误解。更准确的理解是:

协程是运行在线程之上的、由用户程序控制的、更高级的并发抽象。一个线程可以同时运行多个协程,由协程调度器负责在它们之间进行高效的、用户态的切换。

它的"轻",归根结底是因为它将调度的权力从操作系统内核(重量级)夺回,交给了用户程序的运行时(轻量级),从而避免了所有与内核交互带来的巨大开销。

协程分类

这个对于应用开发者来讲并不重要,了解一下就好

按调用栈分类

这个分类关注的是协程在执行过程中如何管理其栈内存,它直接决定了协程的通用性和实现复杂度。

栈式协程

有的也叫有栈协程

  • 核心特征 :拥有独立的调用栈,类似于线程。
  • 工作方式:当一个栈式协程挂起时,它会将其完整的调用栈(包括嵌套调用的函数链)保存起来。恢复时,整个调用栈被还原,可以从最内层的函数继续执行。
  • 能力:非常强大。可以在任意深度的函数调用中挂起。
  • 比喻:就像用录像机完整地录下了一部话剧的整个排练现场(包括所有演员的位置和状态),随时可以按播放键从中断处继续排练。
  • 优点
    • 使用方便:程序员无需关心当前执行点在哪一层函数,可以任意进行函数调用,并在任何地方挂起。
    • 功能强大:支持通用的、任意嵌套的挂起操作。
  • 缺点
    • 内存开销大:每个协程都需要预分配一块足够大的栈内存以防溢出,即使它大部分时间只使用很少的栈空间。当协程数量极多时,总内存消耗可观。
    • 实现复杂:需要管理栈内存的分配、增长和切换。

无栈协程

  • 核心特征没有独立的调用栈 。其状态通过状态机在堆上显式地存储。
  • 工作方式 :编译器会将一个可挂起的函数(如 async 函数)编译成一个状态机。挂起时,只保存必要的局部变量(通常是 await 表达式之前的那些)和当前状态机的状态(如 label),而不是整个调用栈。恢复时,根据保存的状态跳转到对应的 label 继续执行。
  • 能力 :挂起点是受限的,通常只能在协程体顶层函数中显式地使用 awaityield 挂起。你不能在一个未被声明为 async 的深层函数内部随意挂起。
  • 比喻 :就像一本选择你自己的冒险书。你不需要记住所有页面的状态,只需要一个书签(当前状态)和记录下你之前做过的几个关键选择(局部变量)。翻到指定页码,根据选择继续故事。
  • 优点
    • 极其轻量:内存开销极小,通常只有一个对象的大小,创建和切换开销极低。
    • 实现简单:无需复杂的栈管理逻辑,非常适合在语言库层面实现。
    • 与异步IO天然契合:其"传染性"(async/await语法)迫使程序员显式标记挂起点,使得代码的异步流程非常清晰。
  • 缺点
    • 使用受限 :不能在任意函数调用中挂起,所有可能挂起的调用链都必须被标记为 async 并使用 await。这被称为"颜色问题"。
    • 传染性 :一旦一个函数内部需要挂起,它自身也必须被标记为 async,这会影响到它的所有调用者。

按调度方式分类

这个分类关注的是由谁来决定何时挂起当前协程并切换到另一个。

1. 对称协程

  • 核心特征所有协程是平等的 。任何一个协程都可以主动将执行权直接交给另一个指定的协程。
  • 工作方式 :协程A通过一个类似 transfer(B) 的操作,直接切换到协程B。调度逻辑分散在每个协程中。
  • 比喻 :在一个圆桌会议上,任何一个人发言完毕后,可以指定下一位发言人。
  • 优点
    • 灵活:可以实现非常复杂的控制流。
  • 缺点
    • 控制流复杂:由于调度权分散,程序的控制流可能会变得难以理解和维护,容易形成"意大利面条式"的代码。

2. 非对称协程

  • 核心特征协程之间存在调用关系 ,类似于函数调用。提供了两个核心原语:yield(挂起)和 resume(恢复)。
  • 工作方式 :协程B由协程A(或主程序)resume 启动。当B执行 yield 时,它不会指定切换给谁,而是无条件地将控制权返还给恢复它的那个协程A
  • 关系 :这建立了一种半对称的调用者-被调用者关系yieldresume 是配对的。
  • 比喻老板与员工 。老板(调用者)让员工(协程)去完成一项任务(resume)。员工在过程中可以多次回来向老板汇报进度并等待下一步指示(yield),但员工不能直接把任务甩给另一个同事。
  • 优点
    • 控制流清晰:结构类似于函数调用,非常易于理解和推理。生产者-消费者模型用非对称协程实现起来非常直观。
    • 主流选择:更符合大多数编程场景的直觉。
  • 缺点
    • 灵活性不如对称协程。

分类总结与现实世界的映射

分类维度 类型 核心特征 典型代表
调用栈 栈式协程 有独立栈,可在任意函数深度挂起 Go (Goroutine), C++20
无栈协程 通过状态机实现,挂起点受限 JS/Python/C# (async/await), Rust
调度方式 对称协程 协程间直接、平等地切换 早期 Lua
非对称协程 通过 yield/resume 与调用者交互 大多数现代语言 (Python生成器, JS async/await, Kotlin)

重要提示:这两种分类是正交的。一个协程系统可以是:

  • 栈式 + 非对称 :如 Go (Goroutine的调度是Go运行时负责的,但对程序员呈现的 channel 通信模型更像非对称)。
  • 无栈 + 非对称 :如 JavaScript、Python、C#async/await。这是目前最流行的组合,因为它实现了轻量级和清晰控制流的平衡。
  • 栈式 + 对称:一些研究性的或特定领域的协程实现。

以上就是我们需要关注的协程概念了,下一篇我们来看下协程的基础设施

相关推荐
alexhilton2 小时前
Compose中的ContentScale:终极可视化指南
android·kotlin·android jetpack
jzlhll1233 小时前
kotlin Flow first() last()总结
开发语言·前端·kotlin
符哥20088 小时前
充电桩 WiFi 局域网配网(Android/Kotlin)流程、指令及实例说明文档
android·开发语言·kotlin
大傻^11 小时前
SpringAI2.0 Null Safety 实战:JSpecify 注解体系与 Kotlin 互操作
android·开发语言·人工智能·kotlin·springai
jzlhll12316 小时前
Kotlin Mutex vs Java ReentrantLock vs synchronized
java·开发语言·kotlin
Kapaseker16 小时前
一杯 Kotlin 美式品味 object 声明
android·kotlin
俩个逗号。。17 小时前
Kotlin 扩展函数详解
开发语言·kotlin
su1ka1111 天前
Kotlin(3)基本语法
kotlin
su1ka1112 天前
Kotlin(4)面向对象
kotlin
鹧鸪晏2 天前
搞懂 kotlin 泛型 out 和 in 关键字
android·kotlin