TL;DR: um novo benchmark de Misha Strebkov comparou seis formas de implementar cache concorrente em Go: map sem lock, sync.Mutex, sync.RWMutex, sync.Map, sharding com vários locks e copy-on-write com atomic.Pointer. O resultado não é “sempre use X”, mas o recado prático é forte: para caches locais com leitura e escrita misturadas, dividir o mapa em shards com locks independentes entregou o melhor equilíbrio. sync.Map foi competitivo em cenários específicos, copy-on-write brilhou em leitura pura, e um Mutex global mostrou rapidamente o limite de contenção.
O que foi medido
O artigo “Shard your locks: benchmarking 6 Go cache designs” parte de um problema comum em serviços Go: um cache local precisa ser acessado por várias goroutines, mas map puro não é seguro para escrita concorrente. A pergunta então vira: qual sincronização custa menos sem complicar demais o código?
Foram comparadas seis estratégias:
naive:mapcomum sem lock, apenas como linha de base, não seguro para goroutines.mutex: umsync.Mutexprotegendo o mapa inteiro.rwmutex: umsync.RWMutexglobal, com leituras paralelas e escrita exclusiva.syncmap:sync.Mapda biblioteca padrão.sharded: 256 mapas menores, cada um com seu próprio lock.cow: copy-on-write comatomic.Pointer, onde leituras não bloqueiam e escritas copiam o mapa.
A metodologia usou testing.B, b.RunParallel e benchstat, com espaço de 1 milhão de chaves, GOMAXPROCS variando de 1 a 8 e medianas de 10 execuções. O autor também separou distribuições uniformes e Zipfian, porque cache real raramente recebe acesso perfeitamente distribuído: algumas chaves costumam ser muito mais quentes que outras.
Por que um Mutex global trava cedo
O sync.Mutex global é a implementação mais óbvia e, em muitos sistemas, ainda é boa o bastante. Ele tem uma vantagem importante: é simples de revisar. Se o cache tem baixa contenção, poucas goroutines ou fica fora do caminho crítico, a simplicidade pode vencer.
O benchmark mostra o preço quando o cache vira hot path. Em carga uniforme com 8 cores, o mutex ficou em torno de 168 ns/op em leitura pura, 190 ns/op em carga balanceada e 208 ns/op em carga com muita escrita. O problema não é só o custo do lock; é transformar todas as operações em uma fila única. Mesmo leituras independentes precisam esperar.
sync.RWMutex parece a correção natural, mas o resultado também depende da carga. Ele melhora o caso de leitura pura, mas perde força quando entram escritas. No teste uniforme com 8 cores, leitura pura caiu para 53 ns/op, mas a carga balanceada ficou em 282 ns/op. Leituras paralelas ajudam, só que uma escrita ainda precisa bloquear o conjunto inteiro.
Sharding reduz contenção sem mudar o modelo mental
A estratégia vencedora geral foi dividir o cache em vários shards. Em vez de um mapa gigante com um lock, você tem vários mapas pequenos; a chave decide qual shard será usado. Duas goroutines só competem se caírem no mesmo shard.
Um esboço reduzido fica assim:
type shard struct {
mu sync.Mutex
m map[string]string
}
type Cache struct {
shards [256]shard
}
func (c *Cache) Get(key string) (string, bool) {
s := &c.shards[hash(key)&255]
s.mu.Lock()
v, ok := s.m[key]
s.mu.Unlock()
return v, ok
}
func (c *Cache) Set(key, value string) {
s := &c.shards[hash(key)&255]
s.mu.Lock()
s.m[key] = value
s.mu.Unlock()
}
A versão completa do autor inclui detalhes que importam no caminho quente, como Delete, Len, padding para linha de cache e escolha de hash. O benchmark também nota que evitar defer no unlock melhorou um microcaso em cerca de 8%. Esse tipo de ajuste não deve ser aplicado cegamente em código comum, mas faz sentido em bibliotecas ou caches medidos como gargalo real.
Nos números de 8 cores com distribuição uniforme, o sharded cache ficou em 21 ns/op em leitura pura, 22 ns/op em carga read-heavy, 24 ns/op em carga balanceada e 25 ns/op em write-heavy. É uma diferença grande contra o Mutex global porque a contenção deixa de ser centralizada.
Onde sync.Map e copy-on-write entram
sync.Map não é “map concorrente genérico para qualquer cache”. A própria documentação recomenda uso em padrões específicos, como entradas gravadas uma vez e lidas muitas vezes, ou conjuntos de chaves separados por goroutine. No benchmark, ele escalou bem em leitura, mas pagou overhead e ficou atrás do sharding em cargas mistas: 57 ns/op em carga balanceada uniforme, contra 24 ns/op no sharded.
Copy-on-write teve o resultado mais extremo. Em leitura pura, venceu: 11,5 ns/op com distribuição uniforme e 7 ns/op com Zipfian, porque ler via ponteiro atômico evita lock. Mas qualquer escrita fica caríssima, pois exige copiar o mapa. No cenário write-heavy uniforme, o custo chegou a 82,5 ms por operação. É uma boa ideia para snapshots de configuração quase imutáveis; é uma péssima ideia para cache com atualização frequente.
O que levar para produção
A lição prática é medir a carga do seu cache antes de escolher a abstração. Se a estrutura é pequena e pouco disputada, sync.Mutex mantém o código direto. Se o tráfego é praticamente só leitura e as atualizações são raras, copy-on-write pode ser excelente. Se o padrão é cache local com leitura e escrita contínuas em várias goroutines, sharding é um candidato forte.
Também vale lembrar que o benchmark é local, sem HTTP, JSON, banco ou rede. Em uma API real, esses custos podem esconder diferenças de dezenas de nanossegundos. Por outro lado, em caches usados dentro de roteadores, autorização, deduplicação, rate limiters ou pipelines de alta frequência, contenção de lock aparece rápido em pprof.
Para times Go, o melhor uso do artigo não é copiar os números absolutos. É copiar o método: isolar a estrutura, testar distribuições realistas, variar GOMAXPROCS, comparar com benchstat e confirmar no perfil da aplicação. Concorrência em Go é simples de escrever, mas desempenho concorrente ainda exige observar onde as goroutines realmente brigam.