Under load, the quietest latency killer in Python services is often logging: every logger.info(...) that touches a FileHandler, WatchedFileHandler, or synchronous HTTP sink holds the GIL while the OS or network waits. Request threads (or asyncio tasks bridged through executors) serialize behind those handlers. You do not fix this by “logging less”—you fix it by making the hot path emit into a lock-free queue and letting a dedicated listener own the I/O.
⚡ TL;DR: Replace hot-path
StreamHandler/FileHandlerwithQueueHandler+QueueListener(orQueueHandler+ a single asyncio sink). Measureloggingtime withpy-spy/perfunder load; ifPyEval_AcquireLockclusters around handlers, you found it. Pair with Python asyncio vs Threading vs Multiprocessing for worker model choices and Lambda Log Buffering: Cut CloudWatch Ingestion Without Losing Correlation for cloud sinks.
Spot GIL stalls caused by handlers
Symptoms look like “CPU is not maxed but p99 jumped.” py-spy dump shows many threads waiting on the GIL; flamegraphs pin frames inside logging/__init__.py or socket.send. Reproduce with a synthetic handler that sleeps under the lock.
# repro/gil_logging_stall.py
import logging, threading, time
class SleepyHandler(logging.Handler):
def emit(self, record):
# ❌ Holds work on the logging path (formatting + custom sinks serialize)
time.sleep(0.005)
print(self.format(record), flush=True)
log = logging.getLogger("api")
log.setLevel(logging.INFO)
log.addHandler(SleepyHandler())
def handle(_):
log.info("order=%s", "o-1") # every request pays the sleep
# 32 threads → near-linear latency blowup vs QueueHandler
QueueHandler + QueueListener pattern
The stdlib pattern moves formatting/I/O off request threads. Emit is cheap: enqueue a LogRecord. One listener thread drains and writes.
# app/logging_setup.py
import logging
import logging.handlers
import queue
from typing import Optional
_listener: Optional[logging.handlers.QueueListener] = None
def configure_async_logging(path: str = "/var/log/api.jsonl") -> None:
global _listener
q: queue.Queue = queue.Queue(-1)
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers.clear()
qh = logging.handlers.QueueHandler(q)
root.addHandler(qh)
file_h = logging.handlers.WatchedFileHandler(path)
file_h.setFormatter(logging.Formatter(
'{"ts":"%(asctime)s","lvl":"%(levelname)s","msg":%(message)s}'
))
# ✅ Listener owns the slow handler; request threads only enqueue
_listener = logging.handlers.QueueListener(q, file_h, respect_handler_level=True)
_listener.start()
def shutdown_logging() -> None:
if _listener:
_listener.stop()
# ❌ Hot path with FileHandler — every worker contends
logging.basicConfig(
level=logging.INFO,
handlers=[logging.FileHandler("/var/log/api.log")],
)
Asyncio services: one writer task
For asyncio FastAPI/aiohttp, prefer a single writer task over a thread listener when the sink is async (HTTP to a collector, aiofiles). Still use QueueHandler so sync libraries do not block the event loop.
# app/async_log_sink.py
import asyncio, logging, logging.handlers, queue
async def run_log_sink(q: queue.Queue, sink) -> None:
while True:
try:
record = q.get_nowait()
except queue.Empty:
await asyncio.sleep(0.01)
continue
if record is None:
break
line = logging.Formatter("%(message)s").format(record)
await sink.write(line + "\n")
Cross-check sampling and exporter backpressure ideas from Lambda ADOT vs Powertools: Tracing Tradeoffs on Node 20 Runtimes — the same “never block the request on export” rule applies.
What still contends (and how to shrink it)
- Formatting: keep
extra=small; avoidjson.dumpsof huge payloads on the hot path. - Locks:
loggingmodule lock still serializesLogger.handle; QueueHandler minimizes hold time. - Fork: after
os.fork/ Gunicorn prefork, restart the listener in each worker (see prefork pitfalls in this batch). - CloudWatch / Fluent Bit: ship from the listener or sidecar; never
PutLogEventsinline per request.
Production rollout
- Capture a 60s
py-spy recordunder peak; confirm logging frames. - Flip one service to QueueListener behind a feature flag; compare p99 and GIL wait.
- Cap queue size or drop DEBUG under pressure (
QueueHandler+ filter). - On SIGTERM:
listener.stop()after draining HTTP, before process exit. - Alert on listener lag (queue depth) the same way you alert on outbound queue lag.
Closing checklist
✅ Dos
– ✅ Use QueueHandler + QueueListener (or one async writer) on every hot path
– ✅ Prove wins with py-spy / latency histograms, not vibes
– ✅ Bound queue depth and degrade log level under pressure
– ✅ Restart listeners after fork
– ✅ Keep structured fields tiny on INFO
❌ Don’ts
– ❌ Don’t call network log APIs from request threads
– ❌ Don’t attach FileHandler directly under Gunicorn/uvicorn workers
– ❌ Don’t logger.exception with full request bodies in tight loops
– ❌ Don’t ignore queue growth — that is a silent memory leak
– ❌ Don’t treat “async def” as proof logging is non-blocking
Related reading
- Python asyncio vs Threading vs Multiprocessing
- Lambda Log Buffering: Cut CloudWatch Ingestion Without Losing Correlation
- Lambda ADOT vs Powertools: Tracing Tradeoffs on Node 20 Runtimes
- Python Performance Secrets: 20 Techniques to Make Your Code Faster
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: Python tracemalloc in Workers: Find Retained Caches Without Noise - CheatCoders