翻譯自:Avoiding race conditions in SharedArrayBuffers with Atomicsgit
這是圖解 SharedArrayBuffers 系列的第三篇:github
譯者注:文中會屢次出現「線程(threads)」,這個翻譯其實並不許確,但不會妨礙理解編程
上篇文章我介紹了什麼狀況下使用 SharedArrayBuffers 會致使競爭條件,這讓使用 SharedArrayBuffers 變得很困難,咱們並不但願應用開發者直接就這麼使用 SharedArrayBuffers安全
可是在多線程編程方面經驗豐富的庫開發者可使用這些底層 API 創造出高級的工具,應用開發者能夠直接使用這些工具而不用去直接接觸 SharedArrayBuffers 和 Atomics多線程
即便你工做中不須要直接接觸 SharedArrayBuffers 和 Atomics,我以爲去理解它的工做原理也是頗有意思的。所以,在這篇文章裏我會解釋下哪些競爭條件會產生,以及 Atomics 是如何解決這些問題的函數
可是,首先,什麼是競爭條件呢?工具
若是有兩個線程使用同一個變量,那麼就有可能產生競爭條件,這是最簡單的狀況。再具體點,假設一個線程要加載一個文件,而另外一個線程要檢查這個文件是否存在(譯者注:這裏應該是檢查並設置存在標誌位),它們會使用到同一個變量 fileExists 去通訊atom
初始的時候,fileExists 被設置爲 false線程
一旦線程 2 先運行,文件就會被加載翻譯
可是若是線程 1 先運行,就會向用戶拋一個錯誤,說文件不存在
可是這不是問題的關鍵,文件存在與否問題不大,真正的問題在於競爭條件
即便在單線程代碼裏,許多 JavaScript 開發者也會遇到這類競爭條件,你不須要理解多線程就能搞明白爲何會競爭
然而,有些競爭條件在單線程裏就無法發生,只可能在有內存共享的多線程裏發生
如今說點多線程裏不一樣類型的競爭條件,看看如何用 Atomics 解決的。這個並無覆蓋全部狀況,可是卻會給你提供一些思路去理解爲何 Atomics 的 API 會提供這些方法
開始以前,須要再次重申:你不該該直接使用 Atomics!寫多線的代碼原本就是個很苦難的事情,你應該直接使用可靠的庫去處理多線程中共享內存問題
假設有兩個線程同時增長某個變量的值,你可能認爲,不管哪一個線程先運行,最終的結果是同樣的
在代碼裏,即便增長一個變量這種操做看起來像是一個操做,但若是看到編譯後的代碼,會發現並非
從 CPU 層面看,增長一個變量值須要三條指令,這是由於計算機同時有長期存儲器和短時間存儲器(這個在其它文章裏會說)
全部的線程共享同一個長期存儲器(內存),可是短時間存儲器(寄存器)並非共享的
每一個線程須要把值先從內存搬到寄存器,以後就能夠在寄存器上進行計算了,再而後會把計算後的值寫回內存
若是線程 1 的全部的操做都先執行,以後執行全部線程 2 的操做,最終會獲得咱們的預期的結果
可是,若是它們間隔着執行,從線程 2 的裏移到寄存器的值就沒法與內存的值同步了,這意味着線程 2 會沒法用到線程 1 的計算結果。相反,它線程 2 會用覆蓋掉線程 1 寫回內存的值
原子操做作的一件事就是在多線程中讓計算機按照人所想的單操做方式工做
這就是爲何被叫作原子操做,由於它可讓一個包含多條指令(指令能夠暫停和恢復)的操做執行起來像是一會兒就完了,就好像一條指令,相似一個不可分割的原子
使用原子操做會讓加法變得有點不同
如今,咱們可使用 Atomics.add
了,加法執行過程當中不會由於多線程而被打亂。一個線程在執行完原子操做前會阻止其它線程執行,以後其它線程纔會執行本身的原子操做
Atomics 中幫助避免競爭的方法有:
你會發現這個列表數量頗有限,甚至沒有除法和乘法。不過,庫的開發者會提供相似這些常見原子操做的
庫的開發者會藉助 Atomics.compareExchange 從 SharedArrayBuffer 拿到值,應用相應的操做,而後只有在自上次檢查到如今沒有其它線程更新的狀況下才會去寫回。若是期間有其它線程更新了,則會先拿到新的值從新運算一次
這些 Atomic 運算符成功避免了「單運算」中的競爭條件。可是,有時你會同時改變一個對象上的多個值(使用多個運算),在此期間,你並不但願有其它的任務也在修改這個對象。簡單說,就是在你修改這個對象期間,這個對象是處於禁閉狀態,其它線程不能夠訪問
Atomics 沒有提供任何方法去作這個事,可是卻爲庫開發者提供了相應的方案,庫開發者能夠經過鎖來達到目的
若是代碼想使用某個被鎖住的數據,首先它須要去請求鎖,以後它會用這個鎖把其它線程鎖在外面,只有它能夠訪問和更新這塊數據
庫開發者會經過使用 Atomics.wait 和 Atomics.wake,以及可選的 Atomics.compareExchange 和 Atomics.store 建立一個鎖。想了解更多能夠看下這篇文章 簡單鎖的實現
這種狀況下,線程 2 會請求到鎖,並把值設置爲 true,這意味着直到線程 2 交出鎖前,線程 1 是沒法訪問的
若是線程 1 想要訪問這塊數據,它會試圖請求鎖。可是由於鎖處於被使用狀態,它沒法拿到,它因而只能出於等待狀態直到鎖可用
一旦線程 2 結束了,它會調用 unlock,鎖會通知其它等待的線程本身空出來啦
那個線程就會拿起鎖,鎖住數據供本身使用
實現一個鎖可能須要依賴不少 Atomics 的方法,可是用的最多的是下面兩個:
這裏還有第三種同步問題須要用 Atomics 處理,這類問題可能會很神奇
你可能感受不到,你寫的代碼極可能根本沒按你指望的順序執行,由於編譯器和 CPU 會嘗試重排指令使得代碼更快地運行
好比,你寫了一些代碼去計算總和,你想的是計算完了要設置一個標記
編譯的時候須要決定每一個變量該用哪一個寄存器,以後就能夠把代碼翻譯成機器的指令了
目前爲止,一切都在掌握中
若是你對計算機芯片級的原理不理解的話,可能你沒發現到第 2 行須要等待下才能執行
大多數的計算機會把一個指令拆分爲多個步驟,這使得 CPU 能夠被充分利用
下面是一個指令執行步驟的例子:
這就是指令如何像流水線工人同樣工做,理想的狀況是第二個指令會牢牢地跟着第一個指令,當第一個指令進行到步驟 2 的時候,第二個指令進行步驟 1
問題是,指令 1 和指令 2 存在依賴
CPU 須要一直等待直到指令 1 更新了寄存器裏的 subTotal
,可是這就使執行變慢了
爲了讓這一切更加高效,不少編譯器和 CPU 會記錄好代碼,找到不依賴 subTotal
或 total
的指令,而後移到兩個指令之間
這會讓指令執行保持着一個很穩定的流水線
由於第三行不依賴任何前兩行的值,編譯器和 CPU 認爲它是安全的。在單線程裏運行時,直到運行完不會有其它代碼看到這些
可是當有另外一個 CPU 上的線程也在同時運行,狀況就不妙了。其它線程不須要一直等到函數執行完畢,只要值寫到內存裏它就能夠看到,所以,它會認爲 isDone
是在 total
前設置的
若是你用 isDone
做爲 total
被計算好用於其它線程的標記,這裏就會產生競爭條件
Atomics 試圖去解決這些問題,使用 Atomic 的時候就像在代碼塊上加了個圍欄
Atomic 操做相互之間不會重排,其它操做也不會移動到它們的周圍。其中,有兩個常常用到的操做:
Atomics.store
以前的代碼能夠保證在 Atomics.store
以前運行完並把值寫回內存。即便非原子指令相互之間重排了,也不會移到 Atomics.store
的下面
全部 Atomics.load
後面的變量能夠保證只會在 Atomics.load
後面取得值。即便非原子指令重排了,也不會有指令會移到 Atomics.load
上面
提示:這裏我寫的一個 while 循環使用了自旋鎖,很低效。若是它在主線程上運行的話,會讓你的應用程序有無響應一段時間,你不該該在實際代碼裏用
再次提醒,這些方法不建議直接在應用程序裏使用,庫開發者會用這些創造鎖供使用
有內存共享的多線程編程是很困難的,有太多競爭條件的陷進等着你往裏跳
這就是爲何你不會喜歡直接在應用程序裏使用 SharedArrayBuffers 和 Atomics。相反,你應該使用一個由多線程方面經驗豐富的開發者開發的可靠的庫,他確定會內存模型研究很透徹
SharedArrayBuffer 和 Atomics 纔出來沒多久,這樣的庫尚未呢,可是新的 API 已經足夠去構建這些
By Cody