Destrinchando o pacote sync do Go
Na minha opinião, Go fornece um excelente suporte a se trabalhar concorrentemente não só pelas goroutines, mas pelo ecossistema da linguagem. Um grande exemplo disso é o pacote sync, que auxilia na sincronização das rotinas concorrentes. Neste post, vamos aprofundar em tudo que este pacote pode nos oferecer.
Waitgroups
Waitgroups são utilizados para coordenar a execução de diversas rotinas. Ele facilita a criação e a garantia de que todas as sub-rotinas serão finalizadas antes de finalizar a rotina principal. No post sobre waitgroups explico melhor como elas funcionam e o que mudou com a versão 1.25 do Go.
Mutex
Mutex é o mesmo que mutual exclusion locker, ou em português, tranca de exclusão mútua. A sua função é travar o acesso a um recurso enquanto uma operação é executada, evitando que outras rotinas tentem escrever nesse recurso ao mesmo tempo. Por exemplo, qual o retorno da função a seguir?
| |
Se a resposta foi 1000, existe alguma chance de você ter acertado, mas é bem improvável. Isso acontece, pois como as rotinas são executadas concorrentemente, elas podem tentar escrever no recurso ao mesmo tempo. Para garantir que isso não aconteça, basta adicionar uma mutex e travar o acesso àquele recurso.
| |
O uso é bem simples, para travar o acesso a um registro você usa a função Lock e, ao finalizar, é só utilizar o Unlock. Só é necessária atenção para não cair em deadlock. Também existe a função TryLock, que valida se existe uma trava ativa ou não, porém seu caso de uso é mais raro.
RW Mutex
O RW Mutex é uma evolução da mutex onde existem travas específicas para escrita e leitura. Essa distinção é bastante útil quando uma ou mais rotinas precisam acessar um recurso só para leitura, mas não querem que o objeto seja modificado durante sua execução. Contudo, é importante citar que a escrita tem mais prioridade que a leitura e, dessa forma, Go evita o starvation.
| |
No exemplo acima, podemos ter diversas rotinas chamando o avg para pegar a média da lista de inteiros, contudo, se uma rotina decidir inserir mais um valor, todos vão ter que esperar essa escrita finalizar.
Atomic
O atomic é um subpacote do pacote sync que implementa suporte à concorrência em tipos primitivos. Atualmente, ele suporta os seguintes tipos: bool, int32, int64, pointer, uint32, uint64, uintpointer e value. Com ele, podemos simplificar o exemplo utilizado na mutex:
| |
É necessário notar que as operações básicas, como adição, foram reimplementadas para garantir que as rotinas não concorram pelo recurso.
Map
O Map é como qualquer outro map normal. Ele fornece funções para comparar, trocar, atribuir ou recuperar os valores, com a diferença de ser seguro para a concorrência.
| |
A própria documentação sugere que ele deve ser utilizado em dois casos:
- Quando uma chave é escrita somente uma vez, mas lida diversas vezes. Um exemplo é um cache que só cresce.
- Quando múltiplas goroutines leem e escrevem grupos distintos de chaves.
Qualquer outro caso é melhor utilizar o map tradicional com mutexes.
Once
O tipo Once garante que algo será executado uma única vez, mesmo que diversas rotinas tentem executar. Um exemplo disso poderia ser a inicialização de recursos, como é demonstrado pela documentação do Go.
| |
É importante se atentar que, caso a função entre em pânico, ela não será reexecutada.
Cond
Como o próprio nome diz, o Cond funciona a partir de uma condicional, ou seja, quando algo acontece, ele libera a execução de uma rotina. Essa execução pode ser liberada uma a uma utilizando a função signal ou ativando todas de uma só vez com o broadcast.
| |
Primeiro, iniciamos um cond com algum Locker, uma interface que implementa as funções Lock e Unlock, no exemplo, utilizamos um mutex. Ao inicializar cada goroutine, é necessário garantir o lock e então a colocamos em estado de espera com o Wait, que retira o lock, permitindo que as novas rotinas sejam iniciadas. Quando uma rotina é liberada com o Signal ou Broadcast, o Wait pega o Lock novamente e libera a execução do código. A documentação do Go recomenda que o Wait aconteça dentro de um laço esperando uma condição, pois o Cond sozinho não consegue dizer se algo aconteceu ou não, mas isso não é estritamente necessário. O fluxo geral então é:
goroutine garante o lock → wait libera o lock → wait aguarda um sinal → wait recebe um sinal → wait garante novo lock → wait libera a execução → goroutine realiza o trabalho → goroutine libera o lock
Pool
O Pool fornece uma maneira de se lidar com objetos de curta duração na memória. Isso ajuda a aliviar a pressão no GC, pois o espaço de memória é sempre reutilizado. A documentação oficial cita como exemplo o pacote fmt, que usa pools como buffers temporários de saída que ajustam seu tamanho conforme a necessidade.
| |
Para inicializar uma Pool precisamos definir a sua função de inicialização. Ao utilizar o Get recuperamos o que está salvo na memória e com o Put escrevemos um novo valor nela. O New só é utilizado se não existir nada alocado na memória.
Conclusão
O pacote sync fornece diversas funcionalidades que são extremamente úteis ao se trabalhar com múltiplas goroutines. É possível controlar a execução com os tipos Cond e Once. Waitgroups garantem que tudo será executado. Mutex, RWMutex e os tipos atômicos evitam a concorrência pelos recursos. Por fim, o Pool alivia o trabalho do GC quando é possível trabalhar com objetos de curta duração na memória. Sem sombra de dúvidas, esse pacote é crucial para quem trabalha com goroutines. Caso você queira entender os detalhes de implementação deste pacote, recomendo a palestra apresentada na Gophercon UK 2025, Deep dive into the sync package, apresentada pelo Jesus Hawthorn. E também tem uma apresentação que fiz na Golang SP sobre o tema. Diga nos comentários se já utilizou este pacote e se de alguma forma ele te ajudou. Se ainda não utilizou, comente o que achou do post.
