5 approaches to handle I/O operations in any system – Part 1


Introduction

In this series, we are going to dive deep into the low-level system to understand what input/output (I/O) actually is. I am going to write some code to demonstrate it to you and try to be as practical most possible.

From my point of view, read and write are just the operations to transfer data from one buffer to another, if you understand the fundamental stuff, then it would be very easy for you to tackle any problems in complex systems that you might have in the future.

This is the part 1 of this series: Blocking I/O vs Non-blocking I/O

What is I/O operation

I shamelessly copied the definition from Wikipedia

In computinginput/output (I/Oi/o, or informally io or IO) is the communication between an information processing system, such as a computer, and the outside world, such as another computer system, peripherals, or a human operator. Inputs are the signals or data received by the system and outputs are the signals or data sent from it. The term can also be used as part of an action; to “perform I/O” is to perform an input or output operation.

For simplicity, in the context of an operating system in terms of I/O resources, an operating system (OS) such as Linux manages various system resources used to perform I/O operations.

Below are some system resources used to perform I/O operations:

  • Files
  • Pipes
  • Network sockets
  • Devices

What is a file descriptor

I/O resources are identified by file descriptors in Linux

Many I/O resources such as files, sockets, pipes, and devices are identified by a unique ID called file descriptor (FD). In addition, in Unix-like OS, each process contains a set of file descriptors. Each process should have 3 standard POSIX file descriptors. The operating system (OS) uses PID as the process identifier to allocate CPU, memory, file descriptors, and permissions to it.

IntegerInteger valueFile streamLocated at
0Standard inputstdin/proc/PID/fd/0
1Standard outputstdout/proc/PID/fd/1
2Standard errorstderr/proc/PID/fd/2

File descriptor usage

Let’s consider the system call called read() (https://man7.org/linux/man-pages/man2/read.2.html)

  #include <unistd.h>

  ssize_t read(int fd, void buf[.count], size_t count);

The first argument of the system call read() requires a file descriptor. This file descriptor could belong to the files, pipes, sockets, or devices. Once it has determined which type of resource to read from, it begins filling the buffer starting at buf argument.


Model 1: Blocking I/O model

Process using blocking model is blocked when performing I/O operation. It waits for the data to become available in the I/O resource and then the kernel copies them from kernel space to user space (application space). These 2 steps completely block other threads to run other tasks.

Blocking I/O model drawbacks

Imagine we have 1000 sockets to read data from then we have to spawn corresponding 1000 threads, it is totally a waste of resources for this approach as a socket could be empty and there is no data for a thread to read from. As a consequence, this thread still blocks the whole process as data is not yet represented in the socket. In the case half of the number of threads are in the sleep state, then we can know that we are putting 5000 threads holding connection to socket that do nothing into SLEEP state. OS has to remember the state of these threads to perform context switching to put threads on and off, this operation sometimes takes an intensive resource to perform which finally leads to system degradation.

Show me the code

Here is an example of read() system call blocks other threads. Full code implementation is at https://github.com/hexknight01/io-model-demo/tree/master/blocking-io

Main.c
#include <stdio.h>
#include "blockingio.h"

int main() {
    readBlockingIO();
    return 0;
}
#ifndef BLOCKINGIO_H
#define BLOCKINGIO_H

int readBlockingIO();

#endif
#include "blockingio.h"
#include <unistd.h>  
#include <fcntl.h>   
#include <stdio.h>   
#include <stdlib.h>  
#include <pthread.h>

// Function that performs non-blocking work
void* thread_callback(void* arg) {
    for (int i = 0; i < 5; i++) {
        printf("Other thread is working...n");
        sleep(1);  // Simulate work with a 1-second delay
    }
    return NULL;
}

int readBlockingIO() {
    int fd;
    char buffer[100];
    pthread_t other_thread;
    ssize_t bytesRead;

    // Open a file in read-only mode
    fd = open("example.txt", O_RDONLY);
    if (fd == -1) {
        perror("Failed to open file");
        exit(1);
    }

    // This read() function blocks the current thread until it is done
    bytesRead = read(fd, buffer, sizeof(buffer) - 1);
    if (bytesRead == -1) {
        perror("Failed to read file");
        close(fd);
        exit(1);
    }

    // Create a new thread after read() operation is completed
    if (pthread_create(&other_thread, NULL, thread_callback, NULL) != 0) {
        perror("Failed to create thread");
        return 1;
    }

    // Null-terminate the buffer to treat it as a string
    buffer[bytesRead] = '';

    // Print the contents of the buffer
    printf("Read %ld bytes: %sn", bytesRead, buffer);

    // Close the file descriptor
    close(fd);

    // Wait for the other thread to finish
    if (pthread_join(other_thread, NULL) != 0) {
        perror("Failed to join thread");
        return 1;
    }

    return 0;
}
Output
gcc -o program main.c blockingio.c -pthread & ./program
[1] 15045
[1]  + 15045 done       gcc -o program main.c blockingio.c -pthread
Read 38 bytes: hello from the other side of the world
Other thread is working...
Other thread is working...
Other thread is working...
Other thread is working...
Other thread is working..

Model 2: Non-blocking I/O model

In blocking the IO model, putting the thread to the sleep state while waiting for the data to become available in the socket is not efficient, especially in the case we have a lot of threads doing this. To avoid putting it to sleep state, another model called the non-blocking I/O model can solve this.

A thread once asks each socket if the data becomes available to read from (polling). If the data has not yet become available in the socket, instead of putting the application thread into the sleep state, the kernel returns an error called EWOULDBLOCK to the application thread. The application thread receives this error and it indicates there is no data to read from yet, then it repeatedly issues another request to the kernel until data becomes available. When the data is ready to read from, it copies the data from the kernel to user space to handle.

Drawbacks

However, in this non-blocking I/O model, the thread acts as an event loop that has to block all the sockets because it proactively queries for the data. Besides, this approach often wastes CPU resources for polling the data from sockets. And to be honest with you, I don’t see any improvement in this model compared to blocking the I/O model because the main thread is blocked while waiting for the data to be copied from the kernel to the application space.

Show me the code

To use this model, you need to set all the sockets to O_NONBLOCK flag in fcntl(2) function.

The full source code of the implementation of this model is at https://github.com/hexknight01/io-model-demo/tree/master/non-blocking-io

Server.c

Set non-blocking for a socket

// Set non-blocking mode for the socket
int set_non_blocking(int sockfd) {
    int flags = fcntl(sockfd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl(F_GETFL)");
        return -1;
    }

    flags |= O_NONBLOCK;
    if (fcntl(sockfd, F_SETFL, flags) == -1) {
        perror("fcntl(F_SETFL)");
        return -1;
    }

    return 0;
}
Implementing a UDP server that receives a datagram from the client
// Set the socket to non-blocking mode
  if (set_non_blocking(sockfd) == -1) {
      close(sockfd);
      return 1;
  }

  // Define server address
  server_addr.sin_family = AF_INET;
  server_addr.sin_addr.s_addr = INADDR_ANY;
  server_addr.sin_port = htons(8081);

  // Bind the socket to the specified port and IP address
  if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
      perror("bind failed");
      close(sockfd);
      return 1;
  }

  printf("Non-blocking UDP server is running on port %d...n", 8081);

  // Event loop to repeatedly receive data from the socket
  while (1) {
      int received_byte = recvfrom(sockfd, applicationBuffer, sizeof(applicationBuffer), 0, (struct sockaddr *)&client_addr, &client_len);
      if (received_byte > 0) {
          applicationBuffer[received_byte] = '';  // Null-terminate the string
          printf("Received message: %sn", applicationBuffer);

          char client_ip[INET_ADDRSTRLEN];
          inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
          printf("Message from client: %s:%dn", client_ip, ntohs(client_addr.sin_port));
      } else if (received_byte == -1) {
          if (errno == EAGAIN || errno == EWOULDBLOCK) {
              // No data available yet. Come back later...
              usleep(500);  // Sleep for 500ms and retry
          } else {
              perror("recvfrom failed");
              break;
          }
      }
  }

  close(sockfd);  // Close the socket after breaking out of the loop
Client.c – sending a message to the server
#define SERVER_PORT 8081
#define SERVER_IP "127.0.0.1" 
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr;
    char *message = "Hello, server!";
    char buffer[BUFFER_SIZE];
    socklen_t addr_len = sizeof(server_addr);

    // Create a TCP socket
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd == -1) {
        perror("socket failed");
        return 1;
    }

    // Define the server address to send data to
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERVER_PORT);

    // Convert IP address to binary form and assign it
    if (inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr) <= 0) {
        perror("inet_pton failed");
        close(sockfd);
        return 1;
    }

    // Send a message to the server
    int bytes_sent = sendto(sockfd, message, strlen(message), 0,
                            (struct sockaddr *)&server_addr, sizeof(server_addr));
    if (bytes_sent == -1) {
        perror("sendto failed");
        close(sockfd);
        return 1;
    }
    printf("Sent message to server: %sn", message);

    close(sockfd);
    return 0;
}
Result
Server
➜  non-blocking-io git:(master) ✗ gcc -o program main.c nonblockingio.c & ./program
[1] 29039
[1]  + 29039 done       gcc -o program main.c nonblockingio.c
Non-blocking UDP server is running on port 8081...
Received message: Hello, server!
Message from client: 127.0.0.1:56528
Client
➜  client git:(master) ✗ gcc -o program main.c & ./program
[1] 30428
Sent message to server: Hello, server!

The journey keeps continuing

In this part 1, I have illustrated what is I/O model and 2 common models which are the blocking I/O model and the non-blocking I/O model, and their examples. In the next post of this series, I will walk you through 3 more I/O models which are the multiplexing I/O model, the asynchronous I/O model, and the signal I/O model (which is the most interesting one 🙂 ). Stay tuned.

Here is the link to part 2 of this series

Thank you for reading my article.

References:

https://notes.shichao.io/unp/ch6/#io-models

https://man7.org/linux/man-pages/man7/epoll.7.html

The Linux Programming Interface – Book by Michael Kerrisk


0 0 votes
Article Rating
Subscribe
Notify of
guest
1 Comment
Oldest
Newest Most Voted
Inline Feedbacks
View all comments