MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Linux C语言非阻塞I/O的实现策略

2022-07-107.3k 阅读

1. 理解I/O模型基础

在深入探讨非阻塞I/O之前,我们先来了解一下I/O模型的基本概念。I/O操作是计算机与外部设备(如文件、网络套接字、终端等)进行数据交互的过程。在Linux系统中,常见的I/O模型主要有以下几种:

1.1 阻塞I/O模型

阻塞I/O是最基本的I/O模型。当应用程序调用一个阻塞I/O的系统调用时,该调用会一直阻塞,直到操作完成。例如,使用read函数从文件或套接字读取数据时,如果数据尚未准备好,read函数会一直等待,直到有数据可读,此时进程会进入睡眠状态,不会占用CPU资源。以下是一个简单的阻塞I/O读取文件的代码示例:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>

#define BUFFER_SIZE 1024

int main() {
    int fd;
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    fd = open("example.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    bytes_read = read(fd, buffer, BUFFER_SIZE);
    if (bytes_read == -1) {
        perror("read");
        close(fd);
        exit(EXIT_FAILURE);
    }

    buffer[bytes_read] = '\0';
    printf("Read %zd bytes: %s\n", bytes_read, buffer);

    close(fd);
    return 0;
}

在这个示例中,read函数会阻塞,直到文件中的数据被读取到buffer中或者到达文件末尾。阻塞I/O模型简单直观,但在某些情况下会降低程序的效率,特别是当需要同时处理多个I/O操作时。

1.2 非阻塞I/O模型

非阻塞I/O与阻塞I/O相反,当应用程序调用非阻塞I/O的系统调用时,如果操作无法立即完成,系统调用会立即返回,而不是等待操作完成。这使得应用程序可以在等待I/O操作完成的同时,继续执行其他任务,提高了程序的并发处理能力。例如,在网络编程中,使用非阻塞套接字可以在等待数据到达时处理其他网络连接或执行本地计算任务。

1.3 I/O多路复用模型

I/O多路复用是一种允许应用程序同时监控多个I/O描述符(如文件描述符、套接字描述符等)状态的技术。通过使用I/O多路复用函数(如selectpollepoll),应用程序可以在一个线程或进程中同时处理多个I/O事件,避免了为每个I/O操作创建单独的线程或进程带来的资源开销。例如,select函数可以监听多个文件描述符,当其中任何一个描述符上有可读或可写事件发生时,select函数会返回,应用程序可以根据返回结果来处理相应的I/O操作。

1.4 信号驱动I/O模型

信号驱动I/O模型是一种异步I/O模型。应用程序通过设置信号处理函数,并使用sigaction函数注册信号,当I/O操作准备好时,内核会向应用程序发送一个信号,应用程序在信号处理函数中处理I/O操作。这种模型允许应用程序在I/O操作准备好时被通知,而不需要不断地轮询检查I/O状态。

1.5 异步I/O模型

异步I/O是最理想的I/O模型,应用程序发起I/O操作后,立即返回,继续执行其他任务。内核在后台完成I/O操作,并在操作完成后通知应用程序。在Linux中,aio系列函数提供了异步I/O的支持。与信号驱动I/O不同,异步I/O的通知发生在I/O操作完成之后,而信号驱动I/O的通知发生在I/O操作准备好时。

2. 非阻塞I/O原理剖析

2.1 文件描述符与I/O操作

在Linux系统中,所有的I/O操作都是通过文件描述符(File Descriptor,简称FD)进行的。文件描述符是一个非负整数,它是内核为了管理打开的文件而创建的索引值。当应用程序打开一个文件、创建一个套接字或者使用其他I/O相关的系统调用时,内核会返回一个文件描述符,应用程序通过这个文件描述符来进行后续的读写等I/O操作。例如,open函数用于打开一个文件,成功时返回一个文件描述符,后续可以使用readwrite等函数通过这个文件描述符对文件进行读写操作。

2.2 阻塞与非阻塞模式设置

文件描述符的阻塞与非阻塞模式是可以设置的。在Linux中,可以通过fcntl函数来修改文件描述符的属性,从而设置其为阻塞或非阻塞模式。fcntl函数的原型如下:

#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

其中,fd是要操作的文件描述符,cmd是操作命令,对于设置非阻塞模式,通常使用F_SETFL命令,第三个参数是要设置的文件状态标志。要将一个文件描述符设置为非阻塞模式,可以使用如下代码:

int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
    perror("fcntl F_GETFL");
    return -1;
}
flags |= O_NONBLOCK;
if (fcntl(fd, F_SETFL, flags) == -1) {
    perror("fcntl F_SETFL");
    return -1;
}

在这段代码中,首先使用fcntl函数的F_GETFL命令获取文件描述符fd当前的状态标志,然后通过按位或操作将O_NONBLOCK标志添加到状态标志中,最后使用F_SETFL命令将修改后的状态标志设置回文件描述符,从而将其设置为非阻塞模式。

2.3 非阻塞I/O的系统调用行为

当文件描述符设置为非阻塞模式后,相关的I/O系统调用(如readwrite)的行为会发生变化。以read函数为例,在阻塞模式下,如果没有数据可读,read函数会一直阻塞等待数据到达;而在非阻塞模式下,如果没有数据可读,read函数会立即返回,返回值为-1,并且errno会被设置为EAGAINEWOULDBLOCK,表示操作暂时无法完成,需要再次尝试。同样,对于write函数,在非阻塞模式下,如果输出缓冲区已满,write函数也会立即返回-1errno同样会被设置为EAGAINEWOULDBLOCK

3. 非阻塞I/O在网络编程中的应用

3.1 非阻塞套接字的创建与设置

在网络编程中,非阻塞I/O常用于套接字操作,以实现高效的网络通信。首先,需要创建一个套接字,并将其设置为非阻塞模式。以下是一个创建非阻塞TCP套接字的示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>

#define PORT 8080
#define MAX_BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in servaddr, cliaddr;
    char buffer[MAX_BUFFER_SIZE];

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    int flags = fcntl(sockfd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl F_GETFL");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
    flags |= O_NONBLOCK;
    if (fcntl(sockfd, F_SETFL, flags) == -1) {
        perror("fcntl F_SETFL");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

    // 填充服务器地址结构
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);

    // 绑定套接字到指定地址和端口
    if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 监听连接
    if (listen(sockfd, 10) < 0) {
        perror("listen failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    socklen_t len = sizeof(cliaddr);
    int connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
    if (connfd < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 非阻塞模式下没有新连接,继续执行其他任务
        } else {
            perror("accept failed");
            close(sockfd);
            exit(EXIT_FAILURE);
        }
    } else {
        // 处理连接
        ssize_t bytes_read = recv(connfd, buffer, MAX_BUFFER_SIZE - 1, 0);
        if (bytes_read < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 非阻塞模式下没有数据可读,继续执行其他任务
            } else {
                perror("recv failed");
                close(connfd);
                close(sockfd);
                exit(EXIT_FAILURE);
            }
        } else if (bytes_read > 0) {
            buffer[bytes_read] = '\0';
            printf("Received: %s\n", buffer);
        }
        close(connfd);
    }
    close(sockfd);
    return 0;
}

在这个示例中,首先使用socket函数创建一个TCP套接字,然后通过fcntl函数将其设置为非阻塞模式。接着,使用bind函数将套接字绑定到指定的地址和端口,listen函数开始监听连接。在accept函数接收连接时,如果当前没有新连接,在非阻塞模式下会返回-1,并且errnoEAGAINEWOULDBLOCK,此时可以继续执行其他任务。如果成功接收到连接,则在recv函数接收数据时,同样如果没有数据可读,也会返回-1errnoEAGAINEWOULDBLOCK,可以继续执行其他任务。

3.2 处理多个非阻塞套接字

在实际的网络应用中,往往需要同时处理多个非阻塞套接字,例如服务器需要同时处理多个客户端的连接。这时候可以结合I/O多路复用技术(如selectpollepoll)来实现。以select函数为例,以下是一个简单的示例代码,展示如何使用select函数处理多个非阻塞套接字:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/select.h>

#define PORT 8080
#define MAX_CLIENTS 10
#define MAX_BUFFER_SIZE 1024

int main() {
    int sockfd, connfd;
    struct sockaddr_in servaddr, cliaddr;
    char buffer[MAX_BUFFER_SIZE];
    fd_set read_fds;
    fd_set tmp_fds;
    int activity, i, valread;

    // 创建套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    int flags = fcntl(sockfd, F_GETFL, 0);
    if (flags == -1) {
        perror("fcntl F_GETFL");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
    flags |= O_NONBLOCK;
    if (fcntl(sockfd, F_SETFL, flags) == -1) {
        perror("fcntl F_SETFL");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    memset(&servaddr, 0, sizeof(servaddr));
    memset(&cliaddr, 0, sizeof(cliaddr));

    // 填充服务器地址结构
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = INADDR_ANY;
    servaddr.sin_port = htons(PORT);

    // 绑定套接字到指定地址和端口
    if (bind(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
        perror("bind failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 监听连接
    if (listen(sockfd, MAX_CLIENTS) < 0) {
        perror("listen failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(sockfd, &read_fds);

    while (1) {
        tmp_fds = read_fds;
        activity = select(sockfd + 1, &tmp_fds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(sockfd, &tmp_fds)) {
                socklen_t len = sizeof(cliaddr);
                connfd = accept(sockfd, (struct sockaddr *)&cliaddr, &len);
                if (connfd < 0) {
                    if (errno == EAGAIN || errno == EWOULDBLOCK) {
                        continue;
                    } else {
                        perror("accept failed");
                        break;
                    }
                } else {
                    flags = fcntl(connfd, F_GETFL, 0);
                    if (flags == -1) {
                        perror("fcntl F_GETFL");
                        close(connfd);
                        continue;
                    }
                    flags |= O_NONBLOCK;
                    if (fcntl(connfd, F_SETFL, flags) == -1) {
                        perror("fcntl F_SETFL");
                        close(connfd);
                        continue;
                    }
                    FD_SET(connfd, &read_fds);
                }
            }
            for (i = 0; i < MAX_CLIENTS; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    valread = recv(i, buffer, MAX_BUFFER_SIZE - 1, 0);
                    if (valread < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            continue;
                        } else {
                            perror("recv failed");
                            close(i);
                            FD_CLR(i, &read_fds);
                        }
                    } else if (valread == 0) {
                        close(i);
                        FD_CLR(i, &read_fds);
                    } else {
                        buffer[valread] = '\0';
                        printf("Received: %s\n", buffer);
                    }
                }
            }
        }
    }
    close(sockfd);
    return 0;
}

在这个示例中,使用select函数监控多个套接字(包括监听套接字和已连接套接字)。select函数会阻塞,直到有套接字上有可读事件发生。当select函数返回后,通过检查FD_ISSET宏来确定是哪个套接字有事件发生。如果是监听套接字有事件发生,表示有新的连接到来,使用accept函数接受连接,并将新连接的套接字设置为非阻塞模式,然后添加到监控集合中。如果是已连接套接字有事件发生,则使用recv函数接收数据,并根据返回值处理不同的情况。

4. 非阻塞I/O在文件操作中的应用

4.1 非阻塞文件读取

在文件操作中,同样可以使用非阻塞I/O来提高程序的效率。例如,在读取大文件时,如果采用阻塞模式,可能会在等待数据从磁盘读取到内存的过程中浪费大量时间。通过将文件描述符设置为非阻塞模式,可以在等待数据的同时执行其他任务。以下是一个非阻塞读取文件的示例代码:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

#define BUFFER_SIZE 1024

int main() {
    int fd;
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    fd = open("example.txt", O_RDONLY | O_NONBLOCK);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    while (1) {
        bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 没有数据可读,执行其他任务
                continue;
            } else {
                perror("read");
                close(fd);
                exit(EXIT_FAILURE);
            }
        } else if (bytes_read == 0) {
            // 到达文件末尾
            break;
        } else {
            buffer[bytes_read] = '\0';
            printf("Read %zd bytes: %s\n", bytes_read, buffer);
        }
    }

    close(fd);
    return 0;
}

在这个示例中,使用open函数打开文件,并通过O_NONBLOCK标志将文件描述符设置为非阻塞模式。在while循环中,不断使用read函数读取文件数据。如果read函数返回-1errnoEAGAINEWOULDBLOCK,表示没有数据可读,此时可以执行其他任务,然后继续尝试读取。如果read函数返回0,表示到达文件末尾,退出循环。

4.2 非阻塞文件写入

非阻塞文件写入同样具有实际应用场景,例如在日志记录等场景中,可能不希望因为写入日志文件而阻塞主线程的执行。以下是一个非阻塞文件写入的示例代码:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

#define BUFFER_SIZE 1024

int main() {
    int fd;
    const char *message = "This is a test message for non - blocking write.";
    ssize_t bytes_written;

    fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND | O_NONBLOCK, 0644);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    while (1) {
        bytes_written = write(fd, message, strlen(message));
        if (bytes_written == -1) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 输出缓冲区已满,执行其他任务
                continue;
            } else {
                perror("write");
                close(fd);
                exit(EXIT_FAILURE);
            }
        } else {
            break;
        }
    }

    close(fd);
    return 0;
}

在这个示例中,使用open函数打开文件,并通过O_NONBLOCK标志将文件描述符设置为非阻塞模式。在while循环中,不断使用write函数写入数据。如果write函数返回-1errnoEAGAINEWOULDBLOCK,表示输出缓冲区已满,此时可以执行其他任务,然后继续尝试写入。如果write函数成功写入数据,则退出循环。

5. 非阻塞I/O的性能考量

5.1 轮询开销

非阻塞I/O通常需要应用程序不断轮询检查I/O操作是否完成,这会带来一定的CPU开销。例如,在使用非阻塞套接字进行数据读取时,需要在一个循环中不断调用read函数,即使没有数据可读,也会占用CPU时间片。为了减少轮询开销,可以结合I/O多路复用技术,如selectpollepoll等。这些函数可以在一个线程或进程中同时监控多个I/O描述符的状态,只有当有描述符准备好时才会通知应用程序,从而减少不必要的轮询。

5.2 缓冲区管理

在非阻塞I/O中,缓冲区管理非常重要。由于I/O操作可能不会一次性完成,需要合理管理输入输出缓冲区。例如,在非阻塞读取数据时,可能需要多次调用read函数才能读取完所有数据,因此需要一个足够大的缓冲区来存储部分读取的数据。同时,在非阻塞写入数据时,也需要考虑输出缓冲区的大小和状态,避免因为缓冲区已满而导致数据丢失。在网络编程中,还需要考虑网络缓冲区的特性,如TCP的滑动窗口机制等。

5.3 上下文切换开销

当结合多线程或多进程使用非阻塞I/O时,需要注意上下文切换开销。虽然非阻塞I/O可以提高并发处理能力,但如果创建过多的线程或进程,上下文切换的开销可能会抵消非阻塞I/O带来的性能提升。例如,在一个多线程程序中,每个线程都在执行非阻塞I/O操作,频繁的线程切换会导致CPU时间浪费在保存和恢复线程上下文上。因此,需要根据实际应用场景合理控制线程或进程的数量,以平衡并发处理能力和上下文切换开销。

6. 非阻塞I/O的错误处理

6.1 系统调用错误处理

在使用非阻塞I/O的系统调用(如readwriteaccept等)时,需要正确处理可能出现的错误。如前文所述,当操作暂时无法完成时,系统调用会返回-1,并且errno会被设置为EAGAINEWOULDBLOCK,这不是真正的错误,而是表示需要再次尝试。但如果errno被设置为其他值,如EBADF(表示无效的文件描述符)、EFAULT(表示地址错误)等,则表示发生了真正的错误,需要根据具体的错误代码进行相应的处理。例如,在网络编程中,如果accept函数返回-1errnoEINTR(表示系统调用被信号中断),可以选择重新调用accept函数。

6.2 资源管理与错误处理

在非阻塞I/O应用中,资源管理也是错误处理的重要部分。例如,在打开文件或创建套接字后,如果后续的操作失败,需要确保正确关闭已经打开的文件描述符或释放相关资源,以避免资源泄漏。在使用I/O多路复用技术时,也需要注意在出现错误时正确处理监控集合中的文件描述符,例如从selectpoll的监控集合中移除无效的文件描述符。同时,在多线程或多进程环境下,需要注意线程或进程间的资源同步和错误传递,以保证整个应用程序的稳定性。