Threadlocal variable for std.Io.async / std.Io.concurrent

Is there any way to make a global variable that is local to each task created by Io.async and Io.concurrent, similar to threadlocal?

For context, I am currently working on a project in which I would need to pass common instances like allocators to almost all functions. In this case, the allocator instance will almost never change, and I would need to pass along the same instance in every function manually. Although I consider it to be unidiomatic, I decided it is better to make these common instances as shared global variables, and have functions that need to pass different instances replace the global instance temporarily. I have made an example to show what exactly I want to do:

const std = @import("std");
const Allocator = std.mem.Allocator;

pub threadlocal var global_allocator: Allocator = undefined;

pub fn main(init: std.process.Init) !void {
    global_allocator = init.gpa;

    // The allocator is implicitly "passed" to the function.
    try foo();
    try bar();
}

// The global allocator is replaced for only one function call.
pub fn callWithAllocator(
    new_allocator: Allocator,
    function: anytype,
    args: anytype,
) (@typeInfo(@TypeOf(function)).@"fn".return_type orelse @compileError("Return type is null")) {
    const prev_allocator = global_allocator;

    global_allocator = new_allocator;
    defer global_allocator = prev_allocator;

    return @call(.auto, function, args);
}

fn foo() !void {
    var buffer: [256]u8 = undefined;
    var fba = std.heap.FixedBufferAllocator.init(&buffer);

    // bar() will use the FixedBufferAllocator from above instead of the default init.gpa
    try callWithAllocator(fba.allocator(), bar, .{});

    var arena = std.heap.ArenaAllocator.init(global_allocator);
    defer arena.deinit();
    try callWithAllocator(arena.allocator(), bar, .{});
}

fn bar() !void {
    std.debug.print("global_allocator: {any:016}\n", .{global_allocator.ptr});

    var list = try std.array_list.Aligned(u8, null).initCapacity(global_allocator, 1);
    defer list.deinit(global_allocator);

    try list.append(global_allocator, 21);
    try list.append(global_allocator, 27);
    try list.append(global_allocator, 41);
    try list.append(global_allocator, 47);
}

However, if I understand the documentation correctly, threadlocal seems to only be meaningful when using actual os-level threads. If the Io implementation uses green threads instead, the global_allocator could change unexpectedly if the thread is interrupted and a context switch happens.

Is there any way to make the Io implementation respect this variable, so each context gets its own local instance?

No, this is not possible using the Io interface.

You will need an implementation-specific solution. If you are using std.Io.Threaded, you can use threadlocal variables. If you are using std.Io.Evented, you don’t have any good option. You would need your own “task ID” that you assign when using io.concurrent and then look up the data using that ID. Best to just work it around and always pass context.

If you are, by chance, using my zio implementation, I recently added zio.TaskLocal.

2 Likes

Threadlocal variables should be banned. They have all the problems of local variables, with the added qualities of being inefficient and breaking fibers. Just create a struct in you thread start function, and pass a pointer to it around your functions. More efficient and readable.
Task local variables would have the same problems.

I understand that implementing things like these is cool, but just because you can, doesn’t mean that you should.

3 Likes

All Io implementations, including the ones in std, use threadlocals. It’s unavailable for certain problems, but I agree that it should be kept to the unavoidable cases.