带网络功能的程序,几乎是所有C++学习者绕不过去的一道坎。项目标题里的这几个关键词——TCP、UDP、收发消息、收发文件、多线程、多客户端——凑在一起,其实就是个典型的局域网即时通讯加文件共享工具。很多人在网上搜过类似的项目,也下过一些Demo,但真正能跑起来、扛得住多个客户端同时连接、传大文件不崩溃的版本,其实没那么多。这篇文章把我自己实现这个项目时的完整思路、协议设计、线程模型、以及最后压测踩坑的记录都整理出来,希望能帮你少走几步弯路。
1. 项目定位与协议分工:为什么同时用TCP和UDP
1.1 两个协议的本质差异
初学者最容易犯的一个认知错误,是把TCP和UDP当成两个可以随意替换的方案。实际上在动手写代码之前,必须先把"可靠"和"实时"这两个词想清楚。
TCP是面向连接的字节流协议,它保证数据按序到达、不丢失、不重复。为了这份可靠性,TCP内部有确认机制、超时重传、滑动窗口、拥塞控制这些复杂的逻辑。代价就是:传输过程中存在一定的延迟,尤其在网络质量不佳时,重传机制会让延迟进一步变高。
UDP是无连接的数据报协议,它只负责把数据包发出去,不保证到达、不保证顺序、也不保证不重复。看起来缺点一堆,但它有一个TCP比不了的优势——低延迟。没有确认等待,没有重传排队,每个数据报都是独立发出去的,延时非常稳定。
在实际项目中,这两个协议不是竞争关系,而是互补关系。消息聊天里那些丢一两条也不致命的场景,用UDP可以换来更快的响应;而文件传输这种必须逐字节完整、任何一位错了都不行的场景,老老实实走TCP。
1.2 本项目中的协议分工
在给这个项目做架构设计的时候,我定下的分工是这样的:
| 功能模块 | 使用的协议 | 原因 |
|---|---|---|
| 文本消息群发 | UDP | 延迟低,允许极个别丢包,用户感知不明显 |
| 私聊消息 | TCP | 私聊要求准确性,且要保证接收方一定收到 |
| 文件发送 | TCP | 文件是最终要落盘的,完整性和顺序性不可妥协 |
| 客户端在线状态 | UDP心跳 | 心跳包小且频繁,UDP开销远小于TCP长连接 |
| 大文件断点续传 | TCP + 自定义协议 | 断点续传本身就需要可靠传输做基础 |
这个分工的关键在于:不要为了技术演示而强行把某个功能塞在不合适的协议里。UDP传文件虽然技术上可行,但需要自己实现一堆可靠传输机制,工作量和坑会成倍增加。反过来,纯文本实时消息如果全部走TCP,在高并发场景下,TCP连接数多了以后,服务端线程资源和文件描述符都会吃紧。
提示:项目标题说"基于TCP和UDP实现收发消息、收发文件",在博文里要讲清楚的是"哪个部分用哪个协议,以及为什么"。读者真正需要的是判断力,而不是两套代码的堆砌。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. TCP可靠链路:封包协议与多客户端线程模型
2.1 消息封包格式设计
TCP是流式协议,它本身不知道"消息"的边界。你调用send发送一次数据,接收方recv到的不一定就是完整的一条消息,可能是半条,也可能是一次性收到了两条消息的拼接。这就是著名的粘包和半包问题。
我在项目中采用的方案是"消息头 + 消息体"的自定义封包格式。消息头固定长度,包含消息类型和数据长度;消息体变长,存放实际内容。发送端先发头部、再发内容,接收端先收固定头部,根据长度字段再收完整的数据体。
cpp复制#pragma pack(push, 1)
struct NetMessageHeader {
uint32_t magic; // 魔数,用于校验,比如 0x5A5A5A5A
uint16_t type; // 消息类型:登录、私聊、群聊、文件请求、文件数据、心跳
uint32_t length; // 消息体的字节长度
uint32_t sequence; // 消息序号,用于重传或去重
};
#pragma pack(pop)
这里有个特别容易被忽视的细节:#pragma pack(1)。如果不强制一字节对齐,编译器会在结构体成员之间插入填充字节,不同平台、不同编译器下填充规则还不一样。你用sizeof(NetMessageHeader)计算的长度和另一端算出来的长度可能对不上,解析出来的字段全是乱码。这类问题排查起来非常隐蔽,因为程序能编译、能启动,但通信就是不对。
实际收发流程是这样的:
cpp复制// 发送端
std::string serializeMessage(uint16_t type, const std::string& body) {
NetMessageHeader header;
header.magic = 0x5A5A5A5A;
header.type = type;
header.length = static_cast<uint32_t>(body.size());
header.sequence = ++seqCounter;
std::string packet;
packet.reserve(sizeof(header) + body.size());
packet.append(reinterpret_cast<char*>(&header), sizeof(header));
packet.append(body.data(), body.size());
return packet;
}
接收端在处理时,不能直接调用一次recv就完事,要写一个"读够指定字节数"的函数:
cpp复制bool recvExactly(SOCKET sock, char* buffer, int expectedLen) {
int total = 0;
while (total < expectedLen) {
int ret = recv(sock, buffer + total, expectedLen - total, 0);
if (ret <= 0) {
return false; // 连接关闭或出错
}
total += ret;
}
return true;
}
这个函数是整个TCP收包逻辑的核心。recv返回的字节数是不确定的,可能比期望的少,网络状况差的时候尤其明显。必须循环接收,直到收满期望的字节数,或者遇到连接关闭、错误才退出。我第一次写网络代码时没有这个循环,直接拿一次recv的返回值去解析消息头,结果本地测试没问题,丢到WiFi环境下就疯狂出错。
2.2 每客户端一线程的模型与线程安全
对于多客户端并发连接,不同的人会给出不同的方案。有select模型的,有poll模型的,有epoll模型的,还有IOCP的。对于这个项目,最稳妥、最容易理解、也是最适合作为教学案例的,是"每客户端一个线程"的模型。虽然它在大量长连接场景下不是最优解,但对几百个客户端以内的规模,完全够用,而且代码逻辑直白。
服务端主线程负责listen和accept,每accept到一个新连接,就启动一个新线程去处理这个连接的收发:
cpp复制void serverLoop() {
while (running) {
SOCKET clientSock = accept(listenSock, nullptr, nullptr);
if (clientSock == INVALID_SOCKET) {
continue;
}
std::thread clientThread(handleClient, clientSock);
clientThread.detach(); // 注意这里,详细原因见下
addClient(clientSock);
}
}
detach和join的选择是个值得说的地方。如果用join,那么主线程在accept完一个客户端之后会阻塞等待这个客户端的线程结束,根本没法继续accept新连接,那多客户端就成了一句空话。所以必须detach,让客户端线程在后台独立运行。但detach也带来一个问题:如果客户端线程还在运行,服务端就要退出了,程序会崩溃。所以服务端退出前要设置running = false,然后关闭监听socket,让accept返回错误,主流程退出;每个客户端线程在recv失败或收到特殊退出消息时主动退出,这样线程才不会泄漏。
线程安全的重灾区在客户端列表管理上。多个客户端线程可能同时发消息,意味着多个线程会同时操作这个全局列表,如果不对它做任何保护,就会出现数据竞争。轻则列表内容错乱,重则直接导致程序崩溃。我在全局维护一个std::map<SOCKET, std::string>用来记录在线客户端的socket号和昵称,并用一个std::mutex保护:
cpp复制std::map<SOCKET, std::string> g_clients;
std::mutex g_clientsMutex;
void broadcastMessage(const std::string& packet) {
std::lock_guard<std::mutex> lock(g_clientsMutex);
for (auto& [sock, name] : g_clients) {
send(sock, packet.data(), packet.size(), 0);
}
}
这里还要注意一个问题:某个客户端线程在send时如果失败,说明这个客户端可能已经掉线了,但此时你不能立刻从列表里删除它——因为你还持有锁。正确做法是把断开的socket标记下来,等遍历完再统一移除,或者先收集要删除的socket,然后再执行删操作。如果在一个线程持有锁遍历列表的同时,另一个线程在recv返回0后尝试加上同一把锁删客户端,就会产生死锁风险。
我对这个问题的处理是:send失败时只记录失败,等遍历结束并释放锁后,再走一遍清理逻辑。这样既保证了列表的操作是原子的,也避免了锁的嵌套调用。
3. UDP实时通道:低延迟消息与丢包应对
3.1 UDP收发流程与缓冲区限制
UDP部分比TCP简单得多,不需要accept,也不需要每连接一个线程。服务端只需要创建一个socket,绑定端口,然后在一个独立线程里循环recvfrom。
cpp复制int udpSock = socket(AF_INET, SOCK_DGRAM, 0);
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(kUdpPort);
bind(udpSock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
但UDP的"简单"只在收发接口层面,实际使用还是有坑。最大的坑是数据报的大小限制。理论上UDP能承载的最大数据长度是65535字节减去IP头(20字节)和UDP头(8字节),也就是65507字节。但是在局域网里,如果数据报超过了以太网的MTU(通常1500字节),IP层就会分片。分片后任何一个分片丢失,整个数据报都会丢。所以在实际项目里,我倾向于把单个UDP消息控制在1400字节以内,完全避开IP分片问题。
文本消息通常不会超过这个长度,但如果你用它来传文件,就得考虑分块和重组,复杂度一下子就上来了。这也是我为什么在开篇就强调协议分工的另一个原因。
UDP接收缓冲区也是一个值得关注的点。Linux下UDP接收缓冲区默认值比较小,如果发送端短时间内猛发大量数据报,接收端的缓冲区会溢出,多余的数据包会被内核直接丢弃。即使你的程序逻辑没问题,应用层能看到的现象也是"丢包惨重"。在处理高吞吐场景之前,先调大缓冲区:
cpp复制int rcvBufSize = 1024 * 1024; // 1MB
setsockopt(udpSock, SOL_SOCKET, SO_RCVBUF, reinterpret_cast<const char*>(&rcvBufSize), sizeof(rcvBufSize));
这个操作不是玄学,是实实在在有必要的。Kernel默认的rcvbuf在Linux上通常只有几十KB,WiFi环境下一帧数据量大一点,缓冲区就满了。
3.2 应用层可靠机制:序号、确认与重传
当UDP被用来承载"群聊消息"的时候,可以容忍偶尔一两条丢了,但也不能完全放任不管。我采用了一个轻量级的应用层可靠性方案:给每条消息分配一个序号,接收方定期把收到的最高序号反馈给发送方,发送方如果发现序号落后于已发送的序号过多,就补发一个"状态同步包"。
这个机制不要求每条消息都确认,只做周期性确认,把重传频率控制在一个很低的水平。具体来说:
- 发送方维护一个发送计数器
sendSeq,每发一条消息,sendSeq++。 - 接收方维护一个接收计数器
recvSeq,收到消息后,如果消息序号大于当前recvSeq,更新recvSeq;如果序号小于当前序号或等于当前序号,说明是重复包,直接丢弃。 - 接收方每隔500ms发一个ACK包,带上当前的
recvSeq。 - 发送方如果发现
sendSeq和最新的ACK中的recvSeq差值超过50,就降低发送速度或提示用户网络质量差。
这套机制牺牲了一点点复杂度,换来的是"UDP仍能保证最终一致性"的效果。对于群聊这种场景,体验已经非常接近TCP了,但延迟比TCP低不少。
心跳包也走了UDP。每个客户端每隔3秒发送一个很小的UDP包,服务端收到后在映射表里刷新该客户端的时间戳。服务端每10秒扫描一次,超过30秒没收到心跳的客户端视为离线。这个方案比基于TCP的心跳好使,因为UDP不会因为连接半开状态而永远阻塞在recv上。
4. 文件传输实现:分块、校验与断点续传
4.1 文件分块与校验流程
文件传输是整个项目里最容易出bug的部分,因为文件的内容是二进制,不能用处理字符串的思路来对待。我遇到的第一坑就是Windows下用fopen打开文件时没加"b"模式,结果发出去的图片文件到接收端一打开就是损坏的,因为是文本模式下对回车换行做了转换。
文件传输走TCP,封包格式沿用"消息头+消息体",消息头里用type字段区分"文件请求"和"文件数据"。文件请求包携带文件名、文件大小、分块序号、每块大小等元信息。接收端收到请求后,决定是否接受;接受后,双方进入数据阶段。
分块大小我定为64KB(65536字节)。这个值既能减少分块数量,也能适配TCP的发送窗口。数据包的格式带一个4字节的分块序号字段,接收端根据序号决定写入文件的偏移量。这样即使乱序到达,也不会写错位置。
cpp复制constexpr size_t kFileChunkSize = 64 * 1024;
bool sendFileChunk(SOCKET sock, std::ifstream& file, uint32_t chunkIndex, size_t bytesToSend) {
std::vector<char> buffer(kFileChunkSize);
file.read(buffer.data(), bytesToSend);
size_t bytesRead = file.gcount();
FileChunkPacket pkt;
pkt.index = chunkIndex;
pkt.length = static_cast<uint32_t>(bytesRead);
std::string packet;
packet.append(reinterpret_cast<const char*>(&pkt), sizeof(pkt));
packet.append(buffer.data(), bytesRead);
return sendAll(sock, packet.data(), packet.size());
}
每发完一个分块,发送端就打印一次进度。进度计算很直接:
cpp复制int percent = static_cast<int>((totalSent * 100) / fileSize);
为了不让进度打印拖慢速度,我在每发送完1MB或进度变化超过1%时才打印一次,避免控制台刷屏。
接收端的写入逻辑是重点。文件必须以"ab"模式打开,或者按分块序号seek到对应偏移位置写入。如果接收顺序是保序的,直接用"ab"追加写即可;但如果将来要支持并发传送多个分块,就必须按偏移量写。我实现的是后者,这样更健壮。
分块校验我用了CRC32,每个分块发送时把CRC值放在块头里,接收端收到后计算本地CRC并比对,不一致就要求重发。CRC32对64KB的块计算速度很快,性能影响可以接受。
4.2 断点续传的简单实现思路
断点续传是文件传输里用户感知最强、实现起来也不太困难的一个功能。核心思路就是记录"已接收的合法分块序号集合"。
发送端在发送前先发一个"文件请求包",里面除了文件名和大小,还带一个offset字段,表示从哪个偏移开始发。接收端如果发现本地已经有同名文件,就检查文件当前大小,把这个大小作为起传偏移返回给发送端。发送端收到后,把文件读指针seek到对应位置,从这个位置继续读和发。
这个方案只适用于"顺序传输"的场景,因为最终续传的偏移只基于文件大小,不校验中间某个分块是否缺失。但如果前面某个块因为网络原因被跳过,或接收端写入时出错,单纯靠文件大小做续传就会留下隐患。
更严谨的做法是让接收端维护一个"分块位图",记录每个分块的接收状态。续传时把位图发给发送端,发送端只重发位图中标记为"未接收"的块。这个方案实现起来多不了多少代码,但可靠性提高了一个档次。
我最终实现到第二种方案的一半:文件信息里记录了已完成的偏移量,并在本地生成一个.mrk标记文件,里面存了分块位图。实际跑通之后,大文件传输中断后重新连接,能续传成功,体验确实比从头再来好得多。
5. 实际踩坑记录:只在跑起来以后才能发现的问题
5.1 TIME_WAIT和端口复用
开发过程中反复重启服务端是很常见的事情。第一次正常启动,程序跑了一会儿,Crtl+C杀掉进程,再立刻启动,bind报错:Address already in use。这个错误说的大多数人第一反应是端口被占用了,但实际上,你的程序已经退出了,只是TCP连接还停留在TIME_WAIT状态。
TIME_WAIT是TCP主动关闭连接的一方在收到对端最后一次ACK后进入的状态,持续时间通常为2MSL(约2分钟)。对于服务端来说,如果服务端先关闭连接,或者客户端没有正常close就直接退出,服务端的端口上就会积累很多TIME_WAIT连接。
解决办法很简单,在bind之前设置SO_REUSEADDR:
cpp复制int opt = 1;
setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char*>(&opt), sizeof(opt));
这个选项告诉内核,即使该端口上有TIME_WAIT状态的连接,也允许端口被重新绑定。它在开发阶段能让你省掉无数次"重启失败"的烦恼。但要注意,SO_REUSEADDR不等于SO_REUSEPORT,后者允许多个进程同时绑定同一个端口,含义不同,别搞混。
5.2 粘包、半包与recv循环
粘包、半包几乎是每个TCP项目都会遇到的问题,但在开发初期反而很难被发现。因为本地回环测试时,数据包通常一到两个就发送完了,recv一次就能读满。一旦上了真实网络,路由器、交换机一个转发,包的到达时机就变得千奇百怪。
我测试时用了一个比较狠的方法:在发送端把一条消息拆成两段发送,中间sleep 100毫秒,看接收端是否能正确解析出完整消息。如果没有循环recv的逻辑,第二次发的那半条消息会被当成一条独立消息去解析,消息头里的length字段和实际收到的字节数对不上,程序就会崩溃或产生乱码日志。
所以recvExactly这个函数绝对不是可有可无的辅助函数,它是整个TCP收包逻辑的基石。初次接触网络编程的人,一定要盯住这个点,多写几轮测试。
5.3 SIGPIPE导致的进程静默崩溃
这个坑非常隐蔽,而且只在连接异常时出现。当你的程序向一个对端已经关闭的TCP连接写数据时,操作系统会向你的进程发送SIGPIPE信号。如果没有处理这个信号,进程会直接终止,而且不会产生任何异常、日志或core文件,看起来就像是程序莫名其妙消失了。
我第一次遇到这个问题时,服务端运行得好好的,一个客户端突然断开(比如断电或强制杀进程),服务端的发送线程给这个连接发送消息,整个服务端进程瞬间退出。排查了很久,最后用gdb调试才发现进程是收到了SIGPIPE信号。
Linux下解决方案有两个:要么忽略SIGPIPE信号,要么在send时加MSG_NOSIGNAL标志:
cpp复制signal(SIGPIPE, SIG_IGN);
// 或者
send(sock, data, len, MSG_NOSIGNAL);
我两个都做了,双保险。Windows下不需要担心SIGPIPE,但send失败返回SOCKET_ERROR,你要判断WSAGetLastError()的值来决定是重试还是关闭连接。
5.4 结构体对齐与字节序
前面提到封包结构体要加#pragma pack(1),这里再补充一个字节序的问题。所有涉及多字节整数的传输,双方必须使用同一种字节序约定。在Linux小端机器上,直接memcpy一个int到网络包里发送,如果在另一台同样是小端的机器上接收,没问题;但一旦跨架构(比如从x86发到ARM),就会解析出错。
解决方法是,在发送之前把多字节整数统一转成网络字节序:
cpp复制uint16_t netType = htons(header.type);
uint32_t netLength = htonl(header.length);
接收端对应地用ntohs和ntohl转回来。这个习惯要从小项目养成,不要等到做跨平台项目再补课。它属于"平时用不上、用上了就是大坑"的那类知识。
6. 压测结果与后续优化方向
6.1 并发压测数据
项目基本功能跑通后,我写了一个简单的压测工具来验证它到底能扛住多大并发。压测工具本身也是一个多线程程序:创建若干个客户端线程,每个线程连接到服务端,发送固定数量的消息,统计响应时间和成功率。
先说环境:Linux服务器,2核4G,局域网千兆。服务端采用每客户端一线程模型。
测试了三种场景:
| 场景 | 并发客户端数 | 每客户端消息数 | 总耗时 | 消息丢失/失败 |
|---|---|---|---|---|
| 小并发 | 20 | 1000 | 约8秒 | 0 |
| 中并发 | 100 | 1000 | 约35秒 | 0 |
| 高并发 | 300 | 1000 | 约120秒 | 少量超时 |
通过对cpu占用率的观察,300个客户端时CPU占用已经接近60%,主要开销在大量线程的上下文切换和互斥锁竞争上。这实际上印证了"每客户端一线程"模型的一个瓶颈:线程数一多,调度成本就会吃掉相当一部分CPU。
另外我注意到一个有意思的现象:在同一个进程里连接数达到200以上后,新连接的accept有时会出现几百毫秒的等待。后来分析是线程的栈内存加上套接字缓冲区的占用触发了内存分配延迟,不是网络问题。这说明在这个量级上,内存带宽和分配器性能也开始成为隐性瓶颈。
6.2 线程模型演进:从线程池到epoll
压测暴露出来的问题让我认真考虑了后续的优化方向。
第一个优化的方向是线程池化。线程的生命周期成本很高——创建、销毁、上下文切换——每次accept都new一个线程,在高频连接断开场景下开销极大。线程池的思路是预创建固定数量的工作线程,把accept到的socket放入一个任务队列,工作线程从队列里取socket处理。这样可以显著降低线程创建销毁的损耗。但每客户端一线程模型下,每个线程都在recv上阻塞,把recv换掉、改成非阻塞或超时模式,才能配合线程池复用。
第二个方向是事件驱动模型。Linux下用epoll,Windows下用IOCP。epoll把"多个socket的等待"集中到一个事件循环里,由内核帮你监听哪个socket有数据可读,应用层只需要在就绪时去读即可。这种方式不需要为每个客户端创建线程,理论上可以支撑成千上万的连接。我在另一个重构版本里用epoll实现过消息转发和广播服务,实测1万连接、每连接每秒发1条消息时,CPU占用约20%,和每客户端一线程模型完全不在一个数量级上。
第三个方向是发送队列异步化。目前的代码是"谁发消息,谁就在线程里直接send",这有个问题:如果某个客户端的发送缓冲区满了,send会阻塞,整个线程就被卡住,影响该线程上其他客户端的处理(在线程池模型下更明显)。改进方式是每个客户端绑定一个发送队列,网络线程只负责从队列取数据发送,应用线程往队列里塞数据。这样即使某个客户端接收端处理慢,也只会影响它自己的队列长度,不会阻塞对其他客户端的处理。
第四个方向是零拷贝。文件传输场景下,把文件内容从磁盘读到用户态缓冲区,再从用户态buffer发到socket,中间会有一次内存拷贝。如果引入splice或sendfile系统调用,让数据在内核态直接传输,可以减少CPU参与,提高大文件传输吞吐。这个优化属于锦上添花,但对于动不动传几个GB文件的场景,效果非常明显。
目前项目主体功能已经稳定,上述优化方向我逐个在不同分支里开展。如果后续有时间,我打算把epoll版本的代码整理成一个独立项目,专门用来跑大连接数的压测对比,看数据能差到多少倍。网络编程这条路,做完这个项目才算是刚站到这个领域门口,后面能深入进去的东西还有很多。
