Em Programação Paralela e Concorrente em Haskell , de Simon Marlow, nas páginas 133 e 134, o seguinte código é mostrado:
type Name = String
type PhoneNumber = String
type PhoneBook = Map Name PhoneNumber
newtype PhoneBookState = PhoneBookState (MVar PhoneBook)
lookup :: PhoneBookState -> Name -> IO (Maybe PhoneNumber)
lookup (PhoneBookState m) name = do
book <- takeMVar m
putMVar m book
return (Map.lookup name book)
E na página seguinte, algumas observações. A parte final do segundo parágrafo me intriga um pouco. Aqui está a primeira metade do parágrafo:
Usar estruturas de dados imutáveis em um wrapper mutável traz outros benefícios. Observe que, na
lookup
operação, simplesmente capturamos o valor atual do estado e, em seguida, aMap.lookup
operação complexa ocorre fora da sequênciatakeMVar
/putMVar
. Isso é bom para a concorrência, pois significa que o bloqueio é mantido apenas por um período muito curto.
Isso é bastante claro para mim, no sentido de que entendo que isso takeMVar
coloca o bloqueio e putMVar
o libera imediatamente, de modo que os acessos simultâneos subsequentes ao PhoneBookState
não sejam mais prejudicados. Ao mesmo tempo, também vejo que outras mutações do MVar
são possíveis, então, quando o tempo lookup
retorna com um Just smth
, esse (name, smth)
par pode ter sido removido do mapa (ou, se retornou Nothing
, alguns (name, smth)
podem ter sido adicionados ao mapa).
Entretanto, não entendi o que a seguinte parte do parágrafo tenta transmitir:
Isso só é possível porque o valor do estado é imutável. Se a estrutura de dados fosse mutável, teríamos que manter o bloqueio enquanto operávamos nela.⁵
⁵. A outra opção é usar um algoritmo sem bloqueios, que é extremamente complexo e difícil de acertar.
O que isso significa? O autor está se referindo ao caso que Map
expôs alguma API a uma mutação? Isso nem é possível, a menos que estejamos em IO
, certo? Ou ele está se referindo a mutações realizadas no m
conteúdo de (por outras threads após putMVar
a liberação do bloqueio) que de alguma forma afetaram book
o conteúdo de ?
Marlow está contrastando a abordagem Haskell de dados imutáveis com linguagens que permitem que todas as estruturas de dados sejam mutadas.
Em uma linguagem como Java ou JavaScript, não seria suficiente bloquear apenas a variável que contém o
Map
, pois pode haver outra thread que o tenha obtidoMap
e o esteja modificando. Portanto, você precisaria bloquear toda a estrutura enquanto estivesse acessando-a.Em Haskell, já sabemos que o
Map
é imutável. Qualquer coisa que queira pegar oMap
e modificá-lo deve criar um novo valor. Portanto, precisamos bloquear apenas oMVar
que contém oMap
, e não o todoMap
(embora, se você planeja escrever o mapa modificado de volta no originalMVar
, precisa manter um bloqueio para toda a operação).A parte sobre estruturas "sem bloqueio" é explicada com mais detalhes neste artigo da Wikipédia . Resumidamente, é praticamente possível criar uma árvore ordenada na qual várias threads podem alterar o conteúdo sem que nada dê errado. Mas a definição e a comprovação de tais estruturas são magia profunda , não algo a ser tentado durante o desenvolvimento normal.
Aqui está um exemplo completo que pode ajudar a ilustrar o problema. Tenho certeza de que você entenderá se eu não quiser escrever uma implementação completa de mapa mutável para responder a essa pergunta, mas considere o seguinte mapa mutável simplificado, capaz de armazenar apenas um par chave-valor. Ele suporta as operações "new", "lookup" e "replace". (Chamei-o de "replace" em vez de "insert" porque o mapa só pode armazenar um par chave-valor, então a entrada anterior se perde quando um novo é inserido.)
Agora, suponha que queremos escrever uma interface concorrente e segura para threads para uma lista telefônica usando esta estrutura de dados mutável. O código revisado
lookup
é mais ou menos o mesmo que o original:onde realizamos o real
mmapLookup
após desbloquear o estado.O código API correspondente para
new
ereplace
provavelmente seria algo como isto:Observe que, diferentemente de
lookup
, adotamos uma abordagem conservadora comreplace
, mantendo o bloqueio durante toda a operação de substituição.No entanto, esta implementação tem uma condição de corrida para o par
lookup
/replace
. Para entender o porquê, vamos adicionar um atraso ammapLookup
:e considere a seguinte
main
função, que cria uma lista telefônica para o número de telefone de "Alice" e então inicia dois threads — um para procurar "Alice" e outro para substituir a entrada pelo número de "Bob":Quando este programa é executado, ele recupera incorretamente o número de Bob na pesquisa de Alice:
Obviamente, o problema é que a operação de substituição ocorre entre a comparação das chaves e a recuperação do valor.
Se você modificar
lookup
para liberar o bloqueio somente após a conclusão da pesquisa:então não haverá condição de corrida. Não importa como você intercale as operações (e não importa quão cuidadosamente você coloque os atrasos), você recuperará o número de Alice
Just "555-1234"
da lista telefônica original ou não conseguirá recuperar o número de Alice e retornaráNothing
da lista telefônica revisada (onde ele foi substituído pelo número de Bob).Então, para um mutável
MMap
, temos que manter o bloqueio em torno dammapLookup
operação para operar com segurança na estrutura de dados sem permitir que outros threads modifiquem a estrutura de dados sem que precisemos fazer nada enquanto estivermos executando olookup
.Em contraste, se usássemos uma estrutura de dados imutável análoga:
então as implementações correspondentes de
new
,lookup
, ereplace
seriam:Fundamentalmente, quando
replace
se retirabook
doMVar
, que pode muito bem ser o mesmobook
acessado simultaneamente pelaimapLookup
chamada emlookup
, ele não sofre mutação,book
mas cria um novobook'
por meio daimapReplace
chamada sem afetar o originalbook
. Esta é a essência das estruturas de dados imutáveis, e é por isso que aimapLookup
operação pode continuar a ser executada com segurança no inalterado,book
mesmo aoreplace
criar um novobook'
e gravá-lo de volta no vagoMVar
.Mesmo se fizéssemos um esforço para reescrever
imapLookup
como umaIO
operação com ampla oportunidade para outro thread ser executado entre o acesso de chave e valor:ainda assim não dispararíamos uma condição de corrida, porque o
book :: IMap k v
objeto acessadoimapLookup name book
é imutável, independentemente de como ele está sendo acessado em outras threads.Aqui está o exemplo completo ilustrando a condição de corrida com uma
MMap
estrutura mutável:e a versão imutável sem a condição de corrida: