Haskell вычислительно интенсивный поток блокирует все остальные потоки - PullRequest
8 голосов
/ 30 января 2020

Я хочу написать программу, основной поток которой разветвляет новый поток для вычислений и ожидает его до конца sh в течение определенного периода времени. Если дочерний поток не завершает sh в данный момент времени, он истекает и завершается. Для этого у меня есть следующий код:

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' 

Я скомпилировал вышеупомянутую программу с ghc -O2 Test.hs и ghc -O2 -threaded Test.hs и запустил ее, но в обоих случаях программа просто зависает, ничего не печатая и не выходя. Если я добавлю threadDelay (2 * 1000 * 1000) в поток вычислений перед блоком if, тогда программа будет работать, как ожидается, и завершится через секунду, поскольку поток таймера сможет заполнить mvar.

Почему многопоточность не работает так, как я ожидаю?

1 Ответ

10 голосов
/ 30 января 2020

GH C использует своего рода гибрид кооперативной и вытесняющей многозадачности в своей реализации параллелизма.

На уровне Haskell это кажется вытесняющим, потому что потоки не должны явно выдавать и могут быть казалось бы, прервано во время выполнения в любое время. Но на уровне времени выполнения потоки «уступают» всякий раз, когда выделяют память. Поскольку почти все потоки Haskell постоянно выделяются, это обычно работает довольно хорошо.

Однако, если конкретный расчет может быть оптимизирован для нераспределенного кода, он может перестать работать на уровне времени выполнения и, таким образом, выкупаем на уровне Haskell. Как указал @Carl, на самом деле это флаг -fomit-yields, который подразумевается -O2, который позволяет этому произойти:

-fomit-yields

Говорит GH C, чтобы пропустить проверку кучи, когда распределение не выполняется. Хотя это улучшает размер двоичных файлов примерно на 5%, это также означает, что потоки, работающие в узких невыделенных циклах, не будут своевременно вытеснены. Если важно всегда иметь возможность прерывать такие потоки, вы должны отключить эту оптимизацию. Рассмотрим также перекомпиляцию всех библиотек с отключенной оптимизацией, если вам нужно гарантировать прерывание.

Очевидно, что в однопоточном времени выполнения (без флага -threaded) это означает, что один поток может полностью истощить все остальные темы. Менее очевидно, то же самое может произойти, даже если вы компилируете с -threaded и используете опции +RTS -N. Проблема в том, что неработающий поток может истощить саму среду выполнения scheduler . Если в какой-то момент неработающий поток является единственным потоком, запланированным на текущий момент для выполнения, он станет бесперебойным, и планировщик никогда не будет перезапущен для рассмотрения планирования дополнительных потоков, даже если они могут выполняться в других потоках O / S.

Если вы просто пытаетесь что-то протестировать, измените подпись fib на fib :: Integer -> Integer. Поскольку Integer вызывает распределение, все снова начнет работать (с -threaded или без него).

Если вы столкнетесь с этой проблемой в реальном коде, самое простое решение, безусловно, @Carl предлагает тот, что: если вам нужно гарантировать прерывание потоков, код должен быть скомпилирован с -fno-omit-yields, который сохраняет вызовы планировщика в нераспределенном коде. Согласно документации это увеличивает двоичные размеры; Я предполагаю, что это также приводит к небольшому снижению производительности.

В качестве альтернативы, если вычисление уже находится в IO, тогда явно yield в оптимизированном l oop может будь хорошим подходом. Для чистого вычисления вы можете преобразовать его в IO и yield, хотя обычно вы можете найти простой способ снова ввести распределение. В большинстве реальных c ситуаций будет способ ввести только «несколько» yield с или выделений - достаточно, чтобы поток снова стал отзывчивым, но недостаточно, чтобы серьезно повлиять на производительность. (Например, если у вас есть несколько вложенных рекурсивных циклов, yield или принудительное выделение в самом внешнем l oop.)

...