Netty进阶篇3:Channel到底干了些什么?

Posted roykingw

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Netty进阶篇3:Channel到底干了些什么?相关的知识,希望对你有一定的参考价值。


​ 这一节将来讨论NIO中最后一个组件,Channel。并讨论一个重要的问题, IO到底会不会丢数据。

一、Channel与流的区别

​ 在传统的标准IO下,java程序需要进行一些IO操作,例如读写文件,都只能通过流来进行。而在JDK1.4版本以后,java的NIO包中提供了另外一种增强版的IO操作方式, Channel。首先看下Channel的定义。在java.nio.channels.Channel类的开头部分,有一段注释详细解释了Channel。我把他简单翻译一下。

Channel是一种用于IO操作的连接。一个Channel代表了一个与外部设备的开放连接。这些外部设备包含硬件设备、一个文件,一个网络的socket或者是一个支持同时提供一个或多个不同的IO操作的编程组件。
Channel有两种状态, open或者close。Channel在创建时,是open状态,而一旦关闭了之后,就一直保持close状态。对于close状态的channel,所有IO操作都会抛出一个ClosedChannelException异常。
一般来说, Channel以及他的子接口与子实现类,都要设计成多线程安全的。

​ 所以,Channel可以简单认为是一种更安全,更高效的流操作。关于Channel和Stream的区别,在网上找了找,简单总结如下:

1、Stream不支持异步操作,而Channel支持。
2、Stream访问数据只能支持单向访问,所以在JVM中总是有不同的inputStream,outputStream。 而Channel可以进行双向数据传输。
3、Stream可以直接访问目标数据,而Channel必须结合Buffer使用。
4、Channel相比Stream性能更高。

​ Channel的实现类非常多,在Java开发中,由于不需要与硬件打交道,所以常用的Channel也就针对网络的NetWorkChannel和针对文件的FileChannel。

​ NetWorkChannel是一个针对网络的Channel接口。主要是支持TCP和UDP两种协议。而其中,对于TCP协议,之前NIO的各种示例代码都是基于TCP协议的。对于UDP协议,常用的是DatagramChannel。

​ UDP协议只需要发送数据,没有复杂的确认,所以相对会比较简单。 一个最简单的示例如下:

// 服务端
public class UDPServer {
    public static void main(String[] args) throws IOException {
        final DatagramChannel channel = DatagramChannel.open();
        channel.bind(new InetSocketAddress(9999));

        final ByteBuffer buffer = ByteBuffer.allocate(1024);
        while (true){
            System.out.println("=================");
            buffer.clear();
            channel.receive(buffer);
            String message = new String(buffer.array());
            System.out.println("received from client "+message);
        }
    }
}

// 客户端
public class UDPClient {
    public static void main(String[] args) throws IOException {
        final DatagramChannel channel = DatagramChannel.open();
        channel.connect(new InetSocketAddress("localhost",9999));

        String message = "Hello Server. sended from Client ,time ="+System.currentTimeMillis();

        final ByteBuffer buffer = ByteBuffer.allocate(1024);
        buffer.put(message.getBytes());
        buffer.flip();

        final int sended = channel.send(buffer, new InetSocketAddress("localhost", 9999));
        System.out.println("已发送数据 大小 "+sended);
    }
}

另外,对于服务端,也同样可以使用多路复用机制监听多个客户端。

public class UDPServer2 {
    public static void main(String[] args) throws IOException {
        final DatagramChannel channel = DatagramChannel.open();
        channel.configureBlocking(false);
        channel.bind(new InetSocketAddress(9999));

        final Selector selector = Selector.open();
        channel.register(selector, SelectionKey.OP_READ);
        while (selector.select() > 0){
            final Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()){
                final SelectionKey key = iterator.next();
                if(key.isReadable()){
                    final ByteBuffer buffer = ByteBuffer.allocate(1024);
                    channel.receive(buffer);
                    buffer.flip();
                    System.out.println("received from client "+new String(buffer.array()));
                    buffer.clear();
                }
            }
            iterator.remove();
        }
    }
}

​ 下面重点来讨论这个FileChannel。

二、从文件读写看IO到底会不会丢数据

1、神奇的文件丢失现象。

​ 我们先用流的方式,写一个简单的文件写入程序。

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;

public class FileIODemo1 {
    public static void main(String[] args) throws IOException {
        File f = new File("/root/test1.txt");
        if(!f.exists()){
            f.createNewFile();
        }
        FileOutputStream fis = new FileOutputStream(f);
        for (int i = 0; i < 100; i++) {
            fis.write("a".getBytes("utf-8"));
            fis.flush();//每写一个字符就刷一次盘。
        }
        fis.close();
    }
}

​ 然后在本机上使用Vmare搭建一台Linux虚拟机。将代码上传到Linux机器中,javac 编译, java执行。 执行之前,把/root/text1.txt文件给删掉(如果有的话)。执行完成后,可以查看到在/root/test1.txt中写入了很多个a。

​ 这个时候,不做任何操作,将vmare虚拟机直接强制断电。

​ 然后将虚拟机重启。如果你的速度比较快,大概率下,你会发现之前明明已经生成了的test1.txt不见了。

如果正常关机是不会出现文件丢失现象的,只有强制断电才会出现。原因会在后面分析。

​ 在这个小程序中,明明多次调用了flush方法,将文件内容刷到磁盘上了,为什么还是会丢数据呢?

​ 那还是要到系统底层来找原因。 我们先用strace指令,生成这个程序的系统调用日志。

strace -ff -o f1 java FileIODemo1

​ 但是这时候一头扎进去看系统调用,是很难看出个原因的,只能看到,有很多次的write系统调用,每次写入一个a。

2、使用Channel保证数据不丢失。

​ 接下来,我们用FileChannel来试一下,做另一个同样功能的小程序。

import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class FileIODemo3 {
    public static void main(String[] args) throws IOException {
        File f = new File("/root/test2.txt");
        if(!f.exists()){
            f.createNewFile();
        }
        RandomAccessFile raf = new RandomAccessFile(f,"rw");
        final FileChannel fc = raf.getChannel();

        fc.map(FileChannel.MapMode.READ_WRITE,0,5);
        final ByteBuffer byteBuffer = ByteBuffer.allocate(100);
        for (int i = 0; i < 100; i++) {
            byteBuffer.put(i,(byte)'a');
        }
        System.out.println(byteBuffer.toString());
        fc.position(0).write(byteBuffer);
        fc.force(true); //只调用一次force,进行刷盘。
        fc.close();
        raf.close();
    }
}

同样的方式再次测试。你会发现,无论怎么测试,都无法重现出之前的文件丢失现象。

为什么会这样呢?我们同样打印strace系统调用日志,来对比一下就能看到原因了。

由此可以看出FileChannel相比FileOutputStream做的优化。一是将多次的write系统调用整合成一次系统调用,从而减少了系统调用的次数。二是,force方法触发了一次fsync的系统调用,而FileOutputStream的flush方法,却并没有这个系统调用。

对于第一个优化,很显然,FileChannel通过本地缓存,将多次要写入的数据整合成一次write系统调用,性能得到了提高。其实,这种缓存功能在BufferedOutputStream中一样存在。这也是在众多带缓存功能的流的作用。有兴趣可以自己写个BufferedOutputStream的小程序,也来试一下。

对于第二个优化,write这个系统调用,只是将数据写入到操作系统的page cache中,而并没有实际写入到硬盘。操作系统如果正常运行,page cache最终是会写入到硬盘中的,例如正常关机,或者运行一段时间。但是,像强行断电这种非正常的情况,就会造成page cache来不及写入到硬盘中。重启之后,文件就丢失了。

三、Page Cache机制解读

​ page cache,中文名称为页高速缓冲存储器。这是操作系统内核中一个内存级别的缓存机制。当CPU要访问外部磁盘上的文件时,需要先将文件内容从磁盘拷贝到内存中缓存起来,以加快磁盘文件的读取。但是内存毕竟有限,如果遇到大的文件,超过空闲内存的大小,就无法缓存了。在Linux操作系统中,就会以4K为单位来组织内存。这样4K大小的一个内存块就称为一个页page。例如在Linux上使用vi打开一个文件,那vi程序会把这个文件的所有内容,都以页为单位,从硬盘上一次性加载到page cache中。这样,以后再次打开文件,就不用去硬盘中找了。而我们之前调试到的mmap系统调用,就允许程序只缓存文件中要用到的一部分内容,而不用全部缓存,当然,同样是以页为单位的。

​ 在Linux操作系统下,有多种方式可以查看页缓存。

1、整体查看页缓存,可以通过文件/proc/meninfo查看。

[root@192-168-65-174 ~]# cat /proc/meminfo 
MemTotal:       16266172 kB
.....
Cached:           923724 kB
.....
Dirty:                32 kB
Writeback:             0 kB
.....
Mapped:           133032 kB
.....

2、如果要查看某一个文件的页缓存情况,就稍微麻烦点。需要使用一个需要安装的指令pcstate来查看。 该命令需要手动安装,具体可以查看github仓库 https://github.com/tobert/pcstat 。

atobey@brak ~ $ pcstat testfile3
|-----------+----------------+------------+-----------+---------|
| Name      | Size           | Pages      | Cached    | Percent |
|-----------+----------------+------------+-----------+---------|
| LICENSE   | 11323          | 3          | 0         | 000.000 |
| README.md | 6768           | 2          | 2         | 100.000 |
| pcstat    | 3065456        | 749        | 749       | 100.000 |
| pcstat.go | 9687           | 3          | 3         | 100.000 |
| testfile3 | 102401024      | 25001      | 60        | 000.240 |
|-----------+----------------+------------+-----------+---------|

​ 这是在读取文件的时候,页缓存的机制还比较好理解。但是在写数据的时候,问题就比较复杂了。

​ 由于CPU的运行速度非常快,所以CPU在执行指令时,通常只能与缓存进行交互,而不适合直接操作像磁盘、网卡这样的硬件。也因此,在进行文件写入时,操作系统也是先写入到page cache中,缓存起来,然后再往硬件写入。这时就会带来一些问题。

​ page cache页缓存的写入与真正磁盘的写入是有时间差的。所以,总是存在一种可能,应用程序将数据都写入到了page cache中,但是却没有真正写入磁盘。例如像我们刚才的实验,使用FileOutputStream,往磁盘中写入一个新的文件。在应用程序看来,文件是写入成功了。ls、cat、less这些指令也是应用程序,我们使用这些指令也能看到文件确实写入到磁盘了。但是实际上,文件的内容还只在page cache中,并没有写入到硬盘。所以虚拟机一断电后,这个文件就丢失了。在很多高可用的场景,这就会造成数据的丢失。而这种数据丢失,在应用程序上,是很难感知到的。这也是所有重要的开源软件、应用程序需要面对的问题。

​ 在正常情况下,操作系统有稳定的机制保证page cache缓存内的数据最终都会正常写入到硬件。例如,正常关机时,操作系统就会统一将页缓存写入硬件。而在操作系统运行过程中,对于有数据修改的page页,操作系统会将他标记成脏页(dirty page)。当脏页在所有页缓存中所占的比例达到一个阈值时,操作系统也会触发页缓存的写入操作。这些都是不需要应用程序参与的。

​ 但是,考虑到高可用的场景,情况就变得复杂了。操作系统可能因为一些非正常的情况突然终止。而内存中的数据,一断电就会丢失掉了。所以,内核中提供了这样一个系统调用, fsync,允许应用程序强制触发页缓存的写入机制。例如在我们的实验过程中看到, FileChannel的force方法,实际上就是触发了一次fsync的系统调用。而由此再去观察FileOutputStream的flush操作,会发现这个flush方法是个空方法,并没有进行任何系统调用。所以才会造成数据丢失的情况。

​ 所以,这也就引出了一个非常纠结的问题,即 任何应用程序都不可能保证数据100%的不丢失。一方面,为了性能考虑,应用程序不可能每写入一点数据就调用fsync。这样的性能损耗是无法接受的。另一方面,在考虑到操作系统各种非正常情况下,应用程序也不可能保证每一次fsync系统调用都能够成功。因为应用程序发出指令,到CPU真正执行,这中间也是有时间差的。而IO数据的可靠性,就成了所有应用程序都必须综合平衡去考虑的问题。没有最好的方案,只有最适合的方案。

四、进阶篇总结

​ 在进阶篇中完成了对NIO三大核心对象的梳理与总结,这样能够更能理解IO与系统底层的关系。IO不同于其他一些上层模块,他与操作系统底层是息息相关的。这也解释了为什么我们经常听到NIO、AIO需要操作系统底层支持这样的说法。从操作系统底层来梳理BIO、NIO、AIO这些上层机制就会更清晰明了。

​ 我们都知道,Netty只是对NIO的封装,但是到底如何进行的封装?这在java代码层面,其实能看到的内容是非常有限的。而通过对NIO底层的梳理,能够更清晰的理解到Netty工作的基础。如果能够使用同样的方式对Netty程序的运行机制梳理清楚,那Netty上层那些代码就不会再如一团乱麻了。当然,友情提醒,这还是有点难度的。

以上是关于Netty进阶篇3:Channel到底干了些什么?的主要内容,如果未能解决你的问题,请参考以下文章

vue到底干了些什么,别大意,超详细解读

看看C# 6.0中那些语法糖都干了些什么(上篇)

Netty03:进阶篇,十万字教程附源码!

Java进阶作业五:使用Netty写一个EchoServer

Netty4 Channel 概述(通道篇)

Netty源码分析--Channel注册(中)