
先聊点实际的东西。这几年我带过不少新人做网络编程发现很多人一上来就抱着《Unix Network Programming》硬啃结果卡在三次握手、滑动窗口这些概念里头出不来另一批人则相反直接搜一段socket代码粘上去能跑就完事等线上出现客户端连不上、服务端卡死、数据对不上就完全不知道怎么下手。所以这次我打算换个思路把网络编程这条学习路径完整捋一遍从协议栈到底层API再从单一连接到并发处理最后落到真实故障排查。整个过程中我会把C/C的socket编程作为主线因为这套接口是所有语言网络库的地基你把这个搞明白了再去写Go、Java、Python的网络程序基本就是换层皮的事。这篇文章的主要目标是让一个只有基础编程能力、对网络协议没什么概念的同学能照着思路把可靠的服务端和客户端写出来同时遇到连不上、断开、粘包这类经典问题时有完整的排查思路。1. 理论是骨架先搞懂数据怎么在网络里流动1.1 从一次HTTP请求看网络通信的本质咱们不用堆术语。你每天打开浏览器访问网页背后发生的事简化下来就三步你的机器把一个“请求”数据块交给操作系统操作系统通过网卡发出去中间经过路由器、交换机数据最终到达服务器服务器解析请求把“响应”数据块原路送回来。这里有个关键认知通信的两头是进程不是机器。你电脑上的浏览器是一个进程服务器上的Nginx或者你写的自定义程序是另一个进程。所谓的网络编程本质上就是让你写的进程能够和另一台机器上的进程交换数据。而交换数据的通道就是socket。很多初学者把socket当成一个很玄的东西其实它就是一个文件描述符操作系统给你的程序开了一个“口子”你往这个口子里写数据数据就会通过网络去往对端对端从它的口子里读数据就能拿到你发的内容。这个概念我经常跟新人打比方socket就像一根两端都装了话筒和听筒的电话线你对着话筒说话对端从听筒听到对端说话你从听筒听到就这么简单。1.2 分层模型为什么要把网络拆成好几层网络通信涉及到的环节太多所以标准做法是分层。你只需要记住最实用的四层模型就够了层级职责常见协议/工具你写代码时接触到的位置应用层规定数据格式和业务语义HTTP、FTP、自定义协议你直接把数据交给socket发送传输层提供端到端的可靠或不可靠传输TCP、UDPsocket类型的选择SOCK_STREAM还是SOCK_DGRAM网络层负责寻址和路由把数据从一台机器送到另一台IP、ICMP你不用直接处理由内核完成链路层物理网络传输以太网、Wi-Fi网卡驱动分层的好处是每层只需要关注自己的事。你写应用层代码时不用操心数据要怎么拆成帧、怎么走路由内核里的协议栈全帮你处理了。但是有一个例外就是传输层的TCP是面向连接的连接状态的建立和断开你写代码时必须手动参与这就是下面要重点讲的内容。1.3 谈一谈TCP与UDP怎么选很多初学者在选socket类型的时候很随意这里给个判断标准如果你的业务要求数据必须完整到达、顺序不能乱而且能容忍一定的延迟选TCP如果数据量大、对实时性要求高、丢失几条也不致命选UDP。举几个实际的例子文件传输、数据库同步、HTTP请求这些都是TCP的天下视频通话、在线游戏的位置同步、日志上报UDP更合适。我自己做游戏服务器时移动同步用UDP登录和充值走TCP这种混合架构非常常见。为什么TCP能保证可靠因为它有三个核心机制确认应答ACK、超时重传、序号排序。你发一个数据包对端收到了会回一个ACK超时没收到就重发接收方发现序号乱序会重新排好再交给应用层。UDP就没这些机制数据发出去就不管了所以快但可能丢、可能乱。2. 连接管理TCP三次握手和四次挥手不只是面试题2.1 三次握手建立连接为什么非要三次TCP建立连接的流程是客户端先发SYN服务端回SYNACK客户端再回ACK然后连接建立。这个过程叫三次握手。很多教材把这个讲得复杂什么ISN序列号、半连接队列但你要是实在理解不了可以这样想你要给一个陌生人打电话确认通话是否畅通最稳妥的方式就是双方各确认一次“我能听到你”。第一次你打过去说“喂能听到吗”对方回“能听到你能听到我吗”你再回“能听到”。到这里双方都确认了彼此的收发通道是正常的这其实就是三次握手干的活儿。代码层面要注意三次握手是内核自动完成的你在C/C代码里调用connect()和accept()时握手已经完成或正在进行。但有几个关键状态你必须知道服务器上会出现SYN_RECV状态表示收到SYN但还没完成握手正常连接建立后是ESTABLISHED。如果SYN_RECV大量堆积说明有客户端在恶意或无意地只发不完整握手包这在线上是常见的故障点。2.2 四次挥手断开连接为什么是四次断开的时候TCP会走四次挥手主动方发FIN被动方回ACK被动方也发FIN主动方再回ACK。比三次握手多一次原因是TCP允许半关闭——一端关闭发送但还能继续接收。我举个例子客户端要断开连接时先发FIN表示“我不再发数据了”服务端回ACK表示“我知道了”但服务端可能还有数据没发完等它把剩余数据全发完再发FIN表示“我也发完了”客户端回最后一个ACK连接就彻底关闭。这里有一个所有服务端开发者都会遇到的坑主动关闭连接的那一端会进入一个叫TIME_WAIT的状态持续大约2分钟实际是2个MSL即报文最大生存时间。之所以要等2分钟是为了防止最后一个ACK丢失对端重发FIN时还能响应同时保证旧连接的延迟数据包在网络中消逝不会干扰新连接。2.3 服务端大量TIME_WAIT和大量CLOSE_WAIT怎么处理这个知识点必须结合实际来看线上排查时这两个状态几乎是必定会碰到的。TIME_WAIT多说明你的服务端主动关闭了大量连接。短连接场景下这很正常但如果量太大会占用本地端口导致无法发起新连接。对策有几种短连接改长连接、开启端口复用SO_REUSEADDR、调整内核参数来缩短TIME_WAIT时间。不过我要提醒一句改内核参数要谨慎TIME_WAIT本身是安全机制盲目改短可能引发数据错乱。CLOSE_WAIT多绝大多数原因是你程序里的bug不是网络问题。CLOSE_WAIT是对端先发起关闭、你被动关闭时你还没有调用close()关掉自己的socket。说白了就是对方已经说“再见”了你的程序没处理socket就一直挂着。这种故障的排查思路不是调内核参数而是去查你的读循环recv()返回0之后有没有及时关闭socket、释放资源。3. 从理论到代码写一个最小可用的TCP服务端和客户端3.1 环境准备与基础工具实操之前先准备好环境。我自己常用的组合是Linux GCC GDB Wireshark如果你在Windows上开发WSL或者Visual Studio也能完成大部分实验。不需要装额外的第三方库socket编程用的全是操作系统提供的API。Linux下编译时加上-lpthread涉及多线程时要加然后直接gcc server.c -o server就行。调试工具方面我强烈建议你把这三样装好netstat或ss用来查端口和连接状态tcpdump用来抓包分析流量Wireshark用来做可视化分析。准备一个简单的目录结构network-programming/ ├── server.c ├── client.c ├── Makefile └── README.md3.2 服务端骨架socket、bind、listen、accept一条龙TCP服务端的基本流程是固定的一共五步socket创建套接字、bind绑定地址和端口、listen进入监听状态、accept接受客户端连接、recv/send收发数据。整个流程就像开一家店你租个门面socket、挂上招牌地址bind、开门营业listen、来了客人接待accept、然后跟客人聊天做生意recv/send。下面这份代码是一个最小可用的回声服务端客户端发什么它就原样回什么#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8899 #define BUFFER_SIZE 1024 int main() { int server_fd, client_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len sizeof(client_addr); char buffer[BUFFER_SIZE] {0}; // 1. 创建套接字 server_fd socket(AF_INET, SOCK_STREAM, 0); if (server_fd 0) { perror(socket create failed); exit(EXIT_FAILURE); } // 2. 设置端口复用强烈建议加上 int opt 1; if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)) 0) { perror(setsockopt failed); exit(EXIT_FAILURE); } // 3. 绑定地址和端口 memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); if (bind(server_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(bind failed); exit(EXIT_FAILURE); } // 4. 进入监听状态backlog设为5 if (listen(server_fd, 5) 0) { perror(listen failed); exit(EXIT_FAILURE); } printf(server listening on port %d\n, PORT); while (1) { // 5. 接受客户端连接 client_fd accept(server_fd, (struct sockaddr*)client_addr, client_len); if (client_fd 0) { perror(accept failed); continue; } printf(new client connected: %s:%d\n, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port)); // 6. 收发数据这里只处理一次收发 int bytes recv(client_fd, buffer, BUFFER_SIZE, 0); if (bytes 0) { printf(received %d bytes: %s\n, bytes, buffer); send(client_fd, buffer, bytes, 0); } close(client_fd); memset(buffer, 0, BUFFER_SIZE); } close(server_fd); return 0; }代码分几个关键点讲SO_REUSEADDR这个选项一定要加。不加的话服务端崩溃后重新启动时经常会报Address already in use原因就是TIME_WAIT还没结束。加上这个选项你就能立刻重新绑定端口开发调试时能省下大量等待时间。htonl和htons是字节序转换函数。网络字节序是大端序而我们常用的x86机器是小端序端口号是多字节数字必须转成网络字节序再发送。忘了做转换的典型症状是bind端口8099结果客户端连接时显示端口是10044之类的奇怪数字。accept()会阻塞一直等到有客户端连上来才返回。这个阻塞行为接下来要重点讨论因为它关系到你服务端能不能同时服务多个客户端。3.3 客户端骨架socket、connect、send/recv客户端更简单三步socket创建、connect连接服务器、send/recv收发数据。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_IP 127.0.0.1 #define SERVER_PORT 8899 int main() { int sock_fd; struct sockaddr_in server_addr; char buffer[1024] {0}; sock_fd socket(AF_INET, SOCK_STREAM, 0); if (sock_fd 0) { perror(socket create failed); exit(EXIT_FAILURE); } memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); if (inet_pton(AF_INET, SERVER_IP, server_addr.sin_addr) 0) { perror(invalid server ip); exit(EXIT_FAILURE); } if (connect(sock_fd, (struct sockaddr*)server_addr, sizeof(server_addr)) 0) { perror(connect failed); exit(EXIT_FAILURE); } printf(connected to server\n); while (1) { printf(please input: ); fgets(buffer, sizeof(buffer), stdin); buffer[strcspn(buffer, \n)] 0; if (strcmp(buffer, quit) 0) { break; } send(sock_fd, buffer, strlen(buffer), 0); memset(buffer, 0, sizeof(buffer)); int bytes recv(sock_fd, buffer, sizeof(buffer), 0); if (bytes 0) { printf(server reply: %s\n, buffer); } memset(buffer, 0, sizeof(buffer)); } close(sock_fd); return 0; }inet_pton用来把点分十进制的IP地址转换成二进制的网络地址它是inet_addr的替代品推荐用这个而不是旧的inet_addr前者兼容IPv6且错误处理更明确。connect()返回0表示连接成功如果服务器根本没启动、防火墙拦截、IP填错都会返回-1配合perror能看到具体错误信息。最常出现的就是Connection refused意思是根本没有进程在那个端口监听。编译运行的方式# 服务端 gcc server.c -o server ./server # 客户端另开一个终端 gcc client.c -o client ./client实测一下客户端输入hello会收到服务器的回包。到这里你已经跑通了一个最基础的TCP回环。4. 从能跑到能扛并发处理是网络编程的分水岭4.1 单线程阻塞模型为什么撑不住上一节的echo服务端有个致命缺陷它是单线程的accept()接完一个客户端就进入recv()去等这个客户端的数据。在这期间新的客户端连上来只能在内核的完成队列里排队等着。一旦某个客户端不发数据也不断开服务端就被卡死在那里其他所有客户端都连不上。这就是典型的阻塞式I/O模型。它适合教学但不适合生产。要实现真正的并发服务必须做到“同时处理多个连接”。4.2 多线程模型一个连接一个线程最简单的改进方案就是多线程主循环里accept()接到一个客户端就创建一个线程去处理这个连接的收发主线程马上回到accept()继续等新连接。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include pthread.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8899 #define BUFFER_SIZE 1024 void* handle_client(void* arg) { int client_fd *(int*)arg; free(arg); char buffer[BUFFER_SIZE] {0}; int bytes; while ((bytes recv(client_fd, buffer, BUFFER_SIZE, 0)) 0) { printf(received: %s\n, buffer); send(client_fd, buffer, bytes, 0); memset(buffer, 0, BUFFER_SIZE); } close(client_fd); printf(client disconnected\n); return NULL; } int main() { int server_fd, client_fd; struct sockaddr_in server_addr; pthread_t tid; server_fd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); bind(server_fd, (struct sockaddr*)server_addr, sizeof(server_addr)); listen(server_fd, 10); printf(multi-thread server listening on %d\n, PORT); while (1) { client_fd accept(server_fd, NULL, NULL); if (client_fd 0) { perror(accept failed); continue; } int* pclient malloc(sizeof(int)); *pclient client_fd; pthread_create(tid, NULL, handle_client, pclient); pthread_detach(tid); } close(server_fd); return 0; }这里的几个细节值得注意pthread_detach(tid)一定要用。如果不detach线程结束后资源不会自动回收长时间运行会产生大量僵尸线程最终导致无法创建新线程。int* pclient malloc(...)不是多此一举。如果把client_fd直接传给线程下一个客户端连接到来时client_fd变量被重新赋值前面线程里读到的fd就已经错了。正确的做法是把fd放到一个独立的堆内存空间线程自己负责释放。多线程模型能同时处理的连接数受线程数限制。一个进程默认的线程栈是8MB开1000个线程光栈内存就要8GB所以这个模型撑几百个连接没问题上万连接就基本不现实了。4.3 事件驱动与I/O多路复用用单线程管理海量连接真正高性能的服务端靠的是I/O多路复用。核心思想只有一句话一个线程同时监视多个socket哪个socket就绪了就处理哪个。Linux下实现多路复用的API有三个发展阶段select、poll、epoll。select有1024个fd上限每次调用都要把整个fd集合从用户态拷贝到内核态性能很差poll解决了数量限制但每次还是要全量拷贝epoll做了一次彻底的改进内核直接维护一个事件表用户态只需要注册要监视的fd然后等待内核通知“哪些fd就绪”就行复杂度从O(n)降到了O(1)。下面用epoll重写一版支持多连接的服务端这是目前Linux高并发服务的主流方案#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/epoll.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define PORT 8899 #define MAX_EVENTS 1024 #define BUFFER_SIZE 1024 int main() { int server_fd, epoll_fd; struct sockaddr_in server_addr; struct epoll_event ev, events[MAX_EVENTS]; server_fd socket(AF_INET, SOCK_STREAM, 0); int opt 1; setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_addr.s_addr htonl(INADDR_ANY); server_addr.sin_port htons(PORT); bind(server_fd, (struct sockaddr*)server_addr, sizeof(server_addr)); listen(server_fd, 32); epoll_fd epoll_create1(0); if (epoll_fd 0) { perror(epoll create failed); exit(EXIT_FAILURE); } ev.events EPOLLIN; ev.data.fd server_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, ev); printf(epoll server listening on %d\n, PORT); while (1) { int n epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { if (events[i].data.fd server_fd) { // 新连接 int client_fd accept(server_fd, NULL, NULL); ev.events EPOLLIN | EPOLLET; // 边缘触发要仔细阅读后面的说明 ev.data.fd client_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, ev); printf(new client connected: %d\n, client_fd); } else { // 客户端可读 int client_fd events[i].data.fd; char buffer[BUFFER_SIZE] {0}; int bytes recv(client_fd, buffer, BUFFER_SIZE, 0); if (bytes 0) { // 连接关闭或出错 close(client_fd); printf(client %d disconnected\n, client_fd); } else { printf(client %d says: %s\n, client_fd, buffer); send(client_fd, buffer, bytes, 0); } } } } close(server_fd); return 0; }这段代码里EPOLLET是边缘触发模式。很多新人在这里踩坑边缘触发模式下当一个fd变得可读时内核只通知一次如果你没有一次性地把数据读完剩余数据直到新数据到来前都不会再通知你。所以使用边缘触发时读数据必须用循环读到EAGAIN为止。// 边缘触发模式下循环读数据直到读空 while (1) { int bytes recv(client_fd, buffer, BUFFER_SIZE, 0); if (bytes 0) { // 处理数据 } else if (bytes 0) { // 对端关闭 close(client_fd); break; } else { if (errno EAGAIN || errno EWOULDBLOCK) { // 数据全部读完了 break; } // 真正的错误 perror(recv error); close(client_fd); break; } }如果不想处理这种细节可以把EPOLLET去掉用默认的水平触发模式LT。水平触发模式下只要fd还有数据没读完epoll_wait每次都会通知你代码不容易出bug代价是多几次系统调用。我的建议是新手先用水平触发等吃透了再玩边缘触发。4.4 回调里的崩溃为什么难排查非阻塞与阻塞的辨析一个必须搞清楚的概念是使用epoll不等于你就自动拥有了高性能。epoll只解决“同时监视大量fd”的问题但recv()是阻塞的还是非阻塞的是另外一回事。在默认情况下socket是阻塞的。这意味着如果客户端连接后一直不发数据recv()会一直卡住epoll监听到的可读事件是对的还是错的其实这里有个很容易混淆的细节我简单梳理一下epoll_wait返回可读只能说明此刻有数据可读但如果你用的是阻塞socket在调用recv()时遇到恰好数据已经被其他线程拿走等情况它照样会卡住。所以生产级的epoll服务端通常会把所有客户端socket设置为非阻塞模式配合边缘触发使用。用fcntl设置非阻塞int flags fcntl(client_fd, F_GETFL, 0); fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);非阻塞模式下recv()如果没有数据可读不会卡住而是立刻返回-1同时errno被设置为EAGAIN。代码逻辑里必须对这种情况做特殊处理否则你的程序会误以为连接出错了。5. 粘包、拆包与协议设计的实战之路5.1 为什么好好的数据到了对端就“粘”成一团写网络程序绕不开粘包问题。我先说结论TCP是流式协议它只保证字节的传输顺序不保证消息边界。你调用两次send()发了两条消息对端可能只用一次recv()就把两段数据一起读出来这就是粘包反过来一条大消息被分成多次recv()才读完就叫拆包。用生活中的例子理解TCP就像一条自来水管你把两杯水倒进去对端打开水龙头接到的还是一整桶水你没法从水里分辨哪部分是第一杯、哪部分是第二杯。那UDP为什么没有粘包问题因为UDP是数据报协议每次sendto()就是一个完整报文recvfrom()一次读取一个完整报文边界由协议自身保证。但UDP的代价就是前面说的不可靠、可能乱序。5.2 三个方案解决粘包问题既然TCP不保留边界那我们就得应用层自己定边界。常用的方案有三类方案一消息长度前缀最推荐的做法在每个消息前加上固定字节数的长度字段。比如前4个字节用网络字节序表示后面消息体的长度接收方先读够4个字节解析出长度再按这个长度去读消息体。这是一种典型的TLVType-Length-Value编码思路。方案二特殊的结束符每条消息以换行符\n或特殊字符串结尾接收方持续读到结束符就认为一条消息完整了。HTTP协议的头部用的就是这种思路用\r\n\r\n标识头结束。缺点是一旦消息体本身包含结束符就需要转义增加了复杂度。方案三固定长度每条消息定死长度不足用空字符补齐。实现最简单但浪费带宽适合消息结构非常固定的场景。我实际开发中最常用的是方案一下面给一个简单的封包和解包函数作为参考#include stdint.h // 封包在消息体前加上4字节长度 int pack_message(char* data, int data_len, char* output, int output_size) { if (output_size data_len 4) return -1; uint32_t net_len htonl(data_len); memcpy(output, net_len, 4); memcpy(output 4, data, data_len); return data_len 4; } // 解包缓冲区返回完整消息条数和已消费的字节数 int parse_buffer(char* buffer, int buffer_len, char* out_messages[], int max_msgs) { int offset 0; int count 0; while (offset 4 buffer_len) { uint32_t net_len; memcpy(net_len, buffer offset, 4); int msg_len ntohl(net_len); if (offset 4 msg_len buffer_len) { break; // 数据还没收完整等待后续数据 } out_messages[count] buffer offset 4; offset 4 msg_len; if (count max_msgs) break; } return offset; // 返回已经消费的字节数 }使用时要配合一个应用层缓冲区每次recv()到的数据先追加到缓冲区尾部然后再用parse_buffer去解析解析不了的残包数据留在缓冲区里等下一轮数据到了再继续解析。5.3 协议设计里容易忽略的坑除了粘包协议设计还有几个高频踩坑点。第一个是字节序。长度字段转成网络字节序是必须的。如果你发送端和接收端用了不同的转换方式长度值就会变成天文数字解包直接失败。我在联调时遇到过好几次排查到最后都是某处少写了一个ntohl()。第二个是限制消息体大小。永远不要相信消息里的长度字段。如果攻击者故意发一个0xFFFFFFFF的长度你的程序就会尝试分配4GB内存直接崩溃。所以解析时一定要设置一个合理上限比如1MB超过就直接断开连接这是一种常见的安全措施。第三个是半包处理的正确性。新手常见的错误是recv返回多少就认为这是完整消息直接往业务层丢。在真实网络环境下一次send很可能被拆成多个TCP段传输对端recv到的只是其中一部分所以接收端必须维护残包状态不能假设一次recv就是一条完整消息。这也是为什么很多学习网络编程的人写的示例程序在局域网里一切正常一跨公网就出现各种诡异问题。6. 排查网络问题的利器6.1 连接类问题的快速定位路径线上出了问题别急着猜按下面的路径一步步排查第一步确认端口在监听用ss -lntp或netstat -lntp查看端口是否处于LISTEN状态。如果端口没开检查服务进程是否还在、是否绑定的是其他端口。第二步从客户端角度重现连接用telnet ip port测试端口连通性。连不上时报错信息非常重要Connection refused说明端口没有进程监听No route to host说明网络路由有问题或IP不可达一直卡住不报错可能是防火墙丢包。第三步抓包看握手过程用tcpdump -i any port 8899抓包看三次握手的SYN、SYNACK、ACK是否完整。只有客户端发SYN、服务端不回应说明服务端有问题或者防火墙拦截了入包如果SYN重传了好几次都没回应多半是服务端半连接队列被占满了。6.2 高频故障速查表现象可能原因排查方式服务端重启报Address already in use旧连接TIME_WAIT未结束加SO_REUSEADDR客户端连接被拒绝服务端未启动端口未监听确认进程、监听端口连接建立了但发不了数据对端未调用recv或缓冲区已满抓包查窗口、日志查recv服务端连接数涨不上去文件描述符限制或线程数限制ulimit -n调整fd上限recv返回0对端正常关闭了连接代码里判断0并回收资源recv返回-1且errnoEAGAIN非阻塞模式下数据读完了结束本轮读取不能报错recv返回-1且errnoECONNRESET对端异常重启或崩溃检查对端进程send返回-1且errnoEPIPE对端已关闭继续发送触发SIGPIPE忽略或处理SIGPIPE信号特别说一下SIGPIPE这个问题。服务端向一个已经关闭的socket继续写数据时操作系统会向进程发送SIGPIPE信号默认行为是终止进程。很多线上服务莫名崩溃日志里什么异常都没有就是被这个信号干掉的。处理方式是忽略它#include signal.h signal(SIGPIPE, SIG_IGN);6.3 结合抓包逆向理解协议理论排查问题不只是为了解决问题更是加深理解的绝佳机会。我自己带新人时让他们做的第一个练习不是写代码而是用Wireshark抓一个完整HTTP请求的包对照着三次握手的颜色标记、数据包的序号变化亲眼验证TCP的确认应答机制。当你看到客户端发一个HTTP GET请求服务端回复的ACK包里携带着TCP Window Full之类的信息就能真切感受到TCP拥塞控制不是书本上干巴巴的概念。这里给一个我调试时常用的小技巧tcpdump -i any port 8899 -w /tmp/capture.pcap先抓包保存到文件再用Wireshark打开分析。这样比直接看终端输出更直观因为Wireshark会自动标识出TCP握手、挥手、重传、乱序等各种事件。7. 还有一些写在最后的话这篇文章把网络编程的理论和实操串了一遍如果只能记住几个要点我建议你记住这些第一所有网络问题最后都能从协议状态和数据包找到答案。服务器连不上、数据丢了、性能上不去不要靠猜先看ss的状态、先抓包。第二代码里对错误处理的重视程度一定要高于对正常流程的重视程度。我见过太多只处理正常收发、完全不处理异常分支的代码上了生产环境就是定时炸弹。第三不要急着上复杂框架自己用socket写一遍阻塞版、多线程版、epoll版你才能真正理解那些框架替你做了什么。最后分享一个提升实战能力的方法找一份真实协议文档比如Redis的RESP协议、或者WebSocket的帧格式不要看任何现成代码尝试自己从零实现一个客户端或者一个简化服务端。这个过程中你会踩遍粘包、断线重连、并发竞争、缓冲区管理这些坑踩一遍踩明白了网络编程这关就算真正过了。