Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 21

Understanding I/O Models: Blocking, Non-blocking, and Asynchronous

Recommended resources

Hello! Welcome back to our course on backend engineering.

In the last lesson, we uncovered the fundamental role of file descriptors. We saw how the OS uses them as a uniform handle for all kinds of I/O resources—files, pipes, and most importantly for us, network sockets. This led us to a critical question: what should our server process do if it tries to read from a network socket, but the client hasn't sent any data yet? Does it just wait, frozen in place?

This question brings us to the heart of building high-performance, concurrent servers. The answer lies in the different strategies a program can use to handle I/O.

Today's Goal: Our learning outcome is to compare blocking, non-blocking, and asynchronous I/O models with concrete examples. Understanding these models is essential because your choice of I/O strategy directly dictates your server's architecture and its ability to handle thousands of simultaneous connections.

1. The Anatomy of an I/O Operation

Before comparing the models, we need a precise definition of what happens during an I/O operation, like reading data from a socket. From your application's perspective, this involves a system call (e.g., recvfrom() or read()). Inside the kernel, this operation consists of two distinct phases:

  1. Waiting for the data to be ready: The kernel waits for data to arrive on the network. For a read operation, this means waiting for packets to arrive and be assembled in a kernel buffer.
  2. Copying the data from the kernel to the process: Once the data is ready in the kernel's buffer, it must be copied into the buffer your application provided in the system call (e.g., your Python bytes object).

The key difference between the various I/O models is whether your application's process/thread is blocked during these two phases.

Blocking I/O Flow Diagram
This diagram shows the default blocking I/O model. When the application calls `recvfrom`, the process is completely blocked, first while the OS waits for data, and then while it copies the data into the application's memory.

2. An Intuitive Analogy: The Server and The Restaurant

To make these concepts more tangible, let's use an analogy. Imagine a single waiter (a thread) serving several tables (network connections). The way the waiter manages their time when a table is busy (e.g., reading the menu) is analogous to how a server thread manages I/O.

The following video provides an excellent, easy-to-follow explanation using this analogy and distinguishes between I/O-bound and CPU-bound work.

Non-blocking I/O and how Node uses it, in friendly terms: blocking vs async IO, CPU vs IO

The video 'Non-blocking I/O and how Node uses it' introduces the core concepts using a restaurant analogy. It clearly explains the difference between CPU work (active computation) and I/O work (waiting).

Please watch these segments to build a strong mental model: The Restaurant Analogy (01:22 - 03:35): Pay attention to how a single waiter can serve multiple tables by using their 'downtime' effectively. CPU vs. I/O (03:35 - 04:30): Grasp the definition of I/O as 'waiting for something else to happen.' Non-Blocking vs. Blocking I/O (05:05 - 05:57): This is the core comparison. Note how non-blocking I/O prevents the thread from getting 'stuck.'

Now that you have the high-level intuition, let's dive into the technical specifics of each model, as defined by the POSIX standard that underpins Linux and other Unix-like systems. For a deep dive, the classic text "UNIX Network Programming" is the ultimate authority.

Chapter 6. I/O Multiplexing: The select and poll Functions

The article 'I/O Multiplexing: The select and poll Functions' from notes on 'UNIX Network Programming' provides the rigorous definitions we need. We will refer to different parts of it as we go through each model.

For now, just open this resource and read the introductory section 'I/O Models', which lists the five models and defines the two phases of an input operation. We will read the other sections as we discuss each model.


3. The Three Core I/O Models

Model 1: Blocking I/O

This is the default and simplest model.

  • How it works: Your application calls a function like socket.recv(). If data is not immediately ready (phase 1), the kernel puts your process to sleep. It does not wake up until data arrives and is copied into your buffer (phase 2). The process is blocked for the entire duration.
  • Restaurant Analogy: The waiter takes an order, then pulls up a chair at the table and sits there, doing nothing, while the customers read the menu, wait for food, and eat their meal. The waiter only leaves when the customers pay and leave.
  • Python Example:
    import socket
    
    
    
    
    # By default, sockets are in blocking mode
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    
    
    
    # ... bind and listen ...
    conn, addr = server_socket.accept() 
    
    
    
    
    # The process will block here indefinitely until the client sends data.
    data = conn.recv(1024) 
    
    print(f"Received: {data}")
    
  • Pros: Very simple to write and reason about. The code executes sequentially.
  • Cons: Terrible for concurrency. If you have a single-threaded server and one client connects but sends no data, the entire server freezes and cannot accept new connections or serve other clients.

Model 2: Non-Blocking I/O

This model avoids getting stuck.

  • How it works: You first configure the socket to be non-blocking. When you call socket.recv(), if no data is ready, the kernel returns an error immediately (e.g., EWOULDBLOCK) instead of putting the process to sleep.
  • This forces your application into a pattern called polling: you have to repeatedly call recv() in a loop to check if data has arrived.
  • Restaurant Analogy: The waiter approaches a table and asks, "Ready to order?" If they say no, the waiter immediately walks away, checks another table, comes back a second later, and asks again. And again. And again.
  • Python Example:
    import socket
    import time
    
    
    
    
    # ... server_socket setup ...
    conn, addr = server_socket.accept()
    conn.setblocking(False) # Set the socket to non-blocking mode
    
    while True:
        try:
    
    
    
            # If no data, this will raise a BlockingIOError instead of blocking
            data = conn.recv(1024) 
            print(f"Received: {data}")
            break # Exit loop after receiving data
        except BlockingIOError:
    
    
    
            # No data available yet. Do something else or just wait briefly.
            print("No data, polling again...")
            time.sleep(0.5)
    
  • Pros: The server thread doesn't get stuck. It can do other work between checks, like serving other ready clients.
  • Cons: The polling loop is a "busy-wait," consuming CPU cycles just to ask "Are we there yet?". This is highly inefficient.

This inefficiency leads to a much better approach, I/O Multiplexing. Instead of polling, you ask the kernel, "Please wake me up only when one or more of these file descriptors are ready for I/O." The system calls for this are select(), poll(), and on modern Linux, the much more efficient epoll(). Your process blocks on the select() call, but this is a good block—the process is efficiently sleeping, consuming no CPU, until the kernel has work for it. This is the foundation of nearly all modern high-concurrency servers, including Node.js and Python's asyncio.

Please read the "Nonblocking I/O Model" and "I/O Multiplexing Model" sections in the "UNIX Network Programming" article (LINK) to see the official diagrams and explanation.

Model 3: Asynchronous I/O (AIO)

This is the most advanced model and requires a careful definition.

  • How it works: Your application tells the kernel to start an I/O operation (e.g., "read from this socket into this buffer"). The system call returns immediately. The kernel performs the entire operation in the background—both waiting for data (phase 1) and copying it into your buffer (phase 2). When the whole operation is complete, the kernel notifies your application (e.g., by triggering a callback or a signal).
  • Restaurant Analogy: The waiter takes your order and attaches a pager to your belt. They say, "This will buzz when your food is on the table." The waiter is free to serve others, and you are free to talk or use your phone. You don't have to do anything until the pager buzzes, at which point the food is already in front of you, ready to eat.
  • Python Example (Conceptual): True AIO is complex. Libraries like asyncio simulate it for you using I/O multiplexing. A real AIO system call like Linux's io_uring might look conceptually like this:
    
    
    
    # Conceptual code using a hypothetical io_uring library
    
    def on_data_received(data):
        print(f"Asynchronously received: {data}")
    
    
    
    
    # Tell the kernel: "Start reading from 'conn', put data in 'buffer',
    # and call 'on_data_received' when you're completely done."
    kernel.aio_read(conn, buffer, on_data_received)
    
    
    
    
    # This call returns immediately. The program can do other things.
    print("Read operation submitted. Waiting for callback...") 
    

4. Synchronous vs. Asynchronous: A Crucial Distinction

Now we must address a common point of confusion. The POSIX standard defines "synchronous" and "asynchronous" very precisely. Please read the section "Synchronous I/O versus Asynchronous I/O" in the LINK article.

  • Synchronous I/O: An I/O operation that causes the requesting process to be blocked until the operation completes.
  • Asynchronous I/O: An I/O operation that does not cause the requesting process to be blocked.

Based on these definitions:

  • Blocking I/O is synchronous because the process is blocked for the whole operation.
  • Non-blocking I/O is also synchronous. Why? Because while the call doesn't block if data isn't ready, the actual recv() call that does successfully get data will still block the process during phase 2 (copying data from kernel to process).
  • I/O Multiplexing is also synchronous for the same reason. The process blocks on select(), and then the recv() call blocks during the data copy.
  • Only Asynchronous I/O (AIO) is truly asynchronous, because the process is never blocked for any part of the I/O operation. The aio_read call returns immediately, and the data copy happens in the background.

This strict definition is important for deep understanding. In more casual conversation and even some frameworks, "asynchronous" is often used to describe code written with async/await, which typically uses I/O multiplexing (a synchronous mechanism) under the hood to achieve its non-blocking behavior.

The video below discusses these nuances, although it uses a slightly different vocabulary ("asynchronous blocking" vs "asynchronous non-blocking"). It's useful for understanding that "asynchronous programming" often involves running blocking code on a separate thread pool.

Sync, Async, Blocking and Non-Blocking | Rock the JVM

The 'Rock the JVM' video explains the distinction between synchronous/asynchronous and blocking/non-blocking, using Scala as an example. It helps clarify different ways to achieve concurrency.

Watch the segment from 01:06 to 15:38. Don't worry about the Scala syntax. Focus on the conceptual ideas: A blocking call: The thread is idle. An 'async blocking' call: A blocking operation is moved to another thread, freeing up the main thread but still blocking the worker thread. A true 'async non-blocking' system (using Actors): Operations are submitted as messages to a system with a small number of threads that handle them without blocking. This is analogous to how an event loop works.


Conclusion

You've now seen the three primary I/O models from both a high-level, intuitive perspective and a low-level, technically precise one. This knowledge is crucial for making informed architectural decisions for any backend service.

Key Takeaways:

  • I/O operations have two phases: waiting for data and copying data.
  • Blocking I/O is simple but blocks the process during both phases, killing concurrency.
  • Non-Blocking I/O avoids blocking during the wait phase but leads to inefficient CPU-wasting polling.
  • I/O Multiplexing (select/epoll) is a synchronous model that efficiently solves the polling problem by blocking until the kernel signals that data is ready. This is the engine behind most modern async frameworks.
  • Asynchronous I/O (AIO) is the only truly asynchronous model, where the kernel handles both phases in the background and notifies the application only upon final completion.

Preview of the Next Lesson:

We've mentioned I/O multiplexing and epoll as the foundation for modern servers. In the next lesson, we will focus specifically on this. The learning outcome will be to describe the role of an event loop (e.g., using epoll/kqueue) in high-concurrency servers, connecting the theory from today directly to the implementation pattern used by Node.js, Gunicorn, and other high-performance backend technologies.