当前位置: 首页 > news >正文

WebServer05

写一个小demo:回声服务器,以复习一下上次学习的socket:

这次我们在vim上写代码。

安装vim并给vim做一个基础配置:

sudo apt update
sudo apt install vim -y
​
#打开配置文件,如果没有会自动新建
vim ~/.vimrc

然后按i进入插入模式,配置:

  1 set number          " 显示行号2 set syntax=cpp      " C++ 语法高亮3 set tabstop=4       " Tab=4个空格4 set shiftwidth=4    " 自动缩进4个空格5 set autoindent      " 自动缩进6 set smartindent7 set nowrap          " 不自动换行8 set cursorline      " 高亮当前行9 set hlsearch        " 搜索高亮10 set ignorecase      " 搜索不区分大小写11 set completeopt=12 set noomnifunc13 " 自动补全括号:输入左括号自动补右括号,并把光标停在中间14 inoremap ( ()<Left>15 inoremap { {}<Left>16 inoremap [ []<Left>17 inoremap " ""<Left>18 inoremap ' ''<Left>19 " 输入 { 后按回车,自动换行并缩进20 inoremap <expr> { '{' . "\n\n" . '}' . "\<C-o>k\<C-o>A"         

按Esc回到普通模式,输入:wq回车,保存并退出

从test.cpp的根目录进入终端,输入:

vim test.cpp

按回车再输入i,如果下面显示Insert就没问题

用vim新建文件:

vim echo_server.cpp

可以开始写代码了

操作场景操作步骤
新建 / 打开文件终端输入:vim echo_server.cpp终端直接敲命令
开始写代码(进入编辑)打开文件后,按 i(左下角显示 -- INSERT --)按 i 进入插入模式
暂停编辑(回到普通模式)Esc 键(左下角 -- INSERT -- 消失)Esc 退出插入模式
保存文件(不退出)普通模式下,输入 :w 回车:w = write(写)
保存并退出普通模式下,输入 :wq 回车:wq = write + quit
不保存强制退出普通模式下,输入 :q! 回车(写错代码不想保存):q! = quit 强制
查找代码(比如找 socket)普通模式下,输入 /socket 回车(n 下一个,N 上一个)/ + 关键词
删除一行代码普通模式下,光标移到目标行,按 dddd = delete line
复制一行代码普通模式下,光标移到目标行,按 yyyy = yank line
粘贴代码普通模式下,按 p(粘贴到光标下一行)p = paste

写代码时,先按 i 进入插入模式,光标用方向键移动(和记事本一样)

想保存 / 删除 / 复制时,必须先按 Esc 回到普通模式,再敲对应命令

忘了命令就按 Esc 回到普通模式,输入 :help 回车看帮助

可以使用man 2 bind可以查看bind的用法,man 2表示查看系统调用类API

man 3表示查看库函数。如果不确定是2还是3,直接man即可。

如果想要删除多行,把光标放在要删除的第一行,按v进入可视模式,然后按下箭头即可选中多行,最后按d删除。

接下来开始写server.cpp,分析一下思路:

首先我们要指定端口和收发信息的缓冲区大小。然后创建socket,绑定ip地址与端口,开始监听,accept,read,最后发送回声消息。

server.cpp:

#include<iostream>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<cstring>
​
const int PORT=8080;
const int BUFFER_SIZE=1024;
​
int main(){//1.创建socketint server_fd=socket(AF_INET,SOCK_STREAM,0);if(server_fd<0) {std::cerr<<"无法创建socket"<<std::endl;return 0;}
​//2.绑定IP地址与端口struct sockaddr_in server_addr{};//需要<netinet/in.h>头文件,否则只有sockaddrserver_addr.sin_family=AF_INET;//ipv4server_addr.sin_port=htons(PORT);//端口号,本机字节序转换为网络传输字节序server_addr.sin_addr.s_addr=INADDR_ANY;//设置IP地址,网络接口任意
​if(bind(server_fd,(sockaddr*)&server_addr,sizeof(server_addr))<0) {//理解sizeof(server_addr)为什么变成socklen_t类型以及sockaddr与sockaddr_in的关系std::cerr<<"绑定失败"<<std::endl;close(server_fd);//及时关闭,以免浪费资源return 0;}
​//3.开始监听if(listen(server_fd,10)<0) {//已完成连接队列的长度为10std::cerr<<"监听失败"<<std::endl;close(server_fd);return 0;}
​//走到这里说明监听成功std::cout<<"服务器开始监听,端口为"<<PORT<<std::endl;
​//4.有来自客户端的连接,将其放入已完成连接队列中struct sockaddr_in client_addr{};//存储客户端信息socklen_t client_len=sizeof(client_addr);int client_fd=accept(server_fd,(sockaddr*)&client_addr,&client_len);if(client_fd<0){std::cerr<<"接受连接失败"<<std::endl;//这里没有循环,关闭server_fd,后续可以放进循环里面,可处理多个客户端信息close(server_fd);return 0;}//5.显示客户端信息char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET,&(client_addr.sin_addr),client_ip,INET_ADDRSTRLEN);//将二进制ip转换为字符串std::cout<<"客户端ip:"<<client_ip<<std::endl;std::cout<<"端口:"<<ntohs(client_addr.sin_port)<<std::endl;
​//6.与客户端通信char buffer[BUFFER_SIZE];//缓冲区大小,用于存放消息while(1){//持续读取客户端信息//因为是循环,所以每次回到循环开头都要清空缓冲区memset(buffer,0,BUFFER_SIZE);int read_bytes=read(client_fd,buffer,BUFFER_SIZE-1);//最多只能读BUFFERSIZE-1个字符,最后要留一个位置给/0if(read_bytes<=0) {if(read_bytes<0) {std::cerr<<"读取客户端信息失败"<<std::endl;} else {//read_bytes==0,意味读到了EOF,结束std::cout<<"客户端终止了通信"<<std::endl;}break;//不管是上面两种情况的哪一种,都要break}//走到这里意味着读到了信息,打印出来即可std::cout<<"收到客户端信息:"<<buffer;//回声int send_bytes=send(client_fd,&buffer,read_bytes,0);if(send_bytes<0) {std::cerr<<"发送回声消息失败"<<std::endl;break;}std::cout<<"成功发送"<<std::endl;}close(client_fd);close(server_fd);std::cout<<"服务端已关闭"<<std::endl;return 0;
}
 

犯错1:inet_ntop的client_ip多加了&,报错:cannot convert ‘char (*)[16]’ to ‘char*’

前者是数组指针,即指向16个char组成的数组的指针。后者是指向单个char变量的指针,因为数组名转换为首元素地址。

犯错2:int send_bytes=send(server_fd,&buffer,read_bytes,0);

第一个参数应该是client_fd,因为server_fd是监听用的文件描述符,只负责等客户端连接,不负责和具体客户端通信。client_fd则是和单个客户端连接的文件描述符,专门负责与这个客户端收发数据。

我们捋一下整个流程:

1.调用socket()创建server_fd,这是一个监听socket,内核会为它分配一个内核对象,包含未完成连接队列与已完成连接队列

2.调用bind()+listen(),告诉内核:让server_fd绑定8080端口,开始监听客户端连接请求。此时内核为server_fd初始化两个队列。

未完成连接队列:客户端发起SYN握手,但三次握手还没完成的连接。比如客户端发了SYN,服务端还没回ACK+SYN

已完成连接队列:三次握手全部完成,TCP连接已建立的连接

2.客户端调用connect()发送SYN包,服务端内核收到后将这个半连接放到未完成连接队列。服务端内核回复ACK+SYN,完成第二次握手。客户端回复ACK,三次握手完成,内核将这个全连接从未完成队列移动到已完成连接队列,此时连接已就绪,等待服务端处理。

3.服务端调用accept,告诉内核从server_fd的已完成连接队列中取一个就绪的连接。内核收到accept调用后,从已完成连接队列中取出第一个连接,为这个连接创建一个新的文件描述符client_fd,并把它与这个连接绑定。accept返回client_fd,此时拿到的client_fd是服务端和该客户端通信的唯一句柄

4.用client_fd调用read()或send(),内核知道要和这个client_fd绑定的客户端收发数据。而server_fd继续留在原地,监听新的连接请求。

接着编写client.cpp:

首先同样指定端口和缓冲区大小。创建socket,绑定IP地址与端口,连接服务端,发送消息,接收回声消息。

client.cpp:

#include<iostream>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<netinet/in.h>
#include<cstring>
​
const int PORT=8080;
const int BUFFER_SIZE=1024;
​
int main() {//1.创建Socketint sockfd=socket(AF_INET,SOCK_STREAM,0);if(sockfd<0) {std::cerr<<"创建socket失败"<<std::endl;return 0;}
​//2.确定要发送的服务端struct sockaddr_in serv_addr{};serv_addr.sin_family=AF_INET;serv_addr.sin_port=htons(PORT);//将服务端IP地址转换为二进制if(inet_pton(AF_INET,"127.0.0.1",&serv_addr.sin_addr)<0) {std::cerr<<"IP地址无效"<<std::endl;close(sockfd);return 0;}
​//走到这里,已经确定好了目标
​//3.连接服务端if(connect(sockfd,(sockaddr*)&serv_addr,sizeof(serv_addr))<0) {std::cerr<<"连接服务端失败"<<std::endl;close(sockfd);return 0;}//成功建立连接,准备收发消息std::cout<<"成功与服务端建立连接,输入quit可退出"<<std::endl;
​//4.发消息+接收回声消息char buffer[BUFFER_SIZE];std::string input;while(1) {std::cout<<" > ";//表示接收客户端输入的信息std::cin>>input;if(input == "quit") {break;}input += '\n';//方便服务端确认一行消息已经结束,避免粘包//开始发送消息int send_bytes=send(sockfd,input.c_str(),input.length(),0);if(send_bytes<0) {std::cerr<<"发送消息失败"<<std::endl;break;}//发送成功,准备接收服务端的回声消息memset(buffer,0,BUFFER_SIZE);//清空缓冲区,为新来的消息腾出空间int recv_bytes=read(sockfd,buffer,BUFFER_SIZE-1);if(recv_bytes <= 0 ) {if(recv_bytes == 0) {std::cout<<"服务端断开了连接"<<std::endl;} else {std::cerr<<"读取回声消息失败"<<std::endl;}break;}
​//成功读取回声消息,打印出来std::cout<<"服务端发送回声消息:"<<buffer;}
​//5.关闭连接close(sockfd);std::cout<<"连接关闭"<<std::endl;return 0;
}

几个点:

1.如果socket没有创建成功,是否需要close?

不需要。当socket创建失败时,socket()返回-1,而-1并不是一个有效的文件描述符。那么close(-1)也会失败并设置errno为EBADF(错误的文件描述符),但不会造成危害。可以这样:

int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd < 0) {std::cerr << "无法创建Socket: " << strerror(errno) << std::endl;return 0; // 直接返回,不需要close
}
// 只有创建成功时才需要在后续close

2.如何理解INADDR_ANY与网络接口?

网络接口就是计算机连接网络的门户,就好比你家有前门、后门、车库门。INADDR_ANY相当于:我不关心快递放在哪个门,只要是发给我的,我都接收。

3.关于htons与ntohs

网络传输使用的是大端序(TCP协议规定,即数字的高位字节存储在内存的低地址处),而像x86/ARM架构使用小端序(即数字的高位字节存储在内存的高地址处)。htons,即host to network short,它能将主机字节序转换为网络字节序。ntohs反之。

❓ 追问:为什么是端口号需要进行htons或ntohs处理而不是发送的信息?

答:端口号的解析者是操作系统内核的TCP/IP协议栈。而应用数据的解析者是应用程序本身。这意味着端口号必须有一个统一的、明确的格式,确保所有操作系统的协议栈都能正确理解。应用数据则由通信双方自行约定。

4.server_addr.sin_addr 与 server_addr.sin_addr.s_addr

这是一个结构体嵌套:

struct in_addr {in_addr_t s_addr; // 32位IPv4地址(本质是uint32_t)
};
​
struct sockaddr_in {// ... 其他字段struct in_addr sin_addr; // IPv4地址结构
};

所以,server_addr.sin_addr实际上还是一个结构体,我们通常直接操作server_addr.sin_addr.s_addr字段来设置IP地址

5.listen函数的第二个参数的含义

它指定了已完成连接队列的最大长度。在三次握手中,客户端发送SYN,服务端进入SYN-RCVD状态,就将连接放入半连接队列。完成三次握手后,就移入已完成连接队列。最终accept函数从已完成连接队列中取出连接。listen函数的第二个参数控制了已完成连接队列的大小,如果队列满了,那么新的连接就会被拒绝或忽略。

6.为什么accept函数需要client_len?

accept()做了三件事:从已完成连接队列中取出第一个连接、创建一个新的socket文件描述符用于这个具体连接、填充客户端地址信息到client_addr缓冲区。client_len告诉内核在填充的时候不能写入超过client_len的数据,防止缓冲区溢出。

7.inet_ntop()的作用

它将二进制的IP地址转换为可读的字符串形式:

struct sockaddr_in client_addr;
// ... accept() 填充了client_addr ...
​
char client_ip[INET_ADDRSTRLEN]; // INET_ADDRSTRLEN定义为16
inet_ntop(AF_INET, &(client_addr.sin_addr), client_ip, INET_ADDRSTRLEN);
​
// 现在client_ip里是"192.168.1.100"这样的字符串

相反的函数是inet_ptop(),即字符串->二进制

如果我们再开一个终端,运行client,是可以与服务端建立连接的,但是服务端却没有显示IP客户端的IP地址,新开的客户端向服务端发送消息,服务端也接收不到。并且,客户端在输入一条信息之后似乎卡死了,没有显示>符号,这是因为新开的客户端与服务端完成三次握手后放入了已完成连接队列中,但accept没有放在循环里,因此服务端只会从已完成连接队列中取出一次连接,后面即使有新的连接也取不出来了。而客户端connect成功后会进入while循环,然后一直阻塞在read函数。

解决方法:将accept放在while循环里,然后while循环内又有一个while循环负责循环读取客户端发送的信息并回复。但是又衍生出了新的问题:串行处理。即如果上一个客户端不输入quit或按下Ctrl+C强制退出,那么下一个客户端的连接就始终会置于已完成连接队列中,并不会被拿出来进行处理。

解决方案一:开多线程

主线程只负责到accept,取出连接后就交给子线程去处理读写的任务。

server.cpp

#include<iostream>
#include <ostream>
#include<pthread.h>
#include<unistd.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<string.h>
#include<arpa/inet.h>
​
#define PORT 8080
#define BUFFER_SIZE 1024
​
void *handle_client(void *arg) {int client_fd=*(int*)arg;char buffer[BUFFER_SIZE];struct sockaddr_in client_addr=*(struct sockaddr_in*)((int*)arg+1);free(arg);
​char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET,&client_addr.sin_addr,client_ip,INET_ADDRSTRLEN);uint16_t client_port=ntohs(client_addr.sin_port);
​while(1) {memset(buffer,0,BUFFER_SIZE);ssize_t read_bytes=read(client_fd,buffer,BUFFER_SIZE-1);if(read_bytes<=0) {if(read_bytes<0) {std::cerr<<"[客户端"<<client_ip<<":"<<client_port<<"] 读取失败"<<std::endl;} else {std::cout<<"[客户端"<<client_ip<<":"<<client_port<<"] 断开连接"<<std::endl;}break;}
​std::cout<<"[客户端"<<client_ip<<":"<<client_port<<"] 收到消息:"<<buffer;ssize_t send_bytes=send(client_fd,buffer,read_bytes,0);if(send_bytes<0) {std::cerr<<"[客户端"<<client_ip<<":"<<client_port<<"] 发送回声消息失败"<<std::endl;break;} else {std::cout<<"[客户端"<<client_ip<<":"<<client_port<<"] 发送回声消息成功"<<std::endl;}}close(client_fd);pthread_exit(nullptr);
}
​
int main() {//1.创建监听socketint server_fd=socket(AF_INET,SOCK_STREAM,0);if(server_fd<0) {std::cerr<<"创建监听socket失败"<<std::endl;return 0;}
​//2.绑定IP与端口struct sockaddr_in server_addr{};server_addr.sin_family=AF_INET;server_addr.sin_addr.s_addr=INADDR_ANY;server_addr.sin_port=htons(PORT);
​if(bind(server_fd,(sockaddr*)&server_addr,sizeof(server_addr))<0) {std::cerr<<"绑定IP与地址失败"<<std::endl;close(server_fd);return 0;}
​//3.开始监听if(listen(server_fd,10)<0) {std::cerr<<"监听失败"<<std::endl;close(server_fd);return 0;}
​std::cout<<"服务端开始监听,端口号为:"<<PORT<<std::endl;
​while(1) {//主线程持续接收连接struct sockaddr_in client_addr{};socklen_t client_len=sizeof(client_addr);int client_fd=accept(server_fd,(sockaddr*)&client_addr,&client_len);if(client_fd<0) {std::cerr<<"接受连接失败"<<std::endl;continue;}
​//打印客户端信息char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET,&client_addr.sin_addr,client_ip,INET_ADDRSTRLEN);std::cout<<"\n新客户端连接:"<<std::endl;std::cout<<"ip:"<<client_ip<<" port:"<<ntohs(client_addr.sin_port)<<std::endl;
​//创建子线程,用来收发消息void* arg=malloc(sizeof(int)+sizeof(struct sockaddr_in));*(int*)arg=client_fd;memcpy((int*)arg+1,&client_addr,sizeof(struct sockaddr_in));
​pthread_t tid;if(pthread_create(&tid,nullptr,handle_client,arg)!=0) {std::cerr<<"创建子线程失败"<<std::endl;close(client_fd);free(arg);}
​pthread_detach(tid);}close(server_fd);return 0;
}

client.cpp保持不变

几个点:

一、arg是什么?

首先明确一点:pthread_create的规则是:子线程函数只能接收一个参数。但是现在我们既要得到客户端信息,又要得到用来通信的socket,因此我们需要将这两个参数打包。这就是arg存在的意义。

arg本质上是一块用malloc申请的连续的动态内存,里面按顺序存放了client_fd(整数)和client_addr(结构体)两种数据。因为是不同的数据类型,所以它是无类型指针,即void*

❓为什么不将其设置为全局变量?

答:多线程之间可能互相干扰。主线程刚把client_fd这个全局变量设置为5,还没创建子线程,又accept了一个新的连接,迅速将其设置为了6,就和第二个连接冲突了

二、赋值

client_addr是一个结构体,包含多个字段,不能直接用等号赋值到指针指向的内存。因此需要使用memcpy

(int*)arg是第一个数据client_fd的地址,+1表示“跳过第一个数据的大小”,即+1后表示存放client_addr的首地址

(int*)arg+1为什么不能写成arg+1呢?因为arg本身还是void*void*类型不允许做运算。

❓ 为什么(int*)arg可以呢?如果要打包的数据是一个整数、一个结构体、一个整数,那(int*)arg怎么知道是第一个还是第三个?这两个都是整数啊

答:怎么打包就怎么拆包。我们是打包的人,自然知道怎么拆包。

//打包
void* arg = malloc(sizeof(int) + sizeof(struct B) + sizeof(int));
*(int*)arg = A;  
memcpy((int*)arg + 1, &B, sizeof(struct B));  
*(int*)((char*)arg + sizeof(int) + sizeof(struct B)) = C;
​
//拆包
int A = *(int*)arg;  //按约定顺序,先拿A
struct B = *(struct B*)((int*)arg + 1);  //再拿B
int C = *(int*)((char*)arg + sizeof(int) + sizeof(struct B));  //最后拿C

这里的(char*)arg怎么理解?

指针+n的偏移字节数=n×指针指向类型的大小,也就是说 char*指针+1 表示偏移一个字节。sizeof(int)表示第一个整数的大小,sizeof(struct B)表示结构体的大小,因此再往后偏移一个字节就是存放C的起始地址。

为什么赋值一定要是*(int*)arg=client_fd而不能是int* arg=&client_fd?因为后者是将arg指针指向client_fd的地址,而我们是新开辟空间去打包,因此要关注的是内容,而不是地址,所以要解引用。而且,client_fd是主线程的栈变量,栈内存的生命周期是函数作用域,如果主线程后续执行其它操作把这个栈地址释放了,子线程可能就会出现野指针错误。

三、类型转换

int client_fd=*(int*)arg;

(int*)是将原本为通用类型的arg指针转换为(int*),如果不转换编译器不知道它指向的内存里存的是什么。转换之后它还是一个地址,因此我们要解引用再将它赋值给client_fd

四、ssize_tsize_t

特性size_tssize_t
符号性无符号(unsigned)有符号(signed)
取值范围≥ 0(只能表示非负数)-1、0、正数(能表示负数)
设计用途表示「大小 / 长度 / 索引」(比如数组长度、内存块大小)表示「字节数 / 返回值」(比如读写的字节数、函数执行结果)
典型使用场景1. sizeof() 的返回值;2. 数组索引(arr[i]i);3. 内存分配(malloc 的参数);4. 循环计数(无负数值的计数)1. read()/write()/send()/recv() 的返回值;2. 函数返回 “成功字节数” 或 “错误标识”
底层关联类型(Linux)通常是 unsigned long(64 位系统)通常是 long(64 位系统)
关键作用确保表示的大小不会出现负数(符合 “大小” 的物理意义)-1 表示函数执行失败(比如 read 读失败返回 -1

例:如果你用size_t接收read()返回的参数,那可能就会把返回的-1转换成size_t的最大值。

五、pthread_t tid

它是线程的ID,操作系统通过tid识别不同线程,我们也可以通过tid做线程相关操作,比如pthread_detachpthread_cancel等。我们只需要声明它,在pthread_create中系统会给它赋值,这也是为什么我们要传递&tid而不是tid,因为要切实改变tid本身的值,而不是创建副本。

六、pthread_detach

pthread_detach表示主子线程分离,主线程不需要等待tid对应的子线程结束才结束。pthread_join反之,主线程必须等待tid对应的子线程结束才结束。后者适用于主线程需要子线程返回的结果。在这里我们用的是前者,因为主线程只是用来accept的,子线程只是用来收发消息的,子线程收发和主线程accept互不影响。如果用的是pthread_join,那么主线程必须等待子线程结束才accept,那处理消息的效率就很低了,因为又变成了串行处理。

detach是结束后系统回收资源,join是主线程回收资源。如果既不detach也不join,那么子线程会变成“僵尸线程”,占用系统资源,无法释放。

需要注意的是,在C++中使用的是std::thread,而非我们前面使用的pthread库。前者是线程对象,后者只是线程ID,因此对于std::thread来说会有既不能join也不能detach的情形:

  1. 默认构造的thread,即没有绑定任何可执行的线程

  2. 已经被join或者detach的线程。如果再次join或者detach,就会抛出system_error

  3. 被移动过的thread (所有权已转移)。原来的对象不再管理线程。

  4. 线程对象已经被销毁

但对于C来说没有这回事,因为pthread_t只是线程ID,不是管理对象,因此:

1.没有 “默认构造”“移动” 的概念(pthread_t 是简单类型,不是类)

2.不可以对同一个 pthread_t 多次调用 pthread_join()。第一次 pthread_join() 会阻塞到线程结束,第二次调用会返回错误(ESRCH:没有这个线程),但不会抛出异常(C 语言没有异常机制,通过返回值提示错误)

3.可以对同一个 pthread_t 多次调用 pthread_detach()。第一次调用会分离线程,后续多次调用会返回错误(EINVAL:线程已分离),但不会崩溃

我们使用cpp风格重写一遍server.cpp

#include<iostream>
#include<cstring> 
#include<mutex> //
#include<thread> //
#include<memory> //
#include<sys/socket.h>
#include<netinet/in.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<system_error>
​
constexpr int PORT 8080
constexpr int BUFFER_SIZE 1024
​
//全局互斥锁,保护多线程对cout的并发访问
std::mutex cout_mutex;
​
//封装客户端数据,替代void*打包
struct ClientData {int client_fd; //和客户端通信的socketstruct sockaddr_in client_addr; //客户端地址信息
};
​
class EchoServer {
private:int server_fd;//监听socketuint16_t port;//监听端口
​void print_client_info(const ClientData* data,const std::string& title) {char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET, &(data->client_addr.sin_addr), client_ip, INET_ADDRSTRLEN);uint16_t client_port = ntohs(data->client_addr.sin_port);
​std::lock_guard<std::mutex> lock(cout_mutex);std::cout << "\n========== " << title << " ==========\n";std::cout << "客户端IP: " << client_ip << "\n";std::cout << "客户端Port: " << client_port << "\n";std::cout << "=================================\n";}
​void handle_client(std::unique_ptr<ClientData> client_data) {int client_fd=client_data->client_fd;auto& client_addr=client_data->client_addr;char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET,&(client_addr.sin_addr),client_ip,INET_ADDRSTRLEN);uint16_t client_port=ntohs(client_addr.sin_port);
​std::string buffer;buffer.reserve(BUFFER_SIZE);
​while(1) {char raw_buf[BUFFER_SIZE];ssize_t read_bytes=read(client_fd,raw_buf,BUFFER_SIZE);if(read_bytes <= 0) {{std::lock_guard<std::mutex>lock(cout_mutex);if(read_bytes == 0) {std::cout << "客户端[" << client_ip << ":" << client_port << "]已关闭连接\n";} else {std::cerr << "读取客户端[" << client_ip << ":" << client_port << "]消息失败: " << std::strerror(errno) << "\n";}}close(client_fd);break;}buffer.assign(raw_buf,read_bytes);{std::lock_guard<std::mutex>lock(cout_mutex);std::cout << "收到客户端[" << client_ip << ":" << client_port << "]消息: " << buffer << "\n";}
​ssize_t sent_bytes=send(client_fd,buffer.c_str(),buffer.length(),0);if(sent_bytes < 0) {std::lock_guard<std::mutex>lock(cout_mutex);std::cerr << "发送回声消息到客户端[" << client_ip << ":" << client_port << "]失败: " << std::strerror(errno) << "\n";close(client_fd);break;}}close(client_fd);print_client_info(client_data.get(),"客户端连接关闭");}  
​
public:EchoServer(uint16_t port):port(PORT),server_fd(-1){};
​//启动服务器void start() {//1.创建监听socketserver_fd=socket(AF_INET,SOCK_STREAM,0);if(server_fd < 0) {throw std::system_error(errno,std::generic_category(),"创建监听socket失败");}
​//2.绑定地址和端口struct sockaddr_in server_addr;server_addr.sin_family=AF_INET;server_addr.sin_addr.s_addr=INADDR_ANY;server_addr.sin_port=htons(PORT);if(bind(server_fd,(sockaddr*)&server_addr,sizeof(server_addr)) < 0) {close(server_fd);throw std::system_error(errno,std::generic_category(),"绑定地址和端口失败");}
​//3.开始监听if(listen(server_fd,10) < 0) {close(server_fd);throw std::system_error(errno,std::generic_category(),"监听失败");}
​{std::lock_guard<std::mutex>lock(cout_mutex);std::cout << "服务器启动,监听端口: " << PORT << std::endl;}
​//4.接受客户端连接while(1) {struct sockaddr_in client_addr;socklen_t client_addr_len=sizeof(client_addr);int client_fd=accept(server_fd,(sockaddr*)&client_addr,&client_addr_len);if(client_fd < 0) {std::lock_guard<std::mutex>lock(cout_mutex);std::cerr << "接受客户端连接失败: " << std::strerror(errno) << "\n";continue;}
​//封装客户端数据auto client_data=std::make_unique<ClientData>();client_data->client_fd=client_fd;client_data->client_addr=client_addr;
​print_client_info(client_data.get(),"新客户端连接");
​//创建线程处理客户端请求std::thread client_thread(&EchoServer::handle_client, this, std::move(client_data));client_thread.detach(); //分离线程}}~EchoServer() {if(server_fd != -1) {close(server_fd);std::cout<<"服务器已关闭\n";}}
};
​
int main() {try {EchoServer server(PORT);server.start();} catch (const std::system_error& e) {std::cerr << "系统错误: " << e.what() << "\n";return EXIT_FAILURE;} catch (const std::exception& e) {std::cerr << "异常: " << e.what() << "\n";return EXIT_FAILURE;}return EXIT_SUCCESS;
}

几个问题:

❓ std::generic_category()是什么意思?

答:C++的std::error_category是一个抽象基类,用于对错误类型进行分类(例如系统错误、IO错误、逻辑错误等),其中std::generic_category是它的具体实现,专门对应系统级错误。它的核心作用是给std::system_error提供错误分类上下文,让错误信息更加规范(例如区分系统错误与用户自定义错误)

throw std::system_error(errno, std::system_category(), "创建 socket 失败");

std::generic_category告诉std::system_error:这个错误是系统级错误,用系统错误码规则解析

❓ 为什么需要raw_buf?

答:read()是C风格调用,只能写入连续字节缓冲区(char[]void*),而std::string是C++的类,不能直接传给read(),因此我们设置一个临时缓冲区raw_buf,通过它读取后再将内容转移到std::string

read()不会自动处理字符串结尾的反斜杠0,如果直接用std::stringdata()c_str()传给read(),可能会破坏std::string的内部结构,因为它内部是动态管理内存的,直接写底层字节有风险。

❓assign?

答:assign的作用是给字符串赋值(覆盖原有内容)

buffer.assign(raw_buf, read_bytes);  //string.assign(源数据指针, 赋值长度)

作用:把raw_buf中前read_bytes个字节,赋值给buffer(覆盖buffer原有内容)

优势:不用手动加\0,assign会根据read_bytes确定字符串长度,避免缓冲区溢出。如果用strcpy,还需要手动给raw_buf加\0,否则可能会拷贝垃圾值。

char* strcpy(char* dest, const char* src);

strcpy的逻辑是:从src指向的地址开始,逐个拷贝字节到dest,直到遇到\0

但如果src没有\0,那么strcpy就无法停下来。

而assign的逻辑是按长度拷贝,不在乎\0

buffer.assign("hello");  //直接赋值字符串字面量
buffer.assign(5, 'a');   //赋值5个a(结果:"aaaaa")
buffer.assign(other_str, 2, 3);  //从other_str的第2个字符开始,取3个字符赋值

c_str()data()的区别?

c_str()data()
引入标准C++ 早期就有(兼容 C 语言)C++11 新增(C++11 前无此函数)
返回值类型const char*(C++11 后)const char*(C++17 后);C++11-C++14 也是 const char*(但语义不同)
尾零(\0)保证始终保证返回的字符数组以 \0 结尾(C++ 所有标准)C++11-C++14:不保证;C++17 后:保证和 c_str() 完全一致(以 \0 结尾)
底层内存始终指向字符串的连续底层缓冲区(C++11 后)C++17 后:和 c_str() 指向同一缓冲区;C++11-C++14:可能指向不包含尾零的缓冲区
用途场景兼容 C 函数(如 strlenprintf)、系统调用(如 sendC++17 后:和 c_str() 完全通用;C++11-C++14:仅用于读取字符串内容(不依赖尾零)

在我们的服务器中二者都可以使用,是等价的。因为我们使用的是c++20标准

std::string response = "HTTP/1.1 200 OK\r\n\r\nHello";
//c_str()
send(client_fd, response.c_str(), response.size(), 0);
//data()
send(client_fd, response.data(), response.size(), 0);

需要注意的是:如果服务器需要传输二进制数据(比如图片、文件,内容中可能包含 \0,且不需要尾零),data() 的语义更贴合,虽然 c_str() 也能用)。但此时要避免用 strlen() 计算长度(strlen() 会遇到 \0 就停止),必须用 std::string::size()length()

// 二进制数据(包含 \0)
std::string binary_data = "abc\0def";  // size()是6(包含中间的\0)
// 正确:用size()计算长度,data()/c_str() 都可以
send(client_fd, binary_data.data(), binary_data.size(), 0);
// 错误:用 strlen() 计算长度(会把 "abc\0def" 当成 "abc",长度 3)
send(client_fd, binary_data.c_str(), strlen(binary_data.c_str()), 0);

C++17新增了非 const 版本的 data()char* data(),而 c_str() 始终是 const char*, 这是两者在现代标准中唯一的 “功能差异”:

data() 可以返回非 const 指针,允许直接修改字符串的底层缓冲区(前提是字符串是可修改的,且不越界)

c_str() 只能返回 const 指针,不允许修改(避免破坏字符串的内部结构,比如尾零)。

std::string str = "hello";
//非const data():直接修改底层缓冲区
char* ptr = str.data();
ptr[0] = 'H';  //合法(C++17 后),str 变成 "Hello"
​
//c_str()是const char*,不能修改
const char* c_ptr = str.c_str();
//c_ptr[0] = 'h';  //编译报错:const 指针不允许修改

如果项目未来需要兼容 C++11-C++14 标准(比如部署到旧服务器),必须注意:

1.此时 data() 不保证以 \0 结尾,不能用在依赖尾零的场景(比如 printf("%s", str.data()) 可能输出乱码,因为没有尾零,printf 会继续读取内存直到遇到 \0);

2.依赖尾零的场景(如调用 C 函数 strcpyprintf),必须用 c_str()

3.不依赖尾零的场景(如 send() 按长度发送),data()c_str() 都可以,但 data() 语义更贴合

std::strerrorstd::system_errorerrno

答:errno是C/C++全局变量,专门存储 系统调用/库函数的错误码。例如当socket(),bind(),read()等函数失败时,系统会自动给errno赋值(例如errno=2对应文件不存在,errno=11对应资源暂时不可用)。需要注意的是,errno只有在函数失败是才有效,成功时其值未定义,因此不要用errno==0来判断成功。

std::strerrorerrno对应的错误码,转换成人类可读的字符串。例如errno=2对应的字符串为"No such file or directory",使用它需要包含cstring头文件

std::cerr << "读取失败:" << std::strerror(errno) << std::endl;

std::system_error是C++标准库的异常类,结合errnostd::error_category专门封装系统级错误。

优势:相比直接用std::strerror打印,std::system_error会把错误码、错误类别、错误描述封装成异常,能通过try-catch统一捕获。

抛出异常:

throw std::system_error(errno, std::system_category(), "创建 socket 失败");
//参数1:错误码(errno);参数2:错误类别;参数3:自定义错误描述

捕获后获取信息:

catch (const std::exception& e) {std::cerr << "错误:" << e.what() << std::endl;  //e.what()返回完整错误信息(自定义描述+系统错误描述)
}

可见,std::strerror只负责错误码转字符串,需要手动打印,没有异常机制。std::system_error封装成异常,支持try-catch统一处理,错误信息更规范。

❓ client_data的get()方法?

答:client_datastd::unique_ptr<ClientData> 类型(智能指针),get()std::unique_ptr 的成员函数,来自 C++ 标准库的智能指针模板类。get()方法返回 std::unique_ptr 管理的原始指针,但不转移所有权,即智能指针依然拥有对对象的控制权,会自动释放内存。

print_client_info 的第一个参数是 const ClientData*(裸指针),而 std::unique_ptr 不能直接隐式转换成裸指针(为了安全,避免意外释放)。而用 get() 可以显式获取裸指针,传递给需要裸指针的函数,同时保留智能指针的自动内存管理。子线程结束后,client_data 会自动 delete 底层 ClientData 对象。

注意,get() 不转移所有权。不能用 delete client_data.get(),这会导致智能指针再次释放时崩溃。get() 只是临时借用”裸指针,所有权仍在 std::unique_ptr 手中。

❓ 创建线程为什么要传一个this指针?

handle_clientEchoServer 类里的非静态成员函数,不是全局函数,这意味着:同一个 handle_client 函数,能被多个 EchoServer 对象调用,且操作的是各自对象的数据

例如:

假设你创建了两个 EchoServer 对象:EchoServer server1(8080)(监听 8080 端口)和 EchoServer server2(8081)(监听 8081 端口);

两个对象都有 handle_client 成员函数,但 server1handle_client 操作的是 server1portserver_fdserver2handle_client 操作的是 server2 的数据;

那么问题来了:当你用 std::thread 启动 handle_client 时,线程怎么知道要 “绑定” 到 server1 还是 server2

答案:this 指针就是对象的唯一标识

C++ 中,每个非静态成员函数都有一个隐藏的 this 指针参数,这个参数会自动指向 调用该函数的对象。

比如 handle_client 函数,编译器实际处理的签名是:

void EchoServer::handle_client(std::unique_ptr<ClientData> client_data)
​
// 编译器实际处理的(自动加了this指针):
void EchoServer::handle_client(EchoServer* this, std::unique_ptr<ClientData> client_data)

当你调用 server1.handle_client(data) 时,编译器会自动把 &server1 作为 this 指针传给函数,函数就知道要操作 server1 的数据;

当你用 std::thread 启动这个函数时,没有办法 “自动” 传递 this 指针,因为线程是独立执行的,没有默认的 调用对象,所以必须显式传递 this 指针,告诉线程函数:你要操作的是这个 EchoServer 对象(this 指向的对象)。

回到代码:为什么必须传 this

std::thread client_thread(&EchoServer::handle_client, this, std::move(client_data));

第一个参数 &EchoServer::handle_client:告诉线程 要执行的函数是 EchoServer 类的 handle_client 成员函数。这一步已经确定了是哪个函数。

第二个参数 this:告诉线程 这个 handle_client 函数要操作的是当前 EchoServer 对象(比如 server1server2),这才是传 this 的核心目的

反例:如果不传 this 会怎么样?

编译器会直接报错,原因:handle_client 函数需要两个参数(隐藏的 this 指针 + client_data),但你只传了 client_data,参数不匹配,所以编译失败。

❓为什么要使用右值?

答:之所以要使用右值,是因为unique_ptr没有拷贝构造函数,它的特性是“独占所有权”。而std::move会触发它的移动构造函数,转移client_data的所有权给传进去的参数。转移后原来的client_data会变为空指针,这样避免了多个指针同时管理同一个对象。

❓为什么不能使用unique_ptr的引用?

答:unique_ptr的设计哲学是:同一时间,只能有一个unique_ptr管理同一个对象,不允许拷贝,只允许转移所有权。这是它保证内存安全(避免多次释放、野指针)的核心。

如果传递引用,会带来几个致命问题:

1.破坏独占所有权,导致内存安全风险。主线程中的client_data(引用的源头)和子线程中的client_data(引用)会同时指向同一个对象,这就违背了unique_ptr的独占原则。当子线程结束时,unique_ptr会自动释放对象,因为它是智能指针。而主线程的client_data还持有引用,后续如果主线程不小心访问client_data->client_fd,就会触发野指针访问。

或者,如果主线程提前退出,client_data被销毁,子线程中的引用会变为悬垂引用。

2.引用的生命周期不匹配。主线程的client_data是循环内的局部变量,生命周期很短,每次accept后就会被覆盖或销毁。而子线程是异步执行的,可能主线程已经销毁了client_data,子线程还在通过引用访问它,导致悬垂引用。而转移所有权能解决这个问题,主线程把client_data的所有权转给子线程后,自己就变成空指针,不再访问,子线程完全掌握对象的生命周期。

如果需要共享访问,应该使用shared_ptr

❓为什么不使用shared_ptr

答:shared_ptr的设计哲学是共享所有权。多个shared_ptr可以同时管理同一个对象,内部通过引用计数记录有多少个shared_ptr指向对象来决定何时释放内存(引用为0时释放)

我们当然可以改成shared_ptr,但是这没必要,主线程创建client_data并打印后除了传递给子线程,再也不会访问它。主线程的核心是accept新连接并打印客户端信息。之后,子线程是client_data的唯一使用者和所有者,直到子线程结束释放对象。这种创建后直接转移所有权的场景是unique_ptr的最佳适用场景。shared_ptr的引用计数是原子操作,会带来额外的性能开销(不大,但没必要),而unique_ptr转移所有权只是简单的指针赋值,效率更高。

只有当多个线程/对象同时访问同一个对象且无法确定谁先释放时才需要shared_ptr

❓ define、const、constexpr?

答:define是预处理阶段的文本替换工具,本质是预处理指令。它只在编译前做纯文本替换,不参与编译过程的任何语法/类型检查。

特点:

1.无类型、无作用域。例如#define PORT 8080,预处理时会把所有的PORT直接换成8080,不管上下文。例如int PORT=9090会被替换成int 8080=9090,编译报错。

2.这也使得调试困难,调试的时候我们看到的是8080而不是PORT

3.副作用明显:比如定义表达式时,必须加括号,否则运算逻辑混论

const是编译期确定的不可修改的变量 ,有明确的类型和作用域,编译期会做类型检查,但值可能在运行时才确定。

特点:

1.类型安全:const int PORT=8080明确是int类型,赋值给double变量会触发编译警告

2.作用域可控:在函数内定义const int BUF_SIZE=1024仅在该函数内有效。在类内定义const int MAX_CLIENT=100,需要加static才能成为类的静态变量,否则每个对象都会拷贝一份

3.可能占内存:如果const变量的值需要运行时确定,例如const int len=strlen("hello")strlen是运行时函数),编译器会为其分配内存。如果值是编译期确定的,例如const int PORT=8080,编译器可能优化掉内存,直接替换成值

constexpr是编译期确定的常量表达式,专门用于编译期就能确定值的常量。编译器会在编译时把它替换成具体值,无运行时开销。

特点:

1.编译期求值:constexpr变量的值必须是编译期可计算的表达式,例如constexpr int PORT=8080,而不是例如constexpr int len=strlen("hello")这种无法在编译期确定的值,否则会报错。strlen是运行时函数,但在C++17后支持constexpr

2.类型安全:和const一样有严格类型检查

3.无内存占用:编译期直接替换成值,不会分配内存

4.类内默认静态,在类内定义constexpr int MAX_CLIENT=100无需加static,默认是类的静态常量

5.支持函数:constexpr函数可以在编译期计算结果,例如:

constexpr int add(int a, int b) {return a+b;
}

如果调用add(2,3)会在编译期变成5

在我们的回声服务器中,对于端口号、缓冲区大小、数组大小、最大连接数等编译期能确定的值,优先使用constexpr

对于需要从配置文件读取的参数、运行时才确定的只读变量、类成员常量,它们无法在编译期确定值,但需要保证不可修改,使用const

多路复用的代码放到下一篇

http://www.dtcms.com/a/600588.html

相关文章:

  • 【数据分析-Excel】常用函数汇总
  • 深入理解MySQL事务隔离级别与锁机制(从ACID到MVCC的全面解析)
  • RabbitMQ应用(1)
  • .NET驾驭Excel之力:Excel应用程序的创建与管理
  • Unity2.5D视角肉鸽项目架构
  • JAVA和C#的语法对比
  • WPS Excel 图表
  • 电商网站开发需要掌握哪些知识技能品牌设计和vi设计有什么区别
  • Spring 框架整合 JUnit 单元测试——包含完整执行流程
  • .NET驾驭Excel之力:自动化数据处理 - 开篇概述与环境准备
  • 多站点网站群的建设与管理识图搜索在线 照片识别
  • C++ builder xe 用imageen组件ImageEnView1合并多个图片导出一个pdf
  • 深度拆解汽车制造系统设计:用 Java + 设计模式打造高扩展性品牌 - 车型动态生成架构
  • 客户端VS前端VS后端
  • 西安企业网站建设哪家好hs网站推广
  • 【宝塔面板】监控、日志、任务与安全设置
  • RPA财务机器人落地指南:治理架构、流程优化与风险防控
  • GitHub Agent HQ正式发布,构建开放智能体生态
  • XML节点SelectSingleNode(“msbuild:DebugType“ 为什么要加msbuild
  • 【GitHub热门项目】(2025-11-12)
  • 【RAG评测方案汇总】GitHub开源工具全览
  • 数据集月度精选 | 高质量具身智能数据集:打开机器人“感知-决策-动作”闭环的钥匙
  • 深圳网站制作易捷网络湘乡网站seo
  • Java Maven Log4j 项目日志打印
  • 面试:Spring中单例模式用的是哪种?
  • 长芯微LPS5820完全P2P替代NCP51820,LPS5820 是一款高速半桥驱动器,可用来驱动半 桥功率拓扑的 GaN 功率管。
  • Python 第三方库:PyTorch(动态计算图的深度学习框架)
  • 如果网站打开非常缓慢国内全屋定制十大名牌
  • 【操作系统】详解 分页与分段系统存储管理
  • flex:1