Pular para o conteúdo

Especificação §4

Channels e comunicação entre processos

language-design.md §4 · 135 linhas · 9 min de leitura

Channels são tipados e bidirecionais. A direção é anotada no tipo:

Channel[<-int, ->string] # envia int, recebe string
Channel[<-int] # send-only
Channel[->string] # receive-only

A notação <- e -> indica a perspectiva de quem segura o handle: <-T = “eu envio T”, ->T = “eu recebo T”. Isso elimina a assimetria invisível de ter que inverter os tipos na assinatura do processo: cada lado declara o que faz da sua perspectiva. Um Channel[T] cru (sem setas, como aparece nas operações de rede mais adiante) é o simétrico, açúcar para Channel[<-T, ->T], que envia e recebe o mesmo T.

Um channel é um primitivo do core (na prateleira de Result/Optional/Ptr), não um decl de usuário. Ele não tem campos seus para inicializar: o que ele guarda (a fila, o control-block, a sincronização) é estado opaco do runtime. O buffer de um channel é do runtime, não do @mm de quem cria (ver seção 5). Por isso não existe literal cru Channel{...} nem zero-value: um channel “não-inicializado” seria null, que a linguagem não tem (seção 6). Ele só nasce por construção explícita.

A construção é uma função associada (o idioma Tipo.new() da seção 2), porque só o runtime sabe alocar o control-block:

c := Channel.new() // unbuffered (o default: rendezvous, o send bloqueia até o receive)
c := Channel.new(64) // bufferizado, capacidade 64

new() entrega o default (unbuffered); a capacidade como argumento dá o buffer. A direção (<-T/->T) é do tipo, checada na atribuição e no spawn; a fábrica devolve o channel, e o lado que o segura assume a perspectiva que sua assinatura declara.

O channel é um contrato entre as duas pontas. O compilador verifica que os dois lados são complementares no spawn:

chan: Channel[<-int, ->string] // caller: envia int, recebe string
spawn worker(chan)
fn worker(chan: Channel[<-string, ->int]) { // processo: envia string, recebe int
val := -> chan catch |e| { return } // recebe int; a falha é ProcessDown
chan <- val.to_str() // envia string
}
chan <- data // async send (fire and forget)
data := -> chan catch |e| { return } // receive: bloqueia até chegar dado, ou até os remetentes sumirem
result := chan <-> data timeout(5s) // sync request-reply com timeout obrigatório
catch |e| { ... } // Timeout | ProcessDown

O <-> não introduz “coloring” de async; ele simplesmente bloqueia o processo atual. O scheduler cuida do resto. Não há infecção no call stack.

Um send async chan <- data num canal limitado cheio BLOQUEIA o sender (backpressure, a camada de prevenção de OOM da seção 5): o produtor suspende até abrir espaço, então não ultrapassa o consumidor. Se o consumidor morreu, o send bloqueado não trava pra sempre: ele desbloqueia com error.ProcessDown (seção 7). Entre “bloquear enquanto o consumidor está vivo” e “ProcessDown quando morre”, não há erro separado de “buffer cheio” nem um try_send não-bloqueante: uma política de load-shedding “rejeita se cheio” se constrói explicitamente (uma estrutura limitada que o produtor inspeciona, ou <-> com timeout), não embutida no send.

A entrega é FIFO por channel. Dois sends no mesmo channel chegam na ordem em que foram enviados, e isso não depende do scheduler com que você compilou — os dois backends têm a mesma API de comunicação (seção 3), então uma flag de build nunca pode mudar o que um programa significa. Entre remetentes diferentes no mesmo channel não há ordem relativa: o fluxo de cada um é preservado, e os fluxos se intercalam. (É a forma sem-mailbox da garantia: não existe caixa compartilhada onde vários remetentes se misturam, então o channel é o par.)

O que cruza é copiado PROFUNDAMENTE, ou movido com @transfer. Os heaps são isolados (seção 5), então um valor entregue a um channel não pode deixar o receptor apontando pro heap do remetente: quando o remetente morre, o @mm dele é liberado (seção 8) e esse ponteiro ficaria pendurado — a corrupção entre heaps que o isolamento existe pra impedir. Cópia rasa não basta, porque os ponteiros visíveis (*T, [*]T) não são a única indireção: uma string e um []T carregam uma por dentro, assim como qualquer struct que os contenha. Então a travessia copia o valor alcançável inteiro pro heap do receptor. O @transfer é o jeito de evitar a cópia: ele move, invalidando a origem, e o objeto viaja com seu mm.

Timeout é obrigatório no <->. Sem ele, erro de compilação. A razão: dois processos fazendo <-> um no outro simultaneamente produz deadlock garantido, e o timeout é a única saída estrutural. Forçar a declaração torna o contrato explícito: “espero uma resposta, mas por no máximo N tempo”.

// ERRO DE COMPILAÇÃO: <-> sem timeout
result := chan <-> data
// OK: recuperar com valor é match sobre o Result (erro é união → ramifica por variante)
result := match (chan <-> data timeout(5s)) {
Ok(reply) => return reply
Err(error.Timeout) => return retry_logic()
Err(error.ProcessDown) => return escalate()
}

O timeout não muda a semântica de “request-reply garantido”; muda para “request-reply com prazo”. Se o prazo é longo o suficiente para o seu sistema, o comportamento é idêntico. A diferença é que o deadlock tem saída.

Esperar por vários channels ao mesmo tempo e reagir ao primeiro que ficar pronto é o match sem sujeito, o que outras linguagens chamam de select (a forma completa está na seção 14). Não existe keyword select: é o mesmo match, distinguido por não ter um valor entre match e {. O timeout(...) é um trailer do select: o bloco roda se nenhum channel ficar pronto no prazo; sem ele, o select espera indefinidamente, e timeout(0) é o poll não-bloqueante.

match {
msg := -> chan_a => handle_a(msg)
msg := -> chan_b => handle_b(msg)
} timeout(100ms) {
handle_timeout()
}

Quando mais de um channel está pronto, os braços se revezam. O select não pega sempre o primeiro braço pronto, e não sorteia: cada sítio de select lembra qual braço serviu por último e, entre os prontos agora, pega o próximo. Pegar sempre o primeiro pronto mataria os outros de fome (um produtor rápido no primeiro braço e o segundo nunca roda — bug de liveness que não se anuncia); sortear, como o Go faz, resolve a fome mas joga fora a reprodutibilidade. Revezar compra as duas: nenhum braço passa fome, e a mesma sequência de chegadas produz sempre a mesma sequência de escolhas, que é justamente pra isso que o scheduler determinístico existe. O que continua não-determinístico é só quais channels estão prontos — isso é a concorrência em si, e ela é visível no código (seção 14).

Não existe close. Um channel não é encerrado na mão; o ciclo de vida do processo já termina uma conversa. Um receive bloqueado num channel que não tem mais remetente vivo desbloqueia com error.ProcessDown, o espelho do send bloqueado na mesma situação, então um laço consumidor termina tratando esse erro.

É isso que o TIPO de um receive também diz: -> chan entrega Result[T, error{ProcessDown}], porque produz um valor ou falha, e falha precisa ser tratada. Só ProcessDown, e nenhum Timeout: um receive simples não carrega deadline. O deadline é o que o <-> acrescenta, e ele acrescenta porque dois processos fazendo <-> um no outro travam, coisa que um receive de mão única não consegue fazer.

Um receive cujos remetentes acabaram encerra o fluxo mesmo com valores ainda no buffer: os que estão no buffer são entregues primeiro, e a falha vem quando não sobra nada E ninguém mais pode acrescentar.

loop {
v := -> chan catch |e| { break } // o produtor morreu: o fluxo acabou
process(v)
}

Ter close seria um segundo jeito de terminar um fluxo, competindo com a morte do processo que o alimenta, e os dois teriam que ser reconciliados em cada receive. O campo closed da API de introspecção (seção 19) é portanto uma observação, não um estado que alguém seta: ele diz que o channel não tem mais contraparte viva.

Tudo até aqui é channel local: dois processos no mesmo runtime, cada um com seu heap isolado (seção 5), comunicação rápida e confiável. Mas o modelo “mundos isolados falando por channel” não para no runtime: dois runtimes em máquinas diferentes (um servidor e um cliente, dois microserviços teus) são só mais dois mundos isolados, e a comunicação entre eles é o mesmo conceito, um channel. A diferença é a física: esse channel atravessa a rede.

A decisão de design é que não há um tipo de channel de rede. É o mesmo Channel[T], obtido por uma operação de rede que impõe o que a rede fisicamente exige, e as duas coisas que ela exige já existem na linguagem:

1. O payload tem que serializar. Mesmo local, os dois lados têm heaps isolados (seção 5): um channel local move ou copia o valor entre eles, por @transfer (o objeto viaja com seu mm) ou cópia, e nunca aliasa um ponteiro cru (o heap do remetente não é visível no destino, então um *T apontaria pro nada). É barato porque é a mesma máquina: a forma em memória basta, sem traduzir pra bytes. Pela rede, nem isso: o endereço de um ponteiro não significa nada do outro lado e a representação em memória não cruza máquinas, então T tem que virar bytes. Isso não é propriedade do channel; é do que você manda. Então a constraint recai sobre T: as operações de rede exigem T + Serializable (uma interface da stdlib, encoding; ver seção 9 para como interfaces funcionam). Você não consegue mandar algo não-serializável pela rede, porque a assinatura da operação não aceita um T que não satisfaz Serializable: não compila. A rede se auto-seleciona pela constraint; nenhum tipo novo nem decorator marca “isto é rede”, porque o tipo do payload já diz tudo.

2. Estabelecer e manter pode falhar. Um channel local quase sempre sucede (o outro processo está no mesmo runtime). Pela rede, o outro lado pode não estar lá (caiu, fechou, latência), então estabelecer a conexão retorna Result, e o channel obtido herda o timeout obrigatório do sync (acima) pra cada operação. A falha não vive num tipo especial de channel; vive na operação que o cria (um Result/catch) e no timeout que o channel já tem.

As operações moram no pacote net da stdlib:

use net
// servidor: escuta, e cada conexão aceita vira um Channel[T]
listener := net.listen("0.0.0.0:8080") catch |e| { return } // pode falhar (porta ocupada)
loop {
chan := net.accept[Message](listener) catch |e| { continue } // Message implementa Serializable; pode falhar
spawn handle(chan) // trata cada cliente como processo
}
// cliente: conecta, obtém um Channel[T]
chan := net.connect[Message]("api.exemplo:8080") catch |e| { return } // pode falhar (rede)
chan <- request // MESMA API de channel
reply := -> chan timeout(5s) // timeout já obrigatório

net.connect[T] / net.accept[T] exigem T + Serializable e retornam Result (o catch). O que você recebe é um Channel[Message] comum: send, recv, match/select, catch, tudo idêntico ao resto da seção. A rede não adicionou um modelo de comunicação; reusou o channel inteiro e deixou a física (serialização, falha) se expressar pela constraint e pelo Result que já existem. Programar contra um channel de rede é programar contra um channel; o tipo só te obriga a respeitar o que um channel local deixava você ignorar.

E é de propósito mais geral que UI: qualquer comunicação entre runtimes pela rede (microserviços, workers remotos, servidorcliente) usa isso. A fronteira servidor/cliente da extensão de UI (seção 20) é o primeiro consumidor, mas não é um conceito de UI; é concorrência estendida à rede.