Tomcat内核设计剖析
上QQ阅读APP看书,第一时间看更新

第1章 Web服务器机制

所有的Web服务器都根据规定好的协议机制进行不同的实现及扩展。有的Web服务器只能处理静态资源,而有的可以完成动态处理。有的Web服务器用C++语言实现,而有的用Java语言实现。但不管Web服务器具体如何实现及扩展,它都必须要遵循基本的协议规定。在深入研究Tomcat之前很有必要先了解Web服务器的一些机制。

本章分别从通信协议、Socket通信、Web服务器模型三方面对Web服务器机制进行介绍。

1.1 通信协议

1.1.1 HTTP/HTTPS

HTTP是Hyper Text Transfer Protoco(l超文本传输协议)的缩写。HTTP协议是用于从Web服务器传输超文本到本地浏览器的协议,它能使浏览器更加高效,使网络传输减少,保证计算机正确快速地传输超文本文档。现在我们普遍使用的版本是HTTP1.1。

HTTP是一个应用层协议,它由请求和响应组成,是一个标准的B/S模型。同时,它也是一个无状态的协议,即同一个客户端上,此次请求与上一次请求是没有对应关系的。

而HTTPS简单地说就是HTTP的安全版。通常,在安全性要求比较高的网站(例如银行网站)上会看到HTTPS,它本质上也是HTTP协议,只是在HTTP增加了一个SSL或TLS协议层。如图1.1所示,如果在TCP协议上加一层SSL或TLS协议,就构成HTTPS协议了。SSL/TLS协议提供了加解密的机制,所以它比HTTP明文传输更安全。从图1.1中可以看出, HTTP可以直接进入TCP传输层,也可以在TCP层上加一层SSL/TLS层,这样就先经过SSL/TLS再进入TCP传输层。这两种方式便是HTTP与HTTPS。一般HTTP的端口号为80,而HTTPS的端口号为443。

▲图1.1 HTTP与HTTPS

简单地说,SSL/TLS协议层主要的职责就是借助下层协议的信道安全地协商出一份加密密钥,并且用此密钥来加密HTTP请求响应报文。它解决了以下三个安全性方面的议题。

提供验证服务,验证本次会话实体身份的合法性。

提供加密服务,强加密机制能保证通信过程中的消息不会被破译。

提供防篡改服务,利用Hash算法对消息进行签名,通过验证签名保证通信内容不被篡改。

HTTPS运用越来越广泛,而且在安全场景中它是一个很好的解决方案,一般作为解决安全传输的首选解决方案。下面深入了解一下HTTPS的工作原理及流程。

在理解HTTPS工作原理前,先了解一些加密解密算法与Hash算法。

对称加密。密钥只有一个,加密、解密都是这个密码,加解密速度快,典型的对称加密算法有DES、AES、RC4等。

非对称加密。密钥成对出现,分别为公钥与私钥,从公钥无法推知私钥,反之,从私钥也不能推知公钥。加密、解密使用不同的密钥,公钥加密需要私钥解密,反之,私钥加密需要公钥解密。非对称加密速度较慢,典型的非对称加密算法有RSA、DSA、DSS等。

Hash算法,这是一种不可逆的算法,它常用于验证数据的完整性。

图1.2详细描述了HTTPS完成一次通信要做哪些事情。因为HTTPS是基于TCP/IP协议通信的,属于可靠传输,所以它必须要先进行三次握手,完成连接的建立。接着是SSL的握手协议,此协议非常有效地让客户和服务器之间完成相互之间的身份验证及密钥协商。

▲图1.2 HTTPS的工作原理及流程

① 客户端浏览器向服务器发送SSL/TLS协议的版本号、加密算法的种类、产生的随机数,以及其他需要的各种信息。

②服务器从客户端支持的加密算法中选择一组加密算法与Hash算法,并且把自己的证书(包含网站地址、加密公钥、证书颁发机构等)也发送给客户端。

③ 浏览器获取服务器证书后验证其合法性,验证颁发机构是否合法,验证证书中的网址是否与正在访问的地址一致,通过验证的浏览器会显示一个小锁头,否则,提示证书不受信。

④ 客户端浏览器生成一串随机数并用服务器传来的公钥加密,再使用约定好的Hash算法计算握手消息,发送到服务器端。

⑤服务器接到握手消息后用自己的私钥解密,并用散列算法验证,这样双方都有了此次通信的密钥。

⑥服务器再使用密钥加密一段握手消息,返回给客户端浏览器。

⑦ 浏览器用密钥解密,并用散列算法验证,确定算法与密钥。

完成以上7步后双方就可以利用此次协商好的密钥进行通信。

1.1.2 HTTP请求/响应模型

从某种意义上来说,HTTP协议永远都由客户端发起请求,由服务器进行响应并发送回响应报文。如果没有客户端进行请求或曾经请求过,那么服务器是无法将消息推送到客户端的。HTTP采用了请求/响应模型,一个HTTP请求与响应一般如图1.3所示,客户端向服务器发送一个请求,请求头包含请求方法、URI、协议版本、请求修饰符、客户信息,以及类似于MIME结构的消息内容。服务器以一个状态行作为响应,内容包括消息协议版本、成功(或失败)编码、服务器信息、实体元信息及一些实体内容。这样就完成了一个请求/响应过程。

▲图1.3 HTTP请求/响应模型

通常,一个HTTP请求/响应的工作流程大概可以用以下4步来概括。

① 客户端浏览器先要与服务器建立连接,即通过三次握手建立连接。在浏览器上最常见的场景就是单击一个链接,这就触发了连接的建立。

② 连接建立后,客户端浏览器发送一个请求到服务器,这个过程其实是组装请求报文的过程,详细的报文格式与解析会在下一节介绍。

③服务器端接收到请求报文后,对报文进行解析,组装成一定格式的响应报文,返回给客户端。

④ 客户端浏览器接收到响应报文后,通过浏览器内核对其进行解析,按照一定的外观进行显示,然后与服务器断开连接。

1.1.3 解析HTTP报文

上一节介绍了HTTP请求/响应模型,那么具体请求与响应报文格式是怎样的?报文又是怎样解析的?本节将论述HTTP报文解析的整体格式。要深入理解Web服务器就必须对HTTP协议报文有所了解。HTTP报文是面向文本的,报文中每个字段都是一些ASCII码串,它包括请求报文和响应报文。

首先看看HTTP请求报文。一个HTTP请求由三部分组成:请求行、请求头部、请求体。图1.4详细展示了一个HTTP请求报文的结构。请求行(request line)由请求方法字段、URL字段和HTTP协议版本字段组成,它们用空格分隔并以“\r\n”结尾。请求头部(request header)包含若干个属性与属性值,它们通过冒号分隔,格式为“属性名:属性值”,每个属性-属性值对以“\r\n”结尾,整个请求头部又以“\r\n”结尾。请求体(request body)一般在POST方法里使用,而不在GET方法中使用,例如浏览器将表单中的组件格式化成param1=value1&param2=value2键值对组,然后将其存放至请求体中,以此完成对表单参数的传输。

▲图1.4 HTTP请求报文

GET和POST是最常见的请求方法,除此之外,还包括DELETE、HEAD、OPTIONS、PUT、TRACE。当我们单击网页链接或在浏览器输入网址访问时,就使用了GET方法,请求参数和值附加在URL后面,用问号隔开,如/index.jsp? id=10000。用GET方法传递的参数都能在地址栏上看到,大多浏览器对地址的字符长度做了限制,最多是1024个字符,所以要传送大量数据,就要选择用POST方法。POST方法允许客户端提交更多信息给服务器,它把请求参数封装到请求体中,可以传输大量数据,不会对数据大小进行限制,同时也不在地址栏显示参数。其他请求方法不再展开讨论,感兴趣的读者可查阅相关资料。

请求头部常见的典型属性有以下几种。

User-Agent:客户端请求的浏览器类型,更确切地说,是客户端应用程序的名称,不同版本、不同厂商的值都可能不相同。

Accept:告诉服务器客户端可识别的媒体类型列表。这个属性的值可以是一个或多个MIME类型的值,服务器可以根据这个判断是否发送这个媒体类型。

Host:供客户端访问的那台机器的主机名和端口号。

Cookie:用于传输客户端的Cookie到服务器,服务器维护的Session就是通过Cookie附带的JSESSIONID值来区分哪个客户端关联哪个Session的。当然,我们还可以通过重写URL的方式将JSESSIONID附带在URL后面。

Referer:表示这个请求是从哪个URL过来的,可以让服务器知道客户端从哪里获得其请求的RUL。例如在A网站的页面单击一个链接进入B网站的页面,浏览器就会在请求中插入一个带有A网站中该页面地址的Referer头部。

Cache-Control:通过这个属性可以对缓存进行控制。

接着看HTTP响应报文。与请求报文一样,响应报文由三部分组成:响应行、响应头部、响应体(如图1.5所示)。响应行(response line)包含协议及版本、状态码及描述,并以“\r\n”结尾。响应头部(response header)包含若干个属性与属性值,它们通过冒号分隔,格式为“属性名:属性值”,每个属性-键值对都以“\r\n”结尾,并且响应头部最后以“\r\n”结尾。响应体(response body)一般存放我们真正需要的文本。

▲图1.5 HTTP响应报文

响应状态码由三位数字组成,常用的状态码如下。

200 OK:客户端请求成功。

400 Bad Request:客户端请求有语法错误,服务器无法识别。

401 Unauthorized:请求未经授权。

403 Forbidden:服务器收到请求,但拒绝提供服务。

404 Not Found:请求资源不存在。

500 Internal Server Error:服务器发生不可预期的错误。

503 Server Unavailable:服务器当前不能处理客户端的请求,一段时间后可能恢复正常。常用的响应报文头属性如下。

Cache-Control:服务器通过该报文头属性告诉客户端如何对响应的内容进行缓存,例如,值为max-age=600,则表示客户端对响应内容缓存600秒,在此期间,如果客户端再次访问该资源,可以直接从客户端缓存中获取内容,不必再向服务器获取。

Location:这个属性用于网页重定向,例如,服务器把重定向的地址添加到响应报文头部的这个属性,这样客户端浏览器解析报文后就直接重新跳转到这个地址。

Set-Cookie:利用这个属性服务器端可对客户端的Cookie进行设置。

1.2 套接字通信

套接字通信是应用层与TCP/IP协议族通信的中间抽象层,它是一组接口。应用层通过调用这些接口发送和接收数据。一般这种抽象层由操作系统提供或者由JVM自己实现。使用套接字通信可以简单地实现应用程序在网络上的通信。一台机器上的应用向套接字中写入信息,另外一台相连的机器能读取到。TCP/IP协议族中有两种套接字类型,分别是流套接字和数据报套接字,分别对应TCP协议和UDP协议。一个TCP/IP套接字由一个互联网地址、一个协议及一个端口号唯一确定。

如图1.6所示,套接字抽象层位于传输层与应用层之间。增加这一层不但很有必要而且很有用。它类似于设计模式中的门面模式,用户没必要知道和处理复杂的TCP/IP协议族业务逻辑的细节,这时套接字就展现出它的优势了。它把这些复杂的处理过程都隐藏在套接字接口下面,帮助用户解析组织TCP/IP协议族报文数据,以符合TCP/IP协议族,这样用户只要简单调用接口即可实现数据的通信操作。

▲图1.6 套接字通信

1.2.1 单播通信

单播通信是网络节点之间通信方式的一种。单个网络节点与单个网络节点之间的通信就称为单播通信。它是一种一对一的模式,发送、接收信息只在两者之间进行,同时它也是最常见的一种通信。如图1.7所示,你浏览网页访问服务器时发生的通信属于单播通信,报文的发送与接收发生在你的电脑与网站的服务器之间。

▲图1.7 单播通信

Java提供了JDK库,能方便实现单播通信。

在服务器端实现单播通信的代码如下。

      Public class SocketServer {
          public static void main(String[] args) {
              ServerSocket serverSocket = null;
              try {
                  serverSocket = new ServerSocket(8888);
                  Socket socket = serverSocket.accept();
                  DataOutputStream dos = new DataOutputStream(socket
                          .getOutputStream());
                  DataInputStream dis = new DataInputStream(socket.getInputStream());
                  System.out.println("服务器接收到客户端的连接请求:" + dis.readUTF());
                  dos.writeUTF("接受连接请求,连接成功!");
                  socket.close();
                  serverSocket.close();
              } catch (IOException e) {
                  e.printStackTrace();
              }
          }
      }

首先,绑定本地8888端口,然后调用accept()方法进行阻塞,等待客户端的连接,一旦有连接到来就创建一个套接字并返回。接着,获取输入/输出流,输入流用于获取客户端传输的数据,而输出流则用来向客户端响应发送数据,处理完后关闭套接字。为了简化代码,这里完成一次响应后便把ServerSocket关闭。

在客户端实现单播通信的代码如下。

      public class SocketClient {
      public static void main(String[] args) {
                Socket socket = null;
      try {
                socket = new Socket("localhost",8888);
                DataOutputStream dos = new DataOutputStream(socket
                          .getOutputStream());
                DataInputStream dis = new DataInputStream(socket.getInputStream()
      );
                dos.writeUTF("我是客户端,请求连接!");
                System.out.println(dis.readUTF());
                socket.close();
              } catch (UnknownHostException e) {
                e.printStackTrace();
      } catch (IOException e) {
                e.printStackTrace();
              }
          }
      }

服务器端的8888端口已经处于监听状态,客户端如果要与之通信,只须简单地先指定服务器端IP与端口号以实例化一个套接字,然后获取套接字的输出流与输入流。输出流用于向服务器发送数据,输入流用于读取服务器发送过来的数据。交互处理完后关闭套接字。

1.2.2 组播通信

组播通信是为了优化单播通信某些场景下的不足。例如,一份数据要从某台主机发送到其余若干台主机上,这时如果还是使用单播通信模式,数据必须依次发送给其他若干台主机。单播通信的一个特点就是有多少台主机就要发送多少次,当主机的数量越来越大时可能会导致网络阻塞。此外,这种传送方式效率极低。于是引入了组播通信的概念。

如图1.8所示,(a)图为单播通信模式,机器S1向机器S2、S3和S4发送消息时必须发送三次,且每次都是从S1出发到各自目的地,传输效率低且浪费网络资源;(b)图为组播通信模式,S1向S2、S3和S4发送消息只须S1发送一次到路由器,连接S2、S3、S4客户端的路由器将负责向它们发送消息,解决了传输效率低及浪费网络资源的问题。

▲图1.8 单播模式与组播模式

所以组播通信其实是为了弥补单播通信在某些使用场景的局限性,它是一种一对多的传播方式。假如某个主机结点想接收相关的信息,它只需要向路由器或交换机申请加入某组即可,路由器或交换机在接收到相关信息后就会负责向组内所有成员发送信息。组播通信有以下特点:

节省网络资源;

有针对性地向组内成员传播;

可以在互联网上进行传播;

没有可靠传输协议,会导致数据不可靠。

组播通信中最重要的内容是如何维护路由器与主机之间的关系,其主要通过IGMP协议进行维护。它主要维护不同路由器与不同主机之间的成员关系,具体的维护方式比较复杂,因为涉及多个路由器且路由之间互相连接组成一个树状网络,而组内成员可能处于任何一个路由中,即树的任何叶结点,所以需要复杂的算法去维护这些关系才知道信息要往哪里发送。IGMP协议主要负责组成员的加入和退出、组内成员查询等功能,使用组播通信需要通过IGMP协议申请加入组成员才能接收组播的消息,而退出组后将无法接收消息。

因为组播通信相当于把主机与主机之间的通信压力转嫁到了路由器上面,所以要得到路由及网络的支持才能进行组播通信,整个传输过程中涉及的路由器或交换机都要支持组播通信,否则将无法使用组播通信。另外,你的主机必须支持组播通信,在TCP/IP层面支持组播发送与接收。

在IP层面需要一个组播地址以指定组播,它称为D类地址,范围是224.0.0.0~239.255.255.255。这些地址根据范围大致分为局域网地址和因特网地址,224.0.0.0~244.0.0.255用于局域网,224.0.1.0~238.255.255.255用于因特网。Tomcat默认的组播地址为228.0.0.4,而Tomcat为何会涉及组播通信则要归到集群的概念,因为集群涉及内存的共享问题,所以需要使用组播通信进行数据同步,第20章和第21章将进行更加深入的探讨。

在单播通信模式中有服务器端和客户端之分,而组播通信模式与单播通信模式不同,每个端都是以路由器或交换机作为中转广播站,任意一端向路由器或交换机发送消息,路由器或交换机负责发送给其他节点,每个节点都是等同的。

为方便开发者实现组播通信,Java在JDK中提供了java.net.MulticastSocket类。下面展示一个简单的例子,说明两个节点之间通过组播通信传输消息。

① 节点1,指定组播地址为228.0.0.4,端口号为8000。节点1通过调用MulticastSocket的JoinGroup 方法申请将节点1加入到组播队伍中,接着使用一个无限循环往组里发“Hello from node1”消息,这是为了方便节点2加入后接收节点1的消息。需要说明的是,组播通信是通过DatagramPacket对象发送消息的,调用MulticastSocket的Send方法即可把消息发送出去。为了缩减例子长度,这里省去了退出组及关闭套接字的一些操作,实际使用中须完善。

      public class Node1{
          private static int port = 8000;
          private static String address = "228.0.0.4";
          public static void main(String[] args) throws Exception {
              try {
                  InetAddress group = InetAddress.getByName(address);
                  MulticastSocket mss = null;
                  mss = new MulticastSocket(port);
                  mss.joinGroup(group);
                  while (true) {
                      String message = "Hello from node1";
                      byte[] buffer = message.getBytes();
                      DatagramPacket dp = new DatagramPacket(buffer, buffer.length,
                                group, port);
                      mss.send(dp);
                      Thread.sleep(1000);
                  }
              } catch (IOException e) {
                  e.printStackTrace();
              }
          }
      }

② 节点2,指定同样的组播地址与端口,以申请加入与节点1相同的组播组。接着通过循环不断接收从其他节点发送的消息,通过MulticastSocket的Receive方法可读取消息,将不断接收到从节点1发送的消息“receive from node1:Hello from node1”。当然,节点2也可以向组播组发送消息,因为每个节点都是等同的,只要其他节点对组播消息进行接收。如果你还想增加其他节点,尽管申请加入组播组,所有节点都可以接收、发送消息。

      public class Node2 {
          private static int port = 8000;
          private static String address = "228.0.0.4";
          public static void main(String[] args) throws Exception {
              InetAddress group = InetAddress.getByName(address);
              MulticastSocket msr = null;
              try {
                  msr = new MulticastSocket(port);
                  msr.joinGroup(group);
                  byte[] buffer = new byte[1024];
                  while (true) {
                      DatagramPacket dp = new DatagramPacket(buffer, buffer.length);
                      msr.receive(dp);
                      String s = new String(dp.getData(), 0, dp.getLength());
                      System.out.println("receive from node1:"+s);
                  }
              } catch (IOException e) {
                  e.printStackTrace();
              }
          }
      }

1.2.3 广播通信

上一节说到的组播通信是一种一对多的传播方式,同样属于一对多的传播方式的还有广播通信。它与组播通信又有不同的地方。广播通信的重点在于广,它向路由器连接的所有主机都发送消息而不管主机想不想要,虽然浪费了网络资源,但它可以不用维护路由器与主机之间的成员关系。组播通信的重点在于组,它只会向加入了组的所有成员发送消息,具有针对性强、不浪费网络资源的特点。广播通信只能在局域网内传播,组播通信能在公网内传播。

如图1.9所示,在某局域网内,机器S1向网络中广播消息,网络中其他机器都将接收到消息。机器S2、S3、S4、S5和S6预先启动进程监听端口,S1将消息发往交换机,交换机负责将消息广播到这些机器上。

▲图1.9 广播通信

Java的JDK为我们提供了java.net.DatagramSocket类以实现广播通信功能。

在接收端,监听8888端口,一旦接收到广播消息则输出消息。

      public class BroadCastReceiver {
          public static void main(String[] args) {
              try {
                  DatagramSocket ds = new DatagramSocket(8888);
                  byte[] buf = new byte[5];
                  DatagramPacket dp = new DatagramPacket(buf, buf.length);
                  ds.receive(dp);
                  System.out.println(new String(buf));
              } catch (Exception e) {
                  e.printStackTrace();
              }
          }
      }

在发送端,所属的网段为192.168.0,子网掩码为255.255.255.0,所以广播地址为192.168.0.255,然后往该网络中所有机器的8888端口发送“hello”消息,接收端将接收到此消息。

      public class BroadCastSender {
          public static void main(String[] args) {
              try {
                  InetAddress ip = InetAddress.getByName("192.168.0.255");
                  DatagramSocket ds = new DatagramSocket();
                  String str = "hello";
                  DatagramPacket dp = new DatagramPacket(str.getBytes(),
                          str.getBytes().length, ip, 8888);
                  ds.send(dp);
                  ds.close();
              } catch (Exception e) {
                  e.printStackTrace();
              }
          }
      }

1.3 服务器模型

这里探讨的服务器模型主要指的是服务器端对I/O的处理模型。从不同维度可以有不同的分类,本节将从I/O的阻塞与非阻塞、I/O处理的单线程与多线程角度探讨服务器模型。对于I/O,可以分成阻塞I/O与非阻塞I/O两大类型。阻塞I/O在做I/O读写操作时会使当前线程进入阻塞状态,而非阻塞I/O则不进入阻塞状态。对于线程,单线程情况下由一条线程负责所有客户端连接的I/O操作,而多线程情况下则由若干线程共同处理所有客户端连接的I/O操作。下面将对线程和(非)阻塞组合成的模型进行分析,看看各种服务器模型有哪些不同,各自的优缺点又有哪些。

1.3.1 单线程阻塞I/O模型

单线程阻塞I/O模型是最简单的一种服务器模型,几乎所有程序员在刚开始接触网络编程时都从这个简单的模型开始。这种模型只能同时处理一个客户端访问,并且在I/O操作上是阻塞的,线程会一直在等待,而不会做其他事情。对于多个客户端访问,必须要等到前一个客户端访问结束才能进行下一个访问的处理,请求一个一个排队,只提供一问一答服务。

图1.10展示了同步阻塞服务器响应客户端访问的时间节点图。首先,服务器必须初始化一个套接字服务器,并绑定某个端口号并使之监听客户端的访问。接着,客户端1调用服务器的服务,服务器接收到请求后对其进行处理,处理完后写数据回客户端1,整个过程都是在一个线程里面完成的。最后,处理客户端2的请求并写数据回客户端2,期间就算客户端2在服务器处理完客户端1之前就进行请求,也要等服务器对客户端1响应完后才会对客户端2进行响应处理。

▲图1.10 单线程阻塞I/O模型

这种模型的特点在于单线程和阻塞I/O。单线程即服务器端只有一个线程处理客户端的所有请求,客户端连接与服务器端的处理线程比是n:1,它无法同时处理多个连接,只能串行处理连接。而阻塞I/O是指服务器在读写数据时是阻塞的,读取客户端数据时要等待客户端发送数据并且把操作系统内核复制到用户进程中,这时才解除阻塞状态。写数据回客户端时要等待用户进程将数据写入内核并发送到客户端后才解除阻塞状态。这种阻塞给网络编程带来了一个问题,服务器必须要等到客户端成功接收才能继续往下处理另外一个客户端的请求,在此期间线程将无法响应任何客户端请求。

该模型的特点:它是最简单的服务器模型,整个运行过程都只有一个线程,只能支持同时处理一个客户端的请求(如果有多个客户端访问,就必须排队等待),服务器系统资源消耗较小,但并发能力低,容错能力差。

1.3.2 多线程阻塞I/O模型

针对单线程阻塞I/O模型的缺点,我们可以使用多线程对其进行改进,使之能并发地对多个客户端同时进行响应。多线程模型的核心就是利用多线程机制为每个客户端分配一个线程。如图1.11所示,服务器端开始监听客户端的访问,假如有两个客户端发送请求过来,服务器端在接收到客户端请求后分别创建两个线程对它们进行处理,每条线程负责一个客户端连接,直到响应完成。期间两个线程并发地为各自对应的客户端处理请求,包括读取客户端数据、处理客户端数据、写数据回客户端等操作。

▲图1.11 多线程阻塞I/O模型

这种模型的I/O操作也是阻塞的,因为每个线程执行到读取或写入操作时都将进入阻塞状态,直到读取到客户端的数据或数据成功写入客户端后才解除阻塞状态。尽管I/O操作阻塞,但这种模式比单线程处理的性能明显高了,它不用等到第一个请求处理完才处理第二个,而是并发地处理客户端请求,客户端连接与服务器端处理线程的比例是1:1。

多线程阻塞I/O模型的特点:支持对多个客户端并发响应,处理能力得到大幅提高,有较大的并发量,但服务器系统资源消耗量较大,而且多线程之间会产生线程切换成本,同时拥有较复杂的结构。

1.3.3 单线程非阻塞I/O模型

多线程阻塞I/O模型通过引入多线程确实提高了服务器端的并发处理能力,但每个连接都需要一个线程负责I/O操作。当连接数量较多时可能导致机器线程数量太多,而这些线程大多数时间却处于等待状态,造成极大的资源浪费。鉴于多线程阻塞I/O模型的缺点,有没有可能用一个线程就可以维护多个客户端连接并且不会阻塞在读写操作呢?下面介绍单线程非阻塞I/O模型。

单线程非阻塞I/O模型最重要的一个特点是,在调用读取或写入接口后立即返回,而不会进入阻塞状态。在探讨单线程非阻塞I/O模型前必须要先了解非阻塞情况下套接字事件的检测机制,因为对于单线程非阻塞模型最重要的事情是检测哪些连接有感兴趣的事件发生。一般会有如下三种检测方式。

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

如图1.12所示,当多个客户端向服务器请求时,服务器端会保存一个套接字连接列表中,应用层线程对套接字列表轮询尝试读取或写入。对于读取操作,如果成功读取到若干数据,则对读取到的数据进行处理;如果读取失败,则下一个循环再继续尝试。对于写入操作,先尝试将数据写入指定的某个套接字,写入失败则下一个循环再继续尝试。

▲图1.12 非阻塞遍历套接字

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

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

这种方式将套接字的遍历工作交给了操作系统内核,把对套接字遍历的结果组织成一系列的事件列表并返回应用层处理。对于应用层,它们需要处理的对象就是这些事件,这就是其中一种事件驱动的非阻塞方式的实现。

如图1.13所示,服务器端有多个客户端连接,应用层向内核请求读写事件列表。内核遍历所有套接字并生成对应的可读列表readList和可写列表writeList。readList标明了每个套接字是否可读,例如套接字1的值为1,表示可读,socket2的值为0,表示不可读。writeList则标明了每个套接字是否可写。应用层遍历读写事件列表readList和writeList,做相应的读写操作。

▲图1.13 内核遍历套接字的事件检测

内核遍历套接字时已经不用在应用层对所有套接字进行遍历,将遍历工作下移到内核层,这种方式有助于提高检测效率。然而,它需要将所有连接的可读事件列表和可写事件列表传到应用层,假如套接字连接数量变大,列表从内核复制到应用层也是不小的开销。另外,当活跃连接较少时,内核与应用层之间存在很多无效的数据副本,因为它将活跃和不活跃的连接状态都复制到应用层中。

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

通过遍历的方式检测套接字是否可读可写是一种效率比较低的方式,不管是在应用层中遍历还是在内核中遍历。所以需要另外一种机制来优化遍历的方式,那就是回调函数。内核中的套接字都对应一个回调函数,当客户端往套接字发送数据时,内核从网卡接收数据后就会调用回调函数,在回调函数中维护事件列表,应用层获取此事件列表即可得到所有感兴趣的事件。

内核基于回调的事件检测方式有两种。第一种是用可读列表readList和可写列表writeList标记读写事件,套接字的数量与readList和writeList两个列表的长度一样,readList第一个元素标为1则表示套接字1可读,同理,writeList第二个元素标为1则表示套接字2可写。如图1.14所示,多个客户端连接服务器端,当客户端发送数据过来时,内核从网卡复制数据成功后调用回调函数将readList第一个元素置为1,应用层发送请求读、写事件列表,返回内核包含了事件标识的readList和writeList事件列表,进而分表遍历读事件列表readList和写事件列表writeList,对置为1的元素对应的套接字进行读或写操作。这样就避免了遍历套接字的操作,但仍然有大量无用的数据(状态为0的元素)从内核复制到应用层中。于是就有了第二种事件检测方式。

▲图1.14 内核基于回调的事件检测方式一

内核基于回调的事件检测方式二如图1.15所示。服务器端有多个客户端套接字连接。首先,应用层告诉内核每个套接字感兴趣的事件。接着,当客户端发送数据过来时,对应会有一个回调函数,内核从网卡复制数据成功后即调回调函数将套接字1作为可读事件event1加入到事件列表。同样地,内核发现网卡可写时就将套接字2作为可写事件event2添加到事件列表中。最后,应用层向内核请求读、写事件列表,内核将包含了event1和event2的事件列表返回应用层,应用层通过遍历事件列表得知套接字1有数据待读取,于是进行读操作,而套接字2则可以写入数据。

▲图1.15 内核基于回调的事件检测方式二

上面两种方式由操作系统内核维护客户端的所有连接并通过回调函数不断更新事件列表,而应用层线程只要遍历这些事件列表即可知道可读取或可写入的连接,进而对这些连接进行读写操作,极大提高了检测效率,自然处理能力也更强。

对于Java来说,非阻塞I/O的实现完全是基于操作系统内核的非阻塞I/O,它将操作系统的非阻塞I/O的差异屏蔽并提供统一的API,让我们不必关心操作系统。JDK会帮我们选择非阻塞I/O的实现方式,例如对于Linux系统,在支持epoll的情况下JDK会优先选择用epoll实现Java的非阻塞I/O。这种非阻塞方式的事件检测机制就是效率最高的“内核基于回调的事件检测”中的第二种方式。

在了解了非阻塞模式下的事件检测方式后,重新回到对单线程非阻塞I/O模型的讨论。虽然只有一个线程,但是它通过把非阻塞读写操作与上面几种检测机制配合就可以实现对多个连接的及时处理,而不会因为某个连接的阻塞操作导致其他连接无法处理。在客户端连接大多数都保持活跃的情况下,这个线程会一直循环处理这些连接,它很好地利用了阻塞的时间,大大提高了这个线程的执行效率。

单线程非阻塞I/O模型的主要优势体现在对多个连接的管理,一般在同时需要处理多个连接的发场景中会使用非阻塞NIO模式,此模型下只通过一个线程去维护和处理连接,这样大大提高了机器的效率。一般服务器端才会使用NIO模式,而对于客户端,出于方便及习惯,可使用阻塞模式的套接字进行通信。

1.3.4 多线程非阻塞I/O模型

单线程非阻塞I/O模型已经大大提高了机器的效率,而在多核的机器上可以通过多线程继续提高机器效率。最朴实、最自然的做法就是将客户端连接按组分配给若干线程,每个线程负责处理对应组内的连接。如图1.16所示,有4个客户端访问服务器,服务器将套接字1和套接字2交由线程1管理,而线程2则管理套接字3和套接字4,通过事件检测及非阻塞读写就可以让每个线程都能高效处理。

▲图1.16 多线程非阻塞I/O模型

最经典的多线程非阻塞I/O模型方式是Reactor模式。首先看单线程下的Reactor, Reactor将服务器端的整个处理过程分成若干个事件,例如分为接收事件、读事件、写事件、执行事件等。Reactor通过事件检测机制将这些事件分发给不同处理器去处理。如图1.17所示,若干客户端连接访问服务器端,Reactor负责检测各种事件并分发到处理器,这些处理器包括接收连接的accept处理器、读数据的read处理器、写数据的write处理器以及执行逻辑的process处理器。在整个过程中只要有待处理的事件存在,即可以让Reactor线程不断往下执行,而不会阻塞在某处,所以处理效率很高。

基于单线程Reactor模型,根据实际使用场景,把它改进成多线程模式。常见的有两种方式:一种是在耗时的process处理器中引入多线程,如使用线程池;另一种是直接使用多个Reactor实例,每个Reactor实例对应一个线程。

▲图1.17 Reactor模式

Reactor模式的一种改进方式如图1.18所示。其整体结构基本上与单线程的Reactor类似,只是引入了一个线程池。由于对连接的接收、对数据的读取和对数据的写入等操作基本上都耗时较少,因此把它们都放到Reactor线程中处理。然而,对于逻辑处理可能比较耗时的工作,可以在process处理器中引入线程池,process处理器自己不执行任务,而是交给线程池,从而在Reactor线程中避免了耗时的操作。将耗时的操作转移到线程池中后,尽管Reactor只有一个线程,它也能保证Reactor的高效。

▲图1.18 Reactor模式改进一

Reactor模式的另一种改进方式如图1.19所示。其中有多个Reactor实例,每个Reactor实例对应一个线程。因为接收事件是相对于服务器端而言的,所以客户端的连接接收工作统一由一个accept处理器负责,accept处理器会将接收的客户端连接均匀分配给所有Reactor实例,每个Reactor实例负责处理分配到该Reactor上的客户端连接,包括连接的读数据、写数据和逻辑处理。这就是多Reactor实例的原理。

▲图1.19 Reactor模式改进二

多线程非阻塞I/O模式让服务器端处理能力得到很大提高,它充分利用机器的CPU,适合用于处理高并发的场景,但它也让程序更复杂,更容易出现问题。