Dançando com os Mutex de Go

Nível de Leitor: Intermediário – este artigo assume que você tem alguma familiaridade básica com o modelo de Go e de sua concorrência e está familiarizado, pelo menos, com a sincronização de dados na forma de bloqueio e comunicação de canal.

Nota do leitor : Um amigo meu inspirou esta publicação. Como eu o ajudei a solucionar algumas corridas de dados e tentou o meu melhor para dar-lhe algum conselho decente em torno da arte da sincronização de dados, percebi que esse conselho poderia beneficiar os outros. Você deve encontrar-se herdando uma base de código onde certas decisões de design já foram feitas ou se você quer entender as primitivas mais tradicionais de sincronização de Go do que este artigo.

Quando comecei a trabalhar com a linguagem de programação Go, imediatamente comprei no slogan de Go: ” Não se comunique compartilhando memória; Compartilhe memória ao se comunicar. “Para mim, isso significava escrever todos os códigos baseados em concorrentes de maneira” adequada “, sempre sempre usando canais. Penso que, se você alavancar os canais, você tem certeza de evitar as armadilhas de contenção, bloqueio, deadlocks, etc.

À medida que eu progredi com Go, aprendendo a escrever idiomática Go e aprendendo sobre as melhores práticas; Eu tropeçaria em bases de código bastante grandes, onde muitas vezes você encontraria pessoas usando a primitiva de sincronização / sincronização de Go, sincronização / atômica , bem como algumas outras primitivas de sincronização “de nível inferior” e talvez “antigas”. Meu primeiro pensamento foi, bem, eles estão fazendo isso de forma errada e eles claramente não assistiram nenhuma das conversas de Rob Pike sobre os méritos da concorrência em canais, onde muitas vezes ele faz referência à influência do design de Communicating Sequential Processes por Tony Hoare.

A realidade era dura. A comunidade Go recita o slogan mais e mais, mas espreitando vários projetos de código aberto, os mutexes abundam e abundam . Eu lutei com esse enigma por algum tempo, mas, em última instância, vi a luz como era hora de deixar as mãos sujas e pressionar os canais para uma mudança. Agora vamos avançar até 2015, onde eu já escrevi Go por cerca de 2,5 anos, e desde que tive uma epifania ou duas em relação às abordagens de sincronização baseadas em mais tradicionais, como o bloqueio baseado em mutex. Vá em frente, me pergunte novamente agora em 2015? Hey @deckarep, você ainda apenas escreve aplicativos concorrentes usando apenas canais? Hoje eu respondo não, e aqui está o motivo.

Primeiro, não esqueçamos a importância de ser pragmático. Quando se trata de proteger o estado compartilhado com bloqueio tradicional ou sincronização baseada em canal; Vamos começar com a seguinte pergunta: “Então, qual abordagem você deve usar”? E verifica-se que há uma pequena escrita agradável que resume a resposta bem :

Use o que for mais expressivo e / ou mais simples.

Um erro comum do go newbie é o uso excessivo de canais e goroutines apenas porque é possível, e / ou porque é divertido. Não tenha medo de usar Sync.Mutex se isso melhor se adequar ao seu problema. Go é pragmático, permitindo que você use as ferramentas que melhorem seu problema e não forçam você a um estilo de código.

Observe as palavras-chave nesse exemplo: expressivo, simples, excesso de uso, medo, pragmático. Posso admitir algumas coisas aqui: tive medo quando peguei pela primeira vez o Go. Eu era um recém-chegado ao idioma, e eu precisava passar o tempo com o idioma antes de tirar conclusões tão rapidamente. Você também tirará as suas próprias conclusões em referência ao artigo acima, e, à medida que exploramos algumas práticas recomendadas usando o bloqueio baseado em mutex e o que devemos ter em conta. O artigo referenciado acima também tem algumas boas orientações em relação ao mutex versus canais.

Quando usar Canais: passando propriedade de dados, distribuindo unidades de trabalho e comunicando resultados assíncronos

Quando usar Mutexes: caches, indique

Em última análise, cada aplicação é diferente e pode demorar algumas experiências e falsas iniciações. Para mim, eu sigo as diretrizes acima, mas deixe-me elaborar sobre elas. Quando você precisa proteger o acesso a uma estrutura de dados bastante simples, como uma fatia ou um mapa, ou mesmo algo personalizado, e se a interface com a referida estrutura de dados for direta, comece com um mutex. Além disso, sempre ajuda a encapsular os detalhes sujos do bloqueio dentro da sua API. Os usuários finais da sua estrutura de dados não precisam se preocupar com a forma como sua estrutura faz sua sincronização interna.

Se a sua sincronização baseada em mutex começa a se tornar difícil de controlar e você está jogando a dança mutex , é hora de passar para uma estratégia diferente. Novamente, reconheça que os mutex são úteis e diretos para cenários simples para proteger o estado minimamente compartilhado. Use-os para o que são, mas respeite-os e não os deixe sair de controle . Retire o controle da lógica do seu aplicativo e, se você estiver lutando com mutexes, considere repensar seu design. Talvez se mudar para os canais melhor se adequar à sua lógica de aplicação, ou mesmo melhor, não compartilhe o período do estado .

Threading não é difícil de bloquear é difícil.

Compreenda que não estou defendendo o uso de mutex sobre os canais. Estou simplesmente dizendo que se familiarizam com ambos os métodos de sincronização e, se você achar que sua solução baseada em canais parece ser excessivamente complicada, você também tem outra opção. Os tópicos neste artigo estão aqui para ajudá-lo a escrever um código melhor, mais sustentável e robusto. Nós, como engenheiros, temos que ser conscientes de como lidamos com o estado compartilhado e evitar corridas de dados em aplicativos multi-threaded . Go torna incrivelmente fácil produzir aplicativos simultâneos e / ou paralelos de alto desempenho, mas as armadilhas estão lá e é preciso ter cuidado para criar uma aplicação correta. Vamos entrar nos detalhes então:

Item 1 : Ao declarar uma estrutura onde o mutex deve proteger o acesso a um ou mais campos, coloque o mutex acima dos campos que ele irá proteger como prática recomendada. Aqui está um exemplo desse idioma dentro do próprio código fonte de Go . Tenha em mente que isso é puramente convencional e não afeta a lógica do seu aplicativo.

 var sum struct { 
 Sync.Mutex // <- este mutex protege 
 i int // <- este inteiro abaixo 
 }

Item 2 : segure um bloqueio mutex somente durante o tempo necessário. Exemplo: Se você pode evitá-lo, não segure um mutex durante uma chamada baseada em IO. Em vez disso, assegure-se de proteger apenas o seu recurso apenas durante o tempo necessário. Se você fez algo assim em um manipulador da web, por exemplo, você negará efetivamente os efeitos da concorrência ao serializar o acesso ao manipulador.

 // No código abaixo assume que `mu` existe exclusivamente 
 // para proteger o acesso à variável de cache 
 // NOTA: Manuseio de erros de desculpa por brevidade
 // Não faça o seguinte se puder evitá-lo 
 func doSomething () { 
 mu.Lock () 
 item: = cache ["myKey"] 
 http.Get () // Algum io caro 
 mu.Unlock () 
 }
 // Em vez disso, faça o seguinte sempre que possível 
 func doSomething () { 
 mu.Lock () 
 item: = cache ["myKey"] 
 mu.Unlock () 
 http.Get () // Isso pode levar algum tempo e está tudo bem! 
 }

Item 3 : Utilize adiar para Desbloquear o seu mutex onde uma determinada função possui vários locais que pode retornar. Isso significa menos contabilidade para você e pode mitigar os impasses quando alguém chega há 3 meses e adiciona um novo caso para retornar cedo.

 func doSomething () { 
 mu.Lock () 
 adiar mu.Unlock ()
 err: = ... 
 se errar! = nil { 
 // erro de registro 
 retorno // <- seu desbloqueio acontecerá aqui 
 }
 err = ... 
 se errar! = nil { 
 // erro de registro 
 retorno // <- ou aqui aqui 
 }
 retorno // <- e, claro, aqui 
 }

No entanto, fique atento a apenas willy-nilly confiando em desafiadores em todos os casos. O código a seguir é uma armadilha que pode acontecer quando você pensa que os desfragmentadores são limpos no escopo do bloco em vez do escopo da função.

 func doSomething () { 
 para { 
 mu.Lock () 
 adiar mu.Unlock () 

 // algum código interessante 
 // <- o diferimento não é executado aqui como um * pode * pensar 
 } 
 // <- é executado aqui quando a função sai 
 }
 // Portanto, o código acima será Deadlock!

Por último, considere não usar a declaração de diferimento quando você possui funções extremamente simples que não possuem múltiplos caminhos de retorno para espremer um pouco de desempenho. As declarações diferidas têm um ligeiro custo indireto que é muitas vezes negligenciável. Considere isso como uma otimização muito prematura e principalmente desnecessária .

Item 4 : bloqueio de grão fino pode levar a um melhor desempenho ao custo de uma contabilidade mais complicada, enquanto o bloqueio de cursos pode ser menos performante, ainda que seja mais fácil de contabilizar. Mais uma vez, seja pragmático no seu design. Se você se vê jogando a “dança mutex”, pode ser hora de refatorar seu código ou passar para a sincronização baseada em canal.

Item 5: Como mencionado anteriormente nesta publicação, sempre é bom se você pode ocultar ou encapsular o método de sincronização usado. Os usuários do seu pacote não precisam se preocupar com as complexidades de como seu estado compartilhado é protegido.

No exemplo abaixo, consideremos o caso em que fornecemos uma chamada de método get (), que só puxará do cache se houver pelo menos um ou mais itens no cache. Bem, uma vez que precisamos pegar um bloqueio para retirar o item do cache e obter os caches também contam – este código irá bloquear .

 pacote principal
 importação ( 
 "Fmt" 
 "sincronizar" 
 )
 digite DataStore struct { 
 sync.Mutex // ? este mutex protege o cache abaixo 
 Cadeia de caracteres [string] do mapa de cache 
 }
 func Novo () * DataStore { 
 return & DataStore { 
 cache: make (mapa [string] string), 
 } 
 }
 Func (ds * DataStore) set (string de chave, string de valor) { 
 ds.Lock () 
 adiar ds.Unlock ()
 ds.cache [key] = value 
 }
 func (ds * DataStore) get (string de chave) string { 
 ds.Lock () 
 adiar ds.Unlock ()
 se ds.count ()> 0 {<- count () também pegar um bloqueio! 
 item: = ds.cache [key] 
 devolver item 
 } 
 Retorna "" 
 }
 func (ds * DataStore) count () int { 
 ds.Lock () 
 adiar ds.Unlock () 
 retornar len (ds.cache) 
 }
 func main () { 
 / * Executando isso abaixo ficará bloqueado porque o método get () fará um bloqueio e chamará o método count () que também fará um bloqueio antes do método set () destravar () 
 * /
 loja: = Novo () 
 store.set ("Go", "Lang") 
 resultado: = store.get ("Go") 
 fmt.Println (resultado) 
 }

Um padrão sugerido para lidar com o fato de que os bloqueios de Go não são reincidentes é o seguinte:

 pacote principal
 importação ( 
 "Fmt" 
 "sincronizar" 
 )
 digite DataStore struct { 
 sync.Mutex // ? este mutex protege o cache abaixo 
 Cadeia de caracteres [string] do mapa de cache 
 }
 func Novo () * DataStore { 
 return & DataStore { 
 cache: make (mapa [string] string), 
 } 
 }
 Func (ds * DataStore) set (string de chave, string de valor) { 
 ds.cache [key] = value 
 }
 func (ds * DataStore) get (string de chave) string { 
 se ds.count ()> 0 { 
 item: = ds.cache [key] 
 devolver item 
 } 
 Retorna "" 
 }
 func (ds * DataStore) count () int { 
 retornar len (ds.cache) 
 }
 func (ds * DataStore) Set (seqüência de caracteres, cadeia de valores) { 
 ds.Lock () 
 adiar ds.Unlock ()
 ds.set (chave, valor) 
 }
 func (ds * DataStore) Obter (cadeia de caracteres) string { 
 ds.Lock () 
 adiar ds.Unlock ()
 return ds.get (chave) 
 }
 func (ds * DataStore) Count () int { 
 ds.Lock () 
 adiar ds.Unlock () 
 retornar ds.count () 
 }
 func main () { 
 loja: = Novo () 
 store.Set ("Go", "Lang") 
 resultado: = store.Get ("Go") 
 fmt.Println (resultado) 
 }

Observe no código acima que existe um método exportado correspondente para cada método não exportado. Os métodos exportados que operam no nível API público cuidarão de bloqueio e desbloqueio. Em seguida, encaminham-se para os respectivos métodos não exportados que não tomam nenhuma fechadura. Isso significa que todas as invocações exportadas do seu código só serão bloqueadas uma vez para evitar o problema reentrante.

Item 6: Em todos os exemplos acima, utilizamos o bloqueio Sync.Mutex básico que pode simplesmente: Bloquear () e Desbloquear () apenas. O bloqueio Sync.Mutex fornece a mesma garantia de exclusão mútua, independentemente de o goroutine estar lendo ou gravando dados. Existe também o Sync.RWMutex que oferece um pouco mais de controle com a semântica do bloqueio durante os cenários de leitura. Quando você deseja usar o RWMutex sobre o Mutex padrão?

Resposta: use o RWMutex quando você pode garantir absolutamente que seu código dentro de sua seção crítica não mata o estado compartilhado.

 // Eu posso usar com segurança um RLock () para contar, ele não mata 
 func count () { 
 rw.RLock () // <- observe o R no RLock (read-lock) 
 adiar rw.RUnlock () <- observe o R no RUnlock ()
 retornar len (sharedState) 
 }
 // Eu devo usar Lock () para set, ele mata o SharedState 
 conjunto de func (string de chave, string de valor) { 
 rw.Lock () // <- observe que nós tomamos um bloqueio 'regular' (write-lock) 
 adiar rw.Unlock () // <- notar que Unlock () não tem R nela
 sharedState [key] = value // <- muta o SharedState 
 }

No código acima, podemos assumir que a variável `sharedState` é algum tipo de objeto – pode ser um mapa, talvez, onde possamos questionar seu comprimento. Uma vez que a função’count () `acima respeita a regra de que nenhuma mutação está acontecendo na variável` sharedState`, isso significa que é seguro para um número arbitrário de leitores (goroutines) chamar esse método simultaneamente. Em certos cenários, isso pode reduzir o número de goroutines em um estado de bloqueio e potencialmente garante um ganho de desempenho em um cenário lido e pesado. Mas lembre-se, quando você tem um código que altera o estado compartilhado como no `set ()` você não deve usar um comando rw.RLock (), mas sim o comando rw.Lock ().

Item 7: Conheça o Bad-Ass do Go e o Detector de corrida incorporado . O detector de corrida encontrou dezenas de corridas de dados mesmo dentro da biblioteca padrão de Go. É por isso que o detector de corrida está lá e por que há algumas palestras e artigos que vão explicar esta ferramenta melhor do que eu posso.

  • Se você ainda não está executando testes de unidade / integração sob o detector de corrida como parte de seu sistema contínuo de compilação / entrega, configure-o agora.
  • Se você não tiver bons testes que exercem a concorrência de sua aplicação, o detector de corrida não o fará bem.
  • Não execute isso em produção, a menos que você realmente precise, ele vai custar-lhe uma penalidade de desempenho
  • Se o detector de corrida encontrou uma corrida de dados, é uma corrida de dados reais.
  • As condições da corrida ainda podem mostrar sincronização baseada em canal se você não tiver cuidado.
  • Todo o bloqueio no mundo não o salvará se algum goroutine lê ou escreva dados compartilhados que não estejam dentro de uma seção crítica.
  • Se a equipe do Go pode escrever corridas de dados sem saber, então você pode.

Em resumo, espero que este artigo ofereça alguns conselhos sólidos em relação aos mutex de Go. Jogue com as primitivas de sincronização de nível inferior de Go, faça seus erros, respeite e compreenda as ferramentas. Acima de tudo, seja pragmático no seu desenvolvimento e use a ferramenta certa para o trabalho. Não fique assustado como se eu fosse originalmente. Se eu sempre escutei todas as coisas negativas sobre programação e bloqueio multi-threaded, não estaria neste negócio hoje escrevendo sistemas distribuídos de kick-ass enquanto eu usava uma linguagem de kick-ass como Go.

Nota: Eu adoro comentários, se você achou isso útil, por favor, me faça ping, tweet ou me dê um feedback construtivo.

Cheers and Happy Coding!

@deckarep

 

Hacker Noon é como os hackers começam suas tardes. Somos uma parte da família @AMI . Agora estamos aceitando envios e estamos felizes em discutir oportunidades de propaganda e patrocínio .

Se você gostou desta história, recomendamos ler nossas últimas histórias de tecnologia e histórias de tecnologia de tendências . Até a próxima, não concorde com as realidades do mundo!

 

Texto original em inglês.