요약
파이어존이 boringtun 포크를 접고 입출력을 분리한 Rust용 WireGuard 라이브러리 Sanstun을 공개했습니다.
파이어존의 토마스 아이징어가 쓴 소개 글입니다. 파이어존은 2년 넘게 클라우드플레어의 boringtun을 포크해 70건이 넘는 수정을 쌓았습니다. 제품 요구가 달라지면서 기존 API를 유지하기 어려워졌고, 그 경험을 살려 새 API를 설계했습니다.
왜 중요한가
- 사용자 공간 WireGuard(운영체제 커널 밖에서 도는 구현)를 라이브러리로 쓰려는 개발자에게 Apache 2.0 라이선스의 선택지가 하나 늘었습니다.
- 파이어존의 boringtun 포크는 폐기 상태가 되었습니다. 이 회사의 구현은 앞으로 Sanstun으로 이어집니다.
- 터널 하나에 트래픽이 몰리면 순차 처리에 묶이던 암호화를 다른 스레드로 넘길 길이 열렸습니다.
핵심 내용
- sans-IO(입출력을 상태·로직과 떼어 내는 설계)를 따르며, 네트워크뿐 아니라 시간과 난수도 입출력으로 다룹니다.
- boringtun은 세션이 없으면 패킷을 내부에 쌓았지만, Sanstun은 버퍼링과 핸드셰이크 시점을 호출자에게 맡깁니다.
begin_encapsulate가 세션 확인과 카운터 예약만 하고, 실제 암호화는 스레드를 옮길 수 있는 별도 객체가 맡습니다.- 1,280바이트 패킷의 상태 처리는 약 20.8나노초, 암호화는 약 934나노초입니다.
- 글은 이를 근거로 최대 45배가량 빨라질 수 있다고 추정했지만, 스레드 간 전달 비용 때문에 실제 효과는 더 작다고 밝혔습니다.
HN 반응
- async를 입출력처럼 다룬 글의 설명에는 async도 순수한 상태 기계라는 반론이 나왔고, 파이어존 개발자는 입력이 많으면
&mut self호출이 낫다고 답했습니다. - 프로토콜을 같은 방식으로 구현한다는 이용자는 테스트가 쉽다고 호평했고, 제품 소개 문구가 AI 글 같아 믿음이 떨어진다는 비판도 있었습니다.
다 좋았는데, 이 문장을 읽는 순간 흐름이 깨졌습니다.
async는 I/O가 아닙니다. I/O에 쓸 수는 있지만, 본질은 논리적인 흐름을 여러 전이에 흩어 놓지 않고 한곳에 모아 두면서 순수하고 결정적인 상태 기계를 작성하는 방법일 뿐입니다.
async 흐름을 직접 명시적 상태 기계로 컴파일하기로 해도 '함수 색칠'(곧 타이핑) 문제는 여전히 남습니다. 여러 상태 전이를 가로지르는 흐름(그 사이에 다른 전이로 상태의 서로 무관한 부분이 갱신될 수도 있습니다)은 이제 상태 전이(의 연속)로 다시 써야 하고, 블로킹 함수에서는 호출할 수도 없습니다. 오히려
async없이 상태 기계를 전부 다시 쓰면 함수 색칠의 '문제'는 더 나빠집니다. 여러 전이에 걸친 흐름이 일반 함수와 훨씬 더 달라지니까요.원칙적으로는 맞는 말씀입니다. async는 I/O가 아니고,
async함수는 결정적 상태 기계로 컴파일됩니다.문제는
async를 쓰는 순간 상태 기계가poll이라는 API 하나로 줄어든다는 점입니다. 두 상태 전이 사이(즉 두await지점 사이)에서 상태를 바꾸고 싶으면 어떻게 해야 할까요? 알맞은 시점에 데이터를 넘겨줄 채널을 끼워 넣거나, 뮤텍스를 거쳐 공유 데이터에 접근해야 합니다.Rust의
async구현은 상태 기계를 띄우기 전에 필요한 걸 전부 알고 있을 때는 훌륭합니다. 하지만 여러 일을 동시에 처리해야 하는 순간부터는 정말 엉망이 될 수 있습니다.Firezone의 sans-IO 코어는 인바운드 IP, 인바운드 UDP, 웹소켓 제어 메시지, TURN 할당, 타이머, 사용자 명령 등 많은 것을 동시에 처리합니다. 이 입력 가운데 무엇이 들어오든
&mut self함수를 호출하면 되는 커다란 구조체 하나만 있으면, 이해하기도 조합하기도 테스트하기도 아주 쉽습니다.TURN 요청-응답 핸드셰이크 같은 몇몇은
async함수로 짜는 편이 쉬울 수도 있습니다. 하지만 실제로는 큰 문제가 아니고, 이 구조에서 얻는 조합성이 그 단점보다 크다고 봅니다.말씀하신 그대로라면 오히려 Rust 모델의 장점 이라고 생각합니다. 상태 기계의 상태가 전용 상태 기계 타입 안에 완전히 담겨 있으니, 동작이 뜻밖의 외부 상태에 좌우되지 않는다는 걸 알 수 있으니까요.
그래도 비판의 취지는 옳다고 봅니다. 중간 입력과 출력이 있는 프로세스에 타입을 붙이고 싶은 건 일반적으로 당연한 바람인데, Rust에서는 그게 어렵습니다(불가능하지는 않습니다. 예를 들어 Munksgaard가 Rust로 만든 세션 타입 작업을 보세요). 원문 글이 Rust의 퓨처는 자기들 목적에 맞지 않는다고 판단했다면 저는 문제 삼지 않았을 겁니다. 오히려 실제 코드가 API에 정확히 무엇을 요구하는지 보여 주는 흥미로운 사례 연구가 됐을 거고요. 제가 불만인 건 퓨처를 I/O의 한 종류로 분류했다는 점뿐입니다. 사실과 이보다 더 먼 이야기도 없습니다.
하하, wh33zie님과 저는 전에도 HN에서 sans-io를 두고 이런 이야기를 나눈 적이 있어요. https://news.ycombinator.com/item?id=40872020
async를Generator로 일반화하는 게 바로 그 발상 아닌가요?R타입 매개변수를 계산을 재개할 때 넘기고 싶은 타입으로 정하면 되잖아요.맞습니다! 예전에 직접 해 본 적이 있는데, 생각만큼 도움이 안 됩니다. 재개 매개변수가 제네릭이 되는 순간 제네릭 런타임으로는 제너레이터를 더 이상 구동할 수 없습니다.
어차피 상태 기계를 진행시키려고 런타임/이벤트 루프를 직접 짜야 한다면, Generator 트레이트 같은 추상화를 거쳐서 얻는 게 정확히 뭘까요?
게다가 재개와 양보(yield)를 묶은 추상화는 복잡해지기 쉽습니다. 둘 중 하나만 필요한 경우가 많은데,
yield하나는 항상 둘 다 하므로 어느 쪽이든 아무 일도 안 하는 경우를 지원해야 하기 때문입니다.마지막으로, 모든 입력이 하나의 타입으로 들어오면 상태 기계가 모든 yield 지점에서 가능한 모든 입력을 처리해야 합니다.
match문과 분기가 엄청나게 늘어납니다.더 나은 방법이 분명 있을 거고, 커스텀 이터레이터를 표현하는 제너레이터가 생기면 정말 멋질 겁니다. 하지만 범용 상태 기계에서는 큰 이점이 보이지 않습니다.
이 긴 여정의 끝에는 대수적 효과(algebraic effects)가 있다고 봅니다. 계산을 타입이 붙은 효과로 작성하고, 모든 효과를(런타임에 맡길 일부만 남기고 싶다면 유한한 일부를 뺀 나머지를) 처리하고 나면 그 계산을 제네릭하게 구동할 수 있습니다. 효과에 타입이 있으니 yield마다 효과 인자의 타입을 좁힐 수 있어서, (채널 양쪽 어디에서도) 모든 yield 지점에서 가능한 모든 효과를 처리할 필요가 없습니다.
사소한 지적인데, 이제 해당 기능의 이름은
Generator가 아니라Coroutine입니다.저는 정반대라고 봅니다. 동시성을 생각하는 데는 블로킹 스레드가 훨씬 자연스러운 방식입니다.
얼랭 창시자 Joe Armstrong의 박사 논문에서 따온 말입니다. 그는 멀티스레드 프로그램을 이해하는 유일한 방법은 스레드마다 엄격하게 순차적인 코드를 쓰고, 모든 스레드의 코드를 한곳에 뒤섞지 않는 것이라고 주장합니다.
"프로그램의 구조는 문제의 구조를 정확히 따라야 한다. 현실 세계의 동시 활동 하나하나는 프로그래밍 언어의 동시 프로세스 정확히 하나에 대응시켜야 한다. 문제와 프로그램이 1:1로 대응하면 그 프로그램은 문제와 동형(isomorphic)이라고 한다. 이 대응이 정확히 1:1이라는 점은 대단히 중요하다. 문제와 해법 사이의 개념적 간격을 최소화하기 때문이다. 이 대응이 1:1이 아니면 프로그램은 금세 퇴화해 이해하기 어려워진다. 이런 퇴화는 CO가 아닌 언어['동시성 지향이 아닌(non concurrency-oriented)' 언어를 말한다. JavaScript, 너 말이야!]로 동시성 문제를 풀 때 흔히 관찰된다. 프로그램을 동작하게 만드는 유일한 방법이 서로 독립적인 여러 활동을 같은 언어 스레드나 프로세스가 제어하게 강제하는 것뿐인 경우가 많다. 그러면 명료성을 잃을 수밖에 없고, 프로그램은 복잡하고 재현할 수 없는 간섭 오류에 시달리게 된다." [0]
[0] https://erlang.org/download/armstrong_thesis_2003.pdf
반박하는 투로 쓰셨지만 저는 사실 사소한 이견 하나만 빼면 전적으로 동의합니다.
스레드를 작성할 때와 async 태스크를 작성할 때의 (이상적인) 프로그래밍 모델은 똑같습니다. 우연히 시간상 떨어져 있게 된 순차적 흐름을 쓰는 가장 자연스러운 방법도 그 모델이고요. 블로킹 연산과 await는 동등합니다(공유 메모리가 없다면 그렇습니다. 공유 메모리가 있으면 단일 스레드의 async 태스크는 await 사이의 코드가 원자적이라고 가정할 수 있다는 점이 다르지만, 여기서는 그리 중요하지 않습니다). Joe가 반대하는 건 비즈니스 흐름을 명시적인 상태 기계 전이 여러 개로 쪼개는 것 바로 그것이라고 생각합니다.
차이는 호출자에게만 있습니다. (Rust식) async 태스크의 호출자는 자기가 만든 태스크가 어떻게(그리고 과연) 재개되는지 제어할 권리와 책임이 있습니다. 반면 스레드나 BEAM 프로세스(그리고 Go, JavaScript 등의 '스택풀' 퓨처)는 하늘 위의 거대한 전역 이벤트 루프(OS 스케줄러나 BEAM 멀티플렉서 따위라고도 부르죠)에만 책임을 집니다. 그래서 태스크를 구동하는 걸 잊어서 생기는 버그는 피하기 쉽지만, 태스크를 멈추는 걸 잊거나 잘못 멈춰서 생기는 버그는 피하기 어렵습니다. 구조적 동시성(structured concurrency)을 보세요. 구조상 이벤트 루프가 하나만 있을 수 있고 그것을 분해할 방법도 없어서 조합성도 약간 떨어집니다.
이제는 Elixir 같은 언어 덕분에 Erlang보다 BEAM이라고 더 폭넓게 말해야겠지만, 아무튼 Erlang을 OS 프로세스에 빗대어 볼 수도 있습니다. 그래도 Go처럼, 블로킹처럼 보이는 코드가 사실은 전부 async인 전역 이벤트 루프 위의 async 태스크 풀로 보는 편이 똑같이 타당하고 어쩌면 조금 더 정확합니다. 이런 언어는 흔한 문제를 하나 공유합니다. FFI처럼 진짜 블로킹인 코드는 중단시킬 수 없기 때문에, 그런 호출 하나가 다른 프로세스를 굶기지 않도록 아주 조심해야 합니다. async 함수에 다른 색을 칠하는 언어에서는 이 점이 더 분명해집니다(하지만 해결되지는 않습니다). async라는 색이 그 함수가 좋은 이웃이어서 너무 오래(그게 얼마든) 블로킹하지 않겠다는 약속(안타깝게도 언어가 강제하지는 않습니다) 구실을 하기 때문입니다.
협력형에서 선점형으로, 다시 협력형 멀티태스킹으로 돌아왔다는 게 흥미롭습니다.
저도 블로킹 스레드가 대체로 더 자연스럽고 직관적이라는 데 동의합니다. 협력형 멀티태스킹은 늘 성능 때문에 했고, 최근에는 (락이 없는 단일 스레드 구현에서의) 단순함이나 확장성 때문에 하기도 합니다.
메시지 전달 방식 동시성은 한 걸음 더 나아간 것인데도 전체적으로 자리 잡은 적은 없는 듯합니다. 이것도 주된 이유는 성능입니다. 우리가 (네트워크를 건너거나 RAM이나 디스크에서) 메시지를 주고받을 때는 대개 캐싱과 다른 최적화를 얹게 되고, 그 때문에 동시성은 더 추론하기 어려워집니다.
HPC에서 메시지 전달(MPI 등)이 인기 있는 건 하드웨어가 동작하는 방식을 더 가깝게 모델링하기 때문입니다. 하지만 더 높은 수준에서는 대체로 그 위에 async/await 같은 추상화를 쌓는 쪽을 선호합니다. 메시지 전달은 흐름을 프로세스별 상태 기계로 직접 컴파일해야 하는데, 대부분의 사람은 흐름을 그렇게 생각하지 않기 때문입니다(비즈니스 쪽 사람들은 가끔 그렇게 생각하기도 하지만요. https://link.springer.com/chapter/10.1007/978-3-540-72863-4_36 ).
저도 이쪽엔 낯선 사람이 아닌데, 거기엔 미묘한 부분이 있어요. 아래에 쓴 제 다른 댓글은 어떻게 보시나요?
프로그래밍 모델과 구현은 1:1이 아닙니다. 이 스레드는 대부분 모델 이야기이니, 구현 이야기는 아래에서 하죠 :)
JVM의 가상 스레드에서 메시지를 전달하면 스레드를 수백만 개까지 돌릴 수 있고, async와 비교해 성능상 손해도 없습니다.
복사는 하나하나가 다 성능상 손해입니다. 공유 메모리를 꼭 써야 하는 애플리케이션도 많고요. 리눅스를 스레드 간 공유 메모리 없이 다시 쓸 수는 없습니다.
물론 경계는 모호합니다.
RAM 접근 하나하나가 사실은 버스를 통한 메시지고 캐시 접근도 마찬가지라고 말할 수도 있습니다. 반대로 메시지 전달 방식의 키-값 저장소를 짜 놓고 공유 메모리처럼 써서 경쟁 상태를 만들 수도 있죠.
트랜잭셔널 메모리도 '메시지 전달'과 비슷합니다. 공유 메모리 접근을 하나의 트랜잭션 '메시지'로 묶는 거니까요.
공유 메모리 코드 중에는 FIFO나 큐 같은 구조를 쓰는 경우가 많은데, 소유권이 공유되거나 오가는 경우를 빼면 메시지 전달과 크게 다르지 않습니다. 그냥 메시지 전달의 '구현'일 수도 있겠네요.
메시지 전달이 꼭 데이터를 순진하게 복사해서 돌리는 것만은 아닙니다. 공유 메모리에 접근할 수 있다면 포인터를 넘기면 됩니다. 실제로 '전달'해야 하는 것은 메시지 데이터에 접근할 수 있는 권한(capability)뿐입니다. 예를 들어 seL4가 이렇게 합니다.
물론 공유 메모리가 없으면 어차피 복사해야 하죠 :) 그리고 CPU 캐시 구조 때문에, 공유 메모리를 쓸 수 있더라도 데이터의 소유 사본을 갖는 이점이 복사 비용보다 클 때가 있습니다.
메시지 전달을 그렇게 넓게 정의하신다면 동의합니다. 다만 그 정의는 데드락처럼 공유 메모리의 단점까지 끌어들일 만큼 넓다고 생각합니다.
지금은 다른 맥락에서도 흔한 패턴을 이야기하고 있기도 한데, 그건 상관없습니다. 예를 들어 io_uring은 공유 메모리일까요, 비동기일까요, 메시지 전달일까요, 아니면 셋 다일까요(아마 셋 다겠죠)?
덕분에 'holistic'의 철자가 궁금해졌습니다. 이 단어는 분명 온전함(wholeness)이라는 개념에서 나왔으니까요. 알아보니 이렇습니다.
Holistic은 '전체'를 뜻하는 그리스어 어근 hol-에서 왔습니다. hologram과 같습니다.
생각보다 영어와 더 가깝습니다. 현대 영어 whole은 고대 영어 hal(그리스어 단어와 어원이 같습니다)에서 왔고, 이 단어에서 hale과 health도 나왔습니다. 현대 철자의 W는 어원과 무관하게 잘못 끼어든 글자입니다. 어원대로라면 "hole"이었을 겁니다.
Who는 다릅니다. 이 단어의 W는 어원상 맞습니다. (고대 영어 hwa.)
틀린 말입니다. 그 흐름은 블로킹 함수에서 얼마든지 호출할 수 있습니다. 루프에서 호출하면 됩니다.
블로킹 함수가 async 함수를 "그냥" 호출하지 못하게 막는 건 보통, async 함수에는 일부 연산에 런타임 지원이 필요하다는 점입니다. 가장 간단한 것이 알림이 올 때까지 중단하는 기능이고, 더 복잡한 것이 async I/O 프리미티브입니다.
Sans-IO 라이브러리는 이런 게 하나도 필요 없습니다. 루프에서 호출하기만 하면 되고, 호출자가 입력을 계속 주는 한 진행됩니다. 블로킹 호출자는 표준 라이브러리의
Read::read를 호출해 받은 바이트를 넘겨주면 되고, async 호출자는 async I/O 프리미티브로 똑같이 하면 됩니다. 그리고 Sans-IO 라이브러리는 현재 입력을 다 소비하면 제어를 돌려주기 때문에 async 라이브러리를 블로킹하지도 않습니다.양쪽의 장점만 취한 셈이지만, 늘 그렇듯 대가가 있습니다. 여기서는 상태 기계를 평범한 함수처럼 표현하는 능력이 그 대가입니다. Rust에 제네릭 코루틴이 들어오면 나아질지도 모르겠습니다.
물론 async 라이브러리도 마찬가지입니다. 그동안 다른 흐름이 하나도 진행되지 못해도 괜찮다면요. 그냥 루프에서 poll하면 되니까요.
'양쪽의 장점만 취했다'는 건 양쪽의 이점을 다 갖는다는 뜻인데, async/await의 주된(따지면 유일한) 이점은 시간상 떨어진 흐름을 순차적으로 쓸 수 있다는 점입니다. (아마 함수처럼 보인다는 점도 있겠고요.) 여기서는 그게 성립하지 않습니다.
오해 없게 말씀드리면, 저는 'sans-IO'(예전에는 순수 함수형 프로그래밍이라고 불렀죠)가 나쁘다고 주장하는 게 결코 아닙니다. 오히려 아주 찬성합니다. 제 말은 'sans-IO' 설계라고 해서 순수 함수로 쓸 수 있는 async/await를 마다할 필요는 없다는 것뿐입니다.
(옆 댓글이 지적하듯, async/await가 본질적으로 동시성이나 전역 이벤트 루프와 관련된 부수 효과를 끌어들이는 언어도 있습니다. 다행히 Rust는 그렇지 않습니다.)
일반적인 퓨처는 이런 식으로 호출하기가 훨씬 어렵습니다. 웨이커를 만들어야 하고, 제대로 처리하지 않으면 바쁜 루프(busy loop)에 빠집니다. 그 퓨처가 I/O에 필요로 하는 런타임도 정확히 제공해야 하고요.
그게 블로킹 코드가 늘 하는 일 아닌가요?
호출자 입장에서 보면 이렇습니다.
그러니 호출자에게는 분명 양쪽의 장점만 취한 셈입니다.
다만 Sans-IO 라이브러리를 작성하는 사람에게는 양쪽의 단점만 취한 셈이라는 건 맞습니다.
이걸 "순수"하고 "함수형"이라고 설명할 수는 분명 있겠지만, 순수 함수형 프로그래밍이라고 하면 대부분은 다른 패러다임을 떠올릴 거라고 장담합니다.
좋네요, 저도 Rust로 프로토콜 구현을 설계할 때 기본적으로 이렇게 합니다.
테스트하기가 훨씬 쉽고, 대부분을 async와 블로킹 IO가 같이 쓸 수 있고, 의존성에서 unsafe를 (std와 core만 빼고) 완전히 금지하기도 훨씬 쉽습니다.
Mullvad도 boringtun에서 갈라져 나와 자체 구현(gotatun[0])을 만들었어요.
[0] https://github.com/mullvad/gotatun
WireGuard 프로토콜의 근본적으로 훌륭한 점 하나는 단순함입니다. 작은 코드베이스로도 구현할 수 있고, 그 덕분에 멋진 라이브러리와 혁신이 나왔습니다.
또 하나, 미묘하지만 훌륭한 점은 단조 증가하는 시퀀스 번호입니다. DTLS처럼 1씩 증가하는 번호가 아니라는 점에 주목하세요. 이 작은 차이 덕분에 효율적인 동시성을 구현하기가 훨씬 쉽고, 심지어 외부 상태(즉 분산 동시성)까지도 다루기 쉽습니다.
그 후자는 저희가 자체 라이브러리에서 해냈습니다.
수정: 오타(don’t -> done)
저는 최근 Rust로 VPN 매니저 구현을 개발하고 있는데, 기반 아키텍처도 WireGuard를 씁니다. 지금은 Mullvad가 개발한 WireGuard 구현 gotatun을 쓰고 있고요.
성능 곡선이나 연결 지터 같은 관련 데이터, 그리고 보안 구현 같은 부분도 테스트해 보셨는지 궁금합니다.
Rust라고 하면 늘 바이브 코딩이라는 뜻인가요? 더 깊이 들여다보진 않았어요.
제가 마지막으로 본 의견은, 오류 메시지가 좋아서 LLM 코딩에 잘 맞는다는 거였어요.
제품 소개 웹사이트에 이런 글이 있습니다.
그 운영체제는 어디서 돌아가는 걸까요? 배터리가 아니면 그걸 돌릴 전력은 어디서 나오고요?
순 쓰레기 AI 슬롭입니다.
소프트웨어 자체는 뜯어 보면 괜찮을지도 모릅니다. 하지만 이런 AI 슬롭 같은 헛소리 문구를 보면, VPN이 보안에 얼마나 중요한지를 생각할 때 이 소프트웨어를 쓰고 싶은 마음이 들지 않습니다.
실제로는 그보다 더 생각을 담은 문장입니다.
인용한 부분은 가능한 곳에서 하드웨어 오프로딩을 한다는 걸 일반인도 알아듣게 설명하려던 겁니다. 암호화에는 벡터화 명령을 쓰고, 지원되는 환경에서는 체크섬 오프로딩과 패킷 분할을 커널이나 하드웨어 드라이버에서 처리한다는 뜻입니다.
패킷당 효율이 높아지면 같은 양의 데이터를 옮기는 데 전력을 덜 쓰고, 그래서 모바일 플랫폼에서는 전력 소모가 줄어듭니다.
그렇긴 해도 기술 용어를 잔뜩 늘어놓지 않으면서 더 잘 쓸 수 있었다는 말씀에는 동의합니다.