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 协议栈本体。