Синхронизация вычислений Haskell между потоками

#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, программа должна сначала его оценить. После того, как привязка верхнего уровня была оценена, ее не нужно оценивать снова.

  1. Является ли представление для «val» даже общим для отдельных потоков?
  2. Если по какой-то причине thread1 завершает вычисление первым, может ли он передать конечное вычисленное значение в thread2, заменив указатель? Как это будет синхронизировано?
  3. Если thread1 занят оценкой, когда thread2 хочет получить значение, ожидает ли thread2 его завершения или они оба сначала оценивают его?

Ответ №1:

В программах, скомпилированных на GHC, значения проходят три (-иш) фазы оценки:

  1. Удар. Здесь они начинаются.
  2. Черная дыра. При принудительном использовании блок преобразуется в черную дыру и начинается вычисление. Другие потоки, которые запрашивают значение черной дыры, вместо этого добавят себя в список уведомлений при обновлении черной дыры. (Кроме того, если сам thunk попытается получить доступ к черной дыре, он закоротит исключение вместо того, чтобы ждать вечно.)
  3. Оценено. Когда вычисление завершается, его последняя задача — обновить черную дыру до простого значения (ну, в любом случае, значение WHNF).

Указатель, который обновляется во время этих фазовых переходов, используется совместно с другими потоками и не защищен от условий гонки. Это означает, что очень редко возможно, чтобы два (или более) потока одновременно видели указатель на этапе 1 и оба выполняли переход 1 -> 2; в этом случае оба будут оценивать удар, и переход 2 -> 3 также произойдет дважды. Примечательно, однако, что переход 1 -> 2 обычно выполняется намного быстрее, чем вычисления, которые он заменяет (по сути, просто доступ к памяти или два), отчасти именно поэтому запуск гонки затруднен.

Поскольку язык чистый, участвующие в гонках потоки придут к одному и тому же ответу. Таким образом, здесь нет семантических трудностей. Но в некоторых редких случаях небольшая часть работы может дублироваться. Очень, очень редко накладные расходы на блокировку при каждом переходе 1 -> 2 будут лучше, чем это небольшое дублирование. (Если вы обнаружите, что это в вашем случае, подумайте о том, чтобы вручную защитить оценку того, какая дорогая вещь используется совместно!)

Следствие: необходимо проявлять большую осторожность с семейством небезопасных IO a -> a функций; некоторые гарантируют синхронизацию оценки результата a , а некоторые нет. Если ваше IO a действие не такое чистое, как вы обещали, и гонка заставляет его выполняться дважды, могут возникать всевозможные странные ошибки гейзенбага.