Como explicar por que o multi-threading é difícil

84

Sou um bom programador, meu chefe também é um bom programador. Embora ele pareça subestimar algumas tarefas, como multi-threading e quão difícil pode ser (acho muito difícil para qualquer coisa mais do que executar alguns threads, aguardar que todos terminem e retornar resultados).

No momento em que você começa a se preocupar com impasses e condições de corrida, acho isso muito difícil, mas o chefe não parece gostar disso - acho que ele nunca se deparou com isso. Basta dar um tapa nele é a atitude.

Então, como posso apresentá-lo ou explicar por que ele pode estar subestimando as complexidades de simultaneidade, paralelismo e multiencadeamento? Ou talvez eu esteja errado?

Editar: um pouco sobre o que ele fez - percorre uma lista, para cada item dessa lista, crie um encadeamento que executa um comando de atualização do banco de dados com base nas informações desse item. Não sei como ele controlou quantos threads executados ao mesmo tempo, acho que ele deve tê-los adicionado a uma fila se houver muitos em execução (ele não usaria um semáforo).

Shoubs
fonte
17
Multi-threading é fácil. A sincronização correta é difícil.
Vineet Reynolds
33
Traga três pessoas para a sala, de preferência com sotaques diferentes, e peça que expliquem partes diferentes e sobrepostas do problema de simultaneidade ... simultaneamente.
greyfade
A multithreading pode ser muito difícil ou muito fácil, dependendo do problema em questão e do suporte ao idioma. O Clojure facilita as coisas clojure.org/concurrent_programming
Job
4
@Job A programação simultânea é sempre difícil (em projetos do mundo real), independentemente do idioma que você está usando. Scala, Clojure ou Erlang tornam um pouco mais sensato quando você deseja compará-lo com idiomas que usam e incentivam estados mutáveis.
Quíron
4
Minha metáfora favorita para isso é: "Você tomaria uma pílula para dormir e um laxante ao mesmo tempo?" Mesmo usando filas de mensagens complexas, a ordem é fruto da simultaneidade feita corretamente . Isso, a menos que você tenha muita experiência com isso, é difícil para muitas pessoas.
Tim Post

Respostas:

29
  1. Se você pode contar com qualquer experiência matemática, ilustre como um fluxo de execução normal que é essencialmente determinístico se torna não apenas não determinístico com vários encadeamentos, mas exponencialmente complexo, porque você precisa garantir que todas as intercalações possíveis das instruções da máquina ainda façam a coisa certa. Um exemplo simples de uma atualização perdida ou de uma situação de leitura suja é muitas vezes revelador.

  2. "Fechar um bloqueio" é a solução trivial ... resolve todos os seus problemas se você não está preocupado com o desempenho. Tente ilustrar o impacto de um desempenho se, por exemplo, a Amazon tivesse que bloquear toda a costa leste sempre que alguém em Atlanta encomendasse um livro!

Kilian Foth
fonte
1
+1 para a discussão da complexidade matemática - foi assim que cheguei a entender a dificuldade na simultaneidade de estado compartilhado e é o argumento que geralmente defendo na defesa de arquiteturas de transmissão de mensagens. -1 para "dar um tapa na fechadura" ... A frase conota uma abordagem impensada ao uso de bloqueios, o que provavelmente levará a um impasse ou a um comportamento inconsistente (como os clientes do seu código que vivem em threads diferentes tornam conflitantes solicitações, mas não sincronizam entre si, os clientes terão modelos incompatíveis do estado da sua biblioteca).
Aidan Cully
2
Amazon não tem que bloquear o inventário de um item individual em um armazém brevemente ao processar uma ordem. Se houver uma execução repentina e enorme em um item em particular, o desempenho do pedido para esse item sofrerá até que o suprimento esteja esgotado e o acesso ao inventário se torne somente leitura (e, portanto, 100% compartilhável). Uma coisa que a Amazon faz para que outros programas não o façam é a capacidade de enfileirar pedidos até que ocorra um novo estoque e a opção de atender a pedidos em fila antes que um novo estoque seja disponibilizado para novos pedidos.
Blrfl
@Blrfl: Os programas podem fazer isso se forem gravados para usar a passagem de mensagens através de filas. Não há necessidade de ter todas as mensagens para um segmento específico indo através de uma única fila ...
Donal Fellows
4
@Donal Fellows: se houver 1 milhão de widgets em estoque em um armazém e 1 milhão de pedidos chegarem no mesmo instante, todas essas solicitações serão serializadas em algum nível, combinando itens com os pedidos, independentemente de como eles são tratados. A realidade prática é que a Amazon provavelmente nunca tem tantos widgets em estoque que a latência no processamento de uma enxurrada de pedidos fica inaceitavelmente alta antes que o estoque acabe e todos os outros na fila possam saber (em paralelo): "estamos fora. " As filas de mensagens são uma ótima maneira de evitar conflitos, mas não resolvem o problema de alta contenção de um recurso limitado.
Blrfl
79

Multi-threading é simples. Codificar um aplicativo para multiencadeamento é muito, muito fácil.

Há um truque simples, e isso é usar uma fila de mensagens bem projetada ( não faça o seu próprio) para passar dados entre threads.

A parte difícil é tentar fazer com que vários threads atualizem magicamente um objeto compartilhado de alguma forma. É aí que fica propenso a erros, porque as pessoas não prestam atenção às condições da corrida presentes.

Muitas pessoas não usam filas de mensagens e tentam atualizar objetos compartilhados e criar problemas para si mesmas.

O que se torna difícil é projetar um algoritmo que funcione bem ao passar dados entre várias filas. Isso é difícil. Mas a mecânica de threads coexistentes (via filas compartilhadas) é fácil.

Além disso, observe que os threads compartilham recursos de E / S. É improvável que um programa vinculado de E / S (ou seja, conexões de rede, operações de arquivo ou operações de banco de dados) acelere com mais threads.

Se você quiser ilustrar o problema de atualização de objeto compartilhado, isso é simples. Sente-se do outro lado da mesa com um monte de cartões de papel. Anote um conjunto simples de cálculos - 4 ou 6 fórmulas simples - com muito espaço na página.

Aqui está o jogo. Cada um de vocês lê uma fórmula, escreve uma resposta e coloca um cartão com a resposta.

Cada um de vocês fará metade do trabalho, certo? Você terminou na metade do tempo, certo?

Se o seu chefe não pensar muito e apenas começar, você acabará entrando em conflito de alguma maneira e ambos escreverão respostas para a mesma fórmula. Isso não funcionou porque há uma condição de raça inerente entre vocês dois lendo antes de escrever. Nada impede que você leia a mesma fórmula e substitua as respostas uma da outra.

Existem muitas, muitas maneiras de criar condições de corrida com recursos mal ou não bloqueados.

Se você quiser evitar todos os conflitos, recorte o papel em uma pilha de fórmulas. Você tira uma da fila, anota a resposta e publica as respostas. Não há conflitos porque vocês dois leem de uma fila de mensagens de apenas um leitor.

S.Lott
fonte
Mesmo cortar o papel em uma pilha não resolve totalmente as coisas - você ainda tem a situação em que você e seu chefe buscam uma nova fórmula ao mesmo tempo e batem com os nós dos dedos. Na verdade, eu diria que isso é representativo do tipo mais comum de problema de segmentação. Os erros realmente grosseiros são encontrados cedo. Os erros realmente incomuns permanecem para sempre, porque ninguém pode reproduzi-los. As condições de corrida plausíveis - como esta - continuam surgindo nos testes e, eventualmente, todas (ou mais provavelmente a maioria) são eliminadas.
Airsource Ltd
@AirsourceLtd O que exatamente você está dizendo com "bater com os nós dos dedos"? Contanto que você tenha uma fila de mensagens que impeça que dois threads diferentes recebam a mesma mensagem, isso não será um problema. A menos que eu esteja entendendo mal o que você quis dizer.
Zack
25

A programação multithread é provavelmente a solução mais difícil para a simultaneidade. Basicamente, é uma abstração de nível bastante baixo do que a máquina realmente faz.

Existem várias abordagens, como o modelo do ator ou a memória transacional (de software) , que são muito mais fáceis. Ou trabalhando com estruturas de dados imutáveis ​​(como listas e árvores).

Geralmente, uma separação adequada de preocupações facilita a multiencadeamento. Algo que muitas vezes é esquecido quando as pessoas geram 20 threads, todas tentando processar o mesmo buffer. Use reatores onde você precisar de sincronização e geralmente passe dados entre diferentes trabalhadores com filas de mensagens.
Se você tem um bloqueio na lógica do aplicativo, fez algo errado.

Então, sim, tecnicamente, o multi-threading é difícil.
"Bloquear um bloqueio" é praticamente a solução menos escalável para problemas de simultaneidade e, na verdade, derrota todo o propósito do multiencadeamento. O que ele faz é reverter um problema para um modelo de execução não simultâneo. Quanto mais você faz, maior é a probabilidade de ter apenas um thread em execução no momento (ou 0 em um impasse). Isso derrota todo o propósito.
É como dizer "Resolver os problemas do terceiro mundo é fácil. Basta jogar uma bomba nele". Só porque existe uma solução trivial, isso não torna o problema trivial, pois você se importa com a qualidade do resultado.

Mas, na prática, resolver esses problemas é tão difícil quanto qualquer outro problema de programação e é melhor realizado com abstrações apropriadas. O que facilita bastante.

back2dos
fonte
14

Eu acho que há um ângulo não técnico nessa questão - a IMO é uma questão de confiança. Geralmente, somos solicitados a reproduzir aplicativos complexos como - oh, eu não sei - o Facebook, por exemplo. Cheguei à conclusão de que, se você está tendo que explicar a complexidade de uma tarefa para os não-iniciados / gerentes - algo está estragado na Dinamarca.

Mesmo se outros programadores ninjas puderem fazer a tarefa em 5 minutos, suas estimativas serão baseadas em sua capacidade pessoal. Seu interlocutor deve aprender a confiar em sua opinião sobre o assunto ou contratar alguém cuja palavra eles estão dispostos a aceitar.

O desafio não está em transmitir as implicações técnicas, que as pessoas tendem a ignorar ou são incapazes de compreender por meio da conversa, mas em estabelecer uma relação de respeito mútuo.

sunwukung
fonte
1
Resposta interessante, embora seja uma questão técnica. No entanto, eu concordo com o que você diz ... neste caso, porém, meu gerente é um bom programador, no entanto, acho que, porque ele não se deparou com as complexidades de aplicativos multiencadeados, ele os subestima.
Shoubs
6

Um experimento simples para entender os impasses é o problema do " filósofo do jantar ". Um dos exemplos que costumo usar para descrever como as más condições de corrida podem ser é a situação do Therac 25 .

"Apenas dar um tapinha nele" é a mentalidade de alguém que não encontrou bugs difíceis com o multi-threading. E é possível que ele pense que você está exagerando a seriedade da situação (eu não - é possível explodir coisas ou matar pessoas com problemas de condição de corrida, especialmente com software incorporado que acaba em carros).

Tangurena
fonte
1
ou seja, o problema do sanduíche: você faz um monte de sanduíches, mas há apenas 1 prato de manteiga e 1 faca. Geralmente está tudo bem, mas eventualmente alguém agarra a manteiga enquanto outra pessoa agarra a faca.
Gbjbaanb
Problemas de conflito como esse poderiam ser resolvidos sempre adquirindo recursos em uma ordem definida?
compman
@compman, não. Como é possível que 2 threads tentem capturar o mesmo recurso no mesmo momento, e esse segmento não precisa necessariamente do mesmo conjunto de recursos - apenas uma sobreposição suficiente para causar problemas. Um esquema é colocar o recurso "de volta" e aguardar um período aleatório antes de buscá-lo novamente. Esse período de retirada ocorre em vários protocolos, o primeiro dos quais foi o Aloha. en.wikipedia.org/wiki/ALOHAnet
Tangurena
1
E se todos os recursos do programa tivessem um número e, quando um encadeamento / processo precisasse de um conjunto de recursos, ele sempre bloqueava os recursos em ordem numérica crescente? Eu não acho que esse impasse possa acontecer.
11898 compman
1
@compman: Essa é realmente uma maneira de evitar conflitos. É possível projetar ferramentas que permitem verificar automaticamente isso; portanto, se seu aplicativo nunca bloquear recursos, exceto em ordem numérica crescente, você nunca teve um possível conflito. (Observe que possíveis bloqueios só se tornam bloqueios reais quando o código é executado no computador do cliente).
precisa saber é o seguinte
3

Aplicativos concorrentes não são determinísticos. Com a quantidade excepcionalmente pequena de código geral que o programador reconheceu como vulnerável, você não controla quando uma parte de um thread / processo é executada em relação a qualquer parte de outro thread. O teste é mais difícil, leva mais tempo e é improvável que encontre todos os defeitos relacionados à simultaneidade. Os defeitos, se encontrados, geralmente são sutis, não podem ser reproduzidos de maneira consistente; portanto, a fixação é difícil.

Portanto, o único aplicativo simultâneo correto é aquele que é comprovadamente correto, algo que muitas vezes não é praticado no desenvolvimento de software. Como resultado, a resposta da S.Lot é o melhor conselho geral, pois a passagem de mensagens é relativamente fácil de provar correta.

mattnz
fonte
3

Resposta curta em duas palavras: NONDETERMINISMO OBSERVÁVEL

Resposta longa: depende de qual abordagem da programação simultânea você usa, devido ao seu problema. No livro Conceitos, técnicas e modelos de programação de computadores , os autores explicam claramente quatro abordagens práticas principais para escrever programas concorrentes:

  • Programação sequencial : uma abordagem de linha de base que não tem simultaneidade;
  • Simultaneidade declarativa : utilizável quando não existe um não determinismo observável;
  • De passagem de mensagens de simultaneidade : mensagem concorrente passando entre muitas entidades, onde cada entidade processar internamente a mensagem sequencialmente;
  • Concorrência de estado compartilhado : thread atualizando objetos passivos compartilhados por meio de ações atômicas de granulação grossa, por exemplo, bloqueios, monitores e transações;

Agora, a mais fácil dessas quatro abordagens, além da óbvia programação seqüencial, é a simultaneidade declarativa , porque os programas escritos usando essa abordagem não têm um não determinismo observável . Em outras palavras, não há condições de corrida , pois a condição de corrida é apenas um comportamento não determinístico observável.

Mas a falta de não determinismo observável significa que existem alguns problemas que não podemos resolver usando a simultaneidade declarativa. Aqui é onde as duas últimas abordagens não tão fáceis entram em cena. A parte não tão fácil é uma consequência do não determinismo observável. Agora, ambos se enquadram no modelo concorrente estável e também são equivalentes em expressividade. Porém, devido ao número cada vez maior de núcleos por CPU, parece que o setor se interessou mais recentemente pela simultaneidade de passagem de mensagens, como pode ser visto no surgimento de bibliotecas de passagem de mensagens (por exemplo, Akka for JVM) ou linguagens de programação (por exemplo, Erlang ) .

A biblioteca Akka mencionada anteriormente, que é apoiada por um modelo teórico de ator, simplifica a criação de aplicativos simultâneos, pois você não precisa mais lidar com bloqueios, monitores ou transações. Por outro lado, requer uma abordagem diferente para projetar a solução, ou seja, pensar em uma maneira de compor hierarquicamente os atores. Pode-se dizer que requer uma mentalidade totalmente diferente, que novamente pode ser ainda mais difícil do que usar a simultaneidade compartilhada em estado simples.

A programação simultânea é difícil por causa do não determinismo observável, mas ao usar a abordagem correta para o problema em questão e a biblioteca certa que suporta essa abordagem, muitos problemas podem ser evitados.

Jernej Jerin
fonte
0

Foi-me ensinado pela primeira vez que isso poderia trazer à tona problemas ao ver um programa simples que iniciava 2 threads e os dois imprimiam no console ao mesmo tempo de 1 a 100. Ao invés de:

1
1
2
2
3
3
...

Você obtém algo mais como este:

1
2
1
3
2
3
...

Execute-o novamente e você poderá obter resultados totalmente diferentes.

Muitos de nós foram treinados para assumir que nosso código será executado seqüencialmente. Com a maioria dos multi-threading, não podemos tomar isso como garantido "fora da caixa".

Morgan Herlocker
fonte
-3

Tente usar vários martelos para esmagar várias unhas espaçadas ao mesmo tempo, sem comunicação entre os que seguram os martelos ... (suponha que estejam com os olhos vendados).

Escale isso para a construção de uma casa.

Agora tente dormir à noite imaginando que você é o arquiteto. :)

Macke
fonte
-3

Parte fácil: use multithreading com recursos contemporâneos de estruturas, sistemas operacionais e hardware, como semáforos, filas, contadores intertravados, tipos atômicos de caixas etc.

Parte difícil: implementar os recursos por si próprios, sem recursos em primeiro lugar, pode ser exceto poucos recursos muito limitados de hardware, contando apenas com garantias de coerência de clock em vários núcleos.


fonte
3
A parte difícil é realmente mais difícil, mas mesmo essa parte fácil não é tão fácil.
PeterAllenWebb