七叶笔记 » golang编程 » 协程框架实现,底层原理与性能分析《面试利刃》

协程框架实现,底层原理与性能分析《面试利刃》

文章内容不重要 干货在总结

c++作为一个相对古老的语言,曾经是步履蹒跚,直到c++11才奋起直追,但是对新技术的整体演进,其实c++仍然是保守的。现在c++20的标准虽然已经实现了协程,但目前能比较好支持c++20的编译器几乎都和整体的环境不太兼容。换句话说,还需要继续等待整个c++的迭代版本,可能到了c++23,整体的环境就会跟上去,协程才会真正的飞入程序员的“寻常百姓家”。

正如前面提到的,协程一般来说是不需要锁的,但是如果协程的底层操作是跨越线程动态操作,仍然是需要锁的存在的。这也是为什么要求尽量把协和的调度放到一个线程中去的原因。

并发模型

Kiev采用了很先进的CSP开发模型的一个变种(golang就是这种模型),这一模型是继承自libgo的。 选择这种模型的主要原因是这种模型的开发效率远高于异步回调模型,同时不需要在性能上做出任何妥协,在文中会对常见的几种模型做详细的对比。

CSP模型

CSP(Communicating Sequential Process)模型是一种目前非常流行的并发模型,golang语言所采用的并发模型就是CSP模型。 在CSP模型中,协程与协程间不直接通信,也不像Actor模型那样直接向目标协程投递信息,而是通过一个Channel来交换数据

这样设计的好处是通过Channel这个中间层减少协程间交互的耦合性,同时又保证了灵活性,非常适合开发并发程序。

RPC框架

RPC(Remote Procedure Call)是一种远程调用协议,简单地说就是能使应用像调用本地方法一样的调用远程的过程或服务,可以应用在分布式服务、分布式计算、远程服务调用等许多场景。说起 RPC 大家并不陌生,业界有很多开源的优秀 RPC 框架,例如 Dubbo、Thrift、gRPC、Hprose 等等。 RPC框架的出现是为了简化后台内部各服务间的网络通讯,让开发人员可以专注于业务逻辑,而不必与复杂的网络通讯打交道。 在我们看来,RPC框架绝不仅仅是封装一下网络通讯就可以了的,要想应对数以百计的不同服务、数千万用户、百亿级PV的业务量挑战,RPC框架还必须在高可用、负载均衡、过载保护、通信协议向后兼容、优雅降级、超时处理、无序启动几个维度都做到足够完善才行。

服务发现

Kiev使用zookeeper做服务发现,每个kiev服务开放时会在zookeeper上注册一个节点,包含地址和协议信息。水平扩展时,同质化服务会注册到同一个路径下,产生多个节点。 依赖的服务调用时,从zookeeper上查询当前有哪些节点可以使用,依照负载均衡的策略择一连接并调用。

负载均衡

内置两种负载均衡策略:robin和conhash,并且根据实际业务场景可以定制。

过载保护

Kiev内置了一个过载保护队列,分为10个优先级。每个请求到达时先进入过载保护队列,而后由工作协程(work-coroutine)取出请求进行处理。 如果工作协程的处理速度低于请求到达的速度,过载保护队列就会堆积、甚至堆积满。 当过载保护队列堆满时,新请求到达后会在队列中删除一个更低优先级的请求,腾出一个空位,塞入新请求。 同时,队列中的请求也是有时效性的,过长时间未能被处理的请求会被丢弃掉,以此避免处理已超时的请求。 这种机制保证了当系统过载时尽量将有限的资源提供给关键业务使用。

通信协议向后兼容

由于微服务架构经常需要部分发布,所以选择一个支持向后兼容的通信协议是很必要的一个特性。 Kiev选取protobuf作为通信协议。

与第三方库协同工作

最早期的Kiev是基于异步回调模型的,但是很多第三方库只提供了同步模型的版本,很难搭配使用。 当前的Kiev是CSP并发模型,配合libgo提供的Hook机制,可以将同步模型的第三方库中阻塞等待的CPU时间充分利用起来执行其他逻辑,自动转化成了CSP并发模型;异步回调模型的第三方库也可以使用CSP模型中的Channel来等待回调触发;从而完美地与第三方库协同工作。

kiev功能组件结构图

Kiev发展史与技术选型

2012年,魅族的推送业务刚刚有一点从传统架构向微服务架构转型的意识萌芽,为了在拆分系统的同时提高开发效率,我们决定做一个C++开发框架,这就是最早期Kiev的由来.

第一个版本的Kiev使用了多线程同步模型,业务逻辑顺序编写,非常简单。 但是由于os对线程数的支持有限,随着线程数量的增长,调度消耗的增长是非线性的,因此不能支持过高的请求并发。

随着用户量的增长,我们需要支持更高的并发请求,由于当年协程还不像现在这样流行,所以我们决定使用异步回调模型编写Kiev。早期的业务形态非常简单,使用异步回调模型也勉强可以应付开发任务。

在其后几年中,我们使用异步回调模型的Kiev开发了大量的服务,在使用中我们慢慢发现逻辑碎片化的问题越来越多,更可怕的是,有些时候长长的回调链还要和有限状态机纠缠在一起,代码越来越难以维护。常常出现类似于下面这样的代码片段:

 11 一个Rpc接口
void RpcMethod(RpcContext* ctx)
く
int u=ctx->get_param<int>(1);
//第1段业务逻辑
...
//保存这段逻辑产生的一些临时数据
ctx->set_context(...);
redis.Call(....&callback_1,&v);
void callback_1(RpcContext* ctx, void* user_data)//取出上一段逻辑保存的临时数据
ctx->get_context(...);
//第2段业务逻辑
//保存这段逻辑产生的一些临时数据
ctx->set_context(...);
redis.Call(....&callback_2,&u);
void callback_2(RpcContext* ctx, void* user_data)へ
//取出上一段逻辑保存的临时数据
ctx->get_context(...);
//第3段业务逻辑
...
//保存这段逻辑产生的一些临时数据
ctx->set_context(...);
redis.Call(...,&callback_3,&U);
void callback_3(RpcContext* ctx, void* user_data)// 取出上一段逻辑保存的临时数据
ctx->get_context(...);
//第4段业务逻辑
---
//保存这段逻辑产生的一些临时数据
ctx->set_context(...);
ctx->response(u);
//清除整个流程中的临时数据
ctx->release_context();
  

针对这样的问题,我们引入了腾讯开源的协程库libco,在协程中执行同步的代码逻辑;同时使用Hook技术,将阻塞式IO请求中等待的时间片利用起来,切换cpu执行其他协程,等到IO事件触发再切换回来继续执行逻辑。类似于上述的碎片化代码就变成了连续性的业务逻辑,也不再需要手动维护上下文数据,临时数据直接置于栈上即可,代码变成如下的样子:

 //一个Rpc接口
void RpcMethod(RpcContext*ctx)int u=ctx->get_param<int>(1);//第1段业务逻辑
U=redis.Call(...);
//第2段业务逻辑
v=redis.Call(…);
//第3段业务逻辑
u=redis.Call(...);
//第4段业务逻辑
ctx->response(u);


  

然而,libco仅仅提供了协程和HOOK两个功能,协程切换需要我们自己做,为了实现简单,RPC框架进化成了连接池的模式,每次发起RPC调用时从连接池中取一条连接来发送请求,等待回复,然后释放回连接池。 每条连接同一时刻只能跑一个请求,rpc协议退化成了半双工模式。此时为保证性能,不得不在每两个有依赖关系的服务之间建立数以百计的TCP连接,这样在依赖了水平扩展为很多进程的服务上,就会与这些进程分别建立数百连接,TCP连接高达数千,甚至上万,对服务器造成了很大的压力。连接请求如下图所示,其中每条连接线都代表数以百计的TCP连接。

相应地,我们也更新了kiev中的redis、mysql、fastcgi模块,都改为了协程模型的。

这种方式很好地帮我们提升了开发效率,同时也有着还算不错的性能(Rpc请求差不多有20K左右的QPS)。随着时间的流逝,我们的用户越来越多,请求量也越来越大,终于在某次新品发布后,我们的一个非关键性业务出现了故障。

最终导致很多请求还没处理完手机端就认为超时了,重新发起了第二次请求,形成雪崩效应。当时紧急增加了一些服务器,恢复了故障,事后总结下来发现,事件的主因还是因为我们没有做好过载保护机制。于是我们决定在Kiev中内置过载保护功能,增加一个分为10个优先级的过载保护队列。每个请求到达时先进入过载保护队列,而后由工作协程(work-coroutine)取出请求进行处理。当过载保护队列堆满时,队列中删除一个最低优先级的请求,腾出一个空位。同时,队列中的请求也是有时效性的,过长时间未能被处理的请求会被丢弃掉,以此避免处理已超时的请求。如下图所示:

随着机器越来越多,以及后续出现了一些超长链路请求的业务形态(这里解释一下长链路请求的问题,长链路请求是指一个请求要流经很多服务处理,在处理流程中,前面的服务一定要等到后面的服务全部处理完成或超时,才会释放其占用的TCP连接,这样的模式会极大地影响整个系统的请求并发数),TCP连接数方面的压力越来越大,使用的libco功能过于简单,很难基于此开发全双工模式的RPC框架,恰好当时有一位同事在github上做了一个叫libgo的开源项目,是一个和golang语言一样的CSP并发模型的协程库,

通过调研,最终我们决定使用libgo替换掉libco。

基于CSP模型实现全双工通信RPC非常容易,客户端只需在每个request发出后保存id和channel并阻塞地等待相应的channel,收到response时根据id找到对应的channel并写入数据即可。这样只需一条TCP连接,就可以并发无数个request,分布式水平扩展带来的TCP连接管理方面的压力就不再是问题了。同时由于每次RPC所需的资源更少,性能也有了很大提升,Rpc请求的QPS轻松提升到了100K以上。这一性能指标目前已经超越了绝大多数开源的RPC框架。

与流行开源框架对比

总结;详细教程资料关注+后台私信;资料;两个字可以免费视频领取+文档+各大厂面试题 资料内容包括:C/C++,Linux,golang,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,嵌入式 等。

协程最重要的应用方式就是把线程在内核上的开销转到了应用层的开销,避开或者屏蔽(对应用者)线程操作的难度。线程切换的随机性和线程Context的跟随,出入栈的保存和恢复,相关数据的锁和读写控制。这才是多线程的复杂性,如果再加异步引起的数据的非连续性和事件的非必然性操作,就更加增强了多线程遇到问题的判别和断点的准确。

协程和线程一样,同样需要做好两个重点:第一个是协程的调度;第二是上下文的切换。而这两点在OS的相关书籍中的介绍海了去了,这里就不再赘述,原理基本都是一样的。

如果以协程的关系来区分,协程也可以划分为对称和非对称协程两种。协程间是平等关系的,就是对称的;反之为非对称的。名字越起越多,但事儿还是那么两下子,大家自己体会即可。

只要能保证上面所说的对上下文数据的安全性保证又能够实现协程在具体线程上的操作(某一个线程上执行的所有协程是串行的),那么锁的操作,从理论上讲是不需要的(但实际开发中,因为协程的应用还是少,所以还需要具体的问题具体分析)。协程的动作集中在应用层,而把复杂的内核调度的线程屏蔽在下层框架上(或者以后会不会出现OS进行封装),从而大幅的降低了编程的难度,但却拥有了线程快速异步调用的效果。

相关文章