All Articles

gRPC Notes

1. 前言

首先需要申明本文并不是通泛的对gRPC的讲解,而是以Golang为基础的一篇gRPC偏应用方向的博文。因此后面的所有技术点和使用范例都会以Go语言为基础。此外,本文中对gRPC偏基础方面的内容不会涉猎过多,而是更多讲解gRPC偏设计方向的技术点。

2. 基本资料及网络问题

2.1 Go support for Protocol Buffers

关于使用Go语言的情况下的一系列protobuf相关技术点,可以参阅:golang/protobuf

主要需要关注的点有几个:

  • 安装:除了标准的protobuf(protoc)之外,还需要安装go的插件:protoc-gen-go
  • 插件:protoc如果要使用go的插件,且需要生成service相关代码的话,需要在go_out中附带上plugins=grpc:字符串,e.g:--go_out=plugins=grpc:${OUTPUT_PATH}
  • 包管理:protobuf的包定义和go语言的习惯用法有不少差异,使用前建议阅读:Packages and input paths
    • 默认的包名不会转换成相对文件夹,e.g:package com.book会生成com_book文件夹,而不是com/book这样的嵌套文件夹
    • go的包路径可以使用:option go_package = ...来设定
    • 在某些全局编译的情况下,可以:--go_out=$GOPATH,直接输出到GOPATH
  • 生成出来的代码相关规范:Generated code
  • protoc插件的额外参数:Parameters

2.2 gRPC Basics: Go

初次使用Go语言来编写gRPC代码,请阅读文档:grpc-go/examples/gotutorial.md

对于gRPC非常熟悉的程序员可以跳过这个文档,基本上这个文档是面向新手的指引,但是范例代码都使用的是Go语言。

2.3 gRPC enhanced example

进阶的Go语言gRPC代码可以阅读范例:grpc-go/examples/route_guide/。这个范例代码里加入了单向和双向流的范例,算是比较进阶和完整的范例代码了。

我的实验项目中也有比较完整的应用范例代码,相对官方的范例来说少了点不伦不类的业务逻辑,对于只需要了解技术的读者来说更友好:dist-system-practice/golang/src/experiment/grpc/samples/book/

2.4 网络问题

因国情原因,Go的包安装会遇到一些网络问题:

这种情况的最佳对应方法是在命令行下设置HTTP和HTTPS代理:

export http_proxy=http://127.0.0.1:6152
export https_proxy=http://127.0.0.1:6152
go get -u ...

3. 并发编程

3.1 Go语言的gRPC并发

关于在Go语言中使用gRPC的并发问题在官方的issues中已经有很多了:

上述的三个issues都可以仔细阅读下,提出的问题都非常有代表性。从一系列的问题中可以看出,官方对于gRPC的并发其实是有一些指导意见的,但并没有很好的文档化(至少之前如此)。而目前对这些问题已经有部分解答了,请继续阅读下去。

3.2 并发相关官方文档 {#ID_CONCURRENCY_DOC}

这个提交中,官方添加了对于并发相关的官方指引。正式的文档地址是在:grpc-go/Documentation/concurrency.md

这里做下简单翻译:

Concurrency

一般来说,gRPC-go提供了对并发友好的API。下文是一些指引。

Clients

一个ClientConn可以被安全地并发访问。以helloworld作为范例,程序员可以在多个goroutine之间共享一个ClientConn来创建多个GreeterClient客户端类型。在这种使用情况下,RPCs会并行传输。

Streams

当使用Stream的时候,程序员必须小心避免从不同的goroutine向同一个stream发送多次SendMsgRecvMsg请求。换句话来说,如果有一个goroutine向stream中发送SendMsg,然后在同一时间有另一个goroutine向stream中发送RecvMsg,这样做是安全的。但在不同的goroutine中同时向同一个stream发送SendMsg,或是RecvMsg则是不安全的。

Servers

每个被附到已注册服务器上的RPC处理器都会在其自身的goroutine中运作。举例来说,SayHello会在其自身的goroutine中被调用到。同样的,streaming RPC也是一样,route guide例子可以在这里看得到。

3.3 并发范例

我在实验项目中做了点并发的范例,可以看下:dist-system-practice/golang/src/experiment/grpc/samples/concurrency/

3.3.1 代码差异

服务端代码是一致的,根据3.2 并发相关官方文档,服务端的所有请求都是自然并发的,无需代码特殊处理。但客户端则需要自行编码处理。

顺序执行:

for i := 1; i <= 10; i++ {
    start := time.Now()
    if response, err := client.Echo(ctx, &pb.EchoRequest{Id: int64(i)}); err != nil {
        log.Fatalf("[Client] singleConnSequence: err: %v", err)
    } else {
        end := time.Now()
        elapsed := end.Sub(start)
        log.Printf("[Client] singleConnSequence: response: %v, consumed: %v", response, elapsed)
    }
}

并发执行:

var waitgroup sync.WaitGroup

for i := 1; i <= 10; i++ {
    waitgroup.Add(1)
    go func(id int) {
        start := time.Now()
        if response, err := client.Echo(ctx, &pb.EchoRequest{Id: int64(id)}); err != nil {
            log.Fatalf("[Client] singleConnConcurrency: err: %v", err)
        } else {
            end := time.Now()
            elapsed := end.Sub(start)
            log.Printf("[Client] singleConnConcurrency: response: %v, consumed: %v", response, elapsed)
        }
        waitgroup.Done()
    }(i)
}
waitgroup.Wait()

3.3.2 顺序执行

# 启动服务器
DELAY_NO_5=true go run server.go

# 启动客户端,发送请求
MODE=CONN_ONE_SEQUENCE go run client.go
# 客户端输出
2019/05/03 13:58:23 [Client] singleConnSequence: response: id:1 , consumed: 4.284231ms
2019/05/03 13:58:23 [Client] singleConnSequence: response: id:2 , consumed: 381.818µs
2019/05/03 13:58:23 [Client] singleConnSequence: response: id:3 , consumed: 361.608µs
2019/05/03 13:58:23 [Client] singleConnSequence: response: id:4 , consumed: 332.662µs
2019/05/03 13:58:24 [Client] singleConnSequence: response: id:5 , consumed: 1.004485058s
2019/05/03 13:58:24 [Client] singleConnSequence: response: id:6 , consumed: 470.532µs
2019/05/03 13:58:24 [Client] singleConnSequence: response: id:7 , consumed: 468.148µs
2019/05/03 13:58:24 [Client] singleConnSequence: response: id:8 , consumed: 389.214µs
2019/05/03 13:58:24 [Client] singleConnSequence: response: id:9 , consumed: 418.01µs
2019/05/03 13:58:25 [Client] singleConnSequence: response: id:10 , consumed: 1.003602442s

# 服务器输出
2019/05/03 13:58:23 [Server] Echo: request: id:1
2019/05/03 13:58:23 [Server] Echo: response: id:1
2019/05/03 13:58:23 [Server] Echo: request: id:2
2019/05/03 13:58:23 [Server] Echo: response: id:2
2019/05/03 13:58:23 [Server] Echo: request: id:3
2019/05/03 13:58:23 [Server] Echo: response: id:3
2019/05/03 13:58:23 [Server] Echo: request: id:4
2019/05/03 13:58:23 [Server] Echo: response: id:4
2019/05/03 13:58:23 [Server] Echo: request: id:5
2019/05/03 13:58:24 [Server] Echo: response: id:5
2019/05/03 13:58:24 [Server] Echo: request: id:6
2019/05/03 13:58:24 [Server] Echo: response: id:6
2019/05/03 13:58:24 [Server] Echo: request: id:7
2019/05/03 13:58:24 [Server] Echo: response: id:7
2019/05/03 13:58:24 [Server] Echo: request: id:8
2019/05/03 13:58:24 [Server] Echo: response: id:8
2019/05/03 13:58:24 [Server] Echo: request: id:9
2019/05/03 13:58:24 [Server] Echo: response: id:9
2019/05/03 13:58:24 [Server] Echo: request: id:10
2019/05/03 13:58:25 [Server] Echo: response: id:10

3.3.3 并发执行

# 启动服务器
DELAY_NO_5=true go run server.go

# 启动客户端,发送请求
MODE=CONN_ONE_CONCURRENCY go run client.go
# 客户端输出
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:9 , consumed: 5.651184ms
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:6 , consumed: 5.595945ms
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:2 , consumed: 5.693124ms
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:4 , consumed: 5.637734ms
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:1 , consumed: 5.592096ms
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:3 , consumed: 5.58596ms
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:7 , consumed: 5.652258ms
2019/05/03 13:54:41 [Client] singleConnConcurrency: response: id:8 , consumed: 5.658945ms
2019/05/03 13:54:42 [Client] singleConnConcurrency: response: id:10 , consumed: 1.008472004s
2019/05/03 13:54:42 [Client] singleConnConcurrency: response: id:5 , consumed: 1.008440948s

# 服务器输出
2019/05/03 13:54:41 [Server] Echo: request: id:3
2019/05/03 13:54:41 [Server] Echo: response: id:3
2019/05/03 13:54:41 [Server] Echo: request: id:1
2019/05/03 13:54:41 [Server] Echo: response: id:1
2019/05/03 13:54:41 [Server] Echo: request: id:6
2019/05/03 13:54:41 [Server] Echo: response: id:6
2019/05/03 13:54:41 [Server] Echo: request: id:10
2019/05/03 13:54:41 [Server] Echo: request: id:8
2019/05/03 13:54:41 [Server] Echo: response: id:8
2019/05/03 13:54:41 [Server] Echo: request: id:9
2019/05/03 13:54:41 [Server] Echo: response: id:9
2019/05/03 13:54:41 [Server] Echo: request: id:5
2019/05/03 13:54:41 [Server] Echo: request: id:2
2019/05/03 13:54:41 [Server] Echo: response: id:2
2019/05/03 13:54:41 [Server] Echo: request: id:4
2019/05/03 13:54:41 [Server] Echo: response: id:4
2019/05/03 13:54:41 [Server] Echo: request: id:7
2019/05/03 13:54:41 [Server] Echo: response: id:7
2019/05/03 13:54:42 [Server] Echo: response: id:10
2019/05/03 13:54:42 [Server] Echo: response: id:5

服务端对ID逢5的请求sleep 1秒进行反馈,因此并发的客户端请求中,5和10就是最后返回打印出来的,耗时也是1秒多。

3.4 连接池

虽然单连接也是可以进行并发编程的,但在某些极端情况下,数据包可能达到网络吞吐的极限。众所周知Go语言天生对并发编程友好,因此CPU的利用必然是非常充分的,如果RPC服务的类型是低CPU消耗而高网络消耗的情况就有可能出现之前提到的问题。这种情况就需要创建多个连接了。

在多连接管理方面,官方的类库并没有连接池的功能,第三方倒是有:grpc-go-pool/pool_test.go

此外,可以拓展阅读:Pooling gRPC Connections

范例可以看:dist-system-practice/golang/src/experiment/grpc/samples/pool/

3.5 Benchmark

范例代码在:dist-system-practice/golang/src/experiment/grpc/samples/throughput/

测试硬件

rMBP 2014 mid
CPU:2.5GHz 四核 Intel Core i7 处理器 (Turbo Boost 3.7GHz)

测试的时候一些在系统使用中的软件我并没有清空,因此可能有稍许干扰,但总体问题不大。

客户端及服务器启动命令

CORE_NUM=3 go run server.go
CORE_NUM=4 ROUTINES_PER_CORE=50 go run client.go

使用CORE_NUM来控制使用到的物理核心数量,客户端使用ROUTINES_PER_CORE来决定启动的routine总量:CORE_NUM * ROUTINES_PER_CORE

测试结果

请求数量/s:

2019/05/04 14:19:33 [Client] 73065 requests in 1s
2019/05/04 14:19:34 [Client] 68826 requests in 1s
2019/05/04 14:19:35 [Client] 73655 requests in 1s
2019/05/04 14:19:36 [Client] 70973 requests in 1s
2019/05/04 14:19:37 [Client] 73645 requests in 1s
2019/05/04 14:19:38 [Client] 70616 requests in 1s
2019/05/04 14:19:39 [Client] 72252 requests in 1s
2019/05/04 14:19:40 [Client] 69342 requests in 1s
2019/05/04 14:19:41 [Client] 69745 requests in 1s
2019/05/04 14:19:42 [Client] 62251 requests in 1s
2019/05/04 14:19:43 [Client] 58043 requests in 1s
2019/05/04 14:19:44 [Client] 69316 requests in 1s
2019/05/04 14:19:45 [Client] 69796 requests in 1s
2019/05/04 14:19:46 [Client] 57246 requests in 1s
2019/05/04 14:19:47 [Client] 72265 requests in 1s
2019/05/04 14:19:48 [Client] 72940 requests in 1s
2019/05/04 14:19:49 [Client] 70237 requests in 1s

CPU:

Network:

结论

因硬件环境的限制,没有使用通过网卡的测试方案,而是使用了127.0.0.1的环回请求方案。此外,测试期间MAC上的一些软件进程也并没有进行清理,因此可能有部分干扰。结论就不做了,这样简单且限制资源的benchmark实际意义不大,列几点可以观测到的状态:

  • 请求数量/s符合预期
  • CPU占用基本满
  • 提升核心使用数量,提升routine数量,能观察到rps提升

4. 负载均衡

官方有一篇关于负载均衡设计的文档,可以读下:Load Balancing in gRPC

意义不大,可以了解一些简单的负载均衡概念,以及一些设计上的思路。

5. 状态检查

官方对于如何设计及暴露服务端状态检查接口的一份文档:GRPC Health Checking Protocol。可以说是一份guideline。同样意义不大。

6. Interceptor

所谓的interceptor,写过gin或者koa的可以直接理解为gRPC里的middleware。官方文档:Interceptor

虽然gRPC本身的rpc调用分为多种不同的可能:

  • 请求响应皆为unary
  • 请求unary响应stream
  • 请求stream响应unary
  • 请求响应皆为stream

但interceptor的签名则只有unarystream这两种。

范例代码可以查看官方的:

在使用中可能会遇到多个Interceptor同时存在的情况,在编码的时候就需要使用工具对其进行组合:go-grpc-middleware/chain.go

s := grpc.NewServer(
	grpc_middleware.WithUnaryServerChain(
		grpc_opentracing.UnaryServerInterceptor(),
		grpc_prometheus.UnaryServerInterceptor,
	),
	grpc.StreamInterceptor(grpc_prometheus.StreamServerInterceptor),
)

7. Debug

官方的指引:Debugging,包括了一份范例代码:

也可以使用go的trace包,做简单的观察:Golang gRPC实践 连载六 内置Trace

几个环境变量可以大量输出程序执行的细节:

GODEBUG=http2debug=1 \
GODEBUG=http2debug=2 \
GRPC_GO_LOG_VERBOSITY_LEVEL=99 \
GRPC_GO_LOG_SEVERITY_LEVEL=info \
yourapp

8. 监控

Grpc go自身也有监控用的Interceptor:grpc-ecosystem/go-grpc-prometheus,和HTTP服务器无关,主要是grpc自身的指标。

范例代码可以参见README中的说明,服务端和客户端代码都有提供。

资料

链接

EOF

Published 2019/5/6

Some tech & personal blog posts