Skip to content

add mutex::co_unlock_return()#239

Merged
tzcnt merged 7 commits into
mainfrom
co_unlock_return
Jun 7, 2026
Merged

add mutex::co_unlock_return()#239
tzcnt merged 7 commits into
mainfrom
co_unlock_return

Conversation

@tzcnt

@tzcnt tzcnt commented Jun 7, 2026

Copy link
Copy Markdown
Owner

Problem Statement

Say we have the following code:

tmc::task<int> do_operation_safe(tmc::mutex& mut) {
  co_await mut;
  int value = do_operation();
  mut.unlock(); // or `co_await mut.co_unlock();`
  co_return value;
}

If there was a task waiting for the mutex when we unlock it, there are now 2 tasks to continue: the current task, and the waiting task. There are two ways to handle this:

  • unlock() will post the waiting task to its executor, and resume the current task inline.
  • co_unlock() will resume the waiting task inline, and post the current task to its executor.

However, both of these operations have some inefficiency, because "the current task" is basically done. The only thing that remains is to return a value to the parent and then destroy it. So even if we try to be efficient by calling co_unlock() to resume the waiting task immediately, we still have to re-post the current task. When that task resumes, it will do almost nothing - just return its value, do parent synchronization, destroy itself, and then possibly resume its parent.

Solution

This PR provides an awaitable operation tmc::mutex::co_unlock_return() that allows us to combine the co_unlock() and co_return operations together. In addition to the "unlock and symmetric transfer to a possible awaiter" behavior of co_unlock(), it also does the full "return value, perform parent synchronization, destroy this, and possibly resume parent" behavior, as if it were hitting the normal co_return / final_suspend path.

This means we can rewrite the example from the problem statement as:

tmc::task<int> do_operation_safe(tmc::mutex& mut) {
  co_await mut;
  int value = do_operation();
  co_await mut.co_unlock_return(value);
  std::unreachable(); // not required, but here for exposition
}

If the current task has no parent, or the parent is running on a different executor, or this task is part of a group that hasn't completed yet, this allows us to skip an entire round-trip through the executor.

Safety

This operation is generally safe and behaves correctly across all combinations of waiting task / parent. The only thing to remember is that this call unconditionally completes the current coroutine, so nothing will run after it in the current scope. Locally scoped objects will be cleaned up immediately (their destructors run as if co_return was called).

It has two overloads. Using the wrong overload will give you a compile-time error (same as if you tried to co_return the wrong type).

co_unlock_return() returns void (can only be used within a tmc::task<void>) and is equivalent to:

co_await mut.co_unlock();
co_return;

co_unlock_return(result) returns the Result type (can only be used within a tmc::task<Result>) and is equivalent to:

co_await mut.co_unlock();
co_return result;

@tzcnt tzcnt changed the title add mutex::co_unlock_return add mutex::co_unlock_return() Jun 7, 2026
@tzcnt tzcnt merged commit 0536695 into main Jun 7, 2026
46 checks passed
@tzcnt tzcnt deleted the co_unlock_return branch June 7, 2026 23:01
@solbjorn

solbjorn commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Nice, I'll recall whether I have coroutines that unlock the mutex right before return and give it a try, if any.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants