Non-blocking logging with queues

logging · Expert · 13 min

Network or disk handlers can stall request threads and the asyncio event loop; a queue decouples them.

Every handler's emit() runs synchronously in the thread that logged. A slow destination (SMTP, HTTP, a network file system, or even a busy disk) blocks the caller. In asyncio code, that means the event loop.

The fix is two-stage logging:

  • QueueHandler(q) is the only handler on your loggers. Its emit just prepares the record (formats the message, drops unpicklable args and exception objects in favour of text) and puts it on a queue: fast and non-blocking.
  • QueueListener(q, *handlers) runs a background thread that pulls records off the queue and passes them to the real, slow handlers.

Start the listener at startup and call listener.stop() at shutdown to flush remaining records. Since 3.12, dictConfig can create both: a handler with "class": "logging.handlers.QueueHandler" and a "handlers" list builds a listener, available as handler.listener, which you start yourself.

With multiprocessing, a multiprocessing.Queue lets worker processes ship records to one listener in the main process, which avoids interleaved writes to a shared file. Record attributes must be picklable then.

Use respect_handler_level=True on the listener so each downstream handler's level still applies.

Example

import logging, logging.handlers, queue, sys, time

class SlowHandler(logging.StreamHandler):
    def emit(self, record):
        time.sleep(0.2)                    # pretend network I/O
        super().emit(record)

q = queue.Queue(-1)                        # unbounded
listener = logging.handlers.QueueListener(
    q, SlowHandler(sys.stdout), respect_handler_level=True)
log = logging.getLogger("api")
log.addHandler(logging.handlers.QueueHandler(q))
log.setLevel(logging.INFO)

listener.start()
t = time.perf_counter()
for i in range(3):
    log.info("request %d", i)
print(f"logging took {time.perf_counter() - t:.3f}s")  # ~0.000s
listener.stop()                            # flushes: request 0, 1, 2

Remember

  • Handlers run in the logging thread; slow ones block callers
  • QueueHandler enqueues, QueueListener emits from a background thread
  • Stop the listener at shutdown to flush pending records

Watch out for

  • Forgetting listener.stop() at exit, losing the last log lines.

Check yourself

  1. Why is QueueHandler recommended for logging from asyncio applications?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/howto/logging-cookbook.html#dealing-with-handlers-that-block