理解epoll多路复用和Reactor设计思想

1.Linux网络IO模型

在linux系统中所有的外部设备的操作都可以看作是一个文件操作,linux对文件操作的外部设备返回一个文件描述符fd(file descriptor)。对于socket的访问也有一个描述符表示,称为socketfd描述符,它表示一个数字,指向内核系统中的文件路径或者数据区等机构体。

在实际应用开发中可以将数据区域分为两个区域,一个是用户进程区域,一个内核区域,程序的数据操作都是基于用户区域的,用户区域和内核区域的数据交换称为io操作(这个定义比较狭隘,只是想对用户程序和内核之间交换的io进行总结,更详细的内容可以参考《Unix网络编程》)。

以Java的网络传输过程为例,在实际开发中为了提高传输效率,linux开发者使用了很多的缓存用来传输数据,同样的在应用进程中也是内存的处理效率高,在用户进程合内核之间的交互过程是对缓存数据之间的交换。

数据从一个用户进程通过网卡传输到内核空间,另一个用户进程访问内核空间获得这些数据。

1.1 阻塞IO

Linux系统中,缺省的情况下,所有的文件操作都是阻塞的。进程空间在进行系统调用的时候从开始调用到数据复制完成这一过程都是阻塞的,数据准备阶段、数据拷贝阶段整段过程都是阻塞的,因此称为阻塞IO。以socket调度用为例,如图1所示,阻塞IO调用全过程,用户程序在调用读取操作指导数据复制完成这一整段过程结束的时候都是阻塞的,因此被称为阻塞io。


图1 阻塞io模型

1.2 同步非阻塞IO

同步非阻塞IO是在数据准备阶段直接同步返回结果,如果没有数据就会直接返回,如果有数据就进行复制操作指导操作完成返回。内核缓冲区没有数据的时候就会直接返回一个报错,应用程序通过循环调用来判断数据是否准备完成。

图2为调用过程,假设前面的调用内核缓冲区都是没有数据的,就会直接返回用户进程,直达有数据之后才会阻塞进行数据复制。


图2 同步非阻塞io

如图所示同步非阻塞IO流程如下:
(1) 发生一次数据读取操作,如果内核没有数据,系统调用直接返回失败的标志;
(2) 如果内核缓冲区有数据,那么操作成功,并且一直阻塞到数据复制完成返回。

以上流程中可以分析出同步非阻塞IO的特点:首先同步非阻塞IO很好的避免了阻塞IO需要等待内核缓冲区数据准备的过程,用户程序不必阻塞,可以做其他的事情;与此同时也增加了轮训查询访问内核的操作,使得CPU等资源消耗增加,并且增加了轮训的操作,在实际开发中也存在一定的局限性。

1.3 IO多路复用

Linux的IO模型中增加了一种select/epoll模型,将文件描述符传递给select/epoll系统调用,当文件描述符准备就绪之后一般是内核可读写之后通知到用户进程进行相应的IO操作。

这样单个线程就可以处理多个IO事件轮训操作,当内核中的有准备就绪的IO事件之后,事件回调通知到select事件准备就绪。值得注意的是select/poll模型中是采用的还是轮训的方式来处理select事件监控,epoll采用得到是事件驱动的方式来代替顺序扫描的方式,如果有fd准备就绪的时候,立刻调用回调函数。

我们以Java NIO流程来说明IO多路复用的流程:

(1) 首先我们需要创建一个selector选择器,将所有的IO操作都注册到选择器中去;

(2) 选择器选择准备就绪的IO操作,将准备就绪的IO放到准备就绪集合中;

(3) 应用程序将主备对准备就绪的集合进行相关处理,其实就是进行IO操作;

(4) 发生IO操作,将数据从内核缓冲区到用户缓冲区中,这个过程是阻塞的。

如图3所示为IO多路复用调用图:


图3 IO多路复用调用

1.4 信号驱动IO

我的理解信号驱动和epoll模型的最大区别是信号驱动一开始会开启套接字信号驱动IO功能,应用程序通过系统执行一个信号处理程序。当内核数据准备就绪的时候就通过回调函数通知用户进程进行下一步IO操作。

而epoll采用事件驱动方式有一种异曲同工的地方(后续如果有深入研究linux调用流程之后再更新结果)。
如图4所示为信号驱动模型的调用图:


图4 信号驱动IO模型调用

1.5 异步IO

异步IO的整个IO都是异步的,简单来说就是用户线程通知系统进行一个IO操作,在IO操作过程中,包括数据准备,数据复制到用户空间整个过程都是内核自己完成,用户只需要处理接下来的业务就可以了。

如图5为异步IO模型处理过程:


图5 异步IO处理流程

2. Reactor设计模式

2.1 传统OIO模式

首先为什么要使用Reactor设计模式呢?在回答这个问题之前先看下Java传统的IO模式OIO。在Java NIO出现之前,Java一直采用的是阻塞IO模型进行编程,那么阻塞操作会出现在哪些地方呢,或者说我们会更加关心哪里会出现阻塞呢。

如图2.1所示为传统IO模式处理示意图:


图2.1 OIO请求处理示意图

图中所示一般是一个请求一个单独的处理线程。接下来看下具体的编程实现是怎么样的。如下面的一段代码,采用的就是阻塞操作:

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class IOServer{
     private ServerSocket serverSocket = null;
     private static Socket socket = null;
     private static InputStream inputStream = null;
     private static OutputStream outputStream = null;
     public IOServer() throws IOException {
     serverSocket = new ServerSocket(18010);
 }

 public void startServer() {
     try {
         //阻塞操作
         socket = serverSocket.accept();
         handler(socket);
     } catch (IOException e) {
        e.printStackTrace();
     }finally {
           try {
               stop();
              } catch (Exception e) {
                   e.printStackTrace();
              }
       }
 }

 public void handler(Socket socket) throws IOException{
     inputStream = socket.getInputStream();
     outputStream = socket.getOutputStream();
     byte[] bytes = new byte[1024];
     //阻塞操作
     int len = inputStream.read(bytes);
     System.out.println(new String(bytes,0,len,"utf-8"));
     outputStream.write("this is server".getBytes("utf-8"));
 }

 public void stop() throws Exception {
     if(null != outputStream){
         outputStream.close();
         outputStream = null;
     }
     if(null != inputStream){
         inputStream.close();
         inputStream = null;
     }
     if(null != socket){
         socket.close();
         socket = null;
     }
     if(null != serverSocket){
         serverSocket.close();
         serverSocket = null;
     }
 }
 public static void main(String[] args) throws IOException {
     new IOServer().startServer();
 }
}

看下面的代码,accpet操作是阻塞的业务处理中的,handler中的读写请求也是阻塞的,那么这样的一种IO模式将会导致一个线程的请求没有处理完成无法处理下一个请求,这样就大大降低了吞吐量,这将是一个严重的问题。

   //阻塞操作
   socket = serverSocket.accept();
   int len = inputStream.read(bytes);

为了解决这种问题就出现了一个经典的模式——Connection Per Thread即一个线程处理一个请求。

如下面这一段代码就是采用得到了多线程的方式来处理请求:

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public class IOServerMuti implements Runnable {
    private ServerSocket serverSocket = null;
    private static Socket socket = null;
    private static InputStream inputStream = null;
    private static OutputStream outputStream = null;
    
    public IOServerMuti() throws IOException {
        serverSocket = new ServerSocket(18010);

    }

    @Override
    public void run() {
        // 一个请求一个线程处理
        while (!Thread.interrupted())
            startServer();
    }

    public void startServer() {
        try {
            // 阻塞操作
            socket = serverSocket.accept();
            handler(socket);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                stop();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public void handler(Socket socket) throws IOException {
        inputStream = socket.getInputStream();
        outputStream = socket.getOutputStream();
        byte[] bytes = new byte[1024];
        // 阻塞操作
        int len = inputStream.read(bytes);
        System.out.println(new String(bytes, 0, len, "utf-8"));
        outputStream.write("this is server".getBytes("utf-8"));
    }

    public void stop() throws Exception {
        if (null != outputStream) {
            outputStream.close();
            outputStream = null;
        }
        if (null != inputStream) {
            inputStream.close();
            inputStream = null;
        }
        if (null != socket) {
            socket.close();
            socket = null;
        }
        if (null != serverSocket) {
            serverSocket.close();
            serverSocket = null;
        }
    }

}

如下面这段代码:

     // 一个请求一个线程处理
     while (!Thread.interrupted())
         startServer();

对于每一个新的请求都会分配一个新的线程来处理,这样的好处就是每个socket的请求相互之间不受影响,每个请求的业务逻辑相互之间也不影响。任何socket的读写操作都不会影响到后面的请求。

那么这样做的后果是什么呢?很显然每个链接都分配一个线程来处理,但不是每个链接都有请求发生,这样就浪费了很多的线程资源。这种情况下能不能使用一个线程来处理请求能,答案是不可以的。前面讲到的阻塞IO模型的IO读写都是阻塞的,无法做到并行处理。

这个时候可以采用多路复用IO模型的方式来处理IO事件,使用Reactor将响应IO事件和业务处理分开,一个或多个线程来处理IO事件,然后将就绪得到事件分发到业务处理handlers线程去异步非阻塞处理。

2.2 Reactor模式

2.3 单线程Reactor模式

什么是单线程Reactor模式,单线程模式采用一个Reactor线程来处理套接字,新连接的创建,并且将接收到的请求分发到处理器Handler中。

如图2.2为简单的单线程Reactor模式示意图,Reactor线程和处理器线程在一个线程里,图2.2参考doug lea论文《Scalable IO in Java》论文。


图2.2单线程Reactor模式示意图

下面用Java NIO为例说明在实际开发中用到的Reactor模式:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class ReactorDemo implements Runnable {

    private final Selector selector;
    private final ServerSocketChannel serverSocketChannel;

    public ReactorDemo() throws IOException {
        // 创建选择器
        selector = Selector.open();
        // 创建nio服务端
        serverSocketChannel = ServerSocketChannel.open();
        // 绑定端口
        serverSocketChannel.bind(new InetSocketAddress(18010));
        // 将接收事件注册到选择器上,OP_ACCEPT表示注册接收事件
        SelectionKey sk = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        // 设置回调函数
        sk.attach(new AcceptorDemo());
    }

    @Override
    public void run() {
        try {
            while (!Thread.interrupted()) {
                selector.select();
                Set selected = selector.selectedKeys();
                Iterator it = selected.iterator();
                while (it.hasNext()) {
                    // 事件准备就绪,分发到对应的handler进行处理
                    dispatch((SelectionKey) (it.next()));
                }
                selected.clear();
            }
        } catch (IOException ex) {
        }
    }

    public void dispatch(SelectionKey sk) {
        // 调用之间注册得到对象,域之前的attach对应
        Runnable r = (Runnable) (sk.attachment());
        // 调用之前注册的callback对象
        if (r != null) {
            r.run();
        }
    }

    class AcceptorDemo implements Runnable {

        @Override
        public void run() {
            // 创建连接
            SocketChannel channel;
            try {
                channel = serverSocketChannel.accept();
                if (channel != null)
                    new Handler(selector, channel);
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }

        }

    }

    class Handler {
        private SelectionKey selector;
        private SocketChannel channel;

        public Handler(Selector selector, SocketChannel channle) {
            channel.configureBlocking(false);
            sk = channel.register(selector, 0);
            // 将Handler作为callback对象
            sk.attach(this);
            // 第二步,注册Read就绪事件
            sk.interestOps(SelectionKey.OP_READ);
            selector.wakeup();
        }

        public void run() {
            read();
            send();
        }
        /************* 业务处理 ****************/
    }
}

上面演示代码即为单线程Reactor模式,与之前的OIO编程有几个变动的地方,

首先看下面的一段代码,在这种模式下面增加了一个selector选择器,并且将接收事件注册到选择器中,设置回调函数attach,当事件准备就绪得到时候直接执行AcceptorDemo处理类。

SelectionKey sk = serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
sk.attach(new AcceptorDemo());

另外看下面一段代码,这段代码有两个功能,其一是检测事件是否准备就绪,其二是将准备就绪的事件分发到对应的处理线程中去,dispatch对应前面的attach。

selector.select();
Set selected = selector.selectedKeys();
Iterator it = selected.iterator();
while (it.hasNext()){
     //事件准备就绪,分发到对应的handler进行处理
     dispatch((SelectionKey) (it.next()));
}

另外在业务处理的时候可以将IO读写事件传入到同一个Reactor中处理,如下代码所示,这段代码将读写操作注册到了同一个reactor,这样reactor和handler就在同一个线程中执行,并且将本身对象传输到attach作为回调对象,这样在回调的时候就能执行到自己的读写方法。

 channel.configureBlocking(false);
 sk = channel.register(selector, 0);
 //将Handler作为callback对象
 sk.attach(this);
 //第二步,注册Read就绪事件
 sk.interestOps(SelectionKey.OP_READ);
 selector.wakeup();

以上就是单线程Reactor的特点和实现实例,这种实现方式有存在着缺点,从实例代码中可以看出,handler的执行是串行的,如果其中一个handler处理线程阻塞将导致其他的业务处理阻塞。由于handler和reactor在同一个线程中的执行,这也将导致新的无法接收新的请求。为了解决这种问题,有人提出使用多线程的方式来处理业务,也就是在业务处理的地方加入线程池异步处理,将reactor和handler在不同的线程来执行。

2.4 多线程Reactor模式

多线程reactor模式的设计思想就是将handler线程放入到线程次中,在多核的情况下也可以考虑多个Selector选择器来处理事件,如图2.3为简单的多线程Reactor示意图;

图2.3多线程Reactor模式示意图
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ReactorDemo{

    private final Selector[] selectors = new Selector[2];
    private final ServerSocketChannel serverSocketChannel;
    Reactor[] reactors = new Reactor[2];
    SelectionKey sk;
    
    public ReactorDemo() throws IOException {
        //创建两个选择器
        selectors[0] = Selector.open();
        selectors[1] = Selector.open();
        //创建nio服务端
        serverSocketChannel = ServerSocketChannel.open();
        //绑定端口
        serverSocketChannel.bind(new InetSocketAddress(18010));
        //将接收事件注册到选择器上,OP_ACCEPT表示注册接收事件,一个选择器处理请求事件,另一个选择器处理IO读写事件
        sk = serverSocketChannel.register(selectors[0], SelectionKey.OP_ACCEPT);
        //设置回调函数
        sk.attach(new AcceptorDemo());
        //初始化反应器
        reactors[0] = new Reactor(selectors[0]);
        reactors[1] = new Reactor(selectors[1]); 
        
    }
    class Reactor implements Runnable{

        //每个线程负责一个selector
        private final Selector selector;
        
        public Reactor( Selector selector) {
            this.selector = selector;
        }
        @Override
        public void run() {
            try
            {
                while (!Thread.interrupted())
                {
                    selector.select();
                    Set selected = selector.selectedKeys();
                    Iterator it = selected.iterator();
                    while (it.hasNext()){
                        //事件准备就绪,分发到对应的handler进行处理
                        dispatch((SelectionKey) (it.next()));
                    }                
                    selected.clear();
                }
            } catch (IOException ex){}
        }
        
    }

    public void dispatch(SelectionKey sk) {
        //调用之间注册得到对象,域之前的attach对应
        Runnable r = (Runnable) (sk.attachment());
        //调用之前注册的callback对象
        if (r != null)
        {
            r.run();
        }
    }
    
    class AcceptorDemo implements Runnable{

        @Override
        public void run() {
            //创建连接
             SocketChannel channel;
            try {
                channel = serverSocketChannel.accept();
                if (channel != null)
                     new Handler(selectors[0], channel);
            } catch (IOException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
             
        }
        
    }
    
    class Handler implements Runnable{
        ExecutorService pool = new ThreadPoolExecutor(5, 10,
                60L, TimeUnit.SECONDS,
                new SynchronousQueue(),Executors.defaultThreadFactory(),new ThreadPoolExecutor.DiscardPolicy());
        public Handler(Selector selector,SocketChannel channel) throws IOException {
            channel.configureBlocking(false);
            sk = channel.register(selectors[1], 0);
            //将Handler作为callback对象
            sk.attach(this);
            //第二步,注册Read就绪事件
            sk.interestOps(SelectionKey.OP_READ);
            selector.wakeup();
        }
        
        public void handler() {
            read();
            write();
        }
        
        public void read(){};
        public void write() {}

        @Override
        public void run() {
            pool.execute(new Task());
        }
        
        class Task implements Runnable{

            @Override
            public void run() {
                Handler.this.handler(); 
            }
        }
    }
}

如上面程序所示,采用了两个选择器和两个Reactor处理器来处理IO事件,并且在handler处理线程中使用多线程,是的handler处理线程和Reactor线程分离。在Netty实现中也是将接收请求处理事件和IO读写事件分别用不同的反应器实现的。

Reactor有很多的好处,首先是响应快,线程之间不是阻塞的,reactor处理线程复用性高,一个线程可以处理多个事件。总之reactor好处多多。

当然reactor的复用模式需要操作系统的支持,如果是靠自己实现就没有那么高效了,并且reactor也一定情况下增加了编程的复杂度,不过这些都不足以让我们拒绝这样一个优秀的设计模式。

对于IO编程也是初窥门入,后面也将对netty源码进行深入的研究,希望能够有一些更加深刻的体会。

你可能感兴趣的:(理解epoll多路复用和Reactor设计思想)