RSS

所以你想优化 gRPC - 第二部分

gRPC 有多快?如果你了解现代客户端和服务器是如何构建的,它就非常快。在第一部分中,我展示了如何轻松获得 60% 的改进。在这篇文章中,我将展示如何获得 10000% 的改进。

设置

第一部分一样,我们将从现有的、基于 Java 的键值服务开始。该服务将提供并发访问,用于创建、读取、更新和删除键和值。所有代码都可以在这里看到,如果你想试一试。

服务器并发

让我们看一下 KvService 类。此服务处理客户端发送的 RPC,确保它们都不会意外地破坏存储状态。为了确保这一点,该服务使用 synchronized 关键字来确保一次只有一个 RPC 处于活动状态。

private final Map<ByteBuffer, ByteBuffer> store = new HashMap<>();

@Override
public synchronized void create(
    CreateRequest request, StreamObserver<CreateResponse> responseObserver) {
  ByteBuffer key = request.getKey().asReadOnlyByteBuffer();
  ByteBuffer value = request.getValue().asReadOnlyByteBuffer();
  simulateWork(WRITE_DELAY_MILLIS);
  if (store.putIfAbsent(key, value) == null) {
    responseObserver.onNext(CreateResponse.getDefaultInstance());
    responseObserver.onCompleted();
    return;
  }
  responseObserver.onError(Status.ALREADY_EXISTS.asRuntimeException());
}

虽然这段代码是线程安全的,但它付出了高昂的代价:一次只能有一个 RPC 处于活动状态!我们需要某种方法来允许多个操作同时安全地发生。否则,程序就无法利用所有可用的处理器。

打破锁定

为了解决这个问题,我们需要更多地了解我们 RPC 的语义。我们对 RPC 的工作方式了解得越多,我们可以进行的优化就越多。对于键值服务,我们注意到对不同键的操作不会相互干扰。当我们更新键 “foo” 时,它与键 “bar” 存储的值无关。但是,我们的服务器的编写方式是,对任何键的操作都必须相互同步。如果我们能使对不同键的操作并发发生,我们的服务器就可以处理更大的负载。

有了这个想法,我们需要弄清楚如何修改服务器。synchronized 关键字导致 Java 获取 this 上的锁,它是 KvService 的实例。当进入 create 方法时获取锁,并在返回时释放锁。我们需要同步的原因是为了保护 store Map。由于它被实现为HashMap,对它的修改会改变内部数组。由于如果不正确同步,HashMap 的内部状态将被破坏,因此我们不能直接删除该方法上的同步。

但是,Java 在这里提供了一个解决方案:ConcurrentHashMap。此类提供了安全地并发访问 map 内容的能力。例如,在我们的使用中,我们想检查键是否存在。如果不存在,我们想添加它,否则我们想返回一个错误。putIfAbsent 方法原子地检查值是否存在,如果不存在则添加它,并告诉我们它是否成功。

并发映射为 putIfAbsent 提供了更强的安全性保证,因此我们可以将 HashMap 替换为 ConcurrentHashMap 并移除 synchronized

private final ConcurrentMap<ByteBuffer, ByteBuffer> store = new ConcurrentHashMap<>();

@Override
public void create(
    CreateRequest request, StreamObserver<CreateResponse> responseObserver) {
  ByteBuffer key = request.getKey().asReadOnlyByteBuffer();
  ByteBuffer value = request.getValue().asReadOnlyByteBuffer();
  simulateWork(WRITE_DELAY_MILLIS);
  if (store.putIfAbsent(key, value) == null) {
    responseObserver.onNext(CreateResponse.getDefaultInstance());
    responseObserver.onCompleted();
    return;
  }
  responseObserver.onError(Status.ALREADY_EXISTS.asRuntimeException());
}

如果第一次不成功

更新 create 非常容易。 对 retrievedelete 执行相同的操作也很容易。但是,update 方法有点棘手。让我们看看它在做什么

@Override
public synchronized void update(
    UpdateRequest request, StreamObserver<UpdateResponse> responseObserver) {
  ByteBuffer key = request.getKey().asReadOnlyByteBuffer();
  ByteBuffer newValue = request.getValue().asReadOnlyByteBuffer();
  simulateWork(WRITE_DELAY_MILLIS);
  ByteBuffer oldValue = store.get(key);
  if (oldValue == null) {
    responseObserver.onError(Status.NOT_FOUND.asRuntimeException());
    return;
  }
  store.replace(key, oldValue, newValue);
  responseObserver.onNext(UpdateResponse.getDefaultInstance());
  responseObserver.onCompleted();
}

将键更新为新值需要与 store 进行两次交互

  1. 检查键是否实际存在。
  2. 将先前的值更新为新值。

不幸的是,ConcurrentMap 没有直接的方法来执行此操作。由于我们可能不是唯一修改映射的人,因此我们需要处理我们的假设可能已经更改的情况。我们读取了旧的值,但是当我们替换它时,它可能已被删除。

为了解决这个问题,如果 replace 失败,让我们重试。如果替换成功,它将返回 true。(ConcurrentMap 断言操作不会破坏内部结构,但不保证它们会成功!)我们将使用 do-while 循环

@Override
public void update(
    UpdateRequest request, StreamObserver<UpdateResponse> responseObserver) {
  // ...
  ByteBuffer oldValue;
  do {
    oldValue = store.get(key);
    if (oldValue == null) {
      responseObserver.onError(Status.NOT_FOUND.asRuntimeException());
      return;
    }
  } while (!store.replace(key, oldValue, newValue));
  responseObserver.onNext(UpdateResponse.getDefaultInstance());
  responseObserver.onCompleted();
}

代码希望在看到 null 时失败,但如果存在非 null 的先前值则永远不会失败。需要注意的一件事是,如果在 store.get() 调用和 store.replace() 调用之间另一个 RPC 修改了该值,则它将失败。这对我们来说是一个非致命的错误,所以我们将重试。一旦它成功地将新值放入,服务就可以响应用户。

还可能发生另一种情况:两个 RPC 可能更新相同的值并覆盖彼此的工作。虽然这对于某些应用程序来说可能没问题,但它不适用于提供事务性的 API。本文不打算说明如何解决此问题,但请注意它可能会发生。

衡量性能

在上一篇文章中,我们修改了客户端以使其异步并使用 gRPC ListenableFuture API。为了避免内存耗尽,客户端被修改为最多同时具有 100 个活动的 RPC。正如我们现在从服务器代码中看到的,性能瓶颈在于获取锁。由于我们已经删除了这些锁,我们预计会看到 100 倍的性能提升。每个 RPC 完成的工作量相同,但是同时发生的 RPC 更多了。让我们看看我们的假设是否成立

之前

./gradlew installDist
time ./build/install/kvstore/bin/kvstore
Apr 16, 2018 10:38:42 AM io.grpc.examples.KvRunner runClient
INFO: Did 24.067 RPCs/s

real	1m0.886s
user	0m9.340s
sys	0m1.660s

之后

Apr 16, 2018 10:36:48 AM io.grpc.examples.KvRunner runClient
INFO: Did 2,449.8 RPCs/s

real	1m0.968s
user	0m52.184s
sys	0m20.692s

哇!从每秒 24 个 RPC 到每秒 2,400 个 RPC。而且我们不必更改我们的 API 或客户端。这就是为什么理解您的代码和 API 语义很重要。通过利用键值 API 的属性,即不同键上的操作的独立性,代码现在快得多。

此代码的一个值得注意的现象是结果中的 user 时间。以前,用户时间只有 9 秒,这意味着 CPU 在代码运行的 60 秒中仅活跃了 9 秒。之后,使用率增加了 5 倍以上,达到 52 秒。原因是更多的 CPU 核心处于活动状态。KvServer 通过休眠几毫秒来模拟工作。在实际应用程序中,它将执行有用的工作,而不是发生如此剧烈的变化。它不是按 RPC 的数量扩展,而是按核心的数量扩展。因此,如果您的机器有 12 个核心,您应该期望看到 12 倍的性能提升。不过仍然不错!

更多错误

如果您自己运行此代码,您会看到更多如下形式的日志垃圾

Apr 16, 2018 10:38:40 AM io.grpc.examples.KvClient$3 onFailure
INFO: Key not found
io.grpc.StatusRuntimeException: NOT_FOUND

原因是新版本的代码使 API 级别的竞争条件更加明显。随着发生的 RPC 数量增加 100 倍,更新和删除彼此冲突的机会也更大。为了解决这个问题,我们需要修改 API 定义。请继续关注下一篇文章,其中将介绍如何解决此问题。

结论

有很多机会可以优化您的 gRPC 代码。要利用这些机会,您需要了解您的代码在做什么。这篇文章展示了如何将基于锁的服务转换为低争用、无锁的服务。始终确保在更改前后进行测量。