活动回顾 / T SALON

编辑内容

张涛谈 Android 协程原型:JNI 调度、挂起边界与实现限制

张涛从线程调度的成本出发,展示一个连接 Java、JNI 与 C++ 的 Android 协程教学原型,说明任务队列、状态流转和挂起恢复,也坦陈语法与 I/O 支持的不足;回顾同时辨清问答中对 Kotlin 协程的误解。

张涛这场分享的重点,是亲手做一个协程原型时需要回答哪些问题:任务运行在哪个线程,何时交出执行权,调度器怎样找到下一个任务,又怎样回到之前暂停的位置。

这段分享来自上海“移动端技术实践”录播,稿件发布于 2022 年 3 月 12 日,实际活动日期未确认。分享展示的是探索性的教学实现,涉及 Kotlin 的现场问答也应与官方机制区分。

为什么从线程谈起

分享先用做菜的比喻说明并发与并行,再回顾进程、线程、上下文和调度。对开发者而言,创建多个任务容易,控制它们如何等待、切换与协作却需要额外机制。把所有工作交给线程,并不意味着等待、调度和资源占用的问题自动消失。

他的做菜例子从一位厨师一次做一道菜开始:要交替做两道菜,就得记下第一道菜放了什么、做到哪一步,再切到另一道菜。这个“保存现场”的动作对应他想解释的上下文切换。随后他把比喻带到进程和线程,讨论各自需要维护的状态与切换成本;这是帮助理解问题的示意,并非进程与线程真实成本的定量比较。

张涛希望协程让任务之间的切换更可控,降低某些场景下的调度负担,并减少层层嵌套的异步回调。他关注的收益主要是更有效地组织等待和后续工作,而不是让同一段 CPU 密集计算凭空加速。对任意阻塞调用,协程也不会自动把它变成非阻塞调用。

他画出的另一种情形是:一个任务发起文件或网络 I/O 后需要等待,执行线程若能先去处理队列中其他可运行任务,就可能更充分地利用等待时间。与其说“协程提高了计算速度”,不如说它提供了主动让出和恢复的组织方式。若底层 I/O 仍把承载线程阻塞住,单靠把任务命名为协程并不能产生这项收益。

这里还应区分两个概念:任务可以在一个线程上交替推进,形成并发;真正同时执行则涉及并行。不能因为协程运行于单线程,就认为它们之间不可能存在状态协调问题。任务在挂起后恢复时,共享状态可能已被其他任务改变。Kotlin 语言规范

先列出需要实现的操作

原型围绕启动、挂起、恢复、等待另一个任务和延迟执行等操作展开。张涛希望任务等待一段时间时,调度器可以先处理别的工作;如果一个任务依赖另一个任务的结果,也能表达这种先后关系。

分享把目标拆成三步:先有可供任务运行的原生线程,再让多个任务交给它的调度器,最后才处理任务执行中遇到 delay 或 join 时怎样切换。这个顺序很关键:仅仅能启动任务,仍只是把工作排队;只有保存暂停位置、取出别的任务并在合适条件下恢复,才开始触及协程机制。

这些操作看似只是 API 名称,背后却要回答不同问题:启动需要确定任务归属,挂起需要保存恢复位置,等待需要管理依赖,结束需要处理结果和回收。一个方法叫 delay 或 join,并不等于它已经具备成熟协程库相同的语义、取消行为和异常处理能力。

Java、JNI 与原生线程怎样连接

张涛先展示线程封装:上层提供 Java 接口,经 JNI 进入 C++,再借助原生线程设施启动执行,最终回调 Java 侧的任务方法。这一步建立了从 Java 请求到原生执行、再返回 Java 代码的通路。

现场代码从 Java 的 start 进入原生 startNative,由 JNI 调到 C++ 线程封装,再用原生线程库建立执行线程;线程的 run 最后又回调 Java 方法。张涛还列出让出、休眠和中断等接口,但没有逐个讲解实现。这里的重点不是把 Java 线程 API 重新命名,而是证明任务能跨越 Java 与原生层往返,供后面的调度器使用。

协程 API 建在这条通路上。任务主体和结果处理仍通过 Java 对象及回调表达;创建时可关联当前线程或指定线程,初始化过程则向原生层注册相应对象。它展示了如何跨语言连接调度与业务代码,也暴露出一个限制:调用方式仍然带有回调风格,没有得到直接书写顺序异步代码的体验。

他把任务主体放在 async 对应的执行方法中,把完成后的结果交给另一个回调;与 Kotlin 的 Deferred.await 式调用相比,这里的 Java 示例仍需显式写结果回调。构造任务时默认绑定当前线程,也可以传入已有线程的标识。原型通过一个按线程标识查找的映射找到负责调度的线程,这一步决定了任务要被放进哪个执行队列。

按线程组织队列,再处理任务状态

在调度层,原型按线程标识管理相应的任务集合。新任务进入队列,调度循环从中取出可运行的工作。张涛用生产与消费的关系解释任务提交和执行,把“加入工作”与“何时执行工作”分开。

他用两条入口串起代码:构造时的 init 经 JNI 建立线程侧的调度循环;提交时的 async 经原生层的关联操作,把任务放进对应线程的队列。循环不断取出工作;遇到尚未开始的任务就执行,遇到待恢复的任务则按恢复路径处理。生产者是任务提交端,消费者是调度端,这比只说“开一个线程跑任务”更能解释队列的作用。

任务本身有创建、进入挂起过程、等待恢复、恢复运行和结束等阶段。其中一些状态只在切换过程中短暂存在;一个任务也可能多次挂起和恢复。状态不是为了给日志多加几个标签,而是帮助调度器区分“尚未开始”和“已经运行过、需要接着执行”。完成之后还要清理相关资源。

演示中的状态编号从初始状态到完成状态,中间的挂起、待恢复、已恢复可以循环往复。张涛特别说明,进入挂起与等待恢复之间的若干状态可能只短暂出现;它们对应的是调度器正在保存或交还控制权,而非每个状态都代表一个长期排队阶段。

这种分层让原型具有可讲解性:Java 层负责表达任务,JNI 负责跨语言调用,原生层维护线程、队列和执行顺序。不过,视频展示并不等同于对线程安全、异常传播、资源释放或性能的完整验证。

真正困难的是挂起点

张涛随后把重点转向暂停与恢复:协程并不是在任意一条指令上都能随意停下。必须有明确的挂起边界,并保存恢复执行所需的信息。

演示中的延迟操作,意图是记录当前任务,把执行机会让给其他工作,满足条件后再恢复。讨论队列位置和恢复顺序,是为了说明调度流程。但他也明确提到,自己的实现只支持有限的挂起位置:原生层调用整个 Java 方法时,无法仅凭这一层封装拆分方法内部的任意执行过程。

他用一张放大的队列图讲解:当前任务需要暂停时,先暂存它,继续从队列取出下一个任务;后者执行后,再把前者放回可恢复的位置。这个图解释的是调度意图,不能直接证明延迟计时、任务公平性或取消等边界都已实现。最重要的限制是挂起点只有一个:JNI 能按方法回调 Java,却没有把 Java 方法内部的多段代码自动变成可分别恢复的执行片段。

围绕这个限制,他讨论过在更多位置插入调用或改变运行时的设想,同时承认 JNI 调用成本和侵入性。这里需要补充:修改 JVM 并不是支持 Kotlin 挂起函数的必要条件。Kotlin 编译器通过 continuation 与状态机转换保存恢复信息;挂起点与可挂起调用有关,不是每执行一条语句都自动暂停。Kotlin 语言规范

张涛提出过两个探索方向:在 Java 代码更多位置插入到原生调度器的通知,或更深地修改运行时行为。前者会产生大量跨 JNI 调用,后者侵入性更高,所以他没有把它们作为已完成的解决方案。视频中以“每条语句”为单位描述挂起点,是为说明他希望得到更细粒度的控制;Kotlin 的真实挂起机制不要求每条语句都能随时暂停。

底层设施的选择,以及尚未完成的工作

在底层实现讨论中,张涛比较了不同的上下文切换设施和现成协程库,也提到 C++ 协程支持,最终介绍自己选择 setjmp / longjmp 的思路。这一段呈现了原型开发的取舍:语言提供的机制、库提供的调度能力、目标平台支持和维护成本,需要分别考察。

他把 C++20 语言级协程支持视为较低层的构件,认为仍须自己组织可用的调度接口;又比较了依赖平台或指令集的上下文切换库,担心某些实现只覆盖 x86 系列,不能直接满足当时的 Android 目标环境。最终选用 setjmp / longjmp,主要是因为原型易于动手,不是经过平台覆盖、异常安全和生产性能的全面比较后得出的通用推荐。这场比较也不足以证明其他库在 Android 上的可用范围。

这类非局部跳转不是可直接套用的完整协程方案。尤其在 C++ 中,自动对象的生命周期与析构有明确约束,不能忽略它们而只保留“保存位置、跳回来”的示意。C++ 工作草案相关规定

分享最后列出尚未解决的事情:上层仍需回调,挂起边界受限,以及如何让 I/O 等待与调度配合。他谈到信号等可能方向,但没有交付完整的 I/O 集成方案。这些属于后续探索,而不是原型已经具备的能力。

这三项缺口彼此关联:没有编译期或语言层面的改写,Java 调用方仍要写回调;没有更多可恢复的边界,调度器就不能任意切开任务;没有把 I/O 就绪事件接入队列,阻塞调用也不能自动释放承载线程。张涛提到监听信号的设想,但现场没有展示一个从发起 I/O、暂停、收到就绪通知到恢复任务的完整链路。

问答:这个原型与 Kotlin 协程应怎样比较

现场最后一个实质问题涉及 Kotlin 协程。回答中把 Kotlin 协程理解成线程池的包装,并将挂起和 delay 与阻塞执行线程联系起来。这部分解释与 Kotlin 的官方机制不符,不能作为选型或性能判断的依据。

提问发生在演示原型限制之后,因此比较点很具体:Kotlin 是否也只能通过“一个协程占一条线程”运行,挂起时是否必须停住线程。张涛现场给出的判断是肯定的,并以 JVM 无法直接获知某些 I/O 信号作解释。这个判断混淆了“协程可以运行在线程池上”和“每个协程独占线程”,也把普通阻塞 I/O 与 Kotlin 挂起函数混为一谈。

Kotlin 协程运行在线程之上,由 dispatcher 决定执行安排;多个协程可以在同一个线程上交替推进。挂起协程与阻塞线程是不同的操作,是否并行则取决于调度方式和可用线程等条件。Kotlin 协程基础

同样,kotlinx.coroutines.delay 的语义是在等待期间不阻塞线程,不能等同于调用 Thread.sleep。具体计时机制由相应 dispatcher 的实现处理。Kotlin delay API

回到这场分享本身,它提供的是一个拆开调度器、任务状态和跨语言边界的实践案例。原型的限制值得保留,因为这些限制具体展示了从“能够切换任务”走向可用异步运行机制时,还需要解决哪些问题。

查看原视频与分段信息:移动端技术实践:上海场 →

主题
AndroidC++Kotlin协程