I/O密集型进程和CPU密集型进程
聊天应用程序、MMO(大型多人在线)游戏、金融交易系统、等实时服务需要处理大量并发流量和实时数据。
这些服务是I/O密集型的,因为它们花费大量资源处理输入输出操作,例如高吞吐量、低延迟网络通信(客户端与服务器以及其他应用程序组件之间)、实时数据库写入、文件 I/O、与第三方 API 的通信、流式传输实时数据等等。
通常,IO 密集型进程的性能取决于服务器的I/O系统,I/O中(如写数据到磁盘)的任何延迟都可能导致系统瓶颈。在I/O密集型进程中,CPU使用率相对较少,它需要等待I/O过程完成才能执行某个进程。
而在CPU密集型进程中,性能主要取决于CPU的速度。系统大部分时间都花在执行 CPU 中的进程上,而不是与外部组件通信。CPU性能越好,系统性能就越好。
如上所述,实时并发应用程序中的关键进程(例如高吞吐量网络操作、数据库写入、组件间通信等)会由于 IO 操作而引入系统延迟。
为了保证低延迟,不同 的Web 框架利用不同的策略(例如非阻塞 IO、单线程架构的异步事件处理、参与者模型、反应式编程等)来实现可扩展的实时服务。
在本文中,将讨论 NodeJS的单线程事件循环模型架构来处理大量 IO 密集型进程。
让我们开始吧。
在深入研究单线程架构之前,让我们先了解一下传统的基于线程请求的模型存在的问题。
基于线程的同步模型的 IO 瓶颈
在传统的基于线程的同步模型中,应用服务器利用该模型来处理客户端请求时,对于 I/O 密集型应用服务,会面临请求吞吐量瓶颈。
以Apache Tomcat服务器为例,它会维护一个线程池,当它接收到客户端请求时,该请求会被分配给线程池中的一个工作线程来处理,详细流程如下:
- 客户端发送一个请求到Web服务器;
- Web服务器收到请求后,从线程池中选择一个空闲可用的线程用于处理该请求;
- 此线程读取客户端请求,处理客户端请求,执行阻塞的IO操作(如果需要)和准备响应;
- 此线程将准备好的请求发送回Web服务器;
- Web服务器又将此响应发送到相应的服务器。
服务器为所有客户端执行以上步骤,为每一个客户端请求尽量分配一个线程,如果线程池可用线程数少于并发请求数时,则在使用完所有线程之后,剩余的客户端请求会在队列中等待。
而在I/O密集型应用中,大多数请求都会执行 IO 操作,例如,向数据库发出查询。在这种情况下,只要来自服务器的请求正在等待来自数据库的响应,该工作线程就会暂时被阻塞。它无法处理对服务器的其他请求。
因此如果这些线程中有大量的阻塞IO操作(例如:和数据库、文件系统、外部服务等交互),那么剩余的客户端将会等待很长的时间。
可用看出在高并发流量的 I/O 密集型应用中,这种线程阻塞行为会导致资源争用、并发性降低和性能瓶颈。
解决 IO 瓶颈问题
不同的编程语言和各自生态系统会采用一些异步方法(单线程事件循环模型、Actor模型、响应式)来解决同步请求阻塞问题。本文主要介绍NodeJS的架构和单线程事件循环模型。
NodeJS 从最基本的设计出发,目的就在于通过其单线程事件循环架构,以最小的开销高效处理大量并发请求和异步 IO 操作。作为主线程处理所有客户端请求,并将所有 IO 操作委托给其它线程,详细流程如下。
- 客户端发送请求到Web服务器;
- NodeJS的Web服务器在内部维护一个有限的线程池,以便为客户端请求提供服务;
- NodeJS的Web服务器接收这些请求并将它们放入队列(Event Queue)中。 它被称为“事件队列”
- NodeJS的Web服务器内部有一个组件,称为“事件循环(Event Loop Single Thread)”,从英文名可以看出,事件循环只使用到了一个线程,使用无限循环来接收请求并处理它们。它是NodeJS的处理模型的核心
- 事件循环回去检查是否有客户端的请求被放置在事件队列中。如果没有,会一直等待事件队列中存在请求。
- 如果事件队列中有需要处理的客户端请求,则会从事件队列中选择一个请求。
在事件循环线程处理客户端请求时,根据请求的类型,有不同的处理方式:
- 如果该客户端请求不需要任何阻塞IO操作,则处理所有内容,准备响应并将其发送回客户端
- 如果该客户端请求需要一些阻塞IO操作,例如与数据库,文件系统,外部服务交互,就会从从内部线程池获取一个可用的线程并将此客户端请求分配给该线程,这个内部线程池的线程负责接收该请求,处理该请求,执行阻塞IO操作,准备响应并将其发送回事件循环,事件循环依次将响应发送到相应的客户端
以上图为例,Web服务器内部维护着一个有限的线程池,线程池中线程数量为m个,NodeJS的Web服务器接收到Client-1, Client-2, …, Client-n的请求后,将请求放入到事件队列中NodeJS的事件循环从队列中开始拾取这些请求,以Client-1的请求和Client-3为例。
对于Client-1的请求:
- 事件循环检查Client-1 Request-1是否确实需要任何阻塞IO操作,或者需要更多时间来执行复杂的计算任务
- 由于此请求是简单计算和非阻塞IO任务,因此不需要单独的线程来处理它
- 事件循环处理该请求所需要的操作,准备其响应Response-1
- 事件循环发送Response-1到Client-1
对于Client-3的请求:
- 检查Client-n Request-n是否需要任何阻塞IO操作或花费更多时间来执行复杂的计算任务
- 由于此请求有非常复杂的计算或阻塞IO任务,因此事件循环不会处理此请求
- 事件循环从内部线程池中获取线程T-1,并将此Client-n Request-n分配给线程T-1
- 线程T-1读取并处理Request-n,执行必要的阻塞IO或计算任务,最后准备响应Response-n
- 线程T-1将此Response-n发送到事件循环,事件循环依次将此Response-n发送到Client-n
举个生活中的案例,以大排档点餐为例,大排档有已经做好的熟食,此外还可以根据顾客的要求现做,服务员(事件循环)在一个时间段内只能处理一个顾客的点餐请求(事件队列中的任务)。每当一个顾客点完餐,服务员就会检查下菜单,如果顾客点的是熟食(没有阻塞的I/O任务),服务员直接把菜端给客户就行了(直接处理);但是如果顾客点的是现做的食物,那么服务员就选一个空闲的厨师(内部线程池中可用的线程)将顾客的需求转给他,等厨师做好后递给服务员,服务员再端给客户。
为了加深理解,这里有个简单的代码示例:
public class ThreadPoolEventLoop {
private final Queue<Runnable> eventQueue = new LinkedBlockingQueue<>();
创建一个固定大小为3的线程池
private final ExecutorService threadPool = Executors.newFixedThreadPool(3);
public void startEventLoop() {
while (true) {
Runnable event;
synchronized (eventQueue) {
event = eventQueue.poll(); // 从队列中取出事件
}
if (event != null) {
// 判断事件是否是 I/O 阻塞任务
if (isIOBound(event)) {
threadPool.submit(event); // 使用线程池提交 I/O 阻塞任务
} else {
event.run(); // 处理非阻塞任务
}
} else {
try {
Thread.sleep(100); // 如果没有事件,等待
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
// 模拟判断事件是否是 I/O 阻塞任务的方法
private boolean isIOBound(Runnable event) {
// 这里可以根据具体的业务逻辑判断事件是否是 I/O 阻塞任务
// 此处简单地假设所有事件都是非阻塞的
return false;
}
public void registerEvent(Runnable event) {
synchronized (eventQueue) {
eventQueue.offer(event); // 将事件添加到队列尾部
}
}
public static void main(String[] args) {
ThreadPoolEventLoop eventLoop = new ThreadPoolEventLoop();
// 注册几个简单的事件,其中一个模拟 I/O 阻塞任务
eventLoop.registerEvent(() -> System.out.println("Event 1 executed"));
eventLoop.registerEvent(() -> System.out.println("Event 2 executed"));
eventLoop.registerEvent(() -> System.out.println("Event 3 executed"));
// 启动事件循环
eventLoop.startEventLoop();
}
}
//输出
Event 1 executed
Event 2 executed
Event 3 executed