网络编程学习笔记 - 04Netty源码之底层原理

114 阅读13分钟

Netty对三种I/O模式的支持

BIO,NIO,还有AIO(已被移除),BIO也称之为OIO(已被弃用)。

OIONIO-COMMONNIO-LinuxNIO-macOS/BSDAIO
ThreadPerChannelEventLoopGroupNioEventLoopGroupEpollEventLoopGroupKQueueEventLoopGroupAioEventLoopGroup
ThreadPerChannelEventLoopNioEventLoopEpollEventLoopKQueueEventLoopAioEventLoop
OioServerSocketChannelNioServerSocketChannelEpollServerSocketChannelKQueueServerSocketChannelAioServerSocketChannel
OioSocketChannelNioSocketChannelEpollSocketChannelKQueueSocketChannelAioSocketChannel

以上的EventLoopGroup是需要手动指定的,对应的Channel则是根据工厂模式进行切换:

AbstractBootstrap

public B channel(Class<? extends C> channelClass) {
    // 反射工厂的实现
    return channelFactory(new ReflectiveChannelFactory<C>(
            ObjectUtil.checkNotNull(channelClass, "channelClass")
    ));
}

Netty对Reactor模式的支持

上一篇文章对Reactor模式进行了介绍,这里看下Netty是如何实现该模式的。这里贴一张上篇文章的图回忆回忆

7 reactor3.png

以NioEventLoop为例,NioEventLoop可以简单理解为一个线程,其中的run()方法如下:

// 死循环监听、处理事件
protected void run() {
    for (;;) {
        try {
            try {
                switch (selectStrategy.calculateStrategy(selectNowSupplier, hasTasks())) {
                case SelectStrategy.CONTINUE:
                    continue;

                case SelectStrategy.BUSY_WAIT:
                    // fall-through to SELECT since the busy-wait is not supported with NIO

                case SelectStrategy.SELECT:
                    select(wakenUp.getAndSet(false));
                ...
}

是不是和NIO的处理方式很像。

mainReactor

回到EchoServer

    // 无参的构造方法:线程组,多个线程
    EventLoopGroup bossGroup = new NioEventLoopGroup();  
    EventLoopGroup workerGroup = new NioEventLoopGroup();

    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)

bossGroup即为mainReactor,workerGroup即为subReactor。进入group方法:

    public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup) {
        super.group(parentGroup);
        ObjectUtil.checkNotNull(childGroup, "childGroup");
        if (this.childGroup != null) {
            throw new IllegalStateException("childGroup set already");
        }
        this.childGroup = childGroup;
        return this;
    }

我们关心的mainReactor在这里对应的是parentGroup,进入super.group(parentGroup)看一眼:

    public B group(EventLoopGroup group) {
        ObjectUtil.checkNotNull(group, "group");
        if (this.group != null) {
            throw new IllegalStateException("group set already");
        }
        this.group = group;
        return self();
    }

原来是ServerBootstrap的父类AbstractBootstrap管理着这个group,即mainReactor。前面我们说mainReactor只会分一个线程处理acceptor,那么再看代码 。

ChannelFuture f = b.bind(PORT).sync();

进入bind方法,直到doBind方法:

private ChannelFuture doBind(final SocketAddress localAddress) {
    final ChannelFuture regFuture = initAndRegister();
    ...
}

进入initAndRegister方法:

final ChannelFuture initAndRegister() {
        Channel channel = null;
        try {
            // 1 创建一个ServerSocketChannel(NioServerSocketChannel)
            channel = channelFactory.newChannel();
            // 2 初始化ServerSocketChannel(NioServerSocketChannel)
            init(channel);
        } catch (Throwable t) {
            if (channel != null) {
                channel.unsafe().closeForcibly();
                return new DefaultChannelPromise(channel, GlobalEventExecutor.INSTANCE).setFailure(t);
            }
            return new DefaultChannelPromise(new FailedChannel(), GlobalEventExecutor.INSTANCE).setFailure(t);
        }
        // 3 Register:给ServerSocketChannel从Bossgroup中选择一个NioEventLoop
        // 这里是返回一个Future,说明后面要走异步过程了
        ChannelFuture regFuture = config().group().register(channel);
        if (regFuture.cause() != null) {
            if (channel.isRegistered()) {
                channel.close();
            } else {
                channel.unsafe().closeForcibly();
            }
        }
        return regFuture;
    }

initAndRegister主要做了三件事,注释中有描述。这里先关心第三步:

ChannelFuture regFuture = config().group().register(channel);

这里的config().group()点进去看其实就是前面说的AbstractBootstrap中的group。再进入register方法,这里是MultithreadEventLoopGroup中的register方法:

public ChannelFuture register(Channel channel) {
    return next().register(channel);
}

这里只会调用一次,也就是这里一般也只会有一个线程。即给给ServerSocketChannel从boss group中选择一个NioEventLoop。

subReactor

再回到ServerBootstrap中的group方法,看到childGroup赋值给了ServerBootstrap的childGroup属性

this.childGroup = childGroup;

回到initAndRegister方法中的init(channel),这里用于初始化ServerSocketChannel。进入ServerBootstrap中的init方法:

@Override
void init(Channel channel) {
    // 参数和属性设置
    setChannelOptions(channel, options0().entrySet().toArray(newOptionArray(0)), logger);
    setAttributes(channel, attrs0().entrySet().toArray(newAttrArray(0)));
    ChannelPipeline p = channel.pipeline();
    final EventLoopGroup currentChildGroup = childGroup;
    final ChannelHandler currentChildHandler = childHandler;
    final Entry<ChannelOption<?>, Object>[] currentChildOptions =
            childOptions.entrySet().toArray(newOptionArray(0));
    final Entry<AttributeKey<?>, Object>[] currentChildAttrs = childAttrs.entrySet().toArray(newAttrArray(0));
    // 这里需要了解责任链模式pipeline
    // ChannelInitializer一次性、初始化handler:
    // 负责添加一个ServerBootstrapAcceptor handler,添加完后,自己就移除了:
    // ServerBootstrapAcceptor handler: 负责接收客户端连接创建连接后,对连接的初始化工作。
    p.addLast(new ChannelInitializer<Channel>() {
        @Override
        public void initChannel(final Channel ch) {
            final ChannelPipeline pipeline = ch.pipeline();
            ChannelHandler handler = config.handler();
            if (handler != null) {
                pipeline.addLast(handler);
            }

            ch.eventLoop().execute(new Runnable() {
                @Override
                public void run() {
                    pipeline.addLast(new ServerBootstrapAcceptor(
                            ch, currentChildGroup, currentChildHandler, currentChildOptions, currentChildAttrs));
                }
            });
        }
    });
}

先不管Netty中的pipeline是咋回事。看到这里用到的是ServerBootstrapAcceptor的channelRead方法:

@Override
@SuppressWarnings("unchecked")
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    final Channel child = (Channel) msg;

    child.pipeline().addLast(childHandler);

    setChannelOptions(child, childOptions, logger);
    setAttributes(child, childAttrs);

    try {
        childGroup.register(child).addListener(new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                if (!future.isSuccess()) {
                    forceClose(child, future.cause());
                }
            }
        });
    } catch (Throwable t) {
        forceClose(child, t);
    }
}

这里调用的是childGroup.register。前面NioEventLoopGroup使用了无参构造方法,默认nThreads参数为0,那么实际里面的现成数为DEFAULT_EVENT_LOOP_THREADS

    protected MultithreadEventLoopGroup(int nThreads, Executor executor, Object... args) {
        super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, executor, args);
    }

所以这里每接收客户端连接创建连接后,就会创建线程对链接L进行初始化工作。

如何给Channel分配NIO eventLoop

前面提到了MultithreadEventLoopGroup中的register方法,进入next方法,最终能看到用的是EventExecutorChooser接口的next方法。他有两个实现PowerOfTwoEventExecutorChooser和GenericEventExecutorChooser:

    @SuppressWarnings("unchecked")
    @Override
    public EventExecutorChooser newChooser(EventExecutor[] executors) {
        // 根据待绑定的executor是否是2的幂次方,做出不同的选择
        if (isPowerOfTwo(executors.length)) {
            return new PowerOfTwoEventExecutorChooser(executors);
        } else {
            return new GenericEventExecutorChooser(executors);
        }
    }

    private static boolean isPowerOfTwo(int val) {
        return (val & -val) == val;
    }

    private static final class PowerOfTwoEventExecutorChooser implements EventExecutorChooser {
        private final AtomicInteger idx = new AtomicInteger();
        private final EventExecutor[] executors;

        PowerOfTwoEventExecutorChooser(EventExecutor[] executors) {
            this.executors = executors;
        }

        @Override
        // executors总数必须是2的幂次方才会用,&运算效率更高,同时当idx累加成最大值之后,相对于GenericEventExecutorChooser更公平
        public EventExecutor next() {
            return executors[idx.getAndIncrement() & executors.length - 1];
        }
    }

    private static final class GenericEventExecutorChooser implements EventExecutorChooser {
        private final AtomicInteger idx = new AtomicInteger();
        private final EventExecutor[] executors;

        GenericEventExecutorChooser(EventExecutor[] executors) {
            this.executors = executors;
        }

        @Override
        public EventExecutor next() {
            // 递增、取模,取正值,不然可能是负数,另外:有个非常小的缺点,当idx累加成最大值后,有短暂的不公平:
            //1,2,3,4,5,6,7,0,7(注意这里不是1,而是7,然而往前的第二个也是7,所以不够公平),6,5
            // 此处为取模运算,效率不如与运算。会出现短暂的不公平。
            return executors[Math.abs(idx.getAndIncrement() % executors.length)];
        }
    }

为啥会出现不公平呢,简单做个示例:

    public static void main(String[] args) {
        int length = 8;

        AtomicInteger atomicInteger = new AtomicInteger(Integer.MAX_VALUE - length + 1);
        for (int i = 0; i < length * 2; i++) {
            int integer = atomicInteger.getAndIncrement();
            int abs = Math.abs(integer % length);
            System.out.println("int: " + integer + " after %: " + abs);
        }
    }

输出:

int: 2147483640 after %: 0
int: 2147483641 after %: 1
int: 2147483642 after %: 2
int: 2147483643 after %: 3
int: 2147483644 after %: 4
int: 2147483645 after %: 5
int: 2147483646 after %: 6
int: 2147483647 after %: 7
int: -2147483648 after %: 0
int: -2147483647 after %: 7
int: -2147483646 after %: 6
int: -2147483645 after %: 5
int: -2147483644 after %: 4
int: -2147483643 after %: 3
int: -2147483642 after %: 2
int: -2147483641 after %: 1

可见在atomicInteger到达极大值时会出现短暂不公平。

这里也可以看出Netty对性能做了极致的优化。

实现多路复用器是怎么跨平台的

在NioEventLoopGroup构造方法中有这么一段:

    public NioEventLoopGroup(int nThreads, Executor executor) {
        this(nThreads, executor, SelectorProvider.provider());
    }

这里的SelectorProvider.provider()中调用了SelectorProvider的create方法。随着jdk不同,这里的实现也不同。

mac系统为sun.nio.ch.KQueueSelectorProvider

windows系统为WindowsSelectorProvider

Netty对处理粘包/半包的支持

什么是粘包和半包?

假设客户端分别发送了两个数据包ABC和DEF给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下4种情况:

  1. 服务端分两次读取到了两个独立的数据包,分别是ABC和DEF,没有粘包和拆包;
  2. 服务端一次接收到了两个数据包,ABC和DEF粘合在一起,被称为TCP粘包;
  3. 服务端分两次读取到了两个数据包,第一次读取到了完整的ABC包和DEF包的部分内容(ABCD),第二次读取到了DEF包的剩余内容(EF),这被称为TCP拆包
  4. 服务端分两次读取到了两个数据包,第一次读取到了ABC包的部分内容(AB),第二次读取到了CD,第三次读到了EF。

TCP粘包/半包解决实战

方式/比较消息边界优点缺点推荐度
TCP短连接建立连接到释放连接质检的信息即为传输信息简单效率低不推荐
固定长度满足固定长度即可简单空间浪费不推荐
分隔符分隔符质检空间不浪费分隔符需要转义推荐
消息头将消息分位消息头和消息体精确且不用转义实现相对复杂推荐+

具体用到的类分别为:

  • 固定长度FixedLengthFrameDecoder
  • 分隔符,行分隔符LineBasedFrameDecoder,自定义分隔符DelimiterBasedFrameDecoder
  • 消息头和消息体LengthFieldBasedFrameDecoder

Netty中的零拷贝

什么是零拷贝?

个人理解,零拷贝并不是真的不拷贝,而是站在CPU的角度减少内核态和用户态的拷贝。

  • 可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率
  • 减少了用户进程地址空间和内核地址空间之间因为上:下文切换而带来的开销

Linux的I/O机制与DMA

在早期计算机中,用户进程需要读取磁盘数据,需要CPU中断和CPU参与,因此效率比较低,发起IO请求,每次的IO中断,都带来CPU的上下文切换。因此出现了DMA。

DMA(Direct Memory Access,直接内存存取)是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于CPU的大量中断负载。DMA控制器,接管了数据读写请求,减少CPU的负担。这样一来,CPU能高效工作了。现代硬盘基本都支持DMA。

实际因此IO读取,涉及两个过程:

  1. DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区;
  2. 用户进程,将内核缓冲区的数据copy到用户空间。

这两个过程,都是阻塞的。

传统数据传送机制

比如:读取文件,再用socket发送出去,实际经过四次copy。

  1. 第一次:将磁盘文件,读取到操作系统内核缓冲区;
  2. 第二次:将内核缓冲区的数据,copy到应用程序的buffer;
  3. 第三步:将应用程序buffer中的数据,copy到socket网络发送缓冲区(属于操作系统内核的缓冲区);
  4. 第四次:将socket buffer的数据,copy到网卡,由网卡进行网络传输。

9 零拷贝1.png

分析上述的过程,虽然引入DMA来接管CPU的中断请求,但四次copy是存在“不必要的拷贝”的。实际上并不需要第二个和第三个数据副本。应用程序除了缓存数据并将其传输回套接字缓冲区之外什么都不做。相反,数据可以直接从读缓冲区传输到套接字缓冲区。

显然,第二次和第三次数据copy 其实在这种场景下没有什么帮助反而带来开销,这也正是零拷贝出现的背景和意义。

sendfile零拷贝

linux 2.1支持的sendfile

当调用sendfile()时,DMA将磁盘数据复制到kernel buffer,然后将内核中的kernel buffer直接拷贝到socket buffer。在硬件支持的情况下,甚至数据都并不需要被真正复制到socket关联的缓冲区内。取而代之的是,只有记录数据位置和长度的描述符被加入到socket缓冲区中,DMA模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制(CPU copy)。

10 零拷贝2.png

一旦数据全都拷贝到socket buffer,sendfile()系统调用将会return、代表数据转化的完成。socket buffer里的数据就能在网络传输了。

sendfile会经历:3次拷贝,1次CPU copy ,2次DMA copy;

硬件支持的情况下,则是2次拷贝,0次CPU copy, 2次DMA copy。

Netty中的零拷贝

在DefaultFileRegion中包装了NIO的FileChannel.transferTo()方法实现了零拷贝:

long written = file.transferTo(this.position + position, count, target);

案例性能对比

代码如下,Server:

public class Server {
    
    public static void main(String[] args) throws Exception {

        ServerSocket serverSocket = new ServerSocket(10086);
        while (true) {
            Socket socket = serverSocket.accept();
            DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
            int byteCount = 0;
            try {
                byte[] bytes = new byte[1024];
                while (true) {
                    int readCount = dataInputStream.read(bytes, 0, bytes.length);
                    byteCount = byteCount + readCount;
                    if (readCount == -1) {
                        System.out.println("服务端接受:" + byteCount + "字节");
                        break;
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

四次拷贝client:

public class TraditionClient {

    public static void main(String[] args) throws Exception {
        Socket socket = new Socket("localhost", 10086);
        String fileName = "C:\\Users\\admin\\Pictures\\test.jpg";
        // 创建输⼊流对象
        InputStream inputStream = Files.newInputStream(Paths.get(fileName));
        // 创建输出流
        DataOutputStream dataOutputStream = new
                DataOutputStream(socket.getOutputStream());
        byte[] buffer = new byte[1024];
        long readCount = 0;
        long total = 0;
        long startTime = System.currentTimeMillis();
        // 这里要发生2次copy
        while ((readCount = inputStream.read(buffer)) >= 0) {
            total += readCount;
            // 网络发送:这里要发生2次copy
            dataOutputStream.write(buffer);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("发送总字节数:" + total + ",耗时:" + (endTime - startTime) + " ms");
        //释放资源
        dataOutputStream.close();
        socket.close();
        inputStream.close();
    }
}

sendfile client:

public class ZeroCopyClient {

    public static void main(String[] args) throws Exception {
        // socket套接字
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost", 10086));
        socketChannel.configureBlocking(true);
        // 文件
        String fileName = "C:\\Users\\admin\\Pictures\\test.jpg";
        // FileChannel 文件读写、映射和操作的通道
        FileChannel fileChannel = new FileInputStream(fileName).getChannel();
        long startTime = System.currentTimeMillis();
        // transferTo⽅法⽤到了零拷⻉,底层是sendfile,这里只需要发生2次copy和2次上下文切换
        long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);

        long endTime = System.currentTimeMillis();
        System.out.println("发送总字节数:" + transferCount + " 耗时:" + (endTime - startTime) + " ms");
        // 释放资源
        fileChannel.close();
        socketChannel.close();
    }
}

可以拿个稍微大一点的图片尝试一下,可以看到两者的耗时不是一个数量级的。

Netty中的锁机制

锁优化技术:

  1. 减少锁的粒度
  2. 减少锁对象的空间占用
  3. 提高锁的性能
  4. 根据不同的业务场景选择适合的锁
  5. 能不用锁则不用锁

减少锁的粒度

查看老版本(netty-4.1.15.Final)Bootstrap的init方法:

    @Override
    @SuppressWarnings("unchecked")
    void init(Channel channel) throws Exception {
        ChannelPipeline p = channel.pipeline();
        p.addLast(config.handler());

        final Map<ChannelOption<?>, Object> options = options0();
        synchronized (options) {
            setChannelOptions(channel, options, logger);
        }

        final Map<AttributeKey<?>, Object> attrs = attrs0();
        synchronized (attrs) {
            for (Entry<AttributeKey<?>, Object> e: attrs.entrySet()) {
                channel.attr((AttributeKey<Object>) e.getKey()).set(e.getValue());
            }
        }
    }

两次操作分别对两个对象进行锁,没有直接对方法或类上锁。

减少锁对象的空间占用

在ChannelOutboundBuffer中,没有直接使用AtomicLong,而是这样:

    // 使用原子操作类确保多线程安全
    private static final AtomicLongFieldUpdater<ChannelOutboundBuffer> TOTAL_PENDING_SIZE_UPDATER =
            AtomicLongFieldUpdater.newUpdater(ChannelOutboundBuffer.class, "totalPendingSize");

    // 统计待发送的字节数
    private volatile long totalPendingSize;

    private void incrementPendingOutboundBytes(long size, boolean invokeLater) {
        if (size == 0) {
            return;
        }

        long newWriteBufferSize = TOTAL_PENDING_SIZE_UPDATER.addAndGet(this, size);
        // 判断待发送的数据的size是否高于高水位线
        if (newWriteBufferSize > channel.config().getWriteBufferHighWaterMark()) {
            setUnwritable(invokeLater);
        }
    }
  • Atomic long:是一个对象,包含对象头(object header)以用来保存hashcode、lock等信息,32位系统占用8字节;64位系统占16字节。以64位系统为例,占用大小 = 8 bytes (volatile long)+ 16bytes (对象头)+ 8 bytes (引用) = 32 bytes
  • long: 只占用8个字节。

虽然代码实现麻烦一些,但是能够节约至少24个字节。

image.png

提高锁的性能

PlatformDependent中的newLongCounter方法:

    public static LongCounter newLongCounter() {
        if (javaVersion() >= 8) {
            // 继承了LongAdder
            return new LongAdderCounter();
        } else {
            return new AtomicLongCounter();
        }
    }

阿里开发手册也说明了:如果是jdk8,则推荐使用LongAdder,相比AtomicLong性能更好,因为减少了乐观锁的重试次数。

简单理解的话,AtomicLong在每次操作时,所有线程都在CAS都在竞争。而LongAdder可以理解为分了多个cell,每个cell会有多个线程竞争,最后获取value时会将所有cell的值相加得来。LongAdder相比AtomicLong用到了分治的思想。

11 longadder.png

根据不同的业务场景选择适合的锁

SingleThreadEventExecutor中CountDownLatch threadLock = new CountDownLatch(1),这里入参为1,直接替代了wait/notify,代码更清晰更简单那。

能不用锁则不用锁

Recycler中用到了ThreadLocal,属于轻量级的线程池实现,避免资源竞争。

FastThreadLocal中用到的InternalThreadLocalMap,继承了UnpaddedInternalThreadLocalMap,其中用到了ThreadLocal:

static final ThreadLocal<InternalThreadLocalMap> slowThreadLocalMap = new ThreadLocal<InternalThreadLocalMap>();