线程I/O模型
2.1 线程与阻塞 I/O
IO 模型是指计算机在涉及 I/O 操作时使用到的模型。为了解决各种问题,人们提出了很多不同的 I/ 模型,与之相关的概念有线程、阻塞、非阻塞、同步以及异步等。I/O 可以分成阻塞IO 与非寨I/O 两大类型。阻塞 I/O 在进行 操作时会使当前线程进入阻塞状态,而非阻塞IO 则不进人阻寨状态。以服务器处理客户端连接为例,在单线程情况下由一个线程负责所有客户端连接的I/O操作,而在多线程情况下则由若干线程共同处理所有客户端连接的I/O操作。
此外,需要注意的是,计算机的I/O 其实包含了各种设备的 IO,比如网络 IO、磁盘IO键盘I/O 和鼠标IO等。我们以经典的网络 I/O 场景(见图 2.1)为例来讲解单线程阻塞IO模型和多线程阻塞 IO 模型。
程序在执行 I/O 时一般需要从内核空间复制数据,但内核空间的数据可能需要较长时间进行准备,由此导致用户空间产生阻塞。在图 2.2 中,应用程序处于用户空间,一个应用程序对应着一个进程,而进程则包含了缓冲区。当要进行I/O 操作时,需要通过内核来执行相应的操作,比如由内核负责与键盘、磁盘、网络等控制器进行通信。当内核得到不同设备的控制器发送过来的数据后,会将数据复制到用户空间供应用程序使用。
网络I/O产生阻塞的过程如图 2.3 所示。应用程序首先发起读取操作,然后进入阻塞状态接下来由操作系统内核完成IO 操作。内核刚开始时未准备好数据它需要不断读取网络数据一旦数据准备好,就将数据复制到用户空间供应用程序使用。应用程序在从发起读取操作到继续往下处理的这段时间就处于阻塞状态。
2.1.1 单线程阻塞 I/O 模型
单线程阻塞 I/O模型是最简单的一种服务器模型,几乎所有程序员在刚开始接触网络编程时都从这种模型开始。这种模型只能同时处理一个客户端访问,并且在 I/O 操作上是阻塞的线程会一直处于等待状态而不会做其他事情。对于多个客户端访问的情况,必须要等到前一个客户端访问结束后才能进行下一个访问的处理。也就是说,请求一个一个排队,且只提供一问一答服务。
图2.4所示为单线程阻塞服务器响应客户端访问的时间节点。首先,服务器必须初始化一个套接宇(socket)服务器,并绑定某个端口号使之监听客户端的访问。接着,客户端1 调用服务器的服务,服务器接收到请求后对其进行处理,处理完后写数据回客户端1。最后,处理客户端2的请求并写数据回客户端 2,期间即使客户端 2 在服务器处理完客户端1之前就进行请求,也要等服务器对客户端1 响应完后才会对客户端 2 进行响应处理。
这种模型的特点在于单线程和阻塞 I/O。单线程即服务器端只有一个线程处理客户端的所有请求,客户端连接与服务器端的处理线程比是 n:1,它无法同时处理多个连接,只能串行处理连接。而阻塞I/O 是指服务器在读写数据时是阻塞的,在读取客户端数据时要等待客户端发送数据并且把操作系统内核中的数据复制到用户进程中,这时才解除阻塞状态。将数据写回客户端时要等待用户进程将数据写入内核后才解除阻塞状态。
单线程阻塞IO 模型是最简单的一种服务器模型,整个运行过程都只有一个线程,只能同时处理一个客户端的请求(如果有多个客户端访问,就必须排队等待 )。服务器系统资源消耗较小,但并发能力低,容错能力差。
2.1.2 多线程阻塞 1/0 模型
针对单线程阻塞 IO 模型的缺点,最简单的改进方式就是将其多线程化,使之能对多个客户端进行并发响应。多线程模型的核心就是利用多线程机制为每个客户端分配一个线程。如图 2.5所示,服务器端开始监听客户端的访问,假如有两个客户端发送请求过来,服务器端在接收到客户端请求后将创建两个线程分别对它们进行处理。每个线程负责一个客户端连接,直到响应完成。期间两个线程并发地为各自对应的客户端处理请求,包括读取客户端数据、处理客户端数据、将数据写回客户端等操作。
这种模型的I/O操作也是阻塞的,因为每个线程执行到读取或写入操作时都将进入阻塞状态,直到成功读取客户端的数据或数据成功写入内核后才解除阻塞状态。尽管此时的 IO 操作还是会阻塞,但这种模式比单线程模式的性能明显提高,它不用等到第一个请求处理完才处理第二个,而是并发地处理客户端请求,客户端连接与服务器端处理线程的关系是一对一的。多线程阻塞 IO 模型的特点如下所示:
- 支持对多个客户端并发响应,处理能力得到大幅提高,有较强的并发能力;
- 服务器系统资源消耗量较大,而且多线程之间会产生线程切换成本,同时拥有较复杂的结构。
2.2 线程与非阻塞 1/0 模型
前面讲到,多线程阳塞 I/O 模型通过引入多线程的方法来提升服务器端的并发处理能力确实能够达到一定的效果。但它还是存在一个严重的问题,那就是每个连接都需要一个线程负责I/O 操作。当连接数量较多时将导致机器线程数量太多,而这些线程在大多数时间内都处干等待状态,线程之间的切换成本非常高。对于多线程阻塞 I/O 模型的这个缺点,有没有可能只用一个线程就可以维护多个客户端连接并且不会阻塞在读写操作呢?这就是下面要介绍的单线程非阻塞I/O模型。
就单线程非阻塞 IO模型来说,与阻塞 IO型相同的地方是,程序在执行 I/O 时一般要从内核空间和用户空问复制数据,不同之处在于非阻塞 I/O 模型不会一直等到内核准备好需据、而是直接返回去做其他的事,也就是说并不产生阻塞。应用程序进程包含一个缓冲区,详个线得会不断循环滥用所有客户端,尝试对它们进行读写操作。如果内核已准备好数据,那么应用层线程就会将数据复制到用户空间供使用,如图 2.6 所示
非阻塞I/O 模型最重要的一个特点是在调用读或写接口后会立即返回,而不会进入阻塞状态。网络的非阻塞I/O 的过程如图 2.7 所示。应用程序首先发起读取操作,它会告知操作系统内核去执行IO 操作。刚开始时由于内核未准备好数据,所以会马上返回而不是阻塞。此时应用程序可以做其他事,过一段时间后再尝试读取操作。如果发现数据已经准备好,内核就将数据复制到用户空间供应用程序使用。
非阻塞 I/O模型可分为应用层 I/O 多路复用、内核 I/O 多路复用、内核回调事件驱动 I/O这3种。
2.3 Java 多线程非阻塞I/O 模型
虽然现代计算机都是多 CPU 的,而且操作系统也提供了多线程机制,但并不是说单线程完全被抛弃,实际上单线程也有自己的优势。最大的优势就是一个 CPU 只负责一个线程,因此可以完全规避多线程中的所有疑难杂症,这样在编写代码时就简单多了,如图 2.12 所示。比如在以前的多线程环境中,共享变量的操作要考虑很多问题,而单线程则不必考虑这些。同时单线程也免去了线程上下文的切换,进一步提高了 CPU 的真正使用率。
在一个线程对应一个 CPU 的情况下,如果多核计算机中只执行一个线程,那么就只有一个 CPU 工作,这样也就无法充分利用 CPU 资源。为了解决这个问题,我们的程序可以根据CPU的数量来创建线程数,N个 CPU分别对应N个线程,如图 2.13 所示。这种模型充分利用了多个 CPU,同时也保持了单线程的优点,相当于多个线程并行执行而不是并发执行。
在多核的机器时代,多线程和非阻塞都是提升服务器处理性能的利器。那么如何将它们结合起来呢?最常规的做法就是将客户端连接按组分配给若干线程,每个线程负责处理对应组内的连接。如图 2.14 所示,有 4 个客户端访问服务器,服务器将 socketl 和 socket2 交由线程管理,而线程 2则管理 socket3 和 socket4,通过事件检测及非阻塞读写就可以让每个线程都能高效运行。
在实际的工程中,最经典的多线程非阻塞 I/O 模式是 Reactor 模式。首先看单线程下的Reactor。Reactor 将服务器端的整个处理过程分成若干个事件,例如分为接收事件、读事件、写事件、执行事件等。Reactor 通过事件检测机制将这些事件分发给不同的处理器去处理。如图2.15 所示,若干客户端连接访问服务器端,Reactor 负责检测各种事件并分发到处理器,这些处理器包括接收连接的 accept 处理器、读数据的 read 处理器、写数据的 write 处理器以及执行逻辑的 process 处理器。在整个过程中只要有待处理的事件存在,就可以让 Reactor 线程不断往下执行,而不会阻塞在某处,所以处理效率很高。
基于单线程的 Reactor 模式在实际中很少使用,我们更多地是将它改进为多线程模式。常见的有下面两种:
- 多线程 Reactor 模式,即在耗时的 process 处理器中引入多线程,例如使用线程池;
- 多Reactor 实例模式,即直接使用多个 Reactor 实例,每个 Reactor 实例对应一个线程
Reactor 模式的第一种改进方式是多线程 Reactor 模式,如图 2.16 所示。多线程 Reactor 模式的整体结构基本与单线程的 Reactor 类似,只是额外引人了一个线程池。由于对连接的接收对数据的读取和对数据的写人等操作基本上都耗时较少,因此把它们都放到 Reactor 线程中处理。然而,对于可能比较耗时的逻辑处理工作,则在 process 处理器中引入线程池。process 处理器自己不执行任务,而是交给线程池,从而在 Reactor 线程中避免了耗时的操作。将耗时的操作转移到线程池中后,尽管 Reactor 只有一个线程,也能保证 Reactor 的高效性。
Reactor模式的第二种改进方式是多 Reactor 实例模式,如图2.17 所示。其中有多个 Reactor实例,每个 Reactor 实例对应一个线程。因为接收事件是相对于服务器端而言的,所以客户端的连接接收工作统一由一个accept 处理器负责,accept 处理器会将接收的客户端连接均匀分配给所有 Reactor 实例。每个 Reactor 实例负责处理分配到该 Reactor 上的客户端连接,包括连接的读数据、写数据和逻辑处理。这就是多 Reactor 实例的原理。
2.4 多线程带来了什么
并发和并行都是相对于进程或线程来说的。并发是指一个或若干个 CPU 对多个进程或线程进行多路复用,用简单的语言来说就是 CPU 交替执行多个任务,每个任务都执行一小段时间,从宏观上看,就像是全部任务都在同时执行一样。
- 提升执行效率
- 提升用户体验
- 让编码更难
- 资源开销与上下文切换开销