RPC框架总结
该总结主要是研究各个rpc的线程进程模型框架,以及主要提供的一些功能点。通过各个框架对c++的实现来解读。
grpc
gRPC是Google开源的通用高性能RPC框架,它支持的是使用Protocol Buffers来编写Service定义,支持较多语言扩平台并且拥有强大的二进制序列化工具集。
能通过Generator自动生成对应语言的Service接口类似,gRPC也能 自动地生成 Server和Client的 Service存根(Stub),我们只需要 一个命令 就能快速搭建起RPC运行环境。
框架
F:\src\rpc\grpc\examples\cpp\helloworld\greeter_async_server.cc通过这个例子追踪
完成队列
grpc中最重要就是完成队列的概念,客户端和server端都会用到,而且一个完成队列绑定一个pollset,grpc依赖用户创建的线程去执行这个队列的生成和消费,可以多线程去操作这个队列。
官方文档这么说:
A gRPC client or a server can have more than one completion queue. Each completion queue creates a pollset.
The gRPC core library does not create any threads[^1] on its own and relies on the application using the gRPC core library to provide the threads. A thread starts to poll for events by calling the gRPC core surface APIs grpc_completion_queue_next() or grpc_completion_queue_pluck(). More than one thread can call grpc_completion_queue_next()on the same completion queue[^2].
来自:grpc:epoll实现
一般服务端会弄成这个架构去处理异步请求,如图:
image
Execute_thrd: 公有线程,启动时负责启动监听,IO,其他任务;
Timer_thrd:负责执行指定的定时任务(非周期性任务);
Consumer_thrd:从Queue中取Event消费。这个例子只有一个完成队列,所以主线程去消费完成队列。这里可以多线程使用grpc_completion_queue_next去消费。
grpc queue:就是完成队列,completion queues
java版
java版主要是基于Netty
详情可以看:grpc-java原理
image更多参考gRPC线程模型分析
总结
优点
gRPC 的基石就是 HTTP/2,然后在上面使用 protobuf 协议定义好 service RPC。参考https://www.jianshu.com/p/48ad37e8b4ed
使用一套协议就能生成客户端和服务端的代码。
缺点
grpc的客户端(或者是server去访问其他server)异步模型,并没有协程,所以编程上需要异步编码。通过完成队列。和启动新线程消费队列。
使用grpc有很多监控,业务封装都需要用户自己去实现。
gRPC开源组件官方并未直接提供服务注册与发现的功能实现,但其设计文档已提供实现的思路,并在不同语言的gRPC代码API中已提供了命名解析和负载均衡接口供扩展。
thrift
thrift是一个软件框架,用来进行可扩展且跨语言的服务的开发。它结合了功能强大的软件堆栈和代码生成引擎,以构建在 C++, Java, Go,Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, JavaScript, Node.js, Smalltalk, and OCaml 这些编程语言间无缝结合的、高效的服务。
thrift最初由facebook开发用做系统内各语言之间的RPC通信 。
框架
image image
Transport层提供了一个简单的网络读写抽象层。这使得thrift底层的transport从系统其它部分(如:序列化/反序列化)解耦。
Protocol抽象层定义了一种将内存中数据结构映射成可传输格式的机制。换句话说,Protocol定义了datatype怎样使用底层的Transport对自己进行编解码。因此,Protocol的实现要给出编码机制并负责对数据进行序列化。
Processor封装了从输入数据流中读数据和向数据数据流中写数据的操作。读写数据流用Protocol对象表示。与服务相关的processor实现由编译器产生。Processor主要工作流程如下:从连接中读取数据(使用输入protocol),将处理授权给handler(由用户实现),最后将结果写到连接上(使用输出protocol)。
Server将以上所有特性集成在一起:
(1) 创建一个transport对象
(2) 为transport对象创建输入输出protocol
(3) 基于输入输出protocol创建processor
(4) 等待连接请求并将之交给processor处理
thrift使用一种类似pb的协议,来编写IDL文件。
Thrift实际上是实现了C/S模式,通过代码生成工具将thrift文生成服务器端和客户端代码(可以为不同语言),从而实现服务端和客户端跨语言的支持。用户在Thirft文件中声明自己的服务,这些服务经过编译后会生成相应语言的代码文件,然后客户端调用服务,服务器端提服务便可以了。
一般将服务放到一个.thrift文件中,服务的编写语法与C语言语法基本一致,在.thrift文件中有主要有以下几个内容:变量声明(variable)、数据声明(struct)和服务接口声明(service, 可以继承其他接口)。
thrift本身也不直接生成线程模型,也是依赖业务自己生成线程,管理线程,thrift也同样提供了,TSimpleServer,TThreadedServer,TThreadPoolServer,http等多种行为server封装。
总结
优点
Thrift是一个跨语言的服务部署框架,最初由Facebook于2007年开发,2008年进入Apache开源项目。Thrift通过IDL(Interface Definition Language,接口定义语言)来定义RPC(Remote Procedure Call,远程过程调用)的接口和数据类型,然后通过thrift编译器生成不同语言的代码(目前支持C++,Java, Python, PHP, Ruby, Erlang, Perl, Haskell, C#, Cocoa, Smalltalk和OCaml),并由生成的代码负责RPC协议层和传输层的实现。
而且thrift相当轻量
缺点
thrift的线程模型没有和rpc协议等融合,导致很多异步同步细节需要用户去关心。
thrift重点在协议栈和rpc的生成,功能点相对少。
thrift并无负载均衡,master等组件,需要用户使用支持tcp的负载均衡组件,比如lvs,haproxy,nginx
tars
tars是基于名字服务使用Tars协议的高性能RPC开发框架,配套一体化的运营管理平台,并通过伸缩调度,实现运维半托管服务。该框架为用户提供了涉及到开发、运维、以及测试的一整套解决方案,帮助一个产品或者服务快速开发、部署、测试、上线。 它集可扩展协议编解码、高性能RPC通信框架、名字路由与发现、发布监控、日志统计、配置管理等于一体,通过它可以快速用微服务的方式构建自己的稳定可靠的分布式应用,并实现完整有效的服务治理。
tars拥有完整的devops架构,如下,详细可以参考
image框架
rpc实现
image服务端:
NetThread: 收发包,连接管理,多线程(可配置),采用epoll ET触发实现,支持tcp/udp;
BindAdapter: 绑定端口类,用于管理servent对应的绑定端口的信息操作;
ServantHandle:业务线程类,根据对象名分派Servant的对象和接口调用;
AdminServant: 管理端口的对象;
ServantImp: 继承Servant的业务处理基类(Servent:服务端接口对象的基类);
客户端:
NetThread: 收发包,连接管理,多线程(可配置),采用epoll ET触发实现,支持tcp/udp;
AdapterProxy: 具体服务器某个节点的本地代理,管理到服务器的连接,以及请求超时处理;
ObjectProxy: 远程对象代理,负责路由分发、负载均衡、容错,支持轮询/hash/权重;
ServantProxy: 远程对象调用的本地代理,支持同步/异步/单向,Tars协议和非Tars协议;
AsyncThread: 异步请求的回应包处理线程;
Callback: 具体业务Callback的处理基类对象;
线程模型
imageTC_EpollServer核心包含了IO线程池,Adapter和Handle线程池。本质上也是传统的EPOLL多路IO+多线程非阻塞,Reactor模式。不过在IO线程和Handle之间接入了Adapter适配器。
Adapter负责数据传输协议的解析,这使得TC_EpollServer在支持不同传输协议上具有了更大的灵活性。当然,它必然地还起到了IO线程和handle之间的数据桥梁的作用,数据桥梁中放的是一个个等待handle处理的tagRecvData,它代表一个完整的数据包。handle线程主体也在ServantHandle中。IO线程则在TC_EpollServer::NetThread::run中。
handle负责具体的业务逻辑。由于数据包的包体和业务逻辑强相关,因此包体的解析就交给了handle来处理。一般包体中包含的是序列化数据,比如protobuf或者tar序列化。可以根据业务需求,更改handle,使用不同的序列化工具。这也带来了一定的灵活性。
IO_thread pool负责处理网络事件,包括建立连接,网络数据传输等。它是基于经典的Epoll+Non-Blocking+多线程模型。线程池中的线程分为两种:Acceptor线程,负责监听TCP连接;以及监听有效数据传输的IO读写事件的线程。在目前的实现中,TC_EpollServer分配一个线程为acceptor,其余线程负责监听连接上的IO事件
tars的协程Coroutine
一般使用tars协程是在处理clinet的请求时,并且需要去访问一些后端服务器的时候,参考tars\cpp\examples\CoroutineDemo,发送请求后,使用coroWhenAll(sharedPtr)去等待协程返回。
实现是在工作线程的主体ServantHandle::run()里面,如果配置有打开协程,则用协程的方式进行工作线程的工作,逻辑主要在创建协程调度的时候绑定,ServantHandle::handleRequest(),CoroutineScheduler::tars_run()是主协程调度的主逻辑。
而在处理一些用户请求时,使用coroWhenAll会为当前工作线程启动一个线程私有数据(在不存在时才创建,用pthread_key_create创建),然后使用yield把进行线程调度把控制权给主协程。具体底层协程的汇编实现可以参考jump_fcontext等
tars的promise
Future与Promise其实二个完全不同的东西:
Future:用来表示一个尚未有结果的对象,而产生这个结果的行为是异步操作;
Promise:Future对象可以使用Promise对象来创建(getFuture),创建后,Promise对象保存的值可以被Future对象读取,同时将二个对象共享状态关联起来。可以认为Promise为Future结果同步提供了一种手段;
简而言之就是:他们提供了一套非阻塞并行操作的处理方案,当然,你可以阻塞操作来等待Future的结果返回。
通过一个虚拟的例子来说明:你想买房,然后通过微信联系中介看看行情并询问一些信息,最后拿到所有的信息汇总后再评估。
我们假如有中介A、B、C,并且不考虑超时情况。
同步的做法:我们先微信询问A,等待A的回复,接着询问B,等待B的回复,最后询问C,等待C的回复;
异步的做法:我们先微信询问A,在等待A回复的同时,可以干干其他事情(比如看电视,处理工作),等到A回复后再依次询问B,C;
Future/Promise的做法:同时给A、B、C发消息询问,等待回复的同时干其他事情,一直到所有回复都响应;
根据经验,在这种场景下Future/Promise才是最合适的做法。
因为对于这种场景,询问中介A、B、C是三个没有任何耦合的任务(简单理解就是顺序可以打乱的任务,相互之间无依赖,A->B->C,C->B->A的结果一样),所以Future/Promise最适合。
实现如C++异步调用利器future/promise实现原理
image从使用的角度看,我们可以用Promise生成Future,然后Future,get到值之后继续往下执行,不用担心异步编程的数据交互问题。不用理实际是协程还是另一个线程在帮我们callback,而统一写成类似同步的代码。
总结
优点
tars是基于名字服务使用Tars协议的高性能RPC开发框架,配套一体化的运营管理平台,有各种负载均衡的,devops工具。
拥有协程,Promise/Future等编程模型,让异步变得简单。
缺点
比较笨重,代码多。不好抽离和结合已有框架。
tars协议不是被很多平台支持。
brpc
百度内最常使用的工业级RPC框架,在百度内叫做"baidu-rpc". 目前只开源C++版本。
支持多协议,比如流媒体等
支持开源第三方客户端redis,memcached,thrift等。
同步异步,协程,channels
通过http界面调试监控
命名服务,负载均衡
框架
brpc的整体框架如下:
image imagebtread线程模型
bthread是brpc使用的M:N线程库。一个work-stealing的线程池模型。目的是在提高程序的并发度的同时,降低编码难度,并在核数日益增多的CPU上提供更好的scalability和cachelocality。”M:N“是指M个bthread会映射至N个pthread,一般M远大于N。由于linux当下的pthread实现(NPTL)是1:1的,M个bthread也相当于映射至N个LWP。但是bthread还是使用了类似协程的jump和save上下文来达到切换的目的。本质也是把一个线程分成多个协程任务。但在调度方面有所不同。
bthread和goroutine都是实现了用户态的M:N线程库,但实现有差别,bthread更像go的1.0版本。操作系统线程不可以动态扩展,而go的1.1版本就是解决这个问题,变成MPG模式。这就是为什么引入P的原因。
bthread是协程(coroutine)吗?
不是。我们常说的协程特指N:1线程库,即所有的协程运行于一个系统线程中,计算能力和各类eventloop库等价。由于不跨线程,协程之间的切换不需要系统调用,可以非常快(100ns-200ns),受cache一致性的影响也小。但代价是协程无法高效地利用多核,代码必须非阻塞,否则所有的协程都被卡住,对开发者要求苛刻。协程的这个特点使其适合写运行时间确定的IO服务器,典型如httpserver,在一些精心调试的场景中,可以达到非常高的吞吐。但百度内大部分在线服务的运行时间并不确定,且很多检索由几十人合作完成,一个缓慢的函数会卡住所有的协程。在这点上eventloop是类似的,一个回调卡住整个loop就卡住了,比如ubaserver(注意那个a,不是ubserver)是百度对异步框架的尝试,由多个并行的eventloop组成,真实表现糟糕:回调里打日志慢一些,访问redis卡顿,计算重一点,等待中的其他请求就会大量超时。所以这个框架从未流行起来。
bthread是一个M:N线程库,一个bthread被卡住不会影响其他bthread。关键技术两点:workstealing调度和butex,前者让bthread更快地被调度到更多的核心上,后者让bthread和pthread可以相互等待和唤醒。
workstealing调度
工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。
一个大任务分割为若干个互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并未每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。比如线程1负责处理1队列里的任务,2线程负责2队列的。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务待处理。干完活的线程与其等着,不如帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们可能会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务线程永远从双端队列的尾部拿任务执行。
pthread worker在任何时间只会运行一个bthread,当前bthread挂起时,pthread worker先尝试从本地runqueue弹出一个待运行的bthread,若没有,则随机偷另一个worker的待运行bthread,仍然没有才睡眠并会在有新的待运行bthread时被唤醒。
imagebutex & futex
butex是一个类似futex机制来同步bthread的机制。
理想的同步机制应该是没有锁冲突时在用户态利用原子指令就解决问题,而需要挂起等待时再使用内核提供的系统调用进行睡眠与唤醒。换句话说,在用户态的自旋失败时,能不能让进程挂起,由持有锁的线程释放锁时将其唤醒? 如果你没有较深入地考虑过这个问题,很可能想当然的认为类似于这样就行了(伪代码):
void lock(int lockval) {
//trylock是用户级的自旋锁
while(!trylock(lockval)) {
wait();//释放cpu,并将当期线程加入等待队列,是系统调用
}
}
boolean trylock(int lockval){
int i=0;
//localval=1代表上锁成功
while(!compareAndSet(lockval,0,1)){
if(++i>10){
return false;
}
}
return true;
}
void unlock(int lockval) {
compareAndSet(lockval,1,0);
notify();
}
如果一个线程trylock失败,在调用wait时持有锁的线程释放了锁,当前线程还是会调用wait进行等待,但之后就没有人再唤醒该线程了。
为了解决上述问题,linux内核引入了futex机制,futex主要包括等待和唤醒两个方法:futex_wait和futex_wake,其定义如下
//uaddr指向一个地址,val代表这个地址期待的值,当*uaddr==val时,才会进行wait
int futex_wait(int *uaddr, int val);
//唤醒n个在uaddr指向的锁变量上挂起等待的进程
int futex_wake(int *uaddr, int n);
futex在真正将进程挂起之前会检查addr指向的地址的值是否等于val,如果不相等则会立即返回,由用户态继续trylock。否则将当期线程插入到一个队列中去,并挂起。
wait-free
原子指令能为我们的服务赋予两个重要属性:wait-free和lock-free。前者指不管OS如何调度线程,每个线程都始终在做有用的事;后者比前者弱一些,指不管OS如何调度线程,至少有一个线程在做有用的事。如果我们的服务中使用了锁,那么OS可能把一个刚获得锁的线程切换出去,这时候所有依赖这个锁的线程都在等待,而没有做有用的事,所以用了锁就不是lock-free,更不会是wait-free。为了确保一件事情总在确定时间内完成,实时系统的关键代码至少是lock-free的。在百度广泛又多样的在线服务中,对时效性也有着严苛的要求,如果RPC中最关键的部分满足wait-free或lock-free,就可以提供更稳定的服务质量。事实上,brpc中的读写都是wait-free的,具体见IO。
** bthread会有Channel吗?**
不会。channel代表的是两点间的关系,而很多现实问题是多点的,这个时候使用channel最自然的解决方案就是:有一个角色负责操作某件事情或某个资源,其他线程都通过channel向这个角色发号施令。如果我们在程序中设置N个角色,让它们各司其职,那么程序就能分类有序地运转下去。所以使用channel的潜台词就是把程序划分为不同的角色。channel固然直观,但是有代价:额外的上下文切换。做成任何事情都得等到被调用处被调度,处理,回复,调用处才能继续。这个再怎么优化,再怎么尊重cache locality,也是有明显开销的。另外一个现实是:用channel的代码也不好写。由于业务一致性的限制,一些资源往往被绑定在一起,所以一个角色很可能身兼数职,但它做一件事情时便无法做另一件事情,而事情又有优先级。各种打断、跳出、继续形成的最终代码异常复杂。
相反,brpc提供bufferedchannel,扮演的是队列和有序执行的作用,bthread提供了ExecutionQueue,可以完成这个目的。
bthread在接口上和pthread保持一致,符合linux开发人员的习惯。
待研究
seastar
dubbo
网友评论