#haskell #ghc
#haskell #ghc
Вопрос:
Я пытаюсь понять, как GHC Haskell синхронизирует вычисление «базовых» значений (т. Е. Не IORef, tvarи т.д.) Между потоками. Я искал информацию об этом, но не нашел ничего ясного.
Возьмем следующий пример программы:
import Control.Concurrent
expensiveFunction x = sum [1..x] -- Just an example
val = expensiveFunction 12345
thread1 = print val
thread2 = print val
main = do
forkOS thread1
forkOS thread2
Я понимаю, что значение val изначально будет представлено недооцененным замыканием. Чтобы напечатать значение val, программа должна сначала его оценить. После того, как привязка верхнего уровня была оценена, ее не нужно оценивать снова.
- Является ли представление для «val» даже общим для отдельных потоков?
- Если по какой-то причине thread1 завершает вычисление первым, может ли он передать конечное вычисленное значение в thread2, заменив указатель? Как это будет синхронизировано?
- Если thread1 занят оценкой, когда thread2 хочет получить значение, ожидает ли thread2 его завершения или они оба сначала оценивают его?
Ответ №1:
В программах, скомпилированных на GHC, значения проходят три (-иш) фазы оценки:
- Удар. Здесь они начинаются.
- Черная дыра. При принудительном использовании блок преобразуется в черную дыру и начинается вычисление. Другие потоки, которые запрашивают значение черной дыры, вместо этого добавят себя в список уведомлений при обновлении черной дыры. (Кроме того, если сам thunk попытается получить доступ к черной дыре, он закоротит исключение вместо того, чтобы ждать вечно.)
- Оценено. Когда вычисление завершается, его последняя задача — обновить черную дыру до простого значения (ну, в любом случае, значение WHNF).
Указатель, который обновляется во время этих фазовых переходов, используется совместно с другими потоками и не защищен от условий гонки. Это означает, что очень редко возможно, чтобы два (или более) потока одновременно видели указатель на этапе 1 и оба выполняли переход 1 -> 2; в этом случае оба будут оценивать удар, и переход 2 -> 3 также произойдет дважды. Примечательно, однако, что переход 1 -> 2 обычно выполняется намного быстрее, чем вычисления, которые он заменяет (по сути, просто доступ к памяти или два), отчасти именно поэтому запуск гонки затруднен.
Поскольку язык чистый, участвующие в гонках потоки придут к одному и тому же ответу. Таким образом, здесь нет семантических трудностей. Но в некоторых редких случаях небольшая часть работы может дублироваться. Очень, очень редко накладные расходы на блокировку при каждом переходе 1 -> 2 будут лучше, чем это небольшое дублирование. (Если вы обнаружите, что это в вашем случае, подумайте о том, чтобы вручную защитить оценку того, какая дорогая вещь используется совместно!)
Следствие: необходимо проявлять большую осторожность с семейством небезопасных IO a -> a
функций; некоторые гарантируют синхронизацию оценки результата a
, а некоторые нет. Если ваше IO a
действие не такое чистое, как вы обещали, и гонка заставляет его выполняться дважды, могут возникать всевозможные странные ошибки гейзенбага.