避免使用AtomArrayBuffers中的競爭條件
在 《ArrayBuffers和SharedArrayBuffers的介紹》 一文中,我談到了在使用SharedArrayBuffers時是如何可能導致競爭條件的,這使得大家很難使用SharedArrayBuffers。另外在文章的最後我還提到不希望應用程式開發人員直接使用SharedArrayBuffers。
但是,具有其他語言的多執行緒程式設計經驗的庫開發人員可以使用這些新的低階API來建立更高級別的工具。如果是這樣,那麼應用程式開發人員則可以直接使用這些工具而不用接觸SharedArrayBuffers或Atomics。
即使你可能用不到SharedArrayBuffers或Atomics,但我認為了解它們是如何工作的,仍然會有助於你日後的程式開發。所以在本文中,我將介紹併發可以帶來什麼樣的競爭條件,以及Atomics如何幫助庫避免它們。
但首先,需要知道什麼是競爭條件?
競爭條件指多個執行緒或者程序在讀寫一個共享資料時結果依賴於它們執行的相對時間的情形,競爭條件發生在當多個程序或者執行緒在讀寫資料時,其最終的結果依賴於多個程序的指令執行順序。假設兩個程序P1和P2共享了變數a。在某一執行時刻,P1更新a為1,在另一時刻,P2更新a為2。因此兩個任務競爭地寫變數a。在這個例子中,競爭的“失敗者”(最後更新的程序)決定了變數a的最終值。多個程序併發訪問和操作同一資料且執行結果與訪問的特定順序有關,稱為競爭條件。
簡單的說,就是當一個變數在兩個執行緒之間共享時,就很可能發生一個非常簡單的競爭情境。假設一個執行緒想載入一個檔案,另一個執行緒則要檢查它是否存在。他們共享一個變數file_exists函式來檢查檔案或目錄是否存在,以方便進行通訊。
最初,fileExists設定為false。
只要執行緒2中的程式碼首先執行,檔案將被載入。
但如果執行緒1中的程式碼首先執行,那麼它將向用戶記錄一個錯誤,表示該檔案不存在。
但這不是問題的所在,不是檔案不存在,真正的問題是競爭條件。
許多JavaScript開發人員經常會遇到這種競爭條件,即使是在單執行緒程式碼的情境之下。你不必理解有關多執行緒的任何內容,只需看看為什麼會發生競爭條件。
但是,有些型別的競爭條件在單執行緒程式碼中是不可能發生的,但是當你使用多個執行緒進行程式設計並且這些執行緒共享記憶體時,可能會發生這種情況。
Atomics是如何處理不同類別的競爭條件的?
讓我來介紹一些不同型別的競爭條件以及你如何多執行緒程式碼中使用Atomics防止競爭條件,不過,這不包括所有可能的競爭條件,但通過我的介紹,你應該給能對API的做法有所瞭解。
在開始之前,我想再說一遍,你不應該直接使用Atomics。編寫多執行緒程式碼是一個眾所周知的難題。所以,你應該使用可靠的庫來使用多執行緒程式碼中的共享記憶體。
單一執行緒操作中的競爭條件
假設你有兩個執行緒增加相同的變數,無論哪個執行緒首先執行,你可能都會認為最終的結果將是一樣的。
但即使在原始碼中,增加一個變數看起來就像一個操作,當你檢視編譯的程式碼時,它其實不是一個單一的操作。
在CPU級別,增加一個值需要三條指令,這是因為計算機具有長期記憶和短期記憶。
所有執行緒共享長期記憶,但基於暫存器的短期記憶是不會線上程之間共享的。
每個執行緒都需要將記憶體中的值從其記憶體中取出來,之後,可以在短期記憶中對該值進行計算。然後它將這個值從短期記憶回溯到長期記憶。
如果執行緒1中的所有操作首先發生,然後執行緒2中的所有操作都會發生,你將最終得到想要的結果。
但是如果它們在時間上是交錯的,則執行緒2已經被拉入其暫存器的值與記憶體中的值不同步。這意味著執行緒2不考慮執行緒1的計算值,也就是說,它只是消除了執行緒1,用自己的值寫入記憶體的值。
Atomics操作所做的一件事是將人們認為是單一操作而計算機視為多個操作的這些操作,讓計算機統一將它們視為單個操作。這個操作之所以被稱為Atomics操作的原因是因為他們採取的操作通常會有多個指令,指令可以暫停和恢復,並且可以使它們全部發生在瞬間,就像是一個指令一樣,這就像一個不可分割的Atomics。
使用Atomics操作,增量程式碼看起來有點不同。
現在我們使用的是Atomics.add,遞增變數所涉及的不同步驟不會線上程之間混合。相反,會按著順序,當一個執行緒進行其Atomics操作時,會阻止另一個執行緒啟動。然後等操作完成後,另一個將開始自己的Atomics操作。
避免競爭條件發生的Atomics方法有:
· Atomics.add
· Atomics.sub
· Atomics.and
· Atomics.or
· Atomics.xor
·Atomics.exchange
你會注意到這個列表是相當有限的,它甚至不包括分割和倍增的辦法等。不過,庫開發人員可以為其他情境建立類似Atomics的操作。
為此,開發人員將使用Atomics.compareExchange。這樣,你可以從SharedArrayBuffer獲取值,對其執行操作,並且只有在你首次檢查後,確認沒有其他執行緒已更新的情況下才將其寫回SharedArrayBuffer。如果另一個執行緒已更新,那麼你可以獲得新值,然後重試。
多個執行緒操作中的競爭條件
通過上面的介紹,你已經瞭解了這些Atomics操作有助於在單次操作期間避免競爭條件。但有時你想要更改物件上的多個值即使用多個操作,並確保沒有其他人同時對該物件進行更改。基本上,這意味著在對物件的每次更改通過期間,該物件都處於鎖定狀態,而其他執行緒無法訪問。
Atomics物件雖然不提供任何工具來直接處理,但它確實提供了庫作者可以用來處理這個問題的工具。庫作者可以建立一個鎖。
如果程式碼想要使用鎖定的資料,它必須對該資料進行解鎖。同樣它也可以使用鎖來鎖定其他執行緒。只有在解鎖成功時,才能訪問或更新資料。
為了構建一個鎖,庫作者將使用Atomics.wait和Atomics.wake,加上其他的工具,如Atomics.compareExchange和Atomics.store。如果你想看看它們是如何工作的,看看 這個 。
在這種情況下,執行緒2將獲取資料鎖,並將鎖定值設定為true。這意味著執行緒1無法訪問資料,直到執行緒2解鎖。
如果執行緒1需要訪問資料,它將嘗試獲取鎖。但是由於鎖已經在使用,所以不能重複使用。這時執行緒就會處於等待狀態,所以它將被阻止,直到被解鎖。
一旦執行緒2完成,它將呼叫解鎖,該鎖將通知一個或多個等待執行緒現在可用。
這樣使用鎖的執行緒就會鎖定資料供自己獨自使用:
鎖庫將使用Atomics物件上的許多不同方法,其中最重要的方法是:
·Atomics.wait;
· Atomics.wake;
由指令重新排序引起的競爭條件
經過測試,Atomics可以同時處理三個同步問題,這確實非常令人驚訝。
你可能沒有意識到這一點,但是你寫的程式碼並不是按照你期望的順序來執行。編譯器和CPU都會重新排序你編寫的程式碼,使其執行速度更快。
例如,假設你已經編寫了一些計算總和的程式碼,你想在計算結束時設定一個標誌。
為了編譯這個程式碼,你需要決定每個變數使用哪個暫存器。之後,你可以將原始碼轉換為該裝置的說明。
到目前為止,一切都如預期。
如果你不瞭解計算機在晶片級別的工作原理以及它們用於執行程式碼工作的流水線,那你的程式碼中的第2行需要稍等一下才能執行。
大多數計算機將執行指令的過程分解成多個步驟,這樣可以確保CPU的所有不同部分始終處於執行狀態,這樣才能充分利用CPU的效能。
以下是我在處理一個實際案例時的步驟:
1.從記憶體中讀取下一條指令;
2.找出告訴我要做什麼的指令,也就是解碼指令,並從暫存器獲取該值;
3.執行指令;
4.將結果寫回暫存器;
這是一條指令如何通過管道的步驟,理想情況下,我希望直接遵循第二條指令,即一旦進入第二階段,我們就能要獲取下一條指令。
問題是指令#1和指令#2之間存在依賴關係。
你可以暫停CPU,直到指令#1更新了暫存器中的subTotal,但這會減慢執行速度。
為了使執行更有效率,很多編譯器和CPU將做的是重新排序程式碼,它們將尋找不使用subTotal或total的其他指令,並將它們移動到兩行之間。
這麼做就保持了穩定的指令流通過管道,因為第3行不依賴於第1行或第2行中的任何值,所以編譯器或CPU表明可以像這樣重新排序。當你執行在單個執行緒中時,不管發生什麼,在整個函式完成之前,其他程式碼甚至不會看到這些值。
但是當另一個執行緒在另一個處理器上同時執行時,情況並非如此。另一個執行緒不需要等到函式完成才能看到這些更改。一旦它們被記錄回來,它就可以看到這些值,所以該情況可以說是一個被設定在總和之前的isDone執行。
如果你使用isDone來作為計算總和的方法並準備在其他執行緒中使用,則這種重新排序將建立競爭條件。
Atomics試圖解決一些這些錯誤,當你使用Atomic寫入時,就像將程式碼放在兩個部分之間。
Atomics操作相對於彼此不重新排序,其他操作也不會在其周圍移動。特別是,經常用於強制排序的兩個操作是:
· Atomics.load;
· Atomics.store;
在Atomics.store完成將其值並重寫回記憶體之前,函式原始碼中的Atomics.store之上的所有變數更新都將得到保證。即使非Atomics指令相對於彼此重新排序,它們都不會被移動到原始碼下面的Atomics.store。
在保證Atomics.load獲取其值後,在函式中的Atomics.load之後的所有值都將變為可變負載。之後,即使非Atomics指令被重新排序,它們也不會被移動到原始碼之前的Atomics.load。
注意:這裡顯示的while迴圈稱為自旋鎖,效率非常低,自旋鎖是專為防止多處理器併發而引入的一種鎖,它在核心中大量應用於中斷處理等部分。如果它在主執行緒上,它可以使你的應用程式停止,幾乎可以肯定你不想在實踐中的編碼中使用它。
另外,這些方法並不意味著直接用在應用程式程式碼中。相反,庫將使用它們來建立鎖。
總結
程式設計共享記憶體的多個執行緒很難,有很多不同種類的競爭條件會阻礙你。
這就是為什麼你不想直接在應用程式程式碼中使用SharedArrayBuffers和Atomics。所以,你應該依賴具有多執行緒經驗的開發人員的經過驗證的庫,以及經過實踐驗證的記憶體模型。
由於SharedArrayBuffer和Atomics還處於早期研發階段,所以有很多庫還尚未建立。