ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

自学网络编程36天:TCP通信从协议到C语言Socket实战

自学网络编程36天:TCP通信从协议到C语言Socket实战 今天是我自学网络编程的第三十六天。前三十五天的笔记一直在写进程线程、锁、文件描述符好不容易把自己从只会写单机程序的状态拉出来今天终于轮到跨机器通信——TCP通信。这个主题我一开始以为就是记住三次握手、四次挥手后来发现真正把TCP通信跑起来比背概念难十倍bind失败、粘包、超时、TIME_WAIT堆积没有一个坑是白踩的。这篇文章是我今天完整走完一遍TCP通信的全过程从协议状态机到C语言socket API从抓包验证到实战中常见问题的排查思路适合正在学网络编程的人、想自己写服务端的开发者以及准备面试却总被TCP状态机绕晕的人。1. 为什么第三十六天才轮到TCP通信把网络编程放在学习路线的正确位置1.1 从单机程序到网络服务中间差的不只是一张网卡我前35天的笔记在写什么呢主要是C语言基础、Linux文件I/O、进程与线程、锁和条件变量。这些内容有个共同特点都是单机视角。程序自己从文件读数据自己算自己往标准输出写结果。到了今天我开始写跨机器的程序程序A要把一段数据交给另一台机器上的程序B这才发现以前学的所有东西都还能用上socket在Linux里本质是文件描述符read/write的语义在recv/send上依然成立多进程多线程并发也从理论变成了必须用的手段。所以我不觉得第三十六天才接触TCP通信很慢。网络编程不是一门先学后用的技术它寄生在操作系统、进程模型和I/O模型之上。如果你前面不了解什么叫非阻塞、什么叫fd、什么叫线程安全直接上来写socket遇到问题你都不知道该怪协议、怪内核还是怪自己的代码。我的学习路线是先把单机的底子打牢再打开通信这扇门。1.2 TCP和UDP先会选再会写说到TCP通信绕不开和UDP的对比。我自己的理解方式是把TCP想成挂号信把UDP想成明信片。挂号信每一封都有编号收件人收到后会回执寄出方知道信有没有到顺序乱了也能按编号重排明信片则寄出就完了可能丢可能乱序但胜在快、省。真实场景里文件传输、HTTP接口、数据库连接几乎都是TCP因为业务数据绝不能丢而音视频通话、游戏里的位置同步、日志采集这类能容忍偶尔丢包的场景UDP反而更合适。我不建议一上来就追求极致性能而选UDP。对绝大多数业务系统先选TCP让数据准确到达再考虑延迟和带宽这是成本最低的路线。TCP带来了可靠性也带来了今天要讲的三次握手、四次挥手、粘包、TIME_WAIT这些复杂度。这些复杂度不能绕过只能理解。1.3 今天的学习范围与检验标准今天的任务不是把TCP协议全部啃完实际上TCP的拥塞控制、滑动窗口、超时重传每一块都可以单独写一篇。我给自己定的范围是先讲清楚TCP连接的生命周期握手、挥手、状态机然后用C语言把socket API完整跑一遍最后通过抓包和实测把可靠传输和字节流这两个核心特性变成亲眼可见的事实。检验标准很简单不靠搜索引擎能从头写一个既能当作服务端又能联调客户端的通信程序能说清运行状态下每个异常现象的排查方向。下面每一节都围绕这个标准展开。2. 三次握手、四次挥手与TCP状态机连接的生命周期2.1 为什么握手必须是三次历史包问题三次握手的具体过程很多人能背出来客户端发SYN服务端回SYNACK客户端再回ACK。但真正理解为什么必须是三次比背过程重要得多。我的理解是握手的目的不只是双方都确认对面在线更重要的是在不可靠的网络里防止一个过期的连接请求混入新的连接。假设只有两次握手客户端发起连接SYN因为网络重试迟到了很久才到达服务端服务端以为这是新连接就分配资源并回ACK建立起连接等待数据。但客户端早就放弃这个连接了服务端只能白白挂着这个半吊子连接。加上第三次ACK之后客户端只有在确实想建立连接时才会回应这个ACK服务端收到ACK就知道客户端的意图是真实的历史遗留的SYN不会被误认为是建立连接的请求。套到生活里就是收到指令要回执回执确认也要回执A说我要和你通话B说好的我准备好了A再说一句好的我开始说了。前两次只完成了一半的确认第三次才是真正让双方都进入确认收到对方确认的状态。TCP是全双工协议每一端的发送和接收都要单独确认三次正好满足这个需求。2.2 四次挥手与TIME_WAIT主动关闭方躲不掉的惩罚挥手比握手多一次原因也很直白TCP是全双工的每一端的发送通道和接收通道互相独立关闭也要各自独立进行。A说我这边数据发完了我要关闭发送方向B收到ACK之后还能继续往A发数据等B也发送完了B才发出FINA再回ACK。所以从连接建立到完全关闭一共四个报文。这里最值得展开的是TIME_WAIT状态。主动关闭方在发出最后的ACK之后不会立刻回到CLOSED而是要等2MSL最大报文段生存时间的两倍才彻底关闭。我以前觉得这是浪费直到查了资料才明白两个原因第一如果最后这个ACK丢了被动关闭方会重发FIN主动关闭方得留在这个状态里重新回ACK第二网络里可能还有这个连接的老报文在游荡等2MSL能确保这些报文在网络上彻底消失不会干扰同一个四元组的新连接。所以TIME_WAIT不是bug是对旧连接的安全隔离。2.3 用netstat读懂当前连接状态理解了状态机之后最有效的验证方式就是看系统里的连接状态。比如我启动今天的回显服务后在另一终端执行ss -ant | grep 8080输出里会看到LISTEN、ESTABLISHED也会有TIME_WAIT、CLOSE_WAIT。初学者看到一堆状态会很慌我的经验是先把这几个高频状态记住状态含义常见位置LISTEN服务端正在监听端口服务端SYN_SENT客户端已发出SYN等待服务端回应客户端SYN_RCVD服务端收到SYN并回应了SYNACK等待最终ACK服务端ESTABLISHED连接建立成功可以收发数据客户端、服务端FIN_WAIT_1主动关闭方已发出FIN主动关闭方FIN_WAIT_2主动关闭方已收到对方ACK等待对方FIN主动关闭方CLOSE_WAIT被动关闭方收到FIN但应用层还没调用close被动关闭方TIME_WAIT主动关闭方发送完最后ACK等待2MSL主动关闭方线上服务如果CLOSE_WAIT堆积特别多基本能断定是应用程序收到对端关闭通知后没有正确释放fdTIME_WAIT多则说明连接被频繁主动关闭一般结合SO_REUSEADDR和对连复用去优化而不是无脑调低MSL。3. 用C语言的socket API实现一个最小可用的TCP回显服务3.1 服务端完整代码概念讲完必须动手。我用C语言写了一个最原始的回显服务客户端发什么服务端就原样返回什么。代码很短但完整覆盖了服务端编程的五个核心步骤socket、bind、listen、accept、recv/send。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define PORT 8080 #define BUFSIZE 1024 int main() { int lfd socket(AF_INET, SOCK_STREAM, 0); if (lfd 0) { perror(socket); exit(EXIT_FAILURE); } int opt 1; setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(PORT); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(lfd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(lfd); exit(EXIT_FAILURE); } if (listen(lfd, 128) 0) { perror(listen); close(lfd); exit(EXIT_FAILURE); } printf(echo server listening on port %d\n, PORT); while (1) { struct sockaddr_in cli; socklen_t len sizeof(cli); int cfd accept(lfd, (struct sockaddr *)cli, len); if (cfd 0) { perror(accept); continue; } printf(client connected: %s:%d\n, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port)); char buf[BUFSIZE]; ssize_t n; while ((n recv(cfd, buf, sizeof(buf), 0)) 0) { send(cfd, buf, n, 0); } printf(client closed: %s:%d\n, inet_ntoa(cli.sin_addr), ntohs(cli.sin_port)); close(cfd); } close(lfd); return 0; }3.2 客户端完整代码客户端更简单socket、connect、send、recv、close。我让它从标准输入读一行就发给服务端然后打印服务端返回的内容。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include arpa/inet.h #include sys/socket.h #define PORT 8080 #define BUFSIZE 1024 int main() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); exit(EXIT_FAILURE); } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(PORT); inet_pton(AF_INET, 127.0.0.1, addr.sin_addr); if (connect(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(connect); close(fd); exit(EXIT_FAILURE); } char buf[BUFSIZE]; while (fgets(buf, sizeof(buf), stdin) ! NULL) { send(fd, buf, strlen(buf), 0); ssize_t n recv(fd, buf, sizeof(buf) - 1, 0); if (n 0) { break; } buf[n] \0; printf(echo: %s, buf); } close(fd); return 0; }编译运行也很直接gcc -o tcp_echo_server tcp_echo_server.c gcc -o tcp_echo_client tcp_echo_client.c ./tcp_echo_server # 另开一个终端 ./tcp_echo_client3.3 逐个API拆解哪些参数最容易写错新手最容易在几个点上翻车我今天全踩了一遍第一字节序。地址结构体里的端口和IP必须从本机字节序转成网络字节序端口用htonsIP用inet_pton转换。我一开始在bind里直接填8080没转服务端监听的就变成了一个诡异端口客户端怎么连都对不上。今天代码里addr.sin_port htons(PORT)这个坑才算彻底绕过去。第二listen的第二个参数是backlog。它控制的是内核里已完成握手但还没被accept取走的连接队列大小。这个值设成多少取决于业务不是设多大都行。128对学习完全够但在高并发场景要理解它可能影响客户端connect的成功率。第三accept返回的不是监听fd而是新连接对应的fd。监听fd只负责接受连接真正的数据收发在accept返回的cfd上进行。我在多线程版本里就必须小心不要让多个线程同时操作同一个cfd。第四recv的返回值。大于0表示读取到的字节数等于0表示对端关闭了连接小于0才是出错。很多同学把recv等于0和超时混淆实际上在阻塞socket上recv只会等数据除非对端断开否则不会返回0。3.4 从单连接到多连接用线程处理并发上面的单线程版本有个明显缺陷在while循环里一个客户端连接后如果不断开服务端就阻塞在recv上其他客户端即使连上了也无法被accept等于只能服务一个客户端。这是学习TCP通信时最容易产生我已经会了错觉的地方。解决思路是多线程。accept返回一个cfd就创建一个线程去处理这个cfd主线程立刻回到accept继续接新连接。我改动的核心部分如下void *handle_client(void *arg) { int cfd *(int *)arg; free(arg); char buf[BUFSIZE]; ssize_t n; while ((n recv(cfd, buf, sizeof(buf), 0)) 0) { send(cfd, buf, n, 0); } close(cfd); return NULL; } // 在 accept 之后 int *pfd malloc(sizeof(int)); *pfd cfd; pthread_t tid; pthread_create(tid, NULL, handle_client, pfd); pthread_detach(tid);这里有两个细节cfd的地址不能直接传给线程因为下次循环会复用同名变量必须malloc一块内存传进去线程要detach让线程结束后自动释放资源而不是等join。多线程能解决并发但也引入线程安全问题这只是初期方案真正的生产级做法是I/O多路复用加事件驱动那是后面一两天的事。4. 跑通之后的踩坑实录bind失败、粘包与状态异常4.1 服务端重启报Address already in useSO_REUSEADDR解决代码跑通后我做了个很日常的操作CtrlC停掉服务端马上重新启动。结果bind直接报错Address already in use。原因其实在第二节讲过主动关闭连接的一方会进入TIME_WAIT状态而TIME_WAIT默认要持续2MSL我在服务端代码里根本没设置任何恢复选项端口自然还被旧连接占着。解决办法是服务端监听之前设置SO_REUSEADDR。这也是为什么我服务端代码里特意加了那四行setsockopt。注意SO_REUSEADDR允许的是端口被TIME_WAIT占用的连接重用端口并不是允许两个进程同时绑定同一个端口。理解这个区别后线上看到TIME_WAIT多就不会病急乱投医了。4.2 粘包与半包流协议没有消息边界我认为TCP通信里最容易让人困惑的还不是握手而是粘包和半包。TCP是个字节流协议它只保证字节顺序和可靠性不保证你在应用层调一次send对端就一定在一次recv里收到完整数据。可能你连续send了三次对端一次recv就全部收到也可能你只send了一次对端要recv三回才读完。前者叫粘包后者叫半包。很多人在业务上踩坑就是因为下意识把send/recv当成发一条消息/收一条消息。解决思路有三种一是固定每条消息的长度不够就补齐二是用特殊分隔符标记消息边界比如HTTP头里的空行、文本协议里的换行符三是用长度前缀先在消息头里写入这个消息的长度接收端先读长度再按长度读满数据。第三种最通用也最适合二进制协议。我写了一个readn函数来解决半包问题核心就是循环recv直到读满要求的字节数ssize_t readn(int fd, void *buf, size_t count) { char *p (char *)buf; size_t left count; while (left 0) { ssize_t n recv(fd, p, left, 0); if (n 0) { return -1; // 对端关闭 } if (n 0) { if (errno EINTR) { continue; } return -1; } p n; left - n; } return count; }配合长度前缀使用时先读4字节消息长度转成主机字节序再分配缓冲区并按长度readnuint32_t msg_len; if (readn(cfd, msg_len, 4) ! 4) { // 处理异常 return; } msg_len ntohl(msg_len); char *payload malloc(msg_len); if (readn(cfd, payload, msg_len) ! msg_len) { free(payload); return; }业务协议一旦定下来粘包半包就会从玄学变成可控工程。我今天的回显程序没有做这个处理因为recv直接原样send所以看不出问题一旦改成一条消息一个逻辑单元这个函数就是必需品。4.3 close与shutdown的差别CLOSE_WAIT堆积原因写多线程版时我又遇到一个状态异常客户端断开后服务端进程里的连接状态大量停留CLOSE_WAIT。原因是客户端发了FIN内核已经知道对端要关了但我业务代码没有及时close对应的fd。为什么没关闭因为我一开始在图省事用close结束通信但close只会把文件描述符引用计数减一。如果这个fd被fork过或者在多线程里被多次引用close不一定真正发起关闭操作系统要等引用计数归零才会真正关连接。要立即把连接的两端都掐断得用shutdown它可以单独关闭发送方向或接收方向。我的经验是对TCP这种长连接来说代码里该close的地方不要省close之前考虑对方是否还有数据要发来决定要不要shutdown。实际运维里如果发现服务端CLOSE_WAIT不减优先去查应用层有没有在处理完业务后遗漏close调用而不是先去调内核参数。CLOSE_WAIT是应用层问题的高频标志。4.4 心跳保活与TCP_NODELAY让连接保持健康的两个细节跑的连接数量多了之后另一个常遇到的现象是连接假死客户端进程还在服务器也不报错但数据就是发不过去。这通常不是因为网络断了而是链路中间某处已经把连接静默丢弃。TCP内核自带的SO_KEEPALIVE探测间隔默认长达2小时对多数业务来说等于没用所以要在应用层实现心跳。最简单的应用层心跳是双方约定好客户端每N秒发一个心跳包服务端如果超过M秒没收到任何数据就判定连接失效并主动关闭。这个方案能同时解决死链检测和NAT超时刷新两个问题。我在回显服务里没有加但这是从能跑走向能上线的第一步。另一个细节是TCP_NODELAY。TCP默认启用Nagle算法会把多个小包合并发送同时结合对端的延迟ACK小报文可能被拖几十毫秒。实时交互类应用比如游戏操作、远程输入扛不住这种延迟可以在客户端连接建立后这样关掉Nagleint flag 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, flag, sizeof(flag));代价是可能增加一些小包浪费一部分带宽。适合不适合要看你的数据是频繁小包交互还是大块数据传输。5. 用本机抓包亲眼验证TCP机制不靠背靠看5.1 用tcpdump观察本地回环的握手与挥手状态机和API再熟都不如亲眼看到网络包里的SYN、ACK来得扎实。我在本机用tcpdump抓了回环网卡命令是这样sudo tcpdump -i lo -nn -S tcp port 8080另一个终端启动服务端和客户端。当客户端连接的那一刻tcpdump输出里依次出现三行关键报文客户端发的SYN服务端回的SYNACK客户端再回的ACK。看到这三行的时候三次握手在我脑子里从一句话变成了真实现象。等客户端退出又能看到FIN、ACK、FIN、ACK四行挥手报文。如果用-S显示绝对序列号还能直接观察到序号和确认号的对应关系这比任何图都直观。第一次抓包建议就从本机回环开始不涉及防火墙、跨网段路由这些干扰因素看到的现象最纯净。5.2 connect被拒绝、服务端主动断开RST与TIME_WAIT的现场我还做了两个破坏性实验。一个是客户端去连接一个没有服务监听的端口抓包结果能看到一个RST包立刻返回connect报Connection refused。RST包在TCP里就是我这儿没有这个连接别发了的通知。另一个是让服务端主动断开连接也就是让服务端调用close之后用ss观察本机端口能看到TIME_WAIT状态稍等几秒再刷新状态消失。对着状态表一个个看比自己空想快得多。5.3 抓包验证Nagle算法小包如何被延迟合并趁抓包环境还在我又验证了Nagle算法。写一个客户端循环发送10次1字节数据不设置TCP_NODELAY时抓包里常常看到两个1字节的数据被合并成一个2字节的报文发送设置TCP_NODELAY后10个包基本各发各的延迟明显降低但包数量也明显上升。这种对比实验特别能帮你判断自己的应用要不要关Nagle。6. 第三十六天的自测题与留下的几个好习惯6.1 学完TCP通信至少能回答这几个问题我不太建议光靠读笔记结束一天今天给自己留了几个自测问题也分享给同样在学的人三次握手的第三次ACK如果丢了服务端会处于什么状态之后会发生什么客户端调用close后服务端还能继续向客户端发送数据吗为什么一次send 200KB数据对端至少需要几次recv才能读完为什么服务端大量出现CLOSE_WAIT你的排查顺序是什么为什么UDP不需要握手却也没有粘包问题这几个问题如果都能用可靠传输和字节流两个概念回答清楚TCP通信就算真正入门了。6.2 几个长期有用的网络编程习惯今天踩完这么多坑最大的收获是形成了几个习惯先记在这里以后写网络程序都用得上第一每个系统调用都必须检查返回值。服务端和客户端代码里如果忽略返回值很多故障会延迟爆发到时候排查成本反而是十倍以上。第二遇到连接异常先用ss和tcpdump拿到客观现象再翻代码。网络上最忌讳猜一个包一个状态说明一切。第三设计应用层协议时永远把粘包和半包当作必然发生的事先定义好消息边界再写业务逻辑。宁可让协议多占用几个字节也不要让接收端靠运气等数据。第三十六天结束的时候我对TCP通信的整体印象已经不是三次握手四次挥手这八个字了。它让我看到协议层面的每一条设计都有现实理由而真正让协议落地的是那些看似啰嗦的检查、状态和容错。如果你也在自学网络编程我的建议始终是多抓包、多杀进程、多观察状态TCP这东西看十遍书不如亲手捶一遍自己的服务端。
返回列表