Pular para o conteúdo

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

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

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.

Duas threads leem 41 do contador compartilhado e gravam 42 cada uma; a segunda gravação sobrescreve a primeira e uma visita se perde
figura 1 · Duas threads leem o mesmo 41 e uma visita se perde

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.

Com Mutex, a coroutine A suspende e a thread fica livre para rodar C; com synchronized, o Thread.sleep prende a thread e D só roda depois que B sai
figura 2 · Mutex libera a thread enquanto espera; synchronized prende a thread

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
Cem coroutines enviam pedidos por um Channel sem limite a uma coroutine dona, que é a única a escrever no contador de visitas
figura 3 · Cem coroutines enviam pedidos a uma única dona do contador

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çãoferramentao que ela não resolve
um número e uma operaçãoAtomicInteger ou AtomicLongduas variáveis que mudam juntas
bloco com leitura, espera e escritaMutex com withLockchamada aninhada no mesmo trinco
estado que só uma thread tocanewSingleThreadContext ou limitedParallelism(1)limitedParallelism não protege bloco com suspensão
trabalho que vira pedidoChannel com uma coroutine donafila sem limite; o dono pode virar gargalo
valor observado por outras partesMutableStateFlow com updatefunção de update com efeito colateral
mapa de contadores por chaveConcurrentHashMap com mergeget 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. lock suspende quem espera, e unlock libera o trinco.
  • withLock: função que pega o trinco, executa o bloco e solta o trinco no finally.
  • Não reentrante: o Mutex suspende 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 true ou false.
  • 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 synchronized nem newSingleThreadContext sem close.
  • Alguns trechos citados não estão nesta nota: o confinamento com newSingleThreadContext, o limitedParallelism sem delay, MutableStateFlow.update e ConcurrentHashMap.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.

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
?