並發編程#
-
並發 是多線程程序在一個核的 cpu 上運行
-
並行 是多線程程序在多個核的上運行
-
Go 可以充分發揮多核優勢,高效運行
一個重要概念
協程#
- 協程的開銷比線程小,可以理解為輕量級的線程,一個 Go 程序中可以創建上萬個協程。
Go 中 開啟協程 非常簡單,在函數前面增加一個 go
關鍵字就可以為一個函數開啟一個協程。
CSP 與 Channel#
CSP(Communicating Sequential Process)
Go 中提倡通過 通信共享內存 而不是通過共享內存而實現通信
那麼如何通信呢,通過 channel
Channel#
語法: make(chan 元素類型, [緩衝大小])
- 無緩衝通道
make(chan int)
- 有緩衝通道
make(chan int, 2)
這個圖就非常的生動形象~
以下是一個例子:
- 第一個協程 作為生產者發送
0~9
到src
中 - 第二個協程 作為消費者計算
src
中每個數的平方發送到dest
中 - 主線程輸出
dest
中每個數
package main
func CalSquare() {
src := make(chan int) // 生產者
dest := make(chan int, 3) // 消費者 帶緩衝解決生產者太快的問題
go func() { // 該線程發送0~9至src中
defer close(src) // defer 表示延遲到函數結束時執行 用於釋放已分配的資源。
for i := 0; i < 10; i++ {
// <- 運算符 左側為收集數據的一方 右側為要傳的數據
src <- i
}
}() // 立即執行
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest {
// 其他複雜操作
println(i)
}
}
func main() {
CalSquare()
}
可以看到每次都會是順序輸出,代表著 Go 是 並發安全的
Go 語言也保留了共享內存的做法,使用 sync 進行同步,如下
package main
import (
"sync"
"time"
)
var (
x int64
lock sync.Mutex
)
func addWithLock() { // x加到2000 使用鎖則很安全
for i := 0; i < 2000; i++ {
lock.Lock() // 加鎖
x += 3
x -= 2
lock.Unlock() // 解鎖
}
}
func addWithoutLock() { // 不使用鎖
for i := 0; i < 2000; i++ {
x += 3
x -= 2
}
}
func Add() {
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second) // 休眠 1s
println("WithoutLock x =", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second) // 休眠 1s
println("WithLock x =", x)
}
func main() {
Add()
}
ps:試了好多次都沒衝突,樂。把運算稍微改複雜一點就有衝突了
依賴管理#
任何大型項目開發都繞不開依賴管理,Go 中的依賴主要經歷了 GOPATH -> Go Vendor -> Go Module 的演變 而現在主要採用 Go Module 的方式
- 不同環境依賴的版本不同,所以如何控制依賴庫的版本?
GOPATH#
- 項目代碼直接依賴 src 下的代碼
- 通過
go get
下載最新版本的包到 src 目錄下
這樣的話,就會出現一個問題:無法實現多版本的控制(A、B 依賴於同一個包的不同版本,寄)
Go Vender#
- 項目目錄下新增
vendor
文件,所有依賴包副本形式放在其中 - 通過 vendor => GOPATH 的方式曲線救國
ps:感覺挺像前端的 package.json…… 依賴問題真是繞不過去
這又產生了新的問題:
- 無法控制依賴的版本
- 更新項目時可能出現依賴衝突,從而導致編譯出錯
Go Module#
- 通過
go.mod
文件管理依賴包版本 - 通過
go get/go mod
指令工具管理依賴包
達成了終極目標:既能定義版本規則,又能管理項目依賴關係
可以類比一下 Java 中的 Maven
依賴配置 go.mod
#
依賴標識語法:模塊路徑 + 版本來進行唯一標識
[Module Path][Version/Pseudo-version]
module example/project/app 依賴管理基本單元
go 1.16 原生庫
require ( 單元依賴
example/lib1 v1.0.2
example/lib2 v1.0.0 // indirect
example/lib3 v0.1.0-20190725025543-5a5fe074e612
example/lib4 v0.0.0-20180306012644-bacd9c7ef1dd // indirect
example/lib5/v3 v3.0.2
example/lib6 v3.2.0+incompatible
)
如上,需要注意的是:
- 主版本 2 + 的模塊會在路徑後增加 /vN 後綴
- 對於沒有 go.mod 文件且主版本 2 + 的依賴,會
+incompatible
依賴的版本規則分為語義化版本和基於 commit 的伪版本
語義化版本#
格式為:${MAJOR}.${MINOR}.${PATCH}
V1.3.0、V2.3.0、 ……
- 不同的
MAJOR
版本表示是不兼容的 API- 即使是同一個庫,MAJOR 版本不同也會被認為是不同的模塊
MINOR
版本通常是新增函數或功能,向後兼容- 而
PATCH
版本一般是 修復bug
基於 commit 的版本#
格式為:${vx.0.0-yyyymmddhhmmss-abcdefgh1234}
- 版本前綴是和語義化版本一樣的
- 時間戳 (
yyyymmddhhmmss
),也就是提交Commit
的時間 - 校驗碼 (
abcdefgh1234
), 12 位的哈希前綴- 每次提交
commit
後 Go 都會默認生成一個伪版本號
- 每次提交
小測試#
- 如果 X 項目依賴了 A、B 兩個項目,且 A、B 分別依賴了 C 項目的 v1.3、v1.4 兩個版本,依賴圖如上,最終編譯時所使用的 C 項目的版本為 []{.gap} ? {.quiz}
- v1.3
- v1.4 {.correct}
- A 用到 c 時用 v1.3 編譯,B 用到 c 時用 v1.4 編譯
{.options}
答案為:B 選擇最低的兼容版本
這個是 Go 進行版本選擇的算法,選擇最低的兼容版本,而 1.4 版本是向下兼容 1.3 的(語義化版本)。為什麼不選 1.3 呢?因為他又不會向上兼容 ovo,倘若還有 1.5 的話則不會選用 1.5,因為 1.4 就是滿足要求的最低兼容版本。
依賴分發#
這些依賴去哪裡下載呢?就是依賴分發
在 github 等代碼托管系統中對應倉庫上下載?
github 是比較常見給的代碼托管系統平台,而Go Modules
系統中定義的依賴,最終可以對應到多版本代碼管理系統中某一項目的特定提交或版本
對於 go.mod
中定義的依賴,可以從對應倉庫中下載指定軟件依賴,從而完成依賴分發。
問題也有:
- 無法保證構建確定性
- 軟件作者直接修改軟件版本,導致下次構建使用其他版本的依賴,或者找不到依賴版本
- 無法保證依賴可用性
- 軟件作者直接代碼平台刪除軟件,導致依賴不可用
- 增加第三方代碼托管平台壓力。
通過 Proxy 方式來解決以上問題
Go Proxy
是一個服務站點,它會緩存源站中的軟件內容,緩存的軟件版本不會改變,並且在源站軟件刪除之後依然可用
使用 Go Proxy 之後,構建時會直接從 Go Proxy 站點拉取依賴。
Go Modules 通過 GOPROXY
環境變量控制如何使用 Go Proxy
服務站點 URL 列表,direct 表示源站:GOPROXY="https://proxy1.cn, https://proxy2.cn,direct"
- GOPROXY 是一個 Go Proxy 站點 URL 列表,可以使用
direct
表示源站。整體的依賴尋址路徑,會優先從proxy1
下載依賴,如果proxy1
不存在,就下到proxy2
尋找,如果proxy2
也不存在則會回源到源站直接下載依賴,緩存到proxy
站點中。
工具#
go get example.org/pkg
後綴 | 含義 |
---|---|
@update | 默認 |
@none | 刪除依賴 |
@v1.1.2 | tag 版本,語義版本 |
@23dfdd5 | 特定的 commit |
master | 分支的最新 commit |
go mod
後綴 | 含義 |
---|---|
init | 初始化,創建 go.mod 文件 |
download | 下載模炔到本地緩存 |
tidy | 增加需要的依賴,刪除不需要的依賴 |
go mod tidy 可以在每次提交代碼前執行一下,就可以減少構建整個項目的時間 |
測試#
測試一般分為回歸測試、集成測試、單元測試,從前到後覆蓋率逐層變大,成本卻逐層降低,所以單元測試的覆蓋率一定程度上決定這代碼的質量。
- 回歸測試一般是 QA 同學手動通過終端回歸一些固定的主流程場景
- 集成測試是對系統功能維度做測試驗證
- 單元測試測試開發階段,開發者對單獨的函數、模塊做功能驗證
單元測試主要包括:輸入、測試單元、輸出以及校對
單元的概念較廣,包括接口,函數,模塊等,用最後的校對來保證代碼的功能與我們的預期相符
單元測試有以下幾點好處
- 保證質量
- 整體覆蓋率足夠時下,既保證了新功能正確性,又未破壞原有代碼的正確性
- 提升效率
- 代碼有 bug 的情況下,通過單測,可以在一個較短週期內定位和修復問題
Go 中的單元測試有以下規則:
- 所有測試文件以
_test.go
結尾 func TestXxx(testing.T)
- 初始化邏輯放到
TestMain
函數中(測試前的數據裝載配置、測試後的釋放資源等)
例子:
main.go
package main
func HelloTom() string {
return "Jerry"
}
main_test.go
package main
import "testing"
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expect %s do not match actual %s", expectOutput, output)
}
}
在實際項目中,單測覆蓋率
- 一般項目的要求是 50%~60% 覆蓋率
- 對於重要的資金型服務,覆蓋率可能要求達到 80%
單測需要保證穩定性和幂等性
- 穩定是指相互隔離,能在任何時間,任何環境,運行測試
- 幂等是指每一次測試運行都應該產生與之前一樣的結果
而要實現這一目的就要用到mock
機制。
bouk/monkey: Monkey patching in Go
monkey 是一個開源的 mock 測試庫,可以對 method,或者實例的方法進行 mock,反射,指針賦值 Mockey Patch 的作用域在 Runtime,在運行時通過 Go 的 unsafe 包,能夠將內存中函數 A 的地址替換為運行時函數 B 的地址,將待打樁函數的實現跳轉。
Go 語言還提供了基準測試框架
- 基準測試是指測試一段程序的運行性能及耗費 CPU 的程度。
而我們在實際項目開發中,經常會遇到代碼性能瓶頸問題,為了定位問題經常要對代碼做性能分析,這就用到了基準測試。使用方法類似於單元測試
提到了
fastrand
,地址: bytedance/gopkg: Universal Utilities for Go
總結及心得#
本節課主要講了 Go 中的並發管理、依賴配置和測試,內容較多,需要好好消化。後面還有個項目實踐環節,等明天在進行一個實踐。
本節課內容來源於第三屆青訓營趙徵老師的課程