Web服务器详解

852 阅读12分钟

参考 《Tomcat内核设计剖析·汪建》 参考 技术世界文章 www.jasongj.com/java/nio_re…

了解Web服务器之前我们首先要了解一些基础的知识:

套接字通信

套接字通信是应用层与TCP/IP协议族的中间抽象层,是一组接口。屏蔽了底层复杂的通信协议逻辑,让我们可以简单的调用接口实现通信过程

通信方式分为两种:1.单播通信,即单点通信。 2.组播通信,1对多的通信关系,弥补单播通信的不足,比如一份数据要从某台主机传给其他若干个主机上,如果使用单播通信,数据必须依次发送,当主机数量增大时,很可能会导致网络阻塞。所以引入组播通信。

组播通信是将传输压力转移给了路由器,由路由器决定要传输给谁。由IGMP协议进行维护 路由器和主机之间的关系。因为多个路由之间互连组成的树状网络,而组内成员可能处于任何一个路由中,即树的任一叶节点。IGMP协议负责组成员的加入和退出。在这个组中的成员都会收到组播通信的数据

所以组播通信需要路由器及网络的支持才能进行,另外还需要主机也支持,在TCP/IP层面支持组播发送与接收。

IP层中需要定义一个组播地址,称之为D类地址,范围是224.0.0.0~239.255.255.255 。这些地址中又分局域网和因特网 224.0.0.0~224.0.0.255是局域网 224.0.1.0 ~ 238.255.255.255是用于因特网。

Tomcat默认的组播地址是228.0.0.4 ,Tomcat会有组播的概念要追溯到集群,因为集群涉及的共享内存问题,需要用组播通信进行数据同步。

广播通信

与组播通信类似,不过它没有IGMP协议管理组员,而是直接向路由器上所有的连接节点都发送数据,不管这个主机想不想要。所以广播通信只能用在局域网中,而组播通信可以用在公网中

Java的应用层一般都是通过调用套接字Socket来进行TCP通信的,建立连接之后还需要进行Http解析,才能进入处理流程的下一步。下面讲讲服务器的IO模型

服务器模型

这里指服务器端对于I/O的处理模型,从不同维度进行分类:阻塞I/O与非阻塞I/O,I/O处理的单线程与多线程探讨服务器模型。

单线程阻塞I/O模型

这种模型同一时刻只能处理一个客户端,并且在I/O操作上是阻塞的,线程会一直等待I/O操作完毕,而不会做其他事情。这是最简单,最笨的模型了。服务器消耗低,并发能力低,容错能力差

public class IOServer {

  private static final Logger LOGGER = LoggerFactory.getLogger(IOServer.class);

  public static void main(String[] args) {
    ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        InputStream inputstream = socket.getInputStream();
        LOGGER.info("Received message {}", IOUtils.toString(inputstream));
        IOUtils.closeQuietly(inputstream);
      }
    } catch(IOException ex) {
      IOUtils.closeQuietly(serverSocket);
      LOGGER.error("Read message failed", ex);
    }
  }
}
多线程阻塞I/O模型

多线程模型的核心是利用多线程机制为每一个客户端分配一个线程。支持对多个客户端并发响应,处理能力得到大幅提高,有比较大的并发量,但是服务器系统消耗资源大,多线程之间切换有切换成本,结构比较复杂

public class IOServerMultiThread {
  private static final Logger LOGGER = LoggerFactory.getLogger(IOServerMultiThread.class);
  public static void main(String[] args) {
  ServerSocket serverSocket = null;
    try {
      serverSocket = new ServerSocket();
      serverSocket.bind(new InetSocketAddress(2345));
    } catch (IOException ex) {
      LOGGER.error("Listen failed", ex);
      return;
    }
    try{
      while(true) {
        Socket socket = serverSocket.accept();
        new Thread( () -> {
          try{
            InputStream inputstream = socket.getInputStream();
            LOGGER.info("Received message {}", IOUtils.toString(inputstream));
            IOUtils.closeQuietly(inputstream);
          } catch (IOException ex) {
            LOGGER.error("Read message failed", ex);
          }
        }).start();
      }
    } catch(IOException ex) {
      IOUtils.closeQuietly(serverSocket);
      LOGGER.error("Accept connection failed", ex);
    }
  }
}
单线程非阻塞I/O模型

多线程模型为每一个客户端都创建一个线程,会导致机器中线程太多,而这些线程大多数情况下却处于等待状态,造成极大的资源浪费。

单线程非阻塞I/O模型最重要的一个特点是,在调用读取或写入接口后立即返回,不会进入阻塞状态。对于单线程非阻塞模型最重要的事情就是检测哪些连接会有感兴趣的事件发生 ,一般有如下三种检测方式

1)应用程序遍历套接字的事件检测

当多个客户端向服务器请求时,服务器端会保存在一个套接字连接列表中,应用层线程对套接字列表进行轮询尝试读取或写入。对于读取操作,如果成功读取到若干数据,则对读取到的数据进行处理,如果读取失败则对下一个套接字尝试----一直循环往复。对于写操作也是一样的。

不管多少个套接字连接,都可以被一个线程管理,一个线程负责遍历这个列表,不断尝试读取或者写入。很好的利用了阻塞的时间,处理能力得到提升。但是这种模型需要在应用程序中遍历所有的套接字列表,同时需要处理数据,连接大概率处于空闲状态,空闲的连接占据了较多的CPU资源,不适合实际使用。对此改进的方式时 事件驱动的非阻塞方式

2)内核遍历套接字的事件检测

这种方式时上述应用程序遍历套接字的升级版,将遍历工作交给了操作系统。对套接字的遍历结果组织成一系列的事件列表并返回到应用程序进行处理。

所以对于应用层来说,只需要处理事件和数据即可

内核会生成对应的可读列表readList和可写列表writeList,readList标明了每个套接字是否可读 为1表示可读,0表示不可读。writeList也是一样的

然而如果套接字数量变大,从内核复制到应用层也是不小的开销。另外当活跃连接数比较少的时候,内核与应用层之间就会存在很多无效的副本。所以需要过滤掉无效的不活跃的连接

3)内核基于回调的事件检测

用回调函数来优化上面出现很多无效的复制副本,内核中的套接字都对应一个回调函数,当客户端往套接字发送数据时,内核从网卡接收到数据之后就会调用 回调函数,在回调函数中维护事件列表,应用层获取此事件列表即可得到所有感兴趣的事件

  • 1.首先应用层告诉内核每个套接字感兴趣的事件
  • 2.当客户端发送数据过来时,对应会有一个回调函数,内核从网卡复制数据成功后即调用 回调函数,将其标记为可读事件 加入到事件列表中
  • 3.内核发现网卡可写时,将对应套接字标记为可写,加入到事件列表中

对于Java来说,非阻塞I/O的实现完全是基于操作系统内核的非阻塞I/O,JDK会帮我们选择非阻塞I/O的方式。一般就是上述三种方式,例如对于Linux系统,在支持epoll的情况下 JDK会优先选择epoll实现Java的非阻塞I/O,这种方式就是上述第三种 内核基于回调的事件检测

谈到epoll实现非阻塞I/O,它和select和poll都是I/O多路复用中的一员。

先说select和poll这两个函数都会使线程阻塞,和普通阻塞I/O所不同的是 这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作。

从流程上来看,使用select函数进行I/O请求和同步阻塞模型没有太大区别,甚至还多了监视Channel,以及调用select函数的额外操作。但是select最大的优势是用户可以在一个线程内同时处理多个Channel的I/O请求。用户可以注册多个Channel,然后不断地调用select读取被激活的Channel,即可达到在同一个线程同时处理多个I/O请求的目的

select/poll方法在Java NIO中的表现形式为 Selector 选择器,它同样允许一个单独的线程同时监视多个通道,可以注册多个通道到同一个选择器上,让后使用一个线程来选择已就绪的通道,这种选择机制为一个单独线程管理多个通道提供了可能。它本身也是提供事件,让内核检测事件发生然后回调

单线程非阻塞I/O模型的主要优势就是对多个连接的管理,一般在同时需要处理多个连接的场景中会使用非阻塞NIO模式。

多线程非阻塞I/O模型

这是对单线程非阻塞模型的优化,它本身的效率已经很高了,这是在多核机器上的最优策略,最大程度上利用机器的CPU资源。最自然的做法就是将客户端按组分配给若干线程,每个线程负责处理对应组内的连接。

最经典多线程非阻塞模型就是Reactor模型,首先看单线程下的Reactor,Reactor将服务器端的整个处理过程分成若干个事件,例如分为 接收事件,读事件,写事件,执行事件等。Reactor通过事件检测机制将这些事件分发给不同的处理器去处理,这些处理器包括接受连接的accpet处理器,读数据的read处理器,写数据的write处理器,以及执行逻辑的process处理器

在整个过程中只要有待处理的事件存在,既可以让Reactor线程不断往下执行,而不会阻塞在某处,所以处理效率很高

public class NIOServer {

    private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);

    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false); //配置非阻塞
        serverSocketChannel.bind(new InetSocketAddress(1234));
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); //将这个Channel 注册 到selector上 事件为 连接

        while (selector.select() > 0) {
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = keys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                if (key.isAcceptable()) {
                    ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
                    SocketChannel socketChannel = acceptServerSocketChannel.accept();
                    socketChannel.configureBlocking(false);
                    LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress()); //得知客户端端地址
                    socketChannel.register(selector, SelectionKey.OP_READ);//并将该 channel注册到selector上,事件为 读
                } else if (key.isReadable()) {
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    int count = socketChannel.read(buffer);
                    if (count <= 0) {
                        socketChannel.close();
                        key.cancel();
                        LOGGER.info("Received invalide data, close the connection");
                        continue;
                    }
                    LOGGER.info("Received message {}", new String(buffer.array()));
                    //这里可以对 socketChannel进行处理,读数据,解析http协议等
                }
                keys.remove(key);
            }
        }
    }
}

基于单线程的Reactor模型,根据实际使用场景可以把它改进为多线程模型。常见的有两种方式:

1.在耗时的process处理器中引入多线程,比如线程池。对于连接的接收,数据读取和写入操作基本耗时都较少,而逻辑处理可能耗时较长,所以在process处理器中引入线程池,process自己不处理任务而是将任务提交给线程池处理。

public class NIOServer {

  private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);

  public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.configureBlocking(false);
    serverSocketChannel.bind(new InetSocketAddress(1234));
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    while (true) {
      if(selector.selectNow() < 0) {
        continue;
      }
      Set<SelectionKey> keys = selector.selectedKeys();
      Iterator<SelectionKey> iterator = keys.iterator();
      while(iterator.hasNext()) {
        SelectionKey key = iterator.next();
        iterator.remove();
        if (key.isAcceptable()) {
          ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
          SocketChannel socketChannel = acceptServerSocketChannel.accept();
          socketChannel.configureBlocking(false);
          LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
          SelectionKey readKey = socketChannel.register(selector, SelectionKey.OP_READ);
          readKey.attach(new Processor()); //连接一个Processer对象,当获取到可读事件之后 可以取出该对象
        } else if (key.isReadable()) {
          Processor processor = (Processor) key.attachment();
          processor.process(key);
        }
      }
    }
  }
}
//attach对象是NIO提供的一种操作,但该操作并非是Reactor模式的必要操作。
//这里是为了更加方便的演示NIO接口

public class Processor {
  private static final Logger LOGGER = LoggerFactory.getLogger(Processor.class);
  private static final ExecutorService service = Executors.newFixedThreadPool(16);

  public void process(SelectionKey selectionKey) {
    service.submit(() -> {
      ByteBuffer buffer = ByteBuffer.allocate(1024);
      SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
      int count = socketChannel.read(buffer);
      if (count < 0) {
        socketChannel.close();
        selectionKey.cancel();
        LOGGER.info("{}\t Read ended", socketChannel);
        return null;
      } else if(count == 0) {
        return null;
      }
      LOGGER.info("{}\t Read message {}", socketChannel, new String(buffer.array()));
      return null;
    });
  }
}

2.直接使用多个Reactor实例,每个Reactor对应一个线程。而多个Reactor的改进可以充分利用机器的CPU资源,适合处理高并发的场景,但是程序会更复杂,更容易出现问题。顺便提一句:只有在生产环境中,才能体会到稳定的重要性。

下面的例子中,Reactor的数量为当前机器可用核数的两倍(与Netty默认的Reactor数一致)对于每个成功连接的SocketChannel,通过轮询的方式交给不同的子Reactor

public class NIOServer {

  private static final Logger LOGGER = LoggerFactory.getLogger(NIOServer.class);

  public static void main(String[] args) throws IOException {
    Selector selector = Selector.open();
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.configureBlocking(false);
    serverSocketChannel.bind(new InetSocketAddress(1234));
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    int coreNum = Runtime.getRuntime().availableProcessors();
    Processor[] processors = new Processor[2 * coreNum];
    for (int i = 0; i < processors.length; i++) {
      processors[i] = new Processor();
    }

    int index = 0;
    while (selector.select() > 0) {
      Set<SelectionKey> keys = selector.selectedKeys();
      for (SelectionKey key : keys) {
        keys.remove(key);
        if (key.isAcceptable()) {
          ServerSocketChannel acceptServerSocketChannel = (ServerSocketChannel) key.channel();
          SocketChannel socketChannel = acceptServerSocketChannel.accept();
          socketChannel.configureBlocking(false);
          LOGGER.info("Accept request from {}", socketChannel.getRemoteAddress());
          Processor processor = processors[(int) ((index++) % coreNum)]; //这里是按顺序分发,没有使用别的策略 均衡
          processor.addChannel(socketChannel);
          processor.wakeup();
        }
      }
    }
  }
}


public class Processor {
  private static final Logger LOGGER = LoggerFactory.getLogger(Processor.class);
  private static final ExecutorService service =
      Executors.newFixedThreadPool(2 * Runtime.getRuntime().availableProcessors()); //线程池大小也为机器核数的两倍

  private Selector selector;

  public Processor() throws IOException {
    this.selector = SelectorProvider.provider().openSelector();
    start();
  }

  public void addChannel(SocketChannel socketChannel) throws ClosedChannelException {
    socketChannel.register(this.selector, SelectionKey.OP_READ);
  }

  public void wakeup() {
    this.selector.wakeup();
  }

  public void start() {
    service.submit(() -> {
      while (true) {
        if (selector.select(500) <= 0) {
          continue;
        }
        Set<SelectionKey> keys = selector.selectedKeys();
        Iterator<SelectionKey> iterator = keys.iterator();
        while (iterator.hasNext()) {
          SelectionKey key = iterator.next();
          iterator.remove();
          if (key.isReadable()) {
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            SocketChannel socketChannel = (SocketChannel) key.channel();
            int count = socketChannel.read(buffer);
            if (count < 0) {
              socketChannel.close();
              key.cancel();
              LOGGER.info("{}\t Read ended", socketChannel);
              continue;
            } else if (count == 0) {
              LOGGER.info("{}\t Message size is 0", socketChannel);
              continue;
            } else {
              LOGGER.info("{}\t Read message {}", socketChannel, new String(buffer.array()));
            }
          }
        }
      }
    });
  }
}

从Tomcat7之后,Tomcat默认都是采用非阻塞的模型来做处理,它的具体方式在另一篇文章中细说