The BEAM Book
Written by Erik Stenman and contributors.
This work is licensed under a Creative Commons Attribution 4.0 International License (CC BY 4.0). This means you are free to share, copy, redistribute, and modify this book under the following terms:
-
Attribution: Credit must be given to the original authors and contributors.
-
No Additional Restrictions: You cannot impose legal restrictions that prevent others from using the book freely under the same terms.
Contributors: Special thanks to Richard Carlsson, Yoshihiro Tanaka, Roberto Aloi and Dmytro Lytovchenko.
For full attribution details, visit: https://github.com/happi/theBeamBook/graphs/contributors.
Free Digital Version Available. This book is freely available online at https://github.com/happi/theBeamBook/ under the Creative Commons license. This printed edition is provided for convenience.
For more information on the Creative Commons license, visit: https://creativecommons.org/licenses/by/4.0/.
First Edition, 2025 ISBN: 978-91-531-4253-9
© 2025 Erik Stenman.
- 서문
- I: Understanding ERTS
- II: Running ERTS
- Appendix A: Erlang 런타임 시스템 빌드하기
- Appendix B: BEAM 명령어
- Appendix C: 전체 코드 목록
- 참고 문헌
- Index
서문
Erlang과 Elixir는 지금까지 만들어진 가장 견고한 가상 머신 중 하나에서 실행됩니다. 이 책은 개념적인 수준뿐만 아니라 실제로 일이 일어나는 수준에서 그것이 어떻게 동작하는지 보여줍니다.
이 책은 스타일 가이드가 아닙니다. 문법이나 모범 사례에 관한 책이 아닙니다. gen_server를 어떻게 구성해야 하는지, 슈퍼바이저 트리에 대해 어떻게 논해야 하는지는 다루지 않습니다.
이 책이 할 일은 여러분에게 BEAM의 내부를 안내하는 것입니다. 프로세스를 어떻게 스케줄링하는지. 메모리를 어떻게 할당하는지. 동시성을 어떻게 처리하는지. 압박 상황에서 어떻게 트레이드오프를 하는지. 시스템이 라이브 상태이고 버그가 테스트에서 드러나지 않을 때 중요한 것들.
마지막 부분에서는 트레이싱, 프로파일링, 성능 튜닝을 다룰 것입니다. 하지만 진정한 목표는 여러분의 코드가 런타임에 무엇을 하고 있는지, 그리고 왜 그러는지를 이해할 수 있는 도구를 제공하는 것입니다.
시스템이 어떻게 동작하는지 알면, 더 이상 추측할 필요가 없습니다.
이 책에 대하여
이 책은 다음과 같은 분들을 위한 책입니다:
-
Erlang이 실제로 어떻게 동작하는지 이해하고 싶으신가요.
-
성능이나 신뢰성을 위해 BEAM 설치를 튜닝하고 싶습니다.
-
여러분은 VM 내부에서 발생하는 크래시와 이상 동작을 디버깅하고 싶습니다.
-
여러분은 프로덕션 환경에서 시스템의 성능을 최대한 활용하고 싶어 합니다.
-
아니면 그저 처음부터 직접 런타임을 만들어보고 싶을 수도 있습니다.
성능 향상 기법만 보러 오셨다면, 뒤쪽 장으로 건너뛰어도 좋습니다. 하지만 이러한 도구들 이면에서 무슨 일이 일어나는지, 시스템이 실제로 어떻게 동작하는지 이해하고 싶다면, 처음부터 시작하세요.
이해가 깊어질수록, 더 나은 결정을 내릴 수 있습니다.
이 책에서 사용된 표기법
BEAM VM은 소형 임베디드 하드웨어부터 테라바이트 단위의 메모리를 갖춘 대형 멀티코어 서버에 이르기까지 다양한 시스템에서 실행될 수 있습니다. BEAM 애플리케이션의 성능을 효과적으로 최적화하려면, VM이 메모리와 기타 자원을 어떻게 활용하는지 이해해야 합니다.
이 책 전반에 걸쳐, 메모리 구조와 데이터 레이아웃을 명확하게 설명하기 위해 다이어그램을 광범위하게 사용합니다. 메모리 레이아웃과 스택 연산은 표현 방식이 다를 수 있으므로, 다이어그램에 대해 우리가 채택한 규칙은 다음과 같습니다:
-
메모리 다이어그램:
-
다이어그램에서 메모리 주소는 위쪽으로 증가합니다.
-
낮은 메모리 주소는 하단에, 높은 주소는 상단에 표시됩니다.
-
스택은 일반적으로 높은 주소에서 시작하여 아래쪽으로 성장하며, 새 요소는 더 낮은 주소에 푸시됩니다.
-
-
C 구조 다이어그램:
-
C-구조체는 첫 번째 필드가 위쪽에 표시되도록 표현되지만, 실제로 이 첫 번째 필드들은 더 낮은 메모리 주소를 차지합니다.
-
이로 인해 구조체의 다이어그램이 일반적인 메모리 영역 다이어그램에 비해 뒤집힌 형태로 나타나게 됩니다.
-
구조체와 메모리 영역이 다이어그램에 함께 나타날 때, 이들의 주소 표현이 대칭으로 표시된다는 점에 유의하십시오. 이 관례를 이해하면 책 전반에 걸친 시각적 예제를 해석할 때 혼동을 방지하는 데 도움이 될 것입니다.
이 책을 읽기 위해 Erlang 프로그래머일 필요는 없지만, Erlang이 무엇인지에 대한 기본적인 이해는 필요합니다. 다음 절에서는 Erlang에 대한 배경 지식을 제공합니다.
Erlang
이 절에서는 이 책의 나머지 부분을 이해하는 데 필수적인 몇 가지 기본적인 Erlang 개념들을 살펴보겠습니다.
Erlang은, 특히 Erlang의 창시자 중 한 명인 Joe Armstrong에 의해, 동시성 지향 언어라고 불려왔다. 동시성은 확실히 Erlang의 핵심에 있으며, Erlang 시스템이 어떻게 동작하는지 이해하려면 Erlang의 동시성 모델을 이해해야 한다.
먼저, 동시성(concurrency)과 병렬성(parallelism)을 구분할 필요가 있습니다. 이 책에서 동시성은 서로 독립적으로 실행될 수 있는 둘 이상의 프로세스를 갖는다는 개념이며, 이는 하나의 프로세스를 먼저 실행한 다음 다른 프로세스를 실행하거나, 실행을 인터리빙하거나, 프로세스를 병렬로 실행함으로써 이루어질 수 있다. 병렬 실행이란 여러 개의 물리적 실행 유닛을 사용하여 프로세스들이 실제로 정확히 같은 시각에 실행되는 것을 의미합니다. 병렬성은 여러 수준에서 달성될 수 있습니다. 하나의 코어 내 실행 파이프라인에 있는 여러 실행 유닛을 통해서, 하나의 CPU 내 여러 코어를 통해서, 한 대의 머신에 있는 여러 CPU를 통해서, 또는 여러 대의 머신을 통해서(경우에 따라 서로 다른 위치에 있을 수도 있음) 이루어질 수 있습니다.
Erlang는 동시성을 달성하기 위해 프로세스를 사용한다. 개념적으로, Erlang 프로세스는 대부분의 OS 프로세스와 유사하다: 격리된 메모리 공간이며(따라서 스레드가 아니다), 병렬로 실행되고, 시그널을 통해 통신할 수 있다. 실제로는, Erlang 프로세스가 대부분의 OS 프로세스보다 훨씬 더 경량이라는 점에서 큰 차이가 있다. 다른 많은 동시성 프로그래밍 언어는 Erlang 프로세스에 대응하는 것을 에이전트라고 부른다.
Erlang은 Erlang 가상 머신인 BEAM에서 프로세스의 실행을 인터리빙함으로써 동시성을 달성한다. 멀티코어 프로세서에서 BEAM은 코어당 하나의 스케줄러를 실행하고 스케줄러당 하나의 Erlang 프로세스를 실행함으로써 진정한 병렬성을 달성한다. Erlang 시스템의 설계자는 시스템을 여러 컴퓨터에 분산시킴으로써 추가적인 병렬성을 달성할 수 있다.
일반적인 Erlang 시스템(Erlang으로 구축된 서버 또는 서비스)은 마이크로서비스나 라이브러리와 같이 독립적으로 버전이 관리되는 소프트웨어 단위인 다수의 Erlang 애플리케이션 으로 구성됩니다. 각 애플리케이션은 코드 캡슐화의 기본 단위인 여러 Erlang 모듈 로 구성되며, 각 모듈은 로드 가능한 컴파일된 코드를 위한 .beam 확장자와 컴파일할 소스 코드를 위한 .erl 확장자를 가진 별도의 파일로 존재합니다.
각 모듈은 여러 개의 함수를 포함하며, 그중 일부는 익스포트되어 다른 모듈에서 호출될 수 있고, 나머지는 내부용입니다. 함수는 여러 개의 인자를 받아 값을 반환합니다. 함수의 본문은 표현식으로 구성됩니다. Erlang은 함수형 언어이기 때문에 구문(statement)이 없으며, 결과를 산출하는 표현식만 존재합니다. Erlang 코드 예제에서 Erlang 표현식과 함수의 몇 가지 예시를 볼 수 있습니다.
%% Some Erlang expressions:
true.
1+1.
if (X > Y) -> X; true -> Y end.
%% An Erlang function:
max(X, Y) ->
if (X > Y) -> X;
true -> Y
end.
Erlang은 VM에 의해 구현되는 다수의 내장 함수(또는 BIF)를 가지고 있다. 이는 효율성 때문일 수도 있는데, Erlang 자체로도 쉽게 구현할 수 있었던 lists:append()의 구현이 그 예이다. 또한 Erlang 자체로 구현하기 어렵거나 불가능한 저수준 기능을 제공하기 위한 것일 수도 있는데, list_to_atom()이 그 예이다.
최종 사용자로서 여러분은 네이티브 구현 함수(Native Implemented Functions, NIF) 인터페이스를 사용하여 C로 구현된 자신만의 함수를 제공할 수 있습니다 ([NIFs] 참조).
감사의 글
먼저 Erlang과 Erlang 런타임 시스템을 유지보수해 주시고, 제 모든 질문에 인내심을 갖고 답변해 주신 Ericsson의 OTP 팀 전체에 감사드립니다. 특히 Kenneth Lundin, Björn Gustavsson, Lukas Larsson, Rickard Green, Raimo Niskanen에게 감사드립니다.
또한 이 책에 큰 기여를 해주신 Yoshihiro Tanaka, Roberto Aloi, Dmytro Lytovchenko에게, 그리고 이 책의 작업을 후원해주신 HappiHacking과 TubiTV에게도 감사드리고 싶습니다.
마지막으로, 편집과 수정에 기여해주신 모든 분들께 큰 감사를 드립니다:
Alex Fu |
Alex Jiao |
Alex Martsinovich |
Alexandre Rodrigues |
Amir Moulavi |
Andrea Leopardi |
Anthony Molinaro |
Anton N Ryabkov |
Antonio Nikishaev |
Benjamin Tan Wei Hao |
Borja o’Cook |
Buddhika Chathuranga |
Cameron Price |
Chris Keele |
Chris Yunker |
Christopher Keele |
David Ansari |
Davide Bettio |
Dhony Silva |
Dmytro Lytovchenko |
DuskyElf |
Elias Lanfranconi |
Eric Yu |
Erick Dennis |
Gary Coulbourne |
Greg Baraghimian |
Gustaw Lippa |
Humber Aquino |
Humberto Rodríguez A |
Jan Lehnardt |
Jesper Öqvist |
Joakim Fremstad |
Juan Facorro |
Karl Hallsby |
Ken Causey |
Kian-Meng Ang |
Kian-Meng, Ang |
Kim Shrier |
Kyle Baker |
Lincoln Bryant |
Lukas Larsson |
Luke Imhoff |
Marc van Woerkom |
Meraj |
Meraj Molla |
Michael Daines |
Michael Kohl |
Michał Piotrowski |
Milton Inostroza |
Oskar Köök |
Paul Ivanov |
PlatinumThinker |
Ramkumar Rajagopalan |
Richard Carlsson |
Roberto Aloi |
Ryan Kirkman |
Sergey Yelin |
ShalokShalom |
Simon Johansson |
Stefan Hagen |
Steve Yen |
Thales Macedo Garitezi |
Tobias Lindahl |
Trevor Brown |
Vandern Rodrigues |
Yago Riveiro |
Yoshihiro TANAKA |
Yoshihiro Tanaka |
Yves Müller |
fred |
phf-1 |
techgaun |
tomdos |
yagogarea |
yoshi |
I: Understanding ERTS
1. Erlang 런타임 시스템 소개
엄밀히 말하면, BEAM은 Erlang 코드를 BEAM 바이트코드 명령어 형태로 컴파일하고, 배포하고, 실행하는 데 사용되는 추상 머신 모델만을 가리킵니다. 이는 JVM이 Java를 위한 추상 머신인 것과 마찬가지이며, 동일하게 컴파일된 바이트코드를 해당 VM의 규격을 준수하는 어떤 구현체에서든 실행할 수 있어야 합니다.
단순한 인터프리터나 기본적인 JIT 컴파일러를 작성하여 바이트코드를 실행하는 것은 비교적 작은 작업에 불과합니다 — 빙산의 일각일 뿐입니다. 산업용 수준의 프로그래밍 언어에 기대되는 모든 것을 수행하려면, 자동 메모리 관리, 효율적인 I/O, 프로세스 스케줄링, 멀티코어 활용, OS 시그널, 네트워킹, 파일 시스템 통합, 시간 처리 등 훨씬 더 많은 것이 필요합니다. 이 부분 — 수면 아래 숨겨진 모든 것 — 을 런타임 시스템이라고 합니다. 그러나 일상적인 대화에서는 BEAM VM(그리고 JVM)이라고 할 때, 이를 작동하게 만드는 모든 것을 포함한 추상 기계를 의미하는 경우가 많습니다.
Erlang 런타임 시스템 — ERTS — 은 상호 의존적인 많은 컴포넌트로 이루어진 복잡한 시스템입니다. 이 시스템은 C 프로그래밍 언어로 작성되었으며, 매우 이식성이 높아 소형 스틱 컴퓨터부터 테라바이트급 메모리를 갖춘 최대 규모의 멀티코어 시스템까지 어디서든 실행할 수 있습니다. 이 위에서 BEAM 가상 머신이 실행되며, 이에 대해서는 [The Erlang Virtual Machine: BEAM]에서 더 자세히 설명한다.
1.1. ERTS와 Erlang 런타임 시스템
임의의 Erlang 런타임 시스템과 Erlang 런타임 시스템의 특정 구현체 사이에는 차이가 있습니다. Ericsson의 Erlang/OTP는 Erlang 런타임 시스템과 BEAM의 사실상 표준 구현체입니다. 이 책에서는 이 구현을 ERTS라고 부르거나, T를 대문자로 쓴 전체 표현인 Erlang RunTime System이라고 풀어서 부르겠습니다. (OTP의 정의는 Section 1.3 항목을 참고하십시오.)
Erlang 런타임 시스템이 무엇인지, 또는 Erlang 가상 머신이 무엇인지에 대한 공식적인 정의는 없습니다. ERTS에서 모든 구현별 세부 사항을 제거함으로써 그러한 이상적인 플라톤적 시스템이 어떤 모습일지 어렴풋이 상상해 볼 수는 있을 것입니다. 이것은 불행히도 순환 정의인데, 구현별 세부 사항을 식별하려면 일반적인 정의를 먼저 알아야 하기 때문입니다. Erlang 세계에서 우리는 대개 이런 것에 대해 걱정하기에는 너무 실용적입니다.
우리는 Erlang Runtime System이라는 텀을 사용하여 에릭슨의 특정 구현체(우리가 Erlang RunTime System, 또는 보통 그냥 ERTS라고 부르는)와 대비되는, 일반적인 Erlang 런타임 시스템이라는 개념을 지칭하고자 합니다.
참고 이 책은 대부분 특별히 ERTS에 관한 책이며, 일반적인 Erlang 런타임 시스템에 대해서는 아주 작은 부분만 다룹니다. 일반적인 원칙에 대해 이야기하고 있다고 명확히 언급되지 않는 한, 우리가 Ericsson 구현에 대해 이야기하고 있다고 가정하면 아마 맞을 것입니다.
1.2. 이 책을 읽는 방법
Erlang BEAM VM은 껌 스틱 컴퓨터부터 테라바이트 메모리를 갖춘 최대 규모의 멀티코어 시스템까지, 무엇이든 실행할 수 있는 뛰어난 엔지니어링의 산물입니다. 이러한 시스템의 성능을 여러분의 애플리케이션에 맞게 최적화하려면, 애플리케이션을 아는 것만으로는 충분하지 않으며 가상 머신 자체에 대한 철저한 이해도 필요합니다.
이 책의 Part I에서는 런타임 시스템이 어떻게 동작하는지에 대한 깊은 이해를 얻을 수 있습니다.
이 지식을 갖추면 여러분의 애플리케이션이 BEAM 위에서 실행될 때 어떻게 동작하는지 이해할 수 있을 뿐만 아니라, 애플리케이션의 성능 문제를 찾아 수정할 수도 있을 것입니다.
이 책의 Part II에서는 ERTS를 운영하는 방법, 실행 중인 시스템에 연결하고 검사하는 방법을 살펴볼 것입니다.
이어지는 장들에서는 시스템의 각 구성 요소를 개별적으로 살펴볼 것입니다. 다른 구성 요소가 어떻게 구현되어 있는지 완전히 이해하지 못하더라도 이 장들 중 어느 것이든 읽을 수 있어야 하지만, 각 구성 요소가 무엇인지에 대한 기본적인 이해는 필요합니다. 이 서론 장의 나머지 부분을 읽으면 제1부의 나머지 장들을 원하는 순서대로 자유롭게 넘나들 수 있을 만큼 충분한 기본 이해와 용어를 갖추게 될 것입니다.
그러나 시간이 있다면, 처음에는 순서대로 책을 읽으시기 바랍니다. Erlang과 ERTS에 특유한 단어나 이 책에서 특별한 의미로 사용되는 단어는 보통 처음 등장할 때 설명됩니다. 그런 다음, 용어에 익숙해지면 특정 구성 요소에 대해 문제가 있을 때마다 돌아와서 Part I을 참고 자료로 활용할 수 있습니다.
1.3. ERTS
이 절에서는 ERTS의 주요 구성 요소에 대한 기본적인 개요와 이후 장에서 각 구성 요소를 더 자세히 설명할 때 이해하는 데 필요한 몇 가지 용어를 소개합니다.
1.3.1. Erlang 노드
Erlang 또는 Elixir 시스템을 시작하면, Erlang 런타임 시스템을 실행하는 OS 프로세스가 생성되며, 그 OS 프로세스 내부에서 BEAM VM이 다수의 Erlang 프로세스를 실행합니다. 이렇게 실행 중인 런타임 시스템의 인스턴스는 Erlang 노드라고 불리며(ERTS를 실행하든 다른 Erlang 구현체를 실행하든 관계없이 — [Alternative Erlang Implementations] 참고), 같은 네트워크나 심지어 같은 호스트 머신에서 실행 중일 수 있는 다른 Erlang 노드와 구별하기 위해 이름을 부여받을 수 있다. Java 세계에서 이에 대응하는 것은 JVM 인스턴스이지만, Erlang은 투명한 분산이라는 내장 개념도 가지고 있어서, Erlang 노드들은 네트워크를 통해 서로 연결되어 클러스터를 형성할 수 있습니다.
따라서 여러분의 BEAM 애플리케이션 코드는 항상 Erlang 노드의 컨텍스트 내에서 실행되며, 노드의 모든 계층이 애플리케이션의 성능에 영향을 미칩니다. 우리는 노드를 구성하는 계층의 스택을 살펴볼 것입니다. 이를 통해 다양한 환경에서 시스템을 실행하기 위한 선택지를 이해하는 데 도움이 될 것입니다.
Erlang OTP 문서에 따라 완전히 정확하게 말하자면, 노드는 실제로 이름이 부여되어 클러스터에 참여할 준비가 된 실행 중인 런타임 시스템입니다. 즉, 명령줄 스위치 --name NAME@HOST 또는 --sname NAME(또는 Erlang 런타임의 경우 -name과 -sname) 중 하나를 통해 이름을 지정하지 않고 Elixir를 시작하면, 엄밀히 말해 런타임은 있지만 노드는 없는 상태가 됩니다. 이러한 시스템에서는 함수 Node.alive?(Erlang에서는 is_alive())가 false를 반환합니다.
$ iex
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
[async-threads:10] [kernel-poll:false]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Node.alive?
false
iex(2)>
런타임 시스템 자체는 용어 사용에 있어 그다지 엄격하지 않습니다. 노드에 이름을 부여하지 않았더라도 노드의 이름을 조회할 수 있습니다. Elixir에서는 인자 :this와 함께 함수 Node.list를 사용하고, Erlang에서는 nodes(this) 또는 그냥 node()를 호출할 수 있습니다:
iex(2)> Node.list :this [:nonode@nohost] iex(3)>
이 책에서는 이름이 지정되었는지 여부와 상관없이 실행 중인 런타임의 모든 인스턴스를 가리키는 텀으로 노드를 사용합니다.
이 책의 Part I에서는 ERTS의 구성 요소에 대해 모두 배우게 됩니다. Part II에서는 Erlang 노드를 실행, 검사, 디버그 및 프로파일링하는 방법을 설명합니다. Erlang을 소스에서 빌드하는 방법에 대한 정보는 Appendix A를 참조하십시오.
1.3.2. 실행 환경의 계층
여러분의 애플리케이션은 하나 이상의 노드에서 실행되며, 프로그램의 성능은 애플리케이션 코드뿐만 아니라 코드 아래에 있는 모든 계층, 즉 ERTS 스택에도 좌우됩니다. ERTS 스택에서 하나의 머신에서 두 개의 Erlang 노드가 실행되는 ERTS 스택을 도식으로 확인할 수 있습니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
Node1 Node2
+------+ +------+ +------+ +------+
| App1 | | App2 | | App1 | | App3 |
+------+-+------+ +------+-+------+
| OTP | | OTP |
+---------------+ +---------------+
| BEAM | | BEAM |
+---------------+ +---------------+
| ERTS | | ERTS |
+---------------+--+---------------+
| OS |
+----------------------------------+
| HW or VM |
+----------------------------------+
Elixir를 사용하고 있다면 스택에 또 다른 계층이 하나 더 있습니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. Node1 Node2 +------+ +------+ | Apps | | Apps | +------+ +------+ |Elixir| |Elixir| +------+ +------+ | OTP | | OTP | +------+ +------+ | BEAM | | BEAM | +------+ +------+ | ERTS | | ERTS | +------+--+------+ | OS | +----------------+ | HW or VM | +----------------+
스택의 각 계층을 살펴보고 애플리케이션의 필요에 맞게 어떻게 튜닝할 수 있는지 알아보겠습니다.
스택의 맨 아래에는 여러분이 사용하고 있는 하드웨어가 있습니다. 애플리케이션의 성능을 향상시키는 가장 쉬운 방법은 아마도 더 좋은 하드웨어에서 실행하는 것일 겁니다. 경제적 또는 물리적 제약이나 환경적 문제로 인해 하드웨어를 업그레이드할 수 없다면, 스택의 더 높은 수준을 탐색하기 시작해야 할 수도 있습니다.
하드웨어에 대한 가장 중요한 두 가지 선택은 멀티코어 여부와 32비트인지 64비트인지 여부입니다. 멀티코어 사용 여부와 32비트 또는 64비트 사용 여부에 따라 서로 다른 ERTS 빌드가 필요합니다.
스택의 두 번째 계층은 OS 수준이다. ERTS는 대부분의 Windows 버전과 Linux, FreeBSD, Solaris, Mac OS X를 포함한 대부분의 POSIX "호환" 운영체제에서 실행된다. 오늘날 ERTS 개발의 대부분은 Linux와 OS X에서 이루어지며, 이들 플랫폼에서 최고의 성능을 기대할 수 있다. 그러나 Ericsson은 많은 프로젝트에서 내부적으로 Solaris를 사용해 왔으며, ERTS는 오랜 세월에 걸쳐 Solaris에 맞게 튜닝되어 왔다. 사용 사례에 따라 실제로 Solaris 시스템에서 최고의 성능을 얻을 수도 있다. OS 선택은 보통 성능 요구 사항에 기반하지 않으며, 다른 요인에 의해 제약을 받는다. 임베디드 애플리케이션을 구축하는 경우 Raspbian으로 제한될 수 있고, 어떤 이유로든 최종 사용자 또는 클라이언트 애플리케이션을 구축하는 경우 Windows를 사용해야 할 수도 있다. ERTS의 Windows 포트는 지금까지 최우선 순위가 아니었으며, 성능이나 유지보수 관점에서 최선의 선택이 아닐 수 있다. 64비트 ERTS를 사용하려면 당연히 64비트 머신과 64비트 OS가 모두 필요하다. 이 책에서는 OS 관련 질문을 많이 다루지 않을 것이며, 대부분의 예제는 Linux에서 실행하는 것을 전제로 한다.
스택의 세 번째 계층은 Erlang 런타임 시스템입니다. 우리의 경우 이것은 ERTS가 됩니다. 이 계층과 네 번째 계층인 Erlang 가상 머신(BEAM)이 바로 이 책에서 다루는 핵심 내용입니다.
다섯 번째 계층인 OTP는 Erlang 표준 라이브러리를 제공합니다. OTP는 원래 "Open Telecom Platform"의 약자였으며, 견고한 애플리케이션(전화 교환기 같은)을 구축하기 위한 빌딩 블록(슈퍼바이저, gen_server, gen_tcp 등)을 제공하는 다수의 Erlang 라이브러리였습니다. 초기에 이 라이브러리들과 OTP의 의미는 ERTS와 함께 배포되는 다른 모든 표준 라이브러리와 뒤섞이게 되었습니다. 오늘날 대부분의 사람들은 ERTS와 Ericsson이 배포하는 모든 Erlang 라이브러리를 가리키는 이름으로 Erlang과 OTP를 합쳐 "Erlang/OTP"라는 텀을 사용합니다. 이러한 표준 라이브러리를 알고, 언제 어떻게 사용해야 하는지를 아는 것은 애플리케이션의 성능을 크게 향상시킬 수 있습니다. 이 책에서는 표준 라이브러리와 OTP에 대한 세부 사항을 다루지 않으며, 이러한 측면을 다루는 다른 책들이 많이 있습니다.
Elixir 프로그램을 실행하는 경우 여섯 번째 레이어는 Elixir 환경과 Elixir 라이브러리를 제공합니다.
마지막으로, 일곱 번째 계층(Apps)은 여러분의 애플리케이션과 사용하는 서드파티 라이브러리입니다. 애플리케이션은 하위 계층에서 제공하는 모든 기능을 사용할 수 있습니다. 하드웨어를 업그레이드하는 것 외에, 이 계층이 아마도 애플리케이션의 성능을 가장 쉽게 개선할 수 있는 곳일 것입니다. Chapter 21에서는 애플리케이션을 프로파일링하고 최적화하는 데 도움이 되는 힌트와 도구들을 소개합니다. Chapter 18에서는 애플리케이션 충돌의 원인을 찾는 방법과 애플리케이션에서 버그를 찾는 방법을 살펴볼 것입니다.
1.3.3. 분산
Erlang 언어 설계자들의 핵심 통찰 중 하나는 24시간 연중무휴로 동작하는 시스템을 구축하려면 하드웨어 장애를 처리할 수 있어야 한다는 것이었습니다. 따라서 시스템을 최소 두 대의 물리적 머신에 분산시켜야 합니다. 이를 위해 각 머신에서 노드를 시작한 다음, 노드들을 서로 연결하면 프로세스들이 마치 동일한 노드에서 실행되는 것처럼 노드 간에 서로 통신할 수 있습니다. 분산에 대한 자세한 내용은 Chapter 14을 참조하십시오.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. Node1 Node2 Node3 Node4 +------+ +------+ +------+ +------+ | Apps | | Apps | | Apps | | Apps | +------+ +------+ +------+ +------+ |Elixir| |Elixir| |Elixir| |Elixir| +------+ +------+ +------+ +------+ | OTP | | OTP | | OTP | | OTP | +------+ +------+ +------+ +------+ | BEAM | | BEAM | | BEAM | | BEAM | +------+ +------+ +------+ +------+ | ERTS | | ERTS | | ERTS | | ERTS | +------+--+------+ +------+--+------+ | OS | | OS | +----------------+ +----------------+ | HW or VM | | HW or VM | +----------------+--+----------------+ | Network | +------------------------------------+
1.3.4. Erlang 컴파일러
Erlang 컴파일러는 Erlang 소스 코드를 .erl 파일에서 BEAM 가상 머신 코드로 컴파일하는 역할을 합니다. 컴파일러 자체가 Erlang 애플리케이션으로, Erlang으로 작성되었으며 스스로를 BEAM 코드로 컴파일합니다. 배포되는 Erlang 기반 시스템(릴리스)에서는, 해당 시스템이 코드를 실행할 뿐만 아니라 컴파일도 할 수 있어야 하는지에 따라 컴파일러가 포함될 수도 있고 포함되지 않을 수도 있습니다.
런타임 시스템을 부트스트랩하기 위해, Erlang의 소스 분산본에는 bootstrap 및 erts/preloaded 디렉터리에 컴파일러를 포함한 다수의 미리 컴파일된 BEAM 파일이 포함되어 있습니다.
컴파일러에 대한 더 자세한 정보는 Chapter 2를 참조하십시오.
1.3.5. Erlang 가상 머신: BEAM
BEAM은 JVM이 Java 코드를 실행하는 데 사용되는 것처럼, Erlang 코드를 실행하는 데 사용되는 가상 머신입니다. BEAM 코드는 Erlang 노드의 컨텍스트 내에서 실행됩니다.
ERTS가 Erlang 런타임 시스템이라는 보다 일반적인 개념의 구현인 것처럼, BEAM 역시 보다 일반적인 Erlang 가상 머신 (EVM)의 구현입니다. EVM을 구성하는 것이 무엇인지에 대한 정의는 없지만, BEAM은 실제로 두 가지 수준의 명령어를 가지고 있습니다: 일반 명령어와 특정 명령어. 제네릭 명령어 집합은 EVM의 청사진으로 볼 수 있습니다.
BEAM에 대한 자세한 설명은 Chapter 5과 이후 장들에서 확인할 수 있습니다.
1.3.6. 프로세스
Erlang 프로세스는 기본적으로 OS 프로세스와 유사하게 동작한다. 각 프로세스는 자체적인 전용 메모리(메일박스, 힙, 스택)와 프로세스에 대한 정보를 담고 있는 프로세스 제어 블록(PCB)을 가진다. Erlang 프로세스는 스레드와 다르다 — 수정할 수 있는 공유 메모리가 없으며, 오직 메시지를 통해서만 통신할 수 있고, 한 프로세스의 관점에서 볼 때 다른 프로세스가 같은 VM에서 실행되는지 네트워크로 연결된 다른 노드에서 실행되는지는 거의 차이가 없다. Erlang 프로그램은 락이나 보호 구역을 필요로 하지 않는다.
모든 Erlang 코드 실행은 프로세스의 컨텍스트 내에서 이루어진다. 하나의 Erlang 노드는 많은 프로세스를 가질 수 있으며, 이들은 메시지 패싱과 시그널을 통해 통신한다. Erlang 프로세스는 노드들이 연결되어 있는 한 다른 Erlang 노드의 프로세스와도 통신할 수 있다.
프로세스와 PCB에 대해 더 알아보려면 Chapter 3를 참조하세요.
1.3.7. 스케줄링
스케줄러는 실행할 Erlang 프로세스를 선택하는 역할을 담당합니다. 기본적으로 스케줄러는 두 개의 큐를 유지하는데, 실행할 준비가 된 프로세스들의 준비 큐와 메시지를 수신하기를 기다리는 프로세스들의 대기 큐입니다.
스케줄러는 준비 큐(ready queue)에서 첫 번째 프로세스를 선택하여 하나의 타임 슬라이스(time slice) 동안 실행하도록 BEAM에 전달합니다. BEAM은 타임 슬라이스가 소진되면 실행 중인 프로세스를 선점하고 해당 프로세스를 준비 큐의 끝으로 다시 이동시킵니다. 타임 슬라이스가 소진되기 전에 프로세스가 receive에서 블로킹되면, 해당 프로세스는 대신 대기 큐로 이동됩니다. 대기 큐에 있는 프로세스가 메시지를 수신하거나 타임아웃이 발생하면, 해당 프로세스는 준비 큐로 이동됩니다.
Erlang은 본질적으로 동시성을 지닌 언어로, 각 프로세스는 개념적으로 다른 모든 프로세스와 동시에 실행되지만, 실제로는 스케줄러가 한 번에 하나의 프로세스만 실행합니다. 따라서 단일 코어 머신에서도, 그리고 기반 OS가 선점을 지원하지 않더라도, BEAM은 여전히 수십만 개의 동시 프로세스를 가진 동일한 Erlang 프로그램을 실행할 수 있습니다.
멀티코어 머신에서 Erlang은 별도의 OS 스레드에서 자동으로 하나 이상의 스케줄러를 실행하며 — 보통 물리적 코어당 하나씩, 각각 고유한 실행 큐를 가집니다. 하나의 스케줄러가 할 일이 없어지면, 다른 스케줄러들로부터 준비 상태의 프로세스 일부를 가져올 수 있습니다. 이러한 방식으로 Erlang은 프로그래머가 신경 쓸 필요 없이 진정한 병렬성을 달성합니다.
실제로는 프로세스 간 우선순위가 있고 대기 큐가 타이밍 휠을 통해 구현되므로 상황이 더 복잡합니다. 이 모든 것과 그 이상의 내용은 Chapter 10에서 자세히 설명합니다.
1.3.8. Erlang 태그 스킴
Erlang은 동적 타입 언어이며, 런타임 시스템은 각 데이터 객체의 타입을 추적할 방법이 필요하다. 이는 태깅 체계를 통해 수행된다. 각 데이터 객체 또는 데이터 객체에 대한 포인터에는 해당 객체의 데이터 타입에 대한 정보를 담은 태그가 함께 포함되어 있다.
기본적으로, 포인터의 일부 비트는 태그를 위해 예약되어 있으며, VM은 태그의 비트 패턴을 확인하여 객체의 타입을 결정할 수 있습니다.
이 태그들은 패턴 매칭, 타입 검사, 원시 연산뿐만 아니라 가비지 컬렉터에서도 사용됩니다.
전체 태깅 체계는 Chapter 4에서 설명합니다.
1.3.9. 메모리 처리
Erlang은 자동 메모리 관리를 사용하며, 프로그래머는 메모리 할당과 해제에 대해 신경 쓸 필요가 없습니다. 각 프로세스는 힙과 스택을 가지며, 이들은 필요에 따라 늘어나거나 줄어들 수 있습니다. OS 스레드에서 있을 수 있는 것과 달리, 걱정해야 할 스택 크기 제한도 없고, 스택을 위해 미리 할당된 주소 범위도 없습니다. 초기에는 프로세스의 스택과 힙 모두 매우 작으며, 이 덕분에 수천 또는 수백만 개의 프로세스를 가질 수 있습니다.
프로세스의 힙 공간이 부족해지면, VM은 먼저 가비지 컬렉션을 통해 여유 힙 공간을 회수하려고 시도합니다. 그러면 가비지 컬렉터는 프로세스의 스택과 힙을 순회하면서 살아있는 데이터를 새로운 힙으로 복사하고, 죽은 데이터는 모두 버립니다. 그래도 힙 공간이 충분하지 않으면, 더 큰 새로운 힙이 할당되고 살아있는 데이터가 그곳으로 이동됩니다.
참조 카운트 바이너리의 처리를 포함하여 현재의 세대별 복사 가비지 컬렉션의 세부 사항은 Chapter 11에서 확인할 수 있습니다.
1.3.10. 명령줄 인터페이스와 인터프리터
Erlang 노드를 erl로 시작하면 명령 프롬프트가 나타납니다. 이것은 Erlang 읽기-평가-출력 루프(REPL) 또는 명령줄 인터페이스(CLI) 또는 간단히 Erlang 셸입니다.
Erlang 표현식을 입력하고 셸에서 직접 실행할 수 있습니다. 이 경우 코드는 BEAM 코드로 컴파일되어 BEAM에 의해 실행되는 것이 아닙니다. 대신, 코드는 Erlang 인터프리터에 의해 파싱되고 해석됩니다. 일반적으로 해석된 코드는 컴파일된 코드와 정확히 동일하게 동작하지만, 몇 가지 미묘한 차이가 있습니다 — 특히 해석된 코드는 더 느립니다. 이러한 차이점과 셸의 다른 모든 측면은 Chapter 17에서 설명합니다.
1.4. 대안적인 Erlang 구현체
이 책은 주로 Ericsson/OTP에서 만든 ERTS라 불리는 "표준" Erlang 구현을 다루지만, 몇 가지 다른 구현도 존재하며 이 절에서는 그중 일부를 간략히 살펴볼 것이다.
1.4.1. Xen 위의 Erlang
Erlang on Xen (https://github.com/cloudozer/ling)은 중간에 OS 계층 없이 서버 하드웨어 위에서 직접 실행되는 Erlang 구현체로, 얇은 Xen 클라이언트만을 사용합니다.
Ling은 Erlang on Xen의 가상 머신으로, BEAM과 거의 100% 바이너리 호환됩니다. Erlang On Xen에서 Erlang on Xen의 Erlang Solution Stack 구현이 ERTS 스택과 어떻게 다른지 확인할 수 있습니다. 여기서 주목할 점은 Erlang on Xen 스택에는 운영 체제가 없다는 것입니다.
Ling은 BEAM의 범용 명령어 집합을 구현하므로, Erlang을 Ling으로 컴파일하기 위해 OTP 계층의 BEAM 컴파일러를 재사용할 수 있다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. Node1 Node2 Node2 Node3 +------+ +------+ +------+ +------+ | Apps | | Apps | | Apps | | App | +------+ +------+ +------+ +------+ | OTP | | OTP | | OTP | | OTP | +------+ +------+ +------+ +------+ | Ling | | Ling | | BEAM | | BEAM | +------+ +------+ +------+ +------+ | EoX | | EoX | | ERTS | | ERTS | +------+--+------+ +------+--+------+ | XEN | | OS | +----------------+ +----------------+ | HW | | HW or VM | +----------------+ +----------------+
1.4.2. Erjang
Erjang (https://github.com/trifork/erjang)은 JVM 위에서 실행되는 Erlang 구현체입니다. .beam 파일을 로드하여 코드를 Java .class 파일로 재컴파일합니다. Erjang은 (일반적인) BEAM과 거의 100% 바이너리 호환됩니다.
JVM 위의 Erlang에서 Erlang 솔루션 스택의 Erjang 구현이 ERTS 스택과 어떻게 다른지 확인할 수 있습니다. 여기서 주목할 점은 JVM이 가상 머신으로서 BEAM을 대체했으며, Erjang이 JVM 위에서 Java로 구현함으로써 ERTS의 서비스를 제공한다는 것입니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. Node1 Node2 Node3 Node4 +------+ +------+ +------+ +------+ | Apps | | Apps | | Apps | | Apps | +------+ +------+ +------+ +------+ | OTP | | OTP | | OTP | | OTP | +------+ +------+ +------+ +------+ |Erjang| |Erjang| | BEAM | | BEAM | +------+ +------+ +------+ +------+ | JVM | | JVM | | ERTS | | ERTS | +------+--+------+ +------+--+------+ | OS | | OS | +----------------+ +----------------+ | HW or VM | | HW or VM | +----------------+ +----------------+
이제 ERTS의 모든 주요 구성 요소와 필요한 용어에 대한 기본적인 이해를 갖추었으니, 각 구성 요소의 세부 사항을 자세히 살펴볼 수 있습니다. 특정 구성 요소를 이해하고 싶다면, 해당 장으로 바로 건너뛸 수 있습니다. 또는 특정 문제에 대한 해결책을 빨리 찾고 싶다면, Part II의 적절한 장으로 건너뛰어 시스템을 튜닝, 조정 또는 디버깅하는 다양한 방법을 시도해 볼 수 있습니다.
2. 컴파일러
이 책은 프로그래밍 언어 Erlang을 다루지는 않지만, ERTS의 목표가 Erlang 코드를 실행하는 것이므로 Erlang 코드를 컴파일하는 방법을 알아야 합니다. 이 장에서는 사람이 읽을 수 있는 BEAM 코드를 생성하는 데 필요한 컴파일러 옵션과 생성된 .beam 파일에 디버그 정보를 추가하는 방법을 다룹니다. 장의 끝에는 Elixir 컴파일러에 관한 절도 있습니다.
자신이 좋아하는 언어를 ERTS로 컴파일하는 데 관심이 있는 독자들을 위해, 이 장에서는 컴파일러의 다양한 중간 형식과 자신의 컴파일러를 BEAM 컴파일러 백엔드에 연결하는 방법에 대한 상세한 정보도 다룰 것입니다. 또한 파스 트랜스폼을 소개하고, 이를 사용하여 Erlang 언어를 조정하는 방법에 대한 예제를 제시할 것입니다.
2.1. Erlang 컴파일
Erlang은 .erl 파일의 소스 코드 모듈에서 바이너리 .beam 파일로 컴파일됩니다.
컴파일러는 OS 셸에서 erlc 명령으로 실행할 수 있습니다:
> erlc foo.erl
erlc의 전반적인 동작을 제어하기 위한 -o <Directory>와 같은 여러 옵션이 있으며, 대부분 C 컴파일러 옵션과 유사하다. 전체 목록은 https://www.erlang.org/doc/apps/erts/erlc_cmd.html를 참고하라. 더 구체적인 컴파일러 옵션은 +debug_info처럼 더하기 기호로 시작한다. 이것들은 컴파일러 애플리케이션에 그대로 전달되는 Erlang 텀이며, {나 "와 같은 특수 문자가 포함된 경우 셸에서 따옴표로 묶어야 할 수도 있다. 이러한 옵션의 전체 목록은 compile 모듈의 문서에서 찾을 수 있습니다: http://www.erlang.org/doc/man/compile.html을 참조하십시오.
컴파일러는 Erlang 셸에서도 호출할 수 있습니다. 셸 단축 명령 c()를 사용하면 컴파일 후 모듈이 로드되며, compile:file()을 호출하면 컴파일만 하고 로드하지는 않습니다. 파일 이름은 아톰이나 문자열로 지정할 수 있으며, .erl 확장자를 포함할 필요는 없습니다:
1> c(foo).
{ok, foo}
또는
1> compile:file(foo).
{ok, foo}
이 두 함수 모두 두 번째 인자로 옵션 리스트를 받을 수 있습니다. 이는 erlc에서 +-옵션으로 전달될 수 있는 것과 동일한 텀입니다:
1> c(foo, [nowarn_unused_function, debug_info]).
일반적으로 컴파일러는 .erl 파일에서 Erlang 소스 코드를 컴파일하고 결과로 생성된 바이너리 BEAM 코드를 .beam 파일에 기록합니다. binary 옵션을 compile:file()에 지정하면(단, c() 단축 명령에는 지정할 수 없음) 결과 바이너리를 Erlang 텀으로 돌려받을 수도 있습니다:
1> compile:file(foo, [binary]).
{ok, foo, <<70,79,82,...>>}
일부 옵션은 컴파일러에게 컴파일의 특정 단계 이후에 멈추고 중간 표현을 출력으로 생성하도록 지시합니다. 예를 들어, Core Erlang으로 변환된 후 프로그램이 어떤 모습인지 확인하려면:
1> c(foo, [to_core]).
** Warning: No object file created - nothing loaded **
ok
여기서 경고는 c() 셸 단축 명령에서 발생한 것입니다. .beam 오브젝트 파일이 생성되지 않았으므로, 로드할 새 버전의 모듈이 없었습니다. 대신 이제 텍스트 파일 foo.core가 생겼을 것입니다.
전처리 결과를 확인하고 싶다면, 대신 'P' 옵션을 전달할 수 있습니다:
1> c(foo, ['P']).
** Warning: No object file created - nothing loaded **
ok
이제 출력 파일 foo.P는 모든 include 파일이 읽히고, 매크로가 치환되고, -ifdef와 같은 조건부 컴파일 지시어가 평가된 후의 Erlang 소스 코드가 어떤 모습인지 보여줍니다.
사실, binary 옵션은 “컴파일러가 멈추는 단계에서, 중간 형식을 파일에 쓰는 대신 텀으로 반환하라”는 의미로 오버로드되어 사용되어 왔습니다. 예를 들어 컴파일러가 Core Erlang 코드를 반환하도록 하려면 [to_core, binary] 옵션을 지정하면 됩니다:
1> compile:file(foo, [to_core,binary]).
{ok, foo, {c_module, ...}}
이 경우 텍스트 덩어리가 아니라 실제 내부 표현을 Erlang 텀으로 얻게 된다는 점에 유의하세요. 대부분의 내부 표현에는 대응하는 prettyprinting 함수가 있습니다. 예를 들면:
1> {ok, foo, Core} = compile:file(foo, [to_core,binary]).
{ok, foo, {c_module, ...}}
2> io:put_chars(core_pp:format(Core)).
module 'foo' [...]
...
또한 컴파일러에게 예를 들어 .core 소스 파일과 같은 이후 단계부터 컴파일을 시작하도록 지시할 수도 있으며, erlc를 사용하거나:
> erlc foo.core
또는 Erlang에서 직접:
1> c(foo, [from_core]).
{ok, foo}
중간 코드를 Erlang 텀으로 가지고 있다면, 그것을 곧바로 컴파일러에 다시 전달하여 작업을 계속하도록 지시할 수 있습니다. 다음과 같이 합니다:
1> {ok, foo, Core} = compile:file(foo, [to_core,binary]).
{ok, foo, {c_module, ...}}
3> {ok, foo, Bin} = compile:forms(Core, [from_core,binary]).
{ok,foo, <<70,79,82,...>>}
마지막으로, BEAM 모듈을 바이너리 형태로 가지고 있다면, 실행할 수 있도록 메모리에 직접 로드할 수 있습니다:
4> code:load_binary(foo, "nopath", Bin).
{module,foo}
5> foo:f()
hello
6> code:which(foo).
"nopath"
(이 경우 바이너리가 실제로 어떤 파일에도 저장된 적이 없더라도, 로더에게 모듈 이름과 함께 코드 서버가 오브젝트 파일 경로로 보고할 문자열을 알려주어야 합니다.)
이렇게 하면 컴파일러의 여러 지점에 개입하여 출력을 살펴보거나, 수정한 후 다시 전달하거나, 심지어 대응하는 .erl 파일 없이 다른 방법으로 생성한 저수준 코드를 전달하는 것도 가능합니다 — 그리고 원하지 않는 한 파일에 아무것도 쓰지 않고도 이 모든 것을 할 수 있습니다.
2.2. 컴파일러 개요
컴파일러는 여러 패스로 구성되어 있습니다. 가독성을 위해, 이를 두 부분으로 나눕니다: Erlang 소스 코드를 받아 Core Erlang 코드로 변환하는 프런트엔드(컴파일러 패스 (프론트엔드))와, Core Erlang 수준에서 이어받아 코드를 BEAM 바이트코드로 변환하고 최적화하는 백엔드(컴파일러 패스 (백엔드))입니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
(.erl) [] ⇒ Compiler options
| () ⇒ files
v {} ⇒ erlang terms
+---------------+ boxes ⇒ passes
| Scanner |
| (erl_scan) |
+---------------+
|
v
+---------------+
| Preprocessor |
| (epp) |
+---------------+
|
v
+---------------+
| Parser |
| (erl_parse) |
+---------------+
|
v
+---------------+ +---------------+
| Parse | --> | user defined |
| Transforms | <-- | transformation|
+---------------+ +---------------+
|
+---------> (.Pbeam) [makedep]
+---------> {dep} [makedep, binary]
|
+---------> (.pp) [dpp]
+---------> {AST} [dpp, binary]
|
v
+---------------+
| Linter |
| (erl_lint) |
+---------------+
|
+---------> (.P) ['P']
+---------> {AST} ['P',binary]
|
v
+---------------+
| Save AST |
|for debug info |
+---------------+
|
v
+---------------+
|Expand Imports |
| and Records |
+---------------+
|
+---------> (.E) ['E']
+---------> {.E} ['E', binary]
|
v
+---------------+
| Core |
| Erlang |
+---------------+
|
:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
: [] ⇒ Compiler options
| () ⇒ files
+---------------+ {} ⇒ erlang terms
| Core | boxes ⇒ passes
| Erlang |
+---------------+
|
+---------> (.core) [to_core0]
+---------> {core} [to_core0,binary]
|
v
+---------------+ +---------------+ +---------------+
from_core ----> | Core | ... | Core | ... | Core |
erlc *.core ----> | Pass 0 | | Pass 1 | | Pass N |
+---------------+ +---------------+ +---------------+
|
+---------> (.core) [to_core]
+---------> {core} [to_core,binary]
|
v
+---------------+
| Kernel |
| Erlang |
+---------------+
|
v
+---------------+ +---------------+ +---------------+
| Kernel | ... | Kernel | ... | Kernel |
| Pass 0 | | Pass 1 | | Pass N |
+---------------+ +---------------+ +---------------+
|
v
+---------------+
| BEAM ASM |
| |
+---------------+
|
v
+---------------+ +---------------+ +---------------+
from_asm ----> | ASM | ... | ASM | ... | ASM |
erlc *.S ----> | Pass 0 | | Pass 1 | | Pass N |
+---------------+ +---------------+ +---------------+
|
+---------> (.S) ['S']
+---------> {.S} ['S', binary]
|
v
+---------------+
| BEAM Binary |
| |
+---------------+
|
v
(.beam)
컴파일러 패스의 완전한 최신 목록을 보고 싶다면 Erlang 셸에서 compile:options() 함수를 실행하면 됩니다. 컴파일러에 대한 정보의 최종 출처는 물론 Erlang/OTP 소스 코드입니다: compile.erl
2.3. 중간 출력 생성
컴파일러가 생성한 코드를 살펴보는 것은 가상 머신이 어떻게 동작하는지 이해하는 데 큰 도움이 됩니다. 다행히도 컴파일러는 각 컴파일러 패스 이후의 중간 코드와 최종 beam 코드를 보여줄 수 있습니다.
새로 배운 지식을 활용하여 생성된 코드를 살펴보겠습니다.
1> compile:options().
dpp - Generate .pp file
'P' - Generate .P source listing file
...
'E' - Generate .E source listing file
...
'S' - Generate .S file
작은 예제 프로그램 "world.erl"로 시도해 봅시다:
-module(world).
-export([hello/0]).
-include("world.hrl").
hello() -> ?GREETING.
그리고 include 파일 "world.hrl"
-define(GREETING, "hello world").
이제 파싱된 파일을 얻기 위해 'P' 옵션으로 이것을 컴파일하면 "world.P" 파일이 생성됩니다:
2> c(world, ['P']).
** Warning: No object file created - nothing loaded **
ok
결과로 생성된 .P 파일에서 전처리기(및 파스 변환)가 적용된 후의 코드를 보기 좋게 출력한 버전을 확인할 수 있습니다:
-file("world.erl", 1).
-module(world).
-export([hello/0]).
-file("world.hrl", 1).
-file("world.erl", 4).
hello() ->
"hello world".
모든 소스 코드 변환이 끝난 후 코드가 어떤 모습인지 확인하려면, 'E'-플래그를 사용하여 코드를 컴파일하면 됩니다.
3> c(world, ['E']).
** Warning: No object file created - nothing loaded **
ok
이렇게 하면 .E 파일을 얻게 되는데, 이 경우 모든 컴파일러 지시문이 제거되고, 모든 레코드가 튜플로 확장되며, 함수 임포트 사용이 명시적인 원격 호출로 확장되고, 내장 함수 module_info/{1,2}가 소스에 추가됩니다:
-vsn("\002").
-file("world.erl", 1).
-file("world.hrl", 1).
-file("world.erl", 5).
hello() ->
"hello world".
module_info() ->
erlang:get_module_info(world).
module_info(X) ->
erlang:get_module_info(world, X).
나중에 Section 2.5에서 파스 변환(parse transforms)을 살펴볼 때 'P’와 'E' 옵션을 사용할 것입니다.
다른 모든 중간 단계들을 살펴보기 전에, 사람이 읽을 수 있는 “assembler” 형태로 된 최종 생성 BEAM 코드를 먼저 살펴보겠습니다. 컴파일러에 'S' 옵션을 주면 코드 내 각 BEAM 명령어에 대한 Erlang 텀이 담긴 .S 파일을 얻을 수 있습니다.
3> c(world, ['S']).
** Warning: No object file created - nothing loaded **
ok
world.S 파일은 다음과 같은 모습이어야 합니다:
{module, world}. %% version = 0
{exports, [{hello,0},{module_info,0},{module_info,1}]}.
{attributes, []}.
{labels, 7}.
{function, hello, 0, 2}.
{label,1}.
{line,[{location,"world.erl",6}]}.
{func_info,{atom,world},{atom,hello},0}.
{label,2}.
{move,{literal,"hello world"},{x,0}}.
return.
{function, module_info, 0, 4}.
{label,3}.
{line,[]}.
{func_info,{atom,world},{atom,module_info},0}.
{label,4}.
{move,{atom,world},{x,0}}.
{line,[]}.
{call_ext_only,1,{extfunc,erlang,get_module_info,1}}.
{function, module_info, 1, 6}.
{label,5}.
{line,[]}.
{func_info,{atom,world},{atom,module_info},1}.
{label,6}.
{move,{x,0},{x,1}}.
{move,{atom,world},{x,0}}.
{line,[]}.
{call_ext_only,2,{extfunc,erlang,get_module_info,2}}.
이 파일은 점(.)으로 구분된 Erlang 텀들로 이루어진 파일이므로, 다음과 같이 Erlang 셸로 쉽게 다시 읽어들일 수 있습니다:
{ok, BEAM_Code} = file:consult("world.S").
보시다시피, 어셈블리 코드는 대체로 원본 소스 코드의 구조를 따릅니다.
첫 번째 명령어는 코드의 모듈 이름을 정의합니다. 주석에 언급된 버전(%% version = 0)은 옵코드 형식의 버전입니다(beam_opcodes:format_number/0에 의해 주어짐).
그런 다음 다른 Erlang 소스 모듈과 마찬가지로 export 목록과 컴파일러 속성(이 예제에서는 없음)이 나옵니다.
첫 번째 실제 beam 스타일 명령어는 {labels, 7}이며, 코드 내 레이블의 개수를 VM에 알려주어 코드를 한 번 훑는 것만으로 모든 레이블을 위한 공간을 할당할 수 있게 해준다.
그 다음에는 각 함수에 대한 실제 코드가 있습니다. 첫 번째 명령어는 함수 이름, 애리티(arity), 그리고 레이블 번호로 표현된 진입점을 알려줍니다. 진입점이 함수 내의 첫 번째 레이블이 아니라는 점을 알 수 있는데, 그 이전에 함수에 대한 메타데이터를 정의하는 몇 가지 명령어가 있기 때문입니다. 이것들은 런타임에 실행되지 않습니다.
'S' 옵션을 매우 유용하게 활용하여 BEAM이 어떻게 동작하는지 이해할 수 있으며, 이후 장에서도 그런 방식으로 사용할 것입니다. 또한 Core Erlang을 거쳐 BEAM으로 컴파일되는 자신만의 언어를 개발하는 경우, 생성된 코드를 확인하는 데도 매우 유용합니다.
2.4. 컴파일러 패스
다음 섹션들에서는 컴파일러 패스 (프론트엔드)와 컴파일러 패스 (백엔드)에 나타난 대부분의 컴파일러 패스를 살펴볼 것입니다. BEAM을 타겟으로 하는 언어 설계자에게 이는 흥미로운 부분인데, 매크로, 파스 트랜스폼(parse transform), Core Erlang, 그리고 BEAM 코드라는 서로 다른 접근 방식으로 무엇을 이룰 수 있는지, 그리고 이들이 서로 어떻게 의존하는지를 보여주기 때문입니다.
Erlang 코드를 튜닝할 때, 어떤 최적화가 언제 적용되는지, 그리고 최적화 전후의 생성된 코드를 어떻게 살펴볼 수 있는지 아는 것이 좋습니다.
2.4.1. 컴파일러 단계: Erlang 전처리기 (epp)
컴파일은 토큰화(또는 스캐닝)와 전처리로 시작한다. 전처리기 epp는 파일을 읽고 토크나이저 erl_scan을 호출하여 텍스트를 문자가 아닌 별도의 토큰 시퀀스로 확장하며, 공백과 주석은 버립니다. 그런 다음 매크로 정의와 조건부 컴파일 지시문을 처리하고, 매크로 사용을 치환한다.
전처리기는 include 문을 발견하면, 지정된 include 파일을 읽고 토큰화한 후, 그 결과로 생성된 토큰 시퀀스를 include 문 대신 삽입하고, 이를 다시 처리하여 파일들이 재귀적으로 서로를 include할 수 있도록 합니다. 한 파일에서 다른 파일로 전환할 때, 전처리기는 앞서 예제에서 본 것처럼 -file 어노테이션을 삽입하여, 이후 단계에서 특정 코드 조각이 어디에서 왔는지 알 수 있도록 합니다:
...
-file("world.hrl", 1).
-file("world.erl", 4).
...
world.hrl와 같은 인클루드 파일이 처리될 때, 인클루드 파일은 흔히 매크로 정의와 전처리기 조건문만 포함하므로 실제 코드가 전혀 삽입되지 않을 수도 있다는 점에 유의하세요. 남는 것은 “지금 우리는 헤더 파일의 1번째 줄에 있다”와 “지금 우리는 원본 소스 파일의 4번째 줄로 다시 돌아왔다”라는 주석뿐이다.
이 모든 것은 epp가 (C/C++ 전처리기 cpp와 마찬가지로) 토큰 수준에서 동작한다는 것을 의미하며, 따라서 순수한 문자열 치환 프로세서(예를 들어 m4와 같은 것)는 아니라는 점에 유의하십시오. Erlang 매크로를 사용하여 임의의 사용자 정의 문법을 정의할 수는 없습니다. 매크로는 주변 문자들과 별개의 토큰으로 확장되므로, 매크로와 문자를 하나의 토큰으로 연결할 수 없습니다:
-define(plus,+).
t(A,B) -> A?plus+B.
이는 다음과 같이 확장됩니다
t(A,B) -> A + + B.
그리고 아니다
t(A,B) -> A ++ B.
반면에 매크로 확장은 실제 파싱 이전에 토큰 스트림에서 수행되기 때문에, 매크로의 우변에 유효한 Erlang 텀을 가질 필요는 없으며, 결과적으로 유효한 텀을 만들어내는 방식으로 사용하기만 하면 됩니다. 예를 들면:
-define(p,o, o]).
t() -> [f,?p.
토큰 시퀀스로 확장될
t ( ) -> [ f , o , o ] .
이것을 실제로 사용할 만한 곳은 난독화된 Erlang 코드 콘테스트에서 우승하는 것 외에는 거의 없습니다. 기억해야 할 핵심은 Erlang 전처리기를 사용해서 Erlang과 다른 문법을 가진 언어를 정의할 수는 없다는 점입니다.
2.4.2. 컴파일러 단계: Erlang 파서
erl_parse 파서는 전처리기로부터 최종 토큰 시퀀스를 받아 Erlang 문법에 따라 검사하며, 프로그램을 순차적인 텍스트가 아닌 데이터 구조로 표현하는 추상 구문 트리 또는 AST를 생성한다 (http://www.erlang.org/doc/apps/erts/absform.html 참고).
2.4.3. 컴파일러 단계: 파스 변환(Parse Transformations)
parse transform은 AST를 다루는 함수입니다. 컴파일러가 초기 토큰화, 전처리, 파싱을 마치고, 지금까지 오류가 없었다고 가정하면, parse transform 함수가 선언되어 있을 경우 이를 호출하여 현재 AST를 전달하고 수정된 AST를 반환받기를 기대합니다.
이는 입력이 여전히 Erlang 파서에 의해 받아들여져야 하기 때문에 Erlang 문법을 근본적으로 바꿀 수는 없지만, 코드를 원하는 대로 수정하여 의미를 바꿀 수는 있다는 것을 의미합니다.
파스 변환(parse transform)은 사용될 모듈에서 다음과 같이 선언됩니다:
-compile({parse_transform, my_pt}).
컴파일러는 그런 다음 my_pt:parse_transform(Forms, Options)를 추가 패스로 호출하려고 시도합니다. 같은 모듈에서 여러 parse transform을 사용하는 것도 가능하며, 이 경우 단순히 순서대로 실행됩니다.
Parse transform은 Mnesia(QLC 문법을 위해), EUnit, Merl 등의 라이브러리에서 사용됩니다. Section 2.5에서 자신만의 parse transform을 구현하는 방법에 대한 전체 예제를 보여드리겠습니다.
2.4.4. 컴파일러 패스: 린터
“linter”라는 텀은 원래 컴파일러에 부가적으로 추가되는 도구 — 마치 옷 건조기의 보풀 필터처럼 — 로서, 컴파일러 자체가 검사하지 않는 스타일적 오류나 안티패턴, 그 밖의 것들을 검사하는 것을 의미했다(당시 컴파일러는 사용 가능한 메모리가 매우 적었고 속도에 집중되어 있었기 때문이다). 하지만 컴퓨터가 더 빨라지면서, 린터는 가끔씩만이 아니라 항상 실행하고 싶은 하나의 패스로서 컴파일러 자체에 통합되었다.
Erlang 컴파일러에서 린터 erl_lint는 파싱과 변환이 끝난 후 소스 코드의 대부분의 검사를 수행하는 단계이다. (이는 파스 변환(parse transform)이 린터가 허용하는 AST만 반환할 수 있음을 의미한다.)
린터는 정의되지 않은 변수 및 함수와 같은 오류를 검사하며, "export_all flag enabled"와 같이 올바르지만 의심스러운 코드에 대한 경고도 생성합니다.
2.4.5. 컴파일러 패스: 추상 구문 트리(AST) 저장
모듈의 디버깅을 활성화하려면 해당 모듈을 “debug compile” 해야 합니다. 즉, 컴파일러에 debug_info 옵션을 전달해야 합니다. 그러면 추상 구문 트리가 “Save AST” 패스에 의해 저장되며, 컴파일이 끝날 때까지 유지되었다가 중단점 설정, 단일 스텝 실행 등을 위해 .beam 파일(BEAM 파일)에 포함됩니다. 이후 코드는 컴파일된 상태가 아니라 인터프리터로 실행됩니다.
코드는 어떤 최적화가 적용되기 전에 저장된다는 점을 유의하는 것이 중요합니다. 따라서 컴파일러의 최적화 단계에 버그가 있고 디버거에서 인터프리터 코드를 실행하면 다른 동작을 얻게 됩니다. 자신만의 컴파일러 최적화를 구현하고 있다면, 이는 심각한 문제를 일으킬 수 있습니다.
2.4.6. 컴파일러 패스: Expand
확장 단계에서는 레코드와 같은 소스 수준의 Erlang 구성 요소가 더 낮은 수준의 erlang 구성 요소로 확장됩니다. 이는 컴파일러 지시어를 제거하고, 레코드 구문을 튜플에 대한 연산으로 대체하며, 함수 임포트 사용을 명시적인 원격 호출로 확장하고, 내장 함수 module_info/{1,2}를 코드에 추가합니다.
2.4.7. 컴파일러 패스: Core Erlang
Core Erlang은 컴파일러 최적화에 적합한 엄격한(strict) 함수형 언어이다. 동일한 연산을 표현하는 방식의 수를 줄임으로써 코드 변환을 더 쉽게 만든다. 이를 수행하는 한 가지 방법은 스코프를 더 명시적으로 만들기 위해 let 및 letrec 표현식을 도입하는 것이다. 컴파일러는 core 레벨에서 여러 차례의 패스(pass)를 수행한다.
Core Erlang은 ERTS에서 실행하고자 하는 언어를 위한 좋은 타겟이 될 수 있습니다. 이 언어는 거의 변경되지 않으며 Erlang의 모든 측면을 깔끔한 방식으로 담고 있습니다. BEAM 명령어 집합을 직접 타겟으로 삼으면 훨씬 더 많은 세부 사항을 다뤄야 하며, 그 명령어 집합은 보통 ERTS의 주요 릴리스마다 조금씩 변경됩니다. 반면에 Erlang을 직접 타겟으로 삼으면 표현할 수 있는 것에 더 많은 제약이 따르며, Core Erlang이 더 깔끔한 언어이기 때문에 더 많은 세부 사항을 다뤄야 할 수도 있습니다.
하지만 일반 Erlang을 타겟으로 하면 생성된 코드가 올바르게 동작하는지에 대한 컴파일 타임 검사를 더 많이 받을 수 있다는 점에 유의하십시오. Core Erlang을 직접 생성하면 Erlang 컴파일러가 이전에 한 번도 마주친 적 없는 예외적인 경우(corner case)를 발견하게 될 수도 있습니다. 따라서 올바른 코드를 생성해야 하는 책임이 더 커집니다.
Erlang 파일을 core로 컴파일하려면 to_core 옵션을 줄 수 있습니다. 이 옵션은 결과로 생성된 Core Erlang 프로그램을 확장자가 .core인 파일에 출력합니다. .core 파일로부터 Core Erlang 프로그램을 컴파일하려면 컴파일러에 from_core 옵션을 줄 수 있습니다.
1> c(world, to_core).
** Warning: No object file created - nothing loaded **
ok
2> c(world, from_core).
{ok,world}
.core 파일은 사람이 읽을 수 있는 core 형식으로 작성된 텍스트 파일이라는 점에 유의하십시오. core 프로그램을 Erlang 텀으로 얻으려면 컴파일에 바이너리(binary) 옵션을 추가하면 됩니다.
2.4.8. 컴파일러 패스: 커널 Erlang
Kernel Erlang은 몇 가지 차이점을 가진 Core Erlang의 평탄화된(flat) 버전입니다. 예를 들어, 각 변수는 고유하며 스코프는 함수 전체입니다. 패턴 매칭은 더 원시적인 연산으로 컴파일됩니다. 커널 표현은 잘 정의된 파일 형식을 가지고 있지 않으며 안정적일 것이라고 기대해서는 안 됩니다.
2.4.9. 컴파일러 단계: BEAM 어셈블리 코드
일반적인 컴파일의 마지막 단계는 외부 BEAM 어셈블리 코드 형식으로, 개별 BEAM 명령어의 시퀀스를 Erlang 텀으로 표현한 것입니다. 데드 코드 제거 및 핍홀 최적화와 같은 일부 저수준 최적화가 이 단계에서 수행됩니다.
BEAM 코드는 Chapter 7와 Appendix B에서 자세히 설명합니다
2.4.10. 컴파일러 패스: BEAM 바이너리 포맷
BEAM 어셈블리 코드는 마지막으로 바이너리 전송 형식으로 패킹되며, 이는 .beam 파일로 작성되거나, 네트워크를 통해 전송되거나, code:load_binary/3를 통해 메모리에 직접 로드될 수 있습니다. 자세한 내용은 Chapter 6]를 참조하세요.
2.5. Parse Transform 작성하기
Erlang 언어를 조정하는 가장 쉬운 방법은 Parse Transformations(또는 parse transforms)를 통하는 것입니다. Parse Transformations에는 OTP 문서의 다음과 같은 주의사항을 비롯해 온갖 경고가 따라옵니다:
| 프로그래머는 파스 변환(parse transformation)을 사용하지 않도록 강력히 권고되며, 이로 인해 발생하는 문제에 대해서는 어떠한 지원도 제공되지 않습니다. |
parse transform을 사용하면 기본적으로 컴파일러에 추가 패스를 작성하는 것이며, 주의하지 않으면 매우 예상치 못한 결과로 이어질 수 있습니다. 하지만 parse transform을 사용하려면 이를 사용하는 모듈에서 그 사용을 선언해야 하며, 이는 해당 모듈에 국한되므로, 컴파일러 조정치고는 이것이 비교적 안전한 편입니다.
제가 보기에 파스 변환(parse transform)의 가장 큰 문제는 여러분이 자신만의 문법을 만들어내는 것이며, 이는 다른 사람이 여러분의 코드를 읽는 것을 더 어렵게 만든다는 점입니다. 적어도 여러분의 파스 변환이 예를 들어 QLC만큼 대중적이고 널리 사용되기 전까지는 말입니다.
자, 이제 이걸 사용하면 안 된다는 걸 알았겠지만, 그래도 꼭 써야 한다면 사용 방법의 예시를 하나 보여드리겠습니다.
예를 들어 어떤 이유로 Erlang 코드 안에 JSON 코드를 직접 작성하고 싶다고 가정해 보면, JSON과 Erlang의 토큰이 기본적으로 동일하기 때문에 운이 좋다고 할 수 있습니다. 또한, Erlang 컴파일러는 parse transform 패스 다음에 오는 린터(linter) 패스에서 대부분의 무결성 검사를 수행하기 때문에, 유효한 Erlang을 나타내지 않는 추상 구문 트리(AST)도 허용할 수 있습니다.
파스 트랜스폼을 작성하려면 parse_transform/2 함수를 내보내는 Erlang 모듈(이를 p라고 부르자)을 작성해야 합니다. 이 함수는 컴파일 중인 모듈(이를 m이라고 부르자)이 컴파일러 옵션 {parse_transform, p}를 포함하고 있으면, 파스 트랜스폼 패스 동안 컴파일러에 의해 호출됩니다. 이 함수의 인자는 모듈 m의 AST와 컴파일러 호출 시 전달된 컴파일러 옵션입니다.
|
파일 내에 지정된 컴파일러 옵션은 얻을 수 없다는 점에 유의하십시오. 이는 코드에서 파스 변환(parse transform)에 옵션을 전달할 수 없기 때문에 다소 성가신 부분입니다. 컴파일러는 파스 변환(parse transform) 패스 이후에 발생하는 expand 패스가 될 때까지 컴파일러 옵션을 확장하지 않습니다. |
추상 포맷(abstract format)에 대한 문서는 다소 밀도가 높아서, 문서만 읽어서는 추상 포맷을 이해하기가 상당히 어렵습니다. AST에 대한 진지한 작업을 위해 syntax_tools와 특히 erl_syntax_lib를 사용할 것을 권장합니다.
여기서는 AST를 이해하기 위해 간단한 parse transform을 개발해 보겠습니다. 따라서 syntax_tools 대신 AST를 직접 다루면서 오래되었지만 신뢰할 수 있는 io:format 방식을 사용하겠습니다.
먼저 컴파일할 수 있기를 원하는 예시로 json_test.erl을 만듭니다:
-module(json_test).
-compile({parse_transform, json_parser}).
-export([test/1]).
test(V) ->
<<{{
"name" : "Jack (\"Bee\") Nimble",
"format": {
"type" : "rect",
"widths" : [1920,1600],
"height" : (-1080),
"interlace" : false,
"frame rate": V
}
}}>>.
그런 다음 최소한의 파스 트랜스폼 모듈 json_parser.erl을 생성합니다:
-module(json_parser).
-export([parse_transform/2]).
parse_transform(AST, _Options) ->
io:format("~p~n", [AST]),
AST.
이 항등(identity) 파스 변환(parse transform)은 변경되지 않은 AST를 반환하지만, AST가 어떤 모습인지 볼 수 있도록 이를 출력하기도 합니다.
> c(json_parser).
{ok,json_parser}
2> c(json_test).
[{attribute,1,file,{"./json_test.erl",1}},
{attribute,1,module,json_test},
{attribute,3,export,[{test,1}]},
{function,5,test,1,
[{clause,5,
[{var,5,'V'}],
[],
[{bin,6,
[{bin_element,6,
{tuple,6,
[{tuple,6,
[{remote,7,{string,7,"name"},{string,7,"Jack (\"Bee\") Nimble"}},
{remote,8,
{string,8,"format"},
{tuple,8,
[{remote,9,{string,9,"type"},{string,9,"rect"}},
{remote,10,
{string,10,"widths"},
{cons,10,
{integer,10,1920},
{cons,10,{integer,10,1600},{nil,10}}}},
{remote,11,{string,11,"height"},{op,11,'-',{integer,11,1080}}},
{remote,12,{string,12,"interlace"},{atom,12,false}},
{remote,13,{string,13,"frame rate"},{var,13,'V'}}]}}]}]},
default,default}]}]}]},
{eof,16}]
./json_test.erl:7: illegal expression
./json_test.erl:8: illegal expression
./json_test.erl:5: Warning: variable 'V' is unused
error
json_test의 컴파일은 모듈에 잘못된 Erlang 문법이 포함되어 있어서 실패하지만, AST가 어떻게 생겼는지 확인할 수 있습니다. 이제 우리는 AST를 순회하며 json 코드를 Erlang 코드로 재작성하는 몇 가지 함수를 작성하기만 하면 됩니다.[1]
-module(json_parser).
-export([parse_transform/2]).
parse_transform(AST, _Options) ->
json(AST, []).
-define(FUNCTION(Clauses), {function, Label, Name, Arity, Clauses}).
%% We are only interested in code inside functions.
json([?FUNCTION(Clauses) | Elements], Res) ->
json(Elements, [?FUNCTION(json_clauses(Clauses)) | Res]);
json([Other|Elements], Res) -> json(Elements, [Other | Res]);
json([], Res) -> lists:reverse(Res).
%% We are interested in the code in the body of a function.
json_clauses([{clause, CLine, A1, A2, Code} | Clauses]) ->
[{clause, CLine, A1, A2, json_code(Code)} | json_clauses(Clauses)];
json_clauses([]) -> [].
-define(JSON(Json), {bin, _, [{bin_element
, _
, {tuple, _, [Json]}
, _
, _}]}).
%% We look for: <<"json">> = Json-Term
json_code([]) -> [];
json_code([?JSON(Json)|MoreCode]) -> [parse_json(Json) | json_code(MoreCode)];
json_code(Code) -> Code.
%% Json Object -> [{}] | [{Label, Term}]
parse_json({tuple,Line,[]}) -> {cons, Line, {tuple, Line, []}};
parse_json({tuple,Line,Fields}) -> parse_json_fields(Fields,Line);
%% Json Array -> List
parse_json({cons, Line, Head, Tail}) -> {cons, Line, parse_json(Head),
parse_json(Tail)};
parse_json({nil, Line}) -> {nil, Line};
%% Json String -> <<String>>
parse_json({string, Line, String}) -> str_to_bin(String, Line);
%% Json Integer -> Integer
parse_json({integer, Line, Integer}) -> {integer, Line, Integer};
%% Json Float -> Float
parse_json({float, Line, Float}) -> {float, Line, Float};
%% Json Constant -> true | false | null
parse_json({atom, Line, true}) -> {atom, Line, true};
parse_json({atom, Line, false}) -> {atom, Line, false};
parse_json({atom, Line, null}) -> {atom, Line, null};
%% Variables, should contain Erlang encoded Json
parse_json({var, Line, Var}) -> {var, Line, Var};
%% Json Negative Integer or Float
parse_json({op, Line, '-', {Type, _, N}}) when Type =:= integer
; Type =:= float ->
{Type, Line, -N}.
%% parse_json(Code) -> io:format("Code: ~p~n",[Code]), Code.
-define(FIELD(Label, Code), {remote, L, {string, _, Label}, Code}).
parse_json_fields([], L) -> {nil, L};
%% Label : Json-Term --> [{<<Label>>, Term} | Rest]
parse_json_fields([?FIELD(Label, Code) | Rest], _) ->
cons(tuple(str_to_bin(Label, L), parse_json(Code), L)
, parse_json_fields(Rest, L)
, L).
tuple(E1, E2, Line) -> {tuple, Line, [E1, E2]}.
cons(Head, Tail, Line) -> {cons, Line, Head, Tail}.
str_to_bin(String, Line) ->
{bin
, Line
, [{bin_element
, Line
, {string, Line, String}
, default
, default
}
]
}.
이제 오류 없이 json_test를 컴파일할 수 있습니다:
1> c(json_parser).
{ok,json_parser}
2> c(json_test).
{ok,json_test}
3> json_test:test(42).
[{<<"name">>,<<"Jack (\"Bee\") Nimble">>},
{<<"format">>,
[{<<"type">>,<<"rect">>},
{<<"widths">>,[1920,1600]},
{<<"height">>,-1080},
{<<"interlace">>,false},
{<<"frame rate">>,42}]}]
parse_transform/2에 의해 생성된 추상 구문 트리(AST)는, 여러 개의 parse transform을 적용하는 경우가 아니라면 반드시 유효한 Erlang 코드에 대응해야 하며, 여러 개를 적용하는 것도 가능합니다. 코드의 유효성은 다음 컴파일러 패스에서 검사됩니다.
2.6. 기타 컴파일러 도구
코드 생성 및 코드 조작 작업을 도와주는 다양한 도구들이 존재합니다. 이러한 도구들은 Erlang으로 작성되어 있으며 런타임 시스템의 실제 일부는 아니지만, BEAM 위에 다른 언어를 구현하려는 경우 알아두면 매우 유용합니다.
이 절에서는 가장 유용한 코드 도구 세 가지, 즉 렉서인 Leex, 파서 생성기인 Yecc, 그리고 추상 형식(abstract forms)을 조작하는 범용 함수 모음인 Syntax Tools를 다룰 것입니다.
2.6.1. Leex
Leex는 Erlang 렉서 생성기입니다. 렉서 생성기는 정의 파일 xrl에서 DFA에 대한 설명을 가져와 해당 DFA로 기술된 토큰과 일치하는 Erlang 프로그램을 생성합니다.
토크나이저를 위한 DFA 정의를 작성하는 방법의 세부 사항은 이 책의 범위를 벗어납니다. 자세한 설명을 원한다면 "용책(Dragon book)"([DragonBook])을 추천합니다. 다른 좋은 자료로는 leex에 영감을 준 어휘 분석기 프로그램인 flex의 man 및 info 항목과 leex 문서 자체가 있습니다. info와 flex가 설치되어 있다면 다음과 같이 입력하여 전체 매뉴얼을 읽을 수 있습니다:
> info flex
온라인 Erlang 문서에는 leex 매뉴얼도 있습니다 (yecc.html 참조).
렉서 생성기를 사용하여 JSON 토큰을 인식하는 Erlang 프로그램을 만들 수 있습니다. JSON 정의 http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf 를 살펴보면 우리가 처리해야 할 토큰이 몇 개 되지 않는다는 것을 알 수 있습니다.
Definitions.
Digit = [0-9]
Digit1to9 = [1-9]
HexDigit = [0-9a-f]
UnescapedChar = [^\"\\]
EscapedChar = (\\\\)|(\\\")|(\\b)|(\\f)|(\\n)|(\\r)|(\\t)|(\\/)
Unicode = (\\u{HexDigit}{HexDigit}{HexDigit}{HexDigit})
Quote = [\"]
Delim = [\[\]:,{}]
Space = [\n\s\t\r]
Rules.
{Quote}{Quote} : {token, {string, TokenLine, ""}}.
{Quote}({EscapedChar}|({UnescapedChar})|({Unicode}))+{Quote} :
{token, {string, TokenLine, drop_quotes(TokenChars)}}.
null : {token, {null, TokenLine}}.
true : {token, {true, TokenLine}}.
false : {token, {false, TokenLine}}.
{Delim} : {token, {list_to_atom(TokenChars), TokenLine}}.
{Space} : skip_token.
-?{Digit1to9}+{Digit}*\.{Digit}+((E|e)(\+|\-)?{Digit}+)? :
{token, {number, TokenLine, list_to_float(TokenChars)}}.
-?{Digit1to9}+{Digit}* :
{token, {number, TokenLine, list_to_integer(TokenChars)+0.0}}.
Erlang code.
-export([t/0]).
drop_quotes([$" | QuotedString]) -> literal(lists:droplast(QuotedString)).
literal([$\\,$" | Rest]) ->
[$"|literal(Rest)];
literal([$\\,$\\ | Rest]) ->
[$\\|literal(Rest)];
literal([$\\,$/ | Rest]) ->
[$/|literal(Rest)];
literal([$\\,$b | Rest]) ->
[$\b|literal(Rest)];
literal([$\\,$f | Rest]) ->
[$\f|literal(Rest)];
literal([$\\,$n | Rest]) ->
[$\n|literal(Rest)];
literal([$\\,$r | Rest]) ->
[$\r|literal(Rest)];
literal([$\\,$t | Rest]) ->
[$\t|literal(Rest)];
literal([$\\,$u,D0,D1,D2,D3|Rest]) ->
Char = list_to_integer([D0,D1,D2,D3],16),
[Char|literal(Rest)];
literal([C|Rest]) ->
[C|literal(Rest)];
literal([]) ->[].
t() ->
{ok,
[{'{',1},
{string,2,"no"},
{':',2},
{number,2,1.0},
{'}',3}
],
4}.
Leex 컴파일러를 사용하면 이 DFA를 Erlang 코드로 컴파일할 수 있으며, dfa_graph 옵션을 지정하면 예를 들어 Graphviz로 볼 수 있는 dot 파일도 생성할 수 있습니다.
1> leex:file(json_tokens, [dfa_graph]).
{ok, "./json_tokens.erl"}
2>
DFA 그래프(Figure 1)는 예를 들어 dotty를 사용하여 볼 수 있습니다:
> dotty json_tokens.dot
예제 json 파일(test.json)로 우리의 토크나이저를 시험해 볼 수 있습니다.
{
"no" : 1,
"name" : "Jack \"Bee\" Nimble",
"escapes" : "\b\n\r\t\f\//\\",
"format": {
"type" : "rect",
"widths" : [1920,1600],
"height" : -1080,
"interlace" : false,
"unicode" : "\u002f",
"frame rate": 4.5
}
}
먼저 토크나이저를 컴파일한 다음, 파일을 읽어 문자열로 변환해야 합니다. 마지막으로 leex가 생성하는 string/1 함수를 사용하여 테스트 파일을 토큰화할 수 있습니다.
2> c(json_tokens).
{ok,json_tokens}.
3> f(File), f(L), {ok, File} = file:read_file("test.json"), L = binary_to_list(File), ok.
ok
4> f(Tokens), {ok, Tokens,_} = json_tokens:string(L), hd(Tokens).
{'{',1}
5>
셸 함수 f/1은 셸에게 변수 바인딩을 잊어버리도록 지시합니다. 이는 변수를 여러 번 바인딩하는 명령을 시도하고 싶을 때 유용한데, 예를 들어 렉서를 작성하면서 매번 다시 작성할 때마다 시도해 보고 싶은 경우입니다. 셸 명령어에 대해서는 이후 장에서 자세히 살펴보겠습니다.
JSON을 위한 토크나이저를 갖추었으니, 이제 파서 생성기 Yecc를 사용하여 json 파서를 작성할 수 있습니다.
2.6.2. Yecc
Yecc는 Erlang을 위한 파서 생성기이다. 이 이름은 C 언어의 대표적인 파서 생성기인 Yacc(Yet Another Compiler Compiler, 또 다른 컴파일러 컴파일러)에서 유래했다. 최신 오픈소스 대응 도구는 Bison이며, Flex와 함께 자주 사용된다.
이제 JSON 텀을 위한 렉서가 있으니 yecc를 사용하여 파서를 작성할 수 있습니다.
Nonterminals value values object array pair pairs.
Terminals number string true false null '[' ']' '{' '}' ',' ':'.
Rootsymbol value.
value -> object : '$1'.
value -> array : '$1'.
value -> number : get_val('$1').
value -> string : get_val('$1').
value -> 'true' : get_val('$1').
value -> 'null' : get_val('$1').
value -> 'false' : get_val('$1').
object -> '{' '}' : #{}.
object -> '{' pairs '}' : '$2'.
pairs -> pair : '$1'.
pairs -> pair ',' pairs : maps:merge('$1', '$3').
pair -> string ':' value : #{ get_val('$1') => '$3' }.
array -> '[' ']' : {}.
array -> '[' values ']' : list_to_tuple('$2').
values -> value : [ '$1' ].
values -> value ',' values : [ '$1' | '$3' ].
Erlang code.
get_val({_,_,Val}) -> Val;
get_val({Val, _}) -> Val.
그런 다음 yecc를 사용하여 파서를 구현하는 Erlang 프로그램을 생성하고, 토크나이저가 생성한 토큰을 인자로 하여 제공된 parse/1 함수를 호출할 수 있습니다.
5> yecc:file(yecc_json_parser), c(yecc_json_parser).
{ok,yexx_json_parser}
6> f(Json), {ok, Json} = yecc_json_parser:parse(Tokens).
{ok,#{"escapes" => "\b\n\r\t\f////",
"format" => #{"frame rate" => 4.5,
"height" => -1080.0,
"interlace" => false,
"type" => "rect",
"unicode" => "/",
"widths" => {1920.0,1.6e3}},
"name" => "Jack \"Bee\" Nimble",
"no" => 1.0}}
Leex와 Yecc 도구는 여러분 자신만의 완전한 언어를 Erlang 가상 머신용으로 컴파일하고자 할 때 유용합니다. 이들을 Syntax tools, 특히 Merl과 결합하면 Erlang 코드를 생성하거나 Erlang 코드의 동작을 변경하기 위해 Erlang 추상 구문 트리를 조작할 수 있습니다.
2.7. 구문 도구와 Merl
Syntax Tools는 Erlang의 추상 구문 트리(AST)의 내부 표현을 조작하기 위한 라이브러리 모음입니다.
syntax tools 애플리케이션에는 메타프로그래밍 유틸리티인 Merl도 포함되어 있습니다. Merl을 사용하면 구문 트리를 매우 쉽게 조작하고 Erlang 코드로 파스 변환(parse transform)을 작성할 수 있습니다.
Syntax Tools에 대한 문서는 Erlang.org 사이트에서 찾을 수 있습니다: http://erlang.org/doc/apps/syntax_tools/chapter.html.
2.8. Elixir 컴파일하기
BEAM 위에 자신만의 언어를 작성하는 또 다른 방법은 Elixir의 매크로 프로그래밍 기능을 사용하는 것입니다. Elixir는 Erlang 추상 구문 트리를 거쳐 BEAM 코드로 컴파일됩니다.
Elixir의 defmacro를 사용하면 Elixir 내에서 직접 자신만의 도메인 특화 언어(DSL)를 정의할 수 있습니다.
3. 프로세스
경량 프로세스라는 개념은 Erlang과 BEAM의 본질이며, BEAM을 다른 가상 머신과 차별화하는 요소이다. BEAM(그리고 Erlang과 Elixir)이 어떻게 동작하는지 이해하려면 프로세스가 어떻게 동작하는지에 대한 세부 사항을 알아야 하며, 이는 프로세스에게 쉽고 저렴한 것과 어렵고 비용이 많이 드는 것을 포함하여 BEAM의 핵심 개념을 이해하는 데 도움이 될 것이다.
BEAM의 거의 모든 것은 프로세스라는 개념과 연결되어 있으며, 이 장에서는 이러한 연결에 대해 더 자세히 알아볼 것입니다. 서론에서 배운 내용을 확장하여 메모리 관리, 메시지 패싱, 그리고 특히 스케줄링과 같은 개념들을 더 깊이 살펴보겠습니다.
Erlang 프로세스는 OS 프로세스와 매우 유사합니다. 그것은 자신만의 주소 공간을 가지며, 시그널과 메시지를 통해 다른 프로세스들과 통신할 수 있고, 실행은 선점형 스케줄러에 의해 제어됩니다.
Erlang이나 Elixir 시스템에서 성능 문제가 발생하면, 그 문제는 대개 특정 프로세스 내부의 문제이거나 프로세스 간 불균형에서 비롯됩니다. 물론 다른 장에서 살펴볼 잘못된 알고리즘이나 메모리 문제와 같은 다른 흔한 문제들도 있습니다. 그럼에도 불구하고, 문제를 일으키는 프로세스를 정확히 찾아내는 것은 항상 중요하므로, 프로세스 검사를 위해 Erlang 런타임 시스템에서 사용할 수 있는 도구들을 살펴보겠습니다.
이 장에서는 프로세스와 스케줄러가 어떻게 동작하는지 살펴보는 과정에서 도구들을 소개하고, 마지막에는 모든 도구를 모아 실습을 진행하겠습니다.
3.1. 프로세스란 무엇인가?
프로세스는 코드 실행이 일어나는 격리된 개체입니다. 프로세스는 오류가 있는 코드를 실행하는 프로세스로 그 영향을 국한시킴으로써, 여러분의 코드에 있는 오류로부터 시스템을 보호합니다.
런타임은 병목 현상, 문제, 자원 과다 사용을 찾는 데 도움을 주는 프로세스 검사 도구들을 다수 제공합니다. 이 도구들은 문제가 있는 프로세스를 식별하고 검사하는 데 도움이 됩니다.
3.1.1. 셸에서 프로세스 목록 조회
바로 시작해서 실행 중인 시스템에 어떤 프로세스들이 있는지 살펴보겠습니다. 이를 확인하는 가장 쉬운 방법은 Erlang 셸을 실행하고 셸 명령어 i()를 입력하는 것입니다. Elixir에서는 shell_default 모듈에 있는 함수를 :shell_default.i로 호출할 수 있습니다.
$ erl
Erlang/OTP 19 [erts-8.1] [source] [64-bit] [smp:4:4] [async-threads:10]
[kernel-poll:false]
Eshell V8.1 (abort with ^G)
1> i().
Pid Initial Call Heap Reds Msgs
Registered Current Function Stack
<0.0.0> otp_ring0:start/2 376 579 0
init init:loop/1 2
<0.1.0> erts_code_purger:start/0 233 4 0
erts_code_purger erts_code_purger:loop/0 3
<0.4.0> erlang:apply/2 987 100084 0
erl_prim_loader erl_prim_loader:loop/3 5
<0.30.0> gen_event:init_it/6 610 226 0
error_logger gen_event:fetch_msg/5 8
<0.31.0> erlang:apply/2 1598 416 0
application_controlle gen_server:loop/6 7
<0.33.0> application_master:init/4 233 64 0
application_master:main_loop/2 6
<0.34.0> application_master:start_it/4 233 59 0
application_master:loop_it/4 5
<0.35.0> supervisor:kernel/1 610 1767 0
kernel_sup gen_server:loop/6 9
<0.36.0> erlang:apply/2 6772 73914 0
code_server code_server:loop/1 3
<0.38.0> rpc:init/1 233 21 0
rex gen_server:loop/6 9
<0.39.0> global:init/1 233 44 0
global_name_server gen_server:loop/6 9
<0.40.0> erlang:apply/2 233 21 0
global:loop_the_locker/1 5
<0.41.0> erlang:apply/2 233 3 0
global:loop_the_registrar/0 2
<0.42.0> inet_db:init/1 233 209 0
inet_db gen_server:loop/6 9
<0.44.0> global_group:init/1 233 55 0
global_group gen_server:loop/6 9
<0.45.0> file_server:init/1 233 79 0
file_server_2 gen_server:loop/6 9
<0.46.0> supervisor_bridge:standard_error/ 233 34 0
standard_error_sup gen_server:loop/6 9
<0.47.0> erlang:apply/2 233 10 0
standard_error standard_error:server_loop/1 2
<0.48.0> supervisor_bridge:user_sup/1 233 54 0
gen_server:loop/6 9
<0.49.0> user_drv:server/2 987 1975 0
user_drv user_drv:server_loop/6 9
<0.50.0> group:server/3 233 40 0
user group:server_loop/3 4
<0.51.0> group:server/3 987 12508 0
group:server_loop/3 4
<0.52.0> erlang:apply/2 4185 9537 0
shell:shell_rep/4 17
<0.53.0> kernel_config:init/1 233 255 0
gen_server:loop/6 9
<0.54.0> supervisor:kernel/1 233 56 0
kernel_safe_sup gen_server:loop/6 9
<0.58.0> erlang:apply/2 2586 18849 0
c:pinfo/1 50
Total 23426 220863 0
222
ok
i/0 함수는 시스템에 있는 모든 프로세스의 목록을 출력합니다. 각 프로세스는 두 줄의 정보를 갖습니다. 출력의 처음 두 줄은 정보가 무엇을 의미하는지 알려주는 헤더입니다. 보다시피 프로세스 ID(Pid)와 (있다면) 프로세스의 이름, 그리고 프로세스가 시작된 코드와 현재 실행 중인 코드에 대한 정보를 얻을 수 있습니다. 또한 힙과 스택 크기, 그리고 프로세스 내 리덕션과 메시지의 수에 대한 정보도 얻을 수 있습니다. 이 장의 나머지 부분에서는 스택, 힙, 리덕션, 메시지가 무엇인지 자세히 배울 것입니다. 지금은 힙 크기에 큰 값이 있으면 프로세스가 메모리를 많이 사용하는 것이고, 리덕션에 큰 값이 있으면 프로세스가 많은 코드를 실행한 것이라고 가정하면 됩니다.
i/3 함수로 프로세스를 더 자세히 살펴볼 수 있습니다. code_server 프로세스를 살펴보겠습니다. 이전 목록에서 code_server의 프로세스 식별자(pid)가 <0.36.0>인 것을 확인할 수 있습니다. pid의 세 숫자로 i/3을 호출하면 다음 정보를 얻습니다:
2> i(0,36,0).
[{registered_name,code_server},
{current_function,{code_server,loop,1}},
{initial_call,{erlang,apply,2}},
{status,waiting},
{message_queue_len,0},
{messages,[]},
{links,[<0.35.0>]},
{dictionary,[]},
{trap_exit,true},
{error_handler,error_handler},
{priority,normal},
{group_leader,<0.33.0>},
{total_heap_size,46422},
{heap_size,46422},
{stack_size,3},
{reductions,93418},
{garbage_collection,[{max_heap_size,#{error_logger => true,
kill => true,
size => 0}},
{min_bin_vheap_size,46422},
{min_heap_size,233},
{fullsweep_after,65535},
{minor_gcs,0}]},
{suspending,[]}]
3>
이 호출을 통해 많은 정보를 얻었으며, 이 장의 나머지 부분에서는 이러한 항목들이 대부분 무엇을 의미하는지 자세히 알아볼 것입니다. 첫 번째 줄은 프로세스에 code_server라는 이름이 부여되었음을 알려줍니다. 다음으로 프로세스가 현재 실행 중이거나 일시 중단된 함수(current_function)와 프로세스가 실행을 시작한 함수의 이름(initial_call)을 확인할 수 있습니다.
또한 프로세스가 메시지를 기다리며 중단되어 있고({status,waiting}) 메일박스에 메시지가 없다는 것({message_queue_len,0}, {messages,[]})을 확인할 수 있습니다. 메시지 패싱이 어떻게 동작하는지는 이 장의 뒷부분에서 더 자세히 살펴보겠습니다.
priority, suspending, reductions, links, trap_exit, error_handler, group_leader 필드는 프로세스 실행, 오류 처리, IO를 제어합니다. Observer를 소개할 때 이 부분을 좀 더 자세히 살펴보겠습니다.
마지막 몇 개의 필드(dictionary, total_heap_size, heap_size, stack_size, garbage_collection)는 프로세스 메모리 사용량에 대한 정보를 제공합니다. 프로세스 메모리 영역에 대해서는 Chapter 11 장에서 자세히 살펴보겠습니다.
프로세스에 대한 정보를 얻는 또 다른, 더욱 침습적인 방법은 BREAK menu에서 제공하는 프로세스 정보를 사용하는 것입니다: Ctrl+c p [enter]. BREAK 상태에 있는 동안에는 전체 노드가 멈춘다는 점에 유의하세요.
3.1.2. 프로그래밍 방식 프로세스 조사
셸 함수들은 프로세스에 대한 정보를 단순히 출력할 뿐이지만, 실제로는 이 정보를 데이터로 얻을 수 있으므로 프로세스를 검사하기 위한 자신만의 도구를 작성할 수 있습니다. erlang:processes/0로 모든 프로세스의 목록을 얻을 수 있고, erlang:process_info/1로 특정 프로세스에 대한 정보를 얻을 수 있습니다. 또한 whereis/1 함수를 사용하여 이름으로부터 pid를 얻을 수도 있습니다:
1> Ps = erlang:processes().
[<0.0.0>,<0.1.0>,<0.4.0>,<0.30.0>,<0.31.0>,<0.33.0>,
<0.34.0>,<0.35.0>,<0.36.0>,<0.38.0>,<0.39.0>,<0.40.0>,
<0.41.0>,<0.42.0>,<0.44.0>,<0.45.0>,<0.46.0>,<0.47.0>,
<0.48.0>,<0.49.0>,<0.50.0>,<0.51.0>,<0.52.0>,<0.53.0>,
<0.54.0>,<0.60.0>]
2> CodeServerPid = whereis(code_server).
<0.36.0>
3> erlang:process_info(CodeServerPid).
[{registered_name,code_server},
{current_function,{code_server,loop,1}},
{initial_call,{erlang,apply,2}},
{status,waiting},
{message_queue_len,0},
{messages,[]},
{links,[<0.35.0>]},
{dictionary,[]},
{trap_exit,true},
{error_handler,error_handler},
{priority,normal},
{group_leader,<0.33.0>},
{total_heap_size,24503},
{heap_size,6772},
{stack_size,3},
{reductions,74260},
{garbage_collection,[{max_heap_size,#{error_logger => true,
kill => true,
size => 0}},
{min_bin_vheap_size,46422},
{min_heap_size,233},
{fullsweep_after,65535},
{minor_gcs,33}]},
{suspending,[]}]
프로세스 정보를 데이터 형태로 얻으면 원하는 대로 데이터를 분석하거나 정렬하는 코드를 작성할 수 있습니다. 시스템의 모든 프로세스를 가져오고(erlang:processes/0 사용) 각 프로세스의 힙 크기 정보를 얻으면(erlang:process_info(P, total_heap_size) 사용), pid와 힙 크기로 이루어진 리스트를 구성하여 힙 크기 기준으로 정렬할 수 있습니다:
1> lists:reverse(lists:keysort(2,[{P,element(2,
erlang:process_info(P,total_heap_size))}
|| P <- erlang:processes()])).
[{<0.36.0>,24503},
{<0.52.0>,21916},
{<0.4.0>,12556},
{<0.58.0>,4184},
{<0.51.0>,4184},
{<0.31.0>,3196},
{<0.49.0>,2586},
{<0.35.0>,1597},
{<0.30.0>,986},
{<0.0.0>,752},
{<0.33.0>,609},
{<0.54.0>,233},
{<0.53.0>,233},
{<0.50.0>,233},
{<0.48.0>,233},
{<0.47.0>,233},
{<0.46.0>,233},
{<0.45.0>,233},
{<0.44.0>,233},
{<0.42.0>,233},
{<0.41.0>,233},
{<0.40.0>,233},
{<0.39.0>,233},
{<0.38.0>,233},
{<0.34.0>,233},
{<0.1.0>,233}]
2>
많은 프로세스가 233 워드의 힙 크기를 가지고 있는 것을 확인할 수 있는데, 이는 프로세스의 기본 시작 힙 크기이기 때문입니다.
process_info/2에서 얻을 수 있는 모든 정보에 대한 자세한 설명은 Erlang의 erlang 모듈 문서(ERTS 문서)를 참고하십시오: https://erlang.org/doc/apps/erts/erlang.html#process_info/2 process_info/1 함수는 프로세스에 대해 사용 가능한 정보 중 일부만 반환하며, process_info/2 함수를 사용하면 추가 정보를 가져올 수 있다는 점에 유의하십시오. 예를 들어, 위의 code_server 프로세스에 대한 backtrace를 추출하려면 다음과 같이 실행할 수 있습니다:
3> process_info(whereis(code_server), backtrace).
{backtrace,<<"Program counter: 0x00000000161de900 (code_server:loop/1 + 152)\nCP: 0x0000000000000000 (invalid)\narity = 0\n\n0"...>>}
위 바이너리 끝에 있는 점 세 개가 보이나요? 이는 출력이 잘렸다는 것을 의미합니다. 전체 값을 보는 유용한 방법은 위 함수 호출을 rp/1 함수로 감싸는 것입니다:
4> rp(process_info(whereis(code_server), backtrace)).
대안으로 다음과 같이 io:put_chars/1 함수를 사용할 수 있습니다:
5> {backtrace, Backtrace} = process_info(whereis(code_server), backtrace).
{backtrace,<<"Program counter: 0x00000000161de900 (code_server:loop/1 + 152)\nCP: 0x0000000000000000 (invalid)\narity = 0\n\n0"...>>}
6> io:put_chars(Backtrace).
출력이 장황하기 때문에 4>와 6> 명령어의 출력은 여기에 포함하지 않았지만, 위 명령어들을 Erlang 셸에서 직접 실행해 보시기 바랍니다.
3.1.3. Observer를 사용하여 프로세스 검사하기
프로세스를 살펴보는 세 번째 방법은 Observer를 사용하는 것입니다. Observer는 Erlang Runtime System을 살펴보기 위한 광범위한 그래픽 인터페이스입니다. 이 책 전반에 걸쳐 시스템의 다양한 측면을 살펴보기 위해 Observer를 사용할 것입니다.
Observer를 시작하려면, Erlang이 WxWidgets 지원과 함께 컴파일되어 있어야 하며, observer와 wx 애플리케이션이 노드에서 사용 가능해야 합니다. 프로덕션 시스템에서는 아마 이를 원하지 않겠지만, Observer는 바로 이 점을 염두에 두고 만들어졌습니다. 이를 지원하도록 빌드된 Erlang에서 Observer를 시작한 다음, “Nodes” 메뉴로 들어가서 “Connect node”를 선택하면 로컬 노드 대신 연결된 노드를 관찰할 수 있습니다.
|
지금은 다음과 같이 Erlang 셸에서 Observer를 시작하기만 하겠습니다:
7> observer:start().
또는 Elixir 셸에서 :observer.start로 실행할 수도 있습니다. 실행 중인 시스템을 디버깅할 때는, 검사 대상 노드가 옵저버 노드를 인식하지 못하도록 -hidden 플래그를 사용하여 Observer 실행 전용의 임시 Erlang 노드를 실행하는 것이 좋습니다.
$ erl -hidden -sname observer -run observer
Observer를 시작하면 다음 스크린샷과 같이 시스템 개요가 표시됩니다:
이 정보 중 일부는 이번 장과 다음 장에서 나중에 자세히 다룰 것입니다. 지금은 실행 중인 프로세스를 살펴보기 위해 Observer를 사용하겠습니다. 먼저 실행 중인 시스템의 supervision tree를 보여주는 Applications 탭을 살펴보겠습니다:
여기서 우리는 프로세스들이 어떻게 링크되어 있는지 그래픽으로 볼 수 있습니다. 이는 시스템이 어떻게 구조화되어 있는지 개요를 파악하기에 매우 좋은 방법입니다. 또한 프로세스들이 링크를 통해 서로 연결된 채 공간에 떠 있는 고립된 개체라는 멋진 느낌도 받을 수 있습니다.
프로세스에 대한 실제로 유용한 정보를 얻기 위해 Processes 탭으로 전환합니다:
이 화면에서는 셸의 i/0에서와 기본적으로 동일한 정보를 얻습니다. pid, 등록된 이름, 리덕션 수, 메모리 사용량, 메시지 수, 그리고 현재 함수를 볼 수 있습니다.
코드 서버 등 프로세스의 행을 더블 클릭해서 해당 프로세스를 살펴볼 수도 있는데, 이렇게 하면 process_info/2로 얻을 수 있는 종류의 정보를 얻을 수 있습니다:
지금 당장 이 모든 정보가 무엇을 의미하는지 살펴보지는 않겠지만, 계속 읽어나가다 보면 결국 모든 것이 밝혀질 것입니다.
프로세스가 무엇인지에 대한 기본적인 이해와 시스템에서 프로세스를 찾고 검사할 수 있는 몇 가지 도구를 갖추었으니, 이제 프로세스가 어떻게 구현되는지 배우기 위해 더 깊이 파고들 준비가 되었습니다.
3.2. 프로세스는 그저 메모리일 뿐이다
프로세스는 기본적으로 네 개의 메모리 블록, 즉 스택, 힙, 메시지 영역, 그리고 프로세스 제어 블록(PCB)으로 구성됩니다.
스택은 반환 주소를 저장하고, 함수에 인자를 전달하고, 지역 변수를 유지함으로써 프로그램 실행을 추적하는 데 사용됩니다. 리스트와 튜플 같은 더 큰 구조체는 힙에 저장됩니다.
메시지 영역, 즉 메일박스라고도 불리는 이 영역은 다른 프로세스로부터 해당 프로세스로 전송된 메시지를 저장하는 데 사용된다. 프로세스 제어 블록은 프로세스의 상태를 추적하는 데 사용된다.
프로세스를 메모리로 표현한 그림은 Erlang 프로세스 메모리 : 기초을 참고하세요.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. +----------------------------+ | Process | | | | +---------+ +---------+ | | | PCB | | Stack | | | +---------+ +---------+ | | | | +---------+ +---------+ | | | Mailbox | | Heap | | | +---------+ +---------+ | | | +----------------------------+
이 프로세스 그림은 매우 단순화된 것이며, 우리는 더 정확한 그림에 도달하기 위해 더 정교한 버전으로 여러 차례 반복해 나갈 것입니다.
스택, 힙, 메일박스는 모두 동적으로 할당되며 필요에 따라 커지거나 줄어들 수 있습니다. 이것이 정확히 어떻게 동작하는지는 이후 장에서 살펴볼 것입니다. 반면 PCB는 정적으로 할당되며 프로세스를 제어하는 여러 필드를 포함합니다.
이러한 메모리 영역 중 일부는 내장된 introspection 호출을 통해 검사할 수 있습니다. 예를 들어, erlang:process_info/2를 사용하면 프로세스에게 stack_size, heap_size, total_heap_size, 또는 원시 backtrace 바이너리를 요청할 수 있으며, current_stacktrace 항목은 스택을 {M,F,A,Location} 튜플의 구조화된 리스트로 반환하여 직접 패턴 매칭하거나 보기 좋게 출력할 수 있습니다. 쉘 헬퍼 c:bt(Pid)는 erlang:process_display(Pid, backtrace)를 감싸는 편의 래퍼일 뿐입니다. process_info의 memory나 reductions와 같은 항목과 결합하면, 이러한 쿼리들은 프로세스의 스택, 힙, PCB와 유사한 필드에 대한 뷰를 제공합니다.
더 깊은 바이트 수준의 관점을 위해 런타임 시스템은 Emulator Toolbox for Pathologists (ETP)—일시 정지된 가상 머신이나 코어 덤프에서 살아있는 BEAM 데이터 구조를 순회하는 GDB 매크로 모음(etp-stackdump, etp-heapdump, etp-process-info 등)—을 함께 제공합니다.
ETP 지원을 포함한 Erlang 런타임 시스템의 디버그 버전을 빌드하는 방법에 대한 자세한 내용은 Appendix A를 참고하세요.
프로세스의 스택 컨텍스트는 erlang:process_display/2로 확인할 수 있습니다:
1> erlang:process_display(self(), backtrace).
Program counter: 0x00007fdf9baa2e78 (erlang:self/0 + 32)
0x00007fdedf247480 Return addr 0x00007fdf9baa2118 (erlang:process_display/2 + 128)
y(0) []
y(1) backtrace
y(2) <0.89.0>
0x00007fdedf2474a0 Return addr 0x00007fdf9bbb7644 (erl_eval:do_apply/7 + 308)
y(0) none
y(1) []
0x00007fdedf2474b8 Return addr 0x00007fdf9bf0e430 (shell:exprs/7 + 536)
y(0) []
y(1) []
y(2) []
y(3) []
y(4) cmd
y(5) []
y(6) {value,#Fun<shell.5.46532814>}
y(7) {eval,#Fun<shell.23.46532814>}
y(8) #Ref<0.908573933.3456499713.143140>
y(9) []
0x00007fdedf247510 Return addr 0x00007fdf9bf0dc60 (shell:eval_exprs/7 + 152)
y(0) []
y(1) []
y(2) []
y(3) []
y(4) <0.88.0>
y(5) Catch 0x00007fdf9bf0dd0e (shell:eval_exprs/7 + 326)
0x00007fdedf247548 Return addr 0x00007fdf9bf0d87c (shell:eval_loop/4 + 564)
y(0) #Ref<0.908573933.3456499713.143140>
y(1) #Ref<0.908573933.3456499713.143160>
y(2) <0.88.0>
0x00007fdedf247568 Return addr 0x00007fdf9ba2d1f8 (<terminate process normally>)
true
스택과 힙에 있는 값들에 대해서는 Chapter 4에서 더 자세히 살펴볼 것입니다.
RTP와 디버그 버전의 BEAM을 사용하면 프로세스의 힙을 검사할 수 있습니다.
디버그 버전을 빌드했다고 가정하면:
$ERL_TOP/bin/cerl -debug -rgdb
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
...
Reading symbols from beam.debug.smp...
%---------------------------------------------------------------------------
% Use etp-help for a command overview and general help.
%
% To use the Erlang support module, the environment variable ROOTDIR
% must be set to the toplevel installation directory of Erlang/OTP,
% so the etp-commands file becomes:
% $ROOTDIR/erts/etc/unix/etp-commands
% Also, erl and erlc must be in the path.
%---------------------------------------------------------------------------
etp-set-max-depth 20
etp-set-max-string-length 100
--------------- System Information ---------------
OTP release: 24
ERTS version: 12.3.2.17
Arch: x86_64-pc-linux-gnu
Endianness: Little
Word size: 64-bit
BeamAsm support: yes
--Type <RET> for more, q to quit, c to continue without paging--
SMP support: yes
Thread support: yes
Kernel poll: Supported
Debug compiled: yes
Lock checking: yes
Lock counting: no
System not initialized
--------------------------------------------------
그런 다음 run으로 BEAM을 시작할 수 있습니다.
(gdb) run
Starting program: /home/happi/hh/theBeamBook/otp24/bin/x86_64-pc-linux-gnu/beam.debug.smp -- -root /home/happi/hh/theBeamBook/otp24 -progname /home/happi/hh/theBeamBook/otp24/bin/cerl -debug -- -home /home/happi -- -emu_type debug
This GDB supports auto-downloading debuginfo from the following URLs:
<https://debuginfod.ubuntu.com>
Enable debuginfod for this session? (y or [n]) y
Debuginfod has been enabled.
To make this setting permanent, add 'set debuginfod enabled on' to .gdbinit.
warning: could not find '.gnu_debugaltlink' file for /lib/x86_64-linux-gnu/libtinfo.so.6
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7fffb6baf6c0 (LWP 2185)]
...
[New Thread 0x7fff9f4fa6c0 (LWP 2232)]
Erlang/OTP 24 [erts-12.3.2.17] [source] [64-bit] [smp:16:16] [ds:16:16:10] [async-threads:1] [jit] [type-assertions] [debug-compiled] [lock-checking]
Eshell V12.3.2.17 (abort with ^G)
1>
힙에 동적 데이터를 가진 프로세스를 하나 시작해 보겠습니다:
-module(mini_proc).
-export([wait/0]).
wait() ->
X = {timestamp, erlang:timestamp()},
receive _ -> X end,
X.
이제 디버거 내에서 셸에서 해당 프로세스를 컴파일하고 시작할 수 있습니다:
1> c(mini_proc).
{ok,mini_proc}
9> P3 = spawn(mini_proc, wait, []).
<0.101.0>
Ctrl+c를 눌러 디버거로 돌아갈 수 있습니다:
Thread 1 "beam.debug.smp" received signal SIGINT, Interrupt.
[Switching to Thread 0x7ffff79b3c40 (LWP 2170)]
(gdb)
스케줄러 스레드에 있지 않으면 ETP 명령에 문제가 있었으므로, 스케줄러 스레드로 전환하여 프로세스 목록을 확인해 보겠습니다:
(gdb) thread 7 [Switching to thread 7 (Thread 0x7fffb40b96c0 (LWP 2191))] (gdb) etp-processes --- Pix: 0 Pid: <0.0.0> State: prq-prio-normal | usr-prio-normal | act-prio-normal Flags: trap-exit Registered name: init Current function: unknown I: #Cp<init:loop/1+0x64> Heap size: 987 Old-heap size: 987 Mbuf size: 38 Msgq len: 0 (inner=0, outer=0) Msgq Flags: on-heap Parent: [] Pointer: (Process*)0x5555561301c8 ... --- Pix: 808 Pid: <0.101.0> State: prq-prio-normal | usr-prio-normal | act-prio-normal Flags: Current function: unknown I: #Cp<0x7fffb53711dc> Heap size: 233 Old-heap size: 0 Mbuf size: 0 Msgq len: 0 (inner=0, outer=0) Msgq Flags: on-heap Parent: <0.93.0> Pointer: (Process*)0x7fff9ed6e030 --- Pix: 816 Pid: <0.102.0> State: prq-prio-normal | usr-prio-normal | act-prio-normal Flags: Current function: unknown I: #Cp<io:execute_request/3+0x1a4> Heap size: 233 Old-heap size: 0 Mbuf size: 0 Msgq len: 0 (inner=0, outer=0) Msgq Flags: on-heap Parent: <0.79.0> Pointer: (Process*)0x7fff9ed6dce8 --- (gdb)
Pointer 필드에서 '<0.101.0>' 프로세스의 주소를 확인할 수 있습니다. etp-stackdump 및 etp-heapdump 명령을 사용하여 프로세스의 스택과 힙을 검사할 수 있습니다. etp-stackdump 명령은 프로세스 포인터를 인자로 받습니다.
(gdb) set $p = ((Process *) 0x7fff9ed6e030)
(gdb) etp-stackdump $p
% Stacktrace (3)
I: #Cp<0x7fffb53711dc>.
0: {timestamp,{1747,147859,212377}}.
1: #Cp<0x7fffb4c14a78>.
2: #Cp<0x7fffb4c14a78>.
(gdb) etp-heapdump $p
% heapdump (7):
0x7fff9eaf8278: | H: 3-tuple | I: 1747 | I: 147859 | I: 212377 | H: 2-tuple | timestamp | B:0x9eaf827a
(gdb)
예상대로 스택과 힙에는 3-튜플 타임스탬프를 가진 2-튜플이 포함되어 있습니다: {timestamp, {1747,147859,212377}}.
3.3. PCB
프로세스 제어 블록은 프로세스의 동작과 현재 상태를 제어하는 모든 필드를 포함한다. 이 절과 이 장의 나머지 부분에서 가장 중요한 필드들을 살펴볼 것이다. 실행과 트레이싱에 관련된 일부 필드는 이 장에서 다루지 않고, 대신 Chapter 5에서 다룰 것이다.
프로세스 포인터가 가리키는 값을 출력하기만 하면 PCB의 대부분 필드를 확인할 수 있습니다:
print *((Process *) 0x7fff9ed6e030)
$2 = {common =
{id = 3470333576787,
refc = {atmc = {counter = 1}, sint = 1},
tracer = 59, trace_flags = 167772160,
timer = {counter = 0},
u = {alive = {started_interval = 263, reg = 0x0, links = 0x0,
lt_monitors = 0x0, monitors = 0x0},
release = {later = 263, func = 0x0, data = 0x0, next = 0x0}}},
htop = 0x7fff9eaf82b0,
stop = 0x7fff9eaf89a8,
fcalls = 3998,
freason = 0,
fvalue = 59,
heap = 0x7fff9eaf8278,
hend = 0x7fff9eaf89c0,
abandoned_heap = 0x0,
heap_sz = 233,
min_heap_size = 233,
min_vheap_size = 46422,
max_heap_size = 3,
arity = 0,
arg_reg = 0x7fff9ed6e0f8,
max_arg_reg = 6,
def_arg_reg = {588235, 322955, 59, 14178673876263027908, 14178673876263027908, 4000},
i = 0x7fffb53711dc <mini_proc:wait/0+140>,
catches = 0, rcount = 0, schedule_count = 0,
reds = 6, flags = 0, group_leader = 2267742733347, ftrace = 59, next = 0x555556142888,
uniq = 0,
sig_qs = {first = 0x0, last = 0x7fff9ed6e170, save = 0x7fff9ed6e170, cont = 0x0,
cont_last = 0x7fff9ed6e188,
nmsigs = {next = 0x0, last = 0x0},
recv_mrk_blk = 0x0, len = 0, flags = 2},
bif_timers = 0x0, dictionary = 0x0,
seq_trace_clock = 0, seq_trace_lastcnt = 0, seq_trace_token = 59,
u = {real_proc = 0x8f9cb, terminate = 0x8f9cb,
initial = {module = 588235, function = 322955, arity = 0}},
current = 0x0, parent = 3195455669715, static_flags = 0,
high_water = 0x7fff9eaf8278,
old_hend = 0x0,
old_htop = 0x0,
old_heap = 0x0,
gen_gcs = 0,
max_gen_gcs = 65535,
off_heap = {first = 0x0, overhead = 0},
mbuf = 0x0, live_hf_end = 0xfffffffffffffff8, msg_frag = 0x0, mbuf_sz = 0,
psd = {counter = 0},
bin_vheap_sz = 46422, bin_old_vheap_sz = 46422, bin_old_vheap = 0,
sys_task_qs = 0x0, dirty_sys_tasks = 0x0,
state = {counter = 42}, dirty_state = {counter = 0},
sig_inq = {first = 0x0, last = 0x7fff9ed6e2a8, len = 0,
nmsigs = {next = 0x0, last = 0x0}},
trace_msg_q = 0x0,lock = {flags = {counter = 0},
queue = {0x0, 0x0, 0x0, 0x0, 0x0},
locked = {{counter = 0}, {counter = 0}, {counter = 0},
{counter = 0}, {counter = 0}}},
scheduler_data = 0x0, run_queue = {counter = 140736249294720},
last_htop = 0x7fff9eaf8298, last_mbuf = 0x0, heap_hfrag = 0x0, last_old_htop = 0x0,
debug_reds_in = 4000
}
이 장에서 다루는 것보다 더 깊이 파고들고 싶다면 C 소스 코드를 살펴볼 수 있습니다. PCB는 OTP19의 경우 erl_process.h 파일에, 최신 버전의 경우 erl_process.h 파일에 process라는 이름의 C 구조체로 구현되어 있습니다.
id 필드는 프로세스 ID(또는 PID)를 포함합니다. 'id = 3470333576787’는 바이너리로 '0b110010100000000000000000000000011001010011’입니다. 프로세스 ID는 Erlang 텀이며 따라서 태그가 붙는다(Chapter 4 참고). 이는 최하위 4비트가 태그(0011)라는 것을 의미한다. 코드 섹션에는 Erlang 텀을 검사하기 위한 모듈이 있는데(온라인 부록의 show.erl 참고), 이는 타입에 관한 장에서 다룰 것이다. 하지만 지금은 이를 사용해 태그된 워드의 타입을 살펴볼 수 있습니다.
4> show:tag_to_type(3470333576787).
pid
5>
htop과 stop 필드는 힙과 스택의 최상단을 가리키는 포인터이며, 즉 힙이나 스택의 다음 빈 슬롯을 가리킨다. heap(시작) 필드와 hend 필드는 전체 힙의 시작과 끝을 가리키며, heap_sz는 힙의 크기를 워드 단위로 나타낸다. 즉, 64비트 머신에서는 hend - heap = heap_sz * 8이고, 32비트 머신에서는 hend - heap = heap_sz * 4이다.
min_heap_size 필드는 워드 단위의 크기로, 힙이 시작하는 크기이자 그보다 작게 줄어들지 않는 크기이며, 기본값은 233이다.
이제 힙의 형태를 제어하는 PCB의 필드들을 사용하여 프로세스 힙의 그림을 더 구체화할 수 있습니다. Erlang 프로세스 힙를 참고하세요:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
hend -> +----+ -
| | ^
| | | -
htop -> | | | heap_sz*8 ^
|....| | hend-heap | min_heap_size
|....| v v
heap -> +----+ - -
The Heap
그런데 잠깐, 힙에는 시작과 끝이 있는데 왜 스택에는 시작과 끝이 없을까요? 이는 BEAM이 공간과 포인터를 절약하기 위해 힙과 스택을 함께 할당하는 트릭을 사용하기 때문입니다. 이제 메모리로서의 프로세스를 나타낸 첫 번째 개정판 그림을 살펴볼 차례입니다. Erlang 프로세스 메모리: 힙 + 스택에 나와 있듯이, 힙과 스택은 실제로 하나의 메모리 영역일 뿐입니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. +----------------------------+ | Process | | | | +---------+ +---------+ | | | PCB | | Stack | | | +---------+ +---------+ | | | free | | | +---------+ +---------+ | | | Mailbox | | Heap | | | +---------+ +---------+ | | | +----------------------------+
스택은 낮은 메모리 주소 쪽으로 증가하고 힙은 높은 메모리 주소 쪽으로 증가하므로, 스택 top 포인터를 그림에 추가하여 힙에 대한 그림을 더 정교하게 만들 수 있습니다. Erlang 프로세스 힙과 스택를 참조하세요:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
hend -> +----+ -
|....| ^
stop -> | | |
| | |
| | | -
htop -> | | | heap_sz ^
|....| | | min_heap_size
|....| v v
heap -> +----+ - -
The Heap
포인터 htop과 stop이 만나게 되면, 프로세스는 사용 가능한 메모리가 소진된 것이며 메모리를 확보하기 위해 가비지 컬렉터를 호출해야 합니다.
3.4. 가비지 컬렉터(GC)
힙 메모리 관리 방식은 프로세스별로 복사하는 세대별 가비지 컬렉터를 사용하는 것이다. 힙(또는 스택, 이 둘은 할당된 메모리 블록을 공유하므로)에 더 이상 공간이 없을 때, 가비지 컬렉터가 작동하여 메모리를 확보한다.
GC는 to space라는 새로운 메모리 영역을 할당한다. 그런 다음 스택을 순회하여 살아있는 모든 roots를 찾고, 각 루트를 따라가며 힙에 있는 데이터를 새 힙으로 복사한다. 마지막으로 스택도 새 힙으로 복사하고 이전 메모리 영역을 해제한다.
GC는 PCB의 다음 필드들에 의해 제어됩니다:
Eterm *high_water;
Eterm *old_hend; /* Heap pointers for generational GC. */
Eterm *old_htop;
Eterm *old_heap;
Uint max_heap_size; /* Maximum size of heap (in words). */
Uint16 gen_gcs; /* Number of (minor) generational GCs. */
Uint16 max_gen_gcs; /* Max minor gen GCs before fullsweep. */
가비지 컬렉터는 세대별(generational)이므로 대부분의 경우 새 데이터만 살펴보는 휴리스틱을 사용한다. 즉, 이른바 마이너 컬렉션 이라고 하는 것에서, GC는 스택의 상위 부분만 살펴보고 새로운 데이터를 새로운 힙으로 이동시킨다. 오래된 데이터, 즉 힙에서 high_water 마크 아래에 할당된 데이터(Erlang 프로세스 메모리 : GC 참조)는 올드 힙이라는 특수 영역으로 옮겨진다.
그렇다면 대부분의 경우 각 프로세스마다 또 다른 힙 영역이 존재하는데, 바로 PCB의 old_heap, old_htop, old_hend 필드로 처리되는 old heap이다. 이는 Erlang 프로세스 메모리 : GC에서 보듯이, 프로세스를 네 개의 메모리 영역으로 보는 우리의 원래 그림으로 거의 되돌아가게 한다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
+---------+ +-------+
| PCB | | Stack | +-------+ <- old_hend
+---------+ +-------+ + +
| free | +-------+ <- old_htop
+---------+ high_water -> +-------+ | Old |
| Mailbox | | Heap | | Heap |
+---------+ +-------+ +-------+ <- old_heap
프로세스가 시작될 때는 올드 힙이 없지만, 영 데이터가 성숙하여 올드 데이터가 되고 가비지 컬렉션이 발생하는 즉시 올드 힙이 할당된다. 이전 힙은 메이저 컬렉션(major collection)이 발생하면, 즉 풀 스윕(full sweep)이라고도 불리는 시점에 가비지 컬렉션됩니다. 가비지 컬렉션이 작동하는 방식에 대한 더 자세한 내용은 Chapter 11를 참조하라. 해당 장에서는 메모리 관련 문제를 추적하고 해결하는 방법도 살펴보겠습니다.
3.5. 메일박스와 메시지 패싱
프로세스 간 통신은 메시지 패싱을 통해 이루어집니다. 프로세스 전송은 송신 프로세스가 자신의 힙에서 수신 프로세스의 메일박스로 메시지를 복사하는 방식으로 구현됩니다.
Erlang 초기에는 동시성이 오직 Erlang 자체 스케줄러가 태스크 전환을 수행하는 방식으로만 구현되었습니다. 동시성에 대해서는 이 장 뒷부분의 스케줄러 관련 절에서 더 자세히 다룰 것이며, 지금은 Erlang의 첫 번째 버전에는 병렬성이 없었고 한 번에 오직 하나의 프로세스만 실행될 수 있었다는 점을 짚어둘 필요가 있습니다. 그 버전에서는 송신 프로세스가 락 없이 수신 프로세스의 힙에 직접 데이터를 쓸 수 있었습니다.
3.5.1. 병렬로 메시지 보내기
멀티코어 시스템이 도입되고 Erlang 구현이 프로세스를 병렬로 실행하는 여러 스케줄러로 확장되었을 때, 수신자의 메인 락을 획득하지 않고서는 다른 프로세스의 힙에 직접 쓰는 것이 더 이상 안전하지 않았다. 이때 m-bufs(또는 힙 조각이라고도 함)라는 개념이 도입되었다. m-buf는 다른 프로세스가 안전하게 데이터를 쓸 수 있는, 프로세스 힙 외부의 메모리 영역이다. 전송하는 프로세스가 락을 획득할 수 없으면, 대신 m-buf에 쓴다. 메시지의 모든 데이터가 m-buf로 복사되면, 메시지는 메일박스를 통해 프로세스에 연결된다. 연결(erl_message.h의 LINK_MESSAGE)은 수신자의 메시지 큐에 메시지를 추가한다.
가비지 컬렉터는 메시지를 프로세스의 힙으로 복사하게 됩니다. GC에 대한 부담을 줄이기 위해 메일박스는 두 개의 리스트로 나뉘는데, 하나는 이미 확인한 메시지를 포함하고 다른 하나는 새로운 메시지를 포함합니다. GC는 새로운 메시지가 살아남을 것임을(여전히 메일박스에 있으므로) 알고 있기 때문에 새로운 메시지를 살펴볼 필요가 없으며, 이러한 방식으로 일부 복사를 피할 수 있습니다.
3.6. 락 프리 메시지 패싱
Erlang 19에서는 프로세스별 새로운 설정인 message_queue_data가 도입되었으며, 이는 on_heap 또는 off_heap 값을 가질 수 있습니다. on_heap으로 설정하면 송신 프로세스는 먼저 수신자의 메인 락을 잡으려고 시도하며, 성공하면 메시지가 수신자의 힙에 직접 복사됩니다. 이는 수신자가 일시 중단된 상태이고 동일한 프로세스로 전송하기 위해 다른 프로세스가 락을 선점하지 않은 경우에만 수행될 수 있습니다. 송신자가 락을 획득하지 못하면 힙 조각을 할당하고 대신 그곳에 메시지를 복사합니다.
플래그가 off_heap으로 설정되면 발신자는 잠금을 얻으려 하지 않고 대신 힙 조각에 직접 씁니다. 이렇게 하면 잠금 경합이 줄어들지만, 힙 조각을 할당하는 것은 이미 할당된 프로세스 힙에 직접 쓰는 것보다 더 비용이 많이 들며 메모리 사용량이 더 커질 수 있습니다. 큰 빈 힙이 할당되어 있어도 새 메시지는 여전히 새로운 조각에 쓰일 수 있습니다.
on_heap 할당을 사용하면 힙에 직접 할당된 메시지와 힙 조각에 있는 메시지를 포함한 모든 메시지가 GC에 의해 복사됩니다. 메시지 큐가 크고 많은 메시지가 처리되지 않아 여전히 살아있는 경우, 그것들은 old 힙으로 승격되며 프로세스 힙의 크기가 증가하여 메모리 사용량이 늘어납니다.
모든 메시지는 수신 프로세스로 복사된 후 연결 리스트(메일박스)에 추가됩니다. 메시지가 수신 프로세스의 힙으로 복사되면 해당 메시지는 내부 메시지 큐(또는 seen 메시지)에 연결되어 GC에 의해 검사됩니다. off_heap 할당 방식에서 새 메시지는 “external” 메시지 인큐에 배치되며 GC에서 무시됩니다.
3.6.1. 메시지를 위한 메모리 영역
이제 프로세스를 네 개의 메모리 영역으로 다시 한번 수정할 수 있습니다. 이제 프로세스는 다섯 개의 메모리 영역(두 개의 메일박스)과 가변적인 수의 힙 조각(m-bufs)으로 구성되며, Erlang 프로세스 메모리: 메시지에 표시된 것과 같습니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
+-------+ +-------+
| PCB | | Stack |
+-------+ +-------+
| free |
+-------+ +-------+ +-------+ +-------+
| M-box | | M-box | | Heap | | Old |
| intern| | inq | | | | Heap |
+-------+ +-------+ +-------+ +-------+
+-------+ +-------+ +-------+ +-------+
| m−buf | | m−buf | | m−buf | | m−buf |
+-------+ +-------+ +-------+ +-------+
각 메일박스는 길이와 두 개의 포인터로 구성되며, 내부 큐에 대해서는 msg.len, msg.first, msg.last 필드에, 외부 in 큐에 대해서는 msg_inq.len, msg_inq.first, msg_inq.last 필드에 저장됩니다. 또한 selective receive를 구현하기 위해 다음에 확인할 메시지를 가리키는 포인터(msg.save)도 있습니다.
3.6.2. 프로세스에 메시지를 보내는 과정
지금은 분산 사례는 무시하기로 한다. 즉, Erlang 노드 간에 전송되는 메시지는 고려하지 않을 것이다. 두 개의 프로세스 P1과 P2를 상상해 보자. Erlang 메시지 패싱 1단계에 나타난 것처럼, 프로세스 P1이 프로세스 P2에게 메시지(Msg)를 보내고자 한다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
P 1
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | [Msg] | | intern| |
| +-------+ +-------+ +-------+ |
+---------------------------------+
|
| P2 ! Msg
v
P 2
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | | | intern| |
| +-------+ +-------+ +-------+ |
+---------------------------------+
프로세스 P1은 그런 다음 다음 단계를 수행합니다:
-
Msg의 크기를 계산합니다. -
메시지를 저장할 공간을 할당한다(앞서 설명한 대로
P2의 힙 안 또는 밖에). -
Msg를P1의 힙에서 할당된 공간으로 복사합니다. -
메시지를 감싸는
ErlMessage구조체를 할당하고 채웁니다. -
ErlMessage를ErlMsgQueue또는ErlMsgInQueue에 연결합니다.
프로세스 P2가 일시 중단되어 있고 다른 프로세스가 P2에게 메시지를 보내려고 하지 않으며 힙에 공간이 있고 할당 전략이 on_heap인 경우, 메시지는 Erlang 메시지 패싱 2단계에서와 같이 곧바로 힙에 위치하게 됩니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
P 1
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | [Msg] | | intern| |
| +-------+ +-------+ +-------+ |
+---------------------------------+
|
| P2 ! Msg
v
P 2
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | [Msg] | | intern| |
| | | | | | | |
| | | | ^ | | first | |
| +-------+ +--|----+ +---|---+ |
| | v |
| | +-------+ |
| | |next []| |
| | | m * | |
| | +----|--+ |
| | | |
| +------------+ |
+---------------------------------+
만약 P1이 P2의 main lock을 얻지 못하거나 P2의 힙에 충분한 공간이 없고 할당 전략이 on_heap인 경우, 메시지는 m-buf에 남지만 Erlang 메시지 패싱 3단계에서와 같이 내부 메일박스에서 링크됩니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
P 1
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | [Msg] | | intern| |
| +-------+ +-------+ +-------+ |
+---------------------------------+
|
| P2 ! Msg
v
P 2
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | | | intern| |
| | | | | | | |
| | | | | | first | |
| +-------+ +-------+ +---|---+ |
| m−buf v |
| +-------+ +-------+ |
| +->| [Msg] | |next []| |
| | | | | m * | |
| | +-------+ +----|--+ |
| | | |
| +------------------+ |
+---------------------------------+
GC 이후에 메시지는 힙으로 옮겨질 것이다.
할당 전략이 off_heap이면 메시지는 m-buf에 저장되어 Erlang 메시지 패싱 4단계에서와 같이 외부 메일박스에서 링크됩니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
P 1
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | [Msg] | | intern| |
| +-------+ +-------+ +-------+ |
+---------------------------------+
|
| P2 ! Msg
v
P 2
+---------------------------------+
| +-------+ +-------+ +-------+ |
| | PCB | | Stack | | Old | |
| +-------+ +-------+ | Heap | |
| | free | +-------+ |
| | | |
| +-------+ +-------+ +-------+ |
| | M-box | | Heap | | M-box | |
| | inq | | | | intern| |
| | | | | | | |
| | first | | | | first | |
| +---|---+ +-------+ +-------+ |
| v m−buf |
| +--------+ +-----+ |
| |next [] | | | |
| | | | | |
| | m *------>|[Msg]| |
| | | | | |
| +--------+ +-----+ |
+---------------------------------+
GC 이후에도 메시지는 여전히 m-buf에 남아 있습니다. 메시지가 수신되어 힙 상의 다른 객체나 스택에서 도달 가능해지기 전까지는, GC 동안 메시지가 프로세스 힙으로 복사되지 않는다.
3.6.3. 메시지 수신
Erlang은 선택적 수신(selective receive)을 지원하는데, 이는 매칭되지 않는 메시지를 나중의 수신을 위해 메일박스에 남겨둘 수 있다는 것을 의미한다. 그리고 매칭되는 메시지가 없을 때 프로세스는 메일박스에 메시지가 있는 상태로 정지(suspend)될 수 있다. msg.save 필드는 다음에 살펴볼 메시지를 가리키는 포인터에 대한 포인터를 담고 있다.
이후 장에서는 m-bufs의 세부 사항과 가비지 컬렉터가 메일박스를 처리하는 방식을 다룰 것입니다. 또한 이후 장에서 BEAM에서 receive가 어떻게 구현되는지에 대한 세부 사항도 살펴볼 것입니다.
3.6.4. 메시지 패싱 튜닝
Erlang 19에 도입된 새로운 message_queue_data 플래그를 사용하면 새로운 방식으로 메모리와 실행 시간을 맞바꿀 수 있습니다. 수신 프로세스가 과부하 상태이며 main lock을 계속 붙잡고 있다면, 발신 프로세스가 메시지를 m-buf에 빠르게 덤프할 수 있도록 off_heap 할당을 사용하는 것이 좋은 전략일 수 있습니다.
두 프로세스가 프로세스 락에 대한 실질적인 경합이 없는 잘 균형 잡힌 생산자-소비자 동작을 보이는 경우, 수신자의 힙에 직접 할당하는 것이 더 빠르고 메모리를 적게 사용할 것입니다.
수신자가 적체되어 처리할 수 있는 시간보다 더 많은 메시지를 받고 있다면, 메시지가 힙으로 복사되고 구(old) 힙으로 이동됨에 따라 실제로 더 많은 메모리를 사용하기 시작할 수 있습니다. 확인되지 않은 메시지는 살아있는 것으로 간주되므로, 힙이 커져야 하고 더 많은 메모리를 사용하게 됩니다.
시스템에 가장 적합한 할당 전략을 알아내려면 벤치마크를 수행하고 동작을 측정해야 합니다. 가장 먼저 시도해볼 수 있는 가장 쉬운 테스트는 아마도 시스템 시작 시 기본 할당 전략을 변경하는 것입니다. ERTS 플래그 +hmqd는 기본 전략을 off_heap이나 on_heap으로 설정합니다. 이 플래그 없이 Erlang을 시작하면 기본값은 on_heap이 됩니다. Erlang이 +hmqd off_heap로 시작되도록 벤치마크를 설정하면, 모든 프로세스가 오프 힙 할당을 사용할 때 시스템이 더 잘 동작하는지 아니면 더 나쁘게 동작하는지 테스트할 수 있습니다. 그런 다음 병목 지점이 되는 프로세스를 찾아, 해당 프로세스에 대해서만 할당 전략을 전환하는 테스트를 해보고 싶을 수도 있습니다.
3.7. 프로세스 딕셔너리
사실 Erlang 텀을 저장할 수 있는 프로세스 내 메모리 영역이 하나 더 있는데, 바로 프로세스 딕셔너리입니다.
프로세스 딕셔너리(PD)는 프로세스 로컬 키-값 저장소이다. 이것의 한 가지 장점은 모든 키와 값이 프로세스의 힙에 저장되므로, 저장하는 텀들을 메시지 패싱이나 ETS 테이블에서처럼 어딘가로 복사할 필요가 없다는 것이다.
이제 우리는 Erlang 프로세스 메모리 : 프로세스 사전에서 볼 수 있듯이, 또 다른 메모리 영역인 PD(프로세스 딕셔너리)를 추가하여 프로세스에 대한 관점을 업데이트할 수 있습니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
+-------+ +-------+ +-------+
| PCB | | Stack | | PD |
+-------+ +-------+ +-------+
| free |
+-------+ +-------+ +-------+ +-------+
| M-box | | M-box | | Heap | | Old |
| intern| | inq | | | | Heap |
+-------+ +-------+ +-------+ +-------+
+-------+ +-------+ +-------+ +-------+
| m−buf | | m−buf | | m−buf | | m−buf |
+-------+ +-------+ +-------+ +-------+
프로세스 딕셔너리는 매우 단순한 해시 테이블 구현입니다. 실제로 PD 메모리 영역은 단순한 버킷 배열이며, 각 슬롯은 버킷이 비어 있으면 NIL (빈 리스트)이고, 그렇지 않으면 힙에 있는 데이터를 가리키는 태그된 포인터입니다 — 버킷에 항목이 하나만 있으면 단순한 튜플 {Key, Value}이고, 버킷에 N개의 항목이 있으면 NIL로 끝나는 리스트 [{Key1, Value1}, …, {KeyN, ValueN}]입니다. 해당 구현은 erl_process_dict.c 파일에서 찾을 수 있습니다.
이런 방식으로, 동적으로 늘어나고 줄어드는 버킷 목록을 관리하기 위한 추가적인 복잡성이 없으며, 버킷 배열과 별개로 이러한 데이터 구조를 위한 별도의 메모리 관리도 필요 없습니다 — 이는 모두 힙에서 튜플과 목록을 일반적으로 생성하고 해제하는 것으로 처리됩니다 (이는 이미 원하는 만큼 효율적입니다). 이는 또한 가비지 컬렉터가 다른 Erlang 데이터와 마찬가지로 해시 테이블을 쉽게 순회할 수 있다는 것을 의미합니다. (Chapter 11에서 가비지 컬렉션이 어떻게 동작하는지 자세히 살펴보겠습니다.)
단점으로는, PD 안의 텀들은 항상 (복사) GC에 의해 옮겨져야 하는 살아있는 데이터라는 것입니다. 따라서, PD에 대량의 데이터를 저장하는 것은 딕셔너리의 초기 성장 단계 이후 GC에 얼마나 부하가 걸리는지에 따라, 항목들을 복사해 넣고 빼야 하더라도 별도의 ETS 테이블을 사용하는 것보다 더 나쁜 선택이 될 수 있습니다. 큰 PD를 구축할 예정이라는 것을 알고 있다면, spawn_opt(…, [{min_heap_size, Size}])처럼 큰 초기 힙 크기로 프로세스를 시작하는 것이 좋은 방법일 수 있으며, GC 부하가 큰 작업은 다른 프로세스에서 수행하도록 하는 것이 좋습니다.
다른 해시 테이블과 마찬가지로, PD에 요소를 저장하는 것이 완전히 공짜는 아니라는 점에 유의하라 — 이는 키/값 항목에 대한 추가 튜플과, 버킷이 비어 있지 않았다면 추가 cons 셀을 생성하며, 이로 인해 가비지 컬렉션이 촉발될 수도 있다. 이는 또한 버킷 배열의 크기 재조정을 필요로 할 수도 있다. 딕셔너리에서 기존 키를 업데이트하면 이전 힙에서 새 힙으로의 포인터가 생기지 않도록 해당 버킷의 전체 리스트가 재할당됩니다. 하지만 이러한 리스트는 평균적으로 매우 짧을 것입니다.
3.8. 시작하기
이 장에서는 프로세스가 어떻게 구현되는지 살펴보았다. 특히 프로세스의 메모리가 어떻게 구성되는지, 메시지 패싱이 어떻게 동작하는지, 그리고 PCB에 담긴 정보에 대해 살펴보았다. 또한 프로세스 인트로스펙션을 위한 여러 도구, 예를 들어 erlang:process_info/2와 ETP 디버그 매크로에 대해서도 살펴보았습니다.
erlang:processes/0 및 erlang:process_info/1,2 함수를 사용하여 시스템의 프로세스를 조사하세요. 시도해 볼 수 있는 함수는 다음과 같습니다:
1> Ps = erlang:processes().
[<0.0.0>,<0.3.0>,<0.6.0>,<0.7.0>,<0.9.0>,<0.10.0>,<0.11.0>,
<0.12.0>,<0.13.0>,<0.14.0>,<0.15.0>,<0.16.0>,<0.17.0>,
<0.19.0>,<0.20.0>,<0.21.0>,<0.22.0>,<0.23.0>,<0.24.0>,
<0.25.0>,<0.26.0>,<0.27.0>,<0.28.0>,<0.29.0>,<0.33.0>]
2> P = self().
<0.33.0>
3> erlang:process_info(P).
[{current_function,{erl_eval,do_apply,6}},
{initial_call,{erlang,apply,2}},
{status,running},
{message_queue_len,0},
{messages,[]},
{links,[<0.27.0>]},
{dictionary,[]},
{trap_exit,false},
{error_handler,error_handler},
{priority,normal},
{group_leader,<0.26.0>},
{total_heap_size,17730},
{heap_size,6772},
{stack_size,24},
{reductions,25944},
{garbage_collection,[{min_bin_vheap_size,46422},
{min_heap_size,233},
{fullsweep_after,65535},
{minor_gcs,1}]},
{suspending,[]}]
4> lists:keysort(2,[{P,element(2,erlang:process_info(P,
total_heap_size))} || P <- Ps]).
[{<0.10.0>,233},
{<0.13.0>,233},
{<0.14.0>,233},
{<0.15.0>,233},
{<0.16.0>,233},
{<0.17.0>,233},
{<0.19.0>,233},
{<0.20.0>,233},
{<0.21.0>,233},
{<0.22.0>,233},
{<0.23.0>,233},
{<0.25.0>,233},
{<0.28.0>,233},
{<0.29.0>,233},
{<0.6.0>,752},
{<0.9.0>,752},
{<0.11.0>,1363},
{<0.7.0>,1597},
{<0.0.0>,1974},
{<0.24.0>,2585},
{<0.26.0>,6771},
{<0.12.0>,13544},
{<0.33.0>,13544},
{<0.3.0>,15143},
{<0.27.0>,32875}]
9>
4. Erlang 타입 시스템과 태그
ERTS를 이해하는 데 있어 가장 중요한 측면 중 하나는 ERTS가 데이터를 저장하는 방식, 즉 Erlang 텀이 메모리에 저장되는 방식입니다. 이는 가비지 컬렉션이 어떻게 동작하는지, 메시지 패싱이 어떻게 동작하는지 이해하는 기초를 제공하며, 얼마나 많은 메모리가 필요한지에 대한 통찰력을 줍니다.
이 장에서는 Erlang의 기본 데이터 타입과 그것이 ERTS에서 어떻게 구현되는지 배웁니다. 이 지식은 메모리 할당과 가비지 컬렉션을 다루는 장을 이해하는 데 필수적입니다. Chapter 11를 참고하세요.
4.1. Erlang 타입 시스템
Erlang은 강타입입니다. 즉, 한 타입을 다른 타입으로 강제 변환할 방법이 없으며, 오직 한 타입에서 다른 타입으로 변환만 할 수 있다. 이를 예로 들어, char를 int로, 혹은 임의의 포인터 타입을 void *로 강제 변환할 수 있는 C와 비교해 보라.
Erlang 타입 격자는 Erlang 타입 격자에서 볼 수 있듯이 상당히 평탄하다. 실제로 존재하는 하위 타입은 몇 가지뿐이다: numbers는 하위 타입으로 integer와 float를 가지며, list는 하위 타입으로 nil과 cons를 가진다. (tuple은 크기별로 하나씩 하위 타입을 가지며, maps도 이와 비슷하다고 주장할 수도 있다.)
Erlang 타입 격자
Failed to generate image: Could not find the 'dot' executable in PATH; add it to the PATH or specify its location using the 'graphvizdot' document attribute
digraph G {
overlap=false;
splines=false;
node[fontname=Helvetica fontsize=22];
edge [penwidth=0.5]
any[shape=plaintext, label="any()"];
number[shape=plaintext, label="number()"];
atom[shape=plaintext, label="atom()"];
reference[shape=plaintext, label="reference()"];
fun[shape=plaintext, label="fun()"];
port[shape=plaintext, label="port()"];
pid[shape=plaintext, label="pid()"];
tuple[shape=plaintext, label="tuple()"];
map[shape=plaintext, label="map()"];
list[shape=plaintext, label="list()"];
binary[shape=plaintext, label="binary()"];
integer[shape=plaintext, label="integer()"];
float[shape=plaintext, label="float()"];
nil[shape=plaintext, label="nil()"];
cons[shape=plaintext, label="cons()"];
boolean[shape=plaintext, label="boolean()"];
false[shape=plaintext, label="false()"];
true[shape=plaintext, label="true()"];
dummy0[shape=point, width=0.004];
dummy1[shape=point, width=0.004];
dummy2[shape=point, width=0.004];
none[shape=plaintext, label="none()"];
gt0[shape=plaintext, label="<" fontcolor=gray];
gt1[shape=plaintext, label="<" fontcolor=gray];
gt2[shape=plaintext, label="<" fontcolor=gray];
gt3[shape=plaintext, label="<" fontcolor=gray];
gt4[shape=plaintext, label="<" fontcolor=gray];
gt5[shape=plaintext, label="<" fontcolor=gray];
gt6[shape=plaintext, label="<" fontcolor=gray];
gt7[shape=plaintext, label="<" fontcolor=gray];
gt8[shape=plaintext, label="<" fontcolor=gray];
gt9[shape=plaintext, label="<" fontcolor=gray];
subgraph cluster_1 {
style=invis
{rank=same gt0 gt1 gt2 gt3 gt4 gt5 gt6 gt7 gt8 number, atom, reference, fun, port, pid, tuple, map, list, binary}
number -> gt0 -> atom -> gt1 -> reference -> gt2 -> fun -> gt3 -> port -> gt4 -> pid -> gt5 -> tuple -> gt6 -> map -> gt7 -> list -> gt8 -> binary
[color=transparent arrowhead=none labelcolor=gray];
}
subgraph cluster_2 {
style=invis
{rank=same gt9 true, false}
dummy1 dummy2 false -> gt9 -> true
[color=transparent arrowhead=none labelcolor=gray];
}
{rank=same integer, float, boolean, nil, cons, dummy0}
any->number[dir=none];
number->integer[dir=none];
number->float[dir=none];
any->atom[dir=none];
atom->boolean[dir=none];
any->reference[dir=none];
any->fun[dir=none];
any->port[dir=none];
any->pid[dir=none];
any->tuple[dir=none];
any->map[dir=none];
any->list[dir=none];
list->nil[dir=none];
list->cons[dir=none];
any->binary[dir=none];
binary->dummy0[dir=none];
integer->dummy1[dir=none];
dummy1->none[dir=none];
float->dummy2[dir=none];
dummy2->none[dir=none];
atom->none[dir=none];
boolean->false[dir=none];
boolean->true[dir=none];
boolean->none[dir=none];
false->none[dir=none];
true->none[dir=none];
reference->none[dir=none];
fun->none[dir=none];
port->none[dir=none];
pid->none[dir=none];
tuple->none[dir=none];
map->none[dir=none];
nil->none[dir=none];
cons->none[dir=none];
dummy0->none[dir=none];
}
Erlang의 모든 텀에는 위 격자에서 왼쪽에서 오른쪽으로 타입이 정렬되는 부분 순서(<, >)가 존재합니다.
정수와 실수는 비교 전에 변환되기 때문에 이 순서는 전순서가 아니라 부분 순서입니다. 1 < 1.0와 1.0 < 1 모두 거짓이고, 1 =< 1.0, 1 >= 1.0, 1 == 1.0은 참입니다. 정밀도가 낮은 숫자가 정밀도가 높은 숫자로 변환됩니다. 보통 정수는 실수로 변환됩니다. 매우 크거나 작은 실수의 경우 실수가 정수로 변환됩니다. 이는 모든 유효 숫자가 소수점 왼쪽에 있을 때 발생합니다.
튜플과 리스트 같은 복합 구조체의 경우, 순서는 재귀적으로 확장되므로, 예를 들어 {1, 2} < {1, 3}이지만 {2, 1} > {1, 3}입니다. 이는 모든 Erlang 텀 집합이 순서대로 정렬될 수 있음을 의미하며, 이 텀 순서 는 언어에 의해 정의됩니다.
두 맵의 순서를 비교할 때는 다음과 같이 비교합니다: 한 맵의 요소 수가 다른 맵보다 적으면, 튜플의 경우와 마찬가지로 더 작은 것으로 간주됩니다. 그렇지 않으면 키는 키 순서로 비교되며, 이는 모든 정수가 모든 부동소수점보다 작은 것으로 간주됨을 의미합니다. 모든 키가 동일하다면 각 값 쌍은 (키 순서대로) 산술적으로 비교되며, 즉 먼저 동일한 정밀도로 변환한 후 비교합니다.
동등성을 비교할 때도 마찬가지이므로, 따라서 #{1 => 1.0} == #{1 => 1}이지만 #{1.0 => 1} /= #{1 => 1}이다.
Erlang은 동적 타입 언어입니다. 즉, 타입은 런타임에 검사되며 타입 오류가 발생하면 예외가 발생합니다. 컴파일러는 컴파일 타임에 타입을 검사하지 않는데, 이는 C나 Java와 같이 컴파일 중에 타입 오류를 얻을 수 있는 정적 타입 언어와 다른 점입니다.
강하게 동적 타입이며 타입 간에 순서가 있는 Erlang 타입 시스템의 이러한 측면들은 언어 구현에 몇 가지 제약을 가한다. 런타임에 타입을 확인하고 비교할 수 있으려면 각 Erlang 텀은 자신의 타입을 함께 가지고 다녀야 한다.
이는 텀에 태그를 지정하여 해결됩니다.
4.2. 태깅 스킴
Erlang 텀의 메모리 표현에서 몇 비트는 타입 태그를 위해 예약된다. 성능상의 이유로 텀은 즉시값과 박스 텀으로 나뉜다. 즉시 텀은 머신 워드, 즉 레지스터나 스택 슬롯에 들어갈 수 있다. 박싱된 텀은 두 부분, 즉 태그된 포인터와 프로세스 힙에 저장된 여러 개의 워드로 구성된다. 힙에 저장된 박스는 리스트인 경우를 제외하고 헤더와 본문을 가지며, 리스트는 헤더가 없다.
현재 ERTS는 단계적 태그 방식을 사용하고 있으며, 이 방식의 역사와 근거는 HiPE 그룹의 기술 보고서에서 설명하고 있습니다. (http://www.it.uu.se/research/publications/reports/2000-029/ 참조) 태깅 방식은 erl_term.h에 구현되어 있습니다.
기본 아이디어는 최하위 비트를 태그로 사용하는 것입니다. 대부분의 최신 CPU 아키텍처는 32비트 및 64비트 워드를 정렬하기 때문에, 포인터에는 "사용되지 않는" 비트가 최소 두 개 존재합니다. 이 비트들은 대신 태그로 사용될 수 있습니다. 안타깝게도 그 두 비트만으로는 Erlang의 모든 타입을 표현하기에 충분하지 않으므로, 필요에 따라 더 많은 비트가 사용됩니다.
4.2.1. 프라이머리 태그
처음 두 비트는 기본 태그라고 하며, 다음과 같이 사용됩니다:
00 Header (on heap) CP (on stack) 01 List (cons) 10 Boxed 11 Immediate
Immediate(태그 11)는 작은 정수와 같이 그 자체로 완결된 워드입니다. 추가적인 워드가 필요하지 않습니다. Boxed 태그 10은 리스트를 제외한 힙에 있는 객체에 대한 포인터에 사용됩니다.
헤더 태그 00은 header words를 위해서만 힙에서 사용되며, 이는 [Boxed Terms]에서 설명하겠습니다. 힙의 모든 객체는 정수 개수의 머신 워드를 사용하며, 일반적으로 헤더 워드로 시작한다. 스택에서는 00이 대신 반환 주소 또는 Continuation Pointer를 나타냅니다 — 이는 실용적인 선택인데, 반환 주소는 어차피 항상 워드 정렬되어 있으므로 그러한 포인터에 태깅/언태깅을 할 필요가 없다는 것을 의미합니다. 이는 즉시값, 리스트, 박스형 값에 00을 사용하는 것보다 더 나은 절충안입니다.
4.2.2. 즉시값(Immediates)
Immediate 태그는 다음과 같이 더 세분화됩니다:
00 11 Pid 01 11 Port 10 11 Immediate 2 11 11 Small integer
Pid와 포트는 즉시값이며 효율적으로 동등성을 비교할 수 있습니다. 물론 실제로는 단순히 참조일 뿐이며, pid는 프로세스 식별자로서 프로세스를 가리킵니다. 그 프로세스는 어떤 프로세스의 힙에도 존재하지 않으며, PCB에 의해 관리됩니다. 포트도 거의 같은 방식으로 동작합니다.
PID 즉시값(immediate)은 하나의 머신 워드에 패킹됩니다: 최하위 2비트는 기본 태그 11이고, 다음 2비트는 서브타입 00이며, 나머지 상위 비트들이 PID 값을 담습니다. 32비트 시스템에서 이 28비트는 15비트 프로세스 인덱스(프로세스 테이블 내 오프셋), 13비트 시리얼(재사용된 인덱스를 구별하기 위함), 그리고 작은 생성 필드로 세분됩니다. 쉘에서 PID를 출력하면 <Node.Index.Serial> 형태로 나타나며, 여기서 생성(노드)이 재시작 간의 구분을 가능하게 합니다. 포트도 동일한 레이아웃을 사용하며(서브타입 01), 상위 비트를 포트 번호와 생성으로 나눕니다. 전체 참조가 하나의 워드에 담기기 때문에, PID와 포트에 대한 동등성(및 부등성) 검사는 단일 정수 비교로 축소됩니다.
ERTS에는 두 가지 종류의 정수, 즉 작은 정수(small integers)와 빅넘(bignums)이 있다. 작은 정수는 하나의 머신 워드에서 4개의 태그 비트를 뺀 크기, 즉 32비트 시스템에서는 28비트, 64비트 시스템에서는 60비트 안에 들어맞는다. 반면 빅넘은 필요한 만큼 얼마든지 커질 수 있으며(힙 공간에 의해서만 제한됨), 박스형 객체로 힙에 저장된다([Boxed Terms] 참조).
작은 정수의 태그 비트 4개를 모두 1로 설정함으로써 에뮬레이터는 정수 연산을 수행할 때 두 인수가 모두 즉시값인지 확인하는 효율적인 테스트를 할 수 있습니다. (is_both_small(x,y)는 (x & y & 1111) == 1111로 정의됩니다).
Immediate 2 태그는 다음과 같이 더 세분화됩니다:
00 10 11 Atom 01 10 11 Catch 10 10 11 [UNUSED] 11 10 11 Nil
아톰(atom)은 atom table 내의 인덱스와 atom 태그로 구성됩니다. 두 아톰 즉시값(atom immediate)은 즉시값 표현(immediate representation)만 비교하는 것으로 동등성을 비교할 수 있습니다.
아톰 테이블에서 아톰은 다음과 같은 C 구조체로 저장됩니다:
typedef struct atom {
IndexSlot slot; /* MUST BE LOCATED AT TOP OF STRUCT!!! */
int len; /* length of atom name */
int ord0; /* ordinal value of first 3 bytes + 7 bits */
byte* name; /* name of atom */
} Atom;
len 필드와 ord0 필드 덕분에, 두 atom이 같은 네 글자로 시작하지 않는 한 그 순서를 효율적으로 비교할 수 있습니다.
Catch 즉시값은 스택에서만 사용됩니다. 이는 예외 발생 후 실행이 재개되어야 하는 코드 내 지점을 가리키는 간접 포인터를 담고 있습니다. 이에 대한 자세한 내용은 Chapter 8를 참고하세요.
Nil 태그는 Erlang에서 []로 표기되는 빈 리스트에 사용되며, C 코드에서는 NIL이라고 불린다. 워드의 나머지 부분은 1로 채워진다.
4.3. 리스트
리스트는 콘스 셀로 이루어지며, 이는 힙에 있는 두 개의 연속된 워드에 불과하다: 헤드와 테일 (또는 LISP에서, 그리고 ERTS 코드베이스의 일부 곳에서도 car와 cdr이라 불린다). 앞에서 보았듯이 빈 리스트는 즉시값(immediate)이다.
cons 셀은 단일 연결 리스트를 만들기 위한 최소한의 C 구조체로 다음과 같이 설명할 수 있습니다:
struct cons_cell {
Eterm *head; /* the value */
Eterm *tail; /* the next element */
};
tail 요소는 보통 다음 cons 셀을 가리키는 또 다른 List로 태그된 포인터이거나, 리스트의 끝을 표시하는 NIL immediate입니다. 이러한 것을 proper lists 라고 부릅니다. 그러나 Erlang은 동적 타입 언어이기 때문에 tail은 [1|2]처럼 어떤 텀이든 될 수 있습니다. 이러한 것들을 improper lists이라고 부르며, 마지막 요소로 []를 찾지 못하면 proper list를 입력으로 기대하는 함수들에 예상치 못한 문제를 일으킬 것입니다.
각 cons 셀은 셀에 대한 포인터의 태그로 식별되므로 별도의 헤더 워드가 필요하지 않으며, 따라서 cons 셀 [A|B]는 박스형 2-튜플 {A,B}보다 33% 적은 메모리를 사용합니다. 이는 세 개가 아닌 두 개의 힙 워드만 초기화하면 되기 때문에, 그에 상응하는 속도 향상으로도 나타납니다. 따라서 리스트는 항목들의 시퀀스를 위한 임시 데이터 구조로 선호됩니다.
그러나 대량의 데이터를 오래 유지되는 텀으로 표현할 때는 리스트가 많은 메모리를 사용할 수 있다는 점에 유의하십시오. 각각 2바이트로 표현 가능한 백만 개의 작은 정수가 있다고 가정해 봅시다. 64비트 Erlang 시스템에서 이를 [1234, 2001, …] 리스트로 표현하면, 각각 64비트 즉시 정수(immediate integer)와 리스트의 다음 셀을 가리키는 64비트 포인터를 담고 있는 백만 개의 cons 셀을 사용하게 되며, 즉 셀당 16바이트가 됩니다. 이는 2메가바이트가 아니라 16메가바이트에 해당합니다. 이 데이터를 바이너리로 압축하는 편이 공간 효율성이 더 높겠지만, 이는 전적으로 이 숫자들에 어떻게 접근해야 하는지에 달려 있습니다.
4.4. 문자열
Erlang에서 문자열 ("…"로 작성됨)은 유니코드 코드 포인트를 나타내는 정수의 리스트일 뿐입니다. 따라서 "XYZ"는 [88,89,90]에 대한 문법적 설탕입니다. Erlang 대화형 셸은 이러한 출력 가능한 문자의 리스트를 감지하여 대신 문자열로 표시하려고 시도합니다. 예를 들면:
Eshell V15.2.2 (press Ctrl+G to abort, type help(). for help)
1> [88,89,90].
"XYZ"
| Erlang의 초기 시절에는 문자열이 Latin-1 (ISO 8859-1) 코드 포인트만 사용할 것으로 예상되었습니다. 다행히도 이는 단순히 유니코드의 0-255 부분집합이기 때문에, 이 범위가 확장되었을 때 호환성 문제가 거의 없었습니다. |
유니코드 전체 범위를 "출력 가능한 문자"로 허용하면 다양한 정수들의 많은 리스트가 문자열처럼 보일 수 있기 때문에, 셸은 문자열을 감지할 때 기본적으로 Latin-1 범위를 사용합니다. 예를 들면:
2> [955, 960, 963].
[955,960,963]
955, 960, 963은 그리스 문자 람다, 파이, 시그마임에도 불구하고 결과를 단순히 숫자 목록으로 보여줍니다. 하지만 +pc unicode 옵션으로 Erlang을 시작하면, Erlang 셸은 무엇을 출력 가능한 것으로 간주할지에 대해 훨씬 더 관대해집니다:
$ erl +pc unicode
Erlang/OTP 27 [erts-15.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]
Eshell V15.2.2 (press Ctrl+G to abort, type help(). for help)
1> [955, 960, 963].
"λπσ"
다만 콘솔이 어떤 인코딩을 사용하도록 설정되어 있는지, 그리고 사용 가능한 폰트가 무엇인지에 따라 해당 문자들을 실제로 표시할 수 있는지가 달라진다는 점에 유의하세요.
문자열 "hello"는 메모리에서 다음과 같이 표현될 수 있습니다 (32비트 머신에서 문자열 "hello"의 표현.):
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
hend -> +-------- -------- -------- --------+
| ... |
| ... |
|00000000 00000000 00000000 10000001| 128 + list tag -----------------+
stop -> | | |
|
htop -> | | |
132 |00000000 00000000 00000000 01111001| 120 + list tag -----------------|--+
128 |00000000 00000000 00000110 10001111| (h) 104 bsl 4 + small int tag <--+ |
124 |00000000 00000000 00000000 01110001| 112 + list tag --------------------|--+
120 |00000000 00000000 00000110 01011111| (e) 101 bsl 4 + small int tag <-----+ |
116 |00000000 00000000 00000000 01101000| 104 + list tag -----------------------|--+
112 |00000000 00000000 00000110 11001111| (l) 108 bsl 4 + small int tag <--------+ |
108 |00000000 00000000 00000000 01110001| 96 + list tag ---------------------------|--+
104 |00000000 00000000 00000110 11001111| (l) 108 bsl 4 + small int tag <-----------+ |
100 |11111111 11111111 11111111 11111011| NIL |
96 |00000000 00000000 00000110 11111111| (o) 111 bsl 4 + small int tag <--------------+
| ... |
heap -> +-----------------------------------+
이것은 기본적으로 UTF-32로 인코딩된 문자들의 연결 리스트이며, 보시다시피 동일한 문자열을 UTF-8로 인코딩된 바이트 시퀀스로 표현하는 것에 비해 상당히 많은 메모리를 낭비합니다 — 특히 64비트 머신에서는 문자당 16바이트를 사용하게 됩니다. 따라서 문자열은 패턴 매칭, 순회, 앞에 추가하기 등이 가능한 임시 "텍스트 버퍼"로 사용하는 것이 가장 좋으며, 작업이 끝나면 저장, 전송 또는 출력을 위해 UTF-8로 인코딩된 바이너리로 변환할 수 있습니다. (바이너리에 대해서는 아래에서 더 자세히 다루겠습니다.)
Erlang 문자열이 다른 노드로 전송되거나 ETS 테이블 또는 디스크에 저장되기 위해 직렬화될 때, 이는 Erlang 외부 텀 포맷을 사용하여 term_to_binary()를 통해 수행되며, 이는 문자열에 대해 훨씬 더 압축된 바이트 인코딩을 사용한 바이너리를 생성합니다. 이 방식은 문자열에 대해 훨씬 더 컴팩트한 바이트 인코딩을 사용합니다. 이러한 경우에는 일반적으로 문자열에 대해 걱정할 필요가 없습니다 — 여러분이 계속 유지하지 않도록 주의해야 할 것은 힙 위의(on-heap) 표현입니다.
|
4.4.1. IO 리스트
IO 리스트는 바이너리, 중첩 리스트, 문자열, 정수를 평탄화 없이 연결할 수 있게 함으로써 Erlang 문자열을 확장합니다. 각 요소는 바이너리 청크이거나 리스트 노드이며, 드라이버는 최소한의 복사로 이들을 순서대로 출력합니다.
4.5. 박스드 텀
힙에 있는 다른 모든 텀은 헤더 워드로 시작합니다. 헤더 워드는 4비트 서브태그와 프라이머리 태그 00을 사용합니다. 나머지 비트는 박스드 텀이 사용하는 추가 워드의 개수를 나타내는 애리티를 인코딩합니다:
aaaaaaaa...aaaaaaaaaaaaaaaa tttt 00
하위 태그는 다음과 같습니다:
0000 00 ARITYVAL (Tuples) 0001 00 BINARY_AGGREGATE | 001s 00 BIGNUM with sign bit | 0100 00 REF | 0101 00 FUN | THINGS 0110 00 FLONUM | 0111 00 EXPORT | 1000 00 REFC_BINARY | | 1001 00 HEAP_BINARY | BINARIES | 1010 00 SUB_BINARY | | 1011 00 [UNUSED] 1100 00 EXTERNAL_PID | | 1101 00 EXTERNAL_PORT | EXTERNAL THINGS | 1110 00 EXTERNAL_REF | | 1111 00 MAP
4.5.1. 튜플
튜플(ERTS 코드베이스에서는 ARITYVAL로 알려져 있음)은 태그와 요소의 수를 포함하는 헤더와 함께 단순하게 저장되며, 튜플 요소들(있는 경우)은 뒤따르는 워드에 저장됩니다. 예를 들어, 5-튜플 {$h, $e, $l, $l, $o}는 메모리에서 다음과 같이 보입니다 (32비트 머신에서 5-튜플의 표현.)
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
hend -> +-------- -------- -------- --------+
| ... |
| ... |
|00000000 00000000 00000000 10000010| 128 + boxed tag ---------------+
stop -> | | |
|
htop -> | | |
150 |00000000 00000000 00000110 11111111| (o) 111 bsl 4 + small int tag |
144 |00000000 00000000 00000110 11001111| (l) 108 bsl 4 + small int tag |
140 |00000000 00000000 00000110 11001111| (l) 108 bsl 4 + small int tag |
136 |00000000 00000000 00000110 01011111| (e) 101 bsl 4 + small int tag |
132 |00000000 00000000 00000110 10001111| (h) 104 bsl 4 + small int tag |
128 |00000000 00000000 00000001 01000000| 5 bsl 6 + tuple & header tag <-+
| ... |
heap -> +-----------------------------------+
빈 튜플 {} 는 힙에서 모든 비트가 0인 단일 워드(arity 0, 서브태그 0000, 프라이머리 태그 00)로 표현된다는 점에 유의하세요. 하지만 이는 힙에 있는 워드에 대한 박스형 포인터도 필요로 하므로, 레지스터에 보관하거나 추가 힙 워드 없이 리스트의 꼬리로 저장할 수 있는 즉시값인 NIL만큼 저렴하지 않습니다.
4.5.2. 바이너리
바이너리는 불변 바이트 배열이다. 내부적으로 바이너리의 표현 방식에는 네 가지 유형이 있습니다. 힙 바이너리와 refc 바이너리라는 두 가지 타입은 바이너리 데이터를 포함한다. 다른 두 가지 타입인 서브 바이너리와 매치 컨텍스트 (BINARY_AGGREGATE 태그)는 다른 두 타입 중 하나를 가리키는 더 작은 참조이다.
64바이트 이하인 바이너리는 프로세스 힙에 직접 힙 바이너리로 저장될 수 있습니다. 더 큰 바이너리는 참조 카운트되며, 페이로드는 프로세스 힙 외부에 저장되고, 페이로드에 대한 참조는 프로세스 힙에 ProcBin이라는 객체에 저장됩니다.
| 바이너리와 관련하여 중요한 점은, 이들이 단순히 바이트 덩어리를 나타내기 때문에 다른 객체를 참조하지 않으며, 가비지 컬렉터가 페이로드를 검사할 필요가 없다는 것이다. 따라서 바이너리에 맞춤화된 별도의 저장 전략을 사용하는 것이 가능합니다. 특히, 참조 카운팅 바이너리는 순환 참조 문제에 절대 빠지지 않습니다. |
Heap/Refc Binary Header Word (32-bit view)
+------------------------------------------+
| 20-bit or 28-bit length/offset field |
+----------------+-------------+-----------+
| subtag (4-bit) | primary tag | padding? |
+----------------+-------------+-----------+
-
기본 태그
00은 박스형 텀 헤더를 나타냅니다. -
서브태그
1001은 힙 바이너리(페이로드가 힙에 있음)를 나타냅니다. -
서브태그
1000은 참조 카운트 바이너리(ProcBin)를 나타냅니다.
헤더 다음에:
-
힙 바이너리의 경우, 바이너리 페이로드 바이트가 바로 뒤따른다.
-
refc 바이너리의 경우, 헤더 뒤에 ProcBin 구조체가 이어집니다.
서브 바이너리(subtag 1010)는 기존 바이너리에 대한 뷰입니다. 길이와 오프셋 필드가 포함된 헤더 워드를 저장하며, 그 뒤에 두 개의 머신 워드가 따라옵니다. 이를 통해 바이너리의 일부를 슬라이싱하거나 매칭할 때 페이로드 데이터를 복사하지 않아도 됩니다.
매치 컨텍스트(서브태그 0001, BINARY_AGGREGATE)는 바이너리 패턴 매칭 엔진에서 사용됩니다. 이들은 새로운 페이로드 공간을 할당하지 않고 하나 이상의 포함하는 바이너리의 세그먼트를 참조합니다:
-
이들은 세그먼트의 총 개수를 포함하는 헤더 워드를 가지고 있습니다.
-
이들은 원본 바이너리를 가리키는
{pointer, length}튜플의 배열을 포함합니다.
이를 통해 데이터를 평탄화하거나 복사하지 않고도 효율적인 반복 처리가 가능합니다.
바이너리에 대해서는 Chapter 11에서 더 자세히 다루겠습니다.
4.5.3. 큰 정수(Bignum)
작은 즉시값(워드 크기에서 태그 비트 4개를 뺀 크기)에 들어가기에는 너무 큰 정수는 bignum으로 표현되며, 이는 헤더 워드가 부호와 뒤따르는 "limb" 워드의 개수를 모두 인코딩하는 박스형 텀이다. 헤더의 기본 태그(00)는 이것이 힙에 할당된 헤더임을 나타내고, 4비트 서브태그(001)는 이것이 bignum임을 식별하며, 해당 서브태그의 최상위 비트는 부호를 담고 있습니다(0 = 양수, 1 = 음수). 이어지는 각 림(limb)은 하나의 완전한 머신 워드로, 정수의 일부분을 2^D_EXP 진법(일반적으로 32비트 빌드에서는 28비트, 64비트 빌드에서는 60비트)으로 저장하며, 리틀 엔디안(최하위 림이 먼저 오는) 순서로 배열됩니다.
다중 정밀도 산술은 이러한 림(limb) 배열에 대해 직접 연산을 수행한다. 덧셈과 뺄셈은 림을 워드 단위로 순회하면서 올림(carry) 또는 빌림(borrow)을 전파하는 방식으로 구현되며(I_add/I_sub), 최종 올림이 발생하면 길이가 1만큼 늘어날 수 있는 결과 림 배열을 생성한다. 곱셈은 림 개수가 적을 때는 이차 시간 복잡도의 필산 알고리즘(I_mul)을 사용하고, 피연산자 길이가 임계값을 초과하면 카라츠바(Karatsuba) 분할 정복 방식(I_mul_karatsuba)을 사용하며, 중간 이중 정밀도 곱을 상위 절반과 하위 절반으로 분리한다. 나눗셈과 나머지 연산은 크누스(Knuth)의 알고리즘 D(I_div/I_rem)를 사용하여 몫과 나머지 림 배열을 계산하고, 피연산자의 부호에 따라 결과의 부호를 조정한다.
내부적으로 각 limb는 ErtsDigit이며, bignum의 limb 배열에는 BIG_V(xp) 매크로를 통해 접근하는데, 이 매크로는 최하위 워드를 가리키는 ErtsDigit* 포인터를 반환합니다. 개별 림(limb)은 BIG_DIGIT(xp, i)를 통해 읽거나 씁니다 (0 기반 인덱스, 리틀 엔디안). big.c의 모든 핵심 루틴들—예를 들어 I_add, I_sub, I_mul, I_mul_karatsuba, I_div, I_rem—은 이러한 ErtsDigit* 배열에 대해 직접 연산하며, 올림/빌림(carry/borrow), 다중 정밀도 곱셈, 긴 나눗셈 단계를 처리합니다. 결과 자릿수가 계산되면, big_norm은 박스형 헤더(태그, arity, 부호)를 재계산하며, 결과가 태그 비트에 들어맞는 경우 이를 작은 즉시값(small immediate)으로 재통합할 수도 있습니다.
| 빅넘(bignum)은 둘 다 단순히 “바이트 덩어리”라는 점에서 바이너리와 유사하지만, 빅넘은 항상 힙에 전체가 저장되며, 매우 큰 빅넘으로 가득 찬 힙을 갖는다는 것은 가비지 컬렉터가 많은 라이브 데이터를 앞뒤로 복사해야 한다는 것을 의미한다. 따라서 큰 바이너리 데이터 덩어리를 bignum으로 인코딩하는 것은, 가능하다 하더라도, 그다지 영리한 핵이 아닙니다. [2] |
4.5.4. 참고 문헌
레퍼런스는 “고유한” 텀으로, 프로세스 메일박스 위에 채널을 구현하기 위해 메시지에 태그를 붙이는 용도로 자주 사용됩니다. 레퍼런스는 82비트 카운터로 구현됩니다. make_ref/0를 9671406556917033397649407번 호출하면 카운터가 랩어라운드되어 ref 0부터 다시 시작됩니다. 살아있는 동안 그만큼 많은 횟수로 make_ref를 호출하려면 정말 빠른 머신이 필요합니다. 다만 노드를 재시작하면 카운터도 다시 0부터 시작하지만, 그 경우 기존의 로컬 참조는 모두 사라집니다. ref를 다른 노드로 보내면 외부 ref(external ref) 가 됩니다. 아래를 참조하십시오.
32비트 시스템에서 로컬 ref는 힙에서 4개의 32비트 워드를 차지합니다. 64비트 시스템에서 ref는 힙에서 3개의 64비트 워드를 차지합니다.
|00000000 00000000 00000000 11010000| Arity 3 + ref tag
|00000000 000000rr rrrrrrrr rrrrrrrr| Data0
|rrrrrrrr rrrrrrrr rrrrrrrr rrrrrrrr| Data1
|rrrrrrrr rrrrrrrr rrrrrrrr rrrrrrrr| Data2
참조 번호는 (Data2 bsl 50) + (Data1 bsl 18) + Data0입니다.
4.5.5. 부동소수점
Erlang 부동소수점은 FLONUM 서브태그가 지정된 박스드 힙 텀입니다. 각 부동소수점은 두 개의 머신 워드를 차지합니다: 헤더 워드(프라이머리 태그 00, 서브태그 0110)와 64비트 IEEE 754 페이로드 워드입니다. 에뮬레이터는 부동소수점을 가비지 컬렉션과 비교를 위한 불투명한 "박스드" 값으로 취급하며, 가능한 경우 네이티브 CPU 명령어를 사용하여 산술 연산을 수행하고, 그렇지 않으면 소프트웨어 루틴으로 대체합니다.
4.5.6. 레코드
레코드는 튜플 위에 구축된 컴파일 타임 편의 기능이다: 항수(arity)가 N인 레코드는 단순히 첫 번째 요소가 레코드 이름을 나타내는 원자(atom)인 N개 요소의 튜플이다. 접근 및 갱신 매크로는 고정 인덱스 튜플 조회 또는 setelement/3 호출로 변환된다. 튜플 헤더(기본 태그 00, 하위 태그 0000)가 항수(arity)를 인코딩하기 때문에, 레코드 크기와 필드 오프셋은 별도의 메타데이터 없이도 런타임에 알 수 있다.
4.6. 결론
이 장에서는 Erlang의 동적 타입 시스템이 ERTS에서 어떻게 구현되는지 살펴보았으며, 효율적인 런타임 타입 검사와 비교를 위해 즉시값(immediate)과 박스드 텀(boxed term)을 통합하는 태깅 체계를 다루었다. 기저의 태그 비트와 힙 레이아웃이 Erlang의 강력하면서도 동적인 타이핑 모델을 어떻게 반영하는지 살펴보았다. 이러한 인코딩을 이해하면 메모리 할당과 가비지 컬렉션을 다루는 이후 장들을 더 쉽게 파악할 수 있을 것이며, 그 장들에서는 박스드 텀이 프로세스 힙에서 어떻게 관리되는지 살펴볼 것이다.
5. Erlang 가상 머신: BEAM
BEAM(Bogdan’s/Björn’s Abstract Machine)은 Erlang 런타임 시스템에서 코드를 실행하는 머신이다. 이것은 가비지 컬렉션을 수행하고, 리덕션을 카운트하며, 가상적이고, 비선점형이며, 다이렉트 스레디드 방식의 레지스터 머신이다. 이것이 크게 와닿지 않더라도 걱정하지 마라. 다음 절들에서 이 문맥에서 각 단어가 무엇을 의미하는지 하나씩 살펴볼 것이다.
가상 머신인 BEAM은 Erlang 노드의 심장부에 있습니다. Erlang 코드를 실행하는 것이 바로 BEAM입니다. 즉, 여러분의 애플리케이션 코드를 실행하는 것이 BEAM입니다. BEAM이 코드를 어떻게 실행하는지 이해하는 것은 코드를 프로파일링하고 튜닝하는 데 매우 중요합니다.
BEAM 설계는 ERTS의 나머지 부분 대부분에 영향을 미칩니다. 스케줄링을 위한 프리미티브는 스케줄러(Chapter 10)에 영향을 미치고, Erlang 텀의 표현은 Chapter 4에 영향을 미치며, 메모리와의 상호작용은 가비지 컬렉션([Garbage Collection])에 영향을 미칩니다. BEAM의 기본 설계를 이해함으로써 여러분은 이러한 다른 구성 요소들의 구현을 더 쉽게 이해할 수 있을 것입니다.
5.1. 작업 메모리: 스택 머신이 아니다
이전 버전인 JAM (Joe’s Abstract Machine)이 스택 머신이었던 것과 달리, BEAM은 WAM [Warren]을 느슨하게 기반으로 한 레지스터 머신이다. 스택 머신에서는 명령어의 각 피연산자가 먼저 작업 스택에 푸시되며, 그런 다음 명령어가 자신의 피연산자를 팝(pop)하고 나서 결과를 스택에 푸시한다.
스택 머신은 가상 머신 및 프로그래밍 언어 구현자들 사이에서 매우 인기가 있는데, 이는 코드를 생성하기가 매우 쉽고 코드가 매우 간결해지기 때문입니다. 컴파일러는 레지스터 할당을 할 필요가 없으며, 대부분의 연산은 (명령어 스트림에서) 피연산자가 필요하지 않습니다.
"8 + 17 * 2." 표현식을 스택 머신으로 컴파일하면 다음과 같은 코드가 생성될 수 있다:
push 8 push 17 push 2 multiply add
이 코드는 표현식의 파스 트리로부터 직접 생성될 수 있습니다. Erlang 표현식과 erl_scan 및 erl_parse 모듈을 사용하면 세상에서 가장 단순한 컴파일러를 만들 수 있습니다:
compile(String) ->
[ParseTree] = element(2,
erl_parse:parse_exprs(
element(2,
erl_scan:string(String)))),
generate_code(ParseTree).
generate_code({op, _Line, '+', Arg1, Arg2}) ->
generate_code(Arg1) ++ generate_code(Arg2) ++ [add];
generate_code({op, _Line, '*', Arg1, Arg2}) ->
generate_code(Arg1) ++ generate_code(Arg2) ++ [multiply];
generate_code({integer, _Line, I}) -> [push, I].
그리고 훨씬 더 단순한 가상 스택 머신:
interpret(Code) -> interpret(Code, []).
interpret([push, I |Rest], Stack) -> interpret(Rest, [I|Stack]);
interpret([add |Rest], [Arg2, Arg1|Stack]) -> interpret(Rest, [Arg1+Arg2|Stack]);
interpret([multiply|Rest], [Arg2, Arg1|Stack]) -> interpret(Rest, [Arg1*Arg2|Stack]);
interpret([], [Res|_]) -> Res.
그리고 빠른 테스트 실행을 통해 답을 얻을 수 있습니다:
1> stack_machine:interpret(stack_machine:compile("8 + 17 * 2.")).
42
훌륭합니다, 여러분의 첫 번째 가상 머신을 만들었습니다! 뺄셈, 나눗셈 및 나머지 Erlang 언어의 처리는 독자를 위한 연습 문제로 남겨둡니다.
5.1.1. BEAM에서의 레지스터
어쨌든, BEAM은 스택 머신이 아니라, 레지스터 머신입니다. 레지스터 머신에서는 명령어 오퍼랜드가 스택 대신 레지스터에 저장되며, 연산 결과는 보통 특정 레지스터에 저장된다. BEAM에서 레지스터는 실행 중인 프로세스에 전용인 워드 크기의 메모리 위치로, X 레지스터(X0, X1, …)라고 불린다.
대부분의 레지스터 머신은 여전히 함수에 인자를 전달하고 반환 주소를 저장하는 데 사용되는 스택을 가지고 있다. 스택 중 특정 함수 호출에 대한 정보를 담고 있는 부분을 스택 프레임이라고 하며, 여기에는 반환 주소, 레지스터에 담기지 않는 입력 인자와 지역 변수, 그리고 서브루틴 호출 중에 임시로 저장된 값들이 포함된다. [3]
BEAM에는 스택이 있으며, 스택 프레임의 모든 항목은 X 레지스터와 마찬가지로 균일하게 워드 크기를 가지므로 단순히 Y 레지스터(Y0, Y1, …)라고 불린다 — 이는 WAM [Warren]에서 차용한 용어이다. 즉, BEAM 명령어가 Y0을 참조할 때, 이는 현재 호출의 스택 프레임에서 슬롯 0을 의미하며,
X 레지스터는 범용 레지스터이지만 호출되는 함수에 인수를 전달하는 데에도 사용되며, 레지스터 X0(때때로 R0이라고도 함)은 반환 값에 사용됩니다. 이 레지스터들은 하드웨어 레지스터가 아니라 단순한 메모리 위치이기 때문에, 실제 CPU에서처럼(32개의 레지스터만 있어도 많다고 여겨지는) 그렇게 제한적인 자원이 아닙니다. BEAM에는 1024개의 X 레지스터를 사용할 수 있으므로, 함수 인자를 스택을 통해 전달할 필요가 전혀 없습니다. (하지만 다른 살아있는 X 레지스터들의 현재 내용은 함수 호출 시 “caller saves” 방식을 사용하여 Y 레지스터에 스택으로 저장되어야 합니다.)
X 레지스터는 BEAM 에뮬레이터 내의 C 배열에 저장되며(스케줄러마다 별도로 존재), 모든 함수에서 전역적으로 접근할 수 있습니다. X0 레지스터는 지역 변수에 캐시되며, 대부분의 아키텍처에서 물리적 머신 레지스터에 유지됩니다. X0이 많이 사용되므로, 이것은 중요한 최적화입니다.
Y 레지스터는 현재 스택 프레임에 저장되며 실행 중인 함수만 접근할 수 있습니다. 함수 호출에 걸쳐 값을 저장하기 위해, BEAM은 스택 프레임에 공간을 할당한 다음 해당 Y 레지스터로 값을 이동합니다. 예약된 공간이 없는 Y 레지스터에 쓰는 것은 허용되지 않습니다. 그렇게 하면 힙을 덮어쓸 수 있기 때문입니다.
X 레지스터와 Y 레지스터를 제외하고도, BEAM에는 여러 특수 목적 레지스터가 있습니다:
-
Htop — 힙의 최상단 (
htop) -
E — 스택의 최상단 (
stop) -
I — 명령어 포인터
-
FP — 프레임 포인터 (현재 프레임의 시작)
-
CP — 컨티뉴에이션 포인터, 즉 함수 반환 주소
-
fcalls — 리덕션 카운터
Htop, E 같은 레지스터는 PCB의 해당 필드를 캐싱한 버전입니다. 프로세스가 타임 슬라이스를 다 사용하면, 값들은 다시 PCB에 기록되고 다른 프로세스가 실행됩니다. BEAM이 프로세스를 다른 프로세스로 교체하는 방식(협력적 멀티태스킹의 한 형태로 간주될 수 있는 방식을 사용) ([Process Switching] 참고) 때문에, 이 일이 일어날 때 모든 X 레지스터를 저장하고 복원할 필요는 없습니다 — 그 시점에 저장해야 할 모든 것은 Y 레지스터에 있을 것이기 때문입니다.
프레임 포인터(FP)는 반드시 “실제” BEAM 레지스터일 필요는 없다 — 현재 스택 프레임의 크기를 알고 있다면 스택 최상단으로부터 계산할 수 있기 때문이다. 그러나 이를 별도로 추적하면 구현에서 다른 부분들이 더 수월해질 수 있으므로, 여기서는 설명을 단순화하기 위해 이것이 존재하는 것처럼 다룰 것이다.
BEAM이 call 명령어를 실행할 때 Continuation Pointer(CP)는 반환 주소로 설정된다. 반환 주소를 스택에 직접 푸시하는 대신 CP에 보관하여, return 명령어가 I의 새 값을 CP와 같도록 설정하기만 하면 계속 실행할 수 있도록 한다. 이는 평균적으로 (꼬리 호출을 제외한) 모든 호출의 절반이 leaf functions, 즉 더 이상 다른 함수를 호출하지 않는 함수에 대한 호출이라는 사실에 기반한 최적화이다. leaf 함수를 호출할 때는 반환 주소를 스택에 저장할 필요가 없다. 대신, non-leaf 함수만 CP를 스택에 저장했다가 반환하기 전에 복원하면 된다.
BEAM 레지스터와 스택 프레임는 두 개의 Y 레지스터를 사용하고 CP를 스택에 저장한 함수에 대한 BEAM의 레지스터와 스택 프레임 레이아웃을 보여줍니다. X와 Y 레지스터는 즉시값(immediates)이나 힙을 가리키는 태그된 포인터(Chapter 4 참조)를 포함할 수 있지만, 스택 슬롯을 가리키는 경우는 결코 없습니다. 예를 들어, 스택 프레임에 튜플이 저장되는 일은 없습니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
Stack/Heap X registers
hend -> +------+ +------+
|......| X1023 | |
+------+ +------+
FP -> | CP | X1022 | |
+------+ +------+
Y0 -> | | | |
+------+ ... |......|
Y1 -> | | | |
+------+ +------+
E -> | | X2 | |
| | +------+
|......| X1 | |
| | +------+
Htop -> | | (X0) | |
|......| +------+
|......|
heap -> +------+
5.1.2. 스택 레이아웃 예시
다음 프로그램을 'S' 플래그로 컴파일해 봅시다:
-module(add).
-export([add/2]).
add(A,B) -> id(A) + id(B).
id(I) -> I.
그러면 add/2 함수에 대해 다음과 같은 코드를 얻게 됩니다:
{function, add, 2, 2}.
{label,1}.
{line,[{location,"add.erl",4}]}.
{func_info,{atom,add},{atom,add},2}.
{label,2}.
{allocate,1,2}.
{move,{x,1},{y,0}}.
{call,1,{f,4}}.
{swap,{y,0},{x,0}}.
{call,1,{f,4}}.
{gc_bif,'+',{f,0},1,[{y,0},{x,0}],{x,0}}.
{deallocate,1}.
return.
함수에 진입할 때, 관례상 첫 번째 인자 A는 X0으로, 두 번째 인자 B는 X1로 전달된다. 코드(레이블 2에서 시작)는 먼저 단일 스택 슬롯을 위한 공간을 할당한다는 것을 알 수 있는데,[4] 따라서 Y0을 사용하여 함수 호출 id(A) 동안 X1의 값을 저장할 수 있으며, 이는 {move,{x,1},{y,0}} 명령어에 의해 수행된다(“move X1 to Y0”라고 읽거나 명령형 스타일로는 y0 := x1이라고 쓴다).
인자 A는 이미 X0(첫 번째 인자 레지스터)에 있으므로, 레이블 {f,4}(여기에는 표시되지 않음)에 있는 id/1 함수는 {call,1,{f,4}}로 직접 호출할 수 있습니다. (연산자 "1"이 무엇을 의미하는지는 나중에 다시 다루겠습니다.) 그런 다음, 호출 결과 — X0에 반환됨 — 는 스택에 저장되어야 하지만, Y0 슬롯은 이미 인자 B가 차지하고 있습니다. 다행히 이런 경우를 처리하기 위한 swap 명령어가 있어서, 먼저 Y0를 X1로, 그다음 X0를 Y0로, 마지막으로 X1을 X0로 옮기는 세 개의 명령어를 사용할 필요가 없습니다.
이제 X0에 두 번째 인자 B가 있으며, {call,1,{f,4}}로 id/1 함수를 다시 호출할 수 있습니다. 이후 X0에는 id(B)가, Y0에는 id(A)가 들어 있습니다. 내장 함수 +/2를 호출하여 덧셈을 수행할 수 있습니다: {gc_bif,'+',{f,0},1,[{y,0},{x,0}],{x,0}}. (BIF 호출과 GC에 대한 자세한 내용은 나중에 다루겠습니다.) 결과는 다시 X0에 있으며, 이는 add/2에서 반환하기 위해 필요한 위치입니다. 반환 전에 필요한 것은 deallocate 명령어로 스택 포인터를 다시 뒤로 옮기는 것뿐이며, 이로써 함수가 완료됩니다.
5.2. BEAM 인터프리터
| 이 절은 BEAM 코드를 실행하는 더 새로운 방식이지만 지원되는 플랫폼에서만 동작하는 JIT 컴파일러에 관한 것이 아니라, 훨씬 더 오래전부터 존재해 왔고 모든 플랫폼에서 동작하는 BEAM 명령어 인터프리터에 관한 것이다. |
BEAM 인터프리터는 다이렉트 스레드 코드라고 불리는 기법으로 구현된다. 여기서 threaded라는 단어는 OS 스레드, 동시성 또는 병렬성과는 아무런 관련이 없습니다. 이는 가상 머신 자체를 관통하여 실이 꿰이듯 이어지는(threaded) 실행 경로이다.
5.2.1. 바이트코드 에뮬레이션
산술 표현식을 위한 단순한 스택 머신을 살펴보면, 어떤 명령어를 실행할지 선택하기 위해 Erlang 아톰과 패턴 매칭을 사용하는 것을 알 수 있습니다. 이는 기계 명령어를 디코딩하는 것만으로도 매우 무거운 방식입니다. 실제 기계 구현에서는 각 명령어를 "머신 워드" 정수나 심지어 단일 바이트로 코딩할 것입니다.
우리는 스택 머신을 C로 구현된 byte code 머신으로 다시 작성할 수 있습니다. 먼저 우리의 간단한 컴파일러를 바이트 코드를 생성하도록 다시 작성합니다. 이는 간단한데, 아톰으로 인코딩된 각 명령어를 해당 명령어를 나타내는 바이트로 교체하기만 하면 됩니다(아래 참조). 255보다 큰 정수를 처리할 수 있도록, 크기 바이트 뒤에 바이트로 인코딩된 정수가 오는 방식으로 정수를 인코딩합니다.
compile(Expression, FileName) ->
[ParseTree] = element(2,
erl_parse:parse_exprs(
element(2,
erl_scan:string(Expression)))),
file:write_file(FileName, generate_code(ParseTree) ++ [stop()]).
generate_code({op, _Line, '+', Arg1, Arg2}) ->
generate_code(Arg1) ++ generate_code(Arg2) ++ [add()];
generate_code({op, _Line, '*', Arg1, Arg2}) ->
generate_code(Arg1) ++ generate_code(Arg2) ++ [multiply()];
generate_code({integer, _Line, I}) -> [push(), integer(I)].
stop() -> 0.
add() -> 1.
multiply() -> 2.
push() -> 3.
integer(I) ->
L = binary_to_list(binary:encode_unsigned(I)),
[length(L) | L].
이제 C로 간단한 가상 머신과 바이트코드 인터프리터를 작성해 봅시다. (전체 코드는 온라인 부록에서 찾을 수 있습니다.)
#define STOP 0
#define ADD 1
#define MUL 2
#define PUSH 3
#define pop() (stack[--sp])
#define push(X) (stack[sp++] = X)
int run(char *code) {
int stack[1000];
int sp = 0, size = 0, val = 0;
char *ip = code;
while (*ip != STOP) {
switch (*ip++) {
case ADD: push(pop() + pop()); break;
case MUL: push(pop() * pop()); break;
case PUSH:
size = *ip++;
val = 0;
while (size--) { val = val * 256 + *ip++; }
push(val);
break;
}
}
return pop();
}
보다시피, C로 작성된 가상 머신은 그리 복잡할 필요가 없습니다. 이 머신은 단지 하나의 루프로, 명령어 포인터(ip)가 가리키는 값을 확인하여 각 명령어에서 바이트코드를 검사합니다.
각 바이트 코드 명령어에 대해 명령어 바이트 코드를 기준으로 분기하여 해당 명령어를 실행하는 case로 점프합니다. 이를 위해서는 명령어를 디코딩한 후 올바른 코드로 점프해야 합니다. vsm.c의 어셈블리(gcc -S vsm.c)를 살펴보면 디코더의 내부 루프를 확인할 수 있습니다:
L11:
movl -16(%ebp), %eax
movzbl (%eax), %eax
movsbl %al, %eax
addl $1, -16(%ebp)
cmpl $2, %eax
je L7
cmpl $3, %eax
je L8
cmpl $1, %eax
jne L5
바이트코드를 각 명령어 코드와 비교한 다음 조건부 점프를 수행해야 합니다. 명령어가 많은 실제 머신에서는 이 비용이 상당히 커질 수 있습니다.
5.2.2. 스레드 코드
더 나은 해결책은 코드의 주소를 담은 테이블을 두는 것이다 — 그러면 테이블에 대한 인덱스를 사용하여 주소를 로드하고 아무런 비교도 할 필요 없이 점프할 수 있다. 이 기법은 때때로 토큰 스레드 코드라고 불린다. 이를 한 단계 더 발전시키면, 명령어를 구현하는 코드 조각의 주소를 코드 메모리 안에 “명령어” 자체로 저장할 수도 있다. 그러면 인터프리터 루프가 단일 바이트 대신 다음 주소를 읽고, 그 주소에 있는 루틴을 호출한 다음, 반환되면 그저 다음 주소로 넘어가도록 할 수 있다. 이것은 서브루틴 스레드 코드라고 불린다. 하지만 이 코드는 훨씬 더 많은 공간을 차지한다는 점에 유의해야 한다 — 명령어당 단일 바이트 대신 워드 하나 전체를 사용한다 — 그러나 속도상의 이점이 메모리가 넉넉한 하드웨어에서는 대체로 그럴 만한 가치가 있다. (토큰 스레드 코드는 메모리가 부족하고 속도가 덜 중요할 수 있는 상황에서 자주 사용된다.)
이 접근 방식은 런타임에 실행을 더 단순하게 만들지만, 로더를 필요로 하기 때문에 VM을 더 복잡하게 만듭니다. 로더는 바이트 코드 명령어를 명령어를 구현하는 함수의 주소로 대체합니다.
로더는 다음과 같이 생겼을 수 있습니다:
typedef void (*instructionp_t)(void);
void add() { int x,y; x = pop(); y = pop(); push(x + y); }
void mul() { int x,y; x = pop(); y = pop(); push(x * y); }
void pushi(){ int x; x = (int)*ip++; push(x); }
void stop() { running = 0; }
instructionp_t *read_file(char *name) {
FILE *file;
instructionp_t *code;
instructionp_t *cp;
long size;
char ch;
unsigned int val;
file = fopen(name, "r");
if(file == NULL) exit(1);
fseek(file, 0L, SEEK_END);
size = ftell(file);
code = calloc(size, sizeof(instructionp_t));
if(code == NULL) exit(1);
cp = code;
fseek(file, 0L, SEEK_SET);
while ( ( ch = fgetc(file) ) != EOF )
{
switch (ch) {
case ADD: *cp++ = &add; break;
case MUL: *cp++ = &mul; break;
case PUSH:
*cp++ = &pushi;
ch = fgetc(file);
val = 0;
while (ch--) { val = val * 256 + fgetc(file); }
*cp++ = (instructionp_t) val;
break;
}
}
*cp = &stop;
fclose(file);
return code;
}
보시다시피, 여기서는 로드 시점에 255보다 큰 정수를 디코딩하는 작업을 포함하여 더 많은 작업을 수행합니다. (네, 알고 있습니다, 이 코드는 매우 큰 정수에 대해서는 안전하지 않습니다.)
하지만 가상 머신의 디코드 및 디스패치 루프는 매우 단순해집니다:
int run() {
sp = 0;
running = 1;
while (running) (*ip++)();
return pop();
}
마지막으로, 각 명령어 조각을 반환하는 함수로 만드는 대신, 각 조각이 메인 루프가 다음 명령어를 읽어서 그곳으로 점프하는 데 사용할 동일한 작은 코드 조각으로 끝나도록 코드를 작성할 수 있습니다. 이렇게 하면 메인 루프와 조각들 사이를 왔다 갔다 하지 않아도 됩니다 — 사실상 메인 루프는 사라지고, 각 명령어 조각이 다음 명령어로 이어지는 것을 개별적으로 책임지게 됩니다. 이 기법을 다이렉트 스레디드 코드(direct threaded code)라고 한다. 이를 C로 구현하기 위해 BEAM은 GCC 확장 기능인 “labels as values”를 사용합니다.
5.2.3. 진짜 BEAM
BEAM 에뮬레이터에 대해서는 나중에 더 자세히 살펴보겠지만, 지금은 실제 BEAM add 명령어가 어떻게 구현되어 있는지 간단히 살펴보겠습니다. 이 코드는 매크로를 많이 사용하기 때문에 따라가기가 다소 어렵습니다. STORE_ARITH_RESULT 매크로는 실제로 디스패치 함수를 숨기고 있는데, 이는 대략 다음과 같은 형태입니다: I += 4; Goto(*I);.
#define OpCase(OpCode) lb_##OpCode
#define Goto(Rel) goto *(Rel)
...
OpCase(i_plus_jId):
{
Eterm result;
if (is_both_small(tmp_arg1, tmp_arg2)) {
Sint i = signed_val(tmp_arg1) + signed_val(tmp_arg2);
ASSERT(MY_IS_SSMALL(i) == IS_SSMALL(i));
if (MY_IS_SSMALL(i)) {
result = make_small(i);
STORE_ARITH_RESULT(result);
}
}
arith_func = ARITH_FUNC(mixed_plus);
goto do_big_arith2;
}
BEAM 디스패처가 어떻게 구현되는지 좀 더 쉽게 이해할 수 있도록, 다소 가상적인 예제를 하나 살펴보겠습니다. 실제 외부 BEAM 코드에서 시작하되, 일부 내부 BEAM 명령어를 만들어 C로 구현하겠습니다.
Erlang의 간단한 add 함수로 시작해보면:
add(A,B) -> id(A) + id(B).
BEAM 코드로 컴파일하면 다음과 같이 보일 것입니다 (또는 swap 명령어가 추가되기 전에는 그랬을 것입니다 — Section 5.1.2의 스택 예제 참조):
{function, add, 2, 2}.
{label,1}.
{func_info,{atom,add},{atom,add},2}.
{label,2}.
{allocate,1,2}.
{move,{x,1},{y,0}}.
{call,1,{f,4}}.
{move,{x,0},{x,1}}.
{move,{y,0},{x,0}}.
{move,{x,1},{y,0}}.
{call,1,{f,4}}.
{gc_bif,'+',{f,0},1,[{y,0},{x,0}],{x,0}}.
{deallocate,1}.
return.
(전체 코드는 온라인 부록의 add.erl과 add.S를 참조하십시오.)
이제 이 코드에서 함수 호출 사이에 있는 세 개의 명령어를 확대해서 보면:
{move,{x,0},{x,1}}.
{move,{y,0},{x,0}}.
{move,{x,1},{y,0}}.
이 코드는 먼저 함수 호출의 반환값(x0)을 레지스터 x1로 이동합니다. 그런 다음 스택 슬롯 y0에서 이전에 저장된 B를 첫 번째 인수 레지스터(x0)로 이동합니다. 마지막으로 x1의 값을 스택 슬롯(y0)으로 이동하여 다음 함수 호출까지 유지되도록 합니다.
BEAM에서 move_xx, move_yx, move_xy라는 세 가지 명령어를 구현한다고 가정해 보자(이 명령어들은 이 글을 쓰는 시점에 실제 BEAM에는 존재하지 않으며, 이 예제를 설명하기 위해 사용할 뿐이다):
#define OpCase(OpCode) lb_##OpCode
#define Goto(Rel) goto *((void *)Rel)
#define Arg(N) (Eterm *) I[(N)+1]
OpCase(move_xx):
{
x(Arg(1)) = x(Arg(0));
I += 3;
Goto(*I);
}
OpCase(move_yx): {
x(Arg(1)) = y(Arg(0));
I += 3;
Goto(*I);
}
OpCase(move_xy): {
y(Arg(1)) = x(Arg(0));
I += 3;
Goto(*I);
}
goto *의 별표는 역참조를 의미하지 않으며, 이 표현식은 주소 포인터로 점프하는 것을 의미하므로, 실제로는 goto*로 써야 한다.
이제 이 명령어들에 대해 컴파일된 C 코드가 메모리 주소 0x3000, 0x3100, 0x3200에 위치하게 된다고 상상해 보자. BEAM 코드가 로드되면, 코드 내의 세 move 명령어는 해당 명령어 구현의 메모리 주소로 대체된다. 코드({move,{x,0},{x,1}}, {move,{y,0},{x,0}}, {move,{x,1},{y,0}})가 주소 0x1000에 로드된다고 상상해 보자:
/ 0x1000: 0x3000 -> 0x3000: OpCase(move_xx): x(Arg(1)) = x(Arg(0))
{move,{x,0},{x,1}} { 0x1004: 0x0 I += 3;
\ 0x1008: 0x1 Goto(*I);
/ 0x100c: 0x3100
{move,{y,0},{x,0}} { 0x1010: 0x0
\ 0x1014: 0x0
/ 0x1018: 0x3200
{move,{x,1},{y,0}} { 0x101c: 0x1
\ 0x1020: 0x0
주소 0x1000의 워드는 move_xx 명령어의 구현을 가리킵니다. 레지스터 I가 명령어 포인터를 담고 있으며 0x1000을 가리킨다면, 디스패치는 *I(즉 0x3000)를 가져와 그 주소(goto* *I)로 점프하는 것이다.
Chapter 7에서 실제 BEAM 명령어 몇 가지와 그것들이 어떻게 구현되는지 더 자세히 살펴보겠습니다.
5.3. 프로세스 스위칭
대부분의 현대 멀티스레딩 운영체제는 선점형 스케줄링을 사용한다. 이는 프로세스가 무엇을 하고 있는지와 무관하게, 일반적으로 인터럽트를 통해 운영체제가 언제 한 프로세스에서 다른 프로세스로 전환할지를 결정한다는 것을 의미한다. 이는 프로세스가 제때 양보하지 않아 오작동하는 상황으로부터 다른 프로세스들을 보호한다.
비선점형 스케줄러를 사용하는 협력적 멀티태스킹 에서는, 실행 중인 프로세스가 언제 양보(yield)할지를 결정한다. 이는 오작동하는 프로세스가 CPU를 독점하여 다른 어떤 프로세스도 실행되지 못하게 만들 수 있다는 단점이 있다(구버전의 Microsoft Windows나 Mac OS에서, 또는 동시성을 위해 async/await를 사용하는 JavaScript 같은 단일 스레드 언어에서와 마찬가지다).
반면에 양보하는 프로세스가 알려진 상태에서만 양보하도록 결정할 수 있다는 장점이 있습니다. 예를 들어, 동적 메모리 관리와 태그된 값을 가진 Erlang과 같은 언어에서는 작업 메모리에 태그되지 않은 값이 없고, 댕글링 포인터나 초기화되지 않은 메모리 주소가 없을 때만 프로세스가 양보하도록 구현을 설계할 수 있습니다.
add 명령어를 예로 들어봅시다: 두 Erlang 정수를 더하려면, 에뮬레이터는 먼저 정수의 태그를 제거하고, 더한 다음, 결과를 정수로 태그해야 합니다. 완전한 선점형 스케줄러를 사용하면 정수의 태그가 제거된 상태에서 프로세스가 중단되지 않는다는 보장이 없습니다. 또는 프로세스가 힙에 튜플을 생성하는 도중에 중단되어 절반만 완성된 튜플이 남을 수 있습니다. 이렇게 되면 중단된 프로세스의 스택과 힙을 순회하는 것이 매우 어려워집니다.
언어 수준에서 모든 프로세스는 동시에 실행되며, 프로그래머가 명시적 양보를 처리할 필요가 없어야 합니다. BEAM은 프로세스가 얼마나 오래 실행되었는지를 추적함으로써 이 문제를 해결한다. 이는 리덕션 횟수를 세어 수행된다. 이 텀은 원래 람다 계산에서 사용되는 수학적 텀인 베타 축소에서 유래했다.
BEAM에서 리덕션의 정의는 그다지 구체적이지 않지만, “너무 오래 걸리지 않아야 하는 작은 작업 단위”라고 볼 수 있다. 각 함수 호출은 하나의 리덕션으로 계산된다. BEAM은 각 함수에 진입할 때 프로세스가 자신의 리덕션을 모두 소진했는지 확인하는 테스트를 수행한다. 리덕션이 남아 있으면 함수가 실행되고, 그렇지 않으면 프로세스는 일시 중단되며 스케줄러가 준비된 다른 프로세스를 선택하여 time slice라고 하는 일정한 수의 리덕션 동안 실행합니다. 이것은 협력적 멀티태스킹으로 볼 수 있지만, 모든 프로그램이 올바르게 동작하도록 보장하는 것은 컴파일러에 의해 구현된다.
Erlang에는 직접적인 루프가 없고 꼬리 재귀 함수 호출만 존재하므로, 리덕션을 소진하지 않으면서 상당한 양의 작업을 수행하는 프로그램을 작성하는 것은 매우 어렵습니다.
|
직접 NIF를 작성한다면, 반드시 양보(yield)할 수 있도록 하고 실행 시간에 비례하는 만큼 리덕션 카운터를 증가시키도록 하십시오. |
스케줄러가 어떻게 동작하는지에 대한 세부 사항은 Chapter 10에서 다룰 것입니다.
5.4. 메모리 관리
Erlang은 가비지 컬렉션을 지원하는 언어입니다. Erlang 프로그래머로서 명시적인 메모리 관리를 할 필요가 없습니다. 그러나 BEAM 코드 수준에서는 코드가 데이터 구조를 초기화하고, 스택 및 힙 오버런을 검사하며, 스택과 힙에 충분한 공간을 할당하는 것을 책임집니다. 이를 올바르게 수행하지 않으면 크래시가 발생할 수 있습니다.
BEAM 명령어 test_heap은 요청된 만큼의 공간이 힙에 있는지 확인한다. 필요한 경우 이 명령어는 가비지 컬렉터를 호출하여 힙의 공간을 회수한다. 가비지 컬렉터는 다시 메모리 서브시스템의 하위 레벨을 호출하여 필요에 따라 메모리를 할당하거나 해제한다. 메모리 관리와 가비지 컬렉션에 대한 자세한 내용은 Chapter 11에서 살펴볼 것이다.
5.5. BEAM: 그것은 사실상 비현실적입니다
BEAM은 가상 머신으로, 이는 하드웨어가 아닌 소프트웨어로 구현되어 있다는 것을 의미합니다. FPGA로 BEAM을 구현하려는 프로젝트가 있었으며, 누구든 하드웨어로 BEAM을 구현하는 것을 막을 수 없습니다. 더 나은 설명은 BEAM을 추상 머신이라고 부르고, BEAM 코드를 실행할 수 있는 머신의 청사진으로 보는 것일 수 있습니다. 그리고 사실 BEAM에서 "AM"은 "Abstract Machine"을 의미합니다.
이 책에서는 추상 머신과 가상 머신 또는 그 구현 사이의 구분을 두지 않겠습니다. 더 형식적인 맥락에서 추상 기계는 컴퓨터의 이론적 모델이며, 가상 머신은 추상 기계의 소프트웨어 구현체이거나 실제 물리적 기계의 소프트웨어 에뮬레이터입니다.
안타깝게도 BEAM에 대한 공식 명세는 존재하지 않는다 — 현재는 오직 Erlang/OTP의 구현에 의해서만 정의되어 있다. 여러분이 직접 BEAM을 구현하고자 한다면, 어떤 부분이 본질적이고 어떤 부분이 우연적인지 알지 못한 채로 현재 구현을 모방하려고 시도해야 할 것이다. 유효한 BEAM 인터프리터를 갖추었다고 확신하려면 모든 관찰 가능한 동작을 모방해야 할 것입니다.
6. 모듈과 BEAM 파일 포맷
6.1. 모듈
Erlang에서 모듈은 Erlang 함수들을 포함하는 파일입니다. 모듈은 관련 함수들을 함께 그룹화하고 다른 모듈에서 사용할 수 있는 방법을 제공합니다. Erlang에서 코드 로딩은 컴파일된 Erlang 모듈을 BEAM 가상 머신에 적재하는 프로세스입니다. 이는 시작 시 정적으로 수행하거나 시스템이 실행 중인 동안 동적으로 수행할 수 있습니다.
Erlang은 핫 코드 로딩을 지원하며, 이는 시스템을 중지하거나 재시작하지 않고도 시스템이 실행 중인 상태에서 모듈을 업데이트할 수 있음을 의미합니다. 이는 개발 및 디버깅 중에 매우 편리합니다. 시스템을 어떻게 배포하느냐에 따라, 시스템을 중지하지 않고 모듈을 업그레이드할 수 있게 해 주므로 24/7 시스템을 유지보수하고 운영할 때에도 유용할 수 있습니다.
새로운 코드가 로드되면, 이전 버전은 이를 실행하고 있는 프로세스가 없을 때까지 메모리에 남아 있습니다. 그러한 상태가 되면, 이전 코드는 시스템에서 퍼지(purge)됩니다. 첫 번째 버전이 퍼지되기 전에 모듈의 세 번째 버전을 로드하면, 시스템의 기본 동작은 첫 번째 버전을 참조하는(스택에 호출이 있는) 모든 프로세스를 종료시키는 것입니다.
code:load_file(Module) 함수를 사용하여 모듈을 시스템에 동적으로 로드할 수 있습니다. 새 모듈이 로드된 후에는 완전 한정 호출(즉, Module:function), 일명 원격 호출이 새 버전으로 전달됩니다. 원격 호출 없이 서버 루프를 실행하고 있다면 이전 코드가 계속 실행된다는 점에 유의하십시오.
코드 서버는 로드된 모듈과 그 코드를 관리하는 BEAM 가상 머신의 일부입니다.
Erlang의 분산 모델과 핫 코드 로딩 기능은 분산 시스템의 여러 노드에 걸쳐 코드를 업데이트하는 것을 가능하게 합니다. 그러나 이는 신중한 조율이 필요한 복잡한 작업입니다.
6.2. BEAM 파일 포맷
BEAM 파일 포맷에 대한 확실한 정보 출처는 당연히 beam_lib.erl의 소스 코드입니다(https://github.com/erlang/otp/blob/maint/lib/stdlib/src/beam_lib.erl 참조). 실제로 Beam의 주요 개발자이자 관리자가 작성한 좀 더 읽기 쉽지만 다소 오래된 포맷 설명도 있습니다(http://www.erlang.se/~bjorn/beam_file_format.html 참조).
beam 파일 형식은 교환 파일 형식(EA IFF)#을 기반으로 하며, 두 가지 작은 변경 사항이 있다. 이에 대해서는 곧 다루겠다. IFF 파일은 헤더로 시작하고 그 뒤에 여러 개의 "청크"가 이어진다. IFF 명세에는 주로 이미지와 음악을 다루는 다수의 표준 청크 타입이 있다. 하지만 IFF 표준은 자신만의 명명된 청크를 지정할 수 있도록 허용하며, 이것이 바로 BEAM이 하는 방식이다.
| BEAM 파일은 표준 IFF 파일과 달리, 각 청크가 IFF 표준에서와 같이 2바이트 경계가 아닌 4바이트 경계(즉, 32비트 워드)에 정렬된다는 점에서 차이가 있습니다. 이것이 표준 IFF 파일이 아님을 나타내기 위해 IFF 헤더는 "FOR" 대신 "FOR1"로 태그됩니다. IFF 명세에서는 향후 확장을 위해 이 태그를 제안하고 있습니다. |
Beam은 폼 타입 "BEAM"을 사용합니다. BEAM 파일 헤더는 다음과 같은 레이아웃을 가집니다:
BEAMHeader = <<
IffHeader:4/unit:8 = "FOR1",
Size:32/big, // big endian, how many more bytes are there
FormType:4/unit:8 = "BEAM"
>>
헤더 뒤에는 여러 개의 청크가 존재할 수 있습니다. 각 청크의 크기는 4의 배수로 정렬되며, 각 청크는 자체 헤더를 가지고 있습니다(아래 참조).
| 정렬(alignment)은 일부 플랫폼에서 중요한데, 정렬되지 않은 메모리 바이트 접근이 하드웨어 예외(Linux에서는 SIGBUS라고 함)를 발생시킬 수 있기 때문이다. 이는 성능 저하로 이어질 수 있으며, 예외로 인해 VM이 크래시될 수도 있다. |
BEAMChunk = <<
ChunkName:4/unit:8, // "Code", "Atom", "StrT", "LitT", ...
ChunkSize:32/big,
ChunkData:ChunkSize/unit:8, // data format is defined by ChunkName
Padding4:0..3/unit:8
>>
이 파일 형식은 모든 영역 앞에 다음 영역의 크기를 붙여두므로, 디스크에서 읽으면서 파일을 직접 파싱하기가 쉽습니다. BEAM 파일의 구조와 내용을 설명하기 위해, BEAM 파일에서 모든 청크를 추출하는 간단한 프로그램을 작성해 보겠습니다. 이 프로그램을 가능한 한 간단하고 읽기 쉽게 만들기 위해, 파일을 읽으면서 파싱하지 않고 대신 전체 파일을 바이너리로 메모리에 로드한 다음 각 청크를 파싱하겠습니다. 첫 번째 단계는 모든 청크의 목록을 가져오는 것입니다:
-module(beamfile1).
-export([read/1]).
read(Filename) ->
{ok, File} = file:read_file(Filename),
<<"FOR1",
Size:32/integer,
"BEAM",
Chunks/binary>> = File,
{Size, read_chunks(Chunks, [])}.
read_chunks(<<N,A,M,E, Size:32/integer, Tail/binary>>, Acc) ->
%% Align each chunk on even 4 bytes
ChunkLength = align_by_four(Size),
<<Chunk:ChunkLength/binary, Rest/binary>> = Tail,
read_chunks(Rest, [{[N,A,M,E], Size, Chunk}|Acc]);
read_chunks(<<>>, Acc) -> lists:reverse(Acc).
align_by_four(N) -> (4 * ((N+3) div 4)).
샘플 실행은 다음과 같을 수 있습니다:
> beamfile1:read("beamfile1.beam").
{1116, [{"AtU8",127,
<<0,0,0,16,9,98,101,97,109,102,105,108,101,49,4,114,101,
97,100,4,102,105,108,...>>}, {"Code",362,
<<0,0,0,16,0,0,0,0,0,0,0,178,0,0,0,16,0,0,0,5,1,16,...>>},
{"StrT",8,<<"FOR1BEAM">>},
{"ImpT",88,<<0,0,0,7,0,0,0,3,0,0,0,4,0,0,0,1,0,0,0,8,...>>},
{"ExpT",40,<<0,0,0,3,0,0,0,15,0,0,0,1,0,0,0,15,0,0,0,...>>}, {"Meta",29,
<<131,108,0,0,0,1,104,2,100,0,16,101,110,97,98,108,101, 100,...>>},
{"LocT",28,<<0,0,0,2,0,0,0,10,0,0,0,1,0,0,0,11,0,...>>}, {"Attr",40,
<<131,108,0,0,0,1,104,2,100,0,3,118,115,110,108,0,...>>}, {"CInf",122,
<<131,108,0,0,0,3,104,2,100,0,7,118,101,114,115,...>>}, {"Dbgi",49,
<<131,104,3,100,0,13,100,101,98,117,103,95,105,110,...>>},
{"Line",31,<<0,0,0,0,0,0,0,0,0,0,0,12,0,...>>},
{"Type",80,<<0,0,0,1,0,0,0,4,31,255,0,0,...>>}]}
여기서 BEAM이 사용하는 청크 이름들을 확인할 수 있습니다.
6.2.1. 아톰 테이블 청크
Atom이라는 이름의 청크나 AtU8이라는 이름의 청크 중 하나는 필수입니다. 이 청크는 모듈이 참조하는 모든 아톰을 포함한다. latin1 인코딩을 사용하는 소스 파일의 경우, Atom이라는 이름의 청크가 사용됩니다. utf8로 인코딩된 모듈의 경우, 청크의 이름은 AtU8입니다. 아톰 청크의 형식은 다음과 같다:
AtomChunk = <<
ChunkName:4/unit:8 = "Atom",
ChunkSize:32/big,
NumberOfAtoms:32/big,
[<<AtomLength:8, AtomName:AtomLength/unit:8>> || repeat NumberOfAtoms],
Padding4:0..3/unit:8
>>
AtU8 청크의 형식은 위와 동일하지만, 청크의 이름이 AtU8인 점만 다릅니다.
| 모듈 이름은 항상 테이블의 첫 번째 아톰(아톰 인덱스 0)으로 저장됩니다. |
Beam 파일 리더에 아톰 청크용 디코더를 추가해 봅시다:
-module(beamfile2).
-export([read/1]).
read(Filename) ->
{ok, File} = file:read_file(Filename),
<<"FOR1",
Size:32/integer,
"BEAM",
Chunks/binary>> = File,
{Size, parse_chunks(read_chunks(Chunks, []),[])}.
read_chunks(<<N,A,M,E, Size:32/integer, Tail/binary>>, Acc) ->
%% Align each chunk on even 4 bytes
ChunkLength = align_by_four(Size),
<<Chunk:ChunkLength/binary, Rest/binary>> = Tail,
read_chunks(Rest, [{[N,A,M,E], Size, Chunk}|Acc]);
read_chunks(<<>>, Acc) -> lists:reverse(Acc).
parse_chunks([{"AtU8", _Size,
<<_Numberofatoms:32/integer, Atoms/binary>>}
| Rest], Acc) ->
parse_chunks(Rest,[{atoms,parse_atoms(Atoms)}|Acc]);
parse_chunks([Chunk|Rest], Acc) -> %% Not yet implemented chunk
parse_chunks(Rest, [Chunk|Acc]);
parse_chunks([],Acc) -> Acc.
parse_atoms(<<Atomlength, Atom:Atomlength/binary, Rest/binary>>) when Atomlength > 0->
[list_to_atom(binary_to_list(Atom)) | parse_atoms(Rest)];
parse_atoms(_Alignment) -> [].
align_by_four(N) -> (4 * ((N+3) div 4)).
6.2.2. 익스포트 테이블 청크
ExpT(EXPort Table)라는 이름의 청크는 필수이며, 어떤 함수들이 익스포트되는지에 대한 정보를 포함합니다.
익스포트 청크의 형식은 다음과 같습니다:
ExportChunk = <<
ChunkName:4/unit:8 = "ExpT",
ChunkSize:32/big,
ExportCount:32/big,
[ << FunctionName:32/big,
Arity:32/big,
Label:32/big
>> || repeat ExportCount ],
Padding4:0..3/unit:8
>>
FunctionName는 아톰 테이블의 인덱스입니다.
아톰 처리 절 뒤에 다음 절을 추가하여 parse_chunk 함수를 확장할 수 있습니다:
parse_chunks([{"ExpT", _Size,
<<_Numberofentries:32/integer, Exports/binary>>}
| Rest], Acc) ->
parse_chunks(Rest,[{exports,parse_exports(Exports)}|Acc]);
...
parse_exports(<<Function:32/integer,
Arity:32/integer,
Label:32/integer,
Rest/binary>>) ->
[{Function, Arity, Label} | parse_exports(Rest)];
parse_exports(<<>>) -> [].
6.2.3. Import 테이블 청크
ImpT(IMPort Table의 약자)라는 이름의 청크는 필수이며, 어떤 함수들이 임포트되는지에 대한 정보를 포함합니다.
청크의 형식은 다음과 같습니다:
ImportChunk = <<
ChunkName:4/unit:8 = "ImpT",
ChunkSize:32/big,
ImportCount:32/big,
[ << ModuleName:32/big,
FunctionName:32/big,
Arity:32/big
>> || repeat ImportCount ],
Padding4:0..3/unit:8
>>
여기서 ModuleName과 FunctionName은 원자 테이블의 인덱스입니다.
| import 테이블을 파싱하는 코드는 export 테이블을 파싱하는 코드와 비슷하지만 완전히 같지는 않습니다: 둘 다 32비트 정수의 삼중항(triplet)이지만, 그 의미가 다릅니다. 이 장의 끝에서 전체 코드를 확인하세요. |
6.2.4. 코드 청크
Code라는 이름의 청크는 모듈의 BEAM 코드를 포함하며 필수입니다. 이 청크의 형식은 다음과 같습니다:
CodeChunk = <<
ChunkName:4/unit:8 = "Code",
ChunkSize:32/big,
SubSize:32/big,
InstructionSet:32/big, % Must match code version in the emulator
OpcodeMax:32/big,
LabelCount:32/big,
FunctionCount:32/big,
Code:(ChunkSize-SubSize)/binary, % all remaining data
Padding4:0..3/unit:8
>>
SubSize 필드는 코드가 시작되기 전까지의 워드 수를 저장합니다. 이를 통해 기존의 오래된 로더를 손상시키지 않으면서 코드 청크에 새로운 정보 필드를 추가할 수 있습니다.
InstructionSet 필드는 해당 파일이 사용하는 명령어 세트의 버전을 나타냅니다. 명령어가 호환되지 않는 방식으로 변경되면 버전 번호가 증가합니다.
OpcodeMax 필드는 코드에서 사용된 모든 옵코드 중 가장 높은 번호를 나타낸다. 새로운 명령어는 시스템에 추가될 수 있으며, 파일에서 사용된 명령어가 로더가 인식하는 범위 내에 있는 한, 이전 버전의 로더도 여전히 새로운 파일을 로드할 수 있습니다.
LabelCount 필드는 레이블의 수를 포함하며, 이를 통해 로더가 한 번의 호출로 적절한 크기의 레이블 테이블을 미리 할당할 수 있습니다. FunctionCount 필드는 함수의 수를 포함하며, 이를 통해 함수 테이블도 효율적으로 미리 할당할 수 있습니다.
Code 필드는 서로 연결된 명령어들을 포함하며, 각 명령어는 다음과 같은 형식을 가집니다:
Instruction = <<
InstructionCode:8,
[beam_asm:encode(Argument) || repeat Arity]
>>
여기서 Arity는 ops.tab로부터 emulator가 소스에서 빌드될 때 genop 스크립트에 의해 생성되는 테이블에 하드코딩되어 있습니다.
beam_asm:encode가 생성하는 인코딩은 아래 Compact Term Encoding 섹션에서 설명합니다.
다음 코드를 프로그램에 추가하여 코드 청크를 파싱할 수 있습니다:
parse_chunks([{"Code", Size, <<SubSize:32/integer,Chunk/binary>>
} | Rest], Acc) ->
<<Info:SubSize/binary, Code/binary>> = Chunk,
%% 8 is size of ChunkSize & SubSize
OpcodeSize = Size - SubSize - 8,
<<OpCodes:OpcodeSize/binary, _Align/binary>> = Code,
parse_chunks(Rest,[{code,parse_code_info(Info), OpCodes}
| Acc]);
...
parse_code_info(<<Instructionset:32/integer,
OpcodeMax:32/integer,
NumberOfLabels:32/integer,
NumberOfFunctions:32/integer,
Rest/binary>>) ->
[{instructionset, Instructionset},
{opcodemax, OpcodeMax},
{numberoflabels, NumberOfLabels},
{numberofFunctions, NumberOfFunctions} |
case Rest of
<<>> -> [];
_ -> [{newinfo, Rest}]
end].
BEAM 명령어를 디코딩하는 방법은 이후 장에서 배울 것이며, 해당 장의 이름은 적절하게도 "BEAM 명령어"입니다.
6.2.5. 문자열 테이블 청크
StrT라는 이름의 청크는 필수이며, 모듈 내 모든 상수 문자열 리터럴을 하나의 긴 문자열로 포함합니다. 문자열 리터럴이 없는 경우에도 청크는 여전히 존재해야 하지만, 비어 있고 크기가 0이어야 합니다.
청크의 형식은 다음과 같습니다:
StringChunk = <<
ChunkName:4/unit:8 = "StrT",
ChunkSize:32/big,
Data:ChunkSize/binary,
Padding4:0..3/unit:8
>>
문자열 청크는 바이트 문자열을 바이너리로 변환하는 것만으로 쉽게 파싱할 수 있습니다:
parse_chunks([{"StrT", _Size, <<Strings/binary>>} | Rest], Acc) ->
parse_chunks(Rest,[{strings,binary_to_list(Strings)}|Acc]);
6.2.6. Attributes 청크
Attr라는 이름의 청크는 선택 사항이지만, 일부 OTP 도구는 속성이 존재하기를 기대합니다. 릴리스 핸들러는 "vsn" 속성이 존재할 것으로 기대합니다. beam_lib:version(Filename)을 사용하여 파일에서 버전 속성을 가져올 수 있으며, 이 함수는 "vsn" 속성이 포함된 속성 청크가 BEAM 파일에 존재한다고 가정합니다.
청크의 형식은 다음과 같습니다:
AttributesChunk = <<
ChunkName:4/unit:8 = "Attr",
ChunkSize:32/big,
Attributes:ChunkSize/binary,
Padding4:0..3/unit:8
>>
속성 청크를 다음과 같이 파싱할 수 있습니다:
parse_chunks([{"Attr", Size, Chunk} | Rest], Acc) ->
<<Bin:Size/binary, _Pad/binary>> = Chunk,
Attribs = binary_to_term(Bin),
parse_chunks(Rest,[{attributes,Attribs}|Acc]);
6.2.7. 컴파일 정보 청크
CInf라는 이름의 청크는 선택 사항이지만, 일부 OTP 도구는 이 정보가 존재할 것으로 기대합니다.
청크의 형식은 다음과 같습니다:
CompilationInfoChunk = <<
ChunkName:4/unit:8 = "CInf",
ChunkSize:32/big,
Data:ChunkSize/binary,
Padding4:0..3/unit:8
>>
컴파일 정보 청크를 다음과 같이 파싱할 수 있습니다:
parse_chunks([{"CInf", Size, Chunk} | Rest], Acc) ->
<<Bin:Size/binary, _Pad/binary>> = Chunk,
CInfo = binary_to_term(Bin),
parse_chunks(Rest,[{compile_info,CInfo}|Acc]);
6.2.8. 로컬 함수 테이블 청크
LocT라는 청크는 선택 사항이며 상호 참조 도구를 위한 것입니다.
형식은 export 테이블과 동일합니다:
LocalFunTableChunk = <<
ChunkName:4/unit:8 = "LocT",
ChunkSize:32/big,
FunctionCount:32/big,
[ << FunctionName:32/big,
Arity:32/big,
Label:32/big
>> || repeat FunctionCount ],
Padding4:0..3/unit:8
>>
| 로컬 함수 테이블을 파싱하는 코드는 익스포트 테이블 및 임포트 테이블을 파싱하는 코드와 기본적으로 동일하며, 실제로 모든 테이블의 엔트리를 파싱하는 데 같은 함수를 사용할 수 있습니다. 전체 코드는 이 장의 끝부분을 참고하세요. |
6.2.9. 리터럴 테이블 청크
LitT라는 이름의 청크는 선택 사항이며, 모듈 소스에서 즉시 값이 아닌 모든 리터럴 값을 압축된 형태로 포함합니다. 이 청크의 형식은 다음과 같습니다:
LiteralTableChunk = <<
ChunkName:4/unit:8 = "LitT",
ChunkSize:32/big,
UncompressedSize:32/big, % It is nice to know the size to allocate some memory
CompressedLiterals:ChunkSize/binary,
Padding4:0..3/unit:8
>>
여기서 CompressedLiterals는 정확히 UncompressedSize 바이트를 가져야 합니다. 테이블의 각 리터럴은 외부 텀 형식(External Term Format, erlang:term_to_binary)으로 인코딩됩니다. CompressedLiterals의 형식은 다음과 같습니다:
CompressedLiterals = <<
Count:32/big,
[ <<Size:32/big, Literal:binary>> || repeat Count ]
>>
전체 테이블은 zlib:compress/1 (deflate 알고리즘)으로 압축되어 있으며, zlib:uncompress/1 (inflate 알고리즘)으로 압축을 해제할 수 있습니다.
이 청크를 다음과 같이 파싱할 수 있습니다:
parse_chunks([{"LitT", _ChunkSize,
<<_UnCompressedTableSize:32, Compressed/binary>>}
| Rest], Acc) ->
<<_NumLiterals:32,Table/binary>> = zlib:uncompress(Compressed),
Literals = parse_literals(Table),
parse_chunks(Rest,[{literals,Literals}|Acc]);
...
parse_literals(<<Size:32,Literal:Size/binary,Tail/binary>>) ->
[binary_to_term(Literal) | parse_literals(Tail)];
parse_literals(<<>>) -> [].
6.2.10. 추상 코드 청크
Abst라는 청크는 선택 사항이며 추상 형태의 코드를 포함할 수 있습니다. 컴파일러에 debug_info 플래그를 지정하면, 해당 모듈의 추상 구문 트리가 이 청크에 저장됩니다. 디버거나 Xref와 같은 OTP 도구들은 추상 형식을 필요로 합니다. 이 청크의 형식은 다음과 같습니다:
AbstractCodeChunk = <<
ChunkName:4/unit:8 = "Abst",
ChunkSize:32/big,
AbstractCode:ChunkSize/binary,
Padding4:0..3/unit:8
>>
이 청크를 다음과 같이 파싱할 수 있습니다
parse_chunks([{"Abst", _ChunkSize, <<>>} | Rest], Acc) ->
parse_chunks(Rest,Acc);
parse_chunks([{"Abst", _ChunkSize, <<AbstractCode/binary>>} | Rest], Acc) ->
parse_chunks(Rest,[{abstract_code,binary_to_term(AbstractCode)}|Acc]);
6.2.11. 추가 BEAM 청크
Erlang OTP 16 이후로 여러 새로운 청크가 추가되었습니다:
-
타입 청크 (
Type): OTP 25에서 도입되었으며, JIT 최적화에 도움이 되는 단순화된 타입 정보를 담고 있습니다. -
함수 테이블 청크 (
FunT): 익명 함수(fun)에 대한 메타데이터. -
메타데이터 청크 (
Meta): 문서화나 어노테이션 같은 선택적 메타데이터를 담습니다.
새 청크의 형식은 다음과 같습니다:
타입 청크(Type):
TypeChunk = <<
ChunkName:4/unit:8 = "Type",
ChunkSize:32/big,
Version:32/big,
Count:32/big,
TypeData:(Count * 16)/binary,
Padding4:0..3/unit:8
>>
Type 청크는 JIT 컴파일러가 최적화에 사용하는 단순화된 타입 정보를 담고 있습니다. 각 타입 항목은 고정된 16바이트 레코드입니다. 데이터는 Erlang 외부 텀 포맷이 아니므로 binary_to_term으로 디코딩할 수 없습니다. 인코딩 방식은 컴파일러의 beam_types.erl을 참조하세요.
파싱:
parse_chunks([{"Type", _Size, Chunk} | Rest], Acc) ->
<<_Version:32/big, Count:32/big, TypeData/binary>> = Chunk,
Types = parse_types(Count, TypeData, []),
parse_chunks(Rest, [{type_info, Types}|Acc]);
-
UTF-8 아톰 청크 (
AtU8): Atom 청크와 동일한 형식이며, UTF-8 인코딩이 필요한 경우 사용됩니다:
AtU8Chunk = <<
ChunkName:4/unit:8 = "AtU8",
ChunkSize:32/big,
NumberOfAtoms:32/big,
[<<AtomLength:8, AtomName:AtomLength/unit:8>> || repeat NumberOfAtoms],
Padding4:0..3/unit:8
>>
-
함수 테이블 (
FunT): 로컬 익명 함수에 대한 정보:
FunTableChunk = <<
ChunkName:4/unit:8 = "FunT",
ChunkSize:32/big, FunCount:32/big, [<<FunctionName:32/big, Arity:32/big,
Label:32/big
>> || repeat FunCount],
Padding4:0..3/unit:8
>>
-
메타데이터 청크 (
Meta): 어노테이션이나 사용자 정의 속성과 같은 메타데이터를 저장합니다:
MetaChunk = <<
ChunkName:4/unit:8 = "Meta",
ChunkSize:32/big,
MetaInfo:ChunkSize/binary,
Padding4:0..3/unit:8
>>
파싱 예시:
parse_chunks([{"Meta", Size, Chunk} | Rest], Acc) ->
<<MetaInfo:Size/binary, _Pad/binary>> = Chunk,
Meta = binary_to_term(MetaInfo),
parse_chunks(Rest, [{meta, Meta}|Acc]);
6.2.12. 암호화
Erlang은 BEAM 파일 내 디버그 정보의 암호화를 허용합니다. 이 기능을 통해 개발자는 Debugger나 Xref와 같은 도구를 여전히 활용하면서도 소스 코드의 기밀성을 유지할 수 있습니다.
암호화된 디버그 정보를 사용하려면 컴파일러와 beam_lib 양쪽 모두에 키를 제공해야 합니다. 이 키는 문자열로 지정되며, 대문자와 소문자, 숫자, 특수 문자를 포함하여 최소 32자 이상으로 구성하는 것이 이상적입니다.
기본값이자 현재 사용되는 유일한 암호화 알고리즘 유형은 des3_cbc이며, 이는 CBC(Cipher Block Chaining) 모드의 트리플 DES(Data Encryption Standard)를 의미합니다. 키 문자열은 erlang:md5/1을 사용하여 스크램블되어 des3_cbc에 사용되는 키를 생성합니다.
키는 두 가지 방법으로 제공될 수 있습니다:
-
컴파일러 옵션: 컴파일러 옵션 {debug_info_key,Key}와 함수 crypto_key_fun/1을 사용하여, beam_lib이 디버그 정보를 복호화해야 할 때마다 키를 반환하는 함수를 등록하십시오.
-
.erlang.crypt 파일: 등록된 함수가 없으면 beam_lib는 현재 디렉터리에서 .erlang.crypt 파일을 검색한 다음 사용자의 홈 디렉터리, 마지막으로 filename:basedir(user_config, "erlang")을 검색합니다. 파일이 발견되고 키가 포함되어 있으면 beam_lib는 암묵적으로 암호화 키 함수를 생성하여 등록합니다.
.erlang.crypt 파일은 {debug_info, Mode, Module, Key} 형식의 튜플 목록을 포함해야 합니다. Mode는 암호화 알고리즘의 유형이며(현재는 des3_cbc만 허용됨), Module은 아톰(이 경우 Key는 해당 모듈에만 사용됨) 또는 [](이 경우 Key는 모든 모듈에 사용됨)이고, Key는 비어 있지 않은 키 문자열입니다.
Mode와 Module이 모두 일치하는 첫 번째 튜플의 키가 사용됩니다. 암호화된 디버그 정보의 안전성을 보장하려면 고유한 키를 사용하고 이를 안전하게 보관하는 것이 중요합니다.
6.2.13. 압축
Erlang 컴파일러에 compressed 플래그를 전달하면, 컴파일러가 생성하는 BEAM 파일을 압축하도록 지시합니다. 이렇게 하면 파일 크기가 상당히 줄어들 수 있으며, 디스크 공간이 부족한 환경에서 유용할 수 있습니다.
compressed 플래그는 Erlang 코드와 리터럴 데이터를 포함하는 BEAM 파일의 부분에 zlib 압축을 적용합니다. 이는 코드의 실행 속도에 영향을 미치지 않는데, 코드가 실행될 때가 아니라 메모리에 로드될 때 압축이 해제되기 때문입니다.
compressed 플래그를 사용하려면, 다음과 같이 compile 함수의 옵션으로 전달하면 됩니다:
compile:file(Module, [compressed]).
또는, erlc 명령줄 컴파일러를 사용하는 경우 +compressed 옵션을 전달할 수 있습니다:
erlc +compressed module.erl
compressed 플래그가 BEAM 파일의 크기를 줄일 수 있지만, 코드를 압축 해제해야 하므로 모듈을 로드하는 데 걸리는 시간도 증가한다는 점에 유의하는 것이 중요합니다. 따라서 이는 디스크 공간과 로드 시간 사이의 트레이드오프입니다.
6.2.14. 컴팩트 텀 인코딩
beam_asm:encode에서 사용하는 인코딩 방식을 살펴보겠습니다. BEAM 파일은 단순한 텀을 효율적으로 저장하도록 설계된 컴팩트 인코딩 방식을 사용합니다. 이는 파일 크기를 줄이기 위해 특별히 맞춤화되어 있으며, 텀의 인메모리 레이아웃과는 상당히 다릅니다. 디코딩 후에는 인코딩된 모든 값이 전체 크기의 머신 워드 또는 Erlang 텀으로 확장됩니다.
Beam_asm는 Erlang 분산의 일부인 compiler 애플리케이션의 모듈로, beam 모듈의 바이너리 콘텐츠를 어셈블하는 데 사용된다.
|
이 인코딩 접근 방식의 주요 목표는 압축성입니다. 단순한 텀들을 더 적은 비트에 압축하여 효율적으로 인코딩합니다. 디코딩되면, 이러한 압축된 텀들은 표준 머신 워드 또는 Erlang 텀으로 확장됩니다.
다음은 태그 형식이며, 최근 변경 사항(특히 OTP 20 및 OTP 25)을 포함합니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
7 6 5 4 3 | 2 1 0
----------+-------+
| 0 0 0 | Literal (tag_u in beam_opcodes.hrl)
| 0 0 1 | Integer (tag_i)
| 0 1 0 | Atom (tag_a)
| 0 1 1 | X Register (tag_x)
| 1 0 0 | Y Register (tag_y)
| 1 0 1 | Label (tag_f)
| 1 1 0 | Character (tag_h)
| 1 1 1 | Extended (tag_z)
0 0 0 0 0 | 1 1 1 | Extended - Not used after R16B
0 0 0 1 0 | 1 1 1 | Extended - List
0 0 1 0 0 | 1 1 1 | Extended - Floating point register
0 0 1 1 0 | 1 1 1 | Extended - Allocation list
0 1 0 0 0 | 1 1 1 | Extended - Literal
0 1 0 1 0 | 1 1 1 | Register with type hint (new in OTP 25)
OTP 20부터 이 태그 형식이 변경되어 Extended - Float가 사라졌습니다. 이후의 모든 태그 값은 1씩 줄어든다: List는 2#10111, fpreg는 2#100111, alloc list는 2#110111, literal은 2#1010111이다. 부동소수점 값은 이제 BEAM 파일의 literal 영역으로 바로 들어간다.
|
이것은 첫 번째 바이트의 첫 3비트를 사용하여 다음 값의 타입을 정의하는 태그를 저장합니다. 비트가 모두 1이면 (특별한 값 7, 즉 beam_opcodes.hrl의 ?tag_z), 몇 비트가 더 사용됩니다.
16 미만의 값의 경우, 값은 3번 비트를 0으로 설정한 채 4-5-6-7번 비트에 완전히 배치됩니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. 7 6 5 4 | 3 | 2 1 0 --------+---+------ Value | 0 | Tag
2048(16#800) 미만의 값에 대해서는 비트 3이 1로 설정되어 1개의 연속 바이트가 사용됨을 나타내며, 값의 최상위 3비트가 이 바이트의 비트 5-6-7로 확장됩니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. 7 6 5 | 4 3 | 2 1 0 ------+-----+------ Value | 0 1 | Tag
더 큰 값과 음수 값은 먼저 바이트로 변환됩니다. 그런 다음 값이 2..8바이트를 차지하면, 비트 3-4는 1로 설정되고, 비트 5-6-7에는 뒤따르는 값의 (Bytes-2) 크기가 담깁니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. 7 6 5 | 4 3 | 2 1 0 --------+-----+------ Bytes-2 | 1 1 | Tag
다음 값이 8바이트보다 크면 비트 3-4-5-6-7이 모두 1로 설정되고, 그 뒤에 중첩된 인코딩된 부호 없는 리터럴(beam_opcodes.hrl의 매크로 ?tag_u) 값 (Bytes-9):8이 오며, 그다음에 데이터가 옵니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. 7 6 5 4 3 | 2 1 0 || Followed by a || ----------+------ || nested encoded literal || Data . . . 1 1 1 1 1 | Tag || (Size-9) ||
태그 유형
컴팩트 텀 포맷을 읽을 때, 결과로 나오는 정수는 Tag의 값이 무엇인지에 따라 다르게 해석될 수 있습니다.
-
리터럴의 경우 값은 리터럴 테이블에 대한 인덱스입니다.
-
아톰의 경우, 값은 아톰 인덱스 MINUS 1입니다. 값이 0이면, 대신
NIL(빈 리스트)을 의미합니다. -
레이블의 경우 0은 유효하지 않은 값을 의미합니다.
-
태그가 character인 경우, 값은 부호 없는 유니코드 코드포인트입니다.
-
Tag Extended List는 텀 쌍들을 포함합니다.
Size를 읽고,Size크기의 튜플을 생성한 다음,Size/2개의 쌍을 그 안에 읽어 넣습니다. 각 쌍은Value와Label로 이루어져 있습니다.Value는 비교 대상이 되는 텀이고,Label은 일치할 때 점프할 위치입니다. 이는select_val명령어에서 사용됩니다.
| 타입 힌트의 추가는 컴파일러와 JIT가 더 잘 협력할 수 있게 하여, OTP 25 이후 버전에서 더욱 최적화된 런타임 성능을 가능하게 합니다. |
이것이 어떻게 인코딩되는지에 대한 자세한 내용은 컴파일러 애플리케이션의 beam_asm:encode/2를 참고하십시오. 태그 값은 이 절에서 제시되지만, beam_makeops에 의해 생성되는 compiler/src/beam_opcodes.hrl에서도 찾을 수 있습니다.
7. 범용 BEAM 명령어
BEAM은 두 가지 서로 다른 명령어 집합을 가지고 있는데, 하나는 specific이라고 불리는 내부 명령어 집합이고, 다른 하나는 generic이라고 불리는 외부 명령어 집합이다.
일반 명령어 집합(generic instruction set)은 공식 명령어 집합이라고 할 수 있는 것으로, 이는 컴파일러와 BEAM 인터프리터 모두에서 사용되는 명령어 집합입니다. 공식 Erlang 가상 머신 명세가 있다면, 이 명령어 세트를 명시할 것입니다. BEAM을 대상으로 자체 컴파일러를 작성하고 싶다면, 이 명령어 세트를 목표로 해야 합니다. 자체 EVM을 작성하고 싶다면, 이 명령어 세트를 처리해야 합니다.
외부 명령어 세트는 상당히 안정적이지만, Erlang 버전 간에, 특히 메이저 버전 간에는 변경될 수 있습니다.
이것이 이 장에서 다룰 명령어 세트입니다.
다른 명령어 집합인 특정(specific) 명령어 집합은 BEAM이 외부 명령어 집합을 구현하기 위해 사용하는 최적화된 명령어 집합입니다. BEAM이 어떻게 작동하는지 이해할 수 있도록 이 명령어 집합을 온라인 부록에서 다룰 것입니다. 내부 명령어 집합은 마이너 버전 간에, 심지어 패치 릴리스에서도 예고 없이 변경될 수 있습니다. 어떤 도구든 내부 명령어 집합에 기반을 두는 것은 위험합니다.
이 장에서는 명령어의 일반 문법과 일부 명령어 그룹을 자세히 살펴보겠습니다. 명령어의 전체 목록과 간단한 설명은 Appendix B에서 확인할 수 있습니다.
7.1. 인스트럭션 정의
제네릭 명령어의 이름과 오퍼코드는 lib/compiler/src/genop.tab에 정의되어 있습니다.
이 파일은 BEAM 명령어 형식의 버전 번호를 포함하고 있으며, 이 번호는 .beam 파일에도 기록됩니다. 이 번호는 지금까지 한 번도 변경된 적이 없으며 여전히 버전 0입니다. 외부 형식이 하위 호환되지 않는 방식으로 변경된다면 이 번호가 변경될 것입니다.
genop.tab 파일은 ops 탭에서 코드를 생성하는 펄 스크립트인 beam_makeops의 입력으로 사용됩니다. 이 생성기는 컴파일러를 위한 Erlang 코드(beam_opcodes.hrl 및 beam_opcodes.erl)를 생성하는 데 사용되는 동시에, 에뮬레이터를 위한 C 코드(beam_opcodes.c 및 beam_opcodes.h; 전통적인 BEAM 인터프리터의 경우 beam_hot.h, beam_warm.h, beam_cold.h도 생성되며, BeamAsm의 경우 beamasm_emit.h 및 beamasm_protos.h가 생성됨)를 생성하는 데에도 사용됩니다.
파일에서 "#"으로 시작하는 줄은 주석이며 beam_makeops에 의해 무시됩니다. 파일은 정의를 포함할 수 있으며, 이는 perl 스크립트에서 바인딩으로 변환됩니다. 형식은 다음과 같습니다:
NAME=EXPR
예를 들면:
BEAM_FORMAT_NUMBER=0
BEAM 형식 번호는 외부 beam 형식의 instructionset 필드와 동일합니다. 이 번호는 명령어 집합에 하위 호환성이 깨지는 변경이 있을 때만 증가합니다.
이 파일의 주요 내용은 다음과 같은 형식의 opcode 정의입니다:
OPNUM: [-]NAME/ARITY
OPNUM과 ARITY는 정수이고, NAME은 소문자(a-z)로 시작하는 식별자이며, :, -, /는 리터럴이다.
예를 들어:
1: label/1
마이너스 기호(-)는 더 이상 사용되지 않는(deprecated) 함수를 나타냅니다. 더 이상 사용되지 않는 함수는 로더가 어느 정도 하위 호환성을 유지할 수 있도록 opcode를 그대로 보존합니다(로더는 deprecated 명령어를 인식하고 해당 코드의 로딩을 거부합니다).
이 장의 나머지 부분에서는 몇 가지 BEAM 인스트럭션을 자세히 살펴보겠습니다. 간단한 설명이 포함된 전체 목록은 다음을 참조하십시오: Appendix B.
7.2. BEAM 코드 목록
Chapter 2에서 살펴본 것처럼, Erlang 컴파일러에 옵션 'S’를 주면 모듈의 BEAM 코드를 사람과 기계 모두가 읽을 수 있는 형식(실제로는 Erlang 텀)으로 담은 .S 파일을 얻을 수 있습니다.
beamexample1.erl 파일이 다음과 같이 주어졌을 때:
-module(beamexample1).
-export([id/1]).
id(I) when is_integer(I) -> I.
erlc -S beamexample1.erl로 컴파일하면 다음과 같은 beamexmaple1.S 파일을 얻습니다:
{module, beamexample1}. %% version = 0
{exports, [{id,1},{module_info,0},{module_info,1}]}.
{attributes, []}.
{labels, 7}.
{function, id, 1, 2}.
{label,1}.
{line,[{location,"beamexample1.erl",5}]}.
{func_info,{atom,beamexample1},{atom,id},1}.
{label,2}.
{test,is_integer,{f,1},[{x,0}]}.
return.
{function, module_info, 0, 4}.
{label,3}.
{line,[]}.
{func_info,{atom,beamexample1},{atom,module_info},0}.
{label,4}.
{move,{atom,beamexample1},{x,0}}.
{line,[]}.
{call_ext_only,1,{extfunc,erlang,get_module_info,1}}.
{function, module_info, 1, 6}.
{label,5}.
{line,[]}.
{func_info,{atom,beamexample1},{atom,module_info},1}.
{label,6}.
{move,{x,0},{x,1}}.
{move,{atom,beamexample1},{x,0}}.
{line,[]}.
{call_ext_only,2,{extfunc,erlang,get_module_info,2}}.
정수 항등 함수를 위한 실제 BEAM 코드 외에도 몇 가지 메타 명령어도 얻게 됩니다.
첫 번째 줄 {module, beamexample1}. %% version = 0은 모듈 이름 beamexample1과 인스트럭션 셋의 버전 번호 0을 알려줍니다.
그런 다음 내보낸(exported) 함수 목록 id/1, module_info/0, module_info/1을 얻습니다. 보다시피 컴파일러는 자동 생성된 두 개의 함수를 코드에 추가했습니다. 이 두 함수는 모듈 이름을 첫 번째 인자로 추가하여 일반 모듈 정보 BIF(erlang:get_module_info/1 및 erlang:get_module_info/2)로 전달하는 디스패처일 뿐입니다.
{attributes, []} 줄은 정의된 모든 컴파일러 속성을 나열하는데, 우리의 경우에는 없습니다.
그런 다음 모듈에 7개 미만의 레이블이 있다는 것을 알게 되는데, {labels, 7}, 이는 코드 로딩을 한 번의 패스로 수행하기 쉽게 만들어줍니다.
마지막 메타 인스트럭션 유형은 {function, Name, Arity, StartLabel} 형식의 function 인스트럭션이다. id 함수에서 볼 수 있듯이 시작 레이블은 실제로 함수 코드에서 두 번째 레이블이다.
{label, N} 명령어는 실제로는 명령어가 아니며, 로드될 때 메모리에 어떠한 공간도 차지하지 않는다. 이는 단지 코드 내의 한 위치에 로컬 이름(또는 번호)을 부여하기 위한 것이다. 각 레이블은 점프의 잠재적인 목적지이기 때문에, 잠재적으로 기본 블록(basic block)의 시작을 나타낸다.
첫 번째 레이블({label,1}) 다음에 오는 처음 두 명령어는 실제로는 줄 번호와 모듈, 함수, 애리티 정보를 추가하고 예외를 던지는 에러 생성 코드입니다. 이것들이 바로 line과 func_info 명령어입니다.
함수의 핵심 부분은 {label,2} 다음에 있는 명령어인 {test,is_integer,{f,1},[{x,0}]}입니다. 이 test 명령어는 자신의 인자(끝에 있는 리스트, 이 경우에는 변수 {x,0})가 테스트를 만족하는지, 즉 이 경우에는 정수인지(is_integer)를 검사합니다. 테스트가 성공하면 다음 명령어(return)가 실행됩니다. 그렇지 않으면 함수는 레이블 1({f,1})로 실패하며, 즉 실행은 레이블 1로 이어지고 그곳에서 함수 절 예외(function clause exception)가 발생합니다.
파일의 나머지 두 함수는 자동 생성된 것입니다. 두 번째 함수를 보면, {move,{x,0},{x,1}} 명령어는 레지스터 x0에 있는 인자를 두 번째 인자 레지스터 x1로 옮깁니다. 그런 다음 {move,{atom,beamexample1},{x,0}} 명령어는 모듈 이름 아톰을 첫 번째 인자 레지스터 x0로 옮깁니다. 마지막으로 erlang:get_module_info/2({call_ext_only,2,{extfunc,erlang,get_module_info,2}})에 대한 꼬리 호출이 이루어집니다. 다음 절에서 보게 되겠지만, 여러 가지 다른 호출 명령어들이 있습니다.
7.3. 호출
Chapter 8에서 살펴보겠지만, Erlang에는 여러 가지 다른 유형의 호출이 있습니다. 명령어 집합에서 로컬 호출과 원격 호출을 구분하기 위해, 원격 호출은 명령어 이름에 _ext가 포함되어 있다. 로컬 호출은 모듈의 코드 내에 레이블만 가지는 반면, 원격 호출은 {extfunc, Module, Function, Arity} 형태의 목적지를 가집니다.
일반적인 (스택을 쌓는) 호출과 꼬리 재귀 호출을 구분하기 위해, 후자는 이름에 _only 또는 _last를 포함합니다. _last가 붙은 변형은 마지막 인자로 주어진 만큼의 스택 슬롯도 할당 해제합니다.
레지스터 {x, Arity}에 저장된 클로저를 호출하는 call_fun Arity 명령어도 있다. 인수는 x0부터 {x, Arity-1}까지 저장된다.
모든 종류의 호출 명령어(call instruction)에 대한 전체 목록은 Appendix B를 참고하라.
7.4. 스택(및 힙) 관리
BEAM에서 Erlang 프로세스의 스택과 힙은 동일한 메모리 영역을 공유합니다. 자세한 내용은 Chapter 3와 Chapter 11를 참고하십시오. 스택은 낮은 주소 방향으로 증가하고 힙은 높은 주소 방향으로 증가한다. BEAM은 스택이나 힙에서 사용 가능한 것보다 더 많은 공간이 필요하면 가비지 컬렉션을 수행한다.
리프가 아닌 함수에 진입할 때 연속 포인터(CP)는 스택에 저장되며, 종료 시 스택에서 다시 읽힙니다. 이는 allocate 및 deallocate 명령어에 의해 수행되며, 이 명령어들은 현재 명령어의 스택 프레임을 설정하고 해제하는 데 사용됩니다.
리프 함수의 함수 골격은 다음과 같습니다:
{function, Name, Arity, StartLabel}.
{label,L1}.
{func_info,{atom,Module},{atom,Name},Arity}.
{label,L2}.
...
return.
비리프(non leaf) 함수의 함수 골격은 다음과 같습니다:
{function, Name, Arity, StartLabel}.
{label,L1}.
{func_info,{atom,Module},{atom,Name},Arity}.
{label,L2}.
{allocate,Need,Live}.
...
call ...
...
{deallocate,Need}.
return.
allocate StackNeed Live 명령어는 컨티뉴에이션 포인터(CP)를 저장하고, 스택에 StackNeed 만큼의 추가 워드를 위한 공간을 할당한다. 할당 중에 GC가 필요한 경우 Live 개수만큼의 X 레지스터를 저장한다. 예를 들어 Live가 2이면 레지스터 x0과 x1이 저장된다.
스택에 할당할 때, 스택 포인터(E)는 감소합니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
Before After
| xxx | | xxx |
E -> | xxx | | xxx |
| | | ??? | caller save slot
... E -> | CP |
... ...
HTOP -> | | HTOP -> | |
| xxx | | xxx |
allocate 및 deallocate 명령어의 모든 유형에 대한 전체 목록은 Appendix B를 참조하십시오.
7.5. 메시지 패싱
BEAM 코드에서 메시지를 보내는 것은 간단합니다. send 명령어를 사용하기만 하면 됩니다. 다만 send 명령어는 어떤 인수도 받지 않으며, 함수 호출에 더 가깝다는 점에 유의하세요. 인자(목적지와 메시지)가 인자 레지스터 x0와 x1에 있다고 가정합니다. 메시지는 또한 x1에서 x0으로 복사됩니다.
메시지를 수신하는 것은 조금 더 복잡한데, 패턴 매칭을 사용한 선택적 수신(selective receive)이 관련되어 있을 뿐만 아니라 함수 본문 내에서 양보/재개(yield/resume) 지점을 도입하기 때문입니다. (또한 참조(refs)를 사용하여 메시지 큐 스캔을 최소화하는 특별한 기능도 있는데, 이에 대해서는 나중에 더 다루겠습니다.)
7.5.1. 최소한의 수신 루프
어떤 메시지든 받아들이고 타임아웃이 없는 최소한의 receive 루프(예: receive _ -> ok end)는 BEAM 코드에서 다음과 같은 모습입니다:
{label,1}.
{loop_rec,{f,2},{x,0}}.
remove_message.
{jump,{f,3}}.
{label,2}.
{wait,{f,1}}.
{label,3}.
...
loop_rec L2 x0 명령어는 먼저 메시지 큐에 메시지가 있는지 확인합니다. 메시지가 없으면 실행은 L2로 점프하며, 그곳에서 프로세스는 메시지가 도착할 때까지 일시 중단된다.
메시지 큐에 메시지가 있으면 loop_rec 명령어는 메시지를 m-buf에서 프로세스 힙으로도 옮깁니다. m-buf 처리에 대한 자세한 내용은 Chapter 11와 Chapter 3를 참고하세요.
모든 메시지를 받아들이는 receive _ -> ok end와 같은 코드의 경우, 패턴 매칭이 필요 없으며, 그저 메시지 큐에서 다음 메시지의 연결을 해제하는 remove_message를 수행하면 됩니다. (이는 타임아웃도 제거하는데, 이에 대해서는 곧 더 자세히 다루겠습니다.)
7.5.2. 선택적 수신 루프
예를 들어 receive [] → ok end와 같은 선택적 수신(selective receive)의 경우, 메시지 큐에 일치하는 메시지가 있는지 확인하기 위해 메시지 큐를 순회합니다.
{label,1}.
{loop_rec,{f,3},{x,0}}.
{test,is_nil,{f,2},[{x,0}]}.
remove_message.
{jump,{f,4}}.
{label,2}.
{loop_rec_end,{f,1}}.
{label,3}.
{wait,{f,1}}.
{label,4}.
...
이 경우 메일박스에 메시지가 있으면 loop_rec 명령어 이후에 Nil에 대한 패턴 매칭을 수행합니다. 메시지가 매치되지 않으면 L2로 이동하며, 이곳에서 loop_rec_end 명령어가 저장 포인터를 다음 메시지로 전진시키고(p->msg.save = &(*p->msg.save)->next) L1로 다시 점프합니다.
메시지 큐에 더 이상 메시지가 없으면 프로세스는 L3의 wait 명령어에 의해 일시 중단되며, 저장 포인터는 메시지 큐의 끝을 가리키게 됩니다. 프로세스가 다시 스케줄링되면 메시지 큐에서 새로운 메시지만 확인합니다(save 포인트 이후의 메시지).
7.5.3. 타임아웃이 있는 수신 루프
선택적 수신에 타임아웃을 추가하면 wait 명령어가 wait_timeout 명령어로 대체되고, 그 뒤에 timeout 명령어와 타임아웃 이후의 코드가 이어집니다.
{label,1}.
{loop_rec,{f,3},{x,0}}.
{test,is_nil,{f,2},[{x,0}]}.
remove_message.
{jump,{f,4}}.
{label,2}.
{loop_rec_end,{f,1}}.
{label,3}.
{wait_timeout,{f,1},{integer,1000}}.
timeout.
{label,4}.
...
wait_timeout 명령어는 주어진 시간(이 예제에서는 1000ms)으로 타임아웃 타이머를 설정하고, 다음 명령어(timeout)의 주소를 p->def_arg_reg[0]에 저장합니다. 그런 다음 타이머가 설정되면 p->i가 def_arg_reg를 가리키도록 설정됩니다.
이는 프로세스가 일시 중단된 동안 일치하는 메시지가 도착하지 않으면 1초 후에 타임아웃이 발생하고, 해당 프로세스의 실행이 타임아웃 명령어에서 계속됨을 의미합니다.
매칭되지 않는 메시지가 메일박스에 도착하면, 프로세스는 실행 대상으로 스케줄링되어 receive 루프의 패턴 매칭 코드를 실행하지만, 타임아웃은 취소되지 않는다는 점에 유의하십시오. 타임아웃 타이머도 함께 제거하는 것은 바로 remove_message 코드입니다.
timeout 명령어는 메일박스의 저장 지점을 큐의 첫 번째 요소로 재설정하고, PCB에서 타임아웃 플래그(F_TIMO)를 지웁니다.
7.5.4. 동기 호출 기법 (일명 Ref 기법)
이제 수신 루프의 마지막 버전에 도달했습니다. 여기서는 앞서 언급한 ref 트릭을 사용하여 긴 메시지 박스 스캔을 피합니다.
Erlang 코드에서 흔히 사용되는 패턴은 두 프로세스 간에 send와 receive를 사용하여 일종의 "원격 호출"을 구현하는 것입니다. 이는 예를 들어 gen_server에서 사용됩니다. 이 코드는 종종 일반적인 함수 호출 라이브러리 뒤에 숨겨져 있습니다. 예를 들어, counter:increment(Counter) 함수를 호출하면 실제로는 뒤에서 Counter ! {self(), inc}, receive {Counter, Count} -> Count end와 같은 코드로 바뀝니다.
이것은 일반적으로 프로세스에 상태를 캡슐화하기 위한 훌륭한 추상화입니다. 그러나 호출하는 프로세스의 메일박스에 많은 메시지가 있을 경우 약간의 문제가 발생합니다. 이 경우 receive는 메일박스의 각 메시지를 확인하여 마지막 메시지를 제외하고는 반환 메시지와 일치하는 메시지가 없다는 것을 알아내야 합니다.
이는 많은 메시지를 수신하고 각 메시지에 대해 여러 번의 이러한 원격 호출을 수행하는 서버가 있는 경우 꽤 자주 발생할 수 있으며, 역방향 스로틀링이 적용되어 있지 않으면 서버의 메시지 큐가 가득 차게 됩니다.
이를 해결하기 위해 ERTS에는 이 패턴을 인식하고 반환 메시지를 찾기 위해 전체 메시지 큐를 스캔하는 것을 방지하는 핵(hack)이 있습니다.
컴파일러는 receive에서 새로 생성된 참조(ref)를 사용하는 코드를 인식하고, 새로운 ref가 이미 수신함에 있을 수 없으므로 긴 수신함 스캔을 피하기 위한 코드를 생성합니다.
Ref = make_ref(),
Counter ! {self(), inc, Ref},
receive
{Ref, Count} -> Count
end.
이것은 완전한 receive에 대한 다음과 같은 골격 구조를 제공합니다.
{recv_mark,{f,3}}.
{call_ext,0,{extfunc,erlang,make_ref,0}}.
...
send.
{recv_set,{f,3}}.
{label,3}.
{loop_rec,{f,5},{x,0}}.
{test,is_tuple,{f,4},[{x,0}]}.
...
{test,is_eq_exact,{f,4},[{x,1},{y,0}]}.
...
remove_message.
...
{jump,{f,6}}.
{label,4}.
{loop_rec_end,{f,3}}.
{label,5}.
{wait,{f,3}}.
{label,6}.
recv_mark 인스트럭션은 현재 위치(끝 msg.last)는 msg.saved_last에, 레이블의 주소는 msg.mark에 저장한다
recv_set 명령어는 msg.mark가 다음 명령어를 가리키는지 확인하고, 그런 경우 저장 지점(msg.save)을 참조(ref) 생성 이전에 수신된 마지막 메시지(msg.saved_last)로 이동시킨다. 마크가 유효하지 않은 경우(즉, msg.save와 같지 않은 경우) 이 명령어는 아무 작업도 수행하지 않는다.
8. 다양한 호출 유형, 링킹 및 핫 코드 로딩
BEAM에서의 함수 호출은 여러 유형으로 분류할 수 있다.
8.1. 로컬 호출
로컬 호출은 모듈 이름을 명시적으로 지정하지 않고 동일한 모듈 내에서 함수를 호출할 때 발생합니다. 이러한 호출은 컴파일 시점에 해결되며, 호출자와 동일한 버전의 모듈 내에서 실행됨이 보장됩니다.
예시:
foo() -> bar(). % Local call within the same module
8.2. 원격 호출
원격 호출은 모듈 이름을 명시적으로 지정하여, 항상 최신으로 로드된 버전의 모듈이 사용되도록 보장합니다.
예:
foo() -> ?MODULE:bar(). % Remote call ensuring latest module version
원격 호출은 핫 코드 로딩(hot code loading)을 용이하게 하여, 시스템 재시작 없이 모듈에 대한 실시간 업데이트를 가능하게 합니다.
8.3. 코드 로딩
Erlang 런타임 시스템에서 코드 로딩은 코드 서버가 처리합니다. 코드 서버는 실제 로딩을 위해 erlang 모듈의 저수준 BIF들을 호출합니다. 하지만 코드 서버는 제거 정책도 결정합니다.
런타임 시스템은 각 모듈의 두 가지 버전, 즉 현재 버전과 이전 버전을 유지할 수 있습니다. 완전한 한정(원격) 호출은 모두 현재 버전으로 이동합니다. 이전 버전에서의 로컬 호출과 스택 상의 반환 주소는 여전히 이전 버전으로 향할 수 있다.
모듈의 세 번째 버전이 로드되었는데 아직 (스택에 포인터를 가지고) 실행 중인 프로세스가 있다면, 코드 서버는 해당 프로세스들을 종료하고 이전 코드를 퍼지합니다. 그러면 현재 버전이 구 버전이 되고, 세 번째 버전이 현재 버전으로 로드됩니다.
8.4. 핫 코드 로딩
앞서 살펴본 것처럼 로컬 함수 호출과 원격 함수 호출 사이에는 구문적 차이뿐만 아니라 의미적 차이도 있습니다. 원격 호출, 즉 "완전 한정 호출(fully qualified call)"은 명명된 모듈의 함수를 호출하는 것으로, 해당 모듈의 가장 최근에 로드된 버전으로 이동하는 것이 보장됩니다. 로컬 호출, 즉 동일 모듈 내 함수에 대한 비한정 호출은 호출자와 동일한 버전의 코드로 이동하는 것이 보장됩니다.
로컬 함수 호출은 호출 지점에서 모듈 이름을 명시함으로써 원격 호출로 바꿀 수 있습니다. 이는 보통 ?MODULE 매크로를 사용하여 ?MODULE:foo()처럼 수행됩니다. 로컬이 아닌 모듈에 대한 원격 호출은 로컬 호출로 바꿀 수 없습니다. 즉, 호출자 쪽에서 피호출자의 버전을 보장할 방법이 없습니다.
이는 Erlang의 중요한 기능으로, hot code loading(핫 코드 로딩) 또는 hot upgrades(핫 업그레이드)를 가능하게 합니다. 서버 루프 어딘가에 원격 호출(remote call)만 있으면, 시스템이 실행되는 도중에도 새 코드를 로드할 수 있습니다. 실행이 해당 원격 호출에 도달하면 새 코드를 실행하는 쪽으로 전환됩니다.
서버 루프를 작성하는 일반적인 방법은 메인 루프에는 로컬 호출을 사용하고, 코드 업그레이드 핸들러에서는 원격 호출과 필요한 경우 상태 업그레이드를 수행하는 것입니다:
loop(State) ->
receive
upgrade ->
%% Force a call to the latest version of the code.
NewState = ?MODULE:code_upgrade(State),
?MODULE:loop(NewState);
Msg ->
%% In other cases we call the old version of the code.
%% That is the version of the code that also handles old data.
NewState = handle_msg(Msg, State),
loop(NewState)
end.
기본적으로 gen_server가 사용하는 이 구조를 통해, 프로그래머는 코드 업그레이드가 언제 어떻게 이루어지는지 제어할 수 있습니다.
핫 코드 업그레이드는 Erlang의 가장 중요한 기능 중 하나로, 연중무휴 24시간 운영되는 서버를 작성할 수 있게 해줍니다. 이는 또한 Erlang이 동적 타입 언어인 주된 이유 중 하나이기도 합니다. 정적 타입 언어에서 code_upgrade 함수의 타입을 지정하는 것은 매우 어렵습니다. (loop 함수의 타입을 지정하는 것도 어렵습니다.) 새로운 기능을 처리하기 위해 State의 타입이 변경됨에 따라 이러한 타입들도 향후 변경될 것입니다.
하지만 성능에 관심이 있는 언어 구현자에게 핫 코드 로딩 기능은 부담이 된다. 원격 모듈로의 또는 원격 모듈로부터의 각 호출이 미래에 새로운 코드로 변경될 수 있기 때문에, 모듈 경계를 넘어서 전체 프로그램 최적화를 수행하는 것은 매우 어렵습니다. (어렵지만 불가능하지는 않으며, 해결책은 존재하지만 지금까지 완전히 구현된 것은 본 적이 없습니다).
8.5. 클로저 호출
Erlang의 클로저는 함수를 값으로 전달할 수 있게 하며, 정의된 스코프의 변수를 캡처합니다.
예:
make_adder(N) -> fun(X) -> X + N end.
클로저 호출
클로저가 생성되면, 다른 함수와 마찬가지로 호출될 수 있습니다.
예제:
Adder = make_adder(5),
Result = Adder(10). % Returns 15
클로저를 인자로 전달하기
클로저는 더 유연한 동작을 위해 고차 함수에 전달할 수 있습니다.
예시:
apply_fun(F, X) -> F(X).
Result = apply_fun(make_adder(3), 7). % Returns 10
함수에서 클로저 반환하기
함수는 클로저를 반환할 수 있으며, 이를 통해 동적으로 함수를 생성할 수 있습니다.
예:
make_multiplier(N) -> fun(X) -> X * N end.
Multiplier = make_multiplier(2),
Result = Multiplier(4). % Returns 8
클로저는 동적 실행을 가능하게 하며 고차 함수에서 흔히 사용됩니다.
8.6. 동적 호출
런타임 시점에 함수를 동적으로 호출하기 위해 변수를 사용할 수 있습니다.
예:
M = lists,
F = map,
Result = M:F(fun(X) -> X * 2 end, [1,2,3]).
이 동적 호출 기법은 직접 호출보다 효율성이 떨어지지만, 런타임에 실행 경로를 선택할 수 있는 유연성을 제공합니다. 이는 특정 함수 집합을 익스포트하는 모듈의 이름을 전달하기만 하면 되는 콜백 시스템을 구현하는 데 사용할 수 있습니다. 이 기법은 컴파일 시점에 호출 그래프를 알 수 없기 때문에 Dialyzer와 같은 분석 도구가 코드에서 오류를 찾는 것을 훨씬 더 어렵게 만든다.
8.7. 고차 함수와 핫 코드 로딩
고차 함수는 동작(behavior)을 동적으로 전달할 수 있게 하며, 이는 핫 코드 로딩(hot code loading)과 독특한 방식으로 상호작용한다.
예:
init() ->
F = ?MODULE:foo/1,
L = fun(X) -> foo(X) end,
loop(F, L).
foo(X) -> X + 1.
loop(F, L) ->
F(1),
L(2),
loop(F, L).
L은 로컬 함수이므로 항상 X + 1을 반환합니다. 모듈이 다시 로드되고 foo/1이 X + 2를 반환하도록 변경되면 F는 X + 2를 반환합니다. 모듈이 두 번 다시 로드되면 L이 제거된(purged) 버전의 모듈을 참조하기 때문에 프로세스가 크래시할 것입니다.
8.8. 결론
Erlang에서 함수 호출 효율성은 Efficiency Guide에 설명된 대로 명확한 계층 구조를 따릅니다:
명시적 호출, 즉 로컬 호출과 원격 호출(foo(), m:foo()) 모두 가장 효율적입니다. 여기에 덧붙이자면, 가장 효율적인 호출은 같은 모듈 내 함수에 대한 로컬 호출입니다. 이는 해당 호출이 컴파일 시점에 해석될 수 있고 함수가 인라인될 수 있기 때문입니다. 모듈이 런타임에 변경될 수 있으므로 원격 호출에서는 이것이 불가능합니다. JIT 컴파일러가 원격 호출을 잘 처리하긴 하지만, OTP 27 기준으로는 이를 인라인화하지는 않습니다.
클로저 호출(Fun(), apply(Fun, []))은 다소 느리지만 여전히 효율적이다.
컴파일 타임에 인자의 개수를 알 수 있는 함수 () (Mod:Name(), apply(Mod, Name, []))를 적용하는 것이 그 다음으로 효율적입니다. (모듈에서 export되지 않은 함수는 적용할 수 없습니다.)
알 수 없는 개수의 인자로 익스포트된 함수를 적용하는 것(apply(Mod, Name, Args))이 가장 비효율적이다.
Erlang 런타임 시스템은 로컬 호출과 모듈의 현재 버전에 대한 원격 호출에 최적화되어 있습니다. 핫 코드 로딩은 실행 중인 시스템에 대한 라이브 업데이트를 가능하게 하는 강력한 기능이지만, 성능 상의 트레이드오프가 따릅니다. 또한 모듈을 두 번 리로드하면 해당 모듈의 이전 버전이 퍼지(purge)되며, 이전 버전에 대한 모든 참조는 해당 참조를 보유하고 있는 프로세스를 크래시시킨다는 점을 이해하는 것이 중요합니다.
9. BEAM 로더
9.1. Generic 명령어에서 Specific 명령어로의 변환
BEAM 로더는 단순히 외부 beam 형식을 가져와서 메모리에 쓰는 것만 하지 않는다. 코드에 대해 다수의 변환을 수행하고, 외부(범용) 형식에서 내부(특수) 형식으로 번역하기도 한다.
로더의 코드는 beam_load.c (erts/emulator/beam 내)에서 찾을 수 있지만, 변환 로직의 대부분은 같은 디렉토리에 있는 ops.tab 파일에 있습니다.
로더의 첫 번째 단계는 BEAM 파일을 파싱하는 것으로, 기본적으로 Chapter 6에서 Erlang으로 수행한 것과 동일한 작업이지만 C로 작성되어 있습니다.
그런 다음 ops.tab의 규칙이 코드 청크의 명령어에 적용되어 범용 명령어를 하나 이상의 특정 명령어로 변환합니다.
변환 테이블은 패턴 매칭을 통해 작동합니다. 파일의 각 줄은 인자를 가진 하나 이상의 제네릭 명령어 패턴을 정의하며, 선택적으로 화살표 뒤에 변환 대상이 되는 하나 이상의 명령어가 올 수 있습니다.
ops tab의 변환은 컴파일러가 생성한 명령어 패턴을 처리하고 이를 핍홀 최적화하여 더 적은 수의 특화된 명령어로 만들려고 시도합니다. ops tab 변환은 select 패턴에 대해 점프 테이블을 생성하려고 시도합니다.
ops.tab 파일은 런타임에 파싱되지 않으며, 대신 ops.tab으로부터 패턴 매칭 프로그램이 생성되어 생성된 C 파일의 배열에 저장됩니다. Perl 스크립트 beam_makeops (erts/emulator/utils에 위치)는 beam_opcodes.h와 beam_opcodes.c 파일에 타겟별 옵코드 집합과 변환 프로그램을 생성합니다 (이 파일들은 지정된 타겟 디렉터리, 예를 들어 erts/emulator/x86_64-unknown-linux-gnu/opt/smp/에 저장됩니다).
동일한 프로그램(beam_makeops)은 컴파일러 백엔드용 Erlang 코드인 beam_opcodes.erl도 생성합니다.
9.2. ops.tab 이해하기
ops.tab의 변환은 파일에 작성된 순서대로 실행됩니다. 따라서 Erlang의 패턴 매칭과 마찬가지로, 서로 다른 규칙들은 위에서 아래로 순서대로 트리거됩니다.
ops.tab이 명령어의 인자에 사용하는 타입은 Appendix B에서 찾을 수 있습니다.
9.2.1. 변환
ops.tab의 대부분의 규칙은 서로 다른 명령어 간의 변환입니다. 간단한 변환은 다음과 같습니다:
move S x==0 | return => move_return S
이것은 임의의 위치에서 x(0)으로의 move와 반환을 move_return이라는 단일 명령어로 결합합니다. 변환을 분해하여 각 부분이 무엇을 하는지 살펴보겠습니다.
- 이동
-
패턴이 먼저 매치되어야 하는 명령어입니다. 이는 컴파일러가 내보낸 일반적인 명령어일 수도 있고, 변환을 돕기 위해
ops.tab이 내보낸 임시 명령어일 수도 있습니다. - S
-
모든 타입의 값을 바인딩하는 변수이다. 패턴(왼쪽 변 또는
⇒)에 있는 값 중에서 생성자(⇒의 오른쪽 변)에 사용되는 값은 반드시 변수에 바인딩되어야 합니다. - x==0
-
이는 대상 위치가 값이
0인x레지스터인 경우에만 변환을 적용한다는 것을 나타내는 가드입니다. 여기서 여러 타입을 체이닝하고 변수를 바인딩하는 것도 가능합니다. 예를 들어D=xy==0은 값이0인x와y레지스터를 모두 허용하며, 인자를 변수D에 바인딩합니다. - |
-
이는 이 명령어의 끝과 동일한 패턴의 일부인 또 다른 명령어의 시작을 나타냅니다.
- 반환
-
이 패턴에서 매칭할 두 번째 명령어입니다.
⇒-
패턴의 끝과 생성될 코드의 시작을 나타냅니다.
- move_return S
-
는 생성된 명령어의 이름이며, 좌변(lhs)에 있는 변수의 이름과 함께 사용됩니다.
|기호를 사용하여 변환의 일부로 multiple instructions을 생성하는 것이 가능합니다.
더 복잡한 변환은 ops.tab에서 수행할 수 있습니다. 예를 들어 select_val 명령어를 살펴보겠습니다. 이 명령어는 로더에 의해 입력 값에 따라 점프 테이블, 선형 검색 배열 또는 바이너리 검색 배열 중 하나로 변환됩니다.
is_integer Fail=f S | select_val S=s Fail=f Size=u Rest=* | \ use_jump_tab(Size, Rest) => gen_jump_tab(S, Fail, Size, Rest)
위 변환은 가능하다면 select_val의 점프 테이블을 생성합니다. 이러한 변환에는 여러 가지 새로운 기법이 사용된다.
- S
-
is_integer와select_val양쪽에서 사용됩니다. 이는 두 값이 동일한 타입이면서 동일한 값을 가져야 한다는 것을 의미합니다. 또한S=s가드는 타입을 소스 레지스터로 제한합니다. - Rest=*
-
명령어에 가변 개수의 인자를 허용하며 이를 변수
Rest에 바인딩합니다. - use_jump_tab(Size, Rest)
-
beam_load.c에서 use_jump_tab C 함수를 호출하는데, 이 함수는select_val의 인자들이 점프 테이블로 변환될 수 있는지를 결정합니다. - \
-
는 변환 규칙이 다음 줄에서 계속됨을 나타냅니다.
- gen_jump_tab(S, Fail, Size, Rest)
-
적절한 명령어를 생성하는 역할을 하는
beam_load.c의 gen_jump_tab C 함수를 호출합니다.
9.2.2. 특정 명령어
모든 변환이 완료되면, 특정 명령어가 어떤 형태여야 하는지 결정해야 합니다. 계속해서 move_return을 살펴보겠습니다:
%macro: move_return MoveReturn -nonext move_return x move_return c move_return n
이 작업은 세 가지 다른 명령어를 생성하며, 이들은 beam_emu.c에서 작업을 수행하기 위해 MoveReturn 매크로를 사용합니다.
- %macro: move_return
-
이는
ops.tab에게move_return에 대한 코드를 생성하도록 지시합니다.%macro줄이 없으면, 해당 명령어는 beam_emu.c에서 직접 수작업으로 구현해야 합니다. 해당 명령어의 코드는%hot또는%cold지시어가 활성화되어 있는지에 따라beam_hot.h또는beam_cold.h에 배치됩니다. - MoveReturn
-
코드 생성기에게 beam_emu.c에서 사용할 c-매크로의 이름이 MoveReturn이라는 것을 알려준다. 이 매크로는 수동으로 구현해야 합니다.
- -nonext
-
코드 생성기에게 다음 명령어로의 디스패치를 생성하지 않아야 한다고 알려주며,
MoveReturn매크로가 이를 처리할 것입니다. - move_return x
-
명령어의 인자가 x 레지스터일 때 특정 명령어를 생성하도록 코드 생성기에 지시한다. 상수일 때는
c,NIL일 때는n이다. 이 경우 인자가 y 레지스터일 때에 대한 명령어는 생성되지 않는데, 컴파일러가 그러한 코드를 절대 생성하지 않기 때문입니다.
beam_hot.h에 있는 결과 코드는 다음과 같습니다:
OpCase(move_return_c):
{
MoveReturn(Arg(0));
}
OpCase(move_return_n):
{
MoveReturn(NIL);
}
OpCase(move_return_x):
{
MoveReturn(xb(Arg(0)));
}
구현자가 해야 할 일은 beam_emu.c에서 MoveReturn 매크로를 정의하는 것뿐이며, 그러면 명령어가 완성됩니다.
%macro 규칙은 생성되는 코드를 수정하기 위해 여러 가지 플래그를 사용할 수 있다.
아래 예제들은 다음과 같은 형태의 특정 명령어가 있다고 가정합니다:
%macro move_call MoveCall move_call x f
%macro에 아무 플래그도 지정하지 않으면 다음 코드가 생성됩니다:
BeamInstr* next; PreFetch(2, next); MoveCall(Arg(0)); NextPF(2, next);
| PreFetch and NextPF 매크로는 명령어가 실행되기 전에 다음에 점프할 주소를 미리 로드하도록 보장합니다. 이 기법은 캐시 아키텍처와 CPU의 슈퍼스칼라 특성에 따라 다양한 정도로 모든 아키텍처에서 성능을 향상시킵니다. |
- -nonext
-
이 명령어에 대해서는 dispatch를 생성하지 마세요. 이것은 다음 명령어로 계속 진행하지 않는 것으로 알려진 명령어, 즉 return, call, jump에 사용된다.
%macro move_call MoveCall -nonext
MoveCall(xb(Arg(0)));
- -arg_*
-
타입 *의 인자를 c-매크로의 인자로 포함합니다. 모든 인자 타입이 기본적으로 c-매크로에 포함되는 것은 아니다. 예를 들어 실패 레이블과 로컬 함수 호출에 사용되는
f타입은 기본적으로 포함되지 않습니다. 따라서-arg_f옵션을 지정하면 해당 인자가 c-매크로의 인자로 포함됩니다.
%macro move_call MoveCall -arg_f
MoveCall(xb(Arg(0)), Arg(1));
- -size
-
명령어의 크기를 c-매크로의 인자로 포함하세요.
%macro move_call MoveCall -size
MoveCall(xb(Arg(0)), 2);
- -pack
-
가능하다면 인자를 패킹합니다. 이는 가능한 경우 여러 레지스터 인자를 동일한 워드에 배치합니다. 레지스터 인자는 0-1024만 가능하므로, 저장하는 데 10비트 + 태깅을 위한 2비트만 필요합니다. 따라서 32비트 시스템에서는 하나의 워드에 2개의 레지스터를 넣을 수 있고, 64비트에서는 하나의 워드에 4개의 레지스터를 넣을 수 있습니다. 명령어 패킹은 단일 명령어가 사용하는 메모리를 크게 줄일 수 있습니다. 그러나 명령어를 언패킹하는 데도 약간의 비용이 발생하며, 이것이 모든 명령어에 대해 활성화되지 않는 이유입니다.
호출 예제에서는 어떤 패킹도 수행할 수 없는데, f는 패킹할 수 없고 다른 인자가 하나만 존재하기 때문입니다. 그러므로 대신 put_list 명령어를 예제로 살펴보겠습니다.
%macro:put_list PutList -pack put_list x x x
BeamInstr tmp_packed1; BeamInstr* next; PreFetch(1, next); tmp_packed1 = Arg(0); PutList(xb(tmp_packed1&BEAM_TIGHT_MASK), xb((tmp_packed1>>BEAM_TIGHT_SHIFT)&BEAM_TIGHT_MASK), xb((tmp_packed1>>(2*BEAM_TIGHT_SHIFT)))); NextPF(1, next);
이는 3개의 인수를 1개의 머신 워드로 압축하여, 이 명령어에 필요한 메모리를 절반으로 줄입니다.
- -fail_action
-
fail 동작을 c-매크로의 인자로 포함합니다.
ClauseFail()매크로는 실패 레이블이 명령어의 첫 번째 인자에 있다고 가정하므로, 위 예제에서 이를 사용하려면move_call x f를move_call f x로 변환해야 합니다.
%macro move_call MoveCall -fail_action
MoveCall(xb(Arg(0)), ClauseFail());
- -gen_dest
-
store function를 c-매크로의 인자로 포함시키세요.
%macro move_call MoveCall -gen_dest
MoveCall(xb(Arg(0)), StoreSimpleDest);
- -goto
-
일반적인 next 디스패치를
beam_emu.c내부의 c-label로 점프하는 것으로 대체합니다.
%macro move_call MoveCall -goto:do_call
MoveCall(xb(Arg(0))); goto do_call;
9.3. 최적화
로더는 코드를 로딩할 때 많은 핍홀 최적화를 수행합니다. 가장 중요한 것은 명령어 결합(instruction combining)과 명령어 특수화(instruction specialization)입니다.
명령어 결합은 두 개 이상의 작은 명령어를 하나의 큰 명령어로 합치는 것이다. 명령어들이 대부분의 경우 연이어 실행되는 것으로 알려져 있다면, 이는 코드의 상당한 속도 향상으로 이어질 수 있다. 속도 향상이 달성되는 이유는 명령어 사이에서 더 이상 디스패치를 수행할 필요가 없고, 또한 C 컴파일러가 해당 명령어를 최적화할 때 활용할 수 있는 정보가 더 많아지기 때문이다. 명령어 결합을 언제 수행할 것인지는 메인 에뮬레이터 루프의 크기 증가가 미치는 영향과 명령어가 실행될 때 얻는 이득을 비교하여 고려해야 하는 트레이드오프이다.
명령어 특수화는 명령어에서 인자를 디코딩할 필요를 제거합니다. 따라서 하나의 move_sd 명령어를 사용하는 대신, 인수가 이미 디코딩된 move_xx, move_xy 등이 생성됩니다. 이는 명령어의 디코딩 비용을 줄여주지만, 역시 에뮬레이터 코드 크기와의 트레이드오프입니다.
9.3.1. select_val 최적화
select_val 명령어는 많은 함수 또는 case 절의 제어 흐름 처리를 위해 컴파일러에 의해 생성됩니다. 예를 들어:
select(1) -> 3; select(2) -> 3; select(_) -> error.
컴파일 결과:
{function, select, 1, 2}. {label,1}. {line,[{location,"select.erl",5}]}. {func_info,{atom,select},{atom,select},1}. {label,2}. {test,is_integer,{f,4},[{x,0}]}. {select_val,{x,0},{f,4},{list,[{integer,2},{f,3},{integer,1},{f,3}]}}. {label,3}. {move,{integer,3},{x,0}}. return. {label,4}. {move,{atom,error},{x,0}}. return.
조건의 값은 정수 또는 아톰만 허용됩니다. 값이 다른 타입인 경우 컴파일러는 select_val 명령어를 생성하지 않습니다. 로더는 select_val을 수행할 때 어떤 유형의 알고리즘을 사용할지 결정하기 위해 몇 가지 휴리스틱을 사용합니다.
- jump_on_val
-
점프 테이블을 생성하고 그 값을 인덱스로 사용한다. 이는 매우 효율적이며, 서로 가까운 정수들의 그룹이 선택할 값으로 사용될 때 발생한다. 모든 값이 존재하지 않는 경우, 점프 테이블은 추가적인 실패 레이블 슬롯으로 패딩된다.
- select_val2
-
선택할 값이 단 두 개뿐이고 점프 테이블에 들어맞지 않을 때 사용됩니다.
- select_val_lins
-
정렬된 아톰 또는 정수에 대해 선형 검색을 수행합니다. 이것은 적은 수의 아톰 또는 정수에서 선택해야 할 때 사용됩니다.
- select_val_bins
-
정렬된 원자(atom) 또는 정수를 바이너리 검색합니다.
9.3.2. 리터럴의 사전 해싱(pre-hashing)
리터럴이 로드되어 해당 리터럴의 해시 값이 필요한 BIF나 명령어의 인수로 사용될 때, 매번 리터럴 값을 해싱하는 대신, 로더가 해시를 생성하고 명령어가 이를 사용한다.
이 기법을 사용하는 코드의 예로는 맵 명령어와 프로세스 딕셔너리 BIF들이 있다.
10. 스케줄링
ERTS 시스템에서 시간이 어디에 소비되는지 완전히 이해하려면, 시스템이 어떤 Erlang 코드를 실행할지 그리고 언제 실행할지를 어떻게 결정하는지 이해해야 합니다. 이러한 결정은 스케줄러에 의해 이루어집니다.
스케줄러는 시스템의 실시간 보장을 담당한다. 엄밀한 컴퓨터 과학 정의에 따르면, 실시간 시스템은 지정된 시간 내에 응답을 보장할 수 있어야 한다. 즉, 실제 데드라인이 존재하며 각 작업은 데드라인 이전에 완료되어야 합니다. Erlang에는 이러한 보장이 없으며, Erlang에서 타임아웃은 주어진 마감 시간 이전에 트리거되지 않는다는 것만 보장된다.
Erlang과 같이 모든 종류의 프로그램과 부하를 처리할 수 있어야 하는 범용 시스템에서는, 스케줄러가 어느 정도 타협을 할 수밖에 없습니다. 범용 스케줄러가 제대로 동작하지 않는 코너 케이스는 항상 존재하기 마련입니다. 이 장을 읽고 나면 Erlang 스케줄러가 어떻게 동작하는지, 특히 언제 최적으로 동작하지 않을 수 있는지에 대해 더 깊이 이해하게 될 것입니다. 코너 케이스를 피하도록 시스템을 설계할 수 있어야 하며, 비정상적으로 동작하는 시스템을 분석할 수 있어야 합니다.
10.1. 동시성, 병렬성, 그리고 선점형 멀티태스킹
Erlang은 동시성 언어이다. 프로세스가 동시에 실행된다고 말할 때, 이는 외부 관찰자에게 두 프로세스가 동시에 실행되고 있는 것처럼 보인다는 것을 의미한다. 단일 코어 시스템에서는 선점형 멀티태스킹을 통해 이를 달성한다. 이는 하나의 프로세스가 잠시 동안 실행되다가, 가상 머신의 스케줄러가 해당 프로세스를 일시 중단하고 다른 프로세스가 실행되도록 한다는 것을 의미한다.
멀티코어 또는 분산 시스템에서는 진정한 병렬성을 달성할 수 있으며, 이는 두 개 이상의 프로세스가 정확히 같은 시간에 실제로 실행되는 것을 의미합니다. SMP가 활성화된 에뮬레이터에서 시스템은 스레드당 하나의 스케줄러와 에뮬레이터를 실행하여 여러 OS 스레드를 통해 간접적으로 Erlang 프로세스를 실행합니다. ERTS의 기본 설정을 사용하는 시스템에서는 활성화된 코어(물리적 또는 하이퍼 스레드)당 하나의 스레드가 존재합니다.
SMP 지원이 활성화되어 있는지 확인하여 병렬 실행이 가능한 시스템인지 확인할 수 있습니다:
iex(1)> :erlang.system_info :smp_support true
시스템에서 실행 중인 스케줄러가 몇 개인지도 확인할 수 있습니다:
iex(2)> :erlang.system_info :schedulers_online 4
아래 그림에서 보듯이 Observer에서 이 정보를 확인할 수 있습니다.
스케줄러 수보다 더 많은 프로세스를 스폰하고 이들이 바쁜 작업을 수행하도록 하면, 여러 프로세스가 병렬로 running 상태에 있고 일부 프로세스는 runnable 상태이지만 현재 실행 중이 아닌 것을 확인할 수 있습니다. 이는 erlang:process_info/2 함수로 확인할 수 있습니다.
1> Loop = fun (0, _) -> ok; (N, F) -> F(N-1, F) end,
BusyFun = fun() -> spawn(fun () -> Loop(1000000, Loop) end) end,
SpawnThem = fun(N) -> [ BusyFun() || _ <- lists:seq(1, N)] end,
GetStatus = fun() -> lists:sort([{erlang:process_info(P, [status]), P}
|| P <- erlang:processes()]) end,
RunThem = fun (N) -> SpawnThem(N), GetStatus() end,
RunThem(8).
[{[{status,garbage_collecting}],<0.62.0>},
{[{status,garbage_collecting}],<0.66.0>},
{[{status,runnable}],<0.60.0>},
{[{status,runnable}],<0.61.0>},
{[{status,runnable}],<0.63.0>},
{[{status,runnable}],<0.65.0>},
{[{status,runnable}],<0.67.0>},
{[{status,running}],<0.58.0>},
{[{status,running}],<0.64.0>},
{[{status,waiting}],<0.0.0>},
{[{status,waiting}],<0.1.0>},
...
이 장의 뒷부분에서 프로세스가 가질 수 있는 다양한 상태를 더 자세히 살펴보겠지만, 지금 우리가 알아야 할 것은 running 또는 garbage_collecting 상태인 프로세스가 실제로 스케줄러에서 실행되고 있다는 점입니다. 예제의 머신은 코어 네 개와 스케줄러 네 개를 가지고 있기 때문에, 네 개의 프로세스(셸 프로세스와 busy processes 중 세 개)가 병렬로 실행되고 있습니다. 또한 실행을 기다리며 runnable 상태에 있는 바쁜(busy) 프로세스도 다섯 개 있습니다.
Observer의 Load Charts 탭을 사용하면 바쁜 프로세스들이 실행되는 동안 네 개의 스케줄러 모두가 완전히 부하 상태임을 확인할 수 있습니다.
2> observer:start(). ok 3> RunThem(8).
10.2. ERTS의 선점형 멀티태스킹, C로 협력하기
Erlang 수준에서의 선점형 멀티태스킹은 C 수준에서의 협력적 멀티태스킹을 통해 달성됩니다. Erlang 언어, 컴파일러, 그리고 가상 머신은 함께 협력하여 Erlang 프로세스의 실행이 제한된 시간 내에 양보(yield)하고 다음 프로세스가 실행될 수 있도록 보장합니다. 허용된 실행 시간을 측정하고 제한하는 데 사용되는 기법을 리덕션 카운팅이라고 하며, 리덕션 카운팅의 모든 세부 사항은 곧 살펴보겠습니다.
10.3. 리덕션
BEAM의 스케줄링은 협력적 스케줄링 위에 구현된 선점형 스케줄링으로 설명할 수 있다. 프로세스는 receive나 함수 호출과 같은 실행의 특정 지점에서만 중단될 수 있다. 이런 점에서 스케줄링은 협력적이다---프로세스는 중단을 허용하는 코드를 실행해야 한다. Erlang 코드의 특성상 프로세스가 함수 호출 없이 오랜 시간 실행되는 것은 거의 불가능하다. 그러나 여전히 양보(yield) 없이 너무 오래 걸릴 수 있는 몇몇 내장 함수(BIF)가 존재한다. 또한, 잘못 구현된 NIF(Native Implemented Function)에서 C 코드를 호출하면 하나의 스케줄러를 오랫동안 차단할 수 있습니다. 잘 동작하는 NIF를 작성하는 방법은 Chapter 16에서 살펴볼 것입니다.
재귀와 리스트 컴프리헨션 외에는 다른 반복 구문이 없기 때문에, 함수 호출 없이 무한히 반복할 방법은 없습니다. 각 함수 호출은 reduction(리덕션)으로 계산되며, 프로세스의 리덕션 한도에 도달하면 해당 프로세스는 일시 중단됩니다.
|
버전 정보
OTP-20.0 이전에는 |
|
리덕션
'리덕션’이라는 텀은 Erlang의 Prolog 계보에서 유래한 것이다. Prolog에서 각 실행 단계는 목표 리덕션(goal-reduction)으로, 각 단계에서 논리 문제를 구성 요소로 축소한 다음 각 부분을 풀려고 시도합니다. |
10.3.1. 얼마나 많은 리덕션을 받게 될까?
프로세스가 스케줄될 때, 프로세스는 CONTEXT_REDS(erl_vm.h에 정의되어 있으며, 현재 4000으로 설정됨)에 정의된 수만큼의 리덕션을 받는다. 리덕션을 모두 사용한 후, 또는 수신함(inbox)에 일치하는 메시지가 없는 상태에서 receive를 수행할 때, 해당 프로세스는 일시 중단(suspended)되고 새로운 프로세스가 스케줄된다.
VM이 INPUT_REDUCTIONS(현재 2*CONTEXT_REDS이며, erl_vm.h에도 정의되어 있음)에 정의된 만큼의 리덕션을 실행했거나 실행할 준비가 된 프로세스가 없는 경우, 스케줄러는 시스템 수준의 작업을 수행한다. 이는 기본적으로 IO를 확인하는 것을 의미하며, 곧 자세히 다룰 것이다.
10.3.2. 리덕션이란 정확히 무엇인가?
리덕션이 정확히 무엇인지는 완전히 정의되어 있지 않지만, 최소한 각 함수 호출은 하나의 리덕션으로 카운트되어야 한다. BIF와 NIF에 대해 이야기할 때는 상황이 좀 더 복잡해진다. 프로세스는 리덕션을 소비하고 양보(yield)하지 않은 채로 "오랜 시간" 동안 실행될 수 없어야 한다. C로 작성된 함수는 실행 도중에 양보할 수 없으며, 깨끗한 상태를 보장한 후 반환해야 한다. 재진입 가능(re-entrant)하려면 반환하기 전에 어떤 방식으로든 내부 상태를 저장하고, 재진입 시 그 상태를 다시 설정해야 한다. 이는 매우 비용이 클 수 있는데, 특히 때로는 적은 작업만 수행하고 때로는 많은 작업을 수행하는 함수의 경우 더욱 그렇다. Erlang 대신 C로 함수를 작성하는 이유는 보통 성능을 향상시키고 불필요한 부기(book keeping) 작업을 하지 않기 위해서이다. 하나의 리덕션이 무엇인지에 대해 Erlang 수준의 함수 호출 외에는 명확한 정의가 없으므로, C로 구현된 함수가 일반 Erlang 함수보다 리덕션당 훨씬 더 많은 클럭 사이클을 소비할 위험이 있다. 이는 스케줄러의 불균형을 초래할 수 있으며, 심지어 기아 상태(starvation)를 유발할 수도 있다.
예를 들어 R16 이전 버전의 Erlang에서는 BIF binary_to_term/1과 term_to_binary/1이 스케줄러에 양보하지 않았고 오직 하나의 리덕션으로만 계산되었습니다. 이는 큰 텀에 대해 이 함수들을 호출하는 프로세스가 다른 프로세스들을 굶주리게 할 수 있음을 의미했습니다. 이는 프로세스가 스케줄러 간에 균형을 이루는 방식 때문에 SMP 시스템에서도 발생할 수 있는데, 이에 대해서는 곧 다루겠습니다.
프로세스가 실행되는 동안 에뮬레이터는 실행할 남은 리덕션 수를 (레지스터에 매핑된) 변수 FCALLS에 유지합니다 (beam_emu.c 참고).
reds 필드는 프로세스가 마지막으로 중단되기 전까지 수행한 리덕션의 총 횟수를 추적합니다. 이 수치를 모니터링하면 어떤 프로세스가 가장 많은 작업을 하는지 알 수 있습니다.
두 번째 인자로 reductions 아톰을 사용하여 erlang:process_info/2를 호출하면 프로세스의 총 리덕션 수(reds 필드)를 확인할 수 있습니다. 이 수치는 observer의 process 탭에서 또는 Erlang 셸에서 i/0 명령으로도 확인할 수 있습니다.
앞서 언급했듯이, 프로세스가 시작될 때마다 fcalls 필드는 CONTEXT_REDS의 값으로 설정되며, 프로세스가 함수 호출을 실행할 때마다 fcalls는 1씩 감소합니다. 프로세스가 중단(suspended)되면 reds 필드는 실행된 리덕션 수만큼 증가합니다. C와 비슷한 코드로 표현하면 다음과 같습니다: p→reds += (CONTEXT_REDS - p→fcalls).
일반적으로 프로세스는 할당된 리덕션을 모두 소진하며, 이 시점에서 fcalls는 0이 되지만, 프로세스가 메시지를 기다리며 receive에서 중단되면 남은 리덕션이 있을 수 있습니다.
프로세스가 모든 리덕션을 소진하면 다른 프로세스가 실행되도록 양보하며, 프로세스 상태는 running에서 runnable 상태로 바뀌고, receive에서 양보하는 경우에는 대신 (메시지를 기다리는) waiting 상태로 들어간다. 다음 절에서는 프로세스가 가질 수 있는 여러 가지 상태를 모두 살펴보겠다.
10.4. 프로세스 상태 (또는 status)
PCB의 status 필드는 프로세스 상태를 담고 있다. 이 값은 free, runnable, waiting, running, exiting, garbing, suspended 중 하나일 수 있다. 프로세스가 종료되면 free로 표시되는데---여러분은 이 상태의 프로세스를 결코 볼 수 없어야 한다. 이는 매우 짧게 지속되는 상태로, 시스템의 나머지 부분 입장에서는 해당 프로세스가 더 이상 존재하지 않지만 여전히 정리해야 할 작업(메모리 및 기타 자원 해제)이 남아 있는 상태이다.
각 프로세스 상태는 프로세스 상태 머신의 한 상태를 나타냅니다. 타임아웃이나 메시지 전달과 같은 이벤트는 상태 기계의 엣지를 따라 전이를 발생시킵니다. 프로세스 상태 머신은 다음과 같습니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
+--------+
| free |
+-----------+ | |
+---> suspended | +---^----+
| +-+ | |
| | ++-------^^-+ +---+----+
| | | || | exiting|
| | | || | |
| | | || +---^----+
| | | ||suspend |
| | | |+--------+ |
| | | resume| | | exit
| | | | | |
| | +v-------+--+ +-+--+-----+ GC +----------+
| | | runnable |+-->| running +--------> garbing |
| | | | | <--------+ |
| | +^------^---+ +----+-----+ +----------+
| | | | |
| | | msg | timeout | receive
| | | | |
| | | | |
| | | | +----v-----+
| | | +--------+ waiting |
| | +---------------+ |
| | +^---+-----+
| |resume | |
| +-------------------+ |suspend
+-------------------------+
프로세스의 정상 상태는 runnable(실행 가능), waiting(대기), running(실행 중)이다. 실행 중인(running) 프로세스는 현재 스케줄러 중 하나에서 코드를 실행하고 있다. 프로세스가 receive에 진입했을 때 메시지 큐에 일치하는 메시지가 없으면, 메시지가 도착하거나 타임아웃이 발생할 때까지 프로세스는 대기 중 상태가 된다. 프로세스가 모든 리덕션을 소진하면 실행 가능 상태가 되어 스케줄러가 다시 선택해 줄 때까지 대기한다. 대기 중인(waiting) 프로세스는 메시지를 받거나 타임아웃이 발생하면 실행 가능(runnable) 상태가 된다.
프로세스가 가비지 컬렉션을 수행해야 할 때마다, GC가 끝날 때까지 garbing 상태로 들어간다. GC를 수행하는 동안 이전 상태를 필드 gcstatus에 저장하고, 완료되면 gcstatus를 사용하여 상태를 이전 상태로 되돌립니다.
일시 중단된 상태는 디버깅 목적으로만 사용하도록 되어 있습니다. 다른 프로세스에 erlang:suspend_process/2를 호출하여 강제로 정지 상태로 만들 수 있습니다. 프로세스가 다른 프로세스에서 suspend_process를 호출할 때마다, 정지 카운트가 증가합니다. 이는 rcount 필드에 기록됩니다. 일시 중단시킨 프로세스가 (erlang:resume_process/1)를 호출하면 일시 중단 카운트가 감소합니다. 일시 중단 상태에 있는 프로세스는 일시 중단 카운트가 0이 될 때까지 일시 중단 상태를 벗어나지 않습니다.
rstatus(재개 상태, resume status) 필드는 프로세스가 중단(suspend)되기 전에 어떤 상태였는지를 추적하는 데 사용된다. running 또는 runnable 상태였다면 runnable 상태로 시작되며, waiting 상태였다면 대기 큐로 돌아간다. 일시 중단된 대기 중인 프로세스가 타임아웃을 받으면 rstatus가 runnable로 설정되어 runnable 상태로 재개된다.
다음에 어떤 프로세스를 실행할지 추적하기 위해 스케줄러는 프로세스들을 큐에 유지합니다.
10.5. 프로세스 큐
스케줄러의 주요 역할은 작업 큐, 즉 프로세스와 포트의 큐를 추적하는 것입니다.
스케줄러가 처리해야 하는 프로세스 상태는 runnable(실행 가능)과 waiting(대기) 두 가지가 있습니다. 메시지를 수신하기를 기다리는 프로세스는 대기 상태에 있습니다. 대기 중인 프로세스가 메시지를 수신하면, 송신 연산이 수신 프로세스를 실행 가능 상태로 전환하는 것을 트리거한다. receive 문에 타임아웃이 있는 경우, 스케줄러는 타임아웃이 발생할 때 실행 가능 상태로의 상태 전환을 트리거해야 한다. 이 메커니즘은 이 장의 뒷부분에서 다룰 것이다.
10.5.1. 준비 큐
실행 가능 상태의 프로세스는 스케줄러가 관리하는 FIFO(first in first out, 선입선출) 큐에 배치되며, 이를 준비 큐(ready queue) 라고 부른다. 이 큐는 첫 번째(first)와 마지막(last) 포인터, 그리고 각 참여 프로세스의 PCB에 있는 다음(next) 포인터로 구현된다. 새 프로세스가 큐에 추가될 때는 마지막(last) 포인터를 따라가며, O(1) 연산으로 큐의 끝에 프로세스가 추가된다. 새 프로세스가 스케줄링될 때는 큐의 맨 앞(첫 번째(first) 포인터)에서 그냥 꺼내진다.
The Ready Queue
First: --> P5 +---> P3 +-+-> P17
next: ---+ next: ---+ | next: NULL
|
Last: --------------------------------+
SMP 시스템에서는 여러 스케줄러 스레드가 있으며, 스케줄러마다 하나의 큐가 있습니다.
Scheduler 1 Scheduler 2 Scheduler 3 Scheduler 4
Ready: P5 Ready: P1 Ready: P7 Ready: P9
P3 P4 P12
P17 P10
Erlang 프로세스에는 우선순위가 있으므로 실제로는 약간 더 복잡합니다. 각 스케줄러는 실제로 세 개의 큐를 가지고 있습니다. 최대 우선순위 작업을 위한 큐 하나, 높은 우선순위 작업을 위한 큐 하나, 그리고 보통 우선순위와 낮은 우선순위 작업을 모두 포함하는 큐 하나입니다.
Scheduler 1 Scheduler 2 Scheduler 3 Scheduler 4
Max: P5 Max: Max: Max:
High: High: P1 High: High:
Normal: P3 Ready: P4 Ready: P7 Ready: P9
P17 P12
P10
max 큐에 프로세스가 있으면 스케줄러는 이 프로세스들을 실행 대상으로 선택합니다. max 큐에 프로세스가 없지만 high 우선순위 큐에 프로세스가 있으면 스케줄러는 해당 프로세스를 선택합니다. max 큐와 high priority 큐에 프로세스가 없는 경우에만 스케줄러는 normal 큐와 low 큐에서 첫 번째 프로세스를 선택합니다.
일반 프로세스가 큐에 삽입되면 schedule count 값 1을 받고, 낮은 우선순위 프로세스는 schedule count 값 8을 받는다. 프로세스가 큐의 맨 앞에서 선택되면 해당 프로세스의 schedule count가 1만큼 감소하며, 그 값이 0에 도달하면 프로세스가 스케줄링되고, 그렇지 않으면 큐의 맨 끝에 다시 삽입된다. 이는 낮은 우선순위 프로세스가 스케줄링되기 전에 큐를 일곱 번 통과하게 된다는 것을 의미합니다.
10.5.2. 대기, 타임아웃 그리고 타이밍 휠
빈 메일박스 또는 일치하는 메시지가 없는 메일박스에서 receive를 수행하려는 프로세스는 양보(yield)하고 대기 상태로 들어간다.
메시지가 수신함(inbox)에 전달되면 송신 프로세스는 수신자가 대기 상태에서 잠자고 있는지 확인하고, 그런 경우 프로세스를 깨워서 상태를 실행 가능(runnable)으로 변경한 다음 해당 준비 큐(ready queue)의 끝에 넣는다.
receive 문에 timeout 절이 있으면 지정된 타임아웃 시간이 지난 후 트리거되는 타이머가 프로세스를 위해 생성됩니다. 런타임 시스템이 타임아웃에 대해 보장하는 유일한 것은 설정된 시간 이전에는 트리거되지 않는다는 것입니다. 프로세스가 스케줄링되어 실행되기까지는 의도된 시간보다 다소 늦어질 수 있습니다.
타이머는 VM에서 타이밍 휠(timing wheel) 에 의해 처리됩니다. 즉, 순환하는 시간 슬롯의 배열입니다. Erlang 18 이전에는 타이밍 휠이 전역 리소스였으며, 많은 프로세스가 휠에 타이머를 삽입할 경우 쓰기 락에 대한 경합이 발생할 수 있었습니다. 많은 타이머를 사용한다면 반드시 최신 버전의 Erlang을 사용하고 있는지 확인하세요.
타이밍 휠의 기본 크기(TIW_SIZE)는 65536개의 슬롯이다(작은 메모리 사용량을 위해 시스템을 빌드한 경우에는 8192개의 슬롯). 현재 시간은 배열에 대한 인덱스(tiw_pos)로 표시된다. 타임아웃 T를 가진 타이머가 휠에 삽입될 때, 해당 타이머는 (tiw_pos+T)%TIW_SIZE 슬롯에 삽입된다.
0 1 65535
+-+-+- ... +-+-+-+-+-+-+-+-+-+-+-+ ... +-+-----+
| | | | | | | | | |t| | | | | | | |
+-+-+- ... +-+-+-+-+-+-+-+-+-+-+-+ ... +-+-----+
^ ^ ^
| | |
tiw_pos tiw_pos+T TIW_SIZE
타이밍 휠(timing wheel)에 저장된 타이머는 ErlTimer 구조체에 대한 포인터입니다. erl_time.h를 참고하세요. 여러 타이머가 동일한 슬롯에 삽입되면 prev와 next 필드에 의해 연결 리스트로 서로 연결됩니다. count 필드는 T/TIW_SIZE로 설정됩니다.
/*
** Timer entry:
*/
typedef struct erl_timer {
struct erl_timer* next; /* next entry tiw slot or chain */
struct erl_timer* prev; /* prev entry tiw slot or chain */
Uint slot; /* slot in timer wheel */
Uint count; /* number of loops remaining */
int active; /* 1=activated, 0=deactivated */
/* called when timeout */
void (*timeout)(void*);
/* called when cancel (may be NULL) */
void (*cancel)(void*);
void* arg; /* argument to timeout/cancel procs */
} ErlTimer;
10.6. 포트
포트는 Erlang VM 외부 세계와의 통신 지점을 나타내는 Erlang 추상화입니다. 소켓, 파이프, 파일 IO와의 통신은 모두 Erlang 측에서 포트를 통해 수행됩니다.
포트는 프로세스와 마찬가지로 생성하는 프로세스와 동일한 스케줄러에서 생성됩니다. 프로세스와 마찬가지로 포트도 언제 양보할지 결정하기 위해 리덕션을 사용하며, 마찬가지로 4000 리덕션 동안 실행될 수 있다. 하지만 포트는 Erlang 코드를 실행하지 않으므로 리덕션으로 셀 Erlang 함수 호출이 없으며, 대신 각 포트 태스크는 일정 수의 리덕션으로 계산된다. 현재 하나의 태스크는 태스크당 200 리덕션을 조금 넘게 사용하며, 전송된 데이터 크기의 1000분의 1에 비례하는 만큼의 리덕션을 사용한다.
포트 태스크는 포트에 대한 하나의 연산으로, 열기, 닫기, 일정 바이트 수 전송 또는 데이터 수신과 같은 작업을 말합니다. 포트 태스크를 실행하기 위해 실행 스레드는 해당 포트에 대한 락을 획득합니다.
포트 태스크는 스케줄러 루프(아래 참조)의 각 반복에서 새로운 프로세스가 실행 대상으로 선택되기 전에 스케줄링되고 실행됩니다.
10.7. 스케줄러 루프
개념적으로 스케줄러를 Erlang 가상 머신에서 프로그램 실행을 주도하는 존재로 볼 수 있습니다. 실제로는, 즉 C 코드가 구성된 방식을 보면, 실행을 주도하는 것은 에뮬레이터(beam_emu.c의 process_main)이며, 이 에뮬레이터가 다음에 실행할 프로세스를 찾기 위해 스케줄러를 서브루틴으로 호출합니다.
그럼에도 불구하고, 스케줄러 루프에 대한 좋은 개념적 모델을 제공하기 때문에 우리는 그 반대인 것처럼 가정할 것이다. 즉, 스케줄러가 실행할 프로세스를 선택한 다음 실행을 에뮬레이터에 넘기는 것으로 봅니다.
그런 식으로 보면, 스케줄러 루프는 다음과 같습니다:
-
리덕션 카운터를 업데이트한다.
각 프로세스에는 일정 수의 리덕션(작은 실행 단위)이 할당됩니다. 리덕션 횟수가 초과되면 해당 프로세스는 선점되고 다른 프로세스가 스케줄링됩니다.
-
타이머 확인
타이머(예:
receive … after)는 타이밍 휠을 통해 확인됩니다. 타임아웃이 만료되면 해당 프로세스는 준비 큐로 이동됩니다. -
필요한 경우 잔액을 확인
시스템은 일부 스케줄러 스레드가 과부하 상태인지 주기적으로 확인합니다. 과부하 상태라면, 부하 분산 결정이 내려집니다.
-
필요한 경우 프로세스와 포트를 마이그레이션
프로세스와 포트는 멀티코어 환경에서 CPU 부하를 재분배하기 위해 스케줄러 스레드 간에 이동될 수 있습니다.
-
보조 스케줄러 작업 수행
여기에는 더티 NIF, 프로세스 종료, 트레이싱 훅, 그리고 기타 지연된 런타임 유지보수 작업의 처리가 포함됩니다.
-
필요한 경우 IO를 확인하고 시간을 갱신
파일 디스크립터 또는 포트의 I/O 준비 상태를 폴링하며, 시스템 시간이나 벽시계를 갱신할 수 있습니다.
-
필요한 동안 실행할 포트 태스크를 선택
TCP 읽기 또는 쓰기, 드라이버 콜백 호출과 같은 작업은 프로세스를 스케줄링하기 전에 여기서 수행됩니다.
-
실행할 프로세스 선택
실행 가능한 프로세스는 우선순위와 공정성을 고려하여 실행 큐에서 선택되고, 실행을 위해 에뮬레이터에 전달됩니다.
10.8. 부하 분산
로드 밸런서의 현재 전략은 어떤 CPU에도 과부하를 주지 않으면서 가능한 한 적은 수의 스케줄러를 사용하는 것입니다. 이 아이디어는 프로세스들이 동일한 CPU를 공유할 때 더 나은 메모리 지역성을 통해 더 나은 성능을 얻을 수 있다는 것입니다.
다만 한 가지 주의할 점은 스케줄러에서 수행되는 부하 분산은 스케줄러 스레드 간에 이루어지며, 반드시 CPU나 코어 간에 이루어지는 것은 아니라는 것입니다. 런타임 시스템을 시작할 때 스케줄러를 코어에 어떻게 할당할지 지정할 수 있습니다. 기본 동작은 스케줄러 스레드를 코어에 할당하는 것을 OS에 맡기는 것이지만, 스케줄러를 코어에 바인딩하도록 선택할 수도 있습니다.
로드 밸런서는 각 코어에서 하나의 스케줄러가 실행되고 있다고 가정하므로, 과부하된 스케줄러에서 활용도가 낮은 스케줄러로 프로세스를 이동하면 더 많은 병렬 처리 능력을 얻을 수 있습니다. 스케줄러가 코어에 할당되는 방식을 변경했거나, OS가 과부하 상태이거나 스레드를 코어에 할당하는 데 서투르다면, 로드 밸런싱이 오히려 불리하게 작용할 수 있습니다.
로드 밸런서는 부하를 분산시키기 위해 작업 훔치기(task stealing)와 마이그레이션(migration)이라는 두 가지 기법을 사용합니다. 작업 훔치기는 스케줄러가 작업을 다 소진할 때마다 사용된다. 이 기법은 작업이 스케줄러들 사이에 더 고르게 분산되는 결과를 가져온다. 마이그레이션은 더 복잡하며, 적절한 수의 스케줄러에 부하를 압축하려고 시도한다.
10.8.1. 태스크 스틸링
스케줄러가 스케줄링할 새 프로세스를 선택해야 할 때 스케줄러 실행 큐가 비어 있으면, 해당 스케줄러는 다른 스케줄러로부터 작업을 훔쳐오려고 시도합니다.
먼저 스케줄러는 다른 스케줄러가 현재 스케줄러에서 작업을 훔쳐가지 못하도록 자기 자신에 대해 잠금을 건다. 그런 다음 작업을 훔쳐올 수 있는 비활성 스케줄러가 있는지 확인한다. 훔쳐올 수 있는 작업을 가진 비활성 스케줄러가 없으면, 자신보다 높은 id를 가진 스케줄러부터 시작하여 활성 스케줄러를 살펴보며 훔쳐올 수 있는 작업을 찾으려 한다.
태스크 스틸링은 한 번에 하나의 스케줄러를 살펴보고 해당 스케줄러의 가장 높은 우선순위 태스크를 훔치려고 시도합니다. 이 작업이 스케줄러별로 수행되기 때문에, 실제로는 다른 스케줄러에 훔칠 수 있는 더 높은 우선순위의 태스크가 있더라도 가져오지 못할 수 있습니다.
태스크 스틸링은 번호가 더 높은 스케줄러에서 훔쳐오는 방식으로 태스크를 번호가 더 낮은 스케줄러 쪽으로 이동시키려고 시도하지만, 스틸링이 순환하여 번호가 더 낮은 스케줄러에서도 훔쳐오기 때문에 결과적으로 프로세스들은 모든 활성 스케줄러에 분산되게 됩니다.
태스크 스틸링은 상당히 빠르며, 스케줄러가 태스크를 모두 소진했을 때 스케줄러 루프의 매 반복마다 수행될 수 있습니다.
10.8.2. 마이그레이션
스케줄러를 최적으로 활용하기 위해 보다 정교한 마이그레이션 전략이 사용됩니다. 현재 전략은 가능한 한 적은 수의 스케줄러에 부하를 집약하면서도, 동시에 어떤 스케줄러도 과부하되지 않도록 부하를 분산하는 것입니다.
이는 erl_process.c의 check_balance 함수에 의해 수행됩니다.
마이그레이션은 먼저 마이그레이션 계획을 수립한 다음, 새로운 계획이 수립될 때까지 스케줄러들이 해당 계획에 따라 실행하도록 하는 방식으로 수행된다. 매 2000*CONTEXT_REDS 리덕션마다 스케줄러는 모든 스케줄러의 작업 부하를 살펴봄으로써 스케줄러별, 우선순위별로 마이그레이션 경로를 계산한다. 마이그레이션 경로는 세 가지 유형의 값을 가질 수 있다: 1) 비워짐 2) 스케줄러로 이주 3) 스케줄러로부터 이주.
프로세스가 준비 상태가 되면(예를 들어 메시지를 수신하거나 타임아웃이 발생하여) 일반적으로 마지막으로 실행되었던 스케줄러(S1)에 스케줄링된다. 즉, 해당 스케줄러(S1)의 해당 우선순위에서 마이그레이션 경로가 비워져 있는 경우에 그렇습니다. 스케줄러의 마이그레이션 경로가 (S2로) 이주하도록 설정되어 있다면, S1과 S2 모두 불균형한 실행 큐를 가지고 있는 경우 프로세스는 해당 스케줄러로 넘겨집니다. 이것이 무엇을 의미하는지는 나중에 다시 다루겠습니다.
스케줄러(S1)가 실행할 새로운 프로세스를 선택하려 할 때, S2로부터의 이주 경로가 설정되어 있는지 확인합니다. 관련된 두 스케줄러의 실행 큐가 불균형 상태라면, S1은 S2로부터 프로세스를 빼앗아 옵니다.
마이그레이션 경로는 특정 우선순위에 대해 각 스케줄러의 최대 실행 큐를 비교하여 계산됩니다. 각 스케줄러는 스케줄러 루프를 반복할 때마다 카운터를 업데이트하여 최대 큐 길이를 추적합니다. 이 정보는 평균(최대) 큐 길이(AMQL)를 계산하는 데 사용됩니다.
Max
Run Q
Length
5 o
o
o o
Avg: 2.5 --------------
o o o
1 o o o
scheduler S1 S2 S3 S4
그런 다음 스케줄러들은 최대 큐 길이를 기준으로 정렬됩니다.
Max
Run Q
Length
5 o
o
o o
Avg: 2.5 --------------
o o o
1 o o o
scheduler S3 S4 S1 S2
^ ^
| |
tix fix
평균보다 실행 큐가 더 긴 스케줄러(S1, S2)는 이주(emigration) 대상으로 표시되고, 평균보다 최대 실행 큐가 더 짧은 스케줄러(S3, S4)는 유입(immigration) 대상이 됩니다.
이는 두 개의 인덱스, 즉 (fix) (이주해 오는 대상)와 (tix) (이주해 가는 대상)를 사용하여 정렬된 스케줄러 집합을 순회함으로써 이루어진다. 루프의 각 반복마다 S[tix]의 이민 경로(immigration path)는 S[fix]로 설정되고, S[fix]의 이출 경로(emigration path)는 S[tix]로 설정된다. 그런 다음 tix는 증가하고 fix는 감소하며, 둘 다 균형점을 지날 때까지 계속됩니다. 하나의 인덱스가 먼저 균형점에 도달하면 다시 처음으로 되돌아갑니다(wrap).
예제에서:
-
반복 1: S2.emigrate_to = S3 그리고 S3.immigrate_from = S2
-
반복 2: S1.emigrate_to = S4 그리고 S4.immigrate_from = S1
그러면 완료입니다.
실제로는 스케줄러가 오프라인으로 전환될 수 있기 때문에 상황이 조금 더 복잡합니다. 마이그레이션 계획은 온라인 스케줄러에 대해서만 수행됩니다. 또한, 앞서 언급했듯이 이 작업은 우선순위 수준별로 수행됩니다.
프로세스가 준비 큐에 삽입되려 할 때 S1에서 S2로의 마이그레이션 경로가 설정되어 있으면, 스케줄러는 먼저 S1의 실행 큐가 AMQL보다 큰지, 그리고 S2의 실행 큐가 평균보다 작은지를 확인합니다. 이렇게 하면 두 큐가 여전히 불균형 상태일 때만 마이그레이션이 허용됩니다.
그러나 큐가 균형을 이루고 있거나 심지어 반대 방향으로 불균형한 경우에도 마이그레이션이 강제되는 두 가지 예외가 있다. 이 두 경우 모두 균형 테스트를 무시하는 특별한 대피(evacuation) 플래그가 설정된다.
대피 플래그는 스케줄러가 오프라인으로 전환될 때 설정되어, 오프라인 스케줄러에 새로운 프로세스가 스케줄링되지 않도록 보장합니다. 이 플래그는 스케줄러가 특정 우선순위에서 진행이 이루어지지 않음을 감지했을 때도 설정됩니다. 즉, 예를 들어 항상 실행 준비가 되어 있는 최대 우선순위 프로세스가 있어서 일반 우선순위 프로세스가 전혀 스케줄링되지 못하는 경우입니다. 그러면 해당 스케줄러의 일반 우선순위 큐에 대해 대피 플래그가 설정됩니다.
11. 메모리 서브시스템: 스택, 힙 및 할당자
ERTS의 메모리 서브시스템을 깊이 살펴보기 전에, 현대 운영 체제에서 프로그램의 일반적인 메모리 레이아웃에 대한 기본적인 용어와 이해가 필요합니다. 이 개요 섹션에서는 프로그램이 ELF 실행 파일로 컴파일되어 IA-32/AMD64 아키텍처와 같은 환경의 Linux에서 실행되고 있다고 가정하겠습니다. 레이아웃과 용어(텀)는 ERTS가 컴파일되는 모든 운영 체제에서 기본적으로 동일합니다.
프로그램의 메모리 레이아웃은 대략 다음과 같습니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
high
addresses
+--------------+
| Arguments |
| ENV |
+--------------+
| Stack | --+
| | | | Can grow
| v | | dynamically
| | --+
+--------------+
| | -------------------------------+
+--------------+ |
| memory | |
| map | -- files or anonymous |
| segment | |
+--------------+ |
| | | Memory
+--------------+ | Mapping
| Thread Stack | --+ | Region
| | | | Statically allocated |
| v | | on thread start. |
| | --+ |
+--------------+ |
| | |
+--------------+ |
| Thread Stack | --+ |
| | | | Statically allocated |
| v | | on thread start. |
| | --+ |
+--------------+ |
| | -------------------------------+
+--------------+ brk
| | --+
| ^ | | Can grow
| | | | dynamically
| Heap | --+
+--------------+ start_brk
| BSS | -- Static variables initialized to zero
+--------------+
| Data | --+
+--------------+ | Binary (disk image)
| Code | --+
+--------------+
low
addresses
이 그림이 복잡해 보일 수 있지만, 이것은 여전히 단순화된 것입니다. (메모리 서브시스템을 완전히 이해하려면 "Understanding the Linux Kernel"이나 "Linux System Programming" 같은 책을 읽어보세요.) 여기서 기억해야 할 것은 동적으로 할당 가능한 메모리에는 힙과 메모리 매핑 세그먼트, 두 가지 유형이 있다는 점입니다. 앞으로 이 힙을 Erlang 프로세스 힙과 구별하기 위해 C-힙 이라고 부르겠습니다. 메모리 매핑 세그먼트는 단순히 세그먼트 라고 부르고, 이 그림에 나오는 스택들은 C-스택 이라고 부르겠습니다.
C 힙은 malloc을 통해 할당되며, 세그먼트는 mmap으로 할당됩니다.
11.1. 메모리 서브시스템
이제 메모리 서브시스템을 깊이 살펴보면, ERTS가 단순한 프로그래밍 언어 환경이 아니라 운영 체제에 더 가깝다는 것이 다시 한번 분명해질 것입니다. ERTS는 Erlang 프로세스 수준에서 Erlang 텀을 위한 가비지 컬렉터를 제공할 뿐만 아니라, 다양한 저수준 메모리 할당기와 메모리 할당 전략도 제공합니다.
메모리 할당자에 대한 개요는 다음의 ERTS erts_alloc 문서를 참조하십시오: http://www.erlang.org/doc/man/erts_alloc.html
이러한 모든 할당자에는 동작을 조정하는 데 사용할 수 있는 다수의 파라미터가 함께 제공되며, 이는 운영 관점에서 아마도 가장 중요한 영역 중 하나일 것입니다. 이 영역에서 소형 임베디드 제어 시스템(예: Raspberry Pi)부터 인터넷 규모의 2TB 데이터베이스 서버에 이르기까지 모든 환경에 맞게 시스템 동작을 구성할 수 있습니다.
현재 11개의 서로 다른 할당자, 6개의 서로 다른 할당 전략, 그리고 18개 이상의 기타 설정이 있으며, 이 중 일부는 임의의 숫자 값을 취합니다. 이는 기본적으로 가능한 구성의 수가 무한하다는 것을 의미합니다. (엄밀히 말하면 각 숫자에 상한이 있으므로 무한은 아니지만, 셀 수 없을 만큼 많은 구성이 존재합니다.)
이러한 설정을 의미 있게 사용하려면, 이 할당자들이 어떻게 동작하는지, 그리고 각 설정이 할당자의 성능에 어떤 영향을 미치는지 이해해야 합니다.
erts_alloc 매뉴얼은 다음과 같은 경고까지 제공합니다:
이 플래그들은 자신이 무엇을 하고 있는지 확실히 아는 경우에만 사용하십시오. 부적절한 설정은 심각한 성능 저하를 일으킬 수 있으며, 운영 중 언제든지 시스템 크래시로 이어질 수도 있습니다.
http://www.erlang.org/doc/man/erts_alloc.html
여러분이 자신이 무엇을 하고 있는지 확실히 알도록 하는 것, 그것이 바로 이 장에서 다루는 내용입니다.
아, 물론 가비지 컬렉션이 어떻게 동작하는지에 대해서도 자세히 다룰 것입니다.
11.2. 다양한 유형의 메모리 할당자
Erlang 런타임 시스템은 모든 상황과 모든 유형의 부하에서 메모리를 최선으로 처리하려고 노력하지만, 항상 코너 케이스는 존재합니다. 이 장에서는 메모리가 어떻게 할당되는지, 그리고 다양한 할당자들이 어떻게 동작하는지에 대한 세부 사항을 살펴보겠습니다. 이 지식과 나중에 살펴볼 몇 가지 도구를 활용하면, 시스템이 이러한 코너 케이스 중 하나에 빠졌을 때 문제를 감지하고 수정할 수 있을 것입니다.
시스템이 겪을 수 있는 문제와 그 동작을 분석하고 수정하는 방법에 대한 흥미로운 이야기는 Fred Hébert의 에세이 "Troubleshooting Down the Logplex Rabbit Hole"을 읽어보시기 바랍니다.
이 책에서 메모리 할당자에 대해 이야기할 때 우리는 특정한 의미를 염두에 두고 있다. 각 메모리 할당자는 특정 유형의 메모리 할당과 해제를 관리한다. 각 할당자는 특정 유형의 데이터를 위해 설계되었으며, 흔히 하나의 데이터 크기에 특화되어 있다.
각 메모리 할당자는 실제 메모리 할당을 위해 서로 다른 알고리즘과 설정을 사용할 수 있는 할당자 인터페이스를 구현합니다.
서로 다른 할당자를 두는 목적은 같은 크기의 할당을 그룹화하여 단편화를 줄이고, 빈번한 할당을 저비용으로 만들어 성능을 향상시키는 것입니다.
특수하고 기본적이거나 범용적인 메모리 할당자 유형으로 sys_alloc 과 mseg_alloc 두 가지가 있으며, alloc_util 프레임워크를 통해 구현된 아홉 가지의 특정 할당자가 있다.
다음 섹션에서는 다양한 할당자들을 살펴볼 것이며, 할당자를 위한 일반 프레임워크(alloc_util)에 대해서도 잠시 우회하여 알아볼 것입니다.
각 할당자는 문서와 C 코드에서 사용되는 여러 이름을 가지고 있습니다. 모든 할당자와 그 이름의 간략한 목록은 Table 1를 참조하십시오. C-name은 C 코드에서 할당자를 참조하는 데 사용됩니다. Type-name은 erl_alloc.types에서 할당 유형을 할당자에 바인딩하는 데 사용됩니다. Flag는 Erlang을 시작할 때 해당 할당자의 매개변수를 설정하는 데 사용되는 문자입니다.
| 이름 | 설명 | C-name | 타입-이름 | 플래그 |
|---|---|---|---|---|
기본 할당자 |
malloc 인터페이스 |
sys_alloc |
SYSTEM |
Y |
메모리 세그먼트 할당자 |
mmap 인터페이스 |
mseg_alloc |
- |
M |
임시 할당자 |
임시 할당 |
temp_alloc |
TEMPORARY |
T |
힙 할당자 |
Erlang 힙 데이터 |
eheap_alloc |
EHEAP |
H |
바이너리 할당자 |
바이너리 데이터 |
binary_alloc |
BINARY |
B |
ETS 할당자 |
ETS 데이터 |
ets_alloc |
ETS |
E |
드라이버 할당자 |
드라이버 데이터 |
driver_alloc |
DRIVER |
R |
단기 할당자 |
짧은 수명의 메모리 |
sl_alloc |
SHORT_LIVED |
S |
장기 생존 할당자 |
장수명 메모리 |
ll_alloc |
LONG_LIVED |
L |
고정 할당자 |
고정 크기 데이터 |
fix_alloc |
FIXED_SIZE |
F |
표준 할당자 |
대부분의 다른 데이터의 경우 |
std_alloc |
STANDARD |
D |
리터럴 할당자 |
모듈 상수 |
literal_alloc |
LITERAL |
(none) |
11.2.1. 기본 할당자: sys_alloc
할당자 sys_alloc은 비활성화할 수 없으며, 기본적으로 libc의 하위 OS malloc 구현에 대한 직접적인 매핑이다.
특정 할당자가 비활성화되면 대신 sys_alloc이 사용됩니다.
모든 특정 할당자는 필요에 따라 운영 체제로부터 메모리를 할당하기 위해 sys_alloc 또는 mseg_alloc을 사용합니다.
OS로부터 메모리를 할당받을 때, sys_alloc은 요청된 크기에 고정된 킬로바이트 수를 추가(패딩)할 수 있습니다. 이렇게 하면 메모리를 초과 할당하여 시스템 호출 횟수를 줄일 수 있습니다. 기본 패딩 값은 0입니다.
메모리가 해제되면, sys_alloc은 프로세스 내에 일부 여유 메모리를 할당된 상태로 유지합니다. 이 여유 메모리의 크기를 트림 임계값(trim threshold)이라 하며, 기본값은 128킬로바이트입니다. 이는 메모리 사용량이 높아지는 대가로 시스템 호출 횟수를 줄여주기도 합니다. 이는 기본 설정으로 시스템을 실행하는 경우, 메모리가 해제되더라도 BEAM 프로세스가 OS에 메모리를 즉시 반환하지 않는 현상을 경험할 수 있음을 의미합니다.
sys_alloc에 의해 할당된 메모리 영역은 BEAM 프로세스의 C 힙에 저장되며, 필요에 따라 brk 시스템 호출을 통해 확장됩니다.
11.2.2. 메모리 세그먼트 할당기: mseg_alloc
기반 운영 체제가 mmap을 지원하는 경우, 특정 메모리 할당자는 운영 체제로부터 메모리를 할당하기 위해 sys_alloc 대신 mseg_alloc을 사용할 수 있다.
mseg_alloc를 통해 할당된 메모리 영역을 세그먼트라고 한다. 세그먼트가 해제될 때 즉시 OS로 반환되지 않고, 대신 세그먼트 캐시에 보관된다.
새로운 세그먼트가 할당될 때 가능하면 캐시된 세그먼트가 재사용됩니다. 즉, 요청된 크기와 같거나 더 크지만 너무 크지는 않은 경우에 재사용됩니다. absolute max cache bad fit 값은 너무 크지 않다고 간주되는 추가 크기의 킬로바이트 수를 결정합니다. 기본값은 4096 킬로바이트입니다.
매우 작은 할당에 대해 4096 킬로바이트 세그먼트를 재사용하지 않기 위해 상대적 최대 캐시 부적합(relative max cache bad fit) 값도 있으며, 이는 캐시된 세그먼트가 해당 퍼센트 이상 더 클 경우 사용되지 않도록 지정합니다. 기본값은 20퍼센트입니다. 즉, 10 KB 세그먼트를 요청할 때 12 KB 세그먼트가 사용될 수 있다.
캐시의 항목 수는 기본값이 10이지만 0에서 30 사이의 임의의 값으로 설정할 수 있다.
11.2.3. 메모리 할당기 프레임워크: alloc_util
두 개의 범용 할당자(sys_alloc과 mseg_alloc) 위에 구축된 것이 바로 다양한 용도와 데이터 유형에 대한 특정 메모리 할당자를 구현하는 데 사용되는 alloc_util이라는 프레임워크다.
이 프레임워크는 erl_alloc_util.[ch] 에 구현되어 있으며, ERTS에서 사용하는 다양한 할당자는 "erts/emulator/beam/" 디렉터리의 erl_alloc.types에 정의되어 있습니다.
SMP 시스템에서는 보통 스케줄러 스레드당 각 유형의 할당자가 하나씩 존재합니다.
할당자가 다룰 수 있는 가장 작은 메모리 단위를 block이라고 합니다. 할당자를 호출하여 일정량의 메모리를 할당하면 돌려받는 것이 바로 블록입니다. 메모리를 해제하고자 할 때 할당자에게 인자로 전달하는 것도 역시 블록입니다.
그러나 할당자는 운영 체제로부터 직접 블록을 할당하지 않는다. 대신 할당자는 운영 체제로부터 캐리어(carrier) 를 할당하는데, sys_alloc 또는 mseg_alloc을 통해 할당하며, 이들은 각각 malloc이나 mmap을 사용한다. sys_alloc이 사용되면 캐리어는 C 힙에 배치되고, mseg_alloc이 사용되면 캐리어는 세그먼트에 배치된다.
작은 블록은 멀티블록 캐리어에 배치됩니다. 멀티블록 캐리어는 이름이 암시하듯이 많은 블록을 포함할 수 있습니다. 더 큰 블록은 싱글블록 캐리어에 배치되며, 이름이 암시하듯이 하나의 블록을 포함합니다.
작은 블록과 큰 블록의 기준은 단일 블록 캐리어 임계값(singleblock carrier threshold, sbct) 매개변수에 의해 결정되며, 아래의 시스템 플래그 목록을 참조하십시오.
대부분의 할당자는 절대 해제되지 않는 하나의 "메인 멀티블록 캐리어"도 가지고 있다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
high
addresses
|FREE OS MEMORY |
+---------------+ brk
| FREE HEAP | | less than MYtt kb
+---------------+
/ | Unused PAD | | multiple of Muycs
| |---------------| |
S | | | |
singleblock| | | |
carrier 1 | Block | | | larger than MSsbct kb
| | | | |
\ | | | |
+---------------+
/ |Free in Carrier| |
| |---------------| |
S | | |
main | | |
multiblock | Block 2 | | MSmmbcs kb
carrier |---------------| |
| | | |
\ | Block 1 | |
+---------------+
| |
| U S E D |
| |
+---------------+ start_brk
C-Heap
low
addresses
블록, 캐리어, 그리고 할당 전략
Erlang 프로세스가 메모리를 필요로 할 때, 매 할당마다 운영 체제에 직접 메모리를 요청하지는 않습니다. 대신, alloc_util 프레임워크(erl_alloc_util.[ch]에 구현됨)에서 제공하는 특수 할당자와 상호작용합니다. 이러한 할당자들은 "캐리어(carrier)"라고 알려진 더 큰 연속 메모리 영역에서 메모리를 분배하는 방식으로 요청을 처리합니다.
캐리어는 운영 체제에서 직접 할당된 메모리 영역이다. 캐리어는 다음 중 하나를 통해 할당됩니다:
-
sys_alloc(표준 C 라이브러리 함수인malloc()등을 사용), 프로세스 힙에 메모리를 배치하거나 -
mseg_alloc(mmap()을 사용하여), 일반적인 C-힙 영역 밖에 메모리를 배치합니다.
캐리어(carrier)는 "블록(block)"이라고 불리는 더 작은 메모리 세그먼트로 세분됩니다. 메모리가 요청되면, 이 캐리어들로부터 블록이 할당됩니다. 캐리어에는 두 가지 주요 유형이 있습니다:
-
멀티블록 캐리어(Multiblock Carrier): 이는 여러 개의 더 작은 블록을 보유하며, 빈번하고 작은 할당에 적합합니다. 기본적으로 할당자(allocator)는 일반적으로 약 8MB 단위(시스템 플래그로 조정 가능)로 멀티블록 캐리어를 요청하여 일반적인 Erlang 메모리 패턴을 효율적으로 처리합니다.
-
싱글블록 캐리어(Singleblock Carrier): 정확히 하나의 큰 블록에 전용으로 할당됩니다. 싱글블록 캐리어 임계값을 초과하는 할당은 OS에서 직접 할당된 싱글블록 캐리어에 배치됩니다.
캐리어 임계값(sbct)과 그 영향
싱글블록 캐리어 임계값(sbct)으로 알려진 매개변수는 "작은" 할당과 "큰" 할당으로 간주되는 크기의 경계를 결정합니다. sbct 값보다 큰 할당은 싱글블록 캐리어를 사용하고, 더 작은 할당은 멀티블록 캐리어를 사용합니다.
기본적으로, sbct 임계값은 일반적인 Erlang 텀보다 훨씬 큰 대규모 할당이 단일 블록 캐리어에 격리되도록 설정됩니다. 대부분의 할당을 차지하는 작은 객체들은 멀티블록 캐리어 내에서 효율적으로 공간을 공유합니다.
이 임계값(sbct)은 애플리케이션이 비정상적인 할당 패턴을 보이는 경우 조정할 수 있습니다. 이 값을 조정하면 런타임 시스템이 단편화(멀티블록 캐리어에서의 대규모 할당으로 인한)와 오버헤드(다수의 싱글블록 캐리어로 인한) 사이에서 균형을 맞추는 방식에 영향을 미칩니다.
대부분의 할당이 작은 편이 바람직한 이유 (멀티블록 캐리어)
멀티블록 캐리어는 전형적인 Erlang 워크로드—수많은 작고 수명이 짧은 할당—에서 효율적이기 때문에 선호됩니다. HiPE 팀의 연구에 따르면 대부분의 Erlang 텀은 작아서(8워드 미만) 이러한 멀티블록 캐리어에 깔끔하게 들어맞는 것으로 나타났습니다.
일반적인 ERTS 할당자는 보통 다음을 유지 관리합니다:
-
할당자당 하나의 주 멀티블록 캐리어(main multiblock carrier)를 사용하여 빈번하게 발생하는 소규모 할당을 처리합니다. 이 캐리어는 거의 해제되지 않으며, 해제된 블록을 내부적으로 재사용하여 단편화를 줄입니다.
-
여러 개의 싱글블록 캐리어(singleblock carrier), 각각 대형 객체나 바이너리를 위해 개별적으로 할당됩니다. 이 블록들이 해제되면, 싱글블록 캐리어는 즉시 OS로 반환됩니다.
메모리 레이아웃: 캐리어와 블록 (시각화)
시각적으로 명확히 하면:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
hend -> +----+
|....|
stop -> | |
| | +----+ old_hend
| | | |
htop -> | | | |
|....| | | old_htop
|....| |....|
heap -> +----+ +----+ old_heap
The Heap Old Heap
<High Memory Addresses>
+-------------------------------+ OS allocated
| Singleblock Carrier (> sbct) |
+-------------------------------+
| |
| Large Allocation (1 block) |
| |
|-------------------------------|
| Unused or padding |
|-------------------------------|
| Multiblock |
| Carrier (main) |
| Block 1 | Block 2 | ... |
+-------------------------------+
| |
| Allocated Heap |
|-------------------------------| <- start_brk (OS allocation boundary)
| C-Heap |
+-------------------------------+
(low addresses)
sbct 조정 시점
싱글블록 캐리어 임계값(sbct) 파라미터를 최적화하는 것은 애플리케이션의 메모리 할당 패턴을 이해하는 문제입니다. sbct를 증가시키면 더 많은 할당이 멀티블록 캐리어로 유도되어 메모리 재사용이 개선되고 단편화가 줄어듭니다. 이는 애플리케이션이 중간 크기의 데이터 구조를 빈번하게 할당하여 단편화나 잦은 OS 수준의 메모리 요청을 유발하는 경우에 특히 유용합니다.
하지만 sbct를 줄이면 더 많은 할당이 싱글블록 캐리어로 강제되는데, 이는 애플리케이션이 가끔 큰 메모리 블록을 할당할 때 의미가 있습니다. 이러한 대규모 할당을 별도로 관리하면 회수가 간소화되고 더 작은 할당과의 간섭을 방지할 수 있습니다.
sbct만 조정해도 빈번한 마이너 가비지 컬렉션 문제가 해결되지 않는다면—흔히 수많은 단명 할당 때문인데—프로세스의 초기 힙 크기(min_heap_size)를 늘려 할당 처리 부담을 줄이는 것을 고려하십시오.
실제로 기본 sbct 설정은 대부분의 Erlang 애플리케이션에 적합합니다. 프로파일링에서 단편화, 메모리 오버헤드 또는 비정상적인 할당 패턴과 관련된 특정 문제가 나타나는 경우에만 이 매개변수를 미세 조정하십시오.
메모리 할당 전략
멀티 블록 캐리어에서 빈 메모리 블록을 찾기 위해 할당 전략이 사용됩니다. 각 할당자 유형에는 기본 할당 전략이 있지만, as 플래그를 사용하여 할당 전략을 직접 설정할 수도 있습니다.
Erlang 런타임 시스템 애플리케이션 참조 매뉴얼은 다음과 같은 할당 전략을 나열하고 있습니다:
최적 적합(Best fit): 요청된 블록 크기를 만족하는 가장 작은 블록을 찾는다. (bf)
주소 순서 최적 적합(Address order best fit): 요청된 블록 크기를 만족하는 가장 작은 블록을 찾는다. 여러 블록이 발견되면, 가장 낮은 주소를 가진 블록을 선택한다. (aobf)
주소 순서 최초 적합(Address order first fit): 요청된 블록 크기를 충족하는 가장 낮은 주소의 블록을 찾는다. (aoff)
주소 순서 최초 적합 캐리어 최적 적합(Address order first fit carrier best fit): 요청된 블록 크기를 충족할 수 있는 가장 낮은 주소의 캐리어를 찾은 다음, 해당 캐리어 내에서 "최적 적합(best fit)" 전략을 사용하여 블록을 찾는다. (aoffcbf)
주소 순서 첫 번째 적합 캐리어 주소 순서 최적 적합(Address order first fit carrier address order best fit): 요청된 블록 크기를 만족시킬 수 있는 가장 낮은 주소의 캐리어를 찾은 다음, 해당 캐리어 내에서 "주소 순서 최적 적합(address order best fit)" 전략을 사용하여 블록을 찾습니다. (aoffcaobf)
최적 적합(Good fit): 최적 적합을 찾으려 시도하되, 제한된 탐색 동안 발견된 최적 적합으로 타협한다. (gf)
적합(A fit): 적합을 찾기 위해 검색하지 않고, 요청을 만족시키는지 확인하기 위해 하나의 자유 블록만 검사합니다. 이 전략은 임시 할당에만 사용하도록 의도되었습니다. (af)
할당 전략의 선택은 여유 메모리가 얼마나 효율적으로 재사용되는지에 영향을 미치며, 단편화와 성능에 직접적인 영향을 미칩니다. 대부분의 할당자에 대한 기본 전략은 흔히 최적 적합(bf) 또는 주소 순서 최적 적합(aobf)이며, 이는 메모리 활용도와 할당 속도 사이의 균형을 맞춘다.
주소 순서 최초 적합(address order first fit) 또는 최적 적합(good fit) 같은 대안 전략은 할당자별로 구성할 수 있습니다(+M<S>as <strategy> 시스템 플래그 사용). 다른 전략을 선택하면 할당 탐색 시 더 높은 CPU 오버헤드를 감수하는 대신 메모리 단편화를 완화할 수 있습니다.
11.2.4. 임시 할당자: temp_alloc
할당자 temp_alloc은 임시 할당, 즉 매우 짧게 지속되는 할당에 사용됩니다. temp_alloc에 의해 할당된 메모리는 Erlang 프로세스 컨텍스트 전환을 넘어서 할당된 상태로 유지될 수 없습니다.
temp_alloc는 함수 내에서 어떤 작업을 수행하는 동안 작은 스크래치 또는 작업 영역으로 사용할 수 있습니다. 이것을 C 스택의 확장으로 간주하고, 같은 방식으로 해제하십시오. 즉, 안전을 위해 할당을 수행한 함수에서 반환하기 전에 temp_alloc로 할당된 메모리를 해제하십시오. erl_alloc.types에는 에뮬레이터가 Erlang 코드를 실행하기 시작하기 전에 temp_alloc 블록을 해제해야 한다는 주석이 있습니다.
할당자와 동일한 스케줄러에서 실행 중인 어떤 Erlang 프로세스도 해당 블록이 해제되기 전에 Erlang 코드를 실행할 수 없다는 점에 유의하십시오. 이는 BIF 또는 NIF 트랩(yield)에 걸쳐 임시 할당을 사용할 수 없다는 것을 의미합니다.
기본 R16 SMP 시스템에서는 N+1개의 temp_alloc 할당자가 있으며, 여기서 N은 스케줄러의 수입니다. temp_alloc은 "A fit" (af) 전략을 사용합니다. temp_alloc의 할당 패턴은 기본적으로 스택의 할당 패턴(대부분 크기가 0 또는 1)과 같으므로, 이 전략은 잘 동작합니다.
임시 할당자는 R16에서 다음 유형의 데이터에 사용됩니다: TMP_HEAP, MSG_ROOTS, ROOTSET, LOADER_TEMP, NC_TMP, TMP, DCTRL_BUF, TMP_DIST_BUF, ESTACK, DB_TMP, DB_MC_STK, DB_MS_CMPL_HEAP, LOGGER_DSBUF, TMP_DSBUF, DDLL_TMP_BUF, TEMP_TERM, SYS_READ_BUF, ENVIRONMENT, CON_VPRINT_BUF.
각 할당자로 할당되는 할당 유형의 최신 목록은 erl_alloc.types를 참조하십시오 (예: grep TEMPORARY erts/emulator/beam/erl_alloc.types).
이러한 각각의 다른 타입들을 하나하나 살펴보지는 않겠지만, 일반적으로 이름에서 짐작할 수 있듯이 이들은 임시 버퍼 또는 작업 스택입니다.
11.2.5. 힙 할당자: eheap_alloc
힙 할당자는 프로세스의 개인 데이터를 위한 메모리 블록을 관리합니다: 프로세스 힙에 저장된 Erlang 텀(신세대 및 구세대), 그리고 힙 프래그먼트와 BEAM 레지스터 같은 관련 구조체가 이에 해당합니다. 프로세스가 생성하는 거의 모든 Erlang 텀은 eheap_alloc을 통해 얻은 메모리에 저장됩니다.
기본적으로 각 스케줄러는 하나의 eheap_alloc 인스턴스를 가지므로, 해당 스케줄러에서 실행되는 프로세스들의 메모리는 대부분 로컬에 유지되어 경합을 줄입니다. 할당자의 빈번한 작업에는 다음이 포함됩니다:
-
프로세스 힙: 각 Erlang 프로세스는 튜플, 리스트, 맵, 정수, 그리고 작은 바이너리(⇐ 64바이트)와 같은 데이터를 저장하는 자체 힙을 가지고 있습니다.
-
힙 프래그먼트(Heap Fragments): 프로세스가 잠깐 더 많은 메모리를 필요로 하지만 즉시 GC를 수행할 수 없는 경우(예: 큰 메시지를 구성하는 경우), VM은 eheap_alloc으로부터 "힙 프래그먼트(heap fragment)"를 할당할 수 있습니다. 다음 가비지 컬렉션 시, 이 프래그먼트들은 병합되거나 해제됩니다.
-
레지스터 배열(Register Arrays): 일부 런타임 구현 세부 사항(예: "beam_registers" 데이터 구조)도 eheap_alloc을 사용합니다. Erlang 개발자로서, 여러분은 일반적으로 프로세스 힙 크기를 제어하거나 여러분의 프로세스가 크고 수명이 짧은 데이터를 언제 생성하는지 이해함으로써 eheap_alloc 사용을 최적화합니다. 좋은 관행에는 많은 마이너 GC가 발생하도록 방치하기보다, 프로세스가 대용량 데이터를 빈번하게 처리하는 경우 프로세스 힙 크기를 신중하게 설정하는 것이 포함됩니다.
11.2.6. 바이너리 할당자: binary_alloc
바이너리 할당자는 (네, 짐작하셨겠지만) 바이너리를 위한 메모리를 처리합니다. 구체적으로, 64바이트보다 큰 바이너리(refc 바이너리라 불림)를 관리하며, 이를 힙 외부에 저장하고 참조 카운팅을 사용하여 사용 현황을 추적합니다. 각 프로세스 힙은 실제 바이너리를 가리키는 작은 래퍼(ProcBin)만을 보유합니다.
이러한 바이너리는 적당한 크기의 바이너리(수백 바이트)부터 메가바이트 단위의 거대한 바이너리(전체 파일 내용이나 네트워크로부터의 외부 데이터 등)까지 매우 다양합니다.
이 할당자는 몇 가지 흥미로운 특징을 가지고 있습니다:
-
최적 적합 전략(Best-Fit Strategy): 새로운 바이너리를 저장하기 위해 적합한 것 중 가장 작은 빈 블록을 선택하며, 이는 단편화를 줄여줍니다. 마치 꼼꼼한 테트리스 플레이어가 바이너리를 메모리에 빈틈없이 끼워 맞추는 것과 같습니다.
-
Reference Counting: 오프힙(off-heap) 바이너리는 마지막 프로세스가 참조를 멈출 때까지 그대로 남아 있습니다. 단 하나의 프로세스라도 거대한 바이너리를 잊지 않고 계속 참조하고 있다면, 그 바이너리는 계속 살아남으며 때로는 원치 않을 만큼 오래 남아 있기도 합니다.
-
서브 바이너리(슬라이스):
<<X:32, Rest/binary>>와 같은 형태로 바이너리를 매칭하면, 더 작은 바이너리(Rest)는 여전히 원본의 더 큰 바이너리를 참조합니다. 복사를 피할 수 있다는 것은 좋지만, 아주 작은 부분을 참조하기 위해 실수로 거대한 바이너리를 계속 살려두고 있었다는 사실을 깨닫기 전까지만 그렇습니다. 이런 경우에는binary:copy/1을 호출하는 것이 도움이 됩니다.
메모리가 원인 불명으로 증가하면, 프로세스에 남아 있는 참조로 인해 해제되지 않는 큰 바이너리가 있는지 확인하세요. 경우에 따라 가비지 컬렉터를 부드럽게 자극하거나(erlang:garbage_collect/1 사용) 프로세스를 하이버네이트 상태로 전환하여 메모리를 더 빠르게 회수해야 할 수도 있습니다.
11.2.7. ETS 할당자: ets_alloc
할당자 ets_alloc은 여러분이 사랑하는 ETS(Erlang 텀 저장소) 테이블의 메모리를 관리합니다. 기본적으로 ETS 테이블은 개별 프로세스에 묶여 있지 않으므로, 프로세스가 가비지 컬렉션을 수행하더라도 ETS 테이블은 그대로 남아 있습니다. 몇 가지 주요 사항을 기억해 두세요:
-
장수하는 데이터: 데이터를 ETS 테이블에 넣는 순간, 그 데이터는 프로세스 힙을 벗어나 자체적인 아늑한 할당자에 자리 잡습니다. 표준 프로세스 가비지 컬렉션은 이 공간을 정리하지 않습니다. 메모리를 회수하려면, 데이터를 명시적으로 제거하거나 테이블을 삭제해야 합니다.
-
다양한 사용 사례: 이 할당자는 클래식 해시 테이블부터 화려한
ordered_set구조 및 내부 메타데이터에 이르기까지 ETS와 관련된 모든 것을 다룹니다. 인기 있거나 큰 테이블은 쉽게 메모리를 많이 소비하는 괴물이 될 수 있으니 주의하세요. -
단기 ETS 데이터: 때때로 ETS는 매칭이나 중간 결과와 같은 빠른 작업을 위해 다른 할당자를 일시적으로 빌릴 수 있지만, 주 데이터는
ets_alloc에 있습니다.
ETS 테이블은 예상보다 크게 늘어날 수 있으므로, 가끔씩 ets:info(Tab, memory)와 같은 함수로 크기를 확인하거나 시스템 메트릭을 통해 전체 ets_alloc 사용량을 주시하세요.
11.2.8. 드라이버 할당자: driver_alloc
드라이버 할당자는 포트, 링크드인 드라이버, 그리고 NIF 리소스를 위한 메모리를 관리합니다. 더 간단한 텀으로 말하자면, Erlang의 안전망을 넘어서—I/O 드라이버, 외부 라이브러리 또는 파일 디스크립터와 상호작용하는—경우 여기에 도달하게 됩니다. 유의해야 할 주요 사항:
-
포트 & 드라이버 데이터: 여기서의 할당에는 네트워크 소켓, 열린 파일 디스크립터를 위한 구조체와 링크드인 드라이버에 특화된 버퍼가 포함됩니다.
-
NIF-Allocated Data: NIF가
enif_alloc으로 접근하면, 메모리는 결국 이 할당자에서 옵니다. VM은 이 공간을 회수하기 위해, 여러분이enif_free를 호출하거나 NIF 객체가 우아하게 무대에서 퇴장할 때까지 정중히 기다립니다. -
외부 누수 가능성: NIF와 드라이버는 Erlang의 일반적인 메모리 안전 규칙을 우회하기 때문에, 오작동하는 드라이버가 의도치 않게 메모리를 계속 붙잡고 있을 수 있으며, 이는 디지털 세계의 물이 새는 수도꼭지와 같은 상황을 만들어냅니다.
driver_alloc사용량을 주시하면 이러한 누수를 잡아내는 데 도움이 됩니다.
개발자가 driver_alloc을 직접 조작하는 경우는 드물지만, 특히 NIF를 사용하는 경우라면 프로덕션 환경에서 이 할당자를 모니터링하는 것이 현명합니다.
11.2.9. 단기 할당자: sl_alloc
단기 할당자(sl_alloc)는 임시 할당의 눈 깜짝할 사이에 사라지는 특성보다는 약간 더 오래 지속되지만, 그렇다고 훨씬 더 오래 지속되지는 않는 수명을 가진 데이터 구조를 위한 메모리를 처리합니다. 인사를 나누고 커피 한 잔 빠르게 마신 뒤 바로 떠날 정도로만 잠깐 머무르는 메모리라고 생각하면 됩니다. 일반적인 예로는 다음이 있습니다:
-
Intermediate Buffers: 짧은 연산을 위해 필요한 작은 버퍼로, 스케줄링 지점을 넘어 잠시 머무르기는 하지만 하룻밤을 넘길 생각은 절대 없다.
-
Ephemeral Lists: 임시 런타임 구조—예를 들어 빠르게 처리되는 시스템 메시지 버퍼나 수명이 짧은 스케줄링 메타데이터—로, 발견하자마자 거의 즉시 사라집니다.
Erlang/OTP는 매치 상태 객체, 임시 I/O 버퍼, 그리고 기타 일시적인 개체와 같은 짧은 수명의 작업을 위해 sl_alloc을 활용합니다. 이러한 할당은 진정한 의미의 임시 메모리보다 오래 존속할 수 있지만, 여전히 신속하게 해제되어 최소한의 흔적만 남기는 것으로 기대됩니다.
애플리케이션이 특히 수다스러워—짧게 지속되는 할당(예: 빈번한 소규모 드라이버 호출이나 짧은 연산 등)을 다수 생성한다면—sl_alloc이 크게 과열될 수 있습니다. recon_alloc:usage() 같은 도구로 사용량을 확인하면 sl_alloc이 과도하게 사용되고 있는지 알 수 있습니다.
11.2.10. 장기 존속 할당자: ll_alloc
장기 생존 할당자는 오랜 기간 동안, 종종 Erlang 노드 자체만큼 오래 살아 있도록 의도된 데이터를 처리합니다. 대표적인 예로는 다음과 같은 것들이 있습니다:
-
아톰: 한 번 생성된 아톰은 무기한으로 유지되며,
ll_alloc의 영구 거주자가 됩니다. -
로드된 모듈과 코드: 컴파일된 모듈, 익스포트된 함수, 그리고 익명 함수(
funs)와 관련된 메타데이터가 이곳에 저장됩니다. 예를 들어,code:load_file(my_module)을 통해 모듈을 로드하면, 해당 메타데이터는ll_alloc에 저장됩니다. -
스케줄러 및 시스템 구조: 스케줄러 실행 큐(
run_queue), pollset 정보(pollset), 프로세스 레지스트리(proc_tab)와 같은 내부 런타임 구조는 가상 머신 동작의 근간이 되기 때문에 여기에 위치합니다.
ll_alloc에 있는 객체는 오래 유지되는 경향이 있으므로, 이 할당자는 일반적으로 노드의 수명 동안 느리지만 지속적으로 증가합니다. 시스템이 모듈을 자주 로드하고 언로드하는 경우, 변동이 나타날 수 있습니다. 예를 들어, 이전 버전을 제대로 언로드하지 않고 핫 코드 로딩을 반복적으로 실행하면 ll_alloc의 메모리 사용량이 점진적으로 증가할 수 있습니다.
실제로, 대부분의 시스템은 명시적으로 모듈을 언로드하거나 노드를 재시작하지 않는 한 여기서 많은 메모리를 회수하지 못합니다. 따라서 예기치 않은 급증이나 단편화를 감지하기 위해 가끔씩 recon_alloc:usage(ll_alloc)을 확인하는 것이 현명합니다. 특히 애플리케이션이 동적으로 모듈을 관리하거나 방대한 장기 데이터를 다루는 경우라면 더욱 그렇습니다.
11.2.11. 고정 크기 할당자: fix_alloc
고정 할당자인 fix_alloc은 크기가 변하지 않는 작은 C 구조체(예: 메시지 참조, 드라이버 이벤트 데이터, 모니터)와 같은 고정 크기 객체를 할당하는 데 특화되어 있습니다. 이러한 객체들은 균일한 크기를 가지므로, 할당자는 이를 효율적으로 처리할 수 있습니다.
기본적으로 fix_alloc은 "주소 순서 최적 적합(Address Order Best Fit)"을 사용하여, 해제된 객체를 동일한 크기의 블록 리스트에 깔끔하게 반환함으로써 단편화를 효과적으로 최소화합니다. 이는 다양한 크기의 조각들을 상자에 무작위로 던져 넣는 것이 아니라, 동일한 레고 브릭을 완벽하게 스택처럼 쌓는 것과 같다고 생각하면 됩니다.
예를 들어 ErlMessage, 모니터 참조, 스케줄러 부기 데이터와 같은 내부 VM 구조체가 있습니다.
개발자가 보통 fix_alloc과 직접 상호작용하는 일은 없지만, 시스템 수준에서는 필수적입니다. 여기서 효율적인 할당은 런타임이 작고 단편화된 할당을 관리하느라 느려지지 않도록 보장하며, Erlang이 동시성을 우아하게 처리한다는 명성을 유지하는 데 기여합니다.
11.2.12. 표준 할당자: std_alloc
메모리가 Erlang의 특수 할당자 버킷에 깔끔하게 들어맞지 않으면, 런타임의 다재다능한 포괄 할당자인 std_alloc에 자리를 잡는다. 이를 Erlang의 메모리용 "잡동사니 서랍"이라고 생각하면 됩니다.
이 할당자는 다양한 종류의 할당을 처리합니다. 여기에는 수명이 짧은지 긴지 모호한 임시 데이터에 대한 참조, 동적 크기 구조체, 명확한 분류가 없는 VM 하위 시스템 데이터, 또는 단순히 다른 곳에 깔끔하게 분류하기에는 너무 고유한 메모리 할당이 포함될 수 있습니다.
다른 할당자들과 마찬가지로, +Ms나 +Msbct와 같은 시작 플래그로 std_alloc의 동작을 조정할 수 있습니다. 보통은 그대로 두지만, 필요할 때 조정할 수 있다는 것을 알아 두면 좋습니다.
문제 해결 시, std_alloc은 설명할 수 없는 메모리 급증의 주요 용의자가 될 수 있습니다. erlang:system_info({allocator, std_alloc})나 recon_alloc 같은 도구를 사용하면 예상보다 더 많은 메모리를 차지하고 있는지 빠르게 확인할 수 있습니다.
std_alloc은 깔끔하게 분류되지 않는 메모리 요청을 모으기 때문에, 바쁜 노드에서 상당한 사용량이 누적되는 것이 정상입니다. 이 할당자가 지속적으로 증가하는 것이 관찰된다면, 이는 보통 애플리케이션 동작을 재점검하거나 "정상"이 무엇인지에 대한 가정을 다시 살펴봐야 한다는 신호입니다.
11.2.13. 리터럴 할당자: literal_alloc
literal_alloc은 로드된 Erlang 모듈 내에서 흔히 리터럴 풀이라고 불리는 컴파일 타임 상수를 저장합니다. 이것은 가상 머신의 "읽기 전용 메모리 선반"으로 생각할 수 있으며, Erlang이 컴파일 타임에 정의된 큰 정적 바이너리, 튜플, 또는 리스트와 같은 상수를 안전하게 저장하는 곳입니다.
일반적인 할당자와 달리, literal_alloc은 리터럴이 런타임 중에 자주 수정되거나 회수되지 않기 때문에 스케줄러별이 아니라 전역적으로 관리됩니다. 한번 로드되면, 이러한 리터럴은 해당 모듈이 명시적으로 퍼지되거나 리로드될 때까지 유지된다.
literal_alloc 모니터링은 보통 특별한 일이 없지만, 빈번한 동적 모듈 업데이트나 리터럴이 많은 대형 모듈을 반복적으로 로딩하는 경우에는 한번 살펴볼 가치가 있을 수 있습니다.
11.3. 스케줄러별 할당자 인스턴스
현대 OTP(R13B 이후)에서는 위에서 설명한 대부분의 메모리 할당자(예: eheap_alloc, binary_alloc, ets_alloc)가 SMP(대칭 다중 처리) 환경에서 경합을 최소화하기 위해 여러 인스턴스를 가집니다. 기본적으로 각 스케줄러 스레드는 이러한 할당자의 자체 인스턴스를 유지하며, 드라이버 비동기 스레드가 공유하는 추가 인스턴스가 하나 더 있습니다.
이 스케줄러별 할당 방식은 메모리 관리를 분할하여 락 경합을 크게 줄이고 멀티코어 시스템에서의 확장성을 향상시킵니다. 또한 메모리 사용량과 단편화가 각 스케줄러별로 독립적으로 처리된다는 것을 의미하며, 이는 메모리 통계를 해석하거나 메모리 관련 문제를 해결하는 방식에 영향을 줄 수 있습니다.
할당자(allocator) 메트릭을 살펴볼 때(예: recon_alloc이나 erlang:system_info를 통해), 여러 할당자 인스턴스에 걸쳐 집계된 통계를 보게 됩니다. 메모리 패턴을 분석할 때 이 점을 유의하십시오.
11.4. 메모리 관련 시스템 플래그
메모리 할당자 시스템 플래그는 다음 문법을 따릅니다:
+M<S><P> <V>
-
<S>는 할당자를 식별하는 대문자 한 글자입니다. -
<P>는 파라미터를 지정합니다. -
<V>는 사용할 값을 지정합니다.
할당자 식별자(<S>):
-
B: binary_alloc (바이너리) -
D: std_alloc -
E: ets_alloc (ETS) -
F: fix_alloc -
H: eheap_alloc -
I: literal_alloc -
L: ll_alloc -
M: mseg_alloc -
R: driver_alloc -
S: sl_alloc -
T: temp_alloc -
Y: sys_alloc -
u: alloc_util (모든 alloc_util 기반 할당자에 영향을 미침)
11.5. 자주 사용되는 플래그
할당 전략 (as):
캐리어 내에서 메모리 블록을 선택하는 방식을 결정합니다.
-
bf: 최적 적합(Best fit) -
aobf: 주소 순서 최적 적합(Address order best fit) -
aoff: 주소 순서 최초 적합 (Address order first fit) -
aoffcbf: 주소 순서 우선 적합 캐리어 최적 적합 -
ageffcbf: 나이 순서 우선 적합 캐리어 최적 적합 -
gf: 좋은 적합 (Good fit) -
af: 적합(fit)
예시:
+MBas bf
(바이너리 할당자는 최적 적합(best-fit) 전략을 사용합니다.)
싱글블록 캐리어 임계값(sbct): KB 단위로 이 임계값을 초과하는 할당이 싱글블록 캐리어를 사용하도록 정의합니다.
예시:
+MBsbct 1024
(1024KB를 초과하는 바이너리 할당은 싱글블록 캐리어를 사용합니다.)
멀티블록 캐리어 설정:
-
최소(
smbcs) 및 최대(lmbcs) 멀티블록 캐리어 크기: 멀티블록 캐리어의 최소/최대 크기(KB)를 제어합니다. -
캐리어 성장 단계 (
mbcgs): 최소값과 최대값 사이의 캐리어 크기 성장을 정의합니다.
예:
+MBsmbcs 512 +MBlmbcs 8192
(바이너리 할당자 멀티블록 캐리어는 512 KB에서 8 MB까지 범위를 가집니다.)
캐리어 사용률 포기 임계값 (acul): 캐리어가 포기되어 재사용되는 기준이 되는 백분율 임계값입니다.
예제:
+MBacul 50
(사용률이 50% 미만인 바이너리 캐리어는 abandoned로 표시됩니다.)
캐리어 해제 여유 활용률 한계(acful, Abandon Carrier Free Utilization Limit): 이 활용률 미만이면 VM은 사용되지 않는 메모리를 회수할 수 있다고 OS에 알립니다.
예시:
+MDacful 10
(std_alloc은 사용률이 10% 미만이면 메모리를 OS가 회수 가능한 것으로 표시합니다.)
다중 스레드별 인스턴스(t): 할당자가 다중 인스턴스(스케줄러당 하나씩)를 사용할지를 제어합니다.
예:
+MHt true
(eheap_alloc은 스케줄러 스레드마다 별도의 할당자 인스턴스를 사용합니다.)
할당 태깅 (atags): 할당에 태그를 추가하며, 계측(instrumentation)을 통한 디버깅에 유용합니다.
예시:
+MRatags true
(driver_alloc에 대한 태깅 활성화.)
11.6. mseg_alloc에 대한 특수 플래그
mseg_alloc(메모리 세그먼트 할당자)는 특정 설정을 가지고 있습니다:
-
슈퍼 캐리어 크기 (
scs):` +MMscs 1024` (1GB 슈퍼 캐리어를 생성합니다.) -
큰 페이지 사용 (
lp):` +MMlp on` (대용량/초대형 페이지 지원을 활성화합니다.) -
슈퍼 캐리어 전용 (
sco):` +MMsco true` (할당은 슈퍼 캐리어 내에서만 발생합니다.) -
최대 캐시된 세그먼트(
mcs):` +MMmcs 20` (최대 20개의 캐시된 세그먼트를 저장합니다.)
11.7. sys_alloc을 위한 특수 플래그
sys_alloc는 시스템의 malloc과 인터페이스합니다:
-
Trim Threshold (
tt): 여유 힙이 임계값을 초과하면 OS로 메모리를 반환합니다.` +MYtt 256` (트림 임계값을 256 KB로 설정합니다.) -
탑 패드(
tp): 이후 호출을 줄이기 위해 OS에 추가로 요청하는 메모리.` +MYtp 512` (malloc은 512 KB 패딩을 요청합니다.)
11.8. 리터럴 할당자(literal_alloc)
리터럴(컴파일 타임 상수)을 저장합니다:
-
리터럴 슈퍼 캐리어 크기(
scs):` +MIscs 2048` (리터럴 할당자 슈퍼 캐리어를 2GB로 설정한다.)
11.9. 전역 및 편의 플래그
-
최소/최대 할당 설정 (
ea):` +Mea min|max|config` (모든 할당자에 대한 빠른 설정 프리셋.) -
물리 메모리 잠금(
lpm):` +Mlpm all|no` (VM 메모리를 물리 RAM에 고정합니다.) -
더티 할당자 인스턴스(
dai):` +Mdai max|<number>` (더티 스케줄러 전용 할당자 인스턴스.)
11.10. 실용적인 예제
모든 할당자에서 주소 순서 최적 적합(address-order best fit)으로 단편화 줄이기:
+Muas aobf
ETS 할당자의 최대 메모리 제한(예: 2 GB):
+MEamax 2097152
모든 할당자(allocator)에서 디버깅을 위한 할당 태깅(allocation tagging) 활성화하기:
+Muatags true
11.11. 권장 사항
-
문제가 발생하지 않는 한 기본 설정으로 시작하십시오.
-
튜닝 전에 할당자 사용량(
erlang:system_info/1,recon_alloc)을 모니터링하십시오. -
변경 사항을 통제된 환경에서 점진적으로 테스트합니다.
-
벤치마크와 프로파일링 없이 공격적인 튜닝을 피하십시오.
|
위에서 설명한 대부분의 메모리 할당자 플래그는 구현에 크게 의존합니다. 이들의 동작, 가용성 및 기본값은 사전 통지 없이 변경되거나 완전히 제거될 수 있습니다. 또한, ERTS 런타임 시스템( |
11.12. 프로세스 메모리
Chapter 3에서 살펴본 것처럼, 프로세스는 실제로 여러 메모리 영역에 불과하며, 이 장에서는 스택, 힙, 그리고 메일박스가 어떻게 관리되는지 좀 더 자세히 살펴볼 것입니다.
스택과 힙의 기본 크기는 233 워드입니다. 이 기본 크기는 Erlang을 시작할 때 +h 플래그를 통해 전역적으로 변경할 수 있습니다. spawn_opt으로 프로세스를 시작할 때 min_heap_size를 설정하여 최소 힙 크기를 설정할 수도 있습니다.
Erlang 텀은 Chapter 4에서 살펴본 것처럼 태그가 지정되어 있으며, 힙에 저장될 때 cons 셀이거나 박스드 객체입니다.
11.12.1. 텀 공유
힙 위의 객체는 하나의 프로세스 컨텍스트 내에서 참조로 전달됩니다. 튜플을 인자로 하여 함수를 호출하면, 호출된 함수에는 해당 튜플에 대한 태그가 붙은 참조만 전달됩니다. 새로운 텀을 구성할 때도 하위 텀에 대한 참조만 사용하게 됩니다.
예를 들어 문자열 "hello"(정수 리스트 [104,101,108,108,111]과 동일합니다)가 있다면, 다음과 유사한 메모리 레이아웃을 얻게 됩니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
ADDR BINARY VALUE + TAG
hend -> +-------- -------- -------- --------+
| |
| ... |
| |
|00000000 00000000 00000000 10000001| 128 + list tag ----------------+
| | |
stop -> | ... | |
|
|
htop -> | ... | |
| | |
132 |00000000 00000000 00000000 01111001| 120 + list tag --------------- | -+
| | | |
128 |00000000 00000000 00000110 10001111| 'h' 104 bsl 4 + small int tag <-+ |
| | |
124 |00000000 00000000 00000000 01110001| 112 + list tag ------------------ | -+
| | | |
120 |00000000 00000000 00000110 01011111| 'e' 101 bsl 4 + small int tag <----+ |
| | |
116 |00000000 00000000 00000000 01101001| 104 + list tag --------------------- | -+
| | | |
112 |00000000 00000000 00000110 11001111| 'l' 108 bsl 4 + small int tag <-------+ |
| | |
108 |00000000 00000000 00000000 01100001| 96 + list tag ------------------------ | -+
| | | |
104 |00000000 00000000 00000110 11001111| 'l' 108 bsl 4 + small int tag <----------+ |
| | |
100 |11111111 11111111 11111111 11111011| NIL |
| | |
96 |00000000 00000000 00000110 11111111| 'o' 111 bsl 4 + small int tag <-------------+
| |
| ... |
| |
heap -> +-----------------------------------+
그런 다음 해당 리스트의 인스턴스 두 개로 튜플을 생성하면, 반복되는 것은 리스트에 대한 태그가 붙은 포인터뿐입니다: 00000000000000000000000010000001. 코드
L = [104, 101, 108, 108, 111],
T = {L, L}.
아래와 같은 메모리 레이아웃이 생성되며, T는 주소 136에 있는 박스 객체를 가리키고, 거기에서 크기 2인 튜플임을 나타내는 ARITYVAL 헤더와 그 뒤에 두 개의 요소를 찾을 수 있으며, 두 요소 모두 주소 128에 있는 동일한 리스트 L을 가리킵니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
ADDR BINARY VALUE + TAG
| ... |
| |
|00000000 00000000 00000000 10001010| 136 + boxed tag ---+
| | |
stop -> | ... | |
|
|
htop -> | ... | |
| | |
144 |00000000 00000000 00000000 10000001| 128 + list tag --- | ----------+
| | | |
140 |00000000 00000000 00000000 10000001| 128 + list tag --- | ----------+
| | | |
136 |00000000 00000000 00000000 10000000| 2 + ARITYVAL <--+ |
| | |
132 |00000000 00000000 00000000 01111001| 120 + list tag --------------- | -+
| | | |
128 |00000000 00000000 00000110 10001111| 'h' 104 bsl 4 + small int tag <-+ |
| | |
| ... | :
이는 비용이 적게 들고 공간도 매우 적게 사용하므로 좋습니다. 하지만 튜플을 다른 프로세스로 보내거나 다른 종류의 IO를 수행하거나, 깊은 복사라고 불리는 결과를 낳는 다른 연산을 수행하면 데이터 구조가 확장됩니다. 따라서 튜플 T를 다른 프로세스 P2로 (P2 ! T) T2의 힙에는 첫 번째 요소가 문자열의 한 복사본을 가리키고 두 번째 요소가 다른 복사본을 가리키는 튜플이 생기며, 사용되는 공간이 두 배가 됩니다. 이에 대한 결과는 아래의 메시지 패싱에 관한 절에서 확인할 수 있습니다.
중첩된 공유 튜플이 있는 경우, 깊은 복사 시 이러한 중복은 중첩 수준에 따라 기하급수적으로 증가합니다. 높은 수준으로 공유된 텀을 확장하면 Erlang 노드를 빠르게 다운시킬 수 있습니다. 전체 코드 예제는 온라인 부록의 share.erl을 참조하세요.
-module(share).
-export([share/2, size/0]).
share(0, Y) -> {Y,Y};
share(N, Y) -> [share(N-1, [N|Y]) || _ <- Y].
size() ->
T = share:share(5,[a,b,c]),
{{size, erts_debug:size(T)},
{flat_size, erts_debug:flat_size(T)}}.
1> timer:tc(fun() -> share:share(10,[a,b,c]), ok end).
{1131,ok}
2> share:share(10,[a,b,c]), ok.
ok
3> byte_size(term_to_binary(share:share(10,[a,b,c]))), ok.
HUGE size (13695500364)
Abort trap: 6
공유된 텀의 메모리 크기와 텀의 확장된 크기는 erts_debug:size/1 및 erts_debug:flat_size/1 함수를 사용하여 계산할 수 있습니다.
> share:size().
{{size,19386},{flat_size,94110}}
대부분의 애플리케이션에서는 이것이 문제가 되지 않지만, 여러 상황에서 발생할 수 있는 이 문제를 인지하고 있어야 합니다. 깊은 복사는 IO, ETS 테이블, 바이너리를 텀으로 변환하는 binary_to_term, 그리고 메시지 패싱에 사용됩니다.
메시지 패싱이 어떻게 작동하는지 더 자세히 살펴보겠습니다.
11.12.2. 메시지 패싱
프로세스 P1이 다른 (로컬) 프로세스 P2에게 메시지 M을 보낼 때, 프로세스 P1은 먼저 M의 플랫 크기를 계산합니다. 그런 다음 로컬 스케줄러 컨텍스트에서 heap_frag의 heap_alloc을 수행하여 해당 크기의 새로운 메시지 버퍼를 힙에 할당합니다.
send.erl의 코드(온라인 부록 참조)가 주어졌을 때, p1/1에서 전송 직전의 시스템 상태는 다음과 같을 수 있습니다:
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
REG
| |
x0 |00000000 00000000 00000000 00100011| Pid 2
| |
x1 |00000000 00000000 00000000 10001010| 136 + boxed tag ------+
| | |
|
|
ADDR BINARY VALUE + TAG |
htop -> | ... | |
| | |
144 |00000000 00000000 00000000 10000001| 128 + list tag ------ | -------+
| | | |
140 |00000000 00000000 00000000 10000001| 128 + list tag ------ | -------+
| | | |
136 |00000000 00000000 00000000 10000000| 2 + ARITYVAL <----+ |
| | |
132 |00000000 00000000 00000000 01111001| 120 + list tag --------------- | -+
| | | |
128 |00000000 00000000 00000110 10001111| 'h' 104 bsl 4 + small int tag <-+ |
| | |
124 |00000000 00000000 00000000 01110001| 112 + list tag ------------------ | -+
| | | |
120 |00000000 00000000 00000110 01011111| 'e' 101 bsl 4 + small int tag <----+ |
| | |
116 |00000000 00000000 00000000 01101001| 104 + list tag --------------------- | -+
| | | |
112 |00000000 00000000 00000110 11001111| 'l' 108 bsl 4 + small int tag <-------+ |
| | |
108 |00000000 00000000 00000000 01100001| 96 + list tag ------------------------ | -+
| | | |
104 |00000000 00000000 00000110 11001111| 'l' 108 bsl 4 + small int tag <----------+ |
| | |
100 |11111111 11111111 11111111 11111011| NIL |
| | |
96 |00000000 00000000 00000110 11111111| 'o' 111 bsl 4 + small int tag <-------------+
| |
| ... |
그런 다음 P1은 메시지 M을 P2에게 보내기 시작합니다. erl_message.c의 코드는 먼저 M의 플랫 크기(이 예제에서는 23 워드)를 계산한다[5]. 그런 다음 (SMP 시스템에서) P2에 대한 락을 획득할 수 있고 P2의 힙에 충분한 공간이 있으면, 메시지를 P2의 힙에 복사합니다.
P2가 실행 중이거나 (또는 종료 중이거나) 힙에 충분한 공간이 없는 경우, 새로운 힙 프래그먼트가 할당됩니다 (sizeof ErlHeapFragment - sizeof(Eterm) + 23*sizeof(Eterm) 크기로). [6] 초기화 후에는 다음과 같은 모습이 됩니다:
erl_heap_fragment:
ErlHeapFragment* next; NULL
ErlOffHeap off_heap:
erl_off_heap_header* first; NULL
Uint64 overhead; 0
unsigned alloc_size; 23
unsigned used_size; 23
Eterm mem[1]; ?
... 22 free words
그런 다음 메시지가 힙 프래그먼트의 mem 부분으로 복사되고, first 포인터가 업데이트됩니다(이 그림에서는 구조체 레이아웃과 일치하도록 메모리 주소가 아래쪽으로 증가한다는 점에 유의하세요):
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
erl_heap_fragment:
+--------------------+
| |
| ... |
| |
first -> | mem + BOXED| ----+
| ... | |
| | |
mem -> | 2+ARITYVAL| <--+
| |
+1w | 3w + mem + CONS| ---+
| | |
+2w | 13w + mem + CONS| --- | --+
| | | |
+3w |'H' bsl 4 + SMALLINT| <--+ |
| | |
+4w | 5w + mem + CONS| ---+ |
| | | |
+5w |'e' bsl 4 + SMALLINT| <--+ |
| | |
+6w | 7w + mem + CONS| ---+ |
| | | |
+7w |'l' bsl 4 + SMALLINT| <--+ |
| | |
+8w | 9w + mem + CONS| ---+ |
| | | |
+9w |'l' bsl 4 + SMALLINT| <--+ |
| | |
+10w | 11w + mem + CONS| ---+ |
| | | |
+11w |'o' bsl 4 + SMALLINT| <--+ |
| | |
+12w | NIL| |
| | |
+13w |'H' bsl 4 + SMALLINT| <------+
| |
+14w | 15w + mem + CONS| ---+
| | |
+15w |'e' bsl 4 + SMALLINT| <--+
| |
+16w | 17w + mem + CONS| ---+
| | |
+17w |'l' bsl 4 + SMALLINT| <--+
| |
+18w | 19w + mem + CONS| ---+
| | |
+19w |'l' bsl 4 + SMALLINT| <--+
| |
+20w | 21w + mem + CONS| ---+
| | |
+21w |'o' bsl 4 + SMALLINT| <--+
| |
+22w | NIL|
| |
+--------------------+
어느 경우든 새로운 mbox(ErlMessage)가 할당되고, 수신자에 대한 락(ERTS_PROC_LOCK_MSGQ)이 획득되며, 힙 또는 새로운 힙 프래그먼트에 있는 메시지가 mbox에 연결됩니다.
erl_mesg {
struct erl_mesg* next = NULL;
data: ErlHeapFragment *heap_frag = bp;
Eterm m[0] = message;
} ErlMessage;
그런 다음 mbox는 수신자의 수신 메시지 큐(msg_inq)에 연결되고, 락이 해제됩니다. msg_inq.last가 큐에 있는 마지막 메시지의 next 필드를 가리킨다는 점에 유의하십시오. 새로운 mbox가 연결되면, 이 next 포인터는 새로운 mbox를 가리키도록 갱신되고, last 포인터는 새로운 mbox의 next 필드를 가리키도록 갱신됩니다.
11.12.3. 바이너리
Chapter 4에서 살펴보았듯이, 내부적으로 네 가지 유형의 바이너리가 있습니다. 이 중 세 가지 유형—힙 바이너리, 서브 바이너리, 매치 컨텍스트—은 로컬 힙에 저장되며, 다른 객체와 마찬가지로 가비지 컬렉터와 메시지 패싱에 의해 처리되고 필요에 따라 복사됩니다.
참조 카운팅
반면 네 번째 유형인 대형 바이너리 또는 refc 바이너리는 프로세스 힙 외부에 부분적으로 저장되며, 참조 카운트가 적용됩니다.
refc 바이너리의 페이로드는 바이너리 할당자가 할당한 메모리에 저장됩니다. 또한 페이로드를 가리키는 ProcBin이라 불리는 작은 참조가 있으며, 이는 프로세스 힙에 저장됩니다. 이 참조는 메시지 패싱과 GC에 의해 복사되지만, 페이로드는 변경되지 않습니다. 이로 인해 큰 바이너리를 다른 프로세스에 보내는 것이 비교적 비용이 적게 드는데, 바이너리 전체를 복사할 필요가 없기 때문입니다.
ProcBin을 통한 refc 바이너리에 대한 모든 참조는 해당 바이너리의 참조 카운트를 1만큼 증가시킵니다. 프로세스 힙에 있는 모든 ProcBin 객체는 연결 리스트로 함께 연결되어 있습니다. GC 패스가 끝난 후 이 연결 리스트를 순회하면서, 소멸된 각 ProcBin에 대해 해당 바이너리의 참조 카운트를 1만큼 감소시킵니다. refc 바이너리의 참조 카운트가 0에 도달하면 해당 바이너리는 할당 해제됩니다.
대용량 바이너리를 참조 카운트 방식으로 관리하고 전송이나 가비지 컬렉션 시 복사하지 않는 것은 큰 이점이지만, 가비지 컬렉션과 참조 카운팅이 혼합된 환경에는 한 가지 문제가 있다. 순수한 참조 카운팅 구현에서는 객체에 대한 참조가 소멸되는 즉시 참조 카운트가 감소하며, 참조 카운트가 0에 도달하면 객체가 해제된다. ERTS 혼합 환경에서는 참조 카운트 객체에 대한 참조가 가비지 컬렉션이 해당 참조가 죽었음을 감지할 때까지 소멸되지 않는다.
이는 크거나 심지어 매우 큰 경향이 있는 바이너리가, 해당 바이너리에 대한 모든 참조가 죽은 후에도 오랫동안 남아 있을 수 있다는 것을 의미합니다. 바이너리는 전역적으로 할당되므로, 모든 프로세스로부터의 모든 참조가 죽어야 한다는 점에 유의하십시오. 즉, 바이너리를 본 적이 있는 모든 프로세스가 GC를 수행해야 합니다.
안타깝게도 개발자로서 어떤 프로세스가 GC 의미에서 바이너리를 "본" 것인지 파악하는 것이 항상 쉬운 일은 아닙니다. 예를 들어, 작업 항목을 수신하여 워커들에게 분배하는 로드 밸런서가 있다고 상상해 보십시오.
이 코드에는 GC를 수행할 필요가 없는 루프의 예제가 있습니다. (전체 예제는 온라인 부록을 참조하십시오.)
loop(Workers, N) ->
receive
WorkItem ->
Worker = lists:nth(N+1, Workers),
Worker ! WorkItem,
loop(Workers, (N+1) rem length(Workers))
end.
이 서버는 바이너리에 대한 참조를 계속 잡고 있기만 하고 절대 해제하지 않아, 결국 모든 시스템 메모리를 소진하게 됩니다.
문제를 인식하고 나면 수정하기 쉬운데, loop의 매 반복마다 garbage_collect를 수행하거나, receive에 after 절을 추가하여 약 5초마다 수행할 수 있다. (after 5000 → garbage_collect(), loop(Workers, N)).
서브 바이너리와 매칭
바이너리의 일부를 매칭하여 추출하면 서브 바이너리를 얻게 됩니다. 이 서브 바이너리는 실제 바이너리를 가리키는 포인터만을 포함하는 작은 구조체입니다. 이는 바이너리의 참조 카운트를 증가시키지만 추가 공간은 매우 적게 사용합니다.
매칭이 바이너리의 매칭된 부분에 대해 새로운 복사본을 생성한다면, 이는 공간과 시간 모두를 소모하게 될 것입니다. 따라서 대부분의 경우 바이너리에 대해 패턴 매칭을 수행하고 작업할 서브 바이너리를 얻는 것이 원하는 방식입니다.
몇 가지 퇴화된 경우가 있는데, 예를 들어 책과 같은 거대한 파일을 메모리에 로드한 다음 작업할 장(chapter)과 같은 작은 부분을 매칭하여 추출하는 경우를 상상해 보십시오. 이때 문제는 해당 장의 프로세스 처리가 완료될 때까지 책의 나머지 전체가 여전히 메모리에 유지된다는 것입니다. 많은 책에 대해 이 작업을 수행하는 경우, 예를 들어 파일 시스템에 있는 모든 책의 서론을 가져오고 싶다면, 서론 장만이 아니라 각 책의 전체가 메모리에 유지됩니다. 이는 막대한 메모리 사용으로 이어질 수 있습니다.
이 경우, 큰 바이너리의 작은 부분 하나만 필요하다는 것을 알고 있고 그 작은 부분을 한동안 유지하고 싶을 때의 해결책은 binary:copy/1을 사용하는 것입니다. 이 함수는 오직 부수 효과를 위해서만 사용되는데, 그 부수 효과란 실제로 서브 바이너리를 원래 바이너리에서 복사하여 더 큰 바이너리에 대한 참조를 제거하고, 이를 통해 해당 바이너리가 가비지 컬렉션될 수 있도록 하는 것입니다.
바이너리 구성과 매칭이 어떻게 수행되는지에 대한 상당히 자세한 설명이 Erlang 문서에 있습니다: http://www.erlang.org/doc/efficiency_guide/binaryhandling.html.
11.13. 기타 흥미로운 메모리 영역
11.13.1. 아톰 테이블.
Erlang에서 아톰은 내부적으로 정수로 표현되는 고유 식별자이다. 모든 아톰은 아톰 테이블이라고 알려진 전역 구조체에 저장된다. 아톰 테이블은 고정 크기 구조체로, 실행 중인 Erlang 시스템에 존재할 수 있는 아톰의 수에 상한이 있다(기본값은 1,048,576개). 이것이 큰 숫자처럼 들릴 수 있지만, 부주의한 사용(특히 외부 데이터로부터 아톰을 동적으로 생성하는 경우)은 아톰 고갈로 이어질 수 있으며, 이는 결국 BEAM VM 전체를 크래시시킵니다—한밤중에 예기치 않게 레고 블록을 밟는 것과 대략 비슷한 수준의 유쾌한 경험입니다.
아톰 테이블의 각 항목에는 아톰에 대한 메타데이터가 포함되어 있으며, 여기에는 문자열 표현(아톰 자체의 텍스트), 런타임 시스템에서 사용하는 고유한 내부 식별자, 그리고 참조 횟수 및 모듈이나 함수에서의 사용에 관한 세부 정보와 같은 추가 정보가 포함됩니다.
Erlang은 세 가지 핵심 메모리 할당자 유형을 통해 원자(atom)를 관리합니다:
-
atom_text: 아톰의 문자열 표현을 포함합니다. 이 영역은 아톰의 실제 텍스트를 저장합니다. -
atom_tab: 아톰 테이블 자체를 저장합니다—빠른 조회를 위한 해시 테이블 구조입니다. -
atom_entry: 각 아톰의 메타데이터(내부 표현, 사용 횟수 등)를 위한 메모리를 할당합니다.
아톰은 절대 가비지 컬렉션되지 않는다. 아톰은 한번 생성되면 VM이 종료될 때까지 유지된다. 이러한 설계 결정은 구현을 단순화하지만(그리고 조회 성능을 향상시키지만), 위험도 따릅니다: 통제되지 않은 아톰 생성(일반적으로 list_to_atom/1과 같은 것을 통한 동적 아톰 생성)은 아톰 테이블 고갈로 이어질 수 있습니다. 아톰 한계에 도달하면, 새로운 아톰을 생성하려는 시도는 런타임 에러를 발생시키며, 잠재적으로 노드를 다운시킬 수 있다.
아톰이 부주의하게 또는 동적으로 생성될 때 문제가 자주 발생하는데, 예를 들어 사용자가 제공한 데이터를 아톰으로 직접 변환하거나, 신뢰할 수 없는 대량의 외부 입력을 파싱하거나, 루프나 재귀 함수 내에서 아톰을 반복적으로 생성하는 경우가 이에 해당합니다. 이러한 시나리오는 아톰 테이블의 급격한 증가를 초래할 수 있으며, 잠재적으로 아톰 한도를 소진시켜 심각한 시스템 문제를 일으킬 수 있습니다.
이를 방지하기 위해:
-
아톰으로 변환하기 전에 항상 사용자 입력을 검증하거나 허용 목록과 대조하십시오.
-
가능한 한 기존 아톰을 사용하거나, 동적 식별자가 필요한 경우 바이너리 또는 문자열을 사용하십시오.
-
Observer와 같은 도구나 다음과 같은 내장 함수를 사용하여 아톰 테이블 사용량을 정기적으로 모니터링하세요:
-
아톰 수 (
erlang:system_info(atom_count)): 현재 로드된 고유 아톰의 개수. -
아톰 메모리 (
erlang:memory(atom)): 오버헤드를 포함하여 아톰에 사용된 총 바이트 수입니다. -
원자(atom) 사용 메모리 (
erlang:memory(atom_used)): 실제 atom 문자열이 사용하는 바이트만 해당됩니다.
-
Erlang 셸에서 간단히 확인해보면 빠르게 파악할 수 있습니다.
1> erlang:system_info(atom_count).
34319
2> erlang:system_info(atom_limit).
1048576
3> erlang:memory(atom).
336049
4> erlang:memory(atom_used).
324520
atom 한도에 도달했다면, 두 가지 실용적인 해결책이 있습니다:
atom 테이블 크기를 늘리기 (다만 이는 일반적으로 임시방편일 뿐이며, 좋은 atom 사용 습관을 대체해서는 안 됩니다):
erl +t <new_max_atoms>
원자(atom)의 무제한 생성을 피하도록 애플리케이션을 재설계하세요—일반적으로 바이너리, 문자열, 또는 정수 식별자를 대신 사용하는 방식으로 말입니다.
따라서 아톰과 그 관리에는 주의가 필요합니다—오용하면 안정성 문제를 일으킬 수 있습니다—하지만 올바르게 다루면, 시스템 전반에 걸쳐 정적이고 알려진 키나 식별자를 참조하는 매우 효율적인 방법으로 남습니다.
아톰 고갈의 위험 없이 문자열을 안전하게 아톰으로 변환하기 위해, Erlang은 list_to_existing_atom/1 함수를 제공합니다. 이 함수는 해당 아톰이 이미 존재하는 경우에만 성공합니다. 이 함수로 새로운 아톰을 생성하려고 시도하면 예외가 발생합니다:
1> list_to_existing_atom("Hello").
** exception error: bad argument
in function list_to_existing_atom/1
called as list_to_existing_atom("Hello")
*** argument 1: not an already existing atom
2> list_to_existing_atom("true").
true
11.13.2. 코드
또 다른 중요한 메모리 영역은 컴파일된 Erlang 모듈이 로드되는 코드 영역입니다. Erlang 모듈은 컴파일되면 이 메모리의 코드 영역에 로드되며, 이 영역은 Erlang 런타임 시스템 내에서 실행되는 모든 프로세스가 공유합니다. 코드 영역은 일반적으로 정적이고 영속적인데, 모듈이 명시적으로 언로드되거나 교체(핫 코드 로딩을 통해)되지 않는 한 로드된 상태로 유지되기 때문입니다.
l(Module) 또는 code:load_file(Module) 같은 함수를 사용하여 모듈을 로드하거나 리로드할 때, 이전 코드는 즉시 제거되지 않고 어떤 프로세스도 참조하지 않을 때까지 "old" 버전으로 유지됩니다. Erlang은 각 모듈의 두 가지 버전을 동시에 유지합니다. 이를 통해 실행 중인 프로세스를 중단하지 않고 안전하게 업그레이드할 수 있습니다.
Erlang 코드에서 정의된 상수—숫자, 아톰, 바이너리 등—는 모듈의 코드 세그먼트 내 상수 풀에 저장됩니다. 이러한 상수는 단 한 번만 저장되므로 단일 모듈 내에서는 메모리 사용 측면에서 효율적입니다. 그러나 상수가 모듈 외부에서 사용될 때—예를 들어 메시지 패싱 중이거나 ETS 테이블에 삽입될 때—해당 상수는 수신 프로세스의 힙으로 복사되며, 이로 인해 전체 메모리 사용량이 크게 증가할 수 있습니다.
코드 메모리 사용량을 모니터링하고 관리하는 것은 필수적이며, 특히 핫 코드 업그레이드를 빈번하게 수행하는 장기 실행 시스템에서 중요합니다. code:all_loaded/0 등의 내장 함수를 사용하여 로드된 모듈과 그 상태를 검사하고, erlang:memory(code)를 사용하여 로드된 모듈과 그 상수들이 차지하는 총 메모리 사용량을 모니터링할 수 있습니다.
1> erlang:memory(code).
6378630
12. 가비지 컬렉션
프로세스의 스택과 힙 공간이 부족해지면, 해당 프로세스는 마이너 가비지 컬렉션을 수행하여 공간을 회수하려고 시도합니다. 이에 대한 코드는 erl_gc.c에서 찾을 수 있습니다.
12.1. 복사 가비지 컬렉션
ERTS는 세대별 복사 가비지 컬렉터를 사용합니다. 복사 컬렉터란 가비지 컬렉션 중에 살아 있는 모든 젊은 텀이 이전 힙에서 새로운 힙으로 복사된다는 것을 의미합니다. 그런 다음 이전 힙은 폐기됩니다. 세대별 컬렉터는 대부분의 텀이 젊은 나이에 소멸한다는 원칙에 기반하여 동작하며, 이러한 텀들은 생성되고, 사용되고, 버려지는 임시 텀입니다. 오래된 텀은 구세대로 승격되며, 구세대는 더 드물게 수집됩니다. 이는 한번 오래된 텀이 되면 아마도 오랫동안 살아 있을 것이라는 근거에 기반합니다.
개념적으로 가비지 컬렉션 사이클은 다음과 같이 동작합니다:
-
먼저 모든 루트(예: 스택)를 수집합니다.
-
그런 다음 각 루트에 대해, 루트가 포워딩 포인터가 없는 힙에 할당된 객체를 가리키고 있다면 해당 객체를 새 힙으로 복사합니다. 각 복사된 객체에 대해 원본을 새 복사본을 가리키는 포워딩 포인터로 업데이트합니다.
-
이제 새 힙을 순회하면서 루트에 대해 했던 것과 동일한 작업을 수행합니다.
12.1.1. 예제
이것이 구체적으로 어떻게 수행되는지 예제를 통해 살펴보겠습니다. 구세대(old generation) 없이 마이너 컬렉션을 수행하는 과정을 살펴볼 것이며, 루트 셋으로는 스택만 사용할 것입니다. 실제로는 프로세스 딕셔너리, 트레이스 데이터, 프로브 데이터 등도 루트 셋에 포함됩니다.
garbage_collect 호출이 gc_example에서 어떻게 동작하는지 살펴보자. 이 코드는 cons의 두 요소와 튜플이 공유하는 문자열을 생성하며, 튜플은 제거되어 가비지가 됩니다. GC 이후에는 힙에 문자열이 하나만 남아 있어야 합니다. 즉, 먼저 우리는 텀 {["Hello","Hello"], "Hello"}을 생성한다(모든 인스턴스에서 동일한 문자열 "Hello"를 공유). 그런 다음 GC를 트리거할 때 텀 ["Hello","Hello"]만 유지한다.
| 이 기회를 빌려 리눅스 시스템에서 gdb를 사용하여 ERTS의 동작을 살펴보는 방법을 알아보겠습니다. 물론 원하는 디버거를 사용해도 됩니다. 이미 gdb 사용법을 알고 있거나 디버거에 들어가는 것에 관심이 없다면, 시스템을 검사하는 방법에 대한 메타 텍스트는 무시하고 다이어그램과 GC 작동 방식에 대한 설명만 보면 됩니다. Section 18.8.1에서는 gdb를 사용하여 시스템을 상세히 검사하는 방법을 다룰 것입니다. 여기서는 안타깝게도 시간이 지나면서 유실된 자체 헬퍼 스크립트를 일부 사용합니다. |
-module(gc_example).
-export([example/0]).
example() ->
T = gen_data(),
S = element(1, T),
erlang:garbage_collect(),
S.
gen_data() ->
S = gen_string($H, $e, $l, $l, $o),
T = gen_tuple([S,S],S),
T.
gen_string(A,B,C,D,E) ->
[A,B,C,D,E].
gen_tuple(A,B) ->
{A,B}.
예제를 컴파일한 후 Erlang 셸을 시작하고, 호출을 테스트한 다음 예제에 대한 새로운 호출을 준비합니다(엔터를 누르지 않은 상태로):
1> gc_example:example(). ["Hello","Hello"] 2> spawn(gc_example,example,[]).
gdb를 사용하여 시스템 검사하기
그런 다음 gdb를 사용하여 제 erlang 노드(이 경우 OS PID: 2955)에 연결(attach)합니다
$ gdb /home/happi/otp/lib/erlang/erts-6.0/bin/beam.smp 2955
| ptrace_scope 설정에 따라 gdb 실행 앞에 'sudo’를 붙여야 할 수도 있습니다. |
그런 다음 gdb에서 메인 GC 함수의 시작 부분에 브레이크포인트를 설정하고 노드를 계속 실행시켰습니다:
(gdb) break garbage_collect_0 (gdb) cont Continuing.
이제 Erlang 셸에서 엔터를 누르면 실행이 브레이크포인트에서 멈춥니다:
Breakpoint 1, garbage_collect_0 (A__p=0x7f673d085f88, BIF__ARGS=0x7f673da90340) at beam/bif.c:3771 3771 FLAGS(BIF_P) |= F_NEED_FULLSWEEP;
PCB 검사하기
이제 프로세스의 PCB를 살펴볼 수 있습니다:
(gdb) p *(Process *) A__p
$1 = {common = {id = 1408749273747, refc = {counter = 1}, tracer_proc = 18446744073709551611, trace_flags = 0, u = {alive = {
started_interval = 0, reg = 0x0, links = 0x0, monitors = 0x0, ptimer = 0x0}, release = {later = 0, func = 0x0, data = 0x0,
next = 0x0}}}, htop = 0x7f6737145950, stop = 0x7f6737146000, heap = 0x7f67371458c8, hend = 0x7f6737146010, heap_sz = 233,
min_heap_size = 233, min_vheap_size = 46422, fp_exception = 0, hipe = {nsp = 0x0, nstack = 0x0, nstend = 0x0, ncallee = 0x7f673d080000,
closure = 0, nstgraylim = 0x0, nstblacklim = 0x0, ngra = 0x0, ncsp = 0x7f673d0863e8, narity = 0, float_result = 0}, arity = 0,
arg_reg = 0x7f673d086080, max_arg_reg = 6, def_arg_reg = {393227, 457419, 18446744073709551611, 233, 46422, 2000}, cp = 0x7f673686ac40,
i = 0x7f673be17748, catches = 0, fcalls = 1994, rcount = 0, schedule_count = 0, reds = 0, group_leader = 893353197987, flags = 0,
fvalue = 18446744073709551611, freason = 0, ftrace = 18446744073709551611, next = 0x7f673d084cc0, nodes_monitors = 0x0,
suspend_monitors = 0x0, msg = {first = 0x0, last = 0x7f673d086120, save = 0x7f673d086120, len = 0, mark = 0x0, saved_last = 0x7d0}, u = {
bif_timers = 0x0, terminate = 0x0}, dictionary = 0x0, seq_trace_clock = 0, seq_trace_lastcnt = 0,
seq_trace_token = 18446744073709551611, initial = {393227, 457419, 0}, current = 0x7f673be17730, parent = 1133871366675,
approx_started = 1407857804, high_water = 0x7f67371458c8, old_hend = 0x0, old_htop = 0x0, old_heap = 0x0, gen_gcs = 0,
max_gen_gcs = 65535, off_heap = {first = 0x0, overhead = 0}, mbuf = 0x0, mbuf_sz = 0, psd = 0x0, bin_vheap_sz = 46422,
bin_vheap_mature = 0, bin_old_vheap_sz = 46422, bin_old_vheap = 0, sys_task_qs = 0x0, state = {counter = 41002}, msg_inq = {first = 0x0,
last = 0x7f673d086228, len = 0}, pending_exit = {reason = 0, bp = 0x0}, lock = {flags = {counter = 1}, queue = {0x0, 0x0, 0x0, 0x0},
refc = {counter = 1}}, scheduler_data = 0x7f673bd6c080, suspendee = 18446744073709551611, pending_suspenders = 0x0, run_queue = {
counter = 140081362118912}, hipe_smp = {have_receive_locks = 0}}
와, 정말 많은 정보였습니다. 흥미로운 부분은 스택과 힙에 관한 것입니다:
hend = 0x7f6737146010, stop = 0x7f6737146000, htop = 0x7f6737145950, heap = 0x7f67371458c8,
스택 검사하기
일부 헬퍼 스크립트(현재는 시간 속에 유실됨)를 사용하면 스택과 힙을 의미 있는 방식으로 검사할 수 있습니다.
(gdb) source gdb_scripts (gdb) print_p_stack A__p 0x00007f6737146008 [0x00007f6737145929] cons -> 0x00007f6737145928 (gdb) print_p_heap A__p 0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908 0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928 0x00007f6737145938 [0x0000000000000080] Tuple size 2 0x00007f6737145930 [0x00007f6737145919] cons -> 0x00007f6737145918 0x00007f6737145928 [0x00007f6737145909] cons -> 0x00007f6737145908 0x00007f6737145920 [0xfffffffffffffffb] NIL 0x00007f6737145918 [0x00007f6737145909] cons -> 0x00007f6737145908 0x00007f6737145910 [0x00007f67371458f9] cons -> 0x00007f67371458f8 0x00007f6737145908 [0x000000000000048f] 72 0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8 0x00007f67371458f8 [0x000000000000065f] 101 0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8 0x00007f67371458e8 [0x00000000000006cf] 108 0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8 0x00007f67371458d8 [0x00000000000006cf] 108 0x00007f67371458d0 [0xfffffffffffffffb] NIL 0x00007f67371458c8 [0x00000000000006ff] 111
여기서는 프로세스가 힙에 리스트 "Hello"와 그 리스트를 두 번 포함하는 cons, 그리고 그 cons와 리스트를 포함하는 튜플을 할당한 후의 힙 상태를 볼 수 있습니다. 루트 집합(root set), 이 경우에는 스택이 리스트의 두 복사본을 포함하는 cons에 대한 포인터를 가지고 있습니다. 튜플은 죽은 상태입니다. 즉, 그것을 참조하는 것이 없습니다.
가비지 컬렉션은 루트 집합(root set)을 계산하고 새로운 힙(to space)을 할당하는 것으로 시작됩니다. 디버거에서 GC 코드를 단계별로 진입하면 이것이 어떻게 수행되는지 확인할 수 있습니다. 여기서는 세부 사항을 다루지 않겠습니다. 여러 단계를 거친 후 실행은 루트 집합의 모든 텀이 새로운 힙으로 복사되는 지점에 도달합니다. 버전에 따라 다르지만, 이는 erl_gc.c의 1272번째 줄 부근에서 while 루프로 시작됩니다.
우리의 경우 루트는 문자(정수) 'H’를 포함하는 주소 0x00007f95666597f0을 가리키는 cons입니다. cons 셀이 현재 힙(from space라고 부름)에서 to space로 이동되면, 헤드(또는 car) 안의 값은 moved cons 태그(값 0)로 덮어쓰여집니다.
힙 검사하기
루트 셋이 이동하는 첫 번째 단계 이후, from space와 to space는 다음과 같습니다:
from 공간:
(gdb) print_p_heap p 0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908 0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928 0x00007f6737145938 [0x0000000000000080] Tuple size 2 0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0 0x00007f6737145928 [0x0000000000000000] Tuple size 0 0x00007f6737145920 [0xfffffffffffffffb] NIL 0x00007f6737145918 [0x00007f6737145909] cons -> 0x00007f6737145908 0x00007f6737145910 [0x00007f67371458f9] cons -> 0x00007f67371458f8 0x00007f6737145908 [0x000000000000048f] 72 0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8 0x00007f67371458f8 [0x000000000000065f] 101 0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8 0x00007f67371458e8 [0x00000000000006cf] 108 0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8 0x00007f67371458d8 [0x00000000000006cf] 108 0x00007f67371458d0 [0xfffffffffffffffb] NIL 0x00007f67371458c8 [0x00000000000006ff] 111
to 공간:
(gdb) print_heap n_htop-1 n_htop-2 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f6737145918 0x00007f67371445b0 [0x00007f6737145909] cons -> 0x00007f6737145908
from space에서 첫 번째 cons 셀의 head는 0으로 덮어써졌고(크기 0의 튜플처럼 보인다) tail은 to space의 새 cons 셀을 가리키는 forwarding pointer로 덮어써졌다. 이제 to space에는 from space에 있는 cons의 head와 tail을 가리키는 두 개의 backward pointer를 가진 첫 번째 cons 셀이 있다.
나머지 힙 이동
수집기가 루트 집합 처리를 마치면 to space는 여전히 살아있는 모든 텀에 대한 역방향 포인터를 포함합니다. 이 시점에서 수집기는 to space 스위핑을 시작합니다. 이는 두 개의 포인터를 사용하는데, n_hp는 아직 보지 못한 힙의 바닥을 가리키고, n_htop은 힙의 꼭대기를 가리킵니다.
n_htop:
0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f6737145918
n_hp 0x00007f67371445b0 [0x00007f6737145909] cons -> 0x00007f6737145908
GC는 그런 다음 n_hp가 가리키는 값을 확인하는데, 이 경우에는 다시 from space를 가리키는 cons이다. 그래서 GC는 그 cons를 to space로 이동시키고, 새 cons를 위한 공간을 만들기 위해 n_htop을 증가시키며, 첫 번째 cons가 확인되었음을 나타내기 위해 n_hp를 증가시킨다.
from space:
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0xfffffffffffffffb] NIL
0x00007f6737145918 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0
0x00007f6737145908 [0x0000000000000000] Tuple size 0
0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371458f8 [0x000000000000065f] 101
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
to space:
n_htop:
0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371458f8
0x00007f67371445c0 [0x000000000000048f] 72
n_hp 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f6737145918
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
그런 다음 두 번째 cons에서도 같은 일이 발생합니다.
from space:
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0x00007f67371445d1] cons -> 0x00007f67371445d0
0x00007f6737145918 [0x0000000000000000] Tuple size 0
0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0
0x00007f6737145908 [0x0000000000000000] Tuple size 0
0x00007f6737145900 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371458f8 [0x000000000000065f] 101
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
to space:
n_htop:
0x00007f67371445d8 [0xfffffffffffffffb] NIL
0x00007f67371445d0 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371458f8
n_hp 0x00007f67371445c0 [0x000000000000048f] 72
SEEN 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f67371445d0
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
to space 안에서 다음 요소는 immediate 값 72이며, 이는 단지 (n_hp++로) 건너뛰어질 뿐입니다. 그다음에는 이동되는 또 다른 cons가 있습니다.
그런 다음 두 번째 cons에서도 같은 일이 일어납니다.
from space:
0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908
0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928
0x00007f6737145938 [0x0000000000000080] Tuple size 2
0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0
0x00007f6737145928 [0x0000000000000000] Tuple size 0
0x00007f6737145920 [0x00007f67371445d1] cons -> 0x00007f67371445d0
0x00007f6737145918 [0x0000000000000000] Tuple size 0
0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0
0x00007f6737145908 [0x0000000000000000] Tuple size 0
0x00007f6737145900 [0x00007f67371445e1] cons -> 0x00007f67371445e0
0x00007f67371458f8 [0x0000000000000000] Tuple size 0
0x00007f67371458f0 [0x00007f67371458d9] cons -> 0x00007f67371458d8
0x00007f67371458e8 [0x00000000000006cf] 108
0x00007f67371458e0 [0x00007f67371458c9] cons -> 0x00007f67371458c8
0x00007f67371458d8 [0x00000000000006cf] 108
0x00007f67371458d0 [0xfffffffffffffffb] NIL
0x00007f67371458c8 [0x00000000000006ff] 111
to space:
n_htop:
0x00007f67371445e8 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371445e0 [0x000000000000065f] 101
0x00007f67371445d8 [0xfffffffffffffffb] NIL
n_hp 0x00007f67371445d0 [0x00007f6737145909] cons -> 0x00007f6737145908
SEEN 0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371445e0
SEEN 0x00007f67371445c0 [0x000000000000048f] 72
SEEN 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f67371445d0
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
이제 이미 이동된 셀을 가리키는 cons에 도달합니다. GC는 0x00007f6737145908 위치에서 IS_MOVED_CONS 태그를 확인하고, 꼬리(*n_hp++ = ptr[1];)에서 이동된 셀의 목적지를 복사합니다. 이러한 방식으로 가비지 컬렉션 중에 공유가 보존됩니다. 이 단계는 from space에 영향을 주지 않지만, to space의 역방향 포인터는 재작성됩니다.
to space:
n_htop:
0x00007f67371445e8 [0x00007f67371458e9] cons -> 0x00007f67371458e8
0x00007f67371445e0 [0x000000000000065f] 101
n_hp 0x00007f67371445d8 [0xfffffffffffffffb] NIL
SEEN 0x00007f67371445d0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
SEEN 0x00007f67371445c8 [0x00007f67371458f9] cons -> 0x00007f67371445e0
SEEN 0x00007f67371445c0 [0x000000000000048f] 72
SEEN 0x00007f67371445b8 [0x00007f6737145919] cons -> 0x00007f67371445d0
SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
그런 다음 리스트의 나머지 부분(문자열)이 이동됩니다.
from space: 0x00007f6737145948 [0x00007f6737145909] cons -> 0x00007f6737145908 0x00007f6737145940 [0x00007f6737145929] cons -> 0x00007f6737145928 0x00007f6737145938 [0x0000000000000080] Tuple size 2 0x00007f6737145930 [0x00007f67371445b1] cons -> 0x00007f67371445b0 0x00007f6737145928 [0x0000000000000000] Tuple size 0 0x00007f6737145920 [0x00007f67371445d1] cons -> 0x00007f67371445d0 0x00007f6737145918 [0x0000000000000000] Tuple size 0 0x00007f6737145910 [0x00007f67371445c1] cons -> 0x00007f67371445c0 0x00007f6737145908 [0x0000000000000000] Tuple size 0 0x00007f6737145900 [0x00007f67371445e1] cons -> 0x00007f67371445e0 0x00007f67371458f8 [0x0000000000000000] Tuple size 0 0x00007f67371458f0 [0x00007f67371445f1] cons -> 0x00007f67371445f0 0x00007f67371458e8 [0x0000000000000000] Tuple size 0 0x00007f67371458e0 [0x00007f6737144601] cons -> 0x00007f6737144600 0x00007f67371458d8 [0x0000000000000000] Tuple size 0 0x00007f67371458d0 [0x00007f6737144611] cons -> 0x00007f6737144610 0x00007f67371458c8 [0x0000000000000000] Tuple size 0 to space: n_htop: n_hp SEEN 0x00007f6737144618 [0xfffffffffffffffb] NIL SEEN 0x00007f6737144610 [0x00000000000006ff] 111 SEEN 0x00007f6737144608 [0x00007f6737144611] cons -> 0x00007f6737144610 SEEN 0x00007f6737144600 [0x00000000000006cf] 108 SEEN 0x00007f67371445f8 [0x00007f6737144601] cons -> 0x00007f6737144600 SEEN 0x00007f67371445f0 [0x00000000000006cf] 108 SEEN 0x00007f67371445e8 [0x00007f67371445f1] cons -> 0x00007f67371445f0 SEEN 0x00007f67371445e0 [0x000000000000065f] 101 SEEN 0x00007f67371445d8 [0xfffffffffffffffb] NIL SEEN 0x00007f67371445d0 [0x00007f67371445c1] cons -> 0x00007f67371445c0 SEEN 0x00007f67371445c8 [0x00007f67371445e1] cons -> 0x00007f67371445e0 SEEN 0x00007f67371445c0 [0x000000000000048f] 72 SEEN 0x00007f67371445b8 [0x00007f67371445d1] cons -> 0x00007f67371445d0 SEEN 0x00007f67371445b0 [0x00007f67371445c1] cons -> 0x00007f67371445c0
예제 요약
이 예제에서 몇 가지 주목할 점이 있습니다. Erlang에서 텀이 생성될 때는 요소부터 시작하여 아래에서 위로(bottom up) 생성됩니다. 가비지 컬렉터는 위에서 아래로(top down) 동작하며, 최상위 구조부터 시작하여 요소들을 복사합니다. 이는 첫 번째 GC 이후에 포인터의 방향이 바뀐다는 것을 의미합니다. 이것이 실질적인 영향을 미치지는 않지만, 실제 힙을 살펴볼 때 알아두면 좋습니다. 구조가 반드시 아래에서 위로(bottom up) 되어 있을 것이라고 가정해서는 안 됩니다.
또한 GC는 너비 우선 탐색을 수행한다는 점에 유의하세요. 이는 하나의 텀에 대한 지역성이 GC 이후 대부분의 경우 더 나빠진다는 것을 의미합니다. 현대 캐시의 크기를 고려하면 이것은 문제가 되지 않을 것입니다. 물론 이것이 문제가 되는 병적인 예를 만들 수도 있지만, 깊이 우선 방식이 문제를 일으키는 병적인 예도 마찬가지로 만들 수 있습니다.
세 번째로 주목할 점은 공유가 보존된다는 것인데, 이는 정말 중요합니다. 그렇지 않으면 GC 후에 오히려 이전보다 더 많은 공간을 사용하게 될 수 있기 때문입니다.
세대(generation)에 대한 확장된 설명은 다음과 같습니다:
12.2. Erlang 가비지 컬렉션의 세대(Generations)
Erlang의 BEAM 가상 머신은 세대별 복사 가비지 컬렉터를 사용하는데, 이는 대부분의 객체가 "젊어서 죽는다"는 관찰에 기반하여 효율적으로 메모리를 관리하는 방법입니다. 따라서 최근 할당된 데이터는 자주 수집되고, 살아남은 데이터는 수집 빈도가 낮은 이전 세대로 승격됩니다.
PCB(프로세스 제어 블록)는 힙에 대한 정보를 포함하며, 다음을 포함합니다:
-
high_water: 이 포인터는 힙에서 오래된 객체와 새로운 객체 사이의 경계를 나타냅니다. 이 선 아래에 있는 객체는 이전 컬렉션에서 살아남은 것으로, "오래된" 객체로 간주됩니다. 이 선 위에 있는 객체는 새로 생성된 것으로, 수명이 짧을 가능성이 더 높습니다.
-
old_heap: "오래된" 객체를 위해 지정된 힙 영역을 가리키는 포인터입니다. 객체는 마이너 가비지 컬렉션에서 살아남은 후 이 영역으로 승격됩니다.
-
old_htop: 올드 힙(old heap)의 현재 top 위치를 나타내며, 프로모션 시 새 객체가 배치될 수 있는 위치를 의미합니다.
-
old_hend: 올드 힙 영역의 끝 경계를 표시합니다. 이는 올드 힙이 추가적인 가비지 컬렉션이나 크기 조정을 필요로 할 수 있는 시점에 대한 참조 한계로 사용됩니다.
-
gen_gcs: 프로세스에서 수행된 마이너 세대별 가비지 컬렉션(minor generational garbage collection)의 횟수를 셉니다.
-
max_gen_gcs: 가비지 컬렉터가 전체 스윕(full sweep)을 수행하기 전까지의 마이너 컬렉션 최대 횟수를 정의합니다. 이 임계값은 주기적으로 전체 힙(구세대와 신세대 모두)이 포괄적으로 정리되도록 보장합니다.
추가적인 PCB 메모리 관리 관련 필드는 다음을 포함합니다:
-
off_heap: 가비지 컬렉션 동안 가비지 컬렉터가 고려해야 하는 오프힙 구조체에 대한 포인터를 포함합니다. 오프힙 데이터의 예로는 큰 바이너리가 있습니다.
-
mbuf와 mbuf_sz: 이는 주로 메시지 패싱 경합이 발생할 때 사용되는 힙 조각(메모리 버퍼, 또는 "m-buf")을 나타냅니다. 메시지는 락 경합이나 힙 공간 문제를 피하기 위해 m-buf로 복사됩니다.
-
bin_vheap_sz, bin_vheap_mature, bin_old_vheap_sz, and bin_old_vheap: 이는 메인 힙 외부에 저장되는 바이너리 데이터와 관련이 있습니다.
-
bin_vheap_sz는 프로세스 힙에 할당된 바이너리의 크기를 추적합니다. -
bin_vheap_mature는 성숙한(mature) 바이너리 가상 힙 크기를 나타내며, 가비지 컬렉션에서 살아남은 바이너리의 크기를 의미한다. -
bin_old_vheap_sz와bin_old_vheap은 승격되어 젊은 세대의 할당과는 별도로 추적되는, 더 오래된 세대의 바이너리를 구체적으로 가리킨다.
-
12.2.1. 세대별 힙의 시각적 표현 (개념적)
힙과 이전 힙의 배치를 다음과 같이 나타낼 수 있습니다(간략화된 형태):
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
hend -> +----+
|....|
stop -> | |
| | +----+ old_hend
| | | |
htop -> | | | |
|....| | | old_htop
|....| |....|
heap -> +----+ +----+ old_heap
The Heap Old Heap
여기서 두 개의 힙 영역에 대한 개략적인 그림을 볼 수 있습니다:
-
왼쪽에 있는 일반 힙은 새로운 객체가 처음에 할당되는 곳입니다.
-
오른쪽의 올드 힙(old heap)은 마이너 컬렉션에서 살아남아 올드 세대로 승격된, 더 오래되고 수명이 긴 객체를 저장합니다.
12.3. 실용적 함의와 고려 사항
빈번한 마이너 가비지 컬렉션(gen_gcs로 추적됨)은 수명이 짧은 데이터를 빠르게 할당하고 폐기하는 프로세스를 가리키는 경우가 많습니다. 이러한 현상이 관찰된다면, 코드 경로를 최적화하여 성능을 개선할 수 있다는 신호입니다—특히 빠르게 가비지가 되는 임시 또는 중간 데이터 구조(텀)의 생성을 줄이는 것이 효과적입니다. 이렇게 하면 가비지 컬렉터 오버헤드가 최소화되어, 더 효율적인 실행과 낮은 CPU 사용률로 이어집니다.
마찬가지로, high_water 포인터가 계속해서 위로 이동하는 것을 관찰하면 프로세스가 점점 더 많은 양의 지속적인(persistent) 데이터를 생성하고 있음을 나타낼 수 있습니다. 이 패턴은 더 많은 객체가 가비지 컬렉션 사이클에서 살아남아 구세대(old generation)로 승격되면서 시간이 지남에 따라 메모리를 소비하고 있음을 의미합니다. 이를 주시하면 잠재적인 메모리 누수나 데이터 수명 주기의 비효율성을 감지하는 데 도움이 됩니다. 이상적으로는 지속적으로 상승하는 추세가 아니라 안정적인 힙 사용량을 원할 것입니다.
old_heap, old_htop, old_hend와 같은 다른 중요한 필드들은 프로세스가 얼마나 많은 데이터를 덜 자주 수집되는 이전 세대 힙으로 승격시켰는지 확인할 수 있게 해줍니다. 이러한 필드들을 모니터링하면 프로세스가 장기 생존 데이터를 효과적으로 처리하고 있는지, 또는 오래된 데이터에 대한 불필요한 참조를 유지하고 있지는 않은지에 대한 통찰을 얻을 수 있습니다.
마찬가지로, 메모리 버퍼(mbuf, mbuf_sz)의 과도한 사용은 일반적으로 프로세스 간 메시지 전달에서의 경합(contention) 또는 높은 처리량을 나타냅니다. 이 수치가 비정상적으로 크게 증가하고 있다면, 메시지 패싱 전략을 재고해 보십시오—메시지를 배치 처리하거나 통신 패턴을 재구성하는 것을 고려할 수 있습니다. 동일한 원칙이 바이너리 힙(bin_vheap_sz 및 bin_vheap_mature)에도 적용됩니다. 바이너리 힙이 크다면, 바이너리가 처리되는 방식을 재고할 필요가 있다는 신호일 수 있으며, 큰 바이너리를 필요 이상으로 오래 보유하지 않도록 주의해야 합니다.
12.4. 최적화를 위한 제안
BEAM 가상 머신에서의 가비지 컬렉션을 이해하면 애플리케이션의 성능 최적화에 대한 중요한 통찰을 얻을 수 있습니다. 현대 하드웨어는 캐시 지역성과 관련된 문제를 크게 줄였지만, Erlang의 세대별 가비지 컬렉터가 너비 우선 복사 방식을 사용한다는 점을 인식하는 것은 여전히 중요합니다. 이 방식은 때때로 데이터 지역성을 저하시켜, 매우 성능에 민감한 코드 경로(포트 통신 등 포함)에서 눈에 띌 수 있는 미묘한 성능 차이를 유발할 수 있습니다.
또한 힙 단편화, 특히 메모리 버퍼(m-buf)의 사용에 주의를 기울이십시오. 애플리케이션이 메시지 패싱에 크게 의존하는 경우, 이러한 버퍼의 빈번한 사용으로 인해 상당한 힙 단편화를 경험할 수 있습니다. mbuf 또는 mbuf_sz의 과도한 증가는 경합이나 비효율적인 메시지 처리를 나타낼 수 있습니다. 이러한 시나리오에서는 메시지를 일괄 처리하거나 통신 패턴을 재고하여 메시지 패싱 전략을 최적화하면 상당한 개선을 얻을 수 있습니다.
마이너 컬렉션의 빈도(gen_gcs)와 메이저 컬렉션의 임계값(max_gen_gcs) 같은 가비지 컬렉션 통계를 모니터링하는 것은 필수적입니다. 이러한 지표를 정기적으로 검토하면 과도한 임시 데이터 할당이나 의도치 않은 장기 보존 데이터와 같은 비효율성을 조기에 감지할 수 있습니다. 이러한 패턴을 조기에 식별하면 메모리 비효율성을 수정하고 메모리 누수나 불필요한 가비지 컬렉션 오버헤드를 방지하는 데 도움이 됩니다.
마지막으로, 계산이 대량의 임시 데이터를 생성하는 시나리오를 고려하십시오. 빈번한 가비지 컬렉션이 병목이 되는 경우, 프로세스의 초기 힙 크기를 사전에 더 크게 설정하는 것이 도움이 될 수 있습니다. 이는 Erlang의 spawn_opt에 {min_heap_size, Size} 옵션을 사용하여 달성할 수 있습니다. 이 접근 방식은 충분한 힙 공간을 미리 확보하여 GC 빈도를 줄이고, 반복적인 메모리 회수와 관련된 성능 저하를 최소화합니다.
12.5. 스택과 힙 증가
BEAM에서 실행되는 Erlang 애플리케이션을 효과적으로 관리하려면, 프로세스 메모리가 어떻게 증가하고 줄어드는지를 이해해야 합니다. 구체적으로, Erlang의 프로세스는 두 가지 주요 영역인 스택과 힙에서 동적으로 메모리를 할당합니다.
12.5.1. 스택 성장
스택은 함수 호출, 반환 주소, 지역 변수, 그리고 계산 중에 필요한 중간 데이터를 보관합니다. 함수 호출이 발생하면, 매개변수, 반환 주소, 지역 변수를 저장하기 위한 새로운 스택 프레임이 생성됩니다. 코드가 더 깊은 재귀 호출을 수행할수록, 스택은 자연스럽게 성장하며 힙 영역을 향해 아래쪽으로 확장됩니다. 반대로, 함수가 완료되면 더 이상 필요하지 않은 프레임을 폐기하면서 스택은 줄어듭니다. 스택은 아래쪽으로(낮은 메모리 주소 방향으로) 성장합니다.
함수 호출이 너무 깊게 중첩되거나 재귀를 잘못 사용하면 스택 오버플로가 발생합니다. 신중하게 설계된 Erlang 애플리케이션은 지나치게 깊은 재귀를 피하거나, 불필요한 스택 증가를 방지하기 위해 꼬리 호출 최적화된 재귀를 구현해야 합니다.
12.5.2. 힙 성장
반면에 힙은 튜플, 리스트, 맵, 바이너리 및 기타 동적으로 할당되는 구조체와 같은 Erlang 텀을 저장합니다. 스택과 달리, 힙은 위쪽(높은 메모리 주소 방향)으로 성장합니다. 프로세스에 의해 할당된 새로운 데이터는 힙의 상단(htop)에 위치하게 되며, 계속해서 위로 이동합니다. 힙의 공간이 부족해지면 가비지 컬렉션이 트리거되어, 사용되지 않는 텀으로부터 메모리를 회수합니다.
가비지 컬렉션은 살아 있는 텀들을 새로운 영역으로 복사하여 압축함으로써 단편화를 줄입니다. GC 이후 힙 크기는 (회수된 메모리로 인해) 줄어들거나, 일정하게 유지되거나, 프로세스의 요구를 충족시키기에 회수된 메모리가 충분하지 않은 경우 증가할 수 있습니다.
12.5.3. 스택과 힙 간의 상호작용
Erlang에서 스택과 힙은 개념적으로는 분리되어 있지만, 물리적으로는 프로세스에 할당된 동일한 연속 메모리 블록 내에 함께 위치합니다. 이들은 서로를 향해 증가합니다:
-
스택: 아래쪽으로(더 낮은 주소 방향으로) 증가합니다.
-
힙: 위쪽으로(더 높은 주소 쪽으로) 증가합니다.
프로세스는 스택과 힙이 충돌하면 메모리가 부족해지며—이는 가비지 컬렉션을 촉발하여 힙 공간을 회수하려고 시도합니다. 충분한 공간을 회수할 수 없는 경우, BEAM은 해당 프로세스에 더 큰 메모리 영역을 할당합니다.
12.5.4. 실전 최적화 조언
프로파일링 중 스택 깊이와 힙 크기를 주시하세요(observer나 etop이 도움이 될 수 있습니다). 임시 텀이 축적되거나 불필요한 복사가 발생하면 과도한 힙 증가를 볼 수 있습니다. 또한, 깊은 재귀 호출이나 심하게 중첩된 호출에 대해 스택 사용량을 모니터링하십시오.
계산에 대량의 임시 힙 공간이 필요한 경우, 더 큰 힙을 미리 할당하는 것을 고려하십시오.
이 동적 스택과 힙의 상호작용을 이해하면 애플리케이션의 메모리 동작을 정밀하게 제어할 수 있으며, 더 높은 성능과 효율성, 안정성을 갖춘 Erlang 코드를 작성할 수 있습니다.
12.6. 주요 메모리 특성과 최적화 기회
Erlang과 BEAM에서의 메모리 관리 및 가비지 컬렉션(GC)에 대한 이해를 심화하고 접근 방식을 개선하기 위해, 기존 연구에서 얻은 다음과 같은 특성과 통찰을 살펴보시기 바랍니다:
12.6.1. 순환 없음: 참조 계수 또는 복사?
Erlang에서 텀은 불변이며, 이는 메모리에 순환 참조가 생성될 가능성을 자연스럽게 제거합니다. 이러한 불변성 덕분에 Erlang은 이론적으로 참조 카운팅과 같은 더 단순한 가비지 컬렉션 전략을 사용할 수 있습니다. 그러나 참조 카운팅은 그 자체의 오버헤드가 따르며, 특히 빈번한 업데이트와 멀티스레드 경합 상황에서 그렇습니다. 대신 Erlang은 복사 수집기에 의존하는데, 이는 부분적으로 Erlang 텀이 일반적으로 크기가 작고 복사 수집기가 더 나은 캐시 지역성과 최소한의 단편화를 제공하기 때문입니다.
일반적인 텀 크기와 분산
HiPE(High-Performance Erlang) 팀의 연구에 따르면, Erlang 텀은 일반적으로 크기가 작으며 주로 단순한 구조로 구성됩니다. 그들의 측정에 따르면 일반적인 Erlang 애플리케이션에서 다음과 같은 대략적인 텀 분산이 발견되었습니다:
-
할당된 텀 중 약 75%는 단순 cons 셀입니다.
-
약 24%는 콘즈(cons)가 아닌 텀이지만 크기가 8단어보다 작습니다.
-
8단어 이상인 텀은 약 1%에 불과합니다.
이러한 관찰을 바탕으로, 세대별 복사 가비지 컬렉션 전략은 Erlang의 워크로드에 특히 잘 맞습니다. 대다수의 텀(약 99%)은 수명이 짧거나 크기가 작기 때문에, 최소한의 오버헤드로 효율적으로 복사하고 관리할 수 있습니다.
복사 가비지 컬렉션을 통한 단편화 감소와 지역성 향상
대부분의 Erlang 텀이 컴팩트한 특성을 지니기 때문에, 복사 방식의 가비지 컬렉션은 메모리 지역성을 보존하고 힙 단편화를 줄이는 데 탁월합니다. 작은 텀들이 새로운 힙 영역으로 빈번하게 복사됨에 따라, 가비지 컬렉터는 연속적인 메모리 블록을 유지하여 CPU 캐시 지역성을 향상시킵니다. 실제로 이는 캐시 미스가 줄어들고 메모리 접근 패턴이 더 예측 가능해짐으로써 성능이 향상됨을 의미합니다.
애플리케이션 최적화를 위한 시사점
이러한 특성들을 고려할 때, 복사 가비지 컬렉션은 명확한 장점을 제공합니다:
-
최소 단편화: 각 가비지 컬렉션 동안 메모리가 압축되기 때문에 힙 단편화가 매우 적습니다. 이는 메모리 사용을 효율적으로 유지합니다.
-
향상된 지역성(Locality): 살아있는 텀들을 하나의 연속된 메모리 영역 내 새로운 메모리 위치로 복사하면 캐시 지역성이 향상되어, 최신 CPU에서의 실행 속도가 개선됩니다.
그러나 복사 방식에는 다음과 같은 트레이드오프가 있습니다:
-
복사 오버헤드: 텀을 계속해서 복사하는 것은 더 큰 데이터 구조나 오래 지속되는 텀에 대해 오버헤드를 발생시킬 수 있지만, 일반적으로 Erlang의 사용 사례에서는 무시할 수 있는 수준입니다.
-
큰 텀에 미치는 잠재적 영향: 흔하지는 않지만, 큰 텀(8워드 초과, 전체 데이터의 약 1%에 해당)은 복사 시 성능 저하를 유발할 수 있습니다. 이러한 텀은 명시적인 바이너리 또는 오프힙 할당을 통해 최적화할 수 있습니다.
참조 카운팅을 사용하지 않는 이유
얼핏 보면, Erlang에 순환 구조가 없다는 점은 참조 카운팅 전략을 사용할 수 있음을 시사합니다. 그러나 참조 카운팅은 텀 참조가 변경될 때마다 오버헤드가 발생하며, 단편화 문제를 본질적으로 해결하지 못합니다. Erlang의 사용 사례—작은 텀의 빈번한 할당—를 고려하면, 복사 수집이 여전히 참조 카운팅보다 우수한 성능을 보입니다.
12.6.2. 실용적인 결론
Erlang의 데이터 프로파일이 세대별 복사 GC에 자연스럽게 부합한다는 점을 이해하면 BEAM이 이 방법을 채택하는 이유가 명확해집니다. 이는 다음을 포함하는 최적화 전략을 강조합니다:
-
불필요한 할당과 일시적인 데이터를 최소화합니다.
-
가능하면 크거나 복잡한 데이터 구조를 피하거나, 큰 데이터를 메인 힙 밖으로 옮기는 것입니다.
-
대량의 임시 데이터를 생성하는 프로세스에 대해 힙 크기를 신중하게 설정합니다.
Erlang의 가비지 컬렉션 전략에 메모리 관리 방식을 맞춤으로써, 더 나은 성능과 자원 활용을 위해 애플리케이션을 최적화할 수 있습니다.
13. IO, 포트, 그리고 네트워킹
Erlang 내에서 모든 통신은 비동기 시그널링으로 이루어진다. Erlang 노드와 외부 세계 간의 통신은 포트를 통해 이루어집니다. 포트는 Erlang 프로세스와 외부 자원 사이의 인터페이스이다. 초기 버전의 Erlang에서 포트는 프로세스와 매우 유사하게 동작했으며, 시그널을 보내고 받는 방식으로 통신했습니다. 여전히 이 방식으로 포트와 통신할 수 있지만, 포트와 직접 통신하기 위한 다수의 BIF도 제공됩니다.
이 장에서는 포트가 모든 IO의 공통 인터페이스로 어떻게 사용되는지, 포트가 외부 세계와 어떻게 통신하는지, 그리고 Erlang 프로세스가 포트와 어떻게 통신하는지 살펴볼 것입니다. 하지만 먼저 표준 IO가 더 높은 수준에서 어떻게 작동하는지 살펴보겠습니다.
13.1. Standard IO
Erlang에서 표준 I/O를 이해하면 디버깅과 외부 프로그램과의 상호작용에 도움이 됩니다. 이 섹션에서는 I/O 프로토콜, 그룹 리더, 'erlang:display', 'io:format' 사용 방법, 그리고 표준 I/O를 리다이렉트하는 방법을 다룹니다.
13.1.1. I/O 프로토콜
Erlang의 I/O 프로토콜은 프로세스와 I/O 장치 간의 통신을 처리한다. 이 프로토콜은 터미널, 파일, 외부 프로그램과 같은 장치로 데이터를 보내고 받는 방법을 정의합니다. 이 프로토콜에는 읽기, 쓰기, 포맷팅 및 I/O 제어 작업을 처리하기 위한 명령이 포함되어 있습니다. 이러한 명령은 Erlang의 동시성 모델을 유지하면서 비동기적으로 수행됩니다. 더 자세한 정보는 erlang.org:io_protocol의 공식 문서를 참조하십시오.
I/O 프로토콜은 I/O 요청 처리를 담당하는 그룹 리더와 통신하는 데 사용됩니다. 그룹 리더는 다른 프로세스들로부터 I/O 요청을 수신하여 I/O 서버로 전달하는 프로세스입니다. I/O 서버는 요청을 실행하고 응답을 그룹 리더에게 보내는 역할을 담당하며, 그룹 리더는 이를 요청한 프로세스로 전달합니다.
그룹 리더에 대해서는 나중에 다루겠지만, 먼저 실제 I/O 프로토콜을 살펴보겠습니다. 이 프로토콜은 다음과 같은 메시지들을 가지고 있습니다:
기본 메시지
-
{io_request, From, ReplyAs, Request}:-
From: 클라이언트 프로세스의pid(). -
ReplyAs: 응답을 요청과 매칭시키기 위한 식별자. -
Request: I/O 요청.
-
-
{io_reply, ReplyAs, Reply}:-
ReplyAs: 원래 요청과 일치하는 식별자. -
Reply: I/O 요청에 대한 응답.
-
출력 요청
-
{put_chars, Encoding, Characters}:-
Encoding:unicode또는latin1. -
Characters: 기록할 데이터.
-
-
{put_chars, Encoding, Module, Function, Args}:-
Module, Function, Args: 데이터를 생성하는 함수.
-
입력 요청
-
{get_until, Encoding, Prompt, Module, Function, ExtraArgs}:-
Encoding:unicode또는latin1. -
Prompt: 프롬프트로 출력될 데이터. -
Module, Function, ExtraArgs: 충분한 데이터가 읽혔는지 판단하는 함수.
-
-
{get_chars, Encoding, Prompt, N}:-
Encoding:unicode또는latin1. -
Prompt: 프롬프트로 출력될 데이터. -
N: 읽을 문자의 개수.
-
-
{get_line, Encoding, Prompt}:-
Encoding:unicode또는latin1. -
Prompt: 프롬프트로 출력될 데이터.
-
서버 모드
-
{setopts, Opts}:-
Opts: I/O 서버에 대한 옵션 목록.
-
-
getopts:-
I/O 서버로부터 현재 옵션을 요청합니다.
-
다중 요청과 선택적 메시지
-
{requests, Requests}:-
Requests: 순차적으로 실행될 유효한io_request튜플의 리스트.
-
-
{get_geometry, Geometry}:-
Geometry: 행이나 열의 수를 요청합니다 (선택 사항).
-
미구현 요청 처리
I/O 서버가 인식할 수 없는 요청을 만나면 다음과 같이 응답해야 합니다: {error, request}.
커스텀 I/O 서버 예제
메모리에 데이터를 저장하는 I/O 서버의 간단한 예제입니다:
-module(custom_io_server).
-export([start_link/0, stop/1, init/0, loop/1, handle_request/2]).
-record(state, {buffer = <<>>, pos = 0}).
start_link() ->
{ok, spawn_link(?MODULE, init, [])}.
init() ->
?MODULE:loop(#state{}).
stop(Pid) ->
Pid ! {io_request, self(), Pid, stop},
receive
{io_reply, _, {ok, State}} ->
{ok, State#state.buffer};
Other ->
{error, Other}
end.
loop(State) ->
receive
{io_request, From, ReplyAs, Request} ->
case handle_request(Request, State) of
{ok, Reply, NewState} ->
From ! {io_reply, ReplyAs, Reply},
loop(NewState);
{stop, Reply, _NewState} ->
From ! {io_reply, ReplyAs, Reply},
exit(normal);
{error, Reply, NewState} ->
From ! {io_reply, ReplyAs, {error, Reply}},
loop(NewState)
end
end.
handle_request({put_chars, _Encoding, Chars}, State) ->
Buffer = State#state.buffer,
NewBuffer = <<Buffer/binary, Chars/binary>>,
{ok, ok, State#state{buffer = NewBuffer}};
handle_request({get_chars, _Encoding, _Prompt, N}, State) ->
Part = binary:part(State#state.buffer, State#state.pos, N),
{ok, Part, State#state{pos = State#state.pos + N}};
handle_request({get_line, _Encoding, _Prompt}, State) ->
case binary:split(State#state.buffer, <<$\n>>, [global]) of
[Line|_Rest] ->
{ok, <<Line/binary, $\n>>, State};
_ ->
{ok, State#state.buffer, State}
end;
handle_request(getopts, State) ->
{ok, [], State};
handle_request({setopts, _Opts}, State) ->
{ok, ok, State};
handle_request(stop, State) ->
{stop, {ok, State}, State};
handle_request(_Other, State) ->
{error, {error, request}, State}.
이제 이 메모리 저장소를 예를 들어 파일 인터페이스를 사용하여 I/O 장치로 사용할 수 있습니다.
-module(file_client).
-export([open/0, close/1, write/2, read/2, read_line/1]).
open() ->
{ok, Pid} = custom_io_server:start_link(),
{ok, Pid}.
close(Device) ->
custom_io_server:stop(Device).
write(Device, Data) ->
file:write(Device, Data).
read(Device, Length) ->
file:read(Device, Length).
read_line(Device) ->
file:read_line(Device).
이제 이 메모리 스토어를 file_client 인터페이스를 통해 사용할 수 있습니다:
shell V14.2.1 (press Ctrl+G to abort, type help(). for help)
1> {ok, Pid} = file_client:open().
{ok,<0.219.0>}
2> file_client:write(Pid, "Hello, world!\n").
ok
3> R = file_client:close(Pid).
{ok,<<"Hello, world!\n">>}
4>
13.1.2. 그룹 리더
그룹 리더를 사용하면 I/O를 적절한 엔드포인트로 리다이렉트할 수 있습니다. 이 속성은 자식 프로세스에 상속되어 연쇄를 형성합니다. 기본적으로 Erlang 노드에는 표준 입출력 채널과의 통신을 관리하는 'user’라는 그룹 리더가 있습니다. 모든 입력 및 출력 요청은 이 프로세스를 통해 처리됩니다.
Erlang에서 시작된 각 셸은 자체적으로 그룹 리더가 됩니다. 이는 셸에서 실행된 함수가 모든 I/O 데이터를 해당 특정 셸 프로세스로 보낸다는 것을 의미합니다. ^G로 셸을 전환하고 셸을 선택할 때(예: c <number>), 특수한 셸 처리 프로세스가 I/O 트래픽을 올바른 셸로 전달합니다.
분산 시스템에서 슬레이브 노드나 원격 셸은 그룹 리더를 외부 PID로 설정하여, 자손 프로세스로부터의 I/O 데이터가 올바르게 재라우팅되도록 한다.
각 OTP 애플리케이션에는 그룹 리더 역할을 하는 애플리케이션 마스터 프로세스가 있습니다. 이는 두 가지 주요 용도가 있습니다:
-
이는 프로세스가
application:get_env(Var)를 사용해 자신의 애플리케이션 환경 설정에 접근할 수 있게 합니다. -
애플리케이션 종료 중에, 애플리케이션 마스터는 동일한 그룹 리더를 가진 모든 프로세스를 스캔하여 종료함으로써, 실질적으로 애플리케이션 프로세스들을 가비지 컬렉션한다.
그룹 리더는 common_test와 eunit의 테스트 중 I/O를 캡처하는 데에도 사용되며, 대화형 셸은 I/O를 관리하기 위해 그룹 리더를 설정합니다.
그룹 리더 함수
-
group_leader() → pid(): 프로세스의 그룹 리더의 PID를 반환합니다. -
group_leader(GroupLeader, Pid) → true:Pid의 그룹 리더를GroupLeader로 설정합니다.
슈퍼비전 트리가 있는 애플리케이션에서는 OTP가 그룹 리더가 애플리케이션 마스터라고 가정하기 때문에, 프로세스의 그룹 리더는 일반적으로 변경되지 않습니다.
'group_leader/2' 함수는 'group_leader' 시그널을 사용하여 프로세스의 그룹 리더를 설정합니다. 이 시그널은 프로세스에 전송되며, 프로세스는 자신의 그룹 리더를 지정된 PID로 설정합니다. 그룹 리더는 어떤 프로세스든 될 수 있지만, 일반적으로 셸 프로세스나 애플리케이션 마스터입니다.
사용 예시
1> group_leader().
<0.24.0>
2> self().
<0.42.0>
3> group_leader(self(), <0.43.0>).
true
그룹 리더와 BIF display와 함수 io:format의 차이를 이해하면 Erlang에서 기본 I/O를 관리하는 데 도움이 됩니다.
-
erlang:display/1: Erlang I/O 시스템을 우회하여 표준 출력에 직접 쓰는 BIF입니다. -
io:format/1,2: 그룹 리더(group leader)에게 I/O 요청을 보냅니다. rpc:call/4를 통해 수행된 경우, 출력은 호출한 프로세스의 표준 출력으로 전달됩니다.
13.1.3. 시작 시 표준 입출력 리디렉션 (분리 모드)
Erlang에서 시작 시, 특히 분리 모드(detached mode)로 실행할 때 표준 입출력(stdin, stdout, stderr)을 리다이렉션하면 입력과 출력이 전달되는 위치를 제어할 수 있습니다. 이는 출력을 파일에 로그로 남기거나 다른 소스로부터 입력을 처리하고자 할 수 있는 프로덕션 환경에서 특히 유용합니다.
분리 모드 (Detached Mode)
Erlang의 분리(detached) 모드는 -detached 플래그를 사용하여 활성화할 수 있습니다. 이 플래그는 Erlang 런타임 시스템을 연결된 콘솔 없이 백그라운드 프로세스로 시작합니다. 다음은 Erlang 노드를 detached 모드로 시작하는 방법입니다:
erl -sname mynode -setcookie mycookie -detached
분리(detached) 모드로 실행할 때는 연결된 콘솔이 없으므로 표준 입출력을 수동으로 리다이렉션해야 합니다. 이는 Erlang 런타임 시스템에 리다이렉션 옵션을 지정함으로써 수행할 수 있습니다.
표준 출력 및 오류 리다이렉션
표준 출력과 표준 에러를 파일로 리다이렉션하려면, 셸 리다이렉션이나 Erlang의 내장 옵션을 사용하세요. 다음은 표준 출력(stdout)과 표준 에러(stderr)를 별도의 로그 파일로 리다이렉션하는 예시입니다:
erl -sname mynode -setcookie mycookie -detached > mynode_stdout.log 2> mynode_stderr.log
이 명령은 Erlang 노드를 분리(detached) 모드로 시작하고, 표준 출력을 mynode_stdout.log(으)로, 표준 오류를 mynode_stderr.log(으)로 리다이렉트합니다.
또는, Erlang 스크립트나 시작 설정 내에서 이를 구성할 수도 있습니다:
init() ->
% Redirect stdout and stderr
file:redirect(standard_output, "mynode_stdout.log"),
file:redirect(standard_error, "mynode_stderr.log").
표준 입력 리다이렉션
표준 입력을 리다이렉션하는 것은 Erlang 노드를 시작할 때 입력 소스를 제공하는 것을 포함합니다. 예를 들어, 파일을 입력 소스로 사용할 수 있습니다:
erl -sname mynode -setcookie mycookie -detached < input_commands.txt
13.1.4. 표준 입력 및 출력 요약
표준 출력(stdout)은 그룹 리더에 의해 관리되며 일반적으로 콘솔이나 지정된 로그 파일로 전달됩니다. io:format/1과 io:put_chars/2와 같은 함수는 출력을 그룹 리더에게 전송하며, 그룹 리더는 지정된 출력 장치나 파일에 쓰는 작업을 처리합니다. 표준 에러(stderr)도 마찬가지로 그룹 리더에 의해 관리되며 로그 파일이나 다른 출력 대상으로 리디렉션될 수 있습니다. 표준 입력(stdin)은 프로세스가 그룹 리더로부터 읽습니다. detached 모드에서는 입력이 파일이나 다른 입력 소스로부터 리디렉션될 수 있습니다.
I/O 프로토콜을 사용하여 자신만의 I/O 서버를 구현할 수 있으며, 그 I/O 서버를 파일 디스크립터로 사용하거나 프로세스의 그룹 리더로 설정하여 그 서버를 통해 I/O를 리다이렉트할 수 있습니다.
13.2. 포트
포트는 Erlang 프로세스와 Erlang 프로세스가 아닌 모든 것 사이의 프로세스 유사 인터페이스이다. 프로그래머는 세상의 모든 것이 Erlang 프로세스처럼 동작하는 것으로 간주하고 메시지 패싱을 통해 통신할 수 있습니다.
각 포트에는 소유자가 있으며, 이에 대해서는 나중에 더 자세히 다루겠지만, 해당 포트를 알고 있는 모든 프로세스는 포트에 메시지를 보낼 수 있습니다. 아래 그림에서 프로세스가 포트와 통신하는 방법과 포트가 Erlang 노드 외부 세계와 통신하는 방법을 볼 수 있습니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable.
Erlang Node
+-------------------------+
| +------+ Owner |
| |{o} | +------+ +-------+
| |P1 |<------->| |<---->|{d} |
| +------+ |Port1 | |File |
| +------+ +-------+
| ^ |
| | |
| +------+ | |
| |{o} | | |
| |P2 +-----------+ |
| +------+ |
+-------------------------+
프로세스 P1은 파일에 대한 포트(Port1)를 열었으며, 해당 포트의 소유자로서 포트로부터 메시지를 받을 수 있습니다. 프로세스 P2도 해당 포트에 대한 핸들을 가지고 있으며, 포트로 메시지를 전송할 수 있습니다. 프로세스들과 포트는 Erlang 노드 내에 존재합니다. 파일은 Erlang 노드 외부의 파일 시스템 및 운영 체제에 존재합니다.
포트 소유자가 죽거나 종료되면 포트도 함께 종료됩니다. 포트가 종료되면 모든 외부 리소스도 정리되어야 합니다. 이는 Erlang에 기본으로 포함된 모든 포트에 해당하며, 직접 포트를 구현하는 경우에도 이러한 정리 작업이 이루어지도록 해야 합니다.
13.2.1. 다양한 포트 유형
포트에는 파일 디스크립터, 외부 프로그램, 드라이버라는 세 가지 종류가 있다. 파일 디스크립터 포트는 프로세스가 이미 열려 있는 파일 디스크립터에 접근할 수 있게 해준다. 외부 프로그램에 대한 포트는 외부 프로그램을 별도의 OS 프로세스로 호출한다. 드라이버 포트는 Erlang 노드에 드라이버가 로드되어 있어야 한다.
모든 포트는 erlang:open_port(PortName, PortSettings) 호출을 통해 생성됩니다.
파일 디스크립터 포트는 {fd, In, Out}을 PortName으로 사용하여 열립니다. 이 부류의 포트는 예전 셸(shell)과 같은 일부 내부 ERTS 서버에서 사용됩니다. 이들은 그다지 효율적이지 않은 것으로 간주되므로 거의 사용되지 않습니다. 또한 파일 디스크립터는 OS에서 열린 파일 디스크립터를 나타내는 음이 아닌 정수입니다. 파일 디스크립터는 Erlang I/O 서버가 될 수 없습니다.
외부 프로그램 포트는 Erlang 노드의 네이티브 OS에서 임의의 프로그램을 실행하는 데 사용할 수 있습니다. 외부 프로그램 포트를 열려면 외부 프로그램의 이름과 함께 {spawn, Command} 또는 {spawn_executable, FileName} 인자를 전달합니다. 이는 다른 프로그래밍 언어로 작성된 코드와 상호작용하는 가장 쉬우면서도 가장 안전한 방법 중 하나입니다. 외부 프로그램은 자체 OS 프로세스에서 실행되므로, 해당 프로그램이 크래시하더라도 Erlang 노드를 다운시키지 않는다. (물론 모든 CPU나 메모리를 소진하거나 다른 여러 가지 방법으로 전체 OS를 다운시킬 수는 있지만, 링크드인 드라이버나 NIF보다는 훨씬 안전하다).
드라이버 포트는 드라이버 프로그램이 ERTS에 로드되어 있어야 합니다. 이러한 포트는 {spawn, Command} 또는 {spawn_driver, Command}로 시작됩니다. 자신만의 링크드인 드라이버를 작성하는 것은 예를 들어 사용하고자 하는 C 라이브러리 코드와 인터페이스하기 위한 효율적인 방법이 될 수 있습니다. 링크드인 드라이버는 Erlang 노드와 동일한 OS 프로세스에서 실행되며, 드라이버에서 크래시가 발생하면 전체 노드가 다운된다는 점에 유의하십시오. Erlang 드라이버를 작성하는 방법에 대한 자세한 내용은 Chapter 16에서 확인할 수 있습니다.
Erlang/OTP는 미리 정의된 포트 타입을 구현하는 다수의 포트 드라이버와 함께 제공됩니다. 모든 플랫폼에서 사용 가능한 공통 드라이버로는 tcp_inet, udp_inet, sctp_inet, efile, zlib_drv, ram_file_drv, binary_filer, tty_sl이 있습니다. 이 드라이버들은 예를 들어 Erlang에서 파일 처리와 소켓을 구현하는 데 사용됩니다. 윈도우에서는 레지스트리에 접근하기 위한 드라이버인 registry_drv도 있습니다. 그리고 대부분의 플랫폼에는 자신만의 드라이버를 구현할 때 사용할 수 있는 예제 드라이버로 multi_drv와 sig_drv가 있습니다.
Failed to generate image: Could not load Ditaa. Either require 'asciidoctor-diagram-ditaamini' or specify the location of the Ditaa JAR(s) using the 'DIAGRAM_DITAA_CLASSPATH' environment variable. +-----------------------------------------------------------------------------------------------------+ | Entities on an Erlang Node | +--------------------------------------------------+--------------------------------------------------+ | Processes | Ports | +--------------------------------------------------+---------+-------+--------------------------------+ | | FD | OS | Drivers | +--------------------------------------------------+---------+-------+-----------+++-------+++--------+ | | (Heart) | (ls) | tcp_inet ||| efile ||| ... | +--------------------------------------------------+---------+-------+-----------+++-------+++--------+
포트로 주고받는 데이터는 바이트 스트림입니다. 포트를 열 때 PortSettings에서 패킷 크기를 지정할 수 있습니다. R16부터 포트는 진정한 비동기 통신을 지원하여 효율성과 성능을 향상시킵니다.
포트는 표준 IO와 폴링을 대체하는 데 사용할 수 있습니다. 이는 외부 프로그램이나 장치와 상호작용해야 할 때 유용합니다. 파일 디스크립터에 대한 포트를 열면 파일에 데이터를 읽고 쓸 수 있습니다. 마찬가지로, 외부 프로그램에 포트를 열어 포트 인터페이스를 사용해 통신할 수 있습니다.
파일 디스크립터로의 포트
Erlang의 파일 디스크립터 포트는 이미 열려 있는 파일 디스크립터와 상호작용할 수 있는 인터페이스를 제공합니다. 효율성 문제로 인해 일반적으로 사용되지는 않지만, 외부 리소스에 대한 간편한 인터페이스를 제공할 수 있습니다.
파일 디스크립터 포트를 생성하려면, PortName으로 {fd, In, Out} 튜플을 사용하는 open_port/2 함수를 사용합니다. 여기서 In과 Out은 각각 입력과 출력을 위한 파일 디스크립터입니다.
잘못된 예:
Port = open_port({fd, 0, 1}, []).
이는 표준 입력(파일 디스크립터 0)에서 읽고 표준 출력(파일 디스크립터 1)에 쓰는 포트를 엽니다. 이 예제는 Erlang 셸의 IO를 가로채므로 직접 실행해 보지 마십시오.
어쨌든 해보셨죠? 이제 셸을 다시 시작해야 합니다.
파일 디스크립터 포트는 포트 객체를 생성하는 open_port/2 함수를 사용하여 구현됩니다. 포트 객체는 Erlang 프로세스와 파일 디스크립터 간의 통신을 처리합니다.
내부적으로 open_port/2가 {fd, In, Out}으로 호출되면, Erlang 런타임 시스템은 지정된 파일 디스크립터와 상호작용하기 위해 필요한 통신 채널을 설정합니다. 그러면 포트 소유자 프로세스는 포트로/로부터 메시지를 보내고 받을 수 있으며, 포트는 다시 파일 디스크립터와 상호작용합니다.
생성된 OS 프로세스로의 포트
생성된 OS 프로세스에 대한 포트를 생성하려면, {spawn, Command} 또는 {spawn_executable, FileName} 튜플을 PortName으로 사용하는 open_port/2 함수를 사용합니다. 이 방법을 통해 Erlang 프로세스는 외부 프로그램을 별도의 OS 프로세스로 생성하여 상호작용할 수 있습니다.
포트와 상호작용하기 위한 주요 명령어는 다음과 같습니다:
-
{command, Data}: 외부 프로그램에Data를 전송합니다. -
{control, Operation, Data}: 외부 프로그램에 제어 명령을 보냅니다. -
{exit_status, Status}: 외부 프로그램의 종료 상태를 수신합니다.
외부 프로그램을 포트로 스폰하는 방법에 대한 예시는 Chapter 16를 참조하세요. 공식 문서도 참고할 수 있습니다: erlang.org:c_port.
포트에서 링크드인 드라이버로
Erlang의 링크드인 드라이버는 open_port/2 함수를 사용하여 {spawn_driver, Command} 튜플을 PortName으로 지정함으로써 생성됩니다. 이 방법은 명령어의 첫 번째 토큰이 로드된 드라이버의 이름이어야 합니다.
Port = open_port({spawn_driver, "my_driver"}, []).
링크드인 드라이버 포트와 상호작용하기 위한 명령어에는 일반적으로 다음이 포함됩니다:
-
{command, Data}:Data를 드라이버로 보냅니다. -
{control, Operation, Data}: 드라이버에 제어 명령을 보냅니다. 예시:
Port ! {self(), {command, <<"Hello, Driver!\n">>}}.
링크드인 드라이버를 포트로 구현하고 스폰하는 방법에 대한 예제는 Chapter 16를 참고하세요. 공식 문서인 erlang.org:c_portdriver도 참고할 수 있다.
13.2.2. Erlang 포트에서의 흐름 제어
포트는 백프레셔를 관리하고 효율적인 리소스 사용을 보장하기 위해 흐름 제어 메커니즘을 구현합니다. 주요 메커니즘 중 하나는 바쁜 포트(busy port) 기능으로, 너무 많은 동시 작업으로 인해 포트가 과부하되는 것을 방지합니다. 포트가 바쁜 상태에 있을 때, 포트는 기존 데이터를 프로세스가 처리할 때까지 더 이상의 데이터를 처리할 수 없음을 Erlang VM에 신호로 알릴 수 있습니다.
포트의 내부 버퍼가 지정된 하이워터 마크를 초과하면, 포트는 바쁨(busy) 상태에 진입합니다. 이 상태에서는 버퍼링된 데이터를 프로세스(처리)할 수 있을 때까지 새로운 데이터 전송을 중지하도록 VM에 신호를 보냅니다.
바쁜 포트로 데이터를 보내려는 프로세스는 해당 포트가 바쁜 상태에서 벗어날 때까지 일시 중단됩니다. 이는 데이터 손실을 방지하고 포트가 들어오는 모든 데이터를 효율적으로 처리할 수 있도록 보장합니다.
포트가 지정된 저수위(low-water mark) 아래로 떨어질 만큼 충분한 데이터를 처리하면, 바쁜 상태에서 벗어납니다. 그러면 일시 중단된 프로세스들은 데이터 전송을 재개할 수 있게 됩니다.
포트로/로부터의 시그널을 비동기적으로 스케줄링함으로써, Erlang은 데이터를 보내는 프로세스가 블로킹되지 않고 계속 실행될 수 있도록 보장하여 시스템의 병렬성과 응답성을 향상시킵니다.
이는 Erlang의 send 연산이 항상 비동기적인 것은 아니라는 것을 의미합니다. 포트가 바쁜 상태이면 send 연산은 포트가 더 이상 바쁘지 않을 때까지 블로킹됩니다. 이는 많은 프로세스가 동일한 포트로 데이터를 전송하는 경우 문제가 됩니다. 해결책은 배압(backpressure)을 처리할 수 있고 send 연산이 항상 비동기적으로 이루어지도록 보장하는 포트 서버를 사용하는 것입니다.
공식 포트 드라이버 예제를 기반으로 예를 들어보겠습니다: erlang.org:c-driver.
원래의 복잡한 c 함수, 즉 1을 더하거나 2를 곱하는 함수를 사용해 봅시다.
/* example.c */
int foo(int x)
{
return x + 1;
}
int bar(int y)
{
return y * 2;
}
그리고 약간 수정된 포트 드라이버가 있습니다. 각 메시지에 id를 추가했고 결과와 함께 그 id를 반환합니다. 또한 메시지의 id가 14보다 작고 호출이 bar 함수에 대한 것이면 잠시 대기(sleep)한 후 busy port 상태를 설정하여 busy 포트를 시뮬레이션하는 함수도 있습니다.
/* Derived from port_driver.c
https://www.erlang.org/doc/system/c_portdriver.html
*/
#include "erl_driver.h"
#include <stdio.h>
#include <unistd.h> // Include for sleep function
int foo(int x);
int bar(int y);
typedef struct
{
ErlDrvPort port;
} example_data;
static ErlDrvData bp_drv_start(ErlDrvPort port, char *buff)
{
example_data *d = (example_data *)driver_alloc(sizeof(example_data));
d->port = port;
return (ErlDrvData)d;
}
static void bp_drv_stop(ErlDrvData handle)
{
driver_free((char *)handle);
}
static void bp_drv_output(ErlDrvData handle, char *buff,
ErlDrvSizeT bufflen)
{
example_data *d = (example_data *)handle;
char fn = buff[0], arg = buff[1], id = buff[2];
static char res[2];
if (fn == 1)
{
res[0] = foo(arg);
}
else if (fn == 2)
{
res[0] = bar(arg);
if (id > 14)
{
// Signal that the port is free
set_busy_port(d->port, 0);
}
else
{
// Signal that the port is busy
// This is not essential for this example
// However, if multiple processes attempted to use the port
// in parallel, we would need to signal that the port is busy
// This would make even the foo function block.
set_busy_port(d->port, 1);
// Simulate processing delay
sleep(1);
set_busy_port(d->port, 0);
}
}
res[1] = id;
driver_output(d->port, res, 2);
}
ErlDrvEntry bp_driver_entry = {
NULL, /* F_PTR init, called when driver is loaded */
bp_drv_start, /* L_PTR start, called when port is opened */
bp_drv_stop, /* F_PTR stop, called when port is closed */
bp_drv_output, /* F_PTR output, called when erlang has sent */
NULL, /* F_PTR ready_input, called when input descriptor ready */
NULL, /* F_PTR ready_output, called when output descriptor ready */
"busy_port_drv", /* char *driver_name, the argument to open_port */
NULL, /* F_PTR finish, called when unloaded */
NULL, /* void *handle, Reserved by VM */
NULL, /* F_PTR control, port_command callback */
NULL, /* F_PTR timeout, reserved */
NULL, /* F_PTR outputv, reserved */
NULL, /* F_PTR ready_async, only for async drivers */
NULL, /* F_PTR flush, called when port is about
to be closed, but there is data in driver
queue */
NULL, /* F_PTR call, much like control, sync call
to driver */
NULL, /* unused */
ERL_DRV_EXTENDED_MARKER, /* int extended marker, Should always be
set to indicate driver versioning */
ERL_DRV_EXTENDED_MAJOR_VERSION, /* int major_version, should always be
set to this value */
ERL_DRV_EXTENDED_MINOR_VERSION, /* int minor_version, should always be
set to this value */
0, /* int driver_flags, see documentation */
NULL, /* void *handle2, reserved for VM use */
NULL, /* F_PTR process_exit, called when a
monitored process dies */
NULL /* F_PTR stop_select, called to close an
event object */
};
DRIVER_INIT(busy_port_drv) /* must match name in driver_entry */
{
return &bp_driver_entry;
}
이제 이 함수를 Erlang에서 동기적으로 호출하거나, 비동기 메시지를 보낼 수 있습니다. 포트 핸들러에는 포트로 10개의 메시지를 보낸 다음 동기적으로 그리고 비동기적으로 결과를 받는 테스트도 추가했습니다.
%% Derived from https://www.erlang.org/doc/system/c_portdriver.html
-module(busy_port).
-export([start/1, stop/0, init/1]).
-export([foo/1, bar/1, async_foo/1, async_bar/1, async_receive/0]).
-export([test_sync_foo/0,
test_async_foo/0,
test_async_bar/0]).
start(SharedLib) ->
case erl_ddll:load_driver(".", SharedLib) of
ok -> ok;
{error, already_loaded} -> ok;
_ -> exit({error, could_not_load_driver})
end,
spawn(?MODULE, init, [SharedLib]).
init(SharedLib) ->
register(busy_port_example, self()),
Port = open_port({spawn, SharedLib}, []),
loop(Port, [], 0).
test_sync_foo() ->
[foo(N) || N <- lists:seq(1, 10)].
test_async_foo() ->
[async_receive() || _ <- [async_foo(N) || N <- lists:seq(1, 10)]].
test_async_bar() ->
[async_receive() || _ <- [async_bar(N) || N <- lists:seq(1, 10)]].
stop() ->
busy_port_example ! stop.
foo(X) ->
call_port({foo, X}).
bar(Y) ->
call_port({bar, Y}).
async_foo(X) ->
send_message({foo, X}).
async_bar(Y) ->
send_message({bar, Y}).
async_receive() ->
receive
{busy_port_example, Data} ->
Data
after 2000 -> timeout
end.
call_port(Msg) ->
busy_port_example ! {call, self(), Msg},
receive
{busy_port_example, Result} ->
Result
end.
send_message(Message) ->
busy_port_example ! {send, self(), Message}.
reply(Id, [{Id, From}|Ids], Data) ->
From ! {busy_port_example, Data},
Ids;
reply(Id, [Id1|Ids], Data) ->
[Id1 | reply(Id, Ids, Data)];
reply(_Id, [], Data) -> %% oops, no id found
io:format("No ID found for data: ~p~n", [Data]),
[].
loop(Port, Ids, Id) ->
receive
{call, Caller, Msg} ->
io:format("Call: ~p~n", [Msg]),
Port ! {self(), {command, encode(Msg, Id)}},
receive
{Port, {data, Data}} ->
Res = decode_data(Data),
io:format("Received data: ~w~n", [Res]),
Caller ! {busy_port_example, Res}
end,
loop(Port, Ids, Id);
{Port, {data, Data}} ->
{Ref, Res} = decode(Data),
io:format("Received data: ~w~n", [Res]),
NewIds = reply(Ref, Ids, Res),
loop(Port, NewIds, Id);
{send, From, Message} ->
T1 = os:system_time(millisecond),
io:format("Send: ~p~n", [Message]),
Port ! {self(), {command, encode(Message, Id)}},
T2 = os:system_time(millisecond),
if (T2 - T1) > 500 -> io:format("Shouldn't ! be async...~n", []);
true -> ok
end,
loop(Port, [{Id, From} | Ids], Id + 1);
stop ->
Port ! {self(), close},
receive
{Port, closed} ->
exit(normal)
end;
{'EXIT', Port, Reason} ->
io:format("~p ~n", [Reason]),
exit(port_terminated)
end.
encode({foo, X}, Id) -> [1, X, Id];
encode({bar, X}, Id) -> [2, X, Id].
decode([Int, Id]) -> {Id, Int}.
decode_data([Int,_Id]) -> Int.
쉘에서 이것을 시도해 봅시다:
1> c(busy_port).
{ok,busy_port}
2> busy_port:start("busy_port_drv").
<0.89.0>
3> busy_port:test_sync_foo().
Call: {foo,1}
Received data: 2
Call: {foo,2}
Received data: 3
Call: {foo,3}
Received data: 4
Call: {foo,4}
Received data: 5
Call: {foo,5}
Received data: 6
Call: {foo,6}
Received data: 7
Call: {foo,7}
Received data: 8
Call: {foo,8}
Received data: 9
Call: {foo,9}
Received data: 10
Call: {foo,10}
Received data: 11
[2,3,4,5,6,7,8,9,10,11]
예상대로 작동했습니다; 동기 호출을 수행했고 즉시 응답을 받았습니다. 이제 비동기 호출을 시도해 봅시다:
4> busy_port:test_async_foo().
Send: {foo,1}
Send: {foo,2}
Send: {foo,3}
Send: {foo,4}
Send: {foo,5}
Send: {foo,6}
Send: {foo,7}
Send: {foo,8}
Send: {foo,9}
Send: {foo,10}
Received data: 2
Received data: 3
Received data: 4
Received data: 5
Received data: 6
Received data: 7
Received data: 8
Received data: 9
Received data: 10
Received data: 11
[2,3,4,5,6,7,8,9,10,11]
이것도 예상대로 동작했습니다: 10개의 메시지를 보냈고 결과도 같은 순서로 즉시 돌아왔습니다. 이제 바쁜 포트(busy port)를 시도해 봅시다:
5> busy_port:test_async_bar().
Send: {bar,1}
Shouldn't ! be async...
Send: {bar,2}
Shouldn't ! be async...
Send: {bar,3}
Shouldn't ! be async...
Send: {bar,4}
Shouldn't ! be async...
Send: {bar,5}
Shouldn't ! be async...
Send: {bar,6}
Send: {bar,7}
Send: {bar,8}
Send: {bar,9}
Send: {bar,10}
Received data: 2
Received data: 4
Received data: 6
Received data: 8
Received data: 10
Received data: 12
Received data: 14
Received data: 16
Received data: 18
Received data: 20
[timeout,2,4,6,8,10,12,14,16,18]
처음 5개의 메시지는 비동기가 아니지만, 마지막 5개는 비동기임을 알 수 있습니다. 이는 포트가 사용 중이어서 전송 작업이 블로킹되기 때문입니다. 포트가 사용 중인 이유는 메시지의 id가 14 미만이고 호출이 bar 함수에 대한 것이기 때문입니다. 포트는 5초 동안 사용 중이며, 그 후 마지막 5개의 메시지가 비동기적으로 전송됩니다.
13.2.3. 포트 스케줄링
Erlang 포트는 프로세스와 유사하게, TCP와 같은 외부 통신을 처리하기 위해 코드(드라이버)를 실행한다. 원래 포트 시그널은 동기적으로 처리되어 I/O 이벤트 병렬성에 문제를 일으켰다. 이는 심한 락 경합과 병렬성 잠재력의 감소로 인해 문제가 되었다.
이러한 문제를 해결하기 위해 Erlang은 모든 포트 신호를 스케줄링하여 단일 스케줄러에 의한 순차적 실행을 보장합니다. 이를 통해 경합이 제거되고 프로세스들이 병렬로 Erlang 코드를 계속 실행할 수 있게 됩니다.
포트는 공개 잠금 큐와 비공개 무잠금 큐를 사용하는 "반잠금(semi-locked)" 방식으로 관리되는 태스크 큐를 가지고 있습니다. 잠금 경합을 피하기 위해 태스크는 이 큐들 사이에서 이동됩니다. 이 시스템은 원자적 연산을 사용하여 태스크를 중단된 것으로 표시함으로써 I/O 시그널 중단을 처리하며, 잠금 경합 없이 태스크가 안전하게 해제되도록 보장합니다.
포트는 명령 시그널로 과부하가 걸리면 바쁜 상태에 진입하여, 큐가 처리 가능한 수준이 될 때까지 새로운 시그널을 일시 중단합니다. 이는 흐름 제어를 보장하여, 포트가 시그널을 프로세스할 수 있기 전에 과부하되는 것을 방지합니다.
시그널 데이터 준비는 포트 락을 획득하기 전에 수행되어 지연 시간을 줄입니다. 경합이 없는 시그널은 즉시 실행되어 낮은 지연 시간을 유지하며, 경합이 있는 시그널은 병렬성을 보존하기 위해 나중에 실행되도록 스케줄링됩니다.
스케줄러가 어떻게 동작하는지, 그리고 포트가 전체 스케줄링 체계에 어떻게 들어맞는지에 대한 자세한 내용은 Chapter 10 장을 참고하라.
13.3. 분산 Erlang
내장 분산 계층에 대한 자세한 내용은 Chapter 14 장을 참조하십시오.
13.4. 소켓, UDP와 TCP
소켓은 Erlang에서 네트워크 통신의 기본적인 요소입니다. 소켓을 통해 프로세스는 TCP와 UDP 같은 프로토콜을 사용하여 네트워크로 통신할 수 있습니다. 여기서는 소켓을 다루는 방법, 소켓에 대한 정보를 조회하는 방법, 그리고 소켓 동작을 조정하는 방법을 살펴보겠습니다.
Erlang는 소켓을 생성하고 관리하기 위한 강력한 함수 집합을 제공합니다. gen_tcp와 gen_udp 모듈은 각각 TCP와 UDP 프로토콜의 사용을 용이하게 합니다. 다음은 TCP 소켓을 여는 기본 예제입니다:
% Open a listening socket on port 1234
{ok, ListenSocket} = gen_tcp:listen(1234, [binary, {packet, 0}, {active, false}, {reuseaddr, true}]),
% Accept a connection
{ok, Socket} = gen_tcp:accept(ListenSocket),
% Send and receive data
ok = gen_tcp:send(Socket, <<"Hello, World!">>),
{ok, Data} = gen_tcp:recv(Socket, 0).
UDP의 경우 과정은 비슷하지만 gen_udp 모듈을 사용합니다:
% Open a UDP socket on port 1234
{ok, Socket} = gen_udp:open(1234, [binary, {active, false}]),
% Send and receive data
ok = gen_udp:send(Socket, "localhost", 1234, <<"Hello, World!">>),
receive
{udp, Socket, Host, Port, Data} -> io:format("Received: ~p~n", [Data])
end.
Erlang는 소켓에 대한 정보를 조회하기 위한 여러 함수를 제공합니다. 예를 들어, 소켓의 옵션을 가져오고 설정하려면 inet:getopts/2와 inet:setopts/2를 사용할 수 있습니다. 다음은 예시입니다:
% Get options on a socket
{ok, Options} = inet:getopts(Socket, [recbuf, sndbuf, nodelay]),
% Set options on a socket
ok = inet:setopts(Socket, [{recbuf, 4096}, {sndbuf, 4096}, {nodelay, true}]).
또한, inet:peername/1와 inet:sockname/1을 사용하여 소켓의 원격 및 로컬 주소를 가져올 수 있습니다:
% Get the remote address of a connected socket
{ok, {Address, Port}} = inet:peername(Socket),
% Get the local address of a socket
{ok, {LocalAddress, LocalPort}} = inet:sockname(Socket).
소켓 성능과 동작을 최적화하기 위해 다양한 소켓 옵션을 조정할 수 있습니다. 일반적으로 조정되는 옵션으로는 버퍼 크기, 타임아웃, 패킷 크기 등이 있습니다. 다음은 이러한 옵션 중 일부를 조정하는 방법입니다:
% Set socket buffer sizes
ok = inet:setopts(Socket, [{recbuf, 8192}, {sndbuf, 8192}]),
% Set a timeout for receiving data
ok = inet:setopts(Socket, [{recv_timeout, 5000}]),
% Set packet size for a TCP socket
ok = inet:setopts(Socket, [{packet, 4}]).
14. 분산
Erlang은 분산 컴퓨팅을 지원하도록 설계되었습니다. 이 장에서는 노드, 연결, 메시지 패싱을 포함하여 Erlang에서 분산의 핵심 측면을 살펴봅니다.
14.1. 노드와 연결
Erlang에서 노드는 Erlang 런타임 시스템의 인스턴스입니다. 명세에 따르면 이것은 실제로 ERTS의 이름이 지정된 실행 인스턴스인데, 이름이 지정된 인스턴스만이 Erlang 분산을 통해 통신할 수 있기 때문입니다.
이 책에서는 다소 부정확하지만, 분산되지 않은 이름 없는 인스턴스도 노드라고 지칭하는 경우가 많습니다. 이러한 인스턴스도 예를 들어 HTTP를 통해서나 TCP를 통해 직접 다른 하위 시스템과 통신할 수 있습니다.
각 실제 노드는 고유한 이름을 가지며, 이는 일반적으로 아톰 뒤에 '@' 기호와 호스트명을 붙여 구성됩니다. 예를 들어, node1@localhost와 같습니다.
노드의 이름은 짧은 이름(short name) 또는 긴 이름(long name)일 수 있습니다. 짧은 이름은 아톰으로 구성되며 node1처럼 호스트명을 포함하지 않습니다. 짧은 이름은 모든 노드가 동일한 호스트에서 실행될 때 사용됩니다. 반면, 긴 이름은 node1@localhost처럼 아톰과 호스트명을 모두 포함합니다. 긴 이름은 노드들이 서로 다른 호스트에 분산되어 있을 때 사용됩니다. 짧은 이름을 사용하는 노드는 짧은 이름을 사용하는 다른 노드하고만 통신할 수 있고, 긴 이름을 사용하는 노드는 긴 이름을 사용하는 노드하고만 통신할 수 있다는 점에 유의해야 합니다. 따라서 분산 시스템 내의 모든 노드에서 명명 방식을 일관되게 유지하는 것이 중요합니다.
노드는 net_kernel:connect_node/1 함수를 사용하여 서로 연결을 맺을 수 있습니다. 노드의 이름은 이름(atom)과 호스트네임이라는 두 부분으로 구성됩니다. 형식은 name@hostname입니다.
이름 없이 노드를 시작하면 다른 노드에 연결할 수 없습니다:
happi@gdc12:~$ iex
Erlang/OTP 24 [erts-12.0.4] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit]
Interactive Elixir (1.12.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Node.alive?
false
iex(2)> :net_kernel.connect_node(:foo@gdc12)
:ignored
iex(3)> Node.alive?
false
iex(4)>
이름을 지정하여 노드를 시작하면 다른 노드에 연결할 수 있습니다:
happi@gdc12:~$ iex --sname bar
Erlang/OTP 24 [erts-12.0.4] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit]
Interactive Elixir (1.12.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(bar@gdc12)1> Node.alive?
true
iex(bar@gdc12)2> :net_kernel.connect_node(:foo@gdc12)
true
iex(bar@gdc12)3> Node.alive?
true
iex(bar@gdc12)4>
노드에 대한 이름을 직접 정하고 싶지 않다면 특수한 이름인 undefined로 시작할 수 있으며, 그러면 다른 노드에 연결할 때 시스템이 여러분의 노드를 위한 이름을 만들어 줍니다:
happi@gdc12:~$ iex --sname undefined
Erlang/OTP 24 [erts-12.0.4] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit]
Interactive Elixir (1.12.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Node.alive?
false
iex(2)> :net_kernel.connect_node(:foo@gdc12)
true
iex(2YOVGWCSCSR8R@gdc12)3> Node.alive?
true
iex(2YOVGWCSCSR8R@gdc12)4>
name 또는 sname 플래그로 시스템을 시작하지 않았더라도, 분산을 수동으로 시작하는 것도 가능합니다:
Eshell V15.2.2 (press Ctrl+G to abort, type help(). for help)
1> node().
nonode@nohost
2> net_kernel:start([baz, shortnames]).
{ok,<0.92.0>}
(baz@gdc12)3> node().
baz@gdc12
노드들은 동일한 "쿠키"를 사용하고 있는 한 서로 연결될 수 있으며, 이는 인가된 노드만 연결할 수 있도록 보장하는 보안 수단입니다. 쿠키는 서로 연결할 수 있어야 하는 모든 노드에서 동일해야 하는 단순한 아톰입니다. 쿠키가 일치하지 않으면 연결이 성립되지 않습니다.
-setcookie 옵션을 사용하여 Erlang 노드를 시작할 때 쿠키를 설정하거나, erlang:set_cookie/2 함수를 사용하여 동적으로 설정할 수 있습니다. 쿠키가 설정되지 않은 경우 Erlang 노드가 시작될 때 기본 매직 쿠키로 무작위 아톰이 할당되며, 다른 노드의 쿠키는 nocookie로 간주됩니다. 이 매직 쿠키는 노드 간의 기본적인 인증 메커니즘 역할을 합니다.
auth라고도 하는 Erlang 네트워크 인증 서버는 첫 번째 작업으로 사용자의 홈 디렉터리에서 .erlang.cookie라는 파일을 찾은 다음, filename:basedir(user_config, "erlang")으로 지정된 디렉터리에서 찾는다.
이 두 파일 모두 존재하지 않는 경우, auth가 사용자의 홈 디렉터리에 .erlang.cookie 파일을 생성합니다. 이 파일은 사용자에 대해 읽기 전용으로 설정된 UNIX 권한이 부여됩니다. 이 파일의 내용은 무작위로 생성된 문자열입니다.
이후, .erlang.cookie 파일의 내용으로부터 어톰 Cookie가 생성됩니다. 그런 다음 erlang:set_cookie(Cookie) 함수를 사용하여 로컬 노드의 쿠키가 이 아톰으로 설정된다. 이 동작은 로컬 노드가 다른 모든 노드와 상호작용할 때 사용할 기본 쿠키를 설정한다.
이 프로세스는 분산 Erlang 시스템에서 노드들이 서로 통신할 때 기본적인 수준의 보안과 인증을 보장합니다.
연결이 수립되면, 노드들은 서로 자유롭게 통신할 수 있습니다. 노드 간의 연결은 투명합니다. 이는 노드들이 연결되어 있다면, 다른 노드의 프로세스에 메시지를 보내는 것이 같은 노드의 프로세스에 메시지를 보내는 것과 동일하게 할 수 있다는 것을 의미합니다.
Erlang 분산에서 노드들은 기본적으로 완전히 연결되어 있습니다. 노드 N1이 다른 노드 N2에 연결하면, N2가 연결되어 있는 모든 노드의 목록을 받아 그 모든 노드에 연결합니다. 연결은 양방향이므로 N2도 N1이 연결된 모든 노드에 연결됩니다.
시스템을 시작할 때 명령줄 플래그 -connect_all false를 사용하여 이 동작을 끌 수 있습니다.
Erlang은 노드 간 SSL 연결도 지원합니다. 이는 노드 간 통신을 보호해야 할 때 유용하며, 예를 들어 신뢰할 수 없는 네트워크를 통해 통신하는 경우에 해당합니다.
노드 연결에 SSL을 사용하려면 ssl 애플리케이션과 inet_dist 수신 및 연결 옵션을 구성해야 합니다. 이를 위해서는 SSL 인증서와 키를 설정하고, 노드 간 통신에 SSL을 사용하도록 Erlang 런타임 시스템을 구성해야 합니다.
SSL을 사용하면 암호화 및 복호화의 오버헤드로 인해 성능에 영향을 미칠 수 있다는 점을 기억하십시오. 따라서 안전한 통신의 이점이 성능 비용보다 클 때 신중하게 사용해야 합니다.
Erlang의 히든 노드(hidden node)는 분산 Erlang 시스템에서 노드 네트워크에 완전히 합류하지 않고도 연결을 생성하는 데 사용할 수 있는 특수한 유형의 노드입니다.
-hidden 옵션을 사용하여 노드가 히든 노드로 시작되면, 다른 노드에서 nodes() 함수가 반환하는 노드 목록에 나타나지 않으며, 시스템 내 다른 노드들과 자동으로 연결을 맺지 않습니다. 하지만, net_kernel:connect_node/1을 사용하여 개별 노드에 연결을 맺을 수 있으며, 이러한 연결은 완전히 동작합니다: 메시지 패싱, 프로세스 생성 및 기타 분산 작업에 사용할 수 있습니다.
숨겨진 노드(hidden node)의 주요 사용 사례 중 하나는 네트워크의 나머지 부분과 격리된 연결을 생성하는 것입니다. 예를 들어, 시스템의 모든 노드에 연결하지 않고 특정 노드 그룹에만 노드를 연결하고 싶을 수 있습니다. 이는 네트워크 트래픽을 관리하거나, 특정 작업을 격리하거나, 더 큰 분산 시스템 내에서 서브네트워크를 생성하는 데 유용할 수 있습니다.
히든 노드의 또 다른 사용 사례는 노드의 전체 네트워크가 크고 동적이어서 각 노드가 다른 모든 노드에 대한 연결을 유지하는 것이 실현 가능하지 않거나 바람직하지 않은 시스템에서입니다. 히든 노드를 사용하면 보다 유연하고 확장 가능한 네트워크 토폴로지를 만들 수 있습니다.
히든 노드는 보안 기능이 아니라는 점에 유의하는 것이 중요합니다. 히든 노드는 nodes() 목록에 나타나지 않고 다른 노드에 자동으로 연결되지 않지만, 무단 접근이나 도청에 대한 추가적인 보호를 제공하지는 않습니다. 분산 Erlang 시스템을 보호해야 한다면, 쿠키 기반 인증 및 SSL/TLS 암호화와 같은 기능을 사용해야 합니다.
명령줄 플래그와 유용한 모듈 및 함수를 포함하여 Erlang 수준에서의 분산에 대한 전체 설명은 Distributed Erlang 참조 매뉴얼을 읽어보세요.
이제 더 흥미로운 부분, 즉 이것이 BEAM에서 어떻게 동작하는지 살펴보겠습니다.
15. Erlang 분산의 작동 원리
Erlang은 노드 간 통신을 위해 Erlang 분산 프로토콜로 알려진 커스텀 프로토콜을 사용합니다. 이 프로토콜은 ERTS에 의해 구현되며, 모든 노드 간 통신에 사용됩니다.
분산 프로토콜은 프로세스 메시지, 시스템 메시지, 제어 메시지를 포함한 다양한 메시지 유형을 지원합니다. 프로세스 메시지는 Erlang 프로세스 간의 통신에 사용되며, 시스템 메시지는 가상 머신의 서로 다른 부분 간의 통신에 사용됩니다. 제어 메시지는 연결 설정 및 종료와 같이 분산 시스템의 상태를 관리하는 데 사용됩니다.
15.1. Erlang 포트 매퍼 데몬 (EPMD)
Erlang 포트 매퍼 데몬(EPMD)은 Erlang 노드 간의 연결을 설정하는 프로세스를 지원하는 소규모 서버입니다. 이는 Erlang 분산 메커니즘의 핵심적인 부분입니다.
Erlang 노드가 이름과 함께 시작되면(-name 또는 -sname 옵션을 사용하여), 아직 실행 중인 EPMD 인스턴스가 없을 경우 자동으로 하나를 시작한다. 이는 노드 자체가 시작되기 전에 Erlang 런타임 시스템(ERTS)에 의해 수행된다.
EPMD 프로세스는 Erlang VM과 독립적으로, 별도의 운영 체제 프로세스로 실행됩니다. 이는 EPMD를 시작한 Erlang 노드가 중지되더라도 EPMD가 계속 실행된다는 것을 의미합니다. 동일한 호스트에서 여러 Erlang 노드가 실행 중인 경우, 모든 노드는 동일한 EPMD 인스턴스를 사용합니다. EPMD는 기본적으로 포트 4369에서 수신 대기합니다.
EPMD의 주요 역할은 노드 이름을 TCP/IP 포트 번호에 매핑하는 것입니다. Erlang 노드가 시작되면, 다른 노드로부터의 수신 연결을 위해 리스닝 TCP/IP 포트를 엽니다. 그런 다음 자신의 이름과 포트 번호를 제공하며 EPMD에 자신을 등록합니다.
노드가 다른 노드에 연결을 설정하려면, 먼저 (원격 호스트의) EPMD에 접속하여 원격 노드의 이름과 연결된 포트 번호를 요청합니다. EPMD는 포트 번호로 응답하고, 로컬 노드는 이후 원격 노드로 TCP/IP 연결을 열 수 있습니다.
EPMD의 소스 코드는 GitHub의 Erlang/OTP 저장소, 특히 erts/epmd/src 디렉터리에서 찾을 수 있습니다. 이 구현은 비교적 단순하며, 주요 로직은 단일 C 파일(epmd_srv.c)에 담겨 있습니다.
EPMD 서버는 들어오는 연결을 기다리고 요청을 처리하는 단순한 루프로 동작합니다. 요청이 수신되면 이를 파싱하고 적절한 동작을 수행하는데, 노드 등록, 노드 등록 해제, 또는 노드의 포트 번호 조회 등이 이에 해당합니다.
15.2. Erlang 분산 프로토콜
EPMD와 Erlang 노드 간의 통신은 간단한 바이너리 프로토콜을 사용합니다. 메시지는 크기가 작고 고정된 형식을 가지고 있어, 프로토콜을 구현하기 쉽고 효율적으로 사용할 수 있습니다.
이 프로토콜은 ERTS Reference:Distribution Protocol에 자세히 설명되어 있습니다.
Erlang 분산 프로토콜은 서로 다른 Erlang 노드 간의 통신을 가능하게 하는 기저 프로토콜입니다. 이것은 분산 Erlang 시스템의 요구 사항을 위해 특별히 설계된 커스텀 프로토콜입니다.
노드가 다른 노드에 대한 연결을 설정하려고 할 때, 핸드셰이크 프로세스를 시작합니다. 이 프로세스는 통신 프로토콜 버전, 노드 이름, 분산 플래그와 같은 매개변수에 합의하기 위해 두 노드 간에 교환되는 일련의 메시지를 포함합니다.
핸드셰이크 프로세스는 시작 노드가 대상 노드에 SEND_NAME 메시지를 전송하는 것으로 시작됩니다. 이 메시지에는 프로토콜 버전과 시작 노드의 이름이 포함됩니다.
대상 노드는 연결을 수락하면 ALIVE_ACK 메시지로, 연결을 거부하면 NACK 메시지로 응답한다. ALIVE_ACK 메시지에는 노드 자신의 이름과 챌린지(challenge)가 포함되는데, 이는 인증에 사용되는 난수이다.
그러면 개시 노드는 챌린지와 공유 비밀(매직 쿠키)을 사용한 계산 결과를 포함하는 CHALLENGE_REPLY 메시지로 응답해야 합니다. 대상 노드는 이 결과를 검증하여 개시 노드를 인증합니다.
마지막으로, 대상 노드는 핸드셰이크를 완료하기 위해 CHALLENGE_ACK 메시지를 전송한다. 이 시점에서 연결이 수립되고 노드들은 메시지 교환을 시작할 수 있다.
Erlang 분산 프로토콜은 다음을 포함한 여러 유형의 메시지를 지원합니다:
-
제어 메시지: 이는 분산 시스템의 상태를 관리하는 데 사용됩니다. 여기에는 프로세스 링크 및 언링크, 프로세스 모니터링 및 모니터링 해제를 위한 메시지와
EXIT,KILL같은 시그널 전송이 포함됩니다. -
데이터 메시지: 이는 프로세스 간에 데이터를 전송하는 데 사용됩니다. 여기에는 텀 데이터를 전송하기 위한 메시지와 원격 프로시저 호출(RPC)을 수행하기 위한 메시지가 포함됩니다.
-
시스템 메시지: 이는 Erlang VM의 서로 다른 부분 간 통신에 사용됩니다. 여기에는 분산 컨트롤러와 포트 매퍼 데몬(EPMD)을 관리하기 위한 메시지가 포함됩니다.
15.3. 대안적 분산
Erlang의 기본 분산 메커니즘이 특정 시스템의 모든 요구 사항을 충족하지 못하는 상황이 있을 수 있습니다. 이러한 경우에 대안적 분산이 활용됩니다.
대체 분산 메커니즘을 사용하고 싶은 이유는 여러 가지가 있을 수 있습니다:
-
성능: 내장 분산 메커니즘은 통신에 TCP/IP를 사용하는데, 이는 특정 워크로드나 네트워크 구성에서 가장 효율적인 선택이 아닐 수 있습니다. 대체 분산 메커니즘은 성능을 개선하기 위해 다른 프로토콜이나 커스텀 데이터 형식을 사용할 수 있습니다.
-
보안: Erlang의 분산 메커니즘은 인증을 위한 매직 쿠키와 같은 기본적인 보안 기능을 포함하고 있지만, 일부 애플리케이션에 요구되는 수준의 보안을 제공하지 못할 수 있습니다. 대체 분산 메커니즘은 암호화나 접근 제어와 같은 추가적인 보안 기능을 포함할 수 있습니다.
-
신뢰성 향상: Erlang의 분산 메커니즘은 장애 허용을 염두에 두고 설계되었으며 노드 장애와 네트워크 분할을 처리할 수 있습니다. 그럼에도 불구하고 추가적인 신뢰성 기능이 필요한 시나리오가 있을 수 있습니다. 대체 분산 메커니즘은 네트워크 분할을 처리하기 위한 더 정교한 전략을 제공하거나, 메시지 전달에 대한 더 강력한 보장을 제공하거나, 향상된 오류 감지 및 복구 메커니즘을 제공할 수 있습니다. 이러한 향상 기능은 상황에 따라 다르며, Erlang의 이미 견고한 신뢰성 기능을 보완하는 것임에 유의하는 것이 중요합니다.
-
상호운용성: 다른 통신 프로토콜을 사용하는 시스템과 Erlang 시스템을 통합해야 하는 경우, 대체 분산 메커니즘이 필요한 상호운용성을 제공할 수 있습니다. 이것이 아마도 가장 일반적인 사용 사례일 것입니다. Erlang 메시지와 RPC를 사용하여 C나 Scala로 작성된 다른 프로그램과 통신할 수 있다는 것은 매우 강력할 수 있습니다.
Erlang에서 대체 분산을 구현하는 방법에는 여러 가지가 있습니다:
-
커스텀 분산 드라이버: C로 분산 프로토콜을 구현하는 커스텀 분산 드라이버를 작성할 수 있습니다. 이를 통해 네트워크 프로토콜이나 데이터 형식과 같은 노드 간 통신의 저수준 세부사항을 제어할 수 있습니다.
-
분산 콜백 모듈(Distribution Callback Module): 연결 수립 및 종료, 메시지 송수신과 같은 분산 관련 이벤트를 처리하는 콜백 모듈을 Erlang으로 작성할 수 있습니다. 이를 통해 분산 드라이버보다 더 높은 수준에서 사용자 정의 동작을 구현할 수 있습니다.
-
써드파티 라이브러리: Erlang을 위한 대안적인 분산 메커니즘을 제공하는 써드파티 라이브러리들이 있습니다. 이러한 라이브러리들은 일반적으로 저수준의 세부 사항을 추상화한 고수준의 분산 통신 API를 제공합니다.
Erlang에서 대체 분산을 구현하는 것은 여러 단계를 포함합니다:
-
분산 코드 작성: 이는 C로 작성된 분산 드라이버, Erlang으로 작성된 콜백 모듈, 또는 둘의 조합일 수 있습니다. 이 코드는 핸드셰이크 프로세스와 제어 및 데이터 메시지 처리를 포함하여 Erlang 분산 프로토콜을 구현해야 합니다.
-
Erlang VM 설정: VM은 대체 분산 메커니즘을 사용하도록 설정되어야 합니다. 이는 VM을 시작할 때 특정 커맨드 라인 옵션을 전달함으로써 이루어집니다. 예를 들어, 커스텀 분산 드라이버를 사용하려면 드라이버의 이름과 함께
-proto_dist옵션을 전달하면 됩니다. -
분산 메커니즘 테스트: 분산 메커니즘이 구현되고 설정되면, 올바르게 동작하는지 확인하기 위해 테스트해야 한다. 여기에는 연결 프로세스, 메시지 패싱, 오류 처리, 그리고 분산 메커니즘의 기타 기능들을 테스트하는 것이 포함된다.
Erlang 문서에는 대체 캐리어를 구현하는 방법에 관한 장이 있습니다.
또한 대체 노드 검색을 구현하는 방법에 대한 장도 있습니다.
15.4. 분산 Erlang의 프로세스
Erlang의 프로세스는, 지금까지 살펴본 바와 같이, 프로세스 식별자(PID)로 식별됩니다. PID에는 프로세스가 실행 중인 노드에 대한 정보, 인덱스, 그리고 시리얼이 포함됩니다. 인덱스는 프로세스 테이블에서 해당 프로세스를 가리키는 참조이며, 시리얼은 동일한 인덱스를 가진 오래된(죽은) 프로세스와 새로운(살아 있는) 프로세스를 구별하는 데 사용됩니다.
분산 Erlang에서 PID는 자신이 속한 노드에 대한 정보를 포함합니다. 이는 분산 시스템에서의 메시지 패싱에 중요합니다. PID로 메시지를 보낼 때, ERTS는 해당 PID가 노드에 로컬인지 아니면 원격 노드의 프로세스에 속하는지 알아야 합니다.
Erlang 셸에서 PID를 출력하면 <node.index.serial> 형식으로 나타납니다. 예를 들어, <0.10.0>입니다. 여기서 노드 ID 0은 로컬 노드를 나타내는 데 사용됩니다.
메시지가 한 노드에서 다른 노드로 전송될 때, 메시지 내의 모든 로컬 PID는 Erlang 런타임 시스템에 의해 자동으로 원격 PID로 변환됩니다. 이 변환은 관련된 프로세스에게 투명하게 이루어집니다. 프로세스의 관점에서는 단순히 PID를 사용하여 메시지를 보내고 받는 것일 뿐입니다.
변환은 PID의 로컬 노드 식별자 0을 노드의 실제 식별자로 교체하는 것을 포함합니다. 고유 프로세스 번호는 그대로 유지됩니다. 이는 term_to_binary/1에 의해 수행됩니다.
메시지가 수신되면, 메시지 내의 원격 PID는 수신 프로세스에 전달되기 전에 로컬 PID로 다시 변환됩니다. 이는 노드 식별자를 0으로 바꾸고 생성 번호를 제거하는 과정을 포함합니다.
PID의 이러한 자동 변환은 Erlang 프로세스가 분산 메커니즘의 세부 사항을 알 필요 없이 노드 간에 투명하게 통신할 수 있도록 해줍니다.
메시지가 PID로 전송되면, ERTS는 PID의 인덱스 부분을 사용하여 프로세스 테이블에서 프로세스를 조회한 다음 해당 프로세스의 메시지 큐에 메시지를 추가합니다.
프로세스가 종료되면, 프로세스 테이블에서 해당 항목은 빈 상태로 표시되고, PID의 시리얼 부분이 증가합니다. 이렇게 하면 새로운 프로세스가 생성되어 동일한 인덱스를 재사용하더라도 다른 PID를 갖게 됩니다.
분산 Erlang의 경우, PID 처리는 다소 더 복잡합니다. 원격 노드의 PID로 메시지가 전송될 때, 로컬 ERTS는 메시지를 전달하기 위해 원격 노드의 ERTS와 통신해야 합니다. 이는 Erlang 분산 프로토콜을 사용하여 수행됩니다.
-
Erlang 노드 1이 예를 들어
spawn/4를 통해 spawn_request를 시작합니다. -
이 요청은 노드 1의 ERTS에 의해 처리됩니다.
-
ERTS는 그런 다음 분산 프로토콜(Distribution Protocol)을 통해 SPAWN_REQUEST 메시지를 전송합니다. OTP 23 이상에서는:
{29, ReqId, From, GroupLeader, {Module, Function, Arity}, OptList}에 이어ArgList가 뒤따릅니다. -
이 메시지는 노드 2의 ERTS에서 수신됩니다.
-
그런 다음 노드 2의 ERTS는 Erlang 노드 2에서 spawn_request를 시작합니다.
-
노드 2가
Module:Function(ArgList)를 호출하는 새 프로세스를 생성합니다. -
노드 2의 ERTS는 분산 프로토콜을 통해 SPAWN_REPLY 메시지를 다시 보냅니다:
{31, ReqId, To, Flags, Result}. Flags 매개변수는 각 비트가 특정 플래그를 나타내는 바이너리 필드입니다. 이 플래그들은 비트 OR 연산을 사용하여 결합됩니다. 현재 다음과 같은 플래그가 정의되어 있습니다:-
플래그 1: 이 플래그는 원래 프로세스(To)와 새로 생성된 프로세스(Result) 사이에 링크가 설정된 경우에 설정됩니다. 이 링크는 새 프로세스가 존재하는 노드에서 설정됩니다.
-
플래그 2: 이 플래그는 원래 프로세스(To)에서 새로 생성된 프로세스(Result)로 모니터가 설정된 경우에 설정됩니다. 이 모니터는 새 프로세스가 위치한 노드에 설정됩니다.
-
-
이 메시지는 노드 1의 ERTS에 의해 수신됩니다.
-
마지막으로, 노드 1의 ERTS는 호출자에게 PID를 반환합니다.
15.5. 분산 Erlang에서의 원격 프로시저 호출
원격 프로시저 호출(RPC)은 분산 Erlang의 핵심적인 부분입니다. 이를 통해 한 노드의 프로세스가 마치 로컬 함수 호출인 것처럼 다른 노드의 함수를 호출할 수 있다. 여기서는 RPC가 어떻게 구현되는지 더 깊이 살펴보겠습니다.
가장 기본적인 수준에서, Erlang의 RPC는 rpc:call/4 함수를 사용하여 수행됩니다. 이 함수는 네 개의 인자를 받습니다: 원격 노드의 이름, 호출할 함수를 포함하는 모듈의 이름, 함수의 이름, 그리고 함수에 전달할 인자들의 리스트입니다.
다음은 RPC의 예시입니다:
Result = rpc:call(Node, Module, Function, Args).
이 함수가 호출되면 다음 단계가 발생합니다:
-
호출 프로세스는 원격 노드의
rex서버 프로세스로 메시지를 보냅니다. 이 메시지에는 함수 호출의 세부 정보가 포함되어 있습니다. -
원격 노드의
rex서버가 메시지를 수신하고 새 프로세스에서 지정된 함수를 호출합니다. -
이 함수는 원격 노드에서 완료될 때까지 실행되며, 그 결과는 메시지 형태로 호출한 프로세스에게 다시 전송된다.
-
rpc:call/4함수는 결과 메시지를 수신하여 호출자에게 결과를 반환한다.
rex 서버는 모든 Erlang 노드의 표준 구성 요소이며, 들어오는 RPC 요청을 처리하는 역할을 담당합니다. 이 이름은 "Remote EXecution"의 약자입니다.
rex 서버가 RPC 요청을 받으면, 요청을 처리하기 위해 새로운 프로세스를 스폰합니다. 이 프로세스는 요청된 함수를 호출하고 결과를 호출자에게 반환합니다. 함수가 예외를 던지면, 예외는 잡혀서 오류로 호출자에게 반환됩니다.
RPC에 사용되는 메시지는 일반적인 Erlang 메시지이며, 전송을 위해 표준 Erlang 분산 프로토콜을 사용합니다. 이는 RPC가 선택적 수신 및 패턴 매칭과 같은 Erlang의 메시지 패싱 메커니즘의 모든 기능을 활용할 수 있음을 의미합니다.
동기적인 rpc:call/4 함수 외에도, Erlang은 비동기적인 RPC 메커니즘도 제공합니다. 이는 rpc:cast/4 함수를 사용하여 수행되며, 이 함수는 rpc:call/4와 유사하게 작동하지만 결과를 기다리지 않습니다. 대신, 요청을 원격 노드로 보내고 즉시 noreply를 반환합니다.
비동기 RPC는 호출자가 결과를 기다릴 필요가 없거나 호출된 함수가 의미 있는 결과를 반환하지 않는 상황에서 유용할 수 있습니다.
15.6. 대규모 시스템에서의 분산
시스템이 성장함에 따라, 특히 모든 노드가 모든 노드에 연결되는 기본 설정에서는 노드 연결 수가 기하급수적으로 증가할 수 있습니다. 이러한 증가는 네트워크 트래픽의 급증으로 이어질 수 있으며, 연결을 관리하고 성능을 유지하는 시스템의 능력에 부담을 줄 수 있습니다.
분산 시스템에서 데이터는 네트워크를 통해 전송되어야 합니다. 데이터가 한 노드에서 다른 노드로 이동하는 데 걸리는 시간, 즉 네트워크 지연 시간(network latency)은 시스템의 성능에 영향을 미칠 수 있으며, 특히 노드가 지리적으로 분산되어 있을 때 더욱 그렇습니다.
Erlang의 비동기 메시지 패싱 모델은 네트워크 지연을 효과적으로 처리할 수 있게 해주며, 프로세스는 메시지를 보낸 후 응답을 기다릴 필요 없이 다른 작업을 계속 실행할 수 있습니다. 그럼에도 불구하고 지리적으로 분산된 시스템에서 Erlang 분산을 사용하는 것은 여전히 권장되지 않습니다. Erlang 분산은 데이터 센터 내부, 가급적이면 데이터 센터 내 동일한 랙 내에서의 통신을 위해 설계되었습니다. 지리적으로 분산된 시스템에는 다른 비동기 통신 패턴이 권장됩니다.
대규모 시스템에서 장애는 불가피합니다. 노드가 충돌할 수 있고, 네트워크 연결이 끊어질 수 있으며, 데이터가 손상될 수 있습니다. 시스템은 상당한 다운타임 없이 이러한 장애를 감지하고 복구할 수 있어야 합니다.
이는 장애 감지 및 복구를 위한 내장 메커니즘으로 대응할 수 있습니다. 슈퍼바이저 트리를 통해 시스템은 프로세스 장애를 감지하고, 실패한 프로세스를 자동으로 재시작할 수 있습니다.
여러 노드에 걸쳐 데이터 일관성을 유지하는 것은 중대한 과제입니다. 하나의 노드에서 데이터가 업데이트되면, 해당 데이터의 사본을 가진 다른 모든 노드에 변경 사항을 전파해야 합니다. 이를 처리하는 한 가지 방법은 분산이 필요한 상태를 아예 피하는 것입니다. 가능하다면 실제 상태를 데이터베이스와 같은 한 곳에만 유지하십시오.
Erlang은 분산 데이터베이스 관리 시스템인 Mnesia와 같이 데이터 일관성을 관리하기 위한 여러 도구와 라이브러리를 제공합니다. Mnesia는 트랜잭션을 지원하며 여러 노드에 걸쳐 데이터를 복제할 수 있습니다. 안타깝게도, 네트워크 분할이나 노드 재시작 후 Mnesia가 동기화를 처리하는 기본 방식은 정말 작은 테이블을 제외하면 비용이 다소 높습니다. 이에 대한 자세한 내용은 Mnesia 장에서 다룹니다. 대부분의 경우 대규모 데이터 세트에는 고성능 ACID SQL 데이터베이스를, 이벤트 처리에는 메시지 큐를 사용하는 것이 권장됩니다.
15.7. 분산 포트
Erlang 분산은 노드 간 통신 버퍼(inter-node communication buffer)로 알려진 버퍼를 사용합니다. 이 버퍼의 크기는 기본적으로 128 MB입니다. 이는 대부분의 워크로드에 적합한 기본값입니다. 그러나 일부 환경에서는 노드 간 트래픽이 매우 많아 버퍼 용량에 도달할 수 있습니다. 기본값이 적합하지 않은 다른 워크로드로는 버퍼에 들어가지 않는 매우 큰 메시지(예: 수백 메가바이트 단위)를 전송하는 경우가 있습니다.
이런 경우, +zdbbl VM 플래그를 사용하여 버퍼 크기를 늘릴 수 있습니다. 값의 단위는 킬로바이트입니다:
erl +zdbbl 192000
버퍼가 최대 용량에 가까워지면, 노드는 분산 포트가 사용 중(busy_dist_port)이라는 경고를 로그로 남깁니다:
2023-05-28 23:10:11.032 [warning] <0.431.0> busy_dist_port <0.324.0>
버퍼 크기를 늘리면 처리량을 높이거나 지연 시간을 줄이는 데 도움이 될 수 있습니다. 이와 같은 성능 문제를 식별하고 해결하려면 Erlang 시스템을 정기적으로 모니터링하는 것이 중요합니다. etop나 :observer 애플리케이션 같은 도구는 Erlang 노드의 부하와 성능에 대한 유용한 통찰을 제공할 수 있습니다. 이에 대한 자세한 내용은 모니터링에 관한 장에서 다룹니다.
busy dist 포트의 근본 원인을 찾기 위한 다른 해결책은 다음과 같을 수 있습니다:
-
네트워크 문제: 네트워크가 느리거나 불안정하면 메시지 전송이 지연될 수 있습니다. 네트워크 성능을 확인하고 필요하다면 네트워크 인프라 업그레이드를 고려하세요.
-
높은 메시지 양: Erlang 노드가 많은 수의 메시지를 보내고 있다면, 이는 분산 포트에 과부하를 줄 수 있습니다. 보내는 메시지의 수를 줄이도록 코드를 최적화하는 것을 고려해 보세요. 이는 메시지를 함께 일괄 처리하거나 메시지 빈도를 줄이는 것을 포함할 수 있습니다. 통신이 필요한 프로세스들이 같은 노드에 있도록 하는 것도 시도해 볼 수 있습니다.
-
장기 실행 작업(Long-Running Tasks): Erlang 프로세스가 양보(yielding) 없이 장기 실행 작업을 수행하고 있다면, 분산 포트를 차단할 수 있습니다. 프로세스가 다른 프로세스의 메시지 전송을 허용하도록 주기적으로 제어권을 양보하는지 확인하세요. 시스템에 잘못 동작하는 NIF가 있지 않은 이상 이는 보통 문제가 되지 않습니다.
-
Erlang VM 튜닝: 부하를 더 잘 처리하도록 Erlang VM을 튜닝할 수도 있습니다. 이는 스케줄러 수를 늘리거나(
+S옵션 사용), IO 폴링 스레드를 늘리거나(+A옵션 사용), 또는 다른 VM 설정을 조정하는 것을 포함할 수 있습니다.
16. 다른 언어와의 연동 및 BEAM과 ERTS 확장
16.1. 서론
C, C++, Rust 또는 어셈블러와 인터페이싱하면 BEAM의 기능을 확장할 수 있는 기회를 얻게 됩니다. 이 장에서는 대부분의 예제에 C를 사용하겠지만, 설명하는 방법은 거의 모든 다른 프로그래밍 언어와 인터페이싱하는 데 사용할 수 있습니다. 이 장에서는 Rust와 Java의 몇 가지 예제도 제공할 것입니다. 대부분의 경우, 이 장의 나머지 부분에서 C를 다른 언어로 대체할 수 있지만, 간결함을 위해 C만 사용하겠습니다.
C 코드를 통합함으로써, 개발자는 Erlang 애플리케이션의 성능을 향상시킬 수 있으며, 특히 시스템 수준 리소스에 대한 직접적인 접근이 필요한 계산 집약적 작업에서 그러합니다. 또한, C와의 인터페이싱을 통해 Erlang 애플리케이션은 하드웨어 및 시스템 수준 리소스와 직접 상호작용할 수 있습니다. 이 기능은 메모리 조작, 특수 하드웨어 접근, 실시간 데이터 프로세싱 수행과 같은 저수준 연산이 필요한 애플리케이션에 매우 중요합니다. C를 Erlang과 통합하는 또 다른 장점은 기존 C 라이브러리와 코드베이스를 활용할 수 있다는 것입니다. C에는 많은 강력한 라이브러리와 도구가 존재하며, 이들과 인터페이싱함으로써 Erlang 개발자는 해당 기능들을 Erlang으로 다시 구현할 필요 없이 통합할 수 있습니다.
또한, 실행에 대한 정밀한 제어가 필요할 때 C와의 인터페이싱이 도움이 될 수 있습니다. Erlang의 가상 머신이 뛰어난 동시성 관리를 제공하지만, 특정 실시간 애플리케이션은 C로 더 잘 달성할 수 있는 보다 결정론적인 동작을 요구할 수 있습니다. C 코드를 통합함으로써, 개발자는 특정 요구 사항을 충족하도록 애플리케이션의 성능과 동작을 세밀하게 조정할 수 있습니다.
C 코드는 ERTS와 BEAM이 C로 작성되어 있으므로 이들을 확장할 수도 있습니다.
이전 장에서는 소켓이나 포트를 통해 다른 애플리케이션 및 서비스와 안전하게 인터페이스하는 방법을 살펴보았습니다. 이 장에서는 저수준 코드와 더 직접적으로 인터페이스하는 방법을 살펴볼 것이며, 이는 더 안전하지 않은 방식으로 사용하는 것을 의미하기도 합니다.
공식 문서에는 상호운용성에 관한 튜토리얼이 있습니다. Interoperability Tutorial을 참고하세요.
16.1.1. C 코드를 안전하게 인터페이스하는 방법
C 코드를 Erlang과 인터페이스하는 것은 BEAM 가상 머신을 불안정하게 만들 위험을 최소화하는 여러 메커니즘을 사용하여 안전하게 수행할 수 있습니다. 주요 방법은 다음과 같습니다.
os:cmd
os:cmd 함수는 Erlang 프로세스가 셸 명령을 실행하고 그 결과를 가져올 수 있게 해준다. 이 방법은 명령을 별도의 OS 프로세스에서 실행하여 BEAM VM으로부터 격리하기 때문에 안전하다. os:cmd를 사용하면 개발자는 Erlang 런타임 환경에 직접 영향을 주지 않으면서 외부 C 프로그램과 상호작용할 수 있습니다. 이는 오버헤드를 수반하며, C 프로그램은 명령줄에서 실행되어 표준 출력으로 결과를 반환할 수 있는 독립 실행형 프로그램이어야 합니다.
예:
// time.c
#include <stdio.h>
#include <time.h>
void get_system_time()
{
time_t rawtime;
struct tm *timeinfo;
time(&rawtime);
timeinfo = localtime(&rawtime);
printf("Current Date and Time: %s", asctime(timeinfo));
}
int main()
{
get_system_time();
return 0;
}
> os:cmd("./time").
"Current Date and Time: Mon May 20 04:46:37 2024\n"
'open_port' 'spawn_executable'
프로그램과 상호작용하는 더욱 안전한 방법은, 특히 인자가 사용자 입력에 기반한 경우, spawn_executable 인자와 함께 open_port를 사용하는 것입니다. 이 방법은 운영체제 셸을 거치지 않고 인자를 실행 파일에 직접 전달함으로써 인자 주입의 위험을 줄입니다. 이러한 직접 전달 방식은 셸이 인자를 해석하는 것을 방지하여, 사용자 입력에 포함된 특수 문자나 명령으로 인해 발생할 수 있는 잠재적인 인젝션 공격을 방지합니다.
1> Port = open_port({spawn_executable, "./time"}, [{args, []}, exit_status]).
#Port<0.7>
2> receive {Port, {data, R}} -> R after 1000 -> timeout end.
"Current Date and Time: Mon May 20 13:59:32 2024\n"
표준 입력에서 읽고 표준 출력에 쓰는 프로그램으로 포트를 스폰(spawn)하여 해당 포트와 데이터를 주고받을 수도 있습니다.
예:
Port = open_port({spawn, "./system_time"}, [binary]),
port_command(Port, <<"get_time\n">>).
외부 프로그램에 연결하기 위해 포트 드라이버를 사용하는 방법에 대한 자세한 내용은 Chapter 13를 참고하라.
소켓
소켓은 Erlang과 외부 C 프로그램 간의 통신을 가능하게 하는 간단한 방법을 제공합니다. TCP 또는 UDP 소켓을 사용함으로써, C 애플리케이션은 네트워크를 통해 Erlang 프로세스와 데이터를 주고받을 수 있으며, 이를 통해 두 시스템이 서로 격리된 상태를 유지할 수 있습니다. 이 방법은 특히 분산 시스템에 유용하며 비동기 통신을 가능하게 합니다.
가장 일반적이고 쉬운 방법은 HTTP나 HTTPS를 통해 REST와 유사한 인터페이스를 사용하는 것이다. HTTP 클라이언트를 위한 httpc, HTTP 서버를 위한 cowboy와 같이 클라이언트 및 서버 구현을 위한 Erlang 라이브러리가 존재합니다. 이 방식은 C 애플리케이션이 Erlang 프로세스가 호출할 수 있는 API를 노출하도록 하여, 잘 정의된 프로토콜을 통한 상호작용을 용이하게 한다.
다음 단계는 순수 소켓 통신을 사용하는 것으로, HTTP/HTTPS보다 더 효율적일 수 있지만 프로토콜을 직접 설계하거나 다른 저수준 프로토콜을 사용해야 합니다. 이 방법은 사용자 정의 데이터 교환 형식을 허용하며, 상위 수준 프로토콜에 수반되는 오버헤드를 줄여 성능을 최적화할 수 있습니다.
소켓이 어떻게 동작하는지에 대한 자세한 내용은 Chapter 13 장을 참고하십시오.
16.2. 링크드인 드라이버
Erlang의 링크드인 드라이버는 C 코드를 Erlang 런타임 시스템에 직접 통합할 수 있게 해주어, 고성능 연산과 외부 리소스와의 원활한 상호작용을 가능하게 합니다.
링크드인 드라이버는 외부 포트와 비교하여 Erlang과 C 코드 간의 더 빠른 통신을 제공하는데, 이는 같은 프로세스 공간 내에서 동작하여 프로세스 간 통신의 오버헤드를 제거하기 때문이다.
하드웨어 인터페이싱이나 실시간 데이터 프로세싱과 같이 엄격한 타이밍 제약이 있는 애플리케이션의 경우, 링크드인 드라이버가 필요한 응답성을 제공합니다.
그러나 링크드인 드라이버는 직접 메모리 접근과 잠재적인 프로그래밍 오류로 인해 Erlang VM을 불안정하게 만들 위험이 더 높습니다.
16.2.1. 링크드인 드라이버를 구현하는 방법
Erlang에서 링크드인 드라이버를 구현하는 방법을 세 가지 수준에서 살펴보겠습니다: 먼저 무엇을 해야 하는지 추상적으로 살펴보고, 그다음 일반적인 의사 코드로, 마지막으로 전체 예제로 살펴봅니다.
Erlang에서 링크드인 드라이버를 구현하려면 다음 단계를 따르세요:
1단계. 드라이버를 위한 C 코드를 작성하고, 다음을 처리합니다:
-
드라이버 초기화
-
콜백 함수에 대한 포인터를 가진
erl_drv_entry구조체를 정의합니다. -
DRIVER_INIT매크로를 사용하여 드라이버를 등록합니다.
-
-
비동기 연산 구현
-
시작, 정지, 출력 작업을 위한 드라이버 콜백을 처리합니다.
-
driver_select와 같은 함수를 사용하여 I/O 이벤트를 관리합니다.
-
-
리소스 할당 및 해제를 관리한다
-
드라이버별 데이터를 저장할 구조체를 정의합니다.
-
드라이버 시작 시 리소스를 할당하고 중지 시 해제하십시오.
-
2단계. Erlang과 통합하기:
-
erl_ddll:load_driver/2를 사용하여 드라이버를 로드하세요. -
Erlang에서 드라이버와 통신할 포트를 생성합니다.
위의 단계들을 좀 더 자세히 살펴보기 위해 링크드인 드라이버의 의사 코드를 살펴보겠습니다.
1단계. 드라이버를 위한 C 코드를 작성합니다:
-
드라이버 초기화:
-
erl_drv_entry구조체 정의: 이 구조체는 Erlang 런타임 시스템이 호출할 콜백 함수에 대한 포인터를 포함합니다. -
드라이버 등록:
DRIVER_INIT매크로를 구현하여 드라이버를 초기화하고 Erlang 런타임에 등록합니다.
-
static ErlDrvEntry example_driver_entry = {
NULL, // init
example_drv_start, // start
example_drv_stop, // stop
example_drv_output, // output
NULL, // ready_input
NULL, // ready_output
"example_drv", // driver_name
NULL, // finish
NULL, // handle
NULL, // control
NULL, // timeout
NULL, // outputv
NULL, // ready_async
NULL, // flush
NULL, // call
NULL, // event
ERL_DRV_EXTENDED_MARKER, // extended_marker
ERL_DRV_EXTENDED_MAJOR_VERSION, // major_version
ERL_DRV_EXTENDED_MINOR_VERSION, // minor_version
0, // driver_flags
NULL, // handle2
NULL, // process_exit
NULL // stop_select
};
DRIVER_INIT(example_drv) {
return &example_driver_entry;
}
-
비동기 연산:
-
드라이버 콜백 처리:
example_drv_start,example_drv_stop,example_drv_output등 필요한 콜백 함수를 구현하여 드라이버 작업을 관리합니다. -
I/O 이벤트 관리: 비동기 I/O 작업을 효율적으로 처리하기 위해
driver_select와 같은 함수를 활용하세요.
-
static ErlDrvData example_drv_start(ErlDrvPort port, char *command) {
example_data* d = (example_data*)driver_alloc(sizeof(example_data));
d->port = port;
return (ErlDrvData)d;
}
static void example_drv_stop(ErlDrvData handle) {
driver_free((char*)handle);
}
static void example_drv_output(ErlDrvData handle, char *buff, ErlDrvSizeT bufflen) {
example_data* d = (example_data*)handle;
// Process the input and produce output
driver_output(d->port, output_data, output_len);
}
-
자원 관리:
-
할당과 해제: 메모리 누수를 방지하기 위해 드라이버 시작 시 리소스를 할당하고 드라이버 중지 시 이를 해제하여 적절한 메모리 관리를 보장하세요.
-
typedef struct {
ErlDrvPort port;
// Additional driver-specific data
} example_data;
2단계. Erlang과의 통합:
-
드라이버 로딩:
erl_ddll:load_driver/2를 사용하여 드라이버가 포함된 공유 라이브러리를 로드합니다. -
포트 열기: 드라이버와 통신하려면
{spawn, DriverName}튜플과 함께open_port/2를 사용하여 Erlang에서 포트를 생성합니다.
start(SharedLib) ->
case erl_ddll:load_driver(".", SharedLib) of
ok -> ok;
{error, already_loaded} -> ok;
_ -> exit({error, could_not_load_driver})
end,
Port = open_port({spawn, SharedLib}, []),
loop(Port).
예제 구현: 이제 정수를 두 배로 만드는 링크드인 드라이버의 전체 예제를 살펴보겠습니다.
1단계. C 드라이버 작성:
#include "erl_driver.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
typedef struct {
ErlDrvPort port;
} double_data;
// Start function: initialize driver state
static ErlDrvData double_drv_start(ErlDrvPort port, char *command) {
double_data* d = (double_data*)driver_alloc(sizeof(double_data));
d->port = port;
return (ErlDrvData)d;
}
// Stop function: clean up resources
static void double_drv_stop(ErlDrvData handle) {
driver_free((char*)handle);
}
// Output function: process data sent from Erlang
static void double_drv_output(ErlDrvData handle, char *buff, ErlDrvSizeT bufflen) {
double_data* d = (double_data*)handle;
// Convert input buffer to an integer
int input = atoi(buff);
// Perform the operation (double the input value)
int result = input * 2;
// Convert the result back to a string
char result_str[32];
snprintf(result_str, sizeof(result_str), "%d", result);
// Send the result back to Erlang
driver_output(d->port, result_str, strlen(result_str));
}
// Define the driver entry struct
static ErlDrvEntry double_driver_entry = {
NULL, // init
double_drv_start, // start
double_drv_stop, // stop
double_drv_output, // output
NULL, // ready_input
NULL, // ready_output
"double_drv", // driver_name
NULL, // finish
NULL, // handle
NULL, // control
NULL, // timeout
NULL, // outputv
NULL, // ready_async
NULL, // flush
NULL, // call
NULL, // event
ERL_DRV_EXTENDED_MARKER, // extended marker
ERL_DRV_EXTENDED_MAJOR_VERSION, // major version
ERL_DRV_EXTENDED_MINOR_VERSION, // minor version
0, // driver flags
NULL, // handle2
NULL, // process_exit
NULL // stop_select
};
// Driver initialization macro
DRIVER_INIT(double_drv) {
return &double_driver_entry;
}
2단계. Erlang 모듈을 작성합니다:
-module(double).
-export([start/0, stop/0, double/1]).
start() ->
SharedLib = "double_drv",
case erl_ddll:load_driver(".", SharedLib) of
ok -> ok;
{error, already_loaded} -> ok;
_ -> exit({error, could_not_load_driver})
end,
register(double_server, spawn(fun() -> init(SharedLib) end)).
init(SharedLib) ->
Port = open_port({spawn, SharedLib}, []),
loop(Port).
loop(Port) ->
receive
{double, Caller, N} ->
Port ! {self(), {command, integer_to_list(N)}},
receive
{Port, {data, Result}} ->
Caller ! {double_result, list_to_integer(Result)}
end,
loop(Port);
stop ->
Port ! {self(), close},
receive
{Port, closed} -> exit(normal)
end
end.
double(N) ->
double_server ! {double, self(), N},
receive
{double_result, Result} -> Result
end.
stop() ->
double_server ! stop.
double_drv_output 함수는 Erlang 런타임 시스템에서 드라이버로 전송된 데이터를 처리하는 역할을 합니다. 이 함수는 먼저 atoi를 사용해 입력 문자열을 정수로 변환하고, 값을 두 배로 만드는 데 필요한 연산을 수행한 다음, snprintf를 사용해 결과를 다시 문자열로 포맷합니다. 마지막으로, driver_output 함수를 사용하여 결과를 Erlang 런타임으로 다시 전송함으로써, Erlang과 네이티브 코드 간의 원활한 통신을 가능하게 한다.
double_driver_entry 구조체는 드라이버의 중심 정의 역할을 하며, Erlang 런타임 시스템을 드라이버의 핵심 기능과 연결한다. 이는 드라이버의 라이프사이클과 상호작용의 다양한 측면을 처리하는 start, stop, output 같은 콜백 함수에 대한 포인터를 포함합니다. 또한, driver_name 필드는 Erlang에서 드라이버를 로드할 때 사용되는 식별자를 지정한다. 통합을 완료하기 위해, DRIVER_INIT 매크로는 double_driver_entry 구조체에 대한 포인터를 반환함으로써 드라이버를 Erlang 런타임 시스템에 등록하며, 이를 통해 런타임 시스템이 드라이버를 효과적으로 인식하고 관리할 수 있게 한다.
링크드인 드라이버를 테스트하려면, 먼저 C 코드를 공유 라이브러리로 컴파일하세요:
gcc -o double_drv.so -fPIC -shared double_drv.c -I /path/to/erlang/erts/include
이 책의 devcontainer에서 코드를 실행하고 있다면:
cd /code/c_chapter
gcc -o double_drv.so -fPIC -shared -I /usr/local/lib/erlang/usr/include/ double_drv.c
Erlang에서 드라이버를 로드합니다:
Erlang 셸을 시작합니다:
erl
드라이버를 로드합니다:
1> c(double).
{ok,double}
2> double:start().
true
기능 호출:
3> double:double(21).
42
드라이버를 중지합니다:
4> double:stop().
stop
5>
16.3. 내장 함수(BIFs)
내장 함수(BIFs)는 BEAM 가상 머신에 통합된 사전 정의된 네이티브 함수의 집합입니다. 이들은 큰 수(bignum) 산술 연산, 프로세스 관리, 리스트 및 튜플과 같은 데이터 타입 처리 등 Erlang 내의 일부 기본 연산을 구현합니다. BIF는 C로 구현되어 있습니다.
BEAM 명령어, BIF, 연산자, 그리고 라이브러리 함수 간의 차이점을 명확하게 이해하는 것이 좋습니다.
BEAM 명령어는 BEAM 가상 머신이 실행하는 명령어입니다. BEAM 명령어의 코드는 보통 작은 크기로 유지되며, 이후 Erlang 버전에서는 JIT 로더에 의해 인라인될 수 있습니다. (이에 대한 자세한 내용은 JIT 장에서 다룹니다).
BIF는 C로 구현되어 있으며 BEAM 가상 머신의 일부입니다. 이들은 보통 BEAM 명령어에 비해 상당히 큰 bignum '+' 같은 연산자이거나, 성능상의 이유로 C로 구현된 lists:reverse/1과 같은 라이브러리 함수입니다. 일부 BIF는 순수하게 Erlang으로 구현하기에는 까다로운 필수적인 저수준 기능을 제공합니다. 예를 들어 프로세스 간에 메시지를 보내는 데 사용되는 erlang:send/2와 같은 것입니다.
연산자는 라이브러리 함수로 매핑되는 구문상의 언어 구성 요소입니다. 예를 들어 연산자 ``는 컴파일러에 의해 ``erlang:/2로 매핑됩니다. 이 함수들은 복잡도에 따라 BEAM 명령어 또는 BIF로 구현될 수 있습니다.
라이브러리 함수는 Erlang 언어의 일부인 lists:length/1와 같은 기본적인 Erlang 함수입니다. 이들은 BEAM 명령어, BIF, 또는 Erlang으로 구현될 수 있다.
BIF는 C로 작성되어 BEAM 내에서 성능에 최적화되어 있으므로 효율적입니다. BIF의 단점은 장시간 실행되는 BIF가 BEAM 스케줄러를 블로킹하여 시스템 응답성에 영향을 줄 수 있다는 것입니다. BIF는 런타임 시스템 내에 고정되어 있으므로 확장성이 제한적입니다.
대부분의 Erlang 사용자는 BIF를 작성할 필요가 없겠지만, BIF가 무엇이고 어떻게 동작하는지 아는 것은 좋습니다. 또한 EEP(Erlang Enhancement Proposal)를 작성하고 있다면 새로운 기능을 구현하기 위해 BIF를 작성해야 할 수도 있습니다.
다음 섹션에서는 BIF를 구현하는 방법에 대한 세부 사항을 살펴보겠습니다.
16.3.1. 구현 단계
-
BIF 생성하기:
-
BEAM의 메모리 및 프로세스 안전성을 위한 내부 API를 준수하여 C로 함수를 작성합니다.
-
해당 함수의 목적에 맞는 스레드 안전하고 효율적인 알고리즘을 구현하라.
-
해당 함수를 BEAM 소스 코드에 추가하고 런타임 시스템과 함께 컴파일하십시오.
-
BIF가 사용해야 할 리덕션 수를 함수의 시간 복잡도와 관련하여 계산하십시오.
-
함수가 오래 걸릴 수 있다면 양보(yield)할 수 있는 가능성을 추가하는 것을 고려하세요.
-
-
성능 고려 사항:
-
BIF 내에서 장시간 실행되는 작업을 피하세요.
-
시스템 안정성을 유지하기 위해 메모리 안전성과 Erlang 텀의 적절한 처리를 보장하십시오.
-
16.3.2. 구현 예제
숫자의 팩토리얼을 계산하는 간단한 BIF를 구현한다고 상상해 보자. 이 BIF는 다음을 수행한다:
-
입력 인수를 파싱하여 유효성을 확인합니다.
-
C로 효율적으로 연산을 수행합니다.
-
결과를 Erlang 환경으로 반환합니다.
ERTS에서 BIF가 정의된 코드를 찾아보세요: https://github.com/erlang/otp/blob/master/erts/emulator/beam/bif.c 또는 이 경우에는 https://github.com/erlang/otp/blob/master/erts/emulator/beam/erl_math.c 에서 찾을 수 있습니다.
BIF는 다음과 같이 정의되는 특별한 시그니처를 가집니다:
BIF_RETTYPE [NAME]_[ARITY](BIF_ALIST_[ARITY])
이름에는 모듈 이름과 함수 이름이 언더스코어로 구분되어 포함되어 있음에 유의하세요. 애리티(arity)는 함수가 받는 인수의 개수이다. BIF_ALIST_X 매크로는 인수를 정의하는 데 사용된다. BIF_RETTYPE 매크로는 함수의 반환 타입을 정의하는 데 사용됩니다.
코드 스니펫:
BIF_RETTYPE math_factorial_1(BIF_ALIST_1)
{
/* Calculate factorial = n!
for n >= 0
*/
int i, n, reds;
long factorial = 1;
Eterm result;
/* Check the argument and return an error if it is not a positive integer.
When defining a BIF with BIF_ALIST_X the arguments are named
BIF_ARG_1, BIF_ARG_2 etc.
To signal ann error use BIF_ERROR(BIF_P, BADARG);
The macro BIF_P is a pointer to the process struct (PCB) of the process
that called the BIF, containing all the information about the process,
such as heap pointers, stack pointers, registers etc.
*/
if (is_not_small(BIF_ARG_1) || (n = signed_val(BIF_ARG_1)) < 0)
{
BIF_ERROR(BIF_P, BADARG);
}
/* Calculate the factorial */
for (i = 1; i <= n; ++i) factorial *= i;
/* Convert the result to an Erlang term */
result = erts_make_integer(factorial, BIF_P);
/* Calculate reductions relative to the number of iterations of the calculation loop.*/
reds = n / 1000 + 1;
/* Return the result and a number of reductions.
The BIF_RET2 macro is used to return the result and the number of reductions.
The first argument is the result and the second is the number of reductions.
*/
BIF_RET2(result, reds);
}
이 간단한 예제는 큰 수를 잘 처리하지 못한다는 점에 유의하세요: factorial(66)은 64비트 정수('long')를 오버플로우시킵니다. 실제 구현에서는 bignum 라이브러리를 사용할 것이다. 다음 섹션에서 이를 수행하는 방법을 보여줄 것이다.
BIF를 bif.tab 파일(https://github.com/erlang/otp/blob/master/erts/emulator/beam/bif.tab)의 BIF 목록에 추가합니다.
bif math:factorial/1
BIF를 런타임의 다른 부분에서 사용할 수 있게 하려면, 예를 들어 bif.h에 헤더를 추가하는 것을 잊지 마세요.
그런 다음 Erlang 모듈 math.erl (https://github.com/erlang/otp/blob/master/lib/stdlib/src/math.erl)에 스텁을 추가합니다:
-doc "The factorial of `X`.".
-doc(#{since => <<"OTP 29.0">>}).
-spec factorial(X) -> integer() when
X :: pos_integer().
factorial(_) ->
erlang:nif_error(undef).
함수를 export하는 것을 잊지 마세요.
이제 BEAM을 다시 컴파일하고 Erlang에서 새로운 BIF를 실행할 수 있을 것입니다.
1> math:factorial(7).
5040
커널 모듈에 기능을 추가하려면 erl_prim_loader.c 파일에서 미리 로드된 모듈을 처리해야 합니다. 자세한 내용은 Chapter 9를 참고하십시오.
여러분의 머신이 처리할 수 있는 작은 정수보다 더 큰 숫자로 시도해 보면 잘못된 결과를 얻게 될 것입니다. bignum을 사용하는 더 복잡한 예제로 넘어가 봅시다.
BIF_RETTYPE math_factorial_1(BIF_ALIST_1)
{
/* Calculate factorial = n! for n >= 0 */
Sint64 i, n, reds;
Uint64 factorial = 1;
Eterm result;
Eterm *hp;
Eterm big_factorial;
ErtsDigit *temp_digits_a;
ErtsDigit *temp_digits_b;
ErtsDigit *src;
ErtsDigit *dest;
ErtsDigit *temp;
dsize_t temp_size;
dsize_t curr_size;
dsize_t new_size;
if (is_not_integer(BIF_ARG_1) || (!term_to_Sint64(BIF_ARG_1, &n)) || n < 0)
{
BIF_ERROR(BIF_P, BADARG);
}
/* We do not want to use heap space for intermediate results since we are
going to do a lot of them. So we use a temporary buffer.
We create two buffers, one to keep (n-1)! and one to keep n!.
*/
/* Initial allocation of buffers, this will be large enough for most n,
that is up to ~ 50.000. Then we will start increasing the buffer. */
temp_size = 2 * (n + 1);
temp_digits_a = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
temp_digits_b = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
src = temp_digits_a;
dest = temp_digits_b;
for (i = 1; i <= n; ++i)
{
/* We want to avoid heap allocation for small numbers.
These we do on the C stack and just return a small_int. */
if (!IS_USMALL(0, factorial * i))
{
// Initial conversion to bignum in our temp buffer
hp = (Eterm *)src;
big_factorial = uint_to_big(factorial, hp);
curr_size = BIG_SIZE(big_val(big_factorial));
for (; i <= n; ++i)
{
new_size = curr_size + 1;
if (new_size > temp_size)
{
dsize_t alloc_size = new_size * 2;
src = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, src, alloc_size * sizeof(ErtsDigit));
dest = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, dest, alloc_size * sizeof(ErtsDigit));
temp_size = alloc_size;
}
big_factorial = big_times_small(big_factorial, i, (Eterm *)dest);
temp = src;
src = dest;
dest = temp;
}
// We are done.
// Only now allocate on process heap and copy the final result
hp = HAlloc(BIF_P, BIG_SIZE(big_val(big_factorial)) + 1);
sys_memcpy(hp, big_val(big_factorial), (BIG_SIZE(big_val big_factorial)) + 1) * sizeof(Eterm));
result = make_big(hp);
erts_free(ERTS_ALC_T_TMP, temp_digits_a);
erts_free(ERTS_ALC_T_TMP, temp_digits_b);
goto done;
}
factorial *= i;
}
result = make_small(factorial);
done:
reds = n / 1000 + 1;
BIF_RET2(result, reds);
}
이제 1000!과 같이 정말 큰 팩토리얼을 계산할 수 있습니다:
1> math:factorial(1000).
402387260077093773543702433923003985719374864210714632543799910429938512398629020592044208486969404800479988610197196058631666872994808558901323829669944590997424504087073759918823627727188732519779505950995276120874975462497043601418278094646496291056393887437886487337119181045825783647849977012476632889835955735432513185323958463075557409114262417474349347553428646576611667797396668820291207379143853719588249808126867838374559731746136085379534524221586593201928090878297308431392844403281231558611036976801357304216168747609675871348312025478589320767169132448426236131412508780208000261683151027341827977704784635868170164365024153691398281264810213092761244896359928705114964975419909342221566832572080821333186116811553615836546984046708975602900950537616475847728421889679646244945160765353408198901385442487984959953319101723355556602139450399736280750137837615307127761926849034352625200015888535147331611702103968175921510907788019393178114194545257223865541461062892187960223838971476088506276862967146674697562911234082439208160153780889893964518263243671616762179168909779911903754031274622289988005195444414282012187361745992642956581746628302955570299024324153181617210465832036786906117260158783520751516284225540265170483304226143974286933061690897968482590125458327168226458066526769958652682272807075781391858178889652208164348344825993266043367660176999612831860788386150279465955131156552036093988180612138558600301435694527224206344631797460594682573103790084024432438465657245014402821885252470935190620929023136493273497565513958720559654228749774011413346962715422845862377387538230483865688976461927383814900140767310446640259899490222221765904339901886018566526485061799702356193897017860040811889729918311021171229845901641921068884387121855646124960798722908519296819372388642614839657382291123125024186649353143970137428531926649875337218940694281434118520158014123344828015051399694290153483077644569099073152433278288269864602789864321139083506217095002597389863554277196742822248757586765752344220207573630569498825087968928162753848863396909959826280956121450994871701244516461260379029309120889086942028510640182154399457156805941872748998094254742173582401063677404595741785160829230135358081840096996372524230560855903700624271243416909004153690105933983835777939410970027753472000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
심지어 100000!도 계산할 수 있지만 시간이 좀 걸릴 것입니다. BEAM 스케줄러를 블록하지 않기 위해 우리는 이따금씩 양보해야 합니다.
우리는 저장하고 싶은 상태와 돌아가고 싶은 함수와 함께 BIF_TRAP1을 호출함으로써 이를 수행할 수 있습니다. 이 경우에는 math_factorial_trap_1 함수로 되돌아가고자 합니다.
저장하고자 하는 상태와 돌아갈 함수를 담을 구조체를 math_factorial_trap_export에 정의해야 합니다. 또한 이 export 구조체를 초기화해야 합니다. 이 작업은 math_factorial_trap_init 함수에서 erts_init_trap_export를 호출하여 수행합니다. 그런 다음 erl_init.c의 erl_init 함수에서 math_factorial_trap_init을 호출하도록 추가해야 합니다. 또한 새 아톰 am_math_factorial_trap을 atom.names에 추가해야 합니다.
우리가 양보할 때, 현재 i, 현재 factorial, 그리고 원래의 n을 담은 튜플에 상태를 저장합니다. 그래서 트랩 함수의 인자는 이 값들을 담은 튜플입니다. 원래의 BIF math_factorial_1에서 우리는 이 튜플을 생성하고 math_factorial_trap_1을 호출합니다.
이제 yield에서 돌아왔을 때 이미 bignum이 있을 수 있으므로, 이 경우를 처리하기 위해 코드를 약간 재구성합니다.
반복자를 확인하고 1000번 반복할 때마다 상태를 저장하고 양보(yield)합니다. 또한 빅넘(bignum)이 있을 때도 상태를 저장하고 양보(yield)합니다.
static Export math_factorial_trap_export;
static BIF_RETTYPE math_factorial_trap_1(BIF_ALIST_1)
{
Sint64 n, i;
Uint64 factorial;
Eterm big_factorial = THE_NON_VALUE;
ErtsDigit *temp_digits_a = NULL;
ErtsDigit *temp_digits_b = NULL;
ErtsDigit *src;
ErtsDigit *dest;
ErtsDigit *temp;
dsize_t temp_size;
dsize_t curr_size;
dsize_t new_size;
dsize_t big_size;
Eterm result;
Eterm *hp;
Eterm *big_hp;
Eterm *tp = tuple_val(BIF_ARG_1);
Eterm state;
// Extract current state
i = signed_val(tp[1]); // Current i
n = signed_val(tp[3]); // Original n
temp_size = (n * 4);
temp_digits_a = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
temp_digits_b = (ErtsDigit *)erts_alloc(ERTS_ALC_T_TMP, temp_size * sizeof(ErtsDigit));
src = temp_digits_a;
dest = temp_digits_b;
// Handle both small and bignum factorial cases
if (is_small(tp[2]))
{
factorial = unsigned_val(tp[2]);
}
else
{
// We're already in bignum territory, skip the small number loop
big_factorial = tp[2];
goto bignum_loop;
}
for (; i <= n; ++i)
{
if (!IS_USMALL(0, factorial * i))
{
// Initial conversion to bignum in our temp buffer
big_factorial = uint_to_big(factorial, (Eterm *)src);
goto bignum_loop;
}
factorial *= i;
}
if (temp_digits_a)
erts_free(ERTS_ALC_T_TMP, temp_digits_a);
if (temp_digits_b)
erts_free(ERTS_ALC_T_TMP, temp_digits_b);
BIF_RET(make_small(factorial));
bignum_loop:
// Continue multiplying using bignums
for (; i <= n; ++i)
{
curr_size = BIG_SIZE(big_val(big_factorial));
new_size = curr_size + 1;
if (new_size > temp_size)
{
dsize_t alloc_size = new_size * 2;
src = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, src,
alloc_size * sizeof(ErtsDigit));
dest = (ErtsDigit *)erts_realloc(ERTS_ALC_T_TMP, dest,
alloc_size * sizeof(ErtsDigit));
temp_size = alloc_size;
}
big_factorial = big_times_small(big_factorial, i, (Eterm *)dest);
if ((i % 1000) == 0)
{
// Increment i so we don't get stuck in a loop. with i % 1000 == 0.
// Save state: {(i+1), i!, n} and yield.
// Store the tuple with the state on the heap
big_size = BIG_SIZE(big_val(big_factorial));
hp = HAlloc(BIF_P, 4 + big_size + 1);
big_hp = hp + 4;
sys_memcpy(big_hp, big_val(big_factorial), (big_size + 1) * sizeof(Eterm));
state = TUPLE3(hp, make_small(i + 1), make_big(big_hp), tp[3]);
// Free the temporary buffers
erts_free(ERTS_ALC_T_TMP, temp_digits_a);
erts_free(ERTS_ALC_T_TMP, temp_digits_b);
// Yield giving the state to the trap function
BIF_TRAP1(&math_factorial_trap_export, BIF_P, state);
}
temp = src;
src = dest;
dest = temp;
}
hp = HAlloc(BIF_P, BIG_SIZE(big_val(big_factorial)) + 1);
sys_memcpy(hp, big_val(big_factorial),
(BIG_SIZE(big_val(big_factorial)) + 1) * sizeof(Eterm));
result = make_big(hp);
erts_free(ERTS_ALC_T_TMP, temp_digits_a);
erts_free(ERTS_ALC_T_TMP, temp_digits_b);
BIF_RET(result);
}
BIF_RETTYPE math_factorial_1(BIF_ALIST_1)
{
Sint64 n;
Eterm *hp;
Eterm state;
Eterm args[1];
if (is_not_integer(BIF_ARG_1) || (!term_to_Sint64(BIF_ARG_1, &n)) || n < 0)
{
BIF_ERROR(BIF_P, BADARG);
}
// Create initial state
hp = HAlloc(BIF_P, 4);
state = TUPLE3(hp, make_small(1), make_small(1), BIF_ARG_1); // {i, factorial, n}
// Call the continuation function with initial state
args[0] = state;
return math_factorial_trap_1(BIF_P, args, A__I);
}
void erts_init_math_factorial(void)
{
erts_init_trap_export(&math_factorial_trap_export,
am_erts_internal, am_math_factorial_trap, 1,
&math_factorial_trap_1);
return;
}
이제 BEAM 스케줄러를 블로킹하지 않고도 정말 큰 팩토리얼을 처리할 수 있습니다.
16.3.3. 어느 고요한 밤: term_to_binary와 양보하지 않는 BIF에 관한 이야기
조용한 밤이었다, 시스템의 모든 것이 완벽하게 돌아가는 것처럼 보이는 그런 드문 순간들 중 하나였다. 그러나 그때, 아무런 예고도 없이 상상할 수 없는 일이 벌어졌다—시스템이 크래시했다.
사후 조사에서 특이한 일련의 사건이 밝혀졌다. 작업과 휴면을 우아하게 균형 잡도록 설계된 BEAM 스케줄러들이 작업 부하가 줄어들자 너무 성급하게 종료되어 버렸다. 단 하나의 스케줄러만 활성 상태로 남아 시스템의 모든 작업을 처리하고 있었다.
운명의 장난인지, 이 외로운 스케줄러는 로깅 목적으로 4MB 텀을 바이너리로 변환하는 작업을 우연히 맡게 되었다. 언뜻 일상적으로 보이는 이 작업은 term_to_binary 함수—내장 함수(BIF)—를 호출했다. 작업을 관리하기 쉬운 단위로 나누는 대신, 그것은 완료될 때까지 모든 작업을 수행한 뒤 겨우 20 리덕션만을 사용했다고 보고했다.
이 양보하지 않는 특성은 치명적인 결함으로 드러났다. 단일 스케줄러는 완전히 점유되어 다른 작업을 처리하거나 시스템의 요구에 응답할 수 없게 되었다. 항상 경계를 늦추지 않는 Erlang의 HEART 메커니즘은 무응답 상태를 감지하고 해당 노드가 복구 불가능하다고 판단했다. 냉혹할 정도로 효율적으로, 그것은 노드를 종료시켰다.
문제 해결하기
수정 1: 스케줄러를 계속 실행 상태로 유지
첫 번째 단계는 활동이 적은 기간에도 스케줄러가 계속 활성 상태를 유지하도록 보장하는 것이었습니다. 2013년 6월에 도입된 이 수정 사항은 Erlang의 시작 플래그를 조정하는 것을 포함했습니다:
-
+sfwi 50: 스케줄러가 응답성을 유지하도록 강제 웨이크업 간격을 조정했습니다. -
+scl false: 스케줄러 압축을 방지하여 균등한 작업 부하 분산을 유지했습니다. -
+sub true: 시스템 전체에 걸쳐 사용률이 분산되도록 보장했습니다.
이러한 조정은 스케줄러에 새로운 활력을 불어넣어, 활동이 뜸한 시기에도 견고하게 대응할 수 있게 만들었습니다.
스케줄러 설정을 조정하는 방법에 대한 자세한 내용은 Chapter 10을 참조하세요.
수정 2: term_to_binary 다시 작성하기
두 번째, 더 복잡한 수정은 term_to_binary 함수 자체를 대상으로 했습니다. 개발자들은 BIF가 주기적으로 양보(yield)하도록 다시 작성하여, 작업 부하를 여러 리덕션에 걸쳐 분산시켰습니다. 이 접근 방식은 해당 함수가 스케줄러와 원활하게 협력하도록 보장하여, 시스템 자원의 장기적인 독점을 방지했습니다.
같은 시기에 구현된 수정 사항은 해당 함수에 청크 단위 프로세스 처리를 도입했습니다. 이는 또한 BIF 내부의 가비지 컬렉션 발전에 의존했습니다. https://github.com/erlang/otp/commit/47d6fd3ccf35a4d921591dd0a9b5e69b9804b5b0 참조
16.3.4. 내장 함수(BIF) 요약
대부분의 개발자는 BIF의 코드를 작성하거나 읽을 일이 결코 없을 것입니다. 하지만 BIF가 무엇이고 어떻게 동작하는지 아는 것은 좋습니다. 또한 일부 표준 라이브러리의 Erlang 코드를 살펴본 적이 있다면, 왜 그 코드에 실제 구현 없이 스텁만 있는지 이제는 알 수 있을 것입니다.
16.4. NIF
Erlang 개발자에게 약간 더 흔하고 더 안전한 작업은 NIF(Native Implemented Functions)를 작성하는 것입니다. NIF는 C나 C++(또는 다른 언어)로 구현되어 Erlang 코드에서 호출되는 함수입니다. NIF는 성능이 중요한 연산이나 외부 라이브러리와의 인터페이싱을 위해 네이티브 코드로 Erlang을 동적으로 확장하는 방법을 제공합니다. 부적절하게 사용하면 여전히 VM을 불안정하게 만들 수 있으므로, 신중하게 사용해야 합니다.
NIF를 사용하는 방법은 오늘날 Interoperability Tutorial의 NIF section에 잘 문서화되어 있지만, 여기서는 기본 사항을 살펴보겠습니다.
16.4.1. 간단한 NIF 구현하기
빠른 정수 덧셈을 위한 간단한 C 기반 NIF:
C 코드 (nif_add.c):
#include "erl_nif.h"
static ERL_NIF_TERM nif_add(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[])
{
int a, b;
if (!enif_get_int(env, argv[0], &a) || !enif_get_int(env, argv[1], &b)) {
return enif_make_badarg(env);
}
return enif_make_int(env, a + b);
}
static ErlNifFunc nif_funcs[] = {
{"add", 2, nif_add}
};
ERL_NIF_INIT(my_nif, nif_funcs, NULL, NULL, NULL, NULL);
Erlang 래퍼 모듈 (my_nif.erl):
-module(my_nif).
-export([add/2, load/0]).
-on_load(load/0).
load() ->
erlang:load_nif("./my_nif", 0).
add(_A, _B) ->
erlang:nif_error("NIF not loaded").
NIF 컴파일 및 실행:
gcc -shared -fPIC -o my_nif.so nif_add.c -I /usr/lib/erlang/usr/include
그러면 Erlang에서는:
> my_nif:add(10, 20).
30
BEAM 스케줄러가 차단되지 않도록 NIF 실행을 짧게 유지하세요. 성능이 중요한 작업과 외부 라이브러리와의 연동에 NIF를 사용하세요. 오래 실행되는 작업에는 더티 스케줄러를 사용하세요. 항상 크래시를 방지하기 위해 입력을 검증하세요.
II: Running ERTS
17. 운영 및 유지보수
런타임 시스템 설계의 기본 원칙 중 하나는 버그가 어느 정도 불가피하다는 것이다. 엄청난 노력을 기울여 버그 없는 애플리케이션을 구축하는 데 성공하더라도, 곧 세상이나 사용자가 변하고 애플리케이션을 "수정"해야 한다는 사실을 알게 될 것입니다.
Erlang 런타임 시스템은 변경을 용이하게 하고 버그의 영향을 최소화하도록 설계되었습니다.
버그의 영향은 구획화를 통해 최소화된다. 이는 각 데이터 구조가 분리되어 있고 불변인 가장 낮은 수준에서부터, 실행 중인 시스템이 별도의 노드로 나뉘는 가장 높은 수준에 이르기까지 적용된다. 코드 업그레이드를 쉽게 하고, 실행 중인 시스템과 상호작용하며 이를 검사할 수 있게 함으로써 변경이 용이해집니다.
17.1. 시스템에 연결하기
실행 중인 시스템을 모니터링하고 유지 관리하는 다양한 방법을 살펴보겠습니다. 사용할 수 있는 도구와 기법은 많지만, 가장 기본적인 도구인 셸과 셸을 노드에 연결하는 기능을 잊어서는 안 됩니다.
두 노드를 연결하려면 쿠키라고 하는 비밀 암호를 공유하거나 알고 있어야 합니다. 두 노드를 같은 머신에서 실행하고 같은 사용자가 그것들을 시작하는 한, 쿠키는 자동으로 공유됩니다 ($HOME/.erlang.cookie 파일에 저장됨).
우리는 두 개의 노드, 즉 하나의 Erlang 노드와 하나의 Elixir 노드를 시작하여 이것이 동작하는 것을 확인할 수 있습니다. 먼저 node1이라는 이름의 Erlang 노드를 시작합니다. 이 노드에는 연결된 노드가 없다는 것을 확인할 수 있습니다:
$ erl -sname node1
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
[async-threads:10] [kernel-poll:false]
Eshell V8.1 (abort with ^G)
(node1@GDC08)1> nodes().
[]
(node1@GDC08)2>
그런 다음 다른 터미널 창에서 node2라는 Elixir 노드를 시작합니다. (Elixir에서는 명령줄 플래그를 이중 대시로 지정해야 한다는 점에 유의하세요!)
$ iex --sname node2
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
[async-threads:10] [kernel-poll:false]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(node2@GDC08)1>
Elixir에서는 Node.connect(name) 명령을 실행하여 노드를 연결할 수 있습니다. Erlang에서는 net_kernel:connect_node(Name)으로 이 작업을 수행합니다. 노드 연결은 양방향이므로 노드 중 하나에서만 명령을 실행하면 됩니다.
iex(node2@GDC08)1> Node.connect :node1@GDC08
true
iex(node2@GDC08)2> Node.list
[:node1@GDC08]
iex(node2@GDC08)3>
그리고 Erlang 쪽에서는 이제 두 노드가 서로를 알고 있음을 확인할 수 있습니다. (nodes()의 결과에는 로컬 노드 자신은 포함되지 않는다는 점에 유의하세요.)
(node1@GDC08)2> nodes().
[node2@GDC08]
(node1@GDC08)3>
분산 환경에서는 모든 노드가 쿠키를 알고 있거나 공유하고 있는지 확인해야 하므로 이는 다소 더 복잡합니다. 이는 세 가지 방법으로 수행할 수 있습니다. 특정 노드와 통신할 때 사용하는 쿠키를 설정할 수도 있고, 시작 시 -set_cookie 매개변수로 모든 시스템에 동일한 쿠키를 설정할 수도 있으며, 각 머신에서 시스템을 실행하는 사용자의 홈 디렉터리에 .erlang.cookie 파일을 복사할 수도 있습니다.
마지막 대안인, 시스템 내 각 머신의 쿠키 파일에 동일한 쿠키를 두는 방법은 로컬 OS 셸에서 노드로 연결하기 쉽게 해주기 때문에 일반적으로 가장 좋은 선택입니다. VPN이나 ssh를 통해 머신에 로그인하는 안전한 방법을 설정하기만 하면 됩니다.
별도의 머신에 노드를 하나 생성하고, 현재 실행 중인 노드들에 연결해 보겠습니다. 세 번째 터미널 창을 열어 로컬 쿠키 파일의 내용을 확인한 다음, 다른 머신으로 ssh 접속합니다:
happi@GDC08:~$ cat ~/.erlang.cookie
pepparkaka
happi@GDC08:~$ ssh gds01
happi@gds01:~$
우리는 원격 머신에서 노드를 실행하면서 동일한 쿠키 암호구를 사용하도록 지정하고, 그런 다음 기존 노드에 연결할 수 있습니다. (Erlang이 이 노드들을 찾을 수 있도록 node1@GDC08을 지정해야 한다는 점에 유의하세요.)
happi@gds01:~$ erl -sname node3 -setcookie pepparkaka
Erlang/OTP 18 [erts-7.3] [source-d2a6d81] [64-bit] [smp:8:8]
[async-threads:10] [kernel-poll:false]
Eshell V7.3 (abort with ^G)
(node3@gds01)1> net_kernel:connect('node1@GDC08').
true
(node3@gds01)2> nodes().
[node1@GDC08,node2@GDC08]
(node3@gds01)3>
우리가 노드 2와 명시적으로 통신하지 않았음에도 불구하고, 다음에서 볼 수 있듯이 노드 2는 노드 3이 참여했다는 사실을 자동으로 통보받았습니다:
iex(node2@GDC08)3> Node.list
[:node1@GDC08,:node3@gds01]
iex(node2@GDC08)4>
마찬가지로, 노드 중 하나를 종료하면 나머지 노드들은 자동으로 해당 노드를 목록에서 제거합니다. 노드가 재시작되면, 다시 간단히 네트워크에 재합류할 수 있습니다.
|
다른 쿠키로 인한 잠재적 문제
Erlang 분산의 기본 동작은 완전 연결 네트워크(fully connected network)를 생성하는 것임을 유의하십시오. 즉, 모든 노드가 네트워크 내의 다른 모든 노드와 연결됩니다. 각 노드가 자체 쿠키를 가지고 있다면, 노드들을 연결하기 전에 각 노드에 다른 모든 노드의 쿠키를 알려주어야 합니다. 시스템이 완전 연결 네트워크를 만들려고 시도하는 것을 막기 위해 |
이제 서로 다른 머신에 있는 노드들도 서로 연결하는 방법을 알았으니, 셸을 노드에 연결하는 방법을 살펴볼 수 있습니다.
17.2. 셸
Elixir와 Erlang 셸은 컴퓨터의 셸이나 터미널 창과 거의 동일한 방식으로 작동하지만, 런타임 시스템으로 직접 연결되는 터미널 창을 제공한다는 점이 다릅니다. 이를 통해 런타임에 대한 완전한 접근 권한을 가진 CLI라는 매우 강력한 도구를 얻게 됩니다. 이는 운영 및 유지보수에 있어 환상적입니다.
이 절에서는 셸을 통해 노드에 연결하는 다양한 방법과, 셸의 잘 알려지지 않았지만 더 강력한 기능 몇 가지를 살펴보겠습니다.
17.2.1. 셸 설정하기
Elixir 셸과 Erlang 셸 모두 자주 사용하는 함수에 대한 단축키를 제공하도록 설정할 수 있습니다.
Elixir 셸은 .iex.exs 파일을 먼저 로컬 디렉터리에서, 그다음 사용자 홈 디렉터리에서 찾습니다. 이 파일의 코드는 셸 프로세스에서 실행되며, 모든 변수 바인딩은 셸에서 사용할 수 있습니다.
이 파일에서 구문 강조(syntax coloring) 및 히스토리 크기와 같은 항목을 설정할 수 있습니다. 전체 문서는 hexdocs를 참고하세요.
셸 컨텍스트에서 임의의 코드를 실행할 수도 있습니다.
Erlang 런타임 시스템이 시작될 때, 가장 먼저 Erlang 설정 파일의 코드를 해석합니다. 이 파일의 기본 위치는 사용자 홈 디렉토리인 ~/.erlang입니다. 이 파일은 임의의 Erlang 표현식을 포함할 수 있으며, 각 표현식은 마침표와 줄바꿈으로 끝나야 합니다.
이 파일은 일반적으로 Erlang 모듈을 로드하기 위해 코드 경로에 디렉터리를 추가하는 데 사용됩니다:
code:add_path("/home/happi/hacks/ebin").
Erlang 셸을 사용자 정의 함수로 확장하는 데 사용할 수 있는 사용자 정의 user_default 모듈(.beam 파일)을 로드하기 위해서도 사용됩니다:
code:load_abs("/home/happi/.config/erlang/user_default").
위의 경로를 여러분 자신의 시스템에 맞게 바꾸기만 하면 됩니다. load_abs 명령어에 .beam 확장자를 포함하지 마세요.
셸에서 모듈 이름을 지정하지 않고 함수를 호출하면, 예를 들어 foo(bar)처럼, 먼저 user_default 모듈에서(있는 경우) 함수를 찾고, 그다음 shell_default 모듈(stdlib의 일부)에서 찾습니다. 이것이 ls()와 help() 같은 셸 명령이 구현되는 방식이며, 여러분은 자유롭게 자신만의 명령을 추가하거나 기존 명령을 재정의할 수 있습니다.
17.2.2. 셸을 노드에 연결하기
프로덕션 시스템을 실행할 때는 run_erl을 통해 노드를 데몬 모드로 시작하고 싶을 것입니다. 이를 수행하는 방법과 배포 및 프로덕션 운영에 대한 몇 가지 모범 사례는 [xxx](#ch.live)에서 다룰 것입니다. 다행히 기본 셸이 없음을 의미하는 데몬 모드로 시스템을 시작한 경우에도, 다른 셸을 시스템에 연결할 수 있습니다. 실제로 그렇게 하는 방법은 여러 가지가 있습니다. 이러한 방법 대부분은 일반적인 분산 메커니즘에 의존하므로, 이전 절에서 설명한 것처럼 두 머신에 동일한 Erlang 쿠키가 있어야 합니다.
원격 셸(Remsh)
Erlang 노드에 연결하는 가장 쉽고 아마도 가장 일반적인 방법은 원격 셸을 통해 시스템 노드에 연결되는 이름 있는 노드를 시작하는 것입니다. 이는 erl 명령줄 플래그 -remsh NodeName으로 수행됩니다. 다른 노드에 연결하려면 이름이 지정된 노드를 실행 중이어야 한다는 점에 유의하십시오. -remsh를 사용할 때 -name 또는 -sname 플래그를 지정하지 않으면, Erlang은 새 노드에 임의의 이름을 생성합니다. 어느 경우든, 여러분의 셸이 원격 노드에 직접 연결되기 때문에 일반적으로 이 이름이 출력되는 것을 볼 수 없습니다. 예를 들어:
$ erl -sname node4 -remsh node2
Erlang/OTP 18 [erts-7.3] [source-d2a6d81] [64-bit] [smp:8:8]
[async-threads:10] [kernel-poll:false]
Eshell V7.3 (abort with ^G)
(node2@GDC08)1>
또는 Elixir를 사용하는 경우:
$ iex --sname node4 --remsh node2
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
[async-threads:10] [kernel-poll:false]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iex(node2@GDC08)1>
Erlang 노드는 일반적으로 Elixir 노드(위의 node2)에서 셸을 시작할 수 있지만, Erlang 노드(node1)에서 Elixir 셸을 시작하는 것은 작동하지 않는데, Erlang 노드에 필요한 Elixir 라이브러리가 없기 때문입니다:
$ iex --remsh node1
Could not start IEx CLI due to reason: nofile
$
|
기본 보안 없음
일반 Erlang 분산이나 리모트 셸 구현 어느 쪽에도 보안 기능이 내장되어 있지 않습니다. 시스템 노드를 인터넷에 노출된 상태로 두어서는 안 되며, 개발 머신의 노드에서 운영 노드로 연결하는 것도 바람직하지 않습니다. 일반적으로 VPN 터널이나 배스천 호스트를 통한 ssh를 통해 라이브 환경에 접근하여, 라이브 노드 중 하나를 실행하는 머신에 로그인할 수 있습니다. 그런 다음 그곳에서 |
원격 셸을 시작할 때 실제로는 두 개의 노드가 관련되어 있다는 점을 이해하는 것이 중요합니다. 로컬 노드는 이전 예제에서 node4라는 이름이었고, 원격 노드는 node2입니다. 이 노드들은 같은 머신에 있을 수도 있고 서로 다른 머신에 있을 수도 있습니다. 로컬 노드는 remsh와 함께 iex 또는 erl 명령을 입력한 머신에서 항상 실행됩니다. 로컬 노드에는 터미널 창과 상호작용하는 tty 프로그램을 실행하는 프로세스가 있습니다. 실제 셸 프로세스는 원격 노드에서 실행됩니다. 이는 우선, 실행하려는 셸(IEx 또는 Erlang 셸)의 코드가 원격 노드에 존재해야 함을 의미합니다. 또한 이는 코드가 원격 노드에서 실행됨을 의미합니다. 또한 이는 셸의 기본 설정이 원격 머신의 설정에서 가져온다는 것을 의미하기도 합니다.
우리의 홈 디렉토리에 GDC08이라는 머신에 다음과 같은 .erlang 파일이 있다고 상상해 보자.
code:load_abs("/home/happi/.config/erlang/user_default").
io:format("ERTS is starting in ~s~n",[os:cmd("pwd")]).
그리고 user_default.erl 파일은 다음과 같습니다:
-module(user_default). -export([tt/0]). tt() -> test.
그런 다음 우리는 ~/example/dir1와 ~/example/dir2라는 두 개의 디렉터리를 생성하고, 그 디렉터리에 서로 다른 두 개의 .iex.exs 파일을 넣어서 각각 프롬프트가 <d1> 또는 <d2>를 보여주도록 설정합니다.
# File 1
IO.puts "iEx starting in "
pwd()
IO.puts "iEx starting on "
IO.puts Node.self
IEx.configure(
colors: [enabled: true],
alive_prompt: [
"\e[G",
"(%node)",
"%prefix",
"<d1>",
] |> IO.ANSI.format |> IO.chardata_to_string
)
# File 2
IO.puts "iEx starting in "
pwd()
IO.puts "iEx starting on "
IO.puts Node.self
IEx.configure(
colors: [enabled: true],
alive_prompt: [
"\e[G",
"(%node)",
"%prefix",
"<d2>",
] |> IO.ANSI.format |> IO.chardata_to_string
)
이제 이 디렉터리들에서 네 개의 서로 다른 노드를 시작하면 셸 설정이 어떻게 로드되는지 확인할 수 있습니다. 먼저 dir1의 node1:
GDC08:~/example/dir1$ iex --sname node1
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit]
[smp:4:4] [async-threads:10] [kernel-poll:false]
ERTS is starting in /home/happi/example/dir1
on [node1@GDC08]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iEx starting in
/home/happi/example/dir1
iEx starting on
node1@GDC08
(node1@GDC08)iex<d1>
그 다음 dir2의 node2:
GDC08:~/example/dir2$ iex --sname node2
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit]
[smp:4:4] [async-threads:10] [kernel-poll:false]
ERTS is starting in /home/happi/example/dir2
on [node2@GDC08]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iEx starting in
/home/happi/example/dir2
iEx starting on
node2@GDC08
(node2@GDC08)iex<d2>
그런 다음 dir1에 있는 node3지만, node2에서 원격 셸을 실행하면:
GDC08:~/example/dir1$ iex --sname node3 --remsh node2@GDC08
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
[async-threads:10] [kernel-poll:false]
ERTS is starting in /home/happi/example/dir1
on [node3@GDC08]
Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help)
iEx starting in
/home/happi/example/dir2
iEx starting on
node2@GDC08
(node2@GDC08)iex<d2>
보다시피 원격 셸은 dir2에서 시작되었는데, 이는 node2의 디렉터리이기 때문입니다. 마지막으로, Erlang 노드를 시작하고 user_default 모듈에 정의한 함수를 셸에서 호출할 수 있는지 확인합니다:
GDC08:~/example/dir2$ erl -sname node4
Erlang/OTP 19 [erts-8.1] [source-0567896] [64-bit] [smp:4:4]
[async-threads:10] [kernel-poll:false]
ERTS is starting in /home/happi/example/dir2
on [node4@GDC08]
Eshell V8.1 (abort with ^G)
(node4@GDC08)1> tt().
test
(node4@GDC08)2>
위 예시에서 볼 수 있듯이, 이러한 셸 설정은 셸을 실행하는 노드로부터 로드됩니다. 만약 다른 머신에 있는 노드에 연결한다면, 이러한 설정은 존재하지 않을 것입니다.
시작할 때 -remsh 플래그를 전달하는 것이 원격 셸을 실행하는 유일한 방법은 아닙니다. 작업 제어 모드로 들어감으로써 실행 중에 연결된 노드와 셸을 실제로 변경할 수 있습니다.
작업 제어 모드
ctrl+g를 누르면 작업 제어 모드(JCL)로 진입합니다. 그런 다음 또 다른 프롬프트가 나타납니다:
User switch command -->
h (엔터를 입력)를 입력하면 JCL에서 사용 가능한 명령어에 대한 도움말을 볼 수 있습니다:
c [nn] - connect to job i [nn] - interrupt job k [nn] - kill job j - list all jobs s [shell] - start local shell r [node [shell]] - start remote shell q - quit erlang ? | h - this message
여기서 흥미로운 명령어는 원격 셸을 시작하는 r 명령어입니다. 실행하려는 셸의 이름을 지정할 수 있는데, 기본값이 표준 Erlang 셸이므로 Elixir 셸을 시작하려면 이 이름이 필요합니다. 새 작업(즉, 새 셸)을 시작하고 나면 c 명령으로 해당 작업에 연결해야 합니다. j로 모든 작업을 나열할 수도 있습니다.
(node2@GDC08)iex<d2> User switch command --> r node1@GDC08 'Elixir.IEx' --> c Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help) iEx starting in /home/happi/example/dir1 iEx starting on node1@GDC08
매우 오래 실행되는 명령을 시작해 놓고 그 동안 다른 작업을 하고 싶다면, s로 새 로컬 셸을 시작하여 그쪽에 연결하는 것이 유용합니다. 명령이 멈춘 것처럼 보이면 i로 중단시키거나, k로 해당 셸을 종료하고 새로 시작할 수 있습니다.
JCL 모드에 대한 전체 설명은 Erlang Shell 매뉴얼을 참고하세요.
ctrl+g q [enter]를 입력하여 세션을 종료할 수 있습니다. 이렇게 하면 로컬 노드가 종료됩니다. q()., halt(), init:stop(), 또는 System.halt 중 어느 것으로도 종료하고 싶지 않을 것입니다. 이들 모두는 원격 노드를 종료시키는데, 라이브 서버에 연결되어 있을 때 이는 좀처럼 여러분이 원하는 것이 아닙니다. 대신 ctrl+\, ctrl+c ctrl+c, ctrl+g q [enter] 또는 ctrl+c a [enter]를 사용하세요.
두 개의 Erlang 런타임 시스템 인스턴스를 실행해야 하는 원격 셸을 사용하고 싶지 않다면, 노드에 연결할 수 있는 다른 두 가지 방법이 실제로 존재합니다. Unix 파이프를 통해 연결하거나 ssh를 통해 직접 연결할 수도 있지만, 이 두 가지 방법 모두 연결하려는 노드를 특별한 방식으로 시작하거나 ssh 서버를 시작하여 사전에 준비해 두어야 합니다.
파이프를 통한 연결
run_erl 명령을 통해 노드를 시작하면 IO를 위한 네임드 파이프를 얻게 되며, 완전히 새로운 노드를 시작할 필요 없이 그 파이프에 셸을 연결할 수 있습니다. 다음 장에서 살펴보겠지만, Erlang을 데몬 모드로 시작하는 것 대신 run_erl을 사용하면 표준 입출력과 표준 에러 출력을 잃지 않는 등의 몇 가지 장점이 있습니다.
run_erl 명령은 파이프를 구현하는 유닉스 계열 운영 체제에서만 사용할 수 있습니다. run_erl(으)로 시스템을 시작하면 다음과 같습니다:
> run_erl -daemon log/erl_pipe log "erl -sname node1"
또는
> run_erl -daemon log/iex_pipe log "iex --sname node2"
그런 다음 명명된 파이프(run_erl의 첫 번째 인자)를 통해 시스템에 연결할 수 있습니다.
> to_erl dir1/iex_pipe
iex(node2@GDC08)1>
EOF(ctrl+d)를 보내 셸을 종료하면 시스템은 백그라운드에서 계속 실행됩니다.
to_erl을 사용하면 터미널이 실행 중인 노드에 직접 연결되므로, ctrl-c나 ctrl-g q [enter]를 입력하면 해당 노드가 종료됩니다—아마도 원하는 결과는 아닐 것입니다! run_erl을 사용할 때는 +Bi 플래그도 함께 사용하는 것이 좋습니다. 이 플래그는 ctrl-c 신호를 비활성화하고 ctrl-g 메뉴에서 q 옵션을 제거합니다.
|
노드에 연결하는 마지막 방법은 ssh를 통하는 것입니다.
SSH를 통한 연결
Erlang은 노드에서 시작한 후 직접 연결할 수 있는 내장 SSH 서버를 함께 제공합니다. 이는 Erlang 분산 메커니즘과 완전히 별개이므로, -name이나 -sname으로 시스템을 시작할 필요가 없습니다. ssh 모듈 문서에서 모든 세부 사항을 설명합니다. 간단한 테스트를 위해 필요한 것은 ssh-keygen으로 생성할 수 있는 서버 키뿐입니다:
> mkdir ~/ssh-test/
> ssh-keygen -t rsa -f ~/ssh-test/ssh_host_rsa_key
그런 다음 Erlang 노드에서 ssh 데몬을 시작합니다:
gds01> erl
Erlang/OTP 18 [erts-7.3] [source-d2a6d81] [64-bit] [smp:8:8]
[async-threads:10] [kernel-poll:false]
Eshell V7.3 (abort with ^G)
1> ssh:start().
{ok,<0.47.0>}
2> ssh:daemon(8021, [{system_dir, "/home/happi/ssh-test"},
{auth_methods, "password"},
{password, "pwd"}]).
system_dir는 기본값으로 /etc/ssh를 사용하지만, 해당 키는 root 사용자만 읽을 수 있기 때문에 이 예제에서는 직접 키를 생성합니다.
|
이제 다른 머신에서 연결할 수 있습니다. 연결할 때 일반 ssh 명령어를 사용하고 있음에도 불구하고, 노드의 Erlang 셸로 바로 진입한다는 점에 유의하세요:
happi@GDC08:~> ssh -p 8021 happi@gds01
happi@gds01's password: [pwd]
Eshell V7.3 (abort with ^G)
1>
실제 환경에서는 문서에 설명된 대로 서버와 사용자 ssh 키를 설정하고 싶을 것입니다. 최소한 더 나은 비밀번호를 사용하는 것이 좋습니다.
이 셸에서는 JCL 모드(ctrl+g)나 BREAK 모드(ctrl+c)에 접근할 수 없습니다. q(), halt() 또는 init:stop()을 입력하면 원격 노드가 종료됩니다. 셸에서 연결을 끊으려면 exit()을 입력하여 셸 세션을 종료하거나, 터미널 창을 닫으면 됩니다.
브레이크 모드는 개발, 프로파일링, 디버깅 시 매우 강력합니다. 다음에 이를 살펴보겠습니다.
17.2.3. 탈출(밖으로 나가거나 안으로 들어오기).
ctrl+c를 누르면 BREAK 모드로 들어갑니다. 이는 주로 중단을 위해 a [enter]를 입력하거나 ctrl+c를 한 번 더 눌러서 노드를 종료하는 데 사용됩니다. 하지만 실제로는 이 모드를 사용해 Erlang 런타임 시스템의 내부를 들여다볼 수 있습니다.
BREAK 모드로 진입하면 짧은 메뉴가 표시됩니다:
BREAK: (a)bort (A)bort with dump (c)ontinue (p)roc info (i)nfo
(l)oaded (v)ersion (k)ill (D)b-tables (d)istribution
c [enter](계속)를 입력하면 셸로 돌아갑니다. 디버깅 목적으로 A [enter]를 사용하여 강제 크래시 덤프(코어 덤프와는 다르며, 자세한 내용은 Chapter 18 참고)와 함께 노드를 종료할 수도 있습니다.
p [enter]를 입력하면 시스템의 모든 프로세스에 대한 내부 정보를 얻을 수 있습니다. 이 정보가 무엇을 의미하는지는 다음 장에서 더 자세히 살펴보겠습니다 (Chapter 3 참조).
i [enter]를 통해 노드의 메모리와 메모리 할당자에 대한 정보도 얻을 수 있습니다. Chapter 11에서 이 정보를 해석하는 방법을 살펴보겠습니다.
l [enter]로 로드된 모든 모듈과 그 크기를 확인할 수 있고, v [enter]로 시스템 버전을 확인할 수 있으며, k [enter]를 사용하면 모든 프로세스를 하나씩 살펴보고 검사하거나 종료할 수 있습니다. 대문자 D [enter]는 시스템의 모든 ETS 테이블에 대한 정보를 보여주고, 소문자 d [enter]는 분산에 대한 정보(기본적으로 노드 이름)를 보여줍니다.
OPPROF나 DEBUG로 런타임 시스템을 빌드했다면 훨씬 더 많은 정보를 얻을 수 있습니다. 이를 수행하는 방법은 Appendix A에서 살펴보겠습니다. break 모드를 구현하는 코드는 erts/emulator/beam/break.c에서 찾을 수 있습니다.
BREAK 메뉴를 띄우는 대신 ctrl+c를 눌렀을 때 노드가 즉시 종료되기를 원한다면, erl에 +Bd 옵션을 전달할 수 있습니다. 이는 일반적으로 Docker 등에서 Erlang을 실행할 때 원하는 방식입니다. ctrl-c가 현재 셸 명령만 중단하도록 만드는 +Bc 변형도 있습니다—이는 대화형 작업에 유용할 수 있습니다.
|
브레이크 모드로 들어가면 노드가 멈춘다는 점에 유의하세요. 이는 운영 시스템에서 하고 싶은 작업이 아닙니다. 그러나 테스트 시스템에서 디버깅이나 프로파일링을 할 때, 이 모드는 버그와 병목 지점을 찾는 데 도움이 될 수 있으며, 이에 대해서는 이 책의 뒷부분에서 살펴볼 것입니다.
18. BEAM 애플리케이션 디버깅
18.1. 소개
이 장에서는 진행 중인 서비스를 중단시키지 않고 버그를 찾아 수정하는 다양한 방법을 다룹니다. 코드를 테스트하고 디버깅하는 데 도움이 되는 테스트 기법, 도구, 프레임워크를 살펴보겠습니다. 또한 데드락, 메시지 오버플로, 메모리 문제 등 몇 가지 일반적인 버그 원인을 조명하고, 이러한 문제를 식별하고 해결하는 방법에 대한 지침을 제공합니다.
디버깅은 소프트웨어에서 오류, 즉 "버그"를 찾아내고 제거하는 프로세스입니다. Erlang은 디버거와 같은 단계별 디버깅 도구를 제공하지만, 가장 효과적인 디버깅 방법은 흔히 Erlang의 트레이싱 기능에 의존합니다. 이 기능들은 Chapter 19에서 자세히 다룰 것입니다. 이 장에서는 DTrace와 SystemTap을 이용한 시스템 수준 트레이싱에 대해 다룰 것입니다.
이 장에서는 메모리 부족이나 에뮬레이터 한계 도달과 같은 복구 불가능한 오류가 발생했을 때 Erlang 런타임 시스템이 생성하는, 사람이 읽을 수 있는 텍스트 파일인 "크래시 덤프(Crash Dumps)"라는 개념도 살펴봅니다. 크래시 덤프는 Erlang 노드의 사후 분석에 매우 유용하며, 이를 해석하고 이해하는 방법을 배우게 될 것입니다.
이러한 주제들에 더하여, 이 장에서는 코드의 신뢰성과 견고성을 보장하는 데 필수적인 EUnit 및 Common Test를 포함한 다양한 테스트 방법론도 논의할 것입니다. 테스트에서 모킹(mocking)의 중요성과 그 모범 사례도 함께 살펴볼 것입니다.
"그냥 크래시하게 두라(let it crash)" 원칙과 이를 시스템 내에서 효과적으로 구현하는 방법에 대해 익히게 될 것입니다. 예외의 작동 방식과 슈퍼바이저 트리 설계에 대한 통찰을 얻게 될 것입니다.
이 장이 끝날 때쯤이면 여러분은 시스템과 그 개별 구성 요소를 체계적으로 테스트할 수 있는 지식을 갖추게 될 것입니다. 흔히 발생하는 실수와 문제를 식별할 수 있게 될 것이며, 그 과정에서 디버깅 철학까지도 어느 정도 익힐 수 있을 것입니다.
18.2. 디버깅 철학
디버깅은 소프트웨어 개발의 필수적인 부분이며, Erlang에서는 언어의 결함 허용(fault-tolerant) 설계로 인해 독특한 접근 방식을 취합니다. Erlang은 오류를 예방하는 데에만 집중하기보다, 오류를 효과적으로 감지하고, 진단하고, 복구하는 반응형(reactive) 디버깅 철학을 장려합니다. Erlang에서의 디버깅은 체계적인 접근 방식(systematic approaches)을 활용하고, 프로덕션 환경에서 발생한 실패를 분석하며, 실수로부터 배워 코드 품질을 지속적으로 개선하는 것을 포함합니다.
18.2.1. 디버깅에 대한 체계적 접근법
디버깅에 대한 구조화된 접근 방식은 문제를 식별하고 해결하는 데 필요한 시간과 노력을 크게 줄일 수 있습니다. Erlang에서의 디버깅은 관찰, 격리, 테스트를 포함하는 체계적인 프로세스를 따릅니다.
1. 문제 재현
버그를 수정하기 전에 일관되게 재현할 수 있어야 합니다. Erlang 시스템에서 문제를 재현하기 위한 몇 가지 기법은 다음과 같습니다:
-
상세 로깅(
lager,logger)을 활성화한 상태로 시스템을 실행하기. -
함수 호출과 메시지 패싱을 캡처하기 위해
dbg나recon과 같은 추적 도구를 사용합니다. -
제어된 테스트 환경에서 장애 시나리오를 시뮬레이션합니다.
예시: 모듈 내 함수 호출을 검사하기 위해 트레이싱을 활성화하기:
dbg:tracer().
dbg:p(all, c).
dbg:tpl(my_module, my_function, []). % Trace all calls to my_function
문제가 산발적으로 발생하는 경우, prop_er나 Common Test와 같은 도구로 부하 테스트를 수행하면 경쟁 조건을 발견하는 데 도움이 될 수 있습니다.
2. 결함이 있는 컴포넌트 격리
문제가 재현 가능해지면, 다음 단계는 특정 모듈, 프로세스, 또는 함수로 문제를 격리하는 것입니다:
프로세스 메시지 큐를 다음을 사용하여 확인하십시오:
process_info(Pid, messages).
긴 메시지 큐는 성능 병목 현상을 나타낼 수 있습니다.
ETS 테이블과 메모리 사용량을 검사합니다:
ets:info(my_table, size).
erlang:memory().
문제와 관련된 프로세스에만 집중하려면 선택적 트레이싱을 사용하세요:
dbg:p(self(), [m]). % Trace only the current process
결함이 있는 컴포넌트를 격리함으로써, 디버깅 범위를 좁히고 불필요한 방해 요소를 피할 수 있습니다.
3. 로그 및 크래시 덤프 분석
로그와 크래시 덤프는 시스템 장애에 대한 유용한 정보를 제공합니다. Erlang 노드가 크래시하면 다음과 같은 세부 정보를 포함하는 erl_crash.dump 파일이 생성됩니다:
-
크래시의 원인 (예: 메모리 고갈, 무한 루프, 데드락).
-
실패 발생 시점의 프로세스 상태.
-
크래시가 발생한 프로세스의 콜 스택.
예시: 크래시 덤프의 메모리 사용량 섹션 확인하기:
=memory
total: 2147483648
processes: 1807483648
ets: 107374182
binary: 32212254
code: 5242880
프로세스 메모리가 비정상적으로 높다면, 이는 메모리 누수를 나타낼 수 있습니다.
실시간 디버깅을 위해서는 crashdump_viewer를 사용하세요:
crashdump_viewer:start().
4. 디버깅 도구를 효과적으로 사용하라
Erlang는 시스템 동작을 분석하기 위한 강력한 런타임 디버깅 도구를 제공합니다:
-
Observer GUI (
observer:start()) – 대화형 프로세스 모니터링. -
dbg와recon– 저수준 트레이싱 및 검사. -
SystemTap또는DTrace– 고급 디버깅을 위한 커널 수준 프로파일링.
작업에 맞는 올바른 도구를 사용하면 불필요한 코드 수정을 방지하고 디버깅 속도를 높일 수 있습니다.
5. 수정 사항 검증 및 회귀 테스트 작성
버그를 식별하고 수정한 후에는 다시 나타나지 않도록 확인합니다:
-
회귀 테스트를 작성하세요.
Common Test나EUnit을 사용합니다. -
속성 기반 테스트를 실행(
PropEr,QuickCheck)하여 엣지 케이스를 검증하십시오. -
스테이징 환경에서 테스트한 후 프로덕션에 배포하세요.
18.2.2. 실수로부터 배우고 코드 품질 개선하기
모든 버그는 코드베이스를 개선하고 향후 문제를 예방할 수 있는 기회를 제공합니다. 회복력과 자가 치유라는 Erlang의 철학은 개발자가 실수를 다루고 시스템을 개선하는 방식까지 확장됩니다.
1. 포스트모템 진행
심각한 버그를 수정한 후에는 왜 발생했는지와 어떻게 예방할 것인지를 분석하세요. 사후 분석(post-mortem analysis)은 다음 질문에 답해야 합니다:
-
문제의 근본 원인은 무엇이었습니까?
-
그것이 시스템에 어떤 영향을 미쳤는가?
-
유사한 버그를 어떻게 예방할 수 있을까요?
예상치 못한 메시지로 인해 프로세스가 크래시된 경우, 메시지 필터링이 견고한지 확인하세요:
handle_info(_Unexpected, State) ->
{noreply, State}.
2. 로깅 및 관찰 가능성 개선
많은 문제는 로깅 및 모니터링이 불충분하여 발생합니다. 시스템 관측성(observability)을 개선하는 방법에는 다음이 포함됩니다:
로그 레벨과 함께 구조화된 로깅(lager, logger)을 사용:
logger:log(info, "User logged in: ~p", [UserId]).
실시간 모니터링 구현:
recon:bin_leak(10). % Detects potential memory leaks.
더 나은 로깅은 이상 징후가 주요 장애로 확대되기 전에 이를 감지하는 데 도움이 됩니다.
3. 코드 가독성과 유지보수성 향상
잘 구조화된 코드는 디버그하기 더 쉽습니다. Erlang 모범 사례를 따르면 유지보수성이 향상됩니다:
-
명확한 함수 이름을 사용하세요 (
do_it/1대신handle_request/1). -
OTP 설계 원칙을 따르세요 (
gen_server,supervisor). -
모듈화된 코드를 작성하여 디버깅을 더 쉽게 하세요.
예시: 복잡하게 중첩된 case 문 대신:
case Result of
{ok, Data} -> process(Data);
{error, _} -> handle_error()
end.
명확성을 위해 패턴 매칭을 사용하세요:
process_request({ok, Data}) -> process(Data);
process_request({error, _}) -> handle_error().
4. 빠른 실패(Fail-Fast) 메커니즘 구현
Erlang의 Let It Crash 철학은 오류가 발생했을 때 잘못된 상태를 전파하는 대신 프로세스가 빠르게 실패해야 함을 의미합니다.
예시: 가드를 사용하여 빠른 실패(fail-fast) 동작을 강제하기:
handle_request({ok, Data}) when is_list(Data) ->
process(Data);
handle_request(_) ->
exit(bad_request).
Fail-fast(빠른 실패) 메커니즘은 조용한 실패(silent failures)를 방지하고 디버깅을 더 쉽게 만듭니다.
5. 오픈 소스 Erlang 시스템에서 배우기
많은 프로덕션 등급 Erlang 애플리케이션이 오픈 소스로 공개되어 있습니다. 이들의 디버깅 관행을 연구하면 귀중한 통찰을 얻을 수 있습니다:
-
RabbitMQ – 구조화된 로깅과 모니터링 도구를 사용합니다.
-
MongooseIM – 광범위한 트레이싱을 구현합니다.
-
Riak – 분산 장애 복구 기법을 사용합니다.
이러한 프로젝트를 탐구하면 디버깅 능력이 향상되고 시스템 설계 지식이 강화됩니다.
18.3. 흔한 용의자들: 버그의 일반적인 원인
소프트웨어 시스템은 안정성과 성능에 영향을 미칠 수 있는 반복적인 유형의 실패를 자주 나타냅니다. Erlang은 장애 허용을 위해 설계되었음에도 불구하고, 특정 범주의 버그가 자주 발생합니다. 이 절에서는 데드락, 메일박스 오버플로우, 메모리 문제를 포함하여 Erlang 애플리케이션에서 가장 흔한 문제의 원인들을 살펴봅니다. 이러한 문제들을 이해하고 이를 진단하고 해결하는 방법을 배우면 더 안정적이고 효율적인 Erlang 프로그램을 작성하는 데 도움이 될 수 있습니다.
18.3.1. 데드락
데드락은 둘 이상의 프로세스가 서로 자원을 해제하기를 기다리면서, 더 이상 진행이 불가능한 상태에 이를 때 발생합니다. 이는 Erlang의 경량 프로세스로 구축된 시스템을 포함하여, 동시성 시스템에서 흔히 발생하는 문제입니다.
Erlang에서 데드락은 일반적으로 다음과 같은 이유로 발생합니다:
-
순환 의존성(Circular dependencies): 두 프로세스가 각각 상대방이 보유한 자원을 기다리는 상태.
-
잘못 사용된 락:
gen_server나gen_fsm을 사용할 때, 메시지 처리 순서가 잘못되면 교착 상태(deadlock)로 이어질 수 있습니다. -
gen_server내부에서의 블로킹 호출:handle_call/3콜백 내에서gen_server:call/2를 호출하면 프로세스가 무한정 블로킹될 수 있습니다.
데드락을 식별하려면:
-
프로세스 검사: 정체된 프로세스를 확인하려면
observer:start().또는process_info(Pid, status).을 사용하세요. -
dbg로 트레이싱: 함수 호출 트레이싱을 활성화하여 프로세스가 무한정 대기하는 위치를 확인합니다. -
메시지 큐 분석: 프로세스가 도착하지 않는 메시지를 기다리고 있다면,
process_info(Pid, messages).를 사용하여 메일박스를 확인하십시오.
블로킹 연산에서 타임아웃을 사용하세요:
gen_server:call(Server, Request, Timeout).
적절한 타임아웃을 설정하면 무한정 블로킹되는 것을 방지할 수 있습니다.
블로킹을 피하려면 비동기 호출(gen_server:cast/2) 또는 모니터 메시지(erlang:monitor/2)를 사용하세요.
순환 의존성을 방지하기 위해 모든 프로세스에서 잠금(lock)이 일관된 순서로 획득되도록 해야 합니다.
프로세스 상태를 모니터링하고 교착 상태에 빠진 프로세스를 강제로 재시작하는 주기적인 점검을 구현하세요.
18.3.2. 메일박스 오버플로우
Erlang의 메시지 패싱 모델은 프로세스가 메일박스를 통해 비동기적으로 메시지를 수신할 수 있게 합니다. 그러나 프로세스가 메시지를 처리할 수 있는 속도보다 더 빠르게 메시지가 쌓이면, 메일박스가 무한정 커질 수 있으며, 이는 높은 메모리 소비나 크래시로 이어질 수 있습니다.
메시지 오버플로우의 일반적인 원인과 증상은 다음과 같습니다:
-
느린 메시지 처리: 요청을 처리하는 데 너무 오래 걸리는
gen_server는 처리되지 않은 메시지가 쌓이는 결과로 이어질 수 있습니다. -
과도한 메시지 생성: 백프레셔를 확인하지 않고 빈번하게 메시지를 보내는 프로세스.
-
처리되지 않은 시스템 메시지:
gen_server:handle_info/2처럼 시스템 메시지를 처리하지 못하는 경우.
증상은 다음을 포함합니다:
-
증가하는 프로세스 메모리 사용량 (
process_info(Pid, memory).) -
긴 프로세스 메시지 큐 (
process_info(Pid, message_queue_len).) -
유휴 상태로 보이지만 과부하 상태인 응답 없는 프로세스.
메일박스 오버플로우 문제 예방 및 해결
메시지 큐 길이 모니터링:
process_info(Pid, message_queue_len).
큐가 임계값을 초과하여 증가할 때 경고를 트리거하도록 모니터링 도구를 사용하세요.
송신자 속도 제한
-
더 많은 메시지를 보내기 전에 명시적인 확인 응답을 요청하는 등 백프레셔 메커니즘을 사용하세요.
-
흐름 제어를 구현합니다: 프로듀서가 메시지를 무작정 보내는 대신, 컨슈머의 부하를 확인할 수 있습니다.
선택적 수신을 올바르게 사용하라
다음과 같은 패턴은 피하세요:
receive {specific_message, Data} -> process(Data) end.
이는 다른 대기 중인 메시지를 무시하여 메일박스가 계속 커지게 만듭니다. 이 규칙의 예외는 rpc 방식의 송신과 수신에 Ref-trick을 사용하는 경우입니다. 더 자세한 내용은 Section 7.5.4을 참고하세요.
무거운 연산 오프로드:
-
비용이 많이 드는 연산은 메인 프로세스 루프에서 직접 수행하지 말고 워커 프로세스로 오프로드하십시오.
-
처리 후 비동기적으로 메시지에 응답하려면
gen_server:reply/2를 사용하세요.
18.3.3. 메모리 문제
Erlang의 메모리 모델은 프로세스별 힙, 가비지 컬렉션, 그리고 바이너리 할당자에 의존합니다. 효율성을 위해 설계되었지만, 부적절한 메모리 사용은 성능 저하로 이어질 수 있습니다.
Erlang에서 메모리 누수는 종종 다음과 같은 원인에서 비롯됩니다:
-
상태를 축적하는 장수 프로세스: ETS 테이블, 큰 리스트, 또는 처리되지 않은 메시지.
-
무제한 메시지 큐: 메시지를 수신하지만 절대 소비하지 않는 프로세스.
-
바이너리 데이터 누적: 큰 바이너리는 높은 메모리 단편화를 유발할 수 있습니다.
메모리 누수를 감지하는 방법
개별 프로세스의 메모리 사용량 확인:
process_info(Pid, memory).
observer:start().을 사용하여 "Processes" 탭으로 이동해 과도한 메모리를 사용하는 프로세스를 확인하세요.
다음을 사용하여 메모리 할당에 대한 추적을 활성화합니다:
recon_alloc:memory(ets).
바이너리 메모리 사용량 관리
대형 바이너리는 참조 카운팅을 사용하여 프로세스 힙과 별도로 관리됩니다. 다음과 같은 경우에 문제가 발생합니다:
-
프로세스가 필요 이상으로 오래 바이너리 참조를 유지합니다.
-
사용되지 않는 대형 바이너리가 지연된 가비지 컬렉션으로 인해 남아 있습니다.
해결책:
큰 바이너리를 더 작은 청크로 변환:
binary:split(BigBinary, <<"\n">>).
가비지 컬렉션 강제 실행:
erlang:garbage_collect(Pid).
이는 프로세스가 더 이상 참조하지 않는 바이너리가 사용하던 메모리를 회수합니다. 이는 더 이상 바이너리를 사용하지 않지만 여전히 그에 대한 참조를 붙들고 있는 중계 프로세스에서 중요할 수 있습니다. 바이너리는 참조 카운트 방식이며 프로세스 간에 걸쳐 존재한다는 점을 기억하십시오.
바이너리 메모리 할당 모니터링:
erlang:memory(binary).
Erlang 시스템에서 메모리 사용량 최적화
Erlang은 힙 할당 동작을 제어하는 여러 시스템 플래그를 제공합니다.
min_heap_size (최소 프로세스 힙 크기)
-
새로 생성된 프로세스의 초기 힙 크기를 정의합니다.
-
프로세스가 많은 양의 데이터를 처리할 것으로 예상되는 경우 빈번한 힙 확장을 피하는 데 도움이 됩니다.
-
기본값은 일반적으로 233 단어이지만, 이를 약간 늘리면(예: 256 또는 512) 빠르게 증가하는 프로세스의 성능을 향상시킬 수 있습니다.
예시 사용법: 다음을 사용하여 프로세스에 대해 이 설정을 구성할 수 있습니다:
spawn_opt(fun() -> my_function() end, [{min_heap_size, 512}]).
또는 다음을 통해 전역적으로 적용합니다:
erl +hms 512
이는 모든 새 프로세스가 최소 512워드의 힙으로 시작하도록 보장하여, 힙 확장이 빈번하게 필요해지는 것을 줄여줍니다.
min_bin_vheap_size (최소 바이너리 가상 힙 크기)
-
참조 카운트 바이너리(64바이트를 초과하는 바이너리)에 대한 가상 힙 크기를 제어합니다.
-
대용량 바이너리 데이터를 다루는 프로세스의 메모리 할당 최적화에 도움이 됩니다.
-
기본값은 46422이지만, 바이너리가 많은 워크로드의 경우 512 이상으로 조정할 수 있습니다.
spawn_opt(fun() -> handle_large_binaries() end, [{min_bin_vheap_size, 100000}]).
이는 프로세스가 충분한 바이너리 힙 공간을 가지고 시작하도록 보장하여, 빈번한 재할당을 방지합니다.
풀스윕 가비지 컬렉션 임계값(fullsweep_after)을 최적화합니다.
ETS를 효율적으로 사용하기
-
메모리 낭비를 방지하려면 사용하지 않는 항목을 정기적으로 정리하세요.
-
필요한 경우가 아니라면
bag이나ordered_set보다set테이블을 우선적으로 사용하십시오.
텀이 수명이 길고 공유되는 경우, 큰 텀을 전달할 때 주의하세요. 프로세스 간에 큰 텀을 전송하는 대신 참조를 사용하세요 (예: 큰 데이터를 ETS나 데이터베이스에 저장하고 참조를 전송).
18.4. Let It Crash 원칙
Erlang의 "Let It Crash" 원칙은 결함 허용(fault-tolerant) 시스템과 회복력 있는 시스템을 설계하는 데 있어 근본적인 철학입니다. 모든 가능한 오류를 처리하기 위해 방어적인 코드를 작성하는 대신, Erlang 개발자들은 실패를 받아들이고 슈퍼바이저 트리에 의존하여 충돌을 감지하고 복구합니다. 이러한 접근 방식은 코드를 단순화하고, 유지보수성을 향상시키며, 예상치 못한 오류에 직면하더라도 시스템이 견고하게 유지되도록 보장합니다.
18.4.1. 개요 및 근거
전통적인 프로그래밍에서 에러 처리는 종종 방대한 try-catch 문과 실패를 예상하기 위한 방어적 코드를 작성하는 것을 수반합니다. 하지만 이러한 접근 방식은 복잡성을 야기하며 유지보수하기 어려운 코드베이스로 이어질 수 있습니다. Erlang은 철저한 에러 방지보다는 실패가 일어날 것임을 받아들이고 자동 복구에 집중하는 다른 접근 방식을 취합니다.
"Let It Crash" 뒤에 있는 근거는 다음과 같다:
-
장애 격리(Isolation of failures): 각 Erlang 프로세스는 독립적으로 실행되므로, 한 프로세스의 크래시가 다른 프로세스에 영향을 미치지 않습니다.
-
자동 복구: 슈퍼바이저는 프로세스를 감시하다가 실패하면 재시작합니다.
-
더 단순한 코드: 개발자는 방어적인 코드를 덜 작성하고 오류 처리보다 비즈니스 로직에 집중합니다.
-
결함 격리: 프로세스가 통제된 방식으로 크래시하고 재시작하도록 함으로써, 오류가 확산되는 것을 방지합니다.
이러한 철학은 Erlang 시스템을 특히 장애가 불가피한 분산 환경에서 매우 회복력 있게 만듭니다.
18.4.2. Erlang의 예외
Erlang은 예외를 처리하기 위한 내장 메커니즘을 제공하지만, 모든 오류를 로컬에서 복구하는 데 초점을 맞추는 대신 슈퍼비전을 통한 프로세스 종료 및 재시작을 권장합니다.
예외의 종류
Erlang에는 세 가지 주요 예외 유형이 있습니다:
-
에러 (
error:Reason) – 0으로 나누기나 정의되지 않은 함수 호출과 같은 심각한 오류로 인해 발생합니다. -
Throws (
throw:Reason) – 비지역 반환(non-local return)과 제어된 종료에 사용됩니다. -
종료(Exits) (
exit:Reason) – 프로세스가 예기치 않게 또는 의도적으로 종료될 때 발생합니다.
예외 처리 예제
방어적 프로그래밍은 크래시를 지양하지만, Erlang은 필요한 경우 예외를 명시적으로 처리할 수 있도록 합니다:
try 1 / 0 of
Result -> io:format("Result: ~p~n", [Result])
catch
error:badarith -> io:format("Cannot divide by zero!~n")
end.
이는 즉각적인 로컬 처리가 필요한 경우에 유용하지만, Erlang의 대부분의 실패는 크래시하도록 방치되어 슈퍼바이저에 의해 처리됩니다.
프로세스 종료와 모니터링
프로세스가 크래시하면, 링크된 프로세스들에게 exit signal을 보냅니다. 필요한 경우 이러한 종료(exit)를 모니터링하거나 트랩할 수 있습니다:
spawn_monitor(fun() -> exit(died) end).
이를 통해 다른 프로세스가 실패를 감지하고 이에 따라 대응할 수 있습니다.
18.4.3. 슈퍼바이저 트리로 시스템 설계하기
모든 함수 내부에서 오류를 처리하는 대신, Erlang 애플리케이션은 슈퍼바이저 트리에 의존하는데, 이는 슈퍼바이저가 워커 프로세스를 모니터링하고 실패 시 이를 재시작하는 계층적 구조이다.
슈퍼바이저 트리의 구조
슈퍼바이저 트리는 다음으로 구성됩니다:
-
슈퍼바이저(Supervisor): 워커 프로세스와 다른 슈퍼바이저를 관리하는 특수한 프로세스입니다.
-
워커(Workers): 실제로 연산을 수행하는 프로세스입니다. 만약 워커가 크래시하면, 슈퍼바이저가 이를 재시작하는 방법을 결정합니다.
-module(my_supervisor).
-behaviour(supervisor).
-export([start_link/0, init/1]).
start_link() ->
supervisor:start_link(?MODULE, []).
init([]) ->
{ok, {{one_for_one, 3, 10},
[{worker1, {my_worker, start_link, []}, permanent, 5000, worker, [my_worker]}]}}.
이 슈퍼바이저는 my_worker가 크래시하면 자동으로 재시작되도록 보장합니다.
슈퍼바이저 전략
슈퍼바이저는 서로 다른 재시작 전략을 따를 수 있습니다:
-
one_for_one: 크래시된 프로세스만 재시작합니다 (가장 흔한 경우).
-
one_for_all: 하나가 실패하면 모든 자식 프로세스를 재시작합니다.
-
rest_for_one: 실패한 프로세스와 그 이후에 시작된 모든 프로세스를 재시작합니다.
-
simple_one_for_one: 유사한 워커 프로세스를 동적으로 생성할 때 사용됩니다.
슈퍼바이저 트리 사용의 이점
-
자동 장애 복구: 워커가 크래시되면 수동 개입 없이 재시작됩니다.
-
확장성: 슈퍼바이저는 수천 개의 프로세스를 효율적으로 관리할 수 있습니다.
-
관심사의 분리: 비즈니스 로직은 워커에 남고, 장애 복구는 별도로 처리됩니다.
18.5. 디버깅 도구와 기법
디버깅은 Erlang 애플리케이션에서 예상치 못한 동작을 다룰 때 필수적입니다. Erlang 생태계에는 여러 도구가 존재합니다.
18.5.1. Erlang 디버거 (dbg)
dbg 모듈은 성능에 미치는 영향을 최소화하면서 라이브 시스템을 디버깅할 수 있는 강력한 트레이싱 기능을 제공합니다.
dbg 시작하기
dbg 도구를 시작하려면:
1> dbg:tracer().
{ok,<0.85.0>}
이는 디버그 정보를 수집하기 위한 트레이서 프로세스를 설정합니다. 출력을 위해 다양한 백엔드를 선택할 수 있습니다:
-
dbg:tracer(console).→ 셸에 출력 -
dbg:tracer(port, file:open("trace.log", [write])).→ 파일에 쓰기 (포트)
트레이싱이 활성화되면, 프로세스나 함수에 트레이서를 연결할 수 있습니다.
모든 함수 호출 추적하기
dbg:p(all, c). % Trace all function calls in all processes
특정 함수 추적하기
dbg:tpl(my_module, my_function, []). % Trace calls to my_function/0
조건부 트레이스 설정하기
함수 인자가 일치할 때만 트레이스:
dbg:tpl(my_module, my_function, [{'_', [], [{message, "Function called"}]}]).
브레이크포인트는 코드 실행을 단계별로 살펴볼 때 유용합니다. 그래픽 디버거를 시작합니다:
debugger:start().
그런 다음, 모듈에 브레이크포인트를 설정합니다:
int:break(my_module, my_function, Arity).
함수가 트레이스되면, 호출과 반환이 로그로 기록됩니다.
트레이스 출력 예시:
(<0.85.0>) call my_module:my_function(42)
(<0.85.0>) returned from my_function -> "Result: 42"
이를 통해 실행 과정 전반에서 값이 어떻게 변화하는지 추적할 수 있습니다.
18.6. 차세대 디버거: EDB
Erlang 디버거(EDB)는 Erlang 애플리케이션을 위한 현대적이고 기능이 풍부한 디버거입니다. 브레이크포인트 설정, 변수 검사, 코드 실행 단계별 실행을 위한 언어 서버 인터페이스를 제공합니다. https://github.com/WhatsApp/edb 를 참고하세요
OTP 28 이전 버전에서 EDB를 사용하려면 EDB 지원 기능을 포함하여 Erlang을 소스에서 빌드해야 합니다. 다음은 EDB 지원 기능을 포함하여 Erlang을 소스에서 빌드하는 방법에 대한 가이드입니다:
git clone https://github.com/WhatsApp/edb.git
git submodule update --init
pushd otp
./configure --prefix $(pwd)/../otp-bin
make -j$(nproc)
make -j$(nproc) install
popd
rebar3 escriptize
그런 다음 다음 명령어로 EDB를 시작할 수 있습니다:
_build/default/bin/edb dap
이 명령어는 EDB를 실행하여 DAP를 통해 개발 환경과 인터페이스할 수 있게 해주며, 강력한 디버깅 경험을 제공합니다.
현재 상태와 안정성
이 글을 쓰는 시점에서, EDB는 초기 단계의 빠르게 발전하고 있는 도구입니다. 통합과 사용성은 유망하지만, 특히 IDE 설정, 노드 연결, 환경 호환성 측면에서 여전히 까다로울 수 있습니다. 안정성은 OTP 버전과 개발자 도구 선택에 따라 크게 달라질 수 있습니다.
18.7. Erlang의 크래시 덤프
크래시 덤프는 Erlang 시스템의 오류를 진단하기 위한 정보를 제공합니다. 크래시 덤프에는 크래시 발생 시점의 시스템 상태, 메모리 사용량, 프로세스 정보, 콜 스택에 대한 세부 정보가 포함되어 있습니다. 이러한 파일을 해석하는 방법을 이해하면 디버깅 속도를 크게 높이고 향후 크래시를 예방할 수 있습니다.
18.7.1. 크래시 덤프 이해와 읽기
크래시 덤프(erl_crash.dump)는 비정상 종료 시점의 Erlang 런타임 시스템(ERTS) 스냅샷입니다. 여기에는 다음이 포함됩니다:
-
시스템 버전 및 런타임 매개변수
-
메모리 사용 통계
-
로드된 모듈
-
프로세스 상태와 호출 스택
-
포트 및 드라이버 정보
크래시 덤프를 분석하면 시스템이 왜 크래시되었는지—메모리 고갈, 무한 루프, 데드락 또는 기타 장애 때문인지—를 파악할 수 있습니다.
공식 문서에서는 크래시 덤프 형식에 대한 자세한 설명을 제공합니다: How to Interpret the Erlang Crash Dumps. (ERTS 문서 참조) 여기서는 기본적인 내용을 다루겠습니다.
기본적으로 크래시 덤프는 Erlang 시스템이 시작된 작업 디렉터리에 저장됩니다. 파일명은 일반적으로 다음과 같습니다:
erl_crash.dump
환경 변수를 설정하여 위치를 변경할 수 있습니다:
export ERL_CRASH_DUMP=/var/log/erl_crash.dump
또는 런타임에:
erlang:system_flag(crash_dump, "/var/log/erl_crash.dump").
18.7.2. 크래시 덤프의 기본 구조
크래시 덤프는 여러 섹션으로 구성됩니다. 아래는 일부가 생략된 예시입니다:
=erl_crash_dump:0.5
Sun Feb 18 13:45:52 2025
Slogan: eheap_alloc: Cannot allocate 1048576 bytes of memory (of type "heap").
System version: Erlang/OTP 26 [erts-13.1] [source] [64-bit]
Compiled: Fri Jan 26 14:10:07 2025
Taints: none
Atoms: 18423
Processes: 482
Memory: 2147483648
=memory
total: 2147483648
processes: 1807483648
ets: 107374182
binary: 32212254
code: 5242880
이 덤프는 시스템이 메모리 할당 실패(Cannot allocate 1048576 bytes of memory)로 인해 크래시했음을 시사합니다.
크래시 덤프의 주요 섹션
-
슬로건
크래시의 원인을 나타냅니다. 일반적인 슬로건은 다음과 같습니다: - `eheap_alloc: Cannot allocate X bytes of memory` (메모리 소진) - `Init terminating in do_boot ()`(부트 스크립트 오류일 가능성이 있음) - `Could not start kernel pid` (설정에 잘못된 인자가 있을 가능성이 높음)
-
시스템 정보
런타임에 대한 세부 정보를 포함합니다: - `System version`: Erlang/OTP 버전 및 빌드 세부 정보 - `Compiled`: 시스템이 빌드된 시점 - `Taints`: 외부 네이티브 코드(NIF)가 실행 중인지 여부
-
메모리 사용량
메모리 분산을 표시합니다:
-
Total: 전체 메모리 사용량 -
Processes: 프로세스가 사용하는 메모리 (값이 높으면 메모리 누수가 의심됨) -
ETS: Erlang 텀 저장소(Term Storage) 사용량 (무제한으로 증가하면 문제가 될 수 있음) -
Binary: 바이너리에 할당된 메모리 (누수의 원인이 될 수 있음) -
Code: 로드된 코드 메모리 사용량
-
-
프로세스 목록
활성 프로세스에 대한 세부 정보를 제공하는 이 섹션은 다음을 식별하는 데 중요합니다:
-
과도한 메모리를 소비하는 프로세스(
Stack+Heap크기) -
무한 루프에 갇힌 프로세스 (
Reductions카운트가 비정상적으로 높음) -
메시지 큐 과부하 (
Messages필드가 무한정 증가)
-
-
포트와 드라이버
이는 열려 있는 포트와 드라이버를 나열하며, 외부 시스템 상호작용(파일, 소켓, 데이터베이스)이 충돌 원인으로 의심될 때 유용할 수 있습니다.
-
로드된 모듈
이는 동적으로 로드된 코드(예: ``code:load_file/1``를 통해)가 크래시를 유발했는지 판단하는 데 도움이 됩니다.
18.7.3. 크래시 덤프 분석하기
Erlang은 크래시 덤프를 파싱하기 위한 내장 도구를 제공합니다: crashdump_viewer.
시작하려면:
crashdump_viewer:start().
이는 크래시 덤프를 검사할 수 있는 그래픽 인터페이스를 제공합니다.
18.7.4. 크래시 덤프가 생성되지 않는 이유 조사하기
때때로 시스템 크래시가 발생해도 erl_crash.dump 파일이 생성되지 않는 경우가 있습니다. 그 이유와 해결 방법은 다음과 같습니다.
크래시 덤프 비활성화됨
Erlang은 다음을 통해 크래시 덤프를 활성화/비활성화할 수 있습니다:
erlang:system_flag(dump_on_exit, true).
활성화되어 있는지 확인하세요:
ERL_CRASH_DUMP=/var/log/erl_crash.dump
또는 sys.config를 통해:
[{kernel, [{error_logger, {file, "/var/log/erl_crash.dump"}}]}].
권한 부족
Erlang을 실행하는 프로세스가 의도한 덤프 디렉터리에 대한 쓰기 권한을 가지고 있는지 확인하세요:
sudo chmod 777 /var/log/erl_crash.dump
소유권을 확인하세요:
ls -l /var/log/erl_crash.dump
필요한 경우 소유권을 변경하세요:
sudo chown erlang_user /var/log/erl_crash.dump
덤프가 작성되기 전에 크래시 발생
덤프를 작성하기 전에 시스템 메모리가 부족해지면 메모리를 예약해야 할 수 있습니다:
erlang:system_flag(reserved_memory, 1000000).
또는 스왑 공간을 늘리세요.
시스템 전역 제한
Linux/macOS 시스템 제한이 덤프 생성을 방해할 수 있습니다. 확인하세요:
ulimit -a
core file size가 0이면 활성화하세요:
ulimit -c unlimited
macOS에서:
sudo launchctl limit core unlimited
NIF 내부 크래시
Native Implemented Function(NIF)이 충돌(crash)하면, Erlang이 이를 우아하게 처리하지 못할 수 있습니다. 이런 경우, BEAM 에뮬레이터를 gdb와 같은 디버거 하에서 실행하면 충돌 시점의 시스템 상태를 확인하는 데 도움이 될 수 있습니다.
18.8. 런타임 시스템 디버깅
Erlang 런타임 시스템(ERTS) 내의 문제를 이해하고 진단하는 것은 복잡성으로 인해 어려울 수 있습니다. 그러나 GNU 디버거(GDB)와 같은 도구를 활용하면 이 프로세스에 큰 도움이 될 수 있습니다. 이 섹션에서는 환경 설정과 디버깅 워크플로우를 간소화하기 위한 GDB 매크로 활용을 포함하여, GDB를 사용해 BEAM을 디버깅하는 방법에 대한 개요를 제공합니다.
18.8.1. GDB 사용하기
GDB는 머신 레벨에서 애플리케이션을 디버깅하는 강력한 도구로, 컴파일된 프로그램의 실행에 대한 통찰을 제공합니다. BEAM에 적용하면, GDB는 개발자가 실행 중이거나 크래시가 발생한 후에 Erlang 가상 머신의 상태를 검사할 수 있게 해줍니다.
BEAM에서 GDB를 효과적으로 사용하려면 Erlang 런타임 시스템을 디버깅 심볼과 함께 컴파일하는 것이 좋습니다. 이 컴파일은 디버깅 세션 동안 상세한 정보를 제공합니다.
디버깅 정보가 포함된 Erlang 버전을 컴파일하고 실행하는 방법은 [Alternative Beam emulator builds]를 참고하세요.
빌드가 완료되면, cerl 실행 스크립트를 사용하여 빌드 디렉터리에서 BEAM의 디버그 버전을 실행할 수 있습니다:
bin/cerl -debug
GDB에서 Erlang을 실행하는 가장 쉬운 방법은 cerl과 함께 -rgdb 플래그를 사용하는 것이며, 이때 디버그 버전도 함께 선택해야 합니다:
bin/cerl -rgdb -debug
GDB 프롬프트에 진입하면 브레이크포인트 등을 설정할 수 있으며, 준비가 되면 다음 명령으로 BEAM의 실행을 시작합니다:
(gdb) run
충돌한 실행에서 발생한 OS 코어 덤프(Erlang 크래시 덤프와는 다른 것으로, [Crash Dumps in Erlang] 참고)가 있다면, 대신 cerl 에 -rcore 플래그를 붙여 실행하여 GDB를 실행할 수 있습니다:
bin/cerl -rcore <core file>
Emacs 편집기 사용에 익숙하다면, -gdb와 -core (앞에 r이 없음) 플래그를 사용하여 cerl을 실행할 수 있으며, 이는 디버깅 세션을 위한 IDE로 작동하는 Emacs 인스턴스를 실행합니다. (먼저 EMACS=emacsclient를 설정하면, M-x server-start를 실행한 적이 있는 경우 기존 Emacs에서 실행되도록 할 수도 있습니다.) 자세한 내용은 Emacs GDB 문서를 참고하세요.
|
이미 실행 중인 BEAM에 GDB를 연결하고 싶다면, 해당 프로세스의 OS 프로세스 ID를 찾아야 합니다. 예를 들어, 별도의 셸 창을 사용하여 다음을 입력합니다
pgrep -l beam
다음과 같은 결과를 출력해야 합니다
3140019 beam.debug.smp
그런 다음 실제로 사용 중인 실행 파일의 경로와 프로세스 ID를 사용하여 다음과 같이 GDB를 실행할 수 있습니다:
gdb bin/x86_64-unknown-linux-gnu/beam.debug.smp 3140019
(bin/cerl 또는 bin/erl 경로를 GDB에 지정할 수는 없다는 점에 유의하세요. 이들은 BEAM 실행 파일에 적절한 환경 변수를 설정해주는 셸 스크립트일 뿐이기 때문입니다.)
사용자의 OS는 기본적으로 실행 중인 프로세스에 연결하는 것을 제한할 수 있습니다—본인 소유의 프로세스라도 마찬가지입니다. 이를 재설정하는 방법은 이 책의 범위를 벗어납니다.
GDB 매크로 사용하기
GDB 매크로는 반복적인 작업을 자동화하고 복잡한 명령에 대한 단축키를 제공하여 디버깅 세션의 효율성을 높여줍니다. Erlang 런타임에는 병리학자를 위한 에뮬레이터 툴박스(Emulator Toolbox for Pathologists, ETP)라고 알려진 미리 정의된 GDB 매크로 세트가 포함되어 있으며, 이는 내부 BEAM 구조, 프로세스 상태, 메모리 할당, 스케줄링 정보와 같은 BEAM의 다양한 측면을 검사하는 데 도움을 줍니다.
ETP 매크로는 erts/etc/unix/etp-commands에서 찾을 수 있습니다. 이전 섹션에서 설명한 대로 cerl 스크립트를 통해 GDB 세션을 실행하면 자동으로 로드됩니다. 그런 다음 특정 Erlang 프로세스에 대한 자세한 정보를 가져오기 위해 etp-process-info와 같은 매크로를 사용할 수 있습니다:
etp-process-info <process_pointer>
<process_pointer>를 관심 있는 프로세스 제어 블록(PCB)에 대한 실제 포인터로 바꾸세요. 이 매크로들은 BEAM의 내부 구조에서 의미 있는 데이터를 추출하는 프로세스를 단순화합니다.
GDB를 사용하여 BEAM을 디버깅하고 이러한 매크로를 활용하는 방법에 대한 종합적인 가이드는 Debugging the BEAM 및 Debug emulator 문서를 참조하세요. 이 자료들은 Erlang 런타임 시스템 내의 문제를 효과적으로 진단하고 해결하는 데 도움이 되는 심층적인 지침과 예제를 제공합니다.
18.8.2. SystemTap와 DTrace
SystemTap와 DTrace는 애플리케이션 코드를 수정하지 않고도 개발자가 시스템 동작을 실시간으로 분석하고 모니터링할 수 있게 해주는 강력한 동적 트레이싱 프레임워크입니다. 이러한 도구들은 성능 병목 현상을 조사하고, 디버깅 문제를 해결하며, 낮은 수준에서 시스템 상호작용을 이해하는 데 특히 유용합니다. 두 도구 모두 유사한 목적을 수행하지만, 서로 다른 운영 체제를 위해 설계되었습니다—SystemTap은 Linux에서 널리 사용되고, DTrace는 주로 Solaris, macOS 및 BSD 변형에서 사용됩니다.
이러한 도구를 Erlang과 함께 사용하면 BEAM 가상 머신의 동작, 프로세스 스케줄링, 가비지 컬렉션, 프로세스 간 통신에 대한 깊은 통찰을 얻을 수 있습니다.
SystemTap과 DTrace 소개
SystemTap와 DTrace는 실행 중인 커널 및 사용자 공간 애플리케이션에 동적으로 생성된 프로브를 삽입하는 방식으로 동작합니다. 이러한 프로브는 실시간 데이터를 캡처하여, 개발자가 애플리케이션을 중단하거나 수정하지 않고도 프로그램 실행을 검사하고 분석할 수 있게 해줍니다.
-
SystemTap: Linux용으로 개발된 SystemTap은 스크립팅을 사용하여 커널 이벤트, 사용자 공간 프로그램, 런타임 동작을 모니터링할 수 있게 해줍니다. 이는 프로파일링, 결함 감지, 시스템 인트로스펙션에 흔히 사용됩니다.
-
DTrace: 원래 Sun Microsystems가 Solaris용으로 개발한 DTrace는 강력한 스크립팅 언어와 함께 유사한 트레이싱 기능을 제공합니다. macOS, FreeBSD, SmartOS에서 널리 사용됩니다.
두 도구 모두 개발자가 함수 실행 시간을 측정하고, 시스템 호출을 추적하고, 메모리 사용량을 검사하고, 성능 최적화와 복잡한 애플리케이션 디버깅에 중요한 이벤트 기반 데이터를 수집할 수 있게 해줍니다.
Erlang과 함께 SystemTap 및 DTrace 사용하기
Erlang과 함께 SystemTap과 DTrace를 사용하려면, BEAM 런타임 시스템에서 필요한 트레이싱 지원을 활성화해야 합니다. 이를 통해 가상 머신에 프로브를 삽입하여 함수 호출, 메시지 패싱, 가비지 컬렉션, 스케줄링 이벤트를 모니터링할 수 있습니다.
18.8.3. SystemTap을 Erlang과 함께 사용하기
SystemTap 스크립트는 BEAM 에뮬레이터에 내장된 사용자 공간 마커에 의존합니다. 이러한 마커를 통해 SystemTap은 다양한 내부 이벤트에 후킹할 수 있습니다. Erlang과 함께 SystemTap을 사용하려면:
-
SystemTap이 설치되어 있는지 확인하세요 (Ubuntu, Fedora, CentOS 등의 Linux 배포판에서):
sudo apt-get install systemtap systemtap-sdt-dev
또는
sudo dnf install systemtap systemtap-devel
-
Erlang의 SystemTap 프로브 활성화: BEAM VM은 SystemTap 지원을 포함하지만,
--enable-systemtap으로 컴파일해야 합니다:
./configure --enable-systemtap
make
-
사용 가능한 프로브 목록 확인: BEAM 런타임 시스템에서 어떤 프로브를 사용할 수 있는지 확인하려면:
stap -L 'process("*beam.smp").mark("*")'
-
SystemTap 스크립트 작성: 다음 예제는 BEAM VM에서 함수 호출을 추적합니다:
probe process("beam.smp").mark("function_entry") {
printf("Function call in BEAM: %s\n", user_string($arg1))
}
-
스크립트 실행: 스크립트를 실행하여 추적을 시작합니다:
sudo stap my_script.stp
이를 통해 개발자는 함수 호출을 관찰하고, 병목 현상을 감지하며, 실시간으로 성능 문제를 디버깅할 수 있습니다.
18.8.4. Erlang과 함께 DTrace 사용하기
DTrace는 BEAM 런타임과 직접 통합되어 시스템 동작에 대한 깊이 있는 가시성을 제공합니다. 이를 통해 함수 호출, 메모리 할당, 가비지 컬렉션, 그리고 프로세스 간 통신을 추적할 수 있습니다.
Dtrace는 Solaris에서 가장 잘 작동합니다. SystemTap과 함께 제공되는 Linux 버전이 있지만, Solaris 버전만큼 강력하지는 않습니다.
macOS에서는 DTrace가 미리 설치되어 있습니다. Ubuntu에서는 다음을 통해 설치할 수 있습니다:
sudo apt-get install systemtap-sdt-dev
BEAM 가상 머신은 내장 DTrace 지원을 포함합니다. 필요한 경우, DTrace 지원과 함께 Erlang을 재빌드하세요:
./configure --with-dtrace
make
-
간단한 DTrace 스크립트 작성하기: 다음 스크립트는 Erlang 함수 호출을 추적합니다:
syscall::write:entry
/execname == "beam.smp"/ {
printf("Erlang process writing output\n");
}
-
스크립트 실행: DTrace를 실행하여 트레이싱을 시작합니다:
sudo dtrace -s my_script.d
이는 BEAM 가상 머신의 내부 동작을 실시간으로 모니터링할 수 있는 비침습적인 방법을 제공합니다.
19. 트레이싱
동시성 및 분산 시스템은 특히 디버깅과 모니터링과 관련하여 독특한 도전과 복잡성을 안고 있습니다. 이러한 맥락에서 트레이싱은 개발자에게 실행 중인 시스템의 동작을 들여다볼 수 있는 창을 제공하며 유용합니다. 이 장은 BEAM에서 트레이싱의 역할을 탐구하는 것으로 시작합니다.
트레이싱은 프로세스 실행, 이러한 프로세스 간의 통신, 그리고 시스템의 전반적인 동작에 대한 가시성을 제공한다. Erlang 시스템은 견고성과 결함 허용성으로 잘 알려져 있으며, 이는 우리가 달성하고자 하는 특성이다. 하지만 이러한 특성은 오류나 예기치 않은 동작의 원인을 정확히 찾아내는 것을 복잡하게 만들 수 있다. 트레이싱을 통해 개발자는 실시간으로 또는 기록된 로그를 통해 실행 흐름을 따라갈 수 있어, 버그를 식별하고 수정하기가 더 쉬워진다. 트레이싱은 또한 Erlang 애플리케이션의 성능을 모니터링하는 데 중요한 기능을 수행한다. 함수 호출, 메시지 패싱, 그리고 기타 시스템 활동을 트레이싱하면 개발자는 성능 병목 지점, 비효율적인 코드 경로, 예기치 않은 시스템 동작에 대한 데이터를 수집할 수 있다. Erlang의 트레이싱 기능은 시스템 동작에 대한 통찰력을 제공하는 데까지 확장된다. 트레이싱은 시스템의 여러 부분이 어떻게 상호작용하는지, 어떻게 확장되는지, 그리고 장애로부터 어떻게 복구되는지를 이해하는 데 도움이 된다. 따라서 트레이싱은 교육적인 도구가 되어, 개발자가 시스템 동작을 더 정확하게 모델링하고 예측할 수 있게 해준다.
우리는 ERTS가 제공하는 트레이싱 도구 중 일부와 내장 지원 위에 구축된 일부 도구들을 살펴볼 것입니다.
19.1. 내장 Erlang 트레이싱 기능
19.1.1. io:format 트레이싱
전통적인 의미의 트레이싱 도구는 아니지만, io:format는 다른 프로그래밍 언어에서 찾아볼 수 있는 printf 디버깅과 같은 전통적인 디버깅 기법에 상응하는 Erlang의 도구 역할을 합니다. 이를 통해 개발자는 코드 내에 출력 구문을 삽입하여 변수 상태, 함수 호출 결과, 프로세스 상태를 즉각적이고 간단하게 출력할 수 있습니다. 이 방법은 접근성이 매우 높고 설정이 거의 필요하지 않아, 더 정교한 트레이싱 도구를 사용하기 전에 문제를 파악하기 위한 훌륭한 첫 단계가 됩니다.
19.1.2. Erlang 트레이스 (erl_tracer)
이 트레이싱 기능은 Erlang 함수, 메시지, 프로세스를 추적하기 위한 저수준 인터페이스를 제공합니다. 이는 더 상위 수준의 트레이싱 도구가 구축되는 핵심 기능을 제공하며, Erlang 런타임에서 직접 지원하는 필수적인 트레이싱 기본 요소를 제공합니다.
19.1.3. 순차 추적 (seq_trace)
분산 Erlang 시스템 내에서 메시지의 흐름을 추적하는 데 특화된 seq_trace는 개발자가 프로세스 간 메시지 패싱의 순서를 추적할 수 있게 해줍니다.
19.2. 서드파티 도구
19.2.1. Redbug
Redbug는 eper 성능 및 디버깅 도구 모음의 일부이며, 사용자 친화적인 인터페이스와 최소한의 성능 영향으로 잘 알려져 있습니다. 이 도구는 안전하고 효율적인 추적에 중점을 두어 과도한 추적 출력이나 시스템 과부하와 같은 일반적인 함정을 피하므로, 실시간 시스템 분석에 이상적인 선택입니다. 이것은 외부 라이브러리이므로 별도로 설치해야 합니다. redbug의 가장 뛰어난 기능 중 하나는 과부하 시 스스로 종료하는 기능입니다.
Redbug 설치하기
redbug는 다음을 통해 클론할 수 있습니다:
$ git clone https://github.com/massemanet/redbug
그런 다음 다음과 같이 컴파일할 수 있습니다:
$ cd redbug
$ make
Erlang 셸을 시작할 때 redbug가 경로에 포함되어 있는지 확인하면 준비가 끝납니다. 이는 erl을 호출할 때 redbug BEAM 파일의 경로를 명시적으로 추가하여 수행할 수 있습니다:
$ erl -pa /path/to/redbug/ebin
또는 ~/.erlang 파일에 다음 줄을 추가할 수 있습니다. 이렇게 하면 시작할 때마다 redbug의 경로가 자동으로 포함됩니다:
code:add_patha("/path/to/redbug/ebin").
Redbug 사용하기
Redbug은 과부하에 대한 자체 보호 메커니즘 덕분에 프로덕션 환경에서 안전하게 사용할 수 있는데, 이 메커니즘은 트레이싱 메시지가 너무 많이 전송될 경우 도구를 종료시켜 Erlang 노드가 과부하되는 것을 방지합니다. 실제로 어떻게 동작하는지 살펴보겠습니다:
$ erl
Erlang/OTP 19 [erts-8.2] [...]
Eshell V8.2 (abort with ^G)
1> l(redbug). (1)
{module,redbug}
2> redbug:start("lists:sort/1"). (2)
{30,1}
3> lists:sort([3,2,1]).
[1,2,3]
% 15:20:20 <0.31.0>({erlang,apply,2}) (3)
% lists:sort([3,2,1])
redbug done, timeout - 1 (4)
| 1 | 먼저, redbug 모듈을 사용할 수 있고 로드되어 있는지 확인합니다. |
| 2 | 그런 다음 redbug를 시작합니다. 우리는 모듈 lists에서 내보낸, arity가 1인 sort라는 이름의 함수에 관심이 있습니다. Erlang 용어로 애리티는 특정 함수가 받는 입력 인자의 개수를 나타낸다는 점을 기억하세요. |
| 3 | 마지막으로 lists:sort/1 함수를 호출하고, redbug에 의해 메시지가 생성되는지 확인합니다. |
| 4 | 기본 타임아웃(15초)에 도달하면 redbug가 멈추고 "redbug done" 메시지를 표시합니다. Redbug는 또한 친절하게도 멈춘 이유(timeout)와 그 시점까지 수집된 메시지의 개수(1)를 알려줍니다. |
이제 redbug가 생성하는 실제 메시지를 살펴보겠습니다. 기본적으로 메시지는 표준 출력으로 출력되지만, 파일로 덤프하는 것도 가능합니다:
% 15:20:20 <0.31.0>({erlang,apply,2})
% lists:sort([3,2,1])
사용 중인 redbug 버전에 따라 약간 다른 메시지가 나타날 수 있습니다. 이 경우, 메시지는 두 줄에 걸쳐 나뉩니다. 첫 번째 줄에는 타임스탬프, 함수를 호출한 Erlang 프로세스의 프로세스 식별자(또는 PID), 그리고 호출자 함수가 포함됩니다. 두 번째 줄에는 입력 인자를 포함하여 호출된 함수가 포함됩니다. 두 줄 모두 앞에 %가 붙어 있는데, 이는 Erlang 주석 문법을 떠올리게 합니다.
redbug에게 반환 값에 대한 추가 메시지를 생성하도록 요청할 수도 있습니다. 이는 다음 구문을 사용하여 수행됩니다:
4> redbug:start("lists:sort/1->return").
{30,1}
lists:sort/1 함수를 다시 호출해 보겠습니다. 이번에는 redbug의 출력이 약간 다릅니다.
5> lists:sort([3,2,1]).
[1,2,3]
% 15:35:52 <0.31.0>({erlang,apply,2})
% lists:sort([3,2,1])
% 15:35:52 <0.31.0>({erlang,apply,2})
% lists:sort/1 -> [1,2,3]
redbug done, timeout - 1
이 경우 두 개의 메시지가 생성되는데, 하나는 함수에 진입할 때이고 다른 하나는 같은 함수를 떠날 때이다.
실제 코드를 다룰 때는 트레이스 메시지가 복잡해져서 읽기 어려운 경우가 많습니다. 10,000개의 요소를 포함하는 리스트의 정렬을 트레이스하려고 하면 어떤 일이 일어나는지 살펴보겠습니다.
6> lists:sort(lists:seq(10000, 1, -1)).
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,
23,24,25,26,27,28,29|...]
% 15:48:42.208 <0.77.0>({erlang,apply,2})
% lists:sort([10000,9999,9998,9997,9996,9995,9994,9993,9992,9991,9990,9989,9988,9987,9986,
% 9985,9984,9983,9982,9981,9980,9979,9978,9977,9976,9975,9974,9973,9972,9971,
% 9970,9969,9968,9967,9966,9965,9964,9963,9962,9961,9960,9959,9958,9957,9956,
% 9955,9954,9953,9952,9951,9950,9949,9948,9947,9946,9945,9944,9943,9942,9941,
% 9940,9939,9938,9937,9936,9935,9934,9933,9932,9931,9930,9929,9928,9927,9926,
% 9925,9924,9923,9922,9921,9920,9919,9918,9917,9916,9915,9914,9913,9912,9911,
% [...]
% 84,83,82,81,80,79,78,77,76,75,74,73,72,71,70,69,68,67,66,65,64,63,62,61,60,
% 59,58,57,56,55,54,53,52,51,50,49,48,47,46,45,44,43,42,41,40,39,38,37,36,35,
% 34,33,32,31,30,29,28,27,26,25,24,23,22,21,20,19,18,17,16,15,14,13,12,11,10,9,
% 8,7,6,5,4,3,2,1])
% 15:48:42.210 <0.77.0>({erlang,apply,2}) lists:sort/1 ->
% [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,
% 23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,
% 42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,
% 61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,
% 80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,
% 99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,
% [...]
% 9951,9952,9953,9954,9955,9956,9957,9958,9959,9960,9961,
% 9962,9963,9964,9965,9966,9967,9968,9969,9970,9971,9972,
% 9973,9974,9975,9976,9977,9978,9979,9980,9981,9982,9983,
% 9984,9985,9986,9987,9988,9989,9990,9991,9992,9993,9994,
% 9995,9996,9997,9998,9999,10000]
redbug done, timeout - 1
대부분의 출력이 여기서 잘렸지만, 대략적인 느낌은 알 수 있을 것입니다. 상황을 개선하기 위해 redbug 옵션 몇 가지를 사용할 수 있습니다. {arity, true} 옵션은 redbug가 주어진 함수의 실제 입력 인자 값 대신 개수만 표시하도록 지시합니다. {print_return, false} 옵션은 redbug가 함수 호출의 반환 값을 표시하지 않고, 대신 … 기호를 표시하도록 합니다. 이 옵션들이 실제로 작동하는 모습을 살펴보겠습니다.
7> redbug:start("lists:sort/1->return", [{arity, true}, {print_return, false}]).
{30,1}
8> lists:sort(lists:seq(10000, 1, -1)).
[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,
23,24,25,26,27,28,29|...]
% 15:55:32 <0.77.0>({erlang,apply,2})
% lists:sort/1
% 15:55:32 <0.77.0>({erlang,apply,2})
% lists:sort/1 -> '...'
redbug done, timeout - 1
기본적으로 redbug는 15초 후 또는 10개의 메시지를 수신한 후 중지됩니다. 이 값들은 안전한 기본값이지만, 대부분의 경우 충분하지 않습니다. time와 msgs 옵션을 사용하여 이 제한을 늘릴 수 있습니다. time은 밀리초 단위로 표현됩니다.
9> redbug:start("lists:sort/1->return", [{arity, true}, {print_return, false}, {time, 60 * 1000}, {msgs, 100}]).
{30,1}
여러 함수 호출에 대해 동시에 redbug를 활성화할 수도 있습니다. lists:sort/1과 lists:sort_1/3(전자가 사용하는 내부 함수) 두 함수 모두에 대해 트레이싱을 활성화해 봅시다:
10> redbug:start(["lists:sort/1->return", "lists:sort_1/3->return"]).
{30,2}
11> lists:sort([4,4,2,1]).
[1,2,4,4]
% 18:39:26 <0.32.0>({erlang,apply,2})
% lists:sort([4,4,2,1])
% 18:39:26 <0.32.0>({erlang,apply,2})
% lists:sort_1(4, [2,1], [4])
% 18:39:26 <0.32.0>({erlang,apply,2})
% lists:sort_1/3 -> [1,2,4,4]
% 18:39:26 <0.32.0>({erlang,apply,2})
% lists:sort/1 -> [1,2,4,4]
redbug done, timeout - 2
마지막으로, redbug는 일치하는 입력 인자에 대해서만 결과를 표시하는 기능을 제공합니다. 이때 문법이 마치 마법처럼 보입니다.
12> redbug:start(["lists:sort([1,2,5])->return"]).
{30,1}
13> lists:sort([4,4,2,1]).
[1,2,4,4]
14> lists:sort([1,2,5]).
[1,2,5]
% 18:45:27 <0.32.0>({erlang,apply,2})
% lists:sort([1,2,5])
% 18:45:27 <0.32.0>({erlang,apply,2})
% lists:sort/1 -> [1,2,5]
redbug done, timeout - 1
위 예제에서, 우리는 입력 인자가 리스트 [1,2,5]인 경우의 lists:sort/1 함수 호출에만 관심이 있다고 redbug에게 알려주고 있습니다. 이를 통해 대상 함수가 여러 액터에 의해 동시에 사용되는 경우에도 특정 사용 사례에만 관심이 있을 때 발생하는 엄청난 양의 노이즈를 제거할 수 있습니다. 아, 그리고 와일드카드로 언더스코어를 사용할 수 있다는 것을 잊지 마세요:
15> redbug:start(["lists:sort([1,_,5])->return"]). {30,1}
16> lists:sort([1,2,5]). [1,2,5]
% 18:49:07 <0.32.0>({erlang,apply,2}) lists:sort([1,2,5])
% 18:49:07 <0.32.0>({erlang,apply,2}) lists:sort/1 -> [1,2,5]
17> lists:sort([1,4,5]). [1,4,5]
% 18:49:09 <0.32.0>({erlang,apply,2}) lists:sort([1,4,5])
% 18:49:09 <0.32.0>({erlang,apply,2}) lists:sort/1 -> [1,4,5] redbug
% done, timeout - 2
이 절은 redbug에 대한 완전한 가이드를 표방하지 않지만, 시작하기에는 충분할 것입니다. redbug에서 사용 가능한 옵션의 전체 목록을 확인하려면 도구 자체에 물어볼 수 있습니다:
18> redbug:help().
19.2.2. Recon Trace (recon_trace)
프로덕션 시스템 진단을 위한 recon 라이브러리의 구성 요소로서, recon_trace는 안전성과 최소한의 성능 영향을 염두에 두고 설계된 강력한 트레이싱 기능으로 두드러집니다.
19.2.3. 비교 및 사용 사례
내장 도구와 서드파티 도구 중 어떤 것을 선택할지는 특정 디버깅 요구 사항에 크게 좌우됩니다. io:format은 간단하고 빠른 디버깅 방법을 제공하는 반면, erl_tracer와 recon_trace 같은 도구는 더 포괄적인 추적 기능으로 더 깊은 통찰을 제공합니다.
redbug와 recon_trace와 같이 운영 영향을 최소화하도록 설계된 도구들은 프로덕션 환경에서 특히 유용합니다. 이러한 도구들은 상당한 성능 저하 없이 실시간 문제 진단을 가능하게 합니다.
seq_trace는 분산된 Erlang 노드 간의 메시지 패싱과 프로세스 상호작용의 동작을 파악하는 데 필수적인 통찰을 제공합니다.
20. BEAM 생태계에서의 테스팅
20.1. 소개
이 장에서는 개발 과정에서 Erlang 코드의 신뢰성과 견고성을 보장하는 방법을 살펴보겠습니다. 먼저 애플리케이션의 테스트를 쉽게 작성하고 실행할 수 있게 해주는 인기 있는 테스트 프레임워크인 EUnit부터 시작하겠습니다.
20.1.1. EUnit
EUnit은 개별 프로그램 단위를 테스트할 수 있게 해주는 Erlang 단위 테스트 프레임워크입니다. 이러한 단위는 함수와 모듈에서부터 프로세스, 나아가 전체 애플리케이션에 이르기까지 다양합니다. EUnit는 테스트를 작성, 실행하고 결과를 분석하는 데 도움을 주어 코드가 정확하고 신뢰할 수 있도록 보장합니다.
기초 및 설정
Erlang 모듈에서 EUnit을 사용하려면 -module 선언 뒤에 다음 줄을 포함하세요:
-include_lib("eunit/include/eunit.hrl").
이 줄은 EUnit의 기능에 접근할 수 있게 해주며, 모듈 내 모든 단위 테스트를 실행하는 test() 함수를 내보냅니다.
테스트 케이스 및 테스트 스위트 작성하기
간단한 테스트 함수를 만들려면, 인자를 받지 않고 이름이 _test()로 끝나는 함수를 정의하세요. 값을 반환하여 성공하거나 예외를 던져 실패해야 합니다.
더 고급 테스트 케이스를 만들려면 =를 사용한 패턴 매칭을 활용하세요. 예를 들면:
reverse_nil_test() -> [] = lists:reverse([]).
또는, ?assert(Expression) 매크로를 사용하여 표현식을 평가하는 테스트 케이스를 작성할 수 있습니다:
length_test() -> ?assert(length([1,2,3]) =:= 3).
테스트 실행 및 결과 분석
모듈에 EUnit 선언을 포함시켰다면, 모듈을 컴파일하고 자동으로 내보내지는 test() 함수를 실행하세요. 예를 들어 모듈 이름이 m이라면, m:test()를 호출하여 모듈 내 모든 테스트에 대해 EUnit을 실행합니다.
EUnit은 eunit:test/1 함수를 사용하여 테스트를 실행할 수도 있습니다. 예를 들어, eunit:test(m)을 호출하는 것은 m:test()를 호출하는 것과 동일합니다.
테스트 코드를 일반 코드와 분리하려면, 모듈 이름이 m이면 테스트 함수를 m_tests라는 이름의 모듈에 작성하세요. EUnit에게 모듈 m을 테스트하도록 요청하면, m_tests 모듈도 찾아서 해당 테스트를 실행합니다.
EUnit은 테스트 함수의 표준 출력을 캡처하므로, 테스트 코드가 표준 출력에 쓰더라도 해당 텍스트는 콘솔에 나타나지 않습니다. 이를 우회하려면 EUnit 디버깅 매크로를 사용하거나 io:format(user, "~w", [Term])처럼 사용자 출력 스트림에 쓰십시오.
테스트 대상 유닛에서 생성된 출력을 확인하는 방법에 대한 자세한 내용은 출력 확인을 위한 매크로에 관한 EUnit 문서를 참고하세요.
20.1.2. Common Test
기초 및 설정
Common Test(CT)는 테스트 스위트를 작성하고 실행하기 위해 Erlang/OTP에 포함된 강력한 테스팅 프레임워크입니다. 화이트박스 테스팅과 블랙박스 테스팅을 모두 지원하므로, Erlang 애플리케이션, 분산 시스템, 그리고 외부 시스템과의 상호작용까지 테스트하는 데 유연한 선택지가 됩니다.
Common Test 설치
Erlang 설치본이 있다면 Common Test는 이미 포함되어 있을 것입니다. 그러나 최소 설치 환경이나 커스텀 빌드로 작업 중이라면, 의존성에 common_test 애플리케이션을 추가하여 이를 사용할 수 있도록 할 수 있습니다.
common_test가 설치되어 있는지 확인하려면:
1> application:ensure_all_started(common_test).
rebar3를 사용하는 경우, 의존성에 추가되었는지 확인하세요:
{deps, [common_test]}.
기본 테스트 스위트 만들기
Common Test 스위트는 특정 구조를 따르는 단순한 Erlang 모듈입니다. sample_SUITE.erl이라는 이름의 파일을 test/ 디렉터리에 생성하는 것부터 시작하세요.
예제 sample_SUITE.erl:
-module(sample_SUITE).
-compile(export_all).
% Common Test includes these callbacks
-include_lib("common_test/include/ct.hrl").
% Test suite information
suite() ->
[{timetrap, {seconds, 10}}]. % Time limit for tests
init_per_suite(Config) ->
io:format("Initializing test suite...~n"),
Config.
end_per_suite(Config) ->
io:format("Cleaning up test suite...~n"),
Config.
% Define test cases
all() -> [simple_test].
simple_test(_Config) ->
io:format("Running simple test case...~n"),
?assertEqual(42, 21 + 21).
이 테스트 스위트는 suite/0로 테스트 스위트를 정의하고, init_per_suite/1과 end_per_suite/1을 사용해 설정 및 정리를 수행하며, 21 + 21 = 42를 검증하는 테스트 케이스 simple_test/1을 정의합니다.
테스트 케이스 및 테스트 스위트 작성하기
Common Test의 테스트 케이스는 단언(assertion)과 검사를 수행하는 함수입니다. 그것들은 간단한 명명 규칙을 따르며 all/0 함수에 나열되어야 합니다.
Common Test는 ct.hrl에서 어설션 매크로를 제공합니다:
?assert(Expression).
?assertEqual(Expected, Actual).
?assertNotEqual(Unexpected, Actual).
?assertMatch(Pattern, Expression).
?assertNotMatch(Pattern, Expression).
다중 테스트 케이스 예시:
all() -> [math_test, string_test].
math_test(_Config) ->
?assertEqual(10, 5 * 2).
string_test(_Config) ->
?assertEqual("hello", string:to_lower("HELLO")).
테스트 케이스는 설정 파일이나 이전 setup 함수로부터 인자를 전달받을 수 있습니다.
init_per_testcase(math_test, Config) ->
[{base_value, 10} | Config];
init_per_testcase(_, Config) -> Config.
math_test(Config) ->
Base = proplists:get_value(base_value, Config),
?assertEqual(Base * 2, 20).
테스트 실행 및 결과 분석
테스트는 ct_run이나 rebar3를 사용하여 실행됩니다.
Erlang 셸에서:
ct:run_test([{dir, "test/"}]).
rebar3를 사용하는 경우:
rebar3 ct
테스트 출력 이해하기
Common Test는 _build/test/logs/ 디렉토리에 상세한 로그를 생성합니다.
로그 파일:
-
ct_run.*.log- 메인 테스트 실행 로그 -
suite.log- 특정 테스트 스위트 실행 로그 -
testcase.log- 특정 테스트 케이스 실행을 기록합니다.
실시간 디버깅을 위해 verbose 출력을 활성화할 수 있습니다:
ct:run_test([{dir, "test/"}, {verbosity, high}]).
20.1.3. 기타 테스트 프레임워크 및 기법
Common Test가 Erlang/OTP에 포함된 표준 테스트 프레임워크이지만, 개발자들이 테스트 커버리지, 신뢰성, 자동화를 개선하기 위해 자주 사용하는 다른 여러 테스트 프레임워크와 방법론들이 있습니다. 이러한 도구들은 속성 기반 테스트(property-based testing)와 모킹(mocking)과 같은 추가적인 기능을 제공하며, 이는 Erlang 애플리케이션에서 복잡한 동작과 상호작용을 검증하는 데 도움이 됩니다. 이러한 기법들에 대한 상세한 탐구는 이 책의 범위를 벗어나지만, 이 절에서는 테스트 도구를 확장하는 데 관심이 있는 분들을 위해 이를 간략히 소개합니다.
속성 기반 테스팅
속성 기반 테스팅은 시스템이 항상 만족해야 하는 속성을 기반으로 방대한 수의 테스트 케이스를 생성한다는 점에서 전통적인 단위 테스트와 다릅니다. 개별 테스트 케이스를 작성하는 대신, 개발자가 속성을 정의하면 프레임워크가 자동으로 입력을 생성하여 해당 속성이 광범위한 시나리오에서 유지되는지 검증합니다. 이 접근 방식은 수동으로 작성된 테스트로는 다루지 못할 수 있는 엣지 케이스를 발견하는 데 특히 유용합니다.
Erlang 생태계에서 널리 사용되는 두 가지 속성 기반 테스트 라이브러리는 QuickCheck와 PropEr입니다:
-
QuickCheck(상용 및 오픈소스 버전)는 무작위 테스트 케이스를 생성하고 실패한 케이스를 최소 반례로 축소하는 강력한 도구입니다.
-
PropEr (Property-Based Testing for Erlang)는 유사한 기능을 갖춘 오픈소스 대안으로, 타입 명세를 활용한 프로퍼티 정의, 제너레이터, 상태 기반 테스트를 지원합니다.
속성 기반 테스트(Property-based testing)는 알고리즘, 프로토콜, 그리고 복잡한 입력 공간을 가진 시스템을 검증하는 데 매우 효과적입니다. 그러나 이는 명시적인 테스트 케이스를 작성하는 것에서 시스템 불변 조건과 제약 조건을 정의하는 것으로의 사고방식 전환을 필요로 합니다.
모킹
모킹(Mocking)은 테스트에서 의존성이나 외부 컴포넌트를 제어 가능한 대역으로 대체하는 데 사용되는 기법입니다. 이는 특히 단위 테스트(unit testing)에서 유용한데, 함수나 모듈을 그 의존성으로부터 격리시키면 전체 시스템 통합 없이도 특정 동작을 테스트하기가 더 쉬워지기 때문입니다.
-
Mocks는 코드를 격리된 상태에서 테스트할 수 있게 해주며, 함수의 의존성에 대한 실제 구현과 관계없이 함수가 올바르게 동작하는지 보장합니다.
-
데이터베이스나 네트워크 서비스와 같은 외부 시스템과의 상호작용을 피함으로써 테스트 실행 속도를 높입니다.
-
목(Mocks)은 타임아웃, 실패, 또는 의존성으로부터의 예상치 못한 응답을 시뮬레이션하는 것과 같이 엣지 케이스에 대한 통제된 테스트를 가능하게 합니다.
목(mock) 프레임워크가 흔히 사용되는 일부 객체 지향 언어와 달리, Erlang의 함수형 특성과 메시지 패싱 모델은 목킹에 대해 다른 접근 방식을 필요로 합니다. 일반적인 전략에는 다음이 포함됩니다:
-
함수 오버라이드를 이용한 수동 모킹: 고차 함수 또는 명시적 모듈 대체를 사용하는 방법입니다.
-
meck사용: 런타임에 모듈 함수를 교체하여 통제된 테스트를 가능하게 하는 인기 있는 모킹 라이브러리입니다. -
프로세스 기반 목(mock): 미리 정의된 응답을 반환하는 경량 프로세스로 외부 시스템을 시뮬레이션합니다.
모킹을 위한 모범 사례
Erlang 테스트에서 모킹을 사용할 때는 다음 모범 사례를 고려하십시오:
-
목이 꼭 필요한 경우에만 사용하세요—통합 테스트가 가능하다면 실제 구현을 사용하는 것을 선호하세요.
-
오해의 소지가 있는 테스트 결과를 방지하기 위해 모킹된(mocked) 동작을 현실적으로 유지하세요.
-
가능한 경우 목(mock)을 속성 기반 테스트(property-based testing)와 결합하여, 특정 의존성을 제어하면서도 더 넓은 테스트 커버리지를 확보하십시오.
속성 기반 테스트와 모킹은 특정 테스트 시나리오에서 매우 유용할 수 있지만, 효과적으로 활용하려면 더 깊은 이해와 모범 사례에 따른 구현이 필요합니다. 이러한 주제에 대한 더 포괄적인 논의를 원하는 독자는 "Property-Based Testing with PropEr, Erlang, and Elixir"와 Meck와 같은 전문 자료를 참고하시기 바랍니다.
21. 프로파일링, 모니터링, 그리고 성능 최적화
"먼저 작동하게 만들고, 그다음 아름답게 만들고, 정말로, 정말로 그래야만 한다면 그때 빠르게 만드세요. 90%의 경우, 아름답게 만들면 이미 빠를 것입니다." - Joe Armstrong
성능 최적화는 견고하고 확장 가능한 시스템을 구축하는 데 있어 중요한 측면입니다. 기본 실행 모델과 메모리 관리 전략을 이해하는 것은 병목 현상을 파악하고 시스템 성능을 개선하는 데 필수적입니다. 이 장에서는 고성능 Erlang 및 Elixir 애플리케이션을 구축하는 데 도움이 되는 프로파일링 도구, 모니터링 기법, 최적화 전략을 살펴봅니다.
21.1. BEAM 성능의 기초
지금까지 이 책에서 다룬 핵심 개념 중 일부를 간단히 되짚어 보겠습니다.
BEAM 성능은 근본적으로 고도로 동시적이고 결함 허용적인 시스템을 위해 설계된 실행 모델에 결부되어 있습니다. BEAM 가상 머신은 각 Erlang 프로세스가 자신만의 격리된 힙을 가지고 독립적으로 실행되는 프로세스 지향 모델을 사용하여 동작합니다. 이러한 설계는 프로세스 간의 경합을 최소화하지만, 특히 메모리 사용량과 메시지 패싱 측면에서 특정 성능 트레이드오프를 발생시킵니다.
BEAM에서의 실행은 리덕션을 중심으로 구조화되며, 리덕션은 프로세스가 수행하는 작업의 단위를 나타낸다. 각 프로세스는 다른 프로세스에게 선점되기 전까지 특정 개수의 리덕션을 할당받으며, 이를 통해 실행 중인 모든 개체 간의 공정한 스케줄링이 보장된다. 스케줄러 자체는 멀티코어 효율성을 위해 설계되었으며, 일반적으로 CPU 코어당 하나의 스케줄러가 실행된다. 스케줄러 간의 부하 분산은 동적으로 처리되며, 작업 훔치기(work-stealing) 전략이 균등한 활용도를 유지하는 데 도움을 준다. 타이밍 휠(timing wheel)은 예약된 작업을 관리하여, 과도한 CPU 오버헤드 없이 타임아웃과 지연된 메시지를 효율적으로 처리할 수 있도록 보장한다.
BEAM에서 프로세스 간 통신은 비동기 메시지 패싱 모델을 따른다. 프로세스는 메모리를 공유하지 않기 때문에, 공유 바이너리와 같은 특정 최적화가 적용되지 않는 한 메시지는 한 프로세스 힙에서 다른 프로세스 힙으로 복사된다. 이는 고빈도 메시징 시나리오에서 비효율성을 초래할 수 있다. 외부 시스템에 대한 인터페이스 역할을 하는 포트는 스케줄러를 블로킹하지 않기 위해 비동기 통신 메커니즘에 의존한다. 이는 BEAM 런타임 시스템 외부의 I/O 바운드 또는 연산 집약적 작업과 인터페이스할 때 특히 중요하다.
BEAM의 메모리 관리는 각 프로세스별 개별 힙(private heap)을 중심으로 구축되어 있습니다. 이러한 접근 방식은 최소한의 잠금(locking)을 보장하지만, 신중한 가비지 컬렉션 전략을 필요로 합니다. 세대별 가비지 컬렉터(generational garbage collector)가 프로세스별로 실행되어, 수명이 짧은 데이터는 효율적으로 수집하면서 수명이 긴 데이터는 유지될 수 있도록 합니다. 큰 바이너리는 불필요한 중복을 피하기 위해 공유 힙(shared heap)에서 별도로 관리되지만, 부적절하게 처리할 경우 메모리 단편화(memory fragmentation)로 이어질 수 있습니다. ETS(Erlang Term Storage) 테이블은 공유 데이터 저장을 위한 대안을 제공하지만, ETS에 대한 연산은 텀 복사를 수반하므로 높은 처리량이 요구되는 애플리케이션에서는 오버헤드를 유발할 수 있습니다.
BEAM의 데이터 구조는 불변 설계를 따르며, 이는 튜플과 맵에 대한 업데이트가 제자리 수정이 아닌 복사를 통해 이루어짐을 의미합니다. 이러한 복사 후 업데이트(copy-on-update) 동작은 안전성을 보장하지만, 큰 구조체가 자주 수정될 경우 비용이 클 수 있습니다. BEAM 성능을 최적화하려면 불필요한 복사를 최소화하는 방식으로 데이터를 구조화하고, 다양한 접근 패턴에 적합한 저장 메커니즘을 활용해야 합니다.
21.2. 프로파일링 도구
BEAM에는 시간이—그리고 그에 따른 성능 문제가—어디에 숨어 있는지 확인할 수 있게 해주는 내장 프로파일러 세트가 갖추어져 있습니다. 프로파일링에 사용할 수 있는 redbug, recon과 같은 외부 도구도 있습니다. 각 도구는 저마다의 강점과 트레이드오프를 가지고 있습니다.
21.2.1. fprof
도구 fprof는 시간 정보와 함께 상세한 호출 그래프를 캡처하기 위해 코드를 계측하는 트레이스 기반 프로파일러입니다. 이는 모든 함수 호출을 수집한 다음, 별도의 후처리 단계에서 그 방대한 트레이스를 사람이 읽기 쉬운 보고서로 변환합니다. 어떤 함수가 느린지뿐만 아니라 그것들이 어떻게 중첩되어 있는지도 이해하고 싶다면, fprof가 전체 이야기를 알려줍니다. 단점은? 오버헤드가 상당할 수 있으므로, 실제 운영 시스템보다는 격리된 테스트 환경에서 사용하는 것이 가장 좋습니다.
21.2.2. eprof
도구 eprof는 모든 개별 호출을 수집하기보다는 실행 시간 측정에 중점을 둡니다. 실행 중인 시스템을 샘플링함으로써, 함수가 실행되는 데 걸리는 시간에 대한 집계된 뷰를 제공합니다. eprof는 세부 정보와 오버헤드 사이에서 좋은 균형을 이룹니다. 그 보고서는 fprof가 생성하는 데이터의 홍수 없이 코드의 핫스팟을 강조합니다. 이는 빠른 성능 점검에 이상적입니다.
21.2.3. cprof
cprof 도구는 시간에 따라 함수 호출을 샘플링하여 통계적 접근 방식을 취합니다. 완전한 호출 그래프를 생성하는 대신, cprof는 데이터를 집계하여 애플리케이션의 "핫스팟"을 정확히 찾아냅니다. 실행 경로의 모든 굴곡을 알려주지는 않을 수 있지만, 처리 시간의 대부분이 어디에 소비되는지에 대한 전체적인 시각을 얻는 데는 탁월합니다.
21.2.4. tprof
도구 tprof(종종 트레이스 프로파일러로 여겨짐)는 Erlang의 내장 트레이싱 기능을 활용합니다. 트레이스 플래그를 설정함으로써, 전체 계측보다 덜 침습적인 방식으로 성능 데이터를 수집합니다. 결과로 생성된 리포트는 후처리하여 타이밍과 호출 관계를 모두 파악할 수 있습니다. 이 도구는 장시간 실행 중이거나 살아있는 프로세스를 프로파일링해야 할 때 특히 유용하지만, 설정이 다소 더 복잡합니다.
21.2.5. Recon과 Redbug
Recon과 redbug는 런타임 진단에서 강력한 힘을 발휘하는 커뮤니티 주도 라이브러리입니다. 둘 다 실무 사용을 염두에 두고 설계되었으며, 필요할 때 문제를 해결할 수 있도록 프로덕션 시스템에 바로 투입할 수 있을 만큼 가볍습니다.
21.2.6. Observer
Observer는 Erlang/OTP에 번들로 제공되는 그래픽 사용자 인터페이스로, 전체 시스템을 실시간으로 한눈에 볼 수 있게 해줍니다. 이는 프로세스 계층 구조, 메시지 큐, 성능 지표를 직관적인 방식으로 시각화합니다. 우리는 [Using Observer to Inspect Processes]에서 Observer에 대한 간단한 소개를 제공했습니다.
약간의 오버헤드를 추가할 수 있어(그래서 일반적으로 지속적인 프로덕션 프로파일링에는 사용되지 않지만), Observer는 대화형 디버깅과 애플리케이션의 전반적인 아키텍처를 이해하는 데 매우 유용합니다. 개별 프로세스를 세부적으로 파고드는 이 기능은 프로파일러가 제공하는 더 세밀한 통찰력을 보완합니다.
21.2.7. 프로파일링 시나리오
Erlang 애플리케이션이 눈에 띄게 느려지는 시나리오를 상상해 봅시다. 여러분은 자신의 모듈 중 하나가 원인이라고 의심하고 있습니다. 예제에서는 재귀 함수를 호출하여 리스트를 처리하는 함수를 가진 모듈이 있으며, 시간이 어디에서 소요되는지 정확히 짚어내고자 합니다. 이는 재귀적인 팩토리얼 함수만 있는 매우 간단한 시나리오이지만, 각 프로파일러를 성능 문제 진단에 어떻게 활용할 수 있는지 이해하는 데 도움이 될 것입니다.
-module(perf_example).
-export([compute/1, eprof/0, tprof_example/1]).
compute([]) ->
ok;
compute([H|T]) ->
_F = factorial(H),
compute(T).
factorial(0) -> 1;
factorial(N) when N > 0 ->
N * factorial(N - 1).
eprof() ->
Compute = fun() -> perf_example:compute([10,15,20,25,30]) end,
eprof:profile(Compute),
eprof:analyze().
tprof_example(Type) ->
Compute = fun() -> perf_example:compute([10,15,20,25,30]) end,
tprof:profile(Compute, #{type => Type}).
프로덕션 환경에서 적당한 길이의 리스트를 처리할 때 시스템이 느려지는 것을 발견합니다. factorial/1에 대한 반복적인 재귀 호출이 너무 많은 리덕션을 소모하여 CPU 경합을 유발하고 있는 것으로 의심됩니다.
아래는 각 도구가 어떻게 도움이 되는지에 대한 개요와 예제 사용법 및 결과 예시입니다.
fprof 예제
fprof로 모든 함수 호출을 추적하고 타이밍 세부 정보가 포함된 상세한 호출 그래프를 구축할 수 있습니다. 이를 통해 호출 중첩과 실행 시간에 대한 전체적인 그림을 얻을 수 있지만, 오버헤드가 높다는 단점이 있습니다.
Erlang 셸에서 다음을 실행하십시오:
1> fprof:apply(fun perf_example:compute/1, [[10, 15, 20, 25, 30]]).
ok
2> fprof:profile().
Reading trace data...
End of trace!
ok
3> fprof:analyse().
Processing data...
Creating output...
%% Analysis results:
{ analysis_options,
[{callers, true},
{sort, acc},
{totals, false},
{details, true}]}.
% CNT ACC OWN
[{ totals, 113, 0.259, 0.259}]. %%%
% CNT ACC OWN
[{ "<0.105.0>", 113,undefined, 0.259}]. %%
{[{undefined, 0, 0.259, 0.017}],
{ {fprof,apply_start_stop,4}, 0, 0.259, 0.017}, %
[{{perf_example,compute,1}, 1, 0.242, 0.005},
{suspend, 1, 0.000, 0.000}]}.
{[{{fprof,apply_start_stop,4}, 1, 0.242, 0.005},
{{perf_example,compute,1}, 5, 0.000, 0.009}],
{ {perf_example,compute,1}, 6, 0.242, 0.014}, %
[{{perf_example,factorial,1}, 5, 0.228, 0.057},
{{perf_example,compute,1}, 5, 0.000, 0.009}]}.
{[{{perf_example,compute,1}, 5, 0.228, 0.057},
{{perf_example,factorial,1}, 100, 0.000, 0.163}],
{ {perf_example,factorial,1}, 105, 0.228, 0.220}, %
[{garbage_collect, 1, 0.008, 0.008},
{{perf_example,factorial,1}, 100, 0.000, 0.163}]}.
{[{{perf_example,factorial,1}, 1, 0.008, 0.008}],
{ garbage_collect, 1, 0.008, 0.008}, %
[ ]}.
{[ ],
{ undefined, 0, 0.000, 0.000}, %
[{{fprof,apply_start_stop,4}, 0, 0.259, 0.017}]}.
{[{{fprof,apply_start_stop,4}, 1, 0.000, 0.000}],
{ suspend, 1, 0.000, 0.000}, %
[ ]}.
Done!
ok
분석 결과는 프로파일링 시나리오에서 실행 시간이 어디에 소요되는지에 대한 자세한 분석을 제공합니다. 여기서 알 수 있는 것은 다음과 같습니다:
총계는 113번의 함수 호출에 걸쳐 약 0.259초의 실제 경과 시간(wall-clock time)이 소요되었음을 보여줍니다. 이 시간에는 각 함수의 "고유(own)" 시간과 모든 중첩 호출에서 누적된 시간이 모두 포함됩니다.
보고서의 핵심 부분은 재귀 함수에서의 호출들을 그룹으로 묶습니다. 분석이 데이터를 호출 계층 구조를 나타내는 그룹으로 나눈다는 점에 주목하세요. 예를 들어, 한 그룹 내에서 perf_example:compute/1 함수가 perf_example:factorial/1을 반복적으로 호출하는 것을 볼 수 있습니다. 구체적으로, 보고서에 따르면 factorial/1이 105번 호출되었으며, 누적 시간은 약 0.228초, "고유" 시간은 0.220초입니다.
이는 처리 시간의 대부분이 factorial/1에 소요되고 있음을 알려주며, 반복적인 재귀 호출이 성능 병목이라는 우리의 추측을 확인시켜줍니다. 가비지 컬렉션에 소요된 미미한 시간(약 0.008초)은 무거운 연산이 메모리 관리보다는 재귀 호출에 있음을 다시 한번 보여줍니다.
마지막으로, fprof:apply_start_stop/4와 같은 함수의 사용과 suspend 호출에 대한 언급은 프로파일러가 우리 코드의 실행을 어떻게 감쌌는지 보여줍니다. 이러한 세부 사항들을 모두 종합하면 명확한 호출 그래프를 얻을 수 있습니다: compute/1에서 소요된 시간은 대부분 factorial/1 호출에 의해 소비되며, 이는 다시 전체 처리 시간의 대부분을 차지합니다.
eprof 예제
다시 우리의 느린 애플리케이션을 살펴보겠습니다. 이번에는 재귀 함수—우리의 순진한 팩토리얼—가 대부분의 CPU 시간을 소비하는 것으로 의심됩니다. eprof로 Erlang의 트레이스 BIF를 사용하여 각 함수가 실행되는 데 걸리는 시간에 대한 집계된 뷰를 수집할 수 있습니다. eprof는 fprof처럼 완전한 호출 그래프를 제공하지는 않지만, 함수별로 소요된 시간에 대한 간결한 분석을 제공합니다.
아래는 재귀 호출을 프로파일링하기 위해 eprof를 사용하는 예제 모듈입니다:
2> perf_example:eprof().
****** Process <0.108.0> -- 100.00 % of profiled time ***
FUNCTION CALLS % TIME [uS / CALLS]
-------- ----- ------- ---- [----------]
perf_example:compute/1 6 0.00 0 [ 0.00]
perf_example:'-eprof/0-fun-0-'/0 1 0.00 0 [ 0.00]
erlang:apply/2 1 11.76 2 [ 2.00]
perf_example:factorial/1 105 88.24 15 [ 0.14]
-------------------------------- ----- ------- ---- [----------]
Total: 113 100.00% 17 [ 0.15]
ok
3>
보고서에 따르면 총 113회의 함수 호출이 17마이크로초를 소비했으며, 이는 평균적으로 호출당 약 0.15마이크로초에 해당합니다.
-
perf_example:compute/1는 6번 호출되었으며 소요 시간은 거의 0마이크로초로, 재귀 작업에 비해 비용이 무시할 수 있는 수준임을 의미합니다. -
익명 함수(
eprof/0에 의해 생성됨)는 눈에 띄는 비용 없이 한 번 실행되었습니다. -
erlang:apply/2는 한 번 나타나며, 전체 시간의 약 11.76%(약 2마이크로초)를 차지합니다. 이는 프로파일링 프레임워크 내에서 함수 호출을 적용하는 데 따른 오버헤드를 반영합니다. -
대부분의 작업은 105번 호출된
perf_example:factorial/1에서 수행됩니다. 이는 전체 시간의 88.24%를 차지합니다—총 약 15마이크로초, 호출당 약 0.14마이크로초입니다.
요점: factorial/1에 대한 각 호출은 매우 빠르지만, 호출 횟수가 많기 때문에 전체 실행 시간에 가장 큰 기여를 합니다. 이는 이 프로파일링 시나리오에서 factorial/1의 재귀 작업이 지배적인 요인이라는 우리의 의심을 확인시켜 줍니다.
cprof 예제
cprof 도구는 브레이크포인트를 사용해 호출 횟수를 카운트하는데, 이는 재컴파일이나 트레이스 메시지가 필요 없는 가벼운 접근 방식입니다. 하지만 전체 호출 그래프를 얻을 수 없고, 타이밍 정보도 얻을 수 없습니다.
2> f(), cprof:start(perf_example), Result = perf_example:compute([10,15,20,25,70]), PauseCount = cprof:pause().
14269
3> Analysis = cprof:analyse().
{151,
[{perf_example,151,
[{{perf_example,factorial,1},145},
{{perf_example,compute,1},6}]}]}
분석 결과 perf_example 모듈에 대해 총 151회의 호출이 보고됩니다. 그중 두 개의 함수가 추적됩니다:
-
perf_example:compute/1가 6번 호출되었습니다. -
perf_example:factorial/1가 145번 호출되었습니다.
리스트를 처리하는 compute/1 함수는 6번 호출되는데, 이는 5개의 요소로 구성된 리스트를 처리하는 것과 일치합니다(재귀 단계마다 한 번씩, 그리고 기본 케이스까지 포함해 총 6번 호출). 한편, 실제 작업의 대부분은 145번 호출되는 factorial/1이 수행합니다. 이는 대부분의 작업이 factorial/1의 재귀 호출에서 발생하고 있음을 확인시켜 줍니다.
이 데이터는 재귀적인 factorial 함수가 핫스팟임을 명확히 보여줍니다. 비록 factorial/1 호출 각각은 매우 빠르지만, 145회라는 높은 호출 빈도는 이 함수가 더 큰 워크로드에서 병목 지점이 될 경우 최적화를 통해 성능 향상을 얻을 수 있음을 시사합니다.
tprof 예제
tprof는 OTP 27에서 도입된 실험적인 통합 프로세스 프로파일링 도구입니다. 호출 횟수, 실행 시간, 힙 할당(call_memory) 측정을 지원합니다. 이 시나리오에서는 각 유형별로 애드혹 모드에서 tprof를 사용하여 함수가 실행되는 동안 얼마나 많이 호출되는지, 얼마나 많은 시간이 걸리는지, 그리고 (워드 단위로) 얼마나 많은 메모리가 할당되는지 확인합니다.
3> perf_example:tprof_example(call_count).
FUNCTION CALLS [ %]
perf_example:'-tprof_example/1-fun-0-'/0 1 [ 0.89]
perf_example:compute/1 6 [ 5.36]
perf_example:factorial/1 105 [93.75]
[100.0]
ok
4> perf_example:tprof_example(call_time).
****** Process <0.129.0> -- 100.00% of total ***
FUNCTION CALLS TIME (us) PER CALL [ %]
perf_example:compute/1 6 1 0.17 [ 5.00]
perf_example:'-tprof_example/1-fun-0-'/0 1 2 2.00 [10.00]
perf_example:factorial/1 105 17 0.16 [85.00]
20 [100.0]
ok
5> perf_example:tprof_example(call_memory).
****** Process <0.132.0> -- 100.00% of total ***
FUNCTION CALLS WORDS PER CALL [ %]
perf_example:factorial/1 105 51 0.49 [100.00]
51 [ 100.0]
ok
call_count 프로파일은 익명 래퍼 함수가 한 번(0.89%) 호출되고, compute/1이 6번(5.36%) 호출되며, 무거운 작업은 105번(93.75%) 호출되는 factorial/1에서 수행됨을 보여준다. 이는 우리의 예상을 확인시켜 줍니다: 대부분의 호출(그리고 그에 따른 작업 부하)은 재귀적인 factorial/1 함수에서 발생합니다.
call_time 출력에 따르면, compute/1은 6번의 호출에서 총 1마이크로초(호출당 약 0.17µs)를 사용하고, 익명 함수는 2µs를 사용하며, factorial/1은 105번의 호출에서 17µs(호출당 약 0.16µs)를 사용한다. 호출당 시간은 매우 작지만, 누적 시간을 보면 여전히 factorial/1이 실행 시간의 대부분(85%)을 차지하고 있어, 이것이 주된 연산 작업 지점임을 재확인시켜줍니다.
call_memory 프로파일에 따르면, 측정된 전체 힙 할당(총 51워드)은 105번 호출되는 factorial/1에서 발생합니다. 이는 호출당 약 0.49워드가 할당되는 셈입니다. compute/1나 익명 래퍼에는 메모리 할당이 전혀 없으므로, 이는 이 워크로드에서 발생하는 모든 메모리 오버헤드가 전적으로 재귀 함수 때문임을 확인해준다.
Appendix A: Erlang 런타임 시스템 빌드하기
이 장에서는 필요에 맞게 Erlang/OTP를 구성하고 빌드하는 다양한 방법을 살펴보겠습니다. 대부분의 예제에서는 Ubuntu Linux를 사용할 것입니다. 다른 OS를 사용하고 있다면, 소스 코드의 문서(HOWTO/INSTALL.md)나 웹 INSTALL.html 에서 해당 OS에 맞는 빌드 방법에 대한 자세한 지침을 찾을 수 있습니다.
런타임 시스템을 빌드하는 방법은 기본적으로 두 가지가 있는데, autoconf, configure, make를 사용하는 전통적인 방법과 kerl을 사용하는 방법입니다.
먼저 전통적인 방법을 시도해 보시기를 권장합니다. 그래야 빌드할 때 무슨 일이 일어나는지, 어떤 설정을 변경할 수 있는지 더 잘 이해할 수 있습니다. 그런 다음 일상적인 구성 및 빌드 관리 작업에는 kerl을 사용하는 것으로 전환하십시오.
A.1. 최초 빌드
시작을 돕기 위해, 시스템을 처음부터 구축하는 단계별 프로세스를 살펴본 다음, 다양한 목적에 맞게 시스템을 구성하는 방법을 알아보겠습니다.
이 단계별 가이드는 최신 Ubuntu가 설치되어 있다고 가정합니다. OS X와 Windows에서 빌드하는 방법은 이 장의 뒷부분에서 살펴보겠습니다.
A.1.1. 사전 요구 사항
소스를 가져오고, 압축을 풀고, 빌드하려면 여러 도구가 필요합니다. HOWTO/INSTALL.md 파일에 가장 중요한 것들이 나열되어 있습니다.
최신 Ubuntu가 설치되어 있다고 가정하면, sed, tar, perl과 같은 일부 도구는 이미 설치되어 있을 것입니다. 하지만 git, make, gcc, m4, ncurses와 같은 다른 도구들은 별도로 설치해야 할 것입니다.
최근 Ubuntu 설치 환경에서, 다음 명령어를 실행하면 필요한 대부분의 도구를 설치할 수 있습니다:
> sudo apt-get install build-essential git autoconf m4 \
> zlib1g-dev ncurses-dev libssl-dev
wxWidgets 지원을 포함하여 빌드하려면 wx 라이브러리도 설치해야 합니다:
> sudo apt-get install libwxgtk3.2-dev wx3.2-i18n \
> wx3.2-examples wx3.2-doc
및 WX에서 WebView 지원을 위한 선택적 설치:
> sudo apt-get install libwxgtk-webview3.2-1t64
Erlang/OTP 문서도 빌드하려면 몇 가지 도구가 더 필요합니다:
> sudo apt-get install xsltproc libxml2-utils
A.1.2. 소스 얻기
소스를 얻는 방법에는 크게 두 가지가 있습니다. erlang.org에서 tarball을 다운로드하거나, Github에서 직접 Erlang/OTP 소스 코드를 체크아웃할 수 있습니다.
소스의 안정 버전을 빠르게 다운로드하려면 다음을 시도하세요:
> cd ~/otp
> wget http://erlang.org/download/otp_src_27.0.tar.gz
> tar -xzf otp_src_27.0.tar.gz
> cd otp_src_27.0
또는 최신 개발 버전으로 쉽게 업데이트하고 싶거나 커뮤니티에 수정 사항을 기여하고 싶다면 git을 통해 소스를 체크아웃할 수 있습니다:
> git clone https://github.com/erlang/otp.git
> cd otp
오래된 버전의 Erlang을 빌드하는 경우 autoconf를 실행해야 할 수도 있습니다.
> ./otp_build autoconf
A.1.3. 설정 및 빌드
이제 Erlang을 빌드하고 설치할 준비가 되었습니다. 시스템 전체 설치에 영향을 주지 않으려면, 설치 접두사를 자신만의 개인 디렉토리로 구성하는 것이 좋습니다:
> ./configure --prefix=$HOME/.local
> make
> make install
> export PATH="$HOME/.local/bin:$PATH"
A.1.4. 대체 BEAM 에뮬레이터 빌드
현재 에뮬레이터 빌드에는 크게 두 가지 버전이 있습니다: JIT가 활성화된 버전인 jit(지원되는 플랫폼에서 기본값)와 JIT가 없는 버전인 emu입니다. smp라는 이름도 볼 수 있는데, 이는 현재 플랫폼에서의 기본 버전을 의미하는 별칭일 뿐입니다.
> make TYPE=emu
또는 JIT가 활성화된 버전의 디버그 버전:
> make TYPE=debug
(물론 --disable-jit로 설정했거나, 사용 중인 플랫폼이 이를 지원하지 않는 경우에는, jit 요청을 하더라도 jit 플레이버를 빌드할 수 없습니다.)
빌드 타입(build type)을 지정하여 에뮬레이터의 다양한 계측 변형을 빌드할 수도 있습니다. 런타임에 erlang:system_info(build_type)을 호출하면 현재 실행 중인 에뮬레이터가 어떻게 빌드되었는지 알 수 있습니다. 표준 빌드는 opt라고 불립니다.
대체 에뮬레이터 유형 중 하나를 빌드하려면 TYPE 변수를 설정해야 합니다. 이를 FLAVOR 변수와 결합할 수 있습니다.
> make
> make TYPE=debug
> make FLAVOR=emu TYPE=lcnt
debug 외에도, 예를 들어 valgrind와 같이 빌드할 수 있는 계측된 변형이 여러 가지 있습니다. 빌드 트리 밖에서 (소스 코드의 최상위 디렉터리에서) 직접 이를 실행하려면, 계측된 에뮬레이터를 시작하는 데 특별한 실행 스크립트 bin/cerl을 사용할 수 있습니다:
> bin/cerl -debug
cerl 스크립트는 예를 들어 valgrind로 계측된 에뮬레이터를 실행할 때 생성되는 추가 출력 파일들도 도와줍니다. 자세한 내용은 HOWTO/INSTALL.md와 HOWTO/DEVELOPMENT.md를 참조하세요.
make install을 포함하려면, 기본 빌드뿐만 아니라 이러한 변형 빌드도 포함하려면, 각 특정 조합에 대해 별도로 실행해야 합니다:
> make install
> make install TYPE=debug
> make install FLAVOR=emu TYPE=lcnt
그러면 사용자는 -emu_flavor 및 -emu_type 플래그를 사용하여 실행하고자 하는 변형을 선택할 수 있게 됩니다:
> erl -emu_flavor emu -emu_type lcnt
A.2. Kerl로 빌드하기
특히 실험용으로 여러 가지 다른 빌드를 사용할 수 있도록 하려는 경우, 더 쉬운 빌드 방법은 Kerl을 사용하여 빌드하는 것입니다.
Appendix B: BEAM 명령어
여기서는 BEAM 일반 명령어 집합의 대부분의 명령어를 자세히 살펴보겠습니다. 다음 섹션에서는 코드의 문서로부터 생성된 간략한 설명과 함께 모든 명령어를 나열합니다(컴파일러 소스의 lib/compiler/src/genop.tab 참조).
B.1. 함수와 레이블
B.1.1. label Lbl
제네릭 명령어 집합에서 명령어 번호 1은 실제로는 명령어가 전혀 아닙니다. 이것은 단지 코드의 현재 위치에 이름, 정확히는 번호를 부여하는 모듈 로컬 레이블일 뿐입니다.
각 레이블은 점프의 잠재적 목적지이므로 기본 블록의 시작을 나타낼 수 있습니다.
B.1.2. func_info Module Function Arity
각 함수의 코드는 func_info 명령어로 시작합니다. 이 명령어는 함수 절 오류를 생성하는 데 사용되며, 함수 내 코드의 실제 실행은 func_info 명령어 다음에 오는 레이블에서 시작됩니다.
가드가 있는 함수를 상상해 보세요:
id(I) when is_integer(I) -> I.
이 함수의 BEAM 코드는 다음과 같을 수 있습니다:
{function, id, 1, 4}.
{label,3}.
{func_info,{atom,test1},{atom,id},1}.
{label,4}.
{test,is_integer,{f,3},[{x,0}]}.
return.
여기서 메타 정보 {function, id, 1, 4}는 id/1 함수의 실행이 레이블 4에서 시작됨을 알려줍니다. 레이블 4에서는 x0에 대해 is_integer를 수행하며, 실패하면 func_info 명령어를 가리키는 레이블 3(f3)으로 점프하는데, 이는 function clause 예외를 발생시킵니다. 그렇지 않으면 그냥 통과하여 인자(x0)를 반환합니다.
Function info 명령어는 (erts/emulator/beam/export.h에 정의되어 있으며) 메모리의 다른 곳에 위치한 Export 레코드를 가리킵니다. 해당 레코드 내부의 몇몇 전용 메모리 워드는 트레이싱 메커니즘이 모든 프로세스에 의한 함수의 진입/반환마다 트리거되는 특수한 트레이스 명령어를 배치하는 데 사용됩니다.
|
B.2. 테스트 명령어
B.2.1. 타입 검사
타입 테스트 명령어(is_\* Lbl Argument)는 인수가 주어진 타입인지 검사하고, 그렇지 않으면 레이블 Lbl로 점프합니다. BEAM 디스어셈블러는 이러한 모든 명령어를 test 명령어로 감쌉니다. 예:
{test,is_integer,{f,3},[{x,0}]}.
현재 타입 테스트 명령어에는 is_integer, is_float, is_number, is_atom, is_pid, is_reference, is_port(포트), is_nil, is_binary(바이너리), is_list, is_nonempty_list, is_function, is_function2, is_boolean, is_bitstr, is_tuple(튜플)이 있다.
그리고 인자 수(Arity)가 3인 타입 테스트 명령어도 하나 있습니다: test_arity Lbl Arg Arity. 이 명령어는 인자(튜플로 간주됨)의 애리티가 Arity인지 검사합니다. 이 명령어는 일반적으로 is_tuple 명령어 뒤에 옵니다.
B.2.2. 비교
비교 명령어(is_\* Lbl Arg1 Arg2)는 명령어에 따라 두 인자를 비교하고, 비교가 실패하면 Lbl로 점프합니다.
비교 명령어는 다음과 같습니다: is_lt, is_ge, is_eq, is_ne, is_eq_exact, is_ne_exact입니다.
모든 Erlang 텀은 순서가 정해져 있으므로 이 명령어들이 임의의 두 텀을 비교할 수 있다는 점을 기억하세요. 예를 들어 원자 self가 self()가 반환하는 pid보다 작은지 테스트해볼 수 있습니다. (작습니다.)
숫자의 경우 비교는 Erlang 타입 number 에 대해 수행된다는 점에 유의하라. Chapter 4 을 참고하라. 즉, float와 정수가 혼합된 비교에서는 정밀도가 낮은 쪽의 숫자가 비교 전에 다른 타입으로 변환된다. 예를 들어 제 시스템에서 1과 1.0은 동일하게 비교되며, 9999999999999999와 1.0e16도 마찬가지입니다. 부동소수점 숫자의 비교는 항상 위험하며 피하는 것이 좋습니다. 결과는 기반 하드웨어에 따라 달라질 수 있습니다.
정수 1과 부동 소수점 수 1.0이 서로 다르게 비교되도록 하려면 is_eq_exact와 is_ne_exact를 사용할 수 있습니다. 이는 Erlang 연산자 =:=와 =/=에 해당합니다.
B.3. 함수 호출
이 장에서는 다양한 호출 명령어가 수행하는 작업을 요약합니다. 함수 호출이 어떻게 동작하는지에 대한 자세한 설명은 Chapter 8를 참조하십시오.
B.3.1. call Arity Label
동일 모듈 내 레이블 Label에 있는, arity가 Arity인 함수를 호출합니다. 먼저 리덕션을 카운트다운하고 필요한 경우 컨텍스트 스위치를 수행합니다. 호출 후 현재 코드 주소가 CP에 저장됩니다.
모든 로컬 호출에서 레이블은 코드가 시작되는 함수의 두 번째 레이블이다. 컨텍스트 전환이 필요할 경우 MFA를 얻기 위해, 해당 레이블의 앞선 명령어는 func_info라고 가정한다.
B.3.2. call_only Arity Label
같은 모듈의 레이블 Label에서 애리티 Arity의 함수를 꼬리 재귀 호출합니다. 먼저 리덕션을 카운트다운하고, 필요하면 컨텍스트 스위치를 수행합니다. CP는 반환 주소로 갱신되지 않습니다.
B.3.3. call_last Arity Label Deallocate
스택의 Deallocate워드를 할당 해제한 다음, 같은 모듈의 레이블 Label에 있는 arity가 Arity인 함수로 꼬리 재귀 호출을 수행합니다. 먼저 리덕션을 줄이고 필요하면 컨텍스트 스위치를 수행합니다. CP는 반환 주소로 갱신되지 않는다.
B.3.4. call_ext Arity Destination
Destination로 지정된 애리티 Arity를 가진 함수에 대한 외부 호출을 수행합니다. 어셈블리에서 Destination은 보통 {extfunc, Module, Function, Arity}로 작성되며, 이는 모듈의 임포트 섹션에 추가됩니다. 먼저 리덕션을 카운트다운하고, 필요한 경우 컨텍스트 스위치를 수행한다. CP는 반환 주소로 갱신된다.
B.3.5. call_ext_only Arity Destination
Destination이 지정한 arity Arity의 함수에 대해 꼬리 재귀 외부 호출을 수행합니다. 어셈블리에서 Destination은 보통 {extfunc, Module, Function, Arity}로 작성됩니다. 먼저 리덕션을 카운트다운하고, 필요하면 컨텍스트 스위치를 수행한다. CP는 반환 주소로 갱신되지 않는다.
B.3.6. call_ext_last Arity Destination Deallocate
스택의 Deallocate워드를 할당 해제한 다음, Destination이 지정하는 애리티가 Arity인 함수에 대해 꼬리 재귀 외부 호출을 수행한다. 어셈블리에서 Destination은 보통 {extfunc, Module, Function, Arity}로 표기된다. 먼저 리덕션을 카운트다운하고, 필요하면 컨텍스트 스위치를 수행합니다. CP는 반환 주소로 갱신되지 않습니다.
B.3.7. bif0 Bif Reg, bif[1,2] Lbl Bif [Arg,…] Reg
bif Bif를 주어진 인자들과 함께 호출하고, 결과를 Reg에 저장한다. bif가 실패하면 Lbl로 점프한다. 0항(zero arity) bif는 실패할 수 없으므로, bif0는 실패 레이블을 받지 않는다.
이 명령어들에 의해 호출되는 Bif는 힙에 할당하거나 가비지 컬렉션을 유발할 수 없습니다. 그렇지 않으면 다음을 참고하세요: gc_bif.
|
B.3.8. gc_bif[1-3] Lbl Live Bif [Arg, …] Reg
주어진 인자로 bif Bif를 호출하고, 결과를 Reg에 저장한다. bif가 실패하면 Lbl로 점프한다. 인자는 x(Live), x(Live+1), x(Live+2)에 저장된다.
이 명령어는 Live 인자를 가지고 있기 때문에, 가비지 컬렉션을 트리거할 수 있을 만큼 충분한 정보를 제공합니다.
|
B.3.9. call_fun Arity
명령어 call_fun은 인자들이 첫 Arity개의 인자 레지스터에 배치되고, 펀(클로저에 대한 포인터)이 마지막 인자 x[Arity+1]의 다음 레지스터에 배치된다고 가정합니다.
즉, 인자 개수가 0인 호출의 경우, 클로저는 x[0]에 배치됩니다. 인자 개수가 1인 호출의 경우 x[0]에는 인자가 들어 있고, x[1]에는 클로저가 들어 있는 식입니다.
인자 개수(arity)가 함수 객체와 일치하지 않으면 badarity를 발생시킵니다. 함수가 아닌 값이 전달되면 badfun을 발생시킵니다.
|
B.3.10. apply Arity
X 레지스터에 저장된 Arity개의 인자로 함수 호출을 적용합니다. 모듈 아톰은 x[Arity]에 저장되고, 함수 아톰은 x[Arity+1]에 저장됩니다. 모듈은 튜플로도 표현될 수 있습니다.
B.3.11. apply_last Arity Dealloc
CP를 팝(pop)하고, 요소들을 해제한 뒤 CP를 다시 푸시(push)하여 스택에서 Dealloc개의 요소를 할당 해제합니다. 그런 다음 새 위치로 점프하여, X 레지스터에 저장된 Arity개의 인자로 꼬리 재귀 호출을 수행합니다. 모듈과 함수 아톰은 x[Arity]와 x[Arity+1]에 저장됩니다. 모듈은 튜플로 표현될 수도 있습니다.
B.4. 스택 (및 힙) 관리
BEAM에서 Erlang 프로세스의 스택과 힙은 동일한 메모리 영역을 공유하며, 자세한 논의는 Chapter 3와 Chapter 11를 참조하십시오. 스택은 낮은 주소 방향으로, 힙은 높은 주소 방향으로 증가합니다. BEAM은 스택이나 힙 중 어느 한쪽에서든 사용 가능한 공간보다 더 많은 공간이 필요할 때 가비지 컬렉션을 수행합니다.
이러한 명령어는 리프가 아닌(non leaf) 함수가 현재 명령어의 스택 프레임을 설정하고 해제하는 데에도 사용됩니다. 즉, 함수 진입 시 연속 포인터(CP)가 스택에 저장되고, 종료 시 스택에서 다시 읽어온다.
리프 함수의 함수 골격은 다음과 같습니다:
{function, Name, Arity, StartLabel}.
{label,L1}.
{func_info,{atom,Module},{atom,Name},Arity}.
{label,L2}.
...
return.
비리프(non leaf) 함수의 함수 골격은 다음과 같습니다:
{function, Name, Arity, StartLabel}.
{label,L1}.
{func_info,{atom,Module},{atom,Name},Arity}.
{label,L2}.
{allocate,Need,Live}.
...
call ...
...
{deallocate,Need}.
return.
B.4.1. allocate StackNeed Live
컨티뉴에이션 포인터(CP)를 저장하고 스택에 StackNeed개의 추가 워드를 위한 공간을 할당한다. 할당 도중 메모리가 부족해지면 GC를 호출하며, 이때 처음 Live개의 x 레지스터가 루트 집합의 일부를 이룬다. 예를 들어 Live가 2이면 GC는 레지스터 X0과 X1을 저장하고, 나머지는 사용되지 않으므로 해제된다.
스택에 할당할 때, 스택 포인터(E)가 감소합니다.
Before After
| xxx | | xxx |
E -> | xxx | | xxx |
| | | ??? | caller save slot
... E -> | CP |
... ...
HTOP -> | | HTOP -> | |
| xxx | | xxx |
B.4.2. allocate_heap 스택필요(StackNeed) 힙필요(HeapNeed) 생존(Live)
연속 포인터(CP)를 저장하고, 스택에 StackNeed개의 여분 워드를 위한 공간을 할당합니다. 힙에도 HeapNeed개의 워드를 위한 공간이 있는지 확인합니다. 할당 중 메모리가 부족해지면, 보존할 X 레지스터 개수인 Live를 사용하여 GC를 호출합니다.
| 힙 포인터(HTOP)는 실제 힙 할당이 일어나기 전까지는 변경되지 않는다. |
B.4.3. allocate_zero StackNeed Live
이 명령어는 allocate와 같은 방식으로 동작하지만, 할당된 스택 슬롯을 NIL로 초기화합니다.
Before After
| xxx | | xxx |
E -> | xxx | | xxx |
| | | NIL | caller save slot
... E -> | CP |
... ...
HTOP -> | | HTOP -> | |
| xxx | | xxx |
B.4.4. allocate_heap_zero StackNeed HeapNeed Live
allocate_heap_zero 명령어는 allocate_heap 명령어와 동일하게 동작하지만, 할당된 스택 슬롯을 NIL로 초기화한다는 차이가 있다.
B.4.5. test_heap 힙필요량(HeapNeed) Live
test_heap 명령어는 힙에 HeapNeed 워드만큼의 공간이 확보되어 있는지 확인합니다. 할당 중 메모리가 부족해지면, 보존할 X 레지스터의 개수인 Live만큼을 사용하여 GC를 호출합니다.
B.4.6. init N
init 명령어는 CP 포인터 위쪽의 N개 스택 워드를 NIL로 씀으로써 지웁니다.
B.4.7. deallocate N
deallocate 명령어는 allocate의 반대이다. 이 명령어는 CP(연속 포인터)를 복원하고 N+1개의 스택 워드를 해제한다.
B.4.8. return
return 명령어는 연속 포인터(CP)에 있는 주소로 점프합니다. CP의 값은 C에서 0으로 설정됩니다.
B.4.9. trim N Remaining
CP를 임시 변수로 pop하고, N 워드만큼 스택을 해제한 뒤, CP를 다시 스택 맨 위에 놓습니다. (제가 아는 한 Remaining 인자는 사용되지 않습니다.)
Before After
| ??? | | ??? |
| xxx | E -> | CP |
| xxx | | ... |
E -> | CP | | ... |
| | | ... |
... ...
HTOP -> | | HTOP -> | |
| xxx | | xxx |
B.5. 데이터 이동, 추출, 수정
B.5.1. move Source Destination
소스 Source(리터럴 또는 레지스터일 수 있음)의 값을 목적지 레지스터 Destination으로 이동합니다.
B.5.2. get_list Source Head Tail
이것은 리스트 셀에 대한 분해 연산입니다. Source로 지정된 리스트(cons 셀)의 head와 tail(또는 car와 cdr) 부분을 가져와서 레지스터 Head와 Tail에 넣습니다.
B.5.3. get_tuple_element Source Element Destination
이것은 배열 인덱스 읽기 연산입니다. Source 튜플에서 Element 위치의 요소를 가져와 Destination 레지스터에 저장합니다.
B.5.4. set_tuple_element NewElement 튜플 Position
이것은 파괴적 배열 인덱스 업데이트 연산입니다. Tuple에서 Position의 요소를 새로운 NewElement로 갱신합니다.
B.6. 텀 생성.
B.6.1. put_list Head Tail Destination
힙에 새로운 리스트(cons) 셀을 생성하고(2 워드) 그 주소를 Destination 레지스터에 저장합니다. 리스트 셀의 첫 번째 요소는 Head의 값으로 설정되고, 두 번째 요소는 Tail의 값으로 설정됩니다.
B.6.2. put_tuple 크기 목적지
힙에 빈 튜플(Size+1 워드)을 생성하고 그 주소를 Destination 레지스터에 저장합니다. 이 시점에서는 어떤 요소도 설정되지 않습니다. Put_tuple 명령어 뒤에는 항상 여러 개의 put 명령어가 따라오며, 이들이 튜플의 요소를 하나씩 파괴적으로 설정합니다.
B.6.3. put Value
이전 put_tuple 명령으로 생성된 튜플의 다음 요소에 Value를 파괴적으로 배치합니다. 쓰기 주소는 가상 머신 내부에서 유지 및 증가됩니다. 새로운 튜플의 내용을 설정하기 위해 여러 개의 put 명령어가 사용됩니다.
B.6.4. make_fun2 LambdaIndex
모듈의 Lambda 테이블에서 인덱스로 정의된 함수 객체를 생성한다. Lambda 테이블은 진입점(레이블 또는 export 항목), arity, 그리고 가져올 동결 변수의 수를 정의합니다. 고정 변수(frozen variable) 값은 현재 실행 컨텍스트(X 레지스터)에서 복사되어 함수 객체에 저장됩니다.
Unresolved directive in chapters/ap-beam_instructions.asciidoc - include::opcodes_doc.asciidoc[]
B.7. 세부 지침
인자 타입
| 타입 | 설명 |
|---|---|
a |
즉시 값 아톰(immediate atom value), 예: 'foo' |
c |
즉시값 상수(원자, nil, 작은 정수) // Pid? |
d |
레지스터 또는 스택 슬롯 |
e |
익스포트 테이블 항목에 대한 참조 |
f |
레이블, 즉 코드 주소 |
I |
정수, 예: |
j |
선택적 코드 라벨 |
l |
부동소수점 레지스터 |
P |
양의(부호 없는) 정수 리터럴 |
r |
레지스터 R0 ( |
s |
리터럴, 레지스터, 또는 스택 슬롯 중 하나 |
t |
텀, 예: |
x |
레지스터, 예: |
y |
스택 슬롯, 예를 들어 |
B.7.1. 모든 BEAM 명령어 목록
| 인스트럭션 | 인자 | 설명 |
|---|---|---|
allocate |
t t |
스택에 몇 개의 워드를 할당한다 |
allocate_heap |
t I t |
힙에 몇 개의 워드(word)를 할당합니다 |
allocate_heap_zero |
t I t |
힙을 일부 할당하고 해당 워드들을 NIL로 설정한다 |
allocate_init |
t I y |
|
allocate_zero |
t t |
스택을 할당하고 워드를 0으로 설정할까요? |
apply |
I |
|
apply_last |
I P |
|
badarg |
j |
|
badmatch |
rxy |
|
bif1 |
f b s d |
인자 1개로 bif를 호출하며, 실패 시 |
bif1_body |
b s d |
|
bs_context_to_binary |
rxy |
|
bs_put_string |
I I |
|
bs_test_tail_imm2 |
f rx I |
|
bs_test_unit |
f rx I |
|
bs_test_unit8 |
f rx |
|
bs_test_zero_tail2 |
f rx |
|
call_bif0 |
e |
|
call_bif1 |
e |
|
call_bif2 |
e |
|
call_bif3 |
e |
|
case_end |
rxy |
|
catch |
y f |
|
catch_end |
y |
|
deallocate |
I |
스택에서 일부 워드를 해제하고 CP를 pop 합니다 |
deallocate_return |
Q |
|
extract_next_element |
xy |
|
extract_next_element2 |
xy |
|
extract_next_element3 |
xy |
|
fclearerror |
||
fconv |
d l |
|
fmove |
qdl ld |
|
get_list |
rxy rxy rxy |
리스트 셀을 헤드와 테일로 분해합니다 |
i_apply |
함수 |
|
i_apply_fun |
함수 객체 |
|
i_apply_fun_last |
P |
함수 객체 |
i_apply_fun_only |
인자 |
|
i_apply_last |
P |
인자 |
i_apply_only |
함수 |
|
i_band |
j I d |
|
i_bif2 |
f b d |
|
i_bif2_body |
b d |
|
i_bor |
j I d |
|
i_bs_add |
j I d |
|
i_bs_append |
j I I I d |
|
i_bs_get_binary2 |
f rx I s I d |
|
i_bs_get_binary_all2 |
f rx I I d |
|
i_bs_get_binary_all_reuse |
rx f I |
|
i_bs_get_binary_imm2 |
f rx I I I d |
|
i_bs_get_float2 |
f rx I s I d |
|
i_bs_get_integer |
f I I d |
|
i_bs_get_integer_16 |
rx f d |
|
i_bs_get_integer_32 |
rx f I d |
|
i_bs_get_integer_8 |
rx f d |
|
i_bs_get_integer_imm |
rx I I f I d |
|
i_bs_get_integer_small_imm |
rx I f I d |
|
i_bs_get_utf16 |
rx f I d |
|
i_bs_get_utf8 |
rx f d |
|
i_bs_init |
I I d |
|
i_bs_init_bits |
I I d |
|
i_bs_init_bits_fail |
rxy j I d |
|
i_bs_init_bits_fail_heap |
I j I d |
|
i_bs_init_bits_heap |
I I I d |
|
i_bs_init_fail |
rxy j I d |
|
i_bs_init_fail_heap |
I j I d |
|
i_bs_init_heap |
I I I d |
|
i_bs_init_heap_bin |
I I d |
|
i_bs_init_heap_bin_heap |
I I I d |
|
i_bs_init_writable |
||
i_bs_match_string |
rx f I I |
|
i_bs_private_append |
j I d |
|
i_bs_put_utf16 |
j I s |
|
i_bs_put_utf8 |
j s |
|
i_bs_restore2 |
rx I |
|
i_bs_save2 |
rx I |
|
i_bs_skip_bits2 |
f rx rxy I |
|
i_bs_skip_bits2_imm2 |
f rx I |
|
i_bs_skip_bits_all2 |
f rx I |
|
i_bs_start_match2 |
rxy f I I d |
|
i_bs_utf16_size |
s d |
|
i_bs_utf8_size |
s d |
|
i_bs_validate_unicode |
j s |
|
i_bs_validate_unicode_retract |
j |
|
i_bsl |
j I d |
|
i_bsr |
j I d |
|
i_bxor |
j I d |
|
i_call |
f |
|
i_call_ext |
e |
|
i_call_ext_last |
e P |
|
i_call_ext_only |
e |
|
i_call_fun |
I |
|
i_call_fun_last |
I P |
|
i_call_last |
f P |
|
i_call_only |
f |
|
i_element |
rxy j s d |
|
i_fadd |
l l l |
|
i_fast_element |
rxy j I d |
|
i_fcheckerror |
||
i_fdiv |
l l l |
|
i_fetch |
s s |
|
i_fmul |
l l l |
|
i_fnegate |
l l l |
|
i_fsub |
l l l |
|
i_func_info |
I a a I |
|
i_gc_bif1 |
j I s I d |
|
i_gc_bif2 |
j I I d |
|
i_gc_bif3 |
j I s I d |
|
i_get |
s d |
|
i_get_tuple_element |
rxy P rxy |
|
i_hibernate |
||
i_increment |
rxy I I d |
|
i_int_bnot |
j s I d |
|
i_int_div |
j I d |
|
i_is_eq |
f |
|
i_is_eq_exact |
f |
|
i_is_eq_exact_immed |
f rxy c |
|
i_is_eq_exact_literal |
f rxy c |
|
i_is_ge |
f |
|
i_is_lt |
f |
|
i_is_ne |
f |
|
i_is_ne_exact |
f |
|
i_is_ne_exact_immed |
f rxy c |
|
i_is_ne_exact_literal |
f rxy c |
|
i_jump_on_val |
rxy f I I |
|
i_jump_on_val_zero |
rxy f I |
|
i_loop_rec |
f r |
|
i_m_div |
j I d |
|
i_make_fun |
I t |
|
i_minus |
j I d |
|
i_move_call |
c r f |
|
i_move_call_ext |
c r e |
|
i_move_call_ext_last |
e P c r |
|
i_move_call_ext_only |
e c r |
|
i_move_call_last |
f P c r |
|
i_move_call_only |
f c r |
|
i_new_bs_put_binary |
j s I s |
|
i_new_bs_put_binary_all |
j s I |
|
i_new_bs_put_binary_imm |
j I s |
|
i_new_bs_put_float |
j s I s |
|
i_new_bs_put_float_imm |
j I I s |
|
i_new_bs_put_integer |
j s I s |
|
i_new_bs_put_integer_imm |
j I I s |
|
i_plus |
j I d |
|
i_put_tuple |
rxy I |
애리티 |
i_recv_set |
f |
|
i_rem |
j I d |
|
i_select_tuple_arity |
r f I |
|
i_select_tuple_arity |
x f I |
|
i_select_tuple_arity |
y f I |
|
i_select_tuple_arity2 |
r f A f A f |
|
i_select_tuple_arity2 |
x f A f A f |
|
i_select_tuple_arity2 |
y f A f A f |
|
i_select_val |
r f I |
값을 |
i_select_val |
x f I |
위와 동일하지만 x 레지스터에 대한 것 |
i_select_val |
y f I |
위와 동일하지만 y 레지스터에 대한 것입니다 |
i_select_val2 |
r f c f c f |
값을 두 쌍 |
i_select_val2 |
x f c f c f |
위와 동일하지만 x 레지스터를 대상으로 함 |
i_select_val2 |
y f c f c f |
위와 동일하지만 y 레지스터에 대한 것이다. |
i_times |
j I d |
|
i_trim |
I |
|
i_wait_error |
||
i_wait_error_locked |
||
i_wait_timeout |
f I |
|
i_wait_timeout |
f s |
|
i_wait_timeout_locked |
f I |
|
i_wait_timeout_locked |
f s |
|
if_end |
|
|
init |
y |
스택에 있는 워드를 NIL []로 설정합니다 |
init2 |
y y |
스택의 두 워드를 NIL []로 설정한다 |
init3 |
y y y |
스택의 세 워드를 NIL []로 설정 |
int_code_end |
프로그램 종료 (스택이 없는 return과 동일) |
|
is_atom |
f rxy |
값이 atom인지 확인하고, 아니면 점프 |
is_bitstring |
f rxy |
값이 비트 문자열인지 확인하고, 그렇지 않으면 점프한다 |
is_boolean |
f rxy |
값이 atom 'true' 또는 'false’인지 확인하고, 그렇지 않으면 점프한다 |
is_float |
f rxy |
값이 부동소수점 숫자인지 확인하고, 그렇지 않으면 점프한다 |
is_function |
f rxy |
값이 함수인지 확인하고, 아니면 점프한다 |
is_function2 |
f s s |
값이 함수인지 확인하고, 아니면 점프한다 |
is_integer |
f rxy |
값이 빅 정수인지 스몰 정수인지 확인하고, 그렇지 않으면 점프한다 |
is_integer_allocate |
f rx I I |
|
is_list |
f rxy |
값이 리스트인지 NIL인지 확인하고, 그렇지 않으면 점프한다 |
is_nil |
f rxy |
값이 빈 리스트 []인지 확인하고 그렇지 않으면 점프한다 |
is_nonempty_list |
f rxy |
값이 비어 있지 않은 리스트(cons 포인터)인지 확인하고, 그렇지 않으면 점프한다 |
is_nonempty_list_allocate |
f rx I t |
|
is_nonempty_list_test_heap |
f r I t |
|
is_number |
f rxy |
값이 큰 정수(big integer)인지 작은 정수(small integer)인지 부동소수점(float)인지 확인하고, 아니면 점프한다 |
is_pid |
f rxy |
값이 pid인지 확인하고, 아니면 점프한다 |
is_port |
f rxy |
값이 포트인지 확인하고, 그렇지 않으면 점프한다 |
is_reference |
f rxy |
값이 레퍼런스인지 확인하고, 아니면 점프하라 |
is_tuple |
f rxy |
값이 튜플인지 확인하고, 그렇지 않으면 점프 |
is_tuple_of_arity |
f rxy A |
값이 애리티 |
jump |
f |
위치(레이블) |
label |
L |
코드 내 위치를 표시하며, 로드 시점에 제거됨 |
line |
I |
소스 파일 내 위치를 표시하며, 로드 시점에 제거됩니다 |
loop_rec_end |
f |
프로세스의 receive 포인터를 전진시키고 |
move |
rxync rxy |
값이나 레지스터를 다른 레지스터로 이동시킵니다 |
move2 |
x x x x |
한 쌍의 값을 한 쌍의 목적지로 이동한다 |
move2 |
x y x y |
한 쌍의 값을 한 쌍의 목적지로 이동한다 |
move2 |
y x y x |
한 쌍의 값을 한 쌍의 목적지로 이동 |
move_call |
xy r f |
|
move_call_last |
xy r f Q |
|
move_call_only |
x r f |
|
move_deallocate_return |
xycn r Q |
|
move_jump |
f ncxy |
|
move_return |
xcn r |
|
move_x1 |
c |
|
move_x2 |
c |
|
node |
rxy |
|
put |
rxy |
이들의 시퀀스는 |
put_list |
s s d |
헤드와 테일로부터 리스트 셀을 생성하고, cons 포인터를 목적지 |
raise |
s s |
주어진 타입의 예외를 발생시키며, 레거시/호환성 사유로 인해 예외 타입은 두 번째 스택트레이스 인자에서 추출해야 한다. |
recv_mark |
f |
메시지 검색을 위한 알려진 재시작 위치 표시(참조 최적화) |
remove_message |
프로세스 수신함에서 현재 메시지를 제거합니다 (수신됨). |
|
return |
CP에 있는 주소로 점프하고, CP를 0으로 설정한다 |
|
self |
rxy |
현재 프로세스의 pid를 |
send |
프로세스 |
|
set_tuple_element |
s d P |
인덱스로 튜플 요소를 파괴적으로 업데이트한다 |
system_limit |
j |
|
test_arity |
f rxy A |
|
test_heap |
I t |
힙 공간 가용성을 확인한다 |
test_heap_1_put_list |
I y |
|
timeout |
타이머를 설정하고, 수신 메시지 또는 타이머 이벤트 중 먼저 발생하는 것을 기다리며 프로세스의 실행을 양보한다 |
|
timeout_locked |
||
try |
y f |
활성 try 블록을 표시하는 특수한 catch 값을 스택 셀 |
try_case |
y |
|
try_case_end |
s |
|
try_end |
y |
스택 셀 |
wait |
f |
들어오는 메시지를 기다리며 프로세스를 스케줄 아웃시킵니다 (양보) |
wait_locked |
f |
|
wait_unlocked |
f |
Appendix C: 전체 코드 목록
-module(beamfile).
-export([read/1]).
read(Filename) ->
{ok, File} = file:read_file(Filename),
<<"FOR1",
Size:32/integer,
"BEAM",
Chunks/binary>> = File,
{Size, parse_chunks(read_chunks(Chunks, []),[])}.
read_chunks(<<N,A,M,E, Size:32/integer, Tail/binary>>, Acc) ->
%% Align each chunk on even 4 bytes
ChunkLength = align_by_four(Size),
<<Chunk:ChunkLength/binary, Rest/binary>> = Tail,
read_chunks(Rest, [{[N,A,M,E], Size, Chunk}|Acc]);
read_chunks(<<>>, Acc) -> lists:reverse(Acc).
align_by_four(N) -> (4 * ((N+3) div 4)).
parse_chunks([{"Atom", _Size, <<_Numberofatoms:32/integer, Atoms/binary>>} | Rest], Acc) ->
parse_chunks(Rest,[{atoms,parse_atoms(Atoms)}|Acc]);
parse_chunks([{"AtU8", _Size, <<_Numberofatoms:32/integer, Atoms/binary>>} | Rest], Acc) ->
parse_chunks(Rest,[{atU8,parse_atoms(Atoms)}|Acc]);
parse_chunks([{"ExpT", _Size,
<<_Numberofentries:32/integer, Exports/binary>>}
| Rest], Acc) ->
parse_chunks(Rest,[{exports,parse_table(Exports)}|Acc]);
parse_chunks([{"ImpT", _Size,
<<_Numberofentries:32/integer, Imports/binary>>}
| Rest], Acc) ->
parse_chunks(Rest,[{imports,parse_table(Imports)}|Acc]);
parse_chunks([{"Code", Size, <<SubSize:32/integer, Chunk/binary>>} | Rest], Acc) ->
<<Info:SubSize/binary, Code/binary>> = Chunk,
OpcodeSize = Size - SubSize - 8, %% 8 is size of ChunkSize & SubSize
<<OpCodes:OpcodeSize/binary, _Align/binary>> = Code,
parse_chunks(Rest,[{code,parse_code_info(Info), OpCodes}|Acc]);
parse_chunks([{"StrT", _Size, <<Strings/binary>>} | Rest], Acc) ->
parse_chunks(Rest,[{strings,binary_to_list(Strings)}|Acc]);
parse_chunks([{"Attr", Size, Chunk} | Rest], Acc) ->
<<Bin:Size/binary, _Pad/binary>> = Chunk,
Attribs = binary_to_term(Bin),
parse_chunks(Rest,[{attributes,Attribs}|Acc]);
parse_chunks([{"CInf", Size, Chunk} | Rest], Acc) ->
<<Bin:Size/binary, _Pad/binary>> = Chunk,
CInfo = binary_to_term(Bin),
parse_chunks(Rest,[{compile_info,CInfo}|Acc]);
parse_chunks([{"LocT", _Size,
<<_Numberofentries:32/integer, Locals/binary>>}
| Rest], Acc) ->
parse_chunks(Rest,[{locals,parse_table(Locals)}|Acc]);
parse_chunks([{"LitT", _ChunkSize,
<<_CompressedTableSize:32, Compressed/binary>>}
| Rest], Acc) ->
<<_NumLiterals:32,Table/binary>> = zlib:uncompress(Compressed),
Literals = parse_literals(Table),
parse_chunks(Rest,[{literals,Literals}|Acc]);
parse_chunks([{"Abst", _ChunkSize, <<>>} | Rest], Acc) ->
parse_chunks(Rest,Acc);
parse_chunks([{"Abst", _ChunkSize, <<AbstractCode/binary>>} | Rest], Acc) ->
parse_chunks(Rest,[{abstract_code,binary_to_term(AbstractCode)}|Acc]);
parse_chunks([{"Line", _ChunkSize, <<LineTable/binary>>} | Rest], Acc) ->
<<Ver:32,Bits:32,NumLineInstrs:32,NumLines:32,_NumFnames:32,
Lines:NumLines/binary,Fnames/binary>> = LineTable,
parse_chunks(Rest,[{line,
[{version,Ver},
{bits,Bits},
{num_line_instrunctions,NumLineInstrs},
{lines,decode_lineinfo(binary_to_list(Lines),0)},
{function_names,Fnames}]}|Acc]);
parse_chunks([{"FunT", _Size, <<_Numberofentries:32/integer, Funs/binary>>}
| Rest], Acc) ->
parse_chunks(Rest,[{funs,parse_table(Funs)}|Acc]);
parse_chunks([{"Type", _Size, Chunk} | Rest], Acc) ->
<<_Version:32/big, Count:32/big, TypeData/binary>> = Chunk,
Types = parse_types(Count, TypeData, []),
parse_chunks(Rest, [{type_info, Types}|Acc]);
parse_chunks([{"Meta", Size, Chunk} | Rest], Acc) ->
<<MetaInfo:Size/binary, _Pad/binary>> = Chunk,
Meta = binary_to_term(MetaInfo),
parse_chunks(Rest,[{meta,Meta}|Acc]);
parse_chunks([Chunk|Rest], Acc) -> %% Not yet implemented chunk
parse_chunks(Rest, [Chunk|Acc]);
parse_chunks([],Acc) -> Acc.
parse_atoms(<<Atomlength, Atom:Atomlength/binary, Rest/binary>>) when Atomlength > 0->
[list_to_atom(binary_to_list(Atom)) | parse_atoms(Rest)];
parse_atoms(_Alignment) -> [].
parse_table(<<Function:32/integer,
Arity:32/integer,
Label:32/integer,
Rest/binary>>) ->
[{Function, Arity, Label} | parse_table(Rest)];
parse_table(<<>>) -> [].
parse_code_info(<<Instructionset:32/integer,
OpcodeMax:32/integer,
NumberOfLabels:32/integer,
NumberOfFunctions:32/integer,
Rest/binary>>) ->
[{instructionset, Instructionset},
{opcodemax, OpcodeMax},
{numberoflabels, NumberOfLabels},
{numberofFunctions, NumberOfFunctions} |
case Rest of
<<>> -> [];
_ -> [{newinfo, Rest}]
end].
parse_literals(<<Size:32,Literal:Size/binary,Tail/binary>>) ->
[binary_to_term(Literal) | parse_literals(Tail)];
parse_literals(<<>>) -> [].
-define(tag_i, 1).
-define(tag_a, 2).
decode_tag(?tag_i) -> i;
decode_tag(?tag_a) -> a.
decode_int(Tag,B,Bs) when (B band 16#08) =:= 0 ->
%% N < 16 = 4 bits, NNNN:0:TTT
N = B bsr 4,
{{Tag,N},Bs};
decode_int(Tag,B,[]) when (B band 16#10) =:= 0 ->
%% N < 2048 = 11 bits = 3:8 bits, NNN:01:TTT, NNNNNNNN
Val0 = B band 2#11100000,
N = (Val0 bsl 3),
{{Tag,N},[]};
decode_int(Tag,B,Bs) when (B band 16#10) =:= 0 ->
%% N < 2048 = 11 bits = 3:8 bits, NNN:01:TTT, NNNNNNNN
[B1|Bs1] = Bs,
Val0 = B band 2#11100000,
N = (Val0 bsl 3) bor B1,
{{Tag,N},Bs1};
decode_int(Tag,B,Bs) ->
{Len,Bs1} = decode_int_length(B,Bs),
{IntBs,RemBs} = take_bytes(Len,Bs1),
N = build_arg(IntBs),
{{Tag,N},RemBs}.
decode_lineinfo([B|Bs], F) ->
Tag = decode_tag(B band 2#111),
{{Tag,Num},RemBs} = decode_int(Tag,B,Bs),
case Tag of
i ->
[{F, Num} | decode_lineinfo(RemBs, F)];
a ->
[B2|Bs2] = RemBs,
Tag2 = decode_tag(B2 band 2#111),
{{Tag2,Num2},RemBs2} = decode_int(Tag2,B2,Bs2),
[{Num, Num2} | decode_lineinfo(RemBs2, Num2)]
end;
decode_lineinfo([],_) -> [].
decode_int_length(B, Bs) ->
{B bsr 5 + 2, Bs}.
take_bytes(N, Bs) ->
take_bytes(N, Bs, []).
take_bytes(N, [B|Bs], Acc) when N > 0 ->
take_bytes(N-1, Bs, [B|Acc]);
take_bytes(0, Bs, Acc) ->
{lists:reverse(Acc), Bs}.
build_arg(Bs) ->
build_arg(Bs, 0).
build_arg([B|Bs], N) ->
build_arg(Bs, (N bsl 8) bor B);
build_arg([], N) ->
N.
parse_types(_, <<>>, Acc) ->
lists:reverse(Acc);
parse_types(-1, Type, Acc) ->
lists:reverse([Type|Acc]);
parse_types(N, <<TypeBytes:16/binary, Rest/binary>>, Acc) ->
%% See beam_disasm.erl and beam_types.erl to decode.
parse_types(N-1, Rest, [TypeBytes|Acc]).
-module(world).
-export([hello/0]).
-include("world.hrl").
hello() -> ?GREETING.
-module(json_parser).
-export([parse_transform/2]).
parse_transform(AST, _Options) ->
json(AST, []).
-define(FUNCTION(Clauses), {function, Label, Name, Arity, Clauses}).
%% We are only interested in code inside functions.
json([?FUNCTION(Clauses) | Elements], Res) ->
json(Elements, [?FUNCTION(json_clauses(Clauses)) | Res]);
json([Other|Elements], Res) -> json(Elements, [Other | Res]);
json([], Res) -> lists:reverse(Res).
%% We are interested in the code in the body of a function.
json_clauses([{clause, CLine, A1, A2, Code} | Clauses]) ->
[{clause, CLine, A1, A2, json_code(Code)} | json_clauses(Clauses)];
json_clauses([]) -> [].
-define(JSON(Json), {bin, _, [{bin_element
, _
, {tuple, _, [Json]}
, _
, _}]}).
%% We look for: <<"json">> = Json-Term
json_code([]) -> [];
json_code([?JSON(Json)|MoreCode]) -> [parse_json(Json) | json_code(MoreCode)];
json_code(Code) -> Code.
%% Json Object -> [{}] | [{Label, Term}]
parse_json({tuple,Line,[]}) -> {cons, Line, {tuple, Line, []}};
parse_json({tuple,Line,Fields}) -> parse_json_fields(Fields,Line);
%% Json Array -> List
parse_json({cons, Line, Head, Tail}) -> {cons, Line, parse_json(Head),
parse_json(Tail)};
parse_json({nil, Line}) -> {nil, Line};
%% Json String -> <<String>>
parse_json({string, Line, String}) -> str_to_bin(String, Line);
%% Json Integer -> Integer
parse_json({integer, Line, Integer}) -> {integer, Line, Integer};
%% Json Float -> Float
parse_json({float, Line, Float}) -> {float, Line, Float};
%% Json Constant -> true | false | null
parse_json({atom, Line, true}) -> {atom, Line, true};
parse_json({atom, Line, false}) -> {atom, Line, false};
parse_json({atom, Line, null}) -> {atom, Line, null};
%% Variables, should contain Erlang encoded Json
parse_json({var, Line, Var}) -> {var, Line, Var};
%% Json Negative Integer or Float
parse_json({op, Line, '-', {Type, _, N}}) when Type =:= integer
; Type =:= float ->
{Type, Line, -N}.
%% parse_json(Code) -> io:format("Code: ~p~n",[Code]), Code.
-define(FIELD(Label, Code), {remote, L, {string, _, Label}, Code}).
parse_json_fields([], L) -> {nil, L};
%% Label : Json-Term --> [{<<Label>>, Term} | Rest]
parse_json_fields([?FIELD(Label, Code) | Rest], _) ->
cons(tuple(str_to_bin(Label, L), parse_json(Code), L)
, parse_json_fields(Rest, L)
, L).
tuple(E1, E2, Line) -> {tuple, Line, [E1, E2]}.
cons(Head, Tail, Line) -> {cons, Line, Head, Tail}.
str_to_bin(String, Line) ->
{bin
, Line
, [{bin_element
, Line
, {string, Line, String}
, default
, default
}
]
}.
-module(json_test).
-compile({parse_transform, json_parser}).
-export([test/1]).
test(V) ->
<<{{
"name" : "Jack (\"Bee\") Nimble",
"format": {
"type" : "rect",
"widths" : [1920,1600],
"height" : (-1080),
"interlace" : false,
"frame rate": V
}
}}>>.
-module(msg).
-export([send_on_heap/0
,send_off_heap/0]).
send_on_heap() -> send(on_heap).
send_off_heap() -> send(off_heap).
send(How) ->
%% Spawn a function that loops for a while
P2 = spawn(fun () -> receiver(How) end),
%% spawn a sending process
P1 = spawn(fun () -> sender(P2) end),
P1.
sender(P2) ->
%% Send a message that ends up on the heap
%% {_,S} = erlang:process_info(P2, heap_size),
M = loop(0),
P2 ! self(),
receive ready -> ok end,
P2 ! M,
io:format("~p~n",[P2]),
ok.
receiver(How) ->
erlang:process_flag(message_queue_data,How),
receive P -> P ! ready end,
%% loop(100000),
receive x -> ok end,
P.
loop(0) -> [done];
loop(N) -> [loop(N-1)].
-module(stack_machine_compiler).
-export([compile/2]).
compile(Expression, FileName) ->
[ParseTree] = element(2,
erl_parse:parse_exprs(
element(2,
erl_scan:string(Expression)))),
file:write_file(FileName, generate_code(ParseTree) ++ [stop()]).
generate_code({op, _Line, '+', Arg1, Arg2}) ->
generate_code(Arg1) ++ generate_code(Arg2) ++ [add()];
generate_code({op, _Line, '*', Arg1, Arg2}) ->
generate_code(Arg1) ++ generate_code(Arg2) ++ [multiply()];
generate_code({integer, _Line, I}) -> [push(), integer(I)].
stop() -> 0.
add() -> 1.
multiply() -> 2.
push() -> 3.
integer(I) ->
L = binary_to_list(binary:encode_unsigned(I)),
[length(L) | L].
#include <stdio.h>
#include <stdlib.h>
char *read_file(char *name) {
FILE *file;
char *code;
long size;
file = fopen(name, "r");
if(file == NULL) exit(1);
fseek(file, 0L, SEEK_END);
size = ftell(file);
code = (char*)calloc(size, sizeof(char));
if(code == NULL) exit(1);
fseek(file, 0L, SEEK_SET);
fread(code, sizeof(char), size, file);
fclose(file);
return code;
}
#define STOP 0
#define ADD 1
#define MUL 2
#define PUSH 3
#define pop() (stack[--sp])
#define push(X) (stack[sp++] = X)
int run(char *code) {
int stack[1000];
int sp = 0, size = 0, val = 0;
char *ip = code;
while (*ip != STOP) {
switch (*ip++) {
case ADD: push(pop() + pop()); break;
case MUL: push(pop() * pop()); break;
case PUSH:
size = *ip++;
val = 0;
while (size--) { val = val * 256 + *ip++; }
push(val);
break;
}
}
return pop();
}
int main(int argc, char *argv[])
{
char *code;
int res;
if (argc > 1) {
code = read_file(argv[1]);
res = run(code);
printf("The value is: %i\n", res);
return 0;
} else {
printf("Give the file name of a byte code program as argument\n");
return -1;
}
}
#include <stdio.h>
#include <stdlib.h>
#define STOP 0
#define ADD 1
#define MUL 2
#define PUSH 3
#define pop() (stack[--sp])
#define push(X) (stack[sp++] = (X))
typedef void (*instructionp_t)(void);
int stack[1000];
int sp;
instructionp_t *ip;
int running;
void add() { int x,y; x = pop(); y = pop(); push(x + y); }
void mul() { int x,y; x = pop(); y = pop(); push(x * y); }
void pushi(){ int x; x = (int)*ip++; push(x); }
void stop() { running = 0; }
instructionp_t *read_file(char *name) {
FILE *file;
instructionp_t *code;
instructionp_t *cp;
long size;
char ch;
unsigned int val;
file = fopen(name, "r");
if(file == NULL) exit(1);
fseek(file, 0L, SEEK_END);
size = ftell(file);
code = calloc(size, sizeof(instructionp_t));
if(code == NULL) exit(1);
cp = code;
fseek(file, 0L, SEEK_SET);
while ( ( ch = fgetc(file) ) != EOF )
{
switch (ch) {
case ADD: *cp++ = &add; break;
case MUL: *cp++ = &mul; break;
case PUSH:
*cp++ = &pushi;
ch = fgetc(file);
val = 0;
while (ch--) { val = val * 256 + fgetc(file); }
*cp++ = (instructionp_t) val;
break;
}
}
*cp = &stop;
fclose(file);
return code;
}
int run() {
sp = 0;
running = 1;
while (running) (*ip++)();
return pop();
}
int main(int argc, char *argv[])
{
if (argc > 1) {
ip = read_file(argv[1]);
printf("The value is: %i\n", run());
return 0;
} else {
printf("Give the file name of a byte code program as argument\n");
return -1;
}
}
-module(send).
-export([test/0]).
test() ->
P2 = spawn(fun() -> p2() end),
P1 = spawn(fun() -> p1(P2) end),
{P1, P2}.
p2() ->
receive
M -> io:format("P2 got ~p", [M])
end.
p1(P2) ->
L = "hello",
M = {L, L},
P2 ! M,
io:format("P1 sent ~p", [M]).
로드 밸런서.
-module(lb).
-export([start/0]).
start() ->
Workers = [spawn(fun worker/0) || _ <- lists:seq(1,10)],
LoadBalancer = spawn(fun() -> loop(Workers, 0) end),
{ok, Files} = file:list_dir("."),
Loaders = [spawn(fun() -> loader(LoadBalancer, F) end) || F <- Files],
{Loaders, LoadBalancer, Workers}.
loader(LB, File) ->
case file:read_file(File) of
{ok, Bin} -> LB ! Bin;
_Dir -> ok
end,
ok.
worker() ->
receive
Bin ->
io:format("Byte Size: ~w~n", [byte_size(Bin)]),
garbage_collect(),
worker()
end.
loop(Workers, N) ->
receive
WorkItem ->
Worker = lists:nth(N+1, Workers),
Worker ! WorkItem,
loop(Workers, (N+1) rem length(Workers))
end.
show.
%%%-------------------------------------------------------------------
%%% show.erl – minimal BEAM tag sniffing in pure Erlang
%%%-------------------------------------------------------------------
-module(show).
-export([top_tag/1, %% → {primary,sub}
tag/1, hex_tag/1, %% → zero-padded string of low tag bits
tag_to_type/1]). %% → header|cons|boxed|pid|…
%%--------------------------------------------------------------------
%% Constants that depend on the VM word size
%%--------------------------------------------------------------------
small_limits() ->
case erlang:system_info(wordsize) of
8 -> {-(1 bsl 59), (1 bsl 59) - 1}; %% 64-bit build: 60 payload bits
4 -> {-(1 bsl 27), (1 bsl 27) - 1} %% 32-bit build: 28 payload bits
end.
word_bits() -> erlang:system_info(wordsize) * 8.
hex_digits() -> erlang:system_info(wordsize) * 2.
%%--------------------------------------------------------------------
%% High-level tag classifier
%%--------------------------------------------------------------------
top_tag(Term) when is_integer(Term) ->
{Min,Max} = small_limits(),
if Term >= Min, Term =< Max ->
{immed, small_int}; %% primary 11, sub 11
true ->
{boxed, bignum_header}
end;
top_tag(Term) when is_atom(Term) -> {immed, atom};
top_tag(true) -> {immed, bool};
top_tag(false) -> {immed, bool};
top_tag([]) -> {immed, nil};
top_tag(Term) when is_pid(Term) -> {immed, pid};
top_tag(Term) when is_port(Term) -> {immed, port};
top_tag(Term) when is_reference(Term)-> {immed, ref};
top_tag([_|_]) -> {cons_cell, list_ptr}; %% primary 01
top_tag({_}) -> {tuple_boxed, boxed_ptr};
top_tag(Term) when is_float(Term) -> {float_boxed, boxed_ptr};
top_tag(Term) when is_binary(Term) -> {bin_boxed, boxed_ptr};
top_tag(Term) when is_map(Term) -> {map_boxed, boxed_ptr};
top_tag(Term) when is_function(Term) -> {fun_boxed, boxed_ptr};
top_tag(_) -> {unknown, needs_native}.
%%--------------------------------------------------------------------
%% Pretty-print helpers (zero-padded)
%%--------------------------------------------------------------------
tag(Term) ->
pad_left(integer_to_list(tag_word(Term), 2), word_bits(), $0).
hex_tag(Term) ->
pad_left(string:uppercase(integer_to_list(tag_word(Term), 16)),
hex_digits(), $0).
pad_left(Str, Width, PadChar) ->
PadCnt = Width - length(Str),
lists:duplicate(max(PadCnt,0), PadChar) ++ Str.
%% minimal word with just the interesting low bits set ---------------
tag_word(Term) ->
case top_tag(Term) of
{immed, small_int} -> 16#3; %% …0011
{immed, atom} -> 16#F; %% …1111
{immed, bool} -> 16#B; %% …1011 (same as small int tag,
%% VM distinguishes by range)
{immed, nil} -> 16#1F; %% …11111
{immed, pid} -> 16#7; %% …0111
{immed, port} -> 16#B; %% …1011
{cons_cell,_} -> 16#1; %% …0001
{boxed,_} -> 16#2; %% …0010
_ -> 0
end.
%%--------------------------------------------------------------------
%% Decode a *real* machine word’s low tag bits (handy for GDB/ETP)
%%--------------------------------------------------------------------
tag_to_type(Word) ->
case Word band 3 of %% primary tag = two LSBs
0 -> header;
1 -> cons;
2 -> boxed;
3 ->
case (Word bsr 2) band 3 of %% immed-1 secondary tag
0 -> pid;
1 -> port;
2 ->
case (Word bsr 4) band 3 of
0 -> atom;
1 -> 'catch'; %% quoted – ‘catch’ is a keyword
2 -> unused;
3 -> nil
end;
3 -> smallint
end
end.
참고 문헌
-
[DragonBook] Aho, Sethi, Ullman, Compilers: Principles, Techniques, and Tools Addison-Wesley, 1986. ISBN 0-201-10088-6
-
[Warren] D. H. D. Warren. An Abstract Prolog Instruction Set: Technical Note 309, Artificial Intelligence Center, SRI International, 333 Ravenswood Ave, Menlo Park, CA 94025, 1983년 10월.