当前位置: 首页 > 科技观察

epoll原理从头过一遍,收获颇丰!

时间:2023-03-19 14:29:59 科技观察

epoll是一个非常古老的知识点,也是后端工程师的经典必修课。这类知识的特点是研究的人多,因此研究的趋势会越来越深。当然,分享的人很多,而且因为分享者的水平参差不齐,所以也有很多误会。图片来自Pexels。今天再次分享epoll。我绝对不会做桌子。比较差异太无聊了。我会从线程阻塞原理、中断优化、网卡的数据处理过程开始,深入介绍epoll背后的原理,最后diss一些流行的观点。相信不管你是否已经熟悉epoll,这篇文章都会对你有所帮助。在正文开始之前,先问大家几个问题:①epoll的性能有多高?很多文章介绍epoll可以轻松处理几十万个连接。然而,传统的IO只能处理数百个连接。是不是说epoll的性能是传统IO的千倍?②很多文章把网络IO分为阻塞、非阻塞、同步、异步。并说:非阻塞性能优于阻塞性能,异步性能优于同步性能。如果阻塞导致性能低下,那为什么传统IO会阻塞呢?epoll需要阻塞吗?Java的NIO和AIO都是由epoll实现的。您如何理解同步和异步之间的区别?③都是IO复用使用。既然生鱼、何生亮,为什么还有select、poll、epoll?为什么epoll比select更高效?PS:本文包含三个部分:epoll的初识,epoll背后的原理,diss链接。本文重点介绍原理,建议读者关注“为什么”。Linux下进程和线程的区别其实并没有那么大,尤其是在讨论原理和性能问题的时候,所以本文混用了“进程”和“线程”这两个名词。认识epollepoll是Linux内核的一种可扩展的I/O事件通知机制,其最大的特点就是性能优异。下图是libevent(一个知名的异步事件处理软件库)对select、poll、epoll、kqueue等几种I/O多路复用技术的性能测试。很多文章在描述epoll性能的时候都会参考这个benchmark测试,但是很少有文章能把测试结果说清楚。这是一个限制为100个活动连接的基准测试,每个连接发生1000次读取和写入。纵轴是请求的响应时间,横轴是持有的套接字句柄数。随着句柄数的增加,epoll和kqueue的响应时间几乎没有变化,但是poll和select的响应时间增加了很多。可以看出epoll的性能是非常高的,而且随着监控的文件描述符数量的增加,epoll的优势更加明显。不过,这里的100个连接限制很重要。epoll在处理大量网络连接时,只有在活跃连接很少的情况下才能表现良好。换句话说,epoll在处理大量非活动连接时表现良好。如果有15,000个套接字处于活动状态,则epoll和select没有太大区别。为什么epoll的高性能会有这样的局限呢?问题似乎越来越多,似乎还需要更深入的研究。epollBlocking背后的原理①为什么要阻塞我们以网卡接收数据为例,回顾一下我之前分享的网卡接收数据的过程。为了便于理解,我尽量简化技术细节。接收数据的过程可以分为四个步骤:NIC(网卡)接收数据,通过DMA写入内存(RingBuffer和sk_buff)。NIC发出中断请求(IRQ),告诉内核有新数据到来。Linux内核响应中断,系统切换到内核态,处理InterruptHandler,从RingBuffer中取出一个Packet,处理协议栈,填充Socket交给用户进程。系统切换到用户态,用户进程处理数据内容。网卡何时收到数据取决于发送方和传输路径。这种延迟通常非常高,在毫秒(ms)级别。该应用程序以纳秒(ns)级别处理数据。也就是说,在整个过程中,内核态等待数据,处理协议栈是一个比较慢的过程。这么长时间,用户态进程无事可做,所以使用“阻塞(suspend)”。②阻塞不占用CPU阻塞是进程调度的一个关键部分,指的是进程在等待事件发生之前的等待状态。请参见下表。在Linux中,大致有7种进程状态(更多的状态在include/linux/sched.h):从描述中可以发现“runnablestate”会占用CPU资源。另外,进程的创建和销毁也需要CPU资源(cores)。重点是当一个进程“阻塞/挂起”时,它并不占用CPU资源。从另一个角度来看。Linux为了支持多任务,实现了进程调度(CPU时间片的调度)的功能。而这个时间片的切换只会在处于“可运行状态”的进程之间进行。因此,“阻塞/挂起”进程不占用CPU资源。另外一个知识点,为了方便时间片的调度,所有处于“runnablestate”状态的进程都会组成一个队列,称为“workqueue”。③被阻塞的恢复内核当然可以很容易地修改一个进程的状态。问题是在网络IO中,内核应该修改该进程的状态。socket结构包含两个重要数据:进程ID和端口号。进程ID存储了执行connect、send、read函数并被挂起的进程。在socket创建之初,端口号是确定的,操作系统会维护一个从端口号到socket的数据结构。网卡接收数据时,数据中必须携带端口号,内核才能找到对应的socket,并从中获取“挂”进程的ID。修改进程状态为“可运行状态”(加入工作队列)。至此,内核代码执行完毕,控制权交还给用户态。通过正常的“CPU时间片的调度”,用户进程可以处理数据。④进程模型上面描述的整个过程,基本上就是BIO(blockingIO)的基本原理。用户进程独立处理自己的业务,这实际上是一种符合进程模型的处理方式。上下文切换的优化在上述过程中,有两个地方会导致频繁的上下文切换,效率可能会很低:如果频繁接收数据包,网卡可能会频繁发出中断请求(IRQ)。CPU可能在用户态,可能在内核态,也可能还在处理最后一段数据的协议栈。但无论如何,CPU必须尽快响应中断。这样做实际上是非常低效的,会造成大量的上下文切换,还可能导致用户进程长时间无法获取数据。(即使是多核,也不是每次都处理协议栈,自然不能交给用户进程)每个Packet对应一个socket,每个socket对应一个用户态进程。这些用户态进程转为“可运行状态”,必然会引起进程间的上下文切换。①网卡驱动的NAPI机制在NIC上,解决频繁IRQ的技术称为NewAPI(NAPI)。原理其实很简单。InterruptHandler分为两部分:函数名napi_schedule,快速响应IRQ,只记录必要的信息,适时发送软中断softirq。函数名netrxaction,在另一个进程中执行,具体响应napi_schedule发送的软中断,批量处理RingBuffer中的数据。因此,使用NAPI驱动,接收数据的过程可以简化描述为:网卡接收数据,通过DMA方式写入内存(RingBuffer和sk_buff)。NIC发出中断请求(IRQ),告诉内核有新数据到来。驱动程序的napi_schedule函数响应IRQ并在适当的时候发出软中断(NET_RX_SOFTIRQ)。驱动的net_rx_action函数响应软中断,从RingBuffer中批量取出接收到的数据。并处理协议栈,填充Socket交给用户进程。系统切换到用户态,多个用户进程切换到“可运行态”,根据CPU时间片调度处理数据内容。一句话就是:等接收到一批数据,再批量处理数据。②单线程IO多路复用内核优化了称为“IO多路复用”的“进程间上下文切换”技术,其思路与NAPI非常接近。每个socket不再阻塞对其读写的进程,而是使用专用线程批量处理用户态数据,从而减少了线程间的上下文切换。select作为IO多路复用的一种实现,原理也很简单。所有socket统一保存执行select函数的(监控进程)的进程ID。任何接收数据的socket都会唤醒“监听进程”。内核只需要告诉“监控进程”那些sockets已经准备好了,监控进程就可以批量处理了。IO多路复用的演变①比较epoll和selectselect,poll和epoll都是“IO多路复用”,那么为什么会有性能差距呢?限于篇幅,这里我们只简单比较一下select和epoll的基本原理。对于内核来说,可能有很多套接字在同时处理,也可能有多个监听进程。所以每次监控进程“批量数据”时,都需要告诉内核它“关心套接字”。当内核唤醒监控进程时,它可以将“关注套接字”中的就绪套接字传递给监控进程。也就是说,在执行系统调用select或epoll_create时,入参为“关注套接字”,输出参数为“就绪套接字”。select和epoll的区别是:select(一个O(n)搜索):每次将用户空间分配的一个fd_set传递给内核,代表“socketofconcern”。它的结构(相当于bitset)仅限于1024个套接字。每次socket状态变化,内核使用fd_set查询O(1),就可以知道监控进程是否关心这个socket。内核重用fd_set作为一个输出参数返回给监控进程(所以每个select输入参数都需要重新设置)。但是,监控进程必须遍历套接字数组O(n)才能知道哪些套接字就绪。epoll(所有O(1)查找):每次将实例句柄传递给内核。这个句柄就是内核中分配的红黑树rbr+双向链表rdllist。只要句柄保持不变,内核就可以重用上次计算的结果。每次socket状态发生变化时,内核都可以从rbr中快速查询O(1)的时间来监控进程是否关心这个socket。同时也修改了rdllist,所以rdllist实际上是一个“readysockets”的缓存。内核将部分或全部rdllist(LT和ET)复制到特殊的epoll_event作为输出参数。因此,监控进程可以直接对数据进行一项一项的处理,无需遍历和确认。选择示例代码:epoll示例代码:另外epoll_create的底层实现是不是红黑树其实并不重要(可以换成hashtable)。重要的是efd是一个指针,它的数据结构可以透明地修改为任何其他数据结构。②API发布时间线另外,再看看网络IO中各个API的发布时间线:1983年,socket在Unix(4.2BSD)发布1983年,select在Unix(4.2BSD)发布1994年,Linux1.0,已经支持的Socket和select于1997年发布,poll于2002年在Linux2.1.23上发布,epoll在Linux2.5.44上发布。可以得出两个有趣的结论:socket和select是同时发布的。这说明select并不是用来代替传统IO的。这是针对不同场景的两种不同用法(或模型)。select、poll、epoll,这三个“IO多路复用API”相继发布。由此可见,它们是IO多路复用的3个进化版本。由于API设计缺陷,不改变API是不可能优化内部逻辑的。所以用poll代替select,再用epoll代替poll。总结我们用了三章的篇幅解释了epoll背后的原理,现在用三句话总结一下:基于数据发送和接收的基本原理,系统使用阻塞来提高CPU利用率。为了优化线上的上下文切换,设计了“IO多路复用”(和NAPI)。为了优化“内核与监控进程的交互”,设计了三个版本的API(select、poll、epoll)。在Diss环节讲完“epoll背后的原理”,你已经可以回答前几个问题了。这已经是一篇完整的文章了,很多人劝我删掉下面的diss链接。我的观点是:学习是一个研究+理解的过程。以上是研究,下面说说我个人的“理解”。欢迎指正。关于IO模型的分类,阻塞、非阻塞、同步、异步的分类自然是合理的。但从操作系统的角度来看,“这种分类容易引起误解,不好”。①阻塞与非阻塞Linux下所有的IO模型都是阻塞的,这是由发送和接收数据的基本原理造成的。阻塞用户线程是一种有效的方法。你当然可以写一个程序,将socket设置为非阻塞模式,不使用监视器,依靠死循环完成一次IO操作。但是这样做的效率太低了,完全没有意义。也就是说,阻塞不是问题,运行是问题,运行很耗CPU。IO多路复用不会减少阻塞,它会减少运行。上下文切换是问题所在。IO多路复用通过减少运行进程的数量有效地减少了上下文切换。②同步与异步Linux下所有的IO模型都是同步的。BIO是同步的,select是同步的,poll是同步的,epoll还是同步的。Java提供的AIO可以称之为“异步”。但是JVM运行在用户态,Linux不提供任何异步支持。因此,JVM提供的异步支持与你自己的“异步”框架没有本质区别(你可以使用BIO将其封装成一个异步框架)。所谓“同步”和“异步”,只是两种事件派发器(eventdispatcher)或者两种设计模式(Reactor和Proactor)。两者都运行在用户模式下,这两种设计模式之间的性能差异有多大?Reactor对应Java的NIO,是由Channel、Buffer、Selector组成的核心API。Proactor对应java的AIO,是Async组件和Future或Callback组成的核心API。③我的分类我认为IO模型只能分为两类:更符合程序员理解和使用的进程模型。一种更符合操作系统处理逻辑的IO多路复用模型。对于“IO多路复用”的事件分发,分为两类:Reactor和Proactor。mmapepoll是否使用mmap?答:不!这是谣言。其实很容易证明,用epoll写个demo。Strace会说清楚。作者:冯志明简介:2019年至今,负责搜索算法相关工作。擅长处理复杂的业务系统,对底层技术有浓厚的兴趣。编辑:陶佳龙来源:转载自公众号去哪儿科技沙龙(ID:QunarTL)