讓 login 執行緒安全:四筆用血淚換來的併發學費
一個跑在 k8s 多 worker 下的 Python 後端,login 流程零星出現半成品 session。這篇記錄除錯過程中學到的四個併發程式設計盲點與教訓。
簡述
- 情境
- 一個老舊、用 python 寫成的後端系統,跑在 k8s 上,而且單一服務裡是以多 worker/thread 啟動的。它在 calling 內部其他系統 API 時,常常第一天好好的,到第二天就冒出一些「權限不夠」或是 Session 過期的錯誤訊息
-
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()? - 那把鎖保護的,是「資料」還是「一段程式碼路徑」?