ZeroCopy(零拷贝技术)

为什么要有DMA技术

nodedma

我们可以过程中的每一步都需要cpu的参与
当我们引进dma技术后我们再来看整个流程

dma

现在将原来的从磁盘控制器缓冲区搬运到pagecache的过程全部交给dma来完成

传统的文件传输功能

我们假设如果服务端需要文件传输的功能,我们会明白,首先从磁盘上讲文件读取出来,然后通过网络协议发送给客户端

传统的io的 工作方式是数据读取和写入是从用户空间到内核空间来回复制,而内核空间是通过操作系统的io接口从磁盘读取和写入

我们可以发现一共发生了四次拷贝,其中两次dma拷贝,一共四次上下文切换

我们可以来看一下如何实现零拷贝

实现零拷贝的方式有两种

  1. mmap + write
  2. sendfile

mmap

前面我们知道了read()系统调用会把内核缓冲区的数据拷贝到用户缓冲区
于是为了减少这一步的开销,我们可以使用mmap()来替代read()函数,这样可以减少一次拷贝

mmap使用的原理是将用户缓冲区与内核缓冲区进行共享,使用映射的方法,这样操作系统与系统内核空间就不需要任何数据拷贝的操作

mmap

此时系统是四次上下文切换,三次拷贝

sendfile

在linux中有一个函数叫做sendfile专门用来发送文件的系统调用函数,**它可以替代read()和write()函数减少了两次上下文切换的消耗,实现了只有两次上下文切换和三次数据拷贝

sendfile

此时我们可以来看看什么是真正的零拷贝

如果网卡支持scdma我们就可以直接使用dma拷贝将内核缓存区的数据直接搬运到网卡

zerocopy

此时我们可以看到仅仅有两次io,两次的拷贝,实现了文件传输的性能翻倍

io多路复用

多线程模型

既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。
线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
多线程模型

select/poll

我们可以发现这样处理当多线程连接时候
当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。 如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。 那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。 (重点理解)select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

第一次在监听队列中找出(内核态)
第二次在用户态中找出,进行遍历

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。 但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。