Java NIO(8) : 异步模型之状态机

本节课是小密圈《进击的Java新人》第十七周第四课。今天我们主要讲解异步模型。

我们之前讲过,阻塞和非阻塞IO的区别,今天来讲一下同步和异步的区别。异步是啥,其实我也说不好,因为有多种说法相互矛盾,这个定义很难下。我只能大概描述一下,大家领会意思。(同样是很权威的几本书,包括操作系统教程,对于异步的定义都是不同的,有人把IO多路复用归为异步,有人把它归为同步。我个人倾向于归为异步,理由后面会讲)

同步(synchronization),这个概念,我更倾向于使用操作系统中的定义:

是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。它表达了任务间的直接制约关系,A要继续执行需要B完成某一个操作操作才能继续进行。

用人话来说,就是两个人干活,有好多道工序,1,2,3,4,5,必须顺序地一件件地做。我负责1,2,4,你负责3,5。在我干完1,2之前,你什么也不做,就等着,当我干完1,2了,通知你干活了,你再接着干3,我等着你干完以后通知我,我接着干4,然后我干完了再通知你,你接着干5,最后完成整个工作。


对于现实世界来说,这是个很简单的模型。比如我干活的时候,你可以看视频,刷知乎,聊微信。等我干好了,喊你一声,我就去刷知乎了。很简单的事情。

但在计算机上就麻烦了。对应对计算机上来说,主要要回答三个问题:“我”和“你”是什么?怎么相互通知工序做完了?等待的时候就傻等着吗?

我们一个个回答,“我”和“你”其实就是执行单元的抽象,它可以是进程,也可以是线程,还可以是协程,这三个程一个比一个轻量。我们今天就只拿线程来举例,两个线程可以是在同一个物理机上,同一个进程内,也可以是在不同进程内,甚至可以在不同机器上。今天就以服务端和客户端模型来说明,这两个线程不是运行在同一个物理机上的。

第二个问题,如何通讯?这个容易回答了,使用socket进行网络通讯。

第三个问题,线程在等待另一个线程完成工作之前,只能傻等吗?这是我们今天要着重解答的问题,我个人认为傻等的情况就是同步,可以一边等一边再做点其他事的情况就是异步。从这个角度来理解的话,我倾向于把IO多路复用归到异步模型中去。

举个例子

我现在要写一个客户端,向服务端发送两个随机数,第一个数代表被除数,第二个数代表除数,然后服务端会在收到第一个数以后,向客户端报告收到被除数,收到第二个数以后做一个除法运算,并且将商发送回客户端。客户端我使用了阻塞式读写实现了同步模型:

public class EpollClient {
    public static void main(String[] args) {
        try {
            SocketChannel socketChannel = SocketChannel.open();
            socketChannel.connect(new InetSocketAddress("127.0.0.1", 8000));

            ByteBuffer writeBuffer = ByteBuffer.allocate(32);
            ByteBuffer readBuffer = ByteBuffer.allocate(32);
            byte[] buf = new byte[32];
            Random r = new Random();
            int d = 0;

            d = r.nextInt(1000);
            System.out.println(d);
            writeBuffer.put(String.valueOf(d).getBytes());
            writeBuffer.flip();
            socketChannel.write(writeBuffer);

            socketChannel.read(readBuffer);
            readBuffer.flip();
            System.out.println(new String(readBuffer.array()));

            // 这样的客户端是个大麻烦,说好了要发送数据,结果睡起觉来了
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            writeBuffer.clear();
            d = r.nextInt(10);
            System.out.println(d);
            writeBuffer.put(String.valueOf(d).getBytes());
            writeBuffer.flip();
            socketChannel.write(writeBuffer);

            readBuffer.clear();
            socketChannel.read(readBuffer);
            readBuffer.flip();
            readBuffer.get(buf, 0, readBuffer.remaining());
            System.out.println(new String(buf));

            socketChannel.close();
        } catch (IOException e) {
        }
    }
}

代码虽然长,但是很简单,发一个随机数,接受服务端的报告,休眠5秒钟。醒过来以后,继续发送第二个数。可想而知,如果使用阻塞式接口,服务端的线程会在read方法里休眠,直到客户端发送完数据为止。客户端的睡会使得服务端也跟着休眠了,服务端就不能再做其他事情了。最重要的是,不能再响应其他客户端的请求了(假如我们有1万个客户端同时发起请求)。

第一个办法,为每一个客户端连接,开一个线程,这可以解决一部分问题。服务端多开一些线程,一个线程休眠了,再有其他连接到达,我们再开一个线程就可以了。但是我们知道线程是一种很宝贵的资源,而且占据内存开销很大。虽然这是一个简单有效的方案,但还是不能解决问题。

我们前三节课重点讲了Selector,我们可以使用Selector来解决这个问题。

public class EpollServer {
    public static void main(String[] args) {
        try {
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.socket().bind(new InetSocketAddress("127.0.0.1", 8000));
            ssc.configureBlocking(false);

            Selector selector = Selector.open();
            ssc.register(selector, SelectionKey.OP_ACCEPT);

            ByteBuffer readBuff = ByteBuffer.allocate(1024);
            ByteBuffer writeBuff = ByteBuffer.allocate(128);
            writeBuff.put("received".getBytes());
            writeBuff.flip();

            while (true) {
                int nReady = selector.select();
                Set<SelectionKey> keys = selector.selectedKeys();
                Iterator<SelectionKey> it = keys.iterator();

                while (it.hasNext()) {
                    SelectionKey key = it.next();
                    it.remove();

                    if (key.isAcceptable()) {
                        SocketChannel socketChannel = ssc.accept();
                        socketChannel.configureBlocking(false);
                        SelectionKey connectionKey = socketChannel.register(selector, SelectionKey.OP_READ);
                        connectionKey.attach(new EpollTask(socketChannel, connectionKey));
                    }
                    else if (key.isReadable()) {
                        SocketChannel socketChannel = (SocketChannel) key.channel();
                        readBuff.clear();
                        socketChannel.read(readBuff);
                        readBuff.flip();


                        EpollTask conn = (EpollTask)key.attachment();
                        conn.onRead(getInt(readBuff));

                        key.interestOps(SelectionKey.OP_WRITE);
                    }
                    else if (key.isWritable()) {
                        writeBuff.rewind();
                        SocketChannel socketChannel = (SocketChannel) key.channel();

                        EpollTask conn = (EpollTask)key.attachment();
                        key.interestOps(SelectionKey.OP_READ);
                        conn.onWrite();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public static int getInt(ByteBuffer buf) {
        int r = 0;
        while (buf.hasRemaining()) {
            r *= 10;
            r += buf.get() - '0';
        }

        return r;
    }
}

这里面用到的EpollTask是这样定义的:

public class EpollTask {
    private SocketChannel socketChannel;
    private SelectionKey key;
    private int state;
    private int dividend;
    private int divisor;
    private int result;
    private ByteBuffer writeBuffer;

    public EpollTask(SocketChannel socketChannel, SelectionKey key) {
        this.socketChannel = socketChannel;
        writeBuffer = ByteBuffer.allocate(64);
        this.key = key;
    }

    public void onRead(int data) {
        if (state == 0) {
            dividend = data;
            System.out.println(dividend);
            state = 1;
        }
        else if (state == 2) {
            divisor = data;
            System.out.println(divisor);

            if (divisor == 0)
                result = Integer.MAX_VALUE;
            else
                result = dividend / divisor;
            state = 3;
        }
        else {
            throw new RuntimeException("wrong state " + state);
        }
    }

    public void onWrite() {
        try {
            if (state == 1) {
                writeBuffer.clear();
                writeBuffer.put("divident".getBytes());
                writeBuffer.flip();
                socketChannel.write(writeBuffer);
                state = 2;
            }
            else if (state == 3) {
                writeBuffer.clear();
                writeBuffer.put(String.valueOf(result).getBytes());
                writeBuffer.flip();
                socketChannel.write(writeBuffer);

                socketChannel.close();
                key.cancel();
                state = 4;
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这就是一个状态机模型的例子。我们的服务端虽然只有一个线程,但是当多个客户端来连接的时候,仍然可以快速响应。也就是说,ABC三台客户端去连接服务端的时候,在等待A继续发第二个数过来的时候,服务端仍然有能力去读取B和C的第一个数。运行一下,看结果:

服务端:

客户端A:

客户端B:

客户端C:

可以看到,服务端接受数据的顺序是A的第一个数,B的第一个数,C的第一个数,然后是A的第二个数,B二,C二。换句话说,服务端在等待A的时候,可以同时去做其他的任务。所以,我个人是倾向于将IO多路复用归类开异步模型。

今天先给代码。明天再讲解这个Server实现的思路。今天没有作业,把文章中的代码跑通就行了。里面有些接口用的不好,自己去改一下,我这里也就是随手写的,不要太在意。

上一节课:Java NIO(7):Epoll版的Selector

下一节课:Java NIO(9) : 状态机

目录:课程目录

编辑于 2017-06-19

文章被以下专栏收录