Split View: Zig 0.16.0 — I/O를 인터페이스로 만든 릴리스, 그리고 그 청구서
Zig 0.16.0 — I/O를 인터페이스로 만든 릴리스, 그리고 그 청구서
- 들어가며 — 8개월, 1183커밋, 그리고 stdlib를 갈아엎은 릴리스
- Io를 인자로 넘긴다 — Allocator가 걸어간 길
- 구현체 목록을 읽는 법 — 무엇이 완성이고 무엇이 실험인가
- async는 무엇을 약속하고 무엇을 약속하지 않나
- 취소라는, 진짜 어려운 부분
- "Juicy Main" — main이 Io를 만든다
- 함수 색깔 문제는 풀렸나 — 릴리스 노트는 그 말을 하지 않는다
- 컴파일러 쪽 — 증분 컴파일과 새 ELF 링커
- 백엔드 현황 — x86은 기본값, aarch64는 멈춰 섰다
- 업그레이드 비용 — 무엇이 깨지나
- 그래서, 지금 Zig를 써야 하나
- 마치며
- 참고 자료
들어가며 — 8개월, 1183커밋, 그리고 stdlib를 갈아엎은 릴리스
Zig 0.16.0이 2026년 4월 14일에 발표됐습니다. 공식 공지는 짧습니다 — 8개월의 작업, 244명의 기여자, 1183개의 커밋. (다운로드 인덱스에 적힌 타르볼 날짜는 하루 앞선 2026-04-13입니다.) 릴리스 노트가 스스로 꼽는 헤드라인은 하나입니다. "I/O as an Interface".
한 문장으로 요약하면 이렇습니다. 릴리스 노트의 표현을 그대로 옮기면, Zig 0.16.0부터 모든 입출력 기능은 Io 인스턴스를 인자로 받아야 합니다. 파일을 읽든, 소켓에 쓰든, 잠을 자든, 표준 출력에 한 줄을 찍든 마찬가지입니다. 판단 기준도 노트에 적혀 있습니다 — 제어 흐름을 블록할 가능성이 있거나 비결정성을 끌어들이는 것은 대체로 I/O 인터페이스가 소유할 대상이라는 것입니다.
이 블로그는 얼마 전 Hashimoto가 Ghostty를 Zig로 만드는 이유를 다뤘습니다. 거기서 그는 0.15가 "출력 인터페이스, 그리고 무언가를 출력하는 모든 것"을 바꿨다고 말했고, 그 파괴적 변경을 오히려 반겼습니다. 0.16.0은 그 이야기의 다음 장이자, 훨씬 큰 장입니다. 이번엔 출력만이 아니라 입출력 전체입니다.
이 글은 홍보 문구가 아니라 릴리스 노트를 읽습니다. 무엇이 실제로 완성됐고, 무엇이 "실험적"이라고 적혀 있고, 어떤 숫자가 어떤 조건에서 측정된 것인지를 따라갑니다. 결론부터 말하면 — 설계는 흥미롭고, 청구서는 큽니다.
Io를 인자로 넘긴다 — Allocator가 걸어간 길
Zig를 조금이라도 써 본 사람에게 이 설계는 낯설지 않습니다. Zig에는 전역 할당자가 없습니다. 메모리가 필요한 함수는 Allocator를 인자로 받고, 그걸 실제로 무엇으로 채울지는 호출자가 정합니다. 라이브러리는 자기가 아레나 위에서 도는지 GPA 위에서 도는지 알 필요가 없습니다.
0.16.0은 같은 수를 I/O에 둡니다. I/O가 필요한 함수는 Io를 인자로 받습니다. 그리고 그게 스레드 풀인지, 이벤트 루프인지, io_uring인지는 호출자 — 정확히는 애플리케이션의 main — 가 정합니다.
릴리스 노트의 HTTP 예제가 이 설계의 야심을 잘 보여 줍니다. 도메인 하나에 HEAD 요청을 보내는 평범해 보이는 코드입니다.
const std = @import("std");
const Io = std.Io;
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
const host_name: Io.net.HostName = try .init(args[1]);
var http_client: std.http.Client = .{ .allocator = gpa, .io = io };
defer http_client.deinit();
var request = try http_client.request(.HEAD, .{
.scheme = "http",
.host = .{ .percent_encoded = host_name.bytes },
.port = 80,
.path = .{ .percent_encoded = "/" },
}, .{});
defer request.deinit();
try request.sendBodiless();
var redirect_buffer: [1024]u8 = undefined;
const response = try request.receiveHead(&redirect_buffer);
std.log.info("received {d} {s}", .{ response.head.status, response.head.reason });
}
노트는 이 코드가 — 코드를 한 줄도 더 쓰지 않았는데 — 다음 성질을 갖는다고 말합니다. 설정된 각 네임서버에 DNS 질의를 비동기로 보내고, 응답이 들어오는 대로 즉시 그 IP에 TCP 연결을 비동기로 시도하고, 첫 TCP 연결이 성공하는 순간 진행 중이던 다른 연결 시도들을 DNS 질의까지 포함해 전부 취소합니다. Windows에서는 이 모든 게 ws2_32.dll 의존성 없이 일어납니다.
여기서 진짜 흥미로운 문장은 그다음입니다. 노트는 이 코드가 -fsingle-threaded로 컴파일해도 동작한다고 적습니다 — 다만 그때는 연산들이 순차적으로 일어난다는 단서와 함께요. 같은 소스, 다른 실행 모델, 여전히 올바른 동작. 이게 이 설계가 팔고 있는 물건입니다.
구현체 목록을 읽는 법 — 무엇이 완성이고 무엇이 실험인가
인터페이스는 구현체가 있어야 의미가 있습니다. 그리고 이 목록이야말로 0.16.0에 대한 과장과 현실을 가르는 지점입니다. 노트에 적힌 수식어를 그대로 옮기면 이렇습니다.
Io.Threaded— 스레드 기반. 파일 시스템 연산이 read, write, open, close를 직접 호출합니다. 0.15.x에서 코드를 옮겨 올 때 동등한 동작을 주는 구현체입니다. 노트의 표현으로 기능이 완전하고 잘 테스트돼 있으며, 취소까지 지원합니다. "Juicy Main"이 현재 고르는 구현체이기도 합니다.Io.Evented— 노트의 표현 그대로 작업 중이고 실험적이며, 인터페이스의 진화 방향을 알아보기 위한 것입니다. 유저스페이스 스택 스위칭과 작업 훔치기 기반 — M:N 스레딩, 그린 스레드, 스택풀 코루틴이라 부르는 그것입니다.Io.Uring— 이번 릴리스 사이클의 초점이 아니었다고 노트는 밝힙니다. 개념 증명 수준이고, 네트워킹과 에러 처리와 테스트 커버리지가 빠져 있습니다.Io.Kqueue— 개념 증명 전용입니다.Io.Dispatch— macOS의 Grand Central Dispatch 기반.Io.failing— 아무 연산도 지원하지 않는 시스템을 시뮬레이션합니다.
이 목록을 정직하게 읽으면 결론은 하나입니다. 0.16.0이 프로덕션에 배달한 것은 스레드 기반 I/O 하나이고, 사람들이 이 릴리스를 이야기하며 흥분하는 대상 — io_uring 위에서 도는 Zig 비동기 — 은 아직 배달되지 않았습니다. 인터페이스는 그것을 담을 모양을 만들었을 뿐입니다.
이 구분은 냉소가 아닙니다. 오히려 Zig 팀이 릴리스 노트에서 스스로 이렇게 적어 두었다는 점이 인상적입니다. 많은 프로젝트가 개념 증명을 "지원"이라 부릅니다.
async는 무엇을 약속하고 무엇을 약속하지 않나
io.async는 Future(T)를 만듭니다. 여기서 T는 호출된 함수의 반환 타입입니다. 노트가 설명하는 의미론이 미묘하고 중요합니다.
io.async는 비동기성을 표현합니다 — 이 함수 호출이 다른 로직과 독립적이라는 사실을요. 그래서 이 태스크 생성은 실패할 수 없고, 동시성 메커니즘이 아예 없는 제한된 Io 구현체 위에서도 이식 가능합니다. 노트는 여기서 아주 솔직한 문장을 하나 덧붙입니다 — Io 구현체가 async 호출을 그냥 함수를 직접 호출한 뒤 반환하는 식으로 구현해도 합법이라는 것입니다.
이걸 곱씹어 볼 필요가 있습니다. io.async는 "동시에 실행하라"는 명령이 아닙니다. "이건 독립적이다"라는 선언입니다. 실제로 동시에 돌릴지는 구현체가 정합니다. 그래서 -fsingle-threaded 빌드에서도 코드가 컴파일되고 올바르게 동작하는 것입니다 — 그저 순차적으로 돌 뿐이죠.
진짜로 동시성이 필요할 때는 io.concurrent가 따로 있습니다. 노트의 표현으로는 이게 정확성을 위해 반드시 동시에 실행돼야 함을 전달합니다. 그리고 이건 필연적으로 메모리 할당을 요구하고 — 동시에 무언가를 하는 일의 본질이 그렇다고 노트는 설명합니다 — 따라서 실패할 수 있습니다. error.ConcurrencyUnavailable이 그것입니다.
즉 Zig는 "비동기여도 되는 것"과 "반드시 동시여야 하는 것"을 타입 시스템 수준에서 다른 함수로 갈라 놓았습니다. 전자는 무한히 이식 가능하고 실패하지 않으며, 후자는 자원을 요구하고 실패할 수 있습니다. 이건 대부분의 async 런타임이 뭉개는 구분이고, 개인적으로는 이 릴리스에서 가장 잘 설계된 부분이라고 생각합니다.
Future에는 두 메서드가 있습니다. await은 태스크가 끝날 때까지 논리적으로 제어 흐름을 블록하고 반환값을 줍니다. cancel은 await과 같되, 구현체에게 연산을 중단하고 error.Canceled를 반환하라고 요청합니다.
여러 태스크의 수명이 같을 때는 Group이 있고, 노트는 N개 태스크를 띄우는 데 O(1) 오버헤드라고 적습니다. 이 밖에 Queue(T), Select, Batch, 그리고 단위를 타입으로 강제하는 Clock, Duration, Timestamp, Timeout이 함께 들어왔습니다.
취소라는, 진짜 어려운 부분
비동기 런타임을 만들어 본 사람은 압니다. 어려운 건 태스크를 띄우는 게 아니라 죽이는 것입니다. 0.16.0은 여기에 지면을 꽤 씁니다.
가장 인상적인 구현 디테일은 이것입니다. 노트에 따르면 Io.Threaded조차 취소를 지원하는데, 방법은 스레드에 시그널을 보내 블로킹 시스템 콜이 EINTR을 반환하게 만들고, 그 에러 코드에 반응해 시스템 콜을 재시도하기 전에 취소 요청이 있었는지 확인하는 것입니다. 이벤트 루프도 없고 코루틴도 없는 평범한 스레드 풀에서 취소를 구현하는 방법으로는 꽤 영리합니다.
동시에 노트는 중요한 단서를 답니다 — 취소를 요청해도 그 요청이 받아들여질 수도, 받아들여지지 않을 수도 있습니다. 취소는 요청이지 명령이 아닙니다. 받아들여진 요청만 I/O 연산이 error.Canceled를 반환하게 만듭니다.
그리고 error.Canceled를 다루는 방법이 세 가지로 정리돼 있습니다. 흔한 순서대로 — 그냥 전파하거나, io.recancel()을 호출해 취소 요청을 재무장한 뒤 전파하지 않거나, io.swapCancelProtection()으로 도달 불가능하게 만들거나. 취소 요청을 스스로 한 로직만이 error.Canceled를 무시해도 안전하다는 규칙도 함께 적혀 있습니다.
자원 누수를 피하는 패턴도 노트가 직접 제시합니다.
var foo_future = io.async(foo, .{args});
defer if (foo_future.cancel(io)) |resource| resource.deinit() else |_| {}
var bar_future = io.async(bar, .{args});
defer if (bar_future.cancel(io)) |resource| resource.deinit() else |_| {}
const foo_result = try foo_future.await(io);
const bar_result = try bar_future.await(io);
cancel이 필수인 이유가 설명돼 있습니다 — 에러가 반환될 때(error.Canceled 포함) 비동기 태스크 자원을 해제하는 게 바로 이 호출이기 때문입니다. 즉 defer cancel은 취소를 위한 게 아니라 자원 회수를 위한 것이고, 이건 Zig를 처음 만지는 사람이 반드시 밟을 지뢰입니다.
"Juicy Main" — main이 Io를 만든다
Io가 인자라면, 그 첫 번째 Io는 누가 만드나요? 0.16.0의 답은 main입니다.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const ptr = try gpa.create(i32);
defer gpa.destroy(ptr);
try std.Io.File.stdout().writeStreamingAll(io, "Hello, world!\n");
}
main에 std.process.Init 파라미터를 붙이면 미리 초기화된 것들이 딸려 옵니다 — 프로세스 전체 수명의 아레나, 기본 선택된 범용 할당자, 타깃 설정에 맞춰 고른 기본 Io 구현체, 환경 변수 맵, 그리고 WASI용 preopens. Debug 모드에서는 가능한 경우 누수 검사까지 세팅해 준다고 노트는 적습니다.
main의 첫 파라미터는 세 가지 중 하나일 수 있습니다. 없거나(그러면 CLI 인자와 환경 변수에 접근할 수 없습니다), process.Init.Minimal이거나(argv와 environ만 날것으로), process.Init이거나.
환경 변수가 여기로 온 이유도 노트에 적혀 있고, 설득력이 있습니다. 환경이 전역 상태라는 건 아주 흔한 추상화지만 문제가 많다는 것입니다 — C에서 setenv 같은 함수를 스레드 맥락에서 호출하는 건 건전하지 않은데, environ이 아무 락 없이 직접 접근되는 일이 흔하기 때문입니다. 게다가 Zig 표준 라이브러리에는 큰 함정이 있었습니다. std.os.environ은 C의 environ과 동등할 의도였지만, libc를 링크하지 않는 라이브러리에서는 그걸 채울 방법이 없었습니다.
Io 인스턴스가 없는 곳에서 급하게 하나 만드는 방법도 노트가 알려 주는데, 함께 붙은 경고가 정직합니다.
var threaded: Io.Threaded = .init_single_threaded;
const io = threaded.io();
노트는 이게 태스크 수준 동시성이 필요 없는 한 동작하지만 이상적이지 않은 우회책이라고 말합니다. 비유가 좋습니다 — Allocator가 필요한데 없다고 std.heap.page_allocator를 집어 드는 것과 같다는 것입니다. 테스트에서는 std.testing.allocator처럼 std.testing.io를 쓰라고 권합니다.
함수 색깔 문제는 풀렸나 — 릴리스 노트는 그 말을 하지 않는다
이 릴리스를 둘러싼 요약 중 가장 많이 돌아다니는 문장이 "Zig가 함수 색깔 문제를 풀었다"입니다. 그래서 확인해 봤습니다. 0.16.0 릴리스 노트 전문에 "color"라는 단어는 한 번도 등장하지 않습니다.
그 문장의 출처는 따로 있습니다. Zig 코어 팀의 Loris Cro가 2025년 7월 13일에 쓴 Zig's New Async I/O입니다. 그는 거기서 세 단계로 주장을 올립니다 — 하나의 라이브러리가 동기 모드와 비동기 모드 양쪽에서 최적으로 동작할 수 있고, 예전에는 소스 코드가 async/await의 전염성에서 자유로웠어도 런타임에는 여전히 스택리스 코루틴이 강제됐지만 이제 io.async와 Future.await을 다양한 실행 모델과 함께 쓸 수 있으며, 그래서 — 그의 문장 그대로 — 이 마지막 개선으로 Zig는 함수 색깔 문제를 완전히 무찔렀다는 것입니다.
여기엔 단서를 달아야 합니다. 그 글은 0.16.0이 아니라 당시의 계획을 이야기한 2025년 7월의 글이고, 표준 라이브러리 재작성이 남아 있으며 0.15.0에는 그중 작은 일부만 들어간다고 스스로 적고 있습니다. 그리고 계획은 실제로 바뀌었습니다. Cro가 언급한 스택리스 코루틴은 0.16.0 노트에 한 번도 나오지 않고, 실제로 들어온 Io.Evented는 스택풀 코루틴 기반입니다.
반대편 주장도 있습니다. ivnj라는 필자가 같은 날인 2025년 7월 13일에 쓴 글의 논지는 이렇습니다 — 의미론적으로 모든 함수에 std.Io를 넘기는 것은 모든 Node.js 함수를 async로 만들어 프로미스를 반환하게 하는 것과 다르지 않고, Zig는 함수 색깔을 블로킹/논블로킹의 선택에서 io/non-io의 선택으로 옮겼을 뿐이라는 것입니다. I/O를 하는 함수는 std.Io 파라미터를 요구하고, 그런 함수는 std.Io를 받는 다른 함수에서만 호출될 수 있으니 파라미터가 콜 체인 전체로 번져 나간다는 지적이죠.
제 읽기는 이렇습니다. 두 주장 다 맞고, 서로 다른 걸 이야기하고 있습니다. 색깔의 전염성은 남아 있습니다 — Io는 콜 체인을 타고 올라갑니다. 사라진 건 색깔의 경직성입니다. Rust나 JavaScript에서 함수의 색깔은 컴파일 시점에 실행 모델을 못 박습니다. Zig에서 Io를 받는 함수는 실행 모델을 못 박지 않습니다 — 호출자가 나중에 고르고, 심지어 동시성이 없는 구현체를 골라도 코드가 여전히 맞습니다. 그리고 애초에 Allocator가 콜 체인을 타고 올라가는 걸 Zig 개발자들은 이미 받아들이고 살아 왔습니다. 색깔이 하나 더 늘어난 게 아니라, 이미 있던 색깔 옆에 같은 종류의 색깔이 하나 붙은 셈입니다.
다만 이건 공짜가 아닙니다. 노트의 "Lazy Field Analysis" 항목이 그 대가의 한 조각을 보여 줍니다 — I/O를 인터페이스로 만든 뒤 발견한 문제인데, std.Io.Writer를 어떤 식으로든 쓰면 std.Io의 vtable이 딸려 들어오고, 일부 경우에는 불필요한 코드 생성으로 이어져 바이너리를 부풀릴 수 있었다는 것입니다. 0.16.0은 타입 해석을 지연시켜 이걸 고쳤지만, 인터페이스에는 원래 vtable 값이 붙는다는 사실 자체는 남습니다.
컴파일러 쪽 — 증분 컴파일과 새 ELF 링커
I/O가 헤드라인을 가져갔지만, 컴파일러 쪽 변화가 실무에는 더 직접적일 수 있습니다.
증분 컴파일은 이번 사이클에 크게 좋아졌습니다. 노트가 드는 대표 사례가 인상적입니다 — Zig 컴파일러 자체에 증분 컴파일을 쓸 때, 예전에는 컴파일러 거의 전체를 재컴파일하던 변경이 이제 밀리초 단위로 끝난다는 것입니다. 공은 "Reworked Type Resolution"에 돌아갑니다. 타입 해석을 갈아엎으면서 컴파일러 내부 의존성 그래프가 (의존성 루프인 경우를 빼면) 비순환이 됐고, 그 덕에 "과잉 분석" — 필요한 것보다 많은 코드를 다시 빌드하는 일 — 이 대부분의 경우 사라졌습니다.
증분 빌드가 비증분 빌드에 없는 "dependency loop" 컴파일 에러를 뱉던 문제도 해결됐습니다. 노트는 이게 이전 릴리스들에서 증분과 비증분 사이의 가장 큰 불일치였다고 적습니다. LLVM 백엔드도 이제 증분 컴파일을 지원하는데, 여기엔 정직한 단서가 붙습니다 — 이게 "LLVM Emit Object" 단계를 빠르게 해 주지는 않습니다. 그 단계는 전적으로 LLVM의 몫이고 Zig가 할 수 있는 게 거의 없다는 것입니다. 대신 Zig 쪽에서 LLVM 비트코드를 만드는 과정이 빨라지고, 컴파일 에러가 있는 경우에는 — 에러가 있으면 Emit Object가 통째로 생략되므로 — LLVM 백엔드에서도 거의 즉각적인 피드백을 받습니다.
그리고 여기 이 릴리스에서 가장 솔직한 문장이 있습니다. 증분 컴파일에는 오컴파일을 포함한 알려진 버그가 아직 있고, 그래서 0.16.0에서도 기본값은 꺼짐입니다. 그런데 노트는 바로 다음 문장에서 그럼에도 켜기를 권합니다. 사용자들이 절약되는 시간에 자주 놀란다면서요. 켜는 법은 zig build -fincremental --watch입니다.
이 긴장을 어떻게 읽어야 할까요. 저는 이렇게 봅니다 — 컴파일 에러 피드백만 쓴다면 위험이 거의 없습니다. 오컴파일은 결국 잘못된 기계어를 만드는 문제이고, 컴파일이 실패하는 코드에는 기계어가 없으니까요. 반면 증분으로 만든 바이너리를 그대로 테스트하거나 배포한다면, 노트가 경고한 그 오컴파일을 정면으로 맞을 수 있습니다. "개발 루프에는 켜고, 나가는 아티팩트는 클린 빌드로" 정도가 이 릴리스에서 합리적인 선입니다.
새 ELF 링커도 들어왔습니다. -fnew-linker로 켜거나 빌드 스크립트에서 exe.use_new_linker = true로 켜고, -fincremental과 ELF 타깃 조합에서는 이미 기본값입니다. 노트가 제시한 성능 데이터 포인트는 Zig 컴파일러를 빌드한 뒤, 함수 하나에 한 줄짜리 변경을 가하고, 또 한 번 가한 시나리오입니다.
시나리오: Zig 컴파일러 빌드 -> 한 줄 변경 -> 다시 한 줄 변경
옛 링커: 14s, 194ms, 191ms
새 링커: 14s, 65ms, 64ms (66% 빠름)
링킹 아예 생략: 14s, 62ms, 62ms (68% 빠름)
이 표에서 진짜 재미있는 건 세 번째 줄입니다. 새 링커가 링킹을 통째로 건너뛰는 것과 3ms 차이밖에 나지 않습니다. 그래서 노트는 -Dno-bin 빌드 스텝을 노출할 이유가 이제 별로 없다고 말합니다 — 컴파일 속도 차이가 무시할 만하니 코드 생성과 링킹을 항상 켜 두는 편이 낫고, 그러면 끝에 실행 파일까지 덤으로 얻는다는 것이죠.
대가는 분명합니다. 노트는 이 새 링커가 옛 링커에 대해서도, LLD에 대해서도 기능이 완전하지 않다고 명시합니다. 예로 든 게 뼈아픕니다 — 이렇게 만들어진 실행 파일에는 DWARF 정보가 없습니다. 그래서 옛 링커와 LLD가 둘 다 여전히 남아 있고, 새 링커가 기능적으로 완전해지면 그때 옛 링커를 지우고 LLD를 의존성에서 제거하겠다는 게 계획입니다.
즉 66%라는 숫자는 "디버깅을 포기하면"이라는 조건을 달고 있습니다. 저 3ms 차이가 말해 주는 것도 같은 이야기입니다 — 링킹이 거의 공짜가 된 이유의 상당 부분은 링커가 하던 일 중 일부를 아직 안 하기 때문입니다.
백엔드 현황 — x86은 기본값, aarch64는 멈춰 섰다
자체 호스팅 백엔드 이야기는 릴리스마다 나오지만, 0.16.0의 현황은 냉정하게 볼 필요가 있습니다.
x86 백엔드는 이번에 버그 11개가 고쳐졌고, 상수 memcpy 코드를 더 잘 만듭니다. 노트가 LLVM 백엔드와 비교해 적은 문장을 그대로 옮기면 — 이 백엔드는 동작 테스트를 더 많이 통과하고, 컴파일 속도가 현저히 빠르며, 디버그 정보가 우수하고, 기계어 품질은 열등합니다. Debug 모드의 기본값으로 남아 있습니다.
이 네 항목이 왜 Debug 전용인지를 정확히 설명합니다. 개발 루프에서는 컴파일 속도와 디버그 정보가 전부고 기계어 품질은 거의 상관없습니다. 릴리스 빌드에서는 정확히 반대죠. 참고로 LLVM 백엔드는 동작 테스트 2010개 중 2004개를 통과합니다(노트는 이걸 100%로 반올림해 적습니다). x86 백엔드는 그보다 더 통과합니다.
aarch64 백엔드는 나쁜 소식입니다. 노트의 표현 그대로 — 여전히 작업 중이고, 이번 릴리스 사이클에는 "I/O as an Interface" 여파로 진행이 멈췄으며, 현재 동작 테스트를 돌리면 크래시합니다. 표준 라이브러리의 변동이 잦아들면 진행이 다시 붙을 것으로 기대한다고 적혀 있습니다.
이건 이 릴리스의 기회비용을 그대로 보여 줍니다. I/O 인터페이스는 공짜로 온 게 아니라 aarch64 자체 호스팅 백엔드의 한 사이클을 잡아먹고 왔습니다. Apple Silicon이나 ARM 서버에서 자체 호스팅 백엔드의 빠른 컴파일을 기다리던 사람에게 0.16.0은 진전이 아니라 정지입니다. WebAssembly 백엔드는 동작 테스트 1970개 중 1813개(92%)를 통과합니다.
툴체인 쪽에도 아픈 항목이 하나 있습니다. 0.16.0은 LLVM 21.1.0으로 올라갔는데, 루프 벡터화를 통째로 껐습니다. 이유가 심각합니다 — 그 회귀가 흔한 설정에서 컴파일러 자신을 오컴파일하기 때문입니다. 특정 CPU 기능을 끄는 우회는 너무 취약해서, 버그가 고쳐진 LLVM 버전으로 올라갈 때까지 벡터화를 아예 끄기로 했다는 것입니다. 노트는 이게 일부 경우 코드 생성을 나쁘게 만든다는 걸 인정하면서, 오컴파일보다는 낫다고 말합니다. 그리고 이 성능 회귀는 0.16.x만이 아니라 0.17.x까지 영향을 주고 0.18.x에서야 해소될 것으로 예상한다고 적혀 있습니다. 즉 0.16.0으로 올리면 이 페널티를 두 릴리스 동안 안고 가야 합니다.
밝은 소식도 있습니다. translate-c가 libclang 대신 arocc 기반으로 바뀌면서 컴파일러 소스 트리에서 C++ 5,940줄이 사라졌습니다(3,763줄 남음). LLVM에 대한 라이브러리 의존성을 Clang에 대한 프로세스 의존성으로 바꿔 가는 여정의 한 걸음입니다. 슬라이스를 도는 for 루프의 안전성 검사는 코드를 약 30% 덜 만들도록 개선됐고, 새로 들어온 deflate 압축은 기본 압축 레벨에서 zlib 대비 벽시계 시간이 9.7% ± 0.2% 빨랐습니다(다만 zlib이 압축률은 1.00% 더 좋고, 명령어 수는 오히려 18.9% 많습니다 — 캐시 미스와 분기 예측 실패가 줄어든 덕에 시간이 이긴 경우입니다).
업그레이드 비용 — 무엇이 깨지나
"모든 입출력이 Io를 받는다"는 문장은 업그레이드 관점에서 "입출력을 하는 모든 코드가 깨진다"와 같은 말입니다.
제거된 것부터 봅시다. Io.GenericWriter, Io.AnyWriter, Io.null_writer, Io.CountingReader가 사라졌습니다. SegmentedList, meta.declList, Thread.Mutex.Recursive도 없어졌습니다. 포맷팅 API는 fmt.format이 std.Io.Writer.print로, Formatter가 Alt로, FormatOptions가 Options로, bufPrintZ가 bufPrintSentinel로 바뀌었습니다.
에러 셋도 정리됐습니다. error.RenameAcrossMountPoints와 error.NotSameFileSystem이 error.CrossDevice로 합쳐지고, error.SharingViolation이 error.FileBusy로, error.EnvironmentVariableNotFound가 error.EnvironmentVariableMissing으로 바뀌었습니다. DynLib는 Windows 지원이 제거됐는데, 노트의 코멘트가 웃깁니다 — 이제 사용자가 LoadLibraryExW와 GetProcAddress를 직접 써야 하고, 어차피 다들 이미 그러고 있었을 거라는 것입니다.
언어 자체도 움직였습니다. @Type이 개별 타입 생성 빌트인들로 대체됐고, @cImport는 빌드 시스템으로 옮겨 가는 중이며(당분간 빌트인은 남지만 Aro가 뒤를 받칩니다), 런타임 벡터 인덱스가 금지됐고, packed struct와 union에서 포인터가 금지됐습니다. "Reworked Type Resolution"은 전반적으로 더 관대해졌지만 엄밀히 더 관대한 건 아니어서, 예전에 통과하던 일부 코드가 이제 의존성 루프 에러를 냅니다.
여기서 Ghostty 이야기가 다시 떠오릅니다. Hashimoto가 0.15에서 "무언가를 출력하는 모든 것"이 바뀌었다고 했을 때, 그건 끝이 아니라 시작이었습니다. 0.16.0은 그 writer 계열을 또 갈아엎었습니다. 그가 이걸 반긴 이유 — Andrew Kelley가 필요하다고 판단한 변경을 물러서지 않고 밀어붙인다는 것 — 이 여기서도 그대로 확인됩니다. 그리고 그 태도를 반길 수 있는지 여부가, 이 언어를 쓸 수 있는지를 가르는 진짜 기준입니다.
릴리스 노트에는 "This Release Contains Bugs"라는 제목의 절이 따로 있습니다. 이 사이클에 버그 리포트 345개가 닫혔지만, Zig에는 알려진 버그와 오컴파일과 회귀가 있다고 적습니다. 그리고 결정적인 문장 — 0.16.x에서도 Zig로 사소하지 않은 프로젝트를 하는 일은 개발 과정에 참여하는 것을 요구할 수 있습니다. 1.0.0에 도달하면 Tier 1 지원에 버그 정책이 추가 요건으로 붙을 거라는 말도 함께요. 뒤집으면, 지금은 그런 정책이 없다는 뜻입니다.
그래서, 지금 Zig를 써야 하나
정리하면 판단 기준은 이렇게 나뉩니다.
말이 되는 경우
- 도구나 인프라 코드를 만드는데, 툴체인의 파괴적 변경을 흡수할 여력이 팀에 있다. 업그레이드가 이벤트가 아니라 일상인 조직.
- I/O 추상화를 인자로 주입받는 설계가 실제로 필요하다 — 같은 라이브러리를 CLI에서는 단일 스레드로, 서버에서는 스레드 풀로 돌려야 하는 경우. 이건 0.16.0이 진짜로 배달한 물건입니다.
- 크로스 컴파일과 C 상호운용이 핵심 요구사항이다. 이 영역에서 Zig의 강점은 0.16.0의 소란과 무관하게 그대로입니다.
- 컴파일 속도가 개발 경험의 병목이고,
-fincremental --watch의 빠른 에러 피드백만으로도 값을 한다.
아직 아닌 경우
- 안정된 API가 필요하다. 0.15가 writer를 바꿨고, 0.16이 I/O 전체를 바꿨습니다. 0.17이 무엇을 바꿀지는 아무도 약속하지 않습니다. 1.0 전의 Zig에서 "이번이 마지막 큰 변경"이라는 기대는 근거가 없습니다.
- io_uring 위에서 도는 프로덕션 비동기 I/O가 지금 필요하다. 0.16.0이 준 건 개념 증명입니다 — 네트워킹도 에러 처리도 테스트 커버리지도 아직입니다.
- aarch64에서 자체 호스팅 백엔드의 빠른 컴파일이 필요하다. 이번 사이클엔 진행이 멈췄고, 지금은 동작 테스트에서 크래시합니다.
- 릴리스 빌드의 기계어 품질이 최우선이다. 자체 호스팅 x86 백엔드는 기계어 품질이 열등하다고 노트가 직접 적고 있고, 게다가 LLVM 루프 벡터화가 0.17.x까지 꺼져 있습니다.
- 팀이 "언어 버그를 만나면 업스트림에 참여한다"를 감당할 수 없다. 이건 제 평가가 아니라 릴리스 노트에 적힌 경고입니다.
마치며
0.16.0에서 제가 배운 건 언어에 대한 것보다 설계에 대한 것에 가깝습니다.
Allocator를 인자로 넘기는 Zig의 습관은 원래 메모리 이야기가 아니었습니다. "정책은 호출자가 정하고, 라이브러리는 메커니즘만 제공한다"는 이야기였죠. 0.16.0은 그 원칙을 I/O에 밀어붙였고, 그 과정에서 대부분의 async 런타임이 뭉개는 구분 — 비동기여도 되는 것과 반드시 동시여야 하는 것 — 을 io.async와 io.concurrent로 갈라 놓았습니다. 취소를 요청이지 명령이 아닌 것으로 다룬 것도, 스레드 풀에서 시그널과 EINTR로 취소를 구현한 것도 같은 결의 정직함입니다.
함수 색깔 문제를 "무찔렀는지"는 정의하기 나름이라고 생각합니다. Io는 여전히 콜 체인을 타고 번져 나가니까요. 다만 번져 나가는 게 실행 모델이 아니라 선택권이라는 점에서, 저는 이게 의미 있는 차이라고 봅니다. 그리고 릴리스 노트가 그 승리를 스스로 선언하지 않았다는 점도 — 그 문장은 코어 팀원의 개인 블로그에 있지 공식 노트에는 없습니다 — 이 팀에 대한 신뢰를 오히려 높입니다.
동시에 청구서는 실재합니다. 완성된 구현체는 하나이고, aarch64는 멈췄고, 증분 컴파일은 오컴파일 때문에 기본값이 꺼져 있고, 새 링커는 DWARF를 못 만들고, LLVM 벡터화는 두 릴리스 동안 꺼져 있고, I/O를 만지는 코드는 전부 다시 써야 합니다. 릴리스 노트는 이 중 무엇도 숨기지 않습니다 — 오히려 제가 이 글에 쓴 부정적인 사실은 거의 전부 노트에서 그대로 가져온 것입니다.
그게 아마 이 릴리스의 가장 정확한 요약일 겁니다. Zig는 여전히 "완성된 제품"이 아니라 "진행 중인 논증"이고, 릴리스 노트는 그 논증의 회의록입니다. 그 회의에 참여할 생각이 있다면 0.16.0은 흥미로운 릴리스입니다. 제품을 사러 온 거라면, 노트 맨 아래 "This Release Contains Bugs" 절을 먼저 읽으십시오. 그 절이 거기 있는 건 형식적인 면책이 아니라, 이 언어가 지금 어디에 서 있는지에 대한 정확한 진술입니다.
참고 자료
- Zig 0.16.0 Release Notes — 공식 릴리스 노트(이 글의 거의 모든 사실과 숫자의 출처)
- 0.16.0 Released — 공식 공지(2026년 4월 14일, 244명 기여자, 1183커밋)
- ziglang.org 다운로드 인덱스 — 버전별 배포 날짜(0.16.0: 2026-04-13)
- Zig's New Async I/O — Loris Cro, 2025년 7월 13일("함수 색깔을 완전히 무찔렀다"의 출처, 당시 계획 기준)
- Zig's new I/O: function coloring is inevitable? — ivnj, 2025년 7월 13일(반대편 논지)
- 터미널로 돌아온 창업자: Hashimoto의 Ghostty, Zig라는 베팅(관련 글)
Zig 0.16.0 — The Release That Turned I/O into an Interface, and the Bill It Came With
- Introduction — Eight Months, 1,183 Commits, and a Release That Overhauled the Stdlib
- Passing Io as an Argument — The Path Allocator Already Walked
- How to Read the List of Implementations — What Is Done and What Is Experimental
- What async Promises and What It Does Not
- Cancellation, the Genuinely Hard Part
- "Juicy Main" — main Creates the Io
- Was the Function Coloring Problem Solved — The Release Notes Never Say So
- On the Compiler Side — Incremental Compilation and the New ELF Linker
- Backend Status — x86 Is the Default, aarch64 Has Stalled
- The Upgrade Bill — What Breaks
- So, Should You Use Zig Right Now
- Closing
- References
Introduction — Eight Months, 1,183 Commits, and a Release That Overhauled the Stdlib
Zig 0.16.0 was announced on April 14, 2026. The official announcement is short — eight months of work, 244 contributors, 1,183 commits. (The tarball date listed in the download index is one day earlier, 2026-04-13.) The release notes single out one headline for themselves: "I/O as an Interface."
In one sentence: to borrow the release notes' own wording, starting with Zig 0.16.0, every I/O-capable function must take an Io instance as an argument. Whether you are reading a file, writing to a socket, sleeping, or printing a line to stdout, it is the same rule. The notes also state the criterion for what counts as I/O: broadly, anything that can block control flow or introduce nondeterminism belongs to the I/O interface.
This blog recently covered why Hashimoto is building Ghostty in Zig. There, he said 0.15 changed "the output interface, and everything that outputs anything," and welcomed that breaking change. 0.16.0 is the next chapter of that story, and a much bigger one. This time it is not just output — it is all of I/O.
This post reads the release notes rather than the marketing copy. It follows what actually shipped, what is labeled "experimental," and under what conditions each number was measured. The short version up front: the design is interesting, and the bill is large.
Passing Io as an Argument — The Path Allocator Already Walked
For anyone who has used Zig even a little, this design will feel familiar. Zig has no global allocator. A function that needs memory takes an Allocator as an argument, and the caller decides what actually backs it. A library does not need to know whether it is running on an arena or a GPA.
0.16.0 puts the same logic on I/O. A function that needs I/O takes an Io as an argument. And whether that Io is a thread pool, an event loop, or io_uring is decided by the caller — specifically, by the application's main.
The HTTP example in the release notes shows the ambition of this design well. It looks like ordinary code that sends a HEAD request to one domain.
const std = @import("std");
const Io = std.Io;
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
const host_name: Io.net.HostName = try .init(args[1]);
var http_client: std.http.Client = .{ .allocator = gpa, .io = io };
defer http_client.deinit();
var request = try http_client.request(.HEAD, .{
.scheme = "http",
.host = .{ .percent_encoded = host_name.bytes },
.port = 80,
.path = .{ .percent_encoded = "/" },
}, .{});
defer request.deinit();
try request.sendBodiless();
var redirect_buffer: [1024]u8 = undefined;
const response = try request.receiveHead(&redirect_buffer);
std.log.info("received {d} {s}", .{ response.head.status, response.head.reason });
}
The notes say this code — without a single extra line — has the following properties: it asynchronously sends DNS queries to each configured nameserver, asynchronously attempts a TCP connection to an IP as soon as a response for it arrives, and the instant the first TCP connection succeeds, cancels every other connection attempt in flight, DNS queries included. On Windows, all of this happens without a dependency on ws2_32.dll.
The genuinely interesting sentence comes right after. The notes state that this code also works when compiled with -fsingle-threaded — with the caveat that the operations then happen sequentially. Same source, different execution model, still correct behavior. That is what this design is selling.
How to Read the List of Implementations — What Is Done and What Is Experimental
An interface only matters if it has implementations. And this list is exactly where the hype around 0.16.0 and the reality diverge. Carrying over the notes' own qualifiers:
Io.Threaded— thread-based. Filesystem operations call read, write, open, and close directly. It is the implementation that gives equivalent behavior when porting code from 0.15.x. In the notes' words it is feature-complete and well-tested, and it supports cancellation too. It is also the implementation "Juicy Main" currently picks.Io.Evented— in the notes' own words, a work in progress and experimental, meant to explore where the interface can evolve. Based on userspace stack switching and work-stealing — what people call M:N threading, green threads, or stackful coroutines.Io.Uring— the notes state this was not the focus of this release cycle. It is proof-of-concept level, missing networking, error handling, and test coverage.Io.Kqueue— proof-of-concept only.Io.Dispatch— based on macOS's Grand Central Dispatch.Io.failing— simulates a system that supports no operations at all.
Reading this list honestly leads to one conclusion. What 0.16.0 shipped to production is exactly one thread-based I/O implementation, and the thing people get excited about when they talk about this release — Zig async running on io_uring — has not shipped yet. The interface has only built the shape that will eventually hold it.
This distinction is not cynicism. If anything, it is notable that the Zig team wrote it this plainly in their own release notes. Plenty of projects call a proof of concept "support."
What async Promises and What It Does Not
io.async produces a Future(T), where T is the return type of the function called. The semantics the notes describe here are subtle and important.
io.async expresses asynchrony — the fact that this function call is independent of other logic. Because of that, creating this task cannot fail, and it is portable even on top of a restricted Io implementation that has no concurrency mechanism at all. The notes add a remarkably candid sentence here: it is legal for an Io implementation to implement an async call by simply calling the function directly and returning.
That is worth sitting with. io.async is not an instruction to run this concurrently. It is a declaration that this is independent. Whether it actually runs concurrently is up to the implementation. That is exactly why the code still compiles and behaves correctly in a -fsingle-threaded build — it just runs sequentially.
For the cases that genuinely need concurrency, there is a separate io.concurrent. In the notes' words, this conveys that something must run concurrently for correctness. And that inevitably requires a memory allocation — the notes explain this is simply what doing something concurrently entails — so it can fail. That failure is error.ConcurrencyUnavailable.
In other words, Zig splits "things that are allowed to be async" from "things that must be concurrent" into different functions at the type-system level. The former is infinitely portable and cannot fail; the latter demands resources and can fail. Most async runtimes blur this distinction, and personally I think it is the best-designed part of this release.
Future has two methods. await logically blocks control flow until the task finishes and returns the value. cancel behaves like await, except it also asks the implementation to abort the operation and return error.Canceled.
When multiple tasks share a lifetime, there is Group, and the notes state it has O(1) overhead for launching N tasks. Alongside this came Queue(T), Select, Batch, and Clock, Duration, Timestamp, and Timeout, which enforce units through the type system.
Cancellation, the Genuinely Hard Part
Anyone who has built an async runtime knows: the hard part is not launching a task, it is killing one. 0.16.0 spends a fair amount of space on this.
The most impressive implementation detail is this: according to the notes, even Io.Threaded supports cancellation — by signaling the thread so that a blocking system call returns EINTR, and reacting to that error code by checking whether a cancellation was requested before retrying the system call. That is a fairly clever way to implement cancellation in an ordinary thread pool with no event loop and no coroutines.
At the same time, the notes attach an important caveat — requesting a cancellation does not guarantee it will be honored. Cancellation is a request, not a command. Only a request that is honored causes the I/O operation to return error.Canceled.
And the notes lay out three ways to handle error.Canceled, in order of how common they are: propagate it as-is, call io.recancel() to re-arm the cancellation request without propagating it, or make it unreachable with io.swapCancelProtection(). There is also a rule attached: only logic that requested its own cancellation may safely ignore error.Canceled.
The notes also give the pattern for avoiding resource leaks directly.
var foo_future = io.async(foo, .{args});
defer if (foo_future.cancel(io)) |resource| resource.deinit() else |_| {}
var bar_future = io.async(bar, .{args});
defer if (bar_future.cancel(io)) |resource| resource.deinit() else |_| {}
const foo_result = try foo_future.await(io);
const bar_result = try bar_future.await(io);
The notes explain why cancel is mandatory here — it is this call that frees the async task's resources when an error is returned, error.Canceled included. In other words, defer cancel is not there for cancellation itself, it is there for resource reclamation, and that is a landmine anyone new to Zig is bound to step on.
"Juicy Main" — main Creates the Io
If Io is an argument, who creates the first Io? 0.16.0's answer is main.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const ptr = try gpa.create(i32);
defer gpa.destroy(ptr);
try std.Io.File.stdout().writeStreamingAll(io, "Hello, world!\n");
}
Attaching a std.process.Init parameter to main brings along things that are already initialized — a process-lifetime arena, a default general-purpose allocator, a default Io implementation chosen for the target configuration, an environment variable map, and preopens for WASI. The notes state that in Debug mode it even sets up leak checking where possible.
The first parameter of main can be one of three things: absent (in which case you cannot access CLI arguments or environment variables), process.Init.Minimal (raw argv and environ only), or process.Init.
The notes also explain why environment variables landed here, and the argument is persuasive. Treating the environment as global state is an extremely common abstraction, but a problematic one — calling something like C's setenv in a threaded context is unsound, because environ is commonly accessed directly with no lock at all. On top of that, the Zig standard library had a real pitfall: std.os.environ was meant to be equivalent to C's environ, but there was no way to populate it in a library that does not link libc.
The notes also show how to hastily create an Io in a place that has none, with an honest warning attached.
var threaded: Io.Threaded = .init_single_threaded;
const io = threaded.io();
The notes say this works as long as you do not need task-level concurrency, but call it not an ideal workaround. The analogy is a good one — it is like reaching for std.heap.page_allocator because you need an Allocator and do not have one. For tests, they recommend using std.testing.io, the way you would use std.testing.allocator.
Was the Function Coloring Problem Solved — The Release Notes Never Say So
The most widely repeated summary going around about this release is that "Zig solved the function coloring problem." So I checked. The word "color" does not appear a single time in the full text of the 0.16.0 release notes.
That sentence has a different source. It is Zig's New Async I/O, written by Zig core team member Loris Cro on July 13, 2025. He builds the claim in three steps there — a single library can behave optimally in both synchronous and asynchronous mode; previously, even if source code was free from the contagion of async/await, the runtime was still forced into stackless coroutines, but now io.async and Future.await can be used with a variety of execution models; and so — in his own words — with this final improvement, Zig has completely defeated the function coloring problem.
A caveat is needed here. That post is from July 2025, discussing the plan at the time, not 0.16.0 itself, and it states outright that a standard library rewrite still remained and only a small part of it landed in 0.15.0. And the plan did in fact change. The stackless coroutines Cro mentions never appear in the 0.16.0 notes even once; what actually shipped as Io.Evented is based on stackful coroutines.
There is a counter-argument too. A post by a writer named ivnj, published the same day, July 13, 2025, argues this: semantically, passing std.Io to every function is no different from making every Node.js function async and returning a promise — Zig has only moved the choice of function color from blocking/non-blocking to io/non-io. A function that does I/O requires a std.Io parameter, and such a function can only be called from another function that itself receives std.Io, so the parameter inevitably propagates through the entire call chain.
My own reading is this. Both arguments are correct, and they are talking about different things. The contagion of color remains — Io propagates up the call chain. What is gone is the rigidity of color. In Rust or JavaScript, a function's color pins down its execution model at compile time. A function that takes Io in Zig does not pin down an execution model — the caller picks it later, and the code stays correct even if the caller picks an implementation with no concurrency at all. And Zig developers have already lived with the fact that Allocator propagates up the call chain in the first place. This is not a new color added on top; it is one more color of the same kind sitting next to a color that was already there.
That said, this is not free. The "Lazy Field Analysis" entry in the notes shows one piece of that cost — a problem discovered after turning I/O into an interface: using std.Io.Writer in any form pulls in std.Io's vtable, and in some cases this led to unnecessary code generation that could bloat binaries. 0.16.0 fixed this by deferring type resolution, but the underlying fact that an interface carries a vtable value with it remains.
On the Compiler Side — Incremental Compilation and the New ELF Linker
I/O took the headline, but the compiler-side changes may be more directly useful in practice.
Incremental compilation improved a great deal this cycle. The notes' example case is striking — when using incremental compilation on the Zig compiler itself, a change that used to recompile nearly the whole compiler now finishes in milliseconds. Credit goes to "Reworked Type Resolution." Overhauling type resolution made the compiler's internal dependency graph acyclic (except in the case of dependency loops), and that eliminated "over-analysis" — rebuilding more code than necessary — in most cases.
The problem of incremental builds emitting a "dependency loop" compile error that non-incremental builds never hit has also been fixed. The notes state this was the single biggest inconsistency between incremental and non-incremental builds in previous releases. The LLVM backend now also supports incremental compilation, with an honest caveat attached — this does not speed up the "LLVM Emit Object" stage. That stage belongs entirely to LLVM, and there is little Zig can do about it. What it does speed up is the process of generating LLVM bitcode on the Zig side, and when there is a compile error — since Emit Object is skipped entirely whenever there is an error — you get near-instant feedback even on the LLVM backend.
And here is the most candid sentence in this release. Incremental compilation still has known bugs, miscompilation included, so it remains off by default even in 0.16.0. But the very next sentence recommends turning it on anyway, noting that users are often surprised by the time it saves. You turn it on with zig build -fincremental --watch.
How should this tension be read? My take: if you only use it for compile-error feedback, the risk is close to zero. Miscompilation is ultimately a problem of generating wrong machine code, and code that fails to compile has no machine code to speak of. On the other hand, if you take the binary an incremental build produced and test or ship it as-is, you can run straight into the miscompilation the notes warn about. "On in the dev loop, clean build for what ships" seems like the reasonable line to draw with this release.
The new ELF linker also landed. Turn it on with -fnew-linker, or with exe.use_new_linker = true in your build script, and it is already the default for the combination of -fincremental and an ELF target. The performance data point the notes give is a scenario where you build the Zig compiler, make a one-line change to a function, and then make another one-line change.
Scenario: build the Zig compiler -> one-line change -> another one-line change
Old linker: 14s, 194ms, 191ms
New linker: 14s, 65ms, 64ms (66% faster)
Skip linking entirely: 14s, 62ms, 62ms (68% faster)
What is genuinely interesting in this table is the third row. The new linker is only 3ms away from skipping linking entirely. So the notes say there is not much reason left to expose a -Dno-bin build step — since the compile-speed difference is negligible, it is better to just always leave codegen and linking on, and get an executable out the other end for free.
The cost is clear. The notes explicitly state that this new linker is not feature-complete relative to either the old linker or LLD. The example they give stings — executables built this way have no DWARF info. So both the old linker and LLD are still around, and the plan is to drop the old linker and remove the LLD dependency once the new linker reaches feature parity.
In other words, that 66% figure comes with the condition "if you give up debugging." The 3ms gap tells the same story — a large part of why linking became nearly free is that the linker still is not doing part of the work it used to do.
Backend Status — x86 Is the Default, aarch64 Has Stalled
Self-hosted backend news shows up in every release, but 0.16.0's status needs a clear-eyed look.
The x86 backend got 11 bug fixes this time, and produces better code for constant memcpy. Carrying over the notes' own comparison against the LLVM backend — this backend passes more behavior tests, compiles noticeably faster, has superior debug info, and produces inferior machine code. It remains the default for Debug mode.
These four points explain precisely why it stays Debug-only. In the dev loop, compile speed and debug info are everything and machine code quality barely matters. In a release build, it is exactly the opposite. For reference, the LLVM backend passes 2,004 of 2,010 behavior tests (the notes round this to 100%). The x86 backend passes more than that.
The aarch64 backend is bad news. In the notes' own words — it is still a work in progress, progress on it stalled this release cycle as a side effect of "I/O as an Interface," and running behavior tests against it currently crashes. The notes say they expect progress to pick back up once the standard library settles down.
This shows the opportunity cost of this release plainly. The I/O interface did not come for free — it consumed a cycle of the aarch64 self-hosted backend to get here. For anyone waiting on fast self-hosted compilation on Apple Silicon or ARM servers, 0.16.0 is not progress, it is a pause. The WebAssembly backend passes 1,813 of 1,970 behavior tests (92%).
There is a painful item on the toolchain side too. 0.16.0 moved to LLVM 21.1.0, and in doing so, turned off loop vectorization entirely. The reason is serious — that regression miscompiles the compiler itself in common configurations. The workaround of disabling a specific CPU feature was judged too fragile, so vectorization is off entirely until they move to an LLVM version where the bug is fixed. The notes acknowledge this makes codegen worse in some cases, but say it is still better than miscompilation. And this performance regression is expected to affect not just 0.16.x but 0.17.x too, resolving only in 0.18.x. In other words, upgrading to 0.16.0 means carrying this penalty for two releases.
There is good news too. translate-c switched from a libclang base to an arocc base, removing 5,940 lines of C++ from the compiler source tree (3,763 lines remain). It is one step in the journey of turning a library dependency on LLVM into a process dependency on Clang. Bounds checking for for-loops over slices was improved to generate about 30% less code, and the newly added deflate compression was 9.7% ± 0.2% faster in wall-clock time than zlib at the default compression level (though zlib compresses 1.00% better and, interestingly, executes 18.9% more instructions — a case where fewer cache misses and branch mispredictions won out on time).
The Upgrade Bill — What Breaks
"All I/O takes an Io" is, from an upgrade standpoint, the same sentence as "all code that does I/O is broken."
Start with what got removed. Io.GenericWriter, Io.AnyWriter, Io.null_writer, and Io.CountingReader are gone. SegmentedList, meta.declList, and Thread.Mutex.Recursive are gone too. The formatting API changed — fmt.format became std.Io.Writer.print, Formatter became Alt, FormatOptions became Options, and bufPrintZ became bufPrintSentinel.
Error sets got cleaned up too. error.RenameAcrossMountPoints and error.NotSameFileSystem were merged into error.CrossDevice; error.SharingViolation became error.FileBusy; error.EnvironmentVariableNotFound became error.EnvironmentVariableMissing. DynLib lost Windows support, and the notes' comment on it is funny — users now have to call LoadLibraryExW and GetProcAddress directly, and everyone was probably already doing that anyway.
The language itself moved too. @Type was replaced by individual type-construction builtins; @cImport is moving into the build system (the builtin stays for now, but Aro backs it); runtime vector indexing is now forbidden; and pointers are now forbidden inside packed structs and unions. "Reworked Type Resolution" is, overall, more permissive, but not strictly more permissive — some code that used to compile now hits a dependency loop error.
This brings back the Ghostty story. When Hashimoto said "everything that outputs anything" changed in 0.15, that was not the end, it was the beginning. 0.16.0 overhauled that same family of writers again. The reason he welcomed it — that Andrew Kelley pushes through changes he judges necessary without flinching — holds up again here. And whether you can welcome that attitude is really the test of whether you can use this language at all.
The release notes have a section titled "This Release Contains Bugs." This cycle closed 345 bug reports, but the notes state that Zig still has known bugs, miscompilations, and regressions. And the decisive sentence — doing anything nontrivial with Zig, even on 0.16.x, can require participating in the development process. They add that once 1.0.0 lands, a bug policy will be added as a requirement for Tier 1 support. Read the other way around, that means there is no such policy right now.
So, Should You Use Zig Right Now
Summed up, the decision splits like this.
When it makes sense
- You are building tools or infrastructure code, and your team can absorb the toolchain's breaking changes — an organization where upgrading is routine, not an event.
- You genuinely need an injectable I/O abstraction — the same library running single-threaded in a CLI and on a thread pool on a server. This is the thing 0.16.0 actually delivered.
- Cross-compilation and C interop are core requirements. Zig's strength here holds regardless of the noise around 0.16.0.
- Compile speed is your development-experience bottleneck, and the fast error feedback from
-fincremental --watchalone is worth it.
When it is not yet
- You need a stable API. 0.15 changed the writer, 0.16 changed all of I/O. No one is promising what 0.17 changes. Before 1.0, expecting "this is the last big change" in Zig has no basis.
- You need production async I/O running on io_uring right now. What 0.16.0 gave you is a proof of concept — no networking, no error handling, no test coverage yet.
- You need fast self-hosted-backend compilation on aarch64. Progress stalled this cycle, and it currently crashes on behavior tests.
- Machine code quality in release builds is your top priority. The notes themselves say the self-hosted x86 backend produces inferior machine code, and on top of that, LLVM loop vectorization is off until 0.17.x.
- Your team cannot absorb "when you hit a language bug, you participate upstream." That is not my assessment — it is a warning written directly into the release notes.
Closing
What I took away from 0.16.0 is more about design than about the language itself.
Zig's habit of passing Allocator as an argument was never really a story about memory. It was a story about "the caller decides policy, the library only provides the mechanism." 0.16.0 pushed that same principle onto I/O, and along the way split a distinction most async runtimes blur — things that are allowed to be async versus things that must be concurrent — into io.async and io.concurrent. Treating cancellation as a request rather than a command, and implementing cancellation in a thread pool with signals and EINTR, are cut from the same cloth of honesty.
Whether the function coloring problem was "defeated" is, I think, a matter of definition — Io still propagates up the call chain. But given that what propagates is a choice, not an execution model, I think that is a meaningful difference. And the fact that the release notes never declared that victory themselves — that sentence lives on a core team member's personal blog, not in the official notes — only raises my trust in this team.
At the same time, the bill is real. There is exactly one finished implementation, aarch64 has stalled, incremental compilation is off by default because of miscompilation, the new linker cannot produce DWARF, LLVM vectorization is off for two releases, and any code that touches I/O has to be rewritten. The release notes hide none of this — nearly every negative fact in this post, in fact, came straight from the notes themselves.
That is probably the most accurate summary of this release. Zig is still not a "finished product," it is an "argument in progress," and the release notes are the minutes of that argument. If you are willing to sit in on that meeting, 0.16.0 is an interesting release. If you came here to buy a product, read the "This Release Contains Bugs" section at the very bottom of the notes first. That section is not there as boilerplate liability language — it is an accurate statement of where this language actually stands right now.
References
- Zig 0.16.0 Release Notes — the official release notes (source for nearly every fact and number in this post)
- 0.16.0 Released — official announcement (April 14, 2026, 244 contributors, 1,183 commits)
- ziglang.org download index — per-version release dates (0.16.0: 2026-04-13)
- Zig's New Async I/O — Loris Cro, July 13, 2025 (source of "completely defeated function coloring," describing the plan at the time)
- Zig's new I/O: function coloring is inevitable? — ivnj, July 13, 2025 (the counter-argument)
- The founder who went back to the terminal: Hashimoto on Ghostty, Zig, and open source with "no obligation" (related post)