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
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:
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.
Related
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” (
BlockingIOErrorin 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;
aiofilesandto_threadrun 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.