
如果你正在寻找一门足够“贴近底层”、没有隐藏魔法、行为可预测,同时又比 C 更安全、更现代的系统编程语言,那么 Zig 正在成为越来越多工程师的首选。
你可以把 Zig 粗略地理解为 “21 世纪的 C 语言”:
它尤其适合以下场景:
本文的目标非常明确,我们将分三步走:
如果你想用尽量短的路径上手 Zig,这篇博客就是为你准备的最佳向导。
附本文工程的代码仓库:git@github.com:JanYork/zig-group-chat.git

Zig 为什么会出现?它究竟想解决 C / C++ 世界里的哪些“老大难”问题?
Zig 的作者 Andrew Kelley 在长期维护 C/C++ 代码、构建工具链和底层库时,经常被以下问题折磨:
于是,Zig 在 2015 年作为一门实验性语言诞生了。它的发展路线非常清晰:
❝保持语言本身小而稳,把“聪明”和复杂度移到编译期能力和工具链上。
一句话总结 Zig 的野心:解决 C 的痛点,但绝不变成另一个 C++。
针对 C 的核心问题,Zig 给出了它的答案:
!T 错误联合类型,强制开发者处理错误。语言 | 特点 | Zig 的态度 |
|---|---|---|
C | 简单、可控,但陷阱多 | 保留可控性,消灭隐式陷阱 |
Rust | 极度安全,但学习曲线陡峭 | 不做借用检查,用“显式 + 约定”达成足够的安全 |
Go | 开发快,但有 GC,不适合极低层 | 提供无 GC 的环境,掌控每一个字节 |
C++ | 功能极其强大,复杂度爆炸 | 刻意保持语言精简,不走“万能瑞士军刀”路线 |
可以将 Zig 理解为:在 C 的基础上加上了现代安全性和强力工具链,同时减去了 C++ 的复杂度。
Zig 不追求“语法花哨”,它的设计哲学可以概括为四个词:显式、可预测、无魔法、编译期。
Zig 的核心信条是:永远不要猜测语言在背后帮你做了什么。
在 Zig 中:
defer)。malloc)。这意味着,你看到的每一行代码,就是机器将要执行的全部逻辑。对于系统级开发,这种“所见即所得”的安全感是无价的。
Zig 的错误处理没有 Try-Catch 的性能损耗,也没有 Go 语言 if err != nil 的啰嗦。
**错误类型 !T**:
fn readFile() ![]u8 { ... }
这表示:函数要么返回 []u8 数据,要么返回一个错误。
try 关键字:
const data = try readFile();
如果 readFile 成功,data 拿到数据;如果失败,当前函数立刻停止,并将错误向上层抛出。这相当于 Rust 的 ? 操作符。
catch 关键字:
const v = tryToGet() catch 0; // 出错就用默认值 0
comptime:编译期超能力这是 Zig 最具杀伤力的特性。你可以在编译阶段执行普通的 Zig 代码。 这意味着你不需要学习复杂的宏语法或模板元编程,就能实现:
Zig 没有垃圾回收(GC)。它采用了显式的 Allocator 模式。 标准库提供了多种分配器:
page_allocator:直接向操作系统申请页。GeneralPurposeAllocator:通用的堆分配器,Debug 模式下能检测内存泄漏(Double free, Leak)。ArenaAllocator:竞技场分配器,适合阶段性任务,用完后可以一次性释放整个内存块。让我们快速扫盲 Zig 的基础语法,为后面的实战做准备。
const std = @import("std");
pub fn main() !void {
// .{} 是一个匿名结构体,用于传入格式化参数
std.debug.print("Hello, Zig!\n", .{});
}
Zig 是一门强类型语言,但支持推导。
const x = 123; // 推导为 comtime_int 或 i32
var y: i32 = 50; // 显式声明变量
const name = "Zig"; // 字符串本质是字节数组切片
这是新手最容易混淆的地方。
[5]u8。[]u8。切片内部包含一个指针和一个长度。*u8。Zig 的指针不支持类似 C 的随意运算,更加安全。var arr = [_]u8{1, 2, 3, 4, 5};
const slice = arr[1..4]; // 切片指向 arr 的一部分
安装 Zig(推荐): 直接去 Zig 官网 下载对应平台的压缩包,解压后将 bin 目录加入环境变量 PATH 即可。
常用命令:
zig run main.zig:编译并直接运行。zig build-exe main.zig:编译生成可执行文件。zig build:使用内置构建系统(读取 build.zig)。交叉编译(Zig 的杀手锏):
# 在 Mac 上编译 Linux 程序,无需安装任何额外工具
zig build-exe main.zig -target x86_64-linux
我们将使用标准库 std.net 进行开发。为了让大家专注于逻辑,我们将采用 同步阻塞 I/O 模型。
核心概念:
std.net.StreamServer**:用于监听端口。std.net.Stream**:代表一个建立好的 TCP 连接。Reader / Writer**:从 Stream 中获取的读写接口,用法类似文件读写。目标:实现一个简易的聊天室服务。协议:纯文本协议,以换行符 \n 作为消息结束标志。
开发阶段:
目标:服务端监听端口,客户端连接成功即退出。
// src/server.zig
const std = @import("std");
const net = std.net;
pub fn main() !void {
// 1. 解析地址:0.0.0.0 表示监听本机所有网卡
const address = try net.Address.parseIp4("0.0.0.0", 9000);
// 2. 开始监听
// .{} 是初始化配置结构体,使用默认配置
var server = try address.listen(.{});
// defer 确保在 main 函数退出前释放资源
defer server.deinit();
std.debug.print("Server listening on 0.0.0.0:9000...\n", .{});
// 3. 阻塞等待连接
const conn = try server.accept();
defer conn.stream.close();
std.debug.print("A client connected! Exiting now.\n", .{});
}
defer**:这是 Zig 最常用的资源管理关键字。无论函数是正常结束还是因为 try 报错退出,defer 后面的代码都会执行。这完美解决了 C 语言中“忘记关闭 Socket”的问题。try**:address.listen 可能会失败(比如端口被占用),使用 try 意味着“如果失败,直接把错误抛给 main 函数并在终端打印错误信息”。// src/client.zig
const std = @import("std");
const net = std.net;
pub fn main() !void {
// 使用通用分页分配器
const allocator = std.heap.page_allocator;
// 建立连接
var conn = try net.tcpConnectToHost(allocator, "127.0.0.1", 9000);
defer conn.close();
std.debug.print("Connected to server! Exiting.\n", .{});
}
运行测试:先运行 Server,再运行 Client,你应该能看到连接成功的日志。
现在我们让服务端不要“连上就退”,而是不断读取数据并原样发回。
修改 server.zig 的 main 函数:
// ... 前面监听代码不变 ...
// 接受连接
const conn = try server.accept();
defer conn.stream.close();
std.debug.print("Client connected.\n", .{});
var buf: [1024]u8 = undefined; // 声明一个缓冲区
// 循环读取
while (true) {
// stream.read 返回读取到的字节数
const len = conn.stream.read(&buf) catch |err| {
std.debug.print("Read error: {any}\n", .{err});
break;
};
// 关键:如果读到 0 字节,说明客户端主动断开了连接(EOF)
if (len == 0) break;
// 将收到的内容(切片 buf[0..len])原样写回
conn.stream.writeAll(buf[0..len]) catch |err| {
std.debug.print("Write error: {any}\n", .{err});
break;
};
}
std.debug.print("Client disconnected.\n", .{});
undefined**:var buf: [1024]u8 = undefined。在 C 中未初始化的数组包含垃圾值。在 Zig 中显式写 undefined 告诉编译器:“我知道这里没初始化,我稍后会覆盖它”。这比赋 0 值性能更高,且在 Debug 模式下如果误读未初始化的内存,Zig 会报错。buf[0..len]**:这是典型的切片用法。如果读到了 5 个字节,我们只把前 5 个字节发回去,而不是整个 1024 字节的 buffer。为了支持多人聊天,我们需要引入多线程和全局状态管理。
在 server.zig 顶部定义全局变量:
const std = @import("std");
const net = std.net;
// 简单的连接 ID 计数器
var next_id: usize = 1;
// 客户端池:最多支持 128 人同时在线
const max_clients = 128;
// 类型为 ?net.Stream,表示可能是 Stream,也可能是 null
var clients: [max_clients]?net.Stream = [_]?net.Stream{null} ** max_clients;
// 互斥锁:保护 clients 数组不被多线程同时修改
var clients_mutex = std.Thread.Mutex{};
我们将逻辑封装到一个单独的函数中,每个客户端一个线程。
fn handleConnection(conn: net.Server.Connection, id: usize) void {
std.debug.print("[{d}] Connected.\n", .{id});
// Step 1: 注册客户端(加锁)
{
clients_mutex.lock();
defer clients_mutex.unlock();
for (clients, 0..) |c, i| {
if (c == null) {
clients[i] = conn.stream;
break;
}
}
}
// Step 2: 确保退出时清理(移除客户端 + 关闭连接)
defer {
clients_mutex.lock();
defer clients_mutex.unlock();
for (clients, 0..) |c, i| {
// 比较文件句柄来确认身份
if (c != null and c.?.handle == conn.stream.handle) {
clients[i] = null;
break;
}
}
conn.stream.close();
std.debug.print("[{d}] Disconnected.\n", .{id});
}
// Step 3: 广播循环
var buf: [1024]u8 = undefined;
while (true) {
const len = conn.stream.read(&buf) catch break;
if (len == 0) break; // EOF
// 广播给所有人(加锁)
clients_mutex.lock();
for (clients) |c| {
if (c) |stream| { // 语法糖:如果 c 不为 null,解包为 stream
// 忽略发送错误,继续发给下一个人
_ = stream.writeAll(buf[0..len]) catch {};
}
}
clients_mutex.unlock();
}
}
pub fn main() !void {
const address = try net.Address.parseIp4("0.0.0.0", 9000);
var server = try address.listen(.{});
defer server.deinit();
std.debug.print("Chat Server ready on 9000...\n", .{});
while (true) {
// 接受新连接
const conn = try server.accept();
const id = next_id;
next_id += 1;
// 启动新线程
// .{} 是 spawn 的配置,最后是参数元组
const thread = try std.Thread.spawn(.{}, handleConnection, .{ conn, id });
// detach:让线程独立运行,主线程不等待它结束
thread.detach();
}
}
客户端需要同时做两件事:
为了不阻塞彼此,我们需要两个线程。
fn recvLoop(stream: *net.Stream) void {
var buf: [1024]u8 = undefined;
while (true) {
const len = stream.read(&buf) catch break;
if (len == 0) break;
std.debug.print("[recv] {s}\n", .{buf[0..len]});
}
std.debug.print("Server closed connection.\n", .{});
}
pub fn main() !void {
const allocator = std.heap.page_allocator;
var conn = try net.tcpConnectToHost(allocator, "127.0.0.1", 9000);
defer conn.close();
std.debug.print("Chat Room Joined! Type 'quit' to exit.\n", .{});
// 启动接收线程
// 注意:传 &conn 指针,而不是复制 conn 对象
var recv_thread = try std.Thread.spawn(.{}, recvLoop, .{&conn});
defer recv_thread.join(); // 等待接收线程结束
var buf: [1024]u8 = undefined;
const stdin = std.io.getStdIn(); // 获取标准输入
while (true) {
std.debug.print("> ", .{});
// 从 stdin 读取一行
// 兼容性提示:Zig 0.15+ 推荐使用 std.posix.read 或 std.io.getStdIn().read
const len = try std.posix.read(std.posix.STDIN_FILENO, &buf);
if (len == 0) break;
const line = buf[0..len];
if (std.mem.eql(u8, std.mem.trimRight(u8, line, "\r\n"), "quit")) {
break;
}
try conn.writeAll(line);
}
}
recvLoop 接收 *net.Stream 指针。这是为了让两个线程共享同一个连接对象,而不是复制一份。在项目根目录下:
zig build-exe server.zigzig build-exe client.zig./server./client现在,你在一个终端输入消息,其他终端都能实时收到了!
这个 Demo 虽然简单,但它已经包含了系统编程的核心要素:资源管理、并发、网络 I/O。如果你想把它变成生产级应用,可以考虑:
kqueue/epoll 或 Zig 的 async(待回归)替换多线程阻塞模型,以支持万级并发。Zig 的魅力不在于它有多炫酷的语法糖,而在于它的诚实。
它不会在背后偷偷分配内存,不会莫名其妙地抛出异常,也不会隐式地转换数据类型。它将控制权完完整整地交还给了你——作为开发者,这种既拥有 C 的掌控感,又拥有现代语言工具链的体验,是非常美妙的。
希望这篇指南能成为你系统编程之路的一块垫脚石。现在,去写点代码吧,Zig 的世界比你想象的更广阔。