从一次性能排查说起。前阵子帮朋友看一个网关服务的压测结果,瓶颈数值非常诡异:CPU 的使用率并不高,但每秒新建连接数就是上不去,单连接吞吐也忽高忽低。常规手段都试遍了——调 backlog、改 epoll 超时参数、调整缓冲区大小、甚至把中断绑核都安排上,效果依然有限。最后定位到根子上:这个服务走的是标准的 POSIX socket 接口,数据路径完全依赖内核 TCP/IP 协议栈。问题不在应用层代码,而在整个内核网络链路本身。这篇文章我就围绕三个关键词展开: POSIX API、TCP/IP 协议栈、DPDK。适合正在学习网络编程、或者正在做网络性能优化的人,帮你把"传统内核协议栈"和"DPDK用户态方案"之间的逻辑打通,搞清楚它们各自解决什么问题、边界在哪里、怎么选型。
我尽量用一线实操的视角来写,不堆教科书式的术语,遇到的坑、压测结论、配置要点都会带到。建议读者有一点点 C 语言和 Linux 基础,没有也不影响理解主线。
1. 从一次 accept() 说起:POSIX API 把网络编程简化成文件操作
1.1 socket 不是文件,但内核让它"像文件"
很多初学者第一次写 TCP 服务端代码,都会有这种感觉:流程好像挺固定,socket、bind、listen、accept,然后 read/write,跟读写文件差不多。这不是错觉,而是 POSIX 有意为之的设计。Unix 的基本哲学是"一切皆文件",socket 作为一种特殊设备,也被抽象成了文件描述符。
这里的核心机制是 struct file_operations。内核用同一个 read() 系统调用入口,根据文件描述符对应的文件类型,分发到不同的实现。普通文件落到 ext4 的读逻辑,socket 则落到 sock_read() 上,最终进入协议栈的收包路径。这种抽象最大的好处是:应用层可以用统一的方式操作字符串设备、管道、普通文件和网络连接,很多通用工具(比如 read、write、close、poll、epoll)天然就能工作在 socket 上。
从实操角度理解这个设计,有一个很实际的作用:排查问题时,很多 socket 相关参数可以通过 ss、lsof、/proc/net 直接观察,因为 socket 在内核里同样会占用 inode、文件描述符等资源。如果 ulimit -n 限制不放开,你 open 的文件描述符和 socket 连接数是共享的,成千上万的连接一建立,程序可能突然报 "Too many open files",第一反应应该是查 ulimit 和 fs.file-max,而不是怀疑协议栈。
不过"一切皆文件"也有代价。POSIX API 是同步系统调用模型,每次读写都要从用户态切到内核态。你读到的数据从内核 sk_buff 拷贝到用户缓冲区,这中间涉及上下文切换和内存拷贝。这个设计在文件场景完全没问题,但在高性能网络场景就埋下了后来被 DPDK 挑战的伏笔。
1.2 三次握手由内核代劳,accept() 只是领走结果
写 POSIX socket 程序的人容易有一个认知偏差,觉得 accept() 返回一个连接,就等于完成了一次握手。实际上,三次握手早就被内核完成了。accept() 做的只是从内核维护的已完成连接队列里取出一个 struct socket 对应的新 fd。
具体来说,内核在监听 socket 上维护两个队列:
- 半连接队列(SYN Queue):存放收到 SYN、正在握手、尚未完成的连接。
- 全连接队列(Accept Queue):存放已完成三次握手、等待应用层 accept 的连接。
`
