导读
对于开发者而言,传统线程模型逻辑直观但性能受限,而异步模型虽性能高却复杂性大。协程以“同步编程,异步执行”平衡两者,成为现代语言标配。结合自身业务需求,快手基于社区开源版本自研了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频率。
针对上述的问题,我们提炼出调度器设计的通用原则:
(1)线程数最小
在及时响应任务调度需求的前提下,保持尽可能少的活跃WispCarrier线程数,从而使得能WispCarrier尽可能连续执行任务,减少Context-Switch。为了达到该目的,一方面,对业务的负载需求延迟满足,唤醒新的Idle WispCarrier保持谨慎,避免过度响应需求,而是通过合理策略充分压榨现有资源潜力,减少活跃线程数;另一方面,打破传统调度器设计中不同任务类型(如RunQueue、IO、Timer)各自拥有独立调度线程的惯例,改为WispCarrrier在执行任务间隙兼顾IO/Timer调度,减少额外线程带来的系统资源消耗。
(2)连续执行
尽可能保持活跃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协程规模化落地的几个关键问题都得到了很好的解决:
调度器缺陷:通过调度策略的重新设计,我们有效控制了低负载工况下协程的CPU消耗,满足了客户的需求。
抢占机制缺陷:通过新的Handshake和HandOff机制,我们显著降低了Java长任务抢占开销,并实现了JNI长任务的及时抢占,降低了服务可用性风险。
IO管理缺陷:通过IO管理模块关键数据结构的重构改造,消除了IO查询不及时导致的服务平均延时劣化。
上述架构已经在快手的Java17版本中得以实现,填补了Dragonwell社区协程特性在Java17上的空白。
三、协程落地成果与未来展望
通过和Dragonwell社区的深入合作,快手成功在Java17上实现了透明协程,并陆续解决了性能、稳定性、业务功能适配等一系列问题。如今,该技术已趋于成熟稳定,为业界提供了一个在大规模生产环境中成功应用协程的实践范例。目前,协程已在快手全面部署上线,其服务极限QPS实现了30%以上的显著提升,有力推动了快手Java服务的降本增效。据统计,协程技术已为快手节省了数千万的服务器成本。展望未来,随着Java协程覆盖率的持续提高,其带来的服务性能提升将进一步降低快手的服务资源成本,实现更为显著的经济效益。
在协程技术的未来发展中,我们还将致力于以下几方面的探索与改进:
与Loom的深度融合:Loom作为OpenJDK社区的官方协程实现,我们将努力推动其与Wisp协程的和谐共存,以实现技术的互补与协同。
调度策略与调度器的解耦:为了提供更加灵活高效的协程管理,我们将进一步将调度策略与调度器设计进行解耦,允许用户根据自身需求自定义协程策略。这将有助于用户实现更具针对性的、性能更优的调度方案,从而进一步提升服务效能。