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

Linux C语言多路复用技术的应用场景

2023-07-154.4k 阅读

Linux C语言多路复用技术概述

在Linux环境下的C语言编程中,多路复用技术是一项关键的技术手段。它允许一个进程同时监视多个文件描述符(File Descriptor,简称FD),当其中任何一个FD就绪(例如可读或可写)时,进程能够得知并进行相应处理。这大大提高了程序的效率和响应性,尤其在需要同时处理多个I/O源的场景中,避免了传统的阻塞式I/O导致的进程长时间等待,浪费CPU资源。

Linux提供了多种多路复用的机制,如select、poll和epoll,它们各有特点和适用场景。

select多路复用

select是最早出现的多路复用技术,它的基本原理是通过一个fd_set数据结构来管理一组文件描述符。fd_set本质上是一个位数组,每一位对应一个文件描述符。例如,对于一个32位系统,fd_set可以管理32个文件描述符。

以下是一个简单的select示例代码:

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

#define PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024

int main() {
    int server_socket, client_socket[MAX_CLIENTS];
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len = sizeof(client_addr);
    fd_set read_fds, tmp_fds;
    int activity, i, valread;
    char buffer[BUFFER_SIZE];

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

    // 初始化服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 绑定套接字到地址
    if (bind(server_socket, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("Bind failed");
        close(server_socket);
        exit(EXIT_FAILURE);
    }

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

    // 初始化文件描述符集
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(server_socket, &read_fds);

    for (i = 0; i < MAX_CLIENTS; i++) {
        client_socket[i] = -1;
    }

    while (1) {
        tmp_fds = read_fds;

        // 使用select等待文件描述符就绪
        activity = select(server_socket + 1, &tmp_fds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("Select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(server_socket, &tmp_fds)) {
                // 有新的客户端连接
                int new_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_addr_len);
                if (new_socket < 0) {
                    perror("Accept failed");
                    continue;
                }

                printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

                // 将新的客户端套接字添加到文件描述符集中
                for (i = 0; i < MAX_CLIENTS; i++) {
                    if (client_socket[i] < 0) {
                        client_socket[i] = new_socket;
                        FD_SET(new_socket, &read_fds);
                        break;
                    }
                }
            }

            for (i = 0; i < MAX_CLIENTS; i++) {
                int sockfd = client_socket[i];
                if (sockfd > 0 && FD_ISSET(sockfd, &tmp_fds)) {
                    // 客户端有数据可读
                    valread = read(sockfd, buffer, BUFFER_SIZE);
                    if (valread == 0) {
                        // 客户端关闭连接
                        getpeername(sockfd, (struct sockaddr *)&client_addr, &client_addr_len);
                        printf("Host disconnected, ip %s, port %d \n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
                        close(sockfd);
                        FD_CLR(sockfd, &read_fds);
                        client_socket[i] = -1;
                    } else {
                        buffer[valread] = '\0';
                        printf("Message received from client: %s\n", buffer);
                        send(sockfd, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }

    close(server_socket);
    return 0;
}

在这个示例中,首先创建了一个服务器套接字并进行绑定和监听。然后使用select来监视服务器套接字和已连接的客户端套接字。当有新的客户端连接时,将其套接字添加到文件描述符集中。当某个客户端有数据可读时,读取数据并回显给客户端。

select的优点是几乎所有的操作系统都支持,兼容性好。然而,它也有一些缺点。其一,它所管理的文件描述符数量受到fd_set大小的限制,在32位系统上通常为1024个,在64位系统上有所增加但仍然有限。其二,每次调用select时都需要将所有的文件描述符从用户空间复制到内核空间,并且调用返回后需要遍历整个fd_set来找出就绪的文件描述符,这在文件描述符数量较多时效率较低。

poll多路复用

poll是对select的改进,它使用一个pollfd结构体数组来管理文件描述符。pollfd结构体定义如下:

struct pollfd {
    int fd;         /* 文件描述符 */
    short events;   /* 等待的事件 */
    short revents;  /* 发生的事件 */
};

poll通过events字段指定每个文件描述符需要监视的事件(如POLLIN表示可读,POLLOUT表示可写等),revents字段则返回实际发生的事件。

以下是一个简单的poll示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <poll.h>

#define PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024

int main() {
    int server_socket, client_socket[MAX_CLIENTS];
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len = sizeof(client_addr);
    struct pollfd fds[MAX_CLIENTS + 1];
    int activity, i, valread;
    char buffer[BUFFER_SIZE];

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

    // 初始化服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 绑定套接字到地址
    if (bind(server_socket, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("Bind failed");
        close(server_socket);
        exit(EXIT_FAILURE);
    }

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

    // 初始化pollfd数组
    fds[0].fd = server_socket;
    fds[0].events = POLLIN;
    for (i = 1; i <= MAX_CLIENTS; i++) {
        fds[i].fd = -1;
    }

    while (1) {
        // 使用poll等待文件描述符就绪
        activity = poll(fds, MAX_CLIENTS + 1, -1);
        if (activity < 0) {
            perror("Poll error");
            break;
        } else if (activity > 0) {
            if (fds[0].revents & POLLIN) {
                // 有新的客户端连接
                int new_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_addr_len);
                if (new_socket < 0) {
                    perror("Accept failed");
                    continue;
                }

                printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

                // 将新的客户端套接字添加到pollfd数组中
                for (i = 1; i <= MAX_CLIENTS; i++) {
                    if (fds[i].fd < 0) {
                        fds[i].fd = new_socket;
                        fds[i].events = POLLIN;
                        break;
                    }
                }
            }

            for (i = 1; i <= MAX_CLIENTS; i++) {
                int sockfd = fds[i].fd;
                if (sockfd > 0 && (fds[i].revents & POLLIN)) {
                    // 客户端有数据可读
                    valread = read(sockfd, buffer, BUFFER_SIZE);
                    if (valread == 0) {
                        // 客户端关闭连接
                        getpeername(sockfd, (struct sockaddr *)&client_addr, &client_addr_len);
                        printf("Host disconnected, ip %s, port %d \n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
                        close(sockfd);
                        fds[i].fd = -1;
                    } else {
                        buffer[valread] = '\0';
                        printf("Message received from client: %s\n", buffer);
                        send(sockfd, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }

    close(server_socket);
    return 0;
}

与select相比,poll的优点在于它没有文件描述符数量的限制(理论上只受限于系统资源),并且每次调用poll时不需要像select那样将所有文件描述符从用户空间复制到内核空间,而是只需要传递pollfd结构体数组。然而,poll在处理大量文件描述符时,仍然需要遍历整个数组来找出就绪的文件描述符,在高并发场景下效率仍然不够理想。

epoll多路复用

epoll是Linux特有的多路复用机制,它在处理高并发场景下表现出色。epoll有两种工作模式:水平触发(Level Triggered,简称LT)和边缘触发(Edge Triggered,简称ET)。

epoll使用一个epoll实例来管理一组文件描述符,通过epoll_create创建一个epoll实例,返回一个epoll文件描述符。然后使用epoll_ctl来添加、修改或删除要监视的文件描述符及其事件。最后使用epoll_wait等待文件描述符就绪。

以下是一个简单的epoll示例代码(以水平触发为例):

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

#define PORT 8888
#define MAX_CLIENTS 10
#define BUFFER_SIZE 1024
#define EPOLL_SIZE 1024

int main() {
    int server_socket, client_socket[MAX_CLIENTS];
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_addr_len = sizeof(client_addr);
    int epoll_fd, event_count;
    struct epoll_event events[EPOLL_SIZE];
    struct epoll_event ev;
    int i, valread;
    char buffer[BUFFER_SIZE];

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

    // 初始化服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    memset(&client_addr, 0, sizeof(client_addr));

    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 绑定套接字到地址
    if (bind(server_socket, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
        perror("Bind failed");
        close(server_socket);
        exit(EXIT_FAILURE);
    }

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

    // 创建epoll实例
    epoll_fd = epoll_create1(0);
    if (epoll_fd < 0) {
        perror("Epoll creation failed");
        close(server_socket);
        exit(EXIT_FAILURE);
    }

    // 将服务器套接字添加到epoll实例中
    ev.events = EPOLLIN;
    ev.data.fd = server_socket;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_socket, &ev) < 0) {
        perror("Epoll_ctl add server_socket failed");
        close(server_socket);
        close(epoll_fd);
        exit(EXIT_FAILURE);
    }

    for (i = 0; i < MAX_CLIENTS; i++) {
        client_socket[i] = -1;
    }

    while (1) {
        // 使用epoll_wait等待文件描述符就绪
        event_count = epoll_wait(epoll_fd, events, EPOLL_SIZE, -1);
        if (event_count < 0) {
            perror("Epoll_wait error");
            break;
        } else if (event_count > 0) {
            for (i = 0; i < event_count; i++) {
                int sockfd = events[i].data.fd;
                if (sockfd == server_socket) {
                    // 有新的客户端连接
                    int new_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_addr_len);
                    if (new_socket < 0) {
                        perror("Accept failed");
                        continue;
                    }

                    printf("New connection, socket fd is %d, ip is : %s, port : %d\n", new_socket, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

                    // 将新的客户端套接字添加到epoll实例中
                    ev.events = EPOLLIN;
                    ev.data.fd = new_socket;
                    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &ev) < 0) {
                        perror("Epoll_ctl add new_socket failed");
                        close(new_socket);
                        continue;
                    }

                    for (int j = 0; j < MAX_CLIENTS; j++) {
                        if (client_socket[j] < 0) {
                            client_socket[j] = new_socket;
                            break;
                        }
                    }
                } else {
                    // 客户端有数据可读
                    valread = read(sockfd, buffer, BUFFER_SIZE);
                    if (valread == 0) {
                        // 客户端关闭连接
                        getpeername(sockfd, (struct sockaddr *)&client_addr, &client_addr_len);
                        printf("Host disconnected, ip %s, port %d \n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
                        close(sockfd);
                        for (int j = 0; j < MAX_CLIENTS; j++) {
                            if (client_socket[j] == sockfd) {
                                client_socket[j] = -1;
                                break;
                            }
                        }
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, sockfd, NULL);
                    } else {
                        buffer[valread] = '\0';
                        printf("Message received from client: %s\n", buffer);
                        send(sockfd, buffer, strlen(buffer), 0);
                    }
                }
            }
        }
    }

    close(server_socket);
    close(epoll_fd);
    return 0;
}

在水平触发模式下,只要文件描述符对应的缓冲区有数据(可读事件)或者缓冲区有空闲空间(可写事件),epoll_wait就会一直通知。而在边缘触发模式下,只有当文件描述符状态发生变化时(如从不可读到可读),epoll_wait才会通知,这要求应用程序在收到通知后要尽可能多地处理数据,否则可能会错过后续数据。

epoll的优点在于它采用了事件驱动的方式,内核会将就绪的文件描述符通过events数组返回给用户空间,应用程序只需要处理这些就绪的文件描述符,而不需要像select和poll那样遍历所有文件描述符。这使得epoll在高并发场景下具有很高的效率,非常适合处理大量的并发连接。

Linux C语言多路复用技术的应用场景

网络服务器

  1. TCP服务器:在构建高性能的TCP服务器时,多路复用技术是必不可少的。例如,一个Web服务器需要同时处理多个客户端的HTTP请求。使用多路复用技术,服务器可以同时监听多个客户端套接字的可读事件,当有客户端发送请求时,及时读取请求数据并进行处理,然后将响应数据写回客户端。如上述的示例代码,无论是使用select、poll还是epoll,都可以实现一个简单的TCP服务器,能够处理多个客户端的并发连接。在实际的Web服务器中,还需要结合HTTP协议解析、静态资源处理、动态脚本执行等功能,多路复用技术为这些功能的高效实现提供了基础。
  2. UDP服务器:对于UDP服务器,同样可以利用多路复用技术来处理多个客户端的UDP数据包。UDP是无连接的协议,服务器需要监听特定端口,接收来自不同客户端的数据包。通过多路复用,可以同时监视多个UDP套接字的可读事件,当有数据包到达时,读取并处理。例如,一个简单的UDP聊天服务器,多个客户端可以向服务器发送聊天消息,服务器通过多路复用技术监听所有客户端的UDP套接字,接收消息并转发给其他客户端。

实时数据采集与处理

在工业控制、传感器网络等领域,需要实时采集多个传感器的数据,并进行处理。例如,一个环境监测系统可能有多个温度、湿度、空气质量等传感器。每个传感器通过串口或者网络接口与主机相连,主机需要同时读取多个传感器的数据。可以为每个传感器对应的设备文件(如串口设备文件/dev/ttyS0等)或者网络套接字创建文件描述符,使用多路复用技术来监听这些文件描述符的可读事件。当某个传感器有新的数据可读时,及时读取并进行处理,如存储到数据库、进行数据分析等。这样可以高效地实现实时数据的采集和处理,确保系统的实时性和稳定性。

多媒体应用

  1. 音视频流媒体服务器:在音视频流媒体服务器中,需要同时处理多个客户端的连接,为客户端提供音视频数据的实时传输。服务器需要不断地从媒体文件或者实时采集设备(如摄像头、麦克风)获取音视频数据,然后通过网络发送给客户端。使用多路复用技术,服务器可以同时监听客户端套接字的可写事件(当客户端套接字缓冲区有空闲空间时,表示可以发送数据),以及媒体数据来源的可读事件(如媒体文件的读取位置有新数据可读,或者采集设备有新的音视频数据产生)。这样可以确保音视频数据的流畅传输,满足多个客户端的并发请求。
  2. 多媒体播放应用:在本地多媒体播放应用中,也可以使用多路复用技术。例如,当播放一个带有字幕的视频时,应用程序需要同时读取视频文件数据、音频文件数据以及字幕文件数据。可以为每个文件创建文件描述符,使用多路复用技术监听这些文件描述符的可读事件,根据播放进度和需求,及时读取相应的数据进行解码和播放,保证音视频和字幕的同步播放。

分布式系统

  1. 分布式计算节点:在分布式计算系统中,每个计算节点可能需要与多个其他节点进行通信,接收任务数据、发送计算结果等。通过多路复用技术,计算节点可以同时监听多个网络连接的可读和可写事件,高效地处理与其他节点的通信。例如,一个基于分布式计算的大数据处理系统,各个计算节点需要从数据存储节点获取数据块进行计算,然后将计算结果返回给汇总节点。计算节点使用多路复用技术可以同时处理多个数据获取和结果返回的连接,提高整个分布式系统的计算效率。
  2. 分布式存储系统:在分布式存储系统中,存储节点需要处理多个客户端的读写请求。客户端可能同时对存储节点进行数据写入和读取操作。存储节点通过多路复用技术,监听客户端套接字的可读(读取请求)和可写(写入请求)事件,合理安排数据的存储和读取,确保分布式存储系统的高效运行。例如,Ceph这样的分布式存储系统,在其存储节点的实现中就可能会运用到多路复用技术来处理大量客户端的并发请求。

游戏开发

  1. 网络游戏服务器:网络游戏服务器需要同时处理大量玩家的连接,接收玩家的操作指令,如移动、攻击等,并将游戏状态同步给所有玩家。使用多路复用技术,服务器可以同时监听多个玩家套接字的可读事件,及时获取玩家的操作信息,进行游戏逻辑处理,然后通过监听可写事件,将更新后的游戏状态发送给所有玩家。这对于保证游戏的实时性和流畅性至关重要,能够支持大量玩家同时在线进行游戏。
  2. 本地游戏应用:在本地游戏应用中,也可能会用到多路复用技术。例如,一个支持手柄和键盘同时操作的游戏,应用程序需要同时监听手柄设备文件和键盘设备文件的输入事件。通过多路复用技术,应用程序可以高效地处理来自不同输入设备的事件,为玩家提供流畅的游戏操作体验。

多路复用技术应用场景选择策略

在实际应用中,选择合适的多路复用技术对于系统的性能和稳定性至关重要。

连接数量较少的场景

如果应用场景中需要处理的连接数量较少,例如小于100个连接,select和poll通常就能够满足需求。select兼容性好,几乎在所有操作系统上都可用,而poll在管理文件描述符方面相对更灵活一些,没有文件描述符数量的硬限制(虽然在实际应用中系统资源仍然会限制其可管理的数量)。在这种情况下,由于连接数量少,select每次复制文件描述符到内核空间以及遍历fd_set的开销相对较小,不会成为性能瓶颈。

连接数量中等的场景

当连接数量在100到1000之间时,poll可能是一个较好的选择。它避免了select文件描述符数量的限制,并且在处理中等数量的文件描述符时,性能表现优于select。虽然每次调用poll仍然需要遍历pollfd数组,但由于连接数量不是特别大,这种遍历的开销还在可接受范围内。

高并发连接场景

对于高并发连接场景,即连接数量超过1000个甚至更多时,epoll是首选。epoll的事件驱动机制使得它在处理大量文件描述符时具有极高的效率,内核只返回就绪的文件描述符,应用程序只需要处理这些就绪的描述符,避免了select和poll在高并发时遍历大量未就绪文件描述符的开销。尤其是在边缘触发模式下,epoll能够更高效地处理高并发事件,适合如大型Web服务器、大规模网络游戏服务器等高并发应用场景。

在实际选择时,还需要考虑应用场景的具体特点,如是否对操作系统兼容性有严格要求(如果需要跨多种操作系统,select可能是更好的选择,因为它的兼容性最好),是否对实时性要求极高(在这种情况下,epoll的边缘触发模式可能更适合,因为它能够更及时地响应事件变化)等因素,综合权衡后选择最合适的多路复用技术。

综上所述,Linux C语言多路复用技术在众多应用场景中都发挥着重要作用,通过合理选择和使用不同的多路复用机制,能够显著提高程序的性能和效率,满足各种复杂的应用需求。无论是网络服务器、实时数据处理、多媒体应用、分布式系统还是游戏开发等领域,多路复用技术都是实现高效、稳定应用的关键技术之一。