A purely hypothetical question, just trying to wrap my head around things. So, let’s say I have
threadlocal var buf: [16_384]u8 = undefined;
pub fn doSomethingImportantWithBuf(arg1, ...) !void {
// prepare buf
@memcpy(buf[ .. ], argX);
...
moduleX.call_function_f1(); // There are some functions invoked here.
...
// finish working with buf.
writeToFile(..., buf);
}
How do I know whether function moduleX.call_function_f1 is fully synchronous, and it won’t fetch the global io instance (or io kept in closure/anonymous struct in a callback), then fill the buffer or directly .flush(io), which would invite some other code to kick in concurrently in this thread in the interim, possibly clobbering content in my localthread variables?
In TypeScript, functions are marked as returning Async<T>, and ESLint forces you to insert await, explicitly indicating that something might concurrently get scheduled for execution (see “microtask” and “macrotask” in JavaScript V8) in the current thread. Reading Zig code, how do I know whether a function (or some of the functions that this function calls, recursively) switches the context?
You know it’s synchronous as you dont call it via io.async or io.concurrent.
Based on its name, I assume moduleX is not an instance of a type, but some import instead. If that’s the case, then it can’t be passed implicitly, and you don’t pass anything explicitly, so there is no chance of a non-global Io being used.
The only way to be sure a global Io is not used is to read the source, but it should be well known that it would be nonconducive to portable code and therefore is not idiomatic zig. I think it’s reasonable to assume the author of that code is aware of that, and that if they need to do otherwise they should have documented that, either on the function or for their whole library/module.
Your thread local is private, and the code you are calling is presumably from an import. There is no way for that code to access your thread local variable without doing some incredibly unsafe memory manipulation. It is reasonable to assume that won’t happen.
zig is much the same, asynchronous or concurrent tasks return a Future(T), you can’t access the return value without calling await or cancel.
There is the detail that zig does not require the task to be run on the current thread.
Zig also has the benefit of (in idiomatic code) passing the runtime explicitly, so if a function/type does not take a runtime you can reasonably assume it doesn’t do async/concurrent things internally either.
Lastly, there is the caveat that the function being defined does not decide if itself is called asynchronously, concurrently or synchronously. That is instead decided by the caller, if they do foo(arg1) then it’s synchronous, if they io.async(foo, .{arg1})/io.concurrent(foo, .{arg1}) then it is asynchronous/concurrent, and they get a future, they can propagate that future up if they want/need but in idiomatic zig this is not common.
the function can ofc require that it be called concurrently to get the desired behaviour, that should be documented.
Question is based on an invalid premise. This is not a justified use of threadlocal var. This function should accept a pointer to the buffer. As written, the function has an unnecessary and problematic dependency on which thread it executes on, as well as on thread local storage in general, which relies on linker features which may have portability issues.
/// Here's an exact function that is invoked from Node.js, which loads Zig's dynamic library:
pub fn logBuffer(e: napi.napi_env, cb_info: napi.napi_callback_info) callconv(.c) napi.napi_value {
// I want to use Zig's IO_Uring I/O here!
}
Sure. It doesn’t have to be. I could be massaging a globally accessible pointer to mmap()ed memory, with a mutex currently obtained for exclusive access. Now, I call a function, it calls another function, which calls another function, which decides to log something using Zig’s IO_Uring I/O. The log Writer’s buffer gets filled, the current fiber gets unscheduled pending the completion of I/O, another microtask kicks in, it also locks the mutex (which happens to be a Recursive mutex), gets through because the mutex’ thread ID matches, and happily continue clobbering the mmap’ed memory because it thinks that it obtained exclusive access.
But that’s besides the point. So, you’re saying, my current task can’t get unscheduled because I must explicitly await in order for it to happen, and that doesn’t automatically happen in parallel? I definitely need to understand this API better.
In order to correctly use std.Io.Evented (which on Linux is Zig’s IO_Uring implementation of std.Io), all mutexes must be Io.Mutex. Otherwise, you’re interacting with threaded logic and you must therefore use std.Io.Threaded. Note that Zig no longer provides a recursive mutex. It was removed in #31109 - std.Thread: delete Mutex.Recursive - ziglang/zig - Codeberg.org.
For this particular use case, do you really want two independent event loops? It seems to me what you really want is to integrate with libuv.
Any function that is part of the Io interface is allowed to switch context. For example, you could be using writer.interface.writeAll(foo) and that can suspend the task and switch context. Worse than that (for your use case), when the function returns, you could be on a different thread and your threadlocal is empty.
But in general, you should be responsible for what happens in the program. If you don’t want that to happen, don’t use evented I/O implementation and you are safe.
I’d like to retain ability to read and understand Zig code in light of evented I/O.
In Rust, TypeScript, Python, etc the absence of the `await` statement in the function body ensures that the function is linear, and it will run end-to-end without concurrent code (fibers) interference. Sure, OS scheduler might pause and unpause the thread, but that’s parallelism, not concurrency, and a completely separate unrelated concern.
fn legitimateEventedFn(io: Io, context: *MyContext) !void {
try context.mutex.lock(io);
defer context.mutex.unlock(io);
// This is a fiber, currently locking evented I/O non-recursive mutex.
// How do I instruct the Zig compiler that anything that runs here must be free of evented I/O context switches?
// I want a @compileError if any of the code written here might trigger a context switch.
// In Python, Rust, Typescript, etc if there is no await, I know for sure that this thread won’t attempt to execute any other code unintentionally, which makes it explicitly clear and easy to reason about.
...
}
In other words, there are snippets where I want the fiber switch to be strictly under my control, so I could reason about the program’s execution flow.
In colorless world, how do I reason about the linearity of code I’m writing? Can Zig compiler help me ensure that certain segments of my code are free of evented I/O side effects and cannot possibly cause context switch?
What you implemented is truly magical, and I love it! Now, how do I tame the magic?
The compiler can’t help you, because stackful coroutines completely escape the compiler. The io parameter in Zig 0.16 is a hint, but not a prevention, because it can be stored anywhere. The only answer is, don’t do I/O of any sort in those critical sections.
I could be wrong, but I would think/hope that if some library has some global io instance that’s not your io, then calls to its library functions that aren’t given access to your io will be fully blocking from the perspective of your io, regardless of whether they use that distinct internal io instance. If I’m wrong I would love understand why!
I suppose that it’s possible for the library to store your io instance for later use following some earlier call to which you did pass it, but that seems downright malicious…
FWIW, what you are philosophically struggling with is more of a deficiency of fibers than Zig’s IO system. async/await does not magically make the problem go away: care has to be taken to avoid thread sensitive work in fibers, period.
Here is a good reference for why that is the case.
Thank you all for your responses. That was very helpful.
And especially thank you for this, I finally got it!
I never truly understood the dichotomy between stackless (JavaScript, C++, Rust) and stackful coroutines. Once I looked it up, it clicked. I also got really confused by colorless/colorful functions, which turned out to be immaterial.
I know, you suggested this multiple times, and I’m unable to provide a good response. I am currently trying to entirely isolate Zig from Node.js libuv, and keep them separate for easier system understanding, debugging, and maintenance. I also don’t understand why would I want this, albeit I might be, as usual, missing something. If you have a good scenario in mind, sure. My team currently doesn’t need this, and I don’t anticipate why we would ever want to marry Zig’s IO_Uring with libuv. Zig stuff seems to be superior in both design and implementation. At least, this is my current take on this.