在使用Go語言來編寫代碼時,遇到的一些問題總結一下
[參考文檔]:https://www.topgoer.com/%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B/goroutine.html
1. main()函數默認的Goroutine
場景再現:
今天在看到這個教程的時候,在自己的電腦上跑了一下示例的代碼。
發現了描述與我的執行結果不同的地方,官方文檔描述如下:
這一次的執行結果只打印了main goroutine done!,并沒有打印Hello Goroutine!。
但是我執行后的情況是,如圖:
可以看到,我最終的執行結果是都輸出了,而不是只輸出了
main goroutine done!
Why?
原因是——雖然 main() 函數中調用了 go hello(),主 goroutine 在打印完 main goroutine done! 后就會退出,但:
在主 goroutine 退出前,如果新啟動的 goroutine 有足夠的時間運行完,Hello Goroutine! 就會輸出。
上面這段代碼啟動了一個新 goroutine,但程序的執行是并發的,不是同步/阻塞的。
執行流程是:
go hello() 啟動了一個新 goroutine;fmt.Println("main goroutine done!") 被執行;如果此時 main() 返回前,新 goroutine 還沒來得及執行完,那它也會被強行終止;但如果它已經執行完了,就能看到打印的內容。
這兩句都成功輸出,是因為你的電腦配置比較好,執行速度非常快,新啟動的 goroutine 來得及在 main() 退出前完成打印。
正確做法:用 sync.WaitGroup 或 time.Sleep
2 . Go 協程(Goroutine)的兩個關鍵點
協程不能保證執行的順序,但是如果加了time.sleep的話,可以保障協程執行完畢
? Go 協程(Goroutine)的兩個關鍵點:
①. 協程是并發的,不能保證執行順序
go hello() 啟動后,什么時候運行是由 Go 調度器決定的。
主協程和子協程是“誰搶到 CPU 誰先跑”,誰先打印是不確定的。
所以:
go hello()
fmt.Println("main done")
有可能先打印 main done,也可能先打印 Hello,取決于當時調度情況。
② 加 time.Sleep() 可以“間接保障”子協程執行完
加 time.Sleep() 相當于強行讓主協程等一下,給子協程留時間執行完。
所以子協程通常會有時間執行完,看起來“像是被保障了執行”。
?但注意:time.Sleep() ≠ 可靠同步
雖然 time.Sleep() 很簡單,但它存在幾個問題:
問題點 | 說明 |
---|---|
? 不精準 | 你不知道子協程到底需要多少時間,sleep 多了浪費,少了又執行不完 |
? 不可擴展 | 如果你有多個協程,就很難 sleep 到合適的時間 |
? 適合臨時調試 | 用于演示或實驗是可以的 |
? 正確做法:用 sync.WaitGroup
import ("fmt""sync"
)func hello(wg *sync.WaitGroup) {fmt.Println("Hello Goroutine!")wg.Done() // 協程結束,通知 WaitGroup
}func main() {var wg sync.WaitGroupwg.Add(1) // 告訴 WaitGroup 等待 1 個協程go hello(&wg) // 啟動協程wg.Wait() // 等待所有協程結束fmt.Println("main goroutine done!")
}
這樣就能準確地等待協程執行完再退出,不用靠 sleep。
總結一句話:
time.Sleep() 是簡單粗暴的等待方式,可以在小程序中“湊合用”,但真正寫程序,用 sync.WaitGroup 等同步機制更穩、更準、更專業。
3.defer
3.1 defer使用對比
func hello(i int) {defer wg.Done() // goroutine結束就登記-1fmt.Println("Hello Goroutine!", i)
}
與
func hello(i int){fmt.Println("Hello Goroutine!",i)wg.Done()
}
//在 正常情況下的效果是一樣的:都會確保在 goroutine 執行完成后調用 wg.Done(),
//從而通知 WaitGroup,減少一個等待計數。
? defer wg.Done() 的優勢:
defer 會在 函數返回前自動執行,即使函數中間發生了 panic(未被恢復),defer 也會運行(前提是沒有讓程序直接崩潰)。
這意味著:
func hello(i int) {defer wg.Done()// 如果這里出現錯誤,也能保證 Done 會執行fmt.Println("Hello Goroutine!", i)
}
更安全、穩健,防止遺漏。
? 手動調用 wg.Done() 有風險:
func hello(i int) {fmt.Println("Hello Goroutine!", i)wg.Done()
}
如果你寫了更復雜的邏輯,中途 return 或 panic 了,wg.Done() 可能根本執行不到,就會導致 wg.Wait() 永遠卡住。
? 總結:
寫法 | 是否推薦 | 原因 |
---|---|---|
defer wg.Done() | ? 推薦 | 更安全,即使中途出錯也能保證 Done 被調用 |
手動調用 wg.Done() | ?? 慎用 | 必須確保函數末尾一定能執行到,否則容易漏調用 |
所以你的判斷是對的:“兩種寫法是一樣的”,功能上是對的;但為了避免未來的問題,推薦使用 defer 寫法,更健壯、易維護。
3.2defer的作用、在Goroutine中的使用案例以及執行順序
3.2.1 defer的作用
? defer 的作用
defer 用于延遲一個函數的執行,直到外圍函數(即當前函數)返回之前才調用。
換句話說:
無論當前函數中發生了什么(正常結束或提前 return),defer 注冊的語句都會在函數結束前自動執行。
📌 舉個例子說明:
func demo() {fmt.Println("start")defer fmt.Println("this is defer")fmt.Println("end")
}
輸出結果是:
start
end
this is defer
defer fmt.Println(“this is defer”) 被延遲執行到 demo() 函數退出前的最后一刻。
? defer 的常見用途
用途 | 示例 | 說明 |
---|---|---|
釋放資源 | defer file.Close() | 防止文件忘記關閉 |
解鎖 | defer mu.Unlock() | 防止死鎖 |
記錄日志/退出操作 | defer log.Println("退出") | 確保函數末尾執行 |
Done 通知 | defer wg.Done() | 保證 goroutine 退出時減少計數 |
3.2.2 在Goroutine中的使用案例
package mainimport ("fmt""sync"
)var wg sync.WaitGroupfunc hello(i int) {defer wg.Done() // 必須在協程結束時調用 wg.Done() 通知 WaitGroup 協程結束fmt.Println("Hello Goroutine!", i)
}
func main() {for i := 0; i < 10; i++ {wg.Add(1) // 告訴 WaitGroup 等待 1 個協程go hello(i) // 啟動另外一個goroutine去執行hello函數}wg.Wait() // 等待所有的 goroutine 結束fmt.Println("main goroutine done!")
}
3.2.3 多個defer的執行順序
defer 在 Go 中的執行順序就是棧結構的“先進后出(LIFO)”原則:
🧠 defer 的執行順序:后注冊,先執行
也就是:
func demo() {defer fmt.Println("1")defer fmt.Println("2")defer fmt.Println("3")
}
🧾 輸出結果:
3
2
1
📦 原理解釋:
當你寫多個 defer 時,Go 會把它們壓入一個棧中,函數結束時就從棧頂往下依次執行這些 defer。
行號 | 執行內容 | 進入 defer 棧 |
---|---|---|
1 | defer fmt.Println("1") | 棧:[1] |
2 | defer fmt.Println("2") | 棧:[1, 2] |
3 | defer fmt.Println("3") | 棧:[1, 2, 3] |
return前執行 defer | 從棧頂開始彈出 | 輸出順序:3 → 2 → 1 |
? 常見用途
多個 defer 通常用于按順序清理資源,比如:
func handleFile() {f1, _ := os.Open("file1.txt")defer f1.Close() // 最后關閉f2, _ := os.Open("file2.txt")defer f2.Close() // 比 f1 先關閉fmt.Println("doing something...")
}
結果是 file2 會先關閉,然后 file1 再關閉 —— 和你打開文件的順序是相反的,這正是我們想要的行為(像“關門要倒著來
”一樣)。
? 總結:
特性 | 說明 |
---|---|
順序 | 多個 defer 是“后注冊,先執行”(LIFO) |
應用場景 | 清理資源(文件、鎖、連接等)、錯誤處理等 |
安全性 | 即使函數 return 或 panic ,也會執行 |
4.主協程和其他協程的關系,主協退出了,其他的協程還執行嗎?
代碼使用了官網提供的:
package mainimport ("fmt""time"
)func main() {// 合起來寫go func() {i := 0for {i++fmt.Printf("new goroutine: i = %d\n", i)time.Sleep(time.Second)}}()i := 0for {i++fmt.Printf("main goroutine: i = %d\n", i)time.Sleep(time.Second)if i == 2 {break}}
}
執行結果:
main goroutine: i = 1
new goroutine: i = 1
main goroutine: i = 2
new goroutine: i = 2
new goroutine: i = 3Process finished with the exit code 0
證明了主協程結束,其他線程不會再執行
5. java/c/c++線程與go協程的對比(與OS線程)
特性 | Java / C 的線程(OS Thread) | Go 的 goroutine |
---|---|---|
線程類型 | 操作系統線程(內核線程) | 用戶級線程(協程) |
線程模型 | 1:1 模型 | M:N 模型 |
調度者 | 操作系統 | Go 自帶的調度器(runtime) |
映射關系 | 每個語言線程對應一個 OS 線程 | 多個 goroutine 映射到多個 OS 線程 |
棧內存初始大小 | 通常 1MB~2MB(固定) | 起始約 2KB(可動態伸縮) |
創建成本 | 高(需要系統調用) | 極低(用戶態,幾乎無開銷) |
調度成本 | 高(內核態線程切換) | 低(用戶態線程切換) |
并發數量限制 | 一般幾千個 | 十萬甚至百萬級 |
適合場景 | 計算密集、高性能場景 | 高并發、大量 I/O 場景 |
常用語言API | std::thread , Thread | go myFunc() |
內存使用效率 | 相對較低 | 非常高 |
🔍 示例類比:
類比 | Java / C 的線程 | Go 的 goroutine |
---|---|---|
比喻 | 重型卡車:開銷大但能干活 | 自行車大軍:輕量且靈活 |
調度員 | 操作系統 | Go 自己的調度器 |
數量 | 幾千個已很吃力 | 十萬個都輕輕松松 |
? 圖示說明
Java / C => 1:1 線程模型
┌──────────┐ ┌──────────┐
│ Thread A │───────?│ OS 線程 A │
│ Thread B │───────?│ OS 線程 B │
└──────────┘ └──────────┘Go => M:N 線程模型
┌──────────────┐
│ goroutine 1 │
│ goroutine 2 │
│ goroutine 3 │──┐
│ goroutine 4 │ │
│ goroutine 5 │ ├──? 被 Go runtime 調度
│ goroutine N │──┘ 分配到 OS 線程 A/B/C…
└──────────────┘
? 總結一句話:
Java 和 C 的線程就是系統線程(1:1),重量級。
Go 的 goroutine 是用戶級線程,輕量可擴展(M:N),適合高并發。
6.并發與并行
6.1并發 vs 并行 示意圖
+-----------------------+ +-------------------------+
| 單核 CPU | | 多核 CPU |
| (1 個核心處理器核) | | (多個核心同時工作) |
+-----------------------+ +-------------------------+
| | | |
| ┌───────────────┐ | | ┌───────────────┐ |
| │ 核心 CPU 核 │ | | │ 核心 CPU 核 1 │ |
| │ │ | | ├───────────────┤ |
| │ 任務1 │ | | │ 任務1 (并行執行)│ |
| │任務2 (并發切換) │ ←──┐ | │任務2 (并行執行)│ |
| └───────────────┘ | | | └───────────────┘ |
| 任務1和任務2“交替”執行 | | | 多個任務“真正同時”執行 |
| | | | |
+-----------------------+ | +-------------------------+|并發(Concurrency) | 并行(Parallelism)任務切換看似同時進行 | 物理上真正同時執行
6.2Go 的 G-P-M 調度模型簡化示意
+----------------------------+
| G (Goroutine) | // 輕量級任務
+----------------------------+│▼
+----------------------------+
| P (Processor) | // 邏輯處理器(虛擬CPU核)
| 數量 = GOMAXPROCS 的值 |
+----------------------------+│▼
+----------------------------+
| M (Machine/線程) | // 真實的 OS 線程
+----------------------------+│▼真正執行代碼的 CPU 核心
你創建的很多 G (goroutine),由有限個 P(由 GOMAXPROCS 控制) 調度到 M(操作系統線程) 上執行。
當 GOMAXPROCS=1,只有一個 P,也就是說同一時刻只有一個 M 在執行 goroutine,表現為“并發”,但不是“并行”。
當 GOMAXPROCS>1,多個 P 和 M 允許 goroutine 并行運行在多個 CPU 核心上。
6.3舉個例子
🍳 并發(Concurrency):
就像一個廚師(單核 CPU),輪流做多個菜(任務)。他可能:
先把鍋燒熱,然后去切菜,再回來翻炒,接著煮湯……
雖然這些菜好像都在“同時準備”,但其實是一個人來回切換工作狀態完成的。
特點:
看起來同時,其實是輪流進行的本質上是 任務調度,而不是并行執行
👨?🍳👨?🍳 并行(Parallelism):
多個廚師(多核 CPU)在多個灶臺上,真正同時做多個菜(任務):
一人炒菜,一人煮湯,一人蒸飯……
特點:
真正同時執行每個任務由不同核心獨立運行
💡 套用到 Go 的場景里:
go func() 就像是安排一個菜讓廚師做runtime.GOMAXPROCS(1) 就是告訴廚房:“我只配一個廚師”(不管你有幾個灶臺)Go 的調度器就像廚房總管,會安排炒什么菜、先做哪個、后做哪個
7. 緩沖信道和無緩沖信道
🌟 一張表格對比
特性 | 無緩沖通道 (make(chan T) ) | 帶緩沖通道 (make(chan T, N) ) |
---|---|---|
是否有緩沖區 | ? 沒有,容量為 0 | ? 有,容量為 N(N ≥ 1) |
是否阻塞發送方 | ? 會,直到接收方準備好 | ? 不會,只要緩沖區沒滿 |
是否阻塞接收方 | ? 會,直到發送方發送 | ? 不會,只要緩沖區有數據 |
典型應用 | 同步通信,控制執行時機 | 異步通信,解耦發送與接收 |
性能 | 慢,但同步、控制精準 | 快,但可能增加復雜性 |
死鎖風險 | 高(若不成對出現) | 低(發送和接收可以脫鉤) |
是否 FIFO | ? 是 | ? 是 |
? 示例對比:無緩沖通道
package mainimport "fmt"func main() {ch := make(chan int) // 無緩沖通道go func() {val := <-chfmt.Println("接收成功:", val)}()ch <- 10 // 阻塞直到接收方準備好fmt.Println("發送成功")
}
執行順序:
接收成功: 10
發送成功
👉 注意:如果你把 發送(ch <- 10) 放在 go 啟動接收協程 之前,就會死鎖!
? 示例對比:帶緩沖通道
package mainimport "fmt"func main() {ch := make(chan int, 1) // 帶緩沖通道,容量為1ch <- 10 // ? 不阻塞fmt.Println("發送成功")go func() {val := <-chfmt.Println("接收成功:", val)}()
}
執行順序:
發送成功
接收成功: 10
👉 注意:即使接收方還沒準備好,發送也不會阻塞(只要緩沖區有空間)。
🧠 總結一句話:
無緩沖通道:必須“手遞手”,發送和接收必須同步發生。
帶緩沖通道:允許“放下再走”,先發送存儲,之后再接收。
🤔我的理解【ASK ChatGPT】
我的理解
無論是先發送還是先接收,都不應該主程序阻塞后續的程序,否則接收不到,或者發送不了。
無論先發送還是接收,都應該在下一步前建立一個協程來進行類似監聽的協程來阻塞協程這樣才能保證發送接收同步進行嗎?
無論是發送(val<-10),還是接收(<-val)都會阻塞當前的協程,我說的對嗎
GPT回答
? 你的原話分析:
?“無論是先發送還是先接收,都不應該主程序阻塞后續的程序,否則接收不到,或者發送不了。”
? 完全正確!
如果你在 main() 主線程中直接執行 val := <-ch 或 ch <- 10,主 goroutine 會阻塞,導致之后的 goroutine 根本不會被調度運行。
所以要讓 發送/接收那一方跑在 goroutine 中,才能讓另一方執行并完成通信。
?“無論先發送還是接收,都應該在下一步前建立一個協程來進行類似監聽的協程來阻塞協程這樣才能保證發送接收同步進行嗎?”
? 非常正確,表達也很清晰。
Go 的無緩沖通道通信是 兩個 goroutine 的同步配合行為。
不論誰先運行,都必須有 另一個 goroutine 在等待,通信才能發生。
? 舉個常用結構例子:
go func() {ch <- 10 // goroutine A 發送
}()go func() {val := <-ch // goroutine B 接收
}()
兩個 goroutine 各自阻塞在 send/recv 點,一旦兩者都就緒,通信立刻完成。
?“無論是發送(val <- 10),還是接收(<-val)都會阻塞當前的協程,我說的對嗎?”
? 完全正確!
操作 | 是否阻塞 | 說明 |
---|---|---|
ch <- 10 | ? 會阻塞 | 如果沒有協程在接收,就阻塞 |
<-ch | ? 會阻塞 | 如果沒有協程在發送,就阻塞 |
? 只要通道是無緩沖的,這兩個操作都必須等對方準備好才能繼續。
? 最核心的一句話總結:
無緩沖通道中的發送和接收,必須發生在兩個獨立的 goroutine 中,才能順利配對并完成同步。
8. channel關閉(close)
8.1 channel 關閉(close)≠ 銷毀
Go 里沒有顯式的“銷毀”channel操作,只有 關閉(close) 操作。
close(ch) 表示:
禁止再向通道寫入數據(寫入會 panic)可以繼續讀取通道中剩余的緩沖數據讀取到空了之后,再讀取會得到零值(對應類型的默認值)并返回 ok=false
8.2 關閉 channel 后為什么“只能讀取一次”?
通道關閉后,只能讀取到剩余的數據,讀取完就沒有了。你可以多次從關閉的 channel 讀取數據,直到讀完所有剩余數據。但是如果你用 for range ch 循環,循環會在讀完所有數據后自動結束。
重點:
for range ch 循環 只能執行一次,因為通道中的數據只會被消費一次,且通道關閉后不再接收新數據。
8.3 不關閉 channel,能讀多次嗎?
如果 不關閉 channel,就不能用 for range ch 這種方式循環讀取,因為它會阻塞等待新的數據,直到 channel 關閉。但你可以用手動讀取(val, ok := <-ch)不斷讀數據,只要有人往里面寫,就能持續接收。多個接收者從同一個 channel 讀取是“搶占式”的,數據會被分配給其中一個,不能保證所有接收者都能讀到相同數據。
8.4 總結
狀態 | 是否能寫入 | 是否能讀取多次 | 備注 |
---|---|---|---|
channel 已關閉 | 不允許(寫會 panic) | 可以讀取剩余數據,讀完返回零值+ok=false | 只能消費剩余數據,不能寫入 |
channel 未關閉 | 允許 | 可以多次讀取(取決于寫入情況) | 讀取會阻塞等待數據 |
- 舉個例子說明
package mainimport "fmt"func main() {ch := make(chan int, 3)ch <- 1ch <- 2ch <- 3close(ch) // 關閉通道for i := 0; i < 5; i++ {val, ok := <-chfmt.Println(val, ok)}
}
輸出:
1 true
2 true
3 true
0 false // 通道關閉且無剩余數據,返回類型零值,ok=false
0 false
如果你想多次“重新讀取”同一批數據,就需要把數據保存到 slice 或其他容器,而不是依賴 channel 本身,因為 channel 數據是一次性消費的。
9.匿名函數(內聯函數)
官方文檔的Goroutine池中提供了
package mainimport ("fmt""math/rand"
)type Job struct {// idId int// 需要計算的隨機數RandNum int
}type Result struct {// 這里必須傳對象實例job *Job// 求和sum int
}func main() {// 需要2個管道// 1.job管道jobChan := make(chan *Job, 128)// 2.結果管道resultChan := make(chan *Result, 128)// 3.創建工作池createPool(64, jobChan, resultChan)// 4.開個打印的協程go func(resultChan chan *Result) {// 遍歷結果管道打印for result := range resultChan {fmt.Printf("job id:%v randnum:%v result:%d\n", result.job.Id,result.job.RandNum, result.sum)}}(resultChan) //①匿名函數var id int// 循環創建job,輸入到管道for {id++// 生成隨機數r_num := rand.Int()job := &Job{Id: id,RandNum: r_num,}jobChan <- job}
}// 創建工作池
// 參數1:開幾個協程
func createPool(num int, jobChan chan *Job, resultChan chan *Result) {// 根據開協程個數,去跑運行for i := 0; i < num; i++ {go func(jobChan chan *Job, resultChan chan *Result) {// 執行運算// 遍歷job管道所有數據,進行相加for job := range jobChan {// 隨機數接過來r_num := job.RandNum// 隨機數每一位相加// 定義返回值var sum intfor r_num != 0 {tmp := r_num % 10sum += tmpr_num /= 10}// 想要的結果是Resultr := &Result{job: job,sum: sum,}//運算結果扔到管道resultChan <- r}}(jobChan, resultChan)//②匿名函數}
}
對②匿名函數解釋
(jobChan, resultChan)
是createPool(num int, jobChan chan *Job, resultChan chan *Result)
處對應的參數值,- 然后
(jobChan, resultChan)
又被當作參數傳入了go func(jobChan chan *Job, resultChan chan *Result)
用匿名函數的好處是 不需要額外寫一個函數名,邏輯上簡潔一些。
10.select處理多種類型事件例子
常見用法舉例
假設你有幾個不同類型的事件通過不同通道傳遞,你可以寫類似的代碼:
package mainimport ("fmt""time"
)func eventA(ch chan string) {time.Sleep(time.Second * 1)ch <- "Event A occurred"
}func eventB(ch chan int) {time.Sleep(time.Second * 2)ch <- 42
}func eventC(ch chan bool) {time.Sleep(time.Second * 3)ch <- true
}func main() {chA := make(chan string)chB := make(chan int)chC := make(chan bool)go eventA(chA)go eventB(chB)go eventC(chC)for i := 0; i < 3; i++ {select {case msg := <-chA:fmt.Println("Received from chA:", msg)case val := <-chB:fmt.Println("Received from chB:", val)case flag := <-chC:fmt.Println("Received from chC:", flag)}}
}
解釋
-
你啟動3個 goroutine,它們各自延遲不同時間后往對應通道寫入不同類型的事件數據。
-
主協程中用一個 select 監聽這3個通道。
-
每當任意一個通道有數據,就進入對應 case 執行,打印接收到的事件。
-
循環3次保證3個事件都能被處理。
重點總結
-
select 可以監聽任意多個通道。
-
每個 case 可以處理不同類型的數據。
-
只要某個通道準備好數據,select 就會立即觸發對應的 case。
-
select 本身不是“處理多種類型數據”的結構,但結合不同通道的類型可以做到這一點。