Write asynchronous code as plain top-to-bottom code, on top of the callback based APIs you already have.
// Before: every next step lives inside the callback of the previous step
void showAvatar(int userId){
fetchUser(userId, [](User user){
download(user.avatarUrl, [](int status, std::string body){
if( status != 200 ){
showError(status);
return;
}
decodeImage(body, [](Image image){
showImage(image);
});
});
});
}
// After: the same steps, one after another
void showAvatar(int userId){
corofork(=){
User user = co_await invert_function([&](std::function<void(User)> onDone){
fetchUser(userId, onDone);
});
auto [status, body] = co_await invert_function(
[&](std::function<void(int, std::string)> onDone){
download(user.avatarUrl, onDone);
});
if( status != 200 ){
showError(status);
co_return;
}
Image image = co_await invert_function([&](std::function<void(Image)> onDone){
decodeImage(body, onDone);
});
showImage(image);
};
}fetchUser, download and decodeImage need no changes. corofork is one header
(corofork.h, C++20). corofork has no task type to pass around, no scheduler and
no event loop of its own: the thread that calls your callback runs the rest of
your code.
- Getting started
- How invert_function works
- corofork: start a sequence of steps and move on
- Recipes
- Where exceptions go
- Pitfalls
- API at a glance
- Building the tests
Copy corofork.h into your project (plus coro_expected.h for
error codes without exceptions),
include corofork.h and compile as C++20:
| Compiler | Flag | Result |
|---|---|---|
| GCC 16.2 (MSYS2) | -std=c++20 |
all tests pass |
| Clang 22.1.8 (MSYS2) | -std=c++20 |
all tests pass |
| MSVC 2019 (14.29) | /std:c++20 |
all tests pass |
| GCC 13.1 to 15.3 | -std=c++20 |
the co_await bug of GCC 12 is absent; the test suite was not run |
| GCC 12.1 to 12.5 | -std=c++20 |
works only with the rules below |
GCC 12 (every release from 12.1 to 12.5) destroys a lambda written inside a
co_await expression twice. The bug is in the compiler and affects any coroutine
code, not only corofork. A setup lambda that is written inside co_await and
captures by value something whose destructor frees memory (std::string,
std::unique_ptr, std::vector) crashes the program with
free(): double free detected. GCC 13.1 and later, Clang and MSVC are not affected.
With GCC 12, follow three rules (all three work with every compiler and cost nothing):
// WRONG with GCC 12: capture by value in a lambda written inside co_await
co_await invert_function([url](std::function<void(int, std::string)> onDone){
download(url, onDone);
});
// 1. In a lambda written inside co_await, capture by reference only.
// Safe: the setup lambda runs during the co_await, the variables of the block are alive.
co_await invert_function([&](std::function<void(int, std::string)> onDone){
download(url, onDone);
});
// 2. A capture by value goes into a named lambda, moved into invert_function.
auto setup = [url](std::function<void(int, std::string)> onDone){
download(url, onDone);
};
co_await invert_function(std::move(setup));
// 3. Or into a helper function returning invert_function(...) (recipe 6).
co_await downloadAsync(url);With GCC 12, corofork.h prints a compiler warning with these rules. Once the code
follows the rules, define COROFORK_SUPPRESS_GCC12_WARNING (for example
-DCOROFORK_SUPPRESS_GCC12_WARNING) to silence the warning. The minimal reproduction
and the details are in
maintenance.md.
An existing API wants a callback: download(url, onDone). With corofork you write a
small setup lambda. The setup lambda receives a ready-made callback and passes
the callback to the API:
auto [status, body] = co_await invert_function(
[&](std::function<void(int, std::string)> onDone){ // 1. the signature download expects
download(url, onDone); // 2. hand the callback to download
});
// 3. the code continues here once download calls onDone(status, body)Step by step:
co_awaitpauses the corofork block.invert_functiongeneratesonDoneand calls the setup lambda withonDone.- The setup lambda starts the download and returns. The thread that executed the corofork statement goes on with the code after the corofork statement, nobody waits.
- Later
downloadcallsonDone(200, "..."), in whatever threaddownloaduses. The generatedonDonecontinues the block right afterco_await, in that same thread. The arguments ofonDonebecome the result ofco_await.
Rule of thumb: repeat the callback signature the API expects, receive the callback
arguments as the result of co_await.
| Callback signature | co_await gives |
|---|---|
void() |
nothing |
void(Image) |
Image |
void(const std::string&) |
std::string (your own copy) |
void(int, std::string) |
std::tuple<int, std::string>, unpack with auto [status, body] = ... |
void onLoginClicked(int userId){
corofork(=){
showSpinner(); // runs right away, in this thread
User user = co_await invert_function([&](std::function<void(User)> onDone){
fetchUser(userId, onDone);
});
showUser(user); // runs in the thread that called onDone
};
logLine("login started"); // runs as soon as the block waits for onDone
}- The block starts immediately in the current thread and runs up to the first
co_awaitthat has to wait. Then the corofork statement ends and the caller continues. - Captures work as in a lambda:
corofork(=)copies the variables the block uses,corofork(&)refers to them,corofork(url, &log)names them,corofork(data = std::move(data))moves them in. The copies live until the block ends. Read the pitfall about&before usingcorofork(&). - A block without any
co_awaitmust end withco_return;(why).
Some APIs expect an answer from the callback, for example "return true to accept".
Pass that answer as the second argument of invert_function:
// existing API: calls onReady once, onReady returns true to accept the connection
void listenOnce(int port, std::function<bool(Connection)> onReady);
Connection connection = co_await invert_function(
[&](std::function<bool(Connection)> onReady){ listenOnce(port, onReady); },
true // what onReady returns to listenOnce
);
serve(connection);When the answer is sometimes known without asking the API (a cache, a validation error), return the answer from the setup lambda:
std::string text = co_await invert_function(
[&](std::function<void(std::string)> onDone) -> std::optional<std::string> {
if( auto cached = cache.find(url); cached != cache.end() ){
return cached->second; // ready now: loadText is not called, onDone is never used
}
loadText(url, onDone);
return std::nullopt; // not ready: wait for onDone
});| Callback signature | Setup lambda returns |
|---|---|
void() |
bool, true means "already done, the callback will not be called" |
void(T) |
std::optional<T> |
void(A, B) |
std::optional<std::tuple<A, B>> |
Do not call onDone yourself inside the setup lambda for that purpose, return the
value instead (why).
// existing C style API: calls on_done once, from a worker thread of the SDK
void sensor_read(int sensorId, void (*on_done)(void* context, double value), void* context);
void showTemperature(int sensorId){
corofork(=){
auto [context, value] = co_await invert_callback<1>([&](void (*on_done)(void*, double)){
sensor_read(sensorId, on_done, nullptr); // no context object needed
});
showValue(value);
};
}invert_callbackproduces a real C function pointer, so you need novoid* contextjuggling and no global variables.<1>is the maximum number of callbacks generated by this line of code that wait at the same time. One corofork waiting at the line needs1. Ten coroforks started from a loop and waiting at the same line at once need<10>. With more waiting callbacks than reserved,co_awaitthrowsstd::runtime_error.- The reserved callbacks are prepared at compile time in static storage: no heap allocation per callback.
- Variants with a return value work as in recipe 1:
invert_callback<1>(setup, valueForTheApi).
// existing API: calls onMessage for every incoming message until unsubscribe(id)
int subscribeToMessages(std::function<void(std::string)> onMessage);
void unsubscribe(int subscriptionId);
void serveClient(){
corofork(=){
auto messages = invert_subscription([](std::function<void(std::string)> onMessage){
int subscriptionId = subscribeToMessages(onMessage);
return [subscriptionId]{ unsubscribe(subscriptionId); }; // cleanup
});
std::string userName = co_await messages; // first message
std::string password = co_await messages; // second message
if( checkLogin(userName, password) ){
for( ;; ){
std::string command = co_await messages;
if( command == "quit" ){
break;
}
execute(command);
}
}
}; // the destructor of messages calls the cleanup: unsubscribe(subscriptionId)
}- Repeat
co_await messagesas often as you need, eachco_awaitgives the next event. A login handshake or any other protocol reads as the protocol description. - Events that arrive while the block is busy elsewhere are queued in arrival order,
messages.count()gives the number of queued events. - The setup lambda may return a cleanup function, for example the unsubscribe call.
The destructor of
messagescalls the cleanup function. Return nothing when no cleanup is needed.
Each of the three is "an API that calls a callback later":
// continue in a new thread
co_await invert_function([](std::function<void()> resume){
std::thread(resume).detach();
});
// continue in the UI thread (the "post" / "invoke later" function of your toolkit)
co_await invert_function([](std::function<void()> resume){
postToUiThread(resume);
});
label.setText(text); // runs in the UI thread
// pause the block for one second, no thread is blocked meanwhile
co_await invert_function([](std::function<void()> wake){
startTimer(std::chrono::seconds(1), wake);
});The setup lambdas repeat. Put each API call into a helper function that returns
invert_function(...), and co_await the helper:
auto downloadAsync(std::string url){
return invert_function([url = std::move(url)](std::function<void(int, std::string)> onDone){
download(url, onDone);
});
}
auto waitAsync(std::chrono::milliseconds delay){
return invert_function([delay](std::function<void()> wake){
startTimer(delay, wake);
});
}auto [status, body] = co_await downloadAsync(url);
co_await waitAsync(std::chrono::seconds(1));- The result of
invert_functionowns the setup lambda: the setup lambda is moved into the result, without a heap allocation. - Inside a helper, capture by value (
[url = std::move(url)],[delay]). A[&]capture of a parameter or a local variable of the helper refers to a variable that is gone once the helper returns. co_awaitthe helper right away.auto pending = downloadAsync(url); co_await pending;is rejected at compile time, writeco_await downloadAsync(url)instead.
With helpers, a retry with a growing pause is a plain loop:
corofork(url){
for( int attempt = 1; attempt <= 3; ++attempt ){
auto [status, body] = co_await downloadAsync(url);
if( status == 200 ){
save(body);
co_return;
}
co_await waitAsync(std::chrono::seconds(attempt));
}
showError(503);
};int main(){
corosync(&){
auto [status, body] = co_await downloadAsync("https://example.com");
std::printf("%d: %s\n", status, body.c_str());
};
// the download has finished here
}corosyncblocks the calling thread until the block ends. Usecorosyncinmain, in command line tools, in unit tests, or behind an old synchronous interface.corosync(&)is safe: the caller waits, so the referenced variables stay alive.- Every exception thrown in the block reaches the caller of
corosync, in the thread of the caller, whichever thread ran the code that threw. co_await back_to_thread;continues the block in the thread that calledcorosync. Useful when the callback runs in a thread that must not be held up (network, UI) and the next step is heavy:
corosync(&){
auto [status, body] = co_await downloadAsync(url); // in the network thread now
co_await back_to_thread; // in the calling thread again
Report report = parseHugeDocument(body); // the network thread is free
printReport(report);
};- Inside
corosync,co_await invert_*directly. Do not start a nestedcoroforkfor the same purpose:corosyncdoes not wait for the nestedcorofork, and an exception thrown by the nestedcoroforkafter the nestedcoroforkwaited once goes to whatever thread continued the nestedcorofork, never to the caller ofcorosync. - Do not call
corosyncin the thread that has to deliver the callback (why).
A C API wants a function pointer, and you want to capture state:
// C style API: calls on_finished once
void job_start(const char* jobName, void (*on_finished)(int exitCode));
void startBackup(std::string jobName){
job_start(jobName.c_str(), callback_from_lambda<4>([jobName](int exitCode){
std::printf("%s finished with %d\n", jobName.c_str(), exitCode);
}));
}callback_from_lambda<4>reserves 4 function pointers for this line of code. Each function pointer frees its slot after its first call. A function pointer that is never called keeps its slot forever.- For a callback that is called many times and later removed, use
scoped_callback. The slot stays reserved as long as the holder object lives:
// C style API: calls handler for every log line, nullptr removes the handler
void set_log_handler(void (*handler)(const char* line));
void runWithLogFile(std::ofstream& logFile){
auto handler = scoped_callback<1>([&logFile](const char* line){
logFile << line << '\n';
});
set_log_handler(handler.Callback());
runEverything();
set_log_handler(nullptr);
} // the destructor of handler frees the slot- A lambda without captures converts to a function pointer by itself, pass such a
lambda to the API directly.
callback_from_lambdaandscoped_callbackreject a lambda without captures at compile time.
coro_expected.h uses the same co_await keyword for a different job: "give me the
value, or return the error to my caller right now".
#include "coro_expected.h"
coro_expected<int, std::errc> parseDigit(char text){
if( text < '0' || text > '9' ){
co_return coro_unexpected{std::errc::invalid_argument};
}
co_return text - '0';
}
coro_expected<int, std::errc> parseTwoDigits(const char* text){
int tens = co_await parseDigit(text[0]); // on error: parseTwoDigits returns that error here
int ones = co_await parseDigit(text[1]);
co_return tens * 10 + ones;
}
void useParsed(){
auto parsed = parseTwoDigits("4x");
if( !parsed ){
std::printf("error %d\n", int(parsed.error())); // std::errc::invalid_argument
}
int number = parseTwoDigits("42").value(); // 42
std::printf("%d\n", number);
}- Works without exceptions, also with
-fno-exceptions. co_awaitacceptscoro_expected,std::expected(C++23) andcoro_unexpected{error}. A function returningcoro_expected<void, Error>returns an error withco_await coro_unexpected{error};.- A
coro_expectedfunction runs to its end inside the call.co_awaitoninvert_functioninside such a function does not compile. Callcoro_expectedfunctions from acoroforkorcorosyncblock like any other function. - Each call allocates memory on the heap: measured with GCC 16.2 and MSVC 2019,
1000 calls cost 1000 allocations. Clang with
-O2removed all of them.
| Exception thrown | Who receives the exception |
|---|---|
in a corofork block, before the first co_await that waits |
the code that executed the corofork statement |
in a corofork block, after a co_await |
the code that called the callback, for example a thread of the network library. Catch inside the block if that code cannot handle exceptions |
in a corosync block, anywhere |
the caller of corosync |
in a coro_expected function |
rethrown by the first has_value(), value() or error() |
co_await invert_callback<N> while all N callbacks wait |
std::runtime_error at that co_await, catchable inside the block |
Before the exception travels further, the locals and the captured variables of the
block are destroyed. For a corofork block, that holds when the block continues
through the callbacks of invert_function, invert_callback or
invert_subscription. When an awaitable of another library continues a corofork
block, the exception leaves without destroying the block (details in
maintenance.md).
void onRefreshClicked(){
std::string url = currentUrl();
corofork(&){ // WRONG
auto [status, body] = co_await downloadAsync(url);
logLine(url); // onRefreshClicked has returned long ago, url is destroyed
};
}The corofork statement ends at the first co_await that waits.
onRefreshClicked returns and destroys url, while the block continues later.
Use corofork(=) or name the copies: corofork(url). Move big objects in:
corofork(url = std::move(url)). Use corofork(&) only for variables that outlive
the block, and in corosync.
The same holds for this in a member function: the object must outlive the block.
Capture a std::shared_ptr to the object, for example
corofork(self = shared_from_this()).
std::lock_guard lock(cacheMutex); // WRONG
auto [status, body] = co_await downloadAsync(url);
cache[url] = body;- The code after
co_awaitmay run in another thread, the thread that called the callback. Astd::mutexmust be unlocked by the thread that locked the mutex, an unlock from another thread is undefined behavior. - While the block waits, the mutex stays locked for an unknown time, and every
other thread that needs
cacheMutexwaits as well. When the thread that is supposed to call the callback needscacheMutexfirst, nothing moves any more (deadlock).
Lock only around code without co_await:
auto [status, body] = co_await downloadAsync(url);
{
std::lock_guard lock(cacheMutex);
cache[url] = body;
}The same applies to everything bound to a thread: thread_local variables, thread
ids, objects that only the UI thread may touch.
After co_await, the block runs in the thread that called the callback: a network
thread, a timer thread, a thread of an SDK. Before touching the UI from there, go
to the UI thread with recipe 5.
In corosync, co_await back_to_thread; returns to the thread that called
corosync.
- A second call of the callback from
invert_functionorinvert_callbackis undefined behavior: the block has already moved past thatco_await, or has ended. For callbacks that repeat, useinvert_subscription. - A callback that is never called means the block never continues: a
coroforkkeeps the captured copies forever, acorosyncblocks the caller forever. Make sure the API calls the callback on errors and on cancellation too.
co_await invert_function([&, key](std::function<void(std::string)> onDone){ // WRONG
if( auto cached = cache.find(key); cached != cache.end() ){
onDone(cached->second); // the block continues right here, inside the setup lambda
}
else{
loadText(key, onDone);
}
logLine(key); // the block may have moved on: the setup lambda and key are destroyed
});Calling onDone continues the block immediately, inside the running setup lambda.
When the block moves past the co_await, the setup lambda is destroyed while the
setup lambda still runs, and the code after the call reads destroyed captures.
Return the ready value instead (recipe 2), and make the API call the last statement of the setup lambda: some APIs call the callback before returning.
// in the UI thread
corosync(&){ // WRONG: deadlock
co_await invert_function([](std::function<void()> resume){ postToUiThread(resume); });
};corosync blocks the UI thread until the block ends, and the block waits for the UI
thread to run resume. Neither thread moves.
In the UI thread, use corofork. Use corosync only in threads that may block and
that do not deliver the awaited callbacks.
corofork(&){ // WRONG: GCC and Clang only warn, the program crashes at run time
counter = 1;
};Only a block that contains co_await or co_return becomes a coroutine. Without
either keyword, the block is an ordinary function that has to return a value and
returns nothing.
End such a block with co_return;. Enable -Werror=return-type (GCC, Clang) to turn
the warning into an error. MSVC 2019 rejects such a block with error C4716.
After the cleanup function returned by the setup lambda has finished, the API must not call the callback any more: the destructor of the subscription object has run, and a late call reaches a destroyed object.
| Write | To |
|---|---|
corofork(captures){ ... }; |
start a block, the caller continues at the first co_await that waits |
corosync(captures){ ... }; |
run a block, the caller waits until the block ends, exceptions reach the caller |
co_await back_to_thread; |
inside corosync: continue in the thread that called corosync |
co_await invert_function(setup) |
wait for a std::function callback, receive its arguments |
co_await invert_function(setup, value) |
same, the callback returns value to the API |
co_await invert_function(setup, function) |
same, the callback returns function(), called once the block reached its next co_await or its end |
co_await invert_callback<N>(setup) |
same as invert_function for a C function pointer, up to N waiting at once |
auto events = invert_subscription(setup); then co_await events |
receive repeated events one by one, events.count() queued |
callback_from_lambda<N>(lambda) |
C function pointer from a capturing lambda, freed after the first call |
scoped_callback<N>(lambda) then .Callback() |
C function pointer valid while the holder lives |
coro_expected<Value, Error>, co_await, coro_unexpected{error} |
error codes without exceptions (coro_expected.h) |
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failureDemo/ contains small programs with more examples.
maintenance.md: how corofork works inside, design decisions, known limitations and plans, written for maintainers.