第10章:併發和分散式程式設計 10.1併發性和執行緒安全性

啥也博士發表於2019-01-19

大綱

什麼是併發程式設計?
程式,執行緒和時間片
交織和競爭條件
執行緒安全

  • 策略1:監禁
  • 策略2:不可變性
  • 策略3:使用執行緒安全資料型別
  • 策略4:鎖定和同步

如何做安全論證
總結

什麼是併發程式設計?

併發
併發性:多個計算同時發生。

在現代程式設計中無處不在:

  • 網路上的多臺計算機中的多臺計算機
  • 在一臺計算機上執行的多個應用程式一臺計算機上的多個應用程式
  • 計算機中的多個處理器(今天,通常是單個晶片上的多個處理器核心)一個CPU上的多核處理器

併發在現代程式設計中至關重要:

  • 網站必須處理多個同時使用的使用者。多使用者併發請求伺服器的計算資源
  • 移動應用程式需要在雲中執行一些處理。 App在手機端和在雲端都有計算
  • 圖形使用者介面幾乎總是需要不中斷使用者的後臺工作。例如,Eclipse在編輯它時編譯你的Java程式碼。 GUI的前端使用者操作和後臺的計算

為什麼要“併發”?

處理器時鐘速度不再增加。摩爾定律失效了
相反,我們每個新一代晶片都會獲得更多核心。 “核”變得越來越多
為了讓計算更快執行,我們必須將計算分解為併發塊。為了充分利用多核和多處理器,需要將程式轉化為並行執行

並行程式設計的兩種模型

共享記憶體:併發模組通過在記憶體中讀寫共享物件進行互動。
共享記憶體:在記憶體中讀寫共享資料
訊息傳遞:併發模組通過通訊通道相互傳送訊息進行互動。模組傳送訊息,並將傳入的訊息傳送到每個模組以便處理。
訊息傳遞:通過通道(channel)交換訊息

共享記憶體

共享記憶體模型的示例:

  • A和B可能是同一臺計算機中的兩個處理器(或處理器核),共享相同的實體記憶體。

兩個處理器,共享記憶體

  • A和B可能是在同一臺計算機上執行的兩個程式,它們共享一個通用檔案系統及其可讀取和寫入的檔案。

同一臺機器上的兩個程式,共享檔案系統

  • A和B可能是同一個Java程式中的兩個執行緒,共享相同的Java物件。

同一個Java的程式內的兩個執行緒,共享的Java物件

訊息傳遞

訊息傳遞的例子:

  • A和B可能是網路中的兩臺計算機,通過網路連線進行通訊。

網路上的兩臺計算機,通過網路連線通訊

  • A和B可能是一個Web瀏覽器和一個Web伺服器
  • A開啟與B的連線並請求網頁,B將網頁資料傳送回A.

瀏覽器和Web伺服器,A請求頁面,B傳送頁面資料給A

  • A和B可能是即時訊息客戶端和伺服器。

即時通訊軟體的客戶端和伺服器

  • A和B可能是在同一臺計算機上執行的兩個程式,其輸入和輸出已通過管道連線,如鍵入命令提示符中的ls | grep。

同一臺計算機上的兩個程式,通過管道連線進行通訊

程式,執行緒,時間片

程式和執行緒

訊息傳遞和共享記憶體模型是關於併發模組如何通訊的。
併發模組本身有兩種不同的型別:程式和執行緒,兩個基本的執行單元。
併發模組的型別:程式和執行緒

  • 程式是正在執行的程式的一個例項,與同一臺計算機上的其他程式隔離。 特別是,它有自己的機器記憶體專用部分。程式:私有空間,彼此隔離
  • 執行緒是正在執行的程式中的一個控制軌跡。 把它看作是正在執行的程式中的一個地方,再加上導致那個地方的方法呼叫堆疊(所以當執行緒到達返回語句時可以返回堆疊)。執行緒:程式內部的控制機制

(1)程式

程式抽象是一個虛擬計算機(一個獨立的執行環境,具有一套完整的私有基本執行時資源,尤其是記憶體)。程式:擁有整臺計算機的資源

  • 它使程式感覺自己擁有整臺機器
  • 就像一臺全新的計算機一樣,建立了新的記憶體,只是為了執行該程式。

就像連線網路的計算機一樣,程式通常在它們之間不共享記憶體。多程式之間不共享記憶體

  • 程式無法訪問另一個程式的記憶體或物件。
  • 相比之下,新的流程自動準備好傳遞訊息,因為它是使用標準輸入輸出流建立的,這些流是您在Java中使用的System.out和System.in流。程式之間通過訊息傳遞進行協作

程式通常被視為與程式或應用程式的同義詞。一般來說,程式==程式==應用

  • 但是,使用者將其視為單一應用程式實際上可能是一組協作過程。但一個應用中可能包含多個程式

為了促進程式之間的通訊,大多數作業系統都支援程式間通訊(IPC)資源,例如管道和套接字。 OS支援的IPC機制(pipe / socket)支援程式間通訊

  • IPC不僅用於同一系統上的程式之間的通訊,還用於不同系統上的程式。不僅是本機的多個程式之間,也可以是不同機器的多個程式之間。

Java虛擬機器的大多數實現都是作為單個程式執行的。但是Java應用程式可以使用ProcessBuilder物件建立其他程式。 JVM通常執行單一程式,但也可以建立新的程式。

(2)執行緒

執行緒和多執行緒程式設計
就像一個程式代表一個虛擬計算機一樣,執行緒抽象代表一個虛擬處理器,執行緒有時稱為輕量級程式 程式=虛擬機器;執行緒=虛擬CPU

  • 製作一個新的執行緒模擬在由該程式表示的虛擬計算機內部製造新的處理器。
  • 這個新的虛擬處理器執行相同的程式,並與程式中的其他執行緒共享相同的資源(記憶體,開啟的檔案等),即“執行緒存在於程式中”。程式共享,資源共享,都隸屬於程式

執行緒自動準備好共享記憶體,因為執行緒共享程式中的所有記憶體。共享記憶體

  • 需要特殊的努力才能獲得專用於單個執行緒的“執行緒本地”記憶體。很難獲得執行緒私有的記憶體空間(執行緒堆疊怎麼樣?)
  • 通過建立和使用佇列資料結構,還需要顯式設定訊息傳遞。通過建立訊息佇列線上程之間進行訊息傳遞

執行緒與程式

執行緒是輕量級的 程式是重量級的
執行緒共享記憶體空間 程式有自己的
執行緒需要同步(當呼叫可變物件時執行緒保有鎖) 程式不需要
殺死執行緒是不安全的 殺死程式是安全的

多執行緒執行是Java平臺的基本功能。

  • 每個應用程式至少有一個執行緒。每個應用至少有一個執行緒
  • 從應用程式設計師的角度來看,你只從一個叫做主執行緒的執行緒開始。這個執行緒有能力建立額外的執行緒。主執行緒,可以建立其他的執行緒

兩種建立執行緒的方法:

  • (很少使用)子類化執行緒。從Thread類派生子類
  • (更常用)實現Runnable介面並使用new Thread(..)建構函式。從Runnable介面構造執行緒物件

如何建立一個執行緒:子類Thread

子類Thread

  • Thread類本身實現了Runnable,儘管它的run方法什麼都不做。應用程式可以繼承Thread,提供自己的run()實現。

呼叫Thread.start()以啟動新執行緒。

建立執行緒的方法:提供一個Runnable物件

提供一個Runnable物件

  • Runnable介面定義了一個方法run(),意在包含線上程中執行的程式碼。
  • Runnable物件被傳遞給Thread建構函式。

如何建立執行緒

一個非常常見的習慣用法是用一個匿名的Runnable啟動一個執行緒,它消除了命名的類:
Runnable介面表示要由執行緒完成的工作。

為什麼使用執行緒?

面對阻塞活動的表現

  • 考慮一個Web伺服器

在多處理器上的效能
乾淨地處理自然併發

在Java中,執行緒是生活中的事實

  • 示例:垃圾收集器在其自己的執行緒中執行(回憶:第8-1節)

我們都是併發程式設計師……

為了利用我們的多核處理器,我們必須編寫多執行緒程式碼
好訊息:它很多都是為你寫的

  • 存在優秀的庫(java.util.concurrent)

壞訊息:你仍然必須瞭解基本面

  • 有效地使用庫
  • 除錯使用它們的程式

Interleaving and Race Condition

交錯和競爭

(1) 時間分片(Time slicing)

在具有單個執行核心的計算機系統中,在任何給定時刻只有一個執行緒正在執行。雖然有多執行緒,但只有一個核,每個時刻只能執行一個執行緒

  • 單個核心的處理時間通過稱為時間分片的作業系統功能在程式和執行緒間共享。通過時間分片,在多個程式/執行緒之間共享處理器

今天的計算機系統具有多個處理器或具有多個執行核心的處理器。那麼,我的計算機中只有一個或兩個處理器的多個併發執行緒如何處理?即使是多核CPU,程式/執行緒的數目也往往大於核的數目

  • 當執行緒數多於處理器時,併發性通過時間分片模擬,這意味著處理器線上程之間切換。時間分片

時間分片的一個例子

三個執行緒T1,T2和T3可能在具有兩個實際處理器的機器上進行時間分割。

  • 首先一個處理器執行執行緒T1,另一個執行執行緒T2,然後第二個處理器切換到執行執行緒T3。
  • 執行緒T2只是暫停,直到下一個時間片在同一個處理器或另一個處理器上。

在大多數系統中,時間片發生不可預知的和非確定性的,這意味著執行緒可能隨時暫停或恢復。時間分片是由作業系統自動排程的

(2) 執行緒間的共享記憶體

共享記憶體示例

執行緒之間的共享記憶體可能會導致微妙的錯誤!
例如:一家銀行擁有使用共享記憶體模式的取款機,因此所有取款機都可以在記憶體中讀取和寫入相同的賬戶物件。
將銀行簡化為一個賬戶,在餘額變數中儲存美元餘額,以及兩個操作存款和取款,只需新增或刪除美元即可:
客戶使用現金機器進行如下交易:
每筆交易只是一美元存款,然後是基礎提款,所以它應該保持賬戶餘額不變。

  • 在整個一天中,我們網路中的每臺自動提款機正在處理一系列存款/提款交易。

在這一天結束時,無論有多少現鈔機在執行,或者我們處理了多少交易,我們都應該預期帳戶餘額仍然為0.按理說,餘額應該始終為0

  • 但是如果我們執行這個程式碼,我們經常發現在一天結束時的餘額不是0.如果多個cashMachine()呼叫同時執行
  • 例如,在同一臺計算機上的不同處理器上
  • 那麼在一天結束時餘額可能不會為零。為什麼不?

交錯

假設兩臺取款機A和B同時在存款上工作。
以下是deposit()步驟通常如何分解為低階處理器指令的方法:
當A和B同時執行時,這些低階指令彼此交錯…

(3) 競爭條件(Race Condition)

餘額現在是1

  • A的美元丟失了!
  • A和B同時讀取餘額,計算單獨的最終餘額,然後進行儲存以返回新的餘額
  • 沒有考慮到對方的存款。

競爭條件:程式的正確性(後置條件和不變數的滿足)取決於併發計算A和B中事件的相對時間。發生這種情況時,我們說“A與B競爭”。
事件的一些交織可能是可以的,因為它們與單個非併發程式會產生什麼一致,但是其他交織會產生錯誤的答案 – 違反後置條件或不變數。

調整程式碼將無濟於事

所有這些版本的銀行賬戶程式碼都具有相同的競爭條件!
你不能僅僅從Java程式碼中看出處理器將如何執行它。
你不能說出原子操作是什麼。

  • 它不是原子,因為它只是一行Java。
  • 僅僅因為平衡識別符號只在一行中出現一次才平衡一次。單行,單條語句都未必是原子的

Java編譯器不會對您的程式碼生成的低階操作做出任何承諾。是否原子,由JVM確定

  • 一個典型的現代Java編譯器為這三個版本生成完全相同的程式碼!

競爭條件
關鍵的教訓是,你無法通過觀察一個表達來判斷它是否會在競爭條件下安全。
競爭條件也被稱為“執行緒干擾”

(4) 訊息傳遞示例

現在不僅是自動取款機模組,而且賬戶也是模組。
模組通過相互傳送訊息進行互動。

  • 傳入的請求被放入一個佇列中,一次處理一個請求。
  • 發件人在等待對其請求的回答時不停止工作。它處理來自其自己佇列的更多請求。對其請求的回覆最終會作為另一條訊息返回。

訊息傳遞能否解決競爭條件?

不幸的是,訊息傳遞並不能消除競爭條件的可能性。訊息傳遞機制也無法解決競爭條件問題

  • 假設每個賬戶都支援收支平衡和撤銷操作,並帶有相應的訊息。
  • 兩臺A和B取款機的使用者都試圖從同一賬戶中提取一美元。
  • 他們首先檢查餘額,以確保他們永遠不會超過賬戶餘額,因為透支會觸發大銀行的處罰。

問題是再次交錯,但是這次將訊息交給銀行賬戶,而不是A和B所執行的指令。仍然存在訊息傳遞時間上的交錯
如果賬戶以一美元開始,那麼什麼交錯的資訊會欺騙A和B,使他們認為他們既可以提取一美元,從而透支賬戶?

(5) 併發性很難測試和除錯

使用測試發現競爭條件非常困難。很難測試和除錯因為競爭條件導致的錯誤

  • 即使一次測試發現了一個錯誤,也可能很難將其本地化到引發該錯誤的程式部分。 – – 為什麼?

併發性錯誤表現出很差的重現性。因為交錯的存在,導致很難復現錯誤

  • 很難讓它們以同樣的方式發生兩次。
  • 指令或訊息的交織取決於受環境強烈影響的事件的相對時間。
  • 延遲是由其他正在執行的程式,其他網路流量,作業系統排程決策,處理器時鐘速度的變化等引起的。
  • 每次執行包含競爭條件的程式時,您都可能得到不同的行為。

Heisenbugs和Bohrbugs

一個heisenbug是一個軟體錯誤,當一個人試圖研究它時,它似乎消失或改變了它的行為。
順序程式設計中幾乎所有的錯誤都是bohrbugs。

併發性很難測試和除錯!

當您嘗試用println或偵錯程式檢視heisenbug時,甚至可能會消失!增加列印語句甚至導致這種錯誤消失!〜

  • 原因是列印和除錯比其他操作慢得多,通常慢100-1000倍,所以它們會顯著改變操作的時間和交錯。神奇的原因

因此,將一個簡單的列印語句插入到cashMachine()中:
…突然間,平衡總是0,並且錯誤似乎消失了。但它只是被掩蓋了,並沒有真正固定。

3.5干擾執行緒自動交錯的一些操作

Thread.sleep()方法
使用Thread.sleep(time)暫停執行:導致當前執行緒暫停指定時間段的執行。執行緒的休眠

  • 這是使處理器時間可用於其他執行緒或可能在同一臺計算機上執行的其他應用程式的有效方法。將某個執行緒休眠,意味著其他執行緒得到更多的執行機會
  • 執行緒睡眠不會丟失當前執行緒獲取的任何監視器或鎖。進入休眠的執行緒不會失去對現有監視器或鎖的所有權

Thread.interrupt()方法
一個執行緒通過呼叫Thread物件上的中斷來傳送一箇中斷,以便使用interrupt()方法中斷的執行緒 向執行緒發出中斷訊號

  • t.interrupt()在其他執行緒裡向t發出中斷訊號

要檢查執行緒是否中斷,請使用isInterrupted()方法。檢查執行緒是否被中斷

  • t.isInterrupted()檢查t是否已在中斷狀態中

中斷表示執行緒應該停止正在執行的操作並執行其他操作。 當某個執行緒被中斷後,一般來說應停止其run()中的執行,取決於程式設計師在run()中處理

  • 由程式設計師決定執行緒是如何響應中斷的,但執行緒終止是非常常見的。 一般來說,執行緒在收到中斷訊號時應該中斷,直接終止

但是,執行緒收到其他執行緒發來的中斷訊號,並不意味著一定要“停止”…

Thread.yield()方法
這種靜態方法主要用於通知系統當前執行緒願意“放棄CPU”一段時間。使用該方法,執行緒告知排程器:我可以放棄CPU的佔用權,從而可能引起排程器喚醒其他執行緒。

  • 總體思路是:執行緒排程器將選擇一個不同的執行緒來執行而不是當前的執行緒。

這是執行緒程式設計中很少使用的方法,因為排程應該由排程程式負責。儘量避免在程式碼中使用

Thread.join()方法
join()方法用於儲存當前正在執行的執行緒的執行,直到指定的執行緒死亡(執行完畢)。讓當前執行緒保持執行,直到其執行結束

  • 在正常情況下,我們通常擁有多個執行緒,執行緒排程程式排程執行緒,但不保證執行緒執行的順序。一般不需要這種顯式指定執行緒執行次序
  • 通過使用join()方法,我們可以讓一個執行緒等待另一個執行緒。

(6) 總結

併發性:同時執行多個計算
共享記憶體和訊息傳遞引數

程式和執行緒

  • 程式就像一臺虛擬計算機;執行緒就像一個虛擬處理器

競爭條件

  • 結果的正確性(後置條件和不變數)取決於事件的相對時間
  • 多個執行緒共享相同的可變變數,但不協調他們正在做的事情。
  • 這是不安全的,因為程式的正確性可能取決於其低階操作的時間安排事故。

這些想法主要以糟糕的方式與我們的優秀軟體的關鍵屬性相關聯。

併發是必要的,但它會導致嚴重的正確性問題:

  • 從錯誤安全。併發性錯誤是找到並修復最難的錯誤之一,需要仔細設計才能避免。
  • 容易明白。預測併發程式碼如何與其他併發程式碼交錯對於程式設計師來說非常困難。最好以這樣的方式設計程式碼,程式設計師根本不必考慮交錯。

執行緒安全

競爭條件:多個執行緒共享相同的可變變數,但不協調他們正在做的事情。

這是不安全的,因為程式的正確性可能取決於其低階操作時間的事故。
執行緒之間的“競爭條件”:作用於同一個可變資料上的多執行緒,彼此之間存在對該資料的訪問競爭並導致交錯,導致postcondition可能被違反,這是不安全的。

執行緒安全的意思

如果資料型別或靜態方法在從多個執行緒使用時行為正確,則無論這些執行緒如何執行,都無需執行緒安全,也不需要呼叫程式碼進行額外協調。執行緒安全:ADT或方法在多執行緒中要執行正確

如何捕捉這個想法?

  • “正確行為”是指滿足其規範並保留其不變性;不違反規範,保持RI
  • “不管執行緒如何執行”意味著執行緒可能在多個處理器上或在同一個處理器上進行時間片化;與多少處理器,如何排程執行緒,均無關
  • “沒有額外的協調”意味著資料型別不能在與定時有關的呼叫方上設定先決條件,如“在set()進行時不能呼叫get()”。不需要在spec中強制要求客戶端滿足某種“執行緒安全”的義務

還記得迭代器嗎?這不是執行緒安全的。

迭代器的規範說,你不能在迭代它的同時修改一個集合。
這是一個與呼叫程式相關的與時間有關的前提條件,如果違反它,Iterator不保證行為正確。

執行緒安全意味著什麼:remove()的規範

作為這種非本地契約現象的一個症狀,考慮Java集合類,這些類通常記錄在客戶端和實現者之間的非常明確的契約中。

  • 嘗試找到它在客戶端記錄關鍵要求的位置,以便在迭代時無法修改集合。

執行緒安全的四種方法

監禁資料共享。不要線上程之間共享變數。

共享不可變資料。使共享資料不可變。

執行緒安全資料型別共享執行緒安全的可變資料。將共享資料封裝在為您協調的現有執行緒安全資料型別中。

同步 同步機制共享共享執行緒不安全的可變資料,對外即為執行緒安全的ADT。使用同步來防止執行緒同時訪問變數。同步是您構建自己的執行緒安全資料型別所需的。

不要共享:在單獨的執行緒中隔離可變狀態
不要改變:只共享不可變的狀態
如果必須共享可變狀態,請使用執行緒安全資料型別或同步

策略1:監禁

執行緒監禁是一個簡單的想法:

  • 通過將資料監禁在單個執行緒中,避免在可變資料上進行競爭。將可變資料監禁在單一執行緒內部,避免競爭
  • 不要讓任何其他執行緒直接讀取或寫入資料。不允許任何執行緒直接讀寫該資料

由於共享可變資料是競爭條件的根本原因,監禁通過不共享可變資料來解決。核心思想:執行緒之間不共享可變資料型別

區域性變數總是受到執行緒監禁。區域性變數儲存在堆疊中,每個執行緒都有自己的堆疊。一次執行的方法可能會有多個呼叫,但每個呼叫都有自己的變數專用副本,因此變數本身受到監禁。

  • 如果區域性變數是物件引用,則需要檢查它指向的物件。 如果物件是可變的,那麼我們要檢查物件是否被監禁 – 不能引用它,它可以從任何其他執行緒訪問(而不是別名)。

避免全域性變數

這個類在getInstance()方法中有一個競爭

  • 兩個執行緒可以同時呼叫它並最終建立PinballSimulator物件的兩個副本,這違反了代表不變數。

假設兩個執行緒正在執行getInstance()。
對於兩個執行緒正在執行的每對可能的行號,是否有可能違反不變數?
全域性靜態變數不會自動受到執行緒監禁。

  • 如果你的程式中有靜態變數,那麼你必須提出一個論點,即只有一個執行緒會使用它們,並且你必須清楚地記錄這個事實。 [在程式碼中記錄 – 第4章]

更好的是,你應該完全消除靜態變數。

isPrime()方法從多個執行緒呼叫並不安全,其客戶端甚至可能不會意識到它。

  • 原因是靜態變數快取引用的HashMap被所有對isPrime()的呼叫共享,並且HashMap不是執行緒安全的。
  • 如果多個執行緒同時通過呼叫cache.put()來改變地圖,那麼地圖可能會以與上一次讀數中的銀行賬戶損壞相同的方式被破壞。
  • 如果幸運的話,破壞可能會導致雜湊對映深處發生異常,如NullPointerException或IndexOutOfBoundsException。
  • 但它也可能會悄悄地給出錯誤的答案。

策略2:不可變性

實現執行緒安全的第二種方法是使用不可變引用和資料型別。使用不可變資料型別和不可變引用,避免多執行緒之間的競爭條件

  • 不變性解決競爭條件的共享可變資料原因,並簡單地通過使共享資料不可變來解決它。

final變數是不可變的引用,所以宣告為final的變數可以安全地從多個執行緒訪問。

  • 你只能讀取變數,而不能寫入變數。
  • 因為這種安全性只適用於變數本身,我們仍然必須爭辯變數指向的物件是不可變的。

不可變物件通常也是執行緒安全的。不可變資料通常是執行緒安全的
我們說“通常”,因為不可變性的當前定義對於併發程式設計而言過於鬆散。

  • 如果一個型別的物件在整個生命週期中始終表示相同的抽象值,則型別是不可變的。
  • 但實際上,只要這些突變對於客戶是不可見的,例如有益的突變(參見3.3章節),實際上允許型別自由地改變其代表。
  • 如快取,延遲計算和資料結構重新平衡

對於併發性,這種隱藏的變異是不安全的。

  • 使用有益突變的不可變資料型別必須使用鎖使自己執行緒安全。如果ADT中使用了有益突變,必須要通過“加鎖”機制來保證執行緒安全

更強的不變性定義

為了確信一個不可變的資料型別是沒有鎖的執行緒安全的,我們需要更強的不變性定義:

  • 沒有變值器方法
  • 所有欄位都是私人的和最終的
  • 沒有表示風險
  • 表示中的可變物件沒有任何突變
  • 甚至不能是有益的突變

如果你遵循這些規則,那麼你可以確信你的不可變型別也是執行緒安全的。
不要提供“setter”方法 – 修改欄位引用的欄位或物件的方法。
使所有欄位最終和私有。
不要讓子類重寫方法。

  • 最簡單的方法是將類宣告為final。
  • 更復雜的方法是使建構函式保持私有狀態,並使用工廠方法構造例項。

如果例項欄位包含對可變物件的引用,請不要允許更改這些物件:

  • 不要提供修改可變物件的方法。
  • 不要共享對可變物件的引用。
  • 不要儲存對傳遞給建構函式的外部可變物件的引用;如有必要,建立副本,並儲存對副本的引用。
  • 同樣,必要時建立內部可變物件的副本,以避免在方法中返回原件。

策略3:使用執行緒安全資料型別

實現執行緒安全的第三個主要策略是將共享可變資料儲存在現有的執行緒資料型別中。 如果必須要用mutable的資料型別在多執行緒之間共享資料,則要使用執行緒安全的資料型別。

  • 當Java庫中的資料型別是執行緒安全的時,其文件將明確說明這一事實。在JDK中的類,文件中明確指明瞭是否執行緒

一般來說,JDK同時提供兩個相同功能的類,一個是執行緒安全的,另一個不是。執行緒安全的類一般效能上受影響

  • 原因是這個報價表明:與不安全型別相比,執行緒安全資料型別通常會導致效能損失。

執行緒安全集合

Java中的集合介面

  • 列表,設定,地圖
  • 具有不是執行緒安全的基本實現。集合類都是執行緒不安全的
  • ArrayList,HashMap和HashSet的實現不能從多個執行緒安全地使用。

Collections API提供了一組包裝器方法來使集合執行緒安全,同時仍然可變。 Java API提供了進一步的裝飾器

  • 這些包裝器有效地使集合的每個方法相對於其他方法是原子的。
  • 原子動作一次有效地發生
  • 它不會將其內部操作與其他操作的內部操作交錯,並且在整個操作完成之前,操作的任何效果都不會被其他執行緒看到,因此它從未部分完成。

執行緒安全包裝

public static <T> Collection<T> synchronizedCollection(Collection<T> c);
public static <T> Set<T> synchronizedSet(Set<T> s);
public static <T> List<T> synchronizedList(List<T> list);
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m);
public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s);
public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m);

包裝實現將他們所有的實際工作委託給指定的集合,但在集合提供的基礎上新增額外的功能。
這是裝飾者模式的一個例子(參見5-3節)
這些實現是匿名的;該庫不提供公共類,而是提供靜態工廠方法。
所有這些實現都可以在Collections類中找到,該類僅由靜態方法組成。
同步包裝將自動同步(執行緒安全)新增到任意集合。

不要繞開包裝

確保拋棄對底層非執行緒安全集合的引用,並僅通過同步包裝來訪問它。
新的HashMap只傳遞給synchronizedMap(),並且永遠不會儲存在其他地方。
底層的集合仍然是可變的,引用它的程式碼可以規避不變性。
在使用synchronizedMap(hashMap)之後,不要再引數hashMap共享給其他執行緒,不要保留別名,一定要徹底銷燬

迭代器仍然不是執行緒安全的

儘管方法呼叫集合本身(get(),put(),add()等)現在是執行緒安全的,但從集合建立的迭代器仍然不是執行緒安全的。 即使線上程安全的集合類上,使用迭代器也是不安全的
此迭代問題的解決方案將是在需要迭代它時獲取集合的鎖。除非使用鎖機制

原子操作不足以阻止競爭

您使用同步收集的方式仍可能存在競爭條件。
考慮這個程式碼,它檢查列表是否至少有一個元素,然後獲取該元素:
即使您將list放入同步列表中,此程式碼仍可能存在競爭條件,因為另一個執行緒可能會刪除isEmpty()呼叫和get()呼叫之間的元素。

同步對映確保containsKey(),get()和put()現在是原子的,所以從多個執行緒使用它們不會損害對映的rep不變數。
但是這三個操作現在可以以任意方式相互交織,這可能會破壞快取中需要的不變數:如果快取將整數x對映到值f,那麼當且僅當f為真時x是素數。
如果快取永遠失敗這個不變數,那麼我們可能會返回錯誤的結果。

注意

我們必須爭論containsKey(),get()和put()之間的競爭不會威脅到這個不變數。

  • containsKey()和get()之間的競爭是無害的,因為我們從不從快取中刪除專案 – 一旦它包含x的結果,它將繼續這樣做。
  • containsKey()和put()之間存在競爭。 因此,最終可能會有兩個執行緒同時測試同一個x的初始值,並且兩個執行緒都會呼叫put()與答案。 但是他們都應該用相同的答案來呼叫put(),所以無論哪個人贏得比賽並不重要 – 結果將是相同的。

……在註釋中自證執行緒
需要對安全性進行這種仔細的論證 – 即使在使用執行緒安全資料型別時 – 也是併發性很難的主要原因。

一個簡短的總結
通過共享可變資料的競爭條件實現安全的三種主要方式:

  • 禁閉:不共享資料。
  • 不變性:共享,但保持資料不變。
  • 執行緒安全資料型別:將共享的可變資料儲存在單個執行緒安全資料型別中。

減少錯誤保證安全。

  • 我們正試圖消除一大類併發錯誤,競爭條件,並通過設計消除它們,而不僅僅是意外的時間。

容易明白。

  • 應用這些通用的,簡單的設計模式比關於哪種執行緒交叉是可能的而哪些不可行的複雜論證更容易理解。

準備好改變。

  • 我們在一個執行緒安全引數中明確地寫下這些理由,以便維護程式設計師知道程式碼依賴於執行緒安全。

Strategy 4: Locks and Synchronization

最複雜也最有價值的threadsafe策略

回顧
資料型別或函式的執行緒安全性:在從多個執行緒使用時行為正確,無論這些執行緒如何執行,無需額外協調。執行緒安全不應依賴於偶然

原理:併發程式的正確性不應該依賴於時間事件。

有四種策略可以使程式碼安全併發:

  • 監禁:不要線上程之間共享資料。
  • 不變性:使共享資料不可變。
  • 使用現有的執行緒安全資料型別:使用為您協調的資料型別。

前三種策略的核心思想:

  • 避免共享→即使共享,也只能讀/不可寫(immutable)→即使可寫(mutable),共享的可寫資料應該自己具備在多執行緒之間協調的能力,即“使用執行緒安全的mutable ADT”

同步和鎖定

由於共享可變資料的併發操作導致的競爭條件是災難性的錯誤 – 難以發現,重現和除錯 – 我們需要一種共享記憶體的併發模組以實現彼此同步的方式。
很多時候,無法滿足上述三個條件…
使程式碼安全併發的第四個策略是:

  • 同步和鎖:防止執行緒同時訪問共享資料。

程式設計師來負責多執行緒之間對可變資料的共享操作,通過“同步”策略,避免多執行緒同時訪問資料

鎖是一種同步技術。

  • 鎖是一種抽象,最多允許一個執行緒擁有它。保持鎖定是一條執行緒告訴其他現成:“我正在改變這個東西,現在不要觸控它。”
  • 使用鎖機制,獲得對資料的獨家改變權,其他執行緒被阻塞,不得訪問

使用鎖可以告訴編譯器和處理器你正在同時使用共享記憶體,所以暫存器和快取將被重新整理到共享儲存,確保鎖的所有者始終檢視最新的資料。
阻塞一般意味著一個執行緒等待(不再繼續工作)直到事件發生。

兩種鎖定操作

acquire允許執行緒獲取鎖的所有權。

  • 如果一個執行緒試圖獲取當前由另一個執行緒擁有的鎖,它會阻塞,直到另一個執行緒釋放該鎖。
  • 在這一點上,它將與任何其他嘗試獲取鎖的執行緒競爭。
  • 一次只能有一個執行緒擁有該鎖。

release放棄鎖的所有權,允許另一個執行緒獲得它的所有權。

  • 如果另一個執行緒(如執行緒2)持有鎖l,執行緒1上的獲取(l)將會阻塞。它等待的事件是執行緒2執行釋放(l)。
  • 此時,如果執行緒1可以獲取l,則它繼續執行其程式碼,並擁有鎖的所有權。
  • 另一個執行緒(如執行緒3)也可能在獲取(l)時被阻塞。執行緒1或3將採取鎖定並繼續。另一個將繼續阻塞,再次等待釋放(l)。

(1)同步塊和方法

鎖定

鎖是如此常用以至於Java將它們作為內建語言功能提供。鎖是Java的語言提供的內嵌機制

  • 每個物件都有一個隱式關聯的鎖 – 一個String,一個陣列,一個ArrayList,每個類及其所有例項都有一個鎖。
  • 即使是一個不起眼的Object也有一個鎖,因此裸露的Object通常用於顯式鎖定:

但是,您不能在Java的內部鎖上呼叫acquire和release。 而是使用synchronized語句來獲取語句塊持續時間內的鎖定:
像這樣的同步區域提供互斥性:一次只能有一個執行緒處於由給定物件的鎖保護的同步區域中。
換句話說,你回到了順序程式設計世界,一次只執行一個執行緒,至少就其他同步區域而言,它們指向同一個物件。

鎖定保護對資料的訪問

鎖用於保護共享資料變數。鎖保護共享資料

  • 如果所有對資料變數的訪問都被相同的鎖物件保護(被同步塊包圍),那麼這些訪問將被保證為原子 – 不被其他執行緒中斷。

使用以下命令獲取與物件obj關聯的鎖定:
synchronized(obj){…}

  • 它阻止其他執行緒進入synchronized(obj)直到執行緒t完成其同步塊為止。

鎖只與其他獲取相同鎖的執行緒相互排斥。 所有對資料變數的訪問必須由相同的鎖保護。 注意:要互斥,必須使用同一個鎖進行保護

  • 你可以在單個鎖後面保護整個變數集合,但是所有模組必須同意他們將獲得並釋放哪個鎖。

監視器模式

在編寫類的方法時,最方便的鎖是物件例項本身,即this。用ADT自己做鎖
作為一種簡單的方法,我們可以通過在synchronized(this)內包裝所有對rep的訪問來守護整個類的表示。
監視器模式:監視器是一個類,它們的方法是互斥的,所以一次只能有一個執行緒在類的例項中。
每一個觸及表示的方法都必須用鎖來保護,甚至像length()和toString()這樣的顯而易見的小程式碼。
這是因為必須保護讀取以及寫入 – 如果讀取未被保留,則他們可能能夠看到處於部分修改狀態的rep。
如果將關鍵字synchronized新增到方法簽名中,Java將像您在方法主體周圍編寫synchronized(this)一樣操作。

同步方法

同一物件上的同步方法的兩次呼叫不可能交錯。對同步的方法,多個執行緒執行它時不允許交錯,也就是說“按原子的序列方式執行”

  • 當一個執行緒正在為一個物件執行一個同步方法時,所有其他呼叫同一物件的同步方法的執行緒將阻塞(暫停執行),直到第一個執行緒完成物件。
  • 當一個同步方法退出時,它會自動建立與同一物件的同步方法的任何後續呼叫之間的發生前關係。
  • 這保證對所有執行緒都可見物件狀態的更改。

同步語句/塊

同步方法和同步(this)塊之間有什麼區別?

  • 與synchronized方法不同,synchronized語句必須指定提供內部鎖的物件。
  • 同步語句對於通過細粒度同步來提高併發性非常有用。

二者有何區別?

  • 後者需要顯式的給出鎖,且不一定非要是this
  • 後者可提供更細粒度的併發控制

鎖定規則

鎖定規則是確保同步程式碼是執行緒安全的策略。
我們必須滿足兩個條件:

  • 每個共享的可變變數必須由某個鎖保護。除了在獲取該鎖的同步塊內,資料可能不會被讀取或寫入。任何共享的可變變數/物件必須被鎖所保護
  • 如果一個不變數涉及多個共享的可變變數(它甚至可能在不同的物件中),那麼涉及的所有變數都必須由相同的鎖保護。一旦執行緒獲得鎖定,必須在釋放鎖定之前重新建立不變數。涉及到多個mutable變數的時候,它們必須被一個鎖所保護

這裡使用的監視器模式滿足這兩個規則。代表中所有共享的可變資料 – 代表不變數依賴於 – 都被相同的鎖保護。

發生-前關係

這種發生-前關係,只是保證多個執行緒共享的物件通過一個特定語句寫入的內容對另一個讀取同一物件的特定語句是可見的。
這是為了確保記憶體一致性。
發生-前關係(a→ b)是兩個事件的結果之間的關係,因此如果在事件發生之前發生一個事件,那麼結果必須反映出,即使這些事件實際上是無序執行的。

  • 這涉及基於併發系統中的事件對的潛在因果關係對事件進行排序。
  • 它由Leslie Lamport制定。

正式定義為事件中最不嚴格的部分順序,以便:

  • 如果事件a和b在同一個過程中發生,如果在事件b發生之前發生了事件a則a→b;
  • 如果事件a是傳送訊息,並且事件b是在事件a中傳送的訊息的接收,則a→b。

像所有嚴格的偏序一樣,發生-前關係是傳遞的,非自反的和反對稱的。

原子資料訪問的關鍵字volatile

使用volatile(不穩定)變數可降低記憶體一致性錯誤的風險,因為任何對volatile變數的寫入都會在後續讀取該變數的同時建立happen-before關係。
這意味著對其他執行緒總是可見的對volatile變數的更改。
更重要的是,這也意味著當一個執行緒讀取一個volatile變數時,它不僅會看到volatile的最新變化,還會看到導致變化的程式碼的副作用。
這是一個輕量級同步機制。
使用簡單的原子變數訪問比通過同步程式碼訪問這些變數更有效,但需要程式設計師更多的關注以避免記憶體一致性錯誤。

(3)到處使用同步?

那麼執行緒安全是否只需將synchronized關鍵字放在程式中的每個方法上?
不幸的是,
首先,你實際上並不想同步方法。

  • 同步對您的程式造成很大的損失。 同步機制給效能帶來極大影響
  • 由於需要獲取鎖(並重新整理快取記憶體並與其他處理器通訊),因此進行同步方法呼叫可能需要更長的時間。
  • 由於這些效能原因,Java會將許多可變資料型別預設為不同步。當你不需要同步時,不要使用它。除非必要,否則不要用.Java中很多mutable的型別都不是threadsafe就是這個原因

另一個以更慎重的方式使用同步的理由是,它最大限度地減少了訪問鎖的範圍。儘可能減小鎖的範圍

  • 為每個方法新增同步意味著你的鎖是物件本身,並且每個引用了你的物件的客戶端都會自動引用你的鎖,它可以隨意獲取和釋放。
  • 您的執行緒安全機制因此是公開的,可能會受到客戶的干擾。

與使用作為表示內部物件的鎖並使用synchronized()塊適當並節省地獲取相比。
最後,到處使用同步並不夠實際。

  • 在沒有思考的情況下同步到一個方法上意味著你正在獲取一個鎖,而不考慮它是哪個鎖,或者是否它是保護你將要執行的共享資料訪問的正確鎖。

假設我們試圖通過簡單地將synchronized同步到它的宣告來解決findReplace的同步問題:
public static synchronized boolean findReplace(EditBuffer buf, …)

  • 它確實會獲得一個鎖 – 因為findReplace是一個靜態方法,它將獲取findReplace恰好處於的整個類的靜態鎖定,而不是例項物件鎖定。
  • 結果,一次只有一個執行緒可以呼叫findReplace – 即使其他執行緒想要在不同的緩衝區上執行,這些緩衝區應該是安全的,它們仍然會被阻塞,直到單個鎖被釋放。所以我們會遭受重大的效能損失。

synchronized關鍵字不是萬能的。
執行緒安全需要一個規範 – 使用監禁,不變性或鎖來保護共享資料。
這個紀律需要被寫下來,否則維護人員不會知道它是什麼。

Synchronized不是靈丹妙藥,你的程式需要嚴格遵守設計原則,先試試其他辦法,實在做不到再考慮lock。
所有關於執行緒的設計決策也都要在ADT中記錄下來。

(4)活性:死鎖,飢餓和活鎖

活性
併發應用程式的及時執行能力被稱為活躍性。
三個子度量標準:

  • 死鎖
  • 飢餓
  • 活鎖

(1)死鎖

如果使用得當,小心,鎖可以防止競爭狀況。
但是接下來的另一個問題就是醜陋的頭腦。
由於使用鎖需要執行緒等待(當另一個執行緒持有鎖時獲取塊),因此可能會陷入兩個執行緒正在等待對方的情況 – 因此都無法取得進展。
死鎖描述了兩個或更多執行緒永遠被阻塞的情況,等待對方。
死鎖:多個執行緒競爭鎖,相互等待對方釋放鎖
當併發模組卡住等待對方執行某些操作時發生死鎖。
死鎖可能涉及兩個以上的模組:死鎖的訊號特徵是依賴關係的一個迴圈,例如, A正在等待B正在等待C正在等待A,它們都沒有取得進展。
死鎖的醜陋之處在於它
執行緒安全的鎖定方法非常強大,但是(與監禁和不可變性不同)它將阻塞引入程式。
執行緒必須等待其他執行緒退出同步區域才能繼續。
在鎖定的情況下,當執行緒同時獲取多個鎖時會發生死鎖,並且兩個執行緒最終被阻塞,同時持有鎖,每個鎖都等待另一個鎖釋放。
不幸的是,監視器模式使得這很容易做到。

死鎖:

  • 執行緒A獲取harry鎖(因為friend方法是同步的)。
  • 然後執行緒B獲取snape上的鎖(出於同樣的原因)。
  • 他們都獨立地更新他們各自的代表,然後嘗試在另一個物件上呼叫friend() – 這要求他們獲取另一個物件上的鎖。

所以A正在拿著哈利等著斯內普,而B正拿著斯內普等著哈利。

  • 兩個執行緒都卡在friend()中,所以都不會管理退出同步區域並將鎖釋放到另一個區域。
  • 這是一個經典的致命的擁抱。 該程式停止。

問題的實質是獲取多個鎖,並在等待另一個鎖釋放時持有某些鎖。

死鎖解決方案1:鎖定順序

對需要同時獲取的鎖定進行排序,並確保所有程式碼按照該順序獲取鎖定。

  • 在示例中,我們可能總是按照嚮導的名稱按字母順序獲取嚮導物件上的鎖定。

雖然鎖定順序很有用(特別是在作業系統核心等程式碼中),但它在實踐中有許多缺點。
首先,它不是模組化的 – 程式碼必須知道系統中的所有鎖,或者至少在其子系統中。
其次,程式碼在獲取第一個鎖之前可能很難或不可能確切知道它需要哪些鎖。 它可能需要做一些計算來弄清楚。

  • 例如,想一想在社交網路圖上進行深度優先搜尋,在你開始尋找它們之前,你怎麼知道哪些節點需要被鎖定?

死鎖解決方案2:粗略鎖定

要使用粗略鎖定 – 使用單個鎖來防止許多物件例項,甚至是程式的整個子系統。

  • 例如,我們可能對整個社交網路擁有一個鎖,並且對其任何組成部分的所有操作都在該鎖上進行同步。
  • 在程式碼中,所有的巫師都屬於一個城堡,我們只是使用該Castle物件的鎖來進行同步。

但是,它有明顯的效能損失。

  • 如果你用一個鎖保護大量可變資料,那麼你就放棄了同時訪問任何資料的能力。
  • 在最糟糕的情況下,使用單個鎖來保護所有內容,您的程式可能基本上是順序的。

(2)飢餓

飢餓描述了執行緒無法獲得對共享資源的定期訪問並且無法取得進展的情況。

  • 當共享資源被“貪婪”執行緒長時間停用時,會發生這種情況。

例如,假設一個物件提供了一個經常需要很長時間才能返回的同步方法。

  • 如果一個執行緒頻繁地呼叫此方法,那麼其他執行緒也需要經常同步訪問同一物件。

因為其他執行緒鎖時間太長,一個執行緒長時間無法獲取其所需的資源訪問權(鎖),導致無法往下進行。

(3)活鎖

執行緒通常會響應另一個執行緒的動作而行動。
如果另一個執行緒的動作也是對另一個執行緒動作的響應,則可能導致活鎖。
與死鎖一樣,活鎖執行緒無法取得進一步進展。
但是,執行緒並未被阻止 – 他們只是忙於響應對方恢復工作。
這與兩個試圖在走廊上相互傳遞的人相當:

  • 阿爾方塞向左移動讓加斯頓通過,而加斯東向右移動讓阿爾方塞通過。
  • 看到他們仍然互相阻攔,阿爾方塞向右移動,而加斯東向左移動。他們仍然互相阻攔,所以……

(5)wait(),notify()和notifyAll()

保護塊

防護區塊:這樣的區塊首先輪詢一個必須為真的條件才能繼續。
假設,例如guardedJoy是一種方法,除非另一個執行緒設定了共享變數joy,否則該方法不能繼續。

  • 這種方法可以簡單地迴圈直到滿足條件,但是該迴圈是浪費的,因為它在等待時連續執行。 某些條件未得到滿足,所以一直在空迴圈檢測,直到條件被滿足。這是極大浪費。

wait(),notify()和notifyAll()

以下是針對任意Java物件o定義的:

  • o.wait():釋放o上的鎖,進入o的等待佇列並等待
  • o.notify():喚醒o的等待佇列中的一個執行緒
  • o.notifyAll():喚醒o的等待佇列中的所有執行緒

Object.wait()

Object.wait()會導致當前執行緒等待,直到另一個執行緒呼叫此物件的notify()方法或notifyAll()方法。換句話說,這個方法的行為就好像它只是執行呼叫wait(0)一樣。該操作使物件所處的阻塞/等待狀態,直到其他執行緒呼叫該物件的notify()操作

Object.notify()/ notifyAll()

Object.notify()喚醒正在等待該物件監視器的單個執行緒。如果任何執行緒正在等待這個物件,則選擇其中一個執行緒來喚醒。隨機選擇一個在該物件上呼叫等方法的執行緒,解除其阻塞狀態

  • 執行緒通過呼叫其中一個等待方法在物件的監視器上等待。
  • 在當前執行緒放棄對該物件的鎖定之前,喚醒的執行緒將無法繼續。
  • 喚醒的執行緒將以通常的方式與其他可能正在主動競爭的執行緒競爭對該物件進行同步;例如,被喚醒的執行緒在作為下一個執行緒來鎖定這個物件時沒有可靠的特權或缺點。

此方法只應由作為此物件監視器所有者的執行緒呼叫。
執行緒以三種方式之一成為物件監視器的所有者:

  • 通過執行該物件的同步例項方法。
  • 通過執行同步物件的同步語句的主體。
  • 對於Class型別的物件,通過執行該類的同步靜態方法。

在守衛塊中使用wait()

wait()的呼叫不會返回,直到另一個執行緒發出某個特殊事件可能發生的通知 – 儘管不一定是該執行緒正在等待的事件。
Object.wait()會導致當前執行緒等待,直到另一個執行緒呼叫此物件的notify()方法或notifyAll()方法。
當wait()被呼叫時,執行緒釋放鎖並暫停執行。
在將來的某個時間,另一個執行緒將獲得相同的鎖並呼叫Object.notifyAll(),通知所有等待該鎖的執行緒發生重要事件:
第二個執行緒釋放鎖定一段時間後,第一個執行緒重新獲取鎖定,並通過從等待的呼叫返回來恢復。

wait(),notify()和notifyAll()
呼叫物件o的方法的執行緒通常必須預先鎖定o:

如何制定安全性論據

回想一下:開發ADT的步驟
指定:定義操作(方法簽名和規約)。
測試:開發操作的測試用例。測試套件包含基於對操作的引數空間進行分割槽的測試策略。
代表:選擇一個代表。

  • 首先實現一個簡單的,強大的代表。
  • 寫下rep不變和抽象函式,並實現checkRep(),它在每個建構函式,生成器和增量器方法的末尾宣告瞭rep不變數。

+++同步

  • 說出你的代表是執行緒安全的。
  • 在你的類中作為註釋明確地寫下來,直接用rep不變數表示,以便維護者知道你是如何為類設計執行緒安全性的。

做一個安全論證

併發性很難測試和除錯!
所以如果你想讓自己和別人相信你的併發程式是正確的,最好的方法是明確地說明它沒有競爭,並且記下來。在程式碼中註釋的形式增加說明:該ADT採取了什麼設計決策來保證執行緒安全

  • 安全性引數需要對模組或程式中存在的所有執行緒及其使用的資料進行編目,並針對您使用的四種技術中的哪一種來防止每個資料物件或變數的競爭:監禁,不可變性,執行緒安全資料型別或同步。採取了四種方法中的哪一種?
  • 當你使用最後兩個時,你還需要爭辯說,對資料的所有訪問都是適當的原子
  • 也就是說,你所依賴的不變數不受交織威脅。如果是後兩種,還需考慮對資料的訪問都是原子的,不存在交錯

用於監禁的執行緒安全論證

因為您必須知道系統中存在哪些執行緒以及他們有權訪問哪些物件,因此在我們僅就資料型別進行爭論時,監禁通常不是一種選擇。 除非你知道執行緒訪問的所有資料,否則Confinement無法徹底保證執行緒安全

  • 如果資料型別建立了自己的一組執行緒,那麼您可以討論關於這些執行緒的監禁。
  • 否則,執行緒從外部進入,攜帶客戶端呼叫,並且資料型別可能無法保證哪些執行緒具有對什麼的引用。

因此,在這種情況下,Confinement不是一個有用的論證。

  • 通常我們在更高層次使用約束,討論整個系統,並論證為什麼我們不需要執行緒安全的某些模組或資料型別,因為它們不會通過設計線上程間共享。除非是在ADT內部建立的執行緒,可以清楚得知訪問資料有哪些

總結

併發程式設計的目標

併發程式是否可以避免bug?

我們關心三個屬性:

  • 安全。 併發程式是否滿足其不變數和規約? 訪問可變資料的競爭會威脅到安全。 安全問題:你能證明一些不好的事情從未發生過?
  • 活性。 程式是否繼續執行,並最終做你想做的事情,還是會陷入永遠等待事件永遠不會發生的地方? 你能證明最終會發生什麼好事嗎? 死鎖威脅到活性。
  • 公平。 併發模組具有處理能力以在計算上取得進展。 公平主要是OS執行緒排程器的問題,但是你可以通過設定執行緒優先順序來影響它。

實踐中的併發

在真正的專案中通常採用什麼策略?

  • 庫資料結構不使用同步(為單執行緒客戶端提供高效能,同時讓多執行緒客戶端在頂層新增鎖定)或監視器模式。
  • 具有許多部分的可變資料結構通常使用粗粒鎖定或執行緒約束。大多數圖形使用者介面工具包遵循以下方法之一,因為圖形使用者介面基本上是一個可變物件的大型可變樹。 Java Swing,圖形使用者介面工具包,使用執行緒約束。只有一個專用執行緒被允許訪問Swing的樹。其他執行緒必須將訊息傳遞到該專用執行緒才能訪問該樹。

安全失敗帶來虛假的安全感。生存失敗迫使你面對錯誤。有利於活躍而不是安全的誘惑。

  • 搜尋通常使用不可變的資料型別。多執行緒很容易,因為涉及的所有資料型別都是不可變的。不會有競爭或死鎖的風險。
  • 作業系統通常使用細粒度的鎖來獲得高效能,並使用鎖定順序來處理死鎖問題。
  • 資料庫使用與同步區域類似的事務來避免競爭條件,因為它們的影響是原子的,但它們不必獲取鎖定,儘管事務可能會失敗並在事件發生時被回滾。資料庫還可以管理鎖,並自動處理鎖定順序。將在資料庫系統課程中介紹。

總結

生成一個安全無漏洞,易於理解和可以隨時更改的併發程式需要仔細思考。

  • 只要你嘗試將它們固定下來,Heisenbugs就會消失,所以除錯根本不是實現正確執行緒安全程式碼的有效方法。
  • 執行緒可以以許多不同的方式交錯操作,即使是所有可能執行的一小部分,也永遠無法測試。

建立關於資料型別的執行緒安全引數,並在程式碼中記錄它們。
獲取一個鎖允許一個執行緒獨佔訪問該鎖保護的資料,強制其他執行緒阻塞 – 只要這些執行緒也試圖獲取同一個鎖。
監視器使用通過每種方法獲取的單個鎖來引用資料型別的代表。
獲取多個鎖造成的阻塞會造成死鎖的可能性。
什麼是併發程式設計?
程式,執行緒和時間片
交織和競爭條件
執行緒安全

  • 戰略1:監禁
  • 策略2:不可變性
  • 策略3:使用執行緒安全資料型別
  • 策略4:鎖定和同步

如何做安全論證

相關文章