- 前言
- 先建立一张总图
- Socket 到底是什么
- 内核协议栈负责什么
- 一个 TCP 连接从哪里开始
- 服务端 Socket 的生命周期
- 客户端 Socket 的生命周期
- send 成功不等于对方收到
- recv 没读到数据意味着什么
- 从 send 到网卡:发送路径
- 从网卡到 recv:接收路径
- TCP 为什么可靠
- 三次握手到底在协商什么
- 四次挥手与 TIME_WAIT
- 阻塞、非阻塞与 IO 多路复用
- select、poll、epoll 的直觉区别
- 常见调试命令
- 推荐学习路线
- 常见误区
- 总结
前言
这篇笔记用来建立一个清晰的网络编程底层地图:
应用程序如何通过 Socket 把数据交给内核;内核协议栈又如何把这些数据变成网络上的 TCP/IP 数据包。
先不要急着背大量函数和协议细节。入门最重要的是建立几个核心认知:
- Socket 是应用程序和内核网络协议栈之间的接口。
- TCP、UDP、IP 等协议主要由内核协议栈处理。
send()成功通常只代表数据进入了本机内核缓冲区,不代表对端已经收到。recv()读取的是本机内核接收缓冲区里的数据。- 高性能网络编程的关键,经常在于理解缓冲区、阻塞、非阻塞和事件通知。
先建立一张总图
可以先把网络收发理解成这条链路:
应用程序 |
反过来,接收数据时大致是:
网络 |
这张图很重要。以后看到任何网络问题,都可以先问:
- 问题在应用层?
- 问题在 Socket 使用方式?
- 问题在 TCP 状态或缓冲区?
- 问题在 IP 路由?
- 问题在网卡、驱动、防火墙或系统参数?
Socket 到底是什么
Socket 不是 TCP,也不是 UDP。Socket 更像是应用程序操作网络能力的一个“文件描述符”。
在 Linux / Unix 系统里,很多东西都可以被抽象成文件描述符:
- 普通文件。
- 管道。
- 终端。
- Socket。
所以网络编程中常看到:
int fd = socket(AF_INET, SOCK_STREAM, 0); |
这里返回的 fd 就是一个 Socket 文件描述符。应用程序后续通过它调用:
bind(fd, ...); |
可以把 Socket 理解成:
应用程序拿到的一个句柄,用来请求内核帮自己完成网络收发。
内核协议栈负责什么
内核协议栈负责处理网络协议的绝大多数细节。常见层次如下:
应用层:HTTP / WebSocket / Redis 协议 / 自定义协议 |
对于普通应用程序来说,经常直接接触的是:
- TCP Socket。
- UDP Socket。
- Unix Domain Socket。
其中 TCP Socket 最常见,因为 HTTP、HTTPS、SSH、MySQL、Redis 等很多协议都运行在 TCP 之上。
内核协议栈会帮我们处理:
- TCP 三次握手。
- TCP 四次挥手。
- 序列号和确认号。
- 丢包重传。
- 滑动窗口。
- 拥塞控制。
- IP 路由选择。
- 分片与重组。
- 网卡队列和中断处理。
应用程序一般不直接操作这些细节,而是通过 Socket API 间接控制。
一个 TCP 连接从哪里开始
TCP 是面向连接的协议。所谓“连接”,不是一根真的线,而是两端内核维护的一组状态。
一个 TCP 连接通常由四元组唯一标识:
源 IP |
比如:
192.168.1.10:52344 -> 203.0.113.20:443 |
这代表本机 192.168.1.10 使用临时端口 52344 连接远端 203.0.113.20 的 443 端口。
注意:端口不是进程,端口是内核协议栈里的一个寻址概念。一个进程通过 bind() 绑定某个端口后,内核才知道这个端口上的数据应该交给哪个 Socket。
服务端 Socket 的生命周期
一个典型 TCP 服务端大概这样:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0); |
核心步骤:
socket():创建 Socket。bind():绑定本地 IP 和端口。listen():进入监听状态。accept():从已完成连接队列里取出一个连接。recv()/send():收发数据。close():关闭连接。
这里有一个很容易忽略的点:
listen_fd只负责监听;accept()返回的conn_fd才负责和某个客户端真正通信。
所以服务端至少有两类 Socket:
- 监听 Socket。
- 已连接 Socket。
客户端 Socket 的生命周期
一个典型 TCP 客户端大概这样:
int fd = socket(AF_INET, SOCK_STREAM, 0); |
核心步骤:
socket():创建 Socket。connect():发起 TCP 三次握手。send():向连接写数据。recv():从连接读数据。close():关闭连接。
connect() 成功返回,说明 TCP 三次握手完成,连接进入 ESTABLISHED 状态。
send 成功不等于对方收到
这是网络编程里最重要的入门认知之一。
当你调用:
send(fd, buf, len, 0); |
通常发生的是:
- 应用程序从用户态进入内核态。
- 内核检查这个 Socket 的状态。
- 数据从用户缓冲区拷贝到内核发送缓冲区。
send()返回成功。- 后续由 TCP 协议栈决定什么时候真正发包。
所以:
send 成功 ≠ 数据已经到达对方应用程序 |
更准确地说:
send()成功通常只表示数据已经成功交给本机内核。
如果要确认对方业务真的处理成功,需要应用层协议自己设计确认机制。比如 HTTP 响应、RPC 返回值、业务 ACK 等。
recv 没读到数据意味着什么
recv() 的行为和 Socket 是否阻塞有关。
阻塞 Socket 上调用:
recv(fd, buf, sizeof(buf), 0); |
如果接收缓冲区没有数据,线程会睡眠等待。
非阻塞 Socket 上调用 recv(),如果暂时没有数据,通常会返回 -1,并设置:
errno = EAGAIN 或 EWOULDBLOCK |
此外,recv() 返回 0 通常表示:
对端已经有序关闭连接。
也就是对方发送了 FIN,本端读到了 EOF。
常见情况可以这样记:
返回值 > 0:读到了这么多字节 |
从 send 到网卡:发送路径
一次发送大致可以拆成这些步骤:
应用程序 send |
这里的关键点:
- 应用层看到的是字节流。
- TCP 会把字节流切成适合网络传输的段。
- IP 负责寻址和路由。
- 以太网 / WiFi 负责局域网内传输。
从网卡到 recv:接收路径
接收方向大致相反:
网卡收到帧 |
如果应用程序一直不 recv(),数据会堆在内核接收缓冲区里。缓冲区满了以后,TCP 会通过窗口机制告诉对端:
我现在收不下了,先别发。
这就是 TCP 流量控制的一部分。
TCP 为什么可靠
TCP 的可靠性不是魔法,而是一组机制组合出来的:
- 序列号:给字节流编号。
- 确认号:告诉对方我已经收到哪里。
- 超时重传:长时间没确认就重发。
- 快速重传:发现明显丢包时尽快重发。
- 滑动窗口:控制未确认数据量。
- 流量控制:避免把接收方打爆。
- 拥塞控制:避免把网络打爆。
- 校验和:发现传输过程中的错误。
因此 TCP 提供的是:
可靠、有序、面向字节流的传输 |
但 TCP 不保证:
- 应用层消息边界。
- 对端业务处理成功。
- 网络永远不断。
- 延迟稳定。
所以 TCP 上层协议仍然要处理粘包、拆包、超时、重试、幂等等问题。
三次握手到底在协商什么
三次握手的简化过程:
客户端 -> 服务端:SYN |
它主要解决:
- 双方确认彼此收发能力正常。
- 双方同步初始序列号。
- 双方建立连接状态。
- 协商部分 TCP 选项,例如 MSS、窗口扩大、时间戳、SACK 等。
三次握手之后,两端内核才认为连接建立完成。
四次挥手与 TIME_WAIT
TCP 关闭是双向的。因为 TCP 是全双工连接,两边都可以独立关闭自己的发送方向。
简化过程:
主动关闭方 -> 被动关闭方:FIN |
主动关闭方最后通常会进入 TIME_WAIT 状态。
TIME_WAIT 的作用主要有两个:
- 确保最后一个 ACK 如果丢了,对方重发 FIN 时还能回应。
- 让旧连接残留在网络中的包自然过期,避免影响后续同四元组的新连接。
所以看到大量 TIME_WAIT 不一定是错误。要结合连接量、端口耗尽、系统参数和业务模型判断。
阻塞、非阻塞与 IO 多路复用
Socket 默认通常是阻塞的。
阻塞 IO 的直觉:
没有数据时,线程睡着等。 |
非阻塞 IO 的直觉:
没有数据时,立刻返回,不让线程卡住。 |
但如果只是非阻塞,然后自己写死循环不断 recv(),CPU 会被浪费掉。所以还需要事件通知机制:
selectpollepollkqueue(BSD / macOS)- IOCP(Windows)
在 Linux 高性能网络服务里,最常见的是:
非阻塞 Socket + epoll + 事件循环 |
也就是常说的 Reactor 模型。
select、poll、epoll 的直觉区别
非常粗略地理解:
select
特点:
- 历史悠久。
- 需要传入 fd 集合。
- fd 数量有限制。
- 每次调用都要扫描。
适合学习概念,不适合大量连接场景。
poll
特点:
- 用数组保存 fd。
- 没有
select那种固定 fd 上限问题。 - 仍然需要遍历数组。
比 select 灵活,但大量连接下仍不够理想。
epoll
特点:
- 把关心的 fd 注册到内核。
- 就绪事件发生时再通知应用。
- 更适合大量连接。
- 支持水平触发和边缘触发。
常见服务器框架如 Nginx、Redis、很多 C/C++ 网络库都会围绕类似事件模型设计。
常见调试命令
下面命令适合在自己的机器或授权环境里学习观察。
查看监听端口:
ss -lntp |
查看 TCP 连接状态:
ss -ant |
查看某个端口是否监听:
lsof -i :8080 |
抓包观察 TCP 握手:
sudo tcpdump -i any tcp port 8080 |
本机简单起一个 TCP 服务:
nc -l 8080 |
另一个终端连接:
nc 127.0.0.1 8080 |
观察路由:
ip route |
macOS 上类似命令:
netstat -rn |
推荐学习路线
建议按这个顺序学:
第 1 阶段:Socket API
目标:能写出最小 TCP echo server / client。
重点:
socket()bind()listen()accept()connect()send()recv()close()
先能跑通,再理解细节。
第 2 阶段:TCP 基础
目标:能解释一个连接从建立到关闭的状态变化。
重点:
- 三次握手。
- 四次挥手。
ESTABLISHED。TIME_WAIT。CLOSE_WAIT。- 滑动窗口。
- 重传。
- 拥塞控制。
第 3 阶段:缓冲区与阻塞
目标:理解为什么网络程序会卡住、延迟、丢连接或吞吐不足。
重点:
- 发送缓冲区。
- 接收缓冲区。
- 阻塞 IO。
- 非阻塞 IO。
- 超时。
- backpressure,也就是反压。
第 4 阶段:IO 多路复用
目标:理解一个线程如何管理大量连接。
重点:
selectpollepoll- 水平触发。
- 边缘触发。
- Reactor 模型。
第 5 阶段:协议栈路径
目标:能把应用层问题定位到更底层。
重点:
- 用户态 / 内核态切换。
- TCP/IP 分层。
- 路由。
- ARP / 邻居表。
- 网卡驱动。
- 中断与软中断。
tcpdump抓包分析。
常见误区
误区 1:send 成功就是发给对方了
不对。send() 成功多数时候只是写入本机内核发送缓冲区。
误区 2:TCP 有消息边界
不对。TCP 是字节流,不保留应用层消息边界。
比如你发送两次:
hello |
对方可能一次读到:
helloworld |
也可能分多次读到:
he |
所以应用层协议需要自己设计长度字段、分隔符或固定格式。
误区 3:连接断开一定能立刻发现
不一定。网络断开、对方机器掉电、中间链路异常时,本端可能不会马上知道。需要靠:
- TCP keepalive。
- 应用层心跳。
- 读写超时。
- 业务请求超时。
误区 4:TIME_WAIT 一定是问题
不一定。TIME_WAIT 是 TCP 正常机制。真正要关注的是:
- 是否端口耗尽。
- 是否连接创建过于频繁。
- 是否短连接模型不合理。
- 是否服务端主动关闭太多连接。
误区 5:epoll 一定比多线程简单
不一定。epoll 提升的是事件通知效率,但会带来状态机复杂度。小规模程序用阻塞 IO + 线程也可以很清晰。
总结
Socket 与内核协议栈可以用一句话串起来:
应用程序通过 Socket API 把字节交给内核,内核协议栈负责把字节变成 TCP/IP 数据包并完成可靠传输、路由和收发。
初学时先抓住这几个核心点:
- Socket 是接口,不是协议。
- TCP 是字节流,不是消息流。
send()成功不代表对方收到。recv()读的是本机内核接收缓冲区。- TCP 可靠性来自序列号、ACK、重传、窗口和拥塞控制。
- 高性能网络编程绕不开非阻塞 IO 和事件通知。
下一步可以实践一个最小 TCP echo server,然后用 tcpdump 抓包观察三次握手、数据传输和四次挥手。这样 Socket API 和内核协议栈就能真正连起来。