讓 login 執行緒安全:四筆用血淚換來的併發學費
一個跑在 k8s 多 worker 下的 Python 後端,login 流程零星出現半成品 session。這篇記錄除錯過程中學到的四個併發程式設計盲點與教訓。
簡述
-
情境
- 一個老舊用 python 寫成的後端系統,跑在 k8s 上、而且單一服務裡是以多 worker/thread 啟動的,該系統在 calling 內部其他系統 API 時,可能第一天沒有問題的狀況,到第二天就會跳出一些「權限不夠」或是 Session 過期的錯誤訊息
-
需求 │ ▼ 本系統 │ ▼ login 其他系統 → 存 credential │ ▼ 登入成功後開始做事情...
-
Debug 思考脈絡
- 當時因為 API 回傳的是「權限不夠」等字樣,所以就先去看這隻 API 到底做了什麼事,而確定 flow 是卡在「登入看起來已經成功之後」才報 not enough permission
- 就開始在想為什麼「看起來」明明有登入成功(前一步),卻會在後面失敗?為此也特地埋了一些 log 來做 debug,但也看不出所以然
- 最後實在沒招,所以就直接請 AI 幫掃一下問題出在哪並產出報告,才發現關鍵是:Dockerfile 起服務那層就開了 multi-worker/threads,於是那個跨請求「共用、又沒加保護」的 session 就被並發踩到了——multi-worker 設定本身不是 bug,它只是讓一個潛在的執行緒安全問題浮上檯面的觸發條件
- 而這不是語法錯誤,是要用並發的視角才連得起來的 runtime race——靜態讀 code 很難一眼抓到(session 是共享的、又跑在 multi-worker 下,兩個線索得湊在一起看)
-
事後反思
- 以除錯流程上來講,下次可以改進的是:直接丟給 AI 問的同時,平行先看 code 了解整個 flow 來平行處理。
- Take away:併發服務的 logger 一次設好就帶 process / thread 識別,之後每行 log 自動有,不用手動加。這次埋了 log 卻看不出所以然,缺的就是這個關聯鍵——不然半成品 session 是哪個 worker 的哪條 thread 寫的,根本對不起來。
-
logging.basicConfig( format="%(asctime)s [%(processName)s/%(threadName)s:%(thread)d] " "%(levelname)s %(name)s - %(message)s" )
-
Details
一個多執行緒的 worker/service,共用同一個 SessionClient。上線後零星冒出「session 是半成品」的錯誤 —— 有的 worker 讀到只登入一半的連線,有的請求莫名其妙帶著空 cookie。最後發現問題不在網路,而在對「加鎖」這件事的理解,從一開始就沒了解那麼全面,因此爬了一些網路文章補足知識,並記錄以下盲點以加深印象。
盲點 1:mutex 是 ACID 的「I」,沒有 A、C、D
程式 login() 包進一把鎖,以為它就像被包進一個資料庫交易一樣安全。錯了。mutex 只給了 ACID 四個字母裡的一個:I(Isolation,隔離性)。它會把並發工作序列化,但不會回滾 —— 臨界區中途拋錯,就留下半成品。
鎖只給了 ACID 的「I」,其餘的 A、C、D 要自己扛。
盲點 2:鎖不會自動回滾半成品
既然鎖不回滾,原子性就得自己手動打造。方法只有一句話:把共享的「變數」的指派放到最後。先在區域變數把 session 建好、確認成功後,最後才單一一次寫回共享欄位。
session = requests.Session() # 先在區域變數建好
resp = session.post(LOGIN_URL, ...) # 先成功
resp.raise_for_status()
self.session = session # 成功後才發佈,單一寫入
若 post() 拋錯,沒有任何共享狀態被改動——下一條執行緒也不會讀到撕裂狀態。
絕不要發佈一個只初始化一半的物件。
深入閱讀:先做完再發佈的安全發佈模式
盲點 3:網路一拋錯,鎖就永遠不放
login() 的臨界區要呼叫 session.post() 和 raise_for_status(),兩者都可能拋錯。早期版本用手動 acquire()/release()、又沒包 try/finally——一次網路錯誤就讓鎖永遠不放,之後每個取鎖者都卡住,整個 worker 死鎖。
with self._login_lock: # = acquire + try/finally release
... # 就算這裡拋錯,release() 也一定會執行
能用
with就用with——它保證例外時鎖也會被釋放。
盲點 4:想省鎖成本,卻寫錯了雙重檢查
不是每次呼叫都該付鎖的成本。惰性初始化的標準寫法是雙重檢查鎖定:檢查 1 不加鎖走熱路徑、檢查 2 持鎖再確認一次。這兩個看起來一模一樣的 if,其實各司其職——曾經拿掉第二個,結果兩條執行緒同時重建、互相覆蓋。
兩個看起來一樣的
if,其實各司其職——拿掉任何一個都會壞。
深入閱讀:雙重檢查鎖定的兩個檢查各司其職拆解
什麼時候該用,什麼時候是殺雞用牛刀
這一整套手動原子性,只因為「mutex 不是交易」才需要。如果本來就有交易型儲存可用,讓它去做,不必自己手動扛。
雙重檢查鎖定也一樣:只有當熱讀取路徑有競爭、而且臨界區成本不小時才划算。若每次呼叫本來就被 DB 查詢(~ms)主導,鎖成本(~ns)只是雜訊——單重檢查更簡單。
結尾
四筆學費其實是同一課:mutex 只序列化「執行」,不保證「成功」。 剩下的原子性、釋放、去重,全需要自行處理。
之後寫程式 with multi threads handling mindset:
- 程式裡,有沒有「先指派共享變數、再做可能失敗的初始化」的地方?
- 臨界區有沒有可能拋錯,卻還在用手動
acquire()/release()? - 那把鎖保護的,是「資料」還是「一段程式碼路徑」?