Backend / Async & concurrency / 18_blocking_vs_nonblocking_io.md

Blocking and non-blocking I/O

Updated 5 interview angles 5 min read source
On this page9
  1. Two questions, not one
  2. Non-blocking sockets in Python
  3. Waiting on many sockets: readiness APIs
  4. What cannot be made non-blocking
  5. Blocking and the GIL
  6. What “blocking the event loop” means
  7. Where it breaks in production
  8. Related
  9. Interview angle

Blocking and non-blocking I/O

“Blocking” has a precise meaning: the calling thread is suspended by the kernel until the operation can complete. A recv() on a socket with no data, a read() from a slow disk, a DNS lookup: the thread sleeps, and nothing else runs on it. Every concurrency model in Python is a way of not wasting that thread, and asyncio’s rule “never block the loop” is this definition applied to the one thread the loop has. Checked on CPython 3.14.5.

Two questions, not one

People mix up two separate axes:

Waits for readiness Waits for completion
The thread sleeps blocking recv() blocking recv()
The call returns at once non-blocking + select/epoll IOCP, io_uring
  • Blocking or non-blocking is about the call: does it sleep, or return immediately with “not yet”?
  • Readiness or completion is about the notification: are you told “this socket has data, now read it”, or “your read has finished, here are the bytes”?

asyncio on Linux and macOS is non-blocking calls plus readiness notifications; on Windows it uses completion notifications (IOCP) through the proactor loop (Event loop internals).

Non-blocking sockets in Python

python
a, b = socket.socketpair()
a.setblocking(False)
a.recv(1024)        # BlockingIOError: no data yet (EAGAIN, or 10035 on Windows)
b.sendall(b"hello")
a.recv(1024)        # b'hello'

A socket is blocking by default, with no timeout: recv() on a silent peer waits forever. setblocking(False) makes every call return at once, raising BlockingIOError instead of sleeping; settimeout(5) is the middle ground, blocking for at most five seconds and then raising TimeoutError.

Waiting on many sockets: readiness APIs

A non-blocking socket on its own just turns waiting into busy-polling. The kernel offers one call that waits on many sockets at once and reports which are ready:

python
sel = selectors.DefaultSelector()        # epoll, kqueue or select
sel.register(conn, selectors.EVENT_READ)
for key, events in sel.select(timeout=1.0):
    data = key.fileobj.recv(4096)        # ready: will not block
API Platforms Cost per call
select everywhere; Windows’ default scans every watched socket
poll Unix scans every watched socket
epoll Linux proportional to the ready ones
kqueue macOS, BSD proportional to the ready ones

selectors.DefaultSelector picks the best available: SelectSelector on Windows, epoll on Linux. epoll and kqueue are why one thread can watch tens of thousands of connections: registering once and paying only for activity is what made the “C10K” problem routine.

Completion APIs go further: the program hands the kernel a buffer and an operation, and is told when it is done. Windows has had IOCP for decades; Linux added io_uring in 5.1. CPython’s asyncio uses IOCP on Windows and does not use io_uring.

What cannot be made non-blocking

Regular files

A file on disk is always “ready” as far as readiness APIs are concerned: epoll refuses regular files outright, and select reports them readable even when the read will wait on the disk. So asyncio has no truly asynchronous file I/O: aiofiles and asyncio.to_thread(path.read_bytes) run the read in a thread, which is fine for files and the honest way to do it.

DNS

socket.getaddrinfo() is a blocking C library call with no non-blocking variant in the standard library. asyncio’s loop.getaddrinfo() runs it in the default thread pool, so every new connection to a hostname costs a thread for the lookup, and a slow DNS server saturates that pool.

C libraries

A C extension that does its own blocking I/O (some database drivers, SDKs built on synchronous HTTP clients) blocks whichever thread calls it, event loop included. It releases the GIL while it waits, so other threads run, but the loop thread is gone.

Blocking and the GIL

A blocking system call releases the GIL for its duration. That is why threads work for I/O-bound code on the default build: while one thread sleeps in recv(), others run Python. It is also why “blocking” and “CPU-bound” are different problems: a blocked thread costs a thread; a CPU-bound one costs the GIL (The GIL, and free-threaded Python).

What “blocking the event loop” means

The loop is one thread doing step after step of non-blocking work, sleeping only in its own select(). A blocking call made on that thread (a synchronous HTTP request, time.sleep, a file read, a sync database driver) puts the whole loop to sleep in the kernel, so no socket is read and no timer fires until it returns.

The fix is always the same: use the non-blocking version of the call, or move the blocking one to a thread (run_in_executor and asyncio.to_thread).

Where it breaks in production

  • A socket with no timeout. The default is to wait forever; a peer that accepts the connection and never answers holds a thread, or a whole worker, indefinitely. Set timeouts on every client.
  • DNS on a slow resolver. Each connection’s lookup takes a thread from the default executor; when lookups stall, so do new connections. Cache DNS, reuse connections, or use an async resolver library.
  • File I/O on the loop. Reading a large upload or writing a log file synchronously inside a coroutine blocks every task; offload it.
  • A “non-blocking” call that is not. An async-looking library that wraps a synchronous client blocks the loop all the same. Check what it calls underneath, or measure event-loop lag.

Interview angle 5

  • “What does blocking I/O mean?” - the kernel suspends the calling thread until the operation can complete. Non-blocking I/O returns at once with “not ready” (BlockingIOError in Python) instead.
  • “Blocking vs synchronous, non-blocking vs asynchronous?” - two axes. Blocking is whether the call sleeps; readiness vs completion is how you learn the operation can proceed or has finished. asyncio on Linux is non-blocking calls plus epoll readiness; on Windows it is IOCP completion.
  • “How does one thread handle 10,000 connections?” - non-blocking sockets registered with epoll or kqueue, which report only the ready ones, so the thread sleeps in one call and does work only for active sockets.
  • “Is file I/O asynchronous in asyncio?” - no. Readiness APIs cannot tell when a disk read will wait, so asyncio has no native async file I/O; aiofiles and to_thread run it in threads. DNS lookups go through a thread pool too.
  • “Why do threads help with I/O despite the GIL?” - a blocking system call releases the GIL, so while one thread waits in the kernel, others run Python.