Linux
Linux I/O model

The Linux I/O model

Generally, it is believed that there are five I/O models in Linux:

  1. Blocking I/O Model
  2. Non-Blocking I/O Model
  3. Multiplexing I/O Model
  4. Signal-Driven I/O Model
  5. Asynchronous I/O Model

Blocking I/O Model

When a process performs an I/O operation, it is blocked until the operation is completed. This means that during the execution of the I/O operation, the process cannot perform any other tasks. This model is the simplest I/O model, but it may cause a decrease in process performance.

process            Core
 |                 |
 |   I/O Request   |
 |---------------->|
 |                 |
 |                 |
 |                 |   I/O process
 |                 |------------------------>  |
 |                 |                           |
 |                 |                           |
 |                 |                           |
 |                 |  I/O Done,Write data to buffer
 |                 |<------------------------  |
 |                 |                           |
 |                 |   I/O result      |
 |                 |<------------------------  |
 |                 |
 |   I/O result  |
 |<----------------|
 |                 |

when a process initiates an I/O request, the CPU switches from user mode to kernel mode and transfers control to the operating system kernel. Within the kernel, the I/O operation is performed until completion. Once the I/O operation is complete, the kernel copies the data from the device driver to the process's buffer and returns control to the process. Finally, the process handles the result of the I/O operation and continues with other tasks.

import socket
 
HOST = 'localhost'
PORT = 8080
 
# craete tcp socket instance
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 
# bind the socket to HOST and port
sock.bind((HOST, PORT))
 
# start listen
sock.listen(1)
 
while True:
    # accept 
    conn, addr = sock.accept()
    print('Connected by', addr)
 
    data = conn.recv(1024)
    if not data:
        break
 
    # handle
    # ...
    conn.sendall(data)
    # 关闭连接
    conn.close()
 

Non-Blocking I/O Model

  1. Set the file descriptor to non-blocking mode. In non-blocking I/O model, the process needs to set the file descriptor to non-blocking mode to allow I/O operations to return immediately without blocking the process's execution. This can be achieved using the fcntl() function or the ioctl() function.

  2. Initiate non-blocking I/O operations. The process initiates I/O operations using the read() or write() functions. If the operation cannot be completed immediately, the function returns immediately and returns an error code (usually EAGAIN or EWOULDBLOCK).

  3. Poll for completion of I/O operations. The process uses polling or event notification mechanisms to check whether the I/O operation has completed. Polling can be performed using functions such as select(), poll(), epoll(), or event notification mechanisms such as signals, pipes, and event callbacks. If the I/O operation has completed, data can be read or written.

  4. Read or write data. If the I/O operation has completed, data can be read or written using the read() or write() function. If the operation is not completed, the process needs to poll again or wait for an event notification.

It should be noted that the polling mechanism can cause the process to frequently switch between kernel mode and user mode when checking whether the I/O operation has completed, which can result in some performance overhead. Therefore, in practical applications, more efficient event notification mechanisms such as the epoll() function are often used instead of polling mechanisms.

import socket
 
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', 8000))
server_socket.listen()
 
server_socket.setblocking(False)  # Non-blocking
 
while True:
    try:
        client_socket, address = server_socket.accept()
    except BlockingIOError:
        pass
    else:
        print(f"Received connection from {address}")
        client_socket.sendall(b"Hello, client!")
        client_socket.close()

Another example using epoll

#include <sys/epoll.h>
 
// Create an epoll instance
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
    perror("epoll_create1");
    exit(EXIT_FAILURE);
}
 
// Add the socket file descriptor to the epoll instance
struct epoll_event event;
event.events = EPOLLIN | EPOLLOUT | EPOLLET;
event.data.fd = sockfd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &event) == -1) {
    perror("epoll_ctl");
    exit(EXIT_FAILURE);
}
 
// Wait for events to occur
struct epoll_event events[MAX_EVENTS];
while (true) {
    int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    if (num_events == -1) {
        perror("epoll_wait");
        exit(EXIT_FAILURE);
    }
 
    // Process events
    for (int i = 0; i < num_events; i++) {
        if (events[i].events & EPOLLIN) {
            // Data is available to read
            char buf[BUF_SIZE];
            int num_bytes = recv(events[i].data.fd, buf, BUF_SIZE, 0);
            if (num_bytes == -1) {
                perror("recv");
                exit(EXIT_FAILURE);
            }
            // Process received data
            ...
        }
        if (events[i].events & EPOLLOUT) {
            // Socket is ready to write
            char buf[BUF_SIZE];
            int num_bytes = send(events[i].data.fd, buf, BUF_SIZE, 0);
            if (num_bytes == -1) {
                perror("send");
                exit(EXIT_FAILURE);
            }
            // Process sent data
            ...
        }
    }
}
 

Multiplexing I/O Model

The main difference between multiplexing and non-blocking I/O is how they handle I/O operations. With multiplexing, a single thread can monitor multiple I/O channels simultaneously and receive notifications when data is ready to be read or written. In contrast, non-blocking I/O requires the program to initiate I/O operations and check for readiness repeatedly until the operation completes.

Multiplexing is more efficient when dealing with many I/O channels because it allows the program to monitor them all at once, whereas non-blocking I/O requires the program to constantly check for readiness on each individual channel. However, non-blocking I/O is simpler to implement and can be more appropriate for certain situations, such as when dealing with only a small number of I/O channels.

An python example

import socket
import select
 
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', 8888))
server_socket.listen()
 
epoll = select.epoll()
epoll.register(server_socket.fileno(), select.EPOLLIN)
 
try:
    while True:
        events = epoll.poll()
        for fileno, event in events:
            if fileno == server_socket.fileno():
                # New connection
                connection, address = server_socket.accept()
                epoll.register(connection.fileno(), select.EPOLLIN)
            else:
                # Existing connection with incoming data
                connection = socket.fromfd(fileno, socket.AF_INET, socket.SOCK_STREAM)
                data = connection.recv(1024)
                if not data:
                    # Connection closed
                    epoll.unregister(fileno)
                    connection.close()
                else:
                    # Process incoming data
                    pass
 
finally:
    epoll.unregister(server_socket.fileno())
    epoll.close()
    server_socket.close()

Signal-Driven I/O Model

The Signal-driven I/O (Signal-driven I/O) model is an I/O model that uses signal mechanism to implement asynchronous I/O operations. In this model, the process sends a signal to the kernel, telling the kernel to wait for a certain type of I/O event on a file descriptor. When the I/O event occurs, the kernel sends a signal to the process, indicating that the process can perform I/O operations.

The specific process of the signal-driven I/O model is as follows:

  1. The process sends a signal to the kernel, telling the kernel to wait for I/O events on a certain file descriptor.
  2. While waiting for I/O events, the kernel allows the process to continue to execute other tasks, that is, to perform asynchronously. If the waiting I/O event occurs, the kernel generates a signal for the file descriptor, notifying the process that it can perform I/O operations.
  3. After receiving the signal, the process handles the I/O event and continues to execute other tasks.

The advantage of the signal-driven I/O model is that it can make full use of the advantages of asynchronous I/O and avoid blocking, thereby improving the system's performance. However, the disadvantage of this model is that it is only suitable for some relatively simple applications. For some complex applications, using the signal-driven I/O model will greatly increase the program's logic complexity.

Overall, the signal-driven I/O model can implement asynchronous I/O, avoid blocking, and improve system performance, but its application scope is somewhat limited.

import os
import signal
 
# signal handler function
def handler(signum, frame):
    print('File content is:', data)
 
fd = os.open('example.txt', os.O_RDONLY)
 
# register signal handler function
signal.signal(signal.SIGIO, handler)
 
# Set the ownership of the file descriptor fd to the current process to enable it to receive SIGIO signals
fcntl.fcntl(fd, fcntl.F_SETOWN, os.getpid())
 
# Set file descriptor fd to non-blocking mode
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_ASYNC | os.O_NONBLOCK)
 
# read data
data = b''
while True:
    try:
        chunk = os.read(fd, 1024)
        if not chunk:
            break
        data += chunk
    except InterruptedError:
        pass
 
# 关闭文件描述符
os.close(fd)

Asynchronous I/O Model

Asynchronous I/O model and signal-driven I/O model are both non-blocking I/O models that can achieve asynchronous I/O operations, improve system concurrency and response speed. However, their implementation methods are different.

In the signal-driven I/O model, when an I/O operation is completed, the kernel sends a signal to the process to notify it, and the process needs to handle the result of the I/O operation in the signal handler function. The characteristic of this model is that it can achieve asynchronous I/O operations through signals, allowing the process to continue executing other tasks before the I/O operation is completed. However, the signal-driven I/O model requires using signal handler functions to handle the result of I/O operations, which is more complex and may cause race conditions and deadlocks.

In the asynchronous I/O model, when an I/O operation is completed, the kernel directly notifies the process, and the process needs to call the previously registered callback function to handle the result of the I/O operation. The characteristic of this model is that it can directly use callback functions to handle the result of I/O operations, allowing the process to continue executing other tasks before the I/O operation is completed. At the same time, the implementation of the asynchronous I/O model is also more complex and requires a complicated callback mechanism.

Overall, the asynchronous I/O model is more efficient and flexible than the signal-driven I/O model, but it is also more complex to implement. When choosing an I/O model, it is necessary to select based on specific application scenarios and requirements.

import asyncio
 
async def fetch_data():
    await asyncio.sleep(1)
    return "Data from server"
 
async def process_data(data):
    await asyncio.sleep(1)
    return f"Processed data: {data.upper()}"
 
async def main():
    loop = asyncio.get_event_loop()
 
    data = await fetch_data()
 
    processed_data = await process_data(data)
 
    print(processed_data)
 
    loop.close()
 
asyncio.run(main())