FlickrProxy #4 - 修正同步上傳的問題

FlickrProxy #4 - 修正同步上傳的問題

摘要提示

  • 同步上傳重複問題: 多個同時請求導致同一張照片被上傳到 Flickr 兩次
  • 問題根因: 缺乏臨界區保護,並行請求在判斷與建立副本間產生競態條件
  • 初版修正: 以 lock 保護關鍵程式段,避免重複上傳
  • 鎖定範圍過大: 以類別為鎖對象導致所有照片請求互相阻塞,影響效能
  • 精準鎖定策略: 改為「每張照片一把鎖」,只阻擋同一檔案的並行請求
  • LockHandle 設計: 以檔案雜湊為鍵的字典,為每張照片提供專屬物件供 lock 使用
  • 上傳並行控制: 多圖同頁載入會同時觸發多次上傳,需限制併發數量
  • 使用 Semaphore: 以信號量限制同時上傳不超過 2 個,平衡效能與外部服務壓力
  • 多執行緒心法: 鎖太大影響效能,鎖太小結果不正確,需掌握鎖粒度
  • 實務學習: 在 ASP.NET 中運用 lock 與 Semaphore 的案例與心得

全文重點

本文紀錄 FlickrProxy 在實務運作中遇到的同步上傳問題與修正歷程。作者接到用戶回報:當一張照片第一次被下載時,系統會觸發「上傳到 Flickr 並建立快取資訊」的流程,若此時第二個相同檔案的請求也抵達,由於缺乏臨界區保護,會造成重複上傳。作者首先以 lock 將「判斷是否需要上傳」「建立 Flickr 副本檔」兩個關鍵步驟包起來,解決了競態條件,但初版用的是類別層級鎖,導致不相關照片的請求也被彼此阻塞,鎖定範圍過大,潛在大幅影響效能。

接著作者改良鎖的粒度,目標是只針對「同一張照片」的請求互斥,而不同照片可並行處理。其作法是為每張照片提供唯一的鎖對象:以檔案內容雜湊(文章中現成採用 MD5)作為字典鍵,建立並快取對應的 object 作為 lock 物件,確保同一檔案總能拿到同一把鎖。如此一來,不同照片之間互不干擾,僅同檔案的並行請求會被序列化,既確保正確性又兼顧效能。

然而在實測多圖同頁的情境下,即便鎖粒度已經合理,第一次載入仍可能同時為多張照片啟動上傳,造成頻寬擁塞與對 Flickr 的壓力。為此,作者再加入併發控制:使用 Semaphore 將同時上傳數量限制為 2,於上傳前呼叫 WaitOne,完成後 Release,以此平衡吞吐量與外部服務負載。作者最後總結多執行緒程式設計的心法:關鍵在於找出正確的臨界區與適當的鎖粒度;當需要限制並行度超過「單一」時,再引入 Semaphore 等同步原語,避免過度或不足的鎖造成效能或正確性問題。

段落重點

問題背景與用戶回饋:重複上傳的競態條件

作者在維護 FlickrProxy 過程中,收到愛用者的實務回饋:首次下載照片會引發「判斷是否需上傳→上傳到 Flickr→建立快取→重導至 Flickr」的一連串動作,但如果第一個請求尚未完成,第二個相同照片的請求就來了,系統會在兩個請求各自認定「需要上傳」的情況下,各自上傳一次,導致同一張照片被上傳兩次。此乃典型的競態條件:在「檢查快取資訊是否存在」與「建立快取」之間缺少互斥,導致多個執行緒交錯執行破壞了邏輯不變式。作者坦言問題難在「沒想到、沒發現、未知因」,一旦找出原因,修正便相對直接。第一步策略是將關鍵區段包在臨界區內,確保同時只有一個執行緒能完成「需要上傳」的判定與後續建立副本,進而避免重複上傳發生。

初版修正:全域鎖的保護與副作用

初版修正透過 lock 將「判定是否需要上傳」與「建立 Flickr 副本檔」包裹起來,成功封住競態。但作者很快發現用以類別為對象的鎖(例如鎖住 this.GetType())等同全域鎖:只要任何照片觸發上傳,其他所有照片的請求在同時間也會被阻擋,直到鎖被釋放。這種鎖定範圍「過大」的作法雖然簡單保險,卻犧牲了效能與並行度;在高流量或多圖頁面中,明顯不適合。作者指出,理想狀態是「只鎖同一張照片」,不同照片應該可以各自並行處理,否則將浪費多核心 CPU 的計算資源且拉長整體回應時間。這段反思點出鎖粒度的重要性:過度鎖定雖可保正確,但會造成系統級的阻塞與延遲,尤其在 Web 環境中影響更為明顯。

進一步優化:以檔案為單位的鎖與 LockHandle 設計

為達到只鎖同檔案的目標,作者採用「每張照片一把鎖」策略。要點在於同一張照片的所有請求必須取得同一個 lock 物件,不同照片則取得不同物件,才能達到細粒度互斥。字串檔名不保證參照相等,FileInfo 亦無法保證同一實體共享同物件,因此作者自行設計 LockHandle:以檔案雜湊值作為字典鍵(文中使用既有的 MD5 結果),對應一個專屬的 object,並將其快取於靜態 Dictionary 中。當請求處理時,先以雜湊值在字典中取出/建立對應的 object,再用該物件進行 lock。這樣可確保:同檔案的並行請求會互斥執行關鍵區段,不同檔案互不干擾,效能與正確性兼顧。作者也提醒,鎖定範圍要精準落在「判定是否需要上傳」與「建立副本檔」兩步之內,拆開將難以保證結果正確。

併發上傳控制:以 Semaphore 限制同時上傳的數量

即便鎖粒度已優化,實務上仍有一個場景帶來新挑戰:單一頁面含多張首次載入的圖片,瀏覽器會同時發出多個請求,若每張都觸發上傳,便形成「多個不同照片的上傳同時進行」。雖然不會互相鎖住,但會壓縮頻寬、增加上傳耗時,甚至引起外部服務(Flickr)的關切。為此,作者引入 Semaphore 控制整體上傳併發度,將同時上傳數量上限設為 2:上傳前先 WaitOne 取得名額,上傳完成後 Release 歸還。如此達到全域層面的「限流」,在不犧牲單張照片並行下載處理的前提下,平衡了效能、頻寬與外部依賴的穩定性。作者並補充,若僅需「同時只能一個上傳」,以單一鎖亦可;當需求是「可控的多並行」時,Semaphore 才展現其彈性與價值。

結語與學習:鎖的粒度、正確性與效能的取捨

本文雖未加入新功能,卻透過一次真實缺陷修補,完整走過並行程式設計的幾個核心課題:找出競態條件與臨界區、決定正確的鎖粒度、在正確性與效能間取得平衡、以及在系統層面引入併發控制。從全域鎖到每檔案鎖,再到使用 Semaphore 限制同時上傳數量,展示了由粗到細、由單點到全局的設計演進。作者也感謝用戶回報,強調許多 bug 的困難在於「未知」,一旦定位原因,修正往往並不複雜。對 ASP.NET 開發者而言,本文提供了在 Web 環境下正確運用 lock 與 Semaphore 的實務參考:鎖定要小且準、關鍵步驟不可分割;當需限制併發度時,善用同步原語來兼顧穩定與吞吐,避免過度與不足鎖定所帶來的兩難。

資訊整理

知識架構圖

  1. 前置知識
    • .NET/C# 基礎語法(lock、集合、檔案 I/O)
    • 多執行緒與並行控制概念(臨界區、Race Condition、Semaphore)
    • ASP.NET 要求處理流程(每個 HTTP Request 的執行模型)
    • 檔案快取與雜湊(如 MD5)的基本觀念
  2. 核心概念
    • Race Condition:多個同時到達的請求在未完成初始化時觸發重複上傳
    • 臨界區與 lock:用最小必要範圍保護關鍵區段,避免重入與效能損耗
    • 鎖的粒度控制:從全域鎖(lock(this.GetType()))優化為「每檔案一把鎖」
    • 鎖對象設計:以檔案雜湊為鍵,建立/重用同一把鎖(Dictionary<string, object>)
    • 併發上限控制:用 Semaphore 限制同時上傳數(例如最多 2 個)
  3. 技術依賴
    • C# 同步化原語:lock、Semaphore
    • .NET 集合:Dictionary<string, object> 作為鎖物件池
    • 檔案系統 API:File.Exists、檔案雜湊(MD5)
    • ASP.NET 回應操作:Response Header(例如 X-FlickrProxy: Upload)
    • Flickr 上傳 API(UploadPicture)作為實際工作負載
  4. 應用場景
    • 影像代理/快取服務:首次請求觸發備份或上傳,避免重複處理
    • 任何「首次初始化+高併發存取」的資源建立(縮圖產生、索引建置)
    • 對外 API 呼叫節流:限制同時外呼數,避免被服務方限流或封鎖
    • Web 站台多圖頁面載入:在可接受延遲內,限制同時上傳數以控管頻寬與風險

學習路徑建議

  1. 入門者路徑
    • 先理解什麼是 Race Condition 與臨界區
    • 實作最基本的 lock 區段,保護 File.Exists 檢查與檔案建立
    • 觀察全域鎖的副作用(效能下降、請求互相阻塞)
  2. 進階者路徑
    • 設計「每資源一把鎖」:以檔名(或其標準化/雜湊)為鍵,從 Dictionary 取得鎖物件
    • 控制鎖的粒度與範圍,只包住「需要保證一致性」的最小程式碼
    • 加上併發上限:用 Semaphore 對外部上傳動作節流
  3. 實戰路徑
    • 在實際專案中:為每個需懶載入/首次建立的資源配置鎖物件池
    • 用 Semaphore(或 SemaphoreSlim)包住外部 I/O 呼叫,並以 try/finally 釋放
    • 監控與調參:紀錄同時上傳數、上傳失敗重試、字典鍵成長與清理策略

關鍵要點清單

  • 問題起點:首次請求觸發上傳的競態 (優先級: 高)
    • 多個相近時序的請求會導致同一張照片被上傳多次。
  • 臨界區概念 (優先級: 高)
    • 關鍵區段需排他執行,確保判定與建立行為的一致性。
  • 鎖範圍最小化 (優先級: 高)
    • 只鎖需要一致性的最小程式碼區段,避免效能損耗。
  • 全域鎖的壞處 (優先級: 中)
    • lock(this.GetType()) 會讓不同照片的請求彼此阻塞,降低併發。
  • 每檔案一把鎖 (優先級: 高)
    • 以檔案雜湊(或正規化檔名)為鍵,從字典取得同一把鎖,僅阻塞相同資源。
  • 鎖物件池設計 (優先級: 中)
    • Dictionary<string, object> 保存鎖;建立時需以自身再加一層 lock 保護字典訪問。
  • 物件身分 vs 值相等 (優先級: 中)
    • 必須確保同一資源對應到同一個鎖物件,避免相同字串值卻不同物件的陷阱。
  • 檔案存在檢查與建立原子性 (優先級: 高)
    • File.Exists + 建立流程需在同一臨界區避免 TOCTOU 問題。
  • 外部呼叫節流 (優先級: 高)
    • 用 Semaphore 限制並發上傳數(如 2),避免頻寬與對方服務壓力。
  • 正確使用 Semaphore (優先級: 高)
    • WaitOne 後務必 Release;建議以 try/finally 確保例外時仍釋放。
  • 回應可觀測性 (優先級: 低)
    • 加上 X-FlickrProxy: Upload 等 Header 便於診斷上傳何時發生。
  • 效能與擴充性思維 (優先級: 中)
    • 流量提升時,細化鎖粒度與上限控制能顯著改善吞吐與使用者體驗。
  • 雜湊鍵選擇 (優先級: 低)
    • MD5 足以區分檔案;若僅以檔名,需處理大小寫與路徑正規化。
  • ASP.NET 環境考量 (優先級: 中)
    • 靜態字典與鎖物件在單一進程內共享;多實例/多進程部署需額外機制。
  • 失敗與重試策略 (優先級: 中)
    • 上傳失敗時的重試與狀態標記避免再次引發重複處理或死鎖。





Facebook Pages

AI Synthesis Contents

Edit Post (Pull Request)

Post Directory