做Linux后台开发这些年,我几乎每天都会和TCP、UDP这两个协议打交道。不管是写业务接口、搭消息通道,还是排查一个莫名其妙的超时问题,最后十有八九都会追到"连接状态不对"或者"包丢了"上面。很多人对这两个协议的认知还停留在"TCP可靠、UDP快"这种层面,可真到了Linux系统里,连接状态机、内核参数、缓冲区水位、丢包计数,每个细节都能把你折腾一遍。这篇文章我打算把UDP和TCP在Linux下的运行机制、选型思路、调试手段完整讲一遍,适合刚入门网络编程的新手,也适合写了几年服务端代码但一直没有系统梳理过协议栈细节的朋友。
先从最基本的问题开始。这两个协议在设计目标上就是完全相反的,理解了这一点,后面所有内核行为都能串起来。
1. 先把概念讲透:TCP和UDP到底差在哪里
1.1 一个传输协议的"两种人格"
TCP的全称是Transmission Control Protocol,传输控制协议;UDP的全称是User Datagram Protocol,用户数据报协议。教科书定义说TCP是面向连接的可靠协议,UDP是面向无连接的不可靠协议,这句话方向上没毛病,但"面向连接"和"可靠"这几个字的实际含义,不写代码根本体会不到。
我更喜欢用一组"有"和"无"来概括:TCP是有连接、有状态、有反馈的传输方案;UDP是无连接、无状态、无反馈的传输方案。
"有连接"意味着通信前双方要经过三次握手,确认彼此的收发能力都正常,之后在内核里维护一条虚拟链路。"有状态"意味着这条连接在Linux内核里有完整的生命周期:LISTEN、SYN_SENT、ESTABLISHED、FIN_WAIT、CLOSE_WAIT、TIME_WAIT等等,每个状态都有明确的语义和处理逻辑。"有反馈"意味着发出去的每个报文都可能触发确认(ACK),没收到确认就重传,网络发生拥塞就主动降低发送速度。
UDP这边完全是另一个世界。内核收到一个UDP数据报,要做的事情非常简单:查路由、算校验和、放队列、发出去。发送完就完事,不记录序列号,不跟踪确认,不管对方收没收到。接收也一样,进程从socket缓冲区里把数据报取走就结束,如果在取走之前缓冲区满了,新的包直接被丢,连"通知一下应用程序"这种动作都不会有。
一言以蔽之,TCP把通信当成一场需要双方配合的"谈判过程"来管理,UDP把通信当成一次性的"投递动作"来执行。
1.2 用打电话和发快递来理解
如果上面的内容还觉得抽象,我给你两个生活化的类比。
TCP像打电话。拨号就是三次握手,对方接听后连接建立,通话过程中你说一句对方应一句,没听清楚可以要求重复(重传机制),信号不好就放慢语速(拥塞控制),最后说"拜拜"再挂电话,这就是四次挥手。整个过程双方都有明确的"当前状态",而且这个状态是会话级的。
UDP像往快递柜里扔包裹,不签收、不追踪。你把包裹塞进去,快递公司尽力去送,但不保证一定送到,也不保证你按顺序寄出的多个包裹会按顺序到达,更不会主动通知你"对方拒收了"。听起来很不靠谱,但它的好处是:你把包裹放下就能立刻去干下一件事,不需要等对方任何回话。
所以很多场景反而必须用它。语音视频通话就是一个典型:连续传100帧画面,过程中丢了3帧,TCP会拼命重传这3帧,结果导致后面97帧全部堵在路上,听感就成了明显的卡顿。UDP丢掉3帧就丢掉,画面稍微糊了一下,但整体流畅性保住了。这就是"把可靠性交给协议"和"把可靠性交给自己"的区别。
另外一个常被忽略的差异是报文头部开销。TCP头部至少20字节,加上各种选项常常超过40字节;UDP头部固定8字节,就四个字段:源端口、目的端口、长度、校验和。在高频小包场景下,UDP的带宽利用率明显更高。比如一个应用每秒钟发10万个包,每个有效载荷只有几十字节,用TCP光是头部多出来的开销就很可观。
2. Linux下的TCP:连接、状态机与内核调优
2.1 三次握手和四次挥手背后的状态流转
TCP连接的生命周期在Linux内核里是一套严格的状态机,每个状态都可以通过ss命令实时观察。我建议每个学网络编程的人都亲手做一遍这个实验:起一个TCP服务,在客户端连上去,同时在另一个终端循环执行ss -tan,你会亲眼看到连接从LISTEN到ESTABLISHED再到FIN_WAIT的完整变化。
先看一个最小可运行的TCP回显服务,Python几行就够:
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(('0.0.0.0', 8080)) sock.listen(128) while True: conn, addr = sock.accept() data = conn.recv(4096) conn.sendall(data) conn.close()这个例子把两个队列都暴露出来了:listen(128)决定了全连接队列上限,但实际还会被net.core.somaxconn限制,这个后面细说。
三次握手的具体流转过程是这样的:
- 客户端发送SYN包,自己的状态进入SYN_SENT。
- 服务端在LISTEN状态收到SYN,回复SYN+ACK,进入SYN_RCVD。
- 客户端收到SYN+ACK,回复ACK,进入ESTABLISHED;服务端收到这个ACK,也进入ESTABLISHED。
注意有个细节:服务端进入ESTABLISHED是要等收到第三次握手的ACK才完成。在握手完成前,客户端的连接已经处于可用状态,但服务端可能还在半连接队列里等最后一个ACK。
四次挥手就更绕一点。谁先发起关闭,谁就要多走一段状态:
- 主动方发FIN,进入FIN_WAIT_1。
- 被动方收到FIN,回ACK,主动方收到后进入FIN_WAIT_2;被动方进入CLOSE_WAIT。
- 被动方也调用close,发出FIN,进入LAST_ACK。
- 主动方收到FIN,回ACK,进入TIME_WAIT,等2MSL后彻底CLOSED。
这套状态机不是纸上谈兵。线上排查时,你看到大量SYN_RCVD,多半是服务端accept不够快或者半连接队列满了;看到大量CLOSE_WAIT,基本可以断定是程序代码忘记close了;看到大量TIME_WAIT,则需要判断主动关闭方是否承受了过大的连接压力。
2.2 影响TCP性能的几个关键内核参数
Linux的TCP行为高度可调,绝大多数调优都是通过sysctl操作/proc/sys/net/ipv4/目录下的参数。我列几个实战中最常用到的,每个都说明白它管什么、什么时候调。
第一个是net.core.somaxconn。它限定了listen队列的上限,也就是完成握手但还没被accept走的连接最大数量。应用里调用listen(fd, backlog)时,实际生效的backlog是min(你传的值, somaxconn)。默认值只有128,对高并发服务来说太小了。我处理过一个挺典型的故障:某个Web服务的压测QPS涨到一定程度就上不去,ss里出现大量连接超时,调大somaxconn后立竿见影。
第二个是net.ipv4.tcp_max_syn_backlog。它控制半连接队列的最大长度,也就是SYN已收到但握手还没完成的连接数。正常业务下这个参数很少是瓶颈,但受到SYN洪水时,它决定了服务端还能不能正常接受新连接。配合net.ipv4.tcp_syncookies=1一起用,能把半连接压力化解掉一部分。
第三个是tcp_fin_timeout,控制FIN_WAIT_2状态能停留多久,默认60秒。如果被动关闭方一直不回复FIN,主动方的连接会卡在FIN_WAIT_2消耗资源,这个参数就是兜底措施。
第四个是tcp_tw_reuse,允许内核复用处于TIME_WAIT状态的连接来发起新连接。但它有个前提:连接双方都必须启用时间戳选项(默认开启)。这个参数争议比较大,我的建议是:确认业务场景允许的前提下再用,不要盲目打开。
另一个经常在应用层涉及的选项是TCP_NODELAY。它对应Nagle算法——Nagle算法会把多个小包合并成大包再发送,减少网络中微小报文的数量,但副作用是引入了延迟。如果交互型应用频繁发小数据(比如几十字节的心跳),Nagle和延迟ACK机制互相影响,可能造成几十毫秒的额外延迟。我的经验是:即时交互类服务,在socket上设置TCP_NODELAY是标准操作。
最后还要提一下TCP Keepalive。很多新手以为TCP连接是"永远在线"的,其实内核默认要7200秒(两个小时)才开始发送保活探测包,而且还要探测几次没回应才判定连接死了。对很多业务来说这个时间太迟钝了,需要通过tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes调整,或者在应用层自己做心跳。
2.3 处理TIME_WAIT和大批短连接的经验
TIME_WAIT是TCP里最让人头疼的状态之一。只要一方主动关闭连接,它就要停留2MSL(Maximum Segment Lifetime,最大报文生存时间),默认按60秒计。设计这个状态的初衷是防止旧连接的延迟报文污染新连接:如果一对端口刚被释放又被立刻复用,网络上还残留着上一个连接的数据包,新连接就会收到脏数据。
但问题来了:高并发短连接场景下,TIME_WAIT会大量堆积。比如一个HTTP服务,客户端每次请求都新建连接、请求完就关闭,如果主动关闭方是服务端,服务端就会积累成千上万个TIME_WAIT。每个TIME_WAIT连接都要占用内核内存和端口资源,连接数一旦逼近端口上限(默认约28000个),新连接就无法建立。
处理思路有几种,我的建议是按优先级来:
第一,调整设计,让客户端主动关闭连接。很多协议交互是请求-应答模式,服务端答完就让客户端断开,这样TIME_WAIT就落在客户端这边。如果客户端连接数少,问题就自然消解了。
第二,开启tcp_tw_reuse,让内核在满足条件时复用TIME_WAIT连接发新请求。这个参数只对发起连接的一方(客户端角色)生效,服务端靠它解决不了问题。
第三,如果业务场景允许,使用连接池让连接长期复用,从根上减少建连和断连次数。
这里我要特别强调一个原则:改TCP参数前先测量,不要凭感觉。TIME_WAIT多只是表象,真正要解决的是"短连接太多"这个问题。如果业务改不动,再通过参数优化,但一定要知道每个参数背后的权衡。
3. Linux下的UDP:无状态的快与丢包的痛
3.1 UDP的内核收发路径
UDP在Linux内核里的处理路径比TCP短得多。发送路径上,应用调用send或sendto之后,内核走sock_sendmsg、udp_sendmsg,主要工作是查路由、填充UDP头、计算校验和,然后把数据报交给IP层。整个过程没有拥塞窗口、没有重传计时器,只要socket发送缓冲区能放得下,调用基本是瞬时完成的。这也是为什么很多人说UDP"快"——它不是传输速度更快,而是省掉了一大堆管理开销。
接收路径类似。内核收到UDP包后,根据目标端口找到对应的socket,把数据报放进socket的接收缓冲区,然后唤醒等待的进程。就这几个步骤,没有排序、没有确认、没有重组。
这里最大的坑是缓冲区。TCP有流控机制,接收方缓冲区快满时会通告窗口缩小,发送方就会降低速度;UDP没有这个机制,发送方根本不知道接收方缓冲区什么情况。一旦UDP socket的接收缓冲区满了,后续到达的数据报直接在内核里被丢弃,而且应用程序不会收到任何提示。你只能通过netstat -su看到RcvbufErrors在涨。
所以做UDP服务,第一件事就是把接收缓冲区调大。内核默认的rmem_default通常在212992字节左右(约208KB),对高频大包业务往往不够。可以通过sysctl修改net.core.rmem_default和net.core.rmem_max,也可以在应用代码里用SO_RCVBUF选项为单个socket设置。注意内核实际分配的缓冲区大约是设置值的两倍,因为它要额外留出管理开销。
3.2 高并发UDP服务的三个关键配置
如果你的UDP服务要承载高并发流量,有三个配置非常重要。
第一个是SO_REUSEPORT。这个选项允许把多个socket绑定到同一个IP和端口上,内核作为分发器把到达的数据报按哈希规则分发给不同的socket。这样一来,你可以开多个进程或线程各自持有一个socket,充分利用多核CPU,而不是让所有流量挤在一个socket上由单线程处理。这个特性在TCP场景下同样适用,而且它还顺带解决了TCP连接的负载均衡问题。它是Linux 3.9以后内核才支持的,老系统上要用传统方案只能靠外部负载均衡。
用Python演示一下UDP版SO_REUSEPORT的写法:
import socket import os sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) sock.bind(('0.0.0.0', 8000)) print(f"process {os.getpid()} bound to 0.0.0.0:8000") while True: data, addr = sock.recvfrom(65535) print(f"process {os.getpid()} got {data} from {addr}")开多个这个进程,内核会自动把到达的UDP包分发给不同的进程,每个进程处理自己收到的包。
第二个是接收缓冲区调整。前面说过,UDP没有流控,接收方必须自己扛住突发流量。生产环境我一般会把UDP服务的SO_RCVBUF调到4MB以上,具体数值要看业务的包大小和到达速率。这里有个权衡:缓冲区太大,延迟会升高,内存占用也大;缓冲区太小,高峰期的包直接丢。没有万能数值,只能靠压测和线上监控调。
第三个是中断与CPU亲和。网卡收到数据包会产生中断,如果所有中断都在同一个CPU上处理,其他核再闲也插不上手。现代网卡支持RSS和多队列,配合SMP亲和性可以把不同队列的中断绑定到不同CPU。ethtool -L可以查看和修改网卡队列数量,ethtool -G可以调整环形缓冲区大小。这块属于比较进阶的优化,一般业务规模可能用不上,但知道原理很重要。
3.3 UDP丢包排查的基本思路
UDP丢包排查有个好处:内核把丢包的原因分得很清楚,关键是知道去哪里看。
第一步,看socket层有没有丢,也就是接收缓冲区是否溢出。执行netstat -su,输出里如果RcvbufErrors在持续增长,说明应用读包的速度跟不上内核收包的速度。解决办法是调大缓冲区,或者优化应用的读取逻辑,比如用独立的接收线程、批量读取,减少每次取包的系统调用开销。
第二步,看网卡层有没有丢。用ethtool -S eth0查看网卡统计,留意rx_missed、rx_dropped、rx_no_buffer这几个计数。网卡丢包通常是硬件接收队列太小或者中断太频繁导致CPU跟不上,可以用ethtool -G eth0 rx 4096把环形队列调大。
第三步,看内核协议栈有没有丢。cat /proc/net/snmp文件里有Udp: InErrors这样的统计,InErrors包含校验和错误和应用程序来不及接收的包。
第四步,用tcpdump确认数据包是不是真的到达了主机。tcpdump -i eth0 udp and port 8000如果能看到包,但应用就是收不到,问题基本就在socket层:要么socket绑定的地址不对,要么缓冲区设置不对。看不到包,则要往上游查:路由、防火墙、物理链路。
我踩过一个比较隐蔽的坑:程序里把UDP socket绑定到了127.0.0.1,但数据通过服务器的外网IP发过来,结果所有包都被内核默默拒收。当时抓包明明看到包到了机器上,应用却毫无反应,查了半天才发现是绑定地址的问题。这类问题用ss -ulpn查看socket实际绑定地址,秒破。
4. 选型实战:什么场景用TCP,什么场景用UDP
4.1 优先选TCP的场景
先给一个结论:默认选TCP,除非你有明确的理由不选它。理由很简单——可靠性、顺序性、流控、拥塞控制这些能力都被内核实现好了,你不需要在应用层重新造轮子。
典型的TCP场景包括:
Web服务,包括HTTP和HTTPS。请求-响应模型天然适合有连接的通信,浏览器和服务器之间就是一条条TCP连接。
数据库访问。数据库查询要求可靠、有序、完整,任何一条记录都不能丢,TCP是唯一合理选择。
文件传输。文件内容必须按序到达、逐字节完整,而且通常是大流量传输,TCP的拥塞控制和窗口机制能很好地利用链路带宽。
消息队列。生产者和消费者之间的消息需要确认机制,TCP自带的ACK机制虽然不等同于业务确认,但至少保证消息不会在传输环节丢失。
还有一个非常重要的原则:如果业务对延迟不敏感、对吞吐有要求、且通信双方能保持较长会话,TCP几乎总是最优解。它的"代价"主要是握手开销和头部开销,但在长连接场景里,这些代价被摊薄到可以忽略。
4.2 必须选UDP的场景
UDP不是"退而求其次"的选择,在有些场景里它是唯一现实的选择。判断标准只有一句话:你更在意数据"尽快到达",还是更在意数据"完整到达"。如果答案是前者,UDP就是对的。
实时音视频通信是UDP最典型的战场。声音和画面是流式的,每一帧都有时效性,超过播放时刻到达的数据已经没有意义。TCP为了保证可靠性会重传迟到的包,反而把后续数据全部堵住,造成更严重的卡顿。UDP直接丢弃过期帧,流畅度反而更好。
游戏中的位置和动作同步也是UDP的主场。玩家的操作指令要求极低延迟,偶尔丢失一帧指令可以由客户端根据前后帧插值补上,但延迟一旦上去,整个游戏体验立刻崩盘。绝大多数竞技游戏的网络层都是基于UDP定制的。
DNS查询同样基于UDP。一个域名解析请求只关心"得到结果的速度",查询包本身不到100字节,如果用TCP先握手再查询,光握手的开销就超过了查询本身。
局域网内的服务发现、设备发现、流媒体广播这类场景,更是只有UDP能做。TCP是点对点的连接,不支持广播和多播,而UDP天然支持向一组地址分发数据。
4.3 别忽略的"第三条路"
很多人觉得UDP等于不可靠,这个认知在近十年已经被实践彻底推翻了。UDP只是不提供可靠性机制,并不妨碍你在UDP之上自己实现可靠性。
最典型的例子就是QUIC协议。它传输层跑在UDP上面,却实现了类似TCP甚至超越TCP的可靠性能力:多路复用(一个连接上并发多个流,某个流丢包不影响其他流)、快速重传、前向纠错、0-RTT建连。现在主流的HTTP/3就是基于QUIC实现的。
这种"UDP底座+用户态可靠性"的架构给了我们很大的启发。如果你的业务有特殊需求,标准TCP不够用,又不想被TCP的机制束缚,完全可以在UDP之上构建自己的传输层。比如游戏公司做一套带状态同步的自研协议,或者实时通信场景用前向纠错(FEC)在应用层修复丢包。
还有一种常见混合方案:信令走TCP,媒体走UDP。视频通话的建立、结束、码率协商这些低频但必须可靠的交互走TCP;而实际的音视频数据流走UDP,能快则快,不快就丢。这种"各取所长"的思路在真实系统里非常普遍。
5. 排障实录:用ss、tcpdump和内核计数解决真实问题
5.1 排查"连接建立不了"的问题
先看一个实际案例。某服务部署在一台服务器上,客户端访问它的8080端口一直超时,但服务器本身能ping通。这种"主机通、端口不通"的现象,我一般按下面这个顺序排查。
第一步,看服务器的监听状态。ss -ltn确认服务确实在监听目标端口,注意看Send-Q和Recv-Q两列。对于LISTEN状态的socket,Recv-Q表示已完成握手但未被accept的连接数,Send-Q表示全连接队列的最大长度。如果Recv-Q长时间不为零甚至逼近Send-Q,说明应用accept的速度跟不上,连接就被积压了。
第二步,看各状态连接数分布。用ss -tan | awk '{print $1}' | sort | uniq -c统计一下。如果SYN_RECV数量很大,说明握手没有完成:要么服务端的半连接队列溢出了,要么SYN包没有被正常处理。
第三步,抓包看握手过程。在服务器上执行tcpdump -i eth0 tcp port 8080 -nn,观察是否收到SYN、是否回了SYN+ACK。如果收到SYN但没有回SYN+ACK,基本可以排除网络链路问题,问题指向内核处理环节,优先怀疑半连接队列和全连接队列容量。
第四步,查系统的连接相关计数。netstat -s | grep -i listen能看到times the listen queue of a socket overflowed这一项,只要这个数在涨,队列溢出实锤。
我遇到过最诡异的一次:半连接和全连接队列都正常,SYN也回了,但客户端依然连不上。最后发现是防火墙规则里有一条对SYN包的丢弃规则。tcpdump抓包显示SYN到达了网卡,但tcpdump的抓包位置在内核网络栈处理的早期,实际报文随后被防火墙拦下来,根本没有机会进入TCP协议栈。这类问题要结合防火墙规则一起看,否则很容易在队列调优上白费功夫。
5.2 排查"UDP收不到数据"的问题
UDP排障和TCP有个本质区别:没有连接状态可看。TCP可以查ESTABLISHED、查重传计数、查半连接队列;UDP只有一个socket有没有数据在排队可以看,而且很多丢包行为是静默的。
还是用一个实际场景:客户端通过UDP向服务器8000端口发包,服务器上的进程就是收不到。
第一步,确认包有没有到服务器。在服务器上执行tcpdump -i eth0 udp port 8000 -nn。如果一分多钟一个包都没抓到,那不是服务器的接收问题,是上游问题。看看客户端和服务端之间路由是否可达、防火墙是否放行UDP、中间设备是否限速或丢弃。这里有个特别容易忽略的点:很多网络设备对UDP的转发优先级很低,拥塞时先丢UDP包。
第二步,如果tcpdump能看到包,但应用收不到,看端口绑定。ss -ulpn输出里检查这个进程监听的地址到底是0.0.0.0:8000、127.0.0.1:8000还是某个具体IP。前面说过,绑定的地址不对会静默丢包,这个坑我踩过一次就长记性了。
第三步,看socket接收缓冲区。netstat -su里的RcvbufErrors如果持续增长,说明是应用处理速度跟不上。这种情况可以先看当前socket接收队列积压了多少,确认后调大SO_RCVBUF或者优化应用层读取逻辑。
第四步,检查校验和。UDP的校验和是可选的,但Linux默认开启。如果网卡支持UDP校验和卸载(checksum offload),而驱动又存在bug,就可能出现包到达主机但校验和计算失败的诡异情况。netstat -su里的校验和错误计数就是干这个用的,看到非零,优先怀疑网卡驱动。
UDP这边还有个和TCP很不一样的经验:TCP连接出问题,客户端和服务端都能感知到;UDP出问题,只有接收方自己闷头排查。所以做UDP服务,监控一定得提前做好,至少要把netstat -su、ss -ulpn这些计数器纳入基础监控,否则出了问题连从哪查起都不知道。
5.3 常见问题速查表
下面这些是我在实际工作中整理出来的高频问题,基本覆盖了日常会遇到的绝大多数场景。
| 现象 | 可能原因 | 排查命令 | 处理建议 |
|---|---|---|---|
| 大量SYN_RECV | 半连接队列溢出、被SYN洪水攻击 | ss -tan state syn-recv、netstat -s | 开启tcp_syncookies,调大tcp_max_syn_backlog |
| 大量CLOSE_WAIT | 应用代码未调用close | ss -tan state close-wait | 检查代码里的连接释放逻辑 |
| 大量TIME_WAIT | 主动关闭方连接压力大 | ss -tan state time-wait | 让客户端断开、按需开启tcp_tw_reuse、连接池化 |
| 连接建立但业务很慢 | 全连接队列积压 | ss -ltn看Recv-Q | 调大somaxconn,优化accept线程 |
| UDP应用收不到包 | 绑定地址不对、缓冲区溢出 | ss -ulpn、netstat -su | 检查bind地址,调大SO_RCVBUF |
| UDP的RcvbufErrors增长 | 应用读取跟不上 | netstat -su | 调大接收缓冲区,增加接收线程 |
| 网卡rx_dropped增长 | 环形队列太小、中断处理不过来 | ethtool -S、ethtool -G | 调大环形队列,开启RSS多队列 |
| 重传次数过多 | 链路拥塞、MTU问题 | netstat -s | 检查链路,调整拥塞控制算法与MTU |
最后再补一条经验:不管排查什么问题,先记录当前时间点的基线数据,然后立刻抓包。抓包是唯一能还原事实真相的手段,不抓包纯靠猜,很容易在错误方向上浪费几个小时。内核的计数能告诉你"发生了什么",只有抓包才能告诉你"在哪个环节发生的"。
6. 写在最后:一点个人体会
做了这么多年Linux网络相关的开发,我最大的体会是:TCP和UDP不是"好"与"坏"的选择题,而是"合适"与"不合适"的判断题。协议栈本身没有高下之分,关键在于你是否清楚自己的业务对延迟、可靠性、吞吐、实时性的优先级排序。
给正在学习的朋友一个建议:动手写代码比看书快得多。找一台Linux机器,用Python的socket模块分别写一个TCP回显服务和一个UDP回显服务,总共不到50行代码。然后在另一台机器上用ss实时观察连接状态,用tcpdump抓包看报文进出,用netstat -su看计数器变化。这个过程完整跑一遍,你对两个协议的理解会比死记硬背一个月教科书都要深刻。
还有一个小技巧:压测或自测的时候,故意设置一个很小的接收缓冲区,再打一些超过缓冲区的流量,亲眼看看丢包是怎么发生的、计数器是怎么变化的。这种"故意制造故障"的训练方式,比看多少篇文档都有用。等习惯了用ss、tcpdump、netstat、ethtool这四件套观察现象、验证假设,大部分网络问题在你面前都会变得透明。