这篇笔记用于串联浏览器的多进程架构、网络 I/O、事件循环,以及一次 fetch 从页面发起、到服务器处理、再到页面更新的完整过程。

本文主要使用 Chromium 的架构帮助理解,但需要注意:

HTML、Fetch、Event Loop 等 Web 标准通常只规定可观察行为,不规定浏览器内部必须使用哪个进程或线程实现。

因此,本文中的进程、线程、队列和通信链路是便于理解的 Chromium 风格模型,不应被理解为所有浏览器都必须采用的固定实现。

核心结论

浏览器是由多个进程、线程、任务队列和运行时组件协作运行的系统,V8 只是其中负责 JavaScript 的一部分。

  • Browser Process:负责浏览器外壳、标签页管理、导航协调、权限和进程管理等工作。
  • Network Service:负责 DNS、代理、连接管理、HTTP、TLS、缓存等网络能力;现代 Chromium 中它可能运行在独立进程中,而不只是 Browser Process 内的一条固定“网络线程”。
  • Renderer Process:承载网页内容,通常包含 Blink、V8、渲染主线程、合成线程等组件。
  • Blink:Chromium 的渲染引擎,负责 DOM、HTML/CSS 解析、Web API、样式、布局、事件分发以及与页面事件循环的协作。
  • V8:JavaScript 引擎,负责解析、编译和执行 JavaScript,并实现 ECMAScript 语言能力。
  • 操作系统:实现 TCP/IP 协议栈,通过 Socket API 向浏览器和服务器程序提供网络能力。
  • 服务器程序:长期运行并监听端口;网络库从 Socket 中读取字节,HTTP 实现将字节解析成请求,Web 框架再匹配并调用处理函数。
  • Node.js 服务器:单个进程通常在一条主 JavaScript 线程上执行用户代码;同步代码会阻塞该线程,异步 I/O 等待期间则可以处理其他就绪任务。

最重要的认识是:

浏览器拥有 JavaScript 引擎,而不是 JavaScript 引擎拥有浏览器。

V8 可以近似理解成浏览器内部的 JavaScript 执行器,但它不是完整的浏览器运行时,也不负责网络、DOM、CSS、布局或页面事件循环的整体调度。

1. 浏览器整体架构

现代浏览器通常采用多进程、多线程架构。以 Chromium 为例,可以先用下面的简化模型理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Chrome / Browser
├── Browser Process
│ ├── 浏览器 UI
│ ├── 标签页与窗口管理
│ ├── 导航协调
│ ├── 权限与安全策略
│ └── 进程管理

├── Network Service
│ ├── DNS
│ ├── 代理配置
│ ├── HTTP / HTTPS
│ ├── TCP / TLS / QUIC
│ ├── 连接池
│ └── Cache / Cookie 等网络相关能力

├── Renderer Process
│ ├── Main Thread
│ │ ├── Blink
│ │ │ ├── HTML Parser
│ │ │ ├── DOM
│ │ │ ├── CSS / Style
│ │ │ ├── Layout
│ │ │ ├── Web API
│ │ │ └── Event Dispatch
│ │ ├── V8
│ │ │ └── JavaScript 执行
│ │ └── 页面事件循环相关调度
│ ├── Compositor Thread
│ └── Raster 相关线程

└── GPU Process
└── GPU 命令、合成与显示相关工作
组件 主要职责
Browser Process 管理浏览器外壳、导航、标签页、权限和其他进程
Network Service 负责网络请求、协议处理、连接管理和缓存等能力
Renderer Process 承载一个或多个页面或 frame 的执行与渲染环境
Blink 实现 DOM、HTML/CSS、Web API、布局、事件分发等 Web 平台能力
V8 实现 ECMAScript,解析、编译并执行 JavaScript
Renderer Main Thread 运行大量页面主线程工作,包括 JS、DOM、样式、布局和事件处理
Compositor Thread 处理图层合成、滚动等部分可以脱离主线程完成的工作
GPU Process 负责与 GPU 交互及图形相关任务

一个标签页不一定严格对应一个 Renderer Process。Chrome 会根据站点隔离、跨站 iframe、进程复用、扩展和安全策略决定进程分配。

Blink 和 V8 不是简单的上下级关系,而是两个相互调用、共同运行在 Renderer 中的组件。

当 JavaScript 调用 Web API 时:

1
2
3
4
5
6
7
JavaScript

V8 执行函数调用

通过绑定层进入 Blink

Blink 执行 DOM、Fetch、Timer 等具体能力

当浏览器需要执行 JavaScript 时:

1
2
3
4
5
6
7
8
9
Blink / 页面调度系统

确定需要执行某段脚本或回调

调用 V8

V8 执行 JavaScript

执行完成后返回浏览器运行时

例如:

1
document.querySelector("#app");

其中:

  • 函数调用表达式由 V8 执行。
  • document 和 DOM 查询能力由 Blink 实现。
  • V8 通过绑定层调用 Blink。
  • Blink 查询 DOM 后把结果转换为 JavaScript 可访问的对象。

因此:

V8 负责执行 JavaScript;Blink 负责大量浏览器和 Web 平台能力。两者通过绑定层协作。

2. TCP、Socket API、HTTP 与操作系统

需要区分协议、操作系统接口和应用程序库:

1
2
3
4
5
6
7
8
9
10
11
业务代码 / Web 框架

HTTP 实现:按照 HTTP 协议组织和解析消息

网络库:连接管理、缓冲、代理、TLS 等

Socket API:应用使用操作系统网络能力的接口

操作系统内核中的 TCP/IP 协议栈

网卡、以太网、Wi-Fi

2.1 IP 与 TCP

IP 负责寻址和尽力将数据包送往目标主机,但不保证数据一定到达,也不保证到达顺序。

TCP 建立在 IP 之上,为两个网络端点提供可靠、有序的字节流。

操作系统内核中的 TCP 实现通常负责:

  • TCP 连接建立与关闭
  • ACK、超时与重传
  • 数据乱序重排
  • 重复数据处理
  • 滑动窗口与流量控制
  • 拥塞控制
  • 发送与接收缓冲区管理

一条 TCP 连接通常可由四元组区分:

1
源 IP、源端口、目标 IP、目标端口

因此,多个客户端可以同时连接同一个服务器端口,操作系统仍能区分每一条连接。

2.2 Socket 与 Socket API

Socket 可以理解为操作系统维护的通信端点及其相关状态。

Socket API 是应用程序操作这些通信端点的接口。常见操作包括:

1
2
3
4
5
6
7
8
socket()   创建 Socket
bind() 绑定本地 IP 和端口
listen() 将 Socket 设置为监听状态
accept() 取得一条已建立的连接
connect() 主动建立连接
send() 提交要发送的字节
recv() 读取收到的字节
close() 关闭 Socket

应用调用 send() 时,并不是 Socket API 自己完成物理传输,而是将数据交给内核网络协议栈。

应用调用 recv() 时,通常是从内核为对应 Socket 维护的接收缓冲区中读取字节。

Socket API 不只支持 TCP,也可用于 UDP、Unix Domain Socket 等。Windows 常见 Winsock,Linux 和 Unix 系统常见 BSD Socket 风格接口。

2.3 HTTP 与 HTTP 实现

HTTP 是应用层协议,它规定:

  • 请求方法
  • URL 与路径
  • 请求头和响应头
  • 请求体和响应体
  • 状态码
  • 消息分帧和连接语义

HTTP 协议本身是一组规则,不是一段会主动运行的代码。

真正处理 HTTP 的是浏览器网络栈、服务器网络库或 HTTP 库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
发送:
结构化 HTTP 请求

HTTP 编码与协议处理

TLS(HTTPS 情况)

Socket API

网络传输

接收:
Socket API

TLS 解密(HTTPS 情况)

HTTP 协议解析

结构化响应对象与响应体数据

对于 TCP 来说,下面的 HTTP/1.1 请求只是一串需要可靠、有序传输的字节:

1
2
GET /users/42 HTTP/1.1
Host: example.com

准确说法是:

操作系统实现 TCP/IP 协议栈,Socket API 将网络能力暴露给应用;浏览器或服务器的 HTTP 实现按照协议组织和解析数据,并调用底层网络能力。

HTTP/1.1 和 HTTP/2 通常基于 TCP;HTTPS 在 HTTP 与 TCP 之间使用 TLS;HTTP/3 基于 QUIC,而 QUIC 通常运行在 UDP 之上。

3. Network Service 如何等待网络事件

“Network Thread”适合作为教学上的简化概念,但不应理解为浏览器中必然只有一条、名字固定的网络线程。

现代浏览器的网络实现可能包含:

  • Network Service 进程
  • I/O 线程
  • Socket 监听机制
  • DNS、代理和证书相关线程
  • IPC 消息循环
  • 定时器与任务队列

在 Linux 风格的简化模型中,某条负责 I/O 的线程可以近似理解为:

1
2
3
4
while (running) {
events = waitForIoOrTasks();
handleEvents(events);
}

底层等待机制可能使用:

  • Linux:epoll
  • macOS / BSD:kqueue
  • Windows:IOCP
  • 其他平台专用机制

当没有就绪事件时,负责等待的线程可以进入休眠,不持续占用 CPU。此时:

  • Renderer Main Thread 仍可以运行。
  • Browser Process 的 UI 线程仍可以运行。
  • 其他线程和进程仍可以运行。
  • 操作系统和网卡仍可以继续接收数据。

3.1 epoll_wait() 的概念模型

在 Linux 中,可以使用下面的模型理解:

1
2
3
4
5
6
7
8
9
I/O 线程调用 epoll_wait()

进入操作系统内核

没有已就绪的文件描述符

当前线程进入等待状态

CPU 执行其他线程

当某个受监听的文件描述符就绪时:

1
2
3
4
5
6
7
Socket 或唤醒 fd 变为 ready

内核唤醒等待线程

epoll_wait() 返回就绪事件

线程继续处理这些事件

代码从阻塞调用返回后继续向下执行:

1
2
events = epoll_wait(epollFd, ...);
handleEvents(events);

3.2 任务通知和网络数据

I/O 线程通常需要处理两类来源。

控制任务或跨线程消息

例如 Renderer 发起一次请求后,需要通过 IPC 或其他任务投递机制通知 Network Service:

1
2
3
4
5
6
7
Renderer Process

发送“开始请求”的 IPC 消息

Network Service 的消息循环收到任务

创建或复用连接

底层可能使用管道、事件对象、消息端口、平台唤醒机制等。

eventfd 或所谓 wakeupFd 可以作为帮助理解的例子,但不是 Fetch 标准要求,也不是所有平台和 Chromium 版本都必须采用的固定实现。

Socket 就绪事件

当服务器响应数据到达后:

1
2
3
4
5
6
7
8
9
10
11
12
13
服务器

网络

客户端网卡

内核 TCP/IP 协议栈

对应 Socket 的接收缓冲区

Socket 变为可读

I/O 线程获得通知

随后网络实现从 Socket 中读取数据,继续完成 TLS、HTTP 和响应体处理。

4. 一次 fetch 的网络链路

4.1 页面发起请求

页面执行:

1
const promise = fetch("/api/user");

可以按下面的模型理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
Renderer Main Thread

V8 执行 fetch(...)

通过 JavaScript 绑定层调用 Blink 的 Fetch 实现

创建并返回 pending Promise

Blink 按 Fetch 规范准备请求

通过 IPC 将网络工作交给 Network Service

Network Service 进行缓存、代理、DNS、连接和协议处理

需要注意:

fetch() 不是 V8 自己实现的网络功能。

V8 只负责执行函数调用。真正的 Fetch API、请求对象、CORS 检查协调、响应对象和流接口属于浏览器提供的 Web 平台能力。

4.2 创建或复用连接

Network Service 收到请求后,可能:

  • 命中缓存而不进行真实网络传输
  • 复用已有 HTTP/1.1 Keep-Alive 连接
  • 复用已有 HTTP/2 或 HTTP/3 连接
  • 创建新的 TCP/TLS 或 QUIC 连接
  • 跟随重定向
  • 经过代理服务器
  • 执行证书验证
  • 因 Service Worker 而走其他处理路径

因此,不能简单地认为每次 fetch() 都会创建一个新 Socket。

简化流程如下:

1
2
3
4
5
6
7
8
9
收到请求任务

检查 Service Worker、缓存、策略等

选择或创建连接

编码并发送 HTTP 请求

等待响应

4.3 接收响应

服务器响应数据到达后:

1
2
3
4
5
6
7
8
9
10
11
12
13
服务器响应

操作系统接收网络数据

数据进入 Socket 接收缓冲区

Network Service 获得可读通知

读取并处理 TLS / HTTP 数据

得到响应状态、响应头及响应体数据流

通知 Renderer

这里需要特别修正:

fetch() 返回的 Promise 通常在响应状态和响应头可用、并创建了 Response 对象后兑现,不需要等待整个响应体全部下载完毕。

因此:

1
const response = await fetch("/large-file");

执行到下一行时,响应体可能仍在通过网络持续到达。

真正等待完整响应体的是:

1
const data = await response.json();

或者:

1
const text = await response.text();

它们需要消费响应体流,通常要等到对应内容读取和解析完成。

完整模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
响应头可用

创建 Response

兑现 fetch Promise

await / then 对应的 Promise reaction 成为微任务

V8 执行后续 JavaScript

与此同时:

响应体仍可继续从网络到达

进入 Response.body 对应的 ReadableStream

5. 浏览器中的事件循环

浏览器页面的事件循环不是 V8 单独拥有的。

更准确地说:

HTML 标准定义了事件循环、任务、微任务和任务源等概念;浏览器运行时负责实现这些调度规则,V8 负责执行其中涉及的 JavaScript。

在 Chromium 中,Blink、Renderer 的消息循环、调度器以及 V8 共同完成这些行为。

不要简单记成:

1
Event Loop 属于 V8

更适合的模型是:

1
2
3
4
5
6
浏览器页面运行时
├── 任务调度
├── 不同任务源
├── 微任务检查点
├── 渲染更新时机
└── 调用 V8 执行 JavaScript

5.1 页面事件循环的简化模型

1
2
3
4
5
6
7
while (running) {
const task = selectRunnableTask();

runTask(task);
performMicrotaskCheckpoint();
updateRenderingIfNeeded();
}

这只是概念模型,不是浏览器源码的逐行实现。

每轮可以近似理解为:

1
2
3
4
5
6
7
8
9
10
11
选择并执行一个 Task

任务中的 JavaScript 可能调用 Blink Web API

任务完成、JavaScript 调用栈退出

执行 Microtask checkpoint

浏览器在合适时机进行渲染更新

继续处理后续任务

需要注意:

  • 浏览器不保证每执行一个 Task 都一定渲染一次。
  • 渲染通常与刷新时机、页面状态和浏览器调度策略有关。
  • 不同任务来自不同任务源,不能总是假设存在一条全局、严格 FIFO 的“宏任务队列”。

5.2 V8 的角色

当浏览器选择了一个包含 JavaScript 回调的任务时:

1
2
3
4
5
6
7
8
9
页面调度系统选择 Task

浏览器进入对应的事件处理逻辑

调用 V8 执行 JavaScript 回调

V8 执行完毕并返回

浏览器继续后续调度

因此,可以把 V8 近似理解为:

JavaScript 代码执行器。

但完整地说,V8 还负责:

  • JavaScript 源码解析
  • 字节码生成
  • JIT 编译与优化
  • JavaScript 调用栈
  • 对象和内存管理
  • 垃圾回收
  • ECMAScript Promise 等语言级能力
  • 微任务执行所需的 JavaScript 引擎机制

V8 不负责:

  • TCP、HTTP 或 DNS
  • HTML 和 CSS 解析
  • DOM 的底层实现
  • 页面 Layout 和 Paint
  • SSE 协议解析
  • 浏览器整体的页面事件循环调度

6. Task 与 Microtask

6.1 Task

Task 常被中文资料称为“宏任务”,不过 HTML 标准主要使用 task 这个术语。

常见来源包括:

  • 初始脚本执行
  • setTimeoutsetInterval
  • 用户输入事件
  • postMessage
  • MessageChannel
  • SSE 的事件分发
  • 某些网络状态事件
  • DOM 事件分发
  • 历史记录和导航相关工作

任务可以来自不同任务源。浏览器根据标准规则和调度策略选择可运行任务。

6.2 Microtask

常见来源包括:

  • Promise.then
  • Promise.catch
  • Promise.finally
  • await 后续
  • queueMicrotask
  • MutationObserver

到达微任务检查点时,浏览器会持续执行微任务,直到当前微任务队列为空。

如果微任务执行期间继续加入新的微任务,新微任务通常也会在同一个检查点继续执行。

1
2
3
4
5
6
7
8
9
10
11
console.log(1);

setTimeout(() => {
console.log(2);
}, 0);

Promise.resolve().then(() => {
console.log(3);
});

console.log(4);

执行过程:

  1. 当前 Script Task 开始执行。
  2. 输出 1
  3. 注册定时器;回调将在条件满足后成为 Task。
  4. Promise.then 注册 reaction;已兑现 Promise 使其成为微任务。
  5. 输出 4
  6. 当前 Script Task 中的 JavaScript 执行完成。
  7. 执行微任务检查点,输出 3
  8. 后续事件循环中执行定时器 Task,输出 2

最终输出:

1
2
3
4
1
4
3
2

6.3 微任务不是只存在于“JS Task”中

更准确地说:

微任务检查点与事件循环和 JavaScript 执行环境有关,不是某种名叫“JS Task”的任务内部专属机制。

常见情况下,在一个任务执行完成、JavaScript 执行栈退出后,会进行微任务检查点。

此外,规范还可能在其他明确规定的时机执行微任务检查点。

因此,适合记忆的模型是:

1
2
3
4
5
6
7
执行一段当前任务

JavaScript 调用栈清空

在规范要求的检查点清空微任务

继续后续调度

7. HTML Parser、Blink 与 V8

Renderer 收到 HTML 响应体数据后,Blink 的 HTML Parser 可以边接收边解析:

1
2
3
4
5
6
7
HTML 响应体字节持续到达

解码

HTML Tokenizer / Parser

构建 DOM

浏览器不必等整个 HTML 文件下载完成后才开始解析。

7.1 遇到普通同步脚本

例如:

1
2
3
<script>
console.log("hello");
</script>

简化流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HTML Parser 解析文档

遇到需要立即执行的经典同步脚本

必要时等待脚本下载

暂停当前解析过程

调用 V8 执行脚本

脚本执行完成

按规范执行相关微任务检查点

HTML Parser 继续

这里要注意:

  • Blink 负责 HTML Parser 和 DOM。
  • V8 负责执行脚本。
  • 脚本可以通过 DOM API 反过来修改 Blink 管理的 DOM。
  • 外部脚本下载通常可由网络系统进行,但经典同步脚本可能阻塞后续解析和执行。

7.2 deferasync

普通外部脚本:

1
<script src="app.js"></script>

通常会阻塞 HTML Parser,直到脚本可执行并完成。

defer

1
<script defer src="app.js"></script>

通常并行下载,在文档解析完成后、DOMContentLoaded 之前按文档顺序执行。

async

1
<script async src="app.js"></script>

通常并行下载,下载完成后尽快执行,不保证与其他异步脚本保持文档顺序,并可能中断解析。

8. 如何理解异步

异步不等于一定新建线程。

更准确地说:

异步表示当前操作可以先发起或登记后续逻辑,在结果尚未准备好时不持续占用当前 JavaScript 调用栈;结果可用后,再由运行时按相应规则安排后续逻辑。

通用模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
当前 JavaScript 发起异步操作

浏览器或运行时保存状态和后续逻辑

当前 JavaScript 继续执行并最终退出调用栈

异步条件在其他组件中完成

结果被转化为 Task、Microtask 或其他通知

页面事件循环在合适时机处理

V8 执行相应 JavaScript

对于 fetch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
V8 执行 fetch()

Blink 创建请求状态并返回 Promise

Network Service 处理网络请求

响应头可用

Renderer 创建 Response 并兑现 fetch Promise

Promise reaction 成为微任务

页面到达微任务检查点

V8 执行 then 或恢复 await 后续

如果随后调用:

1
await response.json();

则还会继续:

1
2
3
4
5
6
7
8
9
10
11
持续消费响应体流

等待完整内容

解析 JSON

兑现 response.json() 返回的 Promise

await 后续成为微任务

V8 继续执行 JavaScript

9. 多个网络请求为什么不会互相卡住

1
2
3
4
fetch("/api/a");
fetch("/api/b");

console.log("continue");

V8 只是依次执行两次 fetch() 调用。每次调用都会快速返回一个 Promise,网络工作被交给浏览器网络系统。

因此当前同步脚本可以继续:

1
2
3
4
5
6
7
8
9
发起请求 A

返回 Promise A

发起请求 B

返回 Promise B

输出 continue

网络系统可以同时管理大量请求和连接状态:

1
2
3
4
请求 A:等待 DNS / 连接 / 响应
请求 B:已收到响应头
请求 C:正在读取响应体
请求 D:从缓存返回

底层不会以“同步等待 A 完成后再看 B”的方式管理所有请求。

此外,多个请求不一定对应多个 TCP Socket:

  • HTTP/1.1 可能使用多个连接,也可能复用 Keep-Alive 连接。
  • HTTP/2 可以在一条连接上并发传输多个 Stream。
  • HTTP/3 可以在一条 QUIC 连接中并发传输多个 Stream。
  • 请求也可能命中缓存而不访问服务器。

10. fetch、流式响应、SSE 与 WebSocket

方式 通信方向 连接与数据特点 常见场景
普通 fetch 请求—响应 一次请求对应一次响应;响应体本身仍是 Stream 普通 API 请求
Fetch Stream 以响应数据为主 JavaScript 主动读取响应体流 流式文本、大文件、AI 输出
SSE 服务端到客户端 一个长期不结束的 HTTP 响应,以文本事件格式持续发送 通知、日志、进度、内容流
WebSocket 双向 建立 WebSocket 会话后以 Frame 双向传输 聊天、协作、实时控制

10.1 普通 fetch

1
2
3
4
fetch("/api/user").then(async (response) => {
const user = await response.json();
console.log(user);
});

简化链路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
V8 执行 fetch()

Blink Fetch 实现

Network Service

服务器

响应头可用

fetch Promise 兑现

then 回调成为微任务

V8 执行 then 回调

response.json() 消费响应体

JSON 完整读取并解析后兑现 Promise

V8 继续执行

10.2 Fetch Stream

1
2
3
4
5
6
7
8
9
10
11
12
const response = await fetch("/api/chat");
const reader = response.body.getReader();

while (true) {
const { value, done } = await reader.read();

if (done) {
break;
}

console.log(value);
}

简化处理过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
Socket 收到一部分响应体数据

Network Service 处理 HTTP 数据

响应体数据传递给 Renderer

进入 ReadableStream 内部队列

挂起的 reader.read() Promise 兑现

await 后续成为微任务

V8 执行 JavaScript 处理这一块数据

这里的 value 通常是字节数据,如 Uint8Array,还可能需要使用 TextDecoder 进行文本解码。

必须牢记:

网络分块、HTTP 分块、ReadableStream 每次读到的数据和业务消息边界不一定一致。

一次 reader.read() 可能得到:

  • 半条业务消息
  • 一条完整业务消息
  • 多条业务消息拼在一起

因此,应用层通常需要自己维护缓冲区并识别业务协议边界。

10.3 SSE

1
2
3
4
5
const source = new EventSource("/events");

source.addEventListener("progress", (event) => {
console.log(event.data);
});

SSE 的本质是:

一个 Content-Type: text/event-stream 的长期 HTTP 响应。

服务器持续在同一个响应体中写入文本:

1
2
3
4
5
6
event: progress
data: 30

event: progress
data: 60

处理链路可以理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
服务器持续写响应体

Network Service 接收 HTTP Body 数据

数据传递给 Renderer 中的 EventSource 实现

SSE Parser 按行解析

遇到空行,确认一个 SSE Event 完整

构造并分发 MessageEvent

对应事件处理成为页面任务

事件循环选择该任务

V8 执行 onmessage 或事件监听回调

需要特别注意:

  • SSE Parser 不是 V8。
  • V8 不解析 data:event:id:retry:
  • SSE Parser 具体运行在哪条线程属于浏览器实现细节,标准并未要求必须在网络线程或主线程。
  • JavaScript 事件回调最终在 EventSource 所属的执行环境中运行。普通页面通常是 Renderer Main Thread;Worker 中的 EventSource 则对应 Worker 的事件循环。
  • event.data 是字符串,浏览器不会自动执行 JSON.parse()
  • SSE 事件边界由空行确定,而不是由 TCP 包或某次网络读取确定。

10.4 WebSocket

1
2
3
4
5
6
7
const ws = new WebSocket("wss://example.com/socket");

ws.onmessage = (event) => {
console.log(event.data);
};

ws.send("hello");

对于经典 HTTP/1.1 WebSocket 握手:

1
2
3
4
5
6
7
客户端发送 HTTP Upgrade 请求

服务器返回 101 Switching Protocols

连接切换到 WebSocket 协议

双方使用 WebSocket Frame 双向通信

在较新的协议环境中,也存在基于 HTTP/2 扩展 CONNECT 等建立 WebSocket 的方式,因此“WebSocket 永远通过 HTTP/1.1 Upgrade”不是绝对规则。

无论底层如何建立,WebSocket 都具有自己的消息分帧规则。它与 TCP 字节流不同:

  • TCP 本身没有消息边界。
  • WebSocket Frame 提供协议级分帧。
  • 一个 WebSocket Message 仍可能由多个 Frame 构成。
  • 浏览器向 JavaScript 分发的是整理后的消息事件。

11. 服务器端:监听端口与处理 HTTP 请求

服务器程序通常是启动一次并长期运行的进程,不会为每次请求重新启动整个应用。

11.1 服务器启动阶段

以 Node.js 和 Express 为例:

1
2
3
4
5
6
7
8
9
10
const express = require("express");

const app = express();

app.get("/users/:id", async (req, res) => {
const user = await database.findUser(req.params.id);
res.json(user);
});

app.listen(3000);

启动阶段可以近似理解为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
启动 Node.js 进程

加载 JavaScript、配置和依赖

初始化长期对象

注册中间件和路由

请求运行时创建服务器

创建监听 Socket

bind 到本地地址和端口

listen

进入 Node.js / libuv 事件循环

listen(3000) 最终会使操作系统把发往对应地址和端口的新连接交给这个服务器。

11.2 监听 Socket 与连接 Socket

监听 Socket 用于接收新的连接。

当客户端建立 TCP 连接后,操作系统会为该连接维护独立状态,服务器可以得到一个连接 Socket:

1
2
3
4
监听 Socket:0.0.0.0:3000
├── 连接 A:客户端 A:51001 → 服务器:3000
├── 连接 B:客户端 B:51002 → 服务器:3000
└── 连接 C:客户端 C:51003 → 服务器:3000

这些连接共享服务器端口,但四元组不同,所以不会互相混淆。

可以把三层匹配记成:

1
2
3
4
5
6
7
8
9
10
11
目标地址和端口

找到对应监听 Socket / 服务

连接四元组

找到具体 TCP 连接状态

HTTP 方法、主机和路径

由服务器和 Web 框架匹配处理逻辑

在实际部署中,请求还可能先经过:

  • 负载均衡器
  • CDN
  • 反向代理
  • API Gateway
  • Service Mesh

因此,公网端口不一定直接对应最终 Node.js 进程。

11.3 服务器接收请求的链路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
客户端发送数据

服务器网卡接收数据包

内核 TCP/IP 协议栈处理

数据进入连接 Socket 接收缓冲区

服务器运行时得到可读通知

从 Socket 读取字节

HTTP 实现持续解析请求

得到完整请求语义或请求体流

Web 框架匹配中间件和路由

调用请求处理函数

应用生成响应

HTTP 实现编码响应

经 Socket 和网络协议栈发送

网络数据到达并不会直接调用 Express 路由。中间必须经过:

  • 操作系统协议栈
  • Socket 缓冲区
  • Node.js / libuv 网络层
  • HTTP Parser
  • Node.js HTTP 模块
  • Express 中间件和路由匹配

11.4 TCP 字节流与 HTTP 消息边界

TCP 提供连续、有序字节流,但不提供业务消息边界。

客户端连续写入:

1
2
Hello
World

服务端可能读取到:

1
HelloWorld

也可能:

1
2
Hel
loWorld

或者其他组合。

因此:

一次 Socket 可读事件、一次 recv()、一次 TCP Segment,都不等于一个完整 HTTP 请求。

HTTP 实现必须维护解析状态,并依据协议规则识别边界。

HTTP/1.1 中可能依据:

  • 请求行和响应行
  • Header 结束空行
  • Content-Length
  • Transfer-Encoding: chunked
  • 连接关闭语义

HTTP/2 和 HTTP/3 则有自己的 Frame 和 Stream 机制。

同一个 TCP 连接也不一定只处理一个 HTTP 请求:

  • HTTP/1.1 Keep-Alive 可以顺序复用连接。
  • HTTP/1.1 Pipelining 在实践中较少使用。
  • HTTP/2 可以在一条连接中并发多个 Stream。
  • HTTP/3 可以在 QUIC 连接中并发多个 Stream。

12. Node.js 服务器的运行与请求调度

Node.js 单个进程通常使用一条主 JavaScript 线程执行用户 JavaScript。

底层网络 I/O 由操作系统和 libuv 协作处理。部分文件系统、DNS、加密等工作可能进入 libuv 线程池,但不能简单地说所有异步操作都由线程池完成。

同一时刻,在一条 Node.js 主线程上通常只能执行一段 JavaScript。

12.1 每个请求调用一次处理函数

1
app.get("/users/:id", handler);

三个请求到达后,可以近似理解为多次调用同一函数:

1
2
3
handler(reqA, resA);
handler(reqB, resB);
handler(reqC, resC);

函数代码只有一份,但每次调用拥有独立的:

  • 参数
  • 局部变量
  • 闭包状态
  • Promise 链
  • 请求和响应对象
  • 执行进度

同时:

1
2
3
4
5
6
7
8
9
10
11
函数局部状态
→ 每次调用通常独立

进程内全局变量、模块缓存、连接池
→ 由同一进程中的请求共享

不同 Node.js 进程的内存
→ 默认互相隔离

数据库、Redis、对象存储
→ 可由多个进程共同访问

重要业务状态通常不应只保存在单进程全局变量中,因为:

  • 进程重启会丢失。
  • 多进程部署时各进程拥有不同内存副本。
  • 水平扩容后不同机器之间也不共享内存。

12.2 只有同步代码时

如果 A、B、C、D 四个请求对应的 JavaScript 回调已经就绪,而处理函数完全同步,则主线程会逐个执行:

1
2
3
4
5
6
7
8
9
10
11
执行 A 的同步 JavaScript

调用栈清空

执行 B 的同步 JavaScript

调用栈清空

执行 C

执行 D

A 的同步 JavaScript 没有结束前,其他请求的 JavaScript 回调不能插入当前调用栈。

12.3 遇到异步 I/O 时

1
2
3
4
5
6
7
8
app.get("/users/:id", async (req, res) => {
console.log("开始", req.params.id);

const user = await database.findUser(req.params.id);

console.log("结束", req.params.id);
res.json(user);
});

简化流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
执行请求 A 的同步部分

发起数据库操作

await 使当前 async 函数暂停

A 的当前 JavaScript 调用栈退出

事件循环可以执行 B、C、D 等其他任务

A 的 Promise 兑现

恢复 A 后续代码的 reaction 被安排

在合适的事件循环阶段继续执行 A

暂停的是:

A 这一次 async 函数调用的后续执行。

不是:

整个 Node.js 进程或整个主线程一直阻塞等待数据库。

异步结果不会抢占当前正在执行的同步 JavaScript。它必须等待当前调用栈退出,并按照 Node.js 的事件循环和微任务规则获得执行机会。

请求可能:

1
2
按 A、B、C 的顺序开始
按 B、A、C 的顺序完成

因为各自等待的数据库、文件或网络 I/O 耗时不同。

13. 慢函数与事件循环阻塞

判断服务器是否被阻塞,关键不只是函数总耗时,而是:

这段时间里,主 JavaScript 线程是否持续被同步代码占用。

13.1 慢异步 I/O

1
const user = await database.findUser(id);

即使数据库查询需要十秒:

  • 当前请求会很慢。
  • 当前 async 函数会在 await 处暂停。
  • 主 JavaScript 线程可以处理其他就绪任务。

但异步并不意味着资源无限。仍可能受限于:

  • 数据库连接池
  • 最大并发连接数
  • Socket 数量
  • 内存
  • 请求队列
  • 下游服务容量
  • 超时策略
  • 文件描述符上限

13.2 慢同步计算

1
2
3
4
5
6
7
8
9
app.get("/calculate", (req, res) => {
const endTime = Date.now() + 10_000;

while (Date.now() < endTime) {
// 持续占用主线程
}

res.send("完成");
});

这十秒中:

  • 当前 Node.js 主线程无法执行其他 JavaScript 回调。
  • 操作系统仍可能接收网络数据。
  • 内核和运行时缓冲区可能暂存部分数据。
  • 请求可能排队。
  • 缓冲区和队列可能持续增长。
  • 客户端可能超时或断开。

async 关键字不会自动把同步计算移出主线程:

1
2
3
4
5
async function calculate() {
for (let i = 0; i < 10_000_000_000; i++) {
// 仍然同步阻塞
}
}

适合处理 CPU 密集任务的方案包括:

  • Worker Threads
  • 子进程
  • 多个工作进程
  • 独立计算服务
  • 后台任务队列
  • 原生模块或专门计算平台

典型多进程部署:

1
2
3
4
5
负载均衡器 / 反向代理
├── Node.js 工作进程 1
├── Node.js 工作进程 2
├── Node.js 工作进程 3
└── Node.js 工作进程 4

多进程可以利用多个 CPU 核心并隔离部分故障,但某一工作进程中的长时间同步 JavaScript,仍会阻塞该进程。

14. 一次 HTTP 请求的浏览器与服务器完整链路

下面将浏览器端和服务器端串联起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
页面中的 JavaScript 调用 fetch()

V8 执行函数调用

通过绑定层进入 Blink 的 Fetch 实现

Blink 创建请求状态并返回 pending Promise

Renderer 通过 IPC 把网络工作交给 Network Service

Network Service 检查缓存、Service Worker、代理、策略和连接池

创建或复用 TCP/TLS、HTTP/2 或 QUIC 连接

HTTP 实现编码请求

Socket API 将数据交给客户端操作系统网络协议栈

网络传输

服务器操作系统接收数据

数据进入服务器连接 Socket 的接收缓冲区

服务器运行时从 Socket 读取字节

服务器 HTTP 实现解析请求

Web 框架匹配中间件和路由

调用请求处理函数

执行校验、业务逻辑和数据库访问

服务器生成响应

HTTP 实现编码状态码、响应头和响应体

通过服务器 Socket 发出数据

网络传输

客户端 Network Service 接收并处理协议数据

响应状态和响应头可用

通知 Renderer 并创建 Response

fetch Promise 兑现

then / await 后续成为微任务

页面执行微任务检查点

V8 执行后续 JavaScript

JavaScript 可能继续读取 Response.body

JavaScript 修改 DOM

Blink 重新计算 Style

必要时执行 Layout

生成 Paint 信息

Compositor / Raster / GPU 完成合成和显示

页面更新

需要特别区分:

1
fetch Promise 兑现

不等于:

1
整个响应体已经下载完成

前者通常在响应头可用时发生,后者需要进一步消费响应体。

15. 浏览器内部完整模型

可以把浏览器内部各部分的协作汇总为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Browser Process
├── 浏览器外壳 UI
├── 导航协调
├── 标签页和进程管理
└── 权限与安全控制

Network Service
├── DNS / 代理
├── 连接池
├── Socket
├── TCP / TLS / QUIC
├── HTTP / HTTP2 / HTTP3
├── Cache
└── 通过 IPC 通知 Renderer

Renderer Process
├── Blink
│ ├── HTML Parser
│ ├── DOM
│ ├── CSS / Style
│ ├── Layout
│ ├── Web API
│ ├── Fetch / EventSource
│ └── Event Dispatch

├── V8
│ ├── JavaScript 解析
│ ├── 字节码与 JIT
│ ├── JavaScript 执行
│ ├── 对象与内存管理
│ └── 垃圾回收

├── 页面事件循环与调度
│ ├── Task
│ ├── Microtask checkpoint
│ └── Rendering opportunity

├── Compositor Thread
└── Raster 相关线程

GPU Process
└── GPU 与显示相关能力

15.1 一次 fetch 的核心链路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
JavaScript 调用 fetch()

V8 执行 JavaScript

通过绑定层调用 Blink Fetch

请求交给 Network Service

Network Service 创建或复用连接

操作系统和网络协议栈完成传输

服务器处理并发送响应

Network Service 得到响应头和响应体流

通知 Renderer

fetch Promise 兑现

Promise reaction 进入微任务机制

页面到达 Microtask checkpoint

V8 执行 then 或 await 后续

如继续读取响应体,则数据通过 ReadableStream 逐步提供

JavaScript 修改 DOM

Blink 执行样式、布局、绘制

Compositor 和 GPU 完成显示

15.2 一次 SSE 消息的核心链路

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
服务器在长期 HTTP Response 中写入 SSE 文本

Network Service 接收响应体字节

数据传递给 Renderer 中的 EventSource 实现

SSE Parser 持续缓冲并按行解析

遇到空行,确认一个 Event 完整

创建 MessageEvent

将事件分发安排为 Task

页面事件循环选择该 Task

浏览器调用 V8

V8 执行 onmessage 或 addEventListener 回调

任务中的 JavaScript 执行完成

执行 Microtask checkpoint

浏览器按需安排渲染

16. 常见误解修正

误解一:V8 是浏览器引擎

不准确。

更准确地说:

1
2
Blink:渲染引擎 / Web 平台实现
V8:JavaScript 引擎

两者共同位于 Chromium Renderer 中并相互协作。

误解二:fetch 是 V8 实现的

不准确。

fetch 是浏览器提供的 Web API。V8 执行 JavaScript 对 fetch() 的调用,再通过绑定层进入 Blink 和网络系统。

误解三:V8 负责页面 Event Loop

不准确。

HTML 标准定义页面事件循环语义,浏览器运行时负责调度和实现;V8负责执行调度过程中需要运行的 JavaScript。

误解四:浏览器一定有一条固定的 Network Thread

不准确。

“网络线程”是教学简化。现代 Chromium 使用 Network Service,内部可包含不同线程、任务循环和平台 I/O 机制,并可能运行在独立进程中。

过于笼统。

不同层级由不同组件处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TCP / TLS / HTTP
→ 通常由网络系统处理

HTML
→ Blink HTML Parser

CSS
→ Blink CSS Parser

SSE 文本事件
→ Renderer 中的 EventSource 实现

JSON
→ JavaScript 显式 JSON.parse() 或 response.json()

误解六:fetch Promise 在整个响应体下载完成后才兑现

错误。

fetch Promise 通常在响应头可用并创建 Response 后兑现。响应体可以继续流式到达。

误解七:一次 Socket 可读就等于一个 HTTP 请求或 SSE Event

错误。

TCP 是字节流,没有应用消息边界。HTTP Parser、SSE Parser 或业务协议解析器必须维护缓冲区并识别边界。

误解八:每执行一个 Task,浏览器必定渲染一次

错误。

任务结束后通常会进行微任务检查点,但是否立即渲染由刷新时机、页面状态和浏览器调度决定。

误解九:SSE Parser 像 V8 一样执行代码

错误。

SSE Parser 是一个协议解析组件,只解析文本格式并生成事件。V8 是 JavaScript 引擎,负责执行事件回调中的 JavaScript。

误解十:异步就是多线程

错误。

异步表示当前 JavaScript 不需要同步等待结果。底层可能使用:

  • 操作系统异步 I/O
  • 事件通知
  • 其他线程
  • 其他进程
  • 硬件
  • 任务队列

异步是一种控制流和调度方式,不等同于特定线程模型。

17. 复习清单

  • 浏览器是多进程、多线程、多个运行时组件协作的系统。
  • Browser Process、Network Service、Renderer Process 和 GPU Process 承担不同职责。
  • Network Service 在现代 Chromium 中可能是独立进程,不应简单等同于 Browser Process 内的一条固定 Network Thread。
  • Blink 是 Chromium 的渲染引擎和 Web 平台实现,负责 DOM、HTML/CSS、Web API、布局和事件分发等能力。
  • V8 是 JavaScript 引擎,负责解析、编译和执行 JavaScript。
  • V8 可以近似理解为 JavaScript 执行器,但还负责 JIT、对象内存、垃圾回收等 JavaScript 引擎能力。
  • V8 不负责 TCP、HTTP、DOM、CSS、Layout、Paint 或 SSE 协议解析。
  • JavaScript 调用 fetchdocumentsetTimeoutEventSource 时,会通过绑定层进入 Blink 或其他浏览器实现。
  • 浏览器需要执行 JavaScript 回调时,会调用 V8。
  • 页面 Event Loop 不是 V8 单独拥有的;它是浏览器页面运行环境的调度机制。
  • Task 执行完成并退出 JavaScript 调用栈后,通常会进行 Microtask checkpoint。
  • 微任务检查点会持续执行微任务,直到当前微任务队列为空。
  • 浏览器不保证每个 Task 后都立即渲染。
  • TCP/IP 协议栈主要由操作系统实现。
  • Socket 是操作系统维护的通信端点,Socket API 是程序使用网络能力的接口。
  • TCP 提供可靠、有序的字节流,但不提供 HTTP、SSE 或业务消息边界。
  • HTTP 实现负责把结构化消息编码为字节,并从字节中解析 HTTP 语义。
  • epoll_wait()、IOCP、kqueue 等是平台 I/O 等待机制,不是 Web 标准规定的浏览器结构。
  • wakeupFd 可以帮助理解跨线程唤醒,但不能视为每次 Fetch 都必须经过的标准步骤。
  • 一次 fetch() 不一定创建新 Socket,可能复用连接、命中缓存或经过 Service Worker。
  • fetch Promise 通常在响应头可用时兑现,而不是等待整个响应体完成。
  • response.json()response.text() 等需要继续读取并消费响应体。
  • Response.bodyReadableStream,可以逐步读取响应体。
  • 一次 Stream 读取不一定对应一个完整业务消息。
  • SSE 是一个长期不结束的 HTTP 响应。
  • SSE Parser 根据空行识别事件边界,而不是根据 TCP 包或网络 Chunk。
  • SSE Parser 不是 V8,其具体运行线程属于浏览器实现细节。
  • 一个完整 SSE Event 解析完成后,浏览器会安排事件分发任务。
  • EventSource 回调最终由对应执行环境中的 V8 执行。
  • WebSocket 提供双向通信和协议级消息分帧。
  • 服务器监听端口,本质上是创建监听 Socket 并让操作系统将新连接交给服务器。
  • 监听 Socket 负责接收新连接,每条 TCP 连接有独立连接状态和缓冲区。
  • 目标地址和端口帮助找到服务,TCP 四元组区分连接,HTTP 方法和路径匹配应用处理逻辑。
  • Node.js 单进程通常由一条主 JavaScript 线程执行用户代码。
  • Node.js 底层网络 I/O 由操作系统和 libuv 协作完成。
  • Node.js 的同步代码不会被其他 JavaScript 请求处理逻辑抢占。
  • await 暂停的是当前 async 函数的后续执行,不是整个进程。
  • 慢异步 I/O 不会持续占用主 JavaScript 线程,但仍消耗连接、内存和下游容量。
  • 长时间同步计算会阻塞当前 Node.js 工作进程。
  • CPU 密集任务通常需要 Worker Threads、多进程或独立计算服务。

最终心智模型

把浏览器理解成一个完整运行时:

1
2
3
4
5
6
7
浏览器运行时
├── 负责网络
├── 负责 DOM 和 CSS
├── 负责事件循环
├── 负责任务调度
├── 负责布局和绘制
└── 在需要执行 JavaScript 时调用 V8

而 V8:

1
2
3
4
5
V8
├── 接收要执行的 JavaScript
├── 解析、编译并运行
├── 执行过程中调用浏览器提供的 Web API
└── 执行完成后把控制权交还给浏览器运行时

因此,最值得记住的一句话是:

浏览器负责组织整个网页运行环境,V8 负责在这个环境中执行 JavaScript。

或者用更形象但不完全严格的比喻:

1
2
浏览器运行时 ≈ 操作系统和调度环境
V8 ≈ 专门执行 JavaScript 的 CPU

这个比喻不是完整的工程事实,但非常适合建立最初的职责边界。