Making Coroutines Routine: Building a Scalable TPC-C Client in C++
We started with Java, 150,000 OS threads and roughly 600 GiB of RAM. We ended with readable sequential code in C++, 16 worker threads for terminal execution, memory usage reduced by more than 1,000x, and a benchmark client that could finally keep up with a distributed database.

At YDB, we build a fault-tolerant distributed database in C++. That means we also spend a lot of time building and running benchmarks.
Benchmarks are not optional infrastructure. We use them to load-test the system, detect performance regressions, validate optimizations and new features, and compare YDB with other databases. The larger the database becomes, the more important the benchmark client becomes as well: it is surprisingly easy to saturate the machine generating the load long before the database itself is busy.
That is exactly what happened when we tried to run TPC-C at scale. We wanted to benchmark the database. Instead, we benchmarked the client.
This post tells the story of how that led us from OS threads to futures, from futures to callback hell, and finally to C++20 coroutines. Along the way, we will look under co_await, reconstruct a coroutine by hand, and discuss one of the most important practical questions in asynchronous C++:
After a future or coroutine becomes ready, which thread executes the rest of your code?
The code below is simplified and adapted for explanation, but it follows the architecture of our real TPC-C implementation.
TPC-C as a Perfect Coroutine Workload
TPC-C is an OLTP benchmark standardized in 1992. Despite its age, it is still widely used because it models a nontrivial transactional application rather than a stream of independent key-value operations.
The benchmark represents a wholesale company with multiple warehouses. Each warehouse serves ten districts and contains roughly 100 MB of data. Each district has a terminal representing a user or employee interacting with the system.
A terminal repeatedly performs one of several transactions: placing an order, making a payment, checking order status, processing delivery, or checking stock levels. The important part for our purposes is the execution model:
- A large run may have hundreds of thousands of terminals.
- A terminal is usually idle, simulating a human typing or thinking.
- When active, it executes a multi-step interactive transaction.
- A transaction contains roughly 5–10 database requests, with only a small amount of CPU work between them.
In other words, each terminal is a mostly sequential workflow with many natural suspension points. It spends almost all its lifetime waiting: for a timer, the network, or the database.
This is precisely the kind of workload where coroutines should shine.
First We Benchmarked the Client
We initially used BenchBase, the well-known JDBC benchmarking framework developed at Carnegie Mellon University under Andy Pavlo. It has a good architecture, supports multiple databases, and includes one of the few widely used implementations of TPC-C.
The problem was not the benchmark logic. The problem was its execution model. TPC-C has ten terminals per warehouse. Therefore, a run with 15,000 warehouses has 150,000 terminals. In the original implementation we used, that meant 150,000 OS threads.
For one of our runs, the client required approximately:
- 150,000 OS threads;
- 600 GiB of RAM;
- five client machines, each with 128 CPU cores and 512 GiB of RAM.
The YDB cluster under test used only three machines of the same size.
This ratio is difficult to justify. At larger cluster sizes, the load generator becomes a substantial part of the cloud bill. We estimated that a single experiment across several database configurations could cost around $10,000 in AWS, with much of the cost going to the client rather than the databases being tested.
We first optimized the existing Java implementation. We replaced platform threads with Java virtual threads and immediately ran into a subtle deadlock related to their execution model. We also reduced memory consumption. For 15,000 warehouses, memory usage fell from roughly 600 GiB to about 90 GiB, and the benchmark could run on one machine with a reasonable number of platform threads.
That was a major improvement, but it was still heavier than we wanted. We are C++ developers, YDB is written in C++, and we needed more control over memory, scheduling, and observability. So we made the decision every programmer secretly enjoys making:
We rewrote it.

The Ideal Code Is Synchronous
Let us start with the most natural implementation of a TPC-C terminal:
void RunTerminal(TInstant endTs) {
while (Now() < endTs) {
auto type = PickTransaction();
auto input = MakeInput(type);
// Simulate the user entering the request.
Sleep(KeyingTime(type));
auto result = RunTx(input);
// Simulate the user reading the response.
Sleep(ThinkTime(type));
Stats.Record(type, result);
}
}A simplified transaction is equally straightforward:
TxResult RunTx(Input input) {
auto tx = Db.BeginTransaction();
auto r1 = tx.Query(sql1).GetValueSync();
// Process r1 and prepare sql2.
auto r2 = tx.Query(sql2).GetValueSync();
// Process r2.
// Queries 3-9.
auto r10 = tx.Query(sql10).GetValueSync();
// Process r10.
return tx.Commit().GetValueSync();
}This code is excellent from the application developer’s point of view. It is linear, local, and easy to debug. The business logic is visible from top to bottom.
The operating system also does a great deal of work for us. When a thread sleeps or waits for a database response, the kernel saves its execution context and schedules another thread. When the wait finishes and a CPU becomes available, the kernel restores the context and execution continues after the blocking call. The terminal does not need to know that it was suspended.
Unfortunately, this model implies one thread per terminal. A few dozen threads are fine. A few thousand may already be uncomfortable. A hundred and fifty thousand is not a viable architecture.
Each thread needs a stack and kernel resources. Context switching is relatively expensive. Page-level fragmentation adds more memory overhead. Thread creation and destruction also take a significant amount of time when there are a lot of threads already.
The synchronous code is beautiful, but the execution model does not scale.
Threads vs. Coroutines: A Small Experiment
To make the difference visible, we wrote a simple microbenchmark.
The machine had 32 physical cores on one NUMA node. We created N workers, either OS threads or stackless coroutines. Each worker performed approximately one microsecond of CPU work and then yielded. The test used one second of warm-up followed by five seconds of measurement.
Up to 32 workers, both versions scaled similarly: there was a physical core for every worker. Above that point, their behavior diverged.
The coroutine version maintained nearly constant aggregate throughput across thousands of workers. The thread version dropped immediately after oversubscription and became progressively worse as the number of threads increased. As shown in Figure 1, both approaches scaled similarly up to the number of physical cores, but only coroutines preserved throughput beyond that point.

The wall-clock time was even more revealing. The benchmark was supposed to take approximately six seconds, including warm-up. The coroutine version stayed close to that value. With thousands of threads, however, the time spent creating, scheduling, and joining them grew sharply. Figure 2 shows that this overhead eventually dominates the benchmark itself.

The exact numbers depend on the machine and implementation, but the shape is what matters:
OS threads are a good unit of parallel execution. They are a poor unit for representing hundreds of thousands of mostly sleeping workflows.

Futures Fix the Execution Model
The obvious next step is to use the asynchronous database API.
A query returns a future. The SDK eventually stores the result in the corresponding promise, and the future becomes ready. With a composable future implementation, we can attach continuations:
auto future = tx.Query(sql1)
.Apply([&](auto r1) {
// Process r1.
return tx.Query(sql2);
});
The standard std::future is not particularly helpful here because it does not provide continuation chains. Practical async code usually relies on a third-party future implementation such as YDB's TFuture, Folly futures, or another library with then/Apply/Subscribe-style operations.
But there is an important subtle issue hidden in this code: which thread actually executes each part?
With many future implementations, the continuation attached with Apply() is executed by the thread that makes the future ready. In this case, that is an SDK thread:
Future RunTx(Input input) {
auto tx = db.BeginTransaction();
// Executed in the caller's thread.
return tx.Query(sql1)
.Apply([&](auto r1) {
// Executed by the thread that fulfilled
// the previous promise — typically an SDK thread.
// Process r1.
return tx.Query(sql2);
})
// r3-r9, similarly SDK thread
.Apply([&](auto r10) {
// Process r10. Again SDK thread
return tx.Commit();
});
}That means the first part of RunTx() executes in the caller's thread, but everything after the first asynchronous query may execute in an SDK thread.
This is not merely an aesthetic concern. User code may do substantial CPU work, acquire locks, wait for another resource, or accidentally block. If enough continuations run in SDK threads, the application can stall the SDK itself and potentially deadlock.
Async code often hides thread migration. The syntax shows dependencies, but not the execution context.
So futures solve one problem — we no longer need an OS thread waiting for every query — but they introduce another question: where should the continuations execute?
And transactions are only half of the problem.
Our terminal is still written as synchronous code:
while (Now() < EndTs) {
auto type = PickTransaction();
auto input = MakeInput(type);
// Simulate user typing / preparing the request.
Sleep(KeyingTime(type));
auto future = RunTx(input);
// Simulate user thinking after the response.
Sleep(ThinkTime(type));
Stats.Record(type, result);
}RunTx() now returns a future, so we cannot simply wait for it without once again blocking an OS thread.
The two Sleep() calls have exactly the same problem. TPC-C intentionally spends a significant amount of time simulating keying time before a transaction and think time after it. With hundreds of thousands of terminals, blocking an OS thread for every sleeping terminal would bring us straight back to the one-thread-per-terminal design.
The whole terminal therefore has to become asynchronous as well. We need non-blocking timers for the sleeps, and when either a timer or a database request completes, we need to continue the terminal on threads that we control.
This brings us to a worker pool. We need a pool capable of scheduling both continuations and timers:
auto future1 = pool.After(KeyingTime(type));
auto future2 = pool.ContinueWith(future1, [&](auto) {
return RunTx(input, pool);
});
After() gives us a non-blocking sleep: no worker thread has to sit idle while a terminal is waiting. ContinueWith() also gives us an explicit execution boundary: instead of allowing application logic to continue inside an arbitrary SDK thread, we schedule it onto our own pool.
The transaction can now be fully asynchronous without blocking an OS thread:
Future RunTx(Input input, IPool& pool) {
auto tx = db.BeginTransaction();
auto f1 = tx.Query(sql1);
auto f2 = pool.ContinueWith(f1, [&](auto r1) {
// Process r1.
return tx.Query(sql2);
});
// r3-r9
auto f10 = pool.ContinueWith(f9, [&](auto r9) {
// Process r9.
return tx.Query(sql10);
});
return pool.ContinueWith(f10, [&](auto r10) {
// Process r10.
return tx.Commit();
});
}The transaction code is close to the original one. Let’s rewrite the terminal code using futures and pools.
Callback Hell
The terminal used to be a loop. In the asynchronous version, each blocking point becomes a continuation. State that used to live naturally in local variables must be stored somewhere that survives after the function returns.
A nonblocking terminal looks like this:
struct TTerminalState {
Promise Done;
TInstant EndTs;
TransactionType Type;
Input Input;
};
Future RunTerminal(TInstant endTs, IPool& pool) {
auto state = std::make_unique();
state->EndTs = endTs;
auto future = state->Done.GetFuture();
pool.Schedule([state = std::move(state), &pool]() mutable {
TerminalStep(std::move(state), pool);
});
return future;
}
void TerminalStep(std::unique_ptr state, IPool& pool) {
if (Now() >= state->EndTs) {
state->Done.SetValue();
return;
}
state->Type = PickTransaction();
state->Input = MakeInput(state->Type);
auto f1 = pool.After(KeyingTime(state->Type));
pool.ContinueWith(f1, [state = std::move(state), &pool](auto) mutable {
auto f2 = RunTx(state->Input, pool);
pool.ContinueWith(f2,
[state = std::move(state), &pool](auto result) mutable {
Stats.Record(state->Type, result);
auto f3 = pool.After(ThinkTime(state->Type));
pool.ContinueWith(f3,
[state = std::move(state), &pool](auto) mutable {
pool.Schedule(
[state = std::move(state), &pool]() mutable {
TerminalStep(std::move(state), pool);
});
});
});
});
}The code is efficient, but the business logic is wrapped in pool calls, future chains, captures, ownership transfers, and nested lambdas.
Large lambdas are difficult to read. Extracting every continuation into a separate function avoids nesting, but scatters one logical workflow across the file. Error handling and cancellation make the structure even more complicated.

Waiting no longer consumes one OS thread per terminal, and application continuations execute on a pool under our control. A small number of worker threads can execute a very large number of terminals. Unfortunately, the price is readability.
Coroutines Restore the Shape of the Code
With C++20 coroutines, the transaction again looks almost synchronous:
TFuture RunTx(Input input) {
auto tx = Db.BeginTransaction();
auto r1 = co_await tx.Query(sql1);
// Process r1.
auto r2 = co_await tx.Query(sql2);
// Process r2.
// Queries 3-9.
auto r10 = co_await tx.Query(sql10);
// Process r10.
co_return co_await tx.Commit();
}The terminal becomes a loop again:
TFuture RunTerminal(TInstant endTs) {
while (Now() < endTs) {
auto type = PickTransaction();
auto input = MakeInput(type);
co_await Sleep(KeyingTime(type));
auto result = co_await RunTx(input);
Stats.Record(type, result);
co_await Sleep(ThinkTime(type));
}
}The code uses the same asynchronous operations as the future-chain implementation. It does not block a worker thread while waiting. We can create hundreds of thousands of coroutine instances, yet the business logic remains local and sequential.
Coroutines do not make the operation synchronous. They make asynchronous control flow look sequential. That distinction becomes much easier to understand once we look at what the compiler generates.
Looking Under the Hood
co_await Is Syntax Sugar, Not an Operating-System Feature
The operating system knows about threads. It does not know about C++ coroutines. When synchronous code blocks, the kernel saves thread state: registers, stack position, and scheduler metadata. With a stackless C++ coroutine, it is the compiler who generates code that stores the state needed to continue the function later.
If the compiler can generate the code for us, we can write it manually. Before looking at what co_await does, let's try to get the same behavior without using coroutines at all and sketch a sugar-free terminal.
Below is the initial terminal code:
void RunTerminal(TInstant endTs) {
while (Now() < endTs) {
auto type = PickTransaction();
auto input = MakeInput(type);
Sleep(KeyingTime(type));
auto result = RunTx(input);
Stats.Record(type, result);
Sleep(ThinkTime(type));
}
}The code has a natural sequence of stages. At any given moment, the terminal is either waiting before a transaction, running a transaction, processing its result, or finished.
We can make those states explicit:
enum class EState {
Sleep,
RunTx,
ProcessResult,
Terminate,
};Once execution can stop between these stages, local variables can no longer live only on the stack of RunTerminal(). We need to store everything required to continue later:
struct TStatefulTask {
EState State = EState::Sleep;
TFuture StateDone;
IPool* Pool;
TInstant EndTs;
TransactionType Type;
Input Input;
std::optional Result;
void Resume();
bool Done() const;
};StateDone represents the asynchronous operation we are currently waiting for: a timer or a database transaction.
Now RunTerminal() no longer runs the whole terminal. It performs one transition of the state machine and returns.
The first state prepares the next transaction and starts the keying-time timer:
void RunTerminal(TStatefulTask* state) {
if (Now() >= state->EndTs) {
state->State = EState::Terminate;
return;
}
auto& pool = *state->Pool;
switch (state->State) {
case EState::Sleep: {
state->Type = PickTransaction();
state->Input = MakeInput(state->Type);
state->Result.reset();
auto sleepTime = KeyingTime(state->Type);
state->StateDone = pool.After(sleepTime);
state->State = EState::RunTx;
return;
}
// ...
}
}Notice what happened to Sleep(). We no longer block here. pool.After() returns a future, we remember it in StateDone, set the state that should execute next, and return.
Once that future becomes ready, the state machine can be resumed. This time it enters RunTx:
case EState::RunTx: {
state->StateDone = RunTx(state->Input, pool)
.Apply([state](auto result) {
state->Result = std::move(result);
});
state->State = EState::ProcessResult;
return;
}Again, we start an asynchronous operation and return. When the transaction completes, its result is saved in our explicit state.
The next resume processes that result and starts the think-time timer:
case EState::ProcessResult: {
Stats.Record(state->Type, *state->Result);
state->StateDone = pool.After(ThinkTime(state->Type));
state->State = EState::Sleep;
return;
}After the timer fires, the state machine returns to Sleep, selects another transaction, and repeats.
So our original straight-line code:
Sleep(...);
auto result = RunTx(...);
Stats.Record(...);
Sleep(...);
has turned into an explicit state machine that remembers where execution stopped and which local variables must survive until it resumes.
There is still one missing piece. Something has to call RunTerminal() again whenever StateDone becomes ready.
Let’s wrap the state machine in a task:
struct TStatefulTask {
EState State = EState::Sleep;
// ...
void Resume() {
RunTerminal(this);
}
bool Done() const {
return State == EState::Terminate;
}
};Creating a terminal now means creating its persistent state:
TStatefulTask CreateTerminal(TInstant endTs, IPool& pool) {
return TStatefulTask{
.State = EState::Sleep,
.Pool = &pool,
.EndTs = endTs,
};
}And we need a small driver that resumes the task whenever the asynchronous operation it is waiting for completes:
auto state =
std::make_shared(CreateTerminal(endTs, pool));
std::function driver;
driver = [state, &driver] {
if (state->Done()) {
return;
}
state->Resume();
state->Pool->ContinueWith(
state->StateDone,
[&driver](auto) {
driver();
});
};
driver();
The exact mechanics are simplified here, but the structure is the important part:
- Transform the straight-line function into a state machine.
- Move the state that must survive suspension into a persistent task object.
- Have some external entity resume that task when it can make progress.
Here the external entity is a tiny driver. In a real implementation, this role is normally played by a scheduler.
Congratulations. We have effectively implemented a coroutine ourselves.
And that is the useful mental model for understanding C++ coroutines: the compiler is not introducing an entirely new execution mechanism. It is automating much of the tedious transformation we just performed manually.

A useful mental model is:
Every co_await is a possible return from the coroutine, and every resumption is a jump back into the generated state machine.
A Coroutine Function Is Not an Ordinary Function Call
A function containing co_await, co_return, or co_yield is a coroutine function.
Calling it does not behave like calling an ordinary function that must run to completion before returning. The call creates a coroutine frame and a promise object, obtains the return object, and then either starts executing the body immediately or leaves it suspended for later.
Conceptually, this:
auto task = RunTerminal(endTs);
is closer to creating a task object and a coroutine frame first:
auto* promise = new promise_type;
auto task = promise->get_return_object();
auto h = MakeCoroutineHandle(promise);
if (promise->initial_suspend() == suspend_never) {
h.resume();
}
The real compiler transformation is more nuanced, but this model explains several otherwise surprising details:
- Calling a coroutine may execute part of its body immediately.
- It may instead create a lazy task that does nothing until resumed.
- Local variables that cross a suspension point live in the coroutine frame.
- Destroying the returned task may destroy the suspended coroutine, depending on the task type’s ownership rules.
Once you stop thinking of a coroutine call as an ordinary function call, much of the apparent magic disappears.
For a much deeper dive into coroutine internals, I highly recommend Lewis Baker’s series on C++ coroutines, especially his articles on the compiler transform, co_await, and promise types.
The Two Customization Points: Promise and Awaiter
The C++ standard gives us the language transformation, but the surrounding types define how it behaves.
First, the coroutine’s return type must be associated with a promise_type. For a simplified TFuture, it might look like this:
struct promise_type {
TPromise Promise;
TFuture get_return_object() {
return Promise.GetFuture();
}
std::suspend_never initial_suspend() noexcept {
return {};
}
std::suspend_never final_suspend() noexcept {
return {};
}
void return_void() {
Promise.SetValue();
}
void unhandled_exception() {
Promise.SetException(std::current_exception());
}
};The promise type determines the returned object, eager vs. lazy start, final suspension behavior, result propagation, and exception propagation.
Second, the expression to the right of co_await must produce an awaiter. An awaiter exposes three operations:
bool await_ready();
void await_suspend(std::coroutine_handle<> handle);
T await_resume();
A minimal future awaiter could be written as follows:
template
struct TFutureAwaiter {
TFuture Future;
bool await_ready() const {
return Future.IsReady();
}
void await_suspend(std::coroutine_handle<> handle) {
Future.Subscribe([handle] {
handle.resume();
});
}
T await_resume() {
return Future.Get();
}
};
The logic is simple:
- If the future is already ready, continue without suspending.
- Otherwise, store the coroutine handle in a callback and return control to the caller.
- When the future becomes ready, the callback resumes the coroutine.
- await_resume() extracts the value or rethrows the exception.
But this minimal awaiter brings us back to the thread question: handle.resume() runs the coroutine in whichever thread completes the future. Consider the example below:
struct promise_type {
std::suspend_never initial_suspend() noexcept;
};
// coroutine
TFuture RunTerminal(TInstant endTs) {
// this is executed by the calling thread
while (Now() < endTs) {
auto type = PickTransaction();
auto input = MakeInput(type);
co_await Sleep(KeyingTime(type));
// from this point we are executing in a thread
// which completes the future
auto result = co_await RunTx(input);
Stats.Record(type, result);
co_await Sleep(ThinkTime(type));
}
}
// usage
std::vector> tasks;
for (int i = 0; i < Terminals.size(); ++i) {
// terminals are executed by this thread
// until their first coawait
tasks.emplace_back(RunTerminal(endTs));
}This resembles our first future-based version without explicit scheduling. We need a different awaiter.
Scheduling the Coroutine onto Our Pool
We give each terminal a monotonically increasing ID and map it to a worker thread:
const auto threadHint = terminalId % pool.Size();
A terminal is always resumed in its assigned worker. This gives us affinity without creating a thread per terminal and makes per-thread load easy to measure.
First, we need an awaiter that moves execution onto the pool:
struct TScheduleOnPool {
IThreadPool& Pool;
size_t ThreadHint;
bool await_ready() const {
return Pool.IsInThisThread(ThreadHint);
}
bool await_suspend(std::coroutine_handle<> handle) {
if (Pool.IsInThisThread(ThreadHint)) {
return false;
}
Pool.Schedule(handle, ThreadHint);
return true;
}
void await_resume() const noexcept {}
};At the beginning of the terminal, we ensure that it runs in the correct worker:
TFuture RunTerminal(
TInstant endTs,
IThreadPool& pool,
ui64 terminalId)
{
const auto threadHint = terminalId % pool.Size();
co_await TScheduleOnPool{pool, threadHint};
// Terminal loop follows.
}
We also need to resume the coroutine on the pool after a future becomes ready:
template
struct TAwaitFutureOnPool {
TFuture Future;
IThreadPool& Pool;
size_t ThreadHint;
bool await_ready() const {
return Future.IsReady();
}
void await_suspend(std::coroutine_handle<> handle) {
Future.Subscribe([this, handle] {
Pool.Schedule(handle, ThreadHint);
});
}
T await_resume() {
return Future.Get();
}
};
Now an SDK thread may mark the future ready, but it does not execute our application logic. It only schedules the suspended coroutine onto our worker pool.
The final terminal is still readable, while the scheduling boundary is explicit:
TFuture RunTerminal(
TInstant endTs,
IThreadPool& pool,
ui64 terminalId)
{
const auto threadHint = terminalId % pool.Size();
co_await TScheduleOnPool{pool, threadHint};
while (Now() < endTs) {
auto type = PickTransaction();
auto input = MakeInput(type);
auto keying = Sleep(KeyingTime(type));
co_await TAwaitFutureOnPool{
std::move(keying), pool, threadHint};
auto transaction = RunTx(input);
auto result = co_await TAwaitFutureOnPool{
std::move(transaction), pool, threadHint};
Stats.Record(type, result);
auto thinking = Sleep(ThinkTime(type));
co_await TAwaitFutureOnPool{
std::move(thinking), pool, threadHint};
}
}
A library can hide these wrappers behind operator co_await, an executor-bound future, or a task type that carries its scheduler. We kept the scheduling explicit in the examples because it reveals where execution can move between threads.
C++ Gives You Three Keywords, Not a Runtime

C++20 added co_await, co_return, and co_yield, plus the machinery in . It did not add a complete asynchronous runtime.
The standard library does not provide the pieces we needed for this benchmark:
- a coroutine-friendly future/task type
- a thread-pool scheduler
- timers
- an event loop
We built a small runtime for our TPC-C client: futures and promises, a FIFO scheduler, worker threads, a timer thread, and awaiters that preserve execution context.
That low-level work is interesting, but it is library work. Application developers should normally use an existing runtime or library rather than implement every coroutine primitive themselves.
This is also where C++ differs sharply from Java virtual threads and Go goroutines. Their runtimes provide scheduling and I/O integration. The entry barrier is much lower, but the runtime makes more decisions for you. C++ gives much more control and much less infrastructure.
Neither design is free. Java let us move to virtual threads with an almost one-line change, but we still encountered a deadlock in the PostgreSQL TPC-C client because virtual threads impose their own constraints. A simple surface API does not eliminate the need to understand the runtime underneath it.
The Result
The rewritten client includes a terminal UI for both data import and the benchmark run. During import, it reports current and average throughput, progress, index creation, elapsed time, ETA, and logs. In one 15,000-warehouse run, the importer used 53 threads for an estimated 1.49 TiB dataset and showed a current rate of about 966 MiB/s with an average of 744 MiB/s.
During the benchmark, the UI shows a preview of TPC-C efficiency and throughput, latency percentiles for every transaction type, and the load and QPS of every terminal worker.
For a 15,000-warehouse run, the client used approximately:
- 25 CPU cores on average;
- 520 MiB of RAM instead of roughly 600 GiB;
- about 500 process threads instead of 150,000.
The dramatic memory reduction came from the broader client redesign and much smaller per-terminal state, not from coroutines or the transition to C++ alone.
The total thread count includes a large number of gRPC/SDK threads. Only 16 workers executed terminal coroutines.

A proper execution model allowed us to implement the thread-load display, which turned out to be just as important as the resource reduction.
Benchmarking the Client Is Part of Benchmark Correctness
Suppose we configure too few workers. They reach almost 100% utilization, and ready coroutines wait in the client’s run queue before they can send the next database request or process a completed response.
From outside, transaction latency appears to rise dramatically. TPC-C efficiency falls. It is tempting to conclude that the database is slow.
But the database is not necessarily the bottleneck. The client is.
In one deliberately undersized configuration, four terminal workers were saturated at approximately 99% load. Reported median latencies rose to around two seconds, and efficiency fell below 90%. With enough workers, per-thread load stayed much lower and the benchmark reported roughly 99% efficiency.

A load generator must expose its own saturation. Otherwise, it can attribute client-side queueing time to the system under test and produce misleading results.
The same rule applies beyond benchmarks: every asynchronous service should make executor load and queueing visible. Coroutines make concurrency cheap; they do not make CPU capacity infinite.
Coroutines Are Not a Guarantee of Speed
After adopting coroutines, we still found major bottlenecks unrelated to coroutine mechanics.
The first was a thundering herd. Starting all 150,000 terminals at the same instant caused a burst of work and poor behavior. We fixed it by spreading terminal startup across the warm-up interval.
The second was a surprisingly large gRPC slowdown caused by a build configuration issue: what we thought was a proper release build was still missing NDEBUG. The database appeared slow, but the bottleneck was in the client-side RPC stack.
We also found an interesting gRPC channel bottleneck and wrote a separate post about it.
These problems reinforce the central lesson:
Coroutines remove the cost of representing large numbers of suspended workflows. They do not remove bottlenecks in scheduling, networking, allocation, synchronization, or startup behavior.
You still have to measure the whole system.
TPC-C for PostgreSQL
As a small bonus, we also ported our TPC-C implementation to PostgreSQL.
Most of the port was done with an LLM — Opus 4.6. The codebase is about 8,000 lines, so this turned into an interesting experiment in how far an LLM can take a real systems-code port.
The result was useful, but certainly not push-button. The model introduced bugs, occasionally dropped functionality, and needed quite a bit of guidance. It helped enormously that we already knew the original code well: reviewing and correcting an LLM-generated port of unfamiliar code would have been much harder.
Still, it saved a substantial amount of mechanical work, and we ended up with a version of the same coroutine-based TPC-C client that can run against PostgreSQL.
This is useful to us for another reason as well: we can use it to test YDB through its PostgreSQL-compatible interface, while keeping the workload and client architecture close to the native YDB benchmark.
So, at least based on this experiment, programmers are not out of a job yet — but LLMs are already quite good at helping with this kind of large, repetitive porting work.
Conclusion
C++ coroutines gave us the combination we wanted:
- the scalability of asynchronous futures;
- the readability of synchronous code;
- explicit control over scheduling and thread affinity;
- enough efficiency to represent 150,000 TPC-C terminals (and many more) on one client machine.
The language feature itself is only part of the solution. The compiler builds the state machine, but a real application still needs futures, timers, a scheduler, an event loop, and clear rules for where continuations run.
For library developers, this is a workshop full of low-level tools: promise types, awaiters, coroutine handles, lifetime management, and schedulers. For application developers, a good coroutine library turns those tools into something much simpler: code that reads like the business process it implements.

That is the real value of coroutines. They do not make asynchronous systems simple. They let us move much of the unavoidable complexity out of every application workflow and into a reusable runtime.
And once that runtime exists, coroutines can finally become routine.
Making Coroutines Routine: Building a Scalable TPC-C Client in C++ was originally published in YDB.tech blog on Medium, where people are continuing the conversation by highlighting and responding to this story.