grpc client 调用流程解析

883 阅读14分钟

基本概念:

rpc框架本质上都是client将调用的参数进行序列化之后通过某种通信协议发送到server端,server完成相应的功能后,将返回值序列化返回给client,client进行反序列化从而得到远程调用的结果,构造出一种直接在本地调用远程函数的现象。

grpc也是这样的实现逻辑,grpc使用http2协议作为传输层,在http2的一个流上面传递一次rpc调用的参数和返回值,相较于http1,http2设计的更为高效,能够更加有效地利用连接的带宽,同一个流的数据通过流id来进行区分,而不像http1是一问一答似的,前一个请求的响应不返回,就无法发送第二个请求,http2则可以完全并发地发送多个请求,只要依据流id分别接收不同调用的响应即可,实现io多路复用,所以grpc每个server的client都只维护一个连接,而不存在连接池的概念。

在发生rpc调用时,rpc调用的元数据会被编码到http2 header之中,比如调用的service, method,调用的超时时间,golang ctx中包含的元数据以及server返回的err信息。rpc的参数和响应则会通过编码放到http2的payload之中,默认是会使用protobuf进行编码,本次主要是介绍client进行调用的整体流程,不详细介绍grpc通信协议的设计。

除通信协议和参数编解码之外,client处还需要考虑的是服务发现以及负载均衡,比如在分布式环境下,serviceA有3个副本,client需要通过一种方式知道这3个副本的addr是什么,并和这三个副本建立好http2连接,后续需要发送rpc请求时,基于一种均衡策略从这三个http2连接中选择其中一个,并使用这个连接创建一个流,通过这个流传递请求和响应。服务发现在grpc中名为resolver,作用就是解析创建ClientConn时的target值,从target中获取实际的服务地址,负载均衡在grpc中名为balancer,作用是拿到resolver解析后的真实地址列表后,和每个地址发起连接,维护好这些连接,并在实际发生rpc调用时依据一种均衡策略返回一个连接,供上层发起rpc使用。

grpc其实并不是一个非常轻或者说功能很单一的rpc框架,可以说内置了rpc框架的绝大多数功能,还支持通过resolver动态调整配置信息,内部逻辑其实很复杂,但对外暴露的接口确实很少很简单,暴露出的对象的命名也会让人觉得grpc Dial返回的连接就是一个很轻的tcp连接,所以很多人会基于grpc再次封装rpc框架,只将grpc作为传输层使用,但其实Dial返回的ClientConn一点也不轻,反而很复杂。所以我打算先介绍一些grpc框架中的名词,有一个大体印象之后,在完整走一遍client发起rpc的整个流程。

名词解释

ClientConn, SubConn, addrConn

ClientConn就是grpc.Dial(目前最新为grpc.NewClient)返回的连接对象,这个对象其实不仅仅包含连接,还包含完整的配置项,各种manager以及resolver,balancer。Dial函数传入一个target,比如 etcd://localhost:2379/serviceA,etcd的resolver可以从etcd中获取service实际的地址,比如有三个地址,ClientConn会同时持有和这三个副本的连接,所以ClientConn其实是个比较重的对象,可以说是client侧最大的一个对象。

SubConn是一个接口,grpc框架已经做了默认的实现,SubConn代表着和一个副本的连接,比如上面解析器获得三个副本地址后,每个地址都会创建一个SubConn,由ClientConn持有这三个SubConn,每次进行rpc调用时,也会通过均衡器选择出一个SubConn进行通信。

addrConn是一个未导出的类型,也代表着和一个副本的连接,SubConn的默认实现就是acBalancerWrapper,含义就是addrConn和balancer的一个wrapper,也就是说SubConn这个接口实际上是基于addrConn来实现的,从名字也能感觉出来,addrConn就是具体和服务某个副本地址的连接对象,addrConn对象中实际包含着http2 client对象,用于创建流,并在流上传递rpc请求和响应。

resolver

resolver相当于是grpc中的服务发现,resolver含义为解析器,解析的就是grpc.Dial参数中的target,我最开始学习grpc时一直以为target参数就是一个具体的ip地址,其实target是可以带上协议的,比如etcd,含义就是去etcd中获取服务的具体地址,只不过Dail中默认的协议是passthrough,就是直接透传的意思,比如target是localhost:2379,实际上是passthrough://localhost:2379,此时就不会有什么服务发现的逻辑了,采用这个协议就是告诉grpc框架通信时直接连接localhost:2379就可以了,这时Dail返回的ClientConn内部就只有一个连接,如果想要实现注册发现、负载均衡之类的逻辑就只能在外部实现,有一些框架,比如go-micro就是这样做的,相当于把grpc完全作为单个连接的传输层使用。resolver需要实时的监听服务地址的变化,并通知balancer,如etcd resolver就会watch某个key的变化,发生变化时会通知balancer最新的服务地址列表。

除去发现服务的地址之外,resolver还可以实时获取service config,service config就是针对于不同service的不同配置项,一般我们指定ClientConn使用 哪个平衡器时会这样指定 grpc.WithDefaultServiceConfig({"loadBalancingPolicy":"round_robin"}), 这里就是指定了默认的service config,之所以说是默认的,就是因为这个service config是可以从resolver中获取并实时变化的。使用一个json字符串传递这个参数,也是因为这个参数需要能从resolver 服务器处远程传递回来。这个参数会包含负载均衡策略,以及每个service的不同方法的超时时间、重试策略等,是client处针对于service层面的配置,所以叫service config。

balancer, picker

resolver获取到服务的具体地址列表和service config之后,会依据于loadBalancingPolicy创建不同的balancer,并将服务的地址列表给到balancer,balancer获取到地址列表之后,就可以实际和每个地址建立连接了,也就是创建上面所说的SubConn,当然这里也会做一些判断,比如连接已经创建了就不需要再创建了,本地持有的连接不在resolver返回的列表中,需要关闭连接并移除。之后grpc会创建一个picker对象,也就是选择器,picker对象中包含刚刚创建的全部已经ready的SubConn对象,不同均衡策略下的picker对象也会实现不同的Pick方法,用于从这些连接中获取下一次要用的连接。picker准备完毕后,grpc会将这个picker设置到ClientConn的pickerWrapper属性之上,ClientConn如果想要发起rpc请求的话,就可以从pickerWrapper中选择一个连接发起rpc请求,因为picker中的连接都是处于ready状态的,此时不需要做任何等待,直接就可以通过连接发起rpc请求,很高效。

interceptor

interceptor义为拦截器,类似于web框架中的中间件,可以在业务逻辑的前后完成一些通用性的操作,这个处理在整个client的请求链路中占比很小,不做太多介绍。

整体流程:

概念介绍清楚之后,现在开始从头走一遍client发起rpc请求的全部流程,下面是一个整体流程图:

grpc-client整体流程.jpg

1. 初始化ClientConn

使用grpc.NewClient创建连接对象,这里做的事情比较少,不会真的和服务器发生连接,只是完成一些对象的初始化工作,如解析target,并基于target中的协议指定resolverBuilder,用于后续resolver对象的创建,同时解析默认的service config,设置拦截器等。

2. 发起RPC调用

rpc调用会统一使用invoke函数,传入service和method以及请求和响应等,

func invoke(ctx context.Context, method string, req, reply any, cc *ClientConn, opts ...CallOption) error {
    cs, err := newClientStream(ctx, unaryStreamDesc, cc, method, opts...)
    if err != nil {
       return err
    }
    if err := cs.SendMsg(req); err != nil {
       return err
    }
    return cs.RecvMsg(reply)
}

为了能够发送请求、接收响应,首先需要有一个http2 stream,故这里首先调用了newClientStream函数,成功得到stream之后,就可以用这个stream发送消息,也可以从这个stream中接收响应。newClientStream中会调用一个manager的ExitIdleMode方法,试图让连接结束idle状态,进入ready状态,从而收发消息,ExitIdleMode方法内部也会做一些判断,如果已经不是idle了,就不用再重新发起一次连接了。如果之前确实是idle状态,比如刚初始化完就是idle状态,则最终会调用到ClientConn的exitIdleMode方法,

func (cc *ClientConn) exitIdleMode() (err error) {
    cc.mu.Lock()
    if cc.conns == nil {
       cc.mu.Unlock()
       return errConnClosing
    }
    cc.mu.Unlock()

    // This needs to be called without cc.mu because this builds a new resolver
    // which might update state or report error inline, which would then need to
    // acquire cc.mu.
    if err := cc.resolverWrapper.start(); err != nil {
       return err
    }

    cc.addTraceEvent("exiting idle mode")
    return nil
}

func (ccr *ccResolverWrapper) start() error {
    errCh := make(chan error)
    ccr.serializer.TrySchedule(func(ctx context.Context) {
       if ctx.Err() != nil {
          return
       }
       opts := resolver.BuildOptions{
          DisableServiceConfig: ccr.cc.dopts.disableServiceConfig,
          DialCreds:            ccr.cc.dopts.copts.TransportCredentials,
          CredsBundle:          ccr.cc.dopts.copts.CredsBundle,
          Dialer:               ccr.cc.dopts.copts.Dialer,
          Authority:            ccr.cc.authority,
       }
       var err error
       ccr.resolver, err = ccr.cc.resolverBuilder.Build(ccr.cc.parsedTarget, ccr, opts)
       errCh <- err
    })
    return <-errCh
}

从这里可以看到结束idle状态的方法就是通过resolverBuilder的Build方法让resolver开始工作。

3. resolver获取服务地址及service config

这里以最简单的passthrough resolver为例,就是直接连接一个特定的地址,对应的Build方法如下

type passthroughBuilder struct{}

func (*passthroughBuilder) Build(target resolver.Target, cc resolver.ClientConn, opts resolver.BuildOptions) (resolver.Resolver, error) {
    if target.Endpoint() == "" && opts.Dialer == nil {
       return nil, errors.New("passthrough: received empty target in Build()")
    }
    r := &passthroughResolver{
       target: target,
       cc:     cc,
    }
    r.start()
    return r, nil
}

func (r *passthroughResolver) start() {
    r.cc.UpdateState(resolver.State{Addresses: []resolver.Address{{Addr: r.target.Endpoint()}}})
}

resolver是通过调用这个cc.UpdateState来告知框架最新的地址列表的,State对象的Addresses属性就是具体的地址列表,这个cc的默认实现就是ccResolverWrapper,也就是说最终调用的是ccResolverWrapper.UpdateState方法

func (ccr *ccResolverWrapper) UpdateState(s resolver.State) error {
    ...
    return ccr.cc.updateResolverStateAndUnlock(s, nil)
}

func (cc *ClientConn) updateResolverStateAndUnlock(s resolver.State, err error) error {
    ...
    cc.maybeApplyDefaultServiceConfig()

    balCfg := cc.sc.lbConfig
    bw := cc.balancerWrapper
    cc.mu.Unlock()

    uccsErr := bw.updateClientConnState(&balancer.ClientConnState{ResolverState: s, BalancerConfig: balCfg})

    ...
}

ccResolverWrapper首先需要解析state中的service config,passthrough这种resolver是没有传递service config的,此时就会使用初始化ClientConn时的默认的service config,拿到service config中的平衡器配置,准备创建对应的平衡器对象。之后涉及到几步调用,最终会进入这里:

func (gsb *Balancer) UpdateClientConnState(state balancer.ClientConnState) error {
    // The resolver data is only relevant to the most recent LB Policy.
    balToUpdate := gsb.latestBalancer()
    gsbCfg, ok := state.BalancerConfig.(*lbConfig)
    if ok {
       // Switch to the child in the config unless it is already active.
       if balToUpdate == nil || gsbCfg.childBuilder.Name() != balToUpdate.builder.Name() {
          var err error
          balToUpdate, err = gsb.switchTo(gsbCfg.childBuilder)
          if err != nil {
             return fmt.Errorf("could not switch to new child balancer: %w", err)
          }
       }
       // Unwrap the child balancer's config.
       state.BalancerConfig = gsbCfg.childConfig
    }

    if balToUpdate == nil {
       return errBalancerClosed
    }

    // Perform this call without gsb.mu to prevent deadlocks if the child calls
    // back into the channel. The latest balancer can never be closed during a
    // call from the channel, even without gsb.mu held.
    return balToUpdate.UpdateClientConnState(state)
}

switchTo内部会调用依据均衡策略获取到的balancer的Build方法,构建平衡器,最终调用平衡器的UpdateClientConnState方法,触发平衡器持有的连接的更新。

4. balancer实际创建连接

进入具体的balancer的UpdateClientConnState方法,这里以常用的round_robin平衡器举例,round_robin平衡器会实际使用baseBalancer对象,baseBalancer中包含了一些通用的平衡器逻辑,

func (b *baseBalancer) UpdateClientConnState(s balancer.ClientConnState) error {
    addrsSet := resolver.NewAddressMap()
    for _, a := range s.ResolverState.Addresses {
       addrsSet.Set(a, nil)
       // 创建当前未持有的连接
       if _, ok := b.subConns.Get(a); !ok {
          var sc balancer.SubConn
          opts := balancer.NewSubConnOptions{
             HealthCheckEnabled: b.config.HealthCheck,
             StateListener:      func(scs balancer.SubConnState) { b.updateSubConnState(sc, scs) },
          }
           // 创建SubConn对象
          sc, err := b.cc.NewSubConn([]resolver.Address{a}, opts)
          if err != nil {
             logger.Warningf("base.baseBalancer: failed to create new SubConn: %v", err)
             continue
          }
          b.subConns.Set(a, sc)
          b.scStates[sc] = connectivity.Idle
          b.csEvltr.RecordTransition(connectivity.Shutdown, connectivity.Idle)
           // 让SubConn对象连接到server
          sc.Connect()
       }
    }
    // 移除已经不在注册中心的地址的连接
    for _, a := range b.subConns.Keys() {
       sci, _ := b.subConns.Get(a)
       sc := sci.(balancer.SubConn)
       // a was removed by resolver.
       if _, ok := addrsSet.Get(a); !ok {
          sc.Shutdown()
          b.subConns.Delete(a)
       }
    }

    if len(s.ResolverState.Addresses) == 0 {
       b.ResolverError(errors.New("produced zero addresses"))
       return balancer.ErrBadResolverState
    }

    // 重新构建picker
    b.regeneratePicker()

    // 将新的picker更新到ClientConn
    b.cc.UpdateState(balancer.State{ConnectivityState: b.state, Picker: b.picker})
    return nil
}

balancer的主要工作就是依据resolver给的地址列表,创建新的连接,并移除已经停用的连接,SubConn对象的Connect方法是异步执行的,里面主要就是通过addrConn创建http2Client,这些http2Client就是grpc实际的传输层,这里面的代码逻辑很简单就不展示出来了,值得注意的是这里的http2Client并不像标准库的http.Client带有连接池,每个http2Client只持有一个tcp连接,这也是因为上面所说的http2支持io多路复用,一个tcp连接就可以高效利用带宽了。

func (b *baseBalancer) regeneratePicker() {
    if b.state == connectivity.TransientFailure {
       b.picker = NewErrPicker(b.mergeErrors())
       return
    }
    readySCs := make(map[balancer.SubConn]SubConnInfo)

    for _, addr := range b.subConns.Keys() {
       sci, _ := b.subConns.Get(addr)
       sc := sci.(balancer.SubConn)
       if st, ok := b.scStates[sc]; ok && st == connectivity.Ready {
          readySCs[sc] = SubConnInfo{Address: addr}
       }
    }
    b.picker = b.pickerBuilder.Build(PickerBuildInfo{ReadySCs: readySCs})
}

之后重新构建picker对象,可以看到这里将所有处于Ready状态的SubConn对象放入readySCs,并将readySCs传给均衡策略对应的picker的Build方法。 以round_robin picker为例,实现如下:


type rrPickerBuilder struct{}

func (*rrPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
    if len(info.ReadySCs) == 0 {
       return base.NewErrPicker(balancer.ErrNoSubConnAvailable)
    }
    scs := make([]balancer.SubConn, 0, len(info.ReadySCs))
    for sc := range info.ReadySCs {
       scs = append(scs, sc)
    }
    return &rrPicker{
       subConns: scs,
       next: uint32(rand.IntN(len(scs))),
    }
}

type rrPicker struct {
    subConns []balancer.SubConn
    next     uint32
}

func (p *rrPicker) Pick(balancer.PickInfo) (balancer.PickResult, error) {
    subConnsLen := uint32(len(p.subConns))
    nextIndex := atomic.AddUint32(&p.next, 1)

    sc := p.subConns[nextIndex%subConnsLen]
    return balancer.PickResult{SubConn: sc}, nil
}

可以看到Build方法将全部的Ready状态的连接赋值到rrPicker对象的subConns属性,rrPicker对象也实现了Pick方法,不断地给next属性增一,从而轮转地从subConns之中获取下一个提供服务的连接。

5. balancer将最新的picker对象更新回ClientConn

目前picker对象已近包含全部的ready连接了,并且也有满足当前均衡策略的Pick方法。接下来balancer会调用 b.cc.UpdateState(balancer.State{ConnectivityState: b.state, Picker: b.picker}),将picker写回ClientConn从而让其拥有可用的连接。

func (ccb *ccBalancerWrapper) UpdateState(s balancer.State) {
    ...
    ccb.cc.pickerWrapper.updatePicker(s.Picker)
    ccb.cc.csMgr.updateState(s.ConnectivityState)
}

UpdateState还是由ccBalancerWrapper默认实现的,可以看到是通过pickerWrapper更新的当前picker,后面ClientConn也是通过这个pickerWrapper获取到下一个可用连接。

6. 通过pickerWrapper获取可用连接,并创建client stream

现在连接的创建工作已经完成了,回到最开始的newClientStream函数之中,由于刚刚从resolver到balancer再到picker创建连接的过程都是异步的,故这里需要先调用waitForResolvedAddrs等待连接创建完成,之后通过newClientStreamWithParams创建client stream,newClientStreamWithParams做了很多事,比如设置一些超时的ctx,设置一些元数据相关,不过暂时只要关注这部分代码就可以了

    op := func(a *csAttempt) error {
       if err := a.getTransport(); err != nil {
          return err
       }
       if err := a.newStream(); err != nil {
          return err
       }
       cs.attempt = a
       return nil
    }

这里的getTransport实际获取一个传输层,用来发送rpc请求,

func (cc *ClientConn) getTransport(ctx context.Context, failfast bool, method string) (transport.ClientTransport, balancer.PickResult, error) {
    return cc.pickerWrapper.pick(ctx, failfast, balancer.PickInfo{
       Ctx:            ctx,
       FullMethodName: method,
    })
}

最终通过刚刚由balancer更新的pickerWrapper获取的传输层。这里代码页比较多,核心如下

       pickResult, err := p.Pick(info)
       if err != nil {
          ...
       }

       acbw, ok := pickResult.SubConn.(*acBalancerWrapper)
       if !ok {
          logger.Errorf("subconn returned from pick is type %T, not *acBalancerWrapper", pickResult.SubConn)
          continue
       }
       if t := acbw.ac.getReadyTransport(); t != nil {
          if channelz.IsOn() {
             doneChannelzWrapper(acbw, &pickResult)
             return t, pickResult, nil
          }
          return t, pickResult, nil
       }

取出pickerWrapper中的picker对象,比如rrPicker,调用其Pick方法得到一个可用的SubConn对象,SubConn对象的getReadyTransport方法最终会返回addrConn对象创建的http2Client作为传输层。传输层设置完成之后,就可以调用newStream方法创建流用于发送消息了,newStream方法最终是调用的http2Client的NewStream方法,用于创建http2流。

7. 通过client stream读写消息

client stream为一次grpc调用提供了传输层,现在client stream已经创建完成了,即这里的变量cs,

func invoke(ctx context.Context, method string, req, reply any, cc *ClientConn, opts ...CallOption) error {
    cs, err := newClientStream(ctx, unaryStreamDesc, cc, method, opts...)
    if err != nil {
       return err
    }
    if err := cs.SendMsg(req); err != nil {
       return err
    }
    return cs.RecvMsg(reply)
}

后面消息的读写就可以通过stream的SendMsg和RecvMsg来实现了,本次不打算深入grpc通信细节,就不继续分析stream是如何收发消息的了。