Detect and monitor synchronous blocking calls in Python asyncio event loops
Project description
asyncio-event-loop-monitor
Detect and monitor synchronous blocking calls in Python asyncio event loops.
The Problem
In asyncio applications, blocking synchronous calls can severely degrade performance by preventing the event loop from processing other tasks. Common culprits include:
time.sleep()instead ofawait asyncio.sleep()- Synchronous file I/O (
open(),read(),write()) - Blocking network calls
- CPU-intensive computations
- Database queries without async drivers
These blocking calls are often difficult to detect because:
- The code runs without errors
- Performance degradation may only appear under load
- They can be hidden deep in third-party libraries
The Solution
asyncio-event-loop-monitor uses Python's sys.setprofile() to monitor function calls and detect when synchronous code blocks the event loop for longer than a configurable threshold.
Installation
pip install asyncio-event-loop-monitor
Or with uv:
uv add asyncio-event-loop-monitor
Quick Start
Using the Context Manager (Recommended)
import asyncio
from asyncio_event_loop_monitor import event_loop_monitor_ctx
async def main():
with event_loop_monitor_ctx(threshold_ms=10.0):
await process_requests()
asyncio.run(main())
Using the Monitor Directly
import asyncio
from asyncio_event_loop_monitor import EventLoopMonitor
async def main():
monitor = EventLoopMonitor(threshold_ms=10.0)
monitor.activate()
try:
await process_requests()
finally:
monitor.deactivate()
asyncio.run(main())
Custom Callback
from asyncio_event_loop_monitor import event_loop_monitor_ctx, BlockingCallInfo
def my_callback(info: BlockingCallInfo) -> None:
print(f"BLOCKING: {info.method_name} took {info.duration_ms:.2f}ms")
# Send to your metrics system
# statsd.distribution("blocking_call.duration", info.duration_ms, tags=[f"method:{info.method_name}"])
async def main():
with event_loop_monitor_ctx(threshold_ms=10.0, on_blocking_call=my_callback):
await process_requests()
How It Works
sys.setprofile - The Core Mechanism
Python's sys.setprofile(callback) registers a callback that gets invoked on every function call, return, and exception:
def callback(frame: FrameType, event: str, arg: Any) -> None
The monitor tracks:
"call"events - Records start time"return"events - Calculates duration and emits callback if above threshold"c_call"/"c_return"- Same for C extension functions
Why Coroutines Are Skipped
Coroutine frames (async def functions) are skipped because their execution time includes time spent yielded to the event loop, which is NOT blocking:
async def my_coroutine():
await asyncio.sleep(1) # Yields for 1 second - NOT blocking!
The coroutine frame would show 1 second duration, but the event loop was free to run other tasks during that time.
How Blocking Calls Inside Coroutines Are Captured
When a coroutine calls a synchronous function, that sync function's frame does NOT have the CO_COROUTINE flag, so it gets tracked:
async def my_coroutine(): # ← CO_COROUTINE flag = SKIPPED
time.sleep(0.1) # ← Regular sync frame = TRACKED!
json.dumps(big_data) # ← Regular sync frame = TRACKED!
await asyncio.sleep(1) # ← CO_COROUTINE flag = SKIPPED
Configuration Options
EventLoopMonitor / event_loop_monitor_ctx
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold_ms |
float |
50.0 |
Minimum duration in milliseconds for a call to be considered blocking |
include_paths |
list[str] | None |
None |
List of path substrings to include. If provided, only matching paths are monitored |
exclude_paths |
list[str] | None |
[...] |
List of path substrings to exclude from monitoring |
on_blocking_call |
Callable[[BlockingCallInfo], None] | None |
None |
Callback function invoked when blocking is detected |
Default Exclude Paths
By default, the following paths are excluded to reduce noise:
asyncio_event_loop_monitor(the monitor itself)<frozen(frozen modules)importlibtypingcontextlibfunctoolsthreadingasyncioconcurrent
BlockingCallInfo
The callback receives a BlockingCallInfo dataclass with:
| Field | Type | Description |
|---|---|---|
method_name |
str |
Fully qualified method name (e.g., myapp.service.process_data) |
duration_ms |
float |
Duration of the blocking call in milliseconds |
is_c_call |
bool |
Whether this was a C extension function call |
Performance Considerations
WARNING: sys.setprofile adds significant overhead because it's called on EVERY Python function call and return. Typical impact:
- 10-30% slowdown on CPU-bound code
- Higher for code with many small function calls
- Memory overhead for tracking call stacks
Recommended Use Cases
- Debugging sessions - Enable temporarily to find blocking calls
- CI/testing - Run in test suite to catch regressions
- Canary deployments - Enable on a small percentage of production pods
- On-demand profiling - Enable via feature flag or request header
NOT Recommended For
- Always-on production monitoring on all pods
- High-throughput, latency-sensitive code paths
Example: Integration with Metrics
from asyncio_event_loop_monitor import event_loop_monitor_ctx, BlockingCallInfo
from datadog import statsd
def emit_metrics(info: BlockingCallInfo) -> None:
statsd.distribution(
"event_loop.blocking.duration_ms",
info.duration_ms,
tags=[f"method:{info.method_name}"]
)
# In your request handler or main loop
async def handle_request(request):
# Only enable for sampled requests
should_profile = request.headers.get("X-Enable-Profiling") == "true"
with event_loop_monitor_ctx(enabled=should_profile, on_blocking_call=emit_metrics):
return await process_request(request)
Example: Finding Blocking Calls in Tests
import pytest
from asyncio_event_loop_monitor import event_loop_monitor_ctx, BlockingCallInfo
@pytest.fixture
def blocking_call_detector():
blocking_calls = []
def callback(info: BlockingCallInfo) -> None:
blocking_calls.append(info)
yield blocking_calls, callback
@pytest.mark.asyncio
async def test_no_blocking_calls(blocking_call_detector):
blocking_calls, callback = blocking_call_detector
with event_loop_monitor_ctx(threshold_ms=5.0, on_blocking_call=callback):
await my_async_function()
assert len(blocking_calls) == 0, f"Found blocking calls: {blocking_calls}"
License
Apache License 2.0
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file asyncio_event_loop_monitor-0.1.0.tar.gz.
File metadata
- Download URL: asyncio_event_loop_monitor-0.1.0.tar.gz
- Upload date:
- Size: 14.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ad84b4add1c39e6cfb0374d9b416436c91b92af2a152578c51d40b41f0f50f9e
|
|
| MD5 |
7c7b6147cd21acd9aa43b7001a69c094
|
|
| BLAKE2b-256 |
435de5a6cd4be53a4a9b69d66adf812e5f7b9b4a51f115789cc0756432fde082
|
File details
Details for the file asyncio_event_loop_monitor-0.1.0-py3-none-any.whl.
File metadata
- Download URL: asyncio_event_loop_monitor-0.1.0-py3-none-any.whl
- Upload date:
- Size: 13.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.8.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dcd6bff1aa959993184770ba04b050dd1cbbb8e4684f4886dc86ee0ddcc3a44f
|
|
| MD5 |
b2c0b5093219174695eb4b77f913c97d
|
|
| BLAKE2b-256 |
5e082a9e613a23fa99b9bc000e0736a72a16b92180ce816d87b266cdbb712bbc
|