Pular para o conteúdo

Trilha de Kotlin · parte 27 de 59

Flow, StateFlow e Channel

Aula da fase 4 da trilha de Kotlin: Quando um `Flow` frio basta, quando um fluxo quente é necessário, e quando um `Channel` coordena produtor e consumidor?

intermediárioPublicada em · 20 min de leitura

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

Três situações parecem parecidas e pedem ferramentas diferentes. A primeira é uma receita escrita num caderno: ela não cozinha nada sozinha, e cada pessoa que a segue prepara o prato do próprio jeito, do início. A segunda é uma transmissão de rádio ao vivo: ela acontece quer alguém esteja ouvindo ou não, e quem entra no meio recebe o que está tocando naquele momento. A terceira é uma esteira de fábrica com uma caixa de espera: quem empacota retira o item da esteira quando pode, e quem coloca precisa esperar se a caixa estiver cheia. Em Kotlin, essas três situações correspondem a Flow, a StateFlow e SharedFlow, e a Channel. Esta aula mostra o comportamento de cada uma com saída real, para que a escolha não dependa de memória.

Todo código foi compilado com kotlinc 2.1.21 e executado com kotlinx-coroutines-core-jvm 1.10.2. A parte de flowOn foi lida na documentação e não executada.

o mapa da aula

Começamos com o Flow frio, que é a receita, e mostramos que cada coletor dispara uma execução nova. Depois vem o StateFlow, que guarda um valor atual e só emite quando ele muda. A seção 3 trata do SharedFlow, que transmite eventos sem guardar nada para quem chega tarde. A seção 4 trata do Channel, a esteira com caixa de espera, e mostra o efeito do buffer com a saída real. A seção 5 compara as quatro ferramentas, a 6 trata do flowOn, e a 7 traz os erros que um revisor procura. A 8 compara o Channel e os fluxos com o que Go tem para o mesmo problema.

1. Flow frio: a receita

Um Flow é um fluxo assíncrono de valores. A documentação descreve o caso padrão, o fluxo frio, com uma frase que serve de definição: “Cold flows start producing values when collected. Each collector triggers a new, independent execution of the flow.” Em português, fluxos frios começam a produzir valores quando são coletados, e cada coletor dispara uma execução nova e independente. A própria página usa a comparação com uma receita: ela define como produzir valores, mas só começa quando alguém a coleta.

Essa propriedade tem consequências práticas. O código dentro de flow { } não roda quando o Flow é criado, e sim quando collect é chamado. Se dois coletores chamam collect no mesmo fluxo, o código de dentro roda duas vezes. Para um backend, isso significa que um Flow que consulta o banco faz a consulta a cada coleta, e não uma vez só.

O teste abaixo cria um fluxo com dois valores e o coleta duas vezes:

fun contagemFria(): Flow<Int> = flow {
    println("   [flow] começou a produzir")
    for (i in 1..2) emit(i)
}

fun main() = runBlocking {
    println("--- cold flow: cada coletor dispara uma execução")
    contagemFria().collect { println("  recebeu $it") }
    contagemFria().collect { println("  recebeu $it") }
}

A saída real foi:

--- cold flow: cada coletor dispara uma execução
   [flow] começou a produzir
  recebeu 1
  recebeu 2
   [flow] começou a produzir
  recebeu 1
  recebeu 2

A mensagem [flow] começou a produzir aparece duas vezes, e a sequência de valores é repetida para cada coletor. A figura compara esse comportamento com o do fluxo quente, que vem na seção 2 e 3.

Flow frio e fluxo quente. flow { emit } → Coletor 1 (collect: execução 1); flow { emit } → Coletor 2 (collect: execução 2); MutableStateFlow → Coletor 1…
figura 1 · Flow frio e fluxo quente

Na figura, o grupo da esquerda é o Flow frio. O flow { emit }, em laranja, é a receita, e cada coletor recebe uma execução própria, marcada com setas que dizem collect e o número da execução. O grupo da direita é o StateFlow, em que um único valor é mantido e os coletores apenas o recebem. O leitor deve perceber que, no grupo da esquerda, cada seta gera trabalho novo, e no da direita, as setas apenas entregam um valor que já existe.

2. StateFlow: o valor atual

O StateFlow é um fluxo quente. A documentação diz que ele é um fluxo quente que armazena um único valor de estado e emite atualizações sempre que esse valor é substituído por outro. Quem começa a coletar recebe o valor atual imediatamente, e depois recebe cada nova atualização. A documentação também informa que o StateFlow emite uma atualização apenas quando o novo valor é diferente do atual.

Essa regra de igualdade tem efeito direto. Um StateFlow é uma boa escolha para estado que muda ao longo do tempo, como o status de um job em andamento ou a configuração ativa de um serviço. Ele é uma má escolha para eventos que podem se repetir com o mesmo conteúdo, porque a segunda ocorrência de um valor igual não é emitida.

O teste abaixo mostra esses três comportamentos: a leitura do valor inicial, a entrega ao coletor que chega depois e a ausência de emissão quando o valor não muda.

val estado = MutableStateFlow(0)
estado.value = 5
println("estado.value = ${estado.value}")
val coletor = launch { estado.collect { println("  estado: $it") } }
delay(10)
estado.value = 7
delay(10)
estado.value = 7
delay(10)
println("(atribuir o mesmo valor não emite de novo)")
coletor.cancel()

A saída real foi:

estado.value = 5
  estado: 5
  estado: 7
(atribuir o mesmo valor não emite de novo)

O coletor começou depois de o valor ter sido mudado para 5, e recebeu 5 de imediato. Depois, estado.value = 7 gerou uma emissão. A segunda atribuição de 7 não gerou nenhuma linha, porque o valor era o mesmo. Esse comportamento é o que a documentação descreve.

3. SharedFlow: transmissão sem memória

O SharedFlow é outro fluxo quente, e a documentação o descreve como um fluxo quente que transmite valores emitidos ao longo do tempo para todos os seus assinantes. Ao contrário do StateFlow, ele não guarda um valor atual. Se um valor foi emitido enquanto ninguém estava ouvindo, ele se perde, a menos que a configuração de replay o guarde.

O teste abaixo usa um MutableSharedFlow sem replay. Um ouvinte se inscreve, recebe dois eventos, é cancelado e depois um terceiro evento é emitido sem nenhum assinante:

val evento = MutableSharedFlow<String>()
val ouvinte = launch { evento.collect { println("  ouvinte recebeu $it") } }
delay(10)
evento.emit("A")
evento.emit("B")
delay(10)
ouvinte.cancel()
evento.emit("C")
println("emit sem assinante retornou")

A saída real foi:

  ouvinte recebeu A
  ouvinte recebeu B
emit sem assinante retornou

O evento C foi emitido e não foi recebido por ninguém. A chamada emit retornou sem bloquear, e a linha final confirma que o código seguiu. Para um backend, isso serve para notificações que só têm sentido enquanto alguém escuta, como um aviso de progresso para uma tela aberta. Não serve para evento que precisa ser processado por todos os consumidores, porque quem não estava escutando não recebe.

4. Channel: a esteira com caixa de espera

O Channel é o componente que faz a comunicação entre produtor e consumidor, com envio e recebimento suspensos. A documentação diz que ele é conceitualmente muito parecido com uma BlockingQueue, com a diferença de que, em vez de uma operação put bloqueante, ele tem um envio suspenso, e em vez de um take bloqueante, um recebimento suspenso.

Um Channel sem capacidade explícita é sem buffer, e a documentação descreve esse caso como rendezvous: a transferência acontece quando envio e recebimento se encontram. Com capacidade 2, ele ganha uma caixa de espera para dois elementos. O teste abaixo põe um produtor que envia quatro valores e um consumidor que demora 10 ms entre um recebimento e outro:

val canal = Channel<Int>(capacity = 2)
val produtor = launch {
    for (i in 1..4) {
        println("enviando $i")
        canal.send(i)
    }
    canal.close()
}
for (x in canal) {
    println("recebeu $x")
    delay(10)
}
produtor.join()
println("canal fechado e esvaziado")

A saída real foi:

enviando 1
enviando 2
enviando 3
enviando 4
recebeu 1
recebeu 2
recebeu 3
recebeu 4
canal fechado e esvaziado

À primeira vista, a ordem surpreende: o produtor disparou os quatro envios antes de o consumidor receber o primeiro valor. A explicação está no buffer. O consumidor estava esperando no momento do primeiro envio, então o valor 1 foi entregue direto. Os valores 2 e 3 couberam na caixa de capacidade 2. O envio do valor 4 não coube, e o produtor ficou suspenso nele. Só depois disso o consumidor passou a receber, um por vez. A figura mostra essa mesma fila.

Channel com capacidade 2. Produtor → Buffer (send 1 a 3); Produtor → Buffer (send 4 espera); Buffer → Consumidor (receive)
figura 2 · Channel com capacidade 2

A figura tem o produtor à esquerda, o buffer no meio com dois lugares, e o consumidor à direita. As setas de send de 1 a 3 são normais, e a seta de send 4 aparece como bloqueada, porque o produtor espera uma vaga. O leitor deve notar que o bloqueio está no envio, e não no consumidor. A caixa de espera é o que absorve a diferença de velocidade entre os dois lados, e o tamanho dela determina quanto trabalho pode ficar pendente.

O close() é a outra metade do protocolo. A documentação explica que fechar um canal funciona como enviar um token especial de fechamento, e que a iteração para assim que esse token é recebido, com a garantia de que todos os elementos enviados antes do fechamento já foram recebidos. Por isso o for (x in canal) do consumidor termina sozinho, e a linha canal fechado e esvaziado só aparece depois.

5. qual ferramenta escolher

As quatro ferramentas se distinguem por duas perguntas: o fluxo guarda um valor atual, e quem chega tarde recebe o que já aconteceu? A tabela resume a diferença a partir do que a aula mostrou.

FerramentaTipoGuarda valor atualQuem chega tardeUso típico
Flowfrionãoexecuta de novo para cada coletorsequência produzida sob demanda
StateFlowquentesim, um valorrecebe o valor atualestado que muda com o tempo
SharedFlowquentenão, a menos que haja replayperde o que foi emitido anteseventos transmitidos a quem estiver ouvindo
Channelcomunicaçãonão, mas guarda o buffercada elemento é entregue a um consumidortrabalho que passa de um produtor para consumidores

A escolha depende do problema. Um Flow frio serve quando cada coletor precisa receber a sequência inteira desde o início, como uma leitura paginada do banco. Um StateFlow é adequado quando o código precisa sempre saber o último estado, como um indicador de saúde. Um SharedFlow cabe em eventos que podem se perder sem prejuízo. Um Channel cabe quando cada item deve ser processado uma vez, por um consumidor, e a fila precisa absorver diferenças de velocidade.

O ponto central é que a escolha errada costuma funcionar nos testes felizes. Um StateFlow usado como fila de eventos perde repetições, e isso só aparece quando dois eventos iguais chegam em sequência. Um Flow frio usado com uma consulta cara é executado uma vez por coletor, e isso só aparece quando mais de um coletor aparece.

6. flowOn e o contexto

O flowOn muda o contexto em que o código a montante de um fluxo roda, sem mudar o contexto de quem coleta. A documentação diz que o operador preserva o contexto e altera apenas o contexto do fluxo a montante, mantendo o contexto de quem chama no restante. É o operador que permite trocar o dispatcher do trecho de um fluxo que produz os valores, sem mexer em quem coleta. Esta aula não executou um flowOn, e a afirmação vem da página oficial. O uso correto de dispatchers para I/O está na aula Dispatchers e bloqueio.

Pergunta de entrevistaStateFlow, SharedFlow ou Channel: quando usar cada um?

StateFlow guarda o último valor e é o caminho natural para estado de tela: quem chega recebe o valor atual, e valores repetidos são filtrados. SharedFlow serve para eventos: não guarda valor, pode ter replay configurado e vários assinantes. Channel é uma fila em que cada item é entregue a um consumidor, então serve para trabalho que deve ser processado uma vez. Escolho pela pergunta: é estado, é evento ou é trabalho? Cada resposta aponta para uma ferramenta.

7. erros comuns e boas práticas

O primeiro erro é usar StateFlow para eventos. Como ele só emite quando o valor muda, dois eventos iguais em sequência viram um. A boa prática é usar SharedFlow para eventos, ou um Channel quando cada evento precisa de um único consumidor.

O segundo erro é tratar um Flow frio como se fosse um resultado guardado. Cada coleta executa o código do fluxo de novo. Uma consulta ao banco dentro de flow { } roda a cada coleta, e o custo se multiplica com o número de coletores. A boa prática é coletar uma vez e guardar o resultado, ou transformar o fluxo em um fluxo quente quando o valor precisa ser compartilhado.

O terceiro erro é o SharedFlow sem assinante, em que o evento some sem aviso. Se o consumidor pode chegar depois, é preciso uma configuração de replay ou outra ferramenta.

O quarto erro é o Channel sem close(). O for sobre um canal só termina quando o canal é fechado, então um produtor que esquece de fechar deixa o consumidor esperando para sempre. A boa prática é fechar o canal no mesmo bloco que criou o produtor, de preferência em um finally.

O quinto erro é deixar o buffer do Channel sem limite pensado. A capacidade define quanto trabalho pode ficar pendente, e um buffer grande demais esconde por mais tempo a diferença de velocidade entre produtor e consumidor. Como a aula mostrou, o buffer de capacidade 2 fez o produtor parar no quarto envio, e essa parada é o sinal de que o consumidor é mais lento.

8. comparação com Go

Go tem canais, e eles são parentes próximos do Channel do Kotlin. Não tem nada parecido com Flow, StateFlow ou SharedFlow na biblioteca padrão. Por isso a comparação tem duas partes: a de canais, em que as semânticas se parecem sem coincidir, e a dos fluxos, em que a pergunta útil é qual construção de Go faz o mesmo papel, e onde ela não existe.

A parte de canais começa pela capacidade. A especificação da linguagem diz que, “If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready.” Esse é o mesmo comportamento que a seção 4 chamou de rendezvous. Um canal sem buffer, em Go, só transfere quando envio e recebimento se encontram, e um canal com capacidade 2 aceita dois envios antes de travar. Nos dois casos quem envia espera uma vaga, e quem recebe espera um valor. Essa parte é igual, e o nome rendezvous vem do Kotlin, porque Go não o usa como termo.

O fechamento é onde as semânticas começam a divergir. A especificação diz que “A receive operation on a closed channel can always proceed immediately, yielding the element type’s zero value after any previously sent values have been received.” Em Go, ler de um canal fechado não é erro. Depois que o buffer esvazia, cada leitura devolve o valor zero do tipo, e a forma com dois retornos (v, ok := <-c) informa se o valor veio de um envio. O programa abaixo, executado com go run na versão go1.27.2, mostra isso e os outros dois casos da especificação.

// 1. Cada valor de um canal vai para um único recebedor.
ch := make(chan int)
// ... dois recebedores leem de ch com range, somando em total e soma ...
for v := 1; v <= 10; v++ {
	ch <- v
}
close(ch)
wg.Wait()
fmt.Printf("dois recebedores: total = %d, soma = %d\n", total, soma)

// 2. Canal fechado: leitura devolve o valor zero, envio causa panic.
c := make(chan string, 1)
c <- "último"
close(c)
v, ok := <-c
fmt.Printf("primeira leitura: %q, ok = %v\n", v, ok)
v, ok = <-c
fmt.Printf("depois de esvaziar: %q, ok = %v\n", v, ok)

func() {
	defer func() {
		fmt.Println("envio recuperado:", recover())
	}()
	c <- "novo"
}()

// 3. Canal nil não fica pronto para comunicação.
var nilCh chan int
select {
case x := <-nilCh:
	fmt.Println("nunca deveria aparecer", x)
default:
	fmt.Println("leitura em canal nil não fica pronta")
}

A saída foi dois recebedores: total = 10, soma = 55, seguida de primeira leitura: "último", ok = true, depois de esvaziar: "", ok = false, envio recuperado: send on closed channel e leitura em canal nil não fica pronta. O primeiro bloco mostra um detalhe que pesa para quem vem do SharedFlow. Os dez valores foram recebidos uma única vez, divididos entre os dois recebedores, e nenhum deles recebeu a sequência inteira. Um canal de Go é ponto a ponto, e a aula de Kotlin mostrou o mesmo para o Channel, onde cada elemento vai a um consumidor. A diferença aparece quando alguém espera que todos os ouvintes recebam cada evento. Em Go, isso exige distribuir o valor manualmente, com um canal por assinante. A aula não escreveu esse código.

O envio em canal fechado é outra diferença de consequência. Em Go, a especificação diz que “A send on a closed channel proceeds by causing a run-time panic.” O programa recuperou o panic com recover e imprimiu a mensagem do runtime. A aula de Kotlin não testou o envio depois de close(), então esta comparação fica do lado de Go, e a versão do Kotlin não foi verificada aqui. O canal nil é um caso que Kotlin não expõe da mesma forma. A especificação diz que “A send on a nil channel blocks forever”, e por isso uma leitura de canal nil dentro de um select sem default pode travar a goroutine para sempre.

A parte de fluxos é mais curta, porque Go tem menos peças. O Flow frio é uma receita que só roda quando alguém coleta, e cada coletor executa de novo. O equivalente mais próximo em Go é o tipo iter.Seq, uma função que recebe um yield. A documentação define type Seq[V any] func(yield func(V) bool) e diz que o iterador “stops early if yield returns false”. Como uma função comum, um iter.Seq só executa quando é chamado, e cada for range chama a função de novo. A documentação registra a regra em uma frase: “Calling the iterator again walks the sequence again.” O trecho abaixo foi executado com go run.

func contagemFria(yield func(int) bool) {
	fmt.Println("   [seq] começou a produzir")
	for i := 1; i <= 2; i++ {
		if !yield(i) {
			return
		}
	}
}

func main() {
	var seq iter.Seq[int] = contagemFria
	for v := range seq {
		fmt.Println("  recebeu", v)
	}
	for v := range seq {
		fmt.Println("  recebeu", v)
	}
}

A saída mostrou [seq] começou a produzir duas vezes, cada uma seguida de recebeu 1 e recebeu 2, o mesmo padrão do Flow frio da seção 1. Essa construção, porém, não é um Flow completo. Ela não tem operadores de transformação embutidos, não tem contexto de coleta e não compartilha execução entre coletores. A documentação consultada não traz a versão do Go em que iter foi introduzido, então esta aula não afirma esse número.

O lado quente tem outro comportamento em Go. A especificação diz que “A “go” statement starts the execution of a function call as an independent concurrent thread of control, or goroutine”. Quando uma função cria uma goroutine produtora e devolve o canal, a produção começa na chamada, e não na leitura. Ela continua até bloquear em um envio sem vaga. Esse é o comportamento de fluxo quente que a seção 2 descreveu para o StateFlow, com uma diferença: a produção não guarda um valor atual, e quem chega tarde recebe apenas o que ainda está no canal.

O StateFlow não tem equivalente na biblioteca padrão. O pacote sync expõe os tipos Cond, Map, Mutex, Once, Pool, RWMutex e WaitGroup, e nenhum deles guarda um valor observável que notifica quando muda. O caminho usual é combinar um Mutex com um valor e um mecanismo de aviso, como sync.Cond ou um canal. Nesse caminho, a regra de só emitir quando o valor muda precisa ser escrita pelo programador, com uma comparação antes da notificação. Esta aula não escreveu essa implementação.

Os erros mais comuns têm a mesma origem nas duas linguagens, que é achar que um canal ou fluxo entrega a todos o que foi enviado. Em Go, um programador que coloca dois recebedores em um canal espera que ambos vejam cada evento, e a execução acima mostra que cada valor vai para um só. Outro erro comum é o produtor que envia depois do close, o que causa panic, e o produtor que ficou bloqueado em um envio depois que o consumidor saiu, porque a especificação diz que o envio só termina com um recebedor pronto. Em Kotlin, o erro está nas escolhas de fluxo descritas na seção 7, e a comparação com Go mostra que o padrão de canal não resolve o problema de broadcast sozinho. Quem precisa de estado compartilhado precisa escolher o mecanismo de notificação com cuidado, e isso vale nas duas linguagens.

9. exercícios

  1. Modifique o Flow frio para ler de uma lista e registre quantas vezes a lista foi lida por dois coletores. Explique o resultado sem usar a palavra cache.
  2. Troque o StateFlow da seção 2 por um SharedFlow com replay = 1, emita o mesmo valor duas vezes e veja se o segundo é recebido. Compare com o comportamento do StateFlow.
  3. Altere a capacidade do Channel da seção 4 para 0 e para 4, e explique a nova ordem de enviando e recebeu antes de executar.

10. glossário

Flow: fluxo assíncrono de valores, em que o código roda quando é coletado.

Fluxo frio: fluxo que começa a produzir valores quando é coletado, com uma execução independente por coletor.

Fluxo quente: fluxo que emite valores independentemente de haver coletores, e que compartilha o mesmo fluxo com todos.

StateFlow: fluxo quente que guarda um valor atual e emite apenas quando o valor muda.

SharedFlow: fluxo quente que transmite eventos para assinantes, sem guardar o último valor por padrão.

Channel: componente de comunicação com envio e recebimento suspensos, que pode ter buffer de tamanho definido.

Rendezvous: canal sem buffer, em que envio e recebimento acontecem no mesmo encontro.

flowOn: operador que muda o contexto do código a montante de um fluxo.

fontes

Consultadas em 20/10/2026:

  • Página de fluxos, https://kotlinlang.org/docs/coroutines-flow.html. Foram usadas as frases sobre fluxos frios, a comparação com receita, a definição de fluxos quentes, as definições de SharedFlow e StateFlow, a regra de que StateFlow emite apenas quando o valor muda e a descrição de flowOn.
  • Página de channels, https://kotlinlang.org/docs/channels.html. Foram usadas a comparação com BlockingQueue, a descrição do canal sem buffer como rendezvous, a explicação do close como token de fechamento e o exemplo de for sobre o canal.

Comparação com Go (seção 8), consultadas em 20/10/2026:

  • Especificação da linguagem Go, https://go.dev/ref/spec, nas seções de tipos de canal, envio, recebimento, close e a instrução go. As frases citadas foram conferidas também no arquivo doc/go_spec.html da toolchain local.
  • Pacote iter, https://pkg.go.dev/iter, para a declaração de Seq e para a frase sobre chamar o iterador de novo. A página não informa a versão em que o pacote entrou, e esta aula não afirma esse número.
  • Pacote sync, conferido com go doc sync na toolchain local, para a lista de tipos citada no texto.

Os trechos de Go foram executados com go run na versão go1.27.2 (go version go1.27.2 darwin/arm64). A página pública mostra go1.27.3, mas a máquina tinha a 1.27.2. O trecho do canal aparece reduzido na aula, e o programa completo ficou com a mesma lógica. Não verificado em Go: a implementação de StateFlow e de SharedFlow sobre canais, porque a aula só descreve esses caminhos e não escreveu o código.

Ambiente dos exemplos: kotlinc 2.1.21, kotlinx-coroutines-core-jvm 1.10.2, JDK Temurin 21.0.8. A saída foi repetida duas vezes e não mudou, inclusive a ordem do canal.

Não verificado: o comportamento de flowOn não foi executado. A aula não apresenta números de desempenho.

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
?