尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux Socket内核探秘:从文件描述符到epoll排查实战

Linux Socket内核探秘:从文件描述符到epoll排查实战 这两年我在排查线上问题的时候有好几次都是被Linux下的Socket异常状态折磨到深夜。印象最深的一次是凌晨一点业务同学反馈某个接口偶发Connection reset by peer代码层面找不到任何异常日志里就孤零零一行错误。折腾半天之后我才在ss -tnp里发现大量SYN-SENT和CloseWait问题根源根本不是业务代码而是服务端监听队列被打满之后内核直接丢了握手包。从那之后我就养成了一个习惯写socket 编程先不光看 API 文档而是要把内核在背后替你做的那些事一层一层拆开看清楚。这篇东西就是那段时间的笔记整理适合后端开发、网络运维和对内核网络栈感兴趣的同学看完之后你再遇到连接诡异、收包缺数据、epoll不触发这类问题至少能有个清晰的排查方向而不是对着搜索框瞎猜。1. 先看清身份Socket 在内核中为什么是一张文件证1.1 文件描述符是第一层门面很多人第一次接触 socket 的时候都会背一句Linux 下一切皆文件。这句话听着没错但真正理解它含义的人不多。你在用户态拿到的是一个整数 fd比如 3 或者 7然后你往这个 fd 上 write 一段数据对端就收到了。这个过程看起来相当神奇因为 fd 明明只是整数内核凭什么知道要把数据发给谁答案在struct file这个结构体里。每个进程都有个文件描述符表fd 是这张表的下标表里存储的是指向struct file的指针。而struct file里有个private_data字段它指向了一个struct socket。也就是说你每次调用write(fd, buf, len)内核走的是vfs_write的通用路径最终路由到socket_file_ops里的回调把数据送去网络协议栈。fd 看似只是个整数实际是一把钥匙打开了从虚拟文件系统通往网络子系统的门。我在给团队做分享时喜欢打一个比方fd 是你在银行办的卡struct file是这张卡的账户信息而struct socket才是银行金库里的保险柜。你刷的是卡真正干活的是金库里那一层层的机构。1.2 系统调用 socket() 的分层路由我们写代码时往往一个socket(AF_INET, SOCK_STREAM, 0)就完事了但内核里这条路并不短。socket()本身是个系统调用入口对应SYSCALL_DEFINE3(socket, ...)接着调用__sys_socket()再到sock_create()。这一步完成之后内核会继续调用sock_map_fd()分配文件描述符整个过程可以简化成下面这样int __sys_socket(int family, int type, int protocol) { struct socket *sock; int retval; retval sock_create(family, type, protocol, sock); if (retval 0) return retval; return sock_map_fd(sock, flags (O_CLOEXEC | O_NONBLOCK)); }__sock_create()是真正干活的函数。它需要确定你想要的协议族和协议类型然后调用对应创建函数。比如 AF_INET 对应的是inet_create()它负责分配一个struct socket并且根据type参数选择不同的协议处理函数集SOCK_STREAM对应inet_stream_opsSOCK_DGRAM对应inet_dgram_ops。同时还会分配一个struct sock并把这两者挂接起来。这一层路由的意义在于用户态看到的 socket API 是统一的但内核可以根据协议族和类型动态选择不同的实现。比如 TCP 和 UDP 走的是完全不同的发送路径可你写代码时用的都是send()。这种设计后来也被很多用户态网络库借鉴接口统一内部多态。1.3 struct socket 与 struct sock 是两回事我见过不少内核初学者把socket和sock搞混以为它们是同一个东西。实际上struct socket更偏外观它负责面向文件系统的那一面成员里有file指针、ops、sk也有一部分状态位。而struct sock是真正的协议控制块它承载了连接状态、接收队列、发送队列、各种定时器、拥塞控制状态等一大堆信息。对于 TCP 连接来说struct sock并不能直接装下所有 TCP 专属字段所以又有了struct inet_sock、struct inet_connection_sock、struct tcp_sock这几层嵌套。每个 TCP 连接在内核里实际是一大块嵌套结构体光struct tcp_sock就有几百个字段。内核在inet_create()里会通过sk_alloc()分配这块大结构体的内存。很多网络调优参数表面上看是写在/proc/sys/net/ipv4/下的全局变量但当连接建立后内核会把它们拷贝到对应struct sock的字段里。比如tcp_wmem里的max值会影响到每个连接发送缓冲区的上限。理解了这一层你就明白为什么有的参数改了之后对已有连接无效因为连接建立时就把参数固化到自己的控制块里了。2. 握手不是 connect() 一个人的事2.1 connect() 一侧的完整脚步客户端一个connect()能成功背后的链路可不止两三行代码。这一路要经历inet_stream_connect()、tcp_v4_connect()、tcp_connect()最后才把 SYN 包通过网卡发出去。我在调试慢连接问题时经常用strace把系统调用打出来再配合ss -tnp看状态变化这样能清晰地看到每一步停留在哪。tcp_v4_connect()的核心逻辑可以简化成选路由、初始化序列号、把 socket 状态改成TCP_SYN_SENT、调用tcp_connect()发送 SYN。这个过程并不是原子的所以非阻塞模式下的connect()往往返回EINPROGRESS表示我还没连完你先去忙别的。之后你需要用poll()或epoll监听这个 fd 的可写事件当握手完成时fd 会变成可写。我在项目里经常看到有人把EINPROGRESS当成错误直接报出来其实这是非阻塞 connect 的正常返回值。正确做法是继续等可写事件然后调用getsockopt(SOL_SOCKET, SO_ERROR)去确认错误值。如果SO_ERROR返回 0说明 TCP 三次握手已经完成如果是 ECONNREFUSED说明对端拒绝了连接。2.2 listen 端的两条队列服务器端的listen(fd, backlog)并不只是告诉内核可以接受连接了它会在内核里创建两条队列。一条叫半连接队列也叫 SYN queue专门存放那些收到了 SYN但三次握手还没完成的连接另一条叫全连接队列也叫 accept queue存放已经完成三次握手、等待应用层accept()取走的连接。在老的 Linux 内核里半连接队列就是普通链表struct request_sock新内核改成了哈希表结构。但核心流程不变收到 SYN 后内核在半连接队列里创建一个 request sock回复 SYNACK收到客户端的 ACK 后才把这个 request sock 从半连接队列搬到全连接队列并唤醒正在accept()上睡眠的进程。这个方法可能有点抽象但我建议你记住一个现象应用层accept()取走的是已完成握手的连接不参与握手过程。即使你在accept()之前停顿几秒内核也已经把握手做完了。2.3 backlog、somaxconn 与连接拒绝listen()的第二个参数backlog表示全连接队列长度但内核不是你说多大就多大它会取backlog和net.core.somaxconn的最小值。现代 Linux 里somaxconn默认通常是 4096但你如果写listen(fd, 128)那么队列上限就是 128。判断全连接队列是否被打满不用去看业务日志直接看ss -ltn输出里的Recv-Q和Send-Q。处于 LISTEN 状态的 socketRecv-Q表示当前 accept queue 里有多少已完成握手的连接Send-Q表示 backlog 上限。当Recv-Q持续接近Send-Q说明应用层取连接太慢新来的连接会排队等在那里排队排满之后多余的 SYN 会被内核直接丢弃客户端那边表现出来的就是连接超时或者连接重置。这里有个实战参数值得记一下net.ipv4.tcp_max_syn_backlog控制的是半连接队列长度而tcp_syncookies开启后半连接队列满时可以用 SYN cookie 兜底但代价是无法使用部分 TCP 选项。高并发场景下我不建议盲目把半连接队列开得特别大更好的思路是优化应用层的accept速度让它能及时把连接从内核里取出来。3. send/recv 背后的缓冲哲学与流边界3.1 当你设置 SO_SNDBUF 时内核做了什么用户态调 socket 缓冲区的需求很常见但很多人并不知道内核在收到setsockopt(SO_SNDBUF)之后会做多少手脚。首先是会自动翻倍。SO_RCVBUF和SO_SNDBUF这两个选项设置的值在内核里一般会按两倍来分配因为内核需要预留一部分空间给元数据和管理结构。你以为是 64KB实际可能拿到 128KB。我调试时常用ss -m看单个连接的缓冲区实际占用或者直接看/proc/net/sockstat。在高吞吐场景下与其手动调SO_RCVBUF不如先确认net.ipv4.tcp_rmem和net.ipv4.tcp_wmem的第二个值因为内核默认启用了自动调优它会根据实际流量动态调整接收窗口。手动设置反而可能把自动调优关掉导致吞吐上不去。发送缓冲区涉及的逻辑也很有意思。write()成功返回并不代表对方收到了数据只代表数据被拷贝到了内核的发送缓冲区。如果发送缓冲区满了send()会阻塞或者返回EAGAIN。所以判断一个连接是否健康不能只看应用层写成功没有还得看内核缓冲区最终有没有把数据送出去。3.2 奇数字节和随机数背后的字节流真相有一位读者之前问过我一个很奇怪的问题为什么用 TCP 接收数据时有时候会收到奇数字节而且后面会补一个随机数这听起来像是玄学但背后其实是典型的协议解析错误。TCP 是字节流协议它不像 UDP 那样保留消息边界。你连着调两次send()第一次发 3 个字节第二次发 5 个字节对端收到的可能是一个 8 字节的连续块也可能先是 2 个字节再是 6 个字节这完全取决于内核调度、缓冲区状态以及网卡分段情况。至于随机数多半是应用层把某个固定长度的头字段解析错了错位之后把 payload 里的数据当成了随机数。这个问题的根治方法只有一个在应用层给数据定帧。不管是 length-prefix、分隔符还是固定长度结构体必须要让接收方能从字节流里还原出消息边界。依赖 recv 次数等于 send 次数的想法在高并发和复杂网络环境下基本必然出问题这不是概率问题只是时间问题。3.3 定帧解决粘包分包的实际工程方案我在生产环境中最常用的定帧方案是 4 字节长度头加 payload长度头用网络字节序。这样接收方只需要不断地读 4 字节解析出 payload 长度再读对应字节数的 payload就能稳定地还原出完整消息。这里有一个容易踩的小坑recv()不保证一次能读够你要的长度。所以工程上必须自己实现类似read_exact的循环读取逻辑def recv_exact(sock, n): data b while len(data) n: chunk sock.recv(n - len(data)) if not chunk: raise ConnectionError(connection closed) data chunk return data读完 4 字节长度头之后就可以调用recv_exact(sock, payload_len)。这套思路既适用于 TCP也适用于 Unix domain socket。如果你用的是 C/C逻辑一模一样只是把recv的返回值判断写得更仔细。4. epoll 的快快在回调而不是轮询4.1 从 poll 到 epollwaitqueue 回调机制不少教程解释 epoll 为什么快都会说比 select/poll 少遍历 fd。这没错但不完整。select和poll每次调用都要把所有 fd 从用户态拷贝到内核态再逐个扫描复杂度是 O(n)。而 epoll 通过epoll_ctl把 fd 注册进内核后每次调用epoll_wait只需要处理就绪链表复杂度接近 O(1)。这背后的机制是回调。每个 socket 在内核里都有一个 waitqueue 头当 socket 状态变化比如收到数据时协议栈会调用wake_up_interruptible_poll遍历这个 waitqueue唤醒正在等待的进程。epoll 在注册 fd 时实际上往这个 waitqueue 里挂了一个自己的回调函数ep_poll_callback。事件一旦触发这个回调立即把对应的 epitem 加入 epoll 实例的就绪链表。也就是说事件是主动来找你的不是你去翻的。这个机制还解释了为什么 epoll 对 fd 数量不那么敏感。你注册 1 万个连接和注册 100 个连接每次 epoll_wait 都只返回那些真正有事件发生的 fd。但要注意如果你想监听的事件类型非常粗糙比如只监听 EPOLLIN不去监听 EPOLLERR/EPOLLHUP回调触发的次数会多一些因为错误和挂断也会唤醒它。4.2 LT 与 ET差一个标记行为天壤之别水平触发LT是 epoll 的默认模式它的语义是只要还有数据没读完每次 epoll_wait 都会提醒你。边缘触发ET的语义是只有当状态发生跳变时才提醒一次。我用一个比方解释门铃在 LT 模式下表示房里有客人只要客人没走完就一直响ET 模式下则只在从没人变成有人的瞬间响一下之后哪怕房间里堆满了客人也不再响。ET 模式高效的地方在于它减少了系统调用次数但相应的开发成本也高。你必须把数据一次性读到EAGAIN否则剩余的数据可能永远不会再触发事件。大多数情况下我建议用 LT只有当你对性能有极致要求、且对每一个 fd 的读取循环都门清时才上 ET。4.3 监听 socket 上的 ET 陷阱ET 模式下最容易翻车的就是accept()的写法。很多教程演示 ET 时会这样connfd accept(listenfd, NULL, NULL); if (connfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) { // 等下一次事件 } }但这个写法在瞬时高并发下可能会饿死连接。因为EPOLLIN只在没有已就绪连接 - 有已就绪连接这个跳变时触发一次。如果你一次只 accept 一个连接而内核全连接队列里同时来了 10 个连接剩下 9 个可能没有新的下沿跳变来触发下次EPOLLIN。正确写法是死循环一直 accept 到EAGAIN为止while (1) { connfd accept(listenfd, NULL, NULL); if (connfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; if (errno ECONNABORTED) continue; break; } handle_conn(connfd); }我的习惯是监听 socket 一律用 LT已连接 socket 按业务特性决定用 LT 还是 ET。这样既能避免 accept 饥饿又不至于让整个模型复杂度失控。项目里很多人盲目全开 ET最后问题频发实在没必要。5. Socket 疑难杂症排查拒绝、超时与各种诡异错误5.1 拒绝还是超时第一步要分清遇到连接问题先不用着急改代码把错误码分清楚再说。Connection refused和Connection timed out是完全不同的两类问题。refused通常意味着对端收到了 SYN但那个端口上根本没有进程监听或者监听队列已经满到内核直接拒绝timed out则意味着你发的 SYN 包石沉大海中间可能被防火墙丢了或者对端网络路径不通。这两者的排查方向截然不同前者看端口和进程后者看网络链路。Linux 下的常见错误码也和 Windows 不一样。111是 ECONNREFUSED110是 ETIMEDOUT104是 ECONNRESET。Windows 下常见的 10061 其实也是 connection refused对应 WinSock 的错误编码。如果你在一个跨平台项目里兼容不同操作系统最好别硬编码数字直接用系统提供的错误名。5.2 典型异常复盘No more data to read from socket。我最早遇到这条错误是在 Java 的 MySQL 客户端里客户端还在等服务端返回更多数据包结果收到了 EOF。常见原因有两个一是服务端进程主动关闭连接二是数据库的max_allowed_packet不够发了超出限制的包导致连接被强制断开。排优先级的话先看服务端日志有没有报错关连接再查这个会话的包大小。Error 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock。这个热搜词太经典了。MySQL 默认在本地使用 Unix domain socket 连接socket 对应的是一个文件路径而不是 IP 端口。如果这个文件不存在那基本就是 MySQL 没启动或者socket配置指到了别的路径。我查这种问题会先ls /tmp/mysql.sock再看service mysql status。Socket read timed out。Java 系项目里常见本质是SO_RCVTIMEO到了不是 TCP 层真的超时。JDBC 的socketTimeout只影响 socket 读取等待时间不影响写和连接建立。如果你设计的长查询超过这个值就会报这个错。我一般建议把 socketTimeout 设置成比预期最慢查询多 30 秒以上而不是拍脑袋设个小值。tiger vnc unable connect to socket: connection refused (10061)。这个一看就是 VNC 服务端没监听在默认端口 5900 或者防火墙把端口拦截了。用ss -ltnp | grep 5900就能确认。5.3 我常用的排查命令组合遇到 socket 问题我会按顺序执行下面这几条ss -tnp看所有 TCP 连接状态能一次性定位出大量SYN-SENT、CLOSE-WAIT和TIME-WAIT。ss -ltnp看监听端口确认进程是否绑定在正确地址上。ss -m看 socket 缓冲区占用判断是不是 buffer 不够。lsof -i :port确认这个端口被谁占用尤其是端口冲突的时候很管用。strace -p pid -e tracenetwork直接看进程发起的网络系统调用和返回值。tcpdump -i eth0 port 8080抓包看 TCP 握手、RST、FIN 的实际情况。其中我最常用的是ss它比老的netstat快得多而且能显示的信息更细。如果你在排查一个连接为什么频繁 reset抓包几乎是唯一可靠的证据。应用日志只能告诉你现象抓包能告诉你现象背后的 TCP 行为。5.4 内核参数调优的几个谨慎提醒内核网络参数不是越多越好很多参数之间会互相影响。比如你把tcp_max_syn_backlog调得很大又不开 syncookies攻击者可以用海量 SYN 填满内存比如你想通过开启tcp_tw_reuse来减少 TIME_WAIT但如果配合 NAT 环境有可能导致连接串号。TIME_WAIT 本身是 TCP 协议的安全机制一般不建议激进收敛。调优前先量化问题。是 SYN 丢包是全连接队列积压还是缓冲区太小不同问题对应不同参数。如果只是应用层accept太慢调再大的队列都是治标不治本。我见过一个项目把somaxconn调到 65535应用还是频繁连接失败最后发现是业务代码在accept之后做了太多耗时操作导致全连接队列长期阻塞。把accept连同一个事件循环才真正解决。关于 Socket 编程我一直觉得最重要的不是记住 API而是理解每个 API 调用在内核里动了哪些数据结构。写代码的人如果心里有那张图看到Connection reset能想到 RST 的触发条件看到read timed out能想到SO_RCVTIMEO和协议超时的区别排查问题就会快很多。这个系列的经验我还在持续积累尤其是内核网络栈新版本在拥塞控制、BPF 可观测性上的演进等有更多实践再单独写一篇分享。
返回列表