Linux 网络内核协议栈深度剖析(六):Socket 层与系统调用实现

Socket 是用户态程序与内核网络协议栈之间的唯一接口。它不仅是一个文件描述符,更是一套精心设计的多层抽象体系——从 VFS 层的 struct socket,到协议无关的 struct sock,再到 IPv4 专用的 struct inet_sock 和 TCP 专用的 struct tcp_sock,每一层都有其清晰的职责边界。本文基于 Linux 6.4-rc1 源码,深入剖析 socket 系统调用的完整实现路径、epoll 的内核机制,以及缓冲区管理与内存压力控制。

本系列前五篇分别覆盖了网卡驱动收发、NAPI、协议栈 IP/TCP 层、路由子系统和 netfilter,这一篇聚焦于最接近用户态的 socket 接口层,梳理从系统调用入口到数据进出内核缓冲区的全链路,并以 epoll 的内部实现为重点,阐释高并发服务器底层事件通知的工作机制。


一、Socket 数据结构层次

Linux socket 采用四层结构,从上到下依次为 VFS 层、BSD socket 层、网络协议无关层和协议专用层。

在深入代码之前,先理解这套抽象的设计动机:Linux 将”网络连接”和”文件”统一为相同的接口(read/write/poll),这要求存在一个既能被 VFS 理解、又能分发给各协议族(AF_INET、AF_UNIX、AF_NETLINK 等)的通用抽象层。struct socket 承担 VFS 侧的职责,struct sock 承担协议无关的网络核心状态,两者通过指针双向持有,各司其职。

1.1 struct socket:VFS 层抽象

struct socket 定义在 include/linux/net.h,是 VFS(虚拟文件系统)对网络连接的抽象载体:

1
2
3
4
5
6
7
8
9
10
/* include/linux/net.h */
struct socket {
socket_state state; /* SS_UNCONNECTED / SS_CONNECTED 等 */
short type; /* SOCK_STREAM / SOCK_DGRAM 等 */
unsigned long flags; /* SOCK_NOSPACE 等标志 */
struct file *file; /* 对应的 VFS file 对象,用于 GC */
struct sock *sk; /* 指向协议无关的 sock 层 */
const struct proto_ops *ops; /* 协议相关操作集 */
struct socket_wq wq; /* 等待队列(poll/epoll 使用) */
};

socket_wq 中的 wait 字段是整个 epoll 机制的关键入口——网络数据到达时,内核通过它唤醒等待的进程。state 字段记录 BSD socket 语义的状态机(SS_UNCONNECTEDSS_CONNECTINGSS_CONNECTED),与 TCP 状态机(TCP_SYN_SENT 等)相互独立但存在映射关系。flags 字段中的 SOCK_NOSPACE 标志由 sk_stream_wait_memory 设置,告知发送路径”当前缓冲区已满”,ACK 到达后 tcp_write_space 会清除它。

1.2 struct proto_ops:socket 操作集

struct proto_ops 定义了从 socket 文件描述符到协议实现的分发接口,每个协议族(AF_INET、AF_UNIX 等)都注册一套自己的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* include/linux/net.h */
struct proto_ops {
int family;
struct module *owner;
int (*bind) (struct socket *sock, struct sockaddr *myaddr, int sockaddr_len);
int (*connect) (struct socket *sock, struct sockaddr *vaddr,
int sockaddr_len, int flags);
int (*accept) (struct socket *sock, struct socket *newsock,
int flags, bool kern);
__poll_t (*poll) (struct file *file, struct socket *sock,
struct poll_table_struct *wait);
int (*sendmsg) (struct socket *sock, struct msghdr *m, size_t total_len);
int (*recvmsg) (struct socket *sock, struct msghdr *m,
size_t total_len, int flags);
/* ... 其他操作 ... */
};

对于 TCP,这套接口对应 inet_stream_ops,其中 sendmsginet_sendmsgrecvmsginet_recvmsgproto_ops 工作在 socket 粒度(持有 struct socket *),而下一层的 struct prototcp_prot 等)工作在 sock 粒度(持有 struct sock *),两层分工清晰:前者处理用户态接口语义(地址解析、socket 状态检查),后者负责协议逻辑(序列号、重传、窗口)。

1.3 struct sock:协议无关核心与锁模型

struct sock 是内核网络层最核心的结构,几乎包含所有通用的 socket 状态。关键字段(来自 include/net/sock.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* include/net/sock.h(精简展示关键字段) */
struct sock {
struct sock_common __sk_common; /* 包含 sk_state、sk_family、sk_daddr 等 */
/* 以下均是 __sk_common 成员的宏别名 */
/* sk_state, sk_family, sk_daddr, sk_rcv_saddr ... */

socket_lock_t sk_lock; /* socket 锁:spinlock + 信号量语义 */
struct sk_buff_head sk_receive_queue; /* 接收队列:已完成 TCP 重组的数据包 */
struct {
atomic_t rmem_alloc;
int len;
struct sk_buff *head;
struct sk_buff *tail;
} sk_backlog; /* 软中断上下文暂存队列 */

int sk_rcvbuf; /* 接收缓冲区大小上限(字节) */
int sk_sndbuf; /* 发送缓冲区大小上限(字节) */
struct sk_buff_head sk_write_queue; /* 发送队列 */
int sk_wmem_queued; /* 发送队列中已分配字节数 */
refcount_t sk_wmem_alloc; /* 发送路径上已分配(含飞行中)字节数 */

/* 回调函数:由协议层在 sock 初始化时注入 */
void (*sk_state_change)(struct sock *sk);
void (*sk_data_ready)(struct sock *sk); /* 数据到达时触发 epoll 唤醒 */
void (*sk_write_space)(struct sock *sk); /* 发送空间恢复时触发 */

struct socket *sk_socket; /* 反向指针,指向 VFS socket */
struct proto *sk_prot; /* 传输层操作集(tcp_prot 等) */
};

sk_receive_queue 存放经 TCP 重组后可供用户读取的数据;sk_backlog 则是软中断持有锁时暂存的包,进程释放锁后会被消化进 sk_receive_queue

socket_lock_t 的双锁模型sk_lock 由一个 spinlock(slock)和一个整数语义的 owned 字段组成。进程上下文调用 lock_sock(sk) 时,先获取 spinlock,然后设置 owned = 1,表示”用户上下文已拥有该 socket”,最后释放 spinlock。软中断(BH)上下文调用 bh_lock_sock(sk) 时,若发现 owned = 1 则把包挂进 sk_backlog 而非直接处理。这样既避免了进程与软中断间的死锁,又保证了 socket 处理的原子性。

1.4 inet_sock 与 tcp_sock:逐层扩展

1
2
3
4
5
6
7
8
9
10
struct sock
└── struct inet_sock (include/net/inet_sock.h)
├── inet_saddr、inet_sport(本端地址/端口)
├── inet_daddr、inet_dport(对端地址/端口,宏别名自 __sk_common)
└── struct inet_connection_sock (icsk)
└── struct tcp_sock (include/linux/tcp.h)
├── rcv_nxt、snd_nxt、snd_una(序列号)
├── copied_seq(已拷贝给用户的序列号)
├── snd_wnd、rcv_wnd(发送/接收窗口)
└── ... 拥塞控制、重传计时器等 TCP 专有字段

访问时通过辅助宏完成层间转换:

1
2
inet_sk(sk)  /* struct sock * → struct inet_sock * */
tcp_sk(sk) /* struct sock * → struct tcp_sock * */

这种”嵌套继承”设计是 C 语言实现面向对象多态的经典方式:子类型的第一个字段必须是父类型,container_of 宏负责从子字段地址还原出父结构地址。struct tcp_sock 中的 copied_seq 字段记录了”已经拷贝给用户进程的字节序列号位置”,tcp_recvmsg 每次拷贝完数据都会推进它,而 TCP 的接收窗口通知(window advertisement)正是基于 copied_seq 和当前接收队列大小计算出来的。


二、socket() 系统调用

2.1 调用链:__sys_socket → sock_create → __sock_create

用户态 socket(AF_INET, SOCK_STREAM, 0) 进入内核后的路径(net/socket.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* net/socket.c */
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
return __sys_socket(family, type, protocol);
}

int __sys_socket(int family, int type, int protocol)
{
struct socket *sock;
int flags;

sock = __sys_socket_create(family, type, protocol);
if (IS_ERR(sock))
return PTR_ERR(sock);

flags = type & ~SOCK_TYPE_MASK;
if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK))
flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;

return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}

sock_create 最终调用 __sock_create,其核心逻辑是根据 family 参数在全局 net_families[] 数组中查找已注册的协议族处理器,然后调用其 create 回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* net/socket.c */
int __sock_create(struct net *net, int family, int type, int protocol,
struct socket **res, int kern)
{
struct socket *sock;
const struct net_proto_family *pf;

/* 分配 socket 对象(嵌入在 inode 中) */
sock = sock_alloc();

sock->type = type;

rcu_read_lock();
pf = rcu_dereference(net_families[family]); /* 查 AF_INET 对应的处理器 */
/* ... */
err = pf->create(net, sock, protocol, kern); /* 调用 inet_create */
/* ... */
}

2.2 inet_create:AF_INET socket 的实例化

inet_createnet/ipv4/af_inet.c)完成 AF_INET socket 的真正创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* net/ipv4/af_inet.c */
static int inet_create(struct net *net, struct socket *sock, int protocol, int kern)
{
struct sock *sk;
struct inet_protosw *answer;

/* 在 inetsw[] 表中匹配 type/protocol 对 */
list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
if (protocol == answer->protocol) break;
/* ... 通配符匹配 ... */
}

/* 注入 socket 操作集(inet_stream_ops) */
sock->ops = answer->ops;

/* 从对应 slab 分配 struct sock(对 TCP 是 tcp_sock) */
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);

/* 初始化通用 sock 字段 */
sock_init_data(sock, sk);
sk->sk_destruct = inet_sock_destruct;
sk->sk_protocol = protocol;
sk->sk_backlog_rcv = sk->sk_prot->backlog_rcv;
/* ... */
}

inetsw[] 是在 af_inet.c 初始化阶段(inet_init)注册的静态表,将 {SOCK_STREAM, IPPROTO_TCP} 映射到 tcp_protinet_stream_ops

注意 inet_create 的几个细节:首先,sk_alloc 从协议专属的 slab(对 TCP 是 tcp_prot.slab)分配内存,这使得 tcp_sock 的分配对齐到 cacheline,并便于 slab 内存统计;其次,sock_init_data(sock, sk) 同时完成 sock→sksk→sk_socket 的双向指针设置,并初始化 sk_data_ready = sock_def_readable,这正是 TCP 数据到达时触发 epoll 的回调入口;最后,sk_backlog_rcv 被设置为 sk_prot->backlog_rcv(即 tcp_v4_do_rcv),这是从 sk_backlog 消化报文时的处理函数。

2.3 sock_map_fd:socket 与文件系统绑定

socket 必须对应一个文件描述符,才能参与 poll/epoll 和 read/write:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* net/socket.c */
static int sock_map_fd(struct socket *sock, int flags)
{
struct file *newfile;
int fd = get_unused_fd_flags(flags); /* 分配未使用的 fd 编号 */
if (unlikely(fd < 0)) {
sock_release(sock);
return fd;
}

newfile = sock_alloc_file(sock, flags, NULL); /* 创建 file 对象,f_op = &socket_file_ops */
if (!IS_ERR(newfile)) {
fd_install(fd, newfile); /* 将 fd → file 的映射安装到进程文件表 */
return fd;
}
put_unused_fd(fd);
return PTR_ERR(newfile);
}

sock_alloc_file 创建的 file 对象使用 socket_file_ops 作为文件操作集,其中 read_iter/write_iter 均委托给 socket 层的 sock_recvmsg/sock_sendmsgsock->filefile->private_data 互相持有引用,形成双向绑定。值得注意的是,sock_alloc() 中分配的 socket 对象实际上嵌入在 socket_alloc 结构(包含一个 struct inode)中,因此 socket 和 inode 共享同一块内存,SOCK_INODE(sock) 宏可以直接从 socket 指针得到对应的 inode,无需额外分配。


三、connect() 与 bind()

3.1 inet_bind:端口绑定与分配

1
2
3
4
5
6
7
8
9
/* net/ipv4/af_inet.c */
int inet_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
{
struct sock *sk = sock->sk;
/* BPF cgroup 程序可以在此拦截并修改绑定行为 */
err = BPF_CGROUP_RUN_PROG_INET_BIND_LOCK(sk, uaddr, CGROUP_INET4_BIND, &flags);
if (err) return err;
return __inet_bind(sk, uaddr, addr_len, flags);
}

__inet_bind 在验证地址合法性后,调用 sk->sk_prot->get_port(sk, snum)inet_csk_get_port,完成端口号在内核哈希表(tcp_hashinfo.bhash)中的注册。端口 < 1024 时需要 CAP_NET_BIND_SERVICE 能力。

__inet_bind 的地址验证分两步:一是通过 inet_addr_type_table 检查目标地址是否属于本机(RTN_LOCAL)或允许绑定非本机地址(IP_FREEBIND / IP_TRANSPARENT);二是检查是否已被其他 socket 占用(SO_REUSEADDR / SO_REUSEPORT 规则在此生效)。若端口为 0,inet_csk_get_port 会自动在 ip_local_port_range 范围内选取一个可用端口——这与客户端 connect() 时隐式绑定的行为相同。

3.2 inet_stream_connect → tcp_v4_connect

connect() 系统调用经 proto_ops->connect 分发到 inet_stream_connect,后者调用 __inet_stream_connect

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/* net/ipv4/af_inet.c */
int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,
int addr_len, int flags, int is_sendmsg)
{
struct sock *sk = sock->sk;
long timeo;

switch (sock->state) {
case SS_UNCONNECTED:
/* 调用 tcp_v4_connect:构造 SYN 包、选择源端口、建立路由 */
err = sk->sk_prot->connect(sk, uaddr, addr_len);
if (err < 0) goto out;
sock->state = SS_CONNECTING;
err = -EINPROGRESS;
break;
case SS_CONNECTING:
err = -EALREADY;
break;
case SS_CONNECTED:
err = -EISCONN;
goto out;
}

timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);

/* 阻塞等待 TCP 三次握手完成(SYN_SENT → ESTABLISHED) */
if ((1 << sk->sk_state) & (TCPF_SYN_SENT | TCPF_SYN_RECV)) {
if (!timeo || !inet_wait_for_connect(sk, timeo, writebias))
goto out;
}

sock->state = SS_CONNECTED;
err = 0;
out:
return err;
}

对于非阻塞 socket,connect() 立即返回 -EINPROGRESS,之后通过 epoll 监听 EPOLLOUT 事件获知连接完成。连接建立后,再调用 getsockopt(SO_ERROR) 确认是否成功(值为 0)还是遇到了错误(如 ECONNREFUSED)。

源端口自动选择tcp_v4_connect 内部调用 inet_hash_connect,它在 net.ipv4.ip_local_port_range 范围内(默认 32768–60999)用哈希算法随机选择一个未占用的源端口,避免顺序遍历带来的时序攻击风险。选择算法利用 (saddr ^ daddr ^ dport) 的哈希值作为起始偏移,在该范围内线性探测可用端口,因此同一五元组(src_ip, src_port, dst_ip, dst_port, proto)最多只能存在一个连接。当 ip_local_port_range 范围内的端口全部耗尽时,connect() 返回 EADDRNOTAVAIL,这是高频短连接场景(TIME_WAIT 堆积)的常见故障根因。


四、send/recv 数据路径

4.1 发送路径:sendto → tcp_sendmsg

1
2
3
4
5
6
7
8
9
10
11
12
13
用户态: sendto(fd, buf, len, flags, addr, addrlen)

▼ syscall
__sys_sendto (net/socket.c)
│ 构造 msghdr,调用

sock_sendmsg (net/socket.c)
│ LSM 安全检查后,调用

sock->ops->sendmsg = inet_sendmsg (net/ipv4/af_inet.c)
│ 调用传输层

sk->sk_prot->sendmsg = tcp_sendmsg (net/ipv4/tcp.c)

__sys_sendto 的实现(net/socket.c)将用户态缓冲区封装为 msghdr,然后一路向下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* net/socket.c */
int __sys_sendto(int fd, void __user *buff, size_t len, unsigned int flags,
struct sockaddr __user *addr, int addr_len)
{
struct socket *sock;
struct msghdr msg;
struct iovec iov;

err = import_single_range(ITER_SOURCE, buff, len, &iov, &msg.msg_iter);
sock = sockfd_lookup_light(fd, &err, &fput_needed);

if (addr) {
err = move_addr_to_kernel(addr, addr_len, &address);
msg.msg_name = (struct sockaddr *)&address;
msg.msg_namelen = addr_len;
}
if (sock->file->f_flags & O_NONBLOCK)
flags |= MSG_DONTWAIT;
msg.msg_flags = flags;
err = sock_sendmsg(sock, &msg);
/* ... */
}

sock_sendmsg 在完成 LSM 检查后,通过 INDIRECT_CALL_INET 宏(优化间接调用预测)调用 inet_sendmsg,最终进入 tcp_sendmsg_lockedINDIRECT_CALL_INET 是 5.x 内核引入的 Spectre/Retpoline 优化,将最常见的两个函数指针(inet6_sendmsginet_sendmsg)内联为直接调用,减少间接分支预测失败的惩罚。

4.2 tcp_sendmsg_locked:发送缓冲区管理

tcp_sendmsg_lockednet/ipv4/tcp.c)是 TCP 发送的核心,它将用户数据追加到 sk_write_queue 中的 SKB:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* net/ipv4/tcp.c(核心逻辑简化) */
int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
struct tcp_sock *tp = tcp_sk(sk);
long timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);

while (msg_data_left(msg)) {
skb = tcp_write_queue_tail(sk);
if (skb)
copy = size_goal - skb->len; /* 尝试追加到末尾 SKB */

if (copy <= 0 || !tcp_skb_can_collapse_to(skb)) {
new_segment:
if (!sk_stream_memory_free(sk))
goto wait_for_space; /* 发送缓冲区满,等待 */

skb = tcp_stream_alloc_skb(sk, 0, sk->sk_allocation, first_skb);
tcp_skb_entail(sk, skb);
}

/* 将用户数据 copy 到 SKB 的 page fragment 中 */
/* ... skb_copy_to_page_nocache ... */

continue;
wait_for_space:
/* 发送缓冲区耗尽:等待 ACK 腾出空间 */
err = sk_stream_wait_memory(sk, &timeo);
if (err) goto do_error;
goto restart;
}
}

sk_stream_wait_memorynet/core/stream.c)是阻塞等待的关键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* net/core/stream.c */
int sk_stream_wait_memory(struct sock *sk, long *timeo_p)
{
DEFINE_WAIT_FUNC(wait, woken_wake_function);

add_wait_queue(sk_sleep(sk), &wait);

while (1) {
sk_set_bit(SOCKWQ_ASYNC_NOSPACE, sk);
if (sk->sk_err || (sk->sk_shutdown & SEND_SHUTDOWN))
goto do_error;
if (!*timeo_p)
goto do_eagain; /* 非阻塞:返回 EAGAIN */

sk_clear_bit(SOCKWQ_ASYNC_NOSPACE, sk);
if (sk_stream_memory_free(sk) && !vm_wait)
break; /* 有空间了,退出等待 */

set_bit(SOCK_NOSPACE, &sk->sk_socket->flags);
sk->sk_write_pending++;
/* 睡眠,等待 sk_write_space 回调唤醒 */
sk_wait_event(sk, &current_timeo,
sk->sk_err || (sk->sk_shutdown & SEND_SHUTDOWN) ||
(sk_stream_memory_free(sk) && !vm_wait), &wait);
sk->sk_write_pending--;
}
/* ... */
}

当对端的 ACK 回来,TCP 从 sk_write_queue 释放已确认的 SKB,调用 sk_write_spacetcp_write_space,唤醒在此睡眠的进程。

零拷贝发送(MSG_ZEROCOPY)tcp_sendmsg_locked 在检测到 MSG_ZEROCOPY 标志且硬件支持 scatter-gather(NETIF_F_SG)时,会通过 msg_zerocopy_realloc 建立用户页到 SKB 的引用映射,避免 copy_from_user 的内存拷贝。完成后通过 MSG_ERRQUEUE 异步通知应用程序何时可以安全释放用户缓冲区。这对于大吞吐量场景(单次 send 超过 1MB)能显著减少 CPU 时间。

4.3 接收路径:recvfrom → tcp_recvmsg

1
2
3
4
用户态: recvfrom(fd, buf, len, flags, addr, addrlen)


__sys_recvfrom → sock_recvmsg → inet_recvmsg → tcp_recvmsg

tcp_recvmsg_lockednet/ipv4/tcp.c)从 sk_receive_queue 中提取数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/* net/ipv4/tcp.c(核心循环简化) */
static int tcp_recvmsg_locked(struct sock *sk, struct msghdr *msg, size_t len,
int flags, ...)
{
struct tcp_sock *tp = tcp_sk(sk);
u32 *seq = &tp->copied_seq; /* 已拷贝给用户的序列号位置 */
long timeo = sock_rcvtimeo(sk, flags & MSG_DONTWAIT);
int target = sock_rcvlowat(sk, flags & MSG_WAITALL, len);

do {
/* 遍历 sk_receive_queue 中的 SKB */
skb_queue_walk(&sk->sk_receive_queue, skb) {
offset = *seq - TCP_SKB_CB(skb)->seq;
if (offset < skb->len)
goto found_ok_skb;
}

/* 队列为空或数据不足 target:阻塞等待 */
if (copied >= target && !READ_ONCE(sk->sk_backlog.tail))
break;

if (copied) {
/* 已有部分数据且超时 / 非阻塞,退出 */
if (!timeo || ...) break;
}

/* 等待更多数据到达 */
tcp_cleanup_rbuf(sk, copied);
sk_wait_data(sk, &timeo, last); /* 睡眠,等待 sk_data_ready 唤醒 */
continue;

found_ok_skb:
/* 从 SKB 拷贝数据到用户缓冲区 */
err = skb_copy_datagram_msg(skb, offset, msg, used);
WRITE_ONCE(*seq, *seq + used);
} while (len > 0);
}

sk_wait_datanet/core/sock.c)等待 sk_receive_queue 有新数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* net/core/sock.c */
int sk_wait_data(struct sock *sk, long *timeo, const struct sk_buff *skb)
{
DEFINE_WAIT_FUNC(wait, woken_wake_function);
int rc;

add_wait_queue(sk_sleep(sk), &wait);
sk_set_bit(SOCKWQ_ASYNC_WAITDATA, sk);
/* 条件:receive_queue 尾部发生变化(新 SKB 入队) */
rc = sk_wait_event(sk, timeo,
skb_peek_tail(&sk->sk_receive_queue) != skb, &wait);
sk_clear_bit(SOCKWQ_ASYNC_WAITDATA, sk);
remove_wait_queue(sk_sleep(sk), &wait);
return rc;
}

当网卡中断处理完毕、TCP 将新的 SKB 放入 sk_receive_queue 后,调用 sk->sk_data_ready(sk) 唤醒睡眠中的进程。默认情况下 sk_data_ready = sock_def_readable,它会调用 wake_up_interruptible_sync_poll 唤醒 sk->sk_wq 上的等待者,这正是 epoll 的回调挂载点。

SO_RCVLOWAT 的作用target = sock_rcvlowat(sk, flags & MSG_WAITALL, len) 决定了阻塞接收时的最小字节阈值。默认值为 1(到达 1 字节即返回),通过 setsockopt(SO_RCVLOWAT, val) 可以提高到任意值,使接收调用在缓冲区中积累足够数据后才返回,减少系统调用次数,但会增加延迟。


五、epoll 实现原理

epoll 是高并发服务器的核心机制,相比 select/poll,它以 O(1) 的代价获取就绪事件。select/poll 的根本缺陷在于每次调用都需要将全部被监控的 fd 集合从用户态拷贝到内核,扫描后再将结果拷贝回来,时间复杂度是 O(n);同时,select 还有 FD_SETSIZE(通常为 1024)的 fd 数量限制。epoll 的设计通过以下三点解决了这些问题:一是用红黑树在内核中持久化存储被监控的 fd 集合,避免每次调用的拷贝;二是用就绪链表只返回真正有事件的 fd,扫描代价为 O(ready_count) 而非 O(total_count);三是通过在 socket 的等待队列上注册回调(而非每次 poll 扫描),将事件检测的开销摊薄到零。

5.1 核心数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* fs/eventpoll.c */
struct eventpoll {
struct mutex mtx; /* 保护整个 ep 的操作锁 */
wait_queue_head_t wq; /* sys_epoll_wait() 在此睡眠 */
wait_queue_head_t poll_wait; /* epoll fd 本身被 poll 时使用 */
struct list_head rdllist; /* 就绪事件链表 */
rwlock_t lock; /* 保护 rdllist 和 ovflist */
struct rb_root_cached rbr; /* 红黑树:存放所有被监控的 fd */
struct epitem *ovflist; /* 事件传递期间的溢出链表 */
struct user_struct *user;
struct file *file;
refcount_t refcount;
};

struct epitem {
struct rb_node rbn; /* 红黑树节点 */
struct list_head rdllink; /* 就绪链表节点 */
struct epoll_filefd ffd; /* {file *, fd} 二元组,用于红黑树 key */
struct eppoll_entry *pwqlist; /* 注册到目标 fd 等待队列的 entry 链表 */
struct eventpoll *ep; /* 所属 epoll 实例 */
struct epoll_event event; /* 用户设置的感兴趣事件掩码 */
bool dying;
};

红黑树rbr)存放所有被监控的 epitem,提供 O(log n) 的 CRUD。
就绪链表rdllist)存放当前有事件的 epitemepoll_wait 只需扫描这个链表。
ovflist 是一个无锁单链表,用于在 ep_send_events 持有 ep->mtx 向用户空间传递事件期间,临时存放新到达的 ep_poll_callback 回调产生的就绪项,待传递完成后再合并回 rdllist,确保这段窗口期内不丢失事件。

5.2 epoll_create1:创建 epoll 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* fs/eventpoll.c */
SYSCALL_DEFINE1(epoll_create1, int, flags)
{
return do_epoll_create(flags);
}

static int do_epoll_create(int flags)
{
int error, fd;
struct eventpoll *ep = NULL;
struct file *file;

/* 分配并初始化 struct eventpoll */
error = ep_alloc(&ep);

fd = get_unused_fd_flags(O_RDWR | (flags & O_CLOEXEC));

/* 创建匿名 inode 文件,private_data = ep */
file = anon_inode_getfile("[eventpoll]", &eventpoll_fops, ep,
O_RDWR | (flags & O_CLOEXEC));
ep->file = file;
fd_install(fd, file);
return fd;
}

epoll 实例本质上是一个持有 eventpoll 结构的匿名文件,/proc/<pid>/fd/ 中会显示 anon_inode:[eventpoll]。注意旧版 epoll_create(size)size 参数在现代内核中已被忽略(仅保留 > 0 的检查以兼容旧程序),推荐使用 epoll_create1(EPOLL_CLOEXEC) 以避免 fd 泄漏到子进程。

5.3 epoll_ctl:注册 fd 并挂入 poll 等待队列

EPOLL_CTL_ADD 的核心是 ep_insertfs/eventpoll.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* fs/eventpoll.c */
static int ep_insert(struct eventpoll *ep, const struct epoll_event *event,
struct file *tfile, int fd, int full_check)
{
struct epitem *epi;
struct ep_pqueue epq;

/* 分配并初始化 epitem */
epi = kmem_cache_zalloc(epi_cache, GFP_KERNEL);
epi->ep = ep;
ep_set_ffd(&epi->ffd, tfile, fd);
epi->event = *event;

/* 插入红黑树 */
ep_rbtree_insert(ep, epi);

/* 关键:通过 poll() 将 ep_poll_callback 挂到目标 fd 的等待队列 */
epq.epi = epi;
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
revents = ep_item_poll(epi, &epq.pt, 1); /* 调用 file->f_op->poll() */

/* 如果 fd 注册时已经有就绪事件,立即加入就绪链表 */
if (revents && !ep_is_linked(epi)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
if (waitqueue_active(&ep->wq))
wake_up(&ep->wq);
}
}

ep_ptable_queue_proc 是关键的 poll 回调安装函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* fs/eventpoll.c */
static void ep_ptable_queue_proc(struct file *file, wait_queue_head_t *whead,
poll_table *pt)
{
struct epitem *epi = epq->epi;
struct eppoll_entry *pwq;

pwq = kmem_cache_alloc(pwq_cache, GFP_KERNEL);
/* 将 ep_poll_callback 注册为 pwq->wait 的唤醒函数 */
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
pwq->whead = whead; /* 目标 fd 的等待队列头(socket 的 sk_wq) */
pwq->base = epi;
add_wait_queue(whead, &pwq->wait); /* 挂入目标等待队列 */
epi->pwqlist = pwq;
}

这样,当目标 socket 上有数据到达时,内核调用 sk->sk_data_ready(sk) 最终会唤醒 whead 上的所有等待者,其中就包含我们挂入的 ep_poll_callback

注意 ep_item_poll 调用的是 file->f_op->poll(file, &epq.pt),对于 socket 文件来说这是 sock_poll,它会调用 tcp_poll,在内部调用 poll_wait(file, sk_sleep(sk), wait),从而触发 ep_ptable_queue_procep_poll_callback 注册到 socket 的 sk_wq->wait 队列。这是一个巧妙的间接机制:epoll 不是直接操作 socket,而是通过标准 VFS poll 接口”借道”完成等待队列的注册。

5.4 ep_poll_callback:事件到达时的回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* fs/eventpoll.c */
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
__poll_t pollflags = key_to_poll(key);

read_lock_irqsave(&ep->lock, flags);

/* 过滤:事件掩码不匹配则忽略 */
if (pollflags && !(pollflags & epi->event.events))
goto out_unlock;

/* 若正在向用户空间传递事件(持有 ep->mtx),放入溢出链表 */
if (READ_ONCE(ep->ovflist) != EP_UNACTIVE_PTR) {
if (chain_epi_lockless(epi))
ep_pm_stay_awake_rcu(epi);
} else if (!ep_is_linked(epi)) {
/* 加入就绪链表 */
if (list_add_tail_lockless(&epi->rdllink, &ep->rdllist))
ep_pm_stay_awake_rcu(epi);
}

/* 唤醒在 epoll_wait 中睡眠的进程 */
if (waitqueue_active(&ep->wq))
wake_up(&ep->wq);
}

此函数在中断上下文中执行,因此必须无锁或持自旋锁(ep->lockrwlock_t)。wake_up(&ep->wq) 唤醒调用 epoll_wait 阻塞在 ep->wq 上的进程。

注意 ep_poll_callback 的无锁设计:list_add_tail_lockless 使用 cmpxchg 原子操作将 epi->rdllink 加入 ep->rdllist,而不需要持有 ep->lock 写锁,这对于多 CPU 并发触发同一 epoll 实例上多个 fd 事件的场景至关重要。EPOLLEXCLUSIVE 标志(4.5+ 内核)允许多个 epoll 实例共享监听同一 fd,仅唤醒其中一个(避免”惊群”),其实现就在 ep_poll_callback 中对 EPOLLEXCLUSIVE 的特殊处理分支。

5.5 epoll_wait:等待并收割事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* fs/eventpoll.c */
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, struct timespec64 *timeout)
{
wait_queue_entry_t wait;

eavail = ep_events_available(ep); /* 检查就绪链表是否非空 */

while (1) {
if (eavail) {
/* 将就绪事件拷贝到用户空间 */
res = ep_send_events(ep, events, maxevents);
if (res) return res;
}
if (timed_out) return 0;

/* 没有事件,将自身加入 ep->wq 等待队列并睡眠 */
init_wait(&wait);
wait.func = ep_autoremove_wake_function;

write_lock_irq(&ep->lock);
__add_wait_queue_exclusive(&ep->wq, &wait);
write_unlock_irq(&ep->lock);

if (!schedule_hrtimeout_range(to, slack, HRTIMER_MODE_ABS))
timed_out = 1;

eavail = 1;
}
}

5.6 LT vs ET:水平触发与边缘触发的实现区别

两者的差异体现在 ep_send_eventsep_scan_ready_list 的处理逻辑中(fs/eventpoll.c:1754):

1
2
3
4
5
6
7
8
9
10
11
12
13
/* fs/eventpoll.c */
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS;
else if (!(epi->event.events & EPOLLET)) {
/*
* LT 模式:如果 fd 仍有未读数据(revents 非零),
* 将 epitem 重新放回就绪链表,下次 epoll_wait 还会返回它。
*/
list_add_tail(&epi->rdllink, &ep->rdllist);
ep_pm_stay_awake(epi);
}
/* ET 模式(设置了 EPOLLET):不重新入队,
* 只有当 fd 状态从"不可读"变为"可读"时,ep_poll_callback 才会再次触发 */

LT(水平触发,默认):只要 socket 接收缓冲区中还有数据,每次 epoll_wait 都会返回该 fd 的 EPOLLIN 事件。实现上,ep_send_events 处理完后若 revents 仍非零,则把 epitem 重新加回 rdllist

ET(边缘触发,EPOLLET):仅在状态从”无数据”变为”有数据”的瞬间触发一次,epitem 不会被重新放回就绪链表。因此使用 ET 模式时,应用必须循环读取直到 EAGAIN,否则剩余数据永远不会触发新事件。

ET 模式减少了 epoll_wait 的系统调用次数和 rdllist 上的竞争,但对应用的正确性要求更高。

EPOLLONESHOT:与 ET 类似但更进一步——事件触发后会从 epoll 实例中”屏蔽”该 fd(清除其关注的事件掩码),直到显式调用 EPOLL_CTL_MOD 重新激活。这在多线程 epoll 场景中非常有用:每次事件仅由一个线程处理,处理完成后再重新注册,彻底避免多线程同时处理同一 fd 的竞争问题。

EPOLLIN / EPOLLOUT 的 socket 实现tcp_pollnet/ipv4/tcp.c)根据 tp->rcv_nxt != tp->copied_seq(接收队列有未读数据)返回 EPOLLIN,根据 sk_stream_is_writeable(sk)(发送缓冲区有空间)返回 EPOLLOUT。epoll 收到 EPOLLOUT 事件意味着可以继续调用 send(),而不是意味着数据已经到达对端。


六、Socket 缓冲区与内存压力

6.1 SO_SNDBUF / SO_RCVBUF 的”2 倍”规则

用户通过 setsockopt(SO_SNDBUF, val) 设置发送缓冲区,但内核实际使用的是 val * 2net/core/sock.c:1154):

1
2
3
4
5
6
7
8
9
10
11
12
13
/* net/core/sock.c */
case SO_SNDBUF:
val = min_t(u32, val, READ_ONCE(sysctl_wmem_max));
/* sk_sndbuf 存储的是设置值的 2 倍 */
WRITE_ONCE(sk->sk_sndbuf,
max_t(int, val * 2, SOCK_MIN_SNDBUF));
break;

case SO_RCVBUF:
/* 同样乘以 2 */
WRITE_ONCE(sk->sk_rcvbuf,
max_t(int, val * 2, SOCK_MIN_RCVBUF));
break;

内核注释解释:额外的空间用于存储 SKB 的内部元数据(skb->truesize 包含 SKB 头部开销)。getsockopt(SO_SNDBUF) 返回的是实际的 sk_sndbuf 值(即用户设置值的 2 倍),这常常让开发者感到困惑。

6.2 tcp_rmem / tcp_wmem 三段参数

通过 sysctl net.ipv4.tcp_rmemnet.ipv4.tcp_wmem 可以设置 TCP 缓冲区的动态范围:

位置 含义
tcp_rmem[0] 接收缓冲区最小值(即使在内存压力下也保证)
tcp_rmem[1] 接收缓冲区初始值(新连接默认值)
tcp_rmem[2] 接收缓冲区最大值(自动调整上限)
tcp_wmem[0] 发送缓冲区最小值
tcp_wmem[1] 发送缓冲区初始值
tcp_wmem[2] 发送缓冲区最大值

Linux 的 TCP 自动调整(autotuning)机制在 tcp_rcv_space_adjusttcp_new_space 中实现:根据实际吞吐量动态将 sk_rcvbuf/sk_sndbuf[min, max] 范围内调整。

如果显式调用了 SO_SNDBUF/SO_RCVBUF,会设置 sk_userlocks |= SOCK_SNDBUF_LOCK,此后自动调整机制会跳过该 socket。

实践建议:除非有明确的基准测试证明手动设置更优,否则建议依赖内核自动调整(保持 tcp_moderate_rcvbuf = 1,不显式设置 SO_RCVBUF)。手动固定缓冲区大小会使内核失去根据 RTT 和带宽动态优化的能力,在高延迟网络(BDP 较大)上可能导致严重的吞吐量损失。

6.3 内存压力检测:sk_stream_is_writeable

发送路径在两个层面检测内存是否充足:

per-socket 层面

1
2
3
4
5
6
7
8
/* include/net/sock.h */
static inline bool sk_stream_memory_free(const struct sock *sk)
{
if (READ_ONCE(sk->sk_wmem_queued) >= READ_ONCE(sk->sk_sndbuf))
return false; /* 发送队列已满 */
return sk->sk_prot->stream_memory_free ?
READ_ONCE(sk->sk_prot->stream_memory_free(sk)) : true;
}

sk_wmem_queuedsk_write_queue 中排队等待发送的字节总量(含 SKB 元数据),当它超过 sk_sndbuf 时,tcp_sendmsg 进入 sk_stream_wait_memory 睡眠。

全局层面:TCP 有全局内存计数器 tcp_memory_allocated,当它超过 sysctl_tcp_mem[1](pressure 阈值)时,进入”内存压力”模式,sk_under_memory_pressure(sk) 返回 true,新的 SKB 分配会更保守。当超过 sysctl_tcp_mem[2](hard limit)时,内核会拒绝新的内存分配请求,tcp_sendmsg 返回 ENOMEMsysctl_tcp_mem 的单位是 pages(4KB),默认值由系统总内存按比例计算,通常为系统内存的 1/4 ~ 1/2。

orphaned socket 内存/proc/net/sockstat 中的 orphan 计数是已关闭但还在 FIN_WAIT_1/CLOSING/LAST_ACK 等状态等待最终确认的 socket,它们不再与任何进程关联但仍占用内存。内核通过 sysctl_tcp_max_orphans 限制其数量,超出时新的 close() 会直接发 RST 而非正常四次挥手。


七、诊断方法

7.1 ss:完整 socket 统计

1
ss -tmenop

各选项含义:

  • -t:只显示 TCP socket
  • -m:显示内存信息(skmem)
  • -e:显示详细扩展信息(timer、retransmits、uid 等)
  • -n:不解析服务名
  • -o:显示 timer 信息
  • -p:显示关联的进程

输出中的 skmem 字段格式为 (r<rmem_alloc>,rb<sk_rcvbuf>,t<wmem_alloc>,tb<sk_sndbuf>,f<fwd_alloc>,w<wmem_queued>,o<omem_alloc>,bl<backlog>,d<drops>)

1
2
3
Recv-Q Send-Q  Local Address:Port   Peer Address:Port
0 0 127.0.0.1:5432 127.0.0.1:51234 users:(("postgres",pid=1234,fd=8))
skmem:(r0,rb131072,t0,tb87040,f0,w0,o0,bl0,d0)

rb131072 表示 sk_rcvbuf=128KBtb87040 表示 sk_sndbuf(约 85KB)。

7.2 strace 追踪网络系统调用

1
2
3
strace -e trace=network -p <pid>
# 或只追踪发送/接收
strace -e trace=sendto,recvfrom,sendmsg,recvmsg,epoll_wait -f ./your_app

典型输出:

1
2
3
epoll_wait(5, [{events=EPOLLIN, data={u32=7, u64=7}}], 1024, -1) = 1
recvfrom(7, "GET / HTTP/1.1\r\n...", 4096, 0, NULL, NULL) = 89
sendto(7, "HTTP/1.1 200 OK\r\n...", 512, 0, NULL, 0) = 512

7.3 /proc/net/sockstat:全局统计

1
cat /proc/net/sockstat

示例输出:

1
2
3
4
5
sockets: used 1024
TCP: inuse 523 orphan 0 tw 45 alloc 612 mem 34
UDP: inuse 12 mem 2
RAW: inuse 0
FRAG: inuse 0 memory 0

TCP mem 34 表示 TCP 全局内存使用了 34 个 page(tcp_memory_allocated),对照 sysctl net.ipv4.tcp_mem 的三个阈值可判断是否处于内存压力。tw 是 TIME_WAIT 状态的连接数,过高说明短连接过多。

7.4 bpftrace 追踪 tcp_sendmsg/tcp_recvmsg 延迟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 追踪 tcp_sendmsg 调用延迟(纳秒)
bpftrace -e '
kprobe:tcp_sendmsg { @start[tid] = nsecs; }
kretprobe:tcp_sendmsg /@start[tid]/ {
@latency_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'

# 追踪 tcp_recvmsg 并打印调用栈(找慢路径原因)
bpftrace -e '
kprobe:tcp_recvmsg { @start[tid] = nsecs; }
kretprobe:tcp_recvmsg /@start[tid] && (nsecs - @start[tid]) > 1000000/ {
printf("slow recvmsg: %d us\n", (nsecs - @start[tid]) / 1000);
print(kstack);
delete(@start[tid]);
}'

7.5 perf stat 分析 socket 系统调用开销

1
2
3
4
5
6
7
8
9
10
# 统计 sendto/recvfrom 系统调用次数和 CPU 周期
perf stat -e syscalls:sys_enter_sendto,\
syscalls:sys_enter_recvfrom,\
syscalls:sys_enter_epoll_wait,\
context-switches,cpu-migrations \
-p <pid> sleep 10

# 生成火焰图,找 socket 相关热点
perf record -F 99 -g -p <pid> sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

典型优化发现:若 epoll_wait 系统调用次数远高于 recvfrom,说明存在频繁的虚假唤醒(spurious wakeup)或事件处理不彻底(LT 模式未及时读完数据导致反复触发)。

7.6 常见 socket 性能问题速查

现象 诊断命令 根因
connect() 返回 EADDRNOTAVAIL ss -s 查看 TIME_WAIT 数量 本地端口耗尽,调大 ip_local_port_range 或开启 tcp_tw_reuse
send() 返回 EAGAIN 且 Send-Q 不增长 ss -tmenop 查看 tb 字段 发送缓冲区满,对端接收窗口为 0 或网络拥塞
epoll_wait 返回但 recv 无数据 strace + ss 查看 Recv-Q 多线程竞争同一 fd(LT 模式 “intra-thread” 问题),或 EPOLLHUP 误触发
%si(软中断 CPU) cat /proc/net/softnet_stat 网卡接收包速率过高,考虑 RSS/RPS 多队列分散
socket 内存压力 OOM /proc/net/sockstat mem 值接近 tcp_mem[2] 降低单连接缓冲区或限制连接总数

小结

Linux socket 层是用户态与内核网络协议栈之间精心设计的多层抽象。struct socket 负责 VFS 集成;struct sock 提供协议无关的通用状态;struct inet_sockstruct tcp_sock 在其上叠加协议专用字段。__sys_socketinet_create 的创建链把三者组装为一个整体,而 sock_map_fd 完成与 VFS 的最终绑定。

发送路径(tcp_sendmsg)和接收路径(tcp_recvmsg)均围绕 sk_write_queue/sk_receive_queue 工作,sk_stream_wait_memorysk_wait_data 提供阻塞语义。epoll 通过在 socket 等待队列上挂载 ep_poll_callback,实现了 O(1) 的事件通知,LT/ET 差异仅在于就绪后是否重新入队。理解这些机制,是排查高并发 I/O 问题、调优缓冲区参数的前提。

相关源文件速查:

  • net/socket.c — socket 系统调用入口
  • net/ipv4/af_inet.c — AF_INET socket 创建与连接
  • net/ipv4/tcp.c — TCP 发送/接收核心
  • net/core/stream.csk_stream_wait_memory
  • net/core/sock.csk_wait_data、SO_SNDBUF/SO_RCVBUF
  • fs/eventpoll.c — epoll 完整实现
  • include/linux/net.hstruct socketstruct proto_ops
  • include/net/sock.hstruct sockstruct sock_common
  • include/net/inet_sock.hstruct inet_sock
  • include/linux/tcp.hstruct tcp_sock