mirror of
https://github.com/gopl-zh/gopl-zh.github.com.git
synced 2025-12-17 11:14:20 +08:00
回到简体
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
## 9.1. 競爭條件
|
||||
## 9.1. 竞争条件
|
||||
|
||||
在一個線性(就是説隻有一個goroutine的)的程序中,程序的執行順序隻由程序的邏輯來決定。例如,我們有一段語句序列,第一個在第二個之前(廢話),以此類推。在有兩個或更多goroutine的程序中,每一個goroutine內的語句也是按照旣定的順序去執行的,但是一般情況下我們沒法去知道分别位於兩個goroutine的事件x和y的執行順序,x是在y之前還是之後還是同時發生是沒法判斷的。當我們能夠沒有辦法自信地確認一個事件是在另一個事件的前面或者後面發生的話,就説明x和y這兩個事件是併發的。
|
||||
在一个线性(就是说只有一个goroutine的)的程序中,程序的执行顺序只由程序的逻辑来决定。例如,我们有一段语句序列,第一个在第二个之前(废话),以此类推。在有两个或更多goroutine的程序中,每一个goroutine内的语句也是按照既定的顺序去执行的,但是一般情况下我们没法去知道分别位于两个goroutine的事件x和y的执行顺序,x是在y之前还是之后还是同时发生是没法判断的。当我们能够没有办法自信地确认一个事件是在另一个事件的前面或者后面发生的话,就说明x和y这两个事件是并发的。
|
||||
|
||||
考慮一下,一個函數在線性程序中可以正確地工作。如果在併發的情況下,這個函數依然可以正確地工作的話,那麽我們就説這個函數是併發安全的,併發安全的函數不需要額外的同步工作。我們可以把這個概念概括爲一個特定類型的一些方法和操作函數,如果這個類型是併發安全的話,那麽所有它的訪問方法和操作就都是併發安全的。
|
||||
考虑一下,一个函数在线性程序中可以正确地工作。如果在并发的情况下,这个函数依然可以正确地工作的话,那么我们就说这个函数是并发安全的,并发安全的函数不需要额外的同步工作。我们可以把这个概念概括为一个特定类型的一些方法和操作函数,如果这个类型是并发安全的话,那么所有它的访问方法和操作就都是并发安全的。
|
||||
|
||||
在一個程序中有非併發安全的類型的情況下,我們依然可以使這個程序併發安全。確實,併發安全的類型是例外,而不是規則,所以隻有當文檔中明確地説明了其是併發安全的情況下,你才可以併發地去訪問它。我們會避免併發訪問大多數的類型,無論是將變量局限在單一的一個goroutine內還是用互斥條件維持更高級别的不變性都是爲了這個目的。我們會在本章中説明這些術語。
|
||||
在一个程序中有非并发安全的类型的情况下,我们依然可以使这个程序并发安全。确实,并发安全的类型是例外,而不是规则,所以只有当文档中明确地说明了其是并发安全的情况下,你才可以并发地去访问它。我们会避免并发访问大多数的类型,无论是将变量局限在单一的一个goroutine内还是用互斥条件维持更高级别的不变性都是为了这个目的。我们会在本章中说明这些术语。
|
||||
|
||||
相反,導出包級别的函數一般情況下都是併發安全的。由於package級的變量沒法被限製在單一的gorouine,所以脩改這些變量“必須”使用互斥條件。
|
||||
相反,导出包级别的函数一般情况下都是并发安全的。由于package级的变量没法被限制在单一的gorouine,所以修改这些变量“必须”使用互斥条件。
|
||||
|
||||
一個函數在併發調用時沒法工作的原因太多了,比如死鎖(deadlock)、活鎖(livelock)和餓死(resource starvation)。我們沒有空去討論所有的問題,這里我們隻聚焦在競爭條件上。
|
||||
一个函数在并发调用时没法工作的原因太多了,比如死锁(deadlock)、活锁(livelock)和饿死(resource starvation)。我们没有空去讨论所有的问题,这里我们只聚焦在竞争条件上。
|
||||
|
||||
競爭條件指的是程序在多個goroutine交叉執行操作時,沒有給出正確的結果。競爭條件是很惡劣的一種場景,因爲這種問題會一直潛伏在你的程序里,然後在非常少見的時候蹦出來,或許隻是會在很大的負載時才會發生,又或許是會在使用了某一個編譯器、某一種平台或者某一種架構的時候才會出現。這些使得競爭條件帶來的問題非常難以複現而且難以分析診斷。
|
||||
竞争条件指的是程序在多个goroutine交叉执行操作时,没有给出正确的结果。竞争条件是很恶劣的一种场景,因为这种问题会一直潜伏在你的程序里,然后在非常少见的时候蹦出来,或许只是会在很大的负载时才会发生,又或许是会在使用了某一个编译器、某一种平台或者某一种架构的时候才会出现。这些使得竞争条件带来的问题非常难以复现而且难以分析诊断。
|
||||
|
||||
傳統上經常用經濟損失來爲競爭條件做比喻,所以我們來看一個簡單的銀行賬戶程序。
|
||||
传统上经常用经济损失来为竞争条件做比喻,所以我们来看一个简单的银行账户程序。
|
||||
|
||||
```go
|
||||
// Package bank implements a bank with only one account.
|
||||
@@ -22,9 +22,9 @@ func Deposit(amount int) { balance = balance + amount }
|
||||
func Balance() int { return balance }
|
||||
```
|
||||
|
||||
(當然我們也可以把Deposit存款函數寫成balance += amount,這種形式也是等價的,不過長一些的形式解釋起來更方便一些。)
|
||||
(当然我们也可以把Deposit存款函数写成balance += amount,这种形式也是等价的,不过长一些的形式解释起来更方便一些。)
|
||||
|
||||
對於這個具體的程序而言,我們可以瞅一眼各種存款和査餘額的順序調用,都能給出正確的結果。也就是説,Balance函數會給出之前的所有存入的額度之和。然而,當我們併發地而不是順序地調用這些函數的話,Balance就再也沒辦法保證結果正確了。考慮一下下面的兩個goroutine,其代表了一個銀行聯合賬戶的兩筆交易:
|
||||
对于这个具体的程序而言,我们可以瞅一眼各种存款和查余额的顺序调用,都能给出正确的结果。也就是说,Balance函数会给出之前的所有存入的额度之和。然而,当我们并发地而不是顺序地调用这些函数的话,Balance就再也没办法保证结果正确了。考虑一下下面的两个goroutine,其代表了一个银行联合账户的两笔交易:
|
||||
|
||||
```go
|
||||
// Alice:
|
||||
@@ -37,7 +37,7 @@ go func() {
|
||||
go bank.Deposit(100) // B
|
||||
```
|
||||
|
||||
Alice存了$200,然後檢査她的餘額,同時Bob存了$100。因爲A1和A2是和B併發執行的,我們沒法預測他們發生的先後順序。直觀地來看的話,我們會認爲其執行順序隻有三種可能性:“Alice先”,“Bob先”以及“Alice/Bob/Alice”交錯執行。下面的表格會展示經過每一步驟後balance變量的值。引號里的字符串表示餘額單。
|
||||
Alice存了$200,然后检查她的余额,同时Bob存了$100。因为A1和A2是和B并发执行的,我们没法预测他们发生的先后顺序。直观地来看的话,我们会认为其执行顺序只有三种可能性:“Alice先”,“Bob先”以及“Alice/Bob/Alice”交错执行。下面的表格会展示经过每一步骤后balance变量的值。引号里的字符串表示余额单。
|
||||
|
||||
```
|
||||
Alice first Bob first Alice/Bob/Alice
|
||||
@@ -47,9 +47,9 @@ A2 "=200" A1 300 B 300
|
||||
B 300 A2 "=300" A2 "=300"
|
||||
```
|
||||
|
||||
所有情況下最終的餘額都是$300。唯一的變數是Alice的餘額單是否包含了Bob交易,不過無論怎麽着客戶都不會在意。
|
||||
所有情况下最终的余额都是$300。唯一的变数是Alice的余额单是否包含了Bob交易,不过无论怎么着客户都不会在意。
|
||||
|
||||
但是事實是上面的直覺推斷是錯誤的。第四種可能的結果是事實存在的,這種情況下Bob的存款會在Alice存款操作中間,在餘額被讀到(balance + amount)之後,在餘額被更新之前(balance = ...),這樣會導致Bob的交易丟失。而這是因爲Alice的存款操作A1實際上是兩個操作的一個序列,讀取然後寫;可以稱之爲A1r和A1w。下面是交叉時産生的問題:
|
||||
但是事实是上面的直觉推断是错误的。第四种可能的结果是事实存在的,这种情况下Bob的存款会在Alice存款操作中间,在余额被读到(balance + amount)之后,在余额被更新之前(balance = ...),这样会导致Bob的交易丢失。而这是因为Alice的存款操作A1实际上是两个操作的一个序列,读取然后写;可以称之为A1r和A1w。下面是交叉时产生的问题:
|
||||
|
||||
```
|
||||
Data race
|
||||
@@ -60,11 +60,11 @@ A1w 200 balance = ...
|
||||
A2 "= 200"
|
||||
```
|
||||
|
||||
在A1r之後,balance + amount會被計算爲200,所以這是A1w會寫入的值,併不受其它存款操作的榦預。最終的餘額是$200。銀行的賬戶上的資産比Bob實際的資産多了$100。(譯註:因爲丟失了Bob的存款操作,所以其實是説Bob的錢丟了)
|
||||
在A1r之后,balance + amount会被计算为200,所以这是A1w会写入的值,并不受其它存款操作的干预。最终的余额是$200。银行的账户上的资产比Bob实际的资产多了$100。(译注:因为丢失了Bob的存款操作,所以其实是说Bob的钱丢了)
|
||||
|
||||
這個程序包含了一個特定的競爭條件,叫作數據競爭。無論任何時候,隻要有兩個goroutine併發訪問同一變量,且至少其中的一個是寫操作的時候就會發生數據競爭。
|
||||
这个程序包含了一个特定的竞争条件,叫作数据竞争。无论任何时候,只要有两个goroutine并发访问同一变量,且至少其中的一个是写操作的时候就会发生数据竞争。
|
||||
|
||||
如果數據競爭的對象是一個比一個機器字(譯註:32位機器上一個字=4個字節)更大的類型時,事情就變得更麻煩了,比如interface,string或者slice類型都是如此。下面的代碼會併發地更新兩個不同長度的slice:
|
||||
如果数据竞争的对象是一个比一个机器字(译注:32位机器上一个字=4个字节)更大的类型时,事情就变得更麻烦了,比如interface,string或者slice类型都是如此。下面的代码会并发地更新两个不同长度的slice:
|
||||
|
||||
```go
|
||||
var x []int
|
||||
@@ -73,13 +73,13 @@ go func() { x = make([]int, 1000000) }()
|
||||
x[999999] = 1 // NOTE: undefined behavior; memory corruption possible!
|
||||
```
|
||||
|
||||
最後一個語句中的x的值是未定義的;其可能是nil,或者也可能是一個長度爲10的slice,也可能是一個程度爲1,000,000的slice。但是迴憶一下slice的三個組成部分:指針(pointer)、長度(length)和容量(capacity)。如果指針是從第一個make調用來,而長度從第二個make來,x就變成了一個混合體,一個自稱長度爲1,000,000但實際上內部隻有10個元素的slice。這樣導致的結果是存儲999,999元素的位置會碰撞一個遙遠的內存位置,這種情況下難以對值進行預測,而且定位和debug也會變成噩夢。這種語義雷區被稱爲未定義行爲,對C程序員來説應該很熟悉;幸運的是在Go語言里造成的麻煩要比C里小得多。
|
||||
最后一个语句中的x的值是未定义的;其可能是nil,或者也可能是一个长度为10的slice,也可能是一个程度为1,000,000的slice。但是回忆一下slice的三个组成部分:指针(pointer)、长度(length)和容量(capacity)。如果指针是从第一个make调用来,而长度从第二个make来,x就变成了一个混合体,一个自称长度为1,000,000但实际上内部只有10个元素的slice。这样导致的结果是存储999,999元素的位置会碰撞一个遥远的内存位置,这种情况下难以对值进行预测,而且定位和debug也会变成噩梦。这种语义雷区被称为未定义行为,对C程序员来说应该很熟悉;幸运的是在Go语言里造成的麻烦要比C里小得多。
|
||||
|
||||
盡管併發程序的概念讓我們知道併發併不是簡單的語句交叉執行。我們將會在9.4節中看到,數據競爭可能會有奇怪的結果。許多程序員,甚至一些非常聰明的人也還是會偶爾提出一些理由來允許數據競爭,比如:“互斥條件代價太高”,“這個邏輯隻是用來做logging”,“我不介意丟失一些消息”等等。因爲在他們的編譯器或者平台上很少遇到問題,可能給了他們錯誤的信心。一個好的經驗法則是根本就沒有什麽所謂的良性數據競爭。所以我們一定要避免數據競爭,那麽在我們的程序中要如何做到呢?
|
||||
尽管并发程序的概念让我们知道并发并不是简单的语句交叉执行。我们将会在9.4节中看到,数据竞争可能会有奇怪的结果。许多程序员,甚至一些非常聪明的人也还是会偶尔提出一些理由来允许数据竞争,比如:“互斥条件代价太高”,“这个逻辑只是用来做logging”,“我不介意丢失一些消息”等等。因为在他们的编译器或者平台上很少遇到问题,可能给了他们错误的信心。一个好的经验法则是根本就没有什么所谓的良性数据竞争。所以我们一定要避免数据竞争,那么在我们的程序中要如何做到呢?
|
||||
|
||||
我們來重複一下數據競爭的定義,因爲實在太重要了:數據競爭會在兩個以上的goroutine併發訪問相同的變量且至少其中一個爲寫操作時發生。根據上述定義,有三種方式可以避免數據競爭:
|
||||
我们来重复一下数据竞争的定义,因为实在太重要了:数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生。根据上述定义,有三种方式可以避免数据竞争:
|
||||
|
||||
第一種方法是不要去寫變量。考慮一下下面的map,會被“懶”填充,也就是説在每個key被第一次請求到的時候才會去填值。如果Icon是被順序調用的話,這個程序會工作很正常,但如果Icon被併發調用,那麽對於這個map來説就會存在數據競爭。
|
||||
第一种方法是不要去写变量。考虑一下下面的map,会被“懒”填充,也就是说在每个key被第一次请求到的时候才会去填值。如果Icon是被顺序调用的话,这个程序会工作很正常,但如果Icon被并发调用,那么对于这个map来说就会存在数据竞争。
|
||||
|
||||
```go
|
||||
var icons = make(map[string]image.Image)
|
||||
@@ -96,7 +96,7 @@ func Icon(name string) image.Image {
|
||||
}
|
||||
```
|
||||
|
||||
反之,如果我們在創建goroutine之前的初始化階段,就初始化了map中的所有條目併且再也不去脩改它們,那麽任意數量的goroutine併發訪問Icon都是安全的,因爲每一個goroutine都隻是去讀取而已。
|
||||
反之,如果我们在创建goroutine之前的初始化阶段,就初始化了map中的所有条目并且再也不去修改它们,那么任意数量的goroutine并发访问Icon都是安全的,因为每一个goroutine都只是去读取而已。
|
||||
|
||||
```go
|
||||
var icons = map[string]image.Image{
|
||||
@@ -110,13 +110,13 @@ var icons = map[string]image.Image{
|
||||
func Icon(name string) image.Image { return icons[name] }
|
||||
```
|
||||
|
||||
上面的例子里icons變量在包初始化階段就已經被賦值了,包的初始化是在程序main函數開始執行之前就完成了的。隻要初始化完成了,icons就再也不會脩改的或者不變量是本來就併發安全的,這種變量不需要進行同步。不過顯然我們沒法用這種方法,因爲update操作是必要的操作,尤其對於銀行賬戶來説。
|
||||
上面的例子里icons变量在包初始化阶段就已经被赋值了,包的初始化是在程序main函数开始执行之前就完成了的。只要初始化完成了,icons就再也不会修改的或者不变量是本来就并发安全的,这种变量不需要进行同步。不过显然我们没法用这种方法,因为update操作是必要的操作,尤其对于银行账户来说。
|
||||
|
||||
第二種避免數據競爭的方法是,避免從多個goroutine訪問變量。這也是前一章中大多數程序所采用的方法。例如前面的併發web爬蟲(§8.6)的main goroutine是唯一一個能夠訪問seen map的goroutine,而聊天服務器(§8.10)中的broadcaster goroutine是唯一一個能夠訪問clients map的goroutine。這些變量都被限定在了一個單獨的goroutine中。
|
||||
第二种避免数据竞争的方法是,避免从多个goroutine访问变量。这也是前一章中大多数程序所采用的方法。例如前面的并发web爬虫(§8.6)的main goroutine是唯一一个能够访问seen map的goroutine,而聊天服务器(§8.10)中的broadcaster goroutine是唯一一个能够访问clients map的goroutine。这些变量都被限定在了一个单独的goroutine中。
|
||||
|
||||
由於其它的goroutine不能夠直接訪問變量,它們隻能使用一個channel來發送給指定的goroutine請求來査詢更新變量。這也就是Go的口頭禪“不要使用共享數據來通信;使用通信來共享數據”。一個提供對一個指定的變量通過cahnnel來請求的goroutine叫做這個變量的監控(monitor)goroutine。例如broadcaster goroutine會監控(monitor)clients map的全部訪問。
|
||||
由于其它的goroutine不能够直接访问变量,它们只能使用一个channel来发送给指定的goroutine请求来查询更新变量。这也就是Go的口头禅“不要使用共享数据来通信;使用通信来共享数据”。一个提供对一个指定的变量通过cahnnel来请求的goroutine叫做这个变量的监控(monitor)goroutine。例如broadcaster goroutine会监控(monitor)clients map的全部访问。
|
||||
|
||||
下面是一個重寫了的銀行的例子,這個例子中balance變量被限製在了monitor goroutine中,名爲teller:
|
||||
下面是一个重写了的银行的例子,这个例子中balance变量被限制在了monitor goroutine中,名为teller:
|
||||
|
||||
<u><i>gopl.io/ch9/bank1</i></u>
|
||||
```go
|
||||
@@ -145,9 +145,9 @@ func init() {
|
||||
}
|
||||
```
|
||||
|
||||
卽使當一個變量無法在其整個生命週期內被綁定到一個獨立的goroutine,綁定依然是併發問題的一個解決方案。例如在一條流水線上的goroutine之間共享變量是很普遍的行爲,在這兩者間會通過channel來傳輸地址信息。如果流水線的每一個階段都能夠避免在將變量傳送到下一階段時再去訪問它,那麽對這個變量的所有訪問就是線性的。其效果是變量會被綁定到流水線的一個階段,傳送完之後被綁定到下一個,以此類推。這種規則有時被稱爲串行綁定。
|
||||
即使当一个变量无法在其整个生命周期内被绑定到一个独立的goroutine,绑定依然是并发问题的一个解决方案。例如在一条流水线上的goroutine之间共享变量是很普遍的行为,在这两者间会通过channel来传输地址信息。如果流水线的每一个阶段都能够避免在将变量传送到下一阶段时再去访问它,那么对这个变量的所有访问就是线性的。其效果是变量会被绑定到流水线的一个阶段,传送完之后被绑定到下一个,以此类推。这种规则有时被称为串行绑定。
|
||||
|
||||
下面的例子中,Cakes會被嚴格地順序訪問,先是baker gorouine,然後是icer gorouine:
|
||||
下面的例子中,Cakes会被严格地顺序访问,先是baker gorouine,然后是icer gorouine:
|
||||
|
||||
```go
|
||||
type Cake struct{ state string }
|
||||
@@ -168,7 +168,7 @@ func icer(iced chan<- *Cake, cooked <-chan *Cake) {
|
||||
}
|
||||
```
|
||||
|
||||
第三種避免數據競爭的方法是允許很多goroutine去訪問變量,但是在同一個時刻最多隻有一個goroutine在訪問。這種方式被稱爲“互斥”,在下一節來討論這個主題。
|
||||
第三种避免数据竞争的方法是允许很多goroutine去访问变量,但是在同一个时刻最多只有一个goroutine在访问。这种方式被称为“互斥”,在下一节来讨论这个主题。
|
||||
|
||||
**練習 9.1:** 給gopl.io/ch9/bank1程序添加一個Withdraw(amount int)取款函數。其返迴結果應該要表明事務是成功了還是因爲沒有足夠資金失敗了。這條消息會被發送給monitor的goroutine,且消息需要包含取款的額度和一個新的channel,這個新channel會被monitor goroutine來把boolean結果發迴給Withdraw。
|
||||
**练习 9.1:** 给gopl.io/ch9/bank1程序添加一个Withdraw(amount int)取款函数。其返回结果应该要表明事务是成功了还是因为没有足够资金失败了。这条消息会被发送给monitor的goroutine,且消息需要包含取款的额度和一个新的channel,这个新channel会被monitor goroutine来把boolean结果发回给Withdraw。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## 9.2. sync.Mutex互斥鎖
|
||||
## 9.2. sync.Mutex互斥锁
|
||||
|
||||
在8.6節中,我們使用了一個buffered channel作爲一個計數信號量,來保證最多隻有20個goroutine會同時執行HTTP請求。同理,我們可以用一個容量隻有1的channel來保證最多隻有一個goroutine在同一時刻訪問一個共享變量。一個隻能爲1和0的信號量叫做二元信號量(binary semaphore)。
|
||||
在8.6节中,我们使用了一个buffered channel作为一个计数信号量,来保证最多只有20个goroutine会同时执行HTTP请求。同理,我们可以用一个容量只有1的channel来保证最多只有一个goroutine在同一时刻访问一个共享变量。一个只能为1和0的信号量叫做二元信号量(binary semaphore)。
|
||||
|
||||
<u><i>gopl.io/ch9/bank2</i></u>
|
||||
```go
|
||||
@@ -23,7 +23,7 @@ func Balance() int {
|
||||
}
|
||||
```
|
||||
|
||||
這種互斥很實用,而且被sync包里的Mutex類型直接支持。它的Lock方法能夠獲取到token(這里叫鎖),併且Unlock方法會釋放這個token:
|
||||
这种互斥很实用,而且被sync包里的Mutex类型直接支持。它的Lock方法能够获取到token(这里叫锁),并且Unlock方法会释放这个token:
|
||||
|
||||
<u><i>gopl.io/ch9/bank3</i></u>
|
||||
```go
|
||||
@@ -48,13 +48,13 @@ func Balance() int {
|
||||
}
|
||||
```
|
||||
|
||||
每次一個goroutine訪問bank變量時(這里隻有balance餘額變量),它都會調用mutex的Lock方法來獲取一個互斥鎖。如果其它的goroutine已經獲得了這個鎖的話,這個操作會被阻塞直到其它goroutine調用了Unlock使該鎖變迴可用狀態。mutex會保護共享變量。慣例來説,被mutex所保護的變量是在mutex變量聲明之後立刻聲明的。如果你的做法和慣例不符,確保在文檔里對你的做法進行説明。
|
||||
每次一个goroutine访问bank变量时(这里只有balance余额变量),它都会调用mutex的Lock方法来获取一个互斥锁。如果其它的goroutine已经获得了这个锁的话,这个操作会被阻塞直到其它goroutine调用了Unlock使该锁变回可用状态。mutex会保护共享变量。惯例来说,被mutex所保护的变量是在mutex变量声明之后立刻声明的。如果你的做法和惯例不符,确保在文档里对你的做法进行说明。
|
||||
|
||||
在Lock和Unlock之間的代碼段中的內容goroutine可以隨便讀取或者脩改,這個代碼段叫做臨界區。goroutine在結束後釋放鎖是必要的,無論以哪條路徑通過函數都需要釋放,卽使是在錯誤路徑中,也要記得釋放。
|
||||
在Lock和Unlock之间的代码段中的内容goroutine可以随便读取或者修改,这个代码段叫做临界区。goroutine在结束后释放锁是必要的,无论以哪条路径通过函数都需要释放,即使是在错误路径中,也要记得释放。
|
||||
|
||||
上面的bank程序例證了一種通用的併發模式。一繫列的導出函數封裝了一個或多個變量,那麽訪問這些變量唯一的方式就是通過這些函數來做(或者方法,對於一個對象的變量來説)。每一個函數在一開始就獲取互斥鎖併在最後釋放鎖,從而保證共享變量不會被併發訪問。這種函數、互斥鎖和變量的編排叫作監控monitor(這種老式單詞的monitor是受"monitor goroutine"的術語啟發而來的。兩種用法都是一個代理人保證變量被順序訪問)。
|
||||
上面的bank程序例证了一种通用的并发模式。一系列的导出函数封装了一个或多个变量,那么访问这些变量唯一的方式就是通过这些函数来做(或者方法,对于一个对象的变量来说)。每一个函数在一开始就获取互斥锁并在最后释放锁,从而保证共享变量不会被并发访问。这种函数、互斥锁和变量的编排叫作监控monitor(这种老式单词的monitor是受"monitor goroutine"的术语启发而来的。两种用法都是一个代理人保证变量被顺序访问)。
|
||||
|
||||
由於在存款和査詢餘額函數中的臨界區代碼這麽短--隻有一行,沒有分支調用--在代碼最後去調用Unlock就顯得更爲直截了當。在更複雜的臨界區的應用中,尤其是必須要盡早處理錯誤併返迴的情況下,就很難去(靠人)判斷對Lock和Unlock的調用是在所有路徑中都能夠嚴格配對的了。Go語言里的defer簡直就是這種情況下的救星:我們用defer來調用Unlock,臨界區會隱式地延伸到函數作用域的最後,這樣我們就從“總要記得在函數返迴之後或者發生錯誤返迴時要記得調用一次Unlock”這種狀態中獲得了解放。Go會自動幫我們完成這些事情。
|
||||
由于在存款和查询余额函数中的临界区代码这么短--只有一行,没有分支调用--在代码最后去调用Unlock就显得更为直截了当。在更复杂的临界区的应用中,尤其是必须要尽早处理错误并返回的情况下,就很难去(靠人)判断对Lock和Unlock的调用是在所有路径中都能够严格配对的了。Go语言里的defer简直就是这种情况下的救星:我们用defer来调用Unlock,临界区会隐式地延伸到函数作用域的最后,这样我们就从“总要记得在函数返回之后或者发生错误返回时要记得调用一次Unlock”这种状态中获得了解放。Go会自动帮我们完成这些事情。
|
||||
|
||||
```go
|
||||
func Balance() int {
|
||||
@@ -64,11 +64,11 @@ func Balance() int {
|
||||
}
|
||||
```
|
||||
|
||||
上面的例子里Unlock會在return語句讀取完balance的值之後執行,所以Balance函數是併發安全的。這帶來的另一點好處是,我們再也不需要一個本地變量b了。
|
||||
上面的例子里Unlock会在return语句读取完balance的值之后执行,所以Balance函数是并发安全的。这带来的另一点好处是,我们再也不需要一个本地变量b了。
|
||||
|
||||
此外,一個deferred Unlock卽使在臨界區發生panic時依然會執行,這對於用recover (§5.10)來恢複的程序來説是很重要的。defer調用隻會比顯式地調用Unlock成本高那麽一點點,不過卻在很大程度上保證了代碼的整潔性。大多數情況下對於併發程序來説,代碼的整潔性比過度的優化更重要。如果可能的話盡量使用defer來將臨界區擴展到函數的結束。
|
||||
此外,一个deferred Unlock即使在临界区发生panic时依然会执行,这对于用recover (§5.10)来恢复的程序来说是很重要的。defer调用只会比显式地调用Unlock成本高那么一点点,不过却在很大程度上保证了代码的整洁性。大多数情况下对于并发程序来说,代码的整洁性比过度的优化更重要。如果可能的话尽量使用defer来将临界区扩展到函数的结束。
|
||||
|
||||
考慮一下下面的Withdraw函數。成功的時候,它會正確地減掉餘額併返迴true。但如果銀行記録資金對交易來説不足,那麽取款就會恢複餘額,併返迴false。
|
||||
考虑一下下面的Withdraw函数。成功的时候,它会正确地减掉余额并返回true。但如果银行记录资金对交易来说不足,那么取款就会恢复余额,并返回false。
|
||||
|
||||
```go
|
||||
// NOTE: not atomic!
|
||||
@@ -82,9 +82,9 @@ func Withdraw(amount int) bool {
|
||||
}
|
||||
```
|
||||
|
||||
函數終於給出了正確的結果,但是還有一點討厭的副作用。當過多的取款操作同時執行時,balance可能會瞬時被減到0以下。這可能會引起一個併發的取款被不合邏輯地拒絶。所以如果Bob嚐試買一輛sports car時,Alice可能就沒辦法爲她的早咖啡付款了。這里的問題是取款不是一個原子操作:它包含了三個步驟,每一步都需要去獲取併釋放互斥鎖,但任何一次鎖都不會鎖上整個取款流程。
|
||||
函数终于给出了正确的结果,但是还有一点讨厌的副作用。当过多的取款操作同时执行时,balance可能会瞬时被减到0以下。这可能会引起一个并发的取款被不合逻辑地拒绝。所以如果Bob尝试买一辆sports car时,Alice可能就没办法为她的早咖啡付款了。这里的问题是取款不是一个原子操作:它包含了三个步骤,每一步都需要去获取并释放互斥锁,但任何一次锁都不会锁上整个取款流程。
|
||||
|
||||
理想情況下,取款應該隻在整個操作中獲得一次互斥鎖。下面這樣的嚐試是錯誤的:
|
||||
理想情况下,取款应该只在整个操作中获得一次互斥锁。下面这样的尝试是错误的:
|
||||
|
||||
```go
|
||||
// NOTE: incorrect!
|
||||
@@ -100,11 +100,11 @@ func Withdraw(amount int) bool {
|
||||
}
|
||||
```
|
||||
|
||||
上面這個例子中,Deposit會調用mu.Lock()第二次去獲取互斥鎖,但因爲mutex已經鎖上了,而無法被重入(譯註:go里沒有重入鎖,關於重入鎖的概念,請參考java)--也就是説沒法對一個已經鎖上的mutex來再次上鎖--這會導致程序死鎖,沒法繼續執行下去,Withdraw會永遠阻塞下去。
|
||||
上面这个例子中,Deposit会调用mu.Lock()第二次去获取互斥锁,但因为mutex已经锁上了,而无法被重入(译注:go里没有重入锁,关于重入锁的概念,请参考java)--也就是说没法对一个已经锁上的mutex来再次上锁--这会导致程序死锁,没法继续执行下去,Withdraw会永远阻塞下去。
|
||||
|
||||
關於Go的互斥量不能重入這一點我們有很充分的理由。互斥量的目的是爲了確保共享變量在程序執行時的關鍵點上能夠保證不變性。不變性的其中之一是“沒有goroutine訪問共享變量”。但實際上對於mutex保護的變量來説,不變性還包括其它方面。當一個goroutine獲得了一個互斥鎖時,它會斷定這種不變性能夠被保持。其獲取併保持鎖期間,可能會去更新共享變量,這樣不變性隻是短暫地被破壞。然而當其釋放鎖之後,它必須保證不變性已經恢複原樣。盡管一個可以重入的mutex也可以保證沒有其它的goroutine在訪問共享變量,但這種方式沒法保證這些變量額外的不變性。(譯註:這段翻譯有點暈)
|
||||
关于Go的互斥量不能重入这一点我们有很充分的理由。互斥量的目的是为了确保共享变量在程序执行时的关键点上能够保证不变性。不变性的其中之一是“没有goroutine访问共享变量”。但实际上对于mutex保护的变量来说,不变性还包括其它方面。当一个goroutine获得了一个互斥锁时,它会断定这种不变性能够被保持。其获取并保持锁期间,可能会去更新共享变量,这样不变性只是短暂地被破坏。然而当其释放锁之后,它必须保证不变性已经恢复原样。尽管一个可以重入的mutex也可以保证没有其它的goroutine在访问共享变量,但这种方式没法保证这些变量额外的不变性。(译注:这段翻译有点晕)
|
||||
|
||||
一個通用的解決方案是將一個函數分離爲多個函數,比如我們把Deposit分離成兩個:一個不導出的函數deposit,這個函數假設鎖總是會被保持併去做實際的操作,另一個是導出的函數Deposit,這個函數會調用deposit,但在調用前會先去獲取鎖。同理我們可以將Withdraw也表示成這種形式:
|
||||
一个通用的解决方案是将一个函数分离为多个函数,比如我们把Deposit分离成两个:一个不导出的函数deposit,这个函数假设锁总是会被保持并去做实际的操作,另一个是导出的函数Deposit,这个函数会调用deposit,但在调用前会先去获取锁。同理我们可以将Withdraw也表示成这种形式:
|
||||
|
||||
```go
|
||||
func Withdraw(amount int) bool {
|
||||
@@ -134,6 +134,6 @@ func Balance() int {
|
||||
func deposit(amount int) { balance += amount }
|
||||
```
|
||||
|
||||
當然,這里的存款deposit函數很小實際上取款withdraw函數不需要理會對它的調用,盡管如此,這里的表達還是表明了規則。
|
||||
当然,这里的存款deposit函数很小实际上取款withdraw函数不需要理会对它的调用,尽管如此,这里的表达还是表明了规则。
|
||||
|
||||
封裝(§6.6), 用限製一個程序中的意外交互的方式,可以使我們獲得數據結構的不變性。因爲某種原因,封裝還幫我們獲得了併發的不變性。當你使用mutex時,確保mutex和其保護的變量沒有被導出(在go里也就是小寫,且不要被大寫字母開頭的函數訪問啦),無論這些變量是包級的變量還是一個struct的字段。
|
||||
封装(§6.6), 用限制一个程序中的意外交互的方式,可以使我们获得数据结构的不变性。因为某种原因,封装还帮我们获得了并发的不变性。当你使用mutex时,确保mutex和其保护的变量没有被导出(在go里也就是小写,且不要被大写字母开头的函数访问啦),无论这些变量是包级的变量还是一个struct的字段。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
## 9.3. sync.RWMutex讀寫鎖
|
||||
## 9.3. sync.RWMutex读写锁
|
||||
|
||||
在100刀的存款消失時不做記録多少還是會讓我們有一些恐慌,Bob寫了一個程序,每秒運行幾百次來檢査他的銀行餘額。他會在家,在工作中,甚至會在他的手機上來運行這個程序。銀行註意到這些陡增的流量使得存款和取款有了延時,因爲所有的餘額査詢請求是順序執行的,這樣會互斥地獲得鎖,併且會暫時阻止其它的goroutine運行。
|
||||
在100刀的存款消失时不做记录多少还是会让我们有一些恐慌,Bob写了一个程序,每秒运行几百次来检查他的银行余额。他会在家,在工作中,甚至会在他的手机上来运行这个程序。银行注意到这些陡增的流量使得存款和取款有了延时,因为所有的余额查询请求是顺序执行的,这样会互斥地获得锁,并且会暂时阻止其它的goroutine运行。
|
||||
|
||||
由於Balance函數隻需要讀取變量的狀態,所以我們同時讓多個Balance調用併發運行事實上是安全的,隻要在運行的時候沒有存款或者取款操作就行。在這種場景下我們需要一種特殊類型的鎖,其允許多個隻讀操作併行執行,但寫操作會完全互斥。這種鎖叫作“多讀單寫”鎖(multiple readers, single writer lock),Go語言提供的這樣的鎖是sync.RWMutex:
|
||||
由于Balance函数只需要读取变量的状态,所以我们同时让多个Balance调用并发运行事实上是安全的,只要在运行的时候没有存款或者取款操作就行。在这种场景下我们需要一种特殊类型的锁,其允许多个只读操作并行执行,但写操作会完全互斥。这种锁叫作“多读单写”锁(multiple readers, single writer lock),Go语言提供的这样的锁是sync.RWMutex:
|
||||
|
||||
```go
|
||||
var mu sync.RWMutex
|
||||
@@ -14,11 +14,11 @@ func Balance() int {
|
||||
}
|
||||
```
|
||||
|
||||
Balance函數現在調用了RLock和RUnlock方法來獲取和釋放一個讀取或者共享鎖。Deposit函數沒有變化,會調用mu.Lock和mu.Unlock方法來獲取和釋放一個寫或互斥鎖。
|
||||
Balance函数现在调用了RLock和RUnlock方法来获取和释放一个读取或者共享锁。Deposit函数没有变化,会调用mu.Lock和mu.Unlock方法来获取和释放一个写或互斥锁。
|
||||
|
||||
在這次脩改後,Bob的餘額査詢請求就可以彼此併行地執行併且會很快地完成了。鎖在更多的時間范圍可用,併且存款請求也能夠及時地被響應了。
|
||||
在这次修改后,Bob的余额查询请求就可以彼此并行地执行并且会很快地完成了。锁在更多的时间范围可用,并且存款请求也能够及时地被响应了。
|
||||
|
||||
RLock隻能在臨界區共享變量沒有任何寫入操作時可用。一般來説,我們不應該假設邏輯上的隻讀函數/方法也不會去更新某一些變量。比如一個方法功能是訪問一個變量,但它也有可能會同時去給一個內部的計數器+1(譯註:可能是記録這個方法的訪問次數啥的),或者去更新緩存--使卽時的調用能夠更快。如果有疑惑的話,請使用互斥鎖。
|
||||
RLock只能在临界区共享变量没有任何写入操作时可用。一般来说,我们不应该假设逻辑上的只读函数/方法也不会去更新某一些变量。比如一个方法功能是访问一个变量,但它也有可能会同时去给一个内部的计数器+1(译注:可能是记录这个方法的访问次数啥的),或者去更新缓存--使即时的调用能够更快。如果有疑惑的话,请使用互斥锁。
|
||||
|
||||
RWMutex隻有當獲得鎖的大部分goroutine都是讀操作,而鎖在競爭條件下,也就是説,goroutine們必須等待才能獲取到鎖的時候,RWMutex才是最能帶來好處的。RWMutex需要更複雜的內部記録,所以會讓它比一般的無競爭鎖的mutex慢一些。
|
||||
RWMutex只有当获得锁的大部分goroutine都是读操作,而锁在竞争条件下,也就是说,goroutine们必须等待才能获取到锁的时候,RWMutex才是最能带来好处的。RWMutex需要更复杂的内部记录,所以会让它比一般的无竞争锁的mutex慢一些。
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
## 9.4. 內存同步
|
||||
## 9.4. 内存同步
|
||||
|
||||
你可能比較糾結爲什麽Balance方法需要用到互斥條件,無論是基於channel還是基於互斥量。畢竟和存款不一樣,它隻由一個簡單的操作組成,所以不會碰到其它goroutine在其執行"中"執行其它的邏輯的風險。這里使用mutex有兩方面考慮。第一Balance不會在其它操作比如Withdraw“中間”執行。第二(更重要)的是"同步"不僅僅是一堆goroutine執行順序的問題;同樣也會涉及到內存的問題。
|
||||
你可能比较纠结为什么Balance方法需要用到互斥条件,无论是基于channel还是基于互斥量。毕竟和存款不一样,它只由一个简单的操作组成,所以不会碰到其它goroutine在其执行"中"执行其它的逻辑的风险。这里使用mutex有两方面考虑。第一Balance不会在其它操作比如Withdraw“中间”执行。第二(更重要)的是"同步"不仅仅是一堆goroutine执行顺序的问题;同样也会涉及到内存的问题。
|
||||
|
||||
在現代計算機中可能會有一堆處理器,每一個都會有其本地緩存(local cache)。爲了效率,對內存的寫入一般會在每一個處理器中緩衝,併在必要時一起flush到主存。這種情況下這些數據可能會以與當初goroutine寫入順序不同的順序被提交到主存。像channel通信或者互斥量操作這樣的原語會使處理器將其聚集的寫入flush併commit,這樣goroutine在某個時間點上的執行結果才能被其它處理器上運行的goroutine得到。
|
||||
在现代计算机中可能会有一堆处理器,每一个都会有其本地缓存(local cache)。为了效率,对内存的写入一般会在每一个处理器中缓冲,并在必要时一起flush到主存。这种情况下这些数据可能会以与当初goroutine写入顺序不同的顺序被提交到主存。像channel通信或者互斥量操作这样的原语会使处理器将其聚集的写入flush并commit,这样goroutine在某个时间点上的执行结果才能被其它处理器上运行的goroutine得到。
|
||||
|
||||
考慮一下下面代碼片段的可能輸出:
|
||||
考虑一下下面代码片段的可能输出:
|
||||
|
||||
```go
|
||||
var x, y int
|
||||
@@ -18,7 +18,7 @@ go func() {
|
||||
}()
|
||||
```
|
||||
|
||||
因爲兩個goroutine是併發執行,併且訪問共享變量時也沒有互斥,會有數據競爭,所以程序的運行結果沒法預測的話也請不要驚訝。我們可能希望它能夠打印出下面這四種結果中的一種,相當於幾種不同的交錯執行時的情況:
|
||||
因为两个goroutine是并发执行,并且访问共享变量时也没有互斥,会有数据竞争,所以程序的运行结果没法预测的话也请不要惊讶。我们可能希望它能够打印出下面这四种结果中的一种,相当于几种不同的交错执行时的情况:
|
||||
|
||||
```
|
||||
y:0 x:1
|
||||
@@ -27,19 +27,19 @@ x:1 y:1
|
||||
y:1 x:1
|
||||
```
|
||||
|
||||
第四行可以被解釋爲執行順序A1,B1,A2,B2或者B1,A1,A2,B2的執行結果。
|
||||
然而實際的運行時還是有些情況讓我們有點驚訝:
|
||||
第四行可以被解释为执行顺序A1,B1,A2,B2或者B1,A1,A2,B2的执行结果。
|
||||
然而实际的运行时还是有些情况让我们有点惊讶:
|
||||
|
||||
```
|
||||
x:0 y:0
|
||||
y:0 x:0
|
||||
```
|
||||
|
||||
但是根據所使用的編譯器,CPU,或者其它很多影響因子,這兩種情況也是有可能發生的。那麽這兩種情況要怎麽解釋呢?
|
||||
但是根据所使用的编译器,CPU,或者其它很多影响因子,这两种情况也是有可能发生的。那么这两种情况要怎么解释呢?
|
||||
|
||||
在一個獨立的goroutine中,每一個語句的執行順序是可以被保證的;也就是説goroutine是順序連貫的。但是在不使用channel且不使用mutex這樣的顯式同步操作時,我們就沒法保證事件在不同的goroutine中看到的執行順序是一致的了。盡管goroutine A中一定需要觀察到x=1執行成功之後才會去讀取y,但它沒法確保自己觀察得到goroutine B中對y的寫入,所以A還可能會打印出y的一個舊版的值。
|
||||
在一个独立的goroutine中,每一个语句的执行顺序是可以被保证的;也就是说goroutine是顺序连贯的。但是在不使用channel且不使用mutex这样的显式同步操作时,我们就没法保证事件在不同的goroutine中看到的执行顺序是一致的了。尽管goroutine A中一定需要观察到x=1执行成功之后才会去读取y,但它没法确保自己观察得到goroutine B中对y的写入,所以A还可能会打印出y的一个旧版的值。
|
||||
|
||||
盡管去理解併發的一種嚐試是去將其運行理解爲不同goroutine語句的交錯執行,但看看上面的例子,這已經不是現代的編譯器和cpu的工作方式了。因爲賦值和打印指向不同的變量,編譯器可能會斷定兩條語句的順序不會影響執行結果,併且會交換兩個語句的執行順序。如果兩個goroutine在不同的CPU上執行,每一個核心有自己的緩存,這樣一個goroutine的寫入對於其它goroutine的Print,在主存同步之前就是不可見的了。
|
||||
尽管去理解并发的一种尝试是去将其运行理解为不同goroutine语句的交错执行,但看看上面的例子,这已经不是现代的编译器和cpu的工作方式了。因为赋值和打印指向不同的变量,编译器可能会断定两条语句的顺序不会影响执行结果,并且会交换两个语句的执行顺序。如果两个goroutine在不同的CPU上执行,每一个核心有自己的缓存,这样一个goroutine的写入对于其它goroutine的Print,在主存同步之前就是不可见的了。
|
||||
|
||||
所有併發的問題都可以用一致的、簡單的旣定的模式來規避。所以可能的話,將變量限定在goroutine內部;如果是多個goroutine都需要訪問的變量,使用互斥條件來訪問。
|
||||
所有并发的问题都可以用一致的、简单的既定的模式来规避。所以可能的话,将变量限定在goroutine内部;如果是多个goroutine都需要访问的变量,使用互斥条件来访问。
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
## 9.5. sync.Once初始化
|
||||
|
||||
如果初始化成本比較大的話,那麽將初始化延遲到需要的時候再去做就是一個比較好的選擇。如果在程序啟動的時候就去做這類的初始化的話會增加程序的啟動時間併且因爲執行的時候可能也併不需要這些變量所以實際上有一些浪費。讓我們在本章早一些時候看到的icons變量:
|
||||
如果初始化成本比较大的话,那么将初始化延迟到需要的时候再去做就是一个比较好的选择。如果在程序启动的时候就去做这类的初始化的话会增加程序的启动时间并且因为执行的时候可能也并不需要这些变量所以实际上有一些浪费。让我们在本章早一些时候看到的icons变量:
|
||||
|
||||
```go
|
||||
var icons map[string]image.Image
|
||||
```
|
||||
|
||||
這個版本的Icon用到了懶初始化(lazy initialization)。
|
||||
这个版本的Icon用到了懒初始化(lazy initialization)。
|
||||
|
||||
```go
|
||||
func loadIcons() {
|
||||
@@ -27,9 +27,9 @@ func Icon(name string) image.Image {
|
||||
}
|
||||
```
|
||||
|
||||
如果一個變量隻被一個單獨的goroutine所訪問的話,我們可以使用上面的這種模闆,但這種模闆在Icon被併發調用時併不安全。就像前面銀行的那個Deposit(存款)函數一樣,Icon函數也是由多個步驟組成的:首先測試icons是否爲空,然後load這些icons,之後將icons更新爲一個非空的值。直覺會告訴我們最差的情況是loadIcons函數被多次訪問會帶來數據競爭。當第一個goroutine在忙着loading這些icons的時候,另一個goroutine進入了Icon函數,發現變量是nil,然後也會調用loadIcons函數。
|
||||
如果一个变量只被一个单独的goroutine所访问的话,我们可以使用上面的这种模板,但这种模板在Icon被并发调用时并不安全。就像前面银行的那个Deposit(存款)函数一样,Icon函数也是由多个步骤组成的:首先测试icons是否为空,然后load这些icons,之后将icons更新为一个非空的值。直觉会告诉我们最差的情况是loadIcons函数被多次访问会带来数据竞争。当第一个goroutine在忙着loading这些icons的时候,另一个goroutine进入了Icon函数,发现变量是nil,然后也会调用loadIcons函数。
|
||||
|
||||
不過這種直覺是錯誤的。(我們希望現在你從現在開始能夠構建自己對併發的直覺,也就是説對併發的直覺總是不能被信任的!)迴憶一下9.4節。因爲缺少顯式的同步,編譯器和CPU是可以隨意地去更改訪問內存的指令順序,以任意方式,隻要保證每一個goroutine自己的執行順序一致。其中一種可能loadIcons的語句重排是下面這樣。它會在填寫icons變量的值之前先用一個空map來初始化icons變量。
|
||||
不过这种直觉是错误的。(我们希望现在你从现在开始能够构建自己对并发的直觉,也就是说对并发的直觉总是不能被信任的!)回忆一下9.4节。因为缺少显式的同步,编译器和CPU是可以随意地去更改访问内存的指令顺序,以任意方式,只要保证每一个goroutine自己的执行顺序一致。其中一种可能loadIcons的语句重排是下面这样。它会在填写icons变量的值之前先用一个空map来初始化icons变量。
|
||||
|
||||
```go
|
||||
func loadIcons() {
|
||||
@@ -41,9 +41,9 @@ func loadIcons() {
|
||||
}
|
||||
```
|
||||
|
||||
因此,一個goroutine在檢査icons是非空時,也併不能就假設這個變量的初始化流程已經走完了(譯註:可能隻是塞了個空map,里面的值還沒填完,也就是説填值的語句都沒執行完呢)。
|
||||
因此,一个goroutine在检查icons是非空时,也并不能就假设这个变量的初始化流程已经走完了(译注:可能只是塞了个空map,里面的值还没填完,也就是说填值的语句都没执行完呢)。
|
||||
|
||||
最簡單且正確的保證所有goroutine能夠觀察到loadIcons效果的方式,是用一個mutex來同步檢査。
|
||||
最简单且正确的保证所有goroutine能够观察到loadIcons效果的方式,是用一个mutex来同步检查。
|
||||
|
||||
```go
|
||||
var mu sync.Mutex // guards icons
|
||||
@@ -60,7 +60,7 @@ func Icon(name string) image.Image {
|
||||
}
|
||||
```
|
||||
|
||||
然而使用互斥訪問icons的代價就是沒有辦法對該變量進行併發訪問,卽使變量已經被初始化完畢且再也不會進行變動。這里我們可以引入一個允許多讀的鎖:
|
||||
然而使用互斥访问icons的代价就是没有办法对该变量进行并发访问,即使变量已经被初始化完毕且再也不会进行变动。这里我们可以引入一个允许多读的锁:
|
||||
|
||||
```go
|
||||
var mu sync.RWMutex // guards icons
|
||||
@@ -87,9 +87,9 @@ func Icon(name string) image.Image {
|
||||
```
|
||||
|
||||
|
||||
上面的代碼有兩個臨界區。goroutine首先會獲取一個寫鎖,査詢map,然後釋放鎖。如果條目被找到了(一般情況下),那麽會直接返迴。如果沒有找到,那goroutine會獲取一個寫鎖。不釋放共享鎖的話,也沒有任何辦法來將一個共享鎖陞級爲一個互斥鎖,所以我們必須重新檢査icons變量是否爲nil,以防止在執行這一段代碼的時候,icons變量已經被其它gorouine初始化過了。
|
||||
上面的代码有两个临界区。goroutine首先会获取一个写锁,查询map,然后释放锁。如果条目被找到了(一般情况下),那么会直接返回。如果没有找到,那goroutine会获取一个写锁。不释放共享锁的话,也没有任何办法来将一个共享锁升级为一个互斥锁,所以我们必须重新检查icons变量是否为nil,以防止在执行这一段代码的时候,icons变量已经被其它gorouine初始化过了。
|
||||
|
||||
上面的模闆使我們的程序能夠更好的併發,但是有一點太複雜且容易出錯。幸運的是,sync包爲我們提供了一個專門的方案來解決這種一次性初始化的問題:sync.Once。概念上來講,一次性的初始化需要一個互斥量mutex和一個boolean變量來記録初始化是不是已經完成了;互斥量用來保護boolean變量和客戶端數據結構。Do這個唯一的方法需要接收初始化函數作爲其參數。讓我們用sync.Once來簡化前面的Icon函數吧:
|
||||
上面的模板使我们的程序能够更好的并发,但是有一点太复杂且容易出错。幸运的是,sync包为我们提供了一个专门的方案来解决这种一次性初始化的问题:sync.Once。概念上来讲,一次性的初始化需要一个互斥量mutex和一个boolean变量来记录初始化是不是已经完成了;互斥量用来保护boolean变量和客户端数据结构。Do这个唯一的方法需要接收初始化函数作为其参数。让我们用sync.Once来简化前面的Icon函数吧:
|
||||
|
||||
```go
|
||||
var loadIconsOnce sync.Once
|
||||
@@ -101,6 +101,6 @@ func Icon(name string) image.Image {
|
||||
}
|
||||
```
|
||||
|
||||
每一次對Do(loadIcons)的調用都會鎖定mutex,併會檢査boolean變量。在第一次調用時,變量的值是false,Do會調用loadIcons併會將boolean設置爲true。隨後的調用什麽都不會做,但是mutex同步會保證loadIcons對內存(這里其實就是指icons變量啦)産生的效果能夠對所有goroutine可見。用這種方式來使用sync.Once的話,我們能夠避免在變量被構建完成之前和其它goroutine共享該變量。
|
||||
每一次对Do(loadIcons)的调用都会锁定mutex,并会检查boolean变量。在第一次调用时,变量的值是false,Do会调用loadIcons并会将boolean设置为true。随后的调用什么都不会做,但是mutex同步会保证loadIcons对内存(这里其实就是指icons变量啦)产生的效果能够对所有goroutine可见。用这种方式来使用sync.Once的话,我们能够避免在变量被构建完成之前和其它goroutine共享该变量。
|
||||
|
||||
**練習 9.2:** 重寫2.6.2節中的PopCount的例子,使用sync.Once,隻在第一次需要用到的時候進行初始化。(雖然實際上,對PopCount這樣很小且高度優化的函數進行同步可能代價沒法接受)
|
||||
**练习 9.2:** 重写2.6.2节中的PopCount的例子,使用sync.Once,只在第一次需要用到的时候进行初始化。(虽然实际上,对PopCount这样很小且高度优化的函数进行同步可能代价没法接受)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
## 9.6. 競爭條件檢測
|
||||
## 9.6. 竞争条件检测
|
||||
|
||||
卽使我們小心到不能再小心,但在併發程序中犯錯還是太容易了。幸運的是,Go的runtime和工具鏈爲我們裝備了一個複雜但好用的動態分析工具,競爭檢査器(the race detector)。
|
||||
即使我们小心到不能再小心,但在并发程序中犯错还是太容易了。幸运的是,Go的runtime和工具链为我们装备了一个复杂但好用的动态分析工具,竞争检查器(the race detector)。
|
||||
|
||||
隻要在go build,go run或者go test命令後面加上-race的flag,就會使編譯器創建一個你的應用的“脩改”版或者一個附帶了能夠記録所有運行期對共享變量訪問工具的test,併且會記録下每一個讀或者寫共享變量的goroutine的身份信息。另外,脩改版的程序會記録下所有的同步事件,比如go語句,channel操作,以及對(\*sync.Mutex).Lock,(\*sync.WaitGroup).Wait等等的調用。(完整的同步事件集合是在The Go Memory Model文檔中有説明,該文檔是和語言文檔放在一起的。譯註:https://golang.org/ref/mem)
|
||||
只要在go build,go run或者go test命令后面加上-race的flag,就会使编译器创建一个你的应用的“修改”版或者一个附带了能够记录所有运行期对共享变量访问工具的test,并且会记录下每一个读或者写共享变量的goroutine的身份信息。另外,修改版的程序会记录下所有的同步事件,比如go语句,channel操作,以及对(\*sync.Mutex).Lock,(\*sync.WaitGroup).Wait等等的调用。(完整的同步事件集合是在The Go Memory Model文档中有说明,该文档是和语言文档放在一起的。译注:https://golang.org/ref/mem)
|
||||
|
||||
競爭檢査器會檢査這些事件,會尋找在哪一個goroutine中出現了這樣的case,例如其讀或者寫了一個共享變量,這個共享變量是被另一個goroutine在沒有進行榦預同步操作便直接寫入的。這種情況也就表明了是對一個共享變量的併發訪問,卽數據競爭。這個工具會打印一份報告,內容包含變量身份,讀取和寫入的goroutine中活躍的函數的調用棧。這些信息在定位問題時通常很有用。9.7節中會有一個競爭檢査器的實戰樣例。
|
||||
竞争检查器会检查这些事件,会寻找在哪一个goroutine中出现了这样的case,例如其读或者写了一个共享变量,这个共享变量是被另一个goroutine在没有进行干预同步操作便直接写入的。这种情况也就表明了是对一个共享变量的并发访问,即数据竞争。这个工具会打印一份报告,内容包含变量身份,读取和写入的goroutine中活跃的函数的调用栈。这些信息在定位问题时通常很有用。9.7节中会有一个竞争检查器的实战样例。
|
||||
|
||||
競爭檢査器會報告所有的已經發生的數據競爭。然而,它隻能檢測到運行時的競爭條件;併不能證明之後不會發生數據競爭。所以爲了使結果盡量正確,請保證你的測試併發地覆蓋到了你到包。
|
||||
竞争检查器会报告所有的已经发生的数据竞争。然而,它只能检测到运行时的竞争条件;并不能证明之后不会发生数据竞争。所以为了使结果尽量正确,请保证你的测试并发地覆盖到了你到包。
|
||||
|
||||
由於需要額外的記録,因此構建時加了競爭檢測的程序跑起來會慢一些,且需要更大的內存,卽時是這樣,這些代價對於很多生産環境的工作來説還是可以接受的。對於一些偶發的競爭條件來説,讓競爭檢査器來榦活可以節省無數日夜的debugging。(譯註:多少服務端C和C艹程序員爲此盡摺腰)
|
||||
由于需要额外的记录,因此构建时加了竞争检测的程序跑起来会慢一些,且需要更大的内存,即时是这样,这些代价对于很多生产环境的工作来说还是可以接受的。对于一些偶发的竞争条件来说,让竞争检查器来干活可以节省无数日夜的debugging。(译注:多少服务端C和C艹程序员为此尽折腰)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
## 9.7. 示例: 併發的非阻塞緩存
|
||||
## 9.7. 示例: 并发的非阻塞缓存
|
||||
|
||||
本節中我們會做一個無阻塞的緩存,這種工具可以幫助我們來解決現實世界中併發程序出現但沒有現成的庫可以解決的問題。這個問題叫作緩存(memoizing)函數(譯註:Memoization的定義: memoization 一詞是Donald Michie 根據拉丁語memorandum杜撰的一個詞。相應的動詞、過去分詞、ing形式有memoiz、memoized、memoizing.),也就是説,我們需要緩存函數的返迴結果,這樣在對函數進行調用的時候,我們就隻需要一次計算,之後隻要返迴計算的結果就可以了。我們的解決方案會是併發安全且會避免對整個緩存加鎖而導致所有操作都去爭一個鎖的設計。
|
||||
本节中我们会做一个无阻塞的缓存,这种工具可以帮助我们来解决现实世界中并发程序出现但没有现成的库可以解决的问题。这个问题叫作缓存(memoizing)函数(译注:Memoization的定义: memoization 一词是Donald Michie 根据拉丁语memorandum杜撰的一个词。相应的动词、过去分词、ing形式有memoiz、memoized、memoizing.),也就是说,我们需要缓存函数的返回结果,这样在对函数进行调用的时候,我们就只需要一次计算,之后只要返回计算的结果就可以了。我们的解决方案会是并发安全且会避免对整个缓存加锁而导致所有操作都去争一个锁的设计。
|
||||
|
||||
我們將使用下面的httpGetBody函數作爲我們需要緩存的函數的一個樣例。這個函數會去進行HTTP GET請求併且獲取http響應body。對這個函數的調用本身開銷是比較大的,所以我們盡量盡量避免在不必要的時候反複調用。
|
||||
我们将使用下面的httpGetBody函数作为我们需要缓存的函数的一个样例。这个函数会去进行HTTP GET请求并且获取http响应body。对这个函数的调用本身开销是比较大的,所以我们尽量尽量避免在不必要的时候反复调用。
|
||||
|
||||
```go
|
||||
func httpGetBody(url string) (interface{}, error) {
|
||||
@@ -15,9 +15,9 @@ func httpGetBody(url string) (interface{}, error) {
|
||||
}
|
||||
```
|
||||
|
||||
最後一行稍微隱藏了一些細節。ReadAll會返迴兩個結果,一個[]byte數組和一個錯誤,不過這兩個對象可以被賦值給httpGetBody的返迴聲明里的interface{}和error類型,所以我們也就可以這樣返迴結果併且不需要額外的工作了。我們在httpGetBody中選用這種返迴類型是爲了使其可以與緩存匹配。
|
||||
最后一行稍微隐藏了一些细节。ReadAll会返回两个结果,一个[]byte数组和一个错误,不过这两个对象可以被赋值给httpGetBody的返回声明里的interface{}和error类型,所以我们也就可以这样返回结果并且不需要额外的工作了。我们在httpGetBody中选用这种返回类型是为了使其可以与缓存匹配。
|
||||
|
||||
下面是我們要設計的cache的第一個“草稿”:
|
||||
下面是我们要设计的cache的第一个“草稿”:
|
||||
|
||||
<u><i>gopl.io/ch9/memo1</i></u>
|
||||
```go
|
||||
@@ -54,9 +54,9 @@ func (memo *Memo) Get(key string) (interface{}, error) {
|
||||
}
|
||||
```
|
||||
|
||||
Memo實例會記録需要緩存的函數f(類型爲Func),以及緩存內容(里面是一個string到result映射的map)。每一個result都是都是簡單的函數返迴的值對兒--一個值和一個錯誤值。繼續下去我們會展示一些Memo的變種,不過所有的例子都會遵循這些上面的這些方面。
|
||||
Memo实例会记录需要缓存的函数f(类型为Func),以及缓存内容(里面是一个string到result映射的map)。每一个result都是都是简单的函数返回的值对儿--一个值和一个错误值。继续下去我们会展示一些Memo的变种,不过所有的例子都会遵循这些上面的这些方面。
|
||||
|
||||
下面是一個使用Memo的例子。對於流入的URL的每一個元素我們都會調用Get,併打印調用延時以及其返迴的數據大小的log:
|
||||
下面是一个使用Memo的例子。对于流入的URL的每一个元素我们都会调用Get,并打印调用延时以及其返回的数据大小的log:
|
||||
|
||||
```go
|
||||
m := memo.New(httpGetBody)
|
||||
@@ -71,7 +71,7 @@ for url := range incomingURLs() {
|
||||
}
|
||||
```
|
||||
|
||||
我們可以使用測試包(第11章的主題)來繫統地鑒定緩存的效果。從下面的測試輸出,我們可以看到URL流包含了一些重複的情況,盡管我們第一次對每一個URL的(\*Memo).Get的調用都會花上幾百毫秒,但第二次就隻需要花1毫秒就可以返迴完整的數據了。
|
||||
我们可以使用测试包(第11章的主题)来系统地鉴定缓存的效果。从下面的测试输出,我们可以看到URL流包含了一些重复的情况,尽管我们第一次对每一个URL的(\*Memo).Get的调用都会花上几百毫秒,但第二次就只需要花1毫秒就可以返回完整的数据了。
|
||||
|
||||
```
|
||||
$ go test -v gopl.io/ch9/memo1
|
||||
@@ -89,9 +89,9 @@ PASS
|
||||
ok gopl.io/ch9/memo1 1.257s
|
||||
```
|
||||
|
||||
這個測試是順序地去做所有的調用的。
|
||||
这个测试是顺序地去做所有的调用的。
|
||||
|
||||
由於這種彼此獨立的HTTP請求可以很好地併發,我們可以把這個測試改成併發形式。可以使用sync.WaitGroup來等待所有的請求都完成之後再返迴。
|
||||
由于这种彼此独立的HTTP请求可以很好地并发,我们可以把这个测试改成并发形式。可以使用sync.WaitGroup来等待所有的请求都完成之后再返回。
|
||||
|
||||
```go
|
||||
m := memo.New(httpGetBody)
|
||||
@@ -113,9 +113,9 @@ n.Wait()
|
||||
```
|
||||
|
||||
|
||||
這次測試跑起來更快了,然而不幸的是貌似這個測試不是每次都能夠正常工作。我們註意到有一些意料之外的cache miss(緩存未命中),或者命中了緩存但卻返迴了錯誤的值,或者甚至會直接崩潰。
|
||||
这次测试跑起来更快了,然而不幸的是貌似这个测试不是每次都能够正常工作。我们注意到有一些意料之外的cache miss(缓存未命中),或者命中了缓存但却返回了错误的值,或者甚至会直接崩溃。
|
||||
|
||||
但更糟糕的是,有時候這個程序還是能正確的運行(譯:也就是最讓人崩潰的偶發bug),所以我們甚至可能都不會意識到這個程序有bug。。但是我們可以使用-race這個flag來運行程序,競爭檢測器(§9.6)會打印像下面這樣的報告:
|
||||
但更糟糕的是,有时候这个程序还是能正确的运行(译:也就是最让人崩溃的偶发bug),所以我们甚至可能都不会意识到这个程序有bug。。但是我们可以使用-race这个flag来运行程序,竞争检测器(§9.6)会打印像下面这样的报告:
|
||||
|
||||
```
|
||||
$ go test -run=TestConcurrent -race -v gopl.io/ch9/memo1
|
||||
@@ -138,7 +138,7 @@ Found 1 data race(s)
|
||||
FAIL gopl.io/ch9/memo1 2.393s
|
||||
```
|
||||
|
||||
memo.go的32行出現了兩次,説明有兩個goroutine在沒有同步榦預的情況下更新了cache map。這表明Get不是併發安全的,存在數據競爭。
|
||||
memo.go的32行出现了两次,说明有两个goroutine在没有同步干预的情况下更新了cache map。这表明Get不是并发安全的,存在数据竞争。
|
||||
|
||||
```go
|
||||
28 func (memo *Memo) Get(key string) (interface{}, error) {
|
||||
@@ -151,7 +151,7 @@ memo.go的32行出現了兩次,説明有兩個goroutine在沒有同步榦預
|
||||
35 }
|
||||
```
|
||||
|
||||
最簡單的使cache併發安全的方式是使用基於監控的同步。隻要給Memo加上一個mutex,在Get的一開始獲取互斥鎖,return的時候釋放鎖,就可以讓cache的操作發生在臨界區內了:
|
||||
最简单的使cache并发安全的方式是使用基于监控的同步。只要给Memo加上一个mutex,在Get的一开始获取互斥锁,return的时候释放锁,就可以让cache的操作发生在临界区内了:
|
||||
|
||||
<u><i>gopl.io/ch9/memo2</i></u>
|
||||
```go
|
||||
@@ -177,9 +177,9 @@ func (memo *Memo) Get(key string) (value interface{}, err error) {
|
||||
}
|
||||
```
|
||||
|
||||
測試依然併發進行,但這迴競爭檢査器“沉默”了。不幸的是對於Memo的這一點改變使我們完全喪失了併發的性能優點。每次對f的調用期間都會持有鎖,Get將本來可以併行運行的I/O操作串行化了。我們本章的目的是完成一個無鎖緩存,而不是現在這樣的將所有請求串行化的函數的緩存。
|
||||
测试依然并发进行,但这回竞争检查器“沉默”了。不幸的是对于Memo的这一点改变使我们完全丧失了并发的性能优点。每次对f的调用期间都会持有锁,Get将本来可以并行运行的I/O操作串行化了。我们本章的目的是完成一个无锁缓存,而不是现在这样的将所有请求串行化的函数的缓存。
|
||||
|
||||
下一個Get的實現,調用Get的goroutine會兩次獲取鎖:査找階段獲取一次,如果査找沒有返迴任何內容,那麽進入更新階段會再次獲取。在這兩次獲取鎖的中間階段,其它goroutine可以隨意使用cache。
|
||||
下一个Get的实现,调用Get的goroutine会两次获取锁:查找阶段获取一次,如果查找没有返回任何内容,那么进入更新阶段会再次获取。在这两次获取锁的中间阶段,其它goroutine可以随意使用cache。
|
||||
|
||||
<u><i>gopl.io/ch9/memo3</i></u>
|
||||
```go
|
||||
@@ -200,9 +200,9 @@ func (memo *Memo) Get(key string) (value interface{}, err error) {
|
||||
}
|
||||
```
|
||||
|
||||
這些脩改使性能再次得到了提陞,但有一些URL被獲取了兩次。這種情況在兩個以上的goroutine同一時刻調用Get來請求同樣的URL時會發生。多個goroutine一起査詢cache,發現沒有值,然後一起調用f這個慢不拉嘰的函數。在得到結果後,也都會去去更新map。其中一個獲得的結果會覆蓋掉另一個的結果。
|
||||
这些修改使性能再次得到了提升,但有一些URL被获取了两次。这种情况在两个以上的goroutine同一时刻调用Get来请求同样的URL时会发生。多个goroutine一起查询cache,发现没有值,然后一起调用f这个慢不拉叽的函数。在得到结果后,也都会去去更新map。其中一个获得的结果会覆盖掉另一个的结果。
|
||||
|
||||
理想情況下是應該避免掉多餘的工作的。而這種“避免”工作一般被稱爲duplicate suppression(重複抑製/避免)。下面版本的Memo每一個map元素都是指向一個條目的指針。每一個條目包含對函數f調用結果的內容緩存。與之前不同的是這次entry還包含了一個叫ready的channel。在條目的結果被設置之後,這個channel就會被關閉,以向其它goroutine廣播(§8.9)去讀取該條目內的結果是安全的了。
|
||||
理想情况下是应该避免掉多余的工作的。而这种“避免”工作一般被称为duplicate suppression(重复抑制/避免)。下面版本的Memo每一个map元素都是指向一个条目的指针。每一个条目包含对函数f调用结果的内容缓存。与之前不同的是这次entry还包含了一个叫ready的channel。在条目的结果被设置之后,这个channel就会被关闭,以向其它goroutine广播(§8.9)去读取该条目内的结果是安全的了。
|
||||
|
||||
<u><i>gopl.io/ch9/memo4</i></u>
|
||||
```go
|
||||
@@ -245,17 +245,17 @@ func (memo *Memo) Get(key string) (value interface{}, err error) {
|
||||
}
|
||||
```
|
||||
|
||||
現在Get函數包括下面這些步驟了:獲取互斥鎖來保護共享變量cache map,査詢map中是否存在指定條目,如果沒有找到那麽分配空間插入一個新條目,釋放互斥鎖。如果存在條目的話且其值沒有寫入完成(也就是有其它的goroutine在調用f這個慢函數)時,goroutine必須等待值ready之後才能讀到條目的結果。而想知道是否ready的話,可以直接從ready channel中讀取,由於這個讀取操作在channel關閉之前一直是阻塞。
|
||||
现在Get函数包括下面这些步骤了:获取互斥锁来保护共享变量cache map,查询map中是否存在指定条目,如果没有找到那么分配空间插入一个新条目,释放互斥锁。如果存在条目的话且其值没有写入完成(也就是有其它的goroutine在调用f这个慢函数)时,goroutine必须等待值ready之后才能读到条目的结果。而想知道是否ready的话,可以直接从ready channel中读取,由于这个读取操作在channel关闭之前一直是阻塞。
|
||||
|
||||
如果沒有條目的話,需要向map中插入一個沒有ready的條目,當前正在調用的goroutine就需要負責調用慢函數、更新條目以及向其它所有goroutine廣播條目已經ready可讀的消息了。
|
||||
如果没有条目的话,需要向map中插入一个没有ready的条目,当前正在调用的goroutine就需要负责调用慢函数、更新条目以及向其它所有goroutine广播条目已经ready可读的消息了。
|
||||
|
||||
條目中的e.res.value和e.res.err變量是在多個goroutine之間共享的。創建條目的goroutine同時也會設置條目的值,其它goroutine在收到"ready"的廣播消息之後立刻會去讀取條目的值。盡管會被多個goroutine同時訪問,但卻併不需要互斥鎖。ready channel的關閉一定會發生在其它goroutine接收到廣播事件之前,因此第一個goroutine對這些變量的寫操作是一定發生在這些讀操作之前的。不會發生數據競爭。
|
||||
条目中的e.res.value和e.res.err变量是在多个goroutine之间共享的。创建条目的goroutine同时也会设置条目的值,其它goroutine在收到"ready"的广播消息之后立刻会去读取条目的值。尽管会被多个goroutine同时访问,但却并不需要互斥锁。ready channel的关闭一定会发生在其它goroutine接收到广播事件之前,因此第一个goroutine对这些变量的写操作是一定发生在这些读操作之前的。不会发生数据竞争。
|
||||
|
||||
這樣併發、不重複、無阻塞的cache就完成了。
|
||||
这样并发、不重复、无阻塞的cache就完成了。
|
||||
|
||||
上面這樣Memo的實現使用了一個互斥量來保護多個goroutine調用Get時的共享map變量。不妨把這種設計和前面提到的把map變量限製在一個單獨的monitor goroutine的方案做一些對比,後者在調用Get時需要發消息。
|
||||
上面这样Memo的实现使用了一个互斥量来保护多个goroutine调用Get时的共享map变量。不妨把这种设计和前面提到的把map变量限制在一个单独的monitor goroutine的方案做一些对比,后者在调用Get时需要发消息。
|
||||
|
||||
Func、result和entry的聲明和之前保持一致:
|
||||
Func、result和entry的声明和之前保持一致:
|
||||
|
||||
```go
|
||||
// Func is the type of the function to memoize.
|
||||
@@ -273,7 +273,7 @@ type entry struct {
|
||||
}
|
||||
```
|
||||
|
||||
然而Memo類型現在包含了一個叫做requests的channel,Get的調用方用這個channel來和monitor goroutine來通信。requests channel中的元素類型是request。Get的調用方會把這個結構中的兩組key都填充好,實際上用這兩個變量來對函數進行緩存的。另一個叫response的channel會被拿來發送響應結果。這個channel隻會傳迴一個單獨的值。
|
||||
然而Memo类型现在包含了一个叫做requests的channel,Get的调用方用这个channel来和monitor goroutine来通信。requests channel中的元素类型是request。Get的调用方会把这个结构中的两组key都填充好,实际上用这两个变量来对函数进行缓存的。另一个叫response的channel会被拿来发送响应结果。这个channel只会传回一个单独的值。
|
||||
|
||||
<u><i>gopl.io/ch9/memo5</i></u>
|
||||
```go
|
||||
@@ -301,9 +301,9 @@ func (memo *Memo) Get(key string) (interface{}, error) {
|
||||
func (memo *Memo) Close() { close(memo.requests) }
|
||||
```
|
||||
|
||||
上面的Get方法,會創建一個response channel,把它放進request結構中,然後發送給monitor goroutine,然後馬上又會接收到它。
|
||||
上面的Get方法,会创建一个response channel,把它放进request结构中,然后发送给monitor goroutine,然后马上又会接收到它。
|
||||
|
||||
cache變量被限製在了monitor goroutine (\*Memo).server中,下面會看到。monitor會在循環中一直讀取請求,直到request channel被Close方法關閉。每一個請求都會去査詢cache,如果沒有找到條目的話,那麽就會創建/插入一個新的條目。
|
||||
cache变量被限制在了monitor goroutine (\*Memo).server中,下面会看到。monitor会在循环中一直读取请求,直到request channel被Close方法关闭。每一个请求都会去查询cache,如果没有找到条目的话,那么就会创建/插入一个新的条目。
|
||||
|
||||
```go
|
||||
func (memo *Memo) server(f Func) {
|
||||
@@ -335,13 +335,13 @@ func (e *entry) deliver(response chan<- result) {
|
||||
}
|
||||
```
|
||||
|
||||
和基於互斥量的版本類似,第一個對某個key的請求需要負責去調用函數f併傳入這個key,將結果存在條目里,併關閉ready channel來廣播條目的ready消息。使用(\*entry).call來完成上述工作。
|
||||
和基于互斥量的版本类似,第一个对某个key的请求需要负责去调用函数f并传入这个key,将结果存在条目里,并关闭ready channel来广播条目的ready消息。使用(\*entry).call来完成上述工作。
|
||||
|
||||
緊接着對同一個key的請求會發現map中已經有了存在的條目,然後會等待結果變爲ready,併將結果從response發送給客戶端的goroutien。上述工作是用(\*entry).deliver來完成的。對call和deliver方法的調用必須在自己的goroutine中進行以確保monitor goroutines不會因此而被阻塞住而沒法處理新的請求。
|
||||
紧接着对同一个key的请求会发现map中已经有了存在的条目,然后会等待结果变为ready,并将结果从response发送给客户端的goroutien。上述工作是用(\*entry).deliver来完成的。对call和deliver方法的调用必须在自己的goroutine中进行以确保monitor goroutines不会因此而被阻塞住而没法处理新的请求。
|
||||
|
||||
這個例子説明我們無論可以用上鎖,還是通信來建立併發程序都是可行的。
|
||||
这个例子说明我们无论可以用上锁,还是通信来建立并发程序都是可行的。
|
||||
|
||||
上面的兩種方案併不好説特定情境下哪種更好,不過了解他們還是有價值的。有時候從一種方式切換到另一種可以使你的代碼更爲簡潔。(譯註:不是説好的golang推崇通信併發麽)
|
||||
上面的两种方案并不好说特定情境下哪种更好,不过了解他们还是有价值的。有时候从一种方式切换到另一种可以使你的代码更为简洁。(译注:不是说好的golang推崇通信并发么)
|
||||
|
||||
**練習 9.3:** 擴展Func類型和(\*Memo).Get方法,支持調用方提供一個可選的done channel,使其具備通過該channel來取消整個操作的能力(§8.9)。一個被取消了的Func的調用結果不應該被緩存。
|
||||
**练习 9.3:** 扩展Func类型和(\*Memo).Get方法,支持调用方提供一个可选的done channel,使其具备通过该channel来取消整个操作的能力(§8.9)。一个被取消了的Func的调用结果不应该被缓存。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
### 9.8.1. 動態棧
|
||||
### 9.8.1. 动态栈
|
||||
|
||||
每一個OS線程都有一個固定大小的內存塊(一般會是2MB)來做棧,這個棧會用來存儲當前正在被調用或掛起(指在調用其它函數時)的函數的內部變量。這個固定大小的棧同時很大又很小。因爲2MB的棧對於一個小小的goroutine來説是很大的內存浪費,比如對於我們用到的,一個隻是用來WaitGroup之後關閉channel的goroutine來説。而對於go程序來説,同時創建成百上韆個gorutine是非常普遍的,如果每一個goroutine都需要這麽大的棧的話,那這麽多的goroutine就不太可能了。除去大小的問題之外,固定大小的棧對於更複雜或者更深層次的遞歸函數調用來説顯然是不夠的。脩改固定的大小可以提陞空間的利用率允許創建更多的線程,併且可以允許更深的遞歸調用,不過這兩者是沒法同時兼備的。
|
||||
每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。这个固定大小的栈同时很大又很小。因为2MB的栈对于一个小小的goroutine来说是很大的内存浪费,比如对于我们用到的,一个只是用来WaitGroup之后关闭channel的goroutine来说。而对于go程序来说,同时创建成百上千个gorutine是非常普遍的,如果每一个goroutine都需要这么大的栈的话,那这么多的goroutine就不太可能了。除去大小的问题之外,固定大小的栈对于更复杂或者更深层次的递归函数调用来说显然是不够的。修改固定的大小可以提升空间的利用率允许创建更多的线程,并且可以允许更深的递归调用,不过这两者是没法同时兼备的。
|
||||
|
||||
相反,一個goroutine會以一個很小的棧開始其生命週期,一般隻需要2KB。一個goroutine的棧,和操作繫統線程一樣,會保存其活躍或掛起的函數調用的本地變量,但是和OS線程不太一樣的是一個goroutine的棧大小併不是固定的;棧的大小會根據需要動態地伸縮。而goroutine的棧的最大值有1GB,比傳統的固定大小的線程棧要大得多,盡管一般情況下,大多goroutine都不需要這麽大的棧。
|
||||
相反,一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。一个goroutine的栈,和操作系统线程一样,会保存其活跃或挂起的函数调用的本地变量,但是和OS线程不太一样的是一个goroutine的栈大小并不是固定的;栈的大小会根据需要动态地伸缩。而goroutine的栈的最大值有1GB,比传统的固定大小的线程栈要大得多,尽管一般情况下,大多goroutine都不需要这么大的栈。
|
||||
|
||||
練習 9.4: 創建一個流水線程序,支持用channel連接任意數量的goroutine,在跑爆內存之前,可以創建多少流水線階段?一個變量通過整個流水線需要用多久?(這個練習題翻譯不是很確定。。)
|
||||
练习 9.4: 创建一个流水线程序,支持用channel连接任意数量的goroutine,在跑爆内存之前,可以创建多少流水线阶段?一个变量通过整个流水线需要用多久?(这个练习题翻译不是很确定。。)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
### 9.8.2. Goroutine調度
|
||||
### 9.8.2. Goroutine调度
|
||||
|
||||
OS線程會被操作繫統內核調度。每幾毫秒,一個硬件計時器會中斷處理器,這會調用一個叫作scheduler的內核函數。這個函數會掛起當前執行的線程併保存內存中它的寄存器內容,檢査線程列表併決定下一次哪個線程可以被運行,併從內存中恢複該線程的寄存器信息,然後恢複執行該線程的現場併開始執行線程。因爲操作繫統線程是被內核所調度,所以從一個線程向另一個“移動”需要完整的上下文切換,也就是説,保存一個用戶線程的狀態到內存,恢複另一個線程的到寄存器,然後更新調度器的數據結構。這幾步操作很慢,因爲其局部性很差需要幾次內存訪問,併且會增加運行的cpu週期。
|
||||
OS线程会被操作系统内核调度。每几毫秒,一个硬件计时器会中断处理器,这会调用一个叫作scheduler的内核函数。这个函数会挂起当前执行的线程并保存内存中它的寄存器内容,检查线程列表并决定下一次哪个线程可以被运行,并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程。因为操作系统线程是被内核所调度,所以从一个线程向另一个“移动”需要完整的上下文切换,也就是说,保存一个用户线程的状态到内存,恢复另一个线程的到寄存器,然后更新调度器的数据结构。这几步操作很慢,因为其局部性很差需要几次内存访问,并且会增加运行的cpu周期。
|
||||
|
||||
Go的運行時包含了其自己的調度器,這個調度器使用了一些技術手段,比如m:n調度,因爲其會在n個操作繫統線程上多工(調度)m個goroutine。Go調度器的工作和內核的調度是相似的,但是這個調度器隻關註單獨的Go程序中的goroutine(譯註:按程序獨立)。
|
||||
Go的运行时包含了其自己的调度器,这个调度器使用了一些技术手段,比如m:n调度,因为其会在n个操作系统线程上多工(调度)m个goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的goroutine(译注:按程序独立)。
|
||||
|
||||
和操作繫統的線程調度不同的是,Go調度器併不是用一個硬件定時器而是被Go語言"建築"本身進行調度的。例如當一個goroutine調用了time.Sleep或者被channel調用或者mutex操作阻塞時,調度器會使其進入休眠併開始執行另一個goroutine直到時機到了再去喚醒第一個goroutine。因爲因爲這種調度方式不需要進入內核的上下文,所以重新調度一個goroutine比調度一個線程代價要低得多。
|
||||
和操作系统的线程调度不同的是,Go调度器并不是用一个硬件定时器而是被Go语言"建筑"本身进行调度的。例如当一个goroutine调用了time.Sleep或者被channel调用或者mutex操作阻塞时,调度器会使其进入休眠并开始执行另一个goroutine直到时机到了再去唤醒第一个goroutine。因为因为这种调度方式不需要进入内核的上下文,所以重新调度一个goroutine比调度一个线程代价要低得多。
|
||||
|
||||
練習 9.5: 寫一個有兩個goroutine的程序,兩個goroutine會向兩個無buffer channel反複地發送ping-pong消息。這樣的程序每秒可以支持多少次通信?
|
||||
练习 9.5: 写一个有两个goroutine的程序,两个goroutine会向两个无buffer channel反复地发送ping-pong消息。这样的程序每秒可以支持多少次通信?
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
### 9.8.3. GOMAXPROCS
|
||||
|
||||
Go的調度器使用了一個叫做GOMAXPROCS的變量來決定會有多少個操作繫統的線程同時執行Go的代碼。其默認的值是運行機器上的CPU的核心數,所以在一個有8個核心的機器上時,調度器一次會在8個OS線程上去調度GO代碼。(GOMAXPROCS是前面説的m:n調度中的n)。在休眠中的或者在通信中被阻塞的goroutine是不需要一個對應的線程來做調度的。在I/O中或繫統調用中或調用非Go語言函數時,是需要一個對應的操作繫統線程的,但是GOMAXPROCS併不需要將這幾種情況計數在內。
|
||||
Go的调度器使用了一个叫做GOMAXPROCS的变量来决定会有多少个操作系统的线程同时执行Go的代码。其默认的值是运行机器上的CPU的核心数,所以在一个有8个核心的机器上时,调度器一次会在8个OS线程上去调度GO代码。(GOMAXPROCS是前面说的m:n调度中的n)。在休眠中的或者在通信中被阻塞的goroutine是不需要一个对应的线程来做调度的。在I/O中或系统调用中或调用非Go语言函数时,是需要一个对应的操作系统线程的,但是GOMAXPROCS并不需要将这几种情况计数在内。
|
||||
|
||||
你可以用GOMAXPROCS的環境變量呂顯式地控製這個參數,或者也可以在運行時用runtime.GOMAXPROCS函數來脩改它。我們在下面的小程序中會看到GOMAXPROCS的效果,這個程序會無限打印0和1。
|
||||
你可以用GOMAXPROCS的环境变量吕显式地控制这个参数,或者也可以在运行时用runtime.GOMAXPROCS函数来修改它。我们在下面的小程序中会看到GOMAXPROCS的效果,这个程序会无限打印0和1。
|
||||
|
||||
|
||||
```go
|
||||
@@ -18,6 +18,6 @@ $ GOMAXPROCS=2 go run hacker-cliché.go
|
||||
010101010101010101011001100101011010010100110...
|
||||
```
|
||||
|
||||
在第一次執行時,最多同時隻能有一個goroutine被執行。初始情況下隻有main goroutine被執行,所以會打印很多1。過了一段時間後,GO調度器會將其置爲休眠,併喚醒另一個goroutine,這時候就開始打印很多0了,在打印的時候,goroutine是被調度到操作繫統線程上的。在第二次執行時,我們使用了兩個操作繫統線程,所以兩個goroutine可以一起被執行,以同樣的頻率交替打印0和1。我們必須強調的是goroutine的調度是受很多因子影響的,而runtime也是在不斷地發展演進的,所以這里的你實際得到的結果可能會因爲版本的不同而與我們運行的結果有所不同。
|
||||
在第一次执行时,最多同时只能有一个goroutine被执行。初始情况下只有main goroutine被执行,所以会打印很多1。过了一段时间后,GO调度器会将其置为休眠,并唤醒另一个goroutine,这时候就开始打印很多0了,在打印的时候,goroutine是被调度到操作系统线程上的。在第二次执行时,我们使用了两个操作系统线程,所以两个goroutine可以一起被执行,以同样的频率交替打印0和1。我们必须强调的是goroutine的调度是受很多因子影响的,而runtime也是在不断地发展演进的,所以这里的你实际得到的结果可能会因为版本的不同而与我们运行的结果有所不同。
|
||||
|
||||
練習9.6: 測試一下計算密集型的併發程序(練習8.5那樣的)會被GOMAXPROCS怎樣影響到。在你的電腦上最佳的值是多少?你的電腦CPU有多少個核心?
|
||||
练习9.6: 测试一下计算密集型的并发程序(练习8.5那样的)会被GOMAXPROCS怎样影响到。在你的电脑上最佳的值是多少?你的电脑CPU有多少个核心?
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
### 9.8.4. Goroutine沒有ID號
|
||||
### 9.8.4. Goroutine没有ID号
|
||||
|
||||
在大多數支持多線程的操作繫統和程序語言中,當前的線程都有一個獨特的身份(id),併且這個身份信息可以以一個普通值的形式被被很容易地獲取到,典型的可以是一個integer或者指針值。這種情況下我們做一個抽象化的thread-local storage(線程本地存儲,多線程編程中不希望其它線程訪問的內容)就很容易,隻需要以線程的id作爲key的一個map就可以解決問題,每一個線程以其id就能從中獲取到值,且和其它線程互不衝突。
|
||||
在大多数支持多线程的操作系统和程序语言中,当前的线程都有一个独特的身份(id),并且这个身份信息可以以一个普通值的形式被被很容易地获取到,典型的可以是一个integer或者指针值。这种情况下我们做一个抽象化的thread-local storage(线程本地存储,多线程编程中不希望其它线程访问的内容)就很容易,只需要以线程的id作为key的一个map就可以解决问题,每一个线程以其id就能从中获取到值,且和其它线程互不冲突。
|
||||
|
||||
goroutine沒有可以被程序員獲取到的身份(id)的概念。這一點是設計上故意而爲之,由於thread-local storage總是會被濫用。比如説,一個web server是用一種支持tls的語言實現的,而非常普遍的是很多函數會去尋找HTTP請求的信息,這代表它們就是去其存儲層(這個存儲層有可能是tls)査找的。這就像是那些過分依賴全局變量的程序一樣,會導致一種非健康的“距離外行爲”,在這種行爲下,一個函數的行爲可能不是由其自己內部的變量所決定,而是由其所運行在的線程所決定。因此,如果線程本身的身份會改變——比如一些worker線程之類的——那麽函數的行爲就會變得神祕莫測。
|
||||
goroutine没有可以被程序员获取到的身份(id)的概念。这一点是设计上故意而为之,由于thread-local storage总是会被滥用。比如说,一个web server是用一种支持tls的语言实现的,而非常普遍的是很多函数会去寻找HTTP请求的信息,这代表它们就是去其存储层(这个存储层有可能是tls)查找的。这就像是那些过分依赖全局变量的程序一样,会导致一种非健康的“距离外行为”,在这种行为下,一个函数的行为可能不是由其自己内部的变量所决定,而是由其所运行在的线程所决定。因此,如果线程本身的身份会改变——比如一些worker线程之类的——那么函数的行为就会变得神秘莫测。
|
||||
|
||||
Go鼓勵更爲簡單的模式,這種模式下參數對函數的影響都是顯式的。這樣不僅使程序變得更易讀,而且會讓我們自由地向一些給定的函數分配子任務時不用擔心其身份信息影響行爲。
|
||||
Go鼓励更为简单的模式,这种模式下参数对函数的影响都是显式的。这样不仅使程序变得更易读,而且会让我们自由地向一些给定的函数分配子任务时不用担心其身份信息影响行为。
|
||||
|
||||
你現在應該已經明白了寫一個Go程序所需要的所有語言特性信息。在後面兩章節中,我們會迴顧一些之前的實例和工具,支持我們寫出更大規模的程序:如何將一個工程組織成一繫列的包,如果獲取,構建,測試,性能測試,剖析,寫文檔,併且將這些包分享出去。
|
||||
你现在应该已经明白了写一个Go程序所需要的所有语言特性信息。在后面两章节中,我们会回顾一些之前的实例和工具,支持我们写出更大规模的程序:如何将一个工程组织成一系列的包,如果获取,构建,测试,性能测试,剖析,写文档,并且将这些包分享出去。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## 9.8. Goroutines和線程
|
||||
## 9.8. Goroutines和线程
|
||||
|
||||
在上一章中我們説goroutine和操作繫統的線程區别可以先忽略。盡管兩者的區别實際上隻是一個量的區别,但量變會引起質變的道理同樣適用於goroutine和線程。現在正是我們來區分開兩者的最佳時機。
|
||||
在上一章中我们说goroutine和操作系统的线程区别可以先忽略。尽管两者的区别实际上只是一个量的区别,但量变会引起质变的道理同样适用于goroutine和线程。现在正是我们来区分开两者的最佳时机。
|
||||
|
||||
{% include "./ch9-08-1.md" %}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 第九章 基於共享變量的併發
|
||||
# 第九章 基于共享变量的并发
|
||||
|
||||
前一章我們介紹了一些使用goroutine和channel這樣直接而自然的方式來實現併發的方法。然而這樣做我們實際上屏蔽掉了在寫併發代碼時必須處理的一些重要而且細微的問題。
|
||||
前一章我们介绍了一些使用goroutine和channel这样直接而自然的方式来实现并发的方法。然而这样做我们实际上屏蔽掉了在写并发代码时必须处理的一些重要而且细微的问题。
|
||||
|
||||
在本章中,我們會細致地了解併發機製。尤其是在多goroutine之間的共享變量,併發問題的分析手段,以及解決這些問題的基本模式。最後我們會解釋goroutine和操作繫統線程之間的技術上的一些區别。
|
||||
在本章中,我们会细致地了解并发机制。尤其是在多goroutine之间的共享变量,并发问题的分析手段,以及解决这些问题的基本模式。最后我们会解释goroutine和操作系统线程之间的技术上的一些区别。
|
||||
|
||||
Reference in New Issue
Block a user