O thread computacionalmente intensivo da Haskell bloqueia todos os outros threads

8

Quero escrever um programa cujo thread principal bifurque um novo thread para computação e aguarde que ele termine por um período de tempo. Se o encadeamento filho não for concluído no tempo determinado, o tempo limite será excedido e eliminado. Eu tenho o seguinte código para isso.

import Control.Concurrent

fibs :: Int -> Int 
fibs 0 = 0
fibs 1 = 1
fibs n = fibs (n-1) + fibs (n-2)

main = do 
    mvar  <- newEmptyMVar 
    tid   <- forkIO $ do
        threadDelay (1 * 1000 * 1000)
        putMVar mvar Nothing 
    tid'  <- forkIO $ do
        if fibs 1234 == 100
            then putStrLn "Incorrect answer" >> putMVar mvar (Just False)
            else putStrLn "Maybe correct answer" >> putMVar mvar (Just True)
    putStrLn "Waiting for result or timeout"
    result <- takeMVar mvar
    killThread tid
    killThread tid' 

Compilei o programa acima com ghc -O2 Test.hse ghc -O2 -threaded Test.hsexecutei-o, mas em ambos os casos o programa trava sem imprimir nada ou sair. Se eu adicionar um threadDelay (2 * 1000 * 1000)ao thread de computação antes do ifbloco, o programa funcionará conforme o esperado e será concluído após um segundo, pois o thread do timer poderá preencher o mvar.

Por que o encadeamento não está funcionando como eu esperava?

Cara aleatória
fonte
As notas MVarafirmam que é suscetível a condições de corrida. Eu levaria essa nota a sério.
Bob Dalgleish 30/01
@BobDalgleish, duvido muito. A MVardisciplina me parece boa aqui.
dfeuer 30/01
1
você executou o programa +RTS -N? verifique wiki.haskell.org/Concurrency para obter mais informações
lsmor
Aqui está um exemplo semelhante desse comportamento e a resposta do cara que é o principal responsável pela maior parte da concorrência no ghc, o lendário Simon :) github.com/simonmar/async/issues/93
lehins

Respostas:

10

O GHC usa uma espécie de híbrido de multitarefa cooperativa e preventiva em sua implementação simultânea.

No nível Haskell, parece preventivo porque os threads não precisam render explicitamente e podem ser aparentemente interrompidos pelo tempo de execução a qualquer momento. Porém, no nível do tempo de execução, os threads "cedem" sempre que alocam memória. Como quase todos os threads do Haskell estão constantemente alocando, isso geralmente funciona muito bem.

No entanto, se um cálculo específico puder ser otimizado para código não alocado, ele poderá se tornar não cooperativo no nível de tempo de execução e, portanto, imprevisível no nível Haskell. Como o @Carl apontou, na verdade é a -fomit-yieldsbandeira, que está implícita -O2que permite que isso aconteça:

-fomit-yields

Informa ao GHC para omitir verificações de heap quando nenhuma alocação estiver sendo executada. Embora isso melhore os tamanhos binários em cerca de 5%, também significa que os threads executados em loops estreitos e sem alocação não serão impedidos em tempo hábil. Se for importante sempre poder interromper esses threads, você deve desativar essa otimização. Considere também recompilar todas as bibliotecas com essa otimização desativada, se você precisar garantir a interrupção.

Obviamente, no tempo de execução de thread único (sem -threadedsinalizador), isso significa que um thread pode eliminar completamente todos os outros threads. Menos obviamente, a mesma coisa pode acontecer mesmo se você compilar -threadede usar +RTS -Nopções. O problema é que um encadeamento não cooperativo pode deixar de fora o próprio planejador de tempo de execução . Se, em algum momento, o encadeamento não cooperativo for o único agendado atualmente para execução, ele se tornará ininterrupto e o planejador nunca será executado novamente para considerar o agendamento de encadeamentos adicionais, mesmo que eles possam ser executados em outros encadeamentos O / S.

Se você está apenas tentando testar algumas coisas, altere a assinatura de fibpara fib :: Integer -> Integer. Já que Integercausa a alocação, tudo começará a funcionar novamente (com ou sem -threaded).

Se você se deparar com esse problema no código real , a solução mais fácil, de longe, é a sugerida pelo @Carl: se você precisar garantir a interrupção dos encadeamentos, o código deve ser compilado -fno-omit-yields, o que mantém as chamadas do agendador no código não alocado . Conforme a documentação, isso aumenta os tamanhos binários; Suponho que também venha com uma pequena penalidade de desempenho.

Como alternativa, se a computação já estiver dentro IO, então explicitamente yieldno loop otimizado pode ser uma boa abordagem. Para um cálculo puro, você pode convertê-lo em E / S e yield, embora normalmente possa encontrar uma maneira simples de introduzir uma alocação novamente. Na maioria das situações realistas, haverá uma maneira de introduzir apenas alguns yieldou alocações - o suficiente para tornar o thread responsivo novamente, mas não o suficiente para afetar seriamente o desempenho. (Por exemplo, se você tiver alguns loops recursivos aninhados yieldou forçar uma alocação no loop mais externo.)

KA Buhr
fonte
4
Considere também compilar com -fno-omit-yields, uma vez que -O2implica -fomit-yields. downloads.haskell.org/~ghc/latest/docs/html/users_guide/…
Carl
Sim! Eu pensei que havia uma bandeira relevante, mas não conseguia lembrar o nome ou encontrá-lo no manual. Geralmente, essa é a correção mais fácil e confiável, e atualizei minha resposta de acordo.
KA Buhr 30/01