Tail Latency Amplification e Java

Melhorando a performance da sua aplicação com requisições paralelas

Você já se deparou com situações onde o tempo de resposta de sua API é impactado pelo tempo de resposta de outras API's consultadas? Ou de operações independentes realizadas de forma síncrona e sequencial? Se sim você já sofreu com um problema conhecido como Tail latency amplification.

Deixo a explicação formal para livros como Designing Data Intensive Applications mas o parágrafo anterior deixa transparecer um pouco o que é o problema.

Tail Latency Amplification na prática

Ao realizar de forma síncrona e sequencial diversas requisições independentes o tempo de resposta de sua API será, no mínimo, a soma dos tempos de resposta das requisições realizadas.

Portanto se você realizar 3 requisições e cada uma delas responde em 150ms a sua resposta levará no mínimo 450ms (150x3). Agora imagine que um dos serviços em questão está sofrendo instabilidade, tendo tempo de resposta de 400ms. Nesse caso sua API levará no mínimo 700ms (150x2 + 400) para responder.

Considerando que não é possível interferir nos serviços de terceiros, o que podemos fazer para minimizar o problema é mudar a forma como nossa aplicação realiza tais requisições.

Não seria ótimo se pudéssemos buscar um tempo de resposta mais próximo do maior tempo de resposta entre as requisições realizadas? No cenário 1 (3 requisições de 150ms) nossa API responderia em torno de 150ms. No cenário 2 (2 requisições de 150ms e 1 de 400ms) nosso tempo de resposta seria em torno de 400ms.

Como faríamos isso? Paralelizando as requisições.

Java 8 e CompletableFuture

A classe CompletableFuture foi introduzida no Java 8 junto com melhorias na API de concorrência. Através dela é possível executar tarefas independentes de forma assíncrona e paralela.

Para simular requisições custosas iremos criar 3 métodos que serão executados, em um primeiro momento, de forma síncrona e sequencial. Cada método terá um Thread.sleep(x) visando simular o tempo de resposta do servidor. Caso deseje altere os valores para observar o comportamento da aplicação.

public class ThirdPartyService {
    public String getHello() {
        try {
            Thread.sleep(150); // suspende por 150ms
        } catch (InterruptedException e) {
            e.printStackTrace(); // ignora exception
        } 
        return "Hello";
    }

    public String getWorld() {
        try {
            Thread.sleep(150); // suspende por 150ms
        } catch (InterruptedException e) {
            e.printStackTrace(); // ignora exception
        }
        return " World";
    }

    public String getMyFriend() {
        try {
            Thread.sleep(400); // suspende por 400ms
        } catch (InterruptedException e) {
            e.printStackTrace(); // ignora exception
        }
        return " My Friend";
    }
}

Para manter a simplicidade estamos ignorando as exceções. Isso não deve ser realizado em código que for utilizado em produção!

A primeira versão do nosso client realiza as requisições de forma síncrona e sequencial:

class SyncClient {
    public static void main(String[] args) {
        ThirdPartyService service = new ThirdPartyService(); //instancia do serviço
        StringBuilder builder = new StringBuilder(); //utilizado para consolidar o resultado

        Long curTime = System.nanoTime();

        //Realizando requisições ao serviço
        builder.append(service.getHello());
        builder.append(service.getWorld());
        builder.append(service.getMyFriend());

        System.out.println(builder.toString()); //impressão do resultado "Hello World My Friend"
        System.out.println( (double) (System.nanoTime() - curTime) / 1_000_000_000  );
    }
}

Ao executar o código acima será possível observar que o tempo de execução nunca será menor que a soma dos tempos de cada requisição. Ou seja, o tempo de execução será no mínimo 150 + 150 + 400 = 700 ms. A imagem abaixo detalhe a sequência de eventos:

DC24g2C9LyZZ.png

Vamos agora criar a segunda versão do nosso client que executará as requisições de forma assíncrona e em paralelo conforme abaixo:

class AsyncClient {
    public static void main(String[] args) {
        ThirdPartyService service = new ThirdPartyService(); // instancia do serviço
        ExecutorService executor = Executors.newCachedThreadPool(); // 1 - Obtém um pool de threads para execução de tarefas assíncronas

        Long curTime = System.nanoTime();

        CompletableFuture<String> hello = CompletableFuture.supplyAsync(() -> service.getHello(), executor); // 2 - Executa a tarefa de forma assíncrona no pool
        CompletableFuture<String> world = CompletableFuture.supplyAsync(() -> service.getWorld(), executor);
        CompletableFuture<String> myFriend = CompletableFuture.supplyAsync(() -> service.getMyFriend(), executor);

        CompletableFuture.allOf(hello, world, myFriend).join(); // 3 - Aguarda a finalização da execução de todas as requisições

        StringBuilder builder = new StringBuilder();

        builder.append(hello.join()); // 4 - Obtem o valor retornado 
        builder.append(world.join());
        builder.append(myFriend.join());

        System.out.println(builder.toString()); // impressão do resultado "Hello World My Friend"
        System.out.println( (double) (System.nanoTime() - curTime) / 1_000_000_000  );

        executor.shutdownNow();
    }
}

Ao executar o novo client será possível observar que o tempo de execução será próximo ao da requisição mais longa, que no caso é 400ms do método getMyFriend(). Abaixo a nova sequência de eventos:

DC230nUWezqa.png

Pontos de atenção

1 - Entender os diferentes tipos de Thread Pools, seus funcionamentos e impactos é importante para usar o mais adequado ao seu cenário. Aqui usamos Cache Thread Pool que é útil para tarefas assíncronas curtas (short-lived asynchronous tasks). A classe Executors possui diversos métodos estáticos que fornecem thread pools pré-definidos para diversas situações.

2 - CompletableFuture.supplyAsync Retorna um CompletableFuture que será executado de forma assíncrona e fornecerá o resultado da tarefa quando solicitado (ver item 4).

3 - CompletableFuture.allOf Retorna um CompletableFuture que é completado quando todos os CompletableFutures passados também forem.

4 - CompletableFuture.get() e CompletableFuture.join() são operações "blocantes", ou seja, interrompem a execução do programa até obterem o retorno desejado. A diferença principal entre elas é que join() lança unchecked exceptions. Para operações assíncronas que retornam valor você terá que em algum momento usar get() ou join().

Referências