요약
Rust를 주로 쓰던 개발자가 넉 달간 Zig를 다시 써 보고 주력 언어로 삼기로 했습니다.
물리학과 컴퓨팅을 공부하는 애나 리버티가 몇 년 만에 Zig로 돌아와 넉 달 동안 쓴 경험을 정리했습니다. 직접 만든 라이브러리와 Zig Day 시애틀 모임에 참가한 경험을 바탕으로, 이전 주력 언어였던 Rust와 견주어 장단점을 적었습니다.
왜 중요한가
- 적은 의존성과 유연한 빌드 시스템이 Rust 사용자를 Zig로 옮기게 하는 실제 이유가 될 수 있음을 보여 줍니다.
- 저자도 안정성이 필요한 작업에는 아직 Rust를 씁니다. 1.0 이전 Zig의 한계가 여전히 남아 있다는 뜻입니다.
핵심 내용
- 함수가 할당자(메모리를 나눠 주는 객체)와 IO를 인자로 받아, 할당과 운영체제 접근 여부가 코드에 드러나는 점을 가장 높이 샀습니다.
- 저자의 Zig 프로그램은 직접 의존성이 한두 개이고 간접 의존성은 없지만, 작은 Rust 프로젝트는 간접 의존성이 수십 개라고 비교했습니다.
- 증분 컴파일(바뀐 부분만 다시 빌드)은 10초 넘게 걸리던 Rust보다 빠르지만, 아직 x86_64 리눅스와 macOS에서만 됩니다.
- 잦은 호환성 깨짐, 예제가 적은 문서, 지역 변수 포인터 반환을 잡지 못하는 컴파일러를 아쉬운 점으로 꼽았습니다.
- 소스만으로 컴파일러를 빌드할 수 없어 Debian이 Zig를 패키징하지 못하고, 이 때문에 한 프로젝트는 다른 언어로 바꿨다고 했습니다.
HN 반응
- 할당자를 직접 넘기는 방식이 실익보다 취향이라는 비판에, 라이브러리가 할당을 호출자에게 맡겨 재사용성이 높아진다는 반론이 나왔습니다.
- 새 IO 인터페이스를 숨은 마법이라고 보는 쪽과, 동적 디스패치(실행 중 구현을 고르는 방식)로 된 평범한 라이브러리 코드라는 쪽이 맞섰습니다.
Zig에서는 왜 다들 할당자를 직접 제어할 수 있다는 점을 그렇게 좋아하나요? 저는 할당자를 이렇게 저수준에서 다룰 일이 거의 없어요. malloc/free를 쓰기 쉽게 감싼 인터페이스(RAII 같은 것)가 있거나, 불필요한 할당을 피하도록 언어가 이끌어 주는 쪽이 더 좋아요. C와 Zig를 쓰는 사람들은 늘 아레나 할당자가 대안보다 쓰기 쉽다고 말하는데, 저는 동의하지 않아요. 언어가 받쳐 주기만 하면 그 대안이 훨씬 쓰기 쉬우니까요.
아레나에는 함정이 많아요. 예를 들어 아레나를 지울 시점을 로직에 마련해 두지 않으면 끝없이 커지죠(가령 새 HTTP 연결마다 기본 아레나에서 새 할당을 받는다면, 그건 메모리 누수예요). 그리고 아레나가 꼭 malloc보다 빠른 것도 아니에요. 링커처럼 한 번 실행하고 끝나는 프로그램에서 공간을 회수하면 캐시 지역성이 좋아져서 mimalloc이 아레나보다 더 빨랐다는 사례를 들은 기억이 있어요. 게다가 아레나로는 공간 낭비 없이 늘어나는 벡터나 해시맵 같은 걸 구현하기도 어렵고요.
"할당자를 계속 만지작거리기"와 "아레나 쓰기"가 정말 좋은 기본값인지 저는 도무지 모르겠어요.
라이브러리 코드에서는 좋은 기본값입니다. 코드를 진짜로 재사용할 수 있게 해 주니까요. 훌륭한 C 라이브러리도 마찬가지여서, 내부에서 malloc을 부르는 대신 메모리 블록을 넘겨받게 합니다. 할당을 할지 말지, 언제 할지는 라이브러리가 정할 일이 아닙니다.
Zig 애플리케이션 코드에서는 원치 않으면 할당자를 사방에 넘기지 않아도 됩니다. 어차피 본인 코드니까 마음대로 하면 됩니다. 하지만 라이브러리라면 호출하는 쪽이 통제권을 쥐게 되니 그게 옳은 방식입니다.
Odin처럼 암묵적으로 처리하지 않고 할당자를 넘기게 하는 이유는, Zig가 암묵적인 로직에 대해 제가 써 본 어떤 언어보다 확고한 입장을 취하기 때문입니다. 그 결과 Zig를 좋아하는 사람들은 자기 코드에서도 할당자를 넘겨 다니는 걸 좋아하는 경향이 있습니다.
라이브러리 개발자는 어떤 할당자가 넘어올지 모르고, 라이브러리 사용자도 그 라이브러리가 할당자를 어떻게 쓸지 꼭 알지는 못합니다. 둘이 같은 인터페이스를 공유하니 [아마] 동작은 하겠지만, 메모리나 성능 면에서 어느 한쪽이 기대한 대로 동작하지 않을 수 있습니다. 특히 ArenaAllocator는 ArrayList나 중첩된 ArenaAllocator와 잘 어울리지 않습니다.
큰 문제라고 생각하지는 않지만, 명시적 할당자가 유연성을 더 주니까 더 낫다는 말보다는 좀 더 미묘한 문제입니다.
이건 Zig의 또 다른 가치관을 보여 주는 게 아닐까요?
Zig는 프로그램이 하는 모든 일을 유난히 엄격하게 통제하는 데 가치가 있는 시스템 프로그래밍 영역에 초점을 맞추고 있는 것 같습니다. 아두이노를 만지작거려 본 것 말고는 저는 그쪽 사람이 아니었습니다. 하지만 그랬다면 ArrayList를 제멋대로 키우려는 라이브러리는 아마 쓰고 싶지 않을 겁니다. 메모리 페이지(오버커밋이 아니라 실제 메모리)를 가능한 한 적은 곳, 이상적으로는 한 곳에서, 그것도 진입점에 아주 가까운 곳에서 확보하도록 프로그램을 구성하고 싶을 겁니다. 그런 방식에 맞춰 주지 않는 라이브러리는, 자체 메모리 관리 런타임까지 딸려 오는 라이브러리보다 딱히 더 반갑지 않을 겁니다.
그게 전혀 말이 안 되는 건 아니라고 생각합니다. 우리 대부분이 익숙한 것보다 제약이 심한 건 맞습니다. 하지만 항공우주 분야에서 흔히 그러듯 동적 힙 할당을 아예 금지하는 것보다는 훨씬 덜 제한적이기도 합니다.
저도 그게 Zig의 방식이라고 믿었는데, 그러다 색깔 없는 자동 "async/IO" 함수가 나왔죠. 이건 암묵적이고 숨겨진 로직 덩어리가 엄청나요.
제가 아는 한 JavaScript의 async보다도 암묵적인 로직이 더 많아요. 암묵적 캐스팅 규칙이 있는 임의 크기 정수 같은 것도 그렇고요. 더 최근에는 (선택 사항인) 암묵적 GC 모드 같은 걸로 메모리 안전성을 확보하려는 시도도 있었고요. 저는 개인적으로 이해가 안 돼요.
정말 최신 내용을 알고 계신 거 맞나요?
async 설계는 여러 번 바뀌었고, 지금 방식은 할당자를 넘기는 것과 똑같이 로직을 캡슐화한 I/O 인터페이스를 넘기게 하는 겁니다.
그러면 사실상 I/O에 묶인 모든 코드가 "준(準) async"가 되고, 전부 명시적이며 호출하는 쪽이 통제권을 쥡니다.
제가 이해하기로는, IO 인터페이스가 어떤 버전의 함수가 호출될지에 영향을 준다는 점에서 "마법" 같다는 겁니다. 동기식의 선형 버전이 호출되거나, 비동기식의 스택 없는 일시 중단 가능한 상태 기계 버전이 호출됩니다.
이게 특히 함수 포인터나 다른 형태의 동적 디스패치에 미치는 영향이 큽니다.
개인적으로는 async 코드가 동기 코드와 근본적으로 다르다고 봅니다. 여러 트레이드오프의 비중이 극적으로 바뀌니까요. "색깔 없는" 함수는 애초에 추구할 만한 목표가 아니라고 생각합니다. 하지만 제가 틀린 적도 있으니, 결과적으로 훌륭할 수도 있겠죠. 지켜봅시다.
I/O 인터페이스에 마법은 없습니다. C++에서 가상 함수를 쓰는 것과 똑같은 평범한 동적 디스패치입니다. 현재와 가까운 미래에는 실행 방식이 두 가지뿐인데, 각 작업이 전용 스레드를 차지하는 블로킹 모드, 아니면 스택이 있는 코루틴(stackful coroutine)입니다. 인터페이스는 블로킹 함수를 구현하며, 그 안에 비동기 대기는 어디에도 없습니다. 다만 동적 디스패치이기 때문에, 스레드 구현은 블로킹 시스템 호출을 그냥 직접 실행하는 반면 비동기 구현은 "작업 준비, 완료될 때까지 중단, 깨어남"이라는 춤을 출 수 있습니다. 스택 없는 코루틴을 추가할 계획이 있지만, 그러려면 먼저 언어에 많은 제약이 생겨야 하므로 당분간은 일어나지 않을 것이고, 일어나더라도 인터페이스가 그에 맞게 바뀔 가능성이 높습니다.
네, 그러니까 숨겨진 마법에 암묵적인 로직이 한가득이라는 얘기네요. ¯_(ツ)_/¯
전부 언어의 나머지 부분과 같은 규칙을 따르는 명시적인 사용자 영역 코드로 구현되어 있어요. 암묵적인 로직은 말 그대로 하나도 없어요.
"구현을 들여다보면 사실 명시적이다"라는 말은 모든 것에 해당하는 얘기예요.
구현을 들여다보면, 레지스터를 자기 자신과 XOR해서 0으로 만드는 고전적인 요령이 현대 CPU에서는 레지스터 리네이밍 구현 방식 덕분에 ALU를 쓰지 않고 사실상 공짜라는 걸 알 수 있죠. 그렇다고 "CPU 제조사의 기술 매뉴얼을 살 수 있다"는 이유로 이게 명시적인 게 된다고 우기진 않았으면 좋겠네요.
대부분의 언어에서 async는 사용자 영역 코드가 아니라 컴파일러의 특수 기능으로 구현됩니다. async/await를 쓰는 언어에서는 이 키워드의 정의로 바로 이동(jump-to-definition)할 수 없습니다. Zig에서 async/await는 키워드가 아니라 평범한 함수 호출입니다. 라이브러리 코드로 이동하는 것과 컴파일러 내부를 파고드는 것 사이에는 분명 질적으로 다른 수준의 통찰이 있습니다.
핵심은 이게 언어의 마법이 아니라 라이브러리의 마법이라는 점입니다. 다르게 동작하길 원하면 직접 IO 구현을 작성하면 됩니다.
그렇게 말할 수도 있겠지만, 모든 동작은 문서화되어 있습니다. 블로킹될 거라고 가정해야 하고요. 블로킹이 사용자 영역 런타임에서 일어나는지 커널에서 일어나는지는 중요하지 않습니다. 그리고 어떤 런타임을 쓸지 고르는 건 애플리케이션 작성자인 당신입니다. 이건 그저 라이브러리가 범용적일 수 있게 해 줄 뿐입니다.
읽지 않는 사람에게만 숨겨져 있죠.
그건 단일 스레드, 멀티 스레드, 비동기 실행 사이에 본질적인 차이가 없다고 가정하는 것 아닌가요? 단일 스레드에서는 잘 돌다가 멀티 스레드나 async로 돌리면 깨지는 코드가 많고, 멀티 스레드에서는 잘 되다가 async에서 깨지는 코드도 있습니다. 명시적 async에서는 이벤트 루프로 제어를 돌려주는 지점을 정밀하게 제어할 수 있습니다. Zig의 Io에서는 일부러 그렇게 설계하지 않는 한 그럴 수 없고, 그렇게 하면 결국 암묵적으로 색깔이 붙은 함수가 될 뿐입니다.
단일 스레드와 멀티 스레드는 차이가 있지만,
io.concurrent나io.async를 쓴다면 코드가 멀티 스레드 환경을 가정하도록 되어 있습니다. 그리고 자체적으로 I/O를 하는 외부 라이브러리를 호출하는 경우가 아니라면, 블로킹 백엔드와 논블로킹 백엔드 사이에 눈에 보이는 동작 변화는 없습니다. 그래서 블로킹 스레드 I/O에서 작성하고 테스트한 코드는 async 구현에서도 잘 동작합니다. 모든 I/O 연산이 양보 지점입니다.아레나가 마음에 안 들면 오히려 할당을 피하는 쪽으로 가게 될걸요 :)
저는 malloc / free가 별로예요. 할당과 해제를 일대일로 관리하는 건 악몽이거든요. 사실 HTTP 서버는 아레나가 딱 맞는 좋은 예예요. 연결마다 아레나를 하나 만들고 연결이 닫힐 때 그 아레나를 해제하면, 그 연결 동안의 모든 할당이 반드시 해제되니까 하나하나 추적할 필요가 없어요. 연결을 넘어 살아 있어야 하는 게 있으면요? 그런 것들에만 범용 할당자를 쓰면 돼요.
한 번 실행하고 끝나는 프로그램이면 뭐하러 굳이 뭘 해제하나요? gpa든 malloc이든 뭐든 거기선 충분해요.
저는 개인적으로 대부분 고정 크기 버퍼에 의존해요. Zig로 쓰는 건 버퍼가 다 차면 터져도 괜찮은 작은 장난감 프로그램뿐이긴 하지만요. 하지만 장난감이 아닌 큰 프로그램에서도 고정 크기 버퍼를 쓰고, 한계를 도메인에서 실제로 의미 있는 기준으로 정의하는 게 좋을 수 있어요.
RAII나 스마트 포인터도 괜찮지만, 그런 걸 원하면 C++가 이미 갖추고 있어요. Zig는 소멸자가 가져오는 그런 보이지 않는 코드를 일부러 피하고 있고요.
그럼 아레나 크기는 어떻게 정하나요?
그건 문제를 과장한 거라고 생각해요. C++의 RAII 시맨틱이 바로 그걸 위한 거잖아요.
보이지 않는 코드를 초기화 직후에 오는 defer 문과 맞바꾼 셈인데, 그건 뻔히 보이니까 대부분 잡음일 뿐이고, 호출을 잊으면 문제가 되기도 해요. RAII에선 그런 문제가 없어요. 보이지 않는 코드를 원하지 않는 마음은 이해하지만, defer도 크게 나아진 것 같지는 않아요. 잘 봐줘야 트레이드오프죠.
방법은 두 가지예요. "음, 요청당 20MB면 충분하겠지" 하고 정했다가 필요하면 나중에 크기를 바꾸거나, 아니면 다루는 도메인 기준으로 합리적인 한계를 찾아내서 그 한계를 넘으면 설명이 담긴 에러를 돌려주는 거죠. 이 서비스는 HTTP 헤더 N개, URL 쿼리 매개변수 M개까지 지원한다는 식으로요. 거의 모든 것에는 합리적으로 걸 수 있는 한계가 있어요. 한계가 아예 없다는 건 메모리나 디스크 같은 게 바닥날 때가 곧 한계라는 뜻일 뿐이에요.
RAII와 C++가 좋으면 그냥 그걸 쓰세요. 제 생각에 Zig는 대안이지 업그레이드가 아니에요.
"메모리는 싸다" 뭐 그런 거죠? :-)
아레나가 실제로 쓰일 때까지는 실제 메모리를 전혀 쓰지 않게, 즉 가상 메모리 시스템이 쓰기가 일어날 때만 페이지를 할당하게 만들 수 있나요?
네! C에서는 큰 익명 영역을 mmap으로 잡아 두면, 실제로 쓰는 페이지만 상주 공간을 차지해요. Zig가 표준 라이브러리 어딘가에서 이걸 제공하는지, 아니면 libc를 통해 해야 하는지는 잘 모르겠네요.
요청별이나 프레임별 아레나 같은 걸 쓴다면 전체 매핑을 다시 할당하지 않고도 아레나를 비울 수 있어요. 그리고 얼마나 정교하게 하고 싶은지에 따라, 아레나를 비울 때 페이지를 미사용으로 표시해서 필요하면 OS가 회수할 수 있게 할 수도 있고요.
HTTP 서버는 전부 최대 요청 크기를 설정할 수 있어요. 예를 들면 Nginx의 client_max_body_size가 있죠.
HTTP 서버가 요청 크기를 무제한으로 할당하게 둬도 될까요? 서비스 거부 공격이 너무 쉬워질 것 같은데요.
제가 말한 '밀어준다'는 성공의 구덩이(pit-of-success)라는 의미이지, 대안이 불쾌해서 어쩔 수 없이 뭔가를 하게 만든다는 뜻이 아니에요.
언어가 도와주는 도구가 있다면 전혀 악몽이 아니에요. 저는 Rust에서 최적화할 여지를 생각할 때 말고는 해제를 딱히 신경 쓰지 않아요.
글쎄요, 이건 아주 신중하게 판단해야 하는 일이고, 잘못 판단하면(제가 설명하려던 상황처럼) 미묘한 버그가 생겨요.
malloc을 써도 괜찮긴 하죠. 장난감 프로그램이라면 성능도 충분히 합리적일 거예요.
이런 걸 보면 Zig 프로그래머들은 실제로 동작하는 소프트웨어를 쓸 때의 현실적인 고민과는 별 상관없이, 자기 할당을 직접 관리한다는 발상 자체에 일종의 미적 애착을 갖고 있다는 제 생각이 더 굳어져요. 라이브러리 코드가 그 라이브러리에 기대는 사람들에게 유연성을 높여 준다고 말한 다른 답글에는 훨씬 공감이 가요. 모든 게 완벽하게 흘러가는 경우만 생각하면서 아무 이유 없이 고정 크기 버퍼를 쓰는 이 댓글보다는요.
좋든 싫든 Zig의 입장은 신경 써야 한다는 겁니다. Rust에서 Vec<String>을 drop하는 건 O(n)입니다. 앱의 X를 눌렀더니 사라지는 데 10초가 걸린 적 있으신가요? 이런 것도 문제의 일부입니다. exit(0)을 부르려고 프로세스의 전체 메모리 공간을 순회해야 해서는 안 됩니다.
그 for 루프를 직접 써야 한다면 O(n)이 눈에 확 띄어서 아레나나 FixedBufferAllocator, std.process.cleanExit 같은, 그 루프를 피할 수 있는 다른 해법으로 가게 됩니다.
측정, 측정, 측정하세요.
자기가 싫어하는 것을 두고 "문제의 일부"라고 우기는 사람들은 그 "일부"가 얼마나 되는지 생각해 보지도 않은 경우가 너무 많아요. 하긴 생각해 보고도 그냥 밀어붙이는 쪽이 더 나쁘겠죠. 얼버무리는 말장난이 머리 회전 느린 사람들을 설득할지도 모르니까요.
보조금 서류에서 14.86달러를 15달러로 반올림해서 정부 프로그램에서 받을 자격이 없는 14센트를 챙긴 사람이나, 켄 팩스턴이 정부 공직자 월급으로는 설명이 안 되게 엄청난 부자가 된 일이나 둘 다 "문제의 일부"이긴 하지만, 둘 중 어느 쪽을 고칠 가치가 있는지는 분명하길 바라요.
참고로 Rust의
std::process::exit(...)는 소멸자를 하나도 호출하지 않습니다. C++에도std::quick_exit(...)가 있고요. 애플리케이션이 느리게 종료되는 건 앱 개발자가 만든 문제입니다.(프로세스를 정리하는 동안 OS가 열려 있는 파일을 전부 flush해야 해서 시간이 걸리는 경우도 있지만, 보통은 사용자 눈에 띌 정도는 아닙니다.)
실제 Rust 코드에서 문자열이 든 vec을 drop한다고 느려지지는 않습니다. 그런 구조를 drop하는 데 체감될 만큼 시간이 걸리려면 수십억 개의 문자열이 든, 몇 기가바이트를 차지하는 vec이 필요합니다. 그리고 그런 극단적인 경우에는 그냥 아레나를 쓰면 됩니다. bumpalo 크레이트는 Zig와 거의 같은 나이입니다. Rust에서 아레나를 쓰는 경우가 드문 이유는 아레나를 못 써서가 아니라(늘 쓸 수 있었습니다), 아레나는 특정한 메모리 접근 패턴을 위한 아주 구체적인 해법이고, 그 패턴을 엄격히 따르려고 일부러 애써도 대부분의 소프트웨어에서는 측정 가능한 차이가 나지 않기 때문입니다.