Как работает concurrency с использованием неизменяемых/постоянных типов и структур данных?

Недавно я много читал о функциональных языках. Поскольку они используют только неизменные структуры, они утверждают, что проблемы concurrency значительно улучшены/решены. У меня возникли серьезные проблемы с пониманием того, как это можно реально реализовать в реальном контексте. Предположим, у нас есть веб-сервер с одним потоком, прослушивающим порт (ну, IO - это еще одна вещь, с которой мне сложно оборачивать голову, но пусть просто игнорирует это на данный момент); При любой попытке подключения сокет создается и передается во вновь созданный поток, который выполняет некоторую работу с ним, и, в зависимости от принятых сообщений, может применять изменения в большой структуре списка/данных, которая является глобальной для серверного приложения. Итак, как это работает для доступа к списку, чтобы все потоки имели последовательное представление списка (или, по крайней мере, чтобы все изменения, внесенные одним потоком, примененным к списку, как только поток будет умереть правильно)

Понимание моих проблем:

  • Очевидно, что любой поток может получить неизменный "снимок" списка для работы. Однако после "изменения" содержимого путем создания новой версии списка с внесенными изменениями мы по-прежнему остаемся с каждым потоком, имеющим собственную версию списка. Как они объединяются вместе?
  • Другой метод может состоять в использовании традиционных механизмов блокировки, таких как mutex/cond или go-like-channels. Однако, как бы вы даже создали такую ​​вещь, когда все переменные неизменяемы?
  • Я слышал о STM, однако это не может касаться побочных эффектов (т.е. если список также будет прозрачно архивировать данные в файл или db)

Итак, как бы вы могли моделировать такую ​​вещь на функциональном языке?

Ответы

Ответ 1

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

Основной пример, который вы здесь показываете, - это управление глобальным состоянием, поэтому его нельзя выполнять исключительно "неизменно". Однако даже здесь есть очень веские причины использовать неизменяемые структуры данных. Некоторые из них с головы:

  • try-catch ведет себя намного лучше, потому что вы не изменяете общий объект, который может быть изменен на полпути, с неизменяемыми значениями, он автоматически сохраняет последнее согласованное состояние
  • сокращение изменяющегося состояния до нескольких многоквартирных безопасных операций "сравнения и свопинга" на очень ограниченном наборе глобальных переменных (в идеале один), полностью устраняет взаимоблокировки
  • свободный проход структур данных без какого-либо оборонительного копирования, что довольно часто случается с таинственными ошибками при забытии (много раз защитные копии создаются как для вызывающих, так и для вызываемых функций, потому что разработчики начинают склоняться к "лучше безопасно, чем извиняться" после пара сеансов отладки)
  • намного проще модульное тестирование, поскольку многие функции, работающие с неизменяемыми значениями, свободны от побочных эффектов.
  • обычно проще сериализация и более прозрачная семантика сравнения гораздо проще отлаживать и принимать (регистрируя) текущий снимок состояния системы даже асинхронно

Вернемся к вашему вопросу.

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

Ссылка обновляется только с помощью атомной операции CAS.

Неизменяемая структура данных преобразуется свободными функциями побочного эффекта, и когда все преобразования выполняются, эта ссылка обменивается атомарно.

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

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

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

STM полезен довольно редко, и обычно вам лучше использовать атомные свопы структуры данных, содержащие все значения из ссылок, которые вы использовали бы в транзакции STM.