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

C++网络编程select模式的同步特性

2021-11-171.6k 阅读

C++网络编程select模式的同步特性

在C++网络编程中,select模式是一种常用的I/O多路复用机制,它允许程序同时监控多个文件描述符(如套接字)的状态变化,以确定哪些描述符可以进行读、写或异常处理操作。select模式具有一些同步特性,这些特性对于编写高效、稳定的网络应用程序至关重要。

1. select函数概述

select函数定义在<sys/select.h>头文件中(在Windows下为<winsock2.h>),其原型如下:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds:被监控的文件描述符集合中最大文件描述符的值加1。
  • readfds:指向可读文件描述符集合的指针。
  • writefds:指向可写文件描述符集合的指针。
  • exceptfds:指向异常文件描述符集合的指针。
  • timeout:设置select函数的超时时间。如果为NULL,则select函数将一直阻塞,直到有文件描述符状态发生变化;如果timeout的两个成员均为0,则select函数不阻塞,立即返回。

fd_set是一个文件描述符集合类型,通过一组宏来操作:

// 清空文件描述符集合
void FD_ZERO(fd_set *set);
// 将指定文件描述符添加到集合中
void FD_SET(int fd, fd_set *set);
// 将指定文件描述符从集合中移除
void FD_CLR(int fd, fd_set *set);
// 检查指定文件描述符是否在集合中
int FD_ISSET(int fd, fd_set *set);

2. 同步特性分析

2.1 阻塞与非阻塞同步

  • 阻塞模式:当selecttimeout参数为NULL时,select函数会一直阻塞,直到有文件描述符状态发生变化(可读、可写或有异常)。在阻塞期间,程序的执行流会停留在select函数调用处,不会继续执行后续代码。这就实现了一种同步机制,确保只有在有可用I/O操作时,程序才会继续进行相应的读写操作。 例如,在一个简单的服务器程序中,可能会这样使用:
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/select.h>
#include <cstring>

#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};

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

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

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

    // 绑定套接字
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

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

    fd_set read_fds;
    fd_set tmp_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(server_fd, &read_fds);
    int activity;
    while (true) {
        tmp_fds = read_fds;
        activity = select(server_fd + 1, &tmp_fds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(server_fd, &tmp_fds)) {
                if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    continue;
                }
                FD_SET(new_socket, &read_fds);
                std::cout << "New connection, socket fd is " << new_socket << " , ip is : " << inet_ntoa(address.sin_addr) << " , port : " << ntohs(address.sin_port) << std::endl;
            }
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    valread = read(i, buffer, 1024);
                    if (valread == 0) {
                        getpeername(i, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                        std::cout << "Host disconnected , ip " << inet_ntoa(address.sin_addr) << " , port " << ntohs(address.sin_port) << std::endl;
                        close(i);
                        FD_CLR(i, &read_fds);
                    } else {
                        buffer[valread] = '\0';
                        std::cout << "Message from connected user: " << buffer << std::endl;
                    }
                }
            }
        }
    }
    close(server_fd);
    return 0;
}

在上述代码中,select函数以阻塞模式运行,服务器会一直等待客户端连接或已有客户端发送数据。只有当有新连接或数据可读时,程序才会继续执行后续的acceptread操作。

  • 非阻塞模式:当selecttimeout参数不为NULLtv_sectv_usec都为0时,select函数不会阻塞,而是立即返回。这种方式允许程序在不阻塞的情况下检查文件描述符的状态,实现了一种异步查询的同步方式。例如:
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/select.h>
#include <cstring>

#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};

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

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

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

    // 绑定套接字
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

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

    fd_set read_fds;
    fd_set tmp_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(server_fd, &read_fds);
    struct timeval timeout;
    timeout.tv_sec = 0;
    timeout.tv_usec = 0;
    int activity;
    while (true) {
        tmp_fds = read_fds;
        activity = select(server_fd + 1, &tmp_fds, NULL, NULL, &timeout);
        if (activity < 0) {
            perror("select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(server_fd, &tmp_fds)) {
                if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    continue;
                }
                FD_SET(new_socket, &read_fds);
                std::cout << "New connection, socket fd is " << new_socket << " , ip is : " << inet_ntoa(address.sin_addr) << " , port : " << ntohs(address.sin_port) << std::endl;
            }
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    valread = read(i, buffer, 1024);
                    if (valread == 0) {
                        getpeername(i, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                        std::cout << "Host disconnected , ip " << inet_ntoa(address.sin_addr) << " , port " << ntohs(address.sin_port) << std::endl;
                        close(i);
                        FD_CLR(i, &read_fds);
                    } else {
                        buffer[valread] = '\0';
                        std::cout << "Message from connected user: " << buffer << std::endl;
                    }
                }
            }
        }
        // 这里可以执行其他非I/O相关的任务
    }
    close(server_fd);
    return 0;
}

在这个例子中,select函数不会阻塞程序,程序可以在每次调用select后立即执行其他任务,然后再次调用select检查文件描述符状态。

2.2 多文件描述符同步 select模式允许同时监控多个文件描述符,这对于处理多个客户端连接或多种类型的I/O操作(如套接字和管道)非常有用。通过将多个文件描述符添加到相应的fd_set集合中,select函数可以一次性检查这些描述符的状态。例如,一个服务器程序可能同时需要监听新的客户端连接(监听套接字)和读取已连接客户端发送的数据(客户端套接字):

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/select.h>
#include <cstring>

#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};

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

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

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

    // 绑定套接字
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

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

    fd_set read_fds;
    fd_set tmp_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&tmp_fds);
    FD_SET(server_fd, &read_fds);
    int activity;
    while (true) {
        tmp_fds = read_fds;
        activity = select(server_fd + 1, &tmp_fds, NULL, NULL, NULL);
        if (activity < 0) {
            perror("select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(server_fd, &tmp_fds)) {
                if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    continue;
                }
                FD_SET(new_socket, &read_fds);
                std::cout << "New connection, socket fd is " << new_socket << " , ip is : " << inet_ntoa(address.sin_addr) << " , port : " << ntohs(address.sin_port) << std::endl;
            }
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (FD_ISSET(i, &tmp_fds)) {
                    valread = read(i, buffer, 1024);
                    if (valread == 0) {
                        getpeername(i, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                        std::cout << "Host disconnected , ip " << inet_ntoa(address.sin_addr) << " , port " << ntohs(address.sin_port) << std::endl;
                        close(i);
                        FD_CLR(i, &read_fds);
                    } else {
                        buffer[valread] = '\0';
                        std::cout << "Message from connected user: " << buffer << std::endl;
                    }
                }
            }
        }
    }
    close(server_fd);
    return 0;
}

在这个服务器代码中,server_fd用于监听新的客户端连接,而当有新连接建立后,新的客户端套接字new_socket也被添加到read_fds集合中。select函数会同时检查server_fd是否有新连接到来以及已连接客户端的套接字是否有数据可读,实现了多文件描述符之间的同步监控。

2.3 读写同步 select函数可以通过readfdswritefds参数分别监控文件描述符的可读和可写状态,从而实现读写操作的同步。在网络编程中,这一点尤为重要,因为我们需要确保在发送数据之前,套接字是可写的,并且在接收数据之前,套接字是可读的。例如:

#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/select.h>
#include <cstring>

#define PORT 8080
#define MAX_CLIENTS 10

int main() {
    int server_fd, new_socket, valread, send_bytes;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    char response[1024] = "Hello from server!";

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

    // 设置套接字选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        perror("setsockopt");
        exit(EXIT_FAILURE);
    }

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

    // 绑定套接字
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
        perror("bind failed");
        exit(EXIT_FAILURE);
    }

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

    fd_set read_fds;
    fd_set write_fds;
    fd_set tmp_read_fds;
    fd_set tmp_write_fds;
    FD_ZERO(&read_fds);
    FD_ZERO(&write_fds);
    FD_ZERO(&tmp_read_fds);
    FD_ZERO(&tmp_write_fds);
    FD_SET(server_fd, &read_fds);
    int activity;
    while (true) {
        tmp_read_fds = read_fds;
        tmp_write_fds = write_fds;
        activity = select(server_fd + 1, &tmp_read_fds, &tmp_write_fds, NULL, NULL);
        if (activity < 0) {
            perror("select error");
            break;
        } else if (activity > 0) {
            if (FD_ISSET(server_fd, &tmp_read_fds)) {
                if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t *)&addrlen)) < 0) {
                    perror("accept");
                    continue;
                }
                FD_SET(new_socket, &read_fds);
                FD_SET(new_socket, &write_fds);
                std::cout << "New connection, socket fd is " << new_socket << " , ip is : " << inet_ntoa(address.sin_addr) << " , port : " << ntohs(address.sin_port) << std::endl;
            }
            for (int i = 0; i < MAX_CLIENTS; i++) {
                if (FD_ISSET(i, &tmp_read_fds)) {
                    valread = read(i, buffer, 1024);
                    if (valread == 0) {
                        getpeername(i, (struct sockaddr *)&address, (socklen_t *)&addrlen);
                        std::cout << "Host disconnected , ip " << inet_ntoa(address.sin_addr) << " , port " << ntohs(address.sin_port) << std::endl;
                        close(i);
                        FD_CLR(i, &read_fds);
                        FD_CLR(i, &write_fds);
                    } else {
                        buffer[valread] = '\0';
                        std::cout << "Message from connected user: " << buffer << std::endl;
                        if (FD_ISSET(i, &tmp_write_fds)) {
                            send_bytes = send(i, response, strlen(response), 0);
                            if (send_bytes < 0) {
                                perror("send error");
                            }
                        }
                    }
                }
            }
        }
    }
    close(server_fd);
    return 0;
}

在上述代码中,read_fds集合用于监控套接字是否可读,当有数据可读时,读取数据并处理。同时,write_fds集合用于监控套接字是否可写,当读取到客户端数据后,检查该客户端套接字是否可写,如果可写则向客户端发送响应数据,实现了读写操作的同步。

3. select模式同步特性的优缺点

3.1 优点

  • 跨平台支持select模式在UNIX/Linux和Windows平台上都有相应的实现,这使得基于select的网络应用程序具有较好的跨平台性。
  • 简单易用select函数的接口相对简单,对于初学者来说容易理解和掌握。通过操作fd_set集合和设置timeout参数,就可以实现基本的I/O多路复用和同步功能。
  • 多文件描述符监控:能够同时监控多个文件描述符的状态变化,适用于处理多个客户端连接或多种类型I/O操作的场景,提高了程序的并发处理能力。

3.2 缺点

  • 文件描述符数量限制:在一些系统中,fd_set集合所能容纳的文件描述符数量有限(例如在Linux系统中,默认情况下FD_SETSIZE为1024)。当需要处理大量并发连接时,这可能成为瓶颈。
  • 线性扫描效率低select函数返回后,需要线性扫描fd_set集合来确定哪些文件描述符状态发生了变化。随着文件描述符数量的增加,这种线性扫描的效率会显著降低。
  • 内核用户空间数据拷贝开销:每次调用select函数时,都需要将用户空间的fd_set集合拷贝到内核空间,返回时又要将内核空间的结果拷贝回用户空间,这增加了系统开销。

4. 应用场景

4.1 小型网络服务器 对于并发连接数较少的小型网络服务器,select模式的简单性和跨平台性使其成为一个不错的选择。例如,一个简单的日志收集服务器,可能只需要处理少量客户端的连接并接收日志数据,使用select模式可以轻松实现对这些连接的监控和数据读取。

4.2 嵌入式系统网络应用 在一些资源受限的嵌入式系统中,select模式的低复杂度和跨平台特性使其适合用于实现网络功能。例如,一个智能家居设备需要与云端服务器进行通信,通过select模式可以有效地管理设备与服务器之间的网络连接,在有限的资源下实现稳定的通信。

总结

select模式在C++网络编程中提供了一种基本的I/O多路复用和同步机制。通过合理利用其阻塞与非阻塞特性、多文件描述符监控以及读写同步等功能,可以开发出高效、稳定的网络应用程序。然而,由于其自身的一些局限性,在处理大规模并发连接等场景时,可能需要考虑其他更高效的I/O多路复用机制,如epoll(在Linux系统中)或IOCP(在Windows系统中)。但对于许多小型网络应用和嵌入式系统网络开发,select模式仍然具有重要的应用价值。在实际开发中,应根据具体的需求和场景,选择最合适的网络编程技术来实现高效、可靠的网络通信。