天天看點

如何解決常見的并發問題?

作者 | Beekums

譯者 | Rayden

策劃 | 王強

并發錯誤臭名昭著,常常導緻令人十分崩潰的 bug。大多數軟體的 bug 是一緻的。如果你先做 X,然後做 Y,然後做 Z,你将會得到 Bug A。

但通過并發,你會遇到競争條件(race condition)。這是一個 bug,如果你做 X,然後做 Y,你可能有 10% 的幾率得到 Bug A。錯誤的出現是間歇性的,這使得你很難找到錯誤根本原因,因為你不能可靠地重制它。這也使得你很難證明你确實解決了這個問題。如果 Bug A 發生的幾率隻有 10%,那麼你就需要多次嘗試重制該 Bug,以确信自己已經修複了它。

處理并發性問題是我職業生涯早期的謀生之道。我喜歡使用多線程并修複進階開發人員錯過的競争條件,這種工作可以大幅提升我自己的信心。

然後我參加了一個面試,并被問到并發問題。結果相當好。

就在那時,我意識到我擅長某種類型的并發問題,而這類問題恰好是大多數并發問題的原因。

簡單并發問題實踐

首先,讓我們稍微讨論一下什麼是并發。然後,我們将繼續讨論一個簡單的并發問題,然後是一個更複雜的問題。

并發基本上是讓多個獨立的代碼段同時運作。讓我們從假設開始,然後進入一個真實情況。

假設我需要對一個 API 發出 5 個不同的請求。每一個請求都需要 100 毫秒才能完成。如果我等待一個完成後才開始下一個,我将會等待 500ms。

如何解決常見的并發問題?

如果我同時執行這 5 個 web 請求,我最終将等待 100 毫秒加上很少的一些額外開銷。

如何解決常見的并發問題?

這是一個相當大的性能差異,也是人們通常使用并發的原因所在。

這聽起來像是一個簡單的概念,對吧?這是因為它就是一個簡單的概念。

問題在于執行過程。那些 API 請求每個耗時大約 100 毫秒,而不是精确的 100 毫秒。

這意味着你将按順序發出 API 請求,但傳回将是亂序的:

如何解決常見的并發問題?

每次運作執行 API 請求的代碼時,傳回順序都會不同。

你通過并發性獲得了性能改進,但是放棄了一緻性。

如果處理這些 API 請求響應的代碼使用共享資料,就會出現 bug。

讓我們看一個更詳細的例子,看看這是如何發生的。Dynomantle 的搜尋欄建議有個 bug。

如何解決常見的并發問題?

它的問題是:每當你輸入一個字元,就會發出一個 api 請求。這是為了讓你在鍵入時能夠順暢地看到提示。你輸入“i”,以“i”開頭的筆記 / 書簽 / 郵件就會彈出來。你輸入“in”,清單就會順滑的變為以“in”開頭的内容。

當你知道你要搜尋什麼時,輸入 5 個字元要花多長時間?2 秒?1 秒?半秒?

我仍然需要優化這個服務,但是現在處理每個 API 請求需要半秒到一秒的時間。

讓使用者在鍵入每個字元後等待一秒鐘再鍵入下一個字元是一種糟糕的使用者體驗。是以我在使用者鍵入每個字元時發出一個 API 請求。問題是請求傳回的順序不一緻。帶有 2 個字元的請求可能在帶有 5 個字元的請求之後傳回。

搜尋建議被存儲為一個清單。每當響應傳入時,整個清單都會重新整理。在這種情況下,當最後一個請求傳回時,會用正确的建議重新整理整個清單,但是當舊的請求傳回時,會在清單中填充不正确的建議。

如何解決常見的并發問題?

幸運的是,這是一個非常容易解決的問題,因為請求是按順序發出的。

1) 每次送出請求時生成時間戳或哈希值,這被用作請求 ID。

2) 将請求 id 設定為帶有建議清單的附加變量。因為我們按順序送出請求,是以這永遠是最後一個請求。

3) 在每個 API 調用的 success 函數中傳遞請求 id。

4) 當響應到來時,驗證它是否是預期請求的響應。

不幸的是,如果使用者輸入得非常快,他們可能會看到建議清單更新有延遲。即使使用者不使用 2 個字元的建議,看到建議清單出現可以提供一種感覺,即應用正在做一些事情,而非隻是等待。

如何解決常見的并發問題?

解決這個問題需要對上面的代碼做一點小小的修改。

我們将繼續使用時間戳而不是哈希值。

接下來,我們将存儲最後接收到的請求 id,而不是最後發出的請求 id。

最後,隻有當響應的請求 id 高于最後接收到的請求 id 時,我們才會重新整理清單。因為我們使用時間戳作為請求 id,是以所有請求都是有序的,id 越大請求就越新。

注意:這一機制運作的前提是滿足以下條件:使用者不會在同一毫秒内鍵入多個字元。如果他們這樣做,他們是在粘貼内容,此時我們隻需要進行一次 api 請求。

另外需要注意的是,這也隻适用于 Javascript 處理并發性的方式。它并不是真正的并發。每個函數都在另一個函數運作之前執行并完成。

在 Java 中嘗試類似的代碼,你會感覺很糟糕。因為對 suggesReceived() 的多個調用可能同時執行。這意味着對“in”和“inv”的建議響應都可以通過 if 語句中的檢查,然後執行函數的其餘部分。

你看到的行為将非常不一緻,這取決于函數其餘部分的長度和兩個函數調用的時間。要使它在真正的并發程式設計語言中正常運作,你需要查找如何在該語言中使用鎖。如果你要處理跨多個伺服器的并發,也可以考慮 Redis 的分布式鎖。

當某一個函數擁有鎖時,鎖可以阻止其他函數執行。如果我們需要在 Javascript 中使用鎖,它應該是這樣的:

當然,這樣做有風險,如果我們從不解鎖,那麼其他函數就不會執行。如果我們在多個函數中使用多個鎖,可能會出現兩個函數都在等待的情況,此時它們都在等待對方已經鎖定的鎖。我們的程式現在卡住了,因為兩個函數都不能執行。這就是所謂的死鎖情況。

Dynomantle 中的搜尋建議 bug 是一個簡單的并發問題,因為它是在 Javascript 中。讓我們探讨一個更複雜的問題,它發生在 Java 中,但它的教訓應該對許多其他問題有幫助。

複雜并發問題實踐

我大學畢業後的第一份工作是開發一個網絡管理應用程式。例如:你正在通路一家公司,并連接配接到客戶 wifi 網絡。我們的應用程式将允許系統管理者根據登入憑據、在辦公室的位置、一天中的時間等配置你的通路。他們可以根據公司政策啟用或阻止某個端口。

由于我們支援多個協定,并發發揮了作用。我們為 wifi 支援 802.1x,但我們也支援基于使用者所連接配接的以太網端口的身份驗證、Kerberos 身份驗證協定和其他一些協定。

隻要你打開計算機,它就會嘗試使用配置的盡可能多的協定同時進行連接配接。

假設管理者為以太網端口通路設定了一個不太容易通路的政策,你可能通過它通路端口 80 和 443(基本上隻是 web 浏覽)。如果你使用 Kerberos 進行身份驗證,就可以獲得更廣泛的網絡通路。這裡的問題是順序無關緊要。如果一個使用者通過多個認證,管理者可以配置哪個協定具有優先級。

當我開始這個項目時,交給我的代碼将身份驗證的狀态存儲在一個資料庫表中,每個人的 MAC 位址隻有一行:

primary_key - int

mac_address - varchar(255) and a unique key

authentication_protocol - enum

status - enum

(真實的資料表要複雜得多,但這是 15 年前的事了,是以請原諒我)

authentication_protocol 列存儲優先級最高且成功的協定。如果所有的身份驗證嘗試都失敗了,它還是會存儲最高優先級的協定。

我把問題簡化了,實際上我們需要上千行代碼來協調所有不同的協定,找出哪個優先級最高,處理那些有多個認證步驟的協定,處理各種各樣的鎖,同時也要處理各種交換機和路由器制造商在固件方面的一些怪癖。客戶非常不高興,因為它很少正常工作,使用者經常得到配置設定給他們的錯誤的網絡政策。

在我職業生涯的前幾個月,我花了大部分時間來修複一個又一個不斷出現的 bug。最終我意識到問題不在于我們的使用者需求很複雜,問題是我們建立了一個糟糕的資料模型,它使代碼變得特别複雜。

解決辦法很簡單。以上面相同的資料庫表為例。現在為每個 MAC 位址和協定添加一行。此前每個 mac 位址在資料庫表中隻有一行,由程式協調在該行中顯示哪個協定,修改後則為每個協定添加一行。

并發仍然在發生,但是你不再需要協調并發過程中實際儲存哪些資料。每個線程 / 程序都有自己的資料,他們負責修改這些資料而其他線程 / 程序無權修改。當确定一個使用者的網絡通路時,隻需查找該使用者的所有行并選擇相關的行。

沒有鎖,也沒有共享資料要修改。

代碼最終變得更簡單,因為你可以忽略大部分并發情況。開發人員很開心。代碼正常工作,客戶也很高興。

在實際情況下,管理者隻為一個人設定了 2-3 個政策,是以我最終将表的大小增加了 2-3 倍。然而,這是線性增長的。資料庫可以輕松地處理線性增長。從 1000 行增加到 3000 行是無關緊要的。即使從 100 萬行增加到 300 萬行,也可以由現代硬體輕松處理。

從 10 億行增加到 30 億行可能會有問題。但是,在你達到 10 億行之前,你應該已經開始将資料庫擴充到支援 30 億行。

所有這些都是一種冗長的說法:将表的大小增加 3 倍是值得的,因為這可以使我們不必擔心并發。

這類問題是常見的并發性問題。許多資料似乎需要由不同的并發程序同時通路和修改,大多數情況下這并不正确。對資料模型進行微調并利用存儲成本低廉這一事實,可以為你的團隊節省大量工作。

https://blog.professorbeekums.com/2021/solving-concurrency-problems/

繼續閱讀