C++ Socket编程实战:从单连接TCP实例到多客户端并发改造
简介:面向Linux网络编程初学者与需要夯实底层通信原理的开发者,这份资料系统梳理了Socket编程的核心知识体系,帮助读者理解网络中进程如何借助IP地址、协议与端口三元组实现唯一标识与跨主机通信。内容从Socket“一切皆文件”的设计哲学切入,依次讲解socket()、bind()、listen()、connect()、accept()、read()/write()与close()等基本操作函数,并深入剖析TCP三次握手建立连接与四次挥手释放连接的全过程,最后给出服务端与客户端配合的实践示例,兼顾原理理解与动手验证。资源包共1个docx文档,约77KB,以文字讲解与代码片段为主,结构紧凑便于通读与查阅。目前已有241人学习,适合作为网络编程入门笔记或复习提纲使用。
1. 从一份能跑通的 Socket 实例说起
很多人学网络编程,卡在“看得懂函数原型,写不出能跑的程序”这一步。这份资源给的不是概念拼盘,而是一套能直接编译运行的 Linux Socket 编程实例:一个mysocket类把服务端和客户端的连接建立过程封装起来,server.cpp监听 6666 端口,client.cpp连上去循环发消息,编译完先跑服务端再跑客户端,终端里就能看到收发日志。它适合两类人:刚接触网络编程、想照着跑通第一个 TCP 程序的新手;以及需要一份干净骨架、拿去做二次开发底座的熟手。整份材料从进程间通信讲到 socket 基本函数,再落到三次握手、四次挥手和完整代码,路径很直。
2. 项目定位与技术选型:为什么是 C++ 封装的 Socket 骨架
2.1 这份资源解决的是什么问题
网络编程入门最尴尬的地方在于:教材把socket()、bind()、listen()、accept()讲得很细,但真到动手时,服务端和客户端怎么配合、哪个函数会阻塞、地址结构体怎么填,全是空白。这份实例的价值就在于把“函数清单”变成了“可执行链路”。
它没有引入任何第三方网络库,直接基于 Linux 系统调用和 POSIX socket API,用 C++ 类做了一层薄封装。mysocket类持有四个成员:协议域m_idomain、socket 类型m_isoctype、协议m_iprotype、socket 描述符m_isocket。对外暴露createserver()、createclient()、recvmsg()、sedmsg()四个方法,分别对应服务端初始化、客户端初始化、收消息循环、发消息循环。
这种设计的好处是职责清晰:server.cpp和client.cpp的main函数各自只有几行,把“建连接”和“跑业务”分开。坏处也很明显——它是个教学骨架,不是生产级封装,后面改造章节会细说。
2.2 技术栈与运行环境
| 项目 | 说明 |
|---|---|
| 语言 | C++(兼容 C 风格系统调用) |
| 网络 API | POSIX socket,TCP 协议 |
| 关键头文件 | sys/socket.h、netinet/in.h、arpa/inet.h、unistd.h |
| 编译工具 | g++ |
| 运行平台 | Linux(macOS 需微调头文件) |
| 通信模型 | 阻塞式 TCP,单连接循环收发 |
选 TCP 而不是 UDP,是因为这份材料的重点在“连接建立与释放”的完整过程。三次握手、四次挥手这些概念,只有落在connect()、accept()、close()的具体调用上才有体感。UDP 没有连接状态,讲握手就无从谈起。
提示:代码里客户端硬编码了服务端 IP
192.168.1.18,这是原实例留下的坑。跑之前必须改成你本机或目标机器的实际 IP,否则connect()直接失败。
2.3 目录结构与文件职责
整个实例只有四个源文件加两个空头文件,结构极简:
mysocket/ ├── mysocket.h # 类声明,四个成员变量 + 四个方法 ├── mysocket.cpp # 类实现,socket/bind/listen/connect/recv/send 全在这里 ├── server.cpp # 服务端入口,创建 mysocket 对象并调 createserver + recvmsg ├── client.cpp # 客户端入口,创建 mysocket 对象并调 createclient + sedmsg ├── server.h # 空文件,占位 └── client.h # 空文件,占位server.h和client.h是空的,说明作者原本可能想按“一头一实现”的方式组织,后来简化掉了。二次开发时可以直接删掉这两个空文件,不影响编译。真正承载逻辑的是mysocket.cpp,服务端和客户端的所有系统调用都集中在这一个文件里。
3. 核心链路拆解:从 socket() 到 accept() 的完整调用顺序
3.1 服务端初始化:socket → bind → listen
服务端的入口是createserver(),它把三个系统调用串成一条线。先看代码:
bool mysocket::createserver(int domain, int soctype, int protype, int port) { m_idomain = domain; m_isoctype = soctype; m_iprotype = protype; // 1. 创建 socket 描述符 m_isocket = socket(m_idomain, m_isoctype, m_iprotype); if (-1 == m_isocket) { printf("create socket error\n"); return false; } // 2. 填充地址结构体并绑定 struct sockaddr_in servaddr; memset(&servaddr, 0, sizeof(servaddr)); servaddr.sin_family = domain; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 servaddr.sin_port = htons(port); // 主机序转网络序 if (bind(m_isocket, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1) { printf("bind socket error\n"); return false; } // 3. 转为被动监听状态,backlog 为 10 if (listen(m_isocket, 10) == -1) { printf("listen socket error\n"); return false; } printf("======waiting for client's request======\n"); return true; }逐段看。socket(AF_INET, SOCK_STREAM, 0)三个参数分别指定 IPv4 协议族、流式套接字(即 TCP)、协议自动选择。第三个参数传 0 是常见做法,系统会根据前两个参数推导出IPPROTO_TCP。如果这里传IPPROTO_UDP配SOCK_STREAM,调用会失败——类型和协议不是随便组合的。
bind()之前必须把sockaddr_in清零,否则残留字段可能导致绑定到意外地址。sin_addr.s_addr = htonl(INADDR_ANY)表示监听本机所有网卡,INADDR_ANY的值是 0,经过htonl转换后填入。sin_port = htons(port)这一步是血泪经验:端口号在内存里是主机字节序,网络传输要求大端序,不转就会绑到一个完全不相干的端口上。原材料里专门强调过“对主机字节序不要做任何假定”,说的就是这里。
listen()的第二个参数backlog设为 10,表示已完成三次握手、等待accept()取走的连接队列最大长度。这个值不是并发上限,只是排队上限,生产环境一般调到 128 或SOMAXCONN。
3.2 客户端连接:socket → connect
客户端走的是另一条路径,不调bind(),直接connect():
bool mysocket::createclient(int domain, int soctype, int protype, int port) { m_idomain = domain; m_isoctype = soctype; m_iprotype = protype; struct sockaddr_in servaddr; m_isocket = socket(m_idomain, m_isoctype, m_iprotype); if (-1 == m_isocket) { printf("create socket error\n"); return false; } memset(&servaddr, 0, sizeof(servaddr)); servaddr.sin_family = domain; servaddr.sin_port = htons(port); // 关键:这里必须改成服务端的实际 IP if (inet_pton(AF_INET, "192.168.1.18", &servaddr.sin_addr) <= 0) { printf("inet_pton error for 192.168.1.18\n"); return false; } if (-1 == connect(m_isocket, (struct sockaddr*)&servaddr, sizeof(servaddr))) { printf("connect to server error 192.168.1.18\n"); return false; } printf("======Begin sending msg to server=====\n"); return true; }inet_pton()把点分十进制的 IP 字符串转成二进制网络序地址,返回值小于等于 0 都算失败。客户端不调bind()是正常的:内核会在connect()时自动分配一个临时端口和本机 IP。这也是为什么服务端必须bind()而客户端通常不用——服务端需要一个众所周知的地址供人连接,客户端只需要一个能用的源地址。
connect()触发 TCP 三次握手:客户端发 SYN,收到服务端 SYN+ACK 后connect()返回,同时发出 ACK。也就是说,connect()返回成功时,三次握手已经完成了两次半,最后一个 ACK 由内核补上。服务端的accept()则要等到收到这个 ACK 才返回。原材料里那句“客户端的 connect 在三次握手的第二次返回,而服务器端的 accept 在第三次返回”,说的就是这个时序差。
3.3 收发循环:accept 返回新描述符,recv/send 跑业务
服务端的recvmsg()先accept()再循环recv():
void mysocket::recvmsg() { int connfd = -1; char buff[1024] = {0}; int n = 0; // 阻塞等待客户端连接,失败则重试 while (1) { if ((connfd = accept(m_isocket, (struct sockaddr*)NULL, NULL)) == -1) { printf("accept socket error: %s(errno: %d)", strerror(errno), errno); continue; } break; } // 循环接收,直到对端关闭(recv 返回 0) while ((n = recv(connfd, buff, 1023, 0)) > 0) { memset(buff, 0, 1024); buff[n] = '\0'; printf("connfd=%d; rec msg from client: %s.msglen=%d\n", connfd, buff, n); n = 0; } }这里有个容易看漏的细节:accept()返回的是connfd,一个全新的描述符,代表这条具体连接;m_isocket是监听描述符,始终留在原地等下一个连接。原材料的说法是“一个服务器通常只创建一个监听 socket 描述字,内核为每个接受的客户连接创建一个已连接 socket 描述字”。这份代码只处理了一个连接就退出循环,属于单连接演示版。
recv()的返回值有三种情况:大于 0 是实际读到的字节数,等于 0 表示对端调用了close(),小于 0 是出错。循环条件写成> 0,对端一关就自然退出。缓冲区大小 1024,recv最多读 1023 字节,留一个位置给'\0',这是 C 字符串处理的常规操作。
客户端的sedmsg()更简单,一个死循环里sprintf拼消息再send:
void mysocket::sedmsg() { int msgid = 0; char buff[1024] = {0}; while (1) { memset(buff, 0, 1024); sprintf(buff, "msg = %d.\n", msgid); if (send(m_isocket, buff, strlen(buff), 0) < 0) { printf("send msg error: %s(errno: %d)\n", strerror(errno), errno); continue; } else { printf("send msg succ\n"); msgid++; } } }send()返回小于 0 时打印错误但继续循环,这在演示里没问题,生产环境要区分EINTR(被信号中断,可重试)和EPIPE(对端已关闭,应退出)。strlen(buff)作为发送长度是对的,因为消息是文本且以\n结尾,不含'\0'。
3.4 编译与运行:两条命令跑起来
编译命令很直接,两个可执行文件分别链接mysocket.cpp:
# 编译服务端 g++ server.cpp mysocket.cpp -o server # 编译客户端 g++ client.cpp mysocket.cpp -o client运行顺序不能反:先起服务端,它会阻塞在accept()上打印等待提示;再起客户端,connect()成功后开始循环发消息。服务端终端会持续打印rec msg from client: msg = N.,客户端终端打印send msg succ。想停就 Ctrl+C。
注意:如果客户端报
connect to server error,先确认服务端 IP 改对了、服务端已启动、6666 端口没被占用。用ss -tlnp | grep 6666可以看端口监听状态。
4. 避坑与排查:这份实例跑不起来时的五个常见问题
4.1 客户端连不上:IP 硬编码是头号元凶
现象:客户端启动后立刻打印connect to server error 192.168.1.18,服务端毫无反应。
原因:createclient()里inet_pton写死了192.168.1.18,这个地址大概率不是你当前机器的 IP。connect()往一个不存在的地址发 SYN,要么超时要么直接被拒。
解决:用ip addr或hostname -I查到本机实际 IP,替换掉字符串。如果服务端和客户端在同一台机器上,直接改成127.0.0.1最省事。改完重新编译客户端。
4.2 绑定失败:端口被占用或权限不足
现象:服务端打印bind socket error后退出。
原因:6666 端口已被其他进程占用,或者你试图绑定 1024 以下的特权端口但没用 root 运行。
解决:先ss -tlnp | grep 6666看谁占着,杀掉或换端口。换端口要同时改server.cpp和client.cpp里的createserver/createclient调用参数,两边必须一致。低于 1024 的端口需要sudo,但演示程序没必要用特权端口。
4.3 收不到消息:字节序没转或地址结构体没清零
现象:bind()成功但客户端连不上,或者连上了收到的数据是乱码。
原因:sin_port忘了htons(),端口被解释成另一个值;或者sockaddr_in没memset清零,残留的sin_zero字段干扰绑定。
解决:检查bind()前是否有memset(&servaddr, 0, sizeof(servaddr)),检查端口赋值是否走了htons()。IP 赋值用inet_pton而不是直接赋整数,能避开大部分字节序问题。
4.4 服务端只服务一个连接就退出
现象:第一个客户端断开后,服务端进程直接结束,第二个客户端连不上。
原因:recvmsg()里accept()只调了一次,recv循环退出后函数返回,main随之结束。这是单连接演示的设计,不是 bug。
解决:想支持多连接,把accept()放进外层while(1),每个connfd用fork()或线程处理。改造章节会给具体写法。
4.5 编译报错找不到头文件
现象:g++报sys/socket.h: No such file or directory。
原因:在非 Linux 环境(比如某些精简容器或 Windows 的 MinGW)下编译,POSIX socket 头文件不存在。
解决:换到 Linux 或 WSL 下编译。macOS 上大部分头文件兼容,但accept()的参数类型和close()的行为略有差异,需要小改。这份实例的目标平台就是 Linux,别在环境上硬扛。
5. 二次开发改造:把单连接演示改成能扛多客户端的小服务
5.1 从单连接到多连接:fork 还是线程
原版recvmsg()只accept()一次,服务完一个客户端就退出。要改成多客户端,最省事的做法是在accept()外面套循环,每来一个连接fork()一个子进程:
void mysocket::recvmsg() { char buff[1024] = {0}; int connfd = -1; while (1) { connfd = accept(m_isocket, (struct sockaddr*)NULL, NULL); if (connfd == -1) { printf("accept error: %s\n", strerror(errno)); continue; } pid_t pid = fork(); if (pid == 0) { // 子进程:关闭监听描述符,只处理这条连接 close(m_isocket); int n; while ((n = recv(connfd, buff, 1023, 0)) > 0) { buff[n] = '\0'; printf("child %d recv: %s\n", getpid(), buff); memset(buff, 0, 1024); } close(connfd); exit(0); } else if (pid > 0) { // 父进程:关闭已连接描述符,继续 accept close(connfd); } else { printf("fork error\n"); } } }关键点在两处close():子进程关掉监听描述符m_isocket,父进程关掉已连接描述符connfd。不关的话,描述符引用计数永远不归零,连接释放不了,跑久了就是文件描述符耗尽。这是 fork 模型的经典写法,也是我踩过坑之后每次都会检查的地方。
线程模型同理,把fork()换成pthread_create(),注意connfd要传值而不是传地址,否则多个线程可能拿到同一个描述符。
5.2 把硬编码参数抽成配置
原版 IP 和端口写死在代码里,改一次要重编译。二次开发时至少把端口和 IP 抽成main的参数:
int main(int argc, char* argv[]) { int port = (argc > 1) ? atoi(argv[1]) : 6666; mysocket server; if (server.createserver(AF_INET, SOCK_STREAM, 0, port)) { server.recvmsg(); } return 0; }客户端同理,把 IP 和端口都做成命令行参数。这样同一份二进制可以在不同环境跑,不用每次改源码。再进一步可以读配置文件,但对这个体量的骨架来说,命令行参数已经够用。
5.3 验证改造是否生效
改完之后按这个顺序验证:先起服务端,开两个终端各起一个客户端,观察服务端是否同时打印两个客户端的消息;然后关掉其中一个客户端,确认服务端另一个连接不受影响;最后用ss -tnp | grep 6666看连接状态,确认关闭的客户端进入TIME_WAIT而不是一直挂着。如果第二个客户端连上后服务端没反应,多半是accept()循环没写对或者fork后描述符没关干净。
5.4 一个容易忽略的收尾习惯
这份实例的close()调用其实不完整:recvmsg()和sedmsg()里都没有显式关闭connfd和m_isocket,靠进程退出时内核回收。演示程序无所谓,但二次开发时如果进程长期运行,描述符泄漏是迟早的事。我现在的习惯是每个socket()和accept()返回的描述符都配一个明确的close()路径,哪怕当前用不到,也先把释放逻辑写在那里。从那以后我每次改这类网络骨架,都强制走一遍“创建—使用—关闭”的配对检查,希望帮到你。
本文还有配套的精品资源,点击获取