Configurando e Dimensionando Thread Pools
Objective
Ir além de "quantas threads" e entrar na superfície real de configuração do
ThreadPoolExecutor: a fórmula de dimensionamento precisa do livro Java
Concurrency in Practice de Brian Goetz et al. (Capítulo 7, "Applying Thread
Pools"), e os parâmetros de construtor e hooks usados para transformar um
tamanho escolhido num pool funcional e bem comportado — escolha de fila de
trabalho, políticas de saturação para o que acontece quando essa fila enche,
thread factories customizadas, e os hooks de extensão
beforeExecute/afterExecute/terminated. A entrada thread-pool-sizing de
jvm-concepts já cobre a intuição de dimensionamento CPU-bound vs. I/O-bound
qualitativamente (com seus próprios números de benchmark) — esse enquadramento
não é repetido aqui.
Use Cases
- Calcular um tamanho de pool alvo a partir de uma razão medida ou perfilada entre tempo de espera e tempo de computação, em vez de chutar.
- Decidir entre uma fila ilimitada, uma fila limitada, ou um handoff síncrono
ao configurar um
ThreadPoolExecutorpara um servidor sob carga. - Escolher o que deve acontecer quando uma fila de trabalho limitada enche — lançar exceção, descartar, descartar-o-mais-antigo, ou empurrar o trabalho de volta para quem chamou.
- Nomear threads do pool, definir um
UncaughtExceptionHandler, ou customizar a criação de threads com umThreadFactorypara que thread dumps e logs de erro fiquem legíveis. - Estender
ThreadPoolExecutorcombeforeExecute/afterExecute/terminatedpara adicionar logging, medição de tempo, ou coleta de estatísticas em torno da execução de tarefas.
Deep Dive
A fórmula de dimensionamento
Para tarefas puramente compute-intensive, um sistema com Ncpu processadores geralmente atinge utilização ótima com um thread pool de Ncpu + 1 threads. (Mesmo threads compute-intensive ocasionalmente sofrem um page fault ou pausam por algum outro motivo, então uma thread "extra" pronta para executar evita que ciclos de CPU fiquem ociosos quando isso acontece.)
Para tarefas que também incluem I/O ou outras operações bloqueantes, você quer um pool maior, já que nem todas as threads serão escalonáveis o tempo todo. Para dimensionar o pool corretamente, você precisa estimar a razão entre tempo de espera e tempo de computação para suas tarefas — essa estimativa não precisa ser precisa e pode ser obtida por profiling ou instrumentação, ou o tamanho do pool pode ser ajustado rodando a aplicação sob vários tamanhos de pool diferentes com uma carga de benchmark e observando a utilização de CPU.
Dadas estas definições:
- Ncpu = número de CPUs
- Ucpu = utilização de CPU alvo, 0 < Ucpu ≤ 1
- W/C = razão entre tempo de espera e tempo de computação
o tamanho ótimo do pool para manter os processadores na utilização desejada é:
plaintextNthreads = Ncpu * Ucpu * (1 + W/C)
Você pode determinar o número de CPUs em runtime com:
javaint N_CPUS = Runtime.getRuntime().availableProcessors();Ciclos de CPU não são o único recurso que um thread pool pode precisar gerenciar — memória, file handles, socket handles e conexões de banco de dados também podem restringir o dimensionamento. Calcular uma restrição de tamanho de pool para esses recursos é mais fácil: some quanto desse recurso cada tarefa exige e divida pela quantidade total disponível; o resultado é um limite superior para o tamanho do pool. Quando tarefas exigem um recurso poolado como conexões de banco de dados, o tamanho do thread pool e o tamanho do pool de recursos afetam um ao outro — se cada tarefa exige uma conexão, o tamanho efetivo do thread pool é limitado pelo tamanho do connection pool, e vice-versa.
Criação e desmonte de threads
O construtor geral de ThreadPoolExecutor expõe os controles diretamente:
javapublic ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) { ... }O core pool size é o tamanho alvo: a implementação tenta manter o pool nesse tamanho mesmo quando não há tarefas para executar, e não vai criar mais threads do que isso a menos que a fila de trabalho esteja cheia. O maximum pool size é o limite superior de quantas threads do pool podem estar ativas ao mesmo tempo. Uma thread ociosa por mais tempo que o keep-alive time se torna candidata a ser eliminada e pode ser terminada se o tamanho atual do pool exceder o core size. Ajustar o core pool size e o keep-alive time permite que o pool recupere recursos de threads ociosas — mas isso é um trade-off: eliminar threads ociosas incorre em latência adicional depois, se as threads precisarem ser criadas de novo quando a demanda aumentar.
newFixedThreadPool define tanto o core quanto o maximum pool size para o
tamanho pedido, criando o efeito de um timeout infinito. newCachedThreadPool
define o maximum pool size para Integer.MAX_VALUE e o core pool size para
zero com um timeout de um minuto, criando um pool infinitamente expansível
que se contrai novamente quando a demanda diminui.
Gerenciando tarefas enfileiradas
ThreadPoolExecutor permite fornecer uma BlockingQueue para segurar tarefas
aguardando execução. Existem três abordagens básicas:
- Fila ilimitada — o padrão para
newFixedThreadPoolenewSingleThreadExecutor, usando umaLinkedBlockingQueueilimitada. Tarefas se enfileiram se todas as threads de trabalho estiverem ocupadas, mas a fila pode crescer sem limite se tarefas continuarem chegando mais rápido do que podem ser executadas. - Fila limitada — como uma
ArrayBlockingQueue, umaLinkedBlockingQueuelimitada, ou umaPriorityBlockingQueue. Filas limitadas ajudam a prevenir esgotamento de recursos, mas levantam a questão do que fazer com novas tarefas quando a fila está cheia (uma política de saturação — veja abaixo). Com uma fila de trabalho limitada, o tamanho da fila e o tamanho do pool precisam ser ajustados juntos: uma fila grande com um pool pequeno pode reduzir uso de memória, uso de CPU e troca de contexto, ao custo de potencialmente restringir o throughput. - Handoff síncrono — para pools muito grandes ou ilimitados, uma
SynchronousQueueignora o enfileiramento completamente e entrega tarefas diretamente de produtores para threads de trabalho. Ela não é verdadeiramente uma fila: para colocar um elemento nela, outra thread já precisa estar esperando para aceitar o handoff. Se nenhuma thread está esperando mas o pool está abaixo do seu tamanho máximo,ThreadPoolExecutorcria uma nova thread; caso contrário a tarefa é rejeitada de acordo com a política de saturação.SynchronousQueueé uma escolha prática só se o pool for ilimitado ou se rejeitar tarefas em excesso for aceitável —newCachedThreadPoolusa uma.
Uma fila FIFO (LinkedBlockingQueue ou ArrayBlockingQueue) inicia tarefas
na ordem de chegada. Para mais controle sobre a ordem de execução, uma
PriorityBlockingQueue ordena tarefas por prioridade, definida pela ordem
natural (se as tarefas implementam Comparable) ou por um Comparator.
Limitar o thread pool ou a fila de trabalho só é adequado quando as tarefas
são independentes — com tarefas que dependem de outras tarefas, pools ou
filas limitados podem causar deadlock por fome de threads, então uma
configuração ilimitada como newCachedThreadPool é usada em vez disso.
Políticas de saturação
Quando uma fila de trabalho limitada enche, a política de saturação entra em
jogo — ela também se aplica quando uma tarefa é submetida a um Executor
que já foi desligado. A política é definida chamando
setRejectedExecutionHandler, e o JDK fornece quatro implementações de
RejectedExecutionHandler:
AbortPolicy(o padrão) —executelança a exceção uncheckedRejectedExecutionException; quem chamou pode capturá-la e implementar seu próprio tratamento de overflow.DiscardPolicy— descarta silenciosamente a tarefa recém-submetida se ela não puder ser enfileirada.DiscardOldestPolicy— descarta a tarefa que seria executada em seguida, depois tenta resubmeter a nova tarefa. (Se a fila de trabalho for uma fila de prioridade, isso descarta o elemento de maior prioridade, então combinar discard-oldest com uma fila de prioridade não é uma boa ideia.)CallerRunsPolicy— uma forma de throttling: não descarta tarefas nem lança exceção, mas executa a tarefa recém-submetida na thread que chamouexecuteem vez de numa thread do pool. Isso desacelera o fluxo de novas tarefas, já que essa thread que chamou não consegue submeter mais trabalho enquanto está ocupada executando a tarefa empurrada de volta — dando tempo para as threads de trabalho se recuperarem. À medida que o pool fica sobrecarregado, o trabalho é gradualmente empurrado para fora: das threads do pool para a fila de trabalho, para a aplicação e (num servidor de rede) eventualmente para a camada TCP e o cliente, permitindo uma degradação mais graciosa.
javaThreadPoolExecutor executor
= new ThreadPoolExecutor(N_THREADS, N_THREADS,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>(CAPACITY));
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy());Não existe uma política de saturação predefinida que faça execute bloquear
quando a fila está cheia; o mesmo efeito pode ser obtido com um Semaphore
limitando a taxa de injeção de tarefas (usando uma fila ilimitada, já que não
há motivo para limitar tanto o tamanho da fila quanto a taxa de injeção).
Thread factories
Sempre que um thread pool precisa criar uma thread, ele o faz através de um
ThreadFactory:
javapublic interface ThreadFactory {
Thread newThread(Runnable r);
}A factory padrão cria uma nova thread não-daemon sem configuração especial.
Motivos para fornecer uma customizada incluem especificar um
UncaughtExceptionHandler para threads do pool, instanciar uma subclasse
customizada de Thread (por exemplo uma que faz logging de debug), modificar
a prioridade da thread ou o status de daemon, ou simplesmente dar às threads
do pool nomes mais significativos para facilitar a leitura de thread dumps e
logs de erro:
javapublic class MyThreadFactory implements ThreadFactory {
private final String poolName;
public MyThreadFactory(String poolName) {
this.poolName = poolName;
}
public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable, poolName);
}
}MyAppThread (uma subclasse customizada de Thread) é onde essa
customização de fato vive — ela pode aceitar um nome específico do pool,
instalar um UncaughtExceptionHandler customizado que registra a falha, e
manter estatísticas de quantas threads foram criadas e estão atualmente
vivas.
Se uma aplicação depende de políticas de segurança para conceder permissões
a codebases específicas, Executors.privilegedThreadFactory() constrói
threads do pool com as mesmas permissões, AccessControlContext e
contextClassLoader da thread que criou a factory — do contrário threads do
pool herdam permissões de qualquer cliente que estiver chamando execute ou
submit no momento em que uma nova thread é necessária, o que pode causar
exceções confusas relacionadas a segurança.
A maioria das opções de construtor — core pool size, maximum pool size,
keep-alive time, thread factory, rejected execution handler — também podem
ser alteradas após a construção via setters. Se o executor veio de um dos
factory methods de Executors (exceto newSingleThreadExecutor), ele pode
ser convertido (cast) para ThreadPoolExecutor para acessar esses setters:
javaExecutorService exec = Executors.newCachedThreadPool();
if (exec instanceof ThreadPoolExecutor)
((ThreadPoolExecutor) exec).setCorePoolSize(10);
else
throw new AssertionError("Oops, bad assumption");Executors.unconfigurableExecutorService envolve um ExecutorService
existente expondo só a interface ExecutorService, então ele não pode ser
reconfigurado — útil ao expor um executor a código em que você não confia
para não modificá-lo. newSingleThreadExecutor retorna seu resultado
envolto dessa forma, precisamente porque deixar alguém aumentar o pool size
de um executor de thread única minaria a garantia de execução sequencial que
ele promete.
Estendendo ThreadPoolExecutor
ThreadPoolExecutor foi projetado para extensão, com três hooks que uma
subclasse pode sobrescrever: beforeExecute, afterExecute e terminated.
beforeExecuteeafterExecuterodam na thread que executa a tarefa, então podem ser usados para logging, medição de tempo, monitoramento ou coleta de estatísticas.afterExecuteé chamado tanto se a tarefa completou normalmente quanto se lançou umaException— mas não se a tarefa completou com umError. SebeforeExecutelança umaRuntimeException, a tarefa não é executada eafterExecutenão é chamado.terminatedé chamado depois que o pool completa o shutdown — uma vez que todas as tarefas terminaram e todas as threads de trabalho encerraram. Pode liberar recursos, fazer notificação ou logging, ou finalizar estatísticas.
Exemplo: adicionando estatísticas a um thread pool
Como beforeExecute e afterExecute rodam na thread que executa, um valor
guardado num ThreadLocal por beforeExecute pode ser recuperado por
afterExecute para cronometrar a tarefa — TimingThreadPool usa isso para
acumular uma contagem de tarefas e tempo total de processamento, depois
reporta a média em terminated:
javapublic class TimingThreadPool extends ThreadPoolExecutor {
private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
private final Logger log = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
log.fine(String.format("Thread %s: start %s", t, r));
startTime.set(System.nanoTime());
}
protected void afterExecute(Runnable r, Throwable t) {
try {
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
numTasks.incrementAndGet();
totalTime.addAndGet(taskTime);
log.fine(String.format("Thread %s: end %s, time=%dns",
t, r, taskTime));
} finally {
super.afterExecute(r, t);
}
}
protected void terminated() {
try {
log.info(String.format("Terminated: avg time=%dns",
totalTime.get() / numTasks.get()));
} finally {
super.terminated();
}
}
}Trade-offs
- Filas ilimitadas são simples mas arriscam esgotamento de recursos —
newFixedThreadPoolenewSingleThreadExecutortêm por padrão umaLinkedBlockingQueueilimitada, então tarefas se enfileiram barato, mas se as chegadas continuarem superando a execução, a fila (e eventualmente a memória) podem se esgotar. Filas limitadas restringem esse risco mas forçam uma decisão sobre o que fazer quando enchem. - Uma fila limitada precisa de uma política de saturação, e a padrão lança
exceção — deixe
RejectedExecutionHandlersem definir e uma fila cheia disparaAbortPolicy, lançandoRejectedExecutionExceptionpara quem chamou. Trocá-la muda o comportamento inteiramente, ex.: descartando silenciosamente em vez de falhar ruidosamente:javaexecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); CallerRunsPolicylimita o produtor em vez de falhar — executar a tarefa rejeitada na thread que chamou significa que essa thread não pode submeter mais trabalho até terminar, o que empurra a contrapressão de volta para o que estiver gerando tarefas (ex.: conexões de rede chegando) em vez de descartá-las ou rejeitá-las de imediato.- Fila grande + pool pequeno troca throughput por menor uso de recursos — combinar uma fila limitada grande com poucas threads reduz custos de memória, CPU e troca de contexto, mas pode restringir quanto trabalho realmente é feito concorrentemente; o tamanho da fila e o tamanho do pool precisam ser ajustados como um par, não independentemente.
- Eliminar threads ociosas (core size pequeno) economiza recursos mas adiciona latência — deixar o core pool encolher quando ocioso libera memória, mas se a demanda voltar de repente, novas threads precisam ser criadas antes que essas tarefas possam começar a rodar.