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 computing, input/output (I/O, i/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.
Integer | Integer value | File stream | Located at |
---|---|---|---|
0 | Standard input | stdin | /proc/PID/fd/0 |
1 | Standard output | stdout | /proc/PID/fd/1 |
2 | Standard error | stderr | /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
[…] can find the part 1 […]