Netty学习篇之零拷贝的实现

简述

本博客通过分析零拷贝在Netty中的实现及Socket事件的处理过程,讨论如何能更加深刻地清楚利用Netty实现自己的零拷贝功能。
后面所讲的内容,均建立在读者已经了解零拷贝的概念、JAVA NIO的零拷贝、Netty的基本组成及服务器/客户端的简单创建等,因此文章后面涉及到的一些Netty中的类名或是概念词汇,限于篇幅,很多时候不会再讲解,读者可以自行查考官网或是从我的其它博文中搜询。

JAVA NIO Socket连接及事件转发流程

客户端主动连接

假设已经有一个Netty Server正常工作,并监听在某个的端口,如9000端口,用户可以通过Netty Client的代码,也就是通过绑定在Bootstrap实例上的NioSocketChannel请求连接到目标地址。

Netty Server端,在初始化ServerBootstrap时,会在BossGroup线程池(指的是Netty中的EventLoopGroup类的实例)上,绑定一个监听于9000端口的NioServerSocketChannel,并为每一个EventLoop实例化一个新的JAVA NIO Selector对象,用来记录所有注册在这个EventLoop上的NioServerSocketChannel,这样就可以在自己的每一次循环过程中,来查看Selector上记录的所有channel的状态,将触发相应的事件。

如图1所示,是一个Netty Client/Server的交互用例图。当用户连接请求到达Server端时,NioEventLoop实例已经绑定了一个Selector,而这个Selector也已经注册了一个NioServerSocketChannel实例(一个不同的端口就会产生一个不同的实例,但每一个实例只会注册在一个Selector之上,避免并发问题),因此Selector可以知道NioServerSocketChannel有了新的连接请求,(NioServerSocketChannel封装了JAVA ServerSocketChannel,当有新的连接到来时,会产生一个OP_CONNECT事件),那么Selector就将NioServerSocketChannel对应的SelectionKey保存在NioEventLoop的缓存集合中,等待NioEventLoop的处理。

NioEventLoop的工作主体就是一个无限的循环,不停地调用在与自己绑定的Selector的方法,来查看每一个NioServerSocketChannel的状态,然后从自己的缓存集合中拿到所有待处理的SelectionKey(通过这个对象可以拿到对应的channel以及相应的状态),并根据相应的事件,调用相应的方法。

例如:当有一个新的客户端连接到来时,此时NioServerSocketChannel对应的状态是OP_ACCEPT(表明这个Server Channel可以接收连接),就调用它的read()方法处理,源码的代码片断如下:

    private void processSelectedKey(SelectionKey k, AbstractNioChannel ch) {
        final AbstractNioChannel.NioUnsafe unsafe = ch.unsafe();
        ...
        if ((readyOps & (SelectionKey.OP_READ | SelectionKey.OP_ACCEPT)) != 0 || readyOps == 0) {
            unsafe.read();
        }
        ...
    }

最终会执行NioServerSocketChannel中的如下方法,创建一个真正用于读、写的NioSocketChannel类的实例:

public abstract class AbstractNioMessageChannel extends AbstractNioChannel {
     @Override
    protected int doReadMessages(List<Object> buf) throws Exception {
    	// 通过NioServerSocketChannel保存的JAVA ServerSocketChannel的实例,
    	// 创建一个可以与客户端交互的JAVA SocketChannel对象。
        SocketChannel ch = SocketUtils.accept(javaChannel());

        try {
            if (ch != null) {
            	// 封装JAVA SocketChannel对象,以便于在worker group上的注册
                buf.add(new NioSocketChannel(this, ch));
                return 1;
            }
        } catch (Throwable t) {
            logger.warn("Failed to create a new channel from an accepted socket.", t);

            try {
                ch.close();
            } catch (Throwable t2) {
                logger.warn("Failed to close a socket.", t2);
            }
        }

        return 0;
    }
}

当完成新客户端连接,也就是NioSocketChannel的创建,就可以开始在这个channel上的读写了,但这类工作并不是在boss group中完成,而是会交给worker group,如图1中右侧的WorkerGroup,由于boss group与worker group是两个不同的工作组,因此需要将在BossGroup中生成的NioSocketChannel注册到WorkerGroup,才能实现这中模式。

Netty服务器端可以通过指定两个NioEventLoopGroup的实例,可以轻松构建不同的Reactor网络编程模型。

上面说到会将新创建的客户端Socket通道NioSocketChannel对象注册到worker group,实际上在Server端,会最终执行如下的方法:

	// 这个Handler类只有在ServerBootstrap绑定某个端口时,才能被使用,用来初始化NioServerSocketChannel,
	// 以保证新的客户端连接,始终能够先被这个处理。
	// 它只做一件事,就是转发新创建的,需要读写的NioSocketChannel对象到工作组线程池。
    private static class ServerBootstrapAcceptor extends ChannelInboundHandlerAdapter {
        public void channelRead(ChannelHandlerContext ctx, Object msg) {
            final Channel child = (Channel) msg;

            child.pipeline().addLast(childHandler);

            setChannelOptions(child, childOptions, logger);

            for (Entry<AttributeKey<?>, Object> e: childAttrs) {
                child.attr((AttributeKey<Object>) e.getKey()).set(e.getValue());
            }

            try {
            	// 我是NioServerSocketChannel的第一个handler,因此在创建了NioSocketChannel后,
            	// 后续的操作要么读、要么写、要么关闭,我不管了,全委托给工作组吧
                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);
            }
        }
    }

在这里插入图片描述
图1. Netty客户端主动建立连接的事件处理用例图

至此,Netty Server与客户端的交互告一段落,剩下的就是处理客户端的读取请求,也就对应于图1中WorkerGroup与Client的关系。

客户端发送数据

前面讲到在BossGroup中工作的NioServerSocketChannel实例,主要关心OP_ACCEPT事件,即在有新的连接到来时,会创建NioSocketChannel实例,然后调用注册在NioServerSocketChannel上的事件处理器类ServerBootstrapAcceptor的实例,将NioSocketChannel实例注册(转发)到WorkerGroup,由WorkerGroup中的EventLoop负责处理读写事件。

这里再啰嗦一下,Netty构架中,不论是BossGroup还是WorkerGroup,它们都是同一个类的实例,即NioEventLoopGroup,因此它们的事件处理逻辑一致,唯一不同的是WorkerGroup的每一个NioEventLoop绑定的都是NioSocketChannel,而BossGroup中的每一个NioEventLoop绑定的是NioServerSocketChannel,这里充分体现了继承的作用。

WorkerGroup中的EventLoop在循环过程中,会通过自己的Selector实例,拿到所有可读、可写的SelectionKey,然后执行processSelectedKey(…)方法,这个过程与前面的张贴的代码片断是相同,而unsafe.read()的调用,实际执行的以下的过程:

/**
 * 与客户端进行读、写交互的类,内部封装了JAVA NIO中的SocketChannel实例。
 */
public class NioSocketChannel extends AbstractNioByteChannel implements io.netty.channel.socket.SocketChannel {
	/**
	 * javaChannel()方法会返回底层的通信通道,也就是JAV NIO SocketChannel的实例,
	 * 并通过JAVA NIO ByteBuffer的接口,从SocketChannel中读取数据到字节缓存区,
	 * 最终返回给外层的调用,转发到所有当前NioSocketChannel实例绑定的Pipeline。
	 * 至于Pipeline的结构,就是通过前面提到的ServerBootstrapAcceptor类设置的。
 	 */
    @Override protected int doReadBytes(ByteBuf byteBuf) throws Exception {
        final RecvByteBufAllocator.Handle allocHandle = unsafe().recvBufAllocHandle();
        allocHandle.attemptedBytesRead(byteBuf.writableBytes());
        return byteBuf.writeBytes(javaChannel(), allocHandle.attemptedBytesRead());
    }
}
/**
 * 真正用于读取底层SocketChannel的抽象类,也是NioSocketChannel的直接父类。
 */
public abstract class AbstractNioByteChannel extends AbstractNioChannel {
    protected class NioByteUnsafe extends AbstractNioUnsafe {
        @Override
        public final void read() {
        	......
            ByteBuf byteBuf = null;
            boolean close = false;
            try {
                do {
                    byteBuf = allocHandle.allocate(allocator);
                    allocHandle.lastBytesRead(doReadBytes(byteBuf));
                    if (allocHandle.lastBytesRead() <= 0) {
                        // nothing was read. release the buffer.
                        byteBuf.release();
                        byteBuf = null;
                        // 检测客户端是否关闭
                        close = allocHandle.lastBytesRead() < 0;
                        if (close) {
                            // There is nothing left to read as we received an EOF.
                            readPending = false;
                        }
                        break;
                    }

                    allocHandle.incMessagesRead(1);
                    readPending = false;
                    // 使用pipeline来处理对象
                    pipeline.fireChannelRead(byteBuf);
                    byteBuf = null;
                } while (allocHandle.continueReading());

                allocHandle.readComplete();
                // 触发读完成事件
                pipeline.fireChannelReadComplete();

                if (close) {
                    closeOnRead(pipeline);
                }
            } catch (Throwable t) {
                handleReadException(pipeline, byteBuf, t, close, allocHandle);
            } finally {
                    removeReadOp();
                }
            }
        }
    }
}

至此,客户端的发送的数据,就能够在Server端被成功读取,同时经过各种InboundHandler的处理,最终交付到最上层的业务过程,比如强制类型转换成一个对象,然后访问对象中的各个Fields。

服务端发送数据

客户端接收数据的过程,就是服务器端通过NIO SocketChannel发送数据到客户端的过程。在Netty的代码框架下,用户可以通过如下三种方式,尝试写数据到底层的Socket通道:

 public class MyHandler extends ChannelDuplexHandler {
     private ChannelHandlerContext ctx;
 
     public void beforeAdd(ChannelHandlerContext ctx) {
         this.ctx = ctx;
     }

     public void login(String username, password) {
     	 // 消息会被转发给下一个Handler处理,最终写入到底层的Socket缓存区
         ctx.write(new LoginMessage(username, password));
         // 消息会从Pipeline的第一个handler开始处理,最终写入到底层的Socket缓存区
         ctx.pipeline().write(new LoginMessage(username, password));
         // 消息会从Pipeline的第一个handler开始处理,最终写入到底层的Socket缓存区
         ctx.channel().write(new LoginMessage(username, password));
     }
     ...
 }

这里说尝试,意思是它们的write(…)方法并不会同步地将数据写出到Socket缓存冲区,而将每一个待写出的对象对应的ByteBuff,添加到NioSocketChannel的缓存队列(ChannelOutboundBuffer)里,在NioEventLoop的循环过程中,调用SocketChannel的write(…)方法真正地向Socket缓存区中写入数据。

Netty中的零拷贝

通过前面的流程分析,我们发现,Netty内部使用ByteBuf作为应用层数据与操作系统的Socket缓存区中的数据的中间角色,实际上ByteBuf是对JAVA NIO中的ByteBuffer的封装,以便能够在用户代码层提供“零拷贝”的功能,即避免不必要的数据拷贝,注意区别于操作系统层的“零拷贝”。

一、操作系统层

通过FileRegion类,封装了JAVA NIO中的transferTo(…)方法,实现操作系统层的“零拷贝”。

FileRegion:一个文件操作类的基类,能够以零拷贝的方式完成文件内容的转移,底层依赖于JAVA的FileChannel.transferTo(…)方法,默认的实现是DefaultFileRegion,可以认为就是对FileChannel.transferTo(…)方法的封装。

例如Netty源码中有一个内置的文件服务器的样例类,提供客户端下载文件的功能,核心的代码片断如下所示:

public class FileServerHandler extends SimpleChannelInboundHandler<String> {
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        ctx.writeAndFlush("HELLO: Type the path of the file to retrieve.\n");
    }

    @Override
    public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        RandomAccessFile raf = null;
        long length = -1;
        try {
            raf = new RandomAccessFile(msg, "r");
            length = raf.length();
        } catch (Exception e) {
            ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
            return;
        } finally {
            if (length < 0 && raf != null) {
                raf.close();
            }
        }

        ctx.write("OK: " + raf.length() + '\n');
        if (ctx.pipeline().get(SslHandler.class) == null) {
            // SSL not enabled - can use zero-copy file transfer.
            ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
        } else {
            // SSL enabled - cannot use zero-copy file transfer.
            ctx.write(new ChunkedFile(raf));
        }
        ctx.writeAndFlush("\n");
    }
}

DefaultFileRegion实例在经过一系列的Handler处理后,最终会通过NioSocketChannel提供的两个方法,将文件内容从用户空间,写出到系统空间的Socket缓存区,如下面的代码所示:

public class NioSocketChannel extends AbstractNioByteChannel implements io.netty.channel.socket.SocketChannel {
	/**
	 * 一般的方法,将ByteBuf中保存的数据,对JAVA来说,要么是堆内存中的数据,要么是
	 * 堆外内存中的数据,写出到指定的JAVA NIO SocketChannel。
	 */
    @Override
    protected int doWriteBytes(ByteBuf buf) throws Exception {
        final int expectedWrittenBytes = buf.readableBytes();
        return buf.readBytes(javaChannel(), expectedWrittenBytes);
    }
	/**
	 * 调用JAVA NIO的FileChannel.transferTo(...)方法,将FileRegion中绑定的文件
	 * 内容直接从系统空间拷贝到指定的JAVA NIO SocketChannel。
	 */
    @Override
    protected long doWriteFileRegion(FileRegion region) throws Exception {
        final long position = region.transferred();
        return region.transferTo(javaChannel(), position);
    }
}

二、用户代层

在读取、写出数据时,使用Netty的ByteBuf类的各种实现类,完成“零拷贝”。

ByteBuf类,一个可以随机和顺序访问的字节内容的缓存区。一般地,通过Netty中的Unpooled辅助类来创建此类的实例。这个类主要通过两个指针来完成工作,一个是读指针,一个是写指针,就像文件指针那样,因此具体的行为跟JAVA NIO ByteBuffer并不相同。这一点尤为重要,这也就导致在程序中如果想转换Netty的ByteBuf类型到JAVA的ByteBuffer类型时,需要通过nioBufferCount()方法做一次类型判断。

CompositeByteBuf:ByteBuf的子类,可以将一个或多个ByteBuf的所包含的内存区域,组合成一个虚拟的缓存区,所谓虚拟,是指其中的每一个缓存区还是原来的ByteBuf所持有的,并不会通过拷贝的方式来将所有的这些不连续的内存区域合并成一块连续的内存区域,而是逻辑上将这些ByteBuf对象组织成一个顺序的内存区域,因此说是虚拟的。同样,推荐通过Netty中的Unpooled辅助类来创建此对象。

如下下面的代码,是Netty中的一个测试用例,展示读写ChannelOutboundBuffer保存的所有ByteBuf数据:

    @Test public void testNioBuffersExpand2() {
        TestChannel channel = new TestChannel();

        ChannelOutboundBuffer buffer = new ChannelOutboundBuffer(channel);

        CompositeByteBuf comp = compositeBuffer(256);
        ByteBuf buf = directBuffer().writeBytes("buf1".getBytes(CharsetUtil.US_ASCII));
        for (int i = 0; i < 65; i++) {
            comp.addComponent(true, buf.copy());
        }
        buffer.addMessage(comp, comp.readableBytes(), channel.voidPromise());

        assertEquals("Should still be 0 as not flushed yet", 0, buffer.nioBufferCount());
        buffer.addFlush();
        ByteBuffer[] buffers = buffer.nioBuffers();
        assertEquals(65, buffer.nioBufferCount());
        for (int i = 0;  i < buffer.nioBufferCount(); i++) {
            if (i < 65) {
                assertEquals(buffers[i], buf.internalNioBuffer(buf.readerIndex(), buf.readableBytes()));
            } else {
                assertNull(buffers[i]);
            }
        }
        release(buffer);
        buf.release();
    }

Netty在Spark中的应用

Spark定义FileSegmentManagedBuffer类,用来保存一个文件中的部分内容,主要是利用Netty的DefaultFileRegion,保存一个文件描述符、偏移量、长度信息,就能在集群中发送文件时利用“零拷贝功能”。
Spark底层的通信框架早已经基于Netty来做了,因此自然基于Netty来实现“零拷贝”。

public final class FileSegmentManagedBuffer extends ManagedBuffer {
  public FileSegmentManagedBuffer(TransportConf conf, File file, long offset, long length) {
    this.conf = conf;
    this.file = file;
    this.offset = offset;
    this.length = length;
  }
  ...
  @Override
  public Object convertToNetty() throws IOException {
    if (conf.lazyFileDescriptor()) {
      return new DefaultFileRegion(file, offset, length);
    } else {
      FileChannel fileChannel = FileChannel.open(file.toPath(), StandardOpenOption.READ);
      return new DefaultFileRegion(fileChannel, offset, length);
    }
  }
  ...
}

总结

为了实现“零拷贝”的能力,Netty提供了两种技术层面上的“零拷贝”,分别是:

  1. 用户空间层:设计了一系列基于JAVA NIO基于ByteBuffer类的封装类,即ByteBuf家庭以及相关的工具类,不仅方便在系统内部统一数据的使用方式,还向开发者提供了一个能够在应用程序层面实现“零拷贝”的方法,也就是在需要合并、拆分用户空间的缓存区数据时,避免不必要的拷贝。

  2. 系统空间层:通过封装JAVA NIO的ByteBuffer和FileChannel.transferTo(…)、FileChannel.map(…)方法,可以在一些特定的场景下,利用操作系统提供的“零拷贝”方法。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值