Pointers to global variables are considered comptime-known in Zig. This fact allows us to synthesize multiple functions by binding one function to multiple variables. Here’s a simple example:
const std = @import("std");
const PFJMember = struct {
name: []const u8,
pub fn createGreetFn(comptime self: *@This()) fn () void {
const ns = struct {
fn greet() void {
std.debug.print("Hello, {s}!\n", .{self.name});
}
};
return ns.greet;
}
};
var brian: PFJMember = .{ .name = "Brian" };
var loretta: PFJMember = .{ .name = "Loretta" };
pub const greetBrian = brian.createGreetFn();
pub const greetLoretta = loretta.createGreetFn();
pub fn main() void {
greetBrian();
greetLoretta();
}
This is a very useful technique. Everything falls apart though when a variable needs to be thread-local. The problem, of course, is the fact that we’re now dealing with multiple variables. A regular pointer can’t point to all of them simultaneously.
To get around this problem, I’m purposing the addition of a new builtin: @tlsCast(). The builtin accepts a pointer to a threadlocal variable and returns a pointer in the .tls address space (doesn’t exist yet). It’d yield the offset into a thread’s thread-local memory, basically.
To bind a threadlocal variable to a function we would do this:
threadlocal var reg: PFJMember = .{ .name = "Reg" };
pub const greetReg = @tlsCast(®).createGreetFn();
Since @tlsCast() returns a pointer of a different type, createGreetFn() in the example would need to accept anytype instead of *@This():
pub fn createGreetFn(comptime ptr: anytype) fn () void {
const Self = @This();
const ns = struct {
fn greet() void {
const self: *Self = switch (@typeInfo(@TypeOf(ptr))) {
.pointer => |pt| switch (pt.address_space) {
.generic => ptr,
.tls => @addrSpaceCast(ptr),
else => @compileError("Unexpected address space"),
},
else => @compileError("Pointer expected"),
};
std.debug.print("Hello, {s}!\n", .{self.name});
}
};
return ns.greet;
}
@addrSpaceCast() would basically add address of the current thread’s thread-safe storage to the comptime-known offset.
If this effect is the only thing you need, this is already possible using a different technique:
const std = @import("std");
threadlocal var foo: u64 = 0;
threadlocal var bar: u64 = 0;
fn createCounterFunction(T: type, comptime name: []const u8) fn ([]const u8) void {
return struct {
fn counter(threadName: []const u8) void {
@field(T, name) += 1;
std.debug.print("On the {s} thread, {s} has been called {d} time(s).\n", .{ threadName, name, @field(T, name) });
}
}.counter;
}
const fooCount = createCounterFunction(@This(), "foo");
const barCount = createCounterFunction(@This(), "bar");
pub fn main(init: std.process.Init) !void {
fooCount("main");
barCount("main");
var future = init.io.async(otherThread, .{});
future.await(init.io);
fooCount("main");
barCount("main");
}
fn otherThread() void {
fooCount("other");
barCount("other");
}
Prints:
On the main thread, foo has been called 1 time(s).
On the main thread, bar has been called 1 time(s).
On the other thread, foo has been called 1 time(s).
On the other thread, bar has been called 1 time(s).
On the main thread, foo has been called 2 time(s).
On the main thread, bar has been called 2 time(s).
Oh, actually, I think I prefer this form more:
const std = @import("std");
const foo = Counter("foo");
const bar = Counter("bar");
fn Counter(name: []const u8) type {
return struct {
threadlocal var counter: u64 = 0;
fn count(threadName: []const u8) void {
counter += 1;
std.debug.print("On the {s} thread, {s} has been called {d} time(s).\n", .{ threadName, name, counter });
}
};
}
pub fn main(init: std.process.Init) !void {
foo.count("main");
bar.count("main");
var future = init.io.async(otherThread, .{});
future.await(init.io);
foo.count("main");
bar.count("main");
}
fn otherThread() void {
foo.count("other");
bar.count("other");
}
Prints same output.
Note that the capture of name by the struct is important. If you’re not passing a value there, you’ll need to pass something unique. See the 0.12 release notes.
vulpesx July 24, 2026, 3:18am 4
There are two other fundamental issues with this.
Most importantly, they are var, and mutability cannot pass between runtime and comptime, in either direction, even if the pointer is const.
Allowing this for thread locals would be inconsistent with the rest of the language.
The second issue is that thread locals are allocated at runtime when spawning threads, which complicates lowering comptime known pointer to runtime, though it should still be possible.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.