Skip to main content

asyncio-event-loop-monitor

PyPI version Python Versions License

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 of await 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:

  1. The code runs without errors
  2. Performance degradation may only appear under load
  3. 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)
  • importlib
  • typing
  • contextlib
  • functools
  • threading
  • asyncio
  • concurrent

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

  1. Debugging sessions - Enable temporarily to find blocking calls
  2. CI/testing - Run in test suite to catch regressions
  3. Canary deployments - Enable on a small percentage of production pods
  4. 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.

Metadata

Release files for asyncio-event-loop-monitor 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for asyncio-event-loop-monitor 0.1.0
File Size Uploaded Provenance Actions
asyncio_event_loop_monitor-0.1.0.tar.gz 14.0 kB Without provenance Details for asyncio_event_loop_monitor-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for asyncio-event-loop-monitor 0.1.0
File Interpreter ABI Platform Actions
asyncio_event_loop_monitor-0.1.0-py3-none-any.whl Python 3 none any Details for asyncio_event_loop_monitor-0.1.0-py3-none-any.whl

Total release size: 27.9 kB

Release files / asyncio_event_loop_monitor-0.1.0.tar.gz

Download URL asyncio_event_loop_monitor-0.1.0.tar.gz
Size 14.0 kB
Tags Source
SHA-256 checksum
How to use checksums
ad84b4add1c39e6cfb0374d9b416436c91b92af2a152578c51d40b41f0f50f9e
BLAKE2b-256 checksum
How to use checksums
435de5a6cd4be53a4a9b69d66adf812e5f7b9b4a51f115789cc0756432fde082
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.12

Release files / asyncio_event_loop_monitor-0.1.0-py3-none-any.whl

Download URL asyncio_event_loop_monitor-0.1.0-py3-none-any.whl
Size 13.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
dcd6bff1aa959993184770ba04b050dd1cbbb8e4684f4886dc86ee0ddcc3a44f
BLAKE2b-256 checksum
How to use checksums
5e082a9e613a23fa99b9bc000e0736a72a16b92180ce816d87b266cdbb712bbc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.8.12

Security for asyncio-event-loop-monitor 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page