Erlang поддерживает множество архитектурных стилей для распределенных приложений, но одним из самых простых взаимодействий, которые вы можете создать, является классический протокол клиент / сервер. Это не означает, что это самый простой способ работы с Erlang (который делает большую часть из асинхронности), но это тот, который больше всего похож на императивное программирование. Это также хороший способ расширить наши знания о конструкциях отправки и получения сообщений и о том, как обработанный Эрланг обрабатывает параллельные запросы.
Как они справляются с параллельными запросами?
На самом деле, они этого не делают. Как и для процессов ОС, каждый процесс имеет один поток выполнения, получая по одному сообщению за раз, работая над ним и перезапуская исходную процедуру обработки.
Суть в том, что каждый процесс строго содержит свои структуры данных, поэтому два разных процесса, запущенных одновременно, никогда не могут создать условие гонки, например, в общем списке. Даже при обслуживании нескольких клиентов один процесс действует как фасад, который получает все запросы.
Хватит, покажи нам код!
Давайте создадим очень простую гостевую книгу: люди могут оставлять на ней сообщения, которые должны следовать порядку вставки. Люди могут прочитать последнее сообщение в любое время (для простоты в этом примере); на самом деле мы создаем стек только для вставки, в терминах информатики.
Если мы напишем тест, мы сможем использовать отдельный процесс для сервера и смоделировать несколько запросов от одного клиента (позже мы увидим, как писать тесты для нескольких клиентов, требующих синхронизации).
client_server_test() -> Server = new_book(), ok = new_post(Server, "Hello, world"), ok = new_post(Server, "Greetings"), Post = last_post(Server), ?assertEqual("Greetings", Post).
Сервер содержит pid нового процесса, а new_post / 2 и last_post / 1 являются примитивами, вызываемыми клиентом, которые отправляют запрос на сервер и ожидают ответа. Ответ нормально для пустых операций, в то время как это строка для last_post / 1. Мы можем различать переменные параметры и атомы, если не используем последние.
Вот как работают эти примитивы:
new_post(Server, Text) -> call(Server, Text). last_post(Server) -> call(Server, last). call(Server, Request) -> Server ! {self(), request, Request}, receive {reply, Reply} -> Reply end.
Я следую предложению книги программирования Erlang о извлечении запросов к функции call / 2. То же самое можно сделать для ответов сервера (показано далее в этой статье).
Шаблон взаимодействия является синхронным, так как после отправки сообщения мы ожидаем возвращения нового. Мы должны прикрепить текущий запрос к запросу, чтобы сервер знал, кому отправить ответ. Как всегда, атомы типа запроса и ответа используются для сопоставления кортежей, содержащихся в сообщениях.
Как я и ожидал, серверная часть обрабатывает один запрос за раз:
new_book() -> spawn(fun() -> book() end). book() -> book([]). book(Posts) -> receive {Sender, request, last} -> Sender ! {reply, head(Posts)}, NewPosts = Posts; {Sender, request, NewPost} -> Sender ! {reply, ok}, NewPosts = lists:append([NewPost], Posts) end, book(NewPosts). head([]) -> 'No messages yet.'; head([Head|_Tail]) -> Head.
book / 1 получает одно сообщение, воздействует на него и перезапускает цикл. Я хочу, чтобы вы заметили некоторые особые различия с императивным программированием.
Цикл while (true) реализуется посредством оптимизации хвостовых вызовов: книга вызовов book / 1 (NewPosts), когда она обработала сообщение, и эти вызовы могут продолжаться бесконечно, не исчерпывая стек .
Структуры данных являются неизменными : мы должны создать переменную NewPosts, если мы хотим добавить что-то в список, представляющий состояние сервера.
В императивном обработчике «нового сообщения» мы выполняем синхронизированную операцию над сообщениями и только затем отвечаем « ОК» . Здесь у нас есть свобода работать с сообщениями столько, сколько мы хотим, потому что никакое новое сообщение не будет доставлено процессу, пока мы не запросим его с получением . В некотором смысле, по умолчанию уже выполняется синхронизация на объекте, представляющем процесс: очень трудно ввести условие гонки в коде сервера в этих конструкциях.
Обратите также внимание на соединение идентификаторов сервера и примитивов с идентификатором в качестве первого аргумента. Это похоже на наличие объекта Server: эту двойственность между объектами и функциями очень часто можно увидеть в Erlang (например, в модуле списков ), если у вас есть привычка думать о мире в терминах объекта.
Выводы
Я оставляю команду для остановки сервера в качестве упражнения для читателя; Вы можете начать с кода в репозитории Github для этой серии .
Но мы все еще имеем дело с одним запросом в то время. Это решение с низкой производительностью, поэтому мы постараемся сделать что-то лучше: эквивалент многопоточного сервера.