深入讲解Netty那些事儿之从内核角度看IO模型(上)

news2024/11/27 8:27:29

我们都知道Netty是一个高性能异步事件驱动的网络框架。

它的设计异常优雅简洁,扩展性高,稳定性强。拥有非常详细完整的用户文档。

同时内置了很多非常有用的模块基本上做到了开箱即用,用户只需要编写短短几行代码,就可以快速构建出一个具有高吞吐,低延时,更少的资源消耗,高性能(非必要的内存拷贝最小化)等特征的高并发网络应用程序。

本文我们来探讨下支持Netty具有高吞吐,低延时特征的基石----netty的网络IO模型。

由Netty的网络IO模型开始,我们来正式揭开本系列Netty源码解析的序幕:

网络包接收流程

网络包收发过程

  • 网络数据帧通过网络传输到达网卡时,网卡会将网络数据帧通过DMA的方式放到环形缓冲区RingBuffer中。

RingBuffer是网卡在启动的时候分配和初始化环形缓冲队列。当RingBuffer满的时候,新来的数据包就会被丢弃。我们可以通过ifconfig命令查看网卡收发数据包的情况。其中overruns数据项表示当RingBuffer满时,被丢弃的数据包。如果发现出现丢包情况,可以通过ethtool命令来增大RingBuffer长度。

  • DMA操作完成时,网卡会向CPU发起一个硬中断,告诉CPU有网络数据到达。CPU调用网卡驱动注册的硬中断响应程序。网卡硬中断响应程序会为网络数据帧创建内核数据结构sk_buffer,并将网络数据帧拷贝sk_buffer中。然后发起软中断请求,通知内核有新的网络数据帧到达。

sk_buff缓冲区,是一个维护网络帧结构的双向链表,链表中的每一个元素都是一个网络帧。虽然 TCP/IP 协议栈分了好几层,但上下不同层之间的传递,实际上只需要操作这个数据结构中的指针,而无需进行数据复制

  • 内核线程ksoftirqd发现有软中断请求到来,随后调用网卡驱动注册的poll函数poll函数sk_buffer中的网络数据包送到内核协议栈中注册的ip_rcv函数中。

每个CPU会绑定一个ksoftirqd内核线程专门用来处理软中断响应。2个 CPU 时,就会有 ksoftirqd/0 和 ksoftirqd/1这两个内核线程。

这里有个事情需要注意下: 网卡接收到数据后,当DMA拷贝完成时,向CPU发出硬中断,这时哪个CPU上响应了这个硬中断,那么在网卡硬中断响应程序中发出的软中断请求也会在这个CPU绑定的ksoftirqd线程中响应。所以如果发现Linux软中断,CPU消耗都集中在一个核上的话,那么就需要调整硬中断的CPU亲和性,来将硬中断打散不通的CPU核上去。

  • ip_rcv函数中也就是上图中的网络层取出数据包的IP头,判断该数据包下一跳的走向,如果数据包是发送给本机的,则取出传输层的协议类型(TCP或者UDP),并去掉数据包的IP头,将数据包交给上图中得传输层处理。

传输层的处理函数:TCP协议对应内核协议栈中注册的tcp_rcv函数UDP协议对应内核协议栈中注册的udp_rcv函数

  • 当我们采用的是TCP协议时,数据包到达传输层时,会在内核协议栈中的tcp_rcv函数处理,在tcp_rcv函数中去掉TCP头,根据四元组(源IP,源端口,目的IP,目的端口)查找对应的Socket,如果找到对应的Socket则将网络数据包中的传输数据拷贝到Socket中的接收缓冲区中。如果没有找到,则发送一个目标不可达icmp包。

  • 内核在接收网络数据包时所做的工作我们就介绍完了,现在我们把视角放到应用层,当我们程序通过系统调用read读取Socket接收缓冲区中的数据时,如果接收缓冲区中没有数据,那么应用程序就会在系统调用上阻塞,直到Socket接收缓冲区有数据,然后CPU内核空间(Socket接收缓冲区)的数据拷贝用户空间,最后系统调用read返回,应用程序读取数据。

  资料直通车:Linux内核源码技术学习路线+视频教程内核源码

学习直通车:Linux内核源码内存调优文件系统进程管理设备驱动/网络协议栈

性能开销

从内核处理网络数据包接收的整个过程来看,内核帮我们做了非常之多的工作,最终我们的应用程序才能读取到网络数据。

随着而来的也带来了很多的性能开销,结合前面介绍的网络数据包接收过程我们来看下网络数据包接收的过程中都有哪些性能开销:

  • 应用程序通过系统调用用户态转为内核态的开销以及系统调用返回时从内核态转为用户态的开销。

  • 网络数据从内核空间通过CPU拷贝用户空间的开销。

  • 内核线程ksoftirqd响应软中断的开销。

  • CPU响应硬中断的开销。

  • DMA拷贝网络数据包到内存中的开销。

网络包发送流程

网络包发送过程

  • 当我们在应用程序中调用send系统调用发送数据时,由于是系统调用所以线程会发生一次用户态到内核态的转换,在内核中首先根据fd将真正的Socket找出,这个Socket对象中记录着各种协议栈的函数地址,然后构造struct msghdr对象,将用户需要发送的数据全部封装在这个struct msghdr结构体中。

  • 调用内核协议栈函数inet_sendmsg,发送流程进入内核协议栈处理。在进入到内核协议栈之后,内核会找到Socket上的具体协议的发送函数。

比如:我们使用的是TCP协议,对应的TCP协议发送函数是tcp_sendmsg,如果是UDP协议的话,对应的发送函数为udp_sendmsg

  • TCP协议的发送函数tcp_sendmsg中,创建内核数据结构sk_buffer,将struct msghdr结构体中的发送数据拷贝sk_buffer中。调用tcp_write_queue_tail函数获取Socket发送队列中的队尾元素,将新创建的sk_buffer添加到Socket发送队列的尾部。

Socket的发送队列是由sk_buffer组成的一个双向链表

发送流程走到这里,用户要发送的数据总算是从用户空间拷贝到了内核中,这时虽然发送数据已经拷贝到了内核Socket中的发送队列中,但并不代表内核会开始发送,因为TCP协议流量控制拥塞控制,用户要发送的数据包并不一定会立马被发送出去,需要符合TCP协议的发送条件。如果没有达到发送条件,那么本次send系统调用就会直接返回。

  • 如果符合发送条件,则开始调用tcp_write_xmit内核函数。在这个函数中,会循环获取Socket发送队列中待发送的sk_buffer,然后进行拥塞控制以及滑动窗口的管理

  • 将从Socket发送队列中获取到的sk_buffer重新拷贝一份,设置sk_buffer副本中的TCP HEADER

sk_buffer 内部其实包含了网络协议中所有的 header。在设置 TCP HEADER的时候,只是把指针指向 sk_buffer的合适位置。后面再设置 IP HEADER的时候,在把指针移动一下就行,避免频繁的内存申请和拷贝,效率很高。

sk_buffer

为什么不直接使用Socket发送队列中的sk_buffer而是需要拷贝一份呢?因为TCP协议是支持丢包重传的,在没有收到对端的ACK之前,这个sk_buffer是不能删除的。内核每次调用网卡发送数据的时候,实际上传递的是sk_buffer拷贝副本,当网卡把数据发送出去后,sk_buffer拷贝副本会被释放。当收到对端的ACK之后,Socket发送队列中的sk_buffer才会被真正删除。

  • 当设置完TCP头后,内核协议栈传输层的事情就做完了,下面通过调用ip_queue_xmit内核函数,正式来到内核协议栈网络层的处理。

    通过route命令可以查看本机路由配置。

    如果你使用 iptables配置了一些规则,那么这里将检测是否命中规则。如果你设置了非常复杂的 netfilter 规则,在这个函数里将会导致你的线程 CPU 开销极大增加

    • sk_buffer中的指针移动到IP头位置上,设置IP头

    • 执行netfilters过滤。过滤通过之后,如果数据大于 MTU的话,则执行分片。

    • 检查Socket中是否有缓存路由表,如果没有的话,则查找路由项,并缓存到Socket中。接着在把路由表设置到sk_buffer中。

  • 内核协议栈网络层的事情处理完后,现在发送流程进入了到了邻居子系统邻居子系统位于内核协议栈中的网络层网络接口层之间,用于发送ARP请求获取MAC地址,然后将sk_buffer中的指针移动到MAC头位置,填充MAC头

  • 经过邻居子系统的处理,现在sk_buffer中已经封装了一个完整的数据帧,随后内核将sk_buffer交给网络设备子系统进行处理。网络设备子系统主要做以下几项事情:

    • 选择发送队列(RingBuffer)。因为网卡拥有多个发送队列,所以在发送前需要选择一个发送队列。

    • sk_buffer添加到发送队列中。

    • 循环从发送队列(RingBuffer)中取出sk_buffer,调用内核函数sch_direct_xmit发送数据,其中会调用网卡驱动程序来发送数据。

以上过程全部是用户线程的内核态在执行,占用的CPU时间是系统态时间(sy),当分配给用户线程的CPU quota用完的时候,会触发NET_TX_SOFTIRQ类型的软中断,内核线程ksoftirqd会响应这个软中断,并执行NET_TX_SOFTIRQ类型的软中断注册的回调函数net_tx_action,在回调函数中会执行到驱动程序函数 dev_hard_start_xmit来发送数据。

注意:当触发NET_TX_SOFTIRQ软中断来发送数据时,后边消耗的 CPU 就都显示在 si这里了,不会消耗用户进程的系统态时间(sy)了。

从这里可以看到网络包的发送过程和接受过程是不同的,在介绍网络包的接受过程时,我们提到是通过触发NET_RX_SOFTIRQ类型的软中断在内核线程ksoftirqd中执行内核网络协议栈接受数据。而在网络数据包的发送过程中是用户线程的内核态在执行内核网络协议栈,只有当线程的CPU quota用尽时,才触发NET_TX_SOFTIRQ软中断来发送数据。

在整个网络包的发送和接受过程中,NET_TX_SOFTIRQ类型的软中断只会在发送网络包时并且当用户线程的CPU quota用尽时,才会触发。剩下的接受过程中触发的软中断类型以及发送完数据触发的软中断类型均为NET_RX_SOFTIRQ。所以这就是你在服务器上查看 /proc/softirqs,一般 NET_RX都要比 NET_TX大很多的的原因。

  • 现在发送流程终于到了网卡真实发送数据的阶段,前边我们讲到无论是用户线程的内核态还是触发NET_TX_SOFTIRQ类型的软中断在发送数据的时候最终会调用到网卡的驱动程序函数dev_hard_start_xmit来发送数据。在网卡驱动程序函数dev_hard_start_xmit中会将sk_buffer映射到网卡可访问的内存 DMA 区域,最终网卡驱动程序通过DMA的方式将数据帧通过物理网卡发送出去。

  • 当数据发送完毕后,还有最后一项重要的工作,就是清理工作。数据发送完毕后,网卡设备会向CPU发送一个硬中断,CPU调用网卡驱动程序注册的硬中断响应程序,在硬中断响应中触发NET_RX_SOFTIRQ类型的软中断,在软中断的回调函数igb_poll中清理释放 sk_buffer,清理网卡发送队列(RingBuffer),解除 DMA 映射。

无论硬中断是因为有数据要接收,还是说发送完成通知,从硬中断触发的软中断都是 NET_RX_SOFTIRQ

这里释放清理的只是sk_buffer的副本,真正的sk_buffer现在还是存放在Socket的发送队列中。前面在传输层处理的时候我们提到过,因为传输层需要保证可靠性,所以 sk_buffer其实还没有删除。它得等收到对方的 ACK 之后才会真正删除。

性能开销

前边我们提到了在网络包接收过程中涉及到的性能开销,现在介绍完了网络包的发送过程,我们来看下在数据包发送过程中的性能开销:

  • 和接收数据一样,应用程序在调用系统调用send的时候会从用户态转为内核态以及发送完数据后,系统调用返回时从内核态转为用户态的开销。

  • 用户线程内核态CPU quota用尽时触发NET_TX_SOFTIRQ类型软中断,内核响应软中断的开销。

  • 网卡发送完数据,向CPU发送硬中断,CPU响应硬中断的开销。以及在硬中断中发送NET_RX_SOFTIRQ软中断执行具体的内存清理动作。内核响应软中断的开销。

  • 内存拷贝的开销。我们来回顾下在数据包发送的过程中都发生了哪些内存拷贝:

    • 在内核协议栈的传输层中,TCP协议对应的发送函数tcp_sendmsg会申请sk_buffer,将用户要发送的数据拷贝sk_buffer中。

    • 在发送流程从传输层到网络层的时候,会拷贝一个sk_buffer副本出来,将这个sk_buffer副本向下传递。原始sk_buffer保留在Socket发送队列中,等待网络对端ACK,对端ACK后删除Socket发送队列中的sk_buffer。对端没有发送ACK,则重新从Socket发送队列中发送,实现TCP协议的可靠传输。

    • 在网络层,如果发现要发送的数据大于MTU,则会进行分片操作,申请额外的sk_buffer,并将原来的sk_buffer拷贝到多个小的sk_buffer中。

再谈(阻塞,非阻塞)与(同步,异步)

在我们聊完网络数据的接收和发送过程后,我们来谈下IO中特别容易混淆的概念:阻塞与同步非阻塞与异步

网上各种博文还有各种书籍中有大量的关于这两个概念的解释,但是笔者觉得还是不够形象化,只是对概念的生硬解释,如果硬套概念的话,其实感觉阻塞与同步非阻塞与异步还是没啥区别,时间长了,还是比较模糊容易混淆。

所以笔者在这里尝试换一种更加形象化,更加容易理解记忆的方式来清晰地解释下什么是阻塞与非阻塞,什么是同步与异步

经过前边对网络数据包接收流程的介绍,在这里我们可以将整个流程总结为两个阶段:

数据接收阶段

  • 数据准备阶段: 在这个阶段,网络数据包到达网卡,通过DMA的方式将数据包拷贝到内存中,然后经过硬中断,软中断,接着通过内核线程ksoftirqd经过内核协议栈的处理,最终将数据发送到内核Socket的接收缓冲区中。

  • 数据拷贝阶段: 当数据到达内核Socket的接收缓冲区中时,此时数据存在于内核空间中,需要将数据拷贝用户空间中,才能够被应用程序读取。

阻塞与非阻塞

阻塞与非阻塞的区别主要发生在第一阶段:数据准备阶段

当应用程序发起系统调用read时,线程从用户态转为内核态,读取内核Socket的接收缓冲区中的网络数据。

阻塞

如果这时内核Socket的接收缓冲区没有数据,那么线程就会一直等待,直到Socket接收缓冲区有数据为止。随后将数据从内核空间拷贝到用户空间,系统调用read返回。

阻塞IO

从图中我们可以看出:阻塞的特点是在第一阶段和第二阶段都会等待

非阻塞

阻塞非阻塞主要的区分是在第一阶段:数据准备阶段

  • 在第一阶段,当Socket的接收缓冲区中没有数据的时候,阻塞模式下应用线程会一直等待。非阻塞模式下应用线程不会等待,系统调用直接返回错误标志EWOULDBLOCK

  • Socket的接收缓冲区中有数据的时候,阻塞非阻塞的表现是一样的,都会进入第二阶段等待数据从内核空间拷贝到用户空间,然后系统调用返回

非阻塞IO

从上图中,我们可以看出:非阻塞的特点是第一阶段不会等待,但是在第二阶段还是会等待

同步与异步

同步异步主要的区别发生在第二阶段:数据拷贝阶段

前边我们提到在数据拷贝阶段主要是将数据从内核空间拷贝到用户空间。然后应用程序才可以读取数据。

当内核Socket的接收缓冲区有数据到达时,进入第二阶段。

同步

同步模式在数据准备好后,是由用户线程内核态来执行第二阶段。所以应用程序会在第二阶段发生阻塞,直到数据从内核空间拷贝到用户空间,系统调用才会返回。

Linux下的 epoll和Mac 下的 kqueue都属于同步 IO

同步IO

异步

异步模式下是由内核来执行第二阶段的数据拷贝操作,当内核执行完第二阶段,会通知用户线程IO操作已经完成,并将数据回调给用户线程。所以在异步模式下 数据准备阶段数据拷贝阶段均是由内核来完成,不会对应用程序造成任何阻塞。

基于以上特征,我们可以看到异步模式需要内核的支持,比较依赖操作系统底层的支持。

在目前流行的操作系统中,只有Windows 中的 IOCP才真正属于异步 IO,实现的也非常成熟。但Windows很少用来作为服务器使用。

而常用来作为服务器使用的Linux,异步IO机制实现的不够成熟,与NIO相比性能提升的也不够明显。

但Linux kernel 在5.1版本由Facebook的大神Jens Axboe引入了新的异步IO库io_uring 改善了原来Linux native AIO的一些性能问题。性能相比Epoll以及之前原生的AIO提高了不少,值得关注。

异步IO

IO模型

在进行网络IO操作时,用什么样的IO模型来读写数据将在很大程度上决定了网络框架的IO性能。所以IO模型的选择是构建一个高性能网络框架的基础。

在《UNIX 网络编程》一书中介绍了五种IO模型:阻塞IO,非阻塞IO,IO多路复用,信号驱动IO,异步IO,每一种IO模型的出现都是对前一种的升级优化。

下面我们就来分别介绍下这五种IO模型各自都解决了什么问题,适用于哪些场景,各自的优缺点是什么?

阻塞IO(BIO)

阻塞IO

经过前一小节对阻塞这个概念的介绍,相信大家可以很容易理解阻塞IO的概念和过程。

既然这小节我们谈的是IO,那么下边我们来看下在阻塞IO模型下,网络数据的读写过程。

阻塞读

当用户线程发起read系统调用,用户线程从用户态切换到内核态,在内核中去查看Socket接收缓冲区是否有数据到来。

  • Socket接收缓冲区中有数据,则用户线程在内核态将内核空间中的数据拷贝到用户空间,系统IO调用返回。

  • Socket接收缓冲区中无数据,则用户线程让出CPU,进入阻塞状态。当数据到达Socket接收缓冲区后,内核唤醒阻塞状态中的用户线程进入就绪状态,随后经过CPU的调度获取到CPU quota进入运行状态,将内核空间的数据拷贝到用户空间,随后系统调用返回。

阻塞写

当用户线程发起send系统调用时,用户线程从用户态切换到内核态,将发送数据从用户空间拷贝到内核空间中的Socket发送缓冲区中。

  • Socket发送缓冲区能够容纳下发送数据时,用户线程会将全部的发送数据写入Socket缓冲区,然后执行在《网络包发送流程》这小节介绍的后续流程,然后返回。

  • Socket发送缓冲区空间不够,无法容纳下全部发送数据时,用户线程让出CPU,进入阻塞状态,直到Socket发送缓冲区能够容纳下全部发送数据时,内核唤醒用户线程,执行后续发送流程。

阻塞IO模型下的写操作做事风格比较硬刚,非得要把全部的发送数据写入发送缓冲区才肯善罢甘休。

阻塞IO模型

阻塞IO模型

由于阻塞IO的读写特点,所以导致在阻塞IO模型下,每个请求都需要被一个独立的线程处理。一个线程在同一时刻只能与一个连接绑定。来一个请求,服务端就需要创建一个线程用来处理请求。

当客户端请求的并发量突然增大时,服务端在一瞬间就会创建出大量的线程,而创建线程是需要系统资源开销的,这样一来就会一瞬间占用大量的系统资源。

如果客户端创建好连接后,但是一直不发数据,通常大部分情况下,网络连接也并不总是有数据可读,那么在空闲的这段时间内,服务端线程就会一直处于阻塞状态,无法干其他的事情。CPU也无法得到充分的发挥,同时还会导致大量线程切换的开销

适用场景

基于以上阻塞IO模型的特点,该模型只适用于连接数少并发度低的业务场景。

比如公司内部的一些管理系统,通常请求数在100个左右,使用阻塞IO模型还是非常适合的。而且性能还不输NIO。

该模型在C10K之前,是普遍被采用的一种IO模型。

非阻塞IO(NIO)

阻塞IO模型最大的问题就是一个线程只能处理一个连接,如果这个连接上没有数据的话,那么这个线程就只能阻塞在系统IO调用上,不能干其他的事情。这对系统资源来说,是一种极大的浪费。同时大量的线程上下文切换,也是一个巨大的系统开销。

所以为了解决这个问题,我们就需要用尽可能少的线程去处理更多的连接。网络IO模型的演变也是根据这个需求来一步一步演进的。

基于这个需求,第一种解决方案非阻塞IO就出现了。我们在上一小节中介绍了非阻塞的概念,现在我们来看下网络读写操作在非阻塞IO下的特点:

非阻塞IO

非阻塞读

当用户线程发起非阻塞read系统调用时,用户线程从用户态转为内核态,在内核中去查看Socket接收缓冲区是否有数据到来。

  • Socket接收缓冲区中无数据,系统调用立马返回,并带有一个 EWOULDBLOCK 或 EAGAIN错误,这个阶段用户线程不会阻塞,也不会让出CPU,而是会继续轮训直到Socket接收缓冲区中有数据为止。

  • Socket接收缓冲区中有数据,用户线程在内核态会将内核空间中的数据拷贝到用户空间注意这个数据拷贝阶段,应用程序是阻塞的,当数据拷贝完成,系统调用返回。

非阻塞写

前边我们在介绍阻塞写的时候提到阻塞写的风格特别的硬朗,头比较铁非要把全部发送数据一次性都写到Socket的发送缓冲区中才返回,如果发送缓冲区中没有足够的空间容纳,那么就一直阻塞死等,特别的刚。

相比较而言非阻塞写的特点就比较佛系,当发送缓冲区中没有足够的空间容纳全部发送数据时,非阻塞写的特点是能写多少写多少,写不下了,就立即返回。并将写入到发送缓冲区的字节数返回给应用程序,方便用户线程不断的轮训尝试将剩下的数据写入发送缓冲区中。

非阻塞IO模型

非阻塞IO模型

基于以上非阻塞IO的特点,我们就不必像阻塞IO那样为每个请求分配一个线程去处理连接上的读写了。

我们可以利用一个线程或者很少的线程,去不断地轮询每个Socket的接收缓冲区是否有数据到达,如果没有数据,不必阻塞线程,而是接着去轮询下一个Socket接收缓冲区,直到轮询到数据后,处理连接上的读写,或者交给业务线程池去处理,轮询线程则继续轮询其他的Socket接收缓冲区。

这样一个非阻塞IO模型就实现了我们在本小节开始提出的需求:我们需要用尽可能少的线程去处理更多的连接

适用场景

虽然非阻塞IO模型阻塞IO模型相比,减少了很大一部分的资源消耗和系统开销。

但是它仍然有很大的性能问题,因为在非阻塞IO模型下,需要用户线程去不断地发起系统调用去轮训Socket接收缓冲区,这就需要用户线程不断地从用户态切换到内核态内核态切换到用户态。随着并发量的增大,这个上下文切换的开销也是巨大的。

所以单纯的非阻塞IO模型还是无法适用于高并发的场景。只能适用于C10K以下的场景。

IO多路复用

非阻塞IO这一小节的开头,我们提到网络IO模型的演变都是围绕着---如何用尽可能少的线程去处理更多的连接这个核心需求开始展开的。

本小节我们来谈谈IO多路复用模型,那么什么是多路?,什么又是复用呢?

我们还是以这个核心需求来对这两个概念展开阐述:

  • 多路:我们的核心需求是要用尽可能少的线程来处理尽可能多的连接,这里的多路指的就是我们需要处理的众多连接。

  • 复用:核心需求要求我们使用尽可能少的线程尽可能少的系统开销去处理尽可能多的连接(多路),那么这里的复用指的就是用有限的资源,比如用一个线程或者固定数量的线程去处理众多连接上的读写事件。换句话说,在阻塞IO模型中一个连接就需要分配一个独立的线程去专门处理这个连接上的读写,到了IO多路复用模型中,多个连接可以复用这一个独立的线程去处理这多个连接上的读写。

好了,IO多路复用模型的概念解释清楚了,那么问题的关键是我们如何去实现这个复用,也就是如何让一个独立的线程去处理众多连接上的读写事件呢?

这个问题其实在非阻塞IO模型中已经给出了它的答案,在非阻塞IO模型中,利用非阻塞的系统IO调用去不断的轮询众多连接的Socket接收缓冲区看是否有数据到来,如果有则处理,如果没有则继续轮询下一个Socket。这样就达到了用一个线程去处理众多连接上的读写事件了。

但是非阻塞IO模型最大的问题就是需要不断的发起系统调用去轮询各个Socket中的接收缓冲区是否有数据到来,频繁系统调用随之带来了大量的上下文切换开销。随着并发量的提升,这样也会导致非常严重的性能问题。

那么如何避免频繁的系统调用同时又可以实现我们的核心需求呢?

这就需要操作系统的内核来支持这样的操作,我们可以把频繁的轮询操作交给操作系统内核来替我们完成,这样就避免了在用户空间频繁的去使用系统调用来轮询所带来的性能开销。

正如我们所想,操作系统内核也确实为我们提供了这样的功能实现,下面我们来一起看下操作系统对IO多路复用模型的实现。

select

select是操作系统内核提供给我们使用的一个系统调用,它解决了在非阻塞IO模型中需要不断的发起系统IO调用去轮询各个连接上的Socket接收缓冲区所带来的用户空间内核空间不断切换的系统开销

select系统调用将轮询的操作交给了内核来帮助我们完成,从而避免了在用户空间不断的发起轮询所带来的的系统性能开销。

select

  • 首先用户线程在发起select系统调用的时候会阻塞select系统调用上。此时,用户线程从用户态切换到了内核态完成了一次上下文切换

  • 用户线程将需要监听的Socket对应的文件描述符fd数组通过select系统调用传递给内核。此时,用户线程将用户空间中的文件描述符fd数组拷贝内核空间

这里的文件描述符数组其实是一个BitMapBitMap下标为文件描述符fd,下标对应的值为:1表示该fd上有读写事件,0表示该fd上没有读写事件。

fd数组BitMap

文件描述符fd其实就是一个整数值,在Linux中一切皆文件,Socket也是一个文件。描述进程所有信息的数据结构task_struct中有一个属性struct files_struct *files,它最终指向了一个数组,数组里存放了进程打开的所有文件列表,文件信息封装在struct file结构体中,这个数组存放的类型就是struct file结构体,数组的下标则是我们常说的文件描述符fd

  • 当用户线程调用完select后开始进入阻塞状态内核开始轮询遍历fd数组,查看fd对应的Socket接收缓冲区中是否有数据到来。如果有数据到来,则将fd对应BitMap的值设置为1。如果没有数据到来,则保持值为0

注意这里内核会修改原始的fd数组!!

  • 内核遍历一遍fd数组后,如果发现有些fd上有IO数据到来,则将修改后的fd数组返回给用户线程。此时,会将fd数组从内核空间拷贝到用户空间

  • 当内核将修改后的fd数组返回给用户线程后,用户线程解除阻塞,由用户线程开始遍历fd数组然后找出fd数组中值为1Socket文件描述符。最后对这些Socket发起系统调用读取数据。

select不会告诉用户线程具体哪些fd上有IO数据到来,只是在IO活跃fd上打上标记,将打好标记的完整fd数组返回给用户线程,所以用户线程还需要遍历fd数组找出具体哪些fd上有IO数据到来。

  • 由于内核在遍历的过程中已经修改了fd数组,所以在用户线程遍历完fd数组后获取到IO就绪Socket后,就需要重置fd数组,并重新调用select传入重置后的fd数组,让内核发起新的一轮遍历轮询。

API介绍

当我们熟悉了select的原理后,就很容易理解内核给我们提供的select API了。

 int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)

select API中我们可以看到,select系统调用是在规定的超时时间内,监听(轮询)用户感兴趣的文件描述符集合上的可读,可写,异常三类事件。

  • maxfdp1 : select传递给内核监听的文件描述符集合中数值最大的文件描述符+1,目的是用于限定内核遍历范围。比如:select监听的文件描述符集合为{0,1,2,3,4},那么maxfdp1的值为5

  • fd_set *readset: 对可读事件感兴趣的文件描述符集合。

  • fd_set *writeset: 对可写事件感兴趣的文件描述符集合。

  • fd_set *exceptset:异常事件感兴趣的文件描述符集合。

这里的fd_set就是我们前边提到的文件描述符数组,是一个BitMap结构。

  • const struct timeval *timeout:select系统调用超时时间,在这段时间内,内核如果没有发现有IO就绪的文件描述符,就直接返回。

上小节提到,在内核遍历完fd数组后,发现有IO就绪fd,则会将该fd对应的BitMap中的值设置为1,并将修改后的fd数组,返回给用户线程。

在用户线程中需要重新遍历fd数组,找出IO就绪fd出来,然后发起真正的读写调用。

下面介绍下在用户线程中重新遍历fd数组的过程中,我们需要用到的API

  • void FD_ZERO(fd_set *fdset):清空指定的文件描述符集合,即让fd_set中不在包含任何文件描述符。

  • void FD_SET(int fd, fd_set *fdset):将一个给定的文件描述符加入集合之中。

每次调用select之前都要通过FD_ZEROFD_SET重新设置文件描述符,因为文件描述符集合会在内核被修改

  • int FD_ISSET(int fd, fd_set *fdset):检查集合中指定的文件描述符是否可以读写。用户线程遍历文件描述符集合,调用该方法检查相应的文件描述符是否IO就绪

  • void FD_CLR(int fd, fd_set *fdset):将一个给定的文件描述符从集合中删除

性能开销

虽然select解决了非阻塞IO模型中频繁发起系统调用的问题,但是在整个select工作过程中,我们还是看出了select有些不足的地方。

  • 在发起select系统调用以及返回时,用户线程各发生了一次用户态内核态以及内核态用户态的上下文切换开销。发生2次上下文切换

  • 在发起select系统调用以及返回时,用户线程在内核态需要将文件描述符集合从用户空间拷贝到内核空间。以及在内核修改完文件描述符集合后,又要将它从内核空间拷贝到用户空间。发生2次文件描述符集合的拷贝

  • 虽然由原来在用户空间发起轮询优化成了内核空间发起轮询但select不会告诉用户线程到底是哪些Socket上发生了IO就绪事件,只是对IO就绪Socket作了标记,用户线程依然要遍历文件描述符集合去查找具体IO就绪Socket。时间复杂度依然为O(n)

大部分情况下,网络连接并不总是活跃的,如果select监听了大量的客户端连接,只有少数的连接活跃,然而使用轮询的这种方式会随着连接数的增大,效率会越来越低。

  • 内核会对原始的文件描述符集合进行修改。导致每次在用户空间重新发起select调用时,都需要对文件描述符集合进行重置

  • BitMap结构的文件描述符集合,长度为固定的1024,所以只能监听0~1023的文件描述符。

  • select系统调用 不是线程安全的。

以上select的不足所产生的性能开销都会随着并发量的增大而线性增长

很明显select也不能解决C10K问题,只适用于1000个左右的并发连接场景。

poll

poll相当于是改进版的select,但是工作原理基本和select没有本质的区别。

int poll(struct pollfd *fds, unsigned int nfds, int timeout)
struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 需要监听的事件 */
    short revents;    /* 实际发生的事件 由内核修改设置 */
};

select中使用的文件描述符集合是采用的固定长度为1024的BitMap结构的fd_set,而poll换成了一个pollfd结构没有固定长度的数组,这样就没有了最大描述符数量的限制(当然还会受到系统文件描述符限制)

poll只是改进了select只能监听1024个文件描述符的数量限制,但是并没有在性能方面做出改进。和select上本质并没有多大差别。

  • 同样需要在内核空间用户空间中对文件描述符集合进行轮询,查找出IO就绪Socket的时间复杂度依然为O(n)

  • 同样需要将包含大量文件描述符的集合整体在用户空间内核空间之间来回复制无论这些文件描述符是否就绪。他们的开销都会随着文件描述符数量的增加而线性增大。

  • select,poll在每次新增,删除需要监听的socket时,都需要将整个新的socket集合全量传至内核

poll同样不适用高并发的场景。依然无法解决C10K问题。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/51715.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

8、python中的模块和包

文章目录模块模块导入的方式直接导入部分导入import module 和from module import *的区别模块的其他信息_ _ name _ _ 的特殊使用模块的分类包从包中导入模块的方式模块 模块就是工具包,要想使用这个工具包中的工具(就好比函数),就需要导入这个模块 模块是非常简单的Python文…

pandas交叉表与透视表pd.crosstab()和pd.pivot_table()函数详解

一、交叉表 交叉表:用于计算一列数据对于另外一列数据的分组个数(用于统计分组频率的特殊透视表),pd.crosstab(value1, value2)pandas.crosstab(index, columns, valuesNone, rownamesNone, colnamesNone, aggfuncNone, marginsFalse, margins_nameAll,…

【虚幻引擎】UE4/UE5 动画蓝图,混合空间,目标偏移,动画蒙太奇之间的联系

一、UE动画介绍 虚幻引擎在为角色设置移动行走时,为了更好的调节和控制人物的相关动画,设置了一系列的跟人物相关的动画,其中包括一维混合空间,二维混合空间,动画蒙太奇,目标偏移等,动画蓝图的出…

Day16--加入购物车-动态设置tabBar的数组徽标

问题1: ①:刚开始 ②:点击购物车的图标后,跳转到cart页面发现,并没有徽标在tabbar上: 提纲挈领: 我的操作: 1》把 Store 中的 total 映射到 cart.vue 中使用: 2》在页面…

Java ArrayLIst与顺序表

什么是集合类? Java当中的集合类,其实就是封装号的数据结构 原始的数据结构——>Java当中封装成的集合对应的那个原始的数据结构——>用Java封装的集合对应的。 集合类所在的包:java.util这个包底下 顺序表的底层是一个数组&#xff0…

Flutter状态管理

前言 状态管理是什么?简单的来说,就是当某个状态发生变化的时候,告知该状态的监听者,让状态所监听的属性随之而改变,达到UI层随着数据层变化而变化的效果。在Flutter中的状态(State)是一个组件的UI数据模型&#xff0…

【MySQL 读写分离】Sharding JDBC + Spring boot 实现数据库读写分离的登录 Demo

上篇文章我们搭建了 MySQL 数据库主从复制集群 MySQL 搭建主从复制集群~~~ 本篇文章我们利用搭建好的主从复制集群,使用 SpringBoot 结合 Sharding-JDBC 搭建一个小的 登录 Demo,测试实现数据库的读写分离 项目源码地址: https://gitee.com/l…

13 【操作mysql数据库】

13 【操作mysql数据库】 1.mysql 介绍 付费的商用数据库: Oracle,典型的高富帅;SQL Server,微软自家产品,Windows定制专款;DB2,IBM的产品,听起来挺高端;Sybase&#x…

android WebRtc 视频通话(P2P)

概述 WebRTC名称源自网页实时通信(Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的技术,是谷歌2010年以6820万美元收购Global IP Solutions公司而获得的一项技术。Google于2011年6月3日开源的即时通讯项目&#x…

centos7中mysql5.7.32服务离线升级到5.7.39教程

目录 一、导入新的离线安装包 二、备份原有mysql数据库 1、停止tomcat服务 2、查看mysql服务 3、备份数据库 三、停止mysql服务并打包备份旧版本 1、停止mysql 2、打包旧的mysql文件夹 3、删除旧的mysql文件夹 4、删除/etc/init.d/下跟mysql有关的全部文件&#xff0…

MongoDB数据迁移之迁移工具Kettle

MongoDB数据迁移之迁移工具Kettle ETL:简介 ETL(Extract-Transform-Load的缩写,即数据抽取、转换、装载的过程),对于企业或行业应用来说,我们经常会遇到各种数据的处理,转换,迁移,所…

Java+JSP+MySQL基于SSM的医院挂号就诊系统-计算机毕业设计

项目介绍 随着计算机科技的快速发展,很多地方都实现了自动化管理,医院也不例外。在大多数医院,无论是挂号处,还是取药的窗口,都会看到有很长的队伍,很显然这样会让患者就医的过程中浪费太多的时间。其次&a…

【读论文】GANMcC

GANMcC简单介绍网络结构生成器辨别器损失函数生成器损失函数辨别器tips总结参考论文:https://ieeexplore.ieee.org/document/9274337 如有侵权请联系博主 这几天又读了一篇关于GAN实现红外融合的论文,不出意外,还是FusionGAN作者团队的人写…

Python语音合成小工具(PyQt5 + pyttsx3)

TTS简介 TTS(Text To Speech)是一种语音合成技术,可以让机器将输入文本以语音的方式播放出来,实现机器说话的效果。 TTS分成语音处理及语音合成,先由机器识别输入的文字,再根据语音库进行语音合成。现在有…

JavaScript -- 三种循环语句的介绍及示例代码

文章目录循环语句1 While循环2 do-while循环3 for循环4 嵌套循环循环语句 通过循环语句可以使指定的代码反复执行 JS中一共有三种循环语句 while语句do-while语句for语句 通常编写一个循环,要有三个要件 初始化表达式(初始化变量)条件表…

风云气象卫星系列介绍

风云气象卫星系列是中国于1977年开始研制的气象卫星系列,目前发射了风云一号、风云二号、风云三号、风云四号等卫星。 风云一号 FY-1卫星分为两个批次,各两颗星。01批的FY-1A星于1988年7月9日发射,FY-1B星于1990年9月3日发射。02批卫星在01批…

Word处理控件Aspose.Words功能演示:在 Java 中将 Word 文档转换为 EPUB

大多数智能设备,如智能手机、平板电脑、笔记本电脑等,都支持EPUB格式来查看或阅读文档。它是电子书或电子出版物的常用格式。另一方面,MS Word 格式,如DOCX、DOC等,是数字世界中广泛使用的文档格式之一。在本文中&…

Web3中文|NFT无法保障数字所有权?

来源 | nftnow 编译 | DaliiNFTnews.com 2021年,有这样一个头条新闻:一家投资公司以大约400万美元的价格在The Sandbox上买下了2000英亩的虚拟地产。 通过在以太坊区块链上购买792个NFT,该公司得到了元宇宙平台上的1200个城市街区。 但是…

家用宽带如何叠加多条宽带,提高局域网速度

前言 关于多条宽带如何合并,使局域网内带宽更快?通常我们在企业网络或实际项目中,随着用户的增加,一条或者几条带宽不能满足正常使用,便可以对带宽进行叠加,便于网络带度更快; 一、为什么要用…

web基础阶段的小兔鲜儿项目学习

小兔鲜儿1. 所用素材2. 项目文件介绍3. index页面的基本骨架4. 思路:先写外面大盒子和版心,由外往内写5. 源码:1. 所用素材 素材链接,点我跳转:https://download.csdn.net/download/angrynouse/87228151 2. 项目文件…