Linux之内核,零拷贝,select,poll,epoll讲解

1 select、poll、epoll

学习此篇文章可以先学习下 内核与用户空间 相关信息

1.1 引言

操作系统在处理io的时候,主要有两个阶段:

  • 等待数据传到io设备
  • io设备将数据复制到user space

我们一般将上述过程简化理解为:

  • 等到数据传到kernel内核space
  • kernel内核区域将数据复制到user space(理解为进程或者线程的缓冲区)

selectpollepoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但selectpollepoll本质上都是同步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),在这个时期socketblocking(阻塞的)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 引言

在操作系统中,使用传统的方式,数据需要经历几次拷贝,还要经历用户态/内核态切换

  1. 从磁盘复制数据到内核态内存;
  2. 从内核态内存复制到用户态内存;
  3. 然后从用户态内存复制到网络驱动的内核态内存;
  4. 最后是从网络驱动的内核态内存复制到网卡中进行传输。

简略图:
在这里插入图片描述
精细图:
在这里插入图片描述

DMA(Direct Memory Access,直接内存访问)技术,绕过 CPU,直接在内存和外设之间进行数据传输。这样可以减少 CPU 的参与,提高数据传输的效率。

1.3.2 什么是零拷贝

零拷贝技术可以利用 Linux 下的 MMapsendFile 等手段来实现,使得数据能够直接从磁盘映射到内核缓冲区,然后通过 DMA 传输到网卡缓存,整个过程中 CPU 只负责管理和调度,而无需执行实际的数据复制指令。
通过零拷贝的方式,减少用户态内核态的上下文切换和内存拷贝的次数,用来提升I/O的性能。零拷贝比较常见的实现方式是mmap,这种机制在Java中是通过MappedByteBuffer实现的。

1.3.3 MMAP

MMap(Memory Map)Linux 操作系统中提供的一种将文件映射到进程地址空间的一种机制,通过 MMap 进程可以像访问内存一样访问文件,而无需显式的复制操作。

简略图:
在这里插入图片描述
精细图:
在这里插入图片描述
传统的 IO 需要四次拷贝和四次上下文(用户态和内核态)切换,而 MMap 只需要三次拷贝和四次上下文切换,从而能够提升程序整体的执行效率,并且节省了程序的内存空间

1.3.4 sendFile

Linux 操作系统中 sendFile() 是一个系统调用函数,用于高效地将文件数据从内核空间直接传输到网络套接字(Socket)上,从而实现零拷贝技术。这个函数的主要目的是减少 CPU 上下文切换以及内存复制操作,提高文件传输性能。
sendfile,有两个参数一个写出io,一个读入io
在之前是先读取文件到用户空间,再写到内核中去,有了sendfile后,用这一个命令就可以了,不用读取写入
使用 sendFile() 可以把 IO 执行流程优化成以下执行步骤:
简略图:
在这里插入图片描述
精细图:
在这里插入图片描述

1.3.5 哪些地方用到了零拷贝

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 拷贝/次数上下文切换/次数
传统 IO224
MMap124
sendFile()122

1.4 select、poll、epoll

1.4.1 select

1.4.1.1 简介

单个进程就可以同时处理多个网络连接的io请求(同时阻塞多个io操作)。基本原理就是程序呼叫select,然后整个程序就阻塞状态,这时候,kernel内核就会轮询检查所有select负责的文件描述符fd,当找到其中那个的数据准备好了文件描述符,会返回给selectselect通知系统调用,将数据从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 对象,并传回它的id
  • int 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而言,我们一般比较关注EPOLLINEPOLLOUT这两个事件,所以如果是listenfd,我们通常的做法就是accept。对于clientfd来说,如果可读我们就recv,如果可写我们就send。
协议栈将数据解析出来触发回调通知epollepoll是怎么知道哪个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的区别

由于selectpoll没有本质的区别,所以下面统一称为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复制到内核中,所以不需要每次都重新复制一次
  • selectpoll 都是主动轮询机制,需要遍历每一个fd
    epoll被动触发方式,给fd注册了相应事件的时候,我们为每一个fd指定了一个回调函数,当数据准备好之后,就会把就绪的fd加入一个就绪的队列中,epoll_wait的工作方式实际上就是在这个就绪队列中查看有没有就绪的fd,如果有,就唤醒就绪队列上的等待者,然后调用回调函数。
  • 虽然epoll 需要查看是否有fd就绪,但是epoll之所以是被动触发,就在于它只要去查找就绪队列中有没有fd,就绪的fd是主动加到队列中,epoll不需要一个个轮询确认。
    换一句话讲,就是selectpoll只能通知有fd已经就绪了,但不能知道究竟是哪个fd就绪,所以selectpoll就要去主动轮询一遍找到就绪的fd。而epoll则是不但可以知道有fd可以就绪,而且还具体可以知道就绪fd的编号,所以直接找到就可以,不用轮询。
  • 我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效
  • 3
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值