首页 / 资讯中心 / 文章详情

C++ TCP服务端从零实现:核心API、粘包处理与高并发优化

C++ TCP服务端从零实现:核心API、粘包处理与高并发优化 ★ FEATURED ARTICLE
1. 从零拆解一个TCP服务端到底要写哪些东西很多人第一次接触网络编程脑子里冒出来的第一个问题不是怎么写而是我到底要写几行代码才能让两个程序说上话。我当初也是这么想的。后来真正把C的socket API撸了一遍才发现一个能用的TCP服务端核心代码其实不到一百行但要把这一百行写对、写稳、写出能扛住真实场景的版本里面藏着不少门道。TCP服务端这个东西说白了就是一个程序它在某个端口上蹲着等别人来连。有人连上来它就建立一条连接然后双方可以互相发数据。听起来简单但它是几乎所有网络服务的底座——你用的聊天软件、网页服务器、游戏后端、物联网设备上报数据底层都跑着这么一套东西。所以不管你是做后端开发、嵌入式、还是单纯想搞明白网络到底怎么通的把TCP服务端吃透都是一笔划算的投入。这篇文章我打算按真实动手的顺序来讲先讲清楚整体设计思路和几个关键决策再把核心API一个个拆开揉碎然后给出一份可以直接编译运行的完整代码最后重点聊聊那些只有踩过坑才知道的排查技巧。适合有一点C基础、但网络编程刚入门的朋友也适合写过但总是遇到各种诡异问题的同学对照检查。2. 整体设计与关键决策为什么TCP服务端要这么写2.1 先搞清楚TCP服务端的生命周期一个TCP服务端从启动到关闭走的是这么一条固定路线创建socket、绑定地址和端口、开始监听、接受连接、收发数据、关闭连接。这六步是骨架任何语言、任何框架本质都是在这六步上做封装。我用一个生活化的类比帮你记住它。把服务端想象成一家刚开张的店铺创建socket你去租了一间店面向操作系统申请一个通信端点。bind绑定你在门口挂上招牌写上地址和门牌号IP和端口。listen监听你把门打开摆好迎宾姿势告诉系统我准备好接客了。accept接受连接有客人推门进来你安排一个专属服务员去接待他返回一个新的socket专门服务这个客户。recv/send收发数据服务员和客人对话一来一回。close关闭客人走了服务员收工店面最后也要退租。这个类比里最关键的一点是接待客人的服务员和门口的迎宾是两拨人。对应到代码里就是监听socket和连接socket是两个不同的文件描述符。很多新手在这里犯迷糊以为accept之后还是用原来那个socket收发数据结果怎么都收不到东西。2.2 为什么选TCP而不是UDP这个问题几乎每个初学者都会问。TCP和UDP的区别一句话概括TCP像打电话UDP像寄明信片。打电话的时候你得先拨号、对方接通、确认双方都在线然后才能说话而且你说的话对方一定按顺序听到中间断了你会知道。寄明信片就不一样了你写完扔进邮筒就不管了对方收没收到、什么时候收到、顺序对不对你都不知道但胜在便宜快捷。TCP服务端适合这些场景需要数据可靠送达、需要保证顺序、需要知道对方是否断开。比如文件传输、网页请求、数据库连接、即时通讯的消息通道。UDP适合对实时性要求高、能容忍少量丢包的场景比如视频直播、游戏里的位置同步。选TCP的核心理由是它帮你把可靠性这件事在传输层做掉了。你不用自己在应用层写重传、写排序、写校验操作系统内核替你干了。代价是延迟略高、开销略大但对绝大多数业务来说这点代价完全值得。2.3 阻塞还是非阻塞这是个绕不开的选择写TCP服务端你迟早要面对这个问题用阻塞IO还是非阻塞IO阻塞模式下accept没有新连接来就会卡在那里recv没有数据来也会卡住整个线程就停住了。这种模式写起来最简单逻辑最直观适合一个连接一个线程的模型。但问题是如果有一万个连接你就要开一万个线程光是线程切换的开销就能把CPU吃满。非阻塞模式下所有操作立即返回没数据就返回一个暂时没有的错误码你需要自己用循环去轮询或者配合IO多路复用select、poll、epoll这些来管理。这种模式能用一个线程管理成千上万个连接是高并发服务端的标配但代码复杂度直线上升。我的建议是先把阻塞版本写明白再上非阻塞。因为阻塞版本能让你把TCP的基本流程、API的返回值含义、错误处理都摸清楚这些认知在非阻塞版本里一样要用。跳过阻塞直接上epoll很容易变成抄了一份代码能跑但出了任何问题都不知道从哪查。2.4 端口和地址的选择有讲究绑定的时候地址填什么、端口填什么也有门道。地址一般填INADDR_ANY它等价于0.0.0.0意思是监听本机所有网卡。如果你只想让本机访问填127.0.0.1。如果机器有多张网卡只想监听其中一张就填那张网卡的IP。端口的选择要注意1024以下的端口需要管理员权限普通程序用不了1024到49151是注册端口一般应用随便用49152以上是动态端口系统临时分配用。实际开发里服务端通常选一个固定的、不冲突的端口比如8000、9000这种。选之前最好确认一下没被占用不然bind会直接失败。还有一个容易被忽略的点端口复用。服务端重启的时候如果之前的连接还处于TIME_WAIT状态bind会报地址已被占用。解决办法是设置SO_REUSEADDR选项让系统允许复用这个地址。这个选项几乎是服务端的标配后面代码里我会加上。3. 核心API逐个拆解与实操要点3.1 socket创建一切从这里开始socket()函数是入口它的原型是int socket(int domain, int type, int protocol);三个参数的含义domain协议族TCP/IP用AF_INETIPv4或AF_INET6IPv6。type套接字类型TCP用SOCK_STREAMUDP用SOCK_DGRAM。protocol具体协议一般填0让系统根据前两个参数自动选。返回值是一个整数叫文件描述符fd。在Linux里一切皆文件socket也是一个文件你对它的操作和读写文件很像。如果返回-1说明创建失败这时候要用errno看具体原因。注意socket创建失败最常见的原因是文件描述符耗尽也就是进程打开的fd太多超过了上限。可以用ulimit -n查看当前限制高并发场景下需要调大这个值。3.2 bind绑定给服务端一个门牌号sockaddr_in结构体是IPv4地址的载体填的时候有几个坑struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_addr.s_addr INADDR_ANY; addr.sin_port htons(9000);这里htons是host to network short的缩写作用是把主机字节序转成网络字节序。为什么需要这个转换因为不同的CPU架构存储多字节整数的顺序不一样有的高位在前大端有的低位在前小端。网络协议规定统一用大端所以发送前必须转。端口是16位用htonsIP地址是32位用htonl。这个转换忘了写端口就会变成一个莫名其妙的数字连接自然连不上。bind之前记得设置SO_REUSEADDRint opt 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt));这一步放在socket之后、bind之前。它的作用是允许绑定一个处于TIME_WAIT状态的地址避免服务端重启时被地址已被占用卡住。3.3 listen监听把门打开listen()的第二个参数叫backlog表示已完成连接队列的最大长度。当客户端发起连接内核完成三次握手后会把连接放进这个队列等accept来取。如果队列满了新的连接会被拒绝或忽略。backlog填多少合适这个值不是越大越好也不是随便填。Linux内核里有个somaxconn参数限制了这个值的上限默认通常是128或4096。你填的值会被截断到somaxconn。实际经验是填128到1024之间比较稳妥太小了高并发时会丢连接太大了没意义因为会被截断。实操心得如果你发现服务端在高并发下偶尔有连接被拒绝先检查backlog和somaxconn。用cat /proc/sys/net/core/somaxconn看系统上限必要时调大。3.4 accept接受连接迎宾和接待要分清accept()返回一个新的文件描述符这个新fd专门用于和这个客户端通信。原来的监听fd继续留在那里等下一个连接。这是TCP服务端最核心的设计也是新手最容易搞混的地方。int client_fd accept(listen_fd, (struct sockaddr*)client_addr, client_len);第二个参数会填充客户端的地址信息第三个参数是传入传出参数传入时告诉函数缓冲区大小返回时告诉实际地址长度。如果你不关心客户端地址这两个参数都可以传NULL。accept在阻塞模式下会一直卡住直到有连接来。如果返回-1可能是被信号中断了errno为EINTR这种情况应该重试而不是当成错误退出。3.5 recv和send数据的收发recv()从socket读取数据返回实际读到的字节数。这里有个极其重要的认知TCP是字节流协议没有消息边界。什么意思你send了hello和world两次对方recv的时候可能一次收到helloworld也可能分三次收到he、llo、world。TCP只保证字节顺序正确不保证你发几次对方就收几次。这就是所谓的粘包问题。解决办法是在应用层自己定义消息边界常见的有两种长度前缀每条消息前面加固定长度的字段表示消息体长度比如前4字节是长度后面跟实际数据。分隔符用特殊字符分隔消息比如换行符。HTTP协议头就是用\r\n分隔的。send()的返回值也要注意它返回的是实际发送成功的字节数可能小于你要求发送的长度。虽然在内核缓冲区充足时通常一次发完但严谨的代码应该循环发送直到全部发完。int total 0; while (total len) { int n send(fd, buf total, len - total, 0); if (n 0) break; total n; }3.6 close关闭别忘了收尾关闭连接用close()。但TCP的关闭是四次挥手主动关闭的一方会进入TIME_WAIT状态持续2MSL通常几十秒到几分钟。这个状态的存在是为了确保最后的ACK能到达对方以及让旧连接的残留数据包在网络中消散。服务端如果频繁主动关闭连接会积累大量TIME_WAIT占用端口资源。解决办法是让客户端先关闭或者设置SO_LINGER选项强制关闭但会丢失未发送的数据慎用。4. 完整可运行的TCP服务端实现4.1 代码骨架与逐段说明下面这份代码是一个阻塞模式的单连接TCP服务端能接收客户端消息并回显。我把它拆成几段讲最后给完整版。先看头文件和常量#include iostream #include cstring #include unistd.h #include arpa/inet.h #include sys/socket.h #include netinet/in.h const int PORT 9000; const int BUFFER_SIZE 1024;然后是主流程int main() { // 1. 创建socket int listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { std::cerr socket创建失败: strerror(errno) std::endl; return -1; } // 2. 设置端口复用 int opt 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); // 3. 绑定地址 struct sockaddr_in server_addr; memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr INADDR_ANY; server_addr.sin_port htons(PORT); if (bind(listen_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { std::cerr bind失败: strerror(errno) std::endl; close(listen_fd); return -1; } // 4. 监听 if (listen(listen_fd, 128) 0) { std::cerr listen失败: strerror(errno) std::endl; close(listen_fd); return -1; } std::cout 服务端启动监听端口 PORT std::endl; // 5. 接受连接 struct sockaddr_in client_addr; socklen_t client_len sizeof(client_addr); int client_fd accept(listen_fd, (struct sockaddr*)client_addr, client_len); if (client_fd 0) { std::cerr accept失败: strerror(errno) std::endl; close(listen_fd); return -1; } std::cout 客户端连接: inet_ntoa(client_addr.sin_addr) : ntohs(client_addr.sin_port) std::endl; // 6. 收发数据 char buffer[BUFFER_SIZE]; while (true) { memset(buffer, 0, BUFFER_SIZE); int n recv(client_fd, buffer, BUFFER_SIZE - 1, 0); if (n 0) { std::cerr recv错误: strerror(errno) std::endl; break; } else if (n 0) { std::cout 客户端断开连接 std::endl; break; } std::cout 收到: buffer std::endl; send(client_fd, buffer, n, 0); } // 7. 关闭 close(client_fd); close(listen_fd); return 0; }编译命令g -o tcp_server tcp_server.cpp运行后用telnet 127.0.0.1 9000或者nc 127.0.0.1 9000就能连上去测试。4.2 关键参数的计算与选择过程代码里几个参数不是随便填的我解释一下背后的计算。backlog为什么填128这个值表示等待accept的连接队列长度。假设你的服务端每秒能处理100个连接但某一瞬间来了200个多出来的100个就会排在队列里。如果队列长度小于100多出来的连接会被拒绝。128是一个经验值能应对大多数突发情况。如果你的业务有明显的流量峰值可以适当调大但要记得同步检查系统的somaxconn。BUFFER_SIZE为什么是1024这是单次recv的缓冲区大小。它决定了你一次最多能读多少字节。太小了会导致频繁的系统调用太大了浪费内存。1024字节对文本协议够用如果是文件传输可以调到4096或8192。注意recv的第三个参数要传BUFFER_SIZE - 1给字符串结尾的\0留位置否则打印时可能越界。端口为什么选9000避开1024以下的特权端口避开常见服务占用的端口80、443、3306、6379等选一个不冲突的。9000这个数字好记也不容易撞车。4.3 从单连接到多连接的改造思路上面这份代码一次只能服务一个客户端第二个客户端连上来会一直等着直到第一个断开。这显然不实用。改造方向有三个方案一多进程。每来一个连接就fork一个子进程去处理父进程继续accept。优点是隔离性好一个客户端崩溃不影响其他缺点是进程开销大连接数上不去。方案二多线程。每来一个连接就创建一个线程。比多进程轻量但线程多了切换开销也大而且共享数据要加锁。方案三IO多路复用。用select、poll或epoll一个线程管理所有连接。这是高并发的正解但代码复杂度最高。我个人的建议是连接数在几百以内多线程足够上千以上老老实实上epoll。不要一上来就追求最高级的方案先把业务跑通有性能瓶颈了再优化。5. 常见问题与排查技巧实录5.1 连接类问题速查表现象可能原因排查方法bind失败提示地址已被占用端口被占用或处于TIME_WAITnetstat -tlnp看端口占用加SO_REUSEADDR客户端连不上提示拒绝连接服务端没启动或防火墙拦截确认服务端在跑检查防火墙规则客户端连上后立即断开accept后没正确处理或程序崩溃看服务端日志检查accept返回值连接建立但收不到数据粘包问题或缓冲区处理错误打印recv返回值检查消息边界处理高并发下部分连接被拒backlog太小或somaxconn限制调大backlog检查系统参数5.2 那些年我踩过的坑坑一忘了字节序转换。我第一次写的时候端口直接填了9000没写htons。结果服务端监听的端口变成了一个奇怪的数字客户端怎么都连不上。查了半天才发现是字节序问题。记住端口用htonsIP用htonl接收端用ntohs和ntohl转回来。坑二把监听fd和连接fd搞混。有次我在循环里用listen_fd去recv结果一直返回错误。原因是listen_fd只负责接受连接不负责收发数据。收发必须用accept返回的那个新fd。这个错误很隐蔽因为编译不会报错运行时才出问题。坑三recv返回0当成错误。recv返回0表示对端正常关闭了连接这是正常情况不是错误。我一开始把它和返回-1一起处理导致日志里全是错误实际上只是客户端断开了。正确的处理是返回0就跳出循环关闭这个连接。坑四忽略send的部分发送。send不保证一次发完所有数据特别是数据量大或者网络拥塞时。我写过一个文件传输的服务端小文件没问题大文件总是传一半就断了。后来改成循环发送才解决。坑五SIGPIPE信号导致进程退出。当你想往一个已经关闭的连接写数据时系统会发送SIGPIPE信号默认行为是终止进程。这意味着一个客户端异常断开可能把你的整个服务端搞崩。解决办法是忽略这个信号signal(SIGPIPE, SIG_IGN)然后通过send的返回值判断连接是否有效。5.3 调试工具与手段排查TCP问题光看代码是不够的得借助工具。netstat查看当前所有连接状态。netstat -tlnp看监听端口netstat -tnp看已建立的连接。重点关注State列LISTEN表示在监听ESTABLISHED表示已连接TIME_WAIT表示等待关闭。tcpdump抓包工具能看到网络上实际流动的数据。用法是tcpdump -i lo port 9000 -A-A表示以ASCII显示内容。当你怀疑数据没发出去或者发错了抓包是最直接的证据。strace跟踪系统调用。strace -f ./tcp_server能看到程序调用了哪些socket相关的系统调用返回值是什么。当你不确定某个API是否被正确调用时这个工具非常好用。gdb调试崩溃问题。服务端崩溃时用gdb看core dump或者attach到进程上看堆栈能快速定位问题。实操心得我习惯在服务端每个关键步骤都加日志记录fd的值、API的返回值、errno。出问题的时候日志比任何工具都直接。日志不要怕多上线前再精简。5.4 性能优化的几个方向当你的服务端能跑通之后下一步就是让它跑得快、跑得稳。几个优化方向减少系统调用每次recv和send都是系统调用有开销。可以用readv和writev一次读写多个缓冲区或者用更大的缓冲区减少调用次数。使用epoll这是Linux下高并发的核心。epoll用红黑树管理fd用就绪链表返回事件效率不随连接数增长而下降。select和poll每次都要遍历所有fd连接多了就慢。设置TCP_NODELAY默认情况下TCP有Nagle算法会把小数据包攒起来一起发减少网络开销但增加延迟。对延迟敏感的应用比如游戏、实时通信应该设置TCP_NODELAY关闭它。调整缓冲区大小SO_RCVBUF和SO_SNDBUF控制内核收发缓冲区的大小。默认值对大多数场景够用但大带宽高延迟的场景可能需要调大。连接池与心跳频繁建立断开连接开销大可以用连接池复用。同时要有心跳机制检测死连接避免资源被僵尸连接占着。6. 从能跑到好用还差哪些功夫把上面这些串起来一个TCP服务端的完整图景就清晰了。从最基础的socket、bind、listen、accept、recv、send、close七步走到字节序转换、粘包处理、错误处理这些细节再到多连接改造、性能优化、问题排查每一层都有它的价值。我个人在实际操作中的体会是网络编程最难的不是写出能跑的代码而是理解为什么这么写。为什么要有字节序转换为什么accept要返回新fd为什么recv返回0不是错误为什么要有SO_REUSEADDR——这些为什么搞明白了遇到再奇怪的问题你都能顺着原理找到答案。最后再分享一个小技巧写TCP服务端的时候养成先写客户端测试的习惯。服务端和客户端对着写一边写一边测比写完服务端再找工具测要高效得多。客户端可以很简单几十行代码就够但它能帮你快速验证服务端的每个环节是否正常。这个习惯我保持了多年每次写网络程序都受益。
阅读完成 · 觉得有帮助?
咨询建站