지그(Zig)의 새로운 비동기 I/O
(kristoff.it)- Zig의 새로운 비동기 I/O 인터페이스 도입으로, I/O 구현 방식을 호출자가 직접 선택 및 주입 가능함
- 새롭게 설계된 Io 인터페이스는 동시에 비동기성과 병렬성을 지원하며, 코드 재사용성과 최적화에 집중함
- Blocking I/O, 이벤트 루프, 스레드 풀, 그린 스레드, 스택리스 코루틴 등 다양한 표준 라이브러리 구현체를 제공할 예정임
- 새로운 API를 통해 미래 취소 및 리소스 관리, 버퍼링 및 세분화된 입출력 동작 가능함
- 기존의 함수 컬러링 문제를 해결하여, 하나의 라이브러리로 동기/비동기 운영 모두 최적화 가능하게 됨
개요
Zig는 최근 새로운 비동기 I/O 인터페이스를 설계함으로써, I/O 작업의 유연성 및 병렬성 지원에 중점을 두는 방향으로 발전하고 있음. 이번 변화는 기존 async/await 패러다임을 분리하여, 실제 프로그램 작성자가 더욱 다양한 I/O 전략을 채택할 수 있도록 설계되었음.
새로운 I/O 인터페이스
이전에는 I/O 관련 객체들을 코드 내에서 직접 생성 및 사용하였으나, 이제는 Io 인터페이스를 호출자가 주입하도록 변경됨.
- 이 방식은 Allocator 패턴과 유사하게, 호출 측에서 I/O 구체 구현을 선택 및 주입함
- 외부 패키지 코드에도 일관된 방식으로 I/O 전략을 적용할 수 있음
주요 변화
- Io 인터페이스는 이제 동시성(concurrency) 연산도 담당함
- 코드가 동시성을 올바르게 표현할 경우, Io의 구현체에 따라 병렬성(parallelism) 제공 가능
예제 코드
- 동시성이 없는 (직렬) 코드와, io.async 및 await로 병렬 가능성이 표현된 코드 두 가지를 비교함
- 직렬 코드: 두 파일에 차례로 저장, 병렬성 기회 활용 불가
- 병렬 코드: futures를 활용한 파일 저장, 비동기 이벤트루프에서 더 효율적으로 동작
await와 try의 조합
- await와 try를 함께 사용하면, 하나의 future에서 에러 발생 시 다른 future의 리소스를 반납하지 못하는 문제 존재
- defer 및 future.cancel로 적절히 취소 및 정리를 명확히 할 수 있음
Future.cancel API
- Future.cancel()과 Future.await()는 idempotent(여러번 호출해도 부작용 없음)함
- 이미 완료된 future에 cancel을 호출하면 자원만 해제되고, 완료되지 않은 작업은 error.Canceled 반환
표준 라이브러리 I/O 구현
Io 인터페이스는 런타임 다형성 기반 인터페이스로, 직접 구현하거나 서드파티 패키지의 구현을 사용할 수 있음. Zig의 표준 라이브러리는 다양한 유형의 I/O 구현을 제공할 계획임.
- Blocking I/O: 단순히 기존 C 스타일 blocking 입출력을 사용, 추가 오버헤드 없음
- 스레드 풀: Blocking I/O들을 OS 스레드 풀에 분산, 병렬성 일부 도입. 네트워크 클라이언트 등에서는 최적화가 필요
- 그린 스레드: Linux의 io_uring 등 비동기 시스템 콜을 활용, OS 스레드에서 여러 그린(경량) 쓰레드를 처리. 플랫폼 지원 필요(x86_64 Linux 우선)
- 스택리스 코루틴: 명시적 스택 필요 없는 상태 머신 기반의 코루틴. WASM 등 일부 플랫폼 호환 목적. Zig 컴파일러의 프로퍼티브 컨벤션 재도입 필요
설계 목표
코드 재사용성
비동기 I/O의 가장 큰 이슈는 코드 재사용성이며, 타 언어에서는 blocking/async 함수가 별도로 존재하여 코드가 분리되는 문제가 있음. Zig의 방식은
- 하나의 라이브러리가 동기 및 비동기 모드를 모두 효과적으로 지원
- async/await가 ‘함수 컬러링’ 현상을 제거하고, Io 시스템을 통해 런타임에서도 다양한 실행모델에 종속적이지 않음
결론적으로 함수 컬러링 문제를 완전히 해결
최적화
- 새로운 Io 인터페이스는 비제네릭, vtable 기반 가상 호출 방식으로 구현됨
- 가상 호출은 코드 팽창을 줄이지만, 실행 시 약간의 오버헤드가 있음. 최적화 빌드에서는 단일 Io 구현이면 de-virtualization(가상 호출 제거) 가능
- 여러 Io 구현 사용시엔 가상 호출 유지(코드 중복 방지 목적)
버퍼링 전략
- 기존에는 각 구현체(reader/writer)가 버퍼링을 담당했으나, 이제는 Reader와 Writer 인터페이스 레벨에서 버퍼링을 수행
- 버퍼 flush 외에는 가상 호출 경로를 거치지 않아 최적화 용이
의미론적 I/O 연산
Writer 인터페이스는 특정 최적화 연산을 위한 두 가지 새로운 프리미티브 제공
- sendFile: POSIX sendfile에서 영감을 받아, 파일 디스크립터 간 데이터 이동을 커널 내에서 처리. 메모리 복사 최소화
- drain: Vectorized write + splatting 지원. 여러 데이터 세그먼트 일괄 전송, writev 시스템 콜로 변환 가능. splat 파라미터로 마지막 요소 반복 활용 가능(압축 등 스트림에서 활용)
로드맵
이 변화의 일부는 Zig 0.15.0부터 도입되나, 라이브러리 대대적 개편이 필요하여 전체 도입은 차기 릴리즈를 기다려야 함. SSL/TLS, HTTP server/client 등 주요 모듈도 새 Io 시스템으로 재설계 예정
FAQ
Q: Zig는 로우레벨 언어인데 왜 async가 중요한가?
- Zig는 견고함, 최적화, 재사용성을 지향
- Non-blocking 입출력을 표준화함으로써, 타 라이브러리·서드파티 코드도 전체 I/O 전략에 맞게 조정 및 재사용성 확보
Q: 패키지 저자들이 이제 async를 모든 코드에 활용해야 하나?
- 아님. 모든 코드가 동시성을 표현할 필요 없음
- 일반적인 순차적 코드도 사용자가 선택한 I/O 전략에 맞게 동작함
Q: 어떤 실행 모델이든 플러그인만 하면 무조건 정상 동작하나?
- 대부분은 네
- 단, 코드상의 프로그래밍 오류(예: 동시작업 요건 충족 안 함)는 정상 동작 불가
실행 예시와 함께, 비동기성과 병렬성의 차이, 올바른 동작 흐름 설계 필요성 언급
결론
Zig는 새로운 Io 인터페이스 도입으로 입출력 전략의 선택 유연성, 코드 재사용성, 최적화 가능성을 크게 높였음. 이로써 비동기/동기 기반의 함수 작성 제약 없이, 개발자는 동시성·병렬성 구조를 더 명확하게 표현하고 각종 플랫폼·실행 모델에도 효과적으로 대응할 수 있게 됨.
Hacker News 의견
-
나는 이 점을 다시 지적하고 싶음. 기사에서 Zig가 function coloring 문제를 완전히 해결했다고까지 언급하지만, 나는 동의하지 않음. 유명한 "What color is your function?" 글의 5가지 규칙을 다시 생각해보면, Zig에서는 async/sync/red/blue처럼 색상이 구분되지 않는다 해도 결국 IO 함수와 비IO 함수 두 가지 케이스만 존재함. 함수 호출 방식도 색상에 따라 달라지는 문제를 기술적으로 해결했다지만, 여전히 IO가 필요한 함수에는 IO를 인자로 넘겨줘야 하고, 필요 없는 함수는 받지 않음. 결국 본질은 변하지 않은 느낌임. IO 함수는 IO 함수에서만 호출 가능하고, 이 또한 coloring 문제에서 벗어나지 못함. 물론 새로운 executor를 전달할 수도 있지만, 그게 진짜 바라는 것인지는 의문임. Rust에서도 비슷하게 할 수 있음. 색깔이 있는 함수 콜이 번거롭다는 점도 마찬가지임. 몇몇 핵심 라이브러리 함수가 colored라는 부분은 Zig/Rust 모두 해당되지 않음. Coloring 문제의 본질은 컨텍스트(즉, async executor나 auth, allocator 등)를 필요로 하는 함수가, 호출할 때 반드시 그 컨텍스트를 제공해야 한다는 데 있음. Zig가 진짜 이 부분을 해결했다고 보긴 어려움. 다만, Zig의 추상화는 굉장히 잘 돼 있고 Rust는 이 부분이 모자란 면이 있음. 하지만 function coloring 문제 자체는 여전히 남아있음
-
전형적인 async function coloring과의 핵심 차이는 Zig의 'Io'가 단순히 비동기 처리를 위한 특수한 값이 아니라, 파일 읽기, 슬립, 시간 받아오기 등 모든 IO를 위해 필연적으로 필요한 값임. 'Io'는 함수의 속성이 아니라 어디든 둘 수 있는 일반 값임. 실제로는, 이런 특징 덕분에 coloring 문제는 해결된 것처럼 보임. 대부분의 코드베이스에서 IO가 이미 스코프 어딘가에 있어서, 정말 순수 계산 함수만 IO가 필요 없게 됨. 만약 어떤 함수가 갑자기 IO가 필요해진다면, 대부분의 경우 'my_thing.io'에서 바로 가져와 쓸 수 있음. Rust처럼 모든 함수에 Allocator를 넘길 필요가 없어서 번거로움이 없음. 즉, 코드 경로가 바뀌어서 IO를 해야 한다면 굳이 함수마다 변경을 퍼트릴 필요 없이 바로 사용 가능함. 원론적으로는 function coloring이 남아 있다는 데 동의하지만, 사실상 모든 함수가 async-colored가 된 셈이기에 실질적인 문제는 거의 없음. 실제로 Zig 개발자들은 Allocator를 명시적으로 넘기는 것이 function coloring 번거로움을 유발하지 않는다고 여김. 'Io'도 마찬가지로 문제가 크지 않을 거라 생각함
-
중요한 핵심을 언급하지 않은 것 같음. Rust 라이브러리를 쓸 때는 반드시 async/await, tokio, send+sync 같은 조건을 맞춰야 하고, sync API면 async 앱에서는 무용지물이 되는 게 현실임. 반면, Zig의 IO 전달 방식은 이 문제를 근본적으로 해결함. 덕분에 고생하며 procedural macro나 멀티버전을 억지로 구현하지 않아도 되며, 사실상 이런 방식 자체가 라이브러리 멀티버전 문제를 결국 잘 해결하지도 못함. Rust에서 async/sync 혼용 문제에 관한 다양한 논의가 있는데, 다음 링크에도 설명이 있음 https://nullderef.com/blog/rust-async-sync/. 앞으로 Zig가 cooperative scheduling, 고성능 async, 스레드-퍼-코어 async 같은 부분까지 잘 풀 수 있길 바람
-
나는 범주론 전문가는 아니지만, 결국 이런 컨텍스트 관리의 길을 걷다 보면 IO 모나드에 도달하게 됨. 이 맥락(Context)은 암시적으로 있을 수도 있지만, 컴파일러의 도움을 제대로 받으려면 시스템 내에서 실체로 드러내야 함. 그리고 시스템 프로그래밍 언어들의 야망이 다 Async나 코루틴 무덤에 묻혀왔지만, Andrew가 IO 모나드를 나름 다시 발견해서 제대로 구현한 점은 세대의 희망임. 실제 세계 함수에는 색깔이 존재함. 명확한 이동 규칙을 두거나, 아니면 C++의 co_await, tokio처럼 점점 복잡해지는 길로 빠질 수밖에 없음. 이게 바로 ‘The Way’라고 생각함
-
모든 함수를 빨갛게(혹은 파랗게) 만드는 간단한 트릭이 있음
var io: std.Io = undefined; pub fn main() !void { var impl = ...; io = impl.io(); }
io를 글로벌 변수로 두고 쓰면 coloring 걱정할 필요 없어짐. 농담이지만, 확실히 'Io' 인터페이스를 써야 한다는 점에서 마찰이 조금은 있지만, 이건 async/await를 쓸 때 발생하는 실질적인 friction과는 본질적으로 다른 문제임. 내가 보기에 function coloring 문제의 핵심은, 코드 재사용이 불가능해지는 async 키워드의 정적인 색깔 부여 때문임. Zig에서는 어떤 함수를 async로 만들거나 아니거나 모두 IO를 인자로 받기 때문에 그 관점에서는 coloring 자체가 무의미함. 두 번째로, async/await를 쓰면 스택 없는 코루틴(즉, 컴파일러에서 컨트롤되는 스택 전환)을 강제로 쓰게 되지만, Zig의 새로운 IO 시스템은 내부적으로 async를 써도 Blocking IO로 동작하게 할 수 있음. 이런 점이 실질적인 function coloring 문제라고 생각함
-
Go도 “미묘한 coloring” 문제를 겪음. goroutine을 사용할 때는 항상 context 인자를 넘겨주면서 취소를 처리해야 하고, 많은 라이브러리 함수들도 context를 요구하기 때문에 전체 코드가 오염됨. 기술적으로는 context를 안 써도 되지만, context.Background로 무작위로 넘기는 건 권장되지 않는 방법임
-
-
sans-io라는 개념은 Rust 등에서 이미 논의된 바 있는데, 참고 링크는 https://www.firezone.dev/blog/sans-io, https://sans-io.readthedocs.io/, https://news.ycombinator.com/item?id=40872020임
- 함수가 IO 메서드를 직접 호출하면 외부에서 IO를 분리할 수 없는 구조라 sans-io라고 부르기 어렵다는 생각임. 링크에 나온 대로, 바이트 스트림 기반 프로토콜에서는 구현부가 입력/출력 버퍼만 다루고, 네트워크에서 데이터를 받는 부분은 반드시 호출 쪽이 직접 전달해야 진정한 sans-io임. 출력 역시 버퍼에만 쓰거나, 이벤트가 발생할 때 바이트 스트림을 즉시 반환하는 방식이 있음. 반환 방식은 구현 선택이지만, 내부 버퍼는 자동 응답이 필요한 상황에 유용함. 핵심은 IO를 직접 하지 않는 구조임
-
나는 function coloring의 문제점이, 스택에서 처리하건 스택을 unwind하건 결국 둘 중 하나가 남는다는 데 있다고 생각함. Zig가 coloring 문제 해결을 주장하지만, IO 구현 방식으로 여전히 blocking/thread pool/green thread를 사용할 수 있게 해줌. 근데 이런 blocking IO는 애초에 문제가 아니었음. 글로벌 상태를 안 쓰는 관례를 지키면 거의 모든 언어에서 이 정도는 가능함. stackless coroutine은 아직 미구현인데, ‘나머지 부품만 그리면 완성’ 같은 느낌임. 만약 진짜 보편적 함수 호출을 원한다면, 두 가지 방법이 있다고 생각함
-
모든 함수를 async로 만들되, 인자 하나로 동기로 실행할지 여부를 넣어 처리하게 함(성능 저하 있음)
-
각 함수를 두 번 컴파일해서 상황에 맞게 골라 호출하게 함(코드 크기 증가와 함수 포인터 처리의 어려움 있음)
-
핵심팀은 아니지만, 사용자와 실사용자들이 semiblocking 구현을 충분히 써보고 API를 안정화한 뒤, 바로 그 해법(스택 점프 기반의 진짜 코루틴 삽입)을 적용할 계획이라고 들음. 현재 LLVM의 코루틴 상태머신 컴파일러는 libc나 malloc에 의존하는 문제가 있음. Zig의 새 io 인터페이스가 userland async/await를 지원하기 때문에, 향후에 제대로 된 frame jumping 솔루션이 들어와도 이식이 쉽고 디버깅도 편리함. 코루틴이 어려우면 io API도 소폭 수정으로 버틸 수 있게 해두고, stackless coroutine부터 너무 서두르진 않을 생각임
-
C#/.NET의 ValueTask<T>도 비슷한 역할을 함. 동기로 끝나면 오버헤드가 없고, 필요할 때만 Task<T>로 사용할 수 있음. 코드는 보통 await만 해두면 되고, 실행 시점에 런타임이나 컴파일러가 알아서 동기/비동기 선택함
-
-
-
Zig를 좋아하지만 green thread(파일버, stackful coroutine)에 집중하는 걸 보니 아쉬운 마음임. Rust도 1.0 이전에 비슷한 Runtime trait를 퍼포먼스 문제로 폐기함. 실제로 OS와 언어, 라이브러리들이 이런 접근의 폐해를 여러 번 배웠고, 관련 자료도 있음 https://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf. 파일버가 90년대에는 확장성 있는 동시성 처리로 각광받았지만, 현대에는 stackless coroutine, OS/하드웨어 발전 등으로 권장되지 않음. 만약 계속 이대로 간다면 Zig는 Go랑 비슷한 성능에서 한계에 부딪히고, 진정한 퍼포먼스 경쟁자로는 어렵게 됨. std.fs는 퍼포먼스가 필요한 케이스에서 남아있길 바람
-
우리가 green thread(파일버)에 ‘올인’한다는 인상은 오해임. OP 참고 기사에서 stackless coroutine 기반 구현을 기대한다는 점을 명시적으로 언급했고, 관련 제안도 있음 https://github.com/ziglang/zig/issues/23446. 퍼포먼스는 중요하고, 파일버가 성능적으로 기대 이하라면 보편적으로 쓰이지 않을 것임. 이 기사에서 논의된 내용은 stackless coroutine이 기본 ‘Io’ 구현이 되는 걸 막지 않음
-
green thread가 퍼포먼스가 나쁘다는 주장에 대해 의문임. 상위 동시성 서버 플랫폼(Go, Erlang, Java)이 모두 green thread를 쓰거나 쓰려고 함. green thread는 C FFI와의 호환 문제로 더 저수준 언어(Rust 등)에서는 적합하지 않을 수 있지만, 퍼포먼스 자체가 항상 문제라고만 보긴 힘듦
-
여러 선택지 중 하나이기 때문에 ‘all-in’이라고 볼 수 없다고 생각함. 어떤 구현을 택할지는 실행파일에서 결정하고, 라이브러리 코드에서는 결정되지 않음
-
Rust가 green thread를 제거하고 async runtime으로 교체한 선택과 비슷한 효과를 Zig도 노리고 있음. 핵심은 ‘async=IO, IO=async’인 점을 공식화한 직관임. Rust는 tokio 등 pluggable async runtime, Zig는 pluggable IO runtime을 제공하는 쪽임. 결국 언어에서 런타임을 빼내고, 사용자 영역에서 끼울 수 있게 하면서 모두가 공통된 인터페이스를 공유하는 게 방향임
-
자료(P1364R0)는 논쟁적이었고, 나는 특정 접근법을 없애기 위해 동기부여된 주장이라고 생각함. 논의 자료로는 https://old.reddit.com/r/cpp/…, https://old.reddit.com/r/programming/… 등도 참고 가능함
-
-
Zig 같은 시스템 언어에서 흔한 표준 IO 연산에까지 런타임 다형성을 강제하는 건 다소 어색하다고 느낌. 대부분의 실전에서는 IO 구현이 정적으로 확정될 수 있는데 왜 런타임 오버헤드를 강요해야 하는지 의문임
-
IO에서는 동적 디스패치 오버헤드가 실제로는 거의 미미할 거라 생각함. IO 대상에 따라 다르긴 하겠지만 결과적으로 IO가 CPU 병목이 아닌 경우가 훨씬 많음. 그래서 IO 바운드란 이름도 붙음
-
“왜 모두에게 런타임 오버헤드를 강제하나?”라는 질문에, 대부분 한 종류의 io만 쓰는 시스템에서는 컴파일러가 double indirection(간접 참조) 비용 자체를 최적화해서 없앨 의도로 보임. 그리고 IO는 어차피 bottleneck이 따로 있어서, 인디렉션 한 번 늘어나는 건 부담이 거의 없음
-
Zig의 철학상 바이너리 크기에 더 신경을 쓰는 편임. Allocator도 똑같은 트레이드오프가 있는데, 예를 들어 ArrayListUnmanaged는 allocator에 대해 generic하지 않으므로 매 할당마다 dynamic dispatch가 발생함. 실제로는 파일 할당이나 쓰기 비용이 간접 호출 오버헤드를 훨씬 압도함. 이런 바이너리 사이즈에 집착하는 게 Zig 스타일임. 참고로 devirtualization(동적 호출을 정적으로 바꾸는 최적화)은 미신임
-
런타임 다형성 자체가 본질적으로 나쁜 것은 아님. tight loop에서 브랜치가 생긴다든지, 컴파일러가 인라인 최적화를 못한다든지 그런 상황이 아니면 문제 상황이 아님
-
-
새 io 파라미터가 여기저기 드러나는 게 썩 마음에 들진 않지만, 여러 구현(thread 기반, fiber 기반 등)을 쉽게 쓸 수 있고 사용자에게 구현체를 강요하지 않는 점(Allocator 인터페이스처럼)이 아주 마음에 듦. 전체적으로 상당한 개선이고, 여러 stdlib 구현체 중 별도의 오버헤드 없는 동기/블로킹 io 구현이 제공된다면 “쓰지 않는 것에 돈을 내지 않는다”는 Zig 철학을 그대로 따르는 셈임
- “쓰지 않는 것에 돈을 내지 않는다”가 정말 가능한가? 팀 규모가 아주 작고 엄청난 규율이 있지 않은 이상 결국 다른 누군가가 쓰게 되고, 나도 비용을 치르게 될 거임. 그리고 io를 계속 넘기는 게 필요한 곳에서 그냥 호출만 하는 것보다 더 귀찮은 것 같음
-
Zig에서는 io.async가 비동기성(작업의 순서가 보장되지 않을 수 있지만 결과는 올바름)을 표현할 뿐, 동시성(concurrency)을 나타내는 게 아님. 즉, async와 io 호출의 의미를 분리했다는 점이 핵심임. 이 설계가 아주 영리하다고 생각함
-
IO 인터페이스 덕분에 언어 차원의 vfs(Virtual File System)을 만들 수 있다는 점이 마음에 듦
- 예시 코드를 보고 보안 관점에서 capability 기반 보안도 적용할 수 있지 않을까 생각이 들었음. 예를 들어 특정 디렉토리 하위만 읽을 수 있는 io 인스턴스를 라이브러리에 넘겨주기 등. 참고 https://news.ycombinator.com/item?id=44549430
-
나는 Zig를 배우려 ssh 서버를 간단히 만들어봤음. 이번 IO/이벤트 루프 구조 덕분에 코드의 흐름을 한결 쉽게 이해할 수 있었음. Andy에게 감사함
- 새로운 디자인에서 event loop/io를 더 쉽게 이해할 수 있게 된 계기가 어떤 부분인지 궁금함
-
글이 너무 잘 쓰였고, 매우 흥미롭게 봤음. 특히 WebAssembly에서의 시사점이 기대됨. WASI를 userspace에서 쓸 수도 있고, Bring Your Own IO도 가능한 구조라니 정말 재미있다는 생각임