一、网络缓冲区
- 在内核中也是有网络缓冲区的,比如使用
read
读取数据(read
是一种系统调用,第一个参数为fd
),当陷入到内核态的时候,会通过fd
指定socket
,socket
会找到对应的接收缓冲区。 - 在应用程序中设计缓冲区,通过一定的算法来组织好我们的网络数据,方便应用程序在处理业务逻辑的时候从缓冲区中获取数据,进行解析,展开相对应的业务逻辑。
二、Linux 如何接收发送网络数据
- 应用程序通过系统调用,由用户态陷入到内核态,在内核态中找到对应的 socket。
- socket 层有对应的接收缓冲区和发送缓冲区。
- 根据具体的接口判断是 TCP 还是 UDP,如果是 TCP,就加上一些 TCP 的处理,比如说加上 TCP 头。
- 进入 IP 层。
- 进入 MAC 层,会加上一个 MAC 头部信息(帧头帧尾)。
- 经过网卡驱动把数据写到一个环形缓冲区中,便于网卡从环形缓冲区中读取数据,然后把数据发送到网络中。
- 内核中读取数据是独立运行的,应用程序读取数据也是独立运行的。
- 如果应用层需要拿对端发送的数据,先需要通过系统调用陷入到内核态,然后把在 socket 层中的已经准备好的接收缓冲区中的数据拷贝到应用程序中。
- 两个流程:
- 网络协议栈从网络中把数据写到 socket 对应的的接收缓冲区。
- 应用程序通过系统调用从接收缓冲区中拷贝数据到应用层去处理。
- 网络数据
- 数据帧 frame → 网卡驱动、MAC 层。
- 数据包 packet → IP 层。
- 数据段 segment → TCP / UDP 层。
- data → 应用层。
- 接收网络数据包的流程:
- 网卡接收到数据包,然后把数据写到 DMA 区域(ringbuffer 结构)。
- DMA:Direct Memory Access,直接内存操作,不需要 CPU 参与。
- 网卡向 CPU 发起硬件中断,CPU 收到中断请求,根据中断表查找中断处理函数,调用中断处理函数。
- 为什么需要硬件中断 ?
- 因为需要使用 CPU 将数据拷贝出来,然后在内核中进行处理。
- 为什么需要硬件中断 ?
- 中断处理函数将屏蔽硬件中断,发起软件中断 → 让 CPU 参与将 DMA 区域中的数据拷贝到网卡驱动,然后经过网络协议栈进行处理。
- 为什么需要先屏蔽硬件中断 ?
- 避免 CPU 频繁被网卡中断。
- 为什么需要软件中断 ?
- 使用软件中断处理耗时操作,避免执行时间过长,导致 CPU 没法响应其他硬件中断。
- 为什么需要先屏蔽硬件中断 ?
- 内核
ksoftirqd
线程负责软中断处理,该线程从ringbuffer
中逐个取出数据帧到sk_buff
。 - 从帧头取出 IP 协议,判断是 IPv4 还是 IPv6,去掉帧头帧尾。
- 从 IP 头看上一层协议是 TCP 还是 UDP ,根据五元组找到 socket,并将数据提取出来放到 socket 的接收缓冲区,当全部数据提取完毕后,软件中断处理结束,然后开启硬件中断。
- 应用程序通过系统调用将 socket 的接收缓冲区中的数据拷贝到应用层缓冲区。
- 网卡接收到数据包,然后把数据写到 DMA 区域(ringbuffer 结构)。
- 发送网络数据包的流程(TCP)
- 应用程序通过系统调用将用户数据拷贝到
sk_buff
并放到 socket 的发送缓冲区里。(UDP 没有发送缓冲区) - 网络协议栈从 socket 的发送缓冲区中取出
sk_buff
,并克隆出一个新的sk_buff
。(TCP 支持丢失重传) - 向下传递依次增加 TCP / UDP 头部、IP 头部、帧头(MAC 头部)、帧尾。
- 触发软中断通知网卡驱动程序,有新的网络包需要发送。
- 网卡驱动程序从发送队列依次取出
sk_buff
写到 DMA 区域。(有发送 DMA 区域和接收 DMA 区域) - 触发网卡发送,发送成功,触发硬件中断,释放
sk_buff
和ringbuffer
的内存。(TCP 对应的是克隆而来的,UDP 对应的是原始的) - 当收到 TCP 报文的 ACK 应答时,将释放原始的
sk_buff
(socket 的发送缓冲区中的sk_buff
)。
- 应用程序通过系统调用将用户数据拷贝到
三、系统调用
- read / write(TCP)
read
是一个系统调用,由用户态陷入到内核态,内核态会执行system call read
,把接收缓冲区中的数据拷贝到用户态准备的buf
中,sz
是预期拷贝多少字节,n
表示实际拷贝了多少字节。write
是把用户态准备发送的数据拷贝到发送缓冲区中,sz
是预期拷贝多少字节,n
表示实际拷贝了多少字节。read
、write
都是同步 IO 处理读写数据,直接通过返回值就可以判断 IO 是否完成了 → 是不是将数据从内核态拷贝到用户态了。- 使用非阻塞 IO 时,
n = -1
,errno = EWOULDBLOCK
,说明接收缓冲区为空。 - 使用阻塞 IO 时,如果接收缓冲区为空,
read
会阻塞当前线程,直到接收缓冲区中有数据。 - 如果
n = 0
,说明read
在接收缓冲区中读到了一个EOF
(四次挥手,final 包做的标记,1 个字节),即连接断开了。
- recv / send(TCP 或 UDP)
- recv 最后一个参数为 0 的话,就和 read 等价。
- send 最后一个参数为 0 的话,就和 write 等价。
- recvfrom / sendto(UDP)
- recvfrom 能够返回发送方的地址信息,而 recv 则不能。在需要识别或根据发送方地址作出响应的应用场景中,recvfrom 更加适用。
- send 需要预先通过 connect 指定目的地址,之后可以重复使用 send 发送数据到这个地址,而无需每次都指定。sendto 允许在每次调用时指定目的地址,增加了灵活性,特别是在需要与多个不同的远端通信的场景中。
- 与一个固定远端通信使用 recv / send。
- 与多个远端动态通信使用 recvfrom / sendto。
- WSARecv / WSASend(windows 下异步 IO)
WSARecv
、WSASend
都是异步 IO 处理读写数据(在 windows 下的iocp
中),比如通过一个线程不断轮询调用GetQueuedCompletionStatus
接口,获知完成通知。
- 网络编程只处理 4 件事:
- 连接的建立。
- 连接的断开。
- 数据的接收。
- 数据的发送。
四、为什么需要用户态网络缓冲区
- 从业务层生产消费模型出发:
- 对于
read
和接收缓冲区:read
从接收缓冲区中读取数据 → 生产者。- 业务层根据这些数据来处理对应的业务逻辑 → 消费者。
- 如果生产者的速度大于消费者的速度,就会导致读出来了很多数据,但是业务层来不及处理,那这些来不及处理的数据就应该缓存起来,等待业务层来处理。
- 对于
write
和发送缓冲区:- 业务逻辑产生的数据 → 生产者。
- 网络协议栈从发送缓冲区中取出数据并发送 → 消费者。
- 如果生产者的速度大于消费者的速度,也需要把业务逻辑产生的数据缓存起来,等待网络协议栈空闲的时候再把剩余的数据发送出去。
- 会为每一个连接都准备一个接收缓冲区和发送缓冲区。
- 对于
- 从 posix api 接口出发(粘包处理)
- 读取数据的时候,可能不是一个完整数据包,而是多个数据包,也就是不能一次性接收数据,一次性发送数据。
- 完整数据包是用户定义的:
- 特殊字符来界定完整数据包,比如使用
\r\n
。 - 固定长度来界定完整数据包,在包头加上 2 个字节的长度信息。
- 特殊字符来界定完整数据包,比如使用
- 为什么要界定完整数据包 ? 因为应用程序是按照一个完整数据包进行处理的。
- UDP 和 TCP 协议是否影响用户态缓冲区设计 ?
- 不会影响。
- 不同网络编程模型是否影响用户态缓冲区设计 ?
- 不会影响。
- 网络编程模型:不同网络编程模型处理 IO 的方式不一样。
- 处理 IO 分为两部分:先检测 IO 是否就绪、再操作 IO 进行数据拷贝。
- 对于
read
,接收缓冲区中有数据了,IO 就处于就绪状态,否则,IO 处于未就绪状态。 - 对于
write
,发送缓冲区满了,IO 就处于未就绪状态,否则 IO 处于就绪状态。
- 对于
- 处理 IO 分为两部分:先检测 IO 是否就绪、再操作 IO 进行数据拷贝。
- 阻塞 IO 网络编程模型。
- 通过阻塞线程的方式等待 IO 就绪。
- reactor 网络编程模型
- 基于同步 IO 模型。
- IO 多路复用:只能检测 IO 是否就绪,不能操作 IO 。
- 一个 IO 多路复用的对象可以同时检测多个连接的 IO 是否就绪,一个连接对应一个 fd。
- 事件循环:调用 IO 多路复用,获取那些就绪的事件,依次处理,操作 IO。
- 服务端如何知道客户端什么时候发送数据 ?
select
:reactor 网络编程模型将fd
交由select
进行管理,会去注册一个读事件,select
会检测接收缓冲区,判断对端是否发送数据,如果发送数据了,会触发读事件(抛出读事件到应用层),应用层拿到触发的读事件,调用read
, 从用户态陷入到内核态,将数据从接收缓冲区拷贝到用户态。
- proactor 网络编程模型
- 基于异步 IO 模型。
- windows 下的
iocp
机制:- 将 fd 绑定在完成端口上。
- 抛出具体的读写请求(
WSARecv
、WSASend
)到完成端口上。 - 完成端口负责检测 IO 是否就绪。
- IOCP 对象是一个事件队列,用于存储完成的 IO 操作的结果。
iocp
机制会检测接收缓冲区,判断对端是否发送数据,如果发送数据了,会在内核中直接进行拷贝,拷贝到buffer
, 拷贝结束后会以事件的形式通知用户态,用户态通过调用GetQueuedCompletionStatus
接口来获知完成通知。
- reactor 和 proactor 都是一种异步事件的处理方式。
- proactor 在内核中检测 IO 是否就绪,由内核操作 IO 进行数据拷贝 → 内核自己把数据拷贝到
buffer
中。 - reactor 会涉及到多次内核和用户态的交互,在内核中检测 IO 是否就绪,在用户态主动操作 IO 进行数据拷贝 → 用户态主动调用
read
。
- proactor 在内核中检测 IO 是否就绪,由内核操作 IO 进行数据拷贝 → 内核自己把数据拷贝到
五、如何设计用户态网络缓冲区
- 因为处理数据的时候是一个生产消费模型,所以设计用户态网络缓冲区要实现类似队列的结构
定长 buffer
char buffer[16 * 1024 * 1024];
uint offset; // 可用数据包的长度,从网络缓冲区拷贝了多少数据到 buffer
- 生产消费模型
- 生产者:
read
,追加数据 → 把数据从内核态拷贝到用户态,填充用户态网络缓冲区buffer
。 - 消费者:从用户态网络缓冲区界定数据包,界定成功,取数据包 → 把剩余数据挪到最前面,修改
offset
→offset = offset - 完整数据包长度
- 生产者:
- 优点:结构简单,易于实现。
- 缺点:
- 需要频繁腾挪数据,只要界定成功一个完整数据包,就需要把后面的数据挪到前面,以空余更多的空间供生产者往里面填充数据。
- 需要实现扩缩容机制,如果缓冲区剩余空间不足以存放数据,需要对缓冲区进行扩容,并且将旧缓冲区中的数据挪到新缓冲区中。
- 使用场景:
- 客户端发送的数据比较少,并且发送频率不高。
- Redis 的接收缓冲区,使用的是定长 buffer。
ringbuffer
char buffer[16 * 1024 * 1024]
uint head;
uint tail;
ringbuffer
是逻辑上的环形缓冲区,记录头尾指针head
和tail
来标识数据范围。- 生产者往
tail
追加,消费者移动head
指针。 head % size
tail % size
- 生产者往
- 优点:不需要腾挪数据。
- 缺点:
- 需要实现扩缩容机制。
- 造成不连续空间,可能引发多次系统调用。
- 缓冲区数据为不连续空间,虽然剩余空间远大于 100 个字节,但是由于物理空间不连续,需要调用两次
read
,第一次调用read
往末尾填充 50 个字节,第二次调用read
往最前面填充 50 个字节。调用多次read
,即引发多次系统调用,造成系统损耗。
- 缓冲区数据为不连续空间,虽然剩余空间远大于 100 个字节,但是由于物理空间不连续,需要调用两次
- 优化方法:Linux 下的
readv
和writev
,windows 下的WSARecv
和WSASend
。- 内核态中的发送缓冲区和接收缓冲区都是连续空间。
- readv:通过一次系统调用将内核中连续空间的数据拷贝到用户态不连续空间。
- writev:通过一次系统调用将用户态不连续空间数据拷贝到内核中的连续空间。
ssize_t readv(int fd, const struct iovec *iov, int iovcnt); ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
- 系统调用:线程正在执行用户态代码,调用
read
,此时会保存用户态代码运行现场,从用户态陷入到内核态,线程开始执行内核态代码,执行syscall_read
→ 检测 IO 是否就绪,如果就绪了,将数据拷贝到用户态,然后从内核态切换回用户态。
chainbuffer
- 不腾挪数据 →
misalign
表示有效数据的起始指针,offset
表示有效数据的长度。 - 动态扩缩容并且不腾挪数据。
- 动态扩容:当前节点剩余空间不足以存放 100 字节的完整数据,先扩容一个节点,然后将当前节点的剩余空间填充 50 个字节,扩容的节点填充 50 个字节,当前节点的 next 指针指向扩容的节点,并且把 last 指针指向扩容的节点。
- 动态缩容:当发现
offset
为 0 的时候,删除该节点,将 first 指针指向下一个节点。
- 优点:不需要腾挪数据,动态扩缩容,并且无需拷贝数据。
- 缺点:造成不连续空间,可能引发多次系统调用。