文章目录

  • 1 一条服务和一条消息
  • 2 场景
  • 3 结果
  • 4 与普通 HTTP2 比较
  • 5 结语



在到处发送一堆消息时,gRPC 会大放异彩。文件上传呢?gRPC 是否适合文件传输?查看如何使用 gRPC 发送文件,看看这是否有意义。

嘿,前段时间我很好奇gRPC是否适合通过网络发送文件。它的优点之一是对流的原生支持,那么,为什么不呢?

1 一条服务和一条消息

为了实现这个想法,我采用了定义最小可行服务的方法,该服务需要一些块,然后在收到后计算已收到实际内容的字节数。

这些块的定义如下:

message Chunk {
        bytes Content = 1;
}

由于服务将此作为流,我们可以像这样定义它(参见):

service GuploadService {
        rpc Upload(stream Chunk) returns (UploadStatus) {}
}

enum UploadStatusCode {
        Unknown = 0;
        Ok = 1;
        Failed = 2;
}

message UploadStatus {
        string Message = 1;
        UploadStatusCode Code = 2;
}

为了更好地可视化正在发生的事情,我们有以下内容:

grpc stream 文件传输 grpc 传输大文件性能_grpc stream 文件传输


本质上,这意味着我们获得了一个文件句柄,一旦连接启动,我们就开始将其内容分成块,并通过Chunk建立的 gRPC 连接将这些片段作为消息发送。一旦消息到达服务器,我们将这些消息解包(其中包含Content字段中的原始字节。在所有传输完成后,UploadStatus将向客户端发送一条消息,并关闭通道。

您可以在 cirocosta/gupload 中查看代码。
客户的要点如下,为简洁起见,删除了一些部分:

func (c *ClientGRPC) UploadFile(ctx context.Context, f string) (stats Stats, err error) {

        // Get a file handle for the file we 
        // want to upload
        file, err = os.Open(f)

        // Open a stream-based connection with the 
        // gRPC server
	stream, err := c.client.Upload(ctx)
	
        // Start timing the execution
	stats.StartedAt = time.Now()

        // Allocate a buffer with `chunkSize` as the capacity
        // and length (making a 0 array of the size of `chunkSize`)
	buf = make([]byte, c.chunkSize)
	for writing {
                // put as many bytes as `chunkSize` into the
                // buf array.
		n, err = file.Read(buf)

                // ... if `eof` --> `writing=false`...

		stream.Send(&messaging.Chunk{
                        // because we might've read less than
                        // `chunkSize` we want to only send up to
                        // `n` (amount of bytes read).
                        // note: slicing (`:n`) won't copy the 
                        // underlying data, so this as fast as taking
                        // a "pointer" to the underlying storage.
			Content: buf[:n],
		})
	}

        // keep track of the end time so that we can take the elapsed
        // time later
	stats.FinishedAt = time.Now()

        // close
	status, err = stream.CloseAndRecv()
}

对于服务器,几乎相同 - 接收流连接,然后收集每条消息:

// Upload implements the Upload method of the GuploadService
// interface which is responsible for receiving a stream of
// chunks that form a complete file.
func (s *ServerGRPC) Upload(stream messaging.GuploadService_UploadServer) (err error) {
        // while there are messages coming
	for {
		_, err = stream.Recv()
		if err != nil {
			if err == io.EOF {
				goto END
			}

			err = errors.Wrapf(err,
				"failed unexpectadely while reading chunks from stream")
			return
		}
	}

END:
        // once the transmission finished, send the
        // confirmation if nothign went wrong
	err = stream.SendAndClose(&messaging.UploadStatus{
		Message: "Upload received with success",
		Code:    messaging.UploadStatusCode_Ok,
	})
	// ...

	return
}

有了这个集合,我们就可以开始测量传输时间了。

2 场景

了保证传输运行一会儿,我去挑选143M的有效载荷(.git of github.com/moby/moby为未压缩.tar)。

服务器和客户端共享同一主机(macOS 16 GB 1600 MHz DDR3,2.2 GHz Intel Core i7 Retina,15 英寸,2015 年中)并通过环回进行通信。

显然,这不是测试此类东西的最佳方式,但我认为总体思路无论如何仍然有效。

我想了解更改块的大小将如何影响整体传输时间。我假设在一个大块和一个小块之间将存在一个显着的数字,这对于传输来说是最佳的。

话虽如此,我创建了以下测试脚本:

#!/bin/bash

main () {

  # for every chunk size in 
  # "16, 32, 64, 128 ... 2097152 (2MB)"
  for k in $(seq 4 21); do
    
    # run the upload 50 times
    for i in $(seq 1 50); do
      upload_file_via_grpc_tcp_compressed "$k"
    done

  done
}

upload_file_via_grpc_tcp () {
  local number=$1

  printf "$number,"

  gupload upload \
    --address localhost:1313 \
    --chunk-size $(./shift 1 $number) \
    --file ./files.tar 
}

main

shift 只是一个执行两个参数位移的小程序:

int main(int argc, char** argv) {
	// ... validates argc and argv
	int number = atoi(argv[1]);
	int shifts = atoi(argv[2]);

	printf("%d\n", (number << shifts));
	return 0;
}

这意味着我们的测试用例会产生一个CSV: <number_of_bitshifts>,,例如:

4,28418808431
4,31305514409
4,30451840141
...
5,16610844861
5,15894009998
...
6,8175781898
6,8074899000
...

有了这两列,我们就可以把它放在 Excel 中,创建一个数据透视表,然后收集有关数据的见解。

3 结果

我让脚本运行了一段时间,结果如下:

grpc stream 文件传输 grpc 传输大文件性能_grpc_02


从结果来看,如果我们有一个很小的块大小(16、32、64 和 128),我们就不能通过网络传送太多。通过 CPU 使用率可以清楚地看到:我在每个进程(客户端和服务器)上都接近 200%,清楚地看到传输受到 CPU 的瓶颈,这不是我们想要的。

随着我们转向更重要的值,很明显传输数据的时间显着下降,但我们开始看到时间上的巨大差异 - 有时表现很好,有时很糟糕。

似乎在 1024 字节 (1KB) 时,我们有不错的吞吐量。

我期待一些事情32KB: (1 << 15)是最优化的,但看起来它是关于方差的最糟糕的情况之一。

也许这只是在同一台机器上运行客户端和服务器时才会观察到的东西…我不确定。

在这种情况下,我没有使用 TLS,我认为底层 gRPC 可能不会使用 HTTP2(我需要确认这一点!);因此,在启用 TLS 的情况下,我可能会得到一些不同的结果。

但事实并非如此:

grpc stream 文件传输 grpc 传输大文件性能_grpc stream 文件传输_03


方差稍微移动了一点,但结果仍然非常接近。

4 与普通 HTTP2 比较

我想有一个基准来比较这些结果,所以我做client和server一个简单的界面,其他类型的客户端和服务器可以实现。

随着这组我创建了一个HTTP2客户端和服务器,你可以在仓库检出- https://github.com/cirocosta/gupload下./core/client_h2.go和./core/server_h2.go。

grpc stream 文件传输 grpc 传输大文件性能_grpc stream 文件传输_04


我认为很明显,对于这种工作负载,通过 http2 的原始字节传输应该比 gRPC 更快,因为没有消息的编码和解码 - 只是数据的原始传输。

5 结语

我不想从中得出很多结论,但只是在我的脑海中明确表示,了解您的工作负载以及底层传输如何工作以获得最佳结果至关重要。陷入分布式计算的谬误中并开始假设事情是很常见的。这周我试着少假装,多测试。

如果您喜欢这篇文章,还有我写的另一篇关于when-to-buffer-writes的博客文章,我在其中探讨了when-to-buffer-writes在何处发挥最大作用,看看不同的网络条件如何影响传输非常有趣。