Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Understanding Network Programming: Linux I/O Models and Multiplexing

Tech Jul 15 2

Modern computer systems consist of five key components: arithmetic logic unit, control unit, memory, input devices, and output devices. The CPU serves as the arithmetic logic unit, performing computations at the highest speed. Memory, input, and output devices store data for CPU read/write operations, with access speeds decreasing as components are positioned further from the CPU. Memory access, disk seeking, and network transmission are all significantly slower than CPU operations.

When the CPU requires data, it relies on I/O operations, which are considerably slower. As a result, the CPU spends most of its time waiting for I/O operations to complete, leading to significant performance bottlenecks.

In Linux systems, processes operate in either kernel mode or user mode. User mode processes face substantial restrictions on operations and resource access, while kernel mode processes can perform any operation without resource limitations. Many programs start in user mode but require kernel privileges for certain operations, necessitating a transition to kernel mode.

Network communication involves data exchange between two hosts. During data transmission, both user mode and kernel mode transitions occur, following this process:

  • The operating system receives data from the network and copies it to kernel buffers
  • The application copies data from kernel buffers to application buffers
  • The application writes processed data back to kernel socket buffers
  • The operating system transfers data from socket buffers to network card buffers for transmission

Understanding the distinctions between synchronous/asynchronous and blocking/non-blocking operations is essential:

  • Synchronous: Operations proceed in a coordinated, sequential manner
  • Asynchronous: Operations execute without a defined order, potentially overlapping
  • Blocking: A process halts execution while waiting for an operation to complete
  • Non-blocking: A process continues executing other tasks while waiting for an operation to complete

Blocking/non-blocking and synchronous/asynchronous represent distinct concepts. Blocking processes can implement asynchronous execution (e.g., through multithreading), and non-blocking approaches can also achieve asynchronous execution (e.g., through multiplexing).

I/O refers to input and output operations relative to memory. Input involves writing data from files, databases, or networks into memory, while output involves writing data from memory to files, databases, or networks. Linux I/O operations consist of two phases: kernel data preparation and copying data from kernel space to user space.

Linux supports five I/O models:

  1. Blocking I/O
  2. Non-blocking I/O
  3. I/O multiplexing
  4. Signal-driven I/O (SIGIO)
  5. Asynchronous I/O

Blocking I/O

When a user mode process invokes a system function to retrieve data, the process blocks if the kernel hasn't prepared the data. It remains idle until the kernel completes data preparation, then copies the data to user space and returns control to the process.

Non-blocking I/O

When a user mode process invokes a system function to retrieve data, the function returns an error if the kernel hasn't prepared the data. The process doesn't block but repeatedly polls the kernel until data is ready, then copies it to user space and processes it.

I/O Multiplexing

I/O multiprocessing enables a single process to manage multiple message events. This technique allows a single communication channel to handle multiple data streams simultaneously through frequency or time division.

Signal-driven I/O Model

When a user mode process requires data, it signals the kernel with specific requirements and continues other tasks. When the kernel prepares the data, it signals the process, which then copies the data to user space and processes it. This model is rarely used in practice.

Asynchronous I/O Model

A user mode process informs the kernel of its data requirements and continues other tasks. The kernel prepares the data, copies it to user space, and notifies the process. The process can directly handle the data without waiting for any I/O operations.

The first four I/O models require the user process to copy data from kernel space to user space, blocking the process during this operation. Only asynchronous I/O performs this copy operation in the kernel, making the entire operation asynchronous from the user's perspective. Thus, the first four models are classified as synchronous I/O, while the last is asynchronous I/O.

To handle multiple or even tens of thousands of client requests simultaneously, a single-threaded TCP server is impractical for network servers.

Most TCP servers implement one of three models:

  • Multi-process servers: Create multiple processes to handle concurrent requests
  • Multi-thread servers: Generate threads equal to the number of client requests
  • Multiplexing servers: Optimize service response efficiency by centrally managing I/O objects

I/O Multiplexing in Detail

I/O multiplexing allows a single thread to monitor multiple file descriptors (abstract references to files, represented as non-negative integers indexing into an "file open record table"). When one or more descriptors become ready, the kernel notifies the user process, which then handles the data.

Although this approach processes only one client's request at any given moment, it allows other clients to wait while the server handles tasks sequentially. All clients maintain connections with the server, and due to fast processing times, clients perceive normal communication. This method is technically "pseudo-concurrency."

Compared to multi-threading and multi-processing, I/O multiplexing avoids creating new processes or threads, eliminating context switching overhead and reducing system overhead. Common I/O multiplexing implementations in Linux include select, poll, and epoll.

select() Function

The select() function in Linux allows monitoring multiple file descriptors (socket descriptors) in a single set. When any descriptor in the set has an event occur, the program can identify which descriptors have data ready and perform reading operations without blocking as in previous models.

poll() Function

The poll() function is essentially similar to select() but uses linked lists for storage, eliminating the maximum connection limit. Poll copies arrays of file descriptors to kernel address space and queries each descriptor's status. Ready descriptors are placed in a waiting queue. If no descriptors are ready after full traversal, the process suspends until ready descriptors appear or a timeout occurs.

Both select() and poll() require traversing file descriptors to identify ready sockets. With many client connections but few active ones, performance degrades as the number of descriptors increases.

epoll() Function

select() operates by checking each monitored file descriptor (socket) for available operations, which becomes slow due to:

  • Mandatory loop execution for all available file descriptors after each select() call
  • Repeated parameter initialization and passing during each select() call

The epoll() system addresses these limitations with the following functions:

// Create space for epoll file descriptors, similar to creating fd_set in select
int epoll_create(int size);

// Register or unregister monitored file descriptors, similar to FD_SET/FD_CLR operations
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd: File descriptor from epoll call
op: Control operation parameters (EPOLL_CTL_ADD, EPOLL_CTL_MOD, EPOLL_CTL_DEL)
fd: Target descriptor
event: Composed of these macros:
    EPOLLIN: Descriptor is readable
    EPOLLOUT: Descriptor is writable
    EPOLLPRI: Descriptor has urgent data to read
    EPOLLERR: Descriptor has an error
    EPOLLHUP: Descriptor has been hung up
    EPOLLET: Set edge-trigger mode
    EPOLLONESHOT: Monitor descriptor only once
*/

// epoll() waits for event triggers, unlike select() which polls
int epoll_wait(int epfd, struct epoll_event *event, int maxevents, int timeout);

epoll() registers file descriptors in advance and uses a callback mechanism to quickly activate ready descriptors. When a process calls epoll_wait(), it receives notifications for these ready descriptors.

Advantages:

  • No maximum connection limit
  • Event-driven efficiency that doesn't decline with increasing FD count
  • Monitors only active connections, performing better than select/poll with many connections and few active ones
  • epoll_ctl() registers events and callback functions; epoll_wait() only returns descriptors with events
  • Uses mmap for shared user/kernel space memory, avoiding data copying

Note: epoll() doesn't outperform select/poll in scenarios with low concurrency but high connection activity.

epoll() operates in two modes for file descriptors: LT (level trigger) and ET (edge trigger).

  • LT mode: Default mode supporting both blocking and non-blocking sockets. When a descriptor is ready, the kernel notifies the process, and continues notifying if the process doesn't handle it.
  • ET mode: High-speed mode supporting only non-blocking sockets. When a descriptor is ready, the kernel notifies the process only once, regardless of whether it's handled. This reduces repeated event triggering, making it more efficient than LT mode.

The main disadvantage of epoll() is its lack of cross-platform support. It's Linux-specific, with kqueue on BSD and iocp on Windows.

epoll() Example Implementation

#include <iostream>
#include 
#include <cstring>
#include 
#include 

#define BUFFER_SIZE    1024

int main(int argc, char *argv[])
{
    char buffer[BUFFER_SIZE];
    int timeout;
    int epoll_descriptor, epoll_size, event_count;
    struct epoll_event *epoll_events;
    struct epoll_event event;
    int data_length;

    // Set timeout to 5.5 seconds (5500 microseconds)
    timeout = 5500;

    // Create epoll instance with size = 1
    epoll_size = 2;
    epoll_descriptor = epoll_create(epoll_size);
    if(epoll_descriptor < 0)
    {
        std::cout << "epoll_create error." << std::endl;
        return -1;
    }

    // Create event structure buffer for epoll_wait
    epoll_events = (struct epoll_event *)malloc(sizeof(struct epoll_event)*epoll_size);

    // Initialize file descriptor event registration parameters
    event.events = EPOLLIN; // Monitor for readable input
    event.data.fd = 0;  // Monitor standard input
    // Register event
    epoll_ctl(epoll_descriptor, EPOLL_CTL_ADD, 0, &event);

    while(1)
    {
        // Clear buffer
        memset(buffer, 0, BUFFER_SIZE);

        event_count = epoll_wait(epoll_descriptor, epoll_events, epoll_size, timeout);
        if(event_count < 0)
        {
            std::cout << "epoll_wait() error!" << std::endl;
            break;
        }
        else if(event_count == 0)
        {
            std::cout << "epoll_wait() timeout!" << std::endl;
            continue;
        }
        else{
            // Only one descriptor registered, so no loop needed for all events
            // Check if the event occurred on standard input
            if(epoll_events[0].data.fd == 0)
            {
                // Read data and print
                data_length = read(0, buffer, BUFFER_SIZE);
                buffer[data_length] = 0;
                std::cout << "Message from console : " << buffer << std::endl;
            }
        }
    }
    // Close epoll descriptor
    close(epoll_descriptor);

    return 0;
}

From terminal output, when data is entered, the program waits for events via epoll_wait(). When the function returns, it processes the file descriptors in the returned structure. Since it detects readable data, it reads and prints the input. When no messages arrive for an extended period, the timeout triggers, printing a timeout message.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.