Trilha de Kotlin · parte 28 de 59
Estado compartilhado: Mutex e atomics
Proteja um contador compartilhado entre coroutines com Mutex, atomics, confinamento ou canais, e veja por que suspender não basta para evitar o erro.
intermediárioPublicada em · 12 min de leitura
Estado compartilhado: e atomics
Imagine o caderno de presença de uma portaria. Dois porteiros leem o último número, somam um e anotam. Se os dois leem 41 antes de qualquer escrita, os dois anotam 42, e uma visita some. Um contador de visitas sofre isso quando várias coroutines o atualizam ao mesmo tempo. A imagem tem limite: porteiro não faz essa troca em um milésimo de segundo.
O erro aparece com saída real, e suspender não basta para evitá-lo. As saídas possíveis são Mutex, atomics, e canais. Threads, volatile e synchronized estão em Threads e concorrência na JVM.
Os trechos rodaram com Kotlin 2.4.21, Gradle 9.7.0 e kotlinx-coroutines-core 1.11.0, a release do Maven Central. No Gradle, a declaração é implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0"). Cada trecho rodou no JDK 25 e no JDK 21.0.8. Os blocos não trazem package, então as posições de erro batem em Main.kt.
o mapa da aula
A seção 1 mostra a , e a 2 explica por que suspender não protege. As seções 3 a 6 apresentam as saídas, a 7 traz coleções concorrentes, a 8 reúne a escolha, e as seções 9 e 10 trazem erros e exercícios.
1. o contador que perde visitas
A propriedade de topo visitas é estado compartilhado. Com Dispatchers.Default, as coroutines rodam em várias threads, e visitas++ lê, soma e grava em etapas separadas, que outra thread pode interromper.
import kotlinx.coroutines.*
var visitas = 0
fun main() = runBlocking {
val trabalhos = List(100) {
launch(Dispatchers.Default) {
repeat(1_000) { visitas++ }
}
}
trabalhos.joinAll()
println("visitas = $visitas")
}
visitas = 65925
No JDK 25, cinco execuções deram entre 52.262 e 90.503. No JDK 21.0.8, deram entre 49.429 e 65.652. Nenhuma chegou a 100.000, e o valor muda a cada rodada. O teste passa na sua máquina e falha no dia em que a carga aumenta.
A figura mostra duas threads: as duas leem 41, e a gravação da thread 2 apaga a da thread 1. A caixa verde deveria mostrar 43, porque 41 mais dois incrementos dá 43.
A documentação do Kotlin usa esse exemplo e diz, em tradução livre, que é altamente improvável imprimir 100000. Ela também avisa que volatile não torna atômico o incremento.
2. por que suspender não protege
Uma função suspend libera a thread enquanto espera, mas outras coroutines continuam rodando. O trecho usa runBlocking, que roda tudo numa thread só, para tirar o paralelismo da conta.
import kotlinx.coroutines.*
var visitas = 0
fun main() = runBlocking {
val trabalhos = List(100) {
launch {
val lida = visitas
delay(1)
visitas = lida + 1
}
}
trabalhos.joinAll()
println("visitas = $visitas")
}
visitas = 1
A saída foi 1 nas três execuções de cada JDK. Ela indica que os cem trabalhos leram 0 antes de qualquer escrita. O problema é a pausa entre a leitura e a escrita. A proteção precisa cobrir da leitura até a escrita, incluindo a espera.
3. Mutex e
Um Mutex é como a chave do banheiro de um posto: quem pega entra, e quem espera forma fila. A imagem falha num ponto. No posto, quem espera fica parado na porta. No Mutex, a coroutine que espera libera a thread.
withLock é o atalho do padrão lock, try, finally e unlock. O trecho protege o incremento da seção 1.
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
var visitas = 0
val trava = Mutex()
fun main() = runBlocking {
val trabalhos = List(100) {
launch(Dispatchers.Default) {
repeat(1_000) {
trava.withLock { visitas++ }
}
}
}
trabalhos.joinAll()
println("visitas = $visitas")
}
visitas = 100000
Esse trinco resolve a seção 1, mas não a seção 2, onde uma pausa separa a leitura da escrita.
Mutex e synchronized
A documentação do Kotlin compara o Mutex com synchronized, o bloco do Java que deixa uma thread por vez entrar num monitor. A diferença, segundo a página, é que Mutex.lock() suspende e não bloqueia a thread. No trecho, A segura o Mutex e C roda durante a espera. B segura o synchronized com Thread.sleep, e D só roda depois.
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
fun main() = runBlocking {
val trava = Mutex()
val a = launch {
trava.withLock {
println("A entrou")
delay(300)
println("A saiu")
}
}
launch {
delay(10)
println("C rodou enquanto A segura o Mutex")
}
a.join()
val monitor = Any()
val b = launch {
synchronized(monitor) {
println("B entrou")
Thread.sleep(300)
println("B saiu")
}
}
launch {
delay(10)
println("D rodou só depois que B saiu")
}
b.join()
}
A entrou
C rodou enquanto A segura o Mutex
A saiu
B entrou
B saiu
D rodou só depois que B saiu
A saída foi igual nos dois JDKs. A JEP 491 (JDK 24) tratou virtual threads presas em synchronized, mas o exemplo usa threads de plataforma.
Na linha de cima, o Mutex deixa a thread livre. Na de baixo, o synchronized prende a thread, e o ✕ barra D até B sair.
O Kotlin também recusa suspender dentro de synchronized:
import kotlinx.coroutines.*
val monitor = Any()
fun main() = runBlocking {
synchronized(monitor) {
delay(10)
}
}
e: .../src/main/kotlin/Main.kt:7:9 The 'delay' suspension point is inside a critical section.
Mutex não é reentrante
A página da API diz que o Mutex não é reentrante: pedir lock de novo, mesmo na coroutine que já segura o trinco, suspende quem pediu. Sem withTimeout, o trecho ficaria parado para sempre.
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
fun main() = runBlocking {
val trava = Mutex()
try {
withTimeout(500) {
trava.withLock {
println("primeiro lock tomado")
trava.withLock {
println("nunca chega aqui")
}
}
}
} catch (e: TimeoutCancellationException) {
println("travou: ${e.message}")
}
println("ainda travado? ${trava.isLocked}")
}
primeiro lock tomado
travou: Timed out waiting for 500 ms
ainda travado? false
O finally do withLock soltou o trinco com o cancelamento, e isLocked voltou a false. Veja Cancelamento cooperativo.
Pergunta de entrevistaPor que suspender uma coroutine não protege uma seção crítica?
Suspender libera a thread, mas não impede que outra coroutine rode no meio do trecho. Se houver uma pausa entre a leitura e a escrita, duas coroutines leem o mesmo valor e uma atualização se perde. Para proteger, a exclusão precisa cobrir leitura, espera e escrita, e isso é um Mutex com withLock. Diferente de synchronized, o Mutex suspende quem espera e não bloqueia a thread. E lembro que ele não é reentrante: pedir o lock de novo na mesma coroutine trava.
4. atomics: um número, uma operação
Um atomic é uma catraca de contagem: cada passagem soma um numa peça só. A catraca conta uma coisa só, e o atomic também.
AtomicInteger e AtomicLong fazem a operação em um passo. Segundo a documentação do JDK 25, incrementAndGet incrementa de forma atômica e devolve o valor novo, e compareAndSet(esperado, novo) troca só se o atual for o esperado, devolvendo true ou false.
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicInteger
val visitas = AtomicInteger()
fun main() = runBlocking {
val trabalhos = List(100) {
launch(Dispatchers.Default) {
repeat(1_000) { visitas.incrementAndGet() }
}
}
trabalhos.joinAll()
println("visitas = ${visitas.get()}")
}
visitas = 100000
Duas variáveis que mudam juntas pedem outra ferramenta (observação minha, sem teste).
5. confinamento: uma thread só toca o estado
Confinar é garantir que só uma thread acessa o estado. A página do Kotlin mostra a forma fina, muito lenta, que troca de contexto a cada incremento, e a grossa, que roda um bloco inteiro na thread única. A versão com newSingleThreadContext e use não está nesta nota. A nota registra 100000 nos dois JDKs, sem o código. A referência marca essa API como delicada e recomenda Dispatchers.Default.limitedParallelism(1) quando não é preciso uma thread separada.
Como mostra Dispatchers e bloqueio, limitedParallelism não é mutex: uma coroutine que suspende libera a vaga. Veja o efeito de um delay entre a leitura e a escrita:
import kotlinx.coroutines.*
var visitas = 0
fun main() = runBlocking {
val umaVaga = Dispatchers.Default.limitedParallelism(1)
val trabalhos = List(100) {
launch(umaVaga) {
val lida = visitas
delay(1)
visitas = lida + 1
}
}
trabalhos.joinAll()
println("visitas = $visitas")
}
visitas = 2
Nas execuções dos dois JDKs, o valor ficou em 1 ou 2. Sem o delay, a versão deu 100000 numa execução de cada JDK, sem código nesta nota.
6. não compartilhar: um dono e mensagens
Outra saída é não compartilhar. Uma coroutine é dona do contador e recebe pedidos por um Channel, como em Flow, StateFlow e Channel. Segundo a página de canais, send suspende com o buffer cheio. A página não descreve Channel.UNLIMITED, que o trecho usa.
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
fun main() = runBlocking {
val pedidos = Channel<Unit>(Channel.UNLIMITED)
val dono = async(Dispatchers.Default) {
var visitas = 0
for (pedido in pedidos) visitas++
visitas
}
coroutineScope {
repeat(100) {
launch(Dispatchers.Default) {
repeat(1_000) { pedidos.send(Unit) }
}
}
}
pedidos.close()
println("visitas = ${dono.await()}")
}
visitas = 100000
Só a caixa verde escreve em visitas. Como observação minha, o dono pode virar gargalo, e a fila cresce se ele for lento. Não medi esse custo.
Para um valor observado por outras partes, MutableStateFlow.update é atômico. A página da API diz que a função pode ser reavaliada em caso de concorrência, então ela não deve ter efeito colateral. Mil update { it + 1 } em cada uma de cem coroutines deram 100000 numa execução de cada JDK.
7. coleções concorrentes
Para um mapa de contadores, como visitas por página, ConcurrentHashMap é a opção do JDK. A documentação diz que compute, computeIfAbsent, computeIfPresent e merge são atômicos na chamada inteira. Cem coroutines chamando porPagina.merge("home", 1) { a, b -> a + b } mil vezes cada deram 100000 nos dois JDKs, sem código nesta nota. Um get seguido de put continua exposto à condição de corrida.
8. como escolher
O que precisa ser atômico decide a escolha. A última coluna mostra o limite de cada opção.
| situação | ferramenta | o que ela não resolve |
|---|---|---|
| um número e uma operação | AtomicInteger ou AtomicLong | duas variáveis que mudam juntas |
| bloco com leitura, espera e escrita | Mutex com withLock | chamada aninhada no mesmo trinco |
| estado que só uma thread toca | newSingleThreadContext ou limitedParallelism(1) | limitedParallelism não protege bloco com suspensão |
| trabalho que vira pedido | Channel com uma coroutine dona | fila sem limite; o dono pode virar gargalo |
| valor observado por outras partes | MutableStateFlow com update | função de update com efeito colateral |
| mapa de contadores por chave | ConcurrentHashMap com merge | get e put separados |
9. erros comuns e boas práticas
O erro mais comum é achar que suspender protege. A seção 2 mostrou que uma pausa entre leitura e escrita abre a janela. A boa prática é marcar onde começa a leitura e onde termina a escrita, e cobrir tudo com o trinco.
O segundo erro é colocar o trinco só na escrita, como o exercício da seção 10 mostra. O terceiro é aninhar withLock no mesmo trinco: deixe o trinco no ponto mais externo.
O quarto é usar newSingleThreadContext sem fechar o dispatcher. A documentação diz que os recursos são liberados por close, e por isso a boa prática é use. O quinto é tratar limitedParallelism(1) como mutex. Com suspensão no bloco, a escolha é Mutex.
10. exercícios com resposta
exercício guiado: o trinco que cobre a espera
Parta do trecho da seção 2, que deu 1. Primeiro, coloque o trinco só em volta de visitas = lida + 1. A versão com o trinco só na escrita não está nesta nota. Pela seção 2, ela deve continuar em 1. Depois, cubra a leitura, a espera e a escrita com o trinco, e confira se a saída chega a 100.
Resposta:
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.*
var visitas = 0
val trava = Mutex()
fun main() = runBlocking {
val trabalhos = List(100) {
launch {
trava.withLock {
val lida = visitas
delay(1)
visitas = lida + 1
}
}
}
trabalhos.joinAll()
println("visitas = $visitas")
}
visitas = 100
exercício 2: incrementAndGet na mão
Implemente incrementAndGet com get e compareAndSet em um laço, e explique o que acontece quando a troca falha.
import kotlinx.coroutines.*
import java.util.concurrent.atomic.AtomicInteger
val visitas = AtomicInteger()
fun incrementa(): Int {
while (true) {
val atual = visitas.get()
if (visitas.compareAndSet(atual, atual + 1)) return atual + 1
}
}
fun main() = runBlocking {
val trabalhos = List(100) {
launch(Dispatchers.Default) {
repeat(1_000) { incrementa() }
}
}
trabalhos.joinAll()
println("visitas = ${visitas.get()}")
}
visitas = 100000
Quando compareAndSet devolve false, outra coroutine trocou o valor antes, e o laço lê de novo e tenta outra vez. O resultado 100000 mostra que nenhum incremento se perdeu.
11. glossário
- Condição de corrida: resultado que depende da ordem das operações sobre o mesmo dado.
- Seção crítica: trecho que lê e escreve estado compartilhado e precisa de exclusão.
- Mutex: trinco para coroutines.
locksuspende quem espera, eunlocklibera o trinco. - withLock: função que pega o trinco, executa o bloco e solta o trinco no finally.
- Não reentrante: o
Mutexsuspende a coroutine que pede o trinco de novo. - Operação atômica: operação que outras threads veem como um passo só.
- compareAndSet: troca o valor só se o atual for o esperado, e devolve
trueoufalse. - Confinamento: estratégia em que só uma thread acessa o estado.
12. o que não foi confirmado ou medido
- Os testes rodaram num Mac arm64, com JDK 25, JDK 21.0.8, Kotlin 2.4.21 e Gradle 9.7.0. Outros JDKs e sistemas não foram testados.
- Nenhum tempo foi medido. Não testei a reentrância do
synchronizednemnewSingleThreadContextsemclose. - Alguns trechos citados não estão nesta nota: o confinamento com
newSingleThreadContext, olimitedParallelismsemdelay,MutableStateFlow.updateeConcurrentHashMap.merge. - A limitação dos atomics em duas variáveis e o gargalo do canal são observações minhas.
- Os trechos 01 e 11 variam, com cinco execuções em cada JDK. Os trechos 02, 04 e 06 tiveram de duas a três execuções em cada JDK, e os demais foram rodados uma vez. As saídas fixas coincidiram.
- O modelo de memória ficou fora do escopo. A JEP 491 vem da própria página da JEP, sem teste.
Fontes
Consultadas em 04/11/2026.
- Estado compartilhado mutável e concorrência (página datada de 27/09/2024): https://kotlinlang.org/docs/shared-mutable-state-and-concurrency.html
- API
Mutex(kotlinx.coroutines 1.11.0): https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.sync/-mutex/ - API
newSingleThreadContext(1.11.0): https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/new-single-thread-context.html - API
updatedeMutableStateFlow: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/update.html - Canais: https://kotlinlang.org/docs/channels.html
- Javadoc do JDK 25,
AtomicInteger: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/atomic/AtomicInteger.html - Javadoc do JDK 25,
ConcurrentHashMap: https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html - JEP 491: https://openjdk.org/jeps/491
- Maven Central, metadados de
kotlinx-coroutines-core-jvm: https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/maven-metadata.xml