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

Linux C语言非阻塞I/O的超时设置

2021-12-197.8k 阅读

一、Linux 下 I/O 模型简介

在深入探讨 Linux C 语言非阻塞 I/O 的超时设置之前,我们先来了解一下 Linux 下常见的 I/O 模型。

(一)阻塞 I/O 模型

这是最基本的 I/O 模型。在阻塞 I/O 中,当应用程序调用一个 I/O 函数时,该函数会一直阻塞,直到操作完成。例如,当调用 read 函数读取文件描述符的数据时,如果此时内核缓冲区中没有数据可读,进程就会进入睡眠状态,直到有数据到达或者发生错误。

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

#define BUFFER_SIZE 1024

int main() {
    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
    if (bytes_read == -1) {
        perror("read");
    } else {
        printf("Read %zd bytes: %.*s\n", bytes_read, (int)bytes_read, buffer);
    }
    close(fd);
    return 0;
}

在这个例子中,如果 test.txt 文件没有数据,read 函数会一直阻塞,直到有数据可读或者出现错误。阻塞 I/O 模型简单直接,但在需要处理多个 I/O 操作时效率较低,因为进程在等待 I/O 完成时不能做其他事情。

(二)非阻塞 I/O 模型

非阻塞 I/O 允许应用程序在 I/O 操作未完成时不会被阻塞,而是立即返回。通过将文件描述符设置为非阻塞模式,当调用 I/O 函数时,如果操作不能立即完成,函数会返回 -1,并设置 errnoEAGAINEWOULDBLOCK

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

#define BUFFER_SIZE 1024

int main() {
    int fd = open("test.txt", O_RDONLY | O_NONBLOCK);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    char buffer[BUFFER_SIZE];
    ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
    if (bytes_read == -1) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            printf("No data available yet\n");
        } else {
            perror("read");
        }
    } else {
        printf("Read %zd bytes: %.*s\n", bytes_read, (int)bytes_read, buffer);
    }
    close(fd);
    return 0;
}

在上述代码中,我们通过 O_NONBLOCK 标志将文件描述符设置为非阻塞模式。这样,read 函数会立即返回,如果没有数据可读,就会返回 -1 并设置 errnoEAGAINEWOULDBLOCK。非阻塞 I/O 可以让进程在等待 I/O 操作时去处理其他任务,提高了效率,但也增加了编程的复杂性,因为需要不断轮询检查 I/O 操作是否完成。

(三)I/O 多路复用模型

I/O 多路复用允许应用程序在单个线程中同时监控多个文件描述符的 I/O 事件。常见的 I/O 多路复用技术有 selectpollepoll

1. select

select 函数允许应用程序监控一组文件描述符,等待其中一个或多个描述符变为可读或可写状态。

#include <sys/select.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>

#define BUFFER_SIZE 1024

int main() {
    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(fd, &read_fds);
    struct timeval timeout;
    timeout.tv_sec = 5;
    timeout.tv_usec = 0;
    int activity = select(fd + 1, &read_fds, NULL, NULL, &timeout);
    if (activity == -1) {
        perror("select");
    } else if (activity) {
        char buffer[BUFFER_SIZE];
        ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read == -1) {
            perror("read");
        } else {
            printf("Read %zd bytes: %.*s\n", bytes_read, (int)bytes_read, buffer);
        }
    } else {
        printf("Timeout occurred\n");
    }
    close(fd);
    return 0;
}

在这个例子中,我们使用 select 函数监控文件描述符 fd 的可读状态。select 函数的最后一个参数是一个 struct timeval 结构体,用于设置超时时间。如果在指定的超时时间内,fd 变为可读状态,select 函数返回大于 0 的值,我们就可以进行读取操作;如果超时,select 函数返回 0。

2. poll

poll 函数与 select 类似,但它使用 pollfd 结构体数组来表示需要监控的文件描述符及其事件。

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

#define BUFFER_SIZE 1024

int main() {
    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    struct pollfd fds[1];
    fds[0].fd = fd;
    fds[0].events = POLLIN;
    int timeout = 5000; // 5 seconds in milliseconds
    int activity = poll(fds, 1, timeout);
    if (activity == -1) {
        perror("poll");
    } else if (activity) {
        char buffer[BUFFER_SIZE];
        ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read == -1) {
            perror("read");
        } else {
            printf("Read %zd bytes: %.*s\n", bytes_read, (int)bytes_read, buffer);
        }
    } else {
        printf("Timeout occurred\n");
    }
    close(fd);
    return 0;
}

这里,我们通过 poll 函数监控文件描述符 fdPOLLIN(可读)事件。poll 函数的第三个参数是超时时间,单位是毫秒。

3. epoll

epoll 是 Linux 特有的 I/O 多路复用机制,它在处理大量文件描述符时比 selectpoll 更高效。

#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>

#define BUFFER_SIZE 1024
#define MAX_EVENTS 10

int main() {
    int fd = open("test.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    int epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1");
        return 1;
    }
    struct epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event) == -1) {
        perror("epoll_ctl");
        close(epoll_fd);
        return 1;
    }
    struct epoll_event events[MAX_EVENTS];
    int timeout = 5000; // 5 seconds in milliseconds
    int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, timeout);
    if (num_events == -1) {
        perror("epoll_wait");
    } else if (num_events) {
        for (int i = 0; i < num_events; ++i) {
            if (events[i].events & EPOLLIN) {
                char buffer[BUFFER_SIZE];
                ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
                if (bytes_read == -1) {
                    perror("read");
                } else {
                    printf("Read %zd bytes: %.*s\n", bytes_read, (int)bytes_read, buffer);
                }
            }
        }
    } else {
        printf("Timeout occurred\n");
    }
    close(epoll_fd);
    close(fd);
    return 0;
}

在这个代码中,我们首先使用 epoll_create1 创建一个 epoll 实例,然后通过 epoll_ctl 将文件描述符 fd 添加到 epoll 实例中,并指定监控 EPOLLIN 事件。epoll_wait 函数用于等待事件发生,它的最后一个参数是超时时间,单位是毫秒。

二、非阻塞 I/O 的超时设置方法

(一)使用 select 实现非阻塞 I/O 超时

如前面 select 示例代码所示,通过 struct timeval 结构体设置 select 函数的超时时间,从而实现非阻塞 I/O 的超时控制。这种方法适用于监控少量文件描述符的情况。select 的优点是跨平台性较好,在不同的 Unix 系统上都有支持;缺点是可监控的文件描述符数量有限,通常在 1024 个以内,并且每次调用 select 时都需要将整个文件描述符集合从用户空间复制到内核空间,性能较低。

(二)使用 poll 实现非阻塞 I/O 超时

poll 函数通过设置第三个参数(超时时间,单位为毫秒)来实现超时控制。与 select 相比,poll 没有文件描述符数量的限制,并且在性能上有所提升,因为它不需要像 select 那样每次都复制整个文件描述符集合。但 poll 仍然需要遍历整个 pollfd 数组来检查哪些文件描述符有事件发生,当文件描述符数量较多时,性能会受到影响。

(三)使用 epoll 实现非阻塞 I/O 超时

epoll 是 Linux 下高性能的 I/O 多路复用机制。通过 epoll_wait 函数的最后一个参数设置超时时间。epoll 采用事件驱动的方式,只有发生事件的文件描述符才会被通知,因此在处理大量文件描述符时性能优势明显。它通过 epoll_create1 创建 epoll 实例,通过 epoll_ctl 管理文件描述符的监控事件,通过 epoll_wait 等待事件发生并设置超时。

三、应用场景分析

(一)网络编程

在网络编程中,经常需要处理多个客户端的连接。例如,一个服务器程序可能需要同时处理多个客户端的请求。使用非阻塞 I/O 并设置超时可以避免服务器在等待某个客户端数据时阻塞,从而可以同时处理其他客户端的请求。比如,在实现一个简单的 TCP 服务器时:

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

#define MAX_EVENTS 10
#define BUFFER_SIZE 1024

int main() {
    int server_socket = socket(AF_INET, SOCK_STREAM, 0);
    if (server_socket == -1) {
        perror("socket");
        return 1;
    }
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8080);
    server_addr.sin_addr.s_addr = INADDR_ANY;
    if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(server_socket);
        return 1;
    }
    if (listen(server_socket, 5) == -1) {
        perror("listen");
        close(server_socket);
        return 1;
    }
    int epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1");
        close(server_socket);
        return 1;
    }
    struct epoll_event event;
    event.data.fd = server_socket;
    event.events = EPOLLIN;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_socket, &event) == -1) {
        perror("epoll_ctl");
        close(epoll_fd);
        close(server_socket);
        return 1;
    }
    struct epoll_event events[MAX_EVENTS];
    int timeout = 5000; // 5 seconds in milliseconds
    while (1) {
        int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, timeout);
        if (num_events == -1) {
            perror("epoll_wait");
            break;
        } else if (num_events) {
            for (int i = 0; i < num_events; ++i) {
                if (events[i].data.fd == server_socket) {
                    int client_socket = accept(server_socket, NULL, NULL);
                    if (client_socket == -1) {
                        perror("accept");
                        continue;
                    }
                    fcntl(client_socket, F_SETFL, O_NONBLOCK);
                    event.data.fd = client_socket;
                    event.events = EPOLLIN;
                    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_socket, &event) == -1) {
                        perror("epoll_ctl");
                        close(client_socket);
                    }
                } else {
                    int client_socket = events[i].data.fd;
                    char buffer[BUFFER_SIZE];
                    ssize_t bytes_read = read(client_socket, buffer, BUFFER_SIZE);
                    if (bytes_read == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            continue;
                        } else {
                            perror("read");
                            epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_socket, NULL);
                            close(client_socket);
                        }
                    } else if (bytes_read == 0) {
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_socket, NULL);
                        close(client_socket);
                    } else {
                        buffer[bytes_read] = '\0';
                        printf("Received from client: %s\n", buffer);
                        const char *response = "Message received";
                        write(client_socket, response, strlen(response));
                    }
                }
            }
        } else {
            printf("Timeout occurred\n");
        }
    }
    close(epoll_fd);
    close(server_socket);
    return 0;
}

在这个 TCP 服务器示例中,我们使用 epoll 来监控服务器套接字和客户端套接字的可读事件。当有新的客户端连接时,将其添加到 epoll 监控列表中,并设置为非阻塞模式。在读取客户端数据时,如果没有数据可读且 errnoEAGAINEWOULDBLOCK,则继续循环等待下一个事件。通过设置 epoll_wait 的超时时间,我们可以在一定时间内没有事件发生时进行相应的处理。

(二)文件 I/O 场景

在处理文件 I/O 时,有时也需要设置超时。例如,在读取一个远程文件系统上的文件时,如果网络出现问题,可能需要在一定时间后放弃读取操作。通过将文件描述符设置为非阻塞模式,并使用 selectpollepoll 来设置超时,可以实现这种需求。

#include <sys/select.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>

#define BUFFER_SIZE 1024

int main() {
    int fd = open("/mnt/remote_file", O_RDONLY | O_NONBLOCK);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    fd_set read_fds;
    FD_ZERO(&read_fds);
    FD_SET(fd, &read_fds);
    struct timeval timeout;
    timeout.tv_sec = 10;
    timeout.tv_usec = 0;
    int activity = select(fd + 1, &read_fds, NULL, NULL, &timeout);
    if (activity == -1) {
        perror("select");
    } else if (activity) {
        char buffer[BUFFER_SIZE];
        ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);
        if (bytes_read == -1) {
            perror("read");
        } else {
            buffer[bytes_read] = '\0';
            printf("Read from file: %s\n", buffer);
        }
    } else {
        printf("Timeout occurred while reading file\n");
    }
    close(fd);
    return 0;
}

在这个例子中,我们打开一个远程文件系统上的文件,并设置为非阻塞模式。通过 select 函数设置 10 秒的超时时间,如果在 10 秒内文件变为可读状态,就进行读取操作;否则,提示超时。

四、注意事项

(一)资源管理

在使用非阻塞 I/O 并设置超时的过程中,要注意文件描述符和相关资源的正确管理。例如,在使用 epoll 时,当一个文件描述符不再需要监控时,要及时通过 epoll_ctl 使用 EPOLL_CTL_DEL 操作将其从 epoll 实例中删除,避免资源泄漏。同时,在关闭文件描述符时要确保所有相关的操作已经完成,以免出现数据丢失或错误。

(二)错误处理

非阻塞 I/O 操作可能会因为多种原因返回错误,如 EAGAINEWOULDBLOCK 等。要正确处理这些错误,区分是因为没有数据可读/可写导致的临时错误,还是真正的错误(如文件不存在、权限不足等)。在处理错误时,要根据具体情况进行相应的处理,比如重试操作、提示用户或记录日志等。

(三)性能优化

虽然非阻塞 I/O 和超时设置可以提高程序的并发处理能力,但在实际应用中,要注意性能优化。例如,在使用 selectpoll 时,尽量减少监控的文件描述符数量,以提高效率。在使用 epoll 时,合理设置 epoll_wait 的超时时间,避免过长的超时导致程序响应迟钝,也避免过短的超时导致不必要的系统调用开销。同时,要注意内存管理,避免频繁的内存分配和释放操作影响性能。

(四)跨平台兼容性

如果程序需要在不同的操作系统平台上运行,要注意 selectpollepoll 的跨平台兼容性。select 具有较好的跨平台性,但性能相对较低;epoll 是 Linux 特有的,在其他 Unix 系统上不可用。如果需要跨平台,可能需要使用一些跨平台的 I/O 多路复用库,如 libevent 等,以确保程序在不同平台上都能正常运行并具有较好的性能。

通过深入理解 Linux C 语言非阻塞 I/O 的超时设置方法、应用场景及注意事项,开发人员可以编写出更高效、可靠的程序,在处理 I/O 操作时更好地满足实际需求。无论是网络编程还是文件 I/O 等场景,合理运用这些技术都能显著提升程序的性能和用户体验。在实际项目中,根据具体的需求和系统环境,选择合适的 I/O 模型和超时设置方法是非常关键的。同时,不断优化代码,注意资源管理和错误处理,也是确保程序稳定运行的重要环节。