Tail Latency Amplification e Java
Tags: java, microservices, concurrency
Published: 2021-12-28 23:15:00

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](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html) 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.

```java
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:

```java
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 detalha a sequência de eventos:

![DC24g2C9LyZZ.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1640707982934/uJRoAHSyu.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:


```java
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1640708030519/HdxUl9oWB.png)

### Pontos de atenção
1 - Entender os diferentes tipos de [Thread Pools](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadPoolExecutor.html), 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](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/Executors.html) possui diversos métodos estáticos que fornecem thread pools pré-definidos para diversas situações.

2 - [CompletableFuture.supplyAsync](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html#supplyAsync-java.util.function.Supplier-java.util.concurrent.Executor-) Retorna um CompletableFuture que será executado de forma assíncrona e fornecerá o resultado da tarefa quando solicitado (ver item 4).

3 - [CompletableFuture.allOf](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html#allOf-java.util.concurrent.CompletableFuture...-) Retorna um CompletableFuture<Void> que é completado quando todos os CompletableFutures passados também forem.

4 - [CompletableFuture.get()](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html#get--) e [CompletableFuture.join()](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html#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
-  [CompletableFuture](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html) 
- [Executors](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/Executors.html)
- [ExecutorService](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ExecutorService.html)
- [Pools](https://docs.oracle.com/javase/tutorial/essential/concurrency/pools.html) 
- [ThreadPoolExecutor](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/ThreadPoolExecutor.html)