V8 Isolates 執行模型:Workers 為何近乎零冷啟動 | Cloudflare 完整教學

2026/08/03
V8 Isolates 執行模型:Workers 為何近乎零冷啟動 | Cloudflare 完整教學

Cloudflare Workers 之所以能做到近乎零的冷啟動、驚人的部署密度,以及那些看似奇怪的行為限制,答案全都藏在一個核心設計裡——V8 Isolates。這一篇我們掀開引擎蓋,看懂 Isolate 到底是什麼、它憑什麼比容器快上百倍、多租戶如何安全共享同一個進程,以及為什麼你不能把模組層變數當狀態用。

前言

上一篇《第一個 Worker》裡,我們親手部署了一個 Worker,也留下兩個懸念:為什麼冷啟動可以小於 1ms?為什麼模組層級的變數不可靠? 這一篇就是來還這兩個債的。

先給一個一句話定義:Isolate 是 V8 引擎裡一個獨立的執行實例,擁有自己的記憶體堆(Heap)與 JavaScript 執行上下文(Context),但和同一進程內的其他 Isolate 共用同一個 V8 進程。 V8 是 Google 開發的 JavaScript 引擎,也是 Chrome 與 Node.js 的底層。Chrome 用 Isolate 把每個分頁的 JavaScript 彼此隔離;Cloudflare 則把這個機制搬到伺服器端,用一個 Isolate 承載一個 Worker。

打個比方:傳統 Serverless 像是每來一位客人,就替他蓋一間獨立的小屋(容器/VM),蓋屋子要打地基、接水電、裝家具,慢且佔地。V8 Isolates 則像一間大型共享辦公室,每來一位客人只是分配一張獨立的辦公桌(記憶體上下文),桌與桌之間看不到彼此的東西,但共用同一棟大樓的水電與空調(V8 進程與作業系統)。開一張桌子只要幾毫秒,一棟樓裡能塞下上千張桌子。這就是 Workers 高密度、低延遲的物理根源。

讀完本篇,你會掌握:

  • Isolate 的本質,以及它與 AWS Lambda 的 microVM、傳統 Docker 容器在隔離層級、記憶體、啟動時間上的系統性差異
  • 近乎零冷啟動的真正原理——不是「優化得比較好」,而是「根本跳過了整套啟動流程」
  • 高密度多租戶如何在共用進程的前提下維持安全隔離邊界
  • Isolate 的記憶體模型與生命週期,以及由此衍生的全域狀態陷阱
  • CPU time 計費為何與這個執行模型直接相關,對 I/O 密集工作負載為何特別划算

核心概念

Isolate 是什麼,它憑什麼這麼輕

要理解 Isolate 的優勢,得先看清楚傳統 Serverless 慢在哪。以 AWS Lambda 為代表,一次冷啟動大致要走完這幾步:

傳統容器 / microVM 的冷啟動流程:
① 從映像檔建立執行環境(container / microVM spin-up)
② 啟動作業系統核心與環境
③ 啟動語言 runtime(Node.js / Python 直譯器)
④ 載入你的應用程式與依賴
⑤ 執行初始化邏輯
   └─ 最佳情況 100–500ms,尖峰可能 > 1000ms

AWS 為了壓縮這段延遲,2018 年推出了 Firecracker microVM,能在約 125ms 內開好一個微型虛擬機——這已是業界頂尖工程,但仍慢 Isolate 幾個數量級。

V8 Isolate 的啟動方式,本質上是跳過上面每一步

V8 Isolate 的「冷啟動」流程:
V8 進程早已常駐運行(不需要 ①②③)
① 在既有進程內配置一塊記憶體堆與 JS 上下文
② 執行 Worker 模組的頂層程式碼(模組初始化)
   └─ 通常 0.1–0.5ms,小於 1ms

關鍵認知在這裡:Lambda 的冷啟動之所以慢,不是因為工程不夠好,而是它每次都要從無到有蓋一個完整的隔離環境;Isolate 之所以快,是因為那個環境(V8 進程與作業系統)從頭到尾都在,你只是進去分配一小塊空間。 記憶體代價也天差地遠——一個 Isolate 的記憶體基線只有數 KB,而一個容器動輒數十到數百 MB。省下的記憶體直接換成密度:一台 PoP 節點的一個 V8 進程,可以同時承載數百甚至數千個 Isolate

高密度、多租戶,與安全隔離邊界

既然這麼多 Isolate 擠在同一個進程裡,一個立刻浮現的問題是:安全嗎? 我的 Worker 和陌生人的 Worker 跑在同一個進程,他會不會讀到我的資料?

答案是不會,而這正是 Isolate 設計的精髓。每個 Isolate 擁有完全獨立的記憶體空間與全域作用域

  • Isolate A 的變數,Isolate B 無法讀取或修改——它們的記憶體堆是分開的
  • 沒有共享的全域變數。你在 Worker 裡宣告的 globalThis 屬性,只屬於你這個 Isolate
  • 惡意程式碼無法跨越 Isolate 邊界去攻擊同進程的其他租戶

換句話說,Isolate 用記憶體與上下文層級的隔離,換到了接近「每人一台 VM」的安全性,卻只付出「每人一張辦公桌」的成本。這是它和容器/VM 最本質的權衡差異:

┌──────────────── 一台 Cloudflare PoP 節點 ─────────────────┐
│  ┌──────────── 單一 V8 進程(常駐運行) ───────────────┐  │
│  │  ┌─Isolate A─┐ ┌─Isolate B─┐ ┌─Isolate C─┐ ┌─...─┐ │  │
│  │  │ Worker:foo │ │ Worker:bar │ │ Worker:foo │ │     │ │  │
│  │  │ 記憶體:2MB │ │ 記憶體:5MB │ │ 記憶體:2MB │ │千個 │ │  │
│  │  │ 獨立 Heap  │ │ 獨立 Heap  │ │ 獨立 Heap  │ │ ... │ │  │
│  │  └───────────┘ └───────────┘ └───────────┘ └─────┘ │  │
│  │        彼此記憶體完全隔離,共用同一進程與 OS         │  │
│  └────────────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────────────┘

注意上圖裡 Isolate A 和 Isolate C 都是同一支 Worker: foo——這說明同一個 Worker 在同一節點可能同時存在多個 Isolate,也可能在不同節點各有一份。這個事實是下一節「狀態陷阱」的伏筆。

代價當然也存在:Isolate 只能執行 V8 支援的東西(JavaScript、WebAssembly),不能跑任意原生二進位、不能開子行程、不能存取本地檔案系統,Node.js 生態也只有部分相容(需 nodejs_compat flag)。這不是缺陷,而是為了極致密度與安全所做的、非常清楚的取捨。

系統性對照:Isolates vs microVM vs 容器

把三種執行模型並排,差異一目了然:

維度Workers(V8 Isolates)AWS Lambda(Firecracker microVM)傳統容器(Docker)
冷啟動時間< 1ms125–500ms500ms–5s
記憶體基線數 KB數十 MB數十 MB–數百 MB
隔離層級記憶體 / 上下文VM 層級命名空間 / cgroup
單機承載密度數百~數千 / 進程每 VM 一個環境每容器一個環境
計費基準CPU 時間牆上時鐘時間牆上時鐘時間
全球分散原生 330+ PoP需手動多區域部署需自行設計
可執行內容JS / WASM任意 runtime、原生二進位任意,含完整 OS

這張表不只是規格比較,它其實在講同一個故事:Workers 用「限制執行內容」換來了「極致的啟動速度、密度與全球分散」。 你放棄了跑任意二進位的自由,換到的是把程式碼推到全球 330+ 個節點、每個請求近乎零冷啟動的能力。理解這個取捨,你就能判斷什麼工作適合放上 Workers、什麼不適合。

三個關鍵術語

  • Isolate(隔離區):V8 的一個獨立執行實例,含獨立 Heap、Context 與垃圾回收器。一個 Worker 對應一個 Isolate,一個 V8 進程可容納上千個。
  • 冷啟動 / 熱啟動:若目標節點上已有你 Worker 的 Isolate 存活,直接重用即為熱啟動,幾乎零延遲;若沒有、需新建 Isolate,則為冷啟動,小於 1ms。
  • 模組作用域(頂層程式碼)export default 之外、檔案最上層的程式碼。它在每次 Isolate 建立時執行一次,而非每次請求執行。這是理解狀態陷阱的關鍵切入點。

實作範例

概念聊完,我們用程式碼把「Isolate 生命週期」與「全域狀態陷阱」變成可以親眼觀察的東西。

頂層程式碼 vs 請求處理:各跑幾次

先建立一個直覺:模組層級的程式碼在 Isolate 建立時跑一次,fetch 裡的程式碼每個請求跑一次。 下面這段可以直接部署觀察:

// src/index.ts

// ── 頂層(模組作用域):每次 Isolate 建立時執行「一次」 ──
// 這一行只會在 Isolate 冷啟動時印出,不會每個請求都印
console.log("Isolate 已建立,頂層程式碼執行");

// 用一個時間戳當作「這個 Isolate 的出生時間」
const isolateBornAt = Date.now();

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // ── 這裡每個「請求」執行一次 ──
    const ageMs = Date.now() - isolateBornAt; // 此 Isolate 已存活多久
    const data = {
      message: "觀察 Isolate 生命週期",
      isolateAgeMs: ageMs,              // 同一 Isolate 連續請求,這個值會累加
      colo: request.cf?.colo ?? "unknown", // 處理此請求的 PoP 節點
    };
    return new Response(JSON.stringify(data, null, 2), {
      headers: { "Content-Type": "application/json; charset=utf-8" },
    });
  },
} satisfies ExportedHandler<Env>;

部署後連續重新整理幾次,你會觀察到兩件事:isolateAgeMs 通常會隨著每次請求慢慢變大(代表你打到的是同一個熱 Isolate,它被重用了);但偶爾會突然歸零或變得很小(代表這次請求觸發了新 Isolate 冷啟動,或被路由到另一個節點)。這就是 Isolate 「隨時可能重建、且不只一個」最直接的證據。

全域狀態陷阱:一個看起來正確、實際不可靠的計數器

現在來看最經典的坑。很多人第一直覺會這樣寫一個計數器:

// ❌ 危險:requestCount 是模組層級可變變數,不可靠
let requestCount = 0;

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    requestCount++; // 這個數字完全不可信
    return new Response(`Count: ${requestCount}`);
  },
} satisfies ExportedHandler<Env>;

在本地 wrangler dev 測試時,它看起來運作良好:每刷新一次,數字加一。但一旦上線到全球,它會用各種方式「背叛」你:

  • Isolate 被回收:長時間沒請求、節點記憶體壓力、程式碼重新部署後,Isolate 被銷毀重建,requestCount 歸零。
  • 多個 Isolate 並存:同一個 Worker 在同一節點可能有好幾個 Isolate,每個各數各的,你會看到數字跳來跳去。
  • 多節點分散:不同 PoP 節點的 Isolate 記憶體完全不共享,台灣的請求和東京的請求各數各的。

正確做法是把持久狀態交給外部的持久化服務——KV、D1 或 Durable Objects:

// ✅ 正確:狀態存進 KV,跨 Isolate、跨節點都可靠
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // 從 KV 讀出目前計數,加一後寫回
    const current = parseInt((await env.COUNTERS.get("requests")) ?? "0") + 1;
    await env.COUNTERS.put("requests", String(current));
    return new Response(`Count: ${current}`);
  },
} satisfies ExportedHandler<Env>;

補充一個進階觀念:如果你需要的是強一致的全域計數器(例如嚴格不重複的序號),KV 的最終一致性仍不夠,這時該用 Durable Objects——它保證同一個物件的所有請求都路由到單一實例序列化處理。這是後續文章會展開的主題。

什麼可以安全放在模組層級

看到這裡別誤會成「模組層級變數一律有毒」。恰恰相反,不可變的東西放在模組層級是安全且推薦的最佳實踐,因為它能在 Isolate 生命週期內跨請求重用,省下重複初始化的成本:

// ✅ 可以:不可變常數在 Isolate 內安全重用,且提升效能
// 這些只在 Isolate 建立時計算一次,之後每個請求直接共用
const ROUTE_REGEX = /^\/api\/users\/(\d+)$/;           // 編譯好的正規表達式
const CONFIG = { maxRetries: 3, timeout: 5000 } as const; // 唯讀設定
const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "DELETE"]);

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (!ALLOWED_METHODS.has(request.method)) {
      return new Response("Method Not Allowed", { status: 405 });
    }
    const match = new URL(request.url).pathname.match(ROUTE_REGEX);
    return new Response(match ? `User ID: ${match[1]}` : "Not Found", {
      status: match ? 200 : 404,
    });
  },
} satisfies ExportedHandler<Env>;

判斷準則一句話:只讀不變的,放模組層級沒問題;會被寫入、且你期待它跨請求保存的,就一定要交給 KV / D1 / Durable Objects。

常見錯誤與最佳實踐

坑一:把 Worker 當成常駐伺服器來思考。

傳統 Node.js 伺服器是一個長期運行的行程,你可以在啟動時建立連線池、載入快取到記憶體,然後整個生命週期共用。Worker 不是這樣——它更像一個「輸入 Request、輸出 Response」的純函式,每次請求都可能落在不同的 Isolate、不同的節點上。任何「我先在記憶體裡準備好,之後請求都能用」的假設,只要涉及可變狀態,遲早會出錯。心智模型換過來,很多坑自然消失。

坑二:依賴模組層級可變狀態做快取或計數。

如前所述,記憶體內的可變快取(例如把 API 回應存進一個模組層 Map)在單一熱 Isolate 內確實會命中,但它既不可靠也不跨節點共享,甚至可能造成不同用戶看到彼此的快取殘留。正確的邊緣快取應該用 Cache API 或 KV,它們有明確的一致性語義與 TTL 控制。模組層記憶體只適合放「重算一次也無妨、且對所有請求都一樣」的唯讀資料。

坑三:在頂層程式碼放耗時或會失敗的初始化。

模組作用域有 1 秒的執行上限,而且它會在每次 Isolate 冷啟動時執行。如果你在頂層做很重的計算、或發出網路請求去抓設定,不但拖慢每次冷啟動,還可能超時導致部署失敗或請求出錯。頂層只放輕量、同步、不會失敗的初始化(編譯正規表達式、建立唯讀常數);需要 I/O 的初始化應該延遲到 fetch 內、或改用 Bindings 在請求時取得。

坑四:忽略 CPU 時間計費與這個模型的關聯。

這是 Isolate 模型帶來的最實際好處,卻最常被忽略。因為 Isolate 是單執行緒事件迴圈、且 Cloudflare 能精準掌握每個 Isolate 真正消耗 CPU 的時間,Workers 是按 CPU 時間計費,而非牆上時鐘時間——等待 KV、D1、外部 fetch 回應的 I/O 空檔完全不計費:

一個請求的耗時分解範例:
總耗時(Wall Clock):450ms
  ├─ JavaScript 實際執行(CPU Time):8ms   ← 只有這 8ms 計費
  ├─ 等待 KV 回應:120ms                    ← 不計費
  ├─ 等待外部 API:280ms                    ← 不計費
  └─ 等待 D1 查詢:42ms                     ← 不計費

對 I/O 密集型的 API、BFF、代理這類工作負載,實際帳單往往遠低於用總耗時換算的直覺值——這正是 Lambda 按牆上時鐘計費模型下、同樣的 I/O 等待卻要照付的地方。最佳實踐是:把重運算控制在低毫秒級 CPU 時間內,讓大量 I/O 並行(善用 Promise.all),你就能同時拿到低延遲與低成本。

小結

上一篇《第一個 Worker》我們把 Hello World 推上了全球邊緣網路,並留下「為什麼冷啟動小於 1ms、為什麼模組層變數不可靠」兩個懸念。這一篇,我們掀開引擎蓋,把答案講清楚了:

  • Isolate 是 V8 進程內的獨立執行實例,一個進程能容納上千個,這是 Workers 高密度、低成本的物理基礎。
  • 近乎零冷啟動的原理不是「優化」而是「跳過」——V8 進程早已常駐,建立 Isolate 只是配置一小塊記憶體上下文,因此小於 1ms,比 Lambda 的 microVM 快上百倍。
  • 記憶體與上下文層級的隔離讓成千上萬個租戶安全共享同一進程,代價是只能跑 JS / WASM。
  • 模組層級可變狀態不可靠,因為 Isolate 隨時重建、且同時存在多個、跨節點不共享;持久狀態要交給 KV / D1 / Durable Objects,唯讀常數才適合放模組層。
  • CPU 時間計費直接源自這個單執行緒事件迴圈模型,讓 I/O 密集工作負載特別划算。

看懂了執行模型,下一步自然是把「限制」量化。下一篇《定價與限制》,我們會把 Free 與 Paid 方案的請求數、CPU 時間、記憶體、子請求等每一條線畫清楚,並教你怎麼估算成本、避開超限——這些限制,其實全都是本篇 Isolate 模型的直接延伸。

想深入官方細節,可以隨時參考 Cloudflare 官方「How Workers works」文件。準備好把執行模型換算成錢與限制了嗎?我們下一篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →