Os dois botões: workers e threads
Abra o config/puma.rb deste repositório. Os comentários dele já explicam o modelo, e o arquivo é curto o bastante para ler inteiro:
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
port ENV.fetch("PORT", 3000)
plugin :tmp_restart
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
Repare no que não está lá: nenhuma linha workers. Como os próprios comentários do arquivo dizem, você controla o número de workers com WEB_CONCURRENCY, e o padrão já é 1. Sem nada definido, o Puma roda um único processo servindo três threads; o ambiente de deploy define os números reais.
Essa é uma configuração correta para um deployment pequeno, e está a uma linha de ser bem diferente.
Duas dimensões de concorrência
flowchart TB
M["Processo mestre do Puma"] --> W1["Worker 1 — um processo Ruby completo"]
M --> W2["Worker 2 — um processo Ruby completo"]
W1 --> T1["thread"]
W1 --> T2["thread"]
W1 --> T3["thread"]
W2 --> T4["thread"]
W2 --> T5["thread"]
W2 --> T6["thread"]
Workers são processos. Cada um é uma VM Ruby separada, com memória própria, cópia própria da sua aplicação e pool de conexões próprio. O modo cluster, com mais de um worker, é como uma aplicação Ruby usa mais de um núcleo de CPU.
Threads ficam dentro de um worker. Elas compartilham a memória daquele processo, o espaço de objetos e o pool de conexões. Threads são baratas; um worker é caro.
A concorrência total é o produto: workers × threads. Dois workers de três threads atendem seis requisições ao mesmo tempo, que não é a mesma coisa que seis workers de uma thread, e a diferença é sobre o que trata o resto deste curso.
Por que os dois existem, e a GVL
Os comentários do arquivo afirmam esta parte diretamente, e ela é o fato mais importante para dimensionar uma aplicação Ruby:
due to CRuby's Global VM Lock (GVL) it has diminishing returns and will degrade the response time (latency) of the application
No CRuby, uma thread por vez executa código Ruby dentro de um processo. As threads ainda ajudam, porque uma thread esperando I/O (uma consulta ao banco, uma chamada HTTP, ler um arquivo) solta o lock, então outra roda Ruby enquanto a primeira espera.
Duas consequências que decidem tudo daqui para a frente:
- Threads acrescentam vazão na proporção do tempo que o seu app passa esperando, e nada além disso. Uma aplicação que passa metade do tempo no banco ganha muito com threads. Uma que passa o tempo renderizando e computando ganha muito pouco.
- Só workers acrescentam paralelismo de CPU. Dezesseis threads num processo não conseguem usar dois núcleos. Dois workers conseguem.
O aviso do comentário sobre latência é a outra metade. Acrescentar threads significa mais requisições disputando um lock, então cada requisição individual termina mais tarde mesmo com o processo atendendo mais delas por segundo. Vazão e latência são trocadas uma pela outra, e qual você quer é mais uma decisão de produto que técnica.
Threads e o pool
Uma linha do config/puma.rb alcança para fora dele:
Any libraries that use a connection pool or another resource pool should be configured to provide at least as many connections as the number of threads. This includes Active Record's
poolparameter indatabase.yml.
Toda thread de um worker pode estar rodando uma consulta ao mesmo tempo, então um pool menor que o número de threads significa threads na fila por conexões. Isso aparece como latência sem nenhuma consulta lenta por trás. A aula 3 é inteira sobre essa relação, e sobre a aritmética do database.yml deste mesmo repositório.
O que este curso cobre
Escalar, neste contexto, é escolher valores para dois números e depois repetir o processo entre máquinas.
- Os dois botões (esta aula).
- Dimensionando workers e threads.
- O pool de conexões, e outros pools.
- Preload, copy-on-write e memória.
- Restarts no nível do processo.
- Horizontal: além de uma máquina.
- Juntando tudo: uma configuração para esta aplicação.
Pontos-chave
- Sem nada definido, o Puma roda um worker com três threads;
WEB_CONCURRENCYeRAILS_MAX_THREADSmudam os dois números. - Workers são processos com memória e pool próprios; threads compartilham memória e pool de um worker.
- A GVL faz uma thread por processo rodar Ruby por vez, então threads só acrescentam vazão na proporção do tempo esperando I/O.
- Só mais workers usam mais núcleos; mais threads trocam latência por vazão.
- Um pool de conexões precisa ser ao menos do tamanho do número de threads, ou as threads ficam na fila.
Continue com a aula 2
Dimensionando workers e threads
Escolhe workers pelo mínimo entre núcleos, memória e conexões, e threads pela fatia de I/O das requisições.
Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.