Vala Async/Await: A State Machine for I/O, Not a Thread Pool
Vala's yield keyword turns sequential-looking code into GIO's begin/end callback pattern. It keeps I/O-bound code readable, but it won't parallelize CPU work and punishes blocking calls.
29 Apr 2026, 03:31 UTC

You need to read a file or hit a socket without freezing your GTK window or CLI tool. GIO hands you _begin and _end function pairs with callbacks. Vala offers async/await with a yield keyword. If you come from C# or JavaScript, you might assume await schedules work on a thread pool. It doesn't. Vala's async/await is a compiler transformation that builds a state machine on top of GIO's callback convention. It makes I/O-bound code read sequentially, but the thread that started the call keeps running, and nothing spawns a worker.
What yield actually compiles to
When you mark a method async and hit yield, the Vala compiler splits the method into a _begin function and a _end function, mirroring the pattern GIO already uses. The yield suspends execution and returns control to the caller. When the underlying GIO operation finishes, the main loop invokes the callback, which resumes your coroutine at the exact point it left off. The result is code that looks top-to-bottom sequential but never blocks the thread that started it.
This is why async code requires a running main loop. If you call an async method from main and never enter a MainLoop, the coroutine starts, hits yield, and never resumes.
One pending operation per coroutine
A single async method invocation can have only one pending async operation at a time, because yield suspends the whole coroutine. If you need to overlap two file reads, you cannot simply write:
// Bad: this waits for the first read before starting the second
var a = yield read_file ("a.txt");
var b = yield read_file ("b.txt");
Instead, launch both operations from a non-async context or from a point before you yield, then await each result separately:
async void fetch_two () throws Error {
var f1 = File.new_for_path ("a.txt");
var f2 = File.new_for_path ("b.txt");
var op1 = f1.load_contents_async (null);
var op2 = f2.load_contents_async (null);
var d1 = yield op1;
var d2 = yield op2;
}
Blocking calls stall the world
Async code runs on the thread-default GMainContext of the calling thread. Scheduling is cooperative: any blocking call inside an async method — synchronous file I/O, Thread.sleep, a long computation — stalls every other async operation on that main context. This is the most common way Vala async code goes wrong. If you need CPU-bound parallelism, use GLib.Thread, a GLib.ThreadPool, or hand work to a task that runs in a worker. yield is for I/O-bound waiting, not for spreading work across cores.
Worked example and how to check it
Here is a minimal Vala program that reads a file asynchronously and handles errors with ordinary try/catch. Save it as demo.vala.
async uint8[] read_file (string path) throws Error {
var file = File.new_for_path (path);
return yield file.load_contents_async (null);
}
void main () {
var loop = new MainLoop ();
read_file.begin ("demo.txt", (obj, res) => {
try {
var data = read_file.end (res);
stdout.printf ("read %zu bytes\n", data.length);
} catch (Error e) {
stderr.printf ("failed: %s\n", e.message);
}
loop.quit ();
});
loop.run ();
}
Compile and run it from your project directory. You need valac and the gio-2.0 development packages installed:
valac --pkg gio-2.0 -g demo.vala
./demo
Expected check: the program prints read N bytes (or an error) and exits. If it hangs, the main loop is not running or the file path is wrong.
To confirm the toolchain and the GLib/GIO version whose async API surface you are compiling against:
valac --version
pkg-config --modversion gio-2.0
To see what the compiler actually generated, inspect the state machine:
valac --cc-only --save-temps demo.vala
Look for the _begin and _end split and the GAsyncReadyCallback wiring in the emitted C. Debug with Vala line info (-g --save-temps) rather than reading the state machine directly.
Run under G_MESSAGES_DEBUG=all to observe main-loop and warning output while the coroutine is suspended:
G_MESSAGES_DEBUG=all ./demo
Trade-offs worth knowing
Cancellation is per-API. Methods that accept a GLib.Cancellable can be interrupted, but the async machinery does not cancel your coroutine for you, so you must plumb cancellation through explicitly. Also, yield inside a loop re-enters the loop body on resumption, so state captured before the yield must be re-checked rather than assumed unchanged. Finally, async methods cannot be used directly as signal handlers or Idle callbacks; wrap them in a lambda that starts the async call.
If your code is I/O-bound and you want it to read sequentially without callbacks, Vala's async/await is a solid fit. If you need parallelism or you are calling blocking libraries, reach for threads instead.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.