後端知識 - 我以為 JS 會先印 B。

image

這是 backend-lab 自學紀錄的 Day 2,主題是 C# 的 async 和 await。我寫了好幾年 Promise,以為這塊會很快,結果把 C# 和 JavaScript 的同一個實驗並排跑,我連自己最熟的 JS 都猜錯。這篇記錄那次猜錯,還有後面接連踩到的幾個坑:一個永遠等不完的 Promise、一個多此一舉的 Task.Run,以及我花最久才改掉的那個誤會。

執行緒 ID 的實驗、99 個計時器只花 1002 毫秒、還有 await setTimeout 的錯,post-11 都講過了,這篇不重複。

同一個實驗,寫兩次

C# 一份,JS 一份,看誰先印

C# 的 Task 和 JS 的 Promise 長得很像,都是「一個之後才會有結果的東西」。我想確認它們是不是真的同一套機制,所以寫了一個最小的實驗。

C# 的版本是這樣:

List<Task> tasks = new List<Task>();

for (int i = 1; i < 100; i++)
{
    tasks.Add(Task.Delay(1000));
}

async Task ConsoleDisplayAsync()
{
    Console.WriteLine("This is A");
    await Task.WhenAll(tasks);
    Console.WriteLine("This is B");
}

Task displayTask = ConsoleDisplayAsync();

Console.WriteLine("This is C");

await displayTask;

Task.Delay(1000) 是等一秒,Task.WhenAll 是等清單裡的全部做完,大概就是 JS 的 Promise.all。

跑出來是 A、C、B。

async 方法被呼叫之後,會先同步往下跑,跑到第一個 await 才停。停下來的時候,它把一個還沒完成的 Task 交給呼叫端,自己先退場。所以呼叫端接著印出 C,等那一秒過去,B 才出來。

這個結果我能接受,C# 對我來說是新東西,它怎麼跑我都沒意見。

接著我用 JS 寫一份一樣的。

「JS 一定是 A B C」

越熟的東西,越懶得跑

寫 JS 版之前,我很有把握地說,JS 一定是 A B C。

理由我現在已經講不清楚了,大概是覺得 await 寫在 B 前面,那 B 就應該先等完再輪到 C。

跑出來也是 A C B。

(盯著終端機那三行看了五秒,然後把 C 前面的程式碼從頭到尾再滑一遍)

兩邊的行為完全一樣。同步跑到 await,交出一個還沒完成的物件,控制權還給呼叫端,等到有人把它標成完成,後半段才繼續。

C# 我會小心,因為我知道自己不懂。JS 我沒跑就下結論,因為我覺得自己懂。

沒跑過的把握,是假的把握。 這句我寫進了錯誤紀錄,而且特別註明,「我熟的東西」最容易翻車。

一個永遠等不完的 Promise

Promise 不會自己完成,要有人按下去

為了在 JS 那邊做計時,我自己包了一個等一秒的 Promise。我寫成類似這樣:

console.log('start', Date.now());
await new Promise(() => {
  setTimeout(() => {}, 1000);
});
console.log('end', Date.now());

第一個時間戳印出來了,第二個沒有。

不是延遲,是消失。程式就停在那裡。

問題在 new Promise 給你的那個函式。它其實會收到一個參數 resolve,你要親手呼叫它,這個 Promise 才算完成。我沒有接,也沒有呼叫,所以它永遠是「等待中」,await 也就永遠等下去。

改成這樣才對:

await new Promise((resolve) => {
  setTimeout(resolve, 1000);
});

計時器一秒後去按 resolve,Promise 完成,await 醒來。

這個錯反而幫我看清了 C# 那邊。Task.Delay 之所以能被 await,是因為底下也有一個計時器,時間到了就去把那個 Task 標成完成。兩邊都一樣,要有人去按那個按鈕。

多包一層 Task.Run

等待不需要執行緒,我卻硬塞了一條給它

做 99 個計時器那個實驗的時候,我一開始不是直接把 Task.Delay 放進清單,而是包了一層 Task.Run:

tasks.Add(Task.Run(() => Task.Delay(1000)));

Task.Run 是把一段工作丟給 thread pool 去做。thread pool 可以想成後端程式預先準備好的一群執行緒,有工作就派一條出去。它適合拿來跑「需要 CPU 算很久」的東西。

可是 Task.Delay 根本不需要 CPU。它本身就是一個 Task,直接放進清單就好。

包了 Task.Run,等於為了「等一秒」這件事,特地叫一條執行緒出來,讓它去發起一個等待,然後再離開。

程式還是會動,結果也差不多。但這個實驗本來要證明的,正是「等待不佔執行緒」。我自己寫的那一行,剛好在反著做。

還控制權,不是換人

這是我花最久才改掉的誤會

Day 2 結束前,AI 要我口頭講一遍 async 的一生,從呼叫、await、等待、完成,一路到執行緒。總共六題,我答對四題。

錯的兩題,錯法一模一樣。

一題是「await 的時候,控制權還給呼叫端」,我講成「交給新的執行緒」。另一題是「等待交給計時器」,我也講成「交給新的執行緒」。

只要事情一變成非同步,我腦中就自動冒出另一條執行緒,好像不開一條新的,那些事情就沒地方跑。

實際上是這樣分的:

  • 等待的那一秒,交給計時器,不佔任何執行緒
  • 控制權還給呼叫端,還是同一條執行緒,它只是回頭去跑呼叫端的下一行
  • 等待結束之後,後半段才由 thread pool 裡剛好有空的執行緒接手

被追問之後我有自己改正過來。但這個誤會之後又復發了好幾次,最後是靠印出執行緒 ID 才真的釘死,那段在 post-11。

平常寫 JS 的人,可能更容易犯這個誤會。JS 的世界裡本來就只有一條主執行緒,所以寫 JS 的時候從來不需要問「是誰在跑」。到了 C#,同一個問題突然有好幾個答案,而我的直覺選了最錯的那一個。

順帶收掉的兩筆債

都是 Day 1 欠下來、Day 2 補上的

關掉 ImplicitUsings

C# 用別的命名空間裡的東西,要在檔案開頭寫 using,有點像 JS 的 import。但新專案預設開著 ImplicitUsings,編譯器會偷偷幫你加一批常用的,所以 Day 1 的程式碼一行 using 都沒寫也能跑。

我把它改成 disable,想看看它到底藏了什麼。

build 直接噴了一整串錯,大多是 CS0103 和 CS0246,意思都是「這個名字我不認識」。Console、List、Task 全部找不到。

我一個一個補回去。補到 Task 的時候,我猜它在 System.Threading,加了 using System.Threading。

還是找不到。

Task 其實在 System.Threading.Tasks,比我猜的還深一層。這讓我推出一條規則。using X 只會打開 X 這一層,不會連 X 底下的子命名空間一起打開。

這裡我還跟 AI 吵了一下。我翻文件說 Tasks 是從 Threading 改良出來的,所以應該算在一起。最後拆成兩件事才講通,設計上的沿革是真的,但命名空間的巢狀只是分類,跟 using 能看到什麼無關。

.slnx 和 .csproj 的分工

專案裡有兩種專案檔。.csproj 描述一個專案,要編譯哪些檔案、用什麼套件。.slnx 是把好幾個專案串在一起的清單,比較像前端 monorepo 的 workspace 設定。

我憑記憶答了三題,都對:

  1. dotnet run 只認 .csproj,而且目錄裡要剛好有一個能執行的,它不會把 .slnx 當成能跑的東西
  2. .slnx 是給 IDE 讀的,讓 Rider 知道要一起開哪些專案
  3. .slnx 不會自己跟檔案系統同步。刪掉一個專案資料夾,清單裡那一筆還會留著

一句話就是,build 認 .csproj,IDE 讀 .slnx,兩條路互不相干。

同一個觀念,換一個語言再跑一次

你已經會的,可能是你最該重跑一次的

Day 2 讓我翻車的,幾乎都不是 C# 的新語法,而是我以為從 JS 帶得過來的直覺。

A C B 這件事,我寫了好幾年 JS 都沒真正搞清楚,是到了 C# 才回頭發現的。

所以學一個新語言的時候,我會建議把你最有把握的那幾個觀念,用新語言寫一次,再用你熟的語言寫一次,兩邊都實際跑過。

猜錯的那一個,通常就是你一直沒真的懂的地方。

(來自一個 Promise 寫了好幾年、還是猜錯 JS 輸出順序的人)