摘要:对于开发者而言,传统线程模型逻辑直观但性能受限,而异步模型虽性能高却复杂性大。协程以“同步编程,异步执行”平衡两者,成为现代语言标配。结合自身业务需求,快手基于社区开源版本自研了 Java17 透明协程技术,实现对业务无侵入的同时,吞吐能力提升 30%以上。本文将深入剖析快手协程技术的背后原理与架构演进。

一、协程技术的发展与挑战

协程作为计算机领域的一项古老技术,其思想可追溯至 1963 年。然而很遗憾的是在之后的岁月里,协程并没有成为并发编程的主流,取而代之的是对用户更加友好的基于抢占式调度的线程模型。尽管如此,协程并未淡出历史舞台。进入 21 世纪后,随着互联网业务的蓬勃发展,协程因其调度策略的高效性和对吞吐量的友好性而重新受到工业界的青睐,CPP、Lua、Python、Golang、C#等一众编程语言纷纷开始支持协程,迎来了协程实践的广泛应用。相较于其他语言,Java 在协程方面的发展起步较晚。2011 年,JKU 首次提出了 Java 协程的原型,并发表了一系列具有指导意义的论文,为 Java 协程的实现指明了方向。自此,Java 协程进入了快速发展阶段,各大厂商纷纷推出了各具特色的 Java 协程解决方案,如阿里的 Wisp 协程方案、腾讯的 Fiber 协程方案以及 Oracle 官方的 Loom 协程方案等。其中,Oracle 官方的 Loom 协程方案自 2018 年启动以来,备受瞩目,并在 2023 年的 Java 21 版本中正式发布,引发了 Java 业界的广泛关注,被视为 Java 生态中的重要里程碑。

图片

传统并发编程存在线程和异步两种模型,各自特点鲜明:线程模型开发友好,但性能受限;异步模型性能优越,但开发复杂度高。协程则融合了两者的优点,实现了编程效率与运行效率的平衡。通过简化应用示例,下图展示了协程、线程和异步模型之间的关键差异。

图片

尽管协程在性能上具备显著优势,但其应用也需考虑特定场景。当业务服务呈现以下特征时,协程将极大提升服务的极限 QPS 性能:

  • 云原生高负载环境:服务进程在 CPU 资源受限的情况下频繁遭遇节流。
  • 线程上下文切换频繁:服务进程具有 IO 密集或锁密集的特点,导致线程上下文切换频繁。

业界普遍认为,协程的主要优势在于减少了内核线程的上下文切换指令开销。然而,更为关键的收益在于协程显著改善了内核 CFS 的调度延迟。以下图为例,在云原生 k8s 环境中,当服务在单核 CPU 配额不足的高负载工况下运行时,线程的 CFS 公平调度策略可能引发 CPU Throttle,从而严重影响响应时间(RT)。相比之下,协程采用的 FIFO(先进先出)调度策略完全消除了 CPU 节流现象,将平均响应时间从 101ms 缩短至 63ms,显著提升了服务的 QPS 上限。

图片

二、快手 Java 透明协程技术的演进之路

Java 协程作为一种“轻量级线程”,拥有“同步编程,异步运行”的特性,在提升服务 QPS,优化成本等方面具有较大潜力。而快手的线上业务大量运行在 Java 上,鉴于此,快手于 23 年 4 月份启动 Java 透明协程项目。此项目对于快手而言意义重大,具体体现在多个方面:

  • 运行效率提升:协程在提升 QPS 方面的卓越表现,结合系统软件优化的规模化效应,将为快手带来可观的成本节省收益。
  • 编程效率提升:快手各业务线层进行了部分不彻底的异步化改造,导致框架代码复杂度增加,可维护性降低,架构演进受阻。透明协程的引入有助于提升快手高并发架构的开发效率。
  • 云原生架构演进:协程将补齐 Java 语言的短板,助力快手的技术架构更好地适应云原生场景,为长远技术规划奠定基础。

2.1 Java 协程方案选型

目前 Java 业界具有代表性的方案有两类:Oralce 官方的 Loom 协程,阿里 Dragonwell 社区的 Wisp 协程。二者特点如下:

  • 透明性:Loom 不支持透明协程,这意味着业务方在引入 Loom 时需要对原有代码进行一定的改造与适配。相比之下,Wisp 协程则提供了透明协程的支持,使得业务方能够在几乎不感知协程存在的情况下轻松使用。
  • 切换性能:Loom 的切换性能相对较低,这主要源于其对栈序列化的处理。而 Wisp 则凭借高效的切换机制,实现了更高的切换性能。理论上,更高的切换性能意味着能够支持更高的 QPS,从而带来更好的系统性能与用户体验。
  • 并发数:由于 Loom 协程的栈是按需使用的,因此它占用的物理内存较少,同时对 StopTheWorld 事件的影响也更小。这使得 Loom 能够支持更高的并发数,并通过结构化并发的策略,进一步降低服务的响应时间(RT)。
    综合考虑快手 Java 服务的庞大体量以及业务适配改造的成本,最终选择基于 Dragonwell 社区的 Wisp 协程方案进行改造优化。

2.2 快手 Java 协程架构演进

2.2.1 社区协程架构

Dragonwell 社区的原生协程架构作为快手协程架构的雏形,整体如下:

图片

社区 Java 协程架构分为调度器、IO 管理模块、Timer 管理模块和 Locker 管理模块 4 个主体。具体来说:调度器的工作线程是 WispCarrier,其数量和 CPU 核数相当,主要职责包括轮询 RunQueue 获取任务进行执行,查询 IO 管理/Timer 管理/Locker 管理模块获取就绪任务到 RunQueue,Steal 任务等。如果 WispCarrier 处于空闲状态则会进入休眠,让出 CPU 资源;IO 管理模块主要负责维护所有 FD 和阻塞 Task 的映射关系,基于 Epoll 机制提供 IO 就绪状态的查询能力;Timer 管理模块则专注于定时器的全面管理,每个定时器对应一个阻塞 Task,该模块提供定时器到期 Task 查询能力;Locker 管理模块同样不可或缺,负责统筹管理所有因锁而阻塞的任务,并具备高效查询锁就绪状态下相关任务的能力。由于快手的 Java 服务场景相对复杂,上述架构模块内部的一些机制缺陷在落地过程中逐步暴露出来,成为快手 Java 透明协程规模化落地的主要障碍。缺陷主要集中在如下几个方面(对应上面架构图红色标记部分):

  • 调度器缺陷:原生调度实现策略在低负载工况下 CPU 消耗偏高,无法满足客户的需求。
  • 抢占缺陷:原生架构下协程长任务抢占机制开销大,且无法实现 JNI 长任务的及时抢占,导致部分服务的长尾延时高,影响服务可用性。
  • IO 管理缺陷:原生 IO 管理机制在部分场景下 IO 查询不及时,导致服务的平均延时严重劣化。快手需要通过持续的 Java 透明协程架构升级演进,来解决上述一系列制约 Java 透明协程技术规模化落地的障碍。

2.2.2 调度 CPU 优化

Wisp 在线上试点过程中暴露了低负载工况下 CPU 使用率偏高的问题(相比线程模型 CPU 劣化 10%+),尽管高负载时协程有显著优势,但低负载时性能表现却不尽人意。

图片

为了攻克 Wisp 调度器在低负载下的 CPU 效率难题,核心在于优化 Context-Switch 频率。然而,我们面临两大挑战:一是 Wisp 原生的认主模式导致任务均匀分散在所有 WispCarrier 上,难以实现任务集中执行以降低切换开销;二是低负载时,Wisp 需依赖 WispCarrier0 和 WispCarrier1(作为 IO Poller)两个线程协同工作,这进一步加剧了协程间的 Context-Switch 频率。针对上述的问题,我们提炼出调度器设计的通用原则:

  • 线程数最小:在及时响应任务调度需求的前提下,保持尽可能少的活跃 WispCarrier 线程数,从而使得能 WispCarrier 尽可能连续执行任务,减少 Context-Switch。为了达到该目的,一方面,对业务的负载需求延迟满足,唤醒新的 Idle WispCarrier 保持谨慎,避免过度响应需求,而是通过合理策略充分压榨现有资源潜力,减少活跃线程数;另一方面,打破传统调度器设计中不同任务类型(如 RunQueue、IO、Timer)各自拥有独立调度线程的惯例,改为 WispCarrrier 在执行任务间隙兼顾 IO/Timer 调度,减少额外线程带来的系统资源消耗。
  • 连续执行:尽可能保持活跃 WispCarrier 线程的稳定,假设我们保留 5 个活跃 WispCarrier,那么调度器需要尽可能保证这 5 个活跃的 WispCarrier 不发生变化,确保工作的连续性,避免频繁陷入空闲休眠状态,引入额外的 Context-Switch。为了达到该目的,一方面任务提交到 WispCarrier 时,优先提交到当前或其它活跃的 WispCarrier,仅在必要时唤醒新的 WispCarrier 来 Steal,减少新 WispCarrrier 线程出现的概率,即使是必要的新 WispCarrier 唤醒,也尽量遵循 LIFO(后进先出)原则唤醒 Idle WispCarrier,确保新唤醒的载体更可能是“连续工作”的 WispCarrier;另一方面,WispCarrier 执行完所有任务时,避免立即进入休眠状态,而是保留少部分作为活跃自旋的 WispCarrier 尝试 Steal 其它 WispCarrier 的任务/Timer。

基于上述 2 条原则,我们重新设计了协程调度器的架构如下:

图片

在新的架构下,WispCarrier 的 CPU 资源分布呈现出一个倒金字塔状集中分布,完美契合了我们的设计初衷,成功消除了 Wisp 协程在低负载工况下相对于传统协程的 CPU 效率劣势。具体见下图:

图片

2.2.3 调度抢占优化

协程的抢占长尾延时高一直是业界面临的难题,协程的切换时机完全依赖于用户代码行为,如果遇到长任务(用户代码长时间运行非阻塞代码不释放 WispCarrier),就会造成业务长尾延时高。为了缓解该问题,社区 Wisp 协程基于 Safepoint 机制实现了调度抢占,但该机制存在如下问题:

  • Java 长任务抢占代价高:为了抢占一个业务协程,Safepoint 需要打断所有的用户线程进入昂贵的 StopTheWorld,导致所有线程的业务 RT 都会发生抖动,影响面大。
  • JNI 长任务无法抢占:快手大量使用 JNI,而 JNI 是无法被 Safepoint 打断的,因此 JNI 长任务的长尾延时劣化无法解决。

对于 Java 长任务的抢占,我们抛弃了昂贵的全局 Safepoint,改用 Java17 引入的 Handshake 机制来实现抢占。Handshake 机制能够实现特定线程的打断,将 StopTheWorld 改为 StopTheThread,避免了 StopTheWorld 导致的所有线程的暂停。为了实现 JNI 长任务的抢占,我们重新思考了抢占的本质。抢占的本质目的在于消除长任务执行对其它任务的影响,虽然 JNI 没有类似 Handshake 的机制能够打断特定任务,但如果将受影响的任务及时转交给其它的 WispCarrier 进行补偿,同样也可以消除长任务的影响。基于思路的转换,我们针对 JNI 长任务设计了 HandOff 调度抢占机制(Wisp 社区存在 HandOff 机制的原型,但很遗憾并没有最终完全实现):当调度器发现某个 JNI 任务执行时间过长需要触发抢占时,我们将对应的 WispCarrier 中受影响的任务全部 HandOff 移交给其它空闲 WispCarrier 来执行,这样 JNI 长任务就被限制在一个单独的 WispCarrier 里独立运行,不会影响其它 WispTask。HandOff 抢占机制整体架构图如下:

图片

对比旧的抢占机制,新的任务抢占机制有着显著的优点:

  • 能够抢占 JNI 长任务:HandOff 通过补偿空闲线程的方式,巧妙得实现了对于 JNI 长任务的抢占。
  • 抢占代价低:对于 Java 长任务,Handshake 抢占代价显著优于 Safepoint 抢占;对于 JNI 长任务,基于一系列关键数据结构的重构(WP 分离),HandOff 仅仅是唤醒空闲线程和交换指针,抢占开销非常小。
    基于新的调度器抢占设计,我们解决了 JNI 长任务造成的长尾延时劣化,扩大了协程优化的落地适用范围,并提升了抢占的性能。优化效果如下:

图片

2.2.4 IO 模型优化

针对 Wisp IO 模型在生产环境中推广时所暴露的缺陷,我们进行了 IO 模型的重构,旨在解决以下问题:

  • 查询不及时:Wisp 进行 IO 查询的响应速度不足,导致响应时间过长。
  • 设计低效:Wisp 原生的 IO 管理模块采用基于 HashMap 的集中式 FD 到 WispTask 的关系映射设计存在激烈临界态竞争。并且 Epoll 采用 EPOLLONESHOT 模式,系统调用过多。
  • 堆外内存膨胀:Wisp 的 Socket 劫持实现完全拷贝 Java8 的旧的 Socket,相比于 Java17 的线程模型下 NIOSocket,其 ThreadLocal DirectBuffer 堆外缓存不设容量上限,堆外内存资源消耗较多。

这些缺陷在某些服务工况下,使得 Wisp 的 RT 相对于线程模型显著劣化。为了克服这些挑战,我们遵循以下协程 IO 模型的设计原则进行了优化:

  • 非阻塞 IO 补偿:在适当时机进行非阻塞 IO 补偿,以规避 timedEpoll 执行优先级较低可能带来的 IO 延时风险。
  • 复用内核结构:尽可能复用内核数据结构,简化用户态设计,同时采用边沿触发代替 EPOLLONESHOT 模式,一次性为 FD 注册所有 IO 事件,从而实现系统调用作用范围的复用,大幅度减少 epoll_ctl 系统调用的频率。
  • 资源缓存限定:对于 ThreadLocal DirectBuffer 等缓存资源,设定合理的上限,以防止资源消耗失控。

下图是基于上述原则重构后的 IO 架构图:

图片

通过对 IO 模型的改进,我们解决了部分服务下 Wisp 延迟增加的问题,业务延时效果对比如下,优化后的 Wisp 在响应时间上有了显著提升,与线程模型的性能差距明显缩小,甚至在某些场景下实现了超越。

图片

2.2.5 快手 Java 协程架构

通过上述一系列调度器、IO 管理、任务抢占等关键架构模块的深入优化和重构,我们最终完成了快手 Java 协程架构的整体升级,新架构如下:

图片

在新架构下,之前困扰快手 Java 协程规模化落地的几个关键问题都得到了很好的解决:

  1. 调度器缺陷:通过调度策略的重新设计,我们有效控制了低负载工况下协程的 CPU 消耗,满足了客户的需求。
  2. 抢占机制缺陷:通过新的 Handshake 和 HandOff 机制,我们显著降低了 Java 长任务抢占开销,并实现了 JNI 长任务的及时抢占,降低了服务可用性风险。
  3. IO 管理缺陷:通过 IO 管理模块关键数据结构的重构改造,消除了 IO 查询不及时导致的服务平均延时劣化。

上述架构已经在快手的 Java17 版本中得以实现,填补了 Dragonwell 社区协程特性在 Java17 上的空白。

三、协程落地成果与未来展望

通过和 Dragonwell 社区的深入合作,快手成功在 Java17 上实现了透明协程,并陆续解决了性能、稳定性、业务功能适配等一系列问题。如今,该技术已趋于成熟稳定,为业界提供了一个在大规模生产环境中成功应用协程的实践范例。目前,协程已在快手全面部署上线,其服务极限 QPS 实现了 30%以上的显著提升,有力推动了快手 Java 服务的降本增效。据统计,协程技术已为快手节省了数千万的服务器成本。展望未来,随着 Java 协程覆盖率的持续提高,其带来的服务性能提升将进一步降低快手的服务资源成本,实现更为显著的经济效益。在协程技术的未来发展中,我们还将致力于以下几方面的探索与改进:

  • 与 Loom 的深度融合:Loom 作为 OpenJDK 社区的官方协程实现,我们将努力推动其与 Wisp 协程的和谐共存,以实现技术的互补与协同。
  • 调度策略与调度器的解耦:为了提供更加灵活高效的协程管理,我们将进一步将调度策略与调度器设计进行解耦,允许用户根据自身需求自定义协程策略。这将有助于用户实现更具针对性的、性能更优的调度方案,从而进一步提升服务效能。

快手技术
12 声望4 粉丝