Как / почему функциональные языки (в частности, Erlang) хорошо масштабируются?

92

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

Затем, недавно я посетил презентацию Кевина Смита «Основы Erlang» на Codemash .

Мне понравилась презентация, и я узнал, что многие атрибуты функционального программирования позволяют намного легче избежать проблем с потоками / параллелизмом. Я понимаю, что отсутствие состояния и изменчивости делает невозможным изменение одних и тех же данных несколькими потоками, но Кевин сказал (если я правильно понял), вся связь происходит через сообщения, а сообщения обрабатываются синхронно (опять же, что позволяет избежать проблем с параллелизмом).

Но я читал, что Erlang используется в приложениях с высокой степенью масштабируемости (именно поэтому его создал Эрикссон). Как можно эффективно обрабатывать тысячи запросов в секунду, если все обрабатывается как синхронно обрабатываемое сообщение? Разве не поэтому мы начали двигаться к асинхронной обработке - чтобы мы могли использовать преимущества одновременного выполнения нескольких потоков операций и добиться масштабируемости? Кажется, что эта архитектура, хотя и более безопасна, является шагом назад с точки зрения масштабируемости. Что мне не хватает?

Я понимаю, что создатели Erlang намеренно избегали поддержки потоков, чтобы избежать проблем с параллелизмом, но я думал, что многопоточность необходима для достижения масштабируемости.

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

Джим Андерсон
источник
1
[Не упоминается]: виртуальная машина Erlangs выводит асинхронность на новый уровень. С помощью магии вуду (asm) он позволяет выполнять операции синхронизации, такие как socket: read для блокировки, без остановки потока ОС. Это позволяет вам писать синхронный код, когда другие языки вынуждают вас использовать гнезда асинхронного обратного вызова. Гораздо проще написать приложение для масштабирования с мысленной картиной однопоточных микросервисов VS, имея в виду общую картину каждый раз, когда вы добавляете что-то к базе кода.
Vans S
@Vans S Интересно.
Джим Андерсон

Ответы:

99

Функциональный язык не (в целом) полагаться на мутирует переменную. Из-за этого нам не нужно защищать «общее состояние» переменной, потому что значение фиксировано. Это, в свою очередь, позволяет избежать большинства скачков, которые традиционные языки должны пройти для реализации алгоритма на процессорах или машинах.

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

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

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

Сравните это с традиционной системой: мы должны размещать мьютексы и семафоры вокруг «защищенных» переменных и выполнения кода. У нас есть жесткая привязка к вызову функции через стек (ожидание возврата). Все это создает узкие места, которые представляют меньшую проблему в системе без общего доступа, такой как Erlang.

РЕДАКТИРОВАТЬ: я также должен указать, что Erlang асинхронен. Вы отправляете свое сообщение, и, возможно / когда-нибудь, придет другое сообщение. Или не.

Точка зрения Спенсера об исполнении вне очереди также важна, и на нее есть ответы.

Годеке
источник
Я понимаю это, но не вижу, насколько эффективна модель сообщений. Я бы предположил обратное. Это настоящее открытие для меня. Неудивительно, что языкам функционального программирования уделяется столько внимания.
Джим Андерсон,
3
В системе без общего доступа вы получаете большой потенциал параллелизма . Плохая реализация (например, большие накладные расходы на передачу сообщений) может торпедировать это, но Erlang, похоже, понимает это правильно и сохраняет все легким.
Годеке,
Важно отметить, что хотя у Erlang есть семантика передачи сообщений, он имеет реализацию с разделяемой памятью, таким образом, он имеет описанную семантику, но не одновременно копирует данные повсюду, если в этом нет необходимости.
Аарон Маенпаа,
1
@Godeke: «Erlang (как и большинство функциональных языков) по возможности сохраняет единственный экземпляр любых данных». AFAIK, Erlang на самом деле глубоко копирует все, что проходит между его легковесными процессами из-за отсутствия параллельного GC.
JD
1
@JonHarrop почти прав: когда процесс отправляет сообщение другому процессу, сообщение копируется; за исключением больших двоичных файлов, которые передаются по ссылке. См., Например, jlouisramblings.blogspot.hu/2013/10/embrace-copying.html, чтобы узнать, почему это хорошо.
hcs42
74

Система очереди сообщений хороша тем, что эффективно создает эффект «сработал и ждал результата», который является той синхронной частью, о которой вы читаете. Что делает это невероятно удивительным, так это то, что строки не нужно выполнять последовательно. Рассмотрим следующий код:

r = methodWithALotOfDiskProcessing();
x = r + 1;
y = methodWithALotOfNetworkProcessing();
w = x * y

Представьте на мгновение, что для выполнения метода methodWithALotOfDiskProcessing () требуется около 2 секунд, а для выполнения метода methodWithALotOfNetworkProcessing () требуется около 1 секунды. На процедурном языке выполнение этого кода займет около 3 секунд, потому что строки будут выполняться последовательно. Мы тратим время, ожидая завершения одного метода, который мог бы работать одновременно с другим, не конкурируя за единственный ресурс. На функциональном языке строки кода не определяют, когда процессор попытается их выполнить. Функциональный язык мог бы попробовать что-то вроде следующего:

Execute line 1 ... wait.
Execute line 2 ... wait for r value.
Execute line 3 ... wait.
Execute line 4 ... wait for x and y value.
Line 3 returned ... y value set, message line 4.
Line 1 returned ... r value set, message line 2.
Line 2 returned ... x value set, message line 4.
Line 4 returned ... done.

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

РЕДАКТИРОВАТЬ:

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

Спенсер Рупорт
источник
Круто! Я совершенно неправильно понял, как обрабатываются сообщения. Спасибо, ваш пост помогает.
Джим Андерсон,
«Функциональный язык мог бы попробовать что-то вроде следующего» - я не уверен насчет других функциональных языков, но в Erlang пример будет работать точно так же, как в случае с процедурными языками. Вы можете выполнять эти две задачи параллельно, создавая процессы, позволяя им выполнять две задачи асинхронно и получая их результаты в конце, но это не похоже на то, что «хотя код синхронный, он имеет тенденцию иметь другое значение, чем в процедурных языках. " См. Также ответ Криса.
hcs42
16

Вероятно, вы смешиваете синхронный с последовательным .

Тело функции в erlang обрабатывается последовательно. Так что то, что Спенсер сказал об этом «автомагическом эффекте», не относится к erlang. Однако вы можете смоделировать это поведение с помощью erlang.

Например, вы можете запустить процесс, который вычисляет количество слов в строке. Поскольку у нас есть несколько строк, мы создаем по одному процессу для каждой строки и получаем ответы для вычисления суммы.

Таким образом мы порождаем процессы, которые выполняют «тяжелые» вычисления (используя дополнительные ядра, если они доступны), а позже мы собираем результаты.

-module(countwords).
-export([count_words_in_lines/1]).

count_words_in_lines(Lines) ->
    % For each line in lines run spawn_summarizer with the process id (pid)
    % and a line to work on as arguments.
    % This is a list comprehension and spawn_summarizer will return the pid
    % of the process that was created. So the variable Pids will hold a list
    % of process ids.
    Pids = [spawn_summarizer(self(), Line) || Line <- Lines], 
    % For each pid receive the answer. This will happen in the same order in
    % which the processes were created, because we saved [pid1, pid2, ...] in
    % the variable Pids and now we consume this list.
    Results = [receive_result(Pid) || Pid <- Pids],
    % Sum up the results.
    WordCount = lists:sum(Results),
    io:format("We've got ~p words, Sir!~n", [WordCount]).

spawn_summarizer(S, Line) ->
    % Create a anonymous function and save it in the variable F.
    F = fun() ->
        % Split line into words.
        ListOfWords = string:tokens(Line, " "),
        Length = length(ListOfWords),
        io:format("process ~p calculated ~p words~n", [self(), Length]),
        % Send a tuple containing our pid and Length to S.
        S ! {self(), Length}
    end,
    % There is no return in erlang, instead the last value in a function is
    % returned implicitly.
    % Spawn the anonymous function and return the pid of the new process.
    spawn(F).

% The Variable Pid gets bound in the function head.
% In erlang, you can only assign to a variable once.
receive_result(Pid) ->
    receive
        % Pattern-matching: the block behind "->" will execute only if we receive
        % a tuple that matches the one below. The variable Pid is already bound,
        % so we are waiting here for the answer of a specific process.
        % N is unbound so we accept any value.
        {Pid, N} ->
            io:format("Received \"~p\" from process ~p~n", [N, Pid]),
            N
    end.

И вот как это выглядит, когда мы запускаем это в оболочке:

Eshell V5.6.5  (abort with ^G)
1> Lines = ["This is a string of text", "and this is another", "and yet another", "it's getting boring now"].
["This is a string of text","and this is another",
 "and yet another","it's getting boring now"]
2> c(countwords).
{ok,countwords}
3> countwords:count_words_in_lines(Lines).
process <0.39.0> calculated 6 words
process <0.40.0> calculated 4 words
process <0.41.0> calculated 3 words
process <0.42.0> calculated 4 words
Received "6" from process <0.39.0>
Received "4" from process <0.40.0>
Received "3" from process <0.41.0>
Received "4" from process <0.42.0>
We've got 17 words, Sir!
ok
4> 
Крис Чура
источник
13

Ключевым моментом, который позволяет масштабировать Erlang, является параллелизм.

Операционная система обеспечивает параллелизм с помощью двух механизмов:

  • процессы операционной системы
  • потоки операционной системы

Процессы не разделяют состояние - один процесс не может вызвать сбой другого по замыслу.

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

С Erlang - один процесс операционной системы используется виртуальной машиной, а виртуальная машина обеспечивает параллелизм для программы Erlang, не используя потоки операционной системы, а предоставляя процессы Erlang - то есть Erlang реализует свой собственный временной ограничитель.

Эти процессы Erlang взаимодействуют друг с другом, отправляя сообщения (обрабатываются виртуальной машиной Erlang, а не операционной системой). Процессы Erlang обращаются друг к другу, используя идентификатор процесса (PID), который состоит из трех частей <<N3.N2.N1>>:

  • процесс № N1 на
  • ВМ N2 на
  • физическая машина N3

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

Erlang является потокобезопасным только в тривиальном смысле - у него нет потоков. (Язык SMP / многоядерная виртуальная машина использует один поток операционной системы на ядро).

Гордон Гатри
источник
7

Возможно, вы неправильно понимаете, как работает Erlang. Среда выполнения Erlang сводит к минимуму переключение контекста на ЦП, но если доступно несколько ЦП, все они используются для обработки сообщений. У вас нет «цепочек» в том смысле, в котором они есть на других языках, но вы можете обрабатывать много сообщений одновременно.

Кристофер Джонсон
источник
4

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

Похоже, вы перепутали синхронное и последовательное, как упомянул Крис.

Дзебу
источник
-2

На чисто функциональном языке порядок оценки не имеет значения - в приложении функции fn (arg1, .. argn) n аргументов могут оцениваться параллельно. Это гарантирует высокий уровень (автоматического) параллелизма.

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

mfx
источник