TCP服务器并发模型:单线程、多线程与Select实现

目录

一、引言

二、关键函数

[socket() - 创建套接字,这是网络通信的第一步。](#socket() - 创建套接字,这是网络通信的第一步。)

[bind() - 绑定地址](#bind() - 绑定地址)

[listen() - 开始监听](#listen() - 开始监听)

[send() - 发送数据](#send() - 发送数据)

[recv() - 接收数据](#recv() - 接收数据)

[select() - I/O 多路复用](#select() - I/O 多路复用)

三、单线程模型

四、多线程模型

五、select模型

六、结语


一、引言

本文目标:掌握单线程、多线程、select搭建TCP服务器的方法。

关键技术点:

  1. socket / bind / listen / accept
  2. recv / send
  3. select

二、关键函数

在深入每个函数之前,先理解它们在构建一个 TCP 服务器时的标准流程:

  1. socket(): 创建一个通信端点(套接字)。
  2. bind(): 将这个套接字与一个具体的 IP 地址和端口号绑定。
  3. listen(): 将套接字设置为被动监听模式,准备接受连接。
  4. accept(): 从已建立的连接队列中取出一个连接,创建一个新的套接字用于与该客户端通信。
  5. send() / recv(): 使用新的通信套接字与客户端进行数据收发。
  6. close(): 通信结束后,关闭套接字。

select()函数则是一种更高级的机制,用于在一个线程中同时管理多个套接字,实现 I/O 多路复用。

socket() - 创建套接字,这是网络通信的第一步。

  • 作用: 在内核中分配资源,创建一个新的套接字,并返回一个文件描述符。
  • 函数原型:

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

  • 参数 :
    • domain: 指定通信协议族。
      • AF_INET: IPv4 协议(最常用)。
      • AF_INET6: IPv6 协议。
      • AF_UNIX: 本地通信(同一台机器上的进程间通信)。
    • type: 指定套接字类型。
      • SOCK_STREAM: 流式套接字,提供可靠的、面向连接的通信(如 TCP)。
      • SOCK_DGRAM: 数据报套接字,提供无连接的、不可靠的通信(如 UDP)。
    • protocol: 指定具体的协议。通常设为 0,表示根据 domaintype 自动选择默认协议(如 SOCK_STREAM 对应 TCP)。
  • 返回值 :
    • 成功 : 返回一个非负整数,即套接字文件描述符
    • 失败 : 返回 -1,并设置 errno

bind() - 绑定地址

服务器需要将创建的套接字与一个众所周知的地址(IP + 端口)关联起来,以便客户端能够找到它。

  • 作用: 将一个套接字与一个本地 IP 地址和端口号绑定。
  • 函数原型:

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • 参数 :
    • sockfd: socket() 函数返回的套接字文件描述符。
    • addr: 指向一个地址结构体的指针,包含要绑定的 IP 地址和端口号。对于 IPv4,通常使用 struct sockaddr_in
    • addrlen: 地址结构体的大小,通常是 sizeof(struct sockaddr_in)
  • 返回值 :
    • 成功 : 返回 0
    • 失败 : 返回 -1,并设置 errno

listen() - 开始监听

绑定地址后,服务器需要告诉内核它准备接受连接请求。

  • 作用 : 将一个已绑定的套接字转换为被动监听状态,并创建一个连接请求队列。
  • 函数原型:

#include <sys/socket.h>
int listen(int sockfd, int backlog);

  • 参数 :
    • sockfd: bind() 成功后的套接字文件描述符。
    • backlog: 指定连接请求队列的最大长度。当队列满时,新的连接请求会被拒绝。
  • 返回值 :
    • 成功 : 返回 0
    • 失败 : 返回 -1,并设置 errno

accept() - 接受连接

服务器调用 listen() 后,客户端就可以发起连接。accept() 用于从已完成连接的队列中取出一个。

  • 作用 : 从监听套接字的已完成连接队列中取出第一个连接,并创建一个新的套接字用于与该客户端通信。
  • 函数原型:

#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

  • 参数 :
    • sockfd: listen() 成功后的监听套接字文件描述符。
    • addr: 传出参数 。用于获取客户端的地址信息(IP 和端口)。如果不需要,可以传 NULL
    • addrlen: 传入传出参数 。调用前,传入 addr 指向的缓冲区大小;调用后,返回客户端地址结构体的实际长度。
  • 返回值 :
    • 成功 : 返回一个新的套接字文件描述符,专门用于和当前这个客户端通信。
    • 失败 : 返回 -1,并设置 errno

send() - 发送数据

  • 作用: 通过一个已连接的套接字向对端发送数据。
  • 函数原型:

#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

  • 参数 :
    • sockfd: 已建立连接的套接字文件描述符(accept() 的返回值或客户端的套接字)。
    • buf: 指向要发送的数据缓冲区的指针。
    • len: 要发送的数据长度(字节数)。
    • flags: 控制发送行为的标志,通常设为 0。常用标志有 MSG_OOB(发送带外数据)、MSG_DONTWAIT(非阻塞发送)等。
  • 返回值 :
    • 成功 : 返回实际发送的字节数 。这个值可能小于 len,尤其是在非阻塞模式下。
    • 失败 : 返回 -1,并设置 errno

recv() - 接收数据

  • 作用: 从一个已连接的套接字接收来自对端的数据。
  • 函数原型:

#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

  • 参数 :
    • sockfd: 已建立连接的套接字文件描述符。
    • buf: 指向用于存储接收数据的缓冲区的指针。
    • len: 缓冲区的最大长度。
    • flags: 控制接收行为的标志,通常设为 0。常用标志有 MSG_PEEK(窥看数据但不从队列中删除)、MSG_WAITALL(等待所有请求的数据)等。
  • 返回值 :
    • > 0 : 返回实际接收到的字节数
    • = 0 : 表示对端已正常关闭连接(发送了 FIN 包)。
    • = -1 : 表示发生错误,并设置 errno

select() - I/O 多路复用

当一个服务器需要同时处理成百上千个客户端连接时,为每个连接创建一个线程/进程开销巨大。select() 允许一个进程监视多个文件描述符,一旦有描述符就绪(可读、可写或异常),就会通知程序进行相应操作。

  • 作用: 监视多个文件描述符的状态变化(可读、可写、异常),是一种 I/O 多路复用机制。
  • 函数原型:

#include <sys/select.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,则一直阻塞直到有事件发生。
  • 返回值 :
    • > 0 : 返回就绪的文件描述符总数
    • = 0 : 表示超时,没有任何描述符就绪。
    • = -1 : 表示发生错误,并设置 errno

三、单线程模型

cpp 复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define QUEUE_LENGTH 5
#define BUFFER_SIZE 128
#define PORT 8080

int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("绑定失败!\n");
        return -1;
    }

    if (listen(server_fd, QUEUE_LENGTH) < 0) {
        perror("监听失败!\n");
        return -1;
    }

    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE] = { 0 };
    while (1) {
        int client_fd = accept(server_fd,(struct sockaddr*)&client_addr, &client_len);
        if (client_fd == -1) {
            perror("接受连接失败");
            continue;
        }

        ssize_t ret = recv(client_fd, buffer, BUFFER_SIZE, 0);
        if (ret <= 0) {
            perror("客户端断开连接!\n");
            close(client_fd);
            continue;
        }
        printf("client: %s\n", buffer);

        send(client_fd, buffer, sizeof(buffer), 0);
        close(client_fd);
        memset(buffer, 0, sizeof(buffer));
    }
    return 0;
}

可以看到,一发一收之后就断开连接了,如果还想发送数据,就必须重新建立连接。频繁的建连,三次握手四次挥手带来的开销还是挺大的。

四、多线程模型

cpp 复制代码
#include <stdio.h>
#include <netinet/in.h>
#include <unistd.h>
#include <sys/socket.h>
#include <pthread.h>
#include <stdlib.h>

typedef struct {
    int client_fd;
    struct sockaddr_in addr;
} ClientInfo;

void* handle_client(void* arg) {
    ClientInfo* client = (ClientInfo*)arg;
    int client_fd = client->client_fd;
    char buffer[1024] = { 0 };

    printf("新线程已创建\n");

    while (1) {
        ssize_t len = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
        if (len <= 0) {
            printf("客户端断开连接\n");
            break;
        }
        buffer[len] = '\0';
        printf("收到客户端数据:%s\n",buffer);
        
        send(client_fd, buffer, sizeof(buffer), 0);
    }
    close(client_fd);
    free(client);
    pthread_exit(NULL);
}

int main() {
    int listen_fd;
    socklen_t addr_len;

    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket 创建失败!");
        return -1;
    }

    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(8080);

    if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("绑定失败");
        close(listen_fd);
        return -1;
    }

    if (listen(listen_fd, 5) < 0) {
        perror("监听失败");
        close(listen_fd);
        return -1;
    }

    while (1) {
        ClientInfo* client = malloc(sizeof(ClientInfo));
        if (!client) {
            perror("malloc失败");
            continue;
        }
        if ((client->client_fd = accept(listen_fd, (struct sockaddr*)&client->addr, &addr_len)) < 0) {
            perror("accept失败");
            free(client);
            continue;
        }
        pthread_t thread_id;
        if (pthread_create(&thread_id, NULL, handle_client, (void*)client) != 0) {
            perror("创建线程失败");
            close(client->client_fd);
            free(client);
            continue;
        }

        pthread_detach(thread_id);
    }
    close(listen_fd);
    return 0;
}

每来一个连接,就开一个专门的线程来处理,这样就可以随便发数据了。但是,如果连接数量比较多的话,那么会开很多个线程,线程的切换以及每个线程独有的资源加载一块,也是一个大的开销。而且,线程是占用内存的,内存的容量有限,也就是意味着线程的数量也有限。

五、select模型

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <asm-generic/socket.h>

#define MAX_CLIENT 10
#define PORT 8080

int main() {
    int listen_fd;
    int opt = 1;
    struct sockaddr_in addr;

    listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd == -1) {
        perror("socket 创建失败");
        return -1;
    }

    if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR | 
        SO_REUSEPORT, &opt, sizeof(opt)) == -1) {
        perror("socket属性设置失败");
        return -1;
    }

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

    if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("绑定失败");
        return -1;
    }

    if (listen(listen_fd, 5) < 0) {
        perror("监听失败");
        return -1;
    }

    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);
    int client_fds[MAX_CLIENT] = { 0 };
    fd_set read_fds;
    char buffer[1024] = { 0 };

    while (1) {
        FD_ZERO(&read_fds);
        FD_SET(listen_fd, &read_fds);

        int max_fd = listen_fd;
        for (int i = 0; i < MAX_CLIENT; i++) {
            int fd = client_fds[i];
            if (fd > 0) FD_SET(fd, &read_fds);
            if (fd > max_fd) max_fd = fd;
        }

        int activity = select(max_fd + 1, &read_fds, NULL, NULL, NULL);

        // 有新的连接到来
        if (FD_ISSET(listen_fd, &read_fds)) {
            int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
            if (client_fd == -1) {
                perror("accept 失败");
                continue;
            }
            printf("有新客户端 %d 连接\n", client_fd);
            for (int i = 0; i < MAX_CLIENT; i++) {
                if (client_fds[i] == 0) {
                    client_fds[i] = client_fd;
                    break;
                }
            }
        }

        // 遍历所有客户端判断是否有可读事件
        for (int i = 0; i < MAX_CLIENT; i++) {
            int fd = client_fds[i];
            if (FD_ISSET(fd, &read_fds)) {
                int read_num = recv(fd, buffer, sizeof(buffer) - 1, 0);
                if (read_num == 0) {
                    printf("客户端 %d 断开连接\n", fd);
                    close(fd);
                    client_fds[i] = 0;
                }else {
                    buffer[read_num] = '\0';
                    printf("收到客户端 %d 数据: %s \n", fd, buffer);
                    send(fd, buffer, sizeof(buffer), 0);
                    memset(buffer, 0, sizeof(buffer));
                }

            }
        }
    }
    return 0;
}

select的IO多路复用机制,使得一个线程就可以监听多个连接。

不过,可以看到,每一次都要重新传入监听的文件描述符集合,这是因为select内部会修改集合。没有触发读事件的的文件描述符对应的位会被清零。

六、结语

欢迎批评指正!


相关推荐
杨云龙UP2 小时前
Oracle / ODA环境TRACE、alert日志定位与ADRCI清理 SOP_20260423
linux·运维·服务器·数据库·oracle
咖喱o2 小时前
BGP、BGP4+
网络
pengyi8710152 小时前
IP被封禁应急处理,动态IP池快速更换入门
大数据·网络·网络协议·tcp/ip·智能路由器
yaoxin5211232 小时前
388. Java IO API - 处理事件
java·服务器·数据库
cl131413142 小时前
烟气测量格恩朗流量计选型指南
大数据·网络·人工智能·产品运营
xixixi777772 小时前
国内首家“AI+量子”实体公司成立:量智开物发布“追风”“扁鹊”,开启下一代计算文明大门
大数据·网络·人工智能·安全·ai·科大讯飞·量子计算
zzzsde2 小时前
【Linux】线程概念与控制(1)线程基础与分页式存储管理
linux·运维·服务器·开发语言·算法
小樱花的樱花2 小时前
Linux进程管理相关命令
linux·运维·服务器
计算机安禾2 小时前
【Linux从入门到精通】第13篇:磁盘管理与文件系统——数据存在哪了?
linux·运维·服务器