freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Netty技术内幕
2022-12-19 17:58:29
所属地 浙江省

netty是一个高性能,异步事件驱动的网络架构。

一、 从内核角度看IO模型

收包流程:
image.png

网络包通过物理层->网卡->链路层
DMA方式,Direct Memory Access,也称为成组数据传送方式,有时也称为直接内存操作。 DMA方式在数据传送过程中,没有保存现场、恢复现场之类的工作。 由于CPU根本不参加传送操作,因此就省去了CPU取指令、取数、送数等操作。
RingBuffer 网卡启动的时候就分配和初始化环形缓冲队列,当满了新来的包就会丢弃。

DMA完成后,网卡向CPU发出硬中断,告诉CPU有网络包到达。此时硬中断程序 会在内核创建数据结构sk_buffer并copy数据进去。
sk_buffer是维护网络帧结构的双向链表。数据在TCP/IP分层协议传递的时候,仅仅是操作这个数据结构的指针,而不需要数据复制。

然后发起软中断,告诉内核有新的网络数据帧到达。内核线程ksofttirqd发现有软中断请求,就调用网卡驱动注册的poll函数,将sk_buffer发送到内核的ip_rcv函数。

然后ip_rcv跳到对应的tcp_rcv 或者udp_rcv。根据四元组(源IP,源port,目的IP,目的port)找到对应的socket,找到后就把数据copy到接收缓冲区,没找到就发送一个目标不可达的icmp包。

此时应用read读取socket接收缓冲区的数据时,没有数据就会阻塞。有数据CPU就将内核空间(也就是此时的Socket接收缓冲区)copy到用户空间去,然后应用去读取数据。

性能开销:

  1. 用户态转成内核态读取缓冲区数据,内核态转成用户态处理数据的开销。

  2. 内核空间copy数据到用户空间开销。

  3. DMA:网卡->DMA Ringbuffer ;硬中断:DMA->sk_buffer;软中断:sk_buffer->ip_rcv

发包流程:

image.png
应用调用send时,用户态 转成内核态,找到对应的socket,构建发送数据的对象msghdr ->inet_sendmsg->tcp_sendmsg ,创建sk_buffer,并将数据copy到其中。

找到socket的队尾元素,然后把新创建的sk_buffer添加到后面。

经过TCP的流量控制和拥塞控制,达到标准后才会发送。
要发送的sk_buffer copy一个副本(因为TCP支持丢包重传),然后通过传输层->网络层->链路层->网卡->物理层。
以上过程都是用户线程的内核执行。

性能开销:

  1. 用户态->内核态:send;内核态->用户态:return.

  2. cpu时间片用完了,触发软中断以及硬中断

  3. 内存拷贝:tcp_sendmsg->sk_buffer;sk_buffer->副本;数据大于MTU进行分片,申请sk_buffer并copy

TCP 可靠传输的实现:
image.png

  1. 滑动窗口:使用三个指针对应已发送,已发送未确认,未发送已就绪,未发送的字节流。
    image.png
    如果32丢失,即使33-35到了,此时接受方,会把33-35放到接收缓存区内,依旧会返回三次31的ack,触发快重传,重新发送32。发送方发了32之后,窗口就会滑到35的位置。

  2. 拥塞控制
    image.png

实际情况,当用户对网络资源的请求数量超过了网路所能抗住的负荷时,吞吐量会随着请求的增多而减少,直到变为0。
拥塞控制就是解决这个问题的。调整发送窗口的大小,来控制发送速率从而减少网络流量的作用。
发送方需要维护三个变量,发送窗口cwnd;拥塞窗口swnd;窗口阈值ssthresh。发送的窗口大小=min(swnd(拥塞控制),接收方接受窗口大小(流量控制))
image.png
慢开始:cwnd=1,接受了确认报文就给窗口大小*2
拥塞避免:接受了确认报文就给窗口大小+1,超时重传就将cwnd=1,ssthresh/=2
快重传:连续收到3个重复的ACK报文时就重传,不等超时。
快恢复:出现快重传情况,不是把cwnd=1,而是cwnd/=2,ssthresh/=2

  1. 流量控制
    让接收方的发送速率能匹配上接收放的速率,控制发送方的发送窗口大小。
    发送方发送窗口大小 = min(拥塞窗口大小(拥塞控制), 接收方接收窗口大小(流量控制))

发送方在接收到接收方的确认报文段后,会根据报文中的接收方窗口大小值,来重新调整当前的发送窗口大小。(初始大小在TCP三次握手的时候确定)

发送方窗口可以被设成零。 当发送方窗口大小为0时,会定时发送零窗口探测报文,询问接收方的窗口大小,如果还是0,那么就重置这个定时器,如果不是0就重新调整窗口大小。

TCP规定,接收方接收窗口即使为0,也应接收零窗口探测报文,确认报文段,以及携带紧急数据的报文段。

二、什么是阻塞、非阻塞、同步、异步

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

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

阻塞:从read开始直到数据返回,一直都是阻塞状态。也就是数据准备和数据拷贝都阻塞。
非阻塞:第一阶段数据准备阶段如果没有数据,应用线程不会等待,系统调用直接返回错误标志EWOULDBLOCK。而第二阶段数据拷贝依旧是阻塞的。

同步:发生在第二阶段数据拷贝,用户线程的内核态将数据拷贝到用户空间是阻塞的,linux epoll 和 mac kqueue 都是同步IO形式。
异步:内核操作第二阶段,拷贝完成后就通知用户线程IO结束。所以异步第一阶段和第二阶段均由内核完成。

三、IO模型

五种IO模型:阻塞IO,非阻塞IO,IO多路复用,信号驱动IO,异步IO

1、阻塞IO BIO

阻塞读:用户线程read->用户态转成内核态->查看Socket缓冲区是否有数据来。有数据就将内核空间->用户空间;没有则用户IO让出CPU进入阻塞状态,数据到达后内核唤醒用户线程阻塞状态->就绪状态->CPU quota进入运行状态,然后拷贝数据。

阻塞写:用户线程发起send->用户态转成内核态->数据从用户空间拷贝到内核空间socket发送缓冲区。当缓冲区有空间容纳,用户线程就会将全部发送数据写入;如果不够,用户线程就让出CPU进入阻塞状态直到可以容纳所有的发送数据,内核再唤醒用户线程执行发送。

特点:每个请求都需要被一个独立的线程处理,一个线程同一时刻只能与一个链接绑定,来一个请求就创建一个线程处理。并发量大就容易资源耗尽,阻塞状态用户线一直空闲浪费资源,大量线程来回切换开销。C10K

2、非阻塞IO NIO

为了解决BIO的一个线程对应一个连接,且容易空闲浪费资源的情况,NIO用尽量少的线程去处理更多的连接!
非阻塞读:同上;无数据时,系统调用立马返回带有EWOULDBLOCK或EAGAIN错误,此时用户线程不会阻塞,但是也不会让出CPU,而是继续轮询其他的缓冲区直到缓冲区有数据。但是数据拷贝阶段依旧是阻塞的。

非阻塞写:BIO阻塞写是一定要全部写入,而NIO是能写多少写多少,写不下立即返回写入的字节数,然后用户线程不断轮询尝试将剩下的数据写入发送缓冲区。

特点:利用一个线程或者很少的线程去不断轮询每个socket接收缓冲区,没有数据就下一个缓冲区。但是依旧存在不断轮询时候频繁用户态 和内核态来回切换。c10

3、IO多路复用

多路:少的线程处理多的连接,多路指的就是众多连接。
复用:少的线程,少的系统开销处理多的连接,复用指的是有限的资源
避免NIO切换用户态和内核态的开销,把轮询缓冲区的操作交给内核!
**1. select **
select是内核提供的一个系统调用,它解决了在非阻塞IO模型中需要不断的发起系统IO调用去轮询各个连接上的Socket接收缓冲区所带来的用户空间与内核空间不断切换的系统开销。
image.png

  • 用户线程发起select系统调用,此时阻塞在select系统调用上。用户态->内核态

  • 用户线程将socket对应的文件描述符fd -> 内核
    (文件描述符数组 就是bitmap,下标为fd,1表示有读写事件,0表示没有)

  • 内核轮询fd数组,socket接收缓冲区是否有数据来,有就把bitmap值设为为1。

  • 遍历一次fd数组后,发现有数据来就将修改好的fd数组拷贝到用户空间

  • 用户线程接触阻塞,用户线程再次遍历fd数组(因为内核仅仅是标记)找到标记1的文件描述符,最后对这些socket发起系统调用读取数据。
    性能:两次上下文切换;fd文件两次copy;内核只是标记IO就绪事件,用户线程依旧要遍历,时间复杂度0n;BitMap长度只有1024;select线程不安全。c10k

2. poll
poll就是select改进版,换成没有固定长度的数组,这样就可以监听1024以上的数量文件描述符。其他没改。

3. epoll
不论是select或者poll:内核不会保存fd,每次都全量的传入,来回的复制。只标记IO就绪事件。
首先有一个监听socket,当调用accept时就会创建新的socket,这个socket是真正通信的,已经连接的。
内核会维护两个队列:完成TCP三次握手的,一个处于半连接的。socket中有三个队列:接受队列,发送队列,等待队列。等待队列存放系统调用IO阻塞的进程fd以及回调函数用来唤醒阻塞。
epoll_create 是内核提供的创建epoll对象的系统调用,创建eventpoll对象

struct eventpoll {

    //等待队列,阻塞在epoll上的进程会放在这里。当IO就绪就可以在这找到阻塞的进程并唤醒
    wait_queue_head_t wq;

    //就绪队列,IO就绪的socket连接会放在这里。被唤醒的用户进程直接读取这个队列获取IO活跃的socket,无需遍历整个socket集合。select 和poll都是返回全量socket连接。
    struct list_head rdllist;

    //红黑树用来管理所有监听的socket连接,每次添加或者删除都是增量添加删除,而select,poll都是全量socket连接传入内核,避免了大量的内存拷贝
    struct rb_root rbr;

    ......
}

image.png

水平触发:epoll_wait读数据,如果只读了一部分,再次调用epoll_wait,如果还有数据可读,就将socket放回rdllist。
边缘触发:epoll_wait会清空rdllist,除非有新的IO数据到达,不然就不调用这个socket。

优化:

  1. epoll 在内核中通过红黑树管理海量连接,调用epoll_wait获取IO就绪socket不需要传入监听的socket文件,从而避免大量fd在用户空间和内核空间来回复制。

  2. epoll仅会通知IO就绪的socket.避免用户空间遍历的开销。

  3. epoll通过在socket的等待队列上注册回调函数ep_poll_callback通知用户程序IO就绪的socket。避免了在内核中轮询的开销。而select ,poll都是内核轮询的方式获取IO活跃socket。

4. 信号驱动IO

用户进程发送一个IO请求,在对应的socket注册一个信号回调,此时不阻塞用户进程,当内核数据就绪时,内核为该进程生成一个SIGIO信号,通过信号回调通知进程进行IO操作。
但是他依旧是同步IO,阻塞在第二阶段,用户态转成内核态copy数据。

缺点:大量的IO操作导致信号队列溢出,无法通知。
SIGIO是unix信号,缺少附加信息,不知道来源。所以一般不用在TCP上,可以用在UDP上。

5. AIO

阻塞IO,非阻塞IO,IO多路复用,信号驱动IO都是同步IO,均会在数据拷贝阶段阻塞。
但是技术不成熟,现在用的最多还是IO多路复用。

四、IO线程模型

上面都是内核的视角,而用户空间的IO线程模型就相对简单一些,只是在讨论分工。

Reactor是利用NIO对IO线程进行不同的分工。IO多路复用进行IO事件注册和监听;将监听到的就绪IO事件分发处理的Handler中进行IO事件处理。通过IO多路复用就可以不断的监听,产生IO事件。然后分发,就像是反应堆(Reactor)。

单Reactor 单线程:
通过epoll进行IO多路复用,一个Reactor负责监听连接,读写事件,一个线程执行epoll_wait获取的IO就绪socket执行读写和处理业务。

单Reactor 多线程
一个epoll对象监听所有IO事件,一个线程来调用epoll_wait获取IO就绪socket,产生IO就绪事件后,处理的handler是通过一个线程池来执行的。

主从Reactor 多线程
主Reactor专门做连接事件,从Reactor监听socket读写事件,后面依旧是交给线程池处理。这也是netty用的模型。
image.png
main Reactor Group 中Reactor数量取决监听服务端端口个数,通常只有一个。处理最重要的事情:

  • 绑定端口地址

  • 接收客户端链接

  • 为客户端创建对应的SocketChanel,将客户端SocketChanel分配一个固定的Sub Reactor(线程安全)
    sub Reactor Group 中多个Reactor线程,个数可以参数指定,默认CPU*2。Sub Reactor 轮询客户端SocketChannel中IO就绪事件,处理IO就绪事件,执行异步任务。

//创建主从Reactor线程组
        NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
        NioEventLoopGroup workGroup = new NioEventLoopGroup();
        final EchoServerHandler echoServerHandler = new EchoServerHandler();

        try{
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup,workGroup) //配置主从
                    .channel(NioServerSocketChannel.class) //配置Reactor中channel类型
                    .option(ChannelOption.SO_BACKLOG,100) //配置Reactor中channel的option选项
                    .handler(new LoggingHandler(LogLevel.INFO)) //设置主Reactor中Channel->pipline->handler
                    .childHandler(new ChannelInitializer<SocketChannel>() { //设置从Reactor中注册channel的pipeline
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            ChannelPipeline p = socketChannel.pipeline();
                            p.addLast(echoServerHandler);
                        }
                    });
            // Start the server. 绑定端口启动服务,开始监听accept事件
            ChannelFuture f = b.bind(PORT).sync();
            // Wait until the server socket is closed.
            f.channel().closeFuture().sync();

Netty创建

我们首先要创建一个Socket用于listen和bind端口地址,我们把这个叫做监听Socket,这里对应的就是NioServerSocketChannel.class。当客户端连接完成三次握手,系统调用accept函数会基于监听Socket创建出来一个新的Socket专门用于与客户端之间的网络通信我们称为客户端连接Socket,这里对应的就是NioSocketChannel.class

Netty中的Reactor是以Group的形式出现的,EventLoopGroup正是Reactor组的接口定义,负责管理Reactor,Netty中的Channel就是通过EventLoopGroup注册到具体的Reactor上的。

netty有两种Channel类型:一种是服务端用于监听绑定端口地址的NioServerSocketChannel,一种是用于客户端通信的NioSocketChannel。每种Channel类型实例都会对应一个PipeLine用于编排对应channel实例上的IO事件处理逻辑。PipeLine中组织的就是ChannelHandler用于编写特定的IO处理逻辑。
handler是NioServerSocketChannel 的pipline处理逻辑,而带childd前缀都是处理客户端NioSocketChannel
ChannelInitializer用于当SocketChannel成功注册绑定的Reactorhou ,用于初始化该SocketChannel的pipline.

Netty支持五种IO模型中的BIO,NIO,AIO,多系统的IO多路复用。
ThreadPerTaskExecutor的线程池轮询IO就绪事件,启动的时候会将Reactor要做的事情封装成Runnable执行
MultithreadEventExecutorGroup 中实际创建Reactor 根据线程池的个数
children[i] = newChild(executor, args);

可以把Reactor理解成为一个单线程的线程池(所以对Reactor定义都是SingleThread开头),类似于JDK中的SingleThreadExecutor,仅用一个线程来执行轮询IO就绪事件,处理IO就绪事件,执行异步任务。同时待执行的异步任务保存在Reactor里的taskQueue队列中。这里依赖MpscQueue队列,允许多生产者和单消费者,这样就可以线程安全的单个Reactor执行这些异步任务!

Netty 优化原生的selector:原生的selector存在hashSet中,存在hash冲突,插入和遍历性能不好,优化成SelectedSelectionKeySet里面是数组,操作新能更好。通过反射替换。
image.png
SingleThreadEventLoop 注册tailQueue 来统计啥的,提供绑定channel到Reactor(使用轮询策略绑定)。
SingleThreadEventExecutor 注册taskQueue普通队列,异步任务执行,Reactor启动和暂停。
无论是Netty服务端NioServerSocketChannel关注的OP_ACCEPT事件也好,还是Netty客户端NioSocketChannel关注的OP_READ和OP_WRITE事件也好,都需要先注册到Reactor上,Reactor才能监听Channel上关注的IO事件实现IO多路复用。

Netty启动

image.png
启动全在bing(port)方法上。

  1. 创建NioServerSocketChannel并初始化,

  2. NioServerSocketChannel注册到主Reactor中

  3. 初始化NioServerSocketChannel的pipline ,然后再pipline出发channelResgister

  4. NioServerSocketChannel绑定端口

  5. 向pipline中触发传播ChannelActive事件,向Main Reactor注册OP_ACCEPT事件,开始等待客户端连接,服务端启动完成。

Netty运行

Reactor线程其实执行的就是一个死循环,在死循环中不断的通过Selector在内核态去轮训IO就绪事件,如果发生IO绪事件则从Selector系统调用中返回并处理IO就绪事件,如果没有发生IO就绪事件则一直阻塞在Selector系统调用上,直到满足Selector唤醒条件。

以下三个条件中只要满足任意一个条件,Reactor线程就会被从Selector上唤醒:

  • 当Selector轮询到有IO活跃事件发生时。

  • 当Reactor线程需要执行的定时任务到达任务执行时间deadline时。

  • 当有异步任务提交给Reactor时,Reactor线程需要从Selector上被唤醒,这样才能及时的去执行异步任务。
    image.png

运行流程:
image.png
轮询流程:
image.png
上面的 selectSupplier.get()会顺带扫描一下是否有就绪的IO事件,返回值就是就绪的个数(就绪IO channel个数)。

如果Reactor中有异步任务需要执行,那么Reactor线程需要立即执行,不能阻塞在Selector上。在返回前需要再顺带调用selectNow()非阻塞查看一下当前是否有IO就绪事件发生。如果有,那么正好可以和异步任务一起被处理,如果没有,则及时地处理异步任务。
Reactor线程需优先保障IO就绪事件,然后再保证异步任务执行,有异步任务就去执行不会阻塞。

  1. 当为Select=-1时,说明没有异步任务和IO事件,那就你检测定时任务。(添加异步想法的时候会wakeup Reactor线程)
    image.png

Netty Accept事件处理
当与客户端完成三次握手之后,main Reactor中selector产生OP_ACCEPT事件,main Reactor被唤醒,来到unsafe.read()开始接收客户端连接
image.png

int localRead = doReadMessages(readBuf); 
这里会从内核全连接队列中取出客户端连接,返回值是就绪的个数,正常情况都是一个一个接收,所以通常为1
 pipeline.fireChannelRead(readBuf.get(i)); 
这里ServerBootstrapAcceptor会响应ChannelRead事件,回调初始化NioSocketChannel,并注册到Sub Reactor 中,这样就能监听读写事件

SocketChannel ch = serverSocketChannel.accept();
 doReadMessages方法里面调用监听Socket(serverSocketChannel)的accept方法,内核会基于监听Socket创建出来一个新的Socket专门用于与客户端之间的网络通信这个我们称之为客户端连接Socket(SocketChannel)

NioServerSocketChannel vs NioSocketChannel 区别

  1. 层次不同,NioServerSocketChannel 的parent是null,NioSocketChannel 的parent是创建他的NioServerSocketChannel

  2. 注册IO事件不同,NioServerSocketChannel 注册OP_ACCEPT,NioSocketChannel注册OP_READ

  3. 继承类不同,NioServerSocketChannel继承AbstractNioMessageChannel,NioSocketChannel继承AbstractNioByteChannel。前者没信息,只有连接所以是message,后者有数据需处理byte
    image.png

NioServerSocketChannel 中pipline有一个ServerBootstrapAcceptor,主要的作用就是初始化客户端NioSocketChannel,并将客户端NioSocketChannel注册到Sub Reactor Group中,并监听OP_READ事件。

<未完>

# IoT安全
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录