Параллелизм Haskell с использованием простого IORef?

Я задавал несколько вопросов о параллелизме в Haskell, в частности TVarи у меня были опасения по поводу livelock с TVar,

Вместо этого я предложил это решение.

(1) Оберните все общие данные в программе в одну структуру данных и оберните их в IORef, (2) Просто внесите любые изменения, используя atomicModifyIORef,

Я считаю, что это предотвращает как взаимные блокировки, так и livelocks (тогда как TVar предотвращает только первые). Также из-за atomicModifyIORef просто связывает другой блок в цепочку (которая является парой операций с указателями), это не бутылочное горлышко. Все реальные операции с данными могут выполняться параллельно, если они не зависят друг от друга. Система исполнения Haskell справится с этим.

Однако я чувствую, что это слишком просто. Есть ли какие-то "ошибки", которые я пропустил?

3 ответа

Этот дизайн, вероятно, будет в порядке, если верно следующее:

  • чтение будет гораздо более распространенным, чем пишет
  • количество операций чтения будет перемежаться между записями
  • (возможно) записи затронут только небольшую часть глобальной структуры данных

Конечно, учитывая эти условия, почти любая параллельная система будет в порядке. Поскольку вы беспокоитесь о livelock, я подозреваю, что вы имеете дело с более сложной схемой доступа. В этом случае читайте дальше.

Ваш дизайн, кажется, руководствуется следующей цепочкой рассуждений:

  1. atomicModifyIORef это очень дешево, потому что это просто создает гром

  2. так как atomicModifyIORef дешево, это не приведет к конфликту потоков

  3. Дешевый доступ к данным + нет конкуренции = параллелизм FTW!

Вот недостающий шаг в этом рассуждении: ваш IORef модификации только создают thunks, и вы не можете контролировать, где thunks оцениваются. Если вы не можете контролировать, где данные оцениваются, у вас нет реального параллелизма.

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

Обходной путь - убедиться, что ваши данные оценены (по крайней мере, так, как вы этого хотите), прежде чем они будут записаны в IORef. Это то, что возвращаемый параметр atomicModifyIORef для.

Рассмотрим эти функции, предназначенные для изменения aVar :: IORef [Int]

doubleList1 :: [Int] -> ([Int],())
doubleList1 xs = (map (*2) xs, ())

doubleList2 :: [Int] -> ([Int], [Int])
doubleList2 xs = let ys = map (*2) xs in (ys,ys)

doubleList3 :: [Int] -> ([Int], Int)
doubleList3 xs = let ys = map (*2) xs in (ys, sum ys)

Вот что происходит, когда вы используете эти функции в качестве аргументов:

  1. !() <- atomicModifyIORef aVar doubleList1 - создается только thunk, данные не оцениваются. Неприятный сюрприз для того, кто читает ветку aVar Следующий!

  2. !oList <- atomicModifyIORef aVar doubleList2 - новый список оценивается только настолько, чтобы определить начальный конструктор, то есть (:) или же [], Тем не менее никакой реальной работы не было сделано.

  3. !oSum <- atomicModifyIORef aVar doubleList3 - оценивая сумму списка, это гарантирует, что вычисление будет полностью оценено.

В первых двух случаях работы очень мало, поэтому atomicModifyIORef быстро выйдет. Но эта работа не была выполнена в этой теме, и теперь вы не знаете, когда это произойдет.

В третьем случае вы знаете, что работа была проделана в заданной теме. Сначала создается блок и обновляется IORef, затем поток начинает оценивать сумму и, наконец, возвращает результат. Но предположим, что какой-то другой поток читает данные, пока вычисляется сумма. Он может начать оценивать сам Thunk, и теперь у вас есть два потока, выполняющих дублирующую работу.

Короче говоря, этот дизайн ничего не решил. Скорее всего, это сработает в ситуациях, когда ваши проблемы параллелизма были несложными, но для крайних случаев, которые вы рассматривали, вы все равно будете прожигать циклы, когда несколько потоков выполняют дублирующую работу. И в отличие от STM, у вас нет контроля над тем, как и когда повторить попытку. По крайней мере, STM вы можете прервать в середине транзакции, с оценкой, это полностью в ваших руках.

Ну, это не будет хорошо сочинять. А сериализация всех ваших модификаций совместно используемой памяти через один IORef будет означать, что только один поток сможет модифицировать совместно используемую память за раз, все, что вы действительно сделали, - это сделали глобальную блокировку. Да, он будет работать, но он будет медленным и далеко не таким гибким, как TVars или даже MVars.

AFAICT, если ваши вычисления оставляют необработанные блоки после того, как они сделают свое дело с IORef содержимое, этот thunk будет просто оцениваться в любом потоке, который пытается использовать результат, а не оцениваться параллельно, как вам бы хотелось. Посмотрите раздел о проблемах MVar документы, здесь

Это может быть более интересным и полезным для других, если вы предоставили конкретную проблему, которую вы пытаетесь решить (или упрощенную, но похожую).

Другие вопросы по тегам