Channels e comunicação entre processos
Tipo de channel
Seção intitulada “Tipo de channel”Channels são tipados e bidirecionais. A direção é anotada no tipo:
Channel[<-int, ->string] # envia int, recebe stringChannel[<-int] # send-onlyChannel[->string] # receive-onlyA 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.
Criação de channel
Seção intitulada “Criação de channel”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 64new() 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.
Contrato no spawn
Seção intitulada “Contrato no spawn”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}Operadores
Seção intitulada “Operadores”chan <- data // async send (fire and forget)data := -> chan catch |e| { return } // receive: bloqueia até chegar dado, ou até os remetentes sumiremresult := chan <-> data timeout(5s) // sync request-reply com timeout obrigatório catch |e| { ... } // Timeout | ProcessDownO <-> 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 timeoutresult := 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.
Multiplex (match sem sujeito)
Seção intitulada “Multiplex (match sem sujeito)”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.
Channels pela rede
Seção intitulada “Channels pela rede”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 channelreply := -> chan timeout(5s) // timeout já obrigatórionet.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.