Egg框架深入

egg启动自定义

https://eggjs.org/zh-CN/basics/app-start

多进程模型和进程间通讯

来源:https://eggjs.org/zh-CN/core/cluster-and-ipc

进程 vs 线程

先厘清操作系统层面的通用关系,这是理解 Egg 多进程模型的基础。

  • 进程(process) :一次程序运行的实例,是操作系统 资源分配 的单位。每个进程有 独立的内存空间(堆、栈、代码段),进程之间内存隔离,A 进程改不了 B 进程的变量。
  • 线程(thread) :进程内的 执行单位 ,是 CPU 调度 的单位。一个进程里可以有多个线程,它们 共享同一份进程内存

一句话:进程是容器,线程是容器里干活的人。一个进程至少有 1 个线程(主线程),线程活在进程里,不能脱离进程存在。

复制代码
进程 A(独立内存)              进程 B(独立内存,和 A 隔离)
 ├─ 线程1(共享 A 的内存)       ├─ 线程1
 ├─ 线程2(共享 A 的内存)       └─ 线程2
 └─ 线程3
维度 进程 线程
是什么的单位 资源分配 CPU 调度
内存 各自独立、隔离 同进程内共享
崩溃影响 进程间互不影响 可能拖垮整个进程
Egg 里的对应 Master / Agent / Worker 都是进程 worker 内部的主线程 + libuv/V8 后台线程

关键结论:Egg 扩展多核用的是多进程,不是多线程 。因此进程之间内存不共享------这也是为什么 @SingletonProto 单例是每个 worker 进程一份,而非全局唯一。

进程 vs 程序

程序:你写的代码。

进程 = 一个正在运行的程序实例;OS 为它分配独立内存 + 一整套隔离资源,并且里面有线程在执行指令。

一个更贴切的类比:

  • 程序 = 菜谱(纸上的静态步骤)
  • 进程 = 按菜谱正在炒的这道菜------占了灶台、锅、食材(资源,含内存),而且有厨师在动手(线程在执行)
程序实例是什么?

程序实例 = 程序被运行起来的那一份运行体。用类 vs 对象类比最贴切:

  • 程序 = 类(class):硬盘上静态的可执行文件(nodeapp.js),是模板。
  • 实例 = 对象:类被 new 出来后内存里那个活的东西,即运行中的进程。

核心在实例二字:同一个程序可以同时跑出多个互相独立的实例 :跑 3 次 node app.js,程序只有一个(那个文件),却有 3 个运行实例,各有各的 PID 和独立内存,互不干扰。

Egg 的多进程模型:Master、Agent 和 Worker

Egg 启动后是一组进程,而非一堆线程:

复制代码
Master 进程(1 个,总管)
  ├── Agent 进程(1 个)
  └── Worker 进程(N 个,处理 HTTP 请求)
  • Master:守护进程,不处理请求。worker 崩了它负责拉起来,做优雅重启。
  • Agent :一个特殊进程,用来做那些多个 worker 都需要、但只该执行一份的事(比如定时任务,避免 N 个 worker 重复跑)。它 全局只有 1 个 ,且 不监听端口、不处理外部请求 。典型职责:定时任务(schedule)、唯一长连接 / 订阅、监听配置中心变更等。自定义 Agent 逻辑写在应用根目录的 agent.ts(与 worker 的 app.ts 对应)。
  • Worker :业务进程。默认数量 = CPU 核数,这样多核都能吃满。每个 worker 是 独立的 OS 进程,有各自独立的内存空间

请求由谁处理 :连接由操作系统内核 / master 分发,一个请求 只落到一个 worker ,其整个生命周期(Controller → Service → 返回)都在该 worker 内跑完。多 worker 的价值在于不同请求并行分摊到不同 worker,而不是多个 worker 合处理同一个请求。同一 worker 内的多个请求则靠单主线程的 event loop 并发(非并行)处理。

cluster

是什么?

前面说过,一个 Node 进程的 JS 是单主线程,吃不满多核。cluster 的解法是 fork 出多个 worker 进程,大家共享同一个监听端口:

js 复制代码
import cluster from 'node:cluster';
import http from 'node:http';
import { cpus } from 'node:os';

if (cluster.isPrimary) {          // 主进程(master)
  for (let i = 0; i < cpus().length; i++) {
    cluster.fork();               // ← fork 出 worker 子进程
  }
} else {                          // 每个 worker 子进程
  http.createServer((req, res) => res.end('hi')).listen(3000);
  // 多个 worker 都 listen(3000),不会端口冲突
}

关键点:

  • fork() 由主进程调用,派生出子进程(这就是 Master fork 出 worker 的底层)。
  • 多个 worker 监听同一端口不冲突:正常情况下,一个端口只能被一个进程绑定监听(否则报 EADDRINUSE 端口占用错误)。那 Egg 有多个worker,它们怎么能 都监听 3000 端口 还不冲突?答案:其实不是每个 worker 各自独立去 listen(3000),而是 master 统一持有那个监听 socket,再把连接分给 worker。这就是一个请求只落到一个 worker 的机制来源。
  • worker 崩了,master 能收到 exit 事件,再 fork() 一个补上 → 进程守护。

Egg 的 Master/Worker 模型,本质就是在 cluster 之上封装的(还加了 Agent 进程、优雅重启、日志切割等)。所以你可以理解为:cluster 是地基,Egg 的多进程是盖在上面的房子

对主进程的理解

主进程这个词有两层含义:

① 操作系统层面 :任何一个 node xxx.js 启动的进程,它自己就是一个进程。

你在终端敲 node app.js,操作系统就创建了一个进程来跑它。这个进程有没有主/从之分,取决于你代码里用不用 cluster。

② cluster 语境下 :主进程(primary/master)是相对子进程(worker)而言的角色。

只有当你调用了 cluster.fork(),才会分裂出主进程 + 子进程的父子关系。这时:

  • 最初那个进程 = 主进程(primary),负责 fork 和管理;
  • 被 fork 出来的 = 子进程(worker)。

cluster.isPrimary 这个判断,就是用来区分我现在是不是那个最初的主进程。

推论:如果代码里 没有任何 cluster.fork() ,直接 http.createServer(...).listen(3000),那就是一个 普通单进程程序 ------它是进程(操作系统层面),但 不存在主/从之分,所有请求由这唯一进程的单主线程处理。主进程这个称呼要等有了 fork、有了子进程才成立。

Node.js 的单线程模型

Node 的 JS 代码 默认单线程执行 ,但进程内有底层多线程(libuv / V8)在支撑异步,而且需要时可以用 worker_threads 显式开真线程做并行计算。JS 代码跑在单个主线程上(基于事件循环 event loop)。

一个 Node 进程内部的线程构成:

复制代码
Node 进程(1 个,独立内存)
 ├─ 主线程          ← 跑你的 JS、event loop。业务代码只在这根线程上执行
 ├─ libuv 线程池     ← 默认 4 根,后台干文件 I/O、DNS、crypto 等阻塞活
 ├─ V8 后台线程      ← GC、JIT 编译
 └─(可选) worker_threads 开的线程 ← 显式开来做 CPU 密集计算

所以 Node 是单线程指的是 主线程(JS 执行)单线程,不是整个进程只有一根线程。

⚠️ 别把 worker_threads(同进程内的线程 ,用于 CPU 密集计算)和 Egg 的 worker(独立进程,用 cluster fork)搞混------名字都叫 worker,一个是线程、一个是进程。

Node 单线程事件循环 vs Java 多线程

Node和Java的设计哲学不同------Node默认用单线程 + 事件循环处理业务,靠"异步非阻塞"而非"多线程"来抗并发。

为什么 Node 敢用单线程?这就要区分IO密集型还是CPU密集型任务了。Web 业务大多是 IO 密集型(查DB、调接口、读文件),CPU大部分时间在等,而不是在算。

咖啡店例子来理解:来了 3 个顾客各要一杯手冲咖啡,每杯要等 5 分钟滴滤------这就是「IO 等待」。

Java(多线程 = 多个店员):每来一个顾客雇一个专属店员,店员在等咖啡滴滤时干站着(线程阻塞)。3 杯 5 分钟搞定,但雇了 3 个店员;来 1000 个顾客就得雇 1000 个(线程爆炸)。

Node(单线程 = 一个不干等的店员):全店 1 个店员,接单后立刻架上水壶就转身接下一个,哪壶好了「叮」一声再去交付。3 杯也是约 5 分钟,却只用 1 个店员;来 1000 个也是这 1 个。

  • Java:靠「多雇人」同时处理任务,每人可以在自己的活上干等。
  • Node:靠「一个人不干等、快速切换」,把耗时等待(滴滤 / IO)交给水壶(操作系统)去完成。

事件循环就是那个「盯着所有水壶、哪个好了就叫店员处理」的调度机制------一个永不停止的循环,不停问「有没有完成的事要处理?有就执行对应回调」:

javascript 复制代码
console.log('1. 接单');
setTimeout(() => console.log('3. 叮!咖啡好了'), 5000); // 只登记回调,不卡住
console.log('2. 转身接下一个');
// 输出:1 → 2(立刻)→ 3(5秒后事件循环捞回回调才执行)

真正的等待不是店员在等,是水壶在等------即操作系统和底层 libuv 线程池在幕后完成 IO,主线程只负责派活和处理结果。

弱点 :若店员要「亲手磨豆 1 小时」(CPU 密集),没法交给水壶,只能埋头磨,期间全店卡死。补救就是前文的 worker_threads(真线程)和 cluster / 多进程。

补充知识点

IPC 通信

IPC = Inter-Process Communication,进程间通信。

由于进程之间 内存隔离 ,Worker#1 改不了 Worker#2 的变量。它们要交换信息,就靠 IPC。在 Node/cluster 里,IPC 主要通过 process.send() / message 事件 这条消息管道实现------fork 出子进程时,父子之间自动建立了一条通信通道:

js 复制代码
// 主进程 → 子进程
// ↓↓↓ 这两行在【主进程 master】里执行
const worker = cluster.fork(); // 变量 worker 本身在主进程里,它不是子进程,而是主进程手里握着的「子进程的遥控器/句柄」
worker.send({ type: 'reload-config', data: {} }); 

// 子进程接收
// ↓↓↓ 这段在【子进程 worker】里执行
process.on('message', (msg) => {
  if (msg.type === 'reload-config') { /* 处理 */ }
});

特点:

  • 传的是 消息(会被序列化成结构化拷贝) ,不是共享内存。发过去的是数据的 拷贝,不是同一个对象引用。
  • 双向:父子都能 send 和监听 message

在 Egg 里:Agent 和 Worker 不直接通信,消息都经 Master 转发 (Master 是 IPC 的中枢)。Egg 在 cluster 的 IPC 之上封装了更好用的 API,比如 app.messenger

js 复制代码
// 某个进程广播消息给所有进程
app.messenger.broadcast('some-event', data);
// 别的进程监听
app.messenger.on('some-event', (data) => { /* ... */ });

这就是 Agent 进程跑完定时任务 / 拿到配置变更后,通知所有 worker 的实现方式。

复制代码
        ┌─────────── Master ───────────┐
        │  fork + 守护 + 消息中转         │
        └───┬──────────────────┬────────┘
            │ IPC              │ IPC
        ┌───▼───┐        ┌─────▼─────┬──────────┐
        │ Agent │◄──────►│ Worker#1  │ Worker#2 │ ...
        └───────┘  经Master└───────────┴──────────┘
        (1个,后台)  转发消息      (N个,处理请求)
CPU 核数和进程数量关系

进程数 ≠ 核数,进程和核不是一对一。关键点:一个 CPU 核可以跑很多个进程。操作系统的调度器会在多个进程之间快速轮流切换(时间片轮转),让它们看起来同时在跑。所以:

  • 你可以在 12 核机器上 fork 出 4 个、12 个、100 个 worker 都行,不会报错。
  • 核数只是决定了同一瞬间最多有几个进程真正在并行执行的上限(12 核 → 同一时刻最多 12 个进程 / 线程真正并行),超出的部分靠调度器切换。

为什么 worker 数 ≈ 核数是常见推荐

因为 Node 的 worker 是独立进程、JS 单主线程:

  • worker 数 < 核数:有核闲着,浪费。
  • worker 数 = 核数:每个核大致对应一个 worker,并行度拉满,是对 CPU 密集型较优的选择。
  • worker 数 > 核数:进程比核多,得靠切换分时,上下文切换开销上升;但对 I/O 密集型(大部分时间在等数据库 / 网络,CPU 空闲)有时略超核数也无妨。

它是经验推荐值,不是硬限制。此外,cluster 的 主进程几乎不占 CPU (只管 fork 和守护),所以常见 / Egg 默认策略是 worker 数 = 核数(而非核数 - 1),无需专门留一个核给主进程。

本项目实例:config.workers = 1------即便在多核机器上也只 fork 1 个 worker。这是主动放弃多核并行,换取 @SingletonProto 单例全局唯一的语义(历史上曾有进程内会话状态,多 worker 会导致请求落到不同 worker 拿不到同一份内存)。可见进程数完全由配置决定,与核数无关。

相关推荐
RainCity2 小时前
Java Swing 自定义组件库分享(十三)
java·笔记·后端
C+-C资深大佬2 小时前
python while循环
服务器·开发语言·python
Tian_Hang2 小时前
eclipse ditto 学习笔记
运维·服务器·开发语言·javascript·3d
星夜夏空992 小时前
C++学习(2) —— 类与对象基础
开发语言·c++·学习
livemetee2 小时前
【关于Spring声明式事务】
java·后端·spring
倒流时光三十年2 小时前
Java 内存模型(JMM)通俗解释
java·开发语言
码兄科技3 小时前
Java AI智能体开发实战:从零构建企业级智能应用指南
java·开发语言·人工智能
2401_859506243 小时前
AIGC赋能大漆摆件设计:从痛点分析到技术架构与实战验证
java·大数据·人工智能
剑挑星河月3 小时前
54.螺旋矩阵
java·算法·leetcode·矩阵