Conforme cogitado no artigo anterior sobre Go, acabei encarando a tarefa de reescrever em Go o projeto alarme-intelbras.
Não sem hesitar bastante. Antes, li o livro 100 Go Mistakes and How to Avoid Them, que tem uma abordagem didática peculiar e muito efetiva, e me deu coragem para atacar a tarefa supracitada.
O aforismo sobre Go é "fácil de aprender, difícil de dominar". De fato, reescrever a maior parte do código foi tranquila. A linguagem é ergonômica, o toolchain é excepcional, até escrevi testes em paralelo com o desenvolvimento, coisa que quase nunca faço na fase "criativa" do desenvolvimento. O lado difícil do Go é lidar com as questões de concorrência e paralelismo que vêm a reboque das goroutines.
Muita gente é cética com Go porque o modelo de concorrência e paralelismo do Go é de nível relativamente baixo, exige que o desenvolvedor tenha expertise nessa área, e isto é raro e caro. A verdade é que, para a maioria dos projetos, o modelo de concorrência baseado em async/await, sem expor o programador aos problemas oriundos do paralelismo, é menos difícil de aprender, e mais seguro.
Para quem não conhece Go, saiba que não existe um "GIL" como há no Python, que permite rodar diversas threads com relativa facilidade e segurança. Em Go, se duas goroutines manipularem os mesmos dados sem proteção, vai dar bolo. O toolchain Go tem recursos para detecção desse tipo de bug, mas o bug tem de acontecer para ser detectado, e bugs de paralelismo são "gatos de Heisenberg".
Como disse no artigo anterior, eu preferiria que as goroutines fossem mais parecidas com os "processos" ou "Actors" do Elixir. Mas se os próprios criadores do Go vendem-no como um "C melhorado", é preciso conformar-se com a natureza crua das goroutines. São ferramentas para criar outras ferramentas. Ademais, já existem bibliotecas Go que implementam Actors, é algo que pretendo revisitar no futuro.
Como já disse anteriormente, sou fã do "jeito Unix" de fazer as coisas. Uma única thread, nenhum problema de paralelismo. Até escrevi meu próprio event loop.
Mesmo sem paralelismo, não é trivial escrever um event loop, e de vez em quando pego um bugzinho no meu código. Quando o programa responde a um evento, pode fazer alguma coisa que afete outros eventos, talvez os próximos eventos não façam mais sentido.
Por exemplo, quando uma conexão fecha, todos os timeouts relacionados a ela devem ser cancelados. Mas um deles acabou de disparar. Como providenciar para esse evento não seja mais entregue, uma vez que agora ele é indesejado, ou mesmo inesperado? Se o event loop implementar uma máquina de estado, é uma camada adicional de conflitos, de decisões sobre o que deve acontecer antes ou depois do quê.
No processo de reescrita do projeto alarme-intelbras, procurei construir um framework, robusto o suficiente para ser reutilizado, e que não seja tão radicalmente diferente do "event loop" com que estou acostumado.
A bem da verdade, o protocolo da central de alarme é bem simples, não precisaria dessa camada de abstração, mas aí não teria graça, né?
Do ponto de vista do usuário, a vida é simples, e gira em torno de um canal de eventos. ("Usuário" aqui é no sentido de "usuário do framework", ou seja, o código-fonte de nível mais alto, com as regras de negócio.)
Para ilustrar, segue um recorte do tratador de conexão de rede:
for evt := range t.tcp.Events { switch evt.Name { case "Recv": ... t.parse() ... ... case "SendEof", "RecvEof", "Err": fmt.Println("Conexão terminada ", evt.Name) t.tcp.Close() } } fmt.Println("TratadorReceptorIP: fim")
A ideia é que todos os eventos relativos à conexão cheguem através de um único canal. Isso garante que, dentro do contexto de uma conexão, o usuário não tem de lidar com questões de paralelismo. Lida-se com um evento de cada vez, de forma serializada.
O mais provável é que o usuário dedique uma goroutine por fila de eventos, para que diversas conexões possam correr em paralelo. Mas, se for alérgico a paralelismo, poderia "enfeixar" todos os eventos de todas as conexões, e lidar com todas elas em uma única goroutine. (Mais sobre essa técnica depois.)
Se o usuário quiser usar goroutines extras no contexto de uma conexão... aí as questões de paralelismo que possam surgir dessa escolha são de responsabilidade dele.
Quando a conexão termina, todos os timeouts associados são cancelados, e o canal Events é fechado. Isso permite o uso do idioma for...range sem um break explícito.
Por outro lado, exigimos que o usuário feche explicitamente a conexão com Close(). É garantido que, depois dessa chamada, nada mais pode acontecer. O canal de eventos é drenado e fechado. (Por outro lado, enquanto Close() não for invocado, o canal de eventos pode continuar a ser usado, mesmo que a conexão já não exista no nível da rede.)
Fora os eventos de timeout, nomeados livremente, os eventos de rede são: Connected, Recv, Sent, RecvEof, SendEof, Err. O evento Sent permite manter o buffer de transmissão sempre alimentado. Em protocolos de baixa vazão, menos exigentes, como é o caso da central de alarme, o evento Sent pode ser ignorado.
O objeto^Wstruct TCPSession só possui três métodos públicos: Send() e Close() para rede, e Timeout() para criar um timeout atrelado à sessão.
O usuário não pode chamar Close() mais de uma vez, nem tentar chamar Send() depois da conexão fechada. Da forma que o canal de eventos funciona, acredito que isso não crie nenhuma dificuldade, desde que não haja goroutines em paralelo na jogada.
A struct TCPSession nunca é criada diretamente. O usuário cria a struct TCPClient para iniciar uma conexão (exemplo). Ou então cria-se um TCPServer para receber conexões. Este último tem seu próprio canal de eventos por onde chegam novas sessões (exemplo).
As structs Timeout podem ser criadas "soltas" com NewTimeout(), ou então através do método Timeout() presente em todas as structs de rede mencionadas no parágrafo anterior. A vantagem da segunda opção é que o timeout possui um "pai", e vai embora junto com ele.
Não ofereço a opção de Timeout chamar um callback. Ele sempre notifica através de um canal de eventos. Isto evita uma fonte inesperada de paralelismo. Se o usuário fizer questão de timeouts correndo em paralelo, pode criar um canal de eventos só para eles (e lidar com a complexidade adicional).
Para chegar nesse ponto, de ter um framework minimamente coerente, confortável de usar, e cuja implementação não pareça um espaguete, deu um certo trabalho. Fazer funcionar minimamente foi rápido, mas o polimento teve inúmeros capítulos.
Em vez de contar a história linearmente (como se eu fosse lembrar...) levantei uma lista de "pontos de dor" que me afetaram, e provavelmente vão afetar todo aquele que tentar escrever um serviço em Go.
Se você quiser usar a API padrão do Go, sim. Para comunicação assíncrona e bidirecional, é preciso criar duas goroutines por socket: uma para leitura, outra para gravação. Este é o jeito Go de fazer a coisa, e tem a vantagem não-negligenciável de funcionar em qualquer sistema operacional suportado pelo toolchain.
O código do objeto TCPSession é um exemplo razoavelmente didático. Conforme discutirei adiante, iniciar trocentas goroutines paralelas é fácil. O problema é fazê-las parar de forma ordeira.
Existe este módulo gnet que parece implementar comunicação de rede de alta performance, e mais ao estilo Unix, pode ser configurado inclusive para operar em modo single-threaded. (O módulo é escrito em puro Go. É uma vantagem da linguagem ser relativamente de baixo nível. A maioria das outras linguagens teria de implementar algo assim em C.)
Goroutines operando em paralelo e compartilhando recursos é uma fonte permanente de problemas.
O ideal seria passar, via parâmetros de função, apenas os recursos estritamente necessários para a goroutine executar sua tarefa, para não ter chance dela acessar um recurso compartilhado por acidente.
Num projeto crítico, talvez isso devesse ser uma diretiva de coding style. Ensaiei adotá-la no meu projetinho, no fim achei excessivo.
Alguns objetos (*) são preciosos, pois podem ser manipulados à vontade na presença de paralelismo. Idealmente, goroutines só deveriam lidar com esse tipo de objeto. Canais, bem como objetos sync, são exemplos óbvios, foram feitos para isso. Porém, há outros.
(*) Tá, eu sei que em Go não há objetos, apenas "structs com métodos", mas vou chamá-las de objetos, pronto.
Objetos net.Conn, que representam sockets e conexões de rede, também estão nesse grupo. (Nem poderia ser diferente, já que somos praticamente obrigados a usar duas goroutines para fazer comunicação full-duplex.)
O objeto Context, introduzido na versão 1.7 do Go, também é seguro e resolve muitos problemas de concorrência. Dizem que a komunidade estava pressionando para que houvesse um meio de "matar" goroutines, e os contextos canceláveis foram criados para aplacar o povo.
O ponto aqui não é fazer uma lista exaustiva, mas sim de encorajar a consultar a documentação para cada objeto que você é obrigado a compartilhar entre goroutines, e determinar se ele é seguro por si mesmo, ou vai ser necessário garantí-lo por meios externos.
Os objetos sync são as "armas nucleares" para resolver questões de paralelismo.
Pessoalmente, tenho uma birra com eles, não acho elegante usá-los no meu código.
Em tese, é possível se virar apenas com canais e contextos. É relativamente fácil usar um canal como semáforo, e um mutex é equivalente a um semáforo unitário. Pontualmente, fica até bonito, e evita importar sync por pouca coisa. Exemplo:
semaforo := make(chan struct{}, 3) // semáforo com 3 posições semaforo < struct{}{} semaforo < struct{}{} // adiciona 3 fichas semaforo < struct{}{}
A seção crítica retira uma ficha do semáforo no início, e devolve no final:
<-semaforo // bloqueia até haver uma ficha disponível ... ... semaforo <- struct{}{}
Mas, se o código está cheio de seções críticas, é hipócrita não usar sync pois os objetos "seguros" são internamente protegidos por mutexes. Além do que, usar objetos sync explicita melhor suas intenções: mutex onde cabe um mutex, WaitGroup onde cabe um wait group, etc.
Uma regra que li em algum lugar, e me deu paz de espírito, é a seguinte: objetos sync são para código de relativamente baixo nível. Desta forma, faço uso deles no meu framework quando necessário, mas não no código de nível mais alto, uma vez que o framework já oferece garantias suficientes.
Por exemplo, minha primeira implementação de Timeout, herdada de outro projeto, era baseada na ideia de "Actor", onde a goroutine do Timeout só se comunicava com o mundo exterior através de canais. Era elegante, não havia nenhum mutex, nem seção crítica. Mas a versão atual com mutexes ficou muito menor, economizou uma goroutine, e não é um código que vá ser revisitado toda hora.
É claro que, se é possível ressolver seu problema de concorrência com e.g. canais em vez de mutexes, de forma mais elegante e sem contorcionismos, melhor. Uma segunda opção é usar um objeto sync mais especializado, e.g. sync.Once em vez de variável booleana + mutex.
Mutexes e canais sem buffer têm uma coisa em comum: é fácil criar um deadlock com eles, e mais ainda quando os dois andam juntos. É um problema muito mais difícil de diagnosticar do que um panic(). Incorri em pelo menos um no desenvolvimento de Timeout, pelo seguinte método:
Uma solução seria usar um canal com buffer, mas o bug real era ter protegido todos os corpos de todos os métodos de Timeout com mutex, de forma indiscriminada. O envio do evento deve ficar de fora da proteção. Interação com o "mundo exterior" dentro de uma seção crítica é sempre suspeita e fonte de problemas, e deve ser olhada com microscópio.
Não uso nenhum objeto sync/atomic no meu código, porque simplesmente não precisei. Onde achei que poderiam ser úteis, não foram. A própria documentação diz que o código típico faz melhor em não usá-los.
Por exemplo, para que uma certa função bla() seja executada no máximo uma vez, mesmo sendo invocada por inúmeras goroutines, alguém poderia cogitar usar um flag atômico em vez de sync.Once:
flag := atomic.Bool ... func bla() { if !flag.Load() { flag.Store(true) ... ... algo que só deve ser feito uma vez... ... } }
O código acima parece correto, mas ainda existe a chance de duas goroutines entrarem ao mesmo tempo na seção crítica. É o clássico "race condition" que vai acontecer raramente, mas vai, geralmente no dia e hora mais inconveniente possível.
(Para não dizer que sync/atomic não serve para resolver nenhum problema de concorrência, uma solução válida para o problema acima seria usar atomic.CompareAndSwap, usando um inteiro fazendo as vezes de booleano.)
Uma dica direta do livro citado lá no começo: toda goroutine tem de ter um gatilho bem-definido para terminar.
As goroutines são objetos "especiais" em Go, pois não podem ser destruídas pelo coletor de lixo. (O mesmo acontece com threads em toda linguagem que conheço.) Uma goroutine "perdida no espaço" pode ficar rodando para sempre, consumindo memória e/ou CPU eternamente.
Em nosso framework, dentro da implementação de TCPSession, a goroutine recv() termina quando a conexão é fechada. Já a goroutine send() termina quando a conexão é fechada e/ou quando a fila de envio é fechada. Porque send() tem dois motivos para "dormir": esperar pela transmissão de dados, e esperar que o usuário lhe mande enviar dados.
Quando uma goroutine precisa esperar pelo término de outra, temos de usar algum objeto de sincronização, como um canal, ou um sync.WaitGroup, a depender se existem dados a transferir entre elas ou não.
Quase tão crítico quanto a questão do término da goroutine, é determinar quando a goroutine deve começar a trabalhar. Um exemplo simples, mas tropecei em casos análogos mais de uma vez:
func NewObjeto() *Objeto { t := new(Objeto) go t.Inicializar() return t } func (*Objeto) Inicializar() { ... processo demorado ... } func (*Objeto) FazAlgo() { ... }
A pergunta é a seguinte: o método FazAlgo() depende do término da goroutine Inicializar()? Se sim, temos uma race condition.
Existem diversas soluções. A mais direta e honesta é não usar uma goroutine; a inicialização bloqueia até terminar.
Outra opção é empurrar a bomba pra frente, forçando o usuário a chamar Inicializar(). Se ele quiser fazer isso numa goroutine para não ser bloqueado, que tome os devidos cuidados.
Opções mais "civilizadas" seriam: bloquear em FazAlgo() até a inicialização estar completa usando um canal ou WaitGroup; ou tornar FazAlgo() assíncrono jogando a respectiva tarefa numa fila, para ser realizada quando possível.
Como dito antes, meu frameworkzinho comunica ao usuário que uma sessão terminou através do fechamento do respectivo canal de eventos. Para o usuário, ficou simples e claro.
Do lado da implementação, foi a coisa mais difícil de acertar. Basta olhar o método TCPSession.Close(), que devo ter mexido umas vinte vezes até ficar correto e não parecer uma macarronada.
Como as goroutines de TX e RX e os timeouts entregam seus resultados através do canal de eventos, todos têm de estar devidamente cancelados antes de fechar-se o canal. Do contrário, o programa quebra.
Nesse meu framework, eu quebro um "mantra" do Go: quem envia dados pelo canal é quem pode fechá-lo. No meu caso, temos várias goroutines alimentando um mesmo canal de eventos, nenhuma claramente é mais "dona" do canal que as outras.
Poderia não fechar o canal, isso evitaria a quebra do programa. Mas só mudaria o problema de lugar. Uma goroutine que tenta alimentar um canal que não tem mais ouvintes acaba bloqueando para sempre, gastando memória. Pessoalmente, prefiro que o programa quebre, pois torna o bug mais óbvio.
Acredito que, para todo novato em Go, é uma surpresa descobrir que enviar dados para um canal fechado causa um panic(), e não há forma de testar se um canal está fechado para envio. Nem mesmo com select.
Mesmo que uma goroutine esteja bloqueada tentando enviar dados para um canal sem buffer, ou com buffer cheio, e entrementes o canal for fechado, o programa quebra. Daí vem o mantra "só quem envia pelo canal é quem pode fechar o canal", pois evita esse tipo de situação.
Supondo que você desobedeceu o mantra, como eu, os jeitos de lidar com isso são a) garantir que ninguém vá tentar usar o canal depois de fechado, ou b) usar uma variável qualquer para sinalizar a condição, ou até mesmo atribuir nil à variável de canal. No caso (b) você terá de usar um mutex 🤢 ou similar. Exemplo:
var canal chan Algo var mutex sync.Mutex def foo() { ... mutex.Lock() close(canal) canal = nil mutex.Unlock() ... } def bar() { ... mutex.Lock() if canal != nil { canal <- Algo{...} } mutex.Unlock() ... } def main() { go foo() go bar() }
Se removêssemos o mutex, o exemplo acima possuiria duas race condition clássicas. Em bar(), o canal poderia ser fechado justamente entre o teste e o uso. Em foo(), o canal fica por um breve período fechado antes de ser marcado como nulo, dobrando a chance do outro problema.
(Pessoalmente, sinto falta de um método para testar diretamente se um canal está fechado para envio. Mas acredito que o exemplo acima explique o porquê: seria muito fácil haver um race condition entre o teste e o envio, então os criadores do Go simplesmente preferiam não oferecer esse recurso para não induzir o programador a erro.)
Por outro lado, ler dados de um canal fechado não é "perigoso". Só é preciso tomar cuidado para não ficar bloqueado para sempre. Algumas formas de ler de um canal fechado:
close(canal) // bloqueia para sempre, provavelmente não é o que você quer valor := <- canal // idioma "comma ok" para testar se um canal foi fechado valor, ok := <- canal if !ok { // canal está fechado ... } // funciona mesmo com o canal fechado for valor := range canal { // nunca passa por aqui se canal já estiver fechado ... } // "comma ok" também funciona dentro de um select select { case valor, ok := <- canal: ... }
Por último, fechar um canal com buffer não drena as mensagens em trânsito (enviadas mas ainda não recebidas).
Go não implementa "canais de broadcast", em que a mesma mensagem é distribuída a diversos recipientes. A coisa mais parecida com isso que temos à disposição, é o fechamento de um canal. Se diversas goroutines estiverem bloqueadas na linha abaixo:
valor, ok := <- canal
e o canal for fechado, todas receberão um valor falso para ok, e isto pode ser usado para difundir uma condição qualquer.
Os contextos canceláveis do Go são uma abstração desse mecanismo. (E, sempre que puder, você deveria mesmo usar Context para sinalizar cancelamento, em vez de canais a seco.)
Um idioma do Go é usar canais chan struct{} para esse caso de uso, pois struct{} não contém informação nenhuma, o que indica que o canal é usado para sinalização pura, não para compartilhar dados.
Por padrão, os canais Go não possuem buffer. Neles, o envio de uma mensagem sempre bloqueia. Isso pode ser uma surpresa (para mim foi), pois a tendência é ver o canal como um meio de "enviar e esquecer", sem nunca bloquear.
Uma vez que a goroutine de recepção desbloqueia antes da goroutine de envio, isso dá a sensação de que a mensagem foi entregue antes de ser enviada. Por outro lado, se a goroutine de envio desbloqueou, isto significa que mensagem foi garantidamente entregue. Essa garantia é o "tchan" desse tipo de canal.
Por conta da ausência de buffer, é fácil acontecer da goroutine de envio virar a Bela Adormecida woke, sem príncipe à vista. Suponha o seguinte caso: a função A() envia uma mensagem que seria recebida por B(), porém B() seria executada pela mesma goroutine. Temos um deadlock.
(Aliás, se um dado não vai ser compartilhado entre goroutines concorrentes, não faz sentido usar um canal. De maneira geral, uma mesma goroutine usar um mesmo canal para enviar e receber é code smell.)
No geral, os canais sem buffer são mais simples e claros. Qualquer problema de concorrência será mais rapidamente identificado. Só se deve usar canais com buffer quando existe uma necessidade clara. Mesmo assim, pode ser útil usar canais sem buffer para depuração se possível, pois aumenta as chances de pegar qualquer problema de concorrência.
O canal sem buffer pode fazer papel semelhante a sync.WaitGroup, onde uma goroutine deve explicitamente bloquear até outra(s) terminar(em), com o bônus de poder enviar dados, coisa que o WaitGroup não faz.
Se o canal é utilizado para obter o estado de um objeto, via de regra esse canal deve ser sem buffer, para que a informação seja "fresca", produzida no momento em que for solicitada.
Como é de se esperar, os canais com buffer não bloqueiam no envio, desde que sua capacidade seja respeitada.
Eles podem funcionar como "caixas postais" e ferramentas de concorrência massiva, onde goroutines de envio e de recepção rodam em paralelo.
Na minha implementação inicial de TCPSession, o canal Events tinha de ter buffer pelo seguinte motivo: existe uma expectativa que a mesma goroutine que trata a fila de eventos vá chamar TCPSession.Close(). Porém, Close() bloqueia até que as goroutines send() e recv() terminem. Se por acaso uma dessas goroutines tiver um último evento para reportar, e a fila de eventos não tivesse buffer, as goroutines ficariam bloqueadas, e Close() nunca retornaria.
Desde então, o código de Close() foi aprimorado e não sofre mais de deadlock nesta situação. O canal tem de ter um buffer de pelo menos 1, mas por outras razões.
O objeto TCPSession também possui uma fila de envio to_send, que é outro canal com buffer, para que Send() nunca bloqueie (desde que o usuário respeite a capacidade da fila).
O canal to_send até poderia ser sem buffer, porém aí o usuário só poderia chamar Send() uma vez a cada loop do tratador de eventos para não correr risco de bloquear, e isto seria uma limitação meio draconiana. (Chamar Send() a partir de uma goroutine não é bem suportado em nosso framework, pois Close() fecha o canal to_send e a goroutine pendente poderia tentar usar o canal fechado.)
Ademais, se ambos os canais Events e to_send têm buffers, todas as goroutines envolvidas (transmissão, recepção, usuário) podem funcionar 100% em paralelo.
Conforme ilustrado antes, canais com buffer podem ser usados como semáforos, onde cada usuário retira e devolve uma "ficha" conforme entra e sai de uma seção crítica.
Em TCPClient, usamos um canal state com buffer de tamanho unitário, para que a goroutine que estabelece a conexão possa anotar o resultado sem bloquear, e caso o resultado ainda não seja conhecido Close() espera até ele aparecer.
Não é obrigatório "drenar" um canal com buffer. Se ele cair em desuso, o coletor de lixo se encarrega disso.
Em TCPSession, o método Close() drena o canal Events, porém isto é feito para a) evitar deadlocks durante o fechamento e b) garantir que o usuário não receba nenhum evento de uma sessão já fechada.
A goroutine send() também drena a fila de eventos to_send, mas apenas para garantir que Send() não bloqueie se a conexão estiver meio-fechada. Em nenhum caso estamos tentando nos antecipar ao coletor de lixo.
Já falamos antes do canal chan struct{}. Ele é chamado de "canal sem tipo" pois não transmite dados, apenas sinalização.
Claro, ninguém te impede de usar um chan int ou chan string para sinalização. mas usar um "canal sem tipo" economiza um pouquinho de recursos, e principalmente é um idioma Go que deixa claro qual é a utilidade do canal.
Diversos objetos da biblioteca Go fazem uso e/ou abstraem canais sem tipo, como por exemplo Timer e Context. O idioma de canal-como-semáforo também tende a fazer uso dele.
Um canal nil é um canal não inicializado. Também pode-se atribuir nil a uma variável de canal:
var ch chan int // ch == nil neste ponto ... ch = make(chan int) // ch != nil neste ponto ... ch = nil // ch == nil neste ponto
A semântica do canal nil é simples: enviar ou receber de um canal nil bloqueia para sempre. Isoladamente, ele não serve para muita coisa. A grande utilidade do canal nil é na combinação com select, pois essa cláusula ignora canais nil.
Isto permite excluir dinamicamente os canais já fechados, e dá origem ao seguinte idioma (explanado mais longamente aqui):
for a != nil || b != nil { select { case valor, ok := <-a: if !ok { // canal "a" fechado a = nil continue } ... case valor, ok := <-b: if !ok { // canal "b" fechado b = nil continue } ... } }
Note que um canal fechado e um canal nil são coisas diferentes! Um canal fechado nunca bloqueia na recepção; ele retorna uma sequência infinita de valores "zero" ou "nulo".
Por isso, é necessário implementar alguma variação da lógica exibida mais acima. Ao topar com um canal fechado, ele deve ser convertido para canal nil. Do contrário, select ficaria consumindo 100% de uma CPU, recebendo dados inúteis de um ou mais canais fechados.
Outro idioma Go é o "enfeixamento" de canais. É a situação onde há diversos canais heterogêneos, alimentados por diversas goroutines. Mas queremos receber tudo através de um único canal, uma mensagem por vez, de forma serial, sem se preocupar se algum subcanal já foi fechado, etc.
Será preciso dedicar uma goroutine extra para esse fim, provavelmente com uma cláusula select que lide com a situação onde um ou outro canal já esteja fechado, usando o idioma "select com canal nil" ilustrado no item anterior.
Numa versão inicial do nosso framework, o TCPSession tinha uma goroutine "principal", que recebia os eventos vindos das goroutines send() e recv(), a fim de serializar e coordenar os diversos eventos, para só então repassar ao usuário. Até mesmo as chamadas aos métodos Send() e Close() passavam primeiro por essa goroutine central, tendendo ao modelo "Actor".
Depois de inúmeras iterações, conseguimos eliminar a necessidade desse mecanismo, mas deu bastante trabalho resolver todos os problemas de concorrência e paralelismo. Para uma implementação "rápida e suja", fazer tudo passar por um único lugar ajuda a atingir uma implementação funcional sem muita dor-de-cabeça.
Por exemplo, mantendo esse mecanismo em TCPSession, seria muito mais fácil garantir que e.g. TCPSession.Send() pudesse ser chamado para uma sessão já fechada sem causar uma quebra, ou permitir que TCPSession.Close() fosse idempotente. Às vezes tenho ímpetos de reintroduzir esse mecanismo para simplificar outras partes do código.
O canal é o garoto-propaganda de concorrência e paralelismo em Go. Um canal pode ser manipulado à vontade por qualquer número de goroutines, noves fora o fechamento.
Se houver duas ou mais goroutines esperando mensagens de um mesmo canal, a linguagem Go não especifica quem vai recebê-las. Parece que a implementação atual distribui as mensagens segundo um rodízio. Mas não conte com isso! Do contrário, vai introduzir uma race condition no seu código, que mais cedo ou mais tarde vai incomodar.
Além de poder ouvir vários canais ao mesmo tempo (o que inclui canais Timer e Context), essa cláusula também permite enviar dados a canais de forma não-bloqueante.
Por exemplo, o Timeout do nosso projeto VMonitor é implementado usando o padrão "Actor", onde uma goroutine completamente isolada só troca dados através de canais:
loop: for { select { case cmd := <- timeout.control: if !timeout.handle_command(cmd) { break loop } case timeout.info <- TimeoutInfo{timeout.eta, timeout.alive}: continue } } }
O loop acima não gasta nenhuma CPU além do necessário. Ele só funciona quando há uma mensagem de controle para tratar, ou quando alguém solicitou uma mensagem de informação. (O canal de informação é sem buffer para garantir que será a mais atual possível.)
O código acima pode ter um bug. Você desconfia qual é? O código a seguir ilustra mais claramente:
var n atomic.Int32 func atualiz() { for { n.Store(n.Add(1)) time.Sleep(1 * time.Second) } } func gerador(ch chan int32) { for { select { case ch <- n.Load(): continue } } } func main() { ch := make(chan int32) go gerador(ch) go atualiz() for x := range ch { fmt.Printf("Recebido %d, val = %d\n", x, n.Load()) time.Sleep(10 * time.Second) } }
Há uma goroutine alimentando o canal, e outra incrementando "n" a cada segundo. Ao executar o código, acontece o seguinte:
$ go run teste.go Recebido 1, val = 1 Recebido 1, val = 10 Recebido 10, val = 20 Recebido 20, val = 30
No primeiro ciclo, tudo certo: o valor recebido é igual à variável global "val". No segundo ciclo em diante, começamos a receber valores desatualizados, de 10 segundos atrás.
O problema é que select avalia as expressões antes de aguardar pelo desbloqueio do canal. A expectativa era que select determinasse o valor de envio depois do canal ser liberado para envio, ainda mais em se tratando de um canal sem buffer.
Também pode acontecer que a avaliação das expressões tenha efeitos colaterais, que não desejamos que aconteçam antes do canal estar aberto, mas acontecem. Mais um exemplo aqui.
Encontrei algumas menções a esse problema, mas nenhum idioma claro sobre como resolvê-lo. Uma solução meio bruta é a seguinte:
func gerador(ch chan int32, req chan struct{}) { for { select { case <-req: case ch <- n.Load(): } } } func main() { ch := make(chan int32) req := make(chan struct{}) go gerador(ch, req) go atualiz() for { req <- struct{}{} x := <- ch fmt.Printf("Recebido %d, val = %d\n", x, n.Load()) time.Sleep(10 * time.Second) } }
A mensagem enviada para o canal req força select a executar outro ciclo, em que o valor n.Load() estará fresco. Se n.Load() fosse uma operação custosa ou com efeitos colaterais, poderia ser executada quando o sinal é recebido em req e armazenada numa variável temporária, para ser consumida no próximo ciclo.
O outro requisito é que req seja um canal sem buffer, assim temos a garantia que, quando a rotina principal enviar o sinal para req, e em seguida tentar sacar um valor de ch, temos absoluta certeza que a goroutine gerador() refrescou o valor a ser retornado.
Em nosso framework, não prezei muito pela "segurança" da API. O usuário não pode invocar TCPSession.Close() mais de uma vez, não pode invocar Send() em uma sessão já fechada, etc.
Escolhi fazer desse jeito pois deixa o código mais enxuto do lado do framework, e o único usuário, até onde vejo, serei eu mesmo. Garantir que Close() fosse idempotente ou que Send() possuísse uma fila de envio "infinita", é perfeitamente possível. Mas tudo isso custa questões adicionais de concorrência e paralelismo.
Ao criar um framework, logo aparece a questão das relações de pertencimento. Alguns objetos são acessórios de outros, só fazem sentido enquanto o objeto principal está ativo.
Já citei que timeouts costumam estar associados a uma conexão específica de rede, e é extremamente conveniente que eles sejam cancelados assim que a conexão é fechada. De forma análoga, nosso TCPServer é "dono" das conexões TCP que recebe, porque geralmente um servidor precisa manter algum tipo de controle ou contabilidade acerca das conexões em andamento.
Não há nada de difícil em fazer um objeto referenciar seu "pai" ou seus "filhos". O grande problema é na hora da destruição, pela grande possibilidade de race conditions e deadlocks que podem aparecer nessa hora.
Por exemplo: um filho notifica o pai que está sendo destruído, que em resposta resolve destruir outro filho. Só que esse outro filho também vai notificar o pai, pelo mesmo motivo do irmão. Se o método do pai que recebe essas notificações não for reentrante, ou estiver usando um mutex para proteger algum dado crítico (e.g. a lista dos filhos ativos), pode haver um deadlock.
Outra situação: o pai está sendo destruído, e no processo vai destruindo os filhos, mas entrementes um desses filhos foi destruído por outro motivo qualquer. Esse filho vai ser destruído duas vezes. Para resolver essa race condition, o respectivo método Filho.Close() ou Filho.Free() tem de ser idempotente e reentrante.
A situação acima pode evoluir diferente: o pai protege seus métodos com mutex, e o filho também. Haverá um deadlock entre o pai tentar destruir o filho e o filho tentar notificar o pai que já foi destruído. Os mutexes são necessários, mas têm de ser desbloqueados nas chamadas de callbacks para evitar deadlocks.
Em nosso framework, fiz a coisa bem simples, usando callbacks, apenas o suficiente para as duas relações que temos: TCPServer-TCPSession e TCPSession-Timeout.