Pular para o conteúdo

Trilha de Kotlin · parte 25 de 59

Dispatchers e bloqueio

Aula da fase 4 da trilha de Kotlin: Por que bloquear `Dispatchers.Default` trava o serviço, e como `limitedParallelism` controla a concorrência?

intermediárioPublicada em · 18 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Imagine um restaurante com duas cozinhas. A primeira é pequena, com poucas bocas de fogão, e serve para tarefas rápidas que exigem o fogo o tempo todo, como refogar. A segunda é grande, com muitas bancadas, e serve para tarefas que passam a maior parte do tempo esperando, como assar um bolo. Se um cozinheiro parado esperando o forno ocupa a boca de fogo da cozinha pequena, o restaurante inteiro desacelera, mesmo com os outros cozinheiros prontos para trabalhar. O dispatcher de uma coroutine faz o papel de quem decide para qual cozinha cada tarefa vai. Ele escolhe a thread em que o trabalho roda, e quando essa escolha é errada, a thread fica presa.

Esta aula trata dos dispatchers do kotlinx.coroutines, dos tipos de bloqueio que podem prender uma thread compartilhada, e do limitedParallelism, que limita a concorrência de um pool. A pergunta central é por que bloquear o Dispatchers.Default trava o serviço. Todo código foi compilado com kotlinc 2.1.21 e executado com kotlinx-coroutines-core-jvm 1.10.2.

o mapa da aula

Começamos com o que um dispatcher faz e com os principais: Default, IO e Unconfined. Depois mostramos um exemplo em que uma tarefa bloqueante prende as vagas de um pool, com saída real. A seção 3 trata do limitedParallelism, que a documentação descreve com precisão e que não funciona como um mutex. A seção 4 mostra que suspender e bloquear ocupam as vagas de forma diferente, e a 5 cobre o lugar certo para código bloqueante. A seção 6 reúne os erros que um revisor procura. A seção 7 compara com Go, que não tem dispatchers e controla o paralelismo por outro caminho, com um programa rodado nesta aula.

1. o que um dispatcher decide

Um dispatcher é o componente que recebe a continuação de uma coroutine e a entrega a uma thread para executar. Todo builder como launch e async usa um dispatcher, e se nada no contexto indicar outro, a coroutine usa o Dispatchers.Default. A documentação do Dispatchers.Default diz que ele é o dispatcher padrão de todos os builders quando nenhum outro é especificado no contexto, e que é apoiado por um pool compartilhado de threads. A regra de tamanho aparece na referência da API: o número máximo de threads é igual ao número de núcleos da CPU, mas nunca menor que dois.

O Dispatchers.IO existe para outro tipo de trabalho. A referência diz que ele foi projetado para descarregar tarefas de I/O bloqueante em um pool compartilhado de threads, que são criadas e encerradas sob demanda. O número de threads que fazem I/O em paralelo é limitado pela propriedade kotlinx.coroutines.io.parallelism, cujo padrão é o maior valor entre 64 e o número de núcleos. A mesma referência ressalva que o limite de tarefas bloqueantes não é um limite estrito de threads, porque threads extras podem existir durante a inicialização ou o encerramento.

O Dispatchers.Unconfined é um caso à parte. A documentação diz que ele começa a coroutine na thread do chamador, só até o primeiro ponto de suspensão, e depois retoma a execução na thread escolhida pela função suspensa que foi chamada. Ele é apropriado para coroutines que não consomem CPU e não atualizam estado compartilhado ligado a uma thread. Na prática, é raro usá-lo em backend. O Dispatchers.Main existe para interfaces gráficas e não foi usado nesta aula, porque o projeto não tem UI.

Para ver a diferença, rodamos um teste simples que imprime a thread de cada dispatcher. Os nomes podem variar entre execuções, mas o padrão se repete.

import kotlinx.coroutines.*

fun main() = runBlocking {
    println("Default: ${withContext(Dispatchers.Default) { Thread.currentThread().name }}")
    println("IO: ${withContext(Dispatchers.IO) { Thread.currentThread().name }}")
    println("Unconfined: ${withContext(Dispatchers.Unconfined) { Thread.currentThread().name }}")
    println("runBlocking: ${Thread.currentThread().name}")

    println("--- limitedParallelism(1) com tarefas suspensas")
    val uma = Dispatchers.Default.limitedParallelism(1)
    (1..3).map { i ->
        launch(uma) {
            println("tarefa $i começou")
            delay(50)
            println("tarefa $i terminou")
        }
    }.joinAll()

    println("--- limitedParallelism(2) com bloqueio: as vagas ficam presas")
    val pool = Dispatchers.Default.limitedParallelism(2)
    val bloqueios = (1..2).map {
        launch(pool) { Thread.sleep(300) }
    }
    delay(10)
    val t0 = System.nanoTime()
    launch(pool) { println("sonda bloqueada rodou") }.join()
    val esperouMs = (System.nanoTime() - t0) / 1_000_000
    println("sonda esperou pelo menos 200 ms: ${esperouMs >= 200}")
    bloqueios.joinAll()

    println("--- limitedParallelism(2) com suspensão: as vagas ficam livres")
    val esperas = (1..2).map {
        launch(pool) { delay(300) }
    }
    delay(10)
    val t1 = System.nanoTime()
    launch(pool) { println("sonda suspensa rodou") }.join()
    val sondaMs = (System.nanoTime() - t1) / 1_000_000
    println("sonda rodou antes de 200 ms: ${sondaMs < 200}")
    esperas.joinAll()
    println("fim")
}

A saída real foi:

Default: DefaultDispatcher-worker-1
IO: DefaultDispatcher-worker-1
Unconfined: main
runBlocking: main
--- limitedParallelism(1) com tarefas suspensas
tarefa 1 começou
tarefa 2 começou
tarefa 3 começou
tarefa 1 terminou
tarefa 2 terminou
tarefa 3 terminou
--- limitedParallelism(2) com bloqueio: as vagas ficam presas
sonda bloqueada rodou
sonda esperou pelo menos 200 ms: true
--- limitedParallelism(2) com suspensão: as vagas ficam livres
sonda suspensa rodou
sonda rodou antes de 200 ms: true
fim

O Default e o IO mostraram a mesma thread nesta execução, porque ambos usam o mesmo pool de threads do escopo padrão e a thread de trabalho foi reaproveitada. O Unconfined começou na thread main, como a documentação descreve, porque ainda não houve suspensão antes da impressão.

2. bloqueio dentro de um pool

A seção de bloqueio do programa cria um pool com duas vagas, com limitedParallelism(2), e coloca duas tarefas que chamam Thread.sleep(300). Esse método prende a thread atual por 300 ms. Enquanto as duas tarefas dormem, as duas vagas estão ocupadas. A sonda, uma terceira coroutine que só quer imprimir uma linha, espera uma vaga livre e só roda depois que uma delas volta. A saída sonda esperou pelo menos 200 ms: true mostra essa espera.

Quem leu a seção 1 pode notar que a máquina desta execução tem mais núcleos do que duas vagas. O pool padrão do Default teria até o número de núcleos de threads, e o problema só apareceria quando todas essas threads estivessem bloqueadas ao mesmo tempo. Por isso o exemplo usa limitedParallelism(2): ele reduz o pool para um tamanho fixo, e o efeito fica visível sem depender do hardware. A lógica do problema é a mesma para um pool de oito núcleos com oito tarefas bloqueadas.

Num serviço real, o cenário típico é uma chamada JDBC feita dentro de uma coroutine que roda no Default. A consulta espera pela rede, a thread fica parada, e as outras coroutines que precisariam dessa thread também esperam. A figura mostra as duas vagas presas, em vermelho, e a sonda na fila.

Duas vagas: bloqueio x suspensão. Sonda → Vaga 1 (espera); Sonda → Vaga 1 (usa a vaga)
figura 1 · Duas vagas: bloqueio x suspensão

A figura tem dois grupos com o mesmo pool de duas vagas. No grupo de cima, as vagas aparecem em rosa e marcadas com erro, porque estão presas em Thread.sleep, e a sonda espera com a seta lenta. No grupo de baixo, as vagas aparecem em verde, porque estão livres durante o delay, e a sonda usa a vaga imediatamente. O leitor deve comparar a cor das vagas e a velocidade da seta, e perceber que o mesmo pool se comporta de maneiras opostas dependendo de a tarefa bloquear ou suspender.

3. limitedParallelism, e o que ele não faz

O limitedParallelism cria uma visão do dispatcher atual que limita o paralelismo a um valor. A documentação diz que a visão usa o dispatcher original para executar, com a garantia de que não mais do que o número informado de coroutines é executado ao mesmo tempo. A mesma página traz uma seção chamada “It is not a mutex”, que diz, em inglês: “limitedParallelism limits how many threads can execute some code in parallel, but does not limit how many coroutines execute concurrently!”. Em português, o método limita quantas threads executam código em paralelo, mas não limita quantas coroutines executam de forma concorrente. Para proteger um recurso compartilhado, a própria página recomenda mutex ou semáforo.

A saída da primeira seção mostra exatamente essa distinção. Com limitedParallelism(1), as três tarefas começaram antes que qualquer uma terminasse. Elas usam delay(50), que suspende, e a suspensão libera a vaga. A vaga só fica ocupada enquanto o código está rodando, e não durante a espera. Quem esperava que limitedParallelism(1) funcionasse como uma fila serial de tarefas levaria uma surpresa. Para serializar trabalho com suspensões no meio, a ferramenta certa é um Mutex ou um Semaphore, assunto que a fase 4 não cobre em aula própria.

O uso que a seção 2 mostra é o certo para o limitedParallelism: limitar tarefas que bloqueiam a thread de verdade. Quando o código bloqueia, a vaga fica ocupada, e a visão impede que mais tarefas do mesmo grupo prendam threads além do limite.

Pergunta de entrevistaPode bloquear uma thread dentro de uma coroutine?

Pode, mas não deveria fazer isso no Dispatchers.Default nem no Main, que têm poucas threads, porque o bloqueio trava as outras coroutines. Para chamadas bloqueantes, como JDBC, uso Dispatchers.IO ou um pool dedicado, com limitedParallelism se for preciso limitar. Em código novo, prefiro bibliotecas suspend. A regra prática é isolar o bloqueio no dispatcher certo, e nunca no código que outras coroutines esperam.

4. suspender e bloquear na mesma vaga

Compare as duas últimas seções do programa. Na primeira, o bloqueio prendeu as duas vagas, e a sonda esperou. Na segunda, duas tarefas com delay(300) ocuparam o mesmo pool, e a sonda rodou antes de 200 ms (a execução mostrou apenas o resultado booleano da comparação, não o tempo exato). A diferença está no que a tarefa faz com a thread. delay devolve a thread ao pool enquanto espera, e Thread.sleep a mantém presa.

Essa é a razão de a aula suspend e a máquina de estados dizer que suspend sozinho não libera a thread. A função suspend só libera a thread quando chama outra função suspend que suspende de fato, como delay. Se dentro dela houver Thread.sleep, a vaga continua presa, e o compilador não avisa.

5. onde o código bloqueante deve ficar

Na saída da seção 1, Default e IO apresentaram o mesmo nome de thread, o que sugere que os dois reaproveitam threads de um mesmo pool. O que os separa, segundo a referência, é o limite de paralelismo: o IO aceita mais threads fazendo I/O em paralelo, e por isso é o lugar para tarefas que bloqueiam. Uma chamada JDBC, por exemplo, deve ser envolvida em withContext(Dispatchers.IO), para que a thread bloqueada venha do pool de I/O e não do pool de CPU.

Este texto não executou uma consulta JDBC, porque o ambiente não tem banco. O exemplo da seção 2 usou Thread.sleep no lugar de I/O real. A recomendação de Dispatchers.IO para bloqueio vem da descrição da própria referência, que diz que o dispatcher foi feito para descarregar I/O bloqueante. A escolha do tamanho do pool e o impacto no banco ficam para a aula de persistência, na fase 6.

O runBlocking tem papel parecido com o de um bloqueio explícito, e é um erro comum usá-lo em código de servidor. A seção 5 da aula sobre suspend já mostrou que a documentação o descreve como bloqueante. Dentro de um handler, ele prende a thread da requisição até o fim da coroutine, e aparece nos erros que um revisor procura.

6. erros comuns e boas práticas

O primeiro erro, o mais grave da lista, é bloquear o Dispatchers.Default com chamadas JDBC, leitura de arquivo ou Thread.sleep. O Default tem poucas threads, e cada uma bloqueada é uma thread a menos para o trabalho de CPU do serviço inteiro. A boa prática é mover o bloqueio para Dispatchers.IO e deixar o Default para cálculo. O motivo é o mesmo da cozinha da abertura: a boca de fogo pequena não serve para assar bolo.

O segundo erro é o runBlocking dentro de um handler HTTP. A coroutine criada por ele bloqueia a thread da requisição até o fim, e a thread não atende mais nada. A boa prática é usar suspend no handler e deixar o framework cuidar da espera. Esse assunto volta na fase 5.

O terceiro erro é tratar limitedParallelism como mutex. Ele limita quantas threads executam ao mesmo tempo, e não protege uma seção crítica entre suspensões. Para proteger estado compartilhado, use um Mutex. Para limitar concorrência de I/O, um Semaphore costuma ser mais explícito sobre o que está sendo limitado.

O quarto erro é um pool sem limite para tarefas bloqueantes. O IO tem limite padrão alto, mas ainda é um limite, e uma tarefa que cria dezenas de milhares de coroutines bloqueantes pode saturá-lo. A boa prática é limitar o paralelismo da própria tarefa, com limitedParallelism no IO ou com um semáforo, conforme o caso.

7. comparação com Go

Go não tem dispatchers. Nenhuma função da linguagem recebe um pool na hora de criar uma goroutine, e a pergunta da seção 1, em qual thread cada tarefa roda, recebe outra resposta. O controle que o programador tem é sobre quantas goroutines podem executar código Go ao mesmo tempo, e o parâmetro para isso é o GOMAXPROCS. A documentação de runtime diz que a função “sets the maximum number of CPUs that can be executing simultaneously and returns the previous setting”, e que, se a variável de ambiente GOMAXPROCS tiver um número inteiro positivo, esse número vira o padrão. Quando o valor passado é menor que 1, a configuração não muda. O programa desta seção fixa o valor em 2, para que a comparação com o pool de duas vagas do Kotlin seja direta.

O equivalente do Thread.sleep nessa comparação é o time.Sleep, mas a documentação descreve outra coisa. Ela diz que Sleep “pauses the current goroutine for at least the duration d”, ou seja, pausa a goroutine atual por pelo menos o tempo d. A página não fala em threads. O primeiro bloco do programa mostra o efeito observado: duas goroutines dormem 300 ms com GOMAXPROCS(2), e uma sonda lançada em seguida roda antes de 200 ms. Esse resultado é compatível com uma goroutine dormindo sem ocupar um slot de execução, mas a aula não observou o escalonador por dentro, e não afirma o mecanismo.

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

// sondaEsperaVaga mede quanto tempo a sonda leva para pegar uma vaga do semáforo.
func sondaEsperaVaga(sem chan struct{}) time.Duration {
	inicio := time.Now()
	sem <- struct{}{}
	espera := time.Since(inicio)
	<-sem
	return espera
}

func main() {
	runtime.GOMAXPROCS(2)

	fmt.Println("--- 1. time.Sleep sem limite: a goroutine dormindo não segura o P")
	for i := 0; i < 2; i++ {
		go func() { time.Sleep(300 * time.Millisecond) }()
	}
	time.Sleep(10 * time.Millisecond)
	t0 := time.Now()
	var sonda sync.WaitGroup
	sonda.Add(1)
	go func() {
		defer sonda.Done()
		fmt.Println("sonda rodou")
	}()
	sonda.Wait()
	fmt.Println("sonda rodou antes de 200 ms:", time.Since(t0) < 200*time.Millisecond)

	fmt.Println("--- 2. semáforo de 2 vagas, vaga presa durante o time.Sleep")
	sem := make(chan struct{}, 2)
	var presas sync.WaitGroup
	for i := 0; i < 2; i++ {
		presas.Add(1)
		go func() {
			defer presas.Done()
			sem <- struct{}{}
			time.Sleep(300 * time.Millisecond)
			<-sem
		}()
	}
	time.Sleep(10 * time.Millisecond)
	espera := sondaEsperaVaga(sem)
	fmt.Println("sonda esperou pelo menos 200 ms:", espera >= 200*time.Millisecond)
	presas.Wait()

	fmt.Println("--- 3. semáforo de 2 vagas, vaga liberada antes do time.Sleep")
	sem2 := make(chan struct{}, 2)
	var livres sync.WaitGroup
	for i := 0; i < 2; i++ {
		livres.Add(1)
		go func() {
			defer livres.Done()
			sem2 <- struct{}{}
			<-sem2
			time.Sleep(300 * time.Millisecond)
		}()
	}
	time.Sleep(10 * time.Millisecond)
	espera2 := sondaEsperaVaga(sem2)
	fmt.Println("sonda pegou a vaga antes de 200 ms:", espera2 < 200*time.Millisecond)
	livres.Wait()
	fmt.Println("fim")
}

A saída real foi:

--- 1. time.Sleep sem limite: a goroutine dormindo não segura o P
sonda rodou
sonda rodou antes de 200 ms: true
--- 2. semáforo de 2 vagas, vaga presa durante o time.Sleep
sonda esperou pelo menos 200 ms: true
--- 3. semáforo de 2 vagas, vaga liberada antes do time.Sleep
sonda pegou a vaga antes de 200 ms: true
fim

A diferença de limite aparece no segundo e no terceiro blocos. Em Go, o limite de concorrência, quando existe, é escrito no próprio código, por exemplo com um canal de capacidade fixa usado como semáforo. Cada goroutine envia um valor para entrar e recebe para sair. No segundo bloco, a vaga fica presa durante o time.Sleep, e a sonda espera pelo menos 200 ms, como faria no pool de Kotlin com Thread.sleep. No terceiro bloco, a vaga é devolvida antes da espera, e a sonda pega a vaga logo. A lição é a mesma da seção 4: quem devolve a vaga é o código que espera, e em Go esse código é do programador. O programa devolve a vaga com <-sem logo depois do Sleep. Se houvesse um retorno antecipado entre a entrada e a saída, a vaga ficaria presa, e um defer que faça a devolução resolve isso com segurança.

O errgroup oferece o mesmo limite pronto. A documentação do pacote diz que SetLimit “limits the number of active goroutines in this group to at most n”. A aula CoroutineScope e concorrência estruturada mostrou o errgroup com um exemplo de cancelamento. O método SetLimit não foi executado nesta aula, e o uso dele aqui fica como leitura da documentação.

Bloqueio e limite em Kotlin e em Go. sonda → thread 1 (espera); sonda → P2 (roda já); G dormindo → P1 (libera o P)
figura 2 · Bloqueio e limite em Kotlin e em Go

A figura põe os dois modelos lado a lado, com o mesmo número de vagas. No grupo do Kotlin, as duas threads aparecem em rosa e com cadeado, porque estão presas em Thread.sleep, e a sonda espera pela seta bloqueada. No grupo do Go, GOMAXPROCS(2) aparece como dois slots de execução em verde, e as goroutines dormindo aparecem em azul, sem slot. A seta “libera o P” mostra a goroutine dormindo devolvendo o slot, e a sonda roda no slot livre. O leitor deve comparar as duas cores: há o mesmo número de vagas, mas só no Kotlin a vaga fica presa durante a espera. O programa de Go com semáforo cai no caso do Kotlin, e isso aparece na saída do segundo bloco, que o diagrama não mostra.

A comparação termina em duas cautelas. A primeira é que esta seção não mediu desempenho. Nenhum número aqui mede vazão ou latência, e a comparação é de modelo, não de custo. A segunda é que a aula não tratou do que acontece quando uma goroutine faz uma chamada de sistema bloqueante, como ler um arquivo. A aula não verificou o mecanismo do escalonador nesse caso, e a nota Scheduler do vault trata do assunto sem conferência feita aqui. Por isso a figura não mostra esse caso.

8. exercícios

  1. Troque Thread.sleep(300) por delay(300) na seção de bloqueio do programa e rode de novo. Explique com suas palavras por que a sonda passa a rodar antes de 200 ms.
  2. Crie um limitedParallelism(1) sobre Dispatchers.IO e coloque cinco tarefas que chamam Thread.sleep(100). Meça o tempo total e explique por que ele fica perto de 500 ms. Não compare com números de outra máquina.
  3. Escreva uma função que lê um arquivo de texto com File.readText() e a chame dentro de launch(Dispatchers.Default). Depois mude para Dispatchers.IO. Descreva o que muda no código e o que muda, em princípio, para quem usa o pool. Não há medição a apresentar nesta aula.

9. glossário

Dispatcher: componente que decide em qual thread uma coroutine executa.

Dispatchers.Default: dispatcher padrão para trabalho de CPU, com número de threads igual aos núcleos e no mínimo dois.

Dispatchers.IO: dispatcher para I/O bloqueante, com um pool que cresce sob demanda até o limite de sua propriedade de configuração.

Dispatchers.Unconfined: dispatcher que começa na thread do chamador e retoma conforme a função suspensa que foi chamada.

limitedParallelism: função que cria uma visão de um dispatcher com limite de coroutines executando ao mesmo tempo.

Bloqueio: espera que mantém a thread presa, como Thread.sleep ou uma chamada de rede síncrona.

Mutex: primitiva que garante exclusão mútua entre coroutines, assunto fora desta aula.

GOMAXPROCS: função de runtime do Go que define quantas goroutines podem executar código Go ao mesmo tempo. No exemplo da seção 7 foi chamada com 2.

Semáforo: canal com capacidade fixa usado como contador de vagas. A goroutine envia um valor para entrar e recebe para sair.

fontes

Consultadas em 20/10/2026:

Fontes Go, consultadas em 20/10/2026, com a toolchain go1.27.2. As citações vêm das páginas públicas, e a aula conferiu os trechos contra essa data.

  • Pacote time, https://pkg.go.dev/time. Foi usada a descrição de Sleep, que pausa “the current goroutine for at least the duration d”.
  • Pacote runtime, https://pkg.go.dev/runtime. Foi usada a descrição de GOMAXPROCS, incluindo a frase sobre o máximo de CPUs executando ao mesmo tempo e a regra para valores menores que 1.
  • Pacote errgroup, https://pkg.go.dev/golang.org/x/sync/errgroup. Foi usada a descrição de SetLimit. O método não foi executado nesta aula.

Ambiente dos exemplos: kotlinc 2.1.21, kotlinx-coroutines-core-jvm 1.10.2, JDK Temurin 21.0.8. A saída do programa foi repetida duas vezes e não mudou. O programa de Go da seção 7 rodou com go run na toolchain go1.27.2 (darwin/arm64), em três execuções, e a saída não mudou entre elas.

Não verificado: o comportamento de Dispatchers.Main, que não foi executado. O desempenho de uma consulta JDBC em IO não foi medido, e esta aula não apresenta números de desempenho. Em Go, a aula não verificou o escalonamento de goroutines durante chamadas de sistema bloqueantes, nem executou SetLimit.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?