1. 事件驱动 Event-Driven#

事件驱动编程是一种设计模式, 其中程序的执行流程由事件(如用户操作、数据到达、定时器到期等)决定, 程序会等待事件发生, 然后触发相应的处理函数或回调函数, 事件可以理解为系统中发生的某种状态变化或动作, 比如用户点击操作, 数据库读取完成、网络请求返回结果、文件读取完成等, 这些事件发生的时间是不确定的, 程序无法提前知道什么时候会触发, 所以我们需要一个监听器 (也就是事件循环) , 它不断地检查是否有事件发生, 如果有, 就调用对应的处理函数, 那这里你可能有个疑问, 为什么事件循环这个组件会知道在事件发生时调用其对应的函数呢?

当然是因为我们写代码的时候会注册, 比如 写 JS 的时候, 我们可以通过下面的方式为一个事件注册一个回调函数, 告诉事件循环, 当此事件发生时, 执行这个函数:

// DOM事件处理
// 方式1:使用addEventListener
document.getElementById('myButton').addEventListener('click', function() {
  console.log('按钮被点击了');
});

// 方式2:使用on属性
document.getElementById('myButton').onclick = function() {
  console.log('按钮被点击了');
};
// 创建自定义事件
const eventEmitter = new EventTarget();

// 注册事件监听器
eventEmitter.addEventListener('customEvent', function(event) {
  console.log('自定义事件被触发:', event.detail);
});

// 触发自定义事件
const event = new CustomEvent('customEvent', { detail: { message: '这是自定义数据' } });
eventEmitter.dispatchEvent(event);

2. 异步编程#

异步编程在处理 I/O 密集型任务(如网络请求、文件操作、数据库查询)时特别有用, 可以显著提高程序的响应性和吞吐量:

  • Python 通过 asyncio 库和 async/await 语法实现异步编程
  • JavaScript 使用 Promise/.then().then()async/await 和回调函数来处理异步操作

Promise 是 JavaScript 中处理异步操作的一种机制, 它本质上是一个对象, 代表一个异步操作的最终完成(或失败)及其结果值, Promise 有三种状态:pending(等待)、fulfilled(成功)、rejected(失败), 通过 .then() 和 .catch() 方法, 你可以注册回调函数来处理成功或失败的结果: 虽然 Promise 使用回调函数, 但它和上面的注册事件并不是一回事, 事件驱动通常是通过事件监听器(比如 addEventListener)注册回调函数, 当特定事件触发时执行, 而 Promise 更像是一种“一次性的异步任务封装”, 它的回调是在异步操作完成时由 Promise 自身调度, 而不是依赖外部事件触发

Javascript 中的 async/await 是建立在 Promise 之上的语法糖, 目的是让异步代码看起来更像同步代码, async 函数返回一个 Promise, 而 await 暂停函数执行, 直到 Promise 解析完成, 它的核心仍然是 Promise, 因此它本质上也是异步编程的一种实现方式

3. 事件驱动 vs 异步编程#

事件驱动:强调通过事件监听器注册回调,等待外部事件(如用户点击、定时器触发、网络响应)来驱动程序执行。典型例子是 DOM 事件监听或 Node.js 中的 EventEmitter

异步编程:关注如何处理耗时操作(如 I/O、网络请求),不阻塞主线程 (非阻塞 IO), Promise 和 async/await 是异步编程的工具,解决的是“等待结果”的问题,而不是“监听事件”的问题

4. 为什么事件驱动模型适合做高并发web服务器#

高效的资源利用

  • 事件驱动模型通常采用非阻塞 I/O(non-blocking I/O)和 I/O 多路复用(如 epoll, kqueue), 使得:
  • 一个线程就可以同时处理成千上万个连接
  • 不需要为每个连接分配一个线程或进程, 避免线程上下文切换的开销和内存资源浪费

降低线程切换成本

  • 传统多线程模型中,每个连接一个线程,大量并发连接时会产生线程调度开销

  • 事件驱动模型通常是单线程 + 事件循环,避免频繁线程切换,性能更稳定

天然适配网络 I/O 特性

  • 网络 I/O 操作大多是I/O 密集型且具有等待特性(如等待客户端数据、写入响应等)
  • 事件驱动模型能在等待 I/O 的同时继续处理其他任务,大幅提高吞吐量

实际验证的成功案例 很多高性能 Web 服务器或框架都采用事件驱动模型:

  • Nginx:采用 epoll + 非阻塞 I/O,单线程支持上万连接
  • Node.js:基于 libuv 的事件驱动模型,适合处理高并发的 I/O 请求
  • Netty(Java):事件驱动网络编程框架,广泛应用于高性能 Java 服务

Netty 是一个基于 Java NIO 的 异步、事件驱动的网络应用框架,用于快速开发高性能、高可靠性的网络服务器和客户端程序,

Spring Boot 管理业务逻辑 (封装了 Web、MVC、数据库、安全等功能), Netty 负责高性能通信 (负责 TCP/UDP 网络通信): 当你使用 spring-boot-starter-webflux 时, 底层默认就是用 Reactor Netty 实现的服务器, 而不是 Tomcat, 所以如果你用了 WebFlux, 其实你已经在用 Netty 了,

5. Node.js 事件驱动 vs Spring Boot Tomcat 多线程#

在处理并发请求时,Spring Boot 和 Node.js 的运行机制有什么不同?你觉得这些差异会如何影响它们的表现?

Spring Boot 通常用多线程, 每个请求分配一个线程, Node.js 是单线程靠事件循环处理, Spring Boot 可能更适合需要并行计算的任务, Node.js 适合网络请求多的场景

如果请求量突然增加到 10 倍,这些机制会有什么变化?

  • Spring Boot 可能会受限于线程池大小, 队列堆积
    • 当线程池的线程都忙不过来了, 新来的请求就被放进 等待队列, 这就是“堆积”
  • Node.js 如果都是 I/O 操作还能应付, 但如果都是 CPU 任务就会卡住,
    • 因为 CPU 任务 不是 IO 操作, 在发送网络请求之后, 或者文件IO 这些操作期间, CPU 并不需要做什么计算, 就是相当于派个任务给别人, 别人去处理了, 只用呆在那里等待结果
    • 所以 如果 都是 IO 操作, 事件驱动的 Node.js 并不会出什么问题, 但如果都是 CPU 密集型任务, 那就会导致主线程一直执行该任务, 不能完成其他任务了

在 Node.js 里, 主线程是唯一能运行 JavaScript 的线程, 也是处理回调和用户请求的核心, 所以:

  • 只要主线程还“自由”,Node.js 就能持续处理新请求
  • 一旦主线程“被卡住”,哪怕是 1 秒,所有用户请求都得等那 1 秒过去,才轮得到执行

为什么文件IO 适合非阻塞 IO, 而不属于计算密集型任务, 读取文件难道不是一直需要 CPU 把文件加载到内存吗?

文件IO被归类为IO密集型而非计算密集型任务, 这是因为文件读写操作的瓶颈主要在于存储设备的访问速度, 而非CPU处理能力, 当程序需要读取文件时, 实际过程包含多个步骤:

  1. 程序发起文件读取请求

  2. 操作系统接收请求并传递给存储设备控制器

  3. 存储设备从磁盘读取数据

  4. 数据通过DMA(直接内存访问)传输到内存

  5. 操作系统通知程序数据已就绪

FastAPI 本身是一个 Web 框架, 它的核心是处理 HTTP 请求, 而不是管理事件, 它的异步能力依赖 asyncio, 而 asyncio 是事件驱动的(通过事件循环实现), 但如果你用同步方式写 FastAPI 代码(def 而不是 async def), 它就完全不涉及事件驱动, 而是阻塞式执行

Node.js 的 事件循环 组件是由 libuv 库提供的, 当然 libuv 库也有自己的线程池, Node.js 使用单线程运行 libuv 库的事件循环 处理事件的回调函数, I/O 操作(如读写文件、数据库查询、网络请求)会被交给底层(如 libuv 的线程池)异步处理, 主线程不阻塞, 一直监听处理回调函数

// 示例:文件读取操作
const fs = require('fs');

// 1. JavaScript 代码在主线程执行
console.log('开始读取文件');

// 2. fs.readFile 委托给 libuv
fs.readFile('large-file.txt', (err, data) => {
  // 4. 当文件读取完成后,这个回调被放入事件循环队列
  // 然后在主线程上执行
  if (err) throw err;
  console.log('文件读取完成');
});

// 3. 主线程继续执行,不会被阻塞
console.log('继续执行其他代码');

6. 浏览器 JS 异步执行的原理#

JavaScript V8 引擎本身的设计是以单线程方式执行 JavaScript 代码, 这与 Python 和 Java 等语言的执行模型有根本区别,

语言 执行模型 线程支持 并发处理
JavaScript (V8) 单线程执行 不直接支持原生多线程 事件循环、回调、Promise
Python 多线程支持 原生 ⁠threading 模块 线程、进程、协程
Java 多线程支持 原生 ⁠Thread 类 线程、线程池、并发工具

既然 JavaScript 的主执行线程是单线程的, 为什么浏览器可以同时执行多个任务呢?

因为 JavaScript 宿主环境(浏览器、Node.js)可以是多线程的, 也就是说, “JS 是单线程的”指的是执行 JS 代码的线程只有一个, 以 Chrome 为例, 浏览器不仅有多个线程, 还有多个进程, 如渲染进程、GPU 进程和插件进程等, 而每个 tab 标签页都是一个独立的渲染进程, 所以一个 tab 异常崩溃后, 其他 tab 基本不会被影响, 作为前端开发者, 主要重点关注其渲染进程, 渲染进程下包含了 JS 引擎线程、HTTP 请求线程和定时器线程等, 这些线程为 JS 在浏览器中完成异步任务提供了基础