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

Linux C语言非阻塞I/O在网络编程中的实践

2021-10-155.7k 阅读

Linux C语言非阻塞I/O基础概念

阻塞与非阻塞I/O的区别

在深入探讨Linux C语言非阻塞I/O在网络编程中的实践之前,我们先来明确阻塞与非阻塞I/O的概念。

阻塞I/O是指在执行I/O操作时,程序会被挂起,直到操作完成。例如,当一个进程调用read函数从套接字读取数据时,如果此时没有数据可读,进程就会一直等待,直到有数据到达或者发生错误。这种等待的过程中,进程无法执行其他任务,处于阻塞状态。

与之相对,非阻塞I/O则不会让进程在I/O操作时等待。当调用非阻塞I/O函数(如read)时,如果操作不能立即完成,函数会立即返回,并返回一个错误码(通常是EAGAINEWOULDBLOCK),表示操作没有成功完成,但进程可以继续执行其他任务。这使得进程能够在等待I/O操作完成的同时,执行其他的代码逻辑,提高了程序的并发处理能力。

非阻塞I/O的实现原理

在Linux系统中,实现非阻塞I/O主要依赖于文件描述符的属性设置。通过调用fcntl函数,可以修改文件描述符的属性,将其设置为非阻塞模式。例如,对于一个套接字的文件描述符sockfd,可以通过以下代码将其设置为非阻塞模式:

int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

上述代码首先使用fcntl函数的F_GETFL命令获取套接字当前的文件状态标志,然后通过F_SETFL命令将O_NONBLOCK标志添加到这些标志中,从而将套接字设置为非阻塞模式。

在非阻塞模式下,当对套接字进行I/O操作(如readwrite)时,如果操作不能立即完成,系统不会让进程等待,而是立即返回,返回值为 -1,同时设置errnoEAGAINEWOULDBLOCK。应用程序可以根据这个返回值和errno来判断操作是否成功,并决定下一步的操作。

非阻塞I/O在网络编程中的优势

提高并发性能

在网络编程中,服务器通常需要处理多个客户端的连接。如果使用阻塞I/O,当一个客户端连接进行I/O操作(如读取数据)时,服务器进程会被阻塞,无法处理其他客户端的请求。这就导致服务器在同一时间只能处理一个客户端的I/O操作,无法充分利用系统资源,并发性能较低。

而采用非阻塞I/O,服务器可以在一个进程内同时处理多个客户端的I/O请求。当一个客户端的I/O操作不能立即完成时,服务器不会被阻塞,而是可以继续处理其他客户端的请求。这样,服务器能够更高效地利用CPU资源,提高并发处理能力,从而可以同时服务更多的客户端。

资源利用更高效

由于非阻塞I/O不会让进程在I/O操作时长时间等待,进程可以在等待I/O操作完成的间隙执行其他任务,如处理其他客户端的请求、进行数据计算等。这使得系统资源(如CPU、内存等)得到更充分的利用,减少了资源的浪费。

例如,在一个同时处理多个网络连接的服务器程序中,使用阻塞I/O可能会导致大量的进程在等待I/O操作时占用内存资源,而CPU却处于空闲状态。而使用非阻塞I/O,进程在等待I/O时可以将CPU资源释放出来,用于其他有用的任务,从而提高整个系统的资源利用率。

Linux C语言非阻塞I/O在网络编程中的实践

基于套接字的非阻塞I/O示例

下面我们通过一个简单的服务器程序示例,来演示如何在Linux C语言网络编程中使用非阻塞I/O。

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

#define PORT 8080
#define BACKLOG 10
#define BUFFER_SIZE 1024

int main() {
    int sockfd, new_sockfd;
    struct sockaddr_in servaddr, cliaddr;
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        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, BACKLOG) < 0) {
        perror("Listen failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 设置套接字为非阻塞模式
    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

    while (1) {
        new_sockfd = accept(sockfd, (struct sockaddr *)&cliaddr, &(socklen_t){sizeof(cliaddr)});
        if (new_sockfd < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 没有新连接,继续处理其他任务
                sleep(1);
                continue;
            } else {
                perror("Accept failed");
                break;
            }
        }

        // 设置新连接套接字为非阻塞模式
        flags = fcntl(new_sockfd, F_GETFL, 0);
        fcntl(new_sockfd, F_SETFL, flags | O_NONBLOCK);

        char buffer[BUFFER_SIZE] = {0};
        ssize_t n = recv(new_sockfd, buffer, sizeof(buffer), 0);
        if (n < 0) {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                // 没有数据可读,继续处理其他任务
                close(new_sockfd);
                continue;
            } else {
                perror("Recv failed");
                close(new_sockfd);
                break;
            }
        } else if (n == 0) {
            // 客户端关闭连接
            close(new_sockfd);
        } else {
            printf("Received: %s\n", buffer);
            send(new_sockfd, buffer, strlen(buffer), 0);
            close(new_sockfd);
        }
    }
    close(sockfd);
    return 0;
}

在上述代码中,我们首先创建了一个TCP套接字,并将其绑定到指定的端口,然后开始监听连接。通过fcntl函数将监听套接字设置为非阻塞模式。在while循环中,使用accept函数接受客户端连接。如果accept返回 -1且errnoEAGAINEWOULDBLOCK,表示当前没有新连接,程序继续执行其他任务(这里简单地通过sleep函数模拟)。

当有新连接到达时,接受连接并将新连接的套接字也设置为非阻塞模式。然后使用recv函数接收客户端发送的数据,如果recv返回 -1且errnoEAGAINEWOULDBLOCK,表示当前没有数据可读,程序继续执行其他任务。如果接收到数据,打印数据并将其回显给客户端,然后关闭连接。

处理多个连接的非阻塞I/O实践

上述示例只是处理单个连接的非阻塞I/O情况。在实际应用中,服务器通常需要处理多个客户端连接。我们可以使用selectpollepoll等多路复用技术来实现同时处理多个非阻塞套接字。

select为例,下面是一个改进后的服务器程序示例,用于处理多个客户端连接:

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

#define PORT 8080
#define BACKLOG 10
#define BUFFER_SIZE 1024
#define MAX_CLIENTS 100

int main() {
    int sockfd, new_sockfd;
    struct sockaddr_in servaddr, cliaddr;
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        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, BACKLOG) < 0) {
        perror("Listen failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 设置套接字为非阻塞模式
    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

    fd_set read_fds, tmp_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(sockfd, &read_fds);
    int max_fd = sockfd;

    while (1) {
        tmp_fds = read_fds;
        int activity = select(max_fd + 1, &tmp_fds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("Select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(sockfd, &tmp_fds)) {
                new_sockfd = accept(sockfd, (struct sockaddr *)&cliaddr, &(socklen_t){sizeof(cliaddr)});
                if (new_sockfd < 0) {
                    if (errno == EAGAIN || errno == EWOULDBLOCK) {
                        continue;
                    } else {
                        perror("Accept failed");
                        break;
                    }
                }

                // 设置新连接套接字为非阻塞模式
                flags = fcntl(new_sockfd, F_GETFL, 0);
                fcntl(new_sockfd, F_SETFL, flags | O_NONBLOCK);

                FD_SET(new_sockfd, &read_fds);
                if (new_sockfd > max_fd) {
                    max_fd = new_sockfd;
                }
            }

            for (int i = sockfd + 1; i <= max_fd; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    char buffer[BUFFER_SIZE] = {0};
                    ssize_t n = recv(i, buffer, sizeof(buffer), 0);
                    if (n < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            continue;
                        } else {
                            perror("Recv failed");
                            FD_CLR(i, &read_fds);
                            close(i);
                        }
                    } else if (n == 0) {
                        FD_CLR(i, &read_fds);
                        close(i);
                    } else {
                        printf("Received from client %d: %s\n", i, buffer);
                        send(i, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }
    close(sockfd);
    return 0;
}

在这个示例中,我们使用select函数来监控多个套接字的可读事件。首先将监听套接字添加到read_fds集合中,并记录最大的文件描述符max_fd。在while循环中,调用select函数等待套接字上的事件发生。如果select返回有活动的套接字,首先检查监听套接字是否有新连接,如果有则接受连接并将新连接的套接字设置为非阻塞模式,然后将其添加到read_fds集合中。接着遍历read_fds集合中除监听套接字外的其他套接字,检查是否有数据可读,如果有则接收数据并处理。

使用epoll实现高效的非阻塞I/O

epoll是Linux内核提供的一种高效的I/O多路复用机制,相比于selectpoll,它在处理大量连接时具有更高的性能。下面是一个使用epoll实现的非阻塞I/O服务器示例:

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

#define PORT 8080
#define BACKLOG 10
#define BUFFER_SIZE 1024
#define MAX_EVENTS 10

int main() {
    int sockfd, new_sockfd;
    struct sockaddr_in servaddr, cliaddr;
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror("Socket creation failed");
        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, BACKLOG) < 0) {
        perror("Listen failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 设置套接字为非阻塞模式
    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

    int epollfd = epoll_create1(0);
    if (epollfd == -1) {
        perror("Epoll create failed");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    struct epoll_event event;
    event.data.fd = sockfd;
    event.events = EPOLLIN | EPOLLET;
    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &event) == -1) {
        perror("Epoll ctl add failed");
        close(sockfd);
        close(epollfd);
        exit(EXIT_FAILURE);
    }

    struct epoll_event events[MAX_EVENTS];
    while (1) {
        int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
        if (nfds == -1) {
            perror("Epoll wait failed");
            break;
        }

        for (int i = 0; i < nfds; i++) {
            if (events[i].data.fd == sockfd) {
                while (1) {
                    new_sockfd = accept(sockfd, (struct sockaddr *)&cliaddr, &(socklen_t){sizeof(cliaddr)});
                    if (new_sockfd == -1) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;
                        } else {
                            perror("Accept failed");
                            break;
                        }
                    }

                    // 设置新连接套接字为非阻塞模式
                    flags = fcntl(new_sockfd, F_GETFL, 0);
                    fcntl(new_sockfd, F_SETFL, flags | O_NONBLOCK);

                    event.data.fd = new_sockfd;
                    event.events = EPOLLIN | EPOLLET;
                    if (epoll_ctl(epollfd, EPOLL_CTL_ADD, new_sockfd, &event) == -1) {
                        perror("Epoll ctl add new socket failed");
                        close(new_sockfd);
                    }
                }
            } else {
                int client_fd = events[i].data.fd;
                char buffer[BUFFER_SIZE] = {0};
                ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
                if (n < 0) {
                    if (errno == EAGAIN || errno == EWOULDBLOCK) {
                        continue;
                    } else {
                        perror("Recv failed");
                        epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL);
                        close(client_fd);
                    }
                } else if (n == 0) {
                    epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL);
                    close(client_fd);
                } else {
                    printf("Received from client %d: %s\n", client_fd, buffer);
                    send(client_fd, buffer, strlen(buffer), 0);
                }
            }
        }
    }
    close(sockfd);
    close(epollfd);
    return 0;
}

在这个示例中,首先创建了一个epoll实例epollfd,并将监听套接字添加到epoll实例中,设置监听事件为EPOLLIN(可读事件)和EPOLLET(边缘触发模式)。在while循环中,调用epoll_wait等待事件发生。当有事件发生时,检查是否是监听套接字的事件,如果是则接受新连接,并将新连接的套接字也添加到epoll实例中。如果是客户端套接字的事件,则接收数据并处理。

非阻塞I/O的注意事项

处理EAGAIN和EWOULDBLOCK错误

在非阻塞I/O操作中,当操作不能立即完成时,函数会返回 -1,并设置errnoEAGAINEWOULDBLOCK。应用程序必须正确处理这些错误,不能简单地认为操作失败。通常的做法是在接收到这些错误时,继续执行其他任务,然后在适当的时候再次尝试I/O操作。

例如,在上述的代码示例中,当acceptrecv等函数返回 -1且errnoEAGAINEWOULDBLOCK时,程序会继续执行循环,等待下一次检查,而不是直接退出或报错。

边缘触发模式下的缓冲区处理

在使用epoll的边缘触发(EPOLLET)模式时,需要特别注意缓冲区的处理。在边缘触发模式下,当一个套接字有数据可读时,epoll_wait只会通知一次,即使缓冲区中还有未读取完的数据。因此,应用程序必须一次性将缓冲区中的数据读取完,否则可能会错过后续的数据。

例如,在上述使用epoll的示例代码中,当处理客户端套接字的可读事件时,需要在一个循环中不断调用recv函数,直到recv返回 -1且errnoEAGAINEWOULDBLOCK,表示数据已经读取完毕。

资源管理

在使用非阻塞I/O时,由于可能会同时处理多个套接字连接,资源管理变得尤为重要。需要及时关闭不再使用的套接字,避免文件描述符泄漏。同时,在处理大量连接时,要注意内存的使用,避免内存泄漏和过度消耗。

例如,在上述的代码示例中,当客户端关闭连接(recv返回0)或发生错误时,会及时关闭相应的套接字,并从epoll实例中删除该套接字的监控(如果使用epoll)。

通过以上对Linux C语言非阻塞I/O在网络编程中的详细介绍和实践示例,相信读者已经对非阻塞I/O的原理、优势以及实际应用有了更深入的理解。在实际的网络编程项目中,可以根据具体的需求和场景,合理选择和应用非阻塞I/O技术,以提高程序的性能和并发处理能力。