Zig을 네트워크 프로그래밍에 가장 좋아하는 언어로 만든 과정
(lalinsky.com)- 저자는 Zig 언어를 학습하며 AcoustID 인덱스 재작성 프로젝트를 진행하다가, 네트워크 프로그래밍의 한계를 계기로 새로운 접근을 시도함
- 기존 C++과 Go에서 사용하던 비동기 I/O와 동시성 모델을 Zig에서도 구현하기 위해 자체 라이브러리 개발을 결심함
- 그 결과, Go 스타일의 동시성 모델을 Zig에 맞게 구현한 Zio 라이브러리를 제작하여, 콜백 없이 동기식처럼 보이는 비동기 코드를 작성 가능하게 함
- Zio는 비동기 네트워크·파일 I/O, 채널, 동기화 원시, 신호 감시 등을 지원하며, 단일 스레드 모드에서 Go나 Rust의 Tokio보다 빠른 성능을 보임
- 이 프로젝트는 Zig의 시스템 수준 성능과 현대적 동시성 모델의 결합 가능성을 보여주며, Zig 생태계 확장의 중요한 전환점으로 평가됨
Zig 언어와 초기 동기
- 저자는 원래 오디오 소프트웨어용 저수준 언어로 설계된 Zig를 관찰해 왔으나, 실질적 필요성을 느끼지 못했음
- Zig 창시자 Andrew Kelley가 저자의 Chromaprint 알고리듬을 Zig로 재구현한 사례를 보고 관심을 갖게 됨
- AcoustID의 역인덱스 재작성 프로젝트를 Zig 학습 기회로 삼아 진행, 결과적으로 C++ 버전보다 더 빠르고 확장성 높은 구현을 달성함
- 그러나 서버 인터페이스 추가 단계에서 비동기 네트워킹 지원 부족 문제에 직면함
기존 접근 방식과 한계
- 이전 C++ 버전에서는 Qt 프레임워크를 사용해 비동기 I/O를 처리, 콜백 기반이지만 풍부한 지원 덕분에 사용 가능했음
- 이후 프로토타입에서는 Go 언어의 네트워킹과 동시성 편의성을 활용했으나, Zig에서는 유사한 수준의 추상화가 부재함
- Zig로 TCP 서버와 클러스터 계층을 구현하려면 스레드 다수를 생성해야 하는 비효율이 발생
- 이를 해결하기 위해 NATS 메시징 시스템용 Zig 클라이언트(nats.zig) 를 직접 작성하며 Zig의 네트워킹 기능을 심층적으로 탐구함
Zio 라이브러리의 등장
- 이러한 경험을 바탕으로 Zio: Zig용 비동기 I/O 및 동시성 라이브러리를 공개함
- Zio는 콜백 없는 비동기 코드 작성을 목표로 하며, 내부적으로는 비동기 I/O가 동작하지만 외부에서는 동기식처럼 보이는 구조를 가짐
-
Go 스타일의 동시성 모델을 Zig에 맞게 제한적으로 구현
- Zio의 태스크는 고정 크기 스택을 가진 스택풀 코루틴 형태
-
stream.read()호출 시 I/O 작업이 백그라운드에서 수행되고, 완료 시 태스크가 재개되어 결과 반환
- 이 방식은 상태 관리 단순화와 코드 가독성 향상을 동시에 제공
기능 구성과 런타임 구조
- Zio는 완전한 비동기 네트워크 및 파일 I/O, 동기화 원시(mutex, 조건 변수 등) , Go 스타일 채널, OS 신호 감시 등을 지원
- 태스크는 단일 스레드 또는 다중 스레드 모드에서 실행 가능
- 다중 스레드 모드에서는 태스크가 스레드 간 이동 가능, 지연 시간 감소와 부하 균형 향상 효과
- 표준 Reader/Writer 인터페이스를 구현해, 외부 라이브러리와의 호환성 확보
성능 및 비교
- 저자는 아직 공식 벤치마크를 공개하지 않았으나, 단일 스레드 모드에서 Go와 Rust의 Tokio보다 빠른 성능을 확인했다고 언급
- 컨텍스트 스위칭 비용이 함수 호출 수준으로 낮음, 사실상 무료에 가까운 전환 속도 제공
- 다중 스레드 모드는 아직 Go/Tokio만큼 견고하지 않지만, 비슷하거나 약간 더 빠른 성능을 보임
- 향후 공정성(fairness) 기능 추가 시 성능이 일부 감소할 가능성 있음
예시 코드와 활용
- 문서에는 Zio 기반 HTTP 서버 예시 코드가 포함되어 있음
-
zio.net.Stream을 이용해 연결을 수락하고, 각 연결을 별도 태스크로 처리 -
zio.Runtime이 태스크 실행과 I/O 스케줄링을 관리
-
- 이 구조는 비동기 I/O를 동기식 코드처럼 작성할 수 있게 하며, 명확한 흐름 제어와 자원 해제 관리를 가능하게 함
향후 계획과 의의
- 저자는 Zio를 통해 Zig가 단순한 고성능 시스템 코드용 언어를 넘어, 완전한 네트워크 애플리케이션 개발 언어로 발전할 수 있음을 확인
- 다음 단계로 NATS 클라이언트를 Zio 기반으로 재작성하고, Zio 기반 HTTP 클라이언트/서버 라이브러리 개발을 계획 중
- 이 프로젝트는 Zig 생태계의 네트워킹·동시성 인프라 확장을 주도하며, Go나 Rust에 비견되는 현대적 런타임 모델 구축 시도로 평가됨
Hacker News 의견
- 컨텍스트 스위칭이 함수 호출 수준으로 거의 무료라고 하지만, 실제로는 분기 예측기(branch predictor)가 깨지는 등의 미묘한 비용이 있음
Zig의 async 설계가 하드웨어 call/return 쌍을 사용하는지, 아니면 간접 점프 기반으로 번역되는지 확실하지 않음
완벽한 벤치마크를 하려면 두 작업 간 지속적인 스위칭이 있는 프로그램과 완전히 동기적인 프로그램의 총 실행 시간을 비교해야 함. 이건 꽤 까다로운 일임- 스택리스 코루틴에서 호출 스택의 맨 아래에서 두 작업을 계속 전환하고, 스택 전환 코드가 인라인되어 있다면 call/ret 불일치 패널티를 대부분 피할 수 있음
컴파일러를 제어할 수 있다면, I/O 코드의 call/ret을 명시적 점프로 바꾸는 것도 가능함
장기적으로는 CPU가 스택풀 코루틴을 더 잘 예측하도록 메타 예측기(meta-predictor) 를 도입하길 바람 - Zig에는 현재 async가 언어 차원에서 사라졌고, OP가 직접 유저 공간에서 태스크 스위칭을 구현한 것임
- 단순한 코루틴 간 ping-pong 테스트를 했을 때, 다른 솔루션과 비교해 믿기 어려운 수치를 얻은 적이 있음
- Zig에 곧 새로운 async가 추가될 예정이라, 본격적으로 파기 전에 기다리는 중임
관련 글: Zig new async I/O
- 스택리스 코루틴에서 호출 스택의 맨 아래에서 두 작업을 계속 전환하고, 스택 전환 코드가 인라인되어 있다면 call/ret 불일치 패널티를 대부분 피할 수 있음
-
Stackful 코루틴은 RAM이 충분할 때 의미가 있음
나는 Zig를 임베디드(ARM Cortex-M4, 256KB RAM) 환경에서 사용 중이며, C와의 인터롭에서 메모리 안전성을 확보하기 위해 씀
Rust처럼 색이 있는 async를 더 선호함. 동기 코드처럼 보이는 마법 같은 느낌이 좋지만, 큰 코드베이스에서는 어떤 함수가 blocking인지 구분하기 어려워지는 게 문제임- 사실 모든 동기 코드는 소프트웨어가 만든 환상(illusion) 임
CPU는 I/O에서 실제로 블록되지 않으며, OS 스레드 자체가 OS가 구현한 스택풀 코루틴임
언어 차원에서 이 환상을 더 효율적으로 구현할 수 있을 뿐, 본질은 동일함 - 새로운 Zig IO는 Rust보다 더 세련된 방식으로 색을 입힌(colored) 구조가 될 예정임
함수가 I/O를 수행하는지 여부에 따라 색이 결정되고, 호출 시점에서 async 여부를 명시함
Zig는 함수 호출 시 필요한 스택 크기를 계산하는 기능도 목표로 하고 있어, 스택풀 코루틴의 RAM 낭비 문제를 줄일 수 있을 것으로 기대함 - 그래서 Zig가 I/O를 명시적으로 표현하려는 이유가 바로, 어떤 함수가 blocking인지 추적할 수 있게 하려는 것임
- 사실 모든 동기 코드는 소프트웨어가 만든 환상(illusion) 임
- Zig를 지금 도입하기엔 시기상조라는 의견이 있음. I/O 모델이 크게 바뀌는 중이라 몇 년은 걸릴 거라는 인상임
- 나도 2020년에 비슷한 이유로 Zig를 떠났음.
하지만 프로젝트는 여전히 활발하고, 빠른 출시보다 올바른 설계를 우선시하는 점은 긍정적으로 봄
지금은 Go나 C를 쓰며 1.0을 기다리는 중임 - 몇 년은 금방 지나감. Zig는 이미 충분히 쓸 만한 언어임. 쓸 사람은 쓰고, 아닐 사람은 안 씀
- 실제로는 나쁜 시기가 맞음. 0.16에서 큰 I/O 변경이 예정되어 있고, 저자조차 최신 기능을 아직 사용하지 않음
나도 I/O 중심 작업을 위해 0.16을 기다릴 예정임 - 하지만 I/O 관련 작업이라면 Zig 0.15의 버퍼드 리더/라이터 인터페이스를 사용하면 큰 변화는 없을 것임
- 오히려 지금이 틀렸다고 봄. Zig는 언어 자체가 급변하는 게 아니라, 새로운 강력한 std.Io API를 추가하는 중임
기존 코드는 그대로 동작하고, 새 API는 더 인체공학적이고 성능이 좋음
나도 기존 프로젝트를 새 Reader/Writer API로 옮기며 코드가 훨씬 깔끔해졌음
- 나도 2020년에 비슷한 이유로 Zig를 떠났음.
- 콜백 기반 async가 표준이 된 이유가 여전히 의문임
libtask 같은 접근이 훨씬 깔끔해 보임
Rust도 콜백 기반 async를 채택했는데, 이유를 잘 모르겠음
참고: libtask- 스택리스 코루틴은 언어 내부에서 구현 가능하고, 기존 기능과의 예측 가능한 상호작용이 장점임
하지만 스택을 직접 다루면 예외 처리, GC, 디버거 등과 충돌할 수 있음
LLVM 수준에서 이런 변경을 병합하기도 어렵기 때문에, 언어 설계자 입장에서는 현실적인 제약이 많음 - Microsoft가 C++ 표준용으로 연구한 결과, 스택리스 코루틴이 메모리 오버헤드가 훨씬 적고, 실행기 설계의 자유도가 높다는 결론이 나왔음
- zio나 libtask 방식의 단점은 스택 크기를 직접 추정해야 한다는 점임
너무 작으면 오버플로, 너무 크면 메모리 낭비임
플랫폼마다 필요한 스택 크기가 달라서 이식성 문제도 있음
Zig 이슈 #157이 해결되면 이 접근이 더 나아질 것 같음 - libtask 같은 경우 스레드 스택 크기가 모호하고, 일반적인 async 상태보다 훨씬 큼
- Rust의 async는 콜백이 아니라 폴링(polling) 기반임
즉, async를 구현하는 세 가지 방식이 있음- 콜백 기반 (Node.js, Swift)
- 스택풀 기반 (Go, libtask)
- 폴링 기반 (Rust)
Rust는 정적 상태 머신으로 변환되어 런타임이 폴링함
스택풀은 메모리 낭비가 크고, 스택 크기 관리가 어려움
Rust는 이를 피하기 위해 스택리스 구조를 채택했고, Zig는 두 방식을 모두 선택할 수 있게 할 예정임
참고: zio coroutine 코드
- 스택리스 코루틴은 언어 내부에서 구현 가능하고, 기존 기능과의 예측 가능한 상호작용이 장점임
- TCP 읽기가 한 달 동안 블록될 수도 있는데, I/O 타임아웃 인터페이스는 어떻게 되는지 궁금함
- TCP 소켓에서
setsockopt로 읽기/쓰기 타임아웃을 설정할 수 있음
Zig는 POSIX API 레이어를 제공함
참고: setsockopt 문서 - 현재 Zig의 std.Io.Reader는 타임아웃을 인식하지 못함
Python의asyncio.timeout처럼 동작하는 구조를 구상 중임
예시 코드:var timeout: zio.Timeout = .init; defer timeout.cancel(rt); timeout.set(rt, 10); const n = try reader.interface.readVec(&data); - 대부분의 async 프레임워크가 타임아웃과 취소를 간과함
사실 그게 가장 어려운 부분임
- TCP 소켓에서
- Scala에는 이미 ZIO라는 동시성 라이브러리가 있음
참고: zio.dev - 최근 Rust의 Tokio에 감명받았는데, Zig에서도 Go 스타일 동시성을 GC 없이 구현할 수 있다면 꼭 써보고 싶음
- Go는 무한히 확장 가능한 스택 같은 트릭을 GC 덕분에 쓸 수 있음
하지만 Zig는 저수준 언어임에도 불구하고 고수준 API를 깔끔하게 표현할 수 있어서 인상적이었음
- Go는 무한히 확장 가능한 스택 같은 트릭을 GC 덕분에 쓸 수 있음
- Zig를 처음 알게 된 건 Bun 웹사이트였음. 요즘 정말 빠르게 발전 중임
- 예전 C++ 버전에서는 Qt로 비동기 I/O를 구현했는데, 이번엔 Go로 전환했음
Zig와 Go 모두에 Qt 바인딩이 새로 생겼음 - Scala에도 이미 유명한 프레임워크 ZIO가 있어서, 이름 짓기가 참 어렵다는 생각이 듦