Linux之内核,零拷贝,select,poll,epoll讲解
1 select、poll、epoll
学习此篇文章可以先学习下 内核与用户空间 相关信息
1.1 引言
操作系统在处理io
的时候,主要有两个阶段:
- 等待数据传到io设备
- io设备将数据复制到user space
我们一般将上述过程简化理解为:
- 等到数据传到kernel内核space
- kernel内核区域将数据复制到user space(理解为进程或者线程的缓冲区)
select
,poll
,epoll
都是IO
多路复用的机制。I/O
多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select
,poll
,epoll
本质上都是同步I/O
,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O
则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间
1.2 IO和Linux内核发展
1.2.1 整体概述
整体关系流程:
查看进程文件描述符:
获取pid进程号
ps -ef
查看文件描述符
cd /proc/进程号/fd ; ll
或者查看当前进程的fd
$$ 表示 Shell 本身的 PID (ProcessID)
cd /proc/$$/fd ; ll
1.2.2 阻塞IO
计算机是有内核(kernel
)的,内核向下连接很多的客户端,内核向上连接进程或线程
,早先内核通过read
命令读取文件描述符(fd),在这个时期socket
是blocking
(阻塞的)BIO
。
如下图所示:线程通过内核读取文件fd8,读取到用户空间后,在通过内核写入文件fd9,如果fd8阻塞了,它会阻挡后面的操作
1.2.3 非阻塞IO
socket fd nonblock
(非阻塞),进程/线程用一个,用循环遍历文件描述符(轮询发生在用户空间),这个时期是同步非阻塞时期NIO
;
这是由于内核socket
本身就是nio
,同步非阻塞IO
1.2.4 select
如果有1000个文件描述符fd
,代表用户进程轮询调用1000次内核(kernel
),造成成本很大的问题。于是在内核中增加了一个系统调用select
,用户空间调用新的系统调用,统一将所有的文件描述符传给select
,内核监控文件描述符的完成度,文件描述符完成之后返回,返回之后还有系统调用,再调用read
(有数据的文件描述符),这个叫多路复用NIO
,在这个时期,文件描述符考来考去成为累赘;
1.2.5 共享空间
共享空间是进程用户空间一部分,也是内核空间的一部分
引入一个共享空间mmap
,将文件描述符放在共享空间里,文件描述符
放在共享空间的红黑树
里,将资源齐全的文件描述符
放到链表
里
1.3 零拷贝
1.3.1 引言
在操作系统中,使用传统的方式,数据需要经历几次拷贝,还要经历用户态/内核态
切换
- 从磁盘复制数据到内核态内存;
- 从内核态内存复制到用户态内存;
- 然后从用户态内存复制到网络驱动的内核态内存;
- 最后是从网络驱动的内核态内存复制到网卡中进行传输。
简略图:
精细图:
DMA(Direct Memory Access,直接内存访问)
技术,绕过 CPU
,直接在内存和外设之间进行数据传输。这样可以减少 CPU
的参与,提高数据传输的效率。
1.3.2 什么是零拷贝
零拷贝
技术可以利用 Linux
下的 MMap
、sendFile
等手段来实现,使得数据能够直接从磁盘映射到内核缓冲区,然后通过 DMA
传输到网卡缓存,整个过程中 CPU
只负责管理和调度,而无需执行实际的数据复制指令。
通过零拷贝的方式,减少用户态
与内核态
的上下文切换和内存拷贝的次数,用来提升I/O
的性能。零拷贝比较常见的实现方式是mmap
,这种机制在Java
中是通过MappedByteBuffer
实现的。
1.3.3 MMAP
MMap(Memory Map)
是 Linux
操作系统中提供的一种将文件映射到进程地址空间的一种机制,通过 MMap
进程可以像访问内存一样访问文件,而无需显式的复制操作。
mmap
内存映射的拷贝,指的是将用户应用程序的缓冲区和操作系统的内核缓冲区进行映射处理,数据在内核缓冲区和用户缓冲区之间的 CPU 拷贝将其省略,进而加快资源拷贝效率。
简略图:
精细图:
传统的 IO 需要四次拷贝和四次上下文(用户态和内核态)切换,而 MMap
只需要三次拷贝和四次上下文切换,从而能够提升程序整体的执行效率,并且节省了程序的内存空间
1.3.4 sendFile
在 Linux
操作系统中 sendFile()
是一个系统调用函数,用于高效地将文件数据从内核空间直接传输到网络套接字(Socket
)上,从而实现零拷贝技术。这个函数的主要目的是减少 CPU
上下文切换以及内存复制操作,提高文件传输性能。
sendfile
,有两个参数一个写出io
,一个读入io
在之前是先读取文件到用户空间,再写到内核中去,有了sendfile
后,用这一个命令就可以了,不用读取写入
使用 sendFile()
可以把 IO 执行流程优化成以下执行步骤:
简略图:
在Linux 2.1
及之后的版本中,sendfile
函数通过直接在内核空间进行数据传输,避免了用户空间
和内核空间
之间的拷贝,从而显著提高了性能。此时,sendfile
的数据拷贝次数通常被认为是三次拷贝:
硬盘文件到内核缓冲区
:通过DMA(Direct Memory Access)引擎,将硬盘上的文件数据拷贝到内核的缓冲区中。内核缓冲区到Socket缓冲区
:在内核空间内,将内核缓冲区中的数据拷贝到与socket相关的内核缓冲区中。Socket缓冲区到协议栈
:再次通过DMA引擎,将Socket缓冲区中的数据拷贝到网络协议栈中,准备发送。
1.3.5 sendfile With DMA scatter/gather 拷贝流程
在 Linux 2.4
及之后的版本中,sendfile
函数得到了进一步的优化。引入 SG-DMA
技术,需要 DMA
控制器支持。其实就是对 DMA 拷贝加入了 scatter/gather 操作,它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点来实现数据拷贝,可以多省去一次 CPU 拷贝。
在某些情况下,它可以直接将数据从内核缓冲区
拷贝到协议栈
中,而无需经过Socket缓冲区
,这种优化进一步减少了数据拷贝的次数,提高了数据传输的效率。这被称为两次拷贝:
硬盘文件到内核缓冲区
:与上述相同,通过DMA
引擎将硬盘上的文件数据拷贝到内核的缓冲区中。内核缓冲区到协议栈
:通过DMA引擎
,直接将内核缓冲区中的数据拷贝到网络协议栈中,准备发送
整个拷贝过程,可以用如下流程图来描述
Linux 系统 sendfile With DMA scatter/gather
拷贝流程,从上图可以得出如下结论:
- 数据拷贝次数:2 次 DMA 拷贝,0 次 CPU 拷贝
- CPU 切换次数:2 次用户态和内核态的切换
可以发现,sendfile With DMA scatter/gather
实现的拷贝,其中 2 次数据拷贝都是 DMA
拷贝,全程都没有通过 CPU
来拷贝数据,所有的数据都是通过 DMA 来进行传输的,这就是操作系统真正意义上的零拷贝(Zero-copy
) 技术,相比其他拷贝方式,传输效率最佳。
1.3.6 Linux 系统 splice 零拷贝流程
在 Linux 2.6.17
内核版本中,引入了 splice
系统调用方法,和 sendfile
方法不同的是,splice
不需要硬件支持。
它将数据从磁盘读取到 OS
内核缓冲区后,内核缓冲区和 socket
缓冲区之间建立管道来传输数据,避免了两者之间的 CPU 拷贝操作。
整个拷贝过程,可以用如下流程图来描述
Linux 系统 splice 拷贝流程,从上图可以得出如下结论:
- 数据拷贝次数:2 次 DMA 拷贝,0 次 CPU 拷贝
- CPU 切换次数:2 次用户态和内核态的切换
Linux 系统 splice 方法逻辑拷贝,也是操作系统真正意义上的零拷贝。
1.3.7 哪些地方用到了零拷贝
在 Java
中,以下几个地方使用了零拷贝技术:
NIO(New I/O)
通道:java.nio.channels.FileChannel
提供了 transferTo() 和transferFrom()
方法,可以直接将数据从一个通道传输到另一个通道,例如从文件通道直接传输到Socket
通道,整个过程无需将数据复制到用户空间缓冲区,从而实现了零拷贝。Socket Direct Buffer
:在 JDK 1.4 及更高版本中,Java NIO
支持使用直接缓冲区(DirectBuffer
),这类缓冲区是在系统堆外分配的,可以直接由网卡硬件进行DMA
操作,减少数据在用户态与内核态之间复制次数 ,提高网络数据发送效率。Apache Kafka
或者Netty
等高性能框架:这些框架在底层实现上通常会利用Java NIO
的上述特性来优化数据传输,如 Kafka 生产者和消费者在传输消息时会用到零拷贝技术以提升性能。
使用零拷贝技术可以减少 CPU 拷贝,及减少了上下文的切换带来的性能开销,提高了程序的整体执行效率,它们的区别对比如下表格所示:
拷贝机制 | 系统调用 | CPU拷贝次数 | DMA拷贝次数 | 上下文切换次数 | 特点 |
---|---|---|---|---|---|
传统拷贝方式 | read/write | 2 | 2 | 4 | 消耗系统资源比较多,拷贝数据效率慢 |
mmap | mmap/write | 1 | 2 | 4 | 相比传统方法,少了用户缓冲区与内核缓冲区的数据拷贝,效率更高 |
sendfile | sendfile | 1 | 2 | 2 | 相比 mmap 方式,少了内存文件映射步骤,效率更高 |
sendfile With DMA scatter/gather | sendfile | 0 | 2 | 2 | 需要 DMA 控制器支持,没有cpu拷贝数据环节,真正的零拷贝 |
splice | splice | 0 | 2 | 2 | 没有cpu 拷贝数据环节,真正的零拷贝,编程逻辑复杂 |
1.4 select、poll、epoll
1.4.1 select
1.4.1.1 简介
单个进程就可以同时处理多个网络连接的io
请求(同时阻塞
多个io
操作)。基本原理就是程序呼叫select
,然后整个程序就阻塞状态,这时候,kernel内核
就会轮询检查所有select
负责的文件描述符fd
,当找到其中那个的数据准备好了文件描述符,会返回给select
,select
通知系统调用,将数据从kernel
内核复制到进程缓冲区(用户空间)
下图为select
同时从多个客户端接受数据的过程
虽然服务器进程会被select
阻塞,但是select
会利用内核不断轮询监听
其他客户端的io
操作是否完成
1.4.1.2 select缺点
select
的几大缺点:
- 每次调用
select
,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大 - 同时每次调用
select
都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大 select
支持的文件描述符数量太小,默认是1024
select
返回的是含有整个句柄的数组, 应用程序需要遍历整个数组才能发现哪些句柄发生了事件
1.4.2 poll介绍
poll
是改进版的 select
,它也是通过轮询方式来检测多个文件描述符的状态变化。与 select
不同的是,它使用了一个结构体数组
来保存待检测的文件描述符和事件,并且只需要将该结构体数组传递给内核一次即可。
1.4.2.1 与select差别
poll
的原理与select
非常相似,差别如下:
文件描述符fd
集合的方式不同,poll
使用pollfd
结构而不是select
结构fd_set
结构,所以poll
是链式的,没有最大连接数的限制poll
有一个特点是水平触发,也就是通知程序fd
就绪后,这次没有被处理,那么下次poll
的时候会再次通知同个fd
已经就绪。
1.4.2.2 poll缺点
poll
的几大缺点:
- 每次调用
poll
,都需要把fd集合从用户态拷贝到内核态,这个开销在fd
很多时会很大 - 每次调用
poll
都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
1.4.3 epoll
1.4.3.1 epoll相关函数
epoll
:提供了三个函数:
int epoll_create(int size);
建立一个epoll
对象,并传回它的idint epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
事件注册函数,将需要监听的事件和需要监听的fd交给epoll
对象int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待注册的事件被触发或者timeout
发生
1.4.3.2 epoll的数据结构
epoll
至少需要两个集合:
- 所有
fd
的总集-----> 红黑树 - 就绪
fd
的集合-----> 队列
那么这个总集数据结构选为什么用红黑树存储呢
我们知道,一个fd
,其底层对应一个TCB
。那么也就是说key=fd,val=TCB
,是一个典型的kv型数据结构,对于kv
型数据结构我们可以使用以下三种进行存储:
hash
如果使用hash
进行存储,其优点是查询速度很快,O(1)
但是在我们调用epoll_create()
的时候,hash
底层的数组创建多大合适呢?
如果我们有百万的fd
,那么这个数组越大越好,如果我们仅仅十几个fd需要管理,在创建数组的时候,太大的空间就很浪费。而这个fd我们又不能预先知道有多少,所以hash
是不合适的。b/b+tree
b/b+tree
是多叉树,一个结点可以存多个key
,主要是用于降低层高,用于磁盘索引
的,所以在我们这个内存场景下也是不适合的。- 红黑树
在内存索引
的场景下我们一般使用红黑树
来作为首选的数据结构,首先红黑树的查找速度很快O(log(N))
。其次在调用epoll_create()
的时候,只需要创建一个红黑树树根即可,无需浪费额外的空间。
那么就绪集合数据结构为什么用队列呢,首先就绪集合不是以查找为主的,就绪集合的作用是将里面的元素拷贝给用户进行处理,所以集合里的元素没有优先级,那么就可以采用线性的数据结构,使用队列来存储,先进先出,先就绪的先处理。
这个准备就绪list
链表是怎么维护的呢?
当我们执行
epoll_ctl
时,除了把socket
放到epoll
文件系统里file
对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list
链表里;当一个socket
上有数据到了,内核在把网卡上的数据copy
到内核中后就来把socket
插入到准备就绪链表里了
一颗红黑树,一张准备就绪句柄链表,少量的内核cache
,就帮我们解决了大并发下的socket
处理问题。执行epoll_create
时,创建了红黑树
和就绪链表
,执行epoll_ctl
时,如果增加socket
句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait
时立刻返回准备就绪链表里的数据即可
1.4.3.3 红黑树和就绪队列的关系
红黑树的结点和就绪队列的结点是同一个节点,所谓的加入就绪队列,就是将结点的前后指针联系到一起。所以就绪不是将红黑树结点delete
掉然后加入队列。他们是同一个结点,不需要delete
。
struct epitem{
RB_ENTRY(epitem) rbn;
LIST_ENTRY(epitem) rdlink;
int rdy; //exist in list
int sockfd;
struct epoll_event event;
};
struct eventpoll {
ep_rb_tree rbr;
int rbcnt;
LIST_HEAD( ,epitem) rdlist;
int rdnum;
int waiting;
pthread_mutex_t mtx; //rbtree update
pthread_spinlock_t lock; //rdlist update
pthread_cond_t cond; //block for event
pthread_mutex_t cdmtx; //mutex for cond
};
1.4.3.4 epoll的工作环境
应用程序只能通过三个api接口
来操作epoll
。当一个io准备就绪的时候,epoll
是怎么知道io准备就绪了呢?
是由协议栈将数据解析出来触发回调通知epoll
的。也就是说可以把epoll
的工作环境看出三部分,左边应用程序的api
,中间的epoll
,右边是协议栈的回调(协议栈当然不能直接操作epoll
,中间的vfs在此不是重点,就直接省略vfs这一层了)。
1.4.3.5 协议栈触发回调通知epoll的时机
socket
有两类,一类是监听listenfd
,一类是客户端clientfd
。对于sockfd
而言,我们一般比较关注EPOLLIN
和EPOLLOUT
这两个事件,所以如果是listenfd
,我们通常的做法就是accept
。对于clientfd
来说,如果可读我们就recv,如果可写我们就send。
协议栈将数据解析出来触发回调通知epoll
。epoll
是怎么知道哪个io
就绪了呢?我们从ip头可以解析出源ip,目的ip
和协议
,从tcp
头可以解析出源端口和目的端口,此时五元组就凑齐了。socket fd --- <源IP地址 , 源端口 , 目的IP地址 , 目的端口 , 协议>
一个fd就是一个五元组,知道了fd,我们就能从红黑树中找到对应的结点。
那么这个回调函数做什么事情呢?我们传入fd和具体事件这两个参数,然后做下面两个操作
- 通过fd找到对应的结点
- 把结点加入到就绪队列
协议栈中,在三次握手完成之后,会往全连接队列中添加一个TCB
结点,然后触发一个回调函数,通知到epoll
里面有个EPOLLIN
事件
客户端发送一个数据包,协议栈接收后回复ACK
,之后触发一个回调函数,通知到epoll
里面有个EPOLLIN
事件
每个连接的TCB
里面都有一个sendbuf
,在对端接收到数据并返回ACK
以后,sendbuf
就可以将这部分确认接收的数据清空,此时sendbuf
里面就有剩余空间,此时触发一个回调函数,通知到epoll
里面有个EPOLLOUT
事件
当对端发送close
,在接收到fin后回复ACK
,此时会调用回调函数,通知到epoll
有个EPOLLIN
事件
当接收到rst
标志位的时候,回复ack之后也会触发回调函数,通知epoll
有一个EPOLLERR
事件
通知的时机总结,有5个通知的地方:
- 三次握手完成之后
- 接收数据回复
ACK
之后 - 发送数据收到
ACK
之后 - 接收
FIN
回复ACK
之后 - 接收
RST
回复ACK
之后
1.4.3.6 从回调机制看epoll 与 select/poll的区别
由于select
和poll
没有本质的区别,所以下面统一称为poll。
// poll跟select类似, 其实poll就是把select三个文件描述符集合变成一个集合了。
int select(int nfds, fd_set * readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
我们看到每次调用poll
,都需要把总集fds
拷贝到内核态,检测完之后,再有内核态拷贝的用户态,这就是poll
。而epoll
不是这样,epoll
只要有新的io
就调用epoll_ctl()
加入到红黑树里面,一旦有触发就用epoll_wait()
将有事件的结点带出来,可以看到他们的第一个区别:poll
总是拷贝总集,如果有100w个fd,只有两三个就绪呢?这会造成大量资源浪费;而epoll
总是将需要拷贝的东西进行拷贝,没有浪费。
第二个区别:我们从上面知道了epoll
的事件都是由协议栈进行回调然后加入到就绪队列的,而poll
呢?内核如何检测poll
的io是否就绪?只能通过遍历的方法判断,所以poll
检测io通过遍历的方法也是比较慢的。
所以两者的区别:select/poll
需要把总集copy
到内核,而epoll
不用
实现原理上面,select/poll
需要循环遍历总集是否有就绪,而epoll
是那个结点就绪了就加入就绪队列里面。
注意
:poll
不一定就比epoll
慢,在io量小的情况下,poll
是比epoll
快的,而在大io量下,epoll
绝对是有主导地位的。至于有多少个io才算多,其实也很难说,一般认为500或者1024为分界点。
1.4.3.7 epoll线程安全如何加锁
3个api做什么事情:
epoll_create()
:创建红黑树的根节点epoll_ctl()
:add,del,mod
增加、删除、修改结点epoll_wait()
:把就绪队列的结点copy
到用户态放到events里面,跟recv函数很像
分析加锁
如果有3个线程同时操作
epoll
,有哪些地方需要加锁?我们用户层面一共就只有3个api可以使用:
如果同时调用epoll_create()
,那就是创建三颗红黑树,没有涉及到资源竞争,没有关系。
如果同时调用
epoll_ctl()
,对同一颗红黑树进行,增删改,这就涉及到资源竞争需要加锁了,此时我们对整棵树进行加锁。
如果同时调用epoll_wait()
,其操作的是就绪队列,所以需要对就绪队列进行加锁。
我们要扣住epoll
的工作环境,在应用程序调用epoll_ctl()
,协议栈会不会有回调操作红黑树结点?调用epoll_wait() copy
出来的时候,协议栈会不会操作操作红黑树结点加入就绪队列?
综上所述:
epoll_ctl()
:对红黑树加锁epoll_wait()
:对就绪队列加锁回调函数()
:对红黑树加锁,对就绪队列加锁
那么红黑树加什么锁,就绪队列加什么锁呢?
对于红黑树这种节点比较多的时候,采用互斥锁
来加锁。
就绪队列就跟生产者消费者一样,结点是从协议栈回调函数来生产的,消费是epoll_wait()
来消费。那么对于队列而言,用自旋锁(对于队列而言,插入删除比较简单,cpu
自旋等待比让出的成本更低,所以用自旋锁
)
1.4.3.8 ET与LT如何实现
ET
边沿触发,只触发一次,LT
水平触发,如果没有读完就一直触发
代码如何实现ET和LT
的效果呢?水平触发和边沿触发不是故意设计出来的,这是自然而然,水到渠成的功能。水平触发和边沿触发代码只需要改一点点就能实现。从协议栈检测到接收数据,就调用一次回调,这就是ET
,接收到数据,调用一次回调。而LT
水平触发,检测到recvbuf
里面有数据就调用回调。所以ET和LT
就是在使用回调的次数上面的差异。
那么具体如何实现呢?协议栈流程里面触发回调,是天然的符合ET
只触发一次的。那么如果是LT,在recv
之后,如果缓冲区还有数据那么加入到就绪队列。那么如果是LT
,在send
之后,如果缓冲区还有空间那么加入到就绪队列。那么这样就能实现LT了。
ssize_t nty_send(int sockid, const char *buf, size_t len)
...
if (snd->snd_wnd > 0){
if ((socket->epoll & NTY_EPOLLOUT) && !(socket->epoll &NTY_EPOLLET)){
nty_epoll_add_event(tcp->ep,USR_SHADOW_EVENT_QUEUE,socket,NTY_EPOLLOUT);
}
}
...
1.4.3.9 epoll优点
epoll
解决的问题:
epoll
没有fd
数量限制
epoll
没有这个限制,我们知道每个epoll
监听一个fd
,所以最大数量与能打开的fd数量有关,一个g的内存的机器上,能打开10万个左右
cat /proc/sys/fs/file-max
可以查看文件数量epoll
不需要每次都从用户空间将fd
复制到内核kernel
epoll
在用epoll_ctl
函数进行事件注册的时候,已经将fd
复制到内核中,所以不需要每次都重新复制一次select
和poll
都是主动轮询机制,需要遍历每一个fd
;
epoll
是被动触发
方式,给fd
注册了相应事件的时候,我们为每一个fd指定了一个回调函数,当数据准备好之后,就会把就绪的fd
加入一个就绪的队列中,epoll_wait
的工作方式实际上就是在这个就绪队列中查看有没有就绪的fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。- 虽然
epoll
需要查看是否有fd就绪,但是epoll
之所以是被动触发,就在于它只要去查找就绪队列中有没有fd,就绪的fd
是主动加到队列中,epoll
不需要一个个轮询确认。
换一句话讲,就是select
和poll
只能通知有fd已经就绪了,但不能知道究竟是哪个fd就绪,所以select
和poll
就要去主动轮询一遍找到就绪的fd
。而epoll
则是不但可以知道有fd
可以就绪,而且还具体可以知道就绪fd的编号,所以直接找到就可以,不用轮询。 - 我们在调用
epoll_create
时,内核除了帮我们在epoll
文件系统里建了个file
结点,在内核cache
里建了个红黑树
用于存储以后epoll_ctl
传来的socket
外,还会再建立一个list
链表,用于存储准备就绪的事件,当epoll_wait
调用时,仅仅观察这个list
链表里有没有数据即可。有数据就返回,没有数据就sleep
,等到timeout
时间到后即使链表没数据也返回。所以,epoll_wait
非常高效