Инструменты интроспекции в Erlang/OTP

Максим Трескин

Аннотация: В статье описаны отладочные механизмы Erlang/OTP: способы трассировки обмена сообщениями между легковесными процессами, отслеживание обращений к функциям и наблюдение за состояниями процессов. Цель статьи — познакомить читателя с одним из главных достоинств платформы, позволяющим увеличить скорость написания рабочего приложения и снизить временные затраты на его отладку.


The article describes debugging tools provided by Erlang platform: the means for tracing message flows, function calls invocations, and inspection of the process state. These features are considered to be the major part of the Erlang platform’s advantage, decreasing implementation and debugging time.



Обсуждение статьи ведётся в LiveJournal.

1  Введение

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

Можно сказать, что лучше проектировать систему так, чтобы эти инструменты оказались лишними, однако это возможно только теоретически; реальная жизнь же, как правило, сурова и непредсказуема. Например, у заказчика на противоположной стороне земного шара работает сложная, состоящая из многих модулей система, и при превышении некоторой пороговой нагрузки (а то и просто в зависимости от фазы Луны) в ней начинает происходить что-то странное, обусловленное ошибкой в коде или алгоритме (те, кто всегда пишет программы без ошибок — смело бросайте в меня камень покрупнее). Хорошо, если вы предусмотрели ведение логов в критических участках системы — это может вам помочь. Но всего не предусмотришь, и если ошибка затаилась в секции кода, в котором логов отродясь не было, необходимы средства трассировки. Создать систему отслеживания событий в приложении, которая может быть прозрачно использована без серьёзных изменений в коде, проще всего, когда для выполнения программных инструкций используется виртуальная машина.

Erlang/OTP использует виртуальную машину BEAM (Bogdan/Björn’s Erlang Abstract Machine), в которой реализованы (в том числе) следующие возможности:

В этой статье мы рассмотрим работу со следующими трассировочными функциями:

Модуль eintro, содержащий код функций-обёрток, написан автором статьи и доступен на сайте журнала.

Запуск ноды Erlang с последующей компиляцией модуля eintro выглядит так:

[zert@pluto]:erlang-introspection $>> erl
Erlang R13B04 (erts-5.7.5) [source] [...]

Eshell V5.7.5  (abort with ^G)
1> c(eintro).
{ok,eintro}

2  Процессы

Процесс в Erlang/OTP порождается с помощью встроенной функции (built-in function, BIF) spawn, которая принимает в качестве аргументов имя функции и список параметров, с которыми она будет вызвана. Функция spawn возвращает идентификатор процесса (pid()), используя который, мы можем осуществлять различные действия над этим процессом.

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

2.1  Основные события процессов (создание, завершение, выполнение, приём и передача сообщений)

Итак, создадим первый процесс, используя функцию eintro:loop/1, выставив предварительно трассировку для всех новых процессов (параметр new). Трассировать будем по флагам procs и running. Первый флаг даёт возможность наблюдать за порождением нового процесса (метка spawn), завершением процесса (exit), регистрацией и дерегистрацией под символьным именем (register/unregister), связыванием процессов и отменой связывания (link/unlink), получении событий о связывании от другого процесса (getting_linked/getting_unlinked). Использование второго флага покажет моменты, в которые процесс планируется на выполнение (in) и вытесняется другим процессом (out):

1> T = eintro:tracer(new, [procs, running]).
<0.35.0>
<0.36.0> will run in {erlang,apply,2}
<0.36.0> was running in {io,wait_io_mon_reply,2}

В переменной T находится значение идентификатора процесса, принимающего трассировочные сообщения — <0.35.0>. Идентификатор <0.36.0> принадлежит командной оболочке Erlang и нам сейчас не интересен.

2> Pid = spawn(eintro, loop, [5]).
<0.36.0> will run in {io,wait_io_mon_reply,2}
<0.36.0> exits with {ok,[{match,1,
                                {var,1,'Pid'},
                                {call,1,
                                      {atom,1,spawn},
                                      [{atom,1,eintro},
                                       {atom,1,loop},
                                       {cons,1,{integer,1,5},{nil,1}}]}}],
                        2}
<0.37.0> will run in {eintro,loop,1}
<0.37.0>
<0.37.0> was running in {eintro,loop,1}
<0.38.0> will run in {erlang,apply,2}
<0.38.0> was running in {io,wait_io_mon_reply,2}

Здесь необходимо игнорировать уже два идентификатора: <0.36.0> и <0.38.0> (в реальных приложениях обеспечивать подобную фильтрацию необходимо либо с помощью сопоставления по шаблону, либо регистрацией нужных процессов). Идентификатор <0.37.0> хранится в переменной Pid, так как этот процесс нас и интересует. Видно, как процесс помещается планировщиком на выполнение в функции eintro:loop/1 (<0.37.0> will run in {eintro,loop,1}), затем, так как в этой функции сразу же делается вызов receive, процесс вытесняется до получения какого-либо сообщения (<0.37.0> was running in {eintro,loop,1}).

Теперь завершим старый трассировщик, чтобы он больше не сбивал нас сообщениями о ненужных процессах (нам сейчас известен конкретный Pid), и запустим новый трассировочный процесс с новыми флагами:

3> exit(T, kill).
true

4> NT = eintro:tracer(Pid, [procs, running, send, 'receive']).
<0.40.0>

Флаг send отвечает за трассировку отсылки сообщений и порождает два события: метка send — непосредственно отсылка сообщения, и метка send_to_non_existing_process — посылка сообщения несуществующему процессу. Флаг receive включает трассировку приёма сообщений заданным процессом1 (метка receive).

Настал момент послать нашему процессу какое-нибудь сообщение:

5> Pid ! pass.
pass
<0.37.0> receives pass
<0.37.0> will run in {eintro,loop,1}
<0.37.0> was running in {eintro,loop,1}

Трассировщик сообщает, что процесс:

И вот наступает финальный момент (по крайней мере, для процесса <0.37.0>). Посылаем ему сообщение, получив которое, он завершается:

6> Pid ! stop.
<0.37.0> receives stop
Count: 4
stop
<0.37.0> will run in {eintro,loop,1}
<0.37.0> sends {io_request,<0.37.0>,<0.26.0>,
                           {put_chars,unicode,io_lib,format,
                                      ["Count: ~p~n",[4]]}} to <0.26.0>
<0.37.0> was running in {io,wait_io_mon_reply,2}
<0.37.0> receives {io_reply,<0.26.0>,ok}
<0.37.0> will run in {io,wait_io_mon_reply,2}
<0.37.0> receives timeout
<0.37.0> exits with normal

Разбираем, что произошло:

Итак, на данном этапе мы ознакомились со следующими событиями трассировки:

2.2  Регистрация процесса

Процесс в OTP может адресоваться как по своему уникальному (в пределах ноды) идентификатору, который возвращается при вызове функции spawn, так и по его зарегистрированному имени, которое мы можем назначить на своё усмотрение, используя BIF register/2.

Вызов register(RegNamePid) связывает имя RegName с идентификатором процесса Pid. После этого можно посылать сообщения процессу, используя это имя. Так как повлиять на значение идентификатора, возвращаемого вызовом spawn, невозможно, его регистрация под фиксированным именем в некоторых случаях может оказаться полезной. BIF unregister(RegName) удаляет ассоциацию идентификатора с именем RegName. Оба этих события — регистрация и дерегистрация — генерируют событие трассировки.

Создаём процесс, используя уже известную нам функцию eintro:loop/1:

1> Pid = spawn(eintro, loop, [5]).
<0.35.0>

Теперь Pid содержит идентификатор этого процесса. Включаем трассировку событий из группы procs от этого процесса:

2> T = eintro:tracer(Pid, [procs]).
<0.37.0>

Вызываем BIF register/2 для создания ассоциации имени test_loop с идентификатором Pid:

3> register(test_loop, Pid).
true
<0.35.0> registered as test_loop

Функция register/2 вернула значение true, и процесс-трассировщик получает сообщение о регистрации {trace, <0.35.0>, registertest_loop}, по которому можно судить о том, под каким именем зарегистрирован процесс.

Проводим обратную процедуру — дерегистрацию:

4> unregister(test_loop).
<0.35.0> unregistered as test_loop
true

Процесс-трассировщик получает сообщение {trace, <0.35.0>, unregistertest_loop}. После этого момента попытка послать сообщение, используя имя test_loop, будет проваливаться с ошибкой badarg.

2.3  Связи

Установление связи между процессами позволяет корректно продолжить работу системы в целом в случае аварийного завершения одного из них. Для связывания двух процессов предназначена BIF link/1. Например, если процесс A вызовет эту функцию с идентификатором процесса B (PidB), то при завершении процесса B с какой-либо причиной, отличной от normal, процесс A получит сигнал выхода (exit) с такой же причиной и завершится. В случае, если для процесса A выставлен флаг trap_exit (process_flag(trap_exittrue)), вместо сигнала exit он получит сообщение следующего вида:

{'EXIT', PidB, Reason}

которое можно обработать точно так же, как и любое другое сообщение. Связь может быть разрушена вызовом функции unlink/12.

События установления и разрушения связи могут наблюдаться во время трассировки. Для демонстрации этого создадим две функции: sv_proc и sv (от supervisor). Первая функция устанавливает флаг trap_exit для процесса в передаваемое значение и передаёт управление второй функции:

sv_proc(TE) -> process_flag(trap_exit, TE), sv().

Вторая функция принимает сообщения, реагирует на них и рекурсивно вызывает себя:

sv() -> receive {link, Pid} -> io:format("Link with ~p~n", [Pid]), link(Pid); {unlink, Pid} -> io:format("Unlink from ~p~n", [Pid]), unlink(Pid); {'EXIT', FromPid, Reason} -> io:format("Trap exit from ~p with reason ~p~n", [FromPid, Reason]); _Other -> io:format("Other message: ~p~n", [_Other]) end, sv().

Определим три собщения:

Запускаем наш знакомый процесс и трассировщик его событий:

1> Pid = spawn(eintro, loop, [5]).
<0.35.0>
2> T = eintro:tracer(Pid, [procs]).
<0.37.0>

Запускаем процесс-супервизор и трассировщик с флагом trap_exit == true, перехватывающий события от него:

3> SV = spawn(eintro, sv_proc, [true]).
<0.39.0>
4> TSV = eintro:tracer(SV, [procs]).
<0.41.0>

Посылаем процессу-supervisor запрос на установление связи с процессом Pid:

5> SV ! {link, Pid}.
Link with <0.35.0>
{link,<0.35.0>}
<0.39.0> links to <0.35.0>
<0.35.0> gets linked to <0.39.0>

Видим, что процесс SV принял это сообщение, после чего сгенерировал событие link, а противоположный процесс сгенерировал событие getting_linked.

Останавливаем процесс Pid:

6> Pid ! stop.
Count: 5
stop
Trap exit from <0.35.0> with reason normal
<0.35.0> exits with normal
<0.39.0> gets unlinked from <0.35.0>

После получения сообщения stop функция loop/1 завершается, при этом процесс, который её выполняет, выходит с причиной normal. Виден момент, когда процесс SV, связанный завершающимся процессом, получает сообщение 'EXIT' с причиной normal.

3  Функции

Функции в Erlang делятся на локальные и внешние. Локальные функции определены в том же модуле, где используются, и вызываются без указания модуля.

eat(_) -> ok. let() -> eat(bee).

Здесь функция eat/1 используется как локальная. Для того, чтобы она была доступна из других модулей, её экспортируют, используя декларацию export перед определениями функций:

-module(rod). -export([perun/1]). veles() -> ok. mara() -> ok. perun(M) -> case M of man -> veles(); woman -> mara() end.

При вызове внешней функции явно указывается имя модуля, в котором она определена:

-module(olymp). zeus(M) -> rod:perun(M).

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

Ещё одно важное различие между локальными и внешними функциями в том, что при вызове внешней функции будет использована самая последняя версия модуля, который её реализует. Другими словами, обращение к внешней функции после горячей замены кода вытеснит предыдущую реализацию функции и выполнится свежий код. Напротив, вызов локальной функции будет обработан той же самой версией кода, в которой этот вызов осуществляется, и реализация функции останется неизменной. Эта особенность позволяет осуществлять замену кода рабочих процессов, написанных в стиле OTP и использующих типичные поведения (behaviors) gen_server, gen_fsm и т. д.

Определим функцию fc_loop/0:

fc_loop() -> receive local -> fc_test(); external -> ?MODULE:fc_test_ext(); lambda -> Fun = fun() -> ok end, Fun() end, fc_loop().

где функция fc_test/0 является локальной, а fc_test_ext/0 — внешней3.

Для простоты примера обе функции будут всего лишь возвращать атом ok:

fc_test() -> ok. fc_test_ext() -> ok.

Запускаем процесс, отлавливающий трассировочные сообщения о вызываемых функциях:

1> T = eintro:tracer(new, [call], {{eintro, '_', '_'}, true, [global]}).
<0.35.0>

Указываем, что нас интересуют все новые процессы (new) в качестве инициаторов вызовов функций (call). В третьем параметре определяем шаблон модуля, функций и аргументов (MFA), по которому мы хотим отслеживать вызовы ({eintro, '_', '_'})4, разрешаем его (true) и ставим флаг global, который означает, что трассировка будет осуществляться по глобальным вызовам внешних функций.

Запускаем интересующий нас процесс:

2> Pid = spawn(eintro, fc_loop, []).
<0.37.0>
<0.37.0> calls {eintro,fc_loop,[]}

Видим, что процесс, созданный BIF spawn, запустился и вызвал функцию eintro:fc_loop/0.

Пошлём процессу сообщение local, по которому он должен вызвать локальную функцию:

3> Pid ! local.
local

Ничего не произошло, трассировочное сообщение не сгенерировалось.

Теперь посылаем сообщение external, процесс должен будет глобально вызвать внешнюю функцию:

4> Pid ! external.
external
<0.37.0> calls {eintro,fc_test_ext,[]}

Как видно из полученного сообщения, это и произошло, была вызвана функция eintro:fc_text_ext/0.

Чтобы иметь возможность наблюдать за вызовами не только внешних, но и определённых локально функций, укажем флаг local при запуске трассировщика:

1> T = eintro:tracer(new, [call], {{eintro, '_', '_'}, true, [local]}).

Трассировка внешних функций при этом флаге не изменится, но появится дополнительное сообщение — вызов функции fc_loop/0 локально:

3> Pid ! external.
external
<0.37.0> calls {eintro,fc_test_ext,[]}
<0.37.0> calls {eintro,fc_loop,[]}

Вызов локальной функции будет выглядеть так:

4> Pid ! local.
local
<0.37.0> calls {eintro,fc_test,[]}
<0.37.0> calls {eintro,fc_loop,[]}

Если послать процессу Pid сообщение lambda, мы увидим следующее:

5> Pid ! lambda.
lambda
<0.37.0> calls {eintro,'-fc_loop/0-fun-0-',[]}
<0.37.0> calls {eintro,fc_loop,[]}

Функция со странным названием '-fc_loop/0-fun-0-'/0 является ничем иным, как λ-функцией. Это внутреннее представление в Erlang VM, необходимое для адресации к ней. В пользовательском коде нельзя ссылаться на это имя, так как нет гарантии, что оно не изменится.

4  Заключение

Помимо перечисленных способов трассировки событий в Erlang/OTP предусмотрены более высокоуровневые инструменты, такие как:

На этом краткий обзор средств, позволяющих узнать немного больше о том, что происходит в работающем приложении, можно считать завершённым. Более полную информацию можно получить на страницах The Erlang BIFs, Debugger/Interpreter Interface и The Text Based Trace Facility документации Erlang/OTP.


1
Обрамляется одинарными кавычками по той причине, что receive является ключевым словом Erlang, а в данном случае необходимо передать в функцию одноимённый атом.
2
В некоторых случаях целесообразнее вместо системы связей использовать мониторы процессов. Они лишены ряда недостатков в сравнении со связями, например, два вызова link/1 подряд и последующий вызов unlink/1 разрушат связь. Описание принципов работы системы мониторов выходит за рамки этой статьи.
3
Несмотря на то, что функция fc_test_ext/0 определена в этом же модуле, обращение к ней осуществляется как к внешней, через явное указание модуля. Макрос ?MODULE заменяется препроцессором на имя текущего модуля.
4
В данном случае трассировка будет осуществляться для вызовов всех функций, определённых в модуле eintro, с любыми аргументами.

Этот документ был получен из LATEX при помощи HEVEA