brpc源码学习(八)- client端请求server整体流程

还是以上篇的demo为例看下brpc client端请求server是怎样的流程,为了方便,这里再贴下demo的代码,首先是proto的定义

然后就是client端请求逻辑

可以看到使用还是很方便的,channel init,填充request,调用service,然后就可以访问server端返回的response了。

然后放一张官网的图,对整体流程有个大概印象

channel继承自google::protobuf::RpcChannel,channel就相当于client,代表了和server端的交互通道;首先看下channel init做了什么

GlobalInitializeOrDie在server端里讲过,主要就是注册各种协议,handler,创建InputMessenger等,这里不再赘述。

InitChannelOptions中就是根据用户定义的option设置协议对应的序列化方法,连接方式等

ComputeChannelSignature所做的就是根据option计算一个hash值

然后是SocketMapInsert

首先是创建SocketMap,SocketMap存储了key到Socket的映射,key为ipport + channeloption;然后指定socket_creator,即创建socket的方法,然后调用Insert

可以看到这里就是通过socket_creator创建一个Socket,然后记录到SocketMap中,并在251行将SocketId记录到channel的_server_id中,然后看下是怎么创建Socket的,即GlobalSocketCreator的CreateSocket方法,方法中直接调用了InputMessenger的Create()

指定user和on_edge_triggered_events之后直接调用Socket::Create,这个方法在server端介绍过,主要就是根据SocketOption设置一系列成员变量,然后调用ResetFileDescriptor,这里和server端不同的是此时SocetOption中的fd为-1,因此直接return。到这里channel的Init就完成了,可以看到只是创建了Socket,并没有真正的向server端发起连接。

 

然后在demo中接下来的工作就是创建request,response,controller和stub,然后通过stub.echo发起对server的访问,这里实际调用的是CallMethod

在CallMethod中首先设置重试次数,在demo中设置的是3,然后在393行生成CallId,CallId是个uint64,因为在一次rpc过程中可能会因为重试而产生多次请求,CallId则表示了是哪次请求,然后看下是如何生成的CallId。

可以看到是通过bthread_id_create2生成callid,然后cas写入controller的_correlation_id,

bthread_id_create2会调用到id_create_impl

其中data为controller,on_error为null,on_error2是HandleSocketFailed,这个后面会讲。然后创建ID对象,这里的ID和CallId不同,上文有提到,一次rpc过程可能会产生多次请求,ID表示了一次rpc过程,而CallId表示了其中一次请求,ID主要用于同步rpc过程中的多个bthread对资源的竞争,如controller,这个角度ID有点类似mutex。结合ID对象看下这一过程。

ID初始化过程为创建butex和join_butex并置为0,然后回到id_create_impl,这两个变量被置为1,lock_ver为2,然后通过make_id创建CallId返回,make_id就是将butex的值版本号,和slot拼在一起当作CallId,高32位为slot,低32位为版本号,因此可以通过一次请求的CallId的slot定位到当前请求所在的rpc过程对应的ID对象。

pending_q用于保存当前rpc过程中失败的信号,比如下文会介绍的backup_request。

然后回到CallMethod中,接下来是bthread_id_lock_and_reset_range,该函数就类似于申请”锁”,即ID对象。

首先通过CallId获取到slot和version,即高32位和低32位,然后上锁

此时butex等于first_ver,即1,range为重试次数 + 2 = 5,因此走到430行,设置locked_ver为1 + 3 = 4,然后将butex设置为locked_ver。

然后这个分支说明当前butex不等于first_ver,说明有其他线程在访问controller,因此将butex设置为contended_ver,然后通过butex_wait将当前线程挂起。

总结下ID,一个ID对应了一个rpc过程,并用于同步一个rpc过程中多个线程对controller等资源的访问,也因此channel的CallMethod是线程安全的,ID中的first_ver表示初始情况,本例中first_ver为1;locked_ver为1 + 5 = 6,获取到ID对象访问权的时候会将butex设置为locked_ver;contended_ver为7,表示当前有线程在占用ID;unlockable_ver为8,表示ID即将不再使用,不允许再有线程去占用ID;end_ver为9,即当前ID对象被会受到对象池后被下一次rpc调用使用时的first_ver。因此第一次请求CallId的version为2,第一次重试为3,第二次为4,第三次为5。

 

回到CallMethod函数。

接下来将设置各种成员,如超时时间,response等,因为demo中场景没有设置loadbalancer,所以是SingleServer。

通过_serialize_request,这里会request序列化到controllerrequest_buf,原因是为了重试,一个RPC过程中只会调用一次。然后启动定时任务处理超时。

 

接下来就是真正的连接和发送了。

然后看下controller的IssueRPC。

首先通过current_id()得到该次请求的CallId,如上所述,第一次请求版本号为1 + 0 + 1 = 2

然后通过server_id获取到Socket保存在tmp_sock中

 

因为当前使用的是单连接,所以直接将sending_sock指向tmp_sock,然后调用协议的pack_request,本例中协议为baidu_std,将_request_buf,correlation_id等打包到packet中,每次请求前都会调用,包括重试,其中attachment不会经过序列化,而是直接原始的二进制数据。

然后调用Write方法将数据写入Socket。

brpc在多线程向fd写数据时实现了wait-free,这部分逻辑已经抽出单独的ExecutionQueue,之前的文章已经分析过,这里再简单看下。

先新建一个WriteRequest,WriteRequest就是ExecutionQueue中的节点结构,req中的data设置为本次请求要发送的数据data,然后指向UNCONNECTED,然后执行StartWrite

首先通过exchange原子修改当前队列头节点_write_head为新建的req,并返回旧的头节点prev_head,如果prev_head不为null,那么说明当前有bthread在向fd中写数据,因为这个bthread写完队列中所有的WriteRequest,因此直接将req链入队列即可。如果prev_head是null,那么当前线程/bthread就会去写数据。

然后开始调用ConnectIfNot

到现在为止,client端其实还没有向server端发起连接,因此fd为-1,于是调用Connect建立连接。

为了性能的考虑,brpc使用的是异步连接,然后复用EventDispatcher的逻辑来epoll通知连接事件的完成。具体的,首先新建一个socket,然后设置为non_blocking,然后调用connect连接server。

新建一个EpollOutRequest req,设置req的fd和data,然后新建一个Socket connect_id,新建这个Socket的目的就是上文提到的复用EventDispatcher逻辑(这两个Socket的"回调函数"不一样)。新建的Socket的user为这个req。

然后向EventDispatcher中注册epoll out事件。

回到StartWrite的1529行,此时返回的是1,表示正在连接,因此直接返回到IssueRPC执行bthread_id_unlock。

该函数是对ID对象的释放,首先获取到ID对象,此时ID的butex要么是locked,要么是contended,首先要将butex置为first_ver,如果当前状态为contended,那么说明有其他线程/bthread被butex_wait挂起在该butex上,因此使用butex_wake唤醒他,继续竞争ID对象。

此时IssueRPC执行结束了,假设此时完成了对server端的连接,那么将会产生epollout事件,如下:

直接调用HandleEpollOut,data.u64在上文的Connect中被设置为了新建的Socket connect_id。

这里的req就是在Connect中创建的那个EpollOutRequest req,然后执行HandleEpollOutRequest,在EventDispatcher中去掉连接fd的epollout事件,然后执行on_epollout_event,在Connect函数中将on_epollout_event设置为了KeepWriteIfConnected,该函数实际调用如下函数。

首先调用CheckConnected检查连接是否出错,然后调用ResetFileDescriptor完成对Socket的设置,然后在EventDispatcher注册连接fd的epollin事件以处理response,然后执行AfterAppConnected,这个函数核心如下

启动一个bthread执行KeepWrite函数。

req是队列头结点,回忆下之前ExecutionQueue的逻辑,1609这里是反转链表之后回收req节点的资源,因为这里是第一次写,next为null,所以不会走到这个逻辑,然后调用一次DoWrite。

首先聚集多个req的iobuf到数组pieces中,然后将各个iobuf的block信息写入到iovec,然后调用writev,返回写入的长度nw,根据nw调整iobuf,因为我们使用的是非阻塞写,所以write不会阻塞,当send_buffer满导致无法写入时会返回EAGAIN,回到KeepWrite的循环中继续看。

然后回收除了头结点req外其他已完成写入的节点。然后将cur_tail设置为req,IsWriteComplete会判断是否queue中所有节点已经写完,如果现在头结点数据没写完或者有新加入的节点都算没有写完,如果有新加入的节点那么要执行一次链表反转,这块逻辑在execution_queue中描述已经比较详细,这里就不再赘述了。

还有一个小问题是当send_buffer已满导致返回EAGAIN时的处理策略,如上图中的1632行,此时会在epoll中加上监听该fd的epollout事件,因为当前Socket已注册过_on_edge_triggered_events,所以pollin为true,然后看下WaitEpollOut具体是怎么做的。

首先1095行,在EventDispatcher中加上监听epollout事件,然后通过butex_wait挂起当前bthread,当被唤醒后就继续从1100行开始执行返回到KeepWrite。

然后看下当fd可写时,epoll返回可写事件,此时会进入HandleEpollOut,在1269行,因为该Socket的user为InputMessenger,因此这里cast会失败,走到1276行唤醒wait在该butex上的bthread。

到这里已经介绍完在没有连接到server端时,往socket里写数据是怎样的流程,再提一下,当已经连接到server端时,再调用Socket::Write时,如果当前没有bthread在写该fd,那么当前线程会写一次数据,如果没写完,则启动一个bthread后台写,这样可以尽量避免上下文切换,并且有更好的cache locality

这个时候Socket::Write就返回了,在IssueRPC中释放了ID对象,返回到CallMethod中。

demodoneNULL,因此进入Join逻辑。这里就可以看出异步请求和同步请求,如果doneNULL就是同步请求,线程会被挂起,如果不传NULL就是异步请求,CallMethod直接返回。Join核心逻辑如下。

523-529行判断CallId的版本号,若通过版本号发现rpc过程没有结束,那么通过butex_wait将该线程挂起在join_butex上。

当接受到response之后,这时候回想下在channelInit方法中创建Socket时,将on_edge_triggered_events设置为了OnNewMessages,这个函数很熟悉了,在之前介绍server端的时候有讲过,会从fd中读消息并解析,然后调用协议里的process_response函数,即BAIDU_STDProcessRpcResponse

首先拿到消息中的CallId,然后调用bthread_id_lockID上锁并拿到CallId对应的controller

CallId的高32位是IDslot,低32位是版本号,通过slot就拿到了ID,然后开始竞争锁,假设当前bthread竞争到了锁,那么就获取controllercontroller就是IDdata,然后设置butex的值。

然后解析msg中的数据到controllerresponse中。

然后调用OnVersionedRPCReturned,首先拒绝掉非本次CallMethod的请求,这里先跳过容错的逻辑,直接调用EndRPC

如果是异步请求方式,即done不是NULL,那么先调用done->Run()

然后这里将butexjoin_butex全部置为end_ver,唤醒waitbutex上的线程,唤醒waitjoin_butex上的线程,这个线程就是同步调用CallMethod的线程。

然后看下连接方式

上文中介绍了单连接,然后介绍下短连接和连接池。回想在IssueRPC的时候,单连接是直接使用了_single_server_id对应的Socket

短连接:每次请求前通过GetShortSocket临时创建一个Socket,然后每次都需要Connect,结束后关闭连接。每次调用都有建立连接的开销。

连接池:每次请求前通过GetPooledSocket拿一个空闲连接,结束后归还,一个连接上最多只有一个请求,一个client对一台server可能有多条连接。

这里我们看下最后一行cpu占用,回想单连接的时候一个bthread在向fd中写数据的时候会通过一次::writev写多个请求(每个请求都是一个IOBuf,每个IOBuf里有多个block),而连接池的时候一个Socket里的”execution_queue”只有一个节点,即一个请求,因此单连接会有较少的系统调用次数,避免了频繁的内核态用户态切换,因此cpu占用更低。

到这里一次完整无异常的rpc过程便完成了,接下来以backup_request看下有异常的情况是怎么处理的,设置ChannelOptionsbackup_request_ms便可开启backup_request

channelCallMethod方法中,如果发现设置了backup_request_ms且小于timeout_ms,那么便设置一个定时任务执行HandleBackupRequest

这里首先去竞争ID对象,如果当前有人在使用ID,那么在pending_q里加上一个PendingError即可,在ID对象被正在使用的线程释放时会判断pending_q,如果不为空,则会执行错误处理函数,即on_error或者on_error2;如果此时没有人在使用ID,那么直接执行错误处理函数,回忆最开始channel init创建Socket的时候将on_error置为了NULL,将on_error2置为了HandleSocketFailed

 

HandleSocketFailed中首先通过SetFailederror_code_设置为EBACKUPREQUEST,然后调用OnVersionedRPCReturned,然后来看下上文中略过的关于这个函数的异常处理逻辑,以backup_request为例。

首先重置超时的定时任务,然后创建_unfinished_call,对_current_callretry加一,便通过IssueRPC重新发起请求,和正常请求不同的地方是这次CallId因为retry的自增而加一。

假设过了一段时间收到了server端的response,可能是第一次请求的response,也可能是backup_requestresponse,此时都会执行OnVersionedRPCReturned以及EndRPC完成整个rpc过程,之后其他response将会在抢占已经是unlockableID导致return

最后用官方文档总结下backup_request的工作机制:如果response没有在backup_request_ms内返回,则发送另外一个请求,哪个先回来就取哪个。新请求会被尽量送到不同的server。注意如果backup_request_ms大于超时,则backup request总不会被发送。backup request会消耗一次重试次数。backup request不意味着servercancel

  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 13
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 13
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值