1. 网络编程到底在学什么
很多朋友一提到“Linux网络编程”就头皮发麻,觉得这是大佬才配碰的东西。其实换个角度想,你天天用的微信、刷的网页、连的数据库,底层全是同一套玩意儿——socket。我做了这么多年后端,带过不少新人,发现大家卡住的点根本不在语法,而在“不知道代码跑起来以后,操作系统到底干了什么”。
这一篇我打算用执行顺序来讲透Linux网络编程的完整链路:从socket的创建,到bind、listen、accept,再到收发数据的read/write,最后把阻塞、并发、IO多路复用这些绕不开的坎儿一次说清。每段代码都配了“为什么这么写”的说明,不是我抄书,是这些年真在金拱门一样的生产环境里踩坑踩出来的经验。
如果你是刚开始学Linux网络编程,或者写过一点socket但总是“能跑不知道为什么能跑”,这篇应该能帮你把整张地图拼完整。内容偏实操,所有代码都基于Linux环境,gcc直接编译就能跑。涉及多线程和IO多路复用的地方我会标注重点,因为这些才是真正决定你服务器能不能扛住压力的分水岭。
2. 从socket到连接建立:核心链路拆解
2.1 为什么一切从socket开始
socket在中文里常被翻译成“套接字”,听着很玄,其实它就是一个文件描述符。Linux里有个核心哲学:一切皆文件。网络连接也被抽象成文件,你往这个“文件”里写数据,内核帮你通过网卡发出去;别人发来的数据,内核放到这个文件的缓冲区里,你read就能读出来。
这个设计的好处是,你可以用操作普通文件的read/write接口去操作网络连接,上层API统一了,底层逻辑各干各的。我见过不少新手纠结“socket到底在哪一层”,其实不用纠结,你只要记住:socket是应用层和内核网络协议栈之间的门把手。应用程序创建socket,其实是在内核里申请一个网络端点,内核返回给你一个整数(fd),以后所有操作都靠这个整数来指代这次网络通信。
拿生活中打电话来类比:socket就是你的电话机,bind是给自己的电话机绑定一个号码(IP和端口),listen是让电话机进入待机状态,accept是听到铃声后拿起听筒。这样一想,整个流程就顺了。
2.2 服务端第一步:socket与bind的细节
服务端要提供网络服务,第一步一定是创建socket,然后绑定地址。直接看代码:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } struct sockaddr_in server_addr; 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(8080); int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(listen_fd); exit(EXIT_FAILURE); } printf("socket created and bound to port 8080\n"); return 0; }这段代码里有几个地方值得细说。首先是socket(AF_INET, SOCK_STREAM, 0),AF_INET表示IPv4地址族,SOCK_STREAM表示TCP流式套接字,最后一个0表示让内核自动选择协议(TCP)。你要是写UDP,把SOCK_STREAM换成SOCK_DGRAM就行。
然后是htons和htonl,这两个函数专门处理字节序。网络字节序统一是大端的,你的机器可能是小端,所以端口号和IP地址在填入结构体之前必须转换。不转会出现什么情况?你绑定的端口明明是8080,结果实际监听的是0x1F90反过来变成32840,连都连不上。这块出错不报明显异常,排查起来最坑。
SO_REUSEADDR这个选项是经验之谈。如果没有它,服务端程序崩溃重启后,内核可能还认为端口被占用,你会看到“Address already in use”。开发调试时加了它,可以秒级重启服务,不用等TIME_WAIT状态结束。生产环境我也建议保留,配合平滑重启非常有用。
bind的作用是把这个socket和本机的IP、端口关联起来。INADDR_ANY表示监听本机所有网卡地址,也就是说无论客户端连你哪个IP,只要端口对,都能到达这个socket。如果只想让特定IP访问,这里可以填具体地址,比如inet_addr("192.168.1.100")。
2.3 listen和accept:连接队列怎么工作
bind完成之后,socket还只是一个“号码已分配但未接通”的电话机,要让别人能打进来,必须调用listen。listen的本质是在内核里为该socket建立两个队列:半连接队列(SYN队列)和全连接队列(accept队列)。
半连接队列存放的是已完成TCP三次握手第一步(收到SYN)、但还没完成握手的连接;全连接队列存放的是三次握手已完成、等待应用层accept取走的连接。内核自动帮你维护,你不需要操作队列本身,但理解这个机制对后面排查“连接堆积”问题特别有帮助。
继续写代码:
if (listen(listen_fd, 128) < 0) { perror("listen"); close(listen_fd); exit(EXIT_FAILURE); } printf("listening on port 8080...\n"); while (1) { struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len); if (conn_fd < 0) { perror("accept"); continue; } char client_ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN); printf("accept connection from %s:%d\n", client_ip, ntohs(client_addr.sin_port)); close(conn_fd); }listen的第二个参数是backlog,表示全连接队列的最大长度。传统上认为这是“等待accept的连接数上限”,但实际上Linux 2.2以后,它代表全连接队列的长度,半连接队列的长度由内核参数tcp_max_syn_backlog控制。你填128,意思是内核最多帮你缓存128个已完成握手但还没被accept的连接。
accept是个阻塞调用,如果没有新连接到达,这个函数会一直卡住,直到有客户端连入。它返回的是一个“新socket”的fd,这个fd专门用于和当前这个客户端通信。监听socket要留在原地继续accept下一个连接,这里一对多、一对一的角色要分清:listen_fd是永远监听的,conn_fd才是真正干活的。
我在很多教学代码里看到有人用单线程循环,accept一个处理一个,处理完再accept。这种模型只适合演示,真上线会被性能吊打。原因很简单:如果某个客户端连上之后半天不发送数据,服务端就会一直阻塞在读操作上,后面的客户端全部排队等待,整个服务等于瘫痪。这就引出下一节的内容:如何处理多个连接。
3. 收发数据:read、write与缓冲区机制
3.1 阻塞与非阻塞:理解socket的脾气
默认创建的socket是阻塞模式。阻塞的意思是:当你调用read去读数据,如果内核缓冲区里没有数据,这个线程就睡在那儿,直到有数据到达或者对端关闭连接才返回。同理,write如果发送缓冲区满了,也会阻塞等待腾出空间。
这种模式对单客户端场景非常友好,代码简单、逻辑清晰。但多客户端场景下,一个阻塞read就可能让整个进程卡死,因为CPU不知道哪个客户端会先发数据。这不是代码写得不对,而是模型选错了。
为什么阻塞模式让新手又爱又恨?爱在于简单,不爱在于坑多。解决办法有几种:把socket设置成非阻塞模式,配合poll/epoll使用;或者用多线程/多进程,每个连接一个线程去阻塞读写;再或者用IO多路复用,让一个线程管理成千上万个连接。
从实践经验看,初学者应该先吃透阻塞模式,因为非阻塞+epoll只是在阻塞模式的基础上增加了一层事件通知机制,底层数据读写逻辑并没有变。先把read/write搞明白,再谈高并发,节奏才不会乱。
3.2 为什么read返回值必须判断
服务端accept到客户端连接后,就要进入数据收发环节。看一段经典的echo服务代码:
char buf[1024]; while (1) { memset(buf, 0, sizeof(buf)); ssize_t n = read(conn_fd, buf, sizeof(buf) - 1); if (n < 0) { perror("read"); break; } else if (n == 0) { printf("client closed connection\n"); break; } printf("received: %s\n", buf); write(conn_fd, buf, n); }read返回值是这里最关键的判断依据。返回值大于0:读到了n个字节,这就是正常数据;返回值等于0:对端关闭了连接,你这边继续read只会永远返回0,必须关闭socket;返回值小于0:出错,但是要分情况,如果errno是EINTR(被信号打断),可以继续读,如果是EAGAIN或EWOULDBLOCK(非阻塞模式下无数据),也是正常情况,不能当错误处理。
新手最容易犯的错误是只判断n < 0,不判断n == 0,结果客户端断开后服务端陷入死循环,CPU飙升还找不到原因。其实加一个n == 0的break就能解决的事,能省一天排查时间。
write也有门道。你以为write返回n就代表n个字节全部发送成功,其实不然。在阻塞模式下,write返回等于请求发送的字节数,一般就是全部发出去了。但在非阻塞模式下,write可能只发送一部分数据就返回,返回的数值小于你传入的长度,剩下的字节需要你放进应用层缓冲区,等socket可写时继续发送。生产环境经常要配合一个应用层的发送队列来做这件事,不能直接把大块数据硬塞给write。
3.3 粘包问题:TCP是流不是消息
很多从UDP转过来的人会踩同一个坑:第一次send一个“hello”,第二次send一个“world”,结果对端一次recv收到了“helloworld”。这是因为TCP是字节流协议,它只保证字节的顺序,不保证消息的边界。两次send的数据可能被内核合并成一个数据段发出去,对端自然就读到一起了。
解决粘包没有银弹,常规做法有三种:
- 固定长度:每个消息固定是4字节或8字节,不足补位,简单但浪费空间。
- 分隔符:消息之间加特殊分隔符,比如HTTP的
\r\n\r\n,实现简单但要处理数据中恰好出现分隔符的情况。 - 长度前缀:每个消息头用固定字节数声明消息体长度,比如前4字节存长度,后面跟消息体,服务端先读4字节,再读对应长度的数据。
生产环境最常用的是长度前缀。拿JSON协议举例,一般会设计成“4字节长度 + JSON字符串”。接收端先读满4字节,转成int就知道后面要读多少,然后循环读直到读够,再解析JSON。这里要注意一个细节:read不一定一次就能读够你要求的长度,必须循环读,直到累计长度达到消息头声明的字节数。很多人在这块写了个单次read就以为收完整了,数据一多就出现半包问题。
4. 走进多并发:从多进程到epoll
4.1 多进程模型与多线程模型的取舍
早期的网络服务器喜欢用多进程模型,一个连接fork一个子进程。父进程负责accept,把连接fd传给子进程,子进程阻塞读写,处理完退出。这种模型的优点是完全隔离,一个子进程崩了不影响别人;缺点是开销大,进程切换成本高,而且进程数量一多,系统资源就被吃光。
后来流行多线程模型,一个连接创建一个线程。线程比进程轻量,共享地址空间,通信方便,但线程不安全问题也随之而来,比如两个线程同时对同一个fd调用close,可能把别人正在用的连接关掉。线程池是对多线程模型的一种优化:提前创建一批线程,任务队列里来了连接就分给空闲线程处理,避免了频繁创建销毁线程的开销。
不过无论是多进程还是多线程,本质上还是“一连接一线程”的思路,连接数上万以后,线程上下文切换的开销会成为瓶颈。你的机器可能只有几十个核,但连接有十万个,线程切换就会消耗大量CPU在调度上,真正干活的CPU时间比例会很低。C10K问题就是这么来的——如何用单线程管理一万个并发连接。
4.2 IO多路复用:select的缺陷与poll的改进
IO多路复用解决的核心问题,是让一个线程同时监视多个fd,哪个fd有数据可读,就去处理哪个。这样一来,无论连接有多少,线程数都能保持稳定。
select是最早的方案,使用方式是在一个fd集合上做轮询,内核告诉你哪些fd可读可写。但它有三个明显的槽点:第一,fd集合大小有限制,FD_SETSIZE通常是1024,超过这个范围就没法监视;第二,每次调用select都要从用户态把整个fd集合拷贝到内核态,连接多了开销很大;第三,返回后你需要遍历整个集合,逐个判断是哪个fd就绪了,这个复杂度是O(n)的,连接数上万时纯遍历就慢得离谱。
poll的改进是去掉了1024的限制,改用链表结构管理fd,但仍然逃不掉“每次全量拷贝+全量遍历”的宿命。所以在poll和select时代,上万并发基本是极限。
4.3 epoll:Linux高并发的真正答案
epoll是Linux特有的IO多路复用方案,也是目前高性能网络库的基石(Nginx、Redis、Netty底层都是这一套思路)。
epoll的三个关键接口:
int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);epoll_create在内核创建一个事件表,返回一个fd。epoll_ctl负责往这个事件表里添加、修改、删除你要监视的fd,op分别对应EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL。epoll_wait是真正的等待函数,传入一个事件数组,内核把就绪的事件填进去并返回就绪个数,你只需要遍历这个返回的小数组即可,复杂度是O(k),k是就绪的事件数,而不是总连接数。
epoll的高明之处在于,它把fd集合留在内核空间维护,不需要每次调用都从用户态拷贝全量数据;同时采用回调机制,当fd有数据到达时,内核主动把该fd放入就绪链表,epoll_wait只需要从就绪链表里取数据,不用重新扫描全量集合。这就是epoll在大规模连接下依然能保持高性能的根本原因。
再来看看epoll在服务端怎么做:
#define MAX_EVENTS 1024 int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // ... bind和listen的代码同前 ... int epfd = epoll_create(1); struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); while (1) { int nready = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < nready; i++) { int fd = events[i].data.fd; if (fd == listen_fd) { // 有新的客户端连接 struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len); ev.events = EPOLLIN; ev.data.fd = conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev); printf("new connection: %d\n", conn_fd); } else { // 已有连接有数据到达 char buf[1024]; ssize_t n = read(fd, buf, sizeof(buf)); if (n <= 0) { close(fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); } else { write(fd, buf, n); } } } } }这段代码里,事件类型EPOLLIN表示可读事件。监听socket关注EPOLLIN,因为新连接到达时,监听socket会变为可读;连接socket也关注EPOLLIN,因为客户端发数据时,连接socket会变为可读。epoll_wait返回后,先判断是哪个fd就绪,如果是监听fd就accept,把新连接加入事件表;如果是连接fd就read并处理数据。
这套模型有几个必须注意的点:
- 监听socket和连接socket的事件不要混淆。连接socket不应该注册EPOLLIN之外的那些莫名其妙事件,否则内核会不停通知你,CPU白白消耗。
- accept返回的conn_fd默认是阻塞模式,但在epoll托管下,逻辑上其实不需要阻塞,因为内核已经告诉你“这个fd有数据可读”。不过为了万无一失,通常会把conn_fd设置成非阻塞,防止极端情况下read时数据被别的线程读完,导致阻塞卡住。
- 客户端断开时,epoll会返回EPOLLIN或EPOLLRDHUP事件,你需要通过
read(fd, buf, sizeof(buf))的返回值来判断:读到0说明对端关闭,这时要调用close(fd)并epoll_ctl(EPOLL_CTL_DEL)把fd从事件表移除。不彻底移除会导致少量fd泄漏,长期跑下来fd耗尽,服务端无法accept新连接。
如果以后要处理读写两个方向的高并发,还会用到EPOLLOUT事件(socket发送缓冲区可写)和水平触发/边沿触发的区别,但那是进阶话题。先掌握服务端读方向的epoll流程,你就能写出支撑几千连接的小型服务器了。
4.4 水平触发与边沿触发:一个决定性能的选项
epoll默认是水平触发模式(LT),意思是只要fd上有数据没读完,epoll_wait就会一直返回这个fd。处理起来很舒服:哪怕你一次只读了一个字节,剩下的数据下次还会通知你。不容易漏数据,代码安全。
边沿触发模式(ET)则只在状态变化时通知一次。fd缓冲区内从无数据变成有数据,epoll_wait会通知你一次,之后不管数据有没有读完,它都不会再通知。所以ET模式下你必须一次性把数据读完,直到read返回EAGAIN,否则剩下的数据就滞留在缓冲区里,可能影响下一次正常通信。
看起来ET更高效,省去了重复通知的麻烦,但代价是编程复杂度大幅上升。你必须处理“读不够”的情况,配合非阻塞socket循环读;还要处理对端一次发来很多数据、多次读才读完的场景。新手很容易在这里丢数据。
我的建议是:刚开始学习,严格用LT。等你的服务真正出现“大量空闲连接频繁唤醒”的性能问题时,你再考虑ET。说实话,99%的业务场景LT都够用,Nginx为了极致性能用ET,咱们写业务服务的习惯了LT就行。
另外如果你的代码跑起来后,客户端频繁断连,但你确认逻辑没写错,可以检查一下是不是把EPOLLET加上了,却没用非阻塞socket循环读。这种“半吊子ET”是经典事故源之一。真要上ET,记住一个准则:非阻塞读写 + 循环直到EAGAIN。缺一不可。
5. 进阶:惊群问题与TCP粘包再思考
5.1 accept惊群:多线程下的隐藏炸弹
当你从单线程epoll进化到多线程epoll时,会撞上“惊群”问题。什么意思?假设你有4个线程都阻塞在epoll_wait上等待同一个listen_fd,这时来一个新连接,内核会把所有等待线程全部唤醒。但最终只有一个线程能成功accept,其他三个线程accept时可能会返回EAGAIN或者竞争到空连接。被无谓唤醒的线程白白浪费了CPU时间,这就是惊群(thundering herd)。
Linux 2.6以前,这个问题在accept层面就存在。后来内核给accept加了互斥,但epoll_wait层面仍然存在。到了Linux 4.5,引入了EPOLLEXCLUSIVE事件标志,可以保证唤醒等待队列中的一个线程,而不是全部。使用方式是在监听socket注册事件时加上这个标志:
ev.events = EPOLLIN | EPOLLEXCLUSIVE;多线程下,这个参数几乎是必需品。否则同样的服务,单线程100% CPU能扛住的并发,多线程下CPU飙到200%还不一定扛得住,因为大量线程在做无用功。
还有一个小细节:多线程epoll的架构往往是主线程负责处理新连接,worker线程负责处理已建连的数据读写。监听socket只被主线程epoll监视,连接socket才被分发到worker线程管辖的epoll中。这对减少惊群也有一定作用。
5.2 为什么UDP不需要accept三次握手
TCP有连接状态、可靠传输、流量控制,所以编程模型复杂。UDP就不一样,它是无连接的,只管把数据报发出去,不管对方收没收到。体现在代码上,UDP服务端只需要socket、bind、recvfrom、sendto,不需要listen和accept。
所以UDP服务端的核心循环可以写成:
int sock_fd = socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in server_addr; // bind ... while (1) { struct sockaddr_in client_addr; socklen_t len = sizeof(client_addr); char buf[1024]; recvfrom(sock_fd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &len); sendto(sock_fd, buf, strlen(buf), 0, (struct sockaddr *)&client_addr, len); }每次recvfrom不仅拿到数据,还能拿到发送方的地址,sendto时把这个地址回填即可。UDP没有粘包问题,因为每个recvfrom对应一个完整数据报,内核已经帮你按包分了。但也因为无连接,丢包、乱序都得应用层自己负责。比如视频通话场景,丢几帧可以忍,延迟高不行,所以用UDP;文件传输场景,一个字节都不能丢,肯定用TCP。
5.3 发送缓冲区和接收缓冲区:内核帮你干的那些事
很多人不理解,为什么调用send后,数据不会立刻出现在对方的recv里。这中间要经过本机内核的发送缓冲区、网络传输、对端内核的接收缓冲区,最后才是应用程序的read。send成功只代表数据进入了本机的发送缓冲区,不代表对方已经收到。
这个特性在排查问题时特别重要。如果对方应用层迟迟没读到数据,先别急着怀疑网络丢包,可能是发送缓冲区把数据暂存了,也可能对端接收缓冲区满了,导致TCP窗口缩小,本机发送窗口跟着变小。用ss -ant或netstat -an查看连接状态,能看到Recv-Q和Send-Q这两个字段,它们分别是对端未读数据量和本机发送缓冲区的积压量。如果Recv-Q持续增长,说明对方应用层读取速度跟不上;如果Send-Q持续增长,说明对方接收窗口打不开,甚至可能对方进程已经卡死。
修改socket缓冲区大小的接口是setsockopt的SO_RCVBUF和SO_SNDBUF,大小通常要设成4KB的整数倍。但这个操作务必谨慎,随便调大可能白白占用大量内存,毕竟每个连接都有各自的缓冲区,一万个连接乘以128KB就是1.28GB的内存开销。
6. 断线与异常处理:写过线上服务才懂的事
6.1 半包与粘包,不只是粘包
前面提到粘包,其实对应还有一个问题叫半包。服务端声明一条消息长度100字节,但客户端分两次发送,第一次40字节,第二次60字节。服务端第一次read只读到40字节,如果此时就按完整消息解析,必然失败。
正确做法是维护一个应用层缓冲区,每次read到的数据先append到缓冲区,然后检查缓冲区里是否够一个完整消息(根据消息头长度判断)。够长就取出完整的消息处理,剩下不足一个消息的部分继续留在缓冲区等下一波数据。这就是很多网络库里“接收缓冲”的来源,也是所有自定义TCP协议必须具备的基础能力。
我见过有团队直接用recv返回的数据长度来判断一条消息的边界,结果线上隔三差五出解析错误。后来每个人都在自己的业务代码里写了半包处理,但每个人写的都长得不一样,维护成本极高。当时我建议统一封装一个读缓冲类,把“分包”问题收敛到一层解决,才算彻底根治。
6.2 被动断开与主动断开:close和shutdown的区别
socket关闭有两条路:close和shutdown。close是把fd引用计数的引用减一,只有当引用计数减到0时才真正关闭连接。如果同一个fd被fork被子进程继承,父进程调用close并不会立刻断连,因为子进程还握着这个fd。这就是为什么有时候服务端close了,客户端还觉得连接是通的——可能还有别的进程/线程持有该fd。
shutdown则不同,它直接切断连接的方向。shutdown(fd, SHUT_RD)表示关闭读方向,之后read返回0;shutdown(fd, SHUT_WR)表示关闭写方向,之后write返回SIGPIPE信号错误;SHUT_RDWR就是两个方向都关。shutdown不会释放fd,你还得额外调用close才能释放资源。
什么时候必须用shutdown而不是close?比如你写了一个HTTP服务端,给客户端发完响应后,需要半关闭写方向(SHUT_WR),让对方知道“数据发完了,你可以开始读完整响应了”,但你还想继续读客户端可能发来的请求。这种情况close会把整条连接干掉,message边界就丢了。
6.3 SIGPIPE:一个让程序悄悄崩溃的信号
这是网络编程新手最容易栽的坑。当服务端向一个已关闭的socket写入数据时,内核会发送SIGPIPE信号给进程。如果不处理,进程默认会被终止,而且是安静地终止,连日志都没有。
典型的场景:客户端断开连接,但服务端还没感知到,继续往这个fd上write,第一两次可能正常返回,第三次可能就触发SIGPIPE,整个服务进程就没了。你排查半天网络问题,结果发现进程没了。
解决方式有两种。第一,在信号层面忽略它:
signal(SIGPIPE, SIG_IGN);第二,在send/write时加上MSG_NOSIGNAL标志:
send(fd, buf, len, MSG_NOSIGNAL);这样write或send就会返回-1,errno变成EPIPE,你可以正常处理错误,而不是进程直接蒸发。
我个人习惯是两种都做:全局忽略SIGPIPE,然后每个send都带MSG_NOSIGNAL,双保险。日志里宁可看到EPIPE报错,也不能让进程在毫无预警的情况下消失。
7. 常用排查命令与工具清单
7.1 先看连接状态:ss和netstat怎么用
服务器出了问题,第一件事不是改代码,而是确认网络连接状态。Linux下最常用的命令是ss,netstat在部分发行版里已经不再默认安装。
ss -ant这个命令列出所有TCP连接状态。重点看几个字段:Local Address(本地地址和端口)、Peer Address(对端地址和端口)、State(连接状态)、Recv-Q和Send-Q(收发缓冲区积压量)。
如果看到大量连接处于SYN_SENT状态,说明客户端发出的SYN包没有得到服务端响应,可能是服务端端口没监听。如果看到大量TIME_WAIT,这是正常现象,表示主动关闭连接的一方正在等待2MSL时间,防止旧连接的数据包残留。TIME_WAIT本身不是问题,但量大到几万时,需要检查是不是连接频繁建立关闭,可以通过复用TIME_WAIT连接(tcp_tw_reuse)缓解,但这不是银弹,过度依赖反而掩盖了连接管理不合理的问题。
7.2 抓包工具tcpdump与nc组合拳
有时候代码层面看不出来问题,就需要抓包来看真实网络行为。tcpdump是Linux下最强大的抓包工具之一,常用命令:
tcpdump -i eth0 tcp port 8080 -nn -XX-i eth0指定网卡,tcp port 8080过滤端口,-nn不解析域名和端口为服务名,-XX同时输出十六进制和ASCII格式,能直接看到数据内容。如果服务跑在回环地址上,网卡要换成lo。
抓包后重点关注TCP三次握手的SYN、SYN-ACK、ACK三个包。如果只有SYN没有SYN-ACK,说明服务端没有正常响应,可能是防火墙拦截或者服务根本没监听;如果SYN-ACK发了但客户端不回ACK,可能是客户端内核参数问题,或者客户端在收到响应前就超时关闭了。
nc(netcat)是另一个好用的小工具。调试服务端时,可以直接用nc模拟客户端:
nc -vz 127.0.0.1 8080 nc 127.0.0.1 8080第一行是扫描端口连通性,第二行进入交互模式,你输入什么,服务端就会收到什么。在没有完整客户端的情况下,nc是验证服务端逻辑最快的方式。
7.3 压测工具:不只是ab和wrk
服务写完之后,至少要压一下才知道能不能扛住。简单的压测可以用ab:
ab -n 10000 -c 100 http://127.0.0.1:8080/这个命令表示发送10000个请求,并发100。不过ab只能打HTTP协议,如果你写的是自定义TCP协议,更适合用wrk或自定义脚本。wrk支持lua脚本,可以灵活构造请求,压测能力比ab强很多:
wrk -t4 -c1000 -d30s http://127.0.0.1:8080/意思是使用4个线程模拟1000个并发连接,持续30秒。如果压测过程中出现大量连接失败或超时,就要回头检查你的服务端代码,尤其是fd泄漏和事件处理逻辑这两块。压测环境的网络参数也要注意,单机压测时经常是客户端先到瓶颈,因为每次connect都要消耗本地端口,默认范围有限。可以用sysctl net.ipv4.ip_local_port_range查看并调整可用端口范围。
8. 写在最后的几点经验
这四弹Linux网络编程写下来,我自己也重新把整个体系过了一遍。有些人觉得网络编程就是背API,socket、bind、listen、accept、read、write,完事。但实际上,真正的分水岭是在遇到异常、并发、半包、惊群、SIGPIPE这些“教科书不太讲”的问题时,能不能快速定位并解决。
我给新人的建议是:先把单线程的阻塞TCP服务写熟,再上多线程,然后上epoll,最后才是ET和性能优化。每一步都亲手写一遍,不要只抄代码。你调通一个echo服务获得的正反馈,比看十篇教程都管用。
如果非要列几个“过来人”心得的话,我提炼成三条:
第一,所有read/write的返回值都是核心情报,对了断连、错了报错、少了半包,全都藏在返回值里。第二,SIGPIPE一定要尽早全局忽略,否则某天凌晨服务突然挂了你都找不到原因。第三,调试多线程epoll时,把惊群问题当成默认会踩的坑,提前用EPOLLEXCLUSIVE堵住。
这套内容写到这儿,基本上把Linux网络编程从socket到epoll到异常处理的路径捋了一遍。每一段代码我都亲手编译运行过,每一步踩过的坑都有记录。你照着写一遍,再自己压一压,遇到问题了可以回头翻这篇。如果这篇文章能让你少走几段弯路,那我就没白写。