chesterbr / minitruco-android

A popular Brazilian card game (Truco) running on Android.
https://play.google.com/store/apps/details?id=me.chester.minitruco&pli=1&hl=pt
BSD 3-Clause "New" or "Revised" License
87 stars 36 forks source link

Aumenta a capacidade de carga do servidor usando Virtual Threads #195

Closed chesterbr closed 1 year ago

chesterbr commented 1 year ago

O problema

Este PR começou com um teste de carga bem inocente (que só abria várias conexões, entrando em salas e inciando o jogo), mas que travou antes mesmo de eu amadurecer ele a ponto de coletar dados.

O motivo: ao contrário do que eu imaginava, threads no Java são sempre 1:1 com threads do sistema operacional desde muito tempo atrás (não existem mais "green threads", como na época em que o app foi criado), e o modelo uma-thread-por-conexão fica muito pesado com threads "reais" (tanto do lado do S.O., que precisa de um tanto de memória e bate em outras limitações, quanto do lado da JVM, porque mesmo sendo um wrapper leve, Thread usa espaço no stack, que é mais escasso que o heap.

Pesquisa

Eu poderia procurar um sistema de asynchronous I/O para as conexões, mas ainda resta o fato de que o core do jogo usa threads (felizmente poucas: uma para a PartidaLocal, que é onde a partida roda, e uma para cada JogadorBot no jogo - para que os bots respondam de forma independente). Como PartidaLocal é um Runnable (o código que instancia a partida é que cria a Thread) e JogadorBot pode ser modificado facilmente para receber um ThreadFactory, minha motivação foi procurar por sistemas que reimplementem threads de forma parecida com Java old school.

Kilim e Quasar pareciam promissoras, mas não são mantidas há anos. Mas tem o Project Loom (detalhes em JEP-425, seguida por JEP-436), que, entre outras coisas, implementa Virtual Threads - e o melhor de tudo, só difere na incialização.

Implementação

O desafio seria alterar o módulo server (que roda no meu servidor Linux e não está em uso atualmente) sem afetar o app (a Android que eu tenho que evitar quebrar a todo custo). Mas acabou sendo simples, porque:

A parte difícil, incrivelmente, foi descobrir como fazer Virtual Threads dar build e rodar. É um feature preview, o que significa que foi preciso:

1) Passar --enable-preview em todos os cantos (compilação, testes, execução e deploy). 2) Usar um JDK/JVM que tenha esse feature preview. Eu estava usando OpenJDK 20, que até tem, mas o Gradle não roda nela (ele pode usar ela pra compilar, mas se enrosca depois com versão de classes); como tem o Android Studio nessa treta, a solução foi baixar pra OpenJDK 19 de ponta a ponta.

Riscos

Riscos

Status e próximos passos

O servidor agora roda melhor (sem OutOfMemory), mas o primeiro teste de load que eu fiz no Mac causou instabilidade. Próximos passos:

Para PRs futuras:

parte de #38 e #173

chesterbr commented 1 year ago

De fato o build falhou no CI: https://github.com/chesterbr/minitruco-android/actions/runs/5751801600/job/15591369316?pr=195

chesterbr commented 1 year ago

UPDATE: uma vez que o limite de memória das threads foi superado, identifiquei um deadlock quando a sala inteira tenta se desconectar ao mesmo tempo (essencialmente quando eu derrubo o LoadTest). Isso era causado por sincronização desnecessária, que foi removida.

A partir daí, o servidor aguenta bem cargas altas; testei com 10.000 conexões, enquanto jogava com aparelhos reais (no meu laptop que é bem modesto); os celulares ficavam lentos quando as conexões eram estabelecidas, mas não chegaram a desconectar.

O teste no servidor enfrentou alguns problemas (ocasionalmente o fail2ban não curtia todas as conexões, o que é até uma boa notícia, caso alguém tente floodar o servidor), mas no geral o uso de memória ficou abaixo dos 270MB real com as conexões ativas.

Ter apenas um core está se mostrando sofrido quando o servidor atualiza, e também está impactando o cruzalinhas/totransit nessa hora (novamente, apenas lentidão); talvez seja o caso de um upgrade ou split, mas é algo que dá pra deixar pra ver com o uso real.

Talvez seja interessante melhorar o load test para conectar numa velocidade mais realista (é de se imaginar que as pessoas não se conectem todas quase simultaneamente) e ver se, num ritmo normal, conseguimos abraçar todas (ou se é preciso, por exemplo, aumentar a "fila" do ServerSocket). Mas a PR cobre o objetivo básico de garantir que o servidor suporta uma carga de, digamos, uns 4096 jogadores com folga.