1. 程式人生 > >The Little Book of Semaphores 訊號量小書 第一章 簡介

The Little Book of Semaphores 訊號量小書 第一章 簡介

第一章 簡介

1.1 同步

通常,“同步”意味著兩件事情同時發生。在計算機系統中,同步更為通用;它指的是事件之間的關係 - 甚至是任何數量的事件,以及任何型別的關係(之前,期間,之後)。

計算機程式設計師經常關注同步約束,這是與事件順序有關的要求。例子包括:

  • 順序執行(序列化):事件A必須在事件B之前發生。
  • 相互排斥:事件A和事件B不得同時發生。

在現實生活中,我們經常使用時鐘來檢查並強制執行同步約束。我們如何知道A是否發生在B之前?如果我們知道兩個事件發生的時間,我們可以比較時間。

在計算機系統中,我們經常需要在沒有時鐘的情況下滿足同步約束,或者因為沒有統一的時鐘,或者因為我們在事件發生時不能記錄有足夠精確的解析度的時間。

這就是本書的內容:用於實施同步約束的軟體技術。

1.2 執行模型

為了理解軟體同步,您必須擁有計算機程式執行方式的模型。 在最簡單的模型中,計算機按順序執行一個接一個的指令。 在這個模型中,同步是微不足道的;我們可以通過檢視程式來判斷事件的順序。 如果語句A出現在語句B之前,A將先執行。

有兩種情況會讓事情變得複雜。一種情況是計算機是並行的,這意味著它有多個處理器在同時執行。在這種情況下,並不是很容易知道一個處理器上的某條語句是否在另一個處理器上的某條語句之前執行。

另一種情況是單處理器正在執行多個執行緒。執行緒是一系列順序執行的指令。如果有多個執行緒,處理器可以先執行某個執行緒一段時間,然後切換到另一個,依此類推。

通常,程式設計師無法控制每個執行緒何時執行;這個是作業系統(特別是排程程式)決定的。那麼,再重申一次,程式設計師無法判斷不同執行緒中的語句會在何時執行。

出於同步的考慮,多處理器並行模型和單處理器多執行緒模型之間並沒有區別。問題是相同的,即在一個處理器內(或一個執行緒內)我們能知道執行的順序,但是在處理器之間(或者執行緒之間)我們是不可能知道的。

舉一個現實世界的例子可能會使這一點更清楚。想象一下你和你的朋友鮑勃住在不同的城市,有一天吃飯的時候,你突然想知道那天中午你們誰先吃午飯。你怎麼才能知道?

顯然你可以打電話給他,問他什麼時候吃午飯。但是如果你在你的手錶顯示11點59分開始吃午飯,鮑勃在他的手錶顯示12點01分開始吃午飯,你能確定誰先吃嗎?除非你們的手錶都對過時,並且走時非常準。但不一定哦。

計算機系統面臨同樣的問題,因為即使他們的時鐘通常是準確的,它們的精度總是有限的。此外,大多數情況下,計算機無法記錄事情發生的時間。有太多事情發生得太快,無法記錄所有事件發生的確切的時間。

思考:假設鮑勃願意遵循簡單的指示,你有什麼方法可以保證明天你會比鮑勃先吃午飯嗎?

1.3使用訊息進行序列化

一種解決方案是告訴鮑勃在你打電話之前不要吃午飯。 然後,你確保在吃完午餐前不要打電話。 這種方法可能看起來毫無用處,但是,訊息傳遞卻是解決許多同步問題的真正方案。 冒著挑戰顯而易見的事實的危險,請考慮下面這個時間表。

第一列是您執行的操作列表; 換句話說,你的執行執行緒。 第二列是Bob的執行執行緒。 在一個執行緒中,我們總能確定事情發生的順序。 我們可以用下面的式子來表示事件發生的順序。

其中,關係a1 <a2表示a1發生在a2之前。

一般情況下,無法比較來自不同執行緒的事件;例如,我們不知道誰先吃早餐(是a1 <b1?)

但是通過訊息傳遞(電話)我們可以告訴誰先吃午餐(a3 <b3)。 假設Bob沒有其他朋友,除了你之外,他不會接到其他電話,所以b2 > a4。 結合所有的關係,我們得到:

這就能證明你在比鮑勃先吃午飯。

在這種情況下,我們會說你和鮑勃按順序地吃午餐,因為我們知道事件發生的順序。但你們可能是同時(併發)地吃早餐,因為我們不能確定誰先誰後。

當我們談論併發事件時,很可能會說它們在同一時間發生,或者說同時發生。 作為簡寫,這樣是沒問題的,只要你記住嚴格的定義:

如果我們不能通過檢視程式來判斷兩個事件誰將首先發生,那麼他們就是併發的。

有時,在程式執行之後,我們可以知道誰先發生了,但是,即便可以,通常也不能保證,下一次我們會得到同樣的結果。

1.4非決定論

併發程式通常是非確定性的,這意味著通過檢視程式不可能告訴它在執行時會發生什麼。

這是一個非確定性程式的簡單示例:

因為兩個執行緒併發執行,所以執行順序取決於排程程式。 在該程式的給定的任何執行期間,輸出可能是“yes no”或者“no yes”。

非確定性是使併發程式難以除錯的事情之一。程式可能連續1000次正常工作,然後在第1001次執行時崩潰,具體取決於排程程式的特定決策。

通過測試幾乎找不到這種型別的錯誤;只有仔細程式設計才能避免它。

1.5共享變數

大多數情況下,大多數執行緒中的大多數變數都是區域性變數,這意味著它們屬於單個執行緒,沒有其他執行緒可以訪問它們。只要能確保這一點,往往不會有太多的同步問題,因為執行緒之前沒有互動。

但通常一些變數在兩個或多個執行緒之間共享;這是執行緒相互互動的方式之一。例如,線上程之間傳遞資訊的一種方法是一個執行緒讀取另一個執行緒寫入的值。

如果執行緒是不同步的,那麼我們無法通過檢視程式來判斷,“讀者”讀到的是“寫者”寫入的新值還是已經存在的舊值(“讀者-寫者”問題)。因此,許多應用程式強制執行“讀者”在“寫者”寫入之前不能讀取的約束。這正是1.3節中的序列化問題。

執行緒互動的其他方式是併發寫入(兩個或多個寫入器)和併發更新(兩個或多個執行緒執行讀取後寫入)。接下來的兩節將討論這些相互作用。共享變數的另一種可能的用途是併發讀取,這通常不會產生同步問題。

1.5.1併發寫入

在以下示例中,x是由兩個寫入者訪問的共享變數。

列印的x的值是多少? 所有這些語句執行後,x的最終值是多少? 它取決於語句的執行順序,稱為執行路徑。 一條可能的路徑是a1 <a2 <b1,在這種情況下,程式的輸出為5,但最終值為7。

思考:什麼路徑會輸出5,並且x的最終值也是5?

思考:什麼路徑會輸出7,並且x的最終值是7?

思考:是否存在輸出7,並且x的最終值為5的路徑? 你能證明這個嗎?

回答這些問題是併發程式設計的一個重要部分:可能的路徑是什麼,可能產生的影響是什麼? 我們能否證明給定的(理想的)效果是必然的,或者(不良的)效果是不可能的?

1.5.2併發更新

更新是這樣的一種操作,它先讀取變數的值,根據舊值計算新值,並寫入新值。最常見的更新型別是增量運算,其中新值是舊值加1。 以下示例顯示了共享變數count,由兩個執行緒同時更新。

乍一看,這裡存在的同步問題並不明顯。只有兩個執行路徑,它們產生相同的結果。問題是這些操作在執行前會被翻譯成機器語言,而在機器語言中,更新需要兩個步驟,即讀取和寫入。 如果我們用臨時變數temp重寫程式碼,問題就更明顯了。

現在考慮以下執行路徑:

假設x的初始值為0,它的最終值是多少? 因為兩個執行緒讀取相同的初始值,所以它們寫入相同的值。變數只增加一次,這可能是程式設計師沒有想到的。

這種問題是微妙的,因為在高階程式中,並不總是能夠分辨出哪些操作是在一個步驟中執行而哪些操作可以被中斷。實際上,一些計算機提供了一個在硬體中實現並且不能被中斷的增量指令。不能被中斷的操作被認為是原子的。

那麼如果我們不知道哪些操作是原子的,我們如何編寫併發程式呢?一種可能性是收集關於每個硬體平臺上的每個操作的特定資訊。這種方法的缺點是顯而易見的。

最常見的替代方案是保守假設所有更新和所有寫入都不是原子的,並使用同步約束來控制對共享變數的併發訪問。

最常見的約束是相互排除,或者叫互斥,我在1.1節中提到過。互斥可確保一次只有一個執行緒訪問共享變數,從而消除了本節中的各種同步錯誤。

思考:假設100個執行緒同時執行以下程式:

(如果您不熟悉Python,for迴圈會執行100次更新。)

所有執行緒完成後,可能的最大計數值是多少?可能的最小值是多少?提示:第一個問題很簡單; 第二個不是。

1.5.3 使用訊息實現互斥

與序列化一樣,可以使用訊息傳遞實現互斥。 例如,假設您和Bob操作從遠端站監控的核反應堆。大多數時候,你們兩個都在看警示燈,但你們都可以休息一下去吃午餐。誰先吃午餐並不重要,但重要的是,你們不要同時吃午餐,不要讓反應堆掉下來!

思考:找出一個強制執行這些限制的訊息傳遞系統(電話呼叫)。假設沒有時鐘,你無法預測午餐何時開始或持續多長時間。 所需的最少的訊息數量是多少?