当前位置: 首页 > 科技观察

服务器模型详解:从单线程阻塞到多线程非阻塞

时间:2023-03-18 22:23:25 科技观察

前言前言服务器模型涉及到线程模式和IO模式,搞清楚这些可以针对各种场景。该系列分为三部分:单线程/多线程阻塞I/O模型单线程非阻塞I/O模型多线程非阻塞I/O模型、Reactor及其改进的I/O处理模型.从不同的维度可以有不同的分类。这里从I/O阻塞和非阻塞、I/O处理单线程和多线程的角度讨论服务器模型。对于I/O,可以分为两种类型:阻塞I/O和非阻塞I/O。阻塞I/O会导致当前线程在进行I/O读写操作时进入阻塞状态,而非阻塞I/O则不会进入阻塞状态。对于线程,在单线程的情况下,一个线程负责所有客户端连接的I/O操作,在多线程的情况下,多个线程共同处理所有客户端连接的I/O操作。单线程阻塞I/O模型单线程阻塞I/O模型是最简单的服务器模型,几乎所有的程序员在刚接触网络编程时都是从这个简单的模型开始的。这种模型在同一时间只能处理一个客户端访问,在I/O操作上是阻塞的,线程会一直等待而不做其他事情。对于多个客户端访问,必须在上一个客户端访问结束后才能处理下一个访问。请求一一排队,只提供一问一答服务。首先,服务端要初始化一个socket服务器,绑定一定的端口号,让它监听客户端访问。然后,客户端1调用服务端的服务,服务端收到请求后进行处理,处理后将数据写回客户端1。整个过程在一个线程中完成。最后处理客户端2的请求,将数据写回给客户端2。在这期间,即使客户端2在服务器处理完客户端1之前发出请求,也会等待服务器响应客户端1后再响应client2.做响应处理。这种模型的特点是单线程和阻塞I/O。单线程是指服务端只有一个线程来处理客户端的所有请求。客户端连接与服务器端处理线程的比例为n:1。它不能同时处理多个连接,只能串行处理连接。阻塞I/O是指服务器在读写数据时被阻塞。读取客户端数据时,必须等待客户端发送数据并将操作系统内核复制到用户进程,然后解除阻塞状态。在向客户端写回数据时,等待用户进程将数据写入内核发送给客户端后,再释放阻塞状态。这种阻塞给网络编程带来了问题。服务器必须等到客户端成功接收后,才能继续处理另一个客户端的请求。在此期间,线程将无法响应任何客户端请求。该模型的特点:是最简单的服务器模型,整个运行过程只有一个线程,同时只能支持处理一个客户端的请求(如果有多个客户端访问,则必须等待在线),服务器系统资源消耗相对较低小,但并发度低,容错性差。多线程阻塞I/O模型针对单线程阻塞I/O模型的不足,我们可以使用多线程对其进行改进,使其可以并发响应多个客户端。多线程模型的核心是利用多线程机制为每个客户端分配一个线程。服务器端开始监听客户端的访问。如果有两个客户端发送请求,服务器在收到客户端请求后创建两个线程来处理它们。每个线程负责一个客户端连接,直到响应完成。在此期间,两个线程同时处理各自对应客户端的请求,包括读取客户端数据、处理客户端数据、将数据回写给客户端等操作。这种模型的I/O操作也是阻塞的,因为每个线程在执行读或者写操作的时候都会进入阻塞状态,直到客户端的数据被读取或者数据写入成功后才会解除阻塞状态客户端。.虽然I/O操作是阻塞的,但这种模式的性能明显高于单线程处理。它不会等到第一个请求处理完才处理第二个请求,而是并发处理客户端请求。客户端连接到服务器。端处理线程的比例为1:1。多线程阻塞I/O模型的特点:支持并发响应多个客户端,处理能力大幅提升,并发量大,但服务器系统资源消耗大,线程多个线程之间的切换会发生成本,同时具有更复杂的结构。单线程非阻塞I/O模型和多线程阻塞I/O模型确实通过引入多线程提高了服务器的并发处理能力,但是每个连接都需要一个线程负责I/O操作.当连接数很大时,机器线程数可能会过多,但这些线程大部分时间都处于等待状态,造成资源的极大浪费。针对多线程阻塞I/O模型的不足,是否可以在读写操作中不阻塞地使用一个线程维护多个客户端连接?下面介绍单线程非阻塞I/O模型。单线程非阻塞I/O模型最重要的特点之一就是调用read或write接口后立即返回,不会进入阻塞状态。在讨论单线程非阻塞I/O模型之前,有必要了解一下非阻塞情况下socket事件的检测机制,因为单线程非阻塞模型最重要的是检测哪些连接有有趣的事件。一般有以下三种检测方法。应用程序遍历socket的事件检测当多个客户端请求服务器时,服务器会保存一个socket连接列表,应用层线程轮询socket列表尝试读写。对于读操作,如果成功读取了一些数据,则处理读取的数据;如果读取失败,则下一个循环继续尝试。对于写操作,首先尝试向指定的套接字写入数据,如果写入失败,则在下一个周期重试。这样一来,无论有多少个socket连接,都可以由一个线程来管理,一个线程负责遍历这些socket的链表,不断尝试读取或写入数据。这样就很好的利用了阻塞时间,提高了处理能力。但是这种模型需要遍历应用程序中所有的socket列表,同时需要处理数据的拼接。在连接空闲的时候也可能占用较多的CPU资源,不适合实际使用。改进这一点的方法是使用事件驱动的非阻塞方法。内核遍历socket的事件检测这样,socket遍历的工作就交给了操作系统内核,而socket遍历的结果则组织成一系列的事件列表返回给应用层处理。对于应用层来说,他们需要处理的对象就是这些事件,这就是事件驱动的非阻塞方式之一的实现。服务器端有多个客户端连接,应用层请求内核读写事件列表。内核遍历所有socket,生成对应的可读列表readList和可写列表writeList。readList指示每个套接字是否可读。比如socket1的值为1,表示可读,socket2的值为0,表示不可读。writeList指示每个套接字是否可写。应用层遍历读写事件列表readList和writeList,进行相应的读写操作。内核遍历socket时,不再需要在应用层遍历所有socket,遍历工作下移到内核层,有利于提高检测效率。但是需要将所有连接的可读事件列表和可写事件列表传递给应用层。如果socket连接数变大,将列表从内核拷贝到应用层是一个不小的开销。此外,当活动连接较少时,内核与应用层之间的无效数据副本较多,因为它会将活动连接状态和非活动连接状态都复制到应用层中。基于内核回调的事件检测通过遍历来检测socket是否可读写,无论是在应用层遍历还是在内核中遍历,都是一种比较低效的方式。所以需要另外一种机制来优化遍历方法,就是回调函数。内核中的socket对应一个回调函数。当客户端向socket发送数据时,内核从网卡接收到数据后会调用回调函数。回调函数中维护事件列表,应用层获取事件列表。所有感兴趣的事件都可用。内核中有两种基于回调的事件检测方法。第一种是使用可读列表readList和可写列表writeList来标记读写事件。套接字的个数与readList和writeList两个列表的长度相同。readList的第一个元素标记为1表示socket1可以读取,同理,writeList的第二个元素标记为1则表示socket2是可写的。如图所示,多个客户端连接到服务器。客户端发送数据时,内核从网卡复制数据成功后调用回调函数将readList的第一个元素置1,应用层发送请求读写事件列表。,返回内核包含事件标识的readList和writeList的事件列表,然后分别遍历读事件列表readList和写事件列表writeList,对元素集为1对应的socket进行读或写操作.这样避免了socket遍历,但是还是有很多无用的数据(状态为0的元素)从内核复制到应用层。于是就有了第二种事件检测方法。第二种基于内核回调的事件检测方法如图所示。服务器端有多个客户端套接字连接。首先,应用层告诉内核每个socket对哪些事件感兴趣。然后,当客户端发送数据时,会有相应的回调函数。内核从网卡复制数据成功后,会调用回调函数,将socket1作为可读事件event1添加到事件列表中。同样,当内核发现网卡可写时,将socket2作为可写事件event2添加到事件列表中。最后,应用层请求内核读写事件列表,内核返回包括event1和event2的事件列表给应用层。应用层通过遍历事件列表得知socket1有数据需要读取,然后执行读取操作。而socket2可以写入数据。以上两种方式,操作系统内核维护着客户端的所有连接,并通过回调函数不断更新事件列表,应用层线程只需要遍历这些事件列表就可以知道哪些连接可以读或写,然后阅读这些连接。写操作大大提高了检测效率,自然处理能力也更强。对于Java来说,非阻塞I/O的实现完全基于操作系统内核的非阻塞I/O,屏蔽了操作系统非阻塞I/O的差异,提供了统一的API,这样我们就不用关心操作系统了。JDK会帮我们选择非阻塞I/O的实现方式。比如对于Linux系统,如果支持epoll,JDK会优先使用epoll来实现Java的非阻塞I/O。这种非阻塞事件检测机制是最高效的“基于内核回调的事件检测”中的第二种方法。了解完非阻塞模式下的事件检测方法,回到单线程非阻塞I/O模型的讨论。虽然只有一个线程,但是通过非阻塞读写操作结合上述检测机制,可以实现多个连接的及时处理,不会因为某个线程的阻塞操作导致其他连接无法处理联系。在大部分客户端连接都保持活跃的情况下,这个线程会一直循环处理这些连接,很好的利用了阻塞时间,大大提高了这个线程的执行效率。单线程非阻塞I/O模型的主要优势体现在对多连接的管理上。非阻塞NIO模式一般用于需要同时处理多个连接的场景。在这种模型下,只用一个线程来维护和处理连接,大大提高了机器的效率。一般服务器端会使用NIO方式,而对于客户端,出于方便和习惯,可以使用阻塞方式的socket进行通信。多线程非阻塞I/O模型单线程非阻塞I/O模型大大提高了机器的效率,多线程在多核机器上可以继续提高机器效率。最简单、最自然的做法是将客户端连接分配给组中的几个线程,每个线程负责处理对应组中的连接。如图所示,有4个客户端访问服务器。服务器将socket1和socket2交由线程1管理,线程2管理socket3和socket4。通过事件检测和非阻塞读写让每个线程高效处理。最经典的多线程非阻塞I/O模型就是Reactor模式。先看单线程下的Reactor。Reactor将服务端的整个处理过程分成了几个事件,比如接收事件、读事件、写事件、执行事件。Reactor通过事件检测机制将这些事件分发给不同的处理器进行处理。如图所示,几个客户端连接访问服务器。Reactor负责检测各种事件并将它们分发给处理器。这些处理器包括接收连接的accept处理器,读取数据的read处理器,写入数据的write处理器,以及执行逻辑的processhandler。在整个过程中,只要有事件需要处理,Reactor线程就可以一直执行下去,不会被阻塞在某处,所以处理效率非常高。在单线程Reactor模型的基础上,根据实际使用场景改进为多线程模型。常见的方式有两种:一种是在耗时的进程处理器中引入多线程,比如使用线程池;另一种是直接使用多个Reactor实例,每个Reactor实例对应一个线程。Reactor模式的一种改进方式如图所示。其整体结构与单线程Reactor基本相似,只是引入了线程池。由于接收连接、读取数据、写入数据等操作基本上耗时较少,所以都在Reactor线程中处理。但是对于可能比较耗时的逻辑处理,可以在进程处理器中引入线程池。process处理器本身不执行任务,而是交给线程池,这样就避免了在Reactor线程中进行耗时操作。将耗时操作转移到线程池后,即使Reactor只有一个线程,也能保证Reactor的高效率。Reactor模式的另一种改进如图所示。Reactor实例有多个,每个Reactor实例对应一个线程。因为接收事件是相对于服务端的,所以客户端的连接接收工作统一由一个accept处理器负责。accept处理器会将接收到的客户端连接平均分配给所有的Reactor实例,每个Reactor实例负责处理分配。客户端连接到Reactor包括读取数据、写入数据和连接的逻辑处理。这就是多Reactor实例的原理。多线程非阻塞I/O模式大大提高了服务器的处理能力。充分利用了机器的CPU,适合处理高并发场景,但也让程序变得更加复杂,容易出现问题。