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 MFENCE
causar 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).