[C#] Async 常見誤區:不是所有方法都需要 async Task

  • 1
  • 0

很多人一開始看 async/await,都會先把它當成語法糖。

我自己一開始也是這樣想

就覺得不就是把同步改成 await,讓 thread 不要卡住,看起來也比較現代一點,而且你現在用 copilot 應該也是 tab tab 就出來了

但真的在 API、網站服務,或是有點流量的系統裡用久之後,才會慢慢發現

async 不是只有能不能跑而已,它其實還會影響 效能、記憶體使用量,還有整體穩定度

有些 async code 表面上完全沒問題

但如果剛好寫在系統很常被呼叫的地方,它就可能一直默默建立 Task

平常流量小的時候不太有感,等到 request 一多,GC 開始變忙,延遲就會慢慢浮出來

這篇先不講太多,先整理兩個最常遇到,也最容易一開始寫錯的地方:

1.Task 和 ValueTask 到底差在哪

2.不需要 async 的地方,真的不要硬加

先解釋一下什麼是 Hot Path 跟 allocation 這兩個名詞比較容易讓人困惑,這邊我也引入 GPT 幫忙解釋

GPT 表示 : 

Hot Path 可以簡單理解成「系統中被執行最頻繁的那段程式碼」。

例如每個 request 都一定會經過的 middleware、檢查登入狀態、查詢快取 (cache)、取得使用者基本資料等等。

因為這些程式碼每秒可能被呼叫非常多次,所以只要裡面多做了一點點額外的工作,長時間累積起來就可能影響整體效能。也因此在談效能優化時,通常會優先觀察這些 Hot Path。

allocation 指的是程式在執行時建立新的物件,並在記憶體中為這個物件分配空間。

例如建立一個 Task、List、字串,或是 new 一個 class instance,都屬於 allocation。

少量 allocation 通常沒有問題,但如果某段程式在 Hot Path 裡反覆建立很多新物件,就會增加 GC(垃圾回收)的工作量,進而影響效能。簡單來說,allocation 就是「程式一直建立新物件並佔用記憶體」的過程。

 

1. Task vs ValueTask

在 C# 裡,大部分 async 方法最常見的回傳型別就是 Task。

例如下面這種寫法,真的很常看到:

 
    public async Task IsVipUserAsync(int userId)
    {
        return _vipUserIds.Contains(userId);
    }
    
    

這段看起來很合理,方法名有 Async,回傳 Task<bool>,整個形式很完整

但問題在這沒有真的在做 async

這裡沒有呼叫資料庫,沒有打 API,沒有讀檔,也沒有任何 await 要等

它只是單純做一個記憶體內的判斷而已

可是只要你把它寫成 async Task,編譯器就還是會幫你準備 async 那一套東西,像是 async state machine,也會產生 Task

也就是說,明明只是做一個很快的判斷,卻還是多了一些原本不一定需要的成本

而這個成本,本質上就是一種 allocation

如果這個方法只是偶爾跑一次,那真的沒差

但如果它剛好在 hot path,每個 request 都會打到,差別就慢慢出來了

建議作法


    
    public ValueTask IsVipUserAsync(int userId)
    {
        return new ValueTask(_vipUserIds.Contains(userId));
    }
    
    

那 ValueTask 是幹嘛的

如果這個方法真的很常被呼叫,而且大部分時候都能立刻得到結果,就可以考慮 ValueTask

這種情況下,通常就不用額外建立一個 Task 物件。

也就是說,可以少一次 allocation

這其實就是 ValueTask 真正存在的理由

當你發現 方法被呼叫很多次,結果通常很快就能拿到,不想每次都多建立一個 Task
 

這裡重點不是背哪個最快,而是知道

不是所有看起來像 async 的方法,都需要真的寫成 async Task

ValueTask 也不是看到能省 allocation,就整個專案全部改掉

因為大部分商業邏輯根本不在 hot path,很多方法花時間的地方本來就在資料庫、API、網路或磁碟 IO

這種情況下,就算少掉一點 Task 成本,實際幫助通常也不大。

所以 ValueTask 比較像是拿來做局部優化的工具,只在 高頻、而且通常很快就能完成的方法 裡,才比較有意義

如果不懂,你也可以整段問 GPT 讓他評估 ,我想現在應該大家都這樣做吧 :P


2. 不需要 async 就不要加

這件事看起來很小,但其實超常發生

很多人寫方法時,只要方法名稱想加個 Async,就會順手把方法也寫成 async Task

久了之後,整個專案會出現一堆根本沒有 await 的 async 方法

例如:


    
    
   	public async Task GetRoleNameAsync(int roleId)
    {
        return roleId == 1 ? "Admin" : "User";
    }
    
    //移除 async 改成
    public Task GetRoleNameAsync(int roleId)
    {
        return Task.FromResult(roleId == 1 ? "Admin" : "User");
    }
    
  

這種時候如果硬掛一個 async,通常只是讓方法多了一層不必要的包裝

如果這又剛好是 hot path 你就可以直接改成


    
      public ValueTask GetRoleNameAsync(int roleId)
      {
          return new ValueTask(roleId == 1 ? "Admin" : "User");
      }
      

這樣的差別不是只有語法比較短,而是你沒有為了一個其實不用等的邏輯,硬塞進一整套 async 機制


做個簡單小結

大多數情況下直接使用 async Task 就很好,閱讀性也最好。

但如果某個方法

"被呼叫非常頻繁"

"幾乎可以立即得到結果"

"內部沒有真正的 async IO"

那就可以考慮不要使用 async,改成 Task.FromResult(...),甚至在更高頻的情況下使用 ValueTask

因為每建立一個 Task 都會產生 allocation,也就是建立新的物件並佔用記憶體

如果這種情況發生在 hot path,就會增加 GC 壓力,最後影響效能

簡單原則就是:

沒有真的非同步,就不要寫成 async。

--

本文首發於 - Async 常見誤區:不是所有方法都需要 async Task

---

The bug existed in all possible states.
Until I ran the code.