写在前面:
开始写这篇文章之前,我花了很多时间构思应该怎么介绍 TCP。怎么引出 TCP 的概念,怎么解释 TCP 的定位,怎么逐层深入把纷繁复杂的 TCP 方方面面都解释到位,而又不显得不够平滑或刻意。
当然像 TCP 这么优秀的设计,若想一文带你彻底理解TCP在我看来是有些不可能的,所以我决定多花几篇文章来写 TCP,所以如果这篇文章没有让你看到你期待的"烧脑内容",不要着急,后续文章里你可能会找到答案(但是我觉得这篇文章也是值得看一下的~)。
网络传输协议演进--从UDP到TCP
先讲一个小故事,高中的时候我和曾经的一个初中的朋友被分到了不同的班级,但是我们依然每天中午下课之后一起吃饭。有一次我比她先下楼,然后楼梯和每一层的过道就变得很拥挤,她就在二楼的过道对着我喊"等我五分钟",一边喊另一只手还伸出五根手指比划着。可是当时人太多,场面太混乱,声音太嘈杂,我理解的就是"我不吃饭了",毕竟她伸着五个手指头我以为是"拒绝,拜拜"的意思~
举这个例子就是说明,在我们的生活中,如果要进行信息传输,在不加任何的规则约束的情况下,外界的影响很可能会造成结果的不可靠性。
UDP:简洁高效的双刃剑
传输层不能只有 UDP 就是因为 UDP 是不保证可靠性的。注意我这句话中用的是"不能只有 UDP",并不是说可以直接把 UDP 废弃的意思,这是因为 UDP 不保证可靠性并不是一个缺点,而是它的一个特点,使得某些特殊场景下,UDP 就是最合适的传输层协议。
UDP的显著优势
极致的低延迟特性: 由于无需建立连接,UDP 可以立即发送数据。在实际测试中,UDP 的传输延迟通常仅为 1-10ms,而 TCP 在长距离传输中可能达到 50-100ms。原因如下:
- 无连接建立开销: UDP 不需要建立连接,数据包可以直接发送
- 无确认等待机制: 发送方无需等待对方确认是否收到数据,可以连续发送数据
- 无状态维护: 没有建立连接,内核也就不需要维护连接状态,减少了处理延迟
高效传输效率: UDP 的头部开销较小,仅有八个字节,相比 TCP 的 20-60 字节头部,减少了 60-87% 的协议开销。在高频小数据包的传输场景中,这种优势尤为明显。
强大的并发处理能力: UDP 的轻量级特性使得其具备卓越的并发处理能力。由于无需维护连接状态,单机可以轻松处理百万级并发请求,而 TCP 通常在 10 万级并发时就开始遇到性能瓶颈。
多播与广播能力: UDP 原生支持多播和广播传输,这使得它在需要向多个接受者同时发送数据的场景中具有独特优势。一个数据包可以同时发送给数百个接收者,大大提高了网络资源利用率。
UDP的明显缺点
不可靠性: UDP 不保证数据包的可靠到达,这是其最大的短板。在实际网络中,UDP 的丢包率通常在 1-15%之间,具体取决于网络质量。不可靠的具体表现如下:
- 数据包丢失: 网络拥塞时可能被路由器丢弃
- 数据包重复: 网络设备故障可能导致重复数据包
- 数据包乱序: 不同路径传输导致到达顺序错乱
- 无重传机制: 丢失的数据包不会自动补发
缺乏流量控制: UDP 没有内置的流量控制机制,发送方可以以任意速率发送数据。当发送速率超过网络或接收方的处理能力时,会导致大量数据包被丢弃。这可能会导致以下风险:
- 网络拥塞加剧: UDP 的"野蛮发送"可能加剧网络拥塞
- 接收方缓冲区溢出: 接收方来不及处理的数据会被直接丢弃
- 公平性问题: UDP 流量可能挤占 TCP 流量的宽带
安全性薄弱: UDP 的无连接特性使其安全性相对较弱,容易成为各种网络攻击的目标。也就代表使用 UDP 通信存在以下安全威胁:
- Dos 攻击: 攻击者可以轻易伪造源地址发起攻击
- UDP Flood: 大量 UDP 数据包可能耗尽目标主机资源
- 欺骗攻击: 缺乏连接验证机制,容易被欺骗
TCP:应运而生的可靠传输解决方案
UDP 发送数据包的时候,就是我不管你这那的,你网络拥挤关我什么事?你接收方缓冲区打满关我什么事?别人非要对我发动攻击那我能有什么招?不管不管,我就发,高效就完了。但是有一些场景是要求数据完整性极高的,比如金融交易、医疗数据等等,这时候可以接受数据传输得慢一些,但是一旦接收方收到并读取数据,就一定不能是错的。
所以,TCP 就被设计出来了,并且它也被设计为一个"无私"的协议。之所以这么说,是因为用 TCP 通信的时候,它不仅会考虑接收方的状态,还会预防一些恶意的攻击,甚至还会考虑整个网络的状态,做到不在网络拥塞时给网络"添堵"。
TCP 比 UDP 多了什么?
上文说了 TCP 可以保证可靠性,但 UDP 不能。但是为啥呢?
因为 UDP 的报头中除了报文长度和校验和之外,就只有一个源端口和目的端口了,它甚至都不知道自己是在给哪一个客户端进行通信,所以当出现问题的时候自然没办法进行一系列的保障措施。但 TCP 不一样,它在通信之前是会建立一个虚电路的。
虚电路:一种分组交换网络里的连接型服务,核心是在真正发数据之前,用控制分组在源-目的之间建立一条"状态化"逻辑通路;后续所有数据分组按序、按同一 VC 号(一条逻辑连接对应的一个整数编号)转发,通信结束再用控制分组拆除。网络节点必须维护每条虚电路的软状态。
说人话就是通信之前,先在两端之间建立一个逻辑上的通路(逻辑并非物理),并且这个通路是有唯一标识的状态化的,之后 TCP 所有的可靠性保障机制都与此相关。这个虚电路也有一个比较普遍的叫法--连接。
RFC 官方定义的连接的含义是:上述可靠性与流量控制机制要求 TCP 为每条数据流初始化并维护特定的状态信息。这些信息,包括套接字、序列号以及窗口大小等的组合称为"连接"。 原文为:The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.感觉我翻译的还行哈~
但是这篇文章先不对连接中的各种元素做出过多解释(因为一篇文章讲不完),先把上面翻译中提到的状态信息mark一下,在下文中还会出现。现在我们只需要知道,TCP 通信之前需要先建立连接即可。
内核角度的TCP通信流程
我认为要谈网络协议栈是不可能也不该避开操作系统的,因为 TCP/IP 协议栈就是贯穿操作系统的,TCP 的那些通信接口本质上也就是系统调用接口。

所以我想在介绍 TCP 通信流程的同时,把每一个步骤对应的内核所做的工作一起展现出来(介绍时暂时忽略接口所需参数)。
创建套接字--socket()
和 UDP 一样,创建套接字之后返回的是一个文件描述符,也就是说,创建套接字的本质就是打开一个文件。
那打开一个文件在系统内部又做了哪些工作呢?

- 系统内部会创建一个新的 struct file 结构体,其中保存有文件属性结构体和文件缓冲区的地址。
- 会在 struct file* fd_array 数组中第一个未被使用的位置填入新的 struct file 结构体首地址。
- 将新的 struct file* 所在 fd_array 中的下标返回,也就是所谓的文件描述符,也就是所谓的套接字。
后续所谓的从套接字读取数据以及向套接字写入数据,本质上都是在针对网络文件做数据拷贝工作。
绑定--bind()
上一步创建网络文件之后,这个文件实际上跟其他普通的文件没有任何区别。如此就能直接通信了吗?
但是网络通信是从网卡中读取数据的啊,发送数据也是从网卡中发出去的啊,如果说直接使用刚刚创建的网络文件可以做到操作网卡中的数据,那是不是其他文件也能呢?那我本地进程间通信的数据不就也发到网络中了吗?
上述问题的关键点就在于要设法将网络文件和普通文件区分开。区分的方法就是,将每一个网络文件对应的 struct file 关联一个 struct sock 结构体(普通文件是没有这个关联的)。而 bind 就是在往这个 struct sock 结构体里面填充信息。 再补充一下,用户可见层看到的只有 struct socket 结构体,也只能往 socket 结构体里面填入用户自己指定的 IP 和 port,但是 bind() 最终是将信息填入内核中的 struct sock 结构体的。
注:有的文章说 "绑定就是改变网络文件当中文件操作函数的指向,将对应的操作函数改为对网卡的操作方法,让网络文件和网卡关联起来" 是绝对错误的,函数指针从 socket 被创建的那一刻起就定死了,后面再也不会改变。
bind核心调用栈如下:

下面对两个关键接口进行更进一步的解释。
inet_bind():绑定前置校验器
- AF_INET 兼容性检查:校验 sockaddr_in 的 sin_family 是否为 AF_INET,排除 IPv6 等不兼容协议
- 特权端口校验:1024 以下端口需 CAP_NET_BIND_SERVICE 权限,普通用户无权限则返回错误码
- 参数预处理:
- sin_port == 0 则触发随机端口分配
- sin_port != 0 则直接进入冲突校验
- sin_addr == INADDR_ANY 则标记为监听本机所有网卡
- 调用 inet_csk_get_port():端口分配/冲突校验的核心入口
这一步主要是完成合法性校验和参数预处理,还是比较好理解的,接下来主要讲解端口分配和冲突判断流程。
inet_csk_get_port():端口分配与冲突裁判
这一部分主要是对内核源码中处理端口分配和冲突逻辑的梳理,主要可以根据应用层传过来的端口号是否为 0 进行不同的处理动作。
端口号为 0,随机分配流程:
- 获取端口范围:读取内核参数
/proc/sys/net/ipv4/ip_local_port_range(默认通常为32768-60999),确定可分配的端口区间。 - 随机起始位置:通过
net_random()生成伪随机数,作为遍历端口的起始点(避免每次从区间开头分配导致的端口集中问题)。 - 跳过保留端口:调用
net_is_reserved_local_port(rover)排除系统保留端口(如22、25等),确保分配的端口不与系统服务冲突。 - 冲突检测与选择:遍历端口时,通过
inet_bind_bucket_for_each检查端口是否已被占用;若开启SO_REUSEADDR或SO_REUSEPORT,则按重用规则判断是否允许分配(如同一用户的多个进程可共享端口)。
端口号非 0,冲突校验流程:
当应用层指定端口时,函数会先查找该端口对应的inet_bind_bucket(内核中管理端口绑定的哈希桶结构),再按以下逻辑判断:
- 无占用则创建桶:若端口未被绑定,则创建新的
inet_bind_bucket,将当前 socket 加入tb->owners链表,并标记端口为已占用。 - 已占用则判断重用:若端口已被其他 socket 绑定,则通过
inet_csk_bind_conflict(冲突判断函数)检查是否符合重用条件 ---- 仅当满足SO_REUSEADDR或SO_REUSEPORT的规则时,才允许绑定成功(否则返回 EADDRINUSE 错误)。
端口冲突判断的核心逻辑(inet_csk_bind_conflict): 
到这里想必大家就能理解了,为什么绑定的时候要传入 IP 和端口号,以及把端口号指定为 INADDR_ANY 有什么用。
注:服务端需要显示绑定一个不变的端口,因为客户端要根据端口访问服务端,那客户端就要知道服务端的端口是哪个,而且服务端通常一经启动就不会退出,所以这个端口就应该是固定的。
而客户端不需要显示得指明一个端口来进行绑定主要有以下原因:
客户端端口的临时性需求:
- 客户端不需要被其他设备"主动找到"
- 端口只用在当前通信过程中,通信结束后就可以释放,不需要长期占用
操作系统会自动分配临时端口:
- 从临时端口范围(通常是1024~65535)里选一个当前未被占用的端口
- 把这个端口和当前客户端进行绑定,作为本次通信的源端口
避免端口冲突的麻烦:
- 手动选择的端口可能已经被其他进程占用(上文中也说明了端口冲突检测还是有点麻烦的)
- 不同及其的客户端使用了相同的端口也没关系,因为最后组成的五元组还是不同的,不需要用户额外协调
监听--listen()
经过上述的创建套接字以及绑定过程,服务端已经有了一个套接字,并且这个套接字也已经有其对应的 IP 和端口号了。但是要知道此时的服务端套接字和客户端调用 socket() 之后的套接字没有区别,即使客户端没有调用 bind() 进行绑定,但客户端实际的绑定的工作也已经完成了,只不过是系统内核自己选择端口进行绑定的。
上文中说过,TCP 通信之前都要在源-目的之间先建立一个连接,而一个连接是包含套接字和状态信息的。但是在服务端创建套接字以及绑定成功之后,也只是新创建了一个套接字。如果说用这个套接字就可以进行网络通信的话,那岂不是所有的客户端与服务端用的都是同一个文件描述符,这显然是不合理的。所以,通信连接中的套接字肯定不是服务端 scoket() 时得到的套接字!
另外,作为服务端,肯定是不知道什么时候会有哪一个客户端要与自己进行通信,所以服务端显然是不能主动与客户端建立连接的,所以必然是客户端要主动向服务端发起建立连接的请求,进而和服务端建立连接,之后才可以进行通信。
故而监听的作用之一就是将服务端 socket() 返回的"主动套接字"变成"被动套接字",也可以叫做监听套接字,开启"被动接收连接"的能力,否则就无法处理客户端的连接请求。具体做法就是修改 struct sock 中的 sk_state 字段。
cpp
struct sock
{ // ... 其他字段
volatile unsigned char sk_state; // 套接字状态标记
// ... 其他字段(如端口、IP、连接队列、回调函数等)
};
这个字段常见的几种状态如下:

再来的分析一下上文中的 socket 和 bind 这两个过程中套接字的状态:
- socket() 创建套接字,则 sk_state 状态一定是初始状态,即 TCP_CLOSE
- bind() 只是给 struct sock 里面填充 IP 和 port,不会修改 sk_state,即还是 TCP_CLOSE 状态
而监听--listen() 之后,就变成了 TCP_LISTEN 状态。 表格中还有两种状态,接下来一一解释。
首先要知道的是,监听套接字所对应的 sk_state 并不会变成后面两种状态。因为监听套接字的作用就是接收客户端发来的连接请求,当收到请求的时候,一定是会创建一个新的套接字,并将这个套接字当做新的通信连接的一部分。所以,后面两种状态都是针对新创建的通信连接而言的。
客户端要与服务端通信,就要先发起连接建立请求,其中会包含一个 SYN 标志,表示这是一个连接建立请求。服务端收到之后,新创建的通信连接就是 TCP_SYN_RECV 状态,表示服务端收到了客户端的 SYN。
但是,同一时刻是可能有很多个客户端向服务端发起连接建立请求的,并且处于 TCP_SYN_RECV 状态的连接并没有完全建立成功,因为后面还有没有完成的流程,所以就要把这些"连接半成品"先保存起来。

也就是说,listen() 接收到客户端的连接建立请求之前就会创建一个半连接队列,当收到一个连接建立请求,就把未建立完成的连接先放在半连接队列里。
如果是一个正常的客户端,则之后一定会把连接建立的工作完成(这里先不解释剩余的工作内容,只需要先知道连接最终会建立完成即可),这时候通信连接的状态就是 TCP_ESTABLISHED。想都不用想,已经建立完成的连接就不应该继续放在半连接队列里了,而是应该转移到全连接队列。

所以 listen() 的作用就是:将监听套接字对应的 sk_state 变为 TCP_LISTEN,开始接收客户端连接建立请求;以及创建半连接队列和全连接队列,并根据通信连接的状态将其放入合适的队列。
获取通信连接--accept()
在上一步的 listen 中,显然全连接队列里可能已经有建立好的连接了,但是上层并不知道,也就没办法开始通信。所以 accept 的作用就是将全连接队列中的连接"捞取"上来。

本质上就是给每一个通信连接也分配对应的 struct file 和文件描述符,之后客户端和服务端之间进行通信,上层也就是不断在进行从通信文件描述符对应的文件中读取数据,以及向其中写入数据。
总结
这篇文章写到这里,主要就是引入了 TCP 的概念,以及服务端创建套接字、绑定、监听以及接收连接的过程。目的就是让读者眼中的接口不再只是接口,也为之后的文章埋下伏笔。因为这篇文章的某些地方,其实我有意在避重就轻,毕竟冰冻三尺非一日之寒,更多细节会在后续的文章中一一解析~