Até onde podemos ir com um desktop comum e uma aplicação Java reativa para web?

Programação reativaA tendência atual é que cada vez mais usuários fiquem conectados em todos os lugares e o tempo todo, muitas vezes em várias máquinas simultaneamente (desktop, tablet, celular). A promessa da programação reativa é prover recursos para suportar na mesma máquina muito mais conexões paralelas, e lidar com mais requisições por segundo, com menos threads e com muito menos memória e CPU do que os modos convencionais de programação. Para esse estudo nós criamos três versões de uma aplicação de teste:

  • versão servlet tradicional: uma servlet e chamada para um web service com Apache HttpClient
  • versão servlet assíncrona 3.0: uma servlet assíncrona e chamada para um web service com Apache HttpAsyncClient
  • versão 100% reativa: servidor HttpCore NIO e chamada de um web service com ApacheAsyncClient

Em seguida colocamos essas três versões sob testes de carga bastante agressivos para ver o que elas poderiam suportar. Como testar? Os testes foram feitos com Apache Benchmark Qual é a carga que queremos? Para se ter uma idéia, eis alguns números dos gigantes da web. Por segundo:

  • Google: 33.000 buscas
  • Facebook: 41.000 posts, 30.000 likes
  • Twitter: 4.600 twits

Fonte: what happens in just ONE minute on the internet Em termos de connexões simultâneas, se considera normalmente que o desafio final para um servidor web é chegar a suportar 10.000 conexões paralelas. É o famoso problema C10K. Podemos ir um pouco mais longe, mas não muito mais, porque o número de portas TCP disponíveis é 65.536. Além disso, o próprio Apache Benchmark suporta no máximo 20.000 conexões simultâneas.

Nosso objetivo: 10.000 conexões simultâneas e 50.000 requests por segundo.

Os testes realizados

A idéia é simular uma aplicação Java para web típica. Para cada consulta do usuário, a aplicação vai acessar um web service e exibir o resultado ao usuário.

Schema architecture

Todo o código utilizado para os testes está disponível aqui: https://github.com/fxbonnet/nio-benchmark A máquina usada: um notebook comum (não é um servidor!!)

  • Processador Intel Core i7-3517U 1.90GHz 2 cores, 4 hard-threads (hardware threads)
  • Ubuntu 13.10 (3.11.0-12.19 Ubuntu Linux kernel)
  • Rede: os testes foram locais
  • OpenJDK 1.7.0_51 (OpenJDK Runtime Environment (IcedTea 2.4.4) (7u51-2.4.4-0ubuntu0.13.10.1) OpenJDK 64-Bit Server VM (build 24.45-b08, mixed mode))

Preparação da Ferramenta

Primeiro um rápido teste vazio com o Apache Benchmark:

ab -c10000 -n1000000 http://localhost:8081/war/hello This is ApacheBench, Version 2.3 <$Revision: 1430300 @@ARTICLE_CONTENT@@gt; Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)

socket: Too many open files (24)

A máquina não está pronta para suportar tantas conexões simultâneas. Alguns ajustes são necessários, à começar pelo aumento de alguns valores no arquivo /etc/security/limit.conf

# Increases the number of open files by user * hard nofile 65536 * soft nofile 65536 root hard nofile 65536 root soft nofile 65536

Segunda tentativa:

ab -r -c10000 -n1000000 http://localhost:8081/war/hello This is ApacheBench, Version 2.3 <$Revision: 1430300 @@ARTICLE_CONTENT@@gt; Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)

Test aborted after 10 failures

apr_socket_connect(): Cannot assign requested address (99)

São necessárias configurações mais detalhadas para permitir uma reciclagem mais rápida de portas. Modificamos então o arquivo /etc/sysctl.conf (o arquivo está disponível com o código dos testes no Github). Aproveitamos também para ajustar o tamanho dos buffers assim como outros parâmetros recomendados para suportar tráfego pesado de rede. No mais, vamos também adicionar o argumento -k na linha de comando para que o Apache Benchmark use keep-alive sobre as conexões, o que deve permitir ir mais longe.

Terceira tentativa:

ab -r -k -c10000 -n1000000 http://localhost:8080/ This is ApacheBench, Version 2.3 <$Revision: 1430300 @@ARTICLE_CONTENT@@gt; Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient) Completed 100000 requests Completed 200000 requests Completed 300000 requests Completed 400000 requests Completed 500000 requests Completed 600000 requests Completed 700000 requests Completed 800000 requests Completed 900000 requests Completed 1000000 requests Finished 1000000 requests

Server Software: Server Hostname: localhost Server Port: 8080

Document Path: / Document Length: 0 bytes

Concurrency Level: 10000 Time taken for tests: 12.004 seconds Complete requests: 1000000 Failed requests: 1529850 (Connect: 0, Receive: 1019900, Length: 0, Exceptions: 509950) Write errors: 0 Non-2xx responses: 49 Keep-Alive requests: 49 Total transferred: 5194 bytes HTML transferred: 0 bytes Requests per second: 83302.25 [#/sec] (mean) Time per request: 120.045 [ms] (mean) Time per request: 0.012 [ms] (mean, across all concurrent requests) Transfer rate: 0.42 [Kbytes/sec] received

Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.2 0 227 Processing: 0 116 116.3 120 266 Waiting: 0 0 1.6 0 252 Total: 0 116 116.3 120 477

Percentage of the requests served within a certain time (ms) 50% 120 66% 230 75% 232 80% 233 90% 236 95% 240 98% 246 99% 251 100% 477 (longest request)

Desta vez o teste passa. Podemos observar a utilização de CPU:

System monitor

Observamos duas coisas interessantes:

  1. O Apache Benchmark chega à mais de 83.000 requests/segundo, o que é mais que suficiente para o nosso objetivo
  2. Ele não chega a usar nem 100% de uma hard-thread, então nossa aplicação poderá usar as 3 outras hard-threads restantes

Preparação do web service usado nos testes

Esse serviço é implementado com ajuda do Apache HttpCore NIO. Ele é monothreaded, o que vai deixar as últimas duas hard-threads para a nossa aplicação a ser testada.

Tecnicamente esse serviço retorna um HTTP response. Poderia ser testado usando um navegador.

Firefox

Vamos testá-lo com o Apache Benchmark:

ab -r -k -c10000 -n1000000 http://localhost:8081/war/hello This is ApacheBench, Version 2.3 <$Revision: 1430300 @@ARTICLE_CONTENT@@gt; Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient) Completed 100000 requests Completed 200000 requests Completed 300000 requests Completed 400000 requests Completed 500000 requests Completed 600000 requests Completed 700000 requests Completed 800000 requests Completed 900000 requests Completed 1000000 requests Finished 1000000 requests

Server Software: Server Hostname: localhost Server Port: 8081

Document Path: /war/hello Document Length: 11 bytes

Concurrency Level: 10000 Time taken for tests: 24.268 seconds Complete requests: 1000000 Failed requests: 0 Write errors: 0 Keep-Alive requests: 1000000 Total transferred: 151000000 bytes HTML transferred: 11000000 bytes Requests per second: 41206.84 [#/sec] (mean) Time per request: 242.678 [ms] (mean) Time per request: 0.024 [ms] (mean, across all concurrent requests) Transfer rate: 6076.40 [Kbytes/sec] received

Connection Times (ms) min mean[+/-sd] median max Connect: 0 11 300.7 0 15035 Processing: 55 224 52.3 195 594 Waiting: 55 224 52.3 195 594 Total: 165 235 307.3 195 15381

Percentage of the requests served within a certain time (ms) 50% 195 66% 248 75% 274 80% 279 90% 294 95% 332 98% 351 99% 369 100% 15381 (longest request)

System monitor

O serviço chega a tratar mais de 40.000 requests/segundo, e sobra CPU suficiente para nossa aplicação de teste.

Preparação das aplicações de teste

Vamos começar pela versão da servlet tradicional:

@Override protected void doGet(final HttpServletRequest request, final HttpServletResponse response) throws IOException { HttpGet httpGet = new HttpGet(HelloServer.SLOW_HELLO_URL); CloseableHttpResponse httpClientResponse = httpClient.execute(httpGet); try { String result = EntityUtils.toString(httpClientResponse.getEntity()); response.getWriter().write(result); } finally { httpClientResponse.close(); } }

Depois a versão servlet assíncrona (as servlets assíncronas surgiram na especificação Servlet 3.0). Para fazê-la realmente reativa, ela usa igualmente um cliente HTTP assíncrono. Por isso o código é um pouco complicado:

@Override protected void doGet(final HttpServletRequest request, final HttpServletResponse response) { final AsyncContext asyncContext = request.startAsync(); HttpGet httpGet = new HttpGet(HelloServer.SLOW_HELLO_URL); FutureCallback<HttpResponse> responseCallback = new FutureCallback<HttpResponse>() { @Override public void completed(final HttpResponse httpClientResponse) { try { String result = EntityUtils.toString(httpClientResponse.getEntity()); response.getWriter().write(result); } catch (IOException e) { sendError(e, response); } finally { asyncContext.complete(); } }

	@Override
	public void failed(final Exception e) {
		sendError(e, response);
	}

	@Override
	public void cancelled() {
	}
};
httpClient.execute(httpGet, responseCallback);

}

E por fim a versão 100% reativa, que usa um servidor HttpCore NIO e um cliente HTTP assíncrono:

@Override public void handle(final HttpRequest request, final HttpAsyncExchange httpexchange, final HttpContext context) { HttpGet httpGet = new HttpGet(HelloServer.SLOW_HELLO_URL); FutureCallback responseCallback = new FutureCallback() { @Override public void completed(final HttpResponse httpClientResponse) { try { String result = EntityUtils.toString(httpClientResponse .getEntity()); HttpResponse response = httpexchange.getResponse(); response.setStatusCode(HttpStatus.SC_OK); response.setEntity(new NStringEntity(result, ContentType .create("text/html", "UTF-8"))); httpexchange.submitResponse(); } catch (IOException e) { sendError(e, httpexchange); } }

	@Override
	public void failed(final Exception e) {
		sendError(e, httpexchange);
	}

	@Override
	public void cancelled() {
	}

};

httpClient.execute(httpGet, responseCallback);

}

Resultados dos testes

Configuration serveurThreadsApplicationHits/s% erreurs1
Tomcat 8.0.3 connecteur HTTP200Servlet + HttpClient 4.3.23 2500,8
Tomcat 8.0.3 connecteur NIO200AsyncServlet + HttpAsyncClient 4.0.16 9260,3
Tomcat 8.0.3 connecteur HTTP10 000Servlet + HttpClient 4.3.26 4561,3
Tomcat 8.0.3 connecteur HTTP10 000AsyncServlet + HttpAsyncClient 4.0.17 4331,7
Tomcat 8.0.3 connecteur NIO10 000Servlet + HttpClient 4.3.27 0531,5
Tomcat 8.0.3 connecteur NIO10 000AsyncServlet + HttpAsyncClient 4.0.17 4051,6
Tomcat 8.0.5 connecteur NIO10 000AsyncServlet + HttpAsyncClient 4.0.13 2060,6
Tomcat 8.0.5 connecteur NIO210 000AsyncServlet + HttpAsyncClient 4.0.18 1080,05
Jetty 9.1.3200Servlet + HttpClient 4.3.211 834-
Jetty 9.1.310 000Servlet + HttpClient 4.3.212 066-
Jetty 9.1.3200AsyncServlet + HttpAsyncClient 4.0.114 543-
Jetty 9.1.3 -Xss228k210 000Servlet + HttpClient 4.3.212 255-
Jetty 9.1.3 -Xss228k -Xconcurrentio310 000Servlet + HttpClient 4.3.212 256-
Jetty 9.1.3 -Xss228k -Xconcurrentio10 000AsyncServlet + HttpAsyncClient 4.0.112 664-
Jetty 9.1.3 -Xss228k -Xconcurrentio10AsyncServlet + HttpAsyncClient 4.0.114 065-
HttpCore NIO 4.3.21HttpAsyncClient 4.0.116 137-
HttpCore NIO 4.3.22HttpAsyncClient 4.0.116 600-
HttpCore NIO 4.3.24HttpAsyncClient 4.0.118 291-

1 os erros correspondem a socket timeout, ou seja, quando o Apache Benchmark não recebeu resposta após 30 segundos.

2 a opção -Xss permite definir o tamanho da stack associado a cada thread. Ao reduzí-la, reduz-se a memória usada por cada thread. http://www.oracle.com/technetwork/java/hotspotfaq-138619.html#threads_oom

3 a opção -Xconcurrentio melhora, em teoria, as performances das aplicações que usam um grande número de threads, pelo menos em alguns sistemas. http://www.oracle.com/technetwork/java/hotspotfaq-138619.html#threads_general

Requests/s graphic

Nota: O Tomcat leva cerca de 30 segundos para encher seu pool com 10.000 threads, e são necessários 2Gb de RAM. Por isso precisamos adicionar a opção -Xmx2048m na inicialização da JVM.

Análise

  • O modelo 100% reativo é o que tem melhor performance, com 50% a mais de requests processados em média do que a melhor aplicação tradicional, mesmo bem configurada.
  • O modelo servlet assíncrona permite ganhar somente 5% a 20% em relação ao modelo tradicional, talvez por não ser 100% reativo, já que ele entra em modo assíncrono apenas no final do método doGet() e ainda tem que alocar o thread pool do container.
  • Para arquiteturas exatamente iguais, uma aplicação reativa terá mais performance com 10 threads do que com 10.000! (ex.: testes com Jetty, com 10 threads 14.065 hits/s, com 10.000 threads 12.664 hits/s, 10% menos).
  • Como esperado, os melhores resultados são obtidos ao se dimensionar o número de threads para o mesmo número de hard-threads da máquina (ex.: testes com HttpCore NIO, 18.291 hits/s é o melhor de todos os resultados conseguidos, e não usa mais que 4 threads)
  • Onde as aplicações tradicionais precisam aumentar o tamanho do pool de threads e a memória, a própria aplicação reativa configura sozinha por default o número ideal, que é o número de threads disponíveis na máquina, e consome muito pouca memória.
  • As aplicações não reativas precisam de um tempo razoável de "aquecimento" (30s) e de mais memória (2Gb). No nosso exemplo, o pico de carga mais pesado causa erros (timeout). Já nas aplicações reativas, o pico é bem comportado.
  • Existem grandes diferenças entre os servidores de aplicação (Tomcat ou Jetty), e mesmo de uma versão para a outra. A escolha do conector no Tomcat também tem um grande impacto.

Conclusões

O modelo servlet assíncrona (introduzido na versão 3.0 da especificação de servlet) é interessante porque permite ganhos de performance significativos para os objetivos que se pretende. Por outro lado os servidores de aplicação tradicionais não estão bem preparados para esse tipo de uso, sendo necessário fazer testes e ajustes.

O modelo 100% reativo é o que apresenta melhores resultados (nos nossos testes teve um ganho médio de 50%).

Nós ainda não chegamos nos resultados dos gigantes da web, mas a ordem de grandeza é a mesma (10.000 conexões paralelas, mas somente 18.000 requests/segundo, enquanto nosso objetivo era de 50.000 requests/segundo) mas é preciso lembrar que os testes foram feitos numa máquina de somente 2 cores, 4 threads, que foi usada tanto para rodar a aplicação de teste, o web service de teste e o Apache Benchmark. Por essas razões é um resultado empolgante. Em condições normais seria necessário observar também se a rede tem capacidade de suportar esse fluxo (no nosso caso o teste foi local).

A programação reativa cumpriu o prometido: mais conexões paralelas e mais requests respondidos por segundo utilizando menos memória e menos CPU.

Você quer testar por conta própria? Mais fácil impossível… todo o código usado para os testes está disponível aqui: https://github.com/fxbonnet/nio-benchmark