iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Modern Web

用 Effect 實現產品級軟體系列 第 8

[學習 Effect Day8] 執行 Effect

  • 分享至 

  • xImage
  •  

前一篇我們介紹了 Effect Type 的定義和創建 Effect 的方法,這一篇我們要來講講如何執行 Effect。但在實際講語法之前,我想先講一下 Effect 為何需要特定語法來執行程式。背後的設計哲學 Continuation-Passing Style (CPS) 又是什麼。

Continuation-Passing Style (CPS) 介紹

Continuation-Passing Style (CPS) 是一種程式設計風格:function 在執行完成後,不會直接回傳結果,而是將結果傳遞給額外的 function 參數(稱為 continuation)來處理後續流程。在一般程式設計中,function 會「直接回傳結果」。例如:

function add(a: number, b: number): number {
  return a + b
}

const result = add(1, 2) // 結果 = 3

但在 Continuation-Passing Style (CPS) 裡,function 不再主動回傳值,而是把計算的結果交給另一個 function(continuation)處理:

//                                      ┌─── 在 CPS 的語境下,這個 callback 特別被稱為 continuation
//                                      ▼
function addCPS(a: number, b: number, cont: (res: number) => void): void {
  cont(a + b)
}

addCPS(1, 2, (res) => {
  console.log("結果:", res) // 3
})

在這種風格下,function 永遠「不回傳」,而是「把控制權交出去」。這種模式特別適合描述非同步運算,因為我們把「後面要做什麼」包成一個 function(continuation)先存起來,先把目前的執行權讓出來,等到 I/O(像是網路請求、讀檔、計時器)真的完成時,執行環境(runtime)會再幫我們呼叫那個 continuation,繼續後續流程。

我畫了一個序列圖。從圖中應該更好理解整個流程:

cps

簡單例子:

import { readFile } from "fs"

// 這個 function 不回傳檔案內容,而是接收一個 continuation
function readFileCPS(
  path: string,
  cont: (err: NodeJS.ErrnoException | null, data?: string) => void
): void {
  // 發出 I/O 請求給系統,並傳入 callback
  readFile(path, "utf-8", (err, data) => {
    // I/O 完成後,由 runtime 觸發 continuation
    cont(err, data)
  })
}

// 使用:程式呼叫,並傳入 "後續該做什麼"
console.log("1. 發出檔案請求")
readFileCPS("./package.json", (err, data) => {
  if (err) {
    console.error("發生錯誤:", err)
    return
  }
  console.log("3. continuation 被呼叫,檔案內容長度:", data?.length)
})
console.log("2. 我可以繼續做其他事,不被阻塞")

// 輸出:
// 1. 發出檔案請求
// 2. 我可以繼續做其他事,不被阻塞
// 3. continuation 被呼叫,檔案內容長度: 1234

Effect 的世界就是 CPS

從上面的例子,對照到 Effect 的用法,Effect.async 裡的 resume 其實就是那個 continuation 的觸發器:當非同步工作完成時,呼叫 resume(...) 把結果「交給下一步」。

import { Effect } from "effect"

const delayed = Effect.async<string>((resume) => {
  setTimeout(() => {
    resume(Effect.succeed("Hi Hi~"))
  }, 1000)
})

這段程式碼並不會立刻輸出結果。delayed 只是「描述」:

「等 1 秒後,我會呼叫 continuation,然後把字串 "Hi Hi~" 傳下去。」

不過不是只有上面的例子是用CPS 設計的喔~Effect 本身就是以此風格為基礎建立的。所以才能將多個不一樣的 Effect 組合起來,變成新的 Effect。未來我們會介紹 Effect 之間串連的語法。我們先回到此篇主題,介紹如何執行 Effect 吧~😀

Effect 的 run* 方法

runSync

使用 Effect.runSync 運行不會失敗不包含任何非同步操作的 Effect。

import { Effect } from "effect"

const program = Effect.sync(() => {
  console.log("I'm a sweet summer child.")
  return 1
})
const result = Effect.runSync(program)
// 輸出:I'm a sweet summer child.
console.log(result)
// 輸出:1

若用 runSync 執行「會失敗」或「含有非同步」的 Effect,會直接丟出例外:

try {
  Effect.runSync(Effect.fail("my error"))
} catch (e) {
  console.error(e) // 輸出:(FiberFailure) Error: my error
}

try {
  Effect.runSync(Effect.promise(() => Promise.resolve(1)))
} catch (e) {
  console.error(e) // 輸出:(FiberFailure) AsyncFiberException: Fiber #0 cannot be resolved synchronously. This is caused by using runSync on an effect that performs async work
}

但即便是同步程式還是有可能出錯,預期內的或預期外的錯誤(defect)都有可能。這時候我們就要用 runSyncExit 來處理。

runSyncExit

在 Effect 世界裡,當你「執行一個 Effect」時,它的運算結果並不單純只有「成功」或「錯誤」。Effect runtime 需要更完整的資訊來描述「這個 Effect 運行結束時發生了什麼」。這個「封裝任何 Effect 結果的型別」就是 Exit<A, E>

可以把它想成:

👉 Exit = Effect 運行後「最終結果(Outcome)」的封裝

  • 成功時 _tagSuccess,內含值 value
  • 失敗時 _tagFailure,內含物件 Cause 描述失敗原因。
// 輸出型別為:Exit<number, never>
console.log(Effect.runSyncExit(Effect.succeed(1)))
/*
輸出:
{
  _id: "Exit",
  _tag: "Success",
  value: 1
}
*/

console.log(Effect.runSyncExit(Effect.fail("my error")))
/*
輸出:
{
  _id: "Exit",
  _tag: "Failure",
  cause: {
    _id: "Cause",
    _tag: "Fail",
    failure: "my error"
  }
}
*/

如果 Effect 內含非同步操作,使用Effect.runSyncExit執行運算的話,會回傳_tagFailure的結果,其 CauseDie,表示「無法以同步方式完成這個Effect」。

console.log(Effect.runSyncExit(Effect.promise(() => Promise.resolve(1))))
/*
輸出:
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: {
    _id: 'Cause',
    _tag: 'Die',
    defect: [Fiber #0 cannot be resolved synchronously. This is caused by using runSync on an effect that performs async work] {
      fiber: [FiberRuntime],
      _tag: 'AsyncFiberException',
      name: 'AsyncFiberException'
    }
  }
}
*/

runPromise

當你需要用 Promise 語法處理運算時,使用 Effect.runPromise

// 使用 Promise 函數(10ms 後 resolve "resolved!")
function wait(ms: number): Promise<string> {
  return new Promise((resolve) => setTimeout(() => resolve("resolved!"), ms))
}
//         ┌─── Promise<string>
//         ▼
const asyncProgram = Effect.runPromise(
  Effect.promise(() => wait(10))
)
asyncProgram.then((value) => console.log(value))
// 輸出(10ms 後):resolved!
// 包裝原生 Promise(10ms 後 reject)
const asyncFailedProgram = Effect.runPromise(
  Effect.promise(() => new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error("my error")), 10)
  }))
).catch(console.error)
/*
輸出(約 10ms 後):
(FiberFailure) Error: my error
*/

說明:以上示例透過 Effect.promise 包裝原生 Promise,讓運算在延遲後才完成(或失敗)。Effect.runPromise 的角色是把 Effect 的結果以 Promise 交付,所以要得到回傳值的話,要使用 then 來取得;若想以結構化方式取得成功或失敗結果,並避免被 reject 的話,就要使用 Effect.runPromiseExit了。

runPromiseExit

執行 Effect 並回傳一個 Promise<Exit>。當你想在 Promise 流程中,判斷成功或失敗時,就可以使用 Effect.runPromiseExit

Exit 型別:

  • 成功時為 Success,內含成功下的 value
  • 失敗時為 Failure,內含 Cause 描述失敗原因。
import { Effect } from "effect"

Effect.runPromiseExit(Effect.succeed(1)).then(console.log)
/*
輸出:
{
  _id: "Exit",
  _tag: "Success",
  value: 1
}
*/

Effect.runPromiseExit(Effect.fail("my error")).then(console.log)
/*
輸出:
{
  _id: "Exit",
  _tag: "Failure",
  cause: {
    _id: "Cause",
    _tag: "Fail",
    failure: "my error"
  }
}
*/

runFork

runFork 會在新的 Fiber(輕量執行緒)中啟動一個 Effect,並立即回傳一個可操作的 Fiber 物件,因此不會像 runPromise 那樣等待運算完成後才給出結果。這讓你可以把某個 Effect 當作「背景任務」來執行,並透過 Fiber 提供的方法進行控制(之後的文章會講到)。適合的使用時機是:當你需要啟動長時間持續的工作(像是資料同步、事件監聽、定時任務)而不希望阻塞主要流程時,就應選擇 runFork;若是單純需要取得一次性的運算結果,則用 runPromise 會更合適。

// 一個會在 1 秒後完成的 Effect
const program = Effect.async<number>((resume) => {
  setTimeout(() => resume(Effect.succeed(42)), 1000)
})
// 啟動一條背景 fiber
const fiber = Effect.runFork(program)
// 0.5 秒後嘗試中斷(此例中會在完成前被中斷)
setTimeout(() => {
  Effect.runFork(Fiber.interrupt(fiber))
}, 500)

程式碼說明:

  • Effect.async 把非同步工作包裝成 Effect。
  • Effect.runFork 啟動 Effect 並回傳 Fiber
  • 之後可用 Fiber.interrupt 嘗試中斷(本例在 0.5 秒時中斷,所以不會完成)。

❖ 補充:什麼是 Fiber?

在 Effect 的世界裡,Fiber 是 runtime 管理的「使用者層級輕量執行單元」。它不是作業系統 Thread,而是一種在使用者空間運作的輕量級執行單元(類似協程 coroutine),由 runtime 提供,使應用程式能以受控的方式管理並發、支援中止與確保資源安全。這種抽象單元的調度完全發生在使用者空間(User space),而非 OS Kernel。

重點特性:

  • 可中止 (interruptible):Fiber 可接收中止訊號,於「安全點」停止並自動進行清理 → 屬於合作式中止 (cooperative),而非 Thread 強制 kill。
  • 可監督 (supervision):Fiber 之間具父子關係 → 提供結構化併發 (structured concurrency),確保沒有孤兒工作 (no orphan tasks)。
    • 在 Effect runtime 裡,子任務必須附屬在父任務的生命週期內。結構化併發保證:離開 Scope 時,所有子 Fiber 不是結束就是被自動中止。不會像 JS Promise 那樣「fire-and-forget」造成孤兒任務,避免資源洩漏與不可控狀態。
  • 完整結果 (Exit/Cause):Fiber 結束必定產生 Exit<A, E>;失敗時以 Cause 保留錯誤脈絡,可支援錯誤聚合與併發錯誤追蹤。
  • 極低成本:相較 OS Thread,Fiber 的建立/切換幾乎零成本,適合高併發 I/O 模型。

Fiber 與 Promise/Thread 的差異:

  • Promise 不可取消;Fiber 原生支援 interrupt/timeout/race 等中止語義。
  • Promise 只有 fulfill/reject;Fiber 以 Exit/Cause 保留完整結果與錯誤脈絡。
  • Thread 由 OS 排程且昂貴;Fiber 由 Effect runtime 協作式排程,更輕量、可被語言級抽象安全管理。

⚠️ 注意:Fiber 不會「把 JS 變多執行緒」。它處理的是邏輯併發(cancelation/supervision/composability),非 CPU 平行運算;若要多核心利用,仍須搭配 Worker Threads/子行程,再由 Effect 包裝。

這裡舉一個我們最常見邏輯併發的例子:

async function main() {
  const p1 = fetch("/api/users");
  const p2 = fetch("/api/posts");

  const [users, posts] = await Promise.all([p1, p2]);
  console.log(users, posts);
}
  • 在邏輯上:p1 和 p2 同時進行
  • 在實際上:JS 主執行緒只有一個,不可能真的在兩個 CPU 上平行算,而是:
    1. 把請求丟給系統 (非阻塞 I/O)
    2. 等待事件回來 → runtime 喚醒對應的 Promise 回調
      👉 表面上兩個請求同時跑,但在 CPU 裡,它們是交錯(event loop 調度)的 → 這就是邏輯併發。

你可以想像 Fiber 就是一個擁有這樣特性的抽象,但擁有更多流程控制的機制。我們在之後的文章再來細談語法。我們回到我們的主題🤣。

runSyncrunPromiserunFork 如何選擇呢?

1. runSync

  • 特性:直接執行一個 Effect,並立即回傳結果或丟出錯誤。
  • 行為完全同步,不會包裝成 Promise。
  • 適合場景:快速取值、不可能失敗的效應(例如純計算、初始化常數)。
  • ❌ 限制:不能處理異步操作,若遇到 asynchronous effect 會拋錯。

2. runPromise

  • 特性:執行一個 Effect,並把結果包成原生 JavaScript Promise
  • 成功時 → Promise.resolve(value)
  • 失敗時 → Promise.reject(error)
  • 適合場景:需要和 async/await外部 Promise API 整合。
  • 這時候,Effect runtime 的控制會在完成後交回給 JS 世界。

3. runFork

  • 特性:啟動一個新的 Fiber(輕量執行緒),並立即回傳一個 Fiber 物件。
  • 不會等待完成 → 不阻塞主程式。
  • 可以透過 Fiber 進行控制:.await() 等待完成,.interrupt() 中斷。(相關機制之後會再細講)
  • 適合場景:需要啟動背景任務長時間執行的效應(例如事件監聽、排程、stream 處理、伺服器啟動)。
  • 因為 runFork 保持與 Effect runtime 的連結,所以往往是「預設建議選擇」。
Executor 回傳型別 是否阻塞 使用時機
runSync 直接回傳值或拋錯 阻塞 純粹同步運算(例如初始化變數)
runPromise Promise<T> 等待結果 與 async/await、Node.js API 整合
runFork Fiber (可控制) 不阻塞 啟動背景任務、監控長時間運算、作為預設選擇

4. 為什麼 Effect 官網強調 runFork 是預設建議?

官方文件才會提到:

"Unless you specifically need a Promise or synchronous operation, Effect.runFork is a good default choice."

這是因為:

  • runPromise / runSync 會最終把結果交回給 JavaScript 世界,導致失去 Effect runtime 的掌控能力。
  • runFork 保持在 Effect 的語境內,讓程式運行下的錯誤處理、中斷、並行控制,都能交由 Effect 本身管理。

換句話說:
在大多數應用程式中,你需要的是「啟動任務並持續由 Effect runtime 管理」,而不是「一次性拿值」。因此 runFork 更符合 Effect 的設計哲學,通常也是最安全、最合理的預設選擇。

最佳實踐

執行時機

多數情況下,Effect 會在應用程式的「最外層」被執行。以 Effect 為核心的應用,通常只會在入口處啟動一次 main Effect。也就是說:我們大部分程式都在描述「要做什麼」。最後只會有一個「main effect」在程式最外層(aka 程式的邊界 Edge)被執行。這讓程式結構更可控、更純粹,讓副作用的影響都提升到應用最外層。(下一篇我們會更詳細探討這樣做的原因)

優先選擇非同步執行

非同步行為允許時間上的延續。同步行為runSync一旦呼叫 → 必須馬上算完並回傳結果。非同步行為runPromise/runFork呼叫後,程式不用卡住,可以隨時 等待、取消、錯誤恢復、資源釋放。

以下比較同步與非同步執行的差異:

面向 同步 非同步
時間性 需要立即完成,不可延續 可等待、延遲、長時間運作
I/O 適配 不適合需要等待的 I/O 自然承載各種 I/O
錯誤處理 當場 try/catch 集中處理並保留發生步驟與脈絡,較好追蹤
取消/中斷 難以在執行中介入 可發出取消訊號,於安全點停止
並行/併發 一次只做一件事 可同時多工並協調先後
資源清理 通常在 function 結尾手動關 完成/失敗/取消時皆可自動清理連線、檔案、計時器
典型場景 純計算、格式轉換、產生設定值 HTTP/資料庫/檔案、伺服器啟動、排程、事件監聽、WebSocket、背景任務

❖ 補充:為什麼非同步較容易中斷與清理?

  • 非同步工作由執行環境排程,並暴露可觀察的「取消訊號」。當你發出取消時,系統會在預先約定的安全點(多半是等待 I/O 或計時器的邊界)觸發清理動作。常見例子:使用 fetch 時,從請求送出到回應抵達之間是「等待的瞬間」,你可用 AbortController 取消;系統會在下一個安全點停止並關閉相關資源。
  • 同步呼叫會長時間佔用呼叫堆疊,外界無法插手(沒有等待的瞬間);除非程式主動檢查旗標或結束函式,否則很難「中途介入」。跨時間資源因此較容易遺漏清理。對照非同步的例子:

圖解(以 fetch + AbortController 為例):

fetch with AbortController

閱讀說明:

  • ->(實線):主動發出的請求/動作。
  • -->(虛線):回應/通知事件(例如回應完成、AbortError)。
  • 本圖僅示範「取消」情境
  • 圖中亦示範:取消後若回應晚到,會在網路層被丟棄,不再喚醒應用程式。

總結

  • 如果只是「純粹同步」邏輯,才用 runSync
  • 大部分場景都需要非同步 → 選 runPromise(和 async/await 整合)或 runFork(啟動背景任務)。
  • 非同步模式才能發揮 Effect runtime 的真正威力:錯誤管理、取消控制、並行運算與資源釋放

參考資料


上一篇
[學習 Effect Day7] 了解 Effect Type 並創建 Effect
下一篇
[學習 Effect Day9] 透過組裝 Effect 建構程式 (一)
系列文
用 Effect 實現產品級軟體10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言