
🎉博主首页: 有趣的中国人
🎉专栏首页: 操作系统原理
🎉其它专栏: C++初阶 | C++进阶 | 初阶数据结构,
文章目录
- [从网卡收到数据到 epoll 被唤醒:把 Linux 网络接收链路真正串起来](#从网卡收到数据到 epoll 被唤醒:把 Linux 网络接收链路真正串起来)
-
- 一、先从最底下看:数据是怎么进来的
-
- [1. 网卡最先接触到的,其实不是"应用层数据"](#1. 网卡最先接触到的,其实不是“应用层数据”)
- [2. 为什么必须有 DMA](#2. 为什么必须有 DMA)
- [3. DMA 把数据写到哪里了:Ring Buffer](#3. DMA 把数据写到哪里了:Ring Buffer)
- [二、CPU 什么时候接手:中断、软中断和 sk_buff](#二、CPU 什么时候接手:中断、软中断和 sk_buff)
-
- [1. 数据写完以后,网卡怎么通知 CPU](#1. 数据写完以后,网卡怎么通知 CPU)
- [2. 从 Ring Buffer 到 sk_buff,这一步是谁做的](#2. 从 Ring Buffer 到 sk_buff,这一步是谁做的)
- [3. 一个包不是立刻进 socket,而是先过协议栈](#3. 一个包不是立刻进 socket,而是先过协议栈)
- [三、从协议栈到 socket:真正和编程接口接上了](#三、从协议栈到 socket:真正和编程接口接上了)
-
- [1. IP 层和 TCP 层分别在干什么](#1. IP 层和 TCP 层分别在干什么)
- [2. socket 接收队列里放的到底是什么](#2. socket 接收队列里放的到底是什么)
- [3. 到了 socket 队列,还不是应用层](#3. 到了 socket 队列,还不是应用层)
- [4. socket() 创建的到底是什么](#4. socket() 创建的到底是什么)
- [5. TCP 和 UDP 是怎么区分的](#5. TCP 和 UDP 是怎么区分的)
- [四、listen、accept 和 epoll:从"有包"到"有事件"](#四、listen、accept 和 epoll:从“有包”到“有事件”)
-
- [1. listen 之后,socket 的角色就变了](#1. listen 之后,socket 的角色就变了)
- [2. accept 干了什么](#2. accept 干了什么)
- [3. epoll 监听普通 fd 时,到底在等什么](#3. epoll 监听普通 fd 时,到底在等什么)
- [4. epoll 监听 listenfd 时,又在等什么](#4. epoll 监听 listenfd 时,又在等什么)
- [5. 为什么 listenfd 可读了,accept 还可能返回 EAGAIN](#5. 为什么 listenfd 可读了,accept 还可能返回 EAGAIN)
- 五、把整条链路收一下
- 六、最后补一个结构总图
从网卡收到数据到 epoll 被唤醒:把 Linux 网络接收链路真正串起来
在学 Linux 网络编程的时候,最容易出现一种情况:每个点都听过,但连不成一条线。
比如说,知道网卡收包、知道 DMA、知道中断、知道 sk_buff、知道 socket、知道 listen/accept、也知道 epoll 很快,但如果真的问一句:
一个 TCP 连接上的数据到来之后,到底经历了什么,最后为什么会让
epoll_wait()返回?
很多时候就会开始断层。
这篇文章就不把这些知识点拆开讲,而是沿着 "数据进入主机之后的真实流动路径" ,从网卡一路讲到 epoll,再把 listenfd、全连接队列、新连接 socket 这些东西一起串起来。中间会穿插一些内核里的关键结构和代码,尽量把这条链路讲完整。
一、先从最底下看:数据是怎么进来的
1. 网卡最先接触到的,其实不是"应用层数据"
网络数据进入主机时,最先碰到的是网卡。但网卡一开始接收到的,并不是"HTTP 请求"或者"TCP 数据"这种概念上的东西,而是物理层的电信号或光信号。
网卡先做的事情,是把这些信号转换成比特流,然后按链路层格式去解析。对以太网来说,最先处理的是以太网帧。
可以简单理解成这样:
text
网线/光纤
↓
网卡(物理层)
↓
比特流
↓
链路层解析以太网帧
网卡会先看几个关键东西:
- 目标 MAC 是不是自己
- 帧是否合法
- CRC 校验是否通过
如果目标 MAC 不是本机,或者帧本身有问题,很多时候在这一层就直接丢弃了,根本不会进入后面的协议栈。
所以,收包的起点是网卡,但真正进入内核协议栈之前,已经先经过了一轮链路层筛选。
2. 为什么必须有 DMA
如果没有 DMA,那么网卡收到一个包之后,CPU 就得自己亲手把数据从设备搬到内存里。这个思路在理论上当然没问题,但在高并发场景下会非常低效。
因为 CPU 本来就要做很多事情:
- 跑协议栈
- 处理调度
- 执行业务逻辑
- 用户态/内核态切换
如果连搬数据这种体力活都得它自己干,那系统吞吐很快就会掉下来。
所以实际系统里,网卡收包之后,通常会通过 DMA(Direct Memory Access) 直接把数据写进内存。CPU 不是完全不参与,而是主要负责"安排"和"收尾",真正的数据搬运交给 DMA。
可以把这个过程理解成:
text
没有 DMA:
网卡 → CPU → 内存
有 DMA:
网卡 → DMA → 内存
这一步非常关键,因为后面很多高性能网络优化,都是建立在"CPU 不做无意义搬运"这个基础上的。
3. DMA 把数据写到哪里了:Ring Buffer
DMA 写入的目的地,不是随便一块内存,而是驱动层提前准备好的接收缓冲区。这个缓冲区通常组织成环形,所以大家经常叫它 Ring Buffer。
可以画成这样:
text
+-------+ +-------+ +-------+
| slot0 | -> | slot1 | -> | slot2 |
+-------+ +-------+ +-------+
^ |
|_____________________________|
每个槽位都可以存一个包,网卡收到数据后,就通过 DMA 把包写到某个 slot 中。
这里一定要区分清楚:
- Ring Buffer:驱动层/网卡层面的接收缓冲
- socket 接收队列:协议栈处理后,交给 socket 的接收队列
这两个不是一回事。很多人学到后面会混淆,结果把驱动层和 socket 层揉在一起理解,后面就容易乱。
所以在这个阶段,数据只是:
网卡通过 DMA 写进了驱动层的接收缓冲区,还没有进入 socket。
二、CPU 什么时候接手:中断、软中断和 sk_buff
1. 数据写完以后,网卡怎么通知 CPU
DMA 把数据写进 Ring Buffer 后,网卡要告诉 CPU:"我这边有新包到了,你该处理了。"
这个通知机制就是 中断。
中断的本质可以理解为:硬件主动打断 CPU 当前执行的流程,让 CPU 去响应某个事件。比如键盘敲一下、磁盘读完了、网卡收到了新包,这些都可以触发中断。
网卡收包之后的大致流程可以抽象成:
text
网卡收到数据
↓
DMA 写入 Ring Buffer
↓
触发硬件中断
↓
CPU 保存现场
↓
进入中断处理流程
不过网络子系统里有个很重要的设计:
硬中断通常不做太重的活。
因为硬中断优先级很高,如果在这个阶段做大量复杂逻辑,会影响整个系统。所以 Linux 通常会在硬中断里只做少量工作,例如确认中断来源、做一些必要标记,然后把真正的收包处理延后到软中断去完成。
也就是说,网络收包的"真正重活",多数是在 软中断 里做的。
2. 从 Ring Buffer 到 sk_buff,这一步是谁做的
当数据已经在 Ring Buffer 中了,接下来内核要把它转成协议栈统一能处理的对象,也就是 sk_buff。
这一点很关键:
从 Ring Buffer 取数据并封装成
sk_buff,是 CPU 在软中断上下文中完成的。
不是 DMA 自动帮你变成 sk_buff,也不是 socket 自己来拿,而是内核网络接收路径中的代码去做这件事。
Linux 网络里最核心的数据结构之一就是:
c
struct sk_buff
{
union
{
struct
{
struct sk_buff *next;
struct sk_buff *prev;
union
{
struct net_device *dev;
};
};
};
unsigned int len;
unsigned char *data;
unsigned int truesize;
...
};
当然真实结构比这个复杂得多,这里只看几个核心点:
next/prev:说明它可以挂到链表/队列上data:指向真正的数据区域len:数据长度dev:来自哪个网卡设备
你可以把 sk_buff 理解成:
Linux 内核中对"一个网络包"的统一封装对象。
后面无论经过 IP 层、TCP 层,还是最终挂到 socket 的接收队列里,核心流转对象基本都是它。
3. 一个包不是立刻进 socket,而是先过协议栈
sk_buff 被创建出来以后,并不会立刻进入 socket。中间还要经过协议栈逐层处理。
整个过程大致是:
text
Ring Buffer
↓
驱动层取包
↓
创建 sk_buff
↓
IP 层处理
↓
TCP 层处理
↓
找到对应 socket
↓
进入 socket 接收队列
这里最容易搞错的点,就是把"创建 sk_buff"和"进入 socket 队列"当成同一步。实际上不是。它先是一个在协议栈中流动的包对象,走完 IP 层、TCP 层之后,才会被放到具体 socket 上。
三、从协议栈到 socket:真正和编程接口接上了
1. IP 层和 TCP 层分别在干什么
sk_buff 创建出来之后,先走 IP 层。IP 层要检查很多事情,比如:
- 目标 IP 是否是本机
- IP 头是否合法
- 校验和是否正确
- 是否涉及分片/重组
过了 IP 层以后,再进入 TCP 层。TCP 层要干的事情更多,因为 TCP 是有状态、有可靠性保证的协议。典型工作包括:
- 根据四元组找到对应连接
- 检查序列号
- 处理乱序和重组
- 更新确认号、窗口等状态
对于 TCP 来说,最关键的一步是:
根据四元组找到对应的
sock。
所谓四元组就是:
text
源 IP
目标 IP
源端口
目标端口
只有定位到对应连接之后,这个包才知道应该被交给哪个 socket。
2. socket 接收队列里放的到底是什么
socket 接收队列里,放的不是"应用层消息对象",也不是某种已经被上层解析好的东西,放的仍然是 sk_buff。
Linux 内核中,socket 的核心协议结构是 struct sock。里面就有接收队列和发送队列。
可以简化看一下:
c
struct sock {
struct sk_buff_head sk_receive_queue;
struct sk_buff_head sk_write_queue;
...
};
这里:
sk_receive_queue:接收队列sk_write_queue:发送队列
而 struct sk_buff_head 本质上就是一个管理 sk_buff 链表的队列头。
也就是说:
text
socket 接收队列
↓
[skb] -> [skb] -> [skb] -> ...
所以更准确的说法是:
TCP 层处理完成后,会把
sk_buff挂到对应sock的接收队列上。
这一步做完,socket 对应用层来说就"可读"了。
3. 到了 socket 队列,还不是应用层
这也是一个很常见的误区。很多人会说,"数据已经到 socket 里了,那是不是就已经到应用层了?"
不是。
socket 接收队列仍然是内核态的数据结构,数据只是先暂存在内核里。只有当应用程序调用 read()、recv() 这些系统调用时,内核才会把数据从 socket 接收队列拷贝到用户空间缓冲区。
这个过程大致可以画成这样:
text
网卡
↓
Ring Buffer
↓
sk_buff
↓
IP / TCP
↓
socket 接收队列(内核)
↓
read / recv
↓
用户空间 buffer
↓
应用层看到数据
所以,socket 接收队列是内核和应用层之间的重要缓冲层,但它本身不属于应用层。
4. socket() 创建的到底是什么
用户写:
c
int fd = socket(AF_INET, SOCK_STREAM, 0);
这一步并不会建立连接,也不会收发数据,它只是创建一套内核对象,并返回一个文件描述符。
Linux 中 socket 也是"文件"体系的一部分,所以它背后会关联 struct file。而 file 里会通过 private_data 指向真正的 struct socket。
结构关系可以简单画成这样:
text
fd
↓
struct file
↓
private_data
↓
struct socket
↓
struct sock
内核里对应关系很经典:
c
struct socket
{
socket_state state;
short type;
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};
几个关键点:
file:说明 socket 也挂在文件描述符体系里sk:真正协议层面的核心对象ops:不同 socket 的操作函数表
所以 socket() 系统调用干的事,本质上可以概括成一句话:
创建文件描述符,创建 socket 对象,并把它和更底层的
sock结构关联起来。
5. TCP 和 UDP 是怎么区分的
在用户态,通常是通过:
c
socket(AF_INET, SOCK_STREAM, 0); // TCP
socket(AF_INET, SOCK_DGRAM, 0); // UDP
来区分。
在内核里,struct socket 并不直接区分 TCP/UDP,而是通过它关联的 sock 以及操作函数表来区分。Linux 内核用的是一种典型的 C 语言"继承 + 多态"风格。
比如通用层有 struct sock,而 TCP、UDP 会在这个基础上扩展:
c
struct tcp_sock
{
struct inet_connection_sock inet_conn;
...
};
struct udp_sock
{
struct inet_sock inet;
...
};
再往里追,会发现它们底层都包含 struct sock 这一层。
这就是为什么 struct socket 里虽然只有一个 struct sock *sk,但它最终可以指向 TCP 相关对象,也可以指向 UDP 相关对象。上层拿的是"基类指针",真正运行时再分派到不同协议实现上。
如果用面向对象的话来说,这确实有点像多态,只不过 Linux 是用 C 的方式做出来的。
四、listen、accept 和 epoll:从"有包"到"有事件"
1. listen 之后,socket 的角色就变了
服务端最开始调用 socket() 创建出来的,只是一个普通 socket。真正让它变成"监听 socket"的,是 listen()。
一旦 listen() 成功,这个 socket 的职责就不再是正常收发业务数据,而是变成了:
接收新连接。
对于 TCP 来说,监听 socket 会关联连接队列。最常说的就是:
- 半连接队列
- 全连接队列
客户端第一次发 SYN 过来之后,服务端先进入半连接状态;三次握手完成之后,这个连接才会进入全连接队列,等待应用层 accept()。
在内核里,全连接队列是 TCP 监听相关结构的一部分。可以看一个典型成员:
c
struct inet_connection_sock
{
struct request_sock_queue icsk_accept_queue;
...
};
这里的 icsk_accept_queue,就可以理解成我们平时讲的 accept 队列,也就是全连接队列。
所以,不是所有 socket 都有这个队列,只有监听 socket 才会维护它。
2. accept 干了什么
当应用层调用:
c
int connfd = accept(listenfd, NULL, NULL);
本质上做的是:
- 从
listenfd对应的全连接队列里取出一个已经完成三次握手的连接 - 为这个连接创建一个新的通信 socket
- 返回新的文件描述符
connfd
所以服务端通常会有两类 fd:
listenfd:负责监听新连接connfd:负责和某一个客户端实际通信
这两者职责完全不同:
text
listenfd:等连接
connfd :传数据
这也是为什么监听 socket 和普通通信 socket 虽然底层都属于 socket,但它们的"可读"含义并不一样。
3. epoll 监听普通 fd 时,到底在等什么
当我们把一个普通 TCP 通信 fd 加到 epoll 里时,epoll_wait() 等的不是某个"应用层消息对象",也不是专门等某个固定的 sk_buff,它等的是:
这个 fd 是否处于就绪状态。
对于可读事件来说,最核心的判定条件就是:
socket 接收队列是否非空。
也就是说,当 TCP 层把 sk_buff 放入 sk_receive_queue 后,这个 socket 就变成可读了,内核会通过回调把这个就绪事件挂到 epoll 的就绪链表中,最终 epoll_wait() 就会返回。
可以把这一段理解成:
text
数据到来
↓
TCP 层处理完成
↓
skb 进入 socket 接收队列
↓
socket 可读
↓
epoll_wait 返回
所以更准确地说:
epoll 等的不是
sk_buff本身,而是 socket 的就绪状态;而这个就绪状态,常常由接收队列里是否有sk_buff决定。
4. epoll 监听 listenfd 时,又在等什么
监听 listenfd 的情况和普通通信 socket 不一样。
普通 fd 可读,通常代表"有数据了";但 listenfd 可读,不代表来了业务数据,而是代表:
全连接队列里有新的已建立连接了。
也就是说,监听 socket 的可读事件,本质上对应的是 accept queue 非空。
可以画成这样:
text
客户端三次握手完成
↓
连接进入全连接队列
↓
listenfd 变为可读
↓
epoll_wait 返回
↓
应用层 accept()
所以:
- 普通 connfd 的可读:接收队列里有数据
- listenfd 的可读:全连接队列里有连接
这是很多人第一次学 epoll 时最容易混掉的点。
5. 为什么 listenfd 可读了,accept 还可能返回 EAGAIN
这个问题其实很经典,尤其是在非阻塞和 ET 模式下经常遇到。
根本原因在于:
epoll 只负责告诉你"这个 fd 发生过就绪",但不保证你真正去处理的时候,条件还一定成立。
比如有两种常见情况。
第一种,多线程/多进程同时抢同一个 listenfd。
一个新连接到来之后,内核把等待者都唤醒了,但最终只有一个线程抢到了这个连接,其他线程再去 accept() 时,全连接队列已经空了。这时非阻塞 accept() 就会返回 EAGAIN。
第二种,ET 模式下没有一次性把队列读空。
ET 只在状态变化时通知一次,所以 listenfd 一旦变为可读,就应该循环 accept(),直到返回 EAGAIN 为止,否则后面的连接可能还在队列里,但你却收不到新的通知。
典型写法就是:
c
while (true)
{
int connfd = accept(listenfd, NULL, NULL);
if (connfd < 0)
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
else
perror("accept");
}
}
这个循环不是"多余的",而是在 ET + 非阻塞场景下的标准处理方式。
五、把整条链路收一下
到这里,其实整条主线已经很清楚了。
对于一个普通 TCP 包来说,它的大致路径是:
text
网卡收到数据
↓
DMA 写入 Ring Buffer
↓
硬中断通知 CPU
↓
软中断开始真正处理
↓
驱动层从 Ring Buffer 取包并创建 sk_buff
↓
进入 IP 层
↓
进入 TCP 层
↓
根据四元组找到对应 sock
↓
skb 放入 socket 接收队列
↓
socket 变为可读
↓
epoll_wait 返回
↓
应用层 recv/read 取数据
而对于服务端的新连接来说,则是另一条相关但不同的链路:
text
socket()
↓
bind()
↓
listen()
↓
监听 socket 维护连接队列
↓
客户端三次握手完成
↓
连接进入全连接队列
↓
listenfd 可读
↓
epoll_wait 返回
↓
accept()
↓
创建新的 connfd
↓
后续用 connfd 和客户端通信
所以最后真正需要记住的,不是某个孤立定义,而是下面这几句话:
- Ring Buffer 属于驱动层,不是 socket 层
- 协议栈中流动的核心对象是
sk_buff - socket 接收队列里放的也是
sk_buff - socket 接收队列仍然属于内核态,不是应用层
- 普通 fd 可读,通常表示接收队列里有数据
- listenfd 可读,表示全连接队列里有连接
- accept 取出的不是 listenfd 自己,而是一个新的通信 socket
如果把这些点真的串起来了,那么后面再看 Reactor、Muduo、LT/ET、惊群、NAPI,理解成本会低很多。因为你脑子里已经有一条完整的主线了,而不是一堆分散的概念。
六、最后补一个结构总图
文章最后,用一张总图把对象关系和数据流再收一遍:
text
┌─────────────────────┐
│ 用户程序 │
│ read / recv / epoll│
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ fd / struct file │
└─────────┬───────────┘
│ private_data
▼
┌─────────────────────┐
│ struct socket │
│ file / sk / ops │
└─────────┬───────────┘
│
▼
┌─────────────────────┐
│ struct sock │
│ sk_receive_queue │
│ sk_write_queue │
└─────────┬───────────┘
│
┌───────────────┴────────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ 普通通信 socket │ │ listen socket │
│ 接收队列里放 skb │ │ 全连接队列等连接 │
└─────────────────────┘ └─────────────────────┘
数据流:
网卡 → DMA → Ring Buffer → sk_buff → IP → TCP → socket 队列 → epoll → 应用层
说到底,Linux 网络编程难的从来不是某一个函数,也不是某一个结构体,而是能不能把这些零散的知识点真正串成一条线。只要这条主线理顺了, Reactor、Muduo、LT/ET、惊群 这些内容,其实都只是继续往下展开而已。