Eu ouvi o termo RPC há pouco tempo atrás. Curiosamente, um tweet do Elon Musk, atribuindo ao excesso de chamadas RPC os problemas de lentidão do Twitter. Quando fui pesquisar o que era RPC, me senti um pouco mal por nunca ter ouvido falar.

Quando usava thrift para conectar ao Hive, não imaginava ser um protocolo RPC, que poderia ser usado para comunicação entre qualquer tipo de serviço em diferentes linguagens.

Para entender melhor como funciona, comparei a implementação de um serviço simples usando RPC e o bom e velho REST.

Criando um serviço gRPC

Para testar a solução RPC, optei por usar o gRPC do Google, parece ser a solução de RPC mais popular atualmente e com suporte a múltiplas linguagens.

Como caso de uso, vou seguir com o problema do post anterior, sobre engenharia de dados: suponha que um modelo de machine learning, que é pré-calculado para toda uma base de clientes. A ideia é construir um serviço gRPC, para disponibilizar esses valores para aplicações consumidoras.

O serviço irá receber um id_client de entrada, buscar as predições em um Redis e formatar a saída do modelo. Esse contrato, é definido utilizando o protocolo Protobuf no gRPC. Abaixo,

  • a especificação do formato da requisição (PredictRequest);
  • formato da da saída do modelo (PredictionResponse);
  • definição do serviço (Predictions):
syntax="proto3";

message PredictionRequest {
    string id_client = 1;
}

message Prediction {
    string class_name = 1;
    float value = 2;
}

message PredictionResponse {
    repeated Prediction predictions = 1;
}

service Predictions {
    rpc GetPredictions (PredictionRequest) returns (PredictionResponse);
}

A partir desse Protobuf, o gRPC oferece utilitários de linha de comando para gerar códigos, utilizado tanto pelo servidores como pelos clientes. Por exemplo, esse comando para geração dos códigos em Python:

python -m grpc_tools.protoc -I ../protobufs --python_out=. --grpc_python_out=. ../protobufs/predictions.proto

Ao executar esse comando. foram gerados dois arquivos Python, predictions_pb2.py e predictions_pb2_grpc.py. Abaixo, uma implementação de servidor, utilizando esses códigos gerados:

import redis
import grpc
from concurrent import futures

from predictions_pb2 import (
    PredictionResponse,
    Prediction
)

from predictions_pb2_grpc import (
    PredictionsServicer,
    add_PredictionsServicer_to_server
)

r = redis.Redis(
    host='localhost',
    port=6379)


class PredictionService(PredictionsServicer):

    def GetPredictions(self, request, context):
        
        key = f"predictions:{request.id_client}"
        predictions = r.hgetall(key)

        predictions = [Prediction(class_name=k.decode("utf-8"),
                                  value=float(v))
                       for k, v
                       in predictions.items()]

        return PredictionResponse(predictions=predictions)

def serve():

    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    add_PredictionsServicer_to_server(
        PredictionService(),
        server
    )

    server.add_insecure_port("[::]:50051")
    server.start()
    server.wait_for_termination()

if __name__ == "__main__":
    serve()

A implementação do protocolo não é complexa e nem trabalhosa, mas não achei a documentação oficial muito boa. Para entender melhor esse código, o melhor material passo-a-passo que encontrei, foi o tutorial do Real Python.

Apesar de não ser muito complexo a implementação do gRPC, é difícil competir em simplicidade com um implementação REST. Abaixo, a implementação do mesmo serviço usando FastAPI:

from fastapi import FastAPI
import redis

app = FastAPI()

r = redis.Redis(
    host='localhost',
    port=6379)


@app.get("/predictions/{id_client}")
async def get_predictions(id_client):
    return r.hgetall(f"predictions:{id_client}")

A maior complexidade também se reflete no lado cliente da aplicação. Assim como o servidor, o cliente precisa ter o arquivo Protobuf para consumir o serviço e desserializar os objetos descritos, gerando uma camada de complexidade que não é obrigatória em serviços REST.

A maior complexidade de uso do gRPC já era previsto – mas para definir se o seu uso é justificado em um determinado cenário – precisamos saber o quanto ganhamos em desempenho.

Benchmarks

Para comparar a performance de REST e RPC, fiz quatro implementações do mesmo serviço:

  • gRPC com Python
  • gRPC com Go
  • REST com Python (FastAPI)
  • REST com Rust (warp)

Além dos protocolos em si, minha ideia foi implementar gPRC e REST em uma linguagem “lenta” (Python) e comparar com os mesmos protocolos em linguagens “rápidas” (Go e Rust).

O primeiro teste é para comparar o tempo de resposta das requisições. Por isso, fiz um código Python que fez 1.000 requisições para serviço de forma sequencial, calculando o tempo de cada requisição individualmente.

from time import perf_counter

def build_execution_time(f):

    def execution_time(id_client: str) -> int:
        start = perf_counter()
        f(id_client)
        total_time = perf_counter() - start
        return int(total_time * 1_000)
    
    return execution_time

Encapsulando as chamadas nessa função, calculei o tempo de execução desses processos e obtive a seguinte distribuição de tempo:

Figura 1 – Histograma das requisições

Pelo histograma, é possível perceber que apenas requisições gRPC retornam respostas em menos de 30 milissegundos, com muitas delas sendo retornadas em menos de 10 milissegundos. Um resultado que eu não esperava, é a implementação do gRPC em Go ser mais instável que a implementação em Python, gerando mais respostas acima dos 30 milissegundos.

Para melhor visualização, nesse histograma eu filtrei as requisições com mais de 100 segundos. Usando outro tipo de visualização, um gráfico box plot, é possível visualizar esses outliers e comparar melhor a distribuição das diferentes implementações.

Figura 2 – Box plot com outliers
Figura 3 – Box plot sem outliers

Para emular um cenário de múltiplas requisições paralelas, minha ideia é mandar um lote de requisições usando várias threads1:.

from typing import List
from concurrent.futures import ThreadPoolExecutor

def build_total_time(f):

    def execution_time(id_clients: List[str]) -> int:

        start = perf_counter()

        with ThreadPoolExecutor(max_workers=100) as executor:
            executor.map(f, id_clients)
        
        total_time = perf_counter() - start
        return int(total_time * 1_000)
    

    return execution_time

Os batches são de 1.000 requisições, executadas por 100 threads em paralelo. Para cada implementação, executei 20 batches:

Figura 4 – Histograma dos batches

Os resultados da execução em lote (Figura 4), estão mais em linha com o que eu esperava. A implementação em Go com gRPC obteve os melhores tempos, seguida pela implementação Python com gRPC. Entre as soluções REST, ambas tiveram resultados similares, mas Rust apresentou menos variância de tempo para processar um lote.

Como esperado, pela natureza da aplicação, a linguagem não foi o fator preponderante. IO costuma ser o maior tempo gasto nesses cenários – as melhorias em comunicação de rede e serialização trazidas pelo protocolo gRPC – fizeram mais diferença.

Eu fiz esses testes a partir do meu notebook (usando rede Wi-Fi), para o meu desktop conectado via cabo ao switch. Sendo um benchmark que depende muito de rede, esses resultados podem variar drasticamente a depender das condições do ambiente.

Caso o leitor queira realizar os testes em um ambiente mais próximo da realidade em que irá trabalhar, os códigos utilizados estão nesse notebook. A implementação dos servidores utilizados, também estão nesse mesmo repositório.

Escalabilidade > velocidade

Olhando para os resultados promissores de desempenho, mesmo considerando a maior complexidade de implementação, eu ainda esperaria que fosse mais comum o uso de protocolos RPC.

Digo isso, porque é muito comum o desenho de arquitetura ser superdimensionado: melhor garantir que, se um dia o serviço crescer muito, não será necessário reimplementar a solução usando outra arquitetura. Muitas vezes, sacrificando simplicidade a agilidade, em prol de uma solução “future proof”.

A resposta, imagino eu, seja a escalabilidade: REST pode ser mais lento por requisição, mas é relativamente simples de escalar horizontalmente. Se não há necessidade de baixa latência, é discutível as vantagens de sacrificar facilidade de desenvolvimento em prol de RPC.

O discurso geral, é que latência é importante para a usabilidade, muitas vezes citando esse estudo do Google e afins. Na prática, o que observo na maioria dos casos, as priorizações vão no sentido contrário de reduzir latência: adicionar cada vez mais recursos, APIs de rastreamento/analytics e “time to market”.

Nesse contexto, imagino que RPC deva seguir como alternativa para integração de microsserviços. A latência é um problema comum de ser criado ao quebrar monolitos, uma desvantagem inescapável desse tipo de arquitetura, que precisa de uma atenção especial em determinados casos.

Além disso, há questão de custo: REST pode ser mais simples de escalar, mas pode sair caro. Em um cenário de cloud, deve ser possível lidar com mais requisições gastando menos, usando gRPC ao invés de REST para o mesmo serviço.

Vejamos se, no futuro, RPC será mais algo mais popular no mercado. De qualquer forma, o gRPC hoje me parece uma boa ferramenta para ter a disposição, em casos que a latência venha a ser um problema maior.

  1. Sendo um cenário típico de aplicação limitada por IO, mesmo com Python sendo limitado pelo GIL, há um paralelismo da perspectiva do servidor. –>