Socket 与内核协议栈学习笔记

前言

这篇笔记用来建立一个清晰的网络编程底层地图:

应用程序如何通过 Socket 把数据交给内核;内核协议栈又如何把这些数据变成网络上的 TCP/IP 数据包。

先不要急着背大量函数和协议细节。入门最重要的是建立几个核心认知:

  1. Socket 是应用程序和内核网络协议栈之间的接口。
  2. TCP、UDP、IP 等协议主要由内核协议栈处理。
  3. send() 成功通常只代表数据进入了本机内核缓冲区,不代表对端已经收到。
  4. recv() 读取的是本机内核接收缓冲区里的数据。
  5. 高性能网络编程的关键,经常在于理解缓冲区、阻塞、非阻塞和事件通知。

先建立一张总图

可以先把网络收发理解成这条链路:

应用程序
↓ Socket API:socket / bind / listen / accept / connect / send / recv
用户态 / 内核态边界

内核 Socket 对象

TCP / UDP

IP

网卡驱动

网卡

网络

反过来,接收数据时大致是:

网络

网卡

网卡驱动

IP 层

TCP / UDP 层

Socket 接收缓冲区
↓ recv
应用程序

这张图很重要。以后看到任何网络问题,都可以先问:

  1. 问题在应用层?
  2. 问题在 Socket 使用方式?
  3. 问题在 TCP 状态或缓冲区?
  4. 问题在 IP 路由?
  5. 问题在网卡、驱动、防火墙或系统参数?

Socket 到底是什么

Socket 不是 TCP,也不是 UDP。Socket 更像是应用程序操作网络能力的一个“文件描述符”。

在 Linux / Unix 系统里,很多东西都可以被抽象成文件描述符:

  1. 普通文件。
  2. 管道。
  3. 终端。
  4. Socket。

所以网络编程中常看到:

int fd = socket(AF_INET, SOCK_STREAM, 0);

这里返回的 fd 就是一个 Socket 文件描述符。应用程序后续通过它调用:

bind(fd, ...);
listen(fd, ...);
accept(fd, ...);
connect(fd, ...);
send(fd, ...);
recv(fd, ...);
close(fd);

可以把 Socket 理解成:

应用程序拿到的一个句柄,用来请求内核帮自己完成网络收发。

内核协议栈负责什么

内核协议栈负责处理网络协议的绝大多数细节。常见层次如下:

应用层:HTTP / WebSocket / Redis 协议 / 自定义协议
传输层:TCP / UDP
网络层:IP / ICMP
链路层:以太网 / WiFi / ARP
物理层:网线 / 无线电信号

对于普通应用程序来说,经常直接接触的是:

  1. TCP Socket。
  2. UDP Socket。
  3. Unix Domain Socket。

其中 TCP Socket 最常见,因为 HTTP、HTTPS、SSH、MySQL、Redis 等很多协议都运行在 TCP 之上。

内核协议栈会帮我们处理:

  1. TCP 三次握手。
  2. TCP 四次挥手。
  3. 序列号和确认号。
  4. 丢包重传。
  5. 滑动窗口。
  6. 拥塞控制。
  7. IP 路由选择。
  8. 分片与重组。
  9. 网卡队列和中断处理。

应用程序一般不直接操作这些细节,而是通过 Socket API 间接控制。

一个 TCP 连接从哪里开始

TCP 是面向连接的协议。所谓“连接”,不是一根真的线,而是两端内核维护的一组状态。

一个 TCP 连接通常由四元组唯一标识:

源 IP
源端口
目标 IP
目标端口

比如:

192.168.1.10:52344 -> 203.0.113.20:443

这代表本机 192.168.1.10 使用临时端口 52344 连接远端 203.0.113.20443 端口。

注意:端口不是进程,端口是内核协议栈里的一个寻址概念。一个进程通过 bind() 绑定某个端口后,内核才知道这个端口上的数据应该交给哪个 Socket。

服务端 Socket 的生命周期

一个典型 TCP 服务端大概这样:

int listen_fd = socket(AF_INET, SOCK_STREAM, 0);

bind(listen_fd, ...);
listen(listen_fd, SOMAXCONN);

while (1) {
int conn_fd = accept(listen_fd, ...);
recv(conn_fd, buf, sizeof(buf), 0);
send(conn_fd, buf, len, 0);
close(conn_fd);
}

核心步骤:

  1. socket():创建 Socket。
  2. bind():绑定本地 IP 和端口。
  3. listen():进入监听状态。
  4. accept():从已完成连接队列里取出一个连接。
  5. recv() / send():收发数据。
  6. close():关闭连接。

这里有一个很容易忽略的点:

listen_fd 只负责监听;accept() 返回的 conn_fd 才负责和某个客户端真正通信。

所以服务端至少有两类 Socket:

  1. 监听 Socket。
  2. 已连接 Socket。

客户端 Socket 的生命周期

一个典型 TCP 客户端大概这样:

int fd = socket(AF_INET, SOCK_STREAM, 0);
connect(fd, ...);
send(fd, request, len, 0);
recv(fd, response, sizeof(response), 0);
close(fd);

核心步骤:

  1. socket():创建 Socket。
  2. connect():发起 TCP 三次握手。
  3. send():向连接写数据。
  4. recv():从连接读数据。
  5. close():关闭连接。

connect() 成功返回,说明 TCP 三次握手完成,连接进入 ESTABLISHED 状态。

send 成功不等于对方收到

这是网络编程里最重要的入门认知之一。

当你调用:

send(fd, buf, len, 0);

通常发生的是:

  1. 应用程序从用户态进入内核态。
  2. 内核检查这个 Socket 的状态。
  3. 数据从用户缓冲区拷贝到内核发送缓冲区。
  4. send() 返回成功。
  5. 后续由 TCP 协议栈决定什么时候真正发包。

所以:

send 成功 ≠ 数据已经到达对方应用程序
send 成功 ≠ 对方已经 recv
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:读到了这么多字节
返回值 = 0:对端关闭连接
返回值 < 0:出错,非阻塞下可能只是暂时没数据

从 send 到网卡:发送路径

一次发送大致可以拆成这些步骤:

应用程序 send

用户态数据拷贝到内核 Socket 发送缓冲区

TCP 层根据 MSS 切分数据

添加 TCP 头:源端口、目标端口、序列号、确认号等

IP 层添加 IP 头:源 IP、目标 IP、TTL 等

路由选择:决定从哪个网卡发出,下一跳是谁

邻居子系统 / ARP:找到下一跳 MAC 地址

网卡驱动

网卡 DMA 发送

网络

这里的关键点:

  1. 应用层看到的是字节流。
  2. TCP 会把字节流切成适合网络传输的段。
  3. IP 负责寻址和路由。
  4. 以太网 / WiFi 负责局域网内传输。

从网卡到 recv:接收路径

接收方向大致相反:

网卡收到帧

网卡通过 DMA 放入内存

触发中断或轮询机制

网卡驱动取包

IP 层检查目标 IP、校验、分片重组

TCP 层检查端口、序列号、状态

放入对应 Socket 的接收缓冲区

唤醒等待这个 Socket 的进程或线程

应用程序 recv 读走数据

如果应用程序一直不 recv(),数据会堆在内核接收缓冲区里。缓冲区满了以后,TCP 会通过窗口机制告诉对端:

我现在收不下了,先别发。

这就是 TCP 流量控制的一部分。

TCP 为什么可靠

TCP 的可靠性不是魔法,而是一组机制组合出来的:

  1. 序列号:给字节流编号。
  2. 确认号:告诉对方我已经收到哪里。
  3. 超时重传:长时间没确认就重发。
  4. 快速重传:发现明显丢包时尽快重发。
  5. 滑动窗口:控制未确认数据量。
  6. 流量控制:避免把接收方打爆。
  7. 拥塞控制:避免把网络打爆。
  8. 校验和:发现传输过程中的错误。

因此 TCP 提供的是:

可靠、有序、面向字节流的传输

但 TCP 不保证:

  1. 应用层消息边界。
  2. 对端业务处理成功。
  3. 网络永远不断。
  4. 延迟稳定。

所以 TCP 上层协议仍然要处理粘包、拆包、超时、重试、幂等等问题。

三次握手到底在协商什么

三次握手的简化过程:

客户端 -> 服务端:SYN
服务端 -> 客户端:SYN + ACK
客户端 -> 服务端:ACK

它主要解决:

  1. 双方确认彼此收发能力正常。
  2. 双方同步初始序列号。
  3. 双方建立连接状态。
  4. 协商部分 TCP 选项,例如 MSS、窗口扩大、时间戳、SACK 等。

三次握手之后,两端内核才认为连接建立完成。

四次挥手与 TIME_WAIT

TCP 关闭是双向的。因为 TCP 是全双工连接,两边都可以独立关闭自己的发送方向。

简化过程:

主动关闭方 -> 被动关闭方:FIN
被动关闭方 -> 主动关闭方:ACK
被动关闭方 -> 主动关闭方:FIN
主动关闭方 -> 被动关闭方:ACK

主动关闭方最后通常会进入 TIME_WAIT 状态。

TIME_WAIT 的作用主要有两个:

  1. 确保最后一个 ACK 如果丢了,对方重发 FIN 时还能回应。
  2. 让旧连接残留在网络中的包自然过期,避免影响后续同四元组的新连接。

所以看到大量 TIME_WAIT 不一定是错误。要结合连接量、端口耗尽、系统参数和业务模型判断。

阻塞、非阻塞与 IO 多路复用

Socket 默认通常是阻塞的。

阻塞 IO 的直觉:

没有数据时,线程睡着等。

非阻塞 IO 的直觉:

没有数据时,立刻返回,不让线程卡住。

但如果只是非阻塞,然后自己写死循环不断 recv(),CPU 会被浪费掉。所以还需要事件通知机制:

  1. select
  2. poll
  3. epoll
  4. kqueue(BSD / macOS)
  5. IOCP(Windows)

在 Linux 高性能网络服务里,最常见的是:

非阻塞 Socket + epoll + 事件循环

也就是常说的 Reactor 模型。

select、poll、epoll 的直觉区别

非常粗略地理解:

select

特点:

  1. 历史悠久。
  2. 需要传入 fd 集合。
  3. fd 数量有限制。
  4. 每次调用都要扫描。

适合学习概念,不适合大量连接场景。

poll

特点:

  1. 用数组保存 fd。
  2. 没有 select 那种固定 fd 上限问题。
  3. 仍然需要遍历数组。

select 灵活,但大量连接下仍不够理想。

epoll

特点:

  1. 把关心的 fd 注册到内核。
  2. 就绪事件发生时再通知应用。
  3. 更适合大量连接。
  4. 支持水平触发和边缘触发。

常见服务器框架如 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
lsof -iTCP -sTCP:LISTEN

推荐学习路线

建议按这个顺序学:

第 1 阶段:Socket API

目标:能写出最小 TCP echo server / client。

重点:

  1. socket()
  2. bind()
  3. listen()
  4. accept()
  5. connect()
  6. send()
  7. recv()
  8. close()

先能跑通,再理解细节。

第 2 阶段:TCP 基础

目标:能解释一个连接从建立到关闭的状态变化。

重点:

  1. 三次握手。
  2. 四次挥手。
  3. ESTABLISHED
  4. TIME_WAIT
  5. CLOSE_WAIT
  6. 滑动窗口。
  7. 重传。
  8. 拥塞控制。

第 3 阶段:缓冲区与阻塞

目标:理解为什么网络程序会卡住、延迟、丢连接或吞吐不足。

重点:

  1. 发送缓冲区。
  2. 接收缓冲区。
  3. 阻塞 IO。
  4. 非阻塞 IO。
  5. 超时。
  6. backpressure,也就是反压。

第 4 阶段:IO 多路复用

目标:理解一个线程如何管理大量连接。

重点:

  1. select
  2. poll
  3. epoll
  4. 水平触发。
  5. 边缘触发。
  6. Reactor 模型。

第 5 阶段:协议栈路径

目标:能把应用层问题定位到更底层。

重点:

  1. 用户态 / 内核态切换。
  2. TCP/IP 分层。
  3. 路由。
  4. ARP / 邻居表。
  5. 网卡驱动。
  6. 中断与软中断。
  7. tcpdump 抓包分析。

常见误区

误区 1:send 成功就是发给对方了

不对。send() 成功多数时候只是写入本机内核发送缓冲区。

误区 2:TCP 有消息边界

不对。TCP 是字节流,不保留应用层消息边界。

比如你发送两次:

hello
world

对方可能一次读到:

helloworld

也可能分多次读到:

he
llowor
ld

所以应用层协议需要自己设计长度字段、分隔符或固定格式。

误区 3:连接断开一定能立刻发现

不一定。网络断开、对方机器掉电、中间链路异常时,本端可能不会马上知道。需要靠:

  1. TCP keepalive。
  2. 应用层心跳。
  3. 读写超时。
  4. 业务请求超时。

误区 4:TIME_WAIT 一定是问题

不一定。TIME_WAIT 是 TCP 正常机制。真正要关注的是:

  1. 是否端口耗尽。
  2. 是否连接创建过于频繁。
  3. 是否短连接模型不合理。
  4. 是否服务端主动关闭太多连接。

误区 5:epoll 一定比多线程简单

不一定。epoll 提升的是事件通知效率,但会带来状态机复杂度。小规模程序用阻塞 IO + 线程也可以很清晰。

总结

Socket 与内核协议栈可以用一句话串起来:

应用程序通过 Socket API 把字节交给内核,内核协议栈负责把字节变成 TCP/IP 数据包并完成可靠传输、路由和收发。

初学时先抓住这几个核心点:

  1. Socket 是接口,不是协议。
  2. TCP 是字节流,不是消息流。
  3. send() 成功不代表对方收到。
  4. recv() 读的是本机内核接收缓冲区。
  5. TCP 可靠性来自序列号、ACK、重传、窗口和拥塞控制。
  6. 高性能网络编程绕不开非阻塞 IO 和事件通知。

下一步可以实践一个最小 TCP echo server,然后用 tcpdump 抓包观察三次握手、数据传输和四次挥手。这样 Socket API 和内核协议栈就能真正连起来。

algorithms axis-angle bang-bang bode calibration chrome cmake cmakelists cnn colcon conan control cpp cpu d435i data_struct db design-pattern dots economics eigen factory-pattern fcpx figure finance forge fov gazebo gdb git gnu ibus interest isaac gym isaaclab kdl latex launch learning-notes legged locomotion legged-robot life linux linux-kernel mac math matlab matrix memory mlp money motion-control motor moveit mpc mujoco network ocs2 ode operator optimal algorithm optimal-control perf performance personal-finance ppo profiling python qos quadrotor realsense reinforcement learning rnn robot robotics ros ros2 rtb security shell simulation socket stl tcp-ip thread tools twist ubuntu uml unitree urdf vae valgrind vcxsrv velocity vim web wifi work wsl 中文输入 交叉编译 依赖管理 分支管理 四足机器人 实验诊断 强化学习 机器人视觉 构建系统 深度学习 深度相机 点云 版本控制 神经网络 训练曲线 输入法 配置类 飞控
知识共享许可协议