Linux内核分析之网络协议栈-01
45.1 socket 系统调用与 sock 结构
socket 系统调用的每一员都是"薄翻译":进程的 fd 背后是 struct socket(VFS 壳,sockfs 内部文件系统的 inode),壳内挂着 struct sock(协议本体,TCP/UDP/Unix 各有扩展)。两者之间由 proto_ops 操作表连接——POSIX 语义到协议实现的全部翻译都发生在这一跳。本节解剖创建、绑定、监听、接受的完整翻译链与 sock 的关键字段。
45.1.1 socket/sock 双结构
// include/linux/net.h:116-127
struct socket {
socket_state state; /* SS_FREE/UNCONNECTED/
SS_CONNECTED/... */
short type; /* SOCK_STREAM/DGRAM/RAW */
unsigned long flags;
struct file *file; /* fd 的背面 */
struct sock *sk; /* 协议本体 */
const struct proto_ops *ops; /* Might change with IPV6_ADDRFORM
or MPTCP. */
struct socket_wq wq; /* 等待队列 (45.3 节 epoll 挂点) */
};
// include/net/sock.h:360 起(宏别名暴露 sock_common 字段)
struct sock {
/* don't add nothing before this first member (__sk_common) --acme */
struct sock_common __sk_common; /* 查找键/引用/协议指针 */
#define sk_refcnt __sk_common.skc_refcnt /* RCU 存活的引用计数 */
#define sk_hash __sk_common.skc_hash /* 查找哈希 */
#define sk_num __sk_common.skc_num /* 本地端口 */
...
/* :246 区 (配额, 45.2 节) */
* @sk_rcvbuf: size of receive buffer in bytes
* @sk_wmem_alloc: transmit queue bytes committed
* @sk_sndbuf: size of send buffer in bytes
struct sk_buff_head sk_receive_queue; /* 收包队列 */
struct sk_buff_head sk_write_queue; /* 发送队列(面向连接) */
...
struct proto *sk_prot; /* 协议操作表 */
struct proto *sk_prot_creator; /* 创建时的(协议切换保护) */
socket_lock_t sk_lock;
...
void (*sk_data_ready)(struct sock *sk); /* 45.2.2 节统一广播点 */
...
};
socket 是 fd 侧的固定壳:它挂在 sockfs 内部文件系统上(sock_alloc_inode(),net/socket.c:318 分配 inode;sockfs_ops :365)——fd 的读写走 sock_read_iter 等固定入口再分发,与 37 章伪文件系统"函数即文件"同构。sock 是协议实例:TCP 连接的每个 sock 都内嵌 tcp_sock(几 KB 的状态机+计数器)——"每连接一个 sock"是内核连接容量的基本粒子。
ops 可变的注释值得咀嚼:IPV6_ADDRFORM 让 v4-mapped 连接把 ops 换成 v6 版、MPTCP 换成多路径版——操作表是运行期可迁移的。
45.1.2 创建:查表与装配
// net/socket.c:1742
int __sys_socket(int family, int type, int protocol)
// net/ipv4/af_inet.c:260
static int inet_create(struct net *net, struct socket *sock, int protocol, int kern)
// net/ipv4/tcp_ipv4.c:3416
struct proto tcp_prot = { ... };
// include/net/sock.h:1286
struct proto { ... /* alloc/free/recvmsg/sendmsg/backlog_rcv/... */ };
socket() 的装配流水线:
__sys_socket (:1742)
└─ sock_create(family, type, protocol)
├─ sock_alloc_inode: sockfs inode + socket 壳
└─ net_families[AF_INET]->create()
= inet_create (:260)
├─ 按 (type, protocol) 查 inetsw 表
│ → 找到 tcp_prot/udp_prot/raw_prot
├─ sk = sk_alloc(..., tcp_prot)
│ (按 proto->obj_size 分配: tcp_sock 巨大,
│ udp_sock 小 — 内存差异的来源)
├─ sock->ops = &inet_stream_ops ← proto_ops
├─ sock_init_data(sock, sk):
│ 队列初始化 / sk_data_ready =
│ sock_def_readable / wq 建立
└─ prot->init(sk) = tcp_v4_init_sock
(CA 初始化, 46.3 节)
struct proto(sock.h:1286)是协议的"类方法表":recvmsg/sendmsg/close 系统调用的最终实现、backlog_rcv 收包处理、hash/unhash 注册进查找表——TCP 与 UDP 的差异全部编码在各自的 proto 实例里(tcp_prot,tcp_ipv4.c:3416)。sk_prot_creator(sock.h:536)记录创建者,防止 v4/v6 切换后的释放走错分配器。
45.1.3 bind/listen/accept 的翻译
// net/socket.c:1866 与 :2037
int __sys_bind_socket(struct socket *sock, struct sockaddr_storage *address, int addrlen)
int __sys_accept4(int fd, struct sockaddr __user *upeer_sockaddr, ...)
TCP 建立流的全链 (46.2 节状态机的用户态触发面):
bind(fd, INADDR_ANY:8080)
→ sock->ops->bind = inet_bind
→ sk->sk_prot->bind / inet_csk_get_port
端口占用表 (bhash) 查重 + SO_REUSEPORT 分组
listen(fd, backlog)
→ inet_listen: 状态迁 LISTEN + 全连接队列定容
(backlog 与 somaxconn 截断, 46.2.1 节)
connect(fd, 对端)
→ inet_stream_connect → tcp_v4_connect
路由查表定源地址(46.1) → 发 SYN → SYN_SENT
accept(fd) → inet_csk_accept (inet_connection_sock.c:650)
全连接队列空 → 睡眠 (或 O_NONBLOCK → EAGAIN)
有 → 摘一个握手完成的 sock → **新 socket + 新 fd**
(监听 sock 与连接 sock 是不同对象 — epoll(45.3)
监听的是"监听 fd 的可读=可 accept")
fd → socket → sock 的寻径:
fd 表 → file → file->private_data = socket
→ socket->sk — 两次解引用
45.1.4 sock 的查找键与并发
sock_common 作为查找键:
skc_daddr/skc_raddr + skc_num/skc_dport (四元组)
skc_hash: 四元组哈希 — 46.2 节 tcp_v4_rcv 的
__inet_lookup 用它定位连接 (每包一次!)
ehash (established hash) 表: 数十万~百万桶,
RCU 读 + sock 引用计数 — 无锁收包的直接消费者
(44.3 节 NAPI 收的包在这里找到归属)
并发纪律:
sock 锁 (sk_lock): 进程上下文读写
bh_lock_sock: 软中断收包 vs 进程竞争
backlog 队列 (45.2 节): 锁被进程持有时软中断
把包暂存, 释放时冲账 — "永不自旋等待进程"
容量模型由此建立:百万连接 = 百万 sock(tcp_sock 数 KB)+ ehash 桶内存 + 各自缓冲配额——tcp_mem 全局记账与 12 章 cgroup 限额在 sock 分配点交汇。
小结
socket 层以"sockfs 壳(socket + proto_ops)+ 协议本体(sock + proto)"双结构完成 fd 到协议的翻译:创建即查 inetsw 表分配对应大小的 sock 并接好 sk_data_ready 广播点,bind/listen/accept 分别落在端口表、监听队列、连接 sock 的生成;sock_common 作为查找键支撑每包一次的 RCU 哈希查找,sk_lock/backlog 双层并发纪律让软中断永不自旋等待进程。下一节看这些 sock 的血液——收发缓冲与配额。
45.2 socket 缓冲区管理
socket 的收发缓冲不是"一块内存"而是skb 队列 + 字节配额 + 唤醒广播的三位一体:sk_receive_queue/sk_write_queue 装 skb(44.1 节载体),sk_rcvbuf/sk_sndbuf 设 truesize 加权的字节上限,超限触发 TCP 窗口收缩或 UDP 丢包;sock_def_readable 是数据就绪的统一广播点。本节解剖队列、配额、背压与收发全链。
45.2.1 队列与配额的记账语义
// include/linux/skbuff.h:337-345
struct sk_buff_head {
/* These two members must be first to match sk_buff. */
struct_group_tagged(sk_buff_list, list,
struct sk_buff *next; /* 与 skb 联合体对齐 */
struct sk_buff *prev;
);
__u32 qlen; /* 队列长度(个) */
spinlock_t lock; /* 队列锁 */
};
// include/net/sock.h:246-269(配额字段, 注释原文)
* @sk_rcvbuf: size of receive buffer in bytes
* @sk_wmem_alloc: transmit queue bytes committed
* @sk_sndbuf: size of send buffer in bytes
...
#define sk_rmem_alloc sk_backlog.rmem_alloc /* :421 */
配额为什么按 truesize 记账 (而非报文净荷):
skb->truesize = sizeof(sk_buff) + 数据区实际分配
(含对齐、frags 页、shared_info)
→ 反映"这个 skb 真实占用的内存" (44.1.3 节)
小包 (如 40B ACK) 的 truesize ≈ 768B — 净荷的 19 倍!
按净荷记账会严重低估内存压力 → 配额形同虚设
sock_init_data 的默认:
sk_rcvbuf = sysctl_rmem_default (~212KB)
sk_sndbuf = sysctl_wmem_default (~212KB)
SO_RCVBUF/SO_SNDBUF: setsockopt 调整 (翻倍对齐,
上限 rmem_max/wmem_max; TCP 另受自动调优支配)
45.2.2 收包入队与统一唤醒
// net/core/sock.c:3602
void sock_def_readable(struct sock *sk)
{
...
rcu_read_lock();
wqueue = rcu_dereference(sk->sk_wq);
if (skwq_has_sleeper(wqueue))
wake_up_interruptible_sync_poll(&wqueue->wait, EPOLLIN | ...);
sk_wake_async(wqueue, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
tcp_v4_rcv 的最终落点 (46.2 节分发到这里):
[1] 四元组查 sock (__inet_lookup, 45.1.4 节 ehash)
[2] 三选一的交付路径:
a) 进程正在 recvmsg (sk_lock 被持)?
→ backlog 队列暂存, 释放锁时冲账
("永不自旋等待进程" — 软中断纪律, 42.2)
b) 有进程睡在 recvmsg / 队列浅:
→ 直接拷入用户缓冲 (快速路径, 免入队)
c) 常规: sk_receive_queue 入队
+ sk_rmem_alloc += truesize
[3] 配额判定: rmem_alloc > rcvbuf
→ 丢弃 + Linux/RcvbufErrors 计数
(UDP 场景) 或 触发窗口收缩 (TCP 场景, 46.2)
[4] sock_def_readable (:3602) — 一点唤醒三路人:
├─ 阻塞在 recvmsg 的线程 (sk->sk_wq.wait)
├─ epoll 注册的 ep_poll_callback (45.3 节,
│ 同一条 wq 上的另一个 wait_entry!)
└─ FASYNC/SIGIO 异步消费者
"一条等待队列、多种消费者"是设计精髓:sk_wq 上同时挂着阻塞读线程的 wait_entry 与 epoll 的 eppoll_entry——wake_up 按链逐个回调,阻塞读与 epoll 天然共存,无需任何特判。
45.2.3 发送侧:借额与背压
发送缓冲的生命周期:
sendmsg:
sk_stream_wait_memory: sndbuf 配额检查
空间不足 → 阻塞等 ACK 释放 (背压!) 或 EAGAIN
sk_wmem_alloc += truesize (借额)
skb 进 sk_write_queue → tcp_write_xmit (46.3)
ACK 推进 snd_una → 释放已确认 skb → sk_wmem_alloc
归还配额 → 唤醒等配额的 sendmsg
背压链全景 (从应用对端一路推回应用本端):
对端不收 → 对端 rcvbuf 满 → 通告窗口 0
→ 本端无法发送 → 本端 write_queue 涨
→ sndbuf 满 → 本端 sendmsg 阻塞
应用不写不读也会被牵动 — "socket 是有记忆的管道"
TCP 与 UDP 的配额后果差异:TCP 满是"窗口为零、发送阻塞"(流控);UDP 满是"丢包 + 计数"(无流控,应用自兜)——46.4 节的对照在此落地。net.core.rmem_max、tcp_rmem 三档自动调优(动态在 min/default/max 间按 BDP 伸缩)是高带宽长延迟链路的标准调参。
45.2.4 recvmsg 的消费语义
消费侧 (与 33.4 节 VFS 读对齐):
tcp_recvmsg_locked:
循环: sk_receive_queue 摘 skb
→ skb_copy_datagram_iter → 拷入用户 iter
→ 摘完的 skb 释放 → rmem_alloc 归还
部分消费: skb 非整条消费时只动 offset (44.1 节
同款指针语义), 不整块释放
MSG_PEEK: 只看不摘 (配额不动)
MSG_DONTWAIT / O_NONBLOCK: 空则 EAGAIN (31/45 章
非阻塞统一语义)
MSG_WAITALL: 攒够再回 (可能久睡)
返回值 = 实际拷贝字节数 (短读合法, 33.4.3 节)
观测面:ss -m 显示每 socket 的 skmem(各队列与配额明细)、ss -tin 的 rcv_space(TCP 自动调优的当前值)、netstat -s 的 RcvbufErrors/PruneCalled(配额不足的证据——"收得慢丢得快"的诊断入口)。
小结
socket 缓冲 = sk_buff_head 队列(qlen+锁)+ truesize 加权的字节配额(rcvbuf/sndbuf,反映真实内存而非净荷)+ 一点广播(sock_def_readable 同链唤醒阻塞读/epoll/异步三种消费者);收包三选一路径(backlog 暂存/快速拷贝/入队)与发送借额-ACK 归还的背压链把流控从对端一路传导回本端应用。ss -m/netstat -s 的 skmem 与 RcvbufErrors 是配额健康的直接口径。下一节看把三路人统一起来的 epoll 本体。
45.3 epoll —— I/O 多路复用
epoll 解决 select/poll 的 O(n) 扫描:fd 的就绪状态由回调注册维护——数据到达时内核主动把 fd 摘入就绪链表,epoll_wait 只返回就绪者。百万连接的服务器由此只需一次系统调用等"任何事件"。本节解剖 eventpoll/epitem 的结构、从 sock_def_readable 到 epoll_wait 返回的完整回调链、LT/ET 语义与工程纪律。
45.3.1 eventpoll 与 epitem
// fs/eventpoll.c:179 与 :131(节选要点)
struct eventpoll {
struct mutex mtx; /* ctl 操作锁 */
wait_queue_head_t wq; /* epoll_wait 的睡眠队列 */
...
struct list_head rdllist; /* 就绪 fd 链表! */
struct rb_root_cached rbr; /* 注册的 fd (红黑树) */
struct file *file; /* epoll fd 自己 */
...
struct epitem *user; /* 每用户限额记账 */
};
struct epitem {
union {
struct rb_node rbn; /* 挂 rbr 树 (注册态) */
struct list_head rdllink; /* 挂 rdllist (就绪态) */
};
...
struct epoll_filefd ffd; /* 监视的 (fd, file) 对 */
struct eventpoll *ep; /* 归属的 eventpoll */
struct list_head pwqlist; /* 挂到目标的等待队列项 */
struct epoll_event event; /* 用户关心的事件掩码 */
...
};
两级结构:eventpoll 是 epoll fd 的本体(rdllist 就绪链 + rbr 注册树 + 自己的等待队列 wq),epitem 是"一次对某 fd 的关注"——每个被监视 fd 一个,联合体让"注册树节点"与"就绪链节点"复用同一内存(一个 epitem 同一时刻只在其中之一——这正是无重复报就绪的机制)。epoll fd 本身也是 sockfs 式匿名文件(epoll_create1,fs/eventpoll.c:2218——37 章伪文件系统家族)。
45.3.2 注册:ep_insert 与等待队列挂钩
// fs/eventpoll.c:1584(注册)
static int ep_insert(struct eventpoll *ep, const struct epoll_event *event,
struct file *file, int full_check, int tfd_flags)
// :1378(挂到目标 fd 的等待队列)
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead, ...)
epoll_ctl(EPOLL_CTL_ADD, fd) 的装配:
ep_insert (:1584)
├─ 建 epitem → 插入 ep->rbr 红黑树 (O(log n))
├─ ep_ptable_queue_proc (:1378):
│ 在目标 fd 的等待队列 (sock 的 sk->sk_wq,
│ 45.2 节) 上挂一个 wait_entry,
│ 回调函数 = ep_poll_callback
└─ 首次检查: 目标当前已就绪? → 直接入 rdllist
关键联结: 挂的是目标的等待队列 —
sock 的 sk_wq (45.1.2 节 sock_init_data 建立)
同一条队列上同时有:
阻塞 recvmsg 线程的 wait_entry (45.2.2)
epoll 的 ep_poll_callback wait_entry
wake_up 逐个回调 — 三种消费者天然共存
(设备 fd 则挂 file->poll 的 wq — 32 章 eventfd
的 wqh 同理: epoll 监视一切有等待队列的对象)
45.3.3 就绪链路:ep_poll_callback
// fs/eventpoll.c:1267(就绪回调, 全链的心脏)
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
"数据到达 → epoll_wait 返回"的完整链路:
对端发数据 → NIC → NAPI (44.3) → tcp_v4_rcv (46.2)
→ sock_def_readable (45.2.2) → wake_up(sk_wq)
└─▶ ep_poll_callback (:1267) 被调 (软中断上下文!)
├─ key (EPOLLIN 掩码) 与 epitem->event 匹配?
├─ epitem 摘入 ep->rdllist (就绪链!)
│ (已在链上则不重复 — 无重复报)
├─ 有 EPOLLET? 记"只在迁移时通知"
└─ wake_up(ep->wq) — 唤醒等在 epoll_wait
的进程 (ep_poll_safewake :619 防
epoll 套 epoll 的唤醒递归)
epoll_wait (ep_poll, :1956):
rdllist 空 → 睡眠 (ep->wq, 可带 timeout)
非空 → 摘链收集 epitem → 事件 copyout → 返回
LT (默认): 条件仍成立的 fd 重新入 rdllist
(下次 wait 继续报)
ET (EPOLLET): 不重报 — 只在状态迁移时入链
回调式就绪是 O(就绪数) 的根源:select/poll 每次调用"遍历全部 fd 查状态"(内核还要每次拷贝 fd 集合),epoll 把查询反转为"状态变化主动登记"——wait 的成本与注册的 fd 总数无关。代价是每包一次 ep_poll_callback 跳转与 epoll_ctl 的注册成本——高事件率下这是可测的(perf 中可见)。
45.3.4 LT/ET 与工程纪律
语义面与陷阱 (用户态视角):
LT (水平触发, 默认):
只要条件成立就一直报 → 简单安全
读不空下次还报; 写空间恢复会报 EPOLLOUT
ET (边缘触发, EPOLLET):
只报"从无到有"的迁移 → 必须循环读到 EAGAIN
(45.2 节短读语义的强制性使用)
→ 配非阻塞 fd — nginx/redis 的事实标准
经典陷阱:
[1] ET 漏读 (没读到 EAGAIN) → fd 永不再报 → 假死
[2] LT + 大 rcvbuf → "读了但没读完"常驻就绪
→ 单 fd 刷爆事件循环
[3] EPOLLOUT 常开 → 发送缓冲不満就一直报
(正确姿势: 想写时才 ADD, 写完 MOD 成只读监听)
[4] 多进程共享监听 fd 的 accept 惊群
→ EPOLLEXCLUSIVE 标志: 只唤醒一个
[5] closed fd 未 DEL → 回调悬空防护
(内核 close 时自动清, 但引用语义要求应用配对)
规模口径:
epoll_ctl O(log n); wait O(就绪数)
每被监视 fd: epitem ~128B + 挂目标的 wait_entry
百万连接 ≈ 数百 MB 元数据 — 容量预算的一部分
45.3.5 与 select/poll 的对照
| 维度 | select | poll | epoll |
|---|---|---|---|
| fd 上限 | 1024 | 无 | 无(内存限) |
| 每次调用 | 全量 fd 集合拷入内核 | 全量数组拷入 | 只传 maxevents |
| 就绪发现 | 内核全量扫描 | 全量扫描 | 事件回调登记(45.3.3) |
| wait 成本 | O(注册数) | O(注册数) | O(就绪数) |
| 数据结构 | 位图/数组 | 数组 | 红黑树+就绪链 |
| 重复触发 | 天然 LT | LT | LT/ET 可选 |
选型:连接数大、事件稀疏(长连接服务)→ epoll 的主场;fd 少且固定 → select/poll 的简单性反而便宜;io_uring(37 章家族)更进一步把"等待"也做成提交队列里的操作——但 epoll 的"回调式就绪登记"思想在 io_uring 里同样存在。
小结
epoll 以"eventpoll(rdllist+wq)+ epitem(注册树与就绪链复用同一节点)"把就绪发现反转为回调登记:sock_def_readable 唤醒 sk_wq 的瞬间 ep_poll_callback 就把 epitem 摘入就绪链并唤醒 epoll_wait——O(就绪数) 的全部秘密是"一条等待队列挂多个消费者"(45.2 节的三路唤醒在此收口);LT/ET 只是"是否重新入链"的策略差,ET 的循环读到 EAGAIN 与 45.2 节短读语义刚性绑定。socket 层三章至此完成——fd 语义、缓冲配额、多路复用三件事各自落位;下一章进入 TCP/IP 协议栈本体。