Multi-threading sem bloqueio é para verdadeiros especialistas em threading

86

Eu estava lendo uma resposta que Jon Skeet deu a uma pergunta e nela ele mencionou o seguinte:

No que me diz respeito, o multi-threading sem bloqueio é para verdadeiros especialistas em threading, dos quais não sou um.

Não é a primeira vez que ouço isso, mas encontro muito poucas pessoas falando sobre como você realmente faz isso se estiver interessado em aprender como escrever código multi-threading sem bloqueio.

Portanto, minha pergunta é além de aprender tudo que puder sobre threading, etc, onde você começa a tentar aprender a escrever código multi-threading sem bloqueio e quais são alguns bons recursos.

Felicidades

vdhant
fonte
Eu uso as plataformas gcc, linux e X86 / X68. Livre de bloqueio não é tão difícil quanto parece! Os builtins atômicos do gcc têm barreiras de memória na inteligência, mas isso não importa na vida real. O que importa é que a memória seja modificada atomicamente. Quando você projeta estruturas de dados "livres de bloqueios", não importa quando outro encadeamento vê uma mudança. Listas com link único, listas de pular, tabelas de hash, listas gratuitas, etc. são todos muito fáceis de fazer sem bloqueio. O bloqueio gratuito não é para tudo. É apenas mais uma ferramenta certa para certas situações.
johnnycrash
2
1024cores.net
Mankarse
Votando para fechar como recomendação de recurso, ou não está claro o que você está pedindo.
Ciro Santilli 郝海东 冠状 病 六四 事件 法轮功

Respostas:

100

As implementações "livres de bloqueio" atuais seguem o mesmo padrão na maioria das vezes:

  • * leia algum estado e faça uma cópia dele **
  • * modificar cópia **
  • fazer uma operação interligada
  • tente novamente se falhar

(* opcional: depende da estrutura de dados / algoritmo)

O último bit é assustadoramente semelhante a um spinlock. Na verdade, é um spinlock básico . :)
Concordo com @nobugz sobre isso: o custo das operações intertravadas usadas no multi-threading sem bloqueio é dominado pelas tarefas de cache e coerência de memória que ele deve realizar .

O que você ganha, entretanto, com uma estrutura de dados "livre de bloqueio" é que seus "bloqueios" são muito refinados . Isso diminui a chance de que dois threads simultâneos acessem o mesmo "bloqueio" (local da memória).

O truque na maioria das vezes é que você não tem bloqueios dedicados - em vez disso, você trata, por exemplo, todos os elementos em uma matriz ou todos os nós em uma lista vinculada como um "bloqueio de rotação". Você lê, modifica e tenta atualizar se não houve atualização desde sua última leitura. Se houver, você tenta novamente.
Isso torna seu "bloqueio" (oh, desculpe, não bloqueio :) muito refinado, sem introduzir memória adicional ou requisitos de recursos.
Torná-lo mais refinado diminui a probabilidade de esperas. Torná-lo o mais refinado possível sem introduzir requisitos de recursos adicionais parece ótimo, não é?

A maior parte da diversão, entretanto, pode vir de garantir o pedido correto de carregamento / armazenamento .
Contrariamente às intuições de alguém, as CPUs são livres para reordenar leituras / gravações de memória - elas são muito inteligentes, a propósito: você terá dificuldade em observar isso a partir de um único thread. No entanto, você terá problemas quando começar a fazer multi-threading em vários núcleos. Suas intuições irão falhar: só porque uma instrução está no início do seu código, isso não significa que realmente acontecerá antes. CPUs podem processar instruções fora de ordem: e eles gostam especialmente de fazer isso com instruções com acessos à memória, para ocultar a latência da memória principal e fazer melhor uso de seu cache.

Agora, é certo contra a intuição que uma sequência de código não flui "de cima para baixo", ao invés disso, ela funciona como se não houvesse sequência alguma - e pode ser chamada de "playground do diabo". Acredito ser inviável dar uma resposta exata sobre quais reordenamentos de carga / loja ocorrerão. Em vez disso, sempre se fala em termos de mays e mights e latas e se preparar para o pior. "Oh, a CPU pode reordenar esta leitura para vir antes da gravação, então é melhor colocar uma barreira de memória aqui, neste local."

Questões são complicadas pelo fato de que mesmo esses mays e mights podem ser diferentes entre arquiteturas de CPU. Ele pode ser o caso, por exemplo, que algo que é garantido que não aconteceria em um arquitetura poderia acontecer em outro.


Para obter o multithread "livre de bloqueio" certo, você precisa entender os modelos de memória.
Conseguir o modelo de memória e as garantias corretos não é trivial, no entanto, como demonstrado por esta história, em que a Intel e a AMD fizeram algumas correções na documentação para MFENCEcausar confusão entre os desenvolvedores de JVM . No final das contas, a documentação na qual os desenvolvedores confiaram desde o início não era tão precisa em primeiro lugar.

Os bloqueios no .NET resultam em uma barreira de memória implícita, então você está seguro ao usá-los (na maioria das vezes, isto é ... veja por exemplo esta grandeza de Joe Duffy - Brad Abrams - Vance Morrison em inicialização preguiçosa, bloqueios, voláteis e memória barreiras. :) (Certifique-se de seguir os links dessa página.)

Como um bônus adicional, você será apresentado ao modelo de memória .NET em uma missão paralela . :)

Também há um "oldie but goldie" de Vance Morrison: O que todo desenvolvedor deve saber sobre aplicativos multithread .

... e claro, como @Eric mencionou, Joe Duffy é uma leitura definitiva sobre o assunto.

Um bom STM pode chegar o mais próximo possível de um bloqueio de baixa granularidade e provavelmente fornecerá um desempenho próximo ou equivalente a uma implementação feita à mão. Um deles é o STM.NET dos projetos DevLabs da MS.

Se você não é um fanático apenas por .NET, Doug Lea fez um ótimo trabalho na JSR-166 .
Cliff Click tem uma abordagem interessante sobre tabelas de hash que não dependem de lock-striping - como fazem as tabelas de hash simultâneas Java e .NET - e parecem escalar bem para 750 CPUs.

Se você não tem medo de se aventurar no território do Linux, o artigo a seguir fornece mais informações sobre os aspectos internos das arquiteturas de memória atuais e como o compartilhamento de linha de cache pode destruir o desempenho: O que todo programador deve saber sobre memória .

@Ben fez muitos comentários sobre o MPI: Concordo sinceramente que o MPI pode brilhar em algumas áreas. Uma solução baseada em MPI pode ser mais fácil de raciocinar, mais fácil de implementar e menos sujeita a erros do que uma implementação de bloqueio incompleta que tenta ser inteligente. (No entanto, é - subjetivamente - também verdadeiro para uma solução baseada em STM.) Eu também apostaria que é anos-luz mais fácil escrever corretamente um aplicativo distribuído decente em, por exemplo, Erlang, como muitos exemplos bem-sucedidos sugerem.

MPI, no entanto, tem seus próprios custos e seus próprios problemas quando está sendo executado em um sistema único com vários núcleos . Por exemplo, em Erlang, existem problemas a serem resolvidos em torno da sincronização da programação do processo e das filas de mensagens .
Além disso, em seu núcleo, os sistemas MPI geralmente implementam um tipo de programação N: M cooperativa para "processos leves". Isso, por exemplo, significa que há uma mudança de contexto inevitável entre processos leves. É verdade que não é uma "troca de contexto clássica", mas principalmente uma operação de espaço do usuário e pode ser feita rapidamente - no entanto, eu sinceramente duvido que possa ser trazida para os 20-200 ciclos que uma operação interligada leva . A troca de contexto do modo de usuário é certamente mais lentamesmo na biblioteca Intel McRT. A programação N: M com processos leves não é nova. Os LWPs estiveram lá em Solaris por um longo tempo. Eles foram abandonados. Havia fibras no NT. Eles são principalmente uma relíquia agora. Houve "ativações" no NetBSD. Eles foram abandonados. O Linux teve sua própria opinião sobre o assunto de segmentação N: M. Parece estar meio morto agora.
De vez em quando, surgem novos concorrentes: por exemplo, McRT da Intel ou, mais recentemente , Programação em modo de usuário junto com ConCRT da Microsoft.
No nível mais baixo, eles fazem o que um agendador N: M MPI faz. Erlang - ou qualquer sistema MPI - pode se beneficiar muito em sistemas SMP explorando o novo UMS .

Acho que a pergunta do OP não é sobre os méritos e argumentos subjetivos a favor / contra qualquer solução, mas se eu tivesse que responder a isso, acho que depende da tarefa: para construir estruturas de dados básicas de baixo nível e alto desempenho que rodam em um sistema único com muitos núcleos , técnicas low-lock / "lock-free" ou um STM produzirá os melhores resultados em termos de desempenho e provavelmente venceria uma solução MPI a qualquer momento em termos de desempenho, mesmo se as rugas acima forem corrigidas por exemplo, em Erlang.
Para construir qualquer coisa moderadamente mais complexa que seja executada em um único sistema, eu talvez escolheria o bloqueio de granulação grossa clássico ou, se o desempenho for uma grande preocupação, um STM.
Para construir um sistema distribuído, um sistema MPI provavelmente seria uma escolha natural.
Observe que também existem implementações MPI para .NET (embora pareçam não estar tão ativas).

Andras Vass
fonte
1
Embora essa resposta tenha muitas informações boas, a ideia principal de que algoritmos sem bloqueio e estruturas de dados são essencialmente apenas uma coleção de spinlocks de granulação muito fina está errada. Embora você geralmente veja loops de repetição em estruturas sem bloqueio, o comportamento é muito diferente: os bloqueios (incluindo spinlocks) adquirem exclusivamente alguns recursos e outros encadeamentos não podem progredir enquanto eles são mantidos. A "nova tentativa" nesse sentido é simplesmente aguardar que o recurso exclusivo seja liberado.
BeeOnRope 01 de
1
Algoritmos sem bloqueio, por outro lado, não usam CAS ou outras instruções atômicas para adquirir um recurso exclusivo, mas sim para completar alguma operação. Se eles falharem, é devido a uma corrida temporariamente refinada com outro thread e, nesse caso, o outro thread fez progresso (completou sua operação). Se um segmento for indefinidamente suspeito, todos os outros segmentos ainda podem progredir. Isso é qualitativamente e em termos de desempenho muito diferente dos bloqueios exclusivos. O número de "novas tentativas" geralmente é muito baixo para a maioria dos loops CAS, mesmo sob forte contenção ...
BeeOnRope 01 de
1
... mas é claro que isso não implica em um bom escalonamento: a contenção por um único local de memória sempre será bastante lenta em máquinas SMP, apenas devido às latências entre os soquetes entre núcleos, mesmo se o número de falhas CAS for baixo.
BeeOnRope 01 de
1
@AndrasVass - Eu acho que depende também do código livre de bloqueio "bom" versus "ruim". Certamente qualquer um pode escrever uma estrutura e chamá-la de livre de bloqueio, embora na verdade ela use apenas um spinlock de modo de usuário e nem mesmo atenda à definição. Eu também encorajaria qualquer leitor interessado a dar uma olhada neste artigo de Herlihy e Shavit, que examina de uma maneira formal as várias categorias de algoritmos baseados e sem bloqueio. Qualquer coisa de Herlihy sobre este tópico também é leitura recomendada.
BeeOnRope
1
@AndrasVass - Eu discordo. A maioria das estruturas clássicas sem bloqueio (listas, filas, mapas simultâneos, etc) não tinham rotação, mesmo para estruturas mutáveis ​​compartilhadas, e as implementações práticas existentes das mesmas em, por exemplo, Java seguem o mesmo padrão (eu não sou tão familiarizado com o que está disponível em C ou C ++ compilado nativo e é mais difícil lá devido à falta de coleta de lixo). Talvez você e eu tenhamos uma definição diferente de spinning: não considero a "tentativa de CAS" que você encontra em coisas sem bloqueio "spinning". IMO "girar" implica em espera quente.
BeeOnRope
27

Livro de Joe Duffy:

http://www.bluebytesoftware.com/books/winconc/winconc_book_resources.html

Ele também escreve um blog sobre esses tópicos.

O truque para acertar os programas de baixo bloqueio é entender em um nível profundo precisamente quais são as regras do modelo de memória em sua combinação particular de hardware, sistema operacional e ambiente de tempo de execução.

Pessoalmente, não sou nem de perto inteligente o suficiente para fazer a programação correta de low-lock além do InterlockedIncrement, mas se você for, ótimo, vá em frente. Apenas certifique-se de deixar muita documentação no código para que as pessoas que não são tão inteligentes quanto você não quebrem acidentalmente uma das invariáveis ​​do seu modelo de memória e introduzam um bug impossível de encontrar.

Eric Lippert
fonte
38
Portanto, se Eric Lippert e Jon Skeet acham que a programação sem bloqueio é apenas para pessoas mais espertas do que elas, então vou humildemente fugir gritando com a ideia imediatamente. ;-)
dodgy_coder
20

Atualmente, não existe "threading sem bloqueio". Era um playground interessante para a academia e afins, no final do século passado, quando o hardware do computador era lento e caro. O algoritmo de Dekker sempre foi meu favorito, o hardware moderno o colocou no pasto. Não funciona mais.

Dois desenvolvimentos acabaram com isso: a disparidade crescente entre a velocidade da RAM e da CPU. E a capacidade dos fabricantes de chips de colocar mais de um núcleo de CPU em um chip.

O problema de velocidade da RAM exigia que os projetistas do chip colocassem um buffer no chip da CPU. O buffer armazena código e dados, rapidamente acessíveis pelo núcleo da CPU. E pode ser lido e gravado de / para a RAM em um ritmo muito mais lento. Esse buffer é chamado de cache da CPU, a maioria das CPUs tem pelo menos dois deles. O cache de primeiro nível é pequeno e rápido, o segundo é grande e mais lento. Enquanto a CPU puder ler dados e instruções do cache de primeiro nível, ela será executada rapidamente. Uma falha de cache é muito cara, ela coloca a CPU em hibernação por até 10 ciclos se os dados não estiverem no primeiro cache, até 200 ciclos se não estiver no segundo cache e precisam ser lidos RAM.

Cada núcleo da CPU tem seu próprio cache, eles armazenam sua própria "visão" da RAM. Quando a CPU grava dados, a gravação é feita no cache que é então, lentamente, descarregada na RAM. Inevitável, cada núcleo agora terá uma visão diferente do conteúdo da RAM. Em outras palavras, uma CPU não sabe o que outra CPU escreveu até que o ciclo de gravação de RAM seja concluído e a CPU atualize sua própria visualização.

Isso é dramaticamente incompatível com threading. Você sempre se preocupa muito com o estado de outro encadeamento quando deve ler dados que foram escritos por outro encadeamento. Para garantir isso, você precisa programar explicitamente uma chamada barreira de memória. É uma primitiva de CPU de baixo nível que garante que todos os caches da CPU estejam em um estado consistente e tenham uma visão atualizada da RAM. Todas as gravações pendentes devem ser liberadas para a RAM e os caches precisam ser atualizados.

Isso está disponível no .NET, o método Thread.MemoryBarrier () implementa um. Dado que isso representa 90% do trabalho que a instrução de bloqueio faz (e 95 +% do tempo de execução), você simplesmente não está à frente, evitando as ferramentas que o .NET oferece e tentando implementar as suas próprias.

Hans Passant
fonte
2
@ Davy8: a composição torna ainda mais difícil. Se eu tiver duas tabelas de hash sem bloqueio e, como consumidor, acessar as duas, isso não garantirá a consistência do estado como um todo. O mais próximo que você pode chegar hoje são os STMs, onde você pode colocar os dois acessos, por exemplo, em um único atomicbloco. Resumindo, consumir estruturas sem bloqueio pode ser igualmente complicado em muitos casos.
Andras Vass
4
Posso estar errado, mas acho que você explicou mal como funciona a coerência do cache. A maioria dos processadores multicore modernos tem caches coerentes, o que significa que o hardware do cache garante que todos os processos tenham a mesma visualização do conteúdo da RAM - bloqueando as chamadas de "leitura" até que todas as chamadas de "gravação" correspondentes sejam concluídas. A documentação Thread.MemoryBarrier () ( msdn.microsoft.com/en-us/library/… ) não diz nada sobre o comportamento do cache - é simplesmente uma diretiva que impede o processador de reordenar leituras e gravações.
Brooks Moses,
7
"Hoje em dia, não existe" threading sem bloqueio ". Diga isso aos programadores Erlang e Haskell.
Julieta,
4
@HansPassant: "Hoje em dia não existe 'segmentação sem bloqueio'". F #, Erlang, Haskell, Cilk, OCaml, Biblioteca Paralela de Tarefas da Microsoft (TPL) e Threaded Building Blocks (TBB) da Intel encorajam a programação multithread sem bloqueio. Raramente uso bloqueios no código de produção atualmente.
JD
5
@HansPassant: "uma chamada barreira de memória. É uma CPU primitiva de baixo nível que garante que todos os caches da CPU estejam em um estado consistente e tenham uma visualização atualizada da RAM. Todas as gravações pendentes devem ser descarregadas na RAM, o então os caches precisam ser atualizados ". Uma barreira de memória neste contexto evita que as instruções de memória (carregamentos e armazenamentos) sejam reordenadas pelo compilador ou CPU. Nada a ver com a consistência dos caches da CPU.
JD
0

Quando se trata de multi-threading, você precisa saber exatamente o que está fazendo. Quero dizer, explorar todos os cenários / casos possíveis que podem ocorrer quando você está trabalhando em um ambiente multi-thread. Multithreading livre de bloqueio não é uma biblioteca ou uma classe que incorporamos, é um conhecimento / experiência que ganhamos durante nossa jornada nos threads.

fanfarrão
fonte
Existem inúmeras bibliotecas que fornecem semântica de threading sem bloqueio. STM é de particular interesse, do qual existem várias implementações por aí.
Marcelo Cantos
Eu vejo os dois lados deste. Obter desempenho efetivo de uma biblioteca sem bloqueio requer conhecimento profundo dos modelos de memória. Mas um programador que não tem esse conhecimento ainda pode se beneficiar das vantagens da correção.
Ben Voigt
0

Mesmo que o encadeamento sem bloqueio possa ser difícil no .NET, geralmente você pode fazer melhorias significativas ao usar um bloqueio, estudando exatamente o que precisa ser bloqueado e minimizando a seção bloqueada ... isso também é conhecido como minimizar a granularidade do bloqueio .

Por exemplo, basta dizer que você precisa tornar um tópico de coleção seguro. Não lance um bloqueio cegamente em torno de um método iterando sobre a coleção se ele executar alguma tarefa que consome muita CPU em cada item. Talvez você só precise bloquear a criação de uma cópia superficial da coleção. A iteração da cópia pode funcionar sem bloqueio. É claro que isso depende muito das especificações do seu código, mas consegui corrigir um problema de comboio de bloqueio com essa abordagem.

dodgy_coder
fonte