Elixir의 동시성 해부
(underjord.io)- Elixir의 동시성은 문법적 편의가 아니라 Erlang VM인 BEAM의 경량 프로세스, 스케줄러, 메시지 전달 모델 위에 놓인 런타임 특성임
- BEAM 프로세스는 OS 프로세스·스레드와 다르며, 낮은 생성 비용과 작은 메모리 사용량 덕분에 수백만 개까지 운용할 수 있음
- 각 프로세스는 불변 데이터와 사적 상태를 갖고 비동기 메시지로만 통신해, 동시 실행과 병렬 실행의 위험을 줄임
- 긴 작업은 약 4000 reductions 같은 기준 이후 선점되어 큐 뒤로 밀릴 수 있어, CPU-bound 작업도 다른 요청의 진행을 오래 막지 않음
- 애플리케이션에서는 웹 요청이 보통 단일 프로세스에서 처리되고, 필요할 때 Task와
Task.async_stream으로 병렬 작업을 쉽게 구성할 수 있음
BEAM 위에 올라간 Elixir 동시성
- Elixir의 동시성과 병렬성은 Erlang 가상 머신인 BEAM이 핵심 기반임
- Elixir 문맥에서 설명하지만, 장점으로 다루는 대부분은 Erlang에도 해당함
Task모듈은 Elixir가 제공하는 편리한 추상화로 활용됨
PHP·Python 경험과의 대비
- PHP의 동시성은 대체로 웹 서버나 애플리케이션 서버가 정하는 모델에 가까움
mod_php나php-fpm환경에서 각 요청은 shared-nothing 모델을 전제로 함- 요청 간 작업은 동시에 실행될 수 있지만, 외부 파일·데이터베이스·큐 없이는 상태를 공유하지 않음
- Python은 일반 스레딩을 지원하지만 GIL(Global Interpreter Lock) 때문에 제약이 큼
- 더 많은 인터프리터를 실행하거나
multiprocess로 새 프로세스를 포크하는 방식이 더 나은 해법처럼 보였음 - Python 자체는 좋아했지만, 성능이 나오는 동시성 구현은 즐겁지 않았다는 평가임
- 더 많은 인터프리터를 실행하거나
BEAM 프로세스와 스케줄링
- BEAM은 많은 수의 경량 프로세스를 실행하도록 설계됨
- 일종의 green thread로 볼 수 있음
- 생성 시간이 짧고 메모리 사용량도 작음
- 시스템은 수백만 개의 프로세스를 가질 수 있으며, 더 많은 수가 필요하면 기본 제한을 높일 수 있음
- BEAM 프로세스는 OS 프로세스나 OS 스레드와 근본적으로 다른 실행 단위임
- BEAM 자체는 OS에 의해 스케줄링됨
- 시작 시 BEAM은 기본적으로 사용 가능한 CPU 코어당 하나의 스케줄러 스레드를 생성함
- 이 스케줄러들이 BEAM 프로세스 실행을 배분함
- 한 스케줄러에 오래 걸리는 프로세스가 많이 쌓이면 다른 스케줄러가 작업을 가져가 부하를 균형화할 수 있음
- 특정 프로세스가 완료되지 않은 채 많은 함수 호출을 지나면, 예를 들어 약 4000 reductions 이후 스케줄러가 이를 선점함
- 선점된 프로세스는 실행 중간에 멈춰 작업 큐 끝으로 돌아감
- 이는 Linux가 OS 프로세스에 적용하는 선점형 멀티태스킹과 같은 개념임
- BEAM에서는 어떤 작업도 줄을 오래 붙잡지 못해 낮은 지연 시간을 확보하는 데 기여함
불변성, 메시지 전달, 안전한 병렬 실행
- 프로세스는 하나의 함수를 실행하고, 그 함수는 자신의 지역 변수와 전달받은 인자의 사적 복사본에만 접근함
- Elixir와 Erlang의 함수형 프로그래밍 특성상 값은 불변임
- 한 프로세스가 다른 함수나 프로세스 안의 값을 변경할 수 없음
- 프로세스는 메시지를 받을 수 있고 보낼 수 있음
- 함수 실행이 끝나면 해당 프로세스도 종료됨
- 함수가 영구 루프를 돌며 메시지를 확인하고 메시지가 올 때만 동작하면 Actor 모델과 연결되지만, 세부 설명은 범위 밖임
- Elixir와 Erlang의 불변 함수형 특성은 여러 목표를 달성하기 위한 실용적 선택임
- 이 설계는 멀티코어 머신보다 앞선 시기에 만들어졌고, 원래는 여러 머신으로 구성된 분산 시스템과 클러스터를 겨냥했음
- 결과적으로 멀티코어 환경에서 높은 동시성과 병렬성을 얻는 데도 유리해짐
- 프로세스는 상태를 공유하지 않고, 비동기 메시지 전달로만 통신함
- 이 구조 덕분에 동시에 실행해도 안전함
- 동시성은 같은 기간에 작업을 실행하는 것이고, 병렬성은 같은 시점에 작업을 실행하는 것임
- 파일, 제한된 리소스,
stdin/stdout같은 API에는 경합을 제어하는 메커니즘이 있음
애플리케이션 코드에서의 체감과 Task 활용
- 일반적인 코드 작성에서는 BEAM의 저수준 원리를 많이 신경 쓰지 않아도 됨
- 대부분의 웹 요청은 하나의 프로세스 안에서 처리됨
- 데이터베이스 커넥션 풀과 통신할 때는 더 많은 프로세스가 관여할 수 있음
- 애플리케이션 개발자가 기본 프리미티브를 직접 다룰 필요는 크지 않음
- Node.js식으로 IO를 향해 경쟁하는 방식을 고민할 필요가 적음
- Node.js도 높은 동시성을 가질 수 있음
- BEAM에서는 CPU-bound 작업도 다른 프로세스의 진행을 방해하지 않음
- 여러 코어가 작업을 처리할 수 있고, CPU 사이클을 많이 쓰는 프로세스는 선점되어 다른 작업이 지나갈 수 있음
- 명시적으로 동시 작업을 만들 때는
Task모듈의 async/await 메커니즘을 사용할 수 있음- JavaScript의 async/await와 달리
Task참조를 반환함 - 이 참조는 프로세스 ID인 PID 위의 유용한 추상화임
await로 결과를 받거나 여러 작업의 결과를 받을 수 있음
- JavaScript의 async/await와 달리
Task.async_stream은 enumerable의 각 항목에 대해Task를 실행하고 이를 lazy stream으로 다룸- list, map, stream 같은 입력에 사용할 수 있음
- 기본 동시성은 사용 가능한 코어 수와 맞춰짐
- 서로 독립적으로 병렬화하기 쉬운 작업에서 머신 자원을 활용하는 지름길로 볼 수 있음
- PHP와 비교하면 Elixir는 시스템 안에서 일어나는 작업들이 서로 상호작용할 도구가 더 많음
- 프로세스 레지스트리와 그룹
- 메시지 전달을 위한 PubSub 메커니즘
- ETS 같은 안전한 메커니즘을 통한 메모리 기반 공유 상태
- Python과 비교하면 Elixir는 여러 일을 단순하고 유용한 방식으로 동시에 처리할 수 있음
- JavaScript와 비교하면 CPU-bound 작업이 다른 요청을 붙잡는 문제를 덜 걱정하면서 여러 작업을 쉽게 수행할 수 있고, 실제 불변성을 얻을 수 있음
- Elixir와 Erlang의 동시성·병렬성 선택지는 나중에 덧붙인 기능이 아니라 런타임의 토대에 들어 있음
- 이 점은 성능과 실용성 모두에 중요함
- BEAM에서 동시성을 다루는 일은 벌을 받는 일도, 위험한 일도 아니며, 사용하다 보면 자연스럽게 하게 되는 일에 가까움
댓글과 토론
Hacker News 의견들
-
코드 예제가 몇 개 있었으면 더 좋았을 듯함. Elixir의 동시성은 필요할 때만 꺼내 쓰면 되고 대부분 코드에서 강제되지 않는 점이 좋음
JavaScript는 오히려 방해가 될 때도 비동기 방식이 강요되는 느낌임. LiveView 클릭 이벤트에서는 비동기 처리 가능 코드를spawn함수에 넣어 반환을 빠르게 하곤 했음- 맞음. Elixir 입문자에게 이 점이 매우 중요함. 대부분의 경우 동시성이 있다는 사실조차 몰라도 되고, 꽤 복잡한 Phoenix 앱도 동시성을 직접 다루지 않고 만들 수 있음
다만 결국 필요해지는 순간에는 준비되어 있고, 패턴을 이해하면 정말 훌륭함 - LiveView 대시보드에서 표의 아주 많은 항목이 실시간 갱신되다 보니 브라우저 탭이 CPU를 15%쯤 쓰는 상황이 있었음
웹소켓 갱신 묶음 처리를 넣고 중간 GenServer 루프에sleep()을 추가하는 데 1시간 정도 걸렸고, 이후에는 원하는 갱신 속도에 맞춰 sleep 값만 바꾸면 됐음
- 맞음. Elixir 입문자에게 이 점이 매우 중요함. 대부분의 경우 동시성이 있다는 사실조차 몰라도 되고, 꽤 복잡한 Phoenix 앱도 동시성을 직접 다루지 않고 만들 수 있음
-
JavaScript와 달리 Elixir는 동기 코드처럼 쓰게 해줌. 예를 들어
Task.async_stream(&Processor.process/1, max_concurrency: 2, timeout: 7000, on_timeout: :kill_task)뒤에Enum.to_list()를 붙이는 식임
일반 코드와 동시 실행 코드의 의미론이 같음- Node가 동기적이라는 말을 꽤 망쳐놨음. 많은 사람이 이 말을 “그 순간 프로세서에서 실행되는 유일한 작업”이나 “파일 읽기를 동기적으로 기다리면 전체 프로세스가 멈춤”이라는 뜻까지 포함한다고 믿게 됨
그래서 Elixir/Erlang/스레드 기반 언어가 “동기 코드를 쓰게 한다”고 말하면, 비동기 세계관에 익숙해서 이 말을 꼭 들어야 할 사람일수록 “그 언어는 한 번에 하나밖에 못 한다”로 오해함. 표현을 그렇게 하면 이미 아는 사람에게만 닿기 쉬움 - 여기서 핵심은 언어가 표준 동기 호출과 비동기 호출을 구분하는 함수 색깔이 없다는 뜻임
- HTTP 요청을 병렬화하기 아주 편하고, 굳이 이벤트 기반으로 가지 않아도 됨
최근 API 응답이 OpenAPI 명세와 맞는지 검증한 예시는 여기 있음: https://github.com/etalab/transport-site/pull/3351/files#dif... - 맞음. 기본값이 이벤트 루프 안에 있는 방식은 원하지 않음
- 이건
Promise로도 재현할 수 있음
Node.js 개발 8년, 이후 5년째 Elixir 전업 개발자로 일한 기준임
- Node가 동기적이라는 말을 꽤 망쳐놨음. 많은 사람이 이 말을 “그 순간 프로세서에서 실행되는 유일한 작업”이나 “파일 읽기를 동기적으로 기다리면 전체 프로세스가 멈춤”이라는 뜻까지 포함한다고 믿게 됨
-
Elixir를 깊게 써본 건 아니지만 Erlang은 꽤 많이 해봤음. Erlang의 동시성은 나쁘다기보다, 쓰다 보면 다른 거의 모든 언어의 동시성이 얼마나 귀찮은지 짜증나게 됨
2023년의 많은 분산 시스템 도구는 사실상 Erlang 구조를 더 주류 언어로 옮겨놓으려는 장치에 가까움. 캐시, 큐, 버스, RPC를 붙여서 동작하는 분산 시스템을 만들 수는 있지만, Erlang은 그걸 기본으로 제공하고 제대로 작동함- 충분히 복잡한 분산 프로그램은 결국 임시방편에, 비공식 명세에, 버그 많고 느린 Erlang 절반 구현체를 포함하게 됨
- Elixir를 좋아하지만, 주로 언어 설계 때문이지 분산 시스템 기능 때문은 아님. Erlang/Elixir가 큐나 RPC 등을 기본 제공해서 신경 안 써도 된다는 말을 자주 듣지만, 실제로는 Distributed Erlang 사용을 그렇게 추천하지 않는 경우가 많음
내가 겪은 대부분의 Elixir 업무도 Distributed Elixir를 쓰지 않고, 그냥 Kubernetes, Kafka, Redis, GRPC를 썼음 - Erlang에는 꽤 빨리 빠져들었고, 이후 다른 언어를 쓰는 재미가 줄었음. 단순하고 간결한 문법, 패턴 매칭, 불변성, 여기저기 분기문을 깔지 않는 오류 처리, 동시성까지 갖추고 나면 떠나기 어려움
- 동시성에서는 언어가 큰 역할을 하고 코드 작성 방식을 거의 결정함. Erlang은 함수형 스타일이고 JavaScript는 비동기 스타일임
점점 더 확신하는 건, 동기적으로 생각하고 핵심 기술이 동시성을 처리하게 하는 편이 더 낫고 유지보수하기 쉽다는 점임. 예를 들면 Go의 goroutine이나 Ruby/Python의 다중 프로세스 방식이 그렇고, Erlang만큼 동시·분산에 강하진 않아도 작업하기는 쉬울 수 있음 - 예를 몇 가지 들 수 있음?
-
몇 년 전에 본 이 발표가 접근하기 좋았음. Elixir 개발이 Python만큼 쉬웠으면 좋겠음
https://youtu.be/xoNRtWl4fZU- Elixir는 하는 일에 비해 이해하기 쉬운 편이고, 애플리케이션마다 Python보다 훨씬 많은 일을 해줌. 다만 Python에는 저수준 세부사항을 개발자에게 떠넘기지 않고 애플리케이션을 더 멀리 밀어주는 선언적 프레임워크가 많음
Elixir도 비슷하게 Elixir와 OTP가 많은 복잡성을 추상화하며, 때로는 너무 많이 숨기기도 함. Python 앱에 프레임워크를 많이 들여오면 결국 더 많은 결정을 하게 됨. 매 애플리케이션마다 BEAM 프로세스 관리를 생각하면 처음부터 그 패러다임을 고민하게 되고, 익숙해지면 다른 언어에서도 동시성 이해를 적용할 수 있음. Erlang/Elixir 개발자가 Rust를 쓸 때 공유 상태보다 메시지 전달을 택하고, 시스템을 더 견고하게 만들며 OTP식 패러다임을 받아들이는 일이 자주 생김 - Python과 꽤 가깝다고 보는 Ruby를 많이 쓰다가 Elixir로 오니 처음엔 더 어렵게 느껴졌음. 하지만 그건 습관과 Ruby/Python이 주는 거대한 편안함의 종속성 때문이었음
지금도 Ruby는 예전처럼 유창하게 쓰지만, 몇 년 지나고 보니 Elixir 개발이 Ruby보다 더 쉽다고 느끼게 됨 - 어떤 부분이 더 어렵게 느껴짐? 작업을 달성하는 부분인지, 아니면 그걸로 돈을 버는 부분인지 궁금함
- Elixir는 하는 일에 비해 이해하기 쉬운 편이고, 애플리케이션마다 Python보다 훨씬 많은 일을 해줌. 다만 Python에는 저수준 세부사항을 개발자에게 떠넘기지 않고 애플리케이션을 더 멀리 밀어주는 선언적 프레임워크가 많음
-
정적 타입이 있는 Elixir라면 정말 꿈같을 듯함. https://gleam.run/이 있다는 건 알고 있음
- Elixir에 타입 시스템을 도입하려는 계획이 있음
https://elixir-lang.org/blog/2023/06/22/type-system-updates-... - 함수, 패턴 매칭, 가드, 타입 명세, 성공 타이핑은 근본적으로 서로 맞물리지 않기 때문에 불가능하다고 봄. 각각 다른 것의 부분집합을 지정하거나 강제하고, 모두 나름대로 유용하지만 서로 동형은 아님
형식적 시도는 반복해서 실패했고, 가장 어렵고 유용한 메시지 전달을 제외해도 그랬음. Wadler도 못 했다면 나나 당신도 못 할 가능성이 큼. 타입 안전한 동시성 언어는 가능하지만, E/E와 BEAM 위에 얹는 새 언어 계층이어야 함
- Elixir에 타입 시스템을 도입하려는 계획이 있음
-
Underjord라는 사람이 마음에 듦. Elixir 관련 YouTube 콘텐츠를 봤는데, 개념을 잘 설명하고 가르치는 감각이 자연스러움
-
Elixir 얘기가 나온 김에, Podman으로 Elixir 웹 앱을 설정하는 간단한 가이드를 썼음. 더 안전한 Docker 대안으로 쓴 셈이고, Phoenix가 어떻게 만들어지는지 보여주기 위해 Plug도 사용함
https://spacedimp.com/blog/dockerless-setting-up-an-elixir-w... -
Erlang을 많이 써보진 않았지만, 동시성을 더 잘 표현하는 방식이 필요하다고 느껴왔음. 독립 프로세스를 행으로, 시간을 열로 놓고 이벤트 경계를 표시하는 타임라인 격자가 이해하기 쉬워 보임
시퀀스 다이어그램이 가장 가깝지만, Chrome 개발자 도구가 렌더링·페인팅·JavaScript 등을 보여주는 방식도 떠오름. http://bloom-lang.net/은 머신 사이의 비순차 동시성 문제를 격자로 해결함. 주류 언어들이 동시성을 표현하는 방식에는 만족하지 못하고, Rust async는 스케줄 관점에서 큰 그림을 읽고 이해하기 어렵고 함수 색깔 문제도 고통스러움.tcp-connection을 만들고on-ready-for-reading,on-ready-for-writing같은 시스템 이벤트 핸들러를 등록하는 대화형 프로그래밍 환경을 원함. 이런 이벤트는epoll이나liburing을 돌리는 I/O 스레드에서 발생하고, I/O 스레드 루프를 막지 않도록 다른 스레드로 이벤트를 넘겨야 함. 여러 위치에서 발화될 수 있는 상태 머신과 이벤트를 표현하는 문법도 설계해왔고, 예를 들면initialstate = state1 | state1a state1b state1c | state2a state2b state2d | state3처럼 초기 상태,state1, 순서와 무관한state1a state1b state1c등을 차례로 기다리는 식임. 이상적으로는 데이터 흐름이 트리이고, 독립 작업이 동기화되는 장벽 지점을 곳곳에 둬 작업과 데이터를 교환해야 함. 공유 메모리 동기화는 한 번에 옮길 수 있는 데이터 양 면에서 좋지만, 핫 경로에서는 피하고 싶음. Java 런타임으로thread(s) = state1(yes) | send(message) | receive(message2);와thread(r) = state1(yes) | receive(message) | send(message2);같은 프로그램도 실행해봤음. 멀티스레드 서버 구조 설계는 여기 있음: https://github.com/samsquire/three-tier-multithreaded-archit.... 이 문법으로는 정의된 이벤트를 기다리기만 하면 되므로 복잡하게 맞물린 워크플로를 구현할 수 있고, LMAX Disruptor는 초당 요청 수가 꽤 잘 나옴- 효과 핸들러가 있는 언어들을 살펴보면 좋겠음. 주류는 아니지만 여기서 생각하는 방향에 도움이 될 수 있음
Effekt나 OCaml 5.0의 새 이벤트 핸들러 시스템이 목표에 가까운 아이디어를 주는 데 도움이 될 수 있음
- 효과 핸들러가 있는 언어들을 살펴보면 좋겠음. 주류는 아니지만 여기서 생각하는 방향에 도움이 될 수 있음