python
coroutine
async
time-measurement
performance-analysis

How to separately measure time spent suspended and blocking for a given coroutine in python?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In asyncio, a coroutine can spend time actively running Python code, suspended while awaiting something else, or blocking the event loop by calling synchronous code that should not have been there. Measuring those buckets separately is useful, but it requires instrumentation because asyncio does not automatically expose a neat built-in counter for each state per coroutine.

What You Can and Cannot Measure Directly

A coroutine does not carry a built-in stopwatch for “suspended time” and “blocking time.” You have to infer those durations from how the coroutine is written.

A practical model is:

  • Active time: time spent executing coroutine code between await points
  • Suspended time: time spent inside awaited asynchronous operations
  • Blocking time: time spent in synchronous operations that stall the event loop, such as time.sleep() or CPU-heavy work

The important limitation is that you can accurately measure these categories only where you add instrumentation.

Measure Suspended Time Around await

A simple pattern is to wrap awaited operations and accumulate how long the coroutine stays suspended while waiting for them.

python
1import asyncio
2import time
3
4class CoroutineTimer:
5    def __init__(self):
6        self.suspended = 0.0
7        self.blocking = 0.0
8
9    async def measured_await(self, awaitable):
10        start = time.perf_counter()
11        result = await awaitable
12        self.suspended += time.perf_counter() - start
13        return result
14
15    def measured_blocking(self, func, *args, **kwargs):
16        start = time.perf_counter()
17        result = func(*args, **kwargs)
18        self.blocking += time.perf_counter() - start
19        return result

Now use it inside a coroutine:

python
1async def worker(timer):
2    await timer.measured_await(asyncio.sleep(0.2))
3    timer.measured_blocking(time.sleep, 0.1)
4    await timer.measured_await(asyncio.sleep(0.3))
5
6async def main():
7    timer = CoroutineTimer()
8    await worker(timer)
9    print(f"suspended={timer.suspended:.3f}")
10    print(f"blocking={timer.blocking:.3f}")
11
12asyncio.run(main())

This records time spent waiting asynchronously separately from time spent in explicitly measured blocking calls.

Why time.sleep() Is Different from asyncio.sleep()

The distinction matters a lot:

  • 'await asyncio.sleep(1) suspends the coroutine and gives control back to the event loop.'
  • 'time.sleep(1) blocks the thread and prevents the event loop from running other coroutines.'

That means identical wall-clock durations have very different performance implications. Measuring them separately helps you spot accidental blocking inside async code.

Estimate Active Execution Time Too

If you want a fuller picture, track total wall time and subtract the suspended and blocking portions you measured.

python
1async def profile_coroutine(coro):
2    timer = CoroutineTimer()
3    start = time.perf_counter()
4    await coro(timer)
5    total = time.perf_counter() - start
6    active = total - timer.suspended - timer.blocking
7    return total, active, timer.suspended, timer.blocking
8
9async def demo(timer):
10    await timer.measured_await(asyncio.sleep(0.05))
11    timer.measured_blocking(time.sleep, 0.02)
12
13async def main():
14    total, active, suspended, blocking = await profile_coroutine(demo)
15    print(total, active, suspended, blocking)
16
17asyncio.run(main())

This is still approximate because it only counts the code paths you instrument, but it is often enough to answer practical performance questions.

Detect Hidden Blocking

The hardest part is hidden blocking, where a coroutine calls a library function that looks harmless but performs synchronous work. There is no perfect per-coroutine automatic answer, but useful techniques include:

  • Enable asyncio debug mode
  • Log slow callbacks
  • Profile suspicious sections with perf_counter
  • Move CPU-bound work into run_in_executor or a process pool

If you need exact attribution for every awaitable and callback across a large system, you are moving from simple timing into tracing or profiling territory.

Common Pitfalls

A common mistake is measuring only total wall time and assuming any long duration means blocking. A coroutine can take a long time because it spent most of that time properly suspended on I/O.

Another mistake is thinking asyncio automatically tells you which time belonged to suspension versus blocking. It does not; you need wrappers, tracing, or profiler support.

A third mistake is leaving synchronous calls such as time.sleep() or heavy JSON parsing inside async code. Even if the measured blocking time is small in one test, it can become a system-wide bottleneck under load.

Summary

  • 'asyncio does not expose built-in per-coroutine suspended and blocking timers.'
  • You can measure suspended time by wrapping awaited operations.
  • You can measure blocking time by timing known synchronous calls inside the coroutine.
  • Total wall time can be split into approximate active, suspended, and blocking portions.
  • For hidden blocking, use debug mode, profiling, and better task design rather than relying on one magic API.

Course illustration
Course illustration

All Rights Reserved.