tcp_sever:c++实现

目录

1.tcp服务器基础编程部分

1.1首先需要创建监听 socket(迎宾的小姐姐)

1.2定义服务器地址结构(迎宾的地址)

1.3绑定地址到 socket(小姐姐站到迎宾的地方)

1.4 开始监听,最大等待连接队列长度为 10(小姐姐开始迎宾)

1.5客户端地址结构体,用于 accept 获取对端信息

2.接收模式

2.1模式1:单次 accept —— 只接受第一个连接,处理一次请求就退出

2.2模式2:循环 accept —— 每次只处理一个客户端的一次通信,然后继续 accept 下一个

2.3模式3:accept + 多线程 —— 每来一个连接就创建一个线程处理

2.4模式4:使用 select 实现 I/O 多路复用

2.5模式 5:使用 poll 实现 I/O 多路复用

2.6模式 6:使用 epoll 实现高性能 I/O 多路复用

1.int epoll_create(int size);

2.int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

3.int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

1.tcp服务器基础编程部分

1.1首先需要创建监听 socket(迎宾的小姐姐)

IPv4(TCP/IP), 流式套接字(SOCK_STREAM), 使用默认协议(IPPROTO_TCP)

int sockfd = socket(AF_INET, SOCK_STREAM, 0);

1.2定义服务器地址结构(迎宾的地址)

    struct sockaddr_in servaddr;
    servaddr.sin_family = AF_INET;                    // IPv4 协议族
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);     // 绑定所有本地网卡(0.0.0.0)
    servaddr.sin_port = htons(2000);                  // 监听端口号 2000(主机字节序转网络字节序)

1.3绑定地址到 socket(小姐姐站到迎宾的地方)

   if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
        // 绑定失败时打印错误信息
        printf("bind failed: %s\n", strerror(errno));
    }

1.4 开始监听,最大等待连接队列长度为 10(小姐姐开始迎宾)

 listen(sockfd, 10);
    printf("listen finished: %d\n", sockfd);  // 输出监听 socket 的 fd 号(通常是 3)

1.5客户端地址结构体,用于 accept 获取对端信息

    struct sockaddr_in clientaddr;
    socklen_t len = sizeof(clientaddr);  // 必须传指针大小给 accept

2.接收模式

2.1模式1:单次 accept —— 只接受第一个连接,处理一次请求就退出

  printf("accept\n");
    // 阻塞等待第一个客户端连接
    int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
    printf("accept finished\n");

    char buffer[1024] = {0};
    int count = recv(clientfd, buffer, 1024, 0);  // 接收数据
    printf("RECV: %s\n", buffer);

    count = send(clientfd, buffer, count, 0);      // 发送回显
    printf("SEND: %d\n", count);

若想看被监听的端口否被成功监听,可以输入netstat -anop | grep 指定端口:

进入listen后,就可以被连接。

若再次重新运行程序则会出现提示如下错误,则说明该端口已经被占用了:

此时若程序正常运行,说明tcp服务器已经启动,这里用网络助手连接服务器所对应的端口号,客户端可以成功连接到这台tcp服务器:

发送数据成功后会回显,成功实现。

但是这一个fd就对应一个tcp连接信息,无法连接其他的客户端,所以考虑循环调用accept.

2.2模式2:循环 accept —— 每次只处理一个客户端的一次通信,然后继续 accept 下一个

while (1) {
        printf("accept\n");
        int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
        printf("accept finished\n");

        char buffer[1024] = {0};
        int count = recv(clientfd, buffer, 1024, 0);
        printf("RECV: %s\n", buffer);

        count = send(clientfd, buffer, count, 0);
        printf("SEND: %d\n", count);

        close(clientfd);  // 处理完即关闭连接
    }

这样操作服务端没法及时的接收客户端发送来的信息

如果c1,c2一次连接,但是c2先发送消息,c2是接受不到数据的,直到c1发送后才接收到数据

虽然都能连接,数据也发到服务器了,但是程序会阻塞在recv处。要想程序不被阻塞在recv处,我应该将recv,send放入到线程的回调函数中。

2.3模式3:accept + 多线程 —— 每来一个连接就创建一个线程处理

void *client_thread(void *arg) {

	int clientfd = *(int *)arg;

	while (1) {
		
		char buffer[1024] = {0};
		int count = recv(clientfd, buffer, 1024, 0);
		if (count == 0) { // disconnect
			printf("client disconnect: %d\n", clientfd);
			close(clientfd);
			break;
		}
		// parser
		
		printf("RECV: %s\n", buffer);

		count = send(clientfd, buffer, count, 0);
		printf("SEND: %d\n", count);

	}

}

//模式3:
while (1) {
        printf("accept\n");
        int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
        printf("accept finished: %d\n", clientfd);

        pthread_t thid;
        pthread_create(&thid, NULL, client_thread, &clientfd);
    }

 3 是 “监听套接字” 的编号,4、5... 是 “客户端连接套接字” 的编号,两者是不同类型的套接字

    在客户端断开连接时,fd会被回收,重新连接时,会按fd递增次序重新分配一个fd

修改完代码已经不会发生阻塞了,但是只能实现一请求一线程

其弊端:

  • 线程资源消耗大

  • 并发能力受限

  • 线程调度开销高

2.4模式4:使用 select 实现 I/O 多路复用

支持多个客户端同时连接和通信(单线程)

通俗的例子:

  • 「一请求一线程」:像餐厅里来了 100 个顾客,就雇 100 个服务员,每个服务员只盯一个顾客。人少的时候还好,人多了(比如 1000 个顾客),雇 1000 个服务员会占满餐厅(耗内存),服务员之间抢着干活(CPU 调度忙),最后没人能高效服务。

  • 「select 模式」:还是 100 个顾客,只雇 1 个服务员(单线程 / 少线程),这个服务员先站在门口(select),同时盯着所有顾客 —— 哪个顾客举手要点餐(IO 可读 / 可写),就过去服务这个顾客;没顾客举手时,服务员就站着等(阻塞在 select),不瞎忙活。

 fd_set rfds, rset;              // rfds: 所有要监控的读集合;rset: 每次调用 select 修改的副本
    FD_ZERO(&rfds);                 // 清空集合
    FD_SET(sockfd, &rfds);          // 将监听 socket 加入监控集合

    int maxfd = sockfd;             // 当前最大的 fd 编号,用于 select 第一个参数

    while (1) {
        rset = rfds;  // select 会修改集合内容,所以每次都要复制原始集合

        // 阻塞等待任意 fd 就绪(可读)
        int nready = select(maxfd + 1, &rset, NULL, NULL, NULL);

        // 如果是监听 socket 就绪,说明有新连接到来
        if (FD_ISSET(sockfd, &rset)) {
            int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
            printf("accept finished: %d\n", clientfd);

            FD_SET(clientfd, &rfds);  // 将新的客户端 socket 加入监控集合

            if (clientfd > maxfd) maxfd = clientfd;  // 更新最大 fd
        }

        // 遍历所有可能的 fd(从 sockfd+1 开始),检查是否有客户端数据到达
        for (int i = sockfd + 1; i <= maxfd; i++) {
            if (FD_ISSET(i, &rset)) {  // 该 fd 有数据可读
                char buffer[1024] = {0};
                int count = recv(i, buffer, 1024, 0);

                if (count == 0) {  // 客户端断开连接
                    printf("client disconnect: %d\n", i);
                    close(i);
                    FD_CLR(i, &rfds);  // 从集合中移除
                    continue;
                }
                printf("RECV: %s\n", buffer);
                count = send(i, buffer, count, 0);
                printf("SEND: %d\n", count);
            }
        }
    }

因为select是从0开始检查,比如要检查0~7,那么就是要检查8个fd(maxfd + 1)

假如新来的clientfd大于maxfd那么就要更新maxfd.

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

timeout为NULL一直阻塞;

nready返回的是有几个fd就绪,select会把rset已就绪的fd标记好,其他都去除掉

在调用select后,系统会把没有触发事件的fd从集合中移除,只保留触发了事件的fd。因此每次调用select,应该把rfds集合从用户空间copy到内核空间

FD_ISSET(sockfd, &rset);检查sockfd在rset中是否触发了事件,在代码中sockfd是监听事件,触发可读事件说明有新连接了,那么就应该执行accept逻辑。

select相关宏定义:

FD_ZERO(&rfds);

FD_SET(sockfd,&rfds);

FD_ISSET(sockfd,&rset);

FD_CTL(i,&rfds)。

select优点:实现io多路复用。缺点:参数太多,麻烦,因此可以进一步使用poll来处理。

2.5模式 5:使用 poll 实现 I/O 多路复用

struct pollfd {
    int   fd;         // 文件描述符
    short events;     // 希望监听的事件(输入)
    short revents;    // 实际发生的事件(输出)
};

fd:要监控的目标(比如客户端连接的 clientfd、服务器监听的 sockfd);如果设为 -1poll 会忽略这个结构体(可用于占位或删除监控)。

events:我们想监控的事件(输入参数,自己设置),支持多种事件组合

revents:实际发生的事件(输出参数,poll 函数返回后才有效),poll 会根据 fd 的状态填充,比如 “真的有数据可读” 就会置位对应标志。
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

返回值:成功=发生事件的 fd 数量;失败=-1(如被信号中断);超时=0

fds:指向 “监控清单数组” 的指针(每个元素是 struct pollfd,描述 “盯哪个 fd + 盯什么事件”):

nfds:监控清单的长度(数组里有多少个 struct pollfd 元素,必须准确,不能多也不能少)

timeout:超时时间(单位:毫秒):- 正数:等待 timeout 毫秒后返回(没事件也返回);- 0:不阻塞,立即返回(不管有没有事件);- -1:无限阻塞(直到有事件发生才返回)

fds[sockfd].revents & POLLIN

  1. revents 是 poll 函数的 “输出结果”——poll 监控完所有 fd 后,会给每个 fds[i].revents 赋上 “这个 fd 实际发生的事件”(用二进制标志位表示);

  2. POLLIN 是一个 “事件标志宏”(本质是一个整数,比如系统定义为 0x001,二制 00000001),代表 “有数据可读”。

  3. “按位与(&)” 的逻辑很简单:两个二进制位都为 1 时,结果才是 1;否则是 0。用这个逻辑检测:如果 revents & POLLIN 的结果不是 0,就说明 “可读开关” 闭合了(POLLIN 事件真的发生了)。


    struct pollfd fds[1024] = {0};           // 定义 pollfd 数组,最多监控 1024 个 fd
    fds[sockfd].fd = sockfd;                 // 设置监听 socket
    fds[sockfd].events = POLLIN;             // 监听可读事件

    int maxfd = sockfd;                      // 记录当前最大 fd 索引

    while (1) {
        // 阻塞等待事件发生(-1 表示无限等待)
        int nready = poll(fds, maxfd + 1, -1);

        // 检查监听 socket 是否就绪
        if (fds[sockfd].revents & POLLIN) {
            int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
            printf("accept finished: %d\n", clientfd);

            // 添加新客户端到 poll 数组
            fds[clientfd].fd = clientfd;
            fds[clientfd].events = POLLIN;

            if (clientfd > maxfd) maxfd = clientfd;  // 更新最大索引
        }

        // 遍历所有客户端 fd
        for (int i = sockfd + 1; i <= maxfd; i++) {
            if (fds[i].revents & POLLIN) {  // 可读事件触发
                char buffer[1024] = {0};
                int count = recv(i, buffer, 1024, 0);

                if (count == 0) {  // 断开连接
                    printf("client disconnect: %d\n", i);
                    close(i);
                    fds[i].fd = -1;        // 标记为空闲槽位
                    fds[i].events = 0;
                    continue;
                }

                printf("RECV: %s\n", buffer);
                count = send(i, buffer, count, 0);
                printf("SEND: %d\n", count);
            }
        }
    }

poll 和 select 的核心逻辑一致(都靠 “遍历” 检测事件),高并发下性能拉胯

每次调用 poll,都要把整个 struct pollfd 数组(全量监控的 fd 信息)拷贝到内核态,监控的 fd 越多,拷贝的数据量越大。比如监控 10 万个 fd,数组占用约 10 万 × 8 字节 = 800KB,每次调用都要拷贝 800KB,频繁调用时拷贝开销巨大,所以引入epoll

2.6模式 6:使用 epoll 实现高性能 I/O 多路复用

效率最高,适用于高并发场景

epoll 操作围绕 “创建实例→管理监控对象→等待事件” 三个步骤,对应三个核心函数

  1. 创建 epoll 实例(epoll_create)
  2. 管理监控的 fd(epoll_ctl)
  3. 等待就绪事件(epoll_wait)

1.int epoll_create(int size);

在内核中创建一个 “epoll 事件表”(用来存储要监控的 fd 和事件),返回一个 epoll 实例的 fd(类似 socket() 返回的 sockfd)

2.int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

1.epfd:epoll_create 返回的实例 fd

2.op(操作类型):

  • EPOLL_CTL_ADD:添加 fd 到 epoll 事件表(新监控一个 fd);

  • EPOLL_CTL_DEL:从事件表中删除 fd(停止监控);

  • EPOLL_CTL_MOD:修改 fd 的监控事件(比如从 “只读” 改成 “可读可写”);

3.fd:要监控的目标 fd(sockfd 或 clientfd)

4.struct epoll_event(事件结构体):

    uint32_t events;  // 要监控的事件(和 poll 类似,用标志位)

    epoll_data_t data;// 附加数据(通常存 fd,方便后续处理)

  • EPOLLIN:有数据可读(客户端发数据、新连接);

  • EPOLLOUT:可以写数据(发送缓冲区空闲);

3.int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

  1. epfd:epoll 实例 fd
  2. events:存储就绪事件的数组(内核填充)
  3. maxevents:events 数组的最大长度(不能超过监控的 fd 总数)
  4. timeout:超时时间(毫秒):-1=无限阻塞,0=非阻塞,>0=超时时间

核心优势:返回的 events 数组只包含 “有事件的 fd”,用户态直接处理即可,不用遍历全量 fd。

  • 在 “添加 / 删除 fd”(希望内核监听的事件) 时(epoll_ctl)拷贝 1 次 fd 信息到内核(内核维护事件表),后续调用 epoll_wait 时,只拷贝 “就绪的 fd 信息”(“内核检测到的实际发生的事件”)。比如 10 个就绪 fd,只拷贝 10 个 struct epoll_event(约 80 字节),拷贝开销可忽略
  • 除了支持水平触发模式( LT 模式),还支持 边缘触发(ET 模式)——fd 从 “未就绪” 变为 “就绪” 时,只返回 1 次(哪怕数据没读完,后续也不返回,直到有新数据)。这减少了内核和用户态的交互次数,进一步提升效率(比如 2048 字节数据,ET 模式只返回 1 次,循环读完,LT 模式可能返回 2 次,读两次),是高并发场景的首选模式。
    int epfd = epoll_create(1);  // 创建 epoll 实例,参数旧版有用,现在一般写 1

    struct epoll_event ev;
    ev.events = EPOLLIN;         // 监听可读事件
    ev.data.fd = sockfd;         // 用户数据:保存监听 socket
    epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);  // 将监听 socket 添加到 epoll

    while (1) {
        struct epoll_event events[1024] = {0};  // 存放就绪事件的数组
        // 等待事件发生(阻塞)
        int nready = epoll_wait(epfd, events, 1024, -1);

        // 遍历所有就绪事件
        for (int i = 0; i < nready; i++) {
            int connfd = events[i].data.fd;  // 获取触发事件的 socket

            if (connfd == sockfd) {
                // 是监听 socket 触发 → 有新连接
                int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
                printf("accept finished: %d\n", clientfd);

                // 将新客户端加入 epoll 监控
                ev.events = EPOLLIN;
                ev.data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
            } 
            else if (events[i].events & EPOLLIN) {
                // 是客户端 socket 触发 → 有数据可读
                char buffer[1024] = {0};
                int count = recv(connfd, buffer, 1024, 0);

                if (count == 0) {
                    // 客户端断开连接
                    printf("client disconnect: %d\n", connfd);
                    close(connfd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);  // 从 epoll 删除
                    continue;
                }

                printf("RECV: %s\n", buffer);
                count = send(connfd, buffer, count, 0);
                printf("SEND: %d\n", count);
            }
        }
    }
#endif

假设客户端发了 2048 字节数据,服务器缓冲区一次只能存 1024 字节:

  • LT 模式
    1. 数据第一次到达(缓冲区有 1024 字节),epoll_wait 返回 “可读事件”,服务器读 1024 字节;
    2. 缓冲区还剩 1024 字节(fd 仍处于 “就绪状态”),下次调用 epoll_wait 会再次返回 “可读事件”,服务器再读 1024 字节;
    3. 两次 epoll_wait 触发 + 两次用户态处理,内核和用户态交互了 2 次。
  • ET 模式
    1. 数据第一次到达(缓冲区从空→有数据),epoll_wait 只返回 1 次 “可读事件”;
    2. 服务器必须在这次触发中,用循环把 2048 字节(分两次读 1024)全部读完;
    3. 只触发 1 次 epoll_wait,内核和用户态只交互 1 次,没有重复触发。

https://github.com/0voice

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值