一、并发编程概述
一般我们所写的代码都按照[顺序执行],也就是上一句代码执行完,才会执行下一句,这样的代码逻辑简单,也符合我们的阅读习惯。但这样是不够的,因为计算机很强大,如果只让它干完一件事情再干另外一件事情就太浪费了。比如一款音乐软件,使用它听音乐的时候还想让它下载歌曲,同一时刻做了两件事,在编程中,这就是并发,并发可以让你编写的程序在同一时刻做多几件事情。
Go语言采用的是 CSP(Communicating Sequential Processes,通信顺序进程) 并发模型,核心思想是通过通信来共享内存,而不是通过共享内存来通信。 Go 语言中并发编程的两大核心:Goroutine 和 Channel。它们是 Go 语言"天生并发"设计理念的基石。
- Goroutine:Go语言的轻量级线程,由Go运行时管理,启动成本低,适合高并发场景。
- Channel:用于goroutine之间的通信,支持同步和异步操作,避免数据竞争问题。
1.1 进程
在操作系统中,进程是一个非常重要的概念。当你启动一个软件(比如浏览器)的时候,操作系统会为这个软件创建一个进程,这个进程是该软件的工作空间,它包含了软件运行所需的所有资源,比如内存空间、文件句柄。
1.2 线程
线程是进程的执行空间,一个进程可以有多个线程,线程被操作系统调度执行,比如下载一个文件,发送一个消息等。这种多个线程被操作系统同时调度执行的情况,就是多线程的并发。
一个程序启动,就会有对应的进程被创建,同时进程也会启动一个线程,这个线程叫作主线程。如果主线程结束,那么整个程序就退出了。有了主线程,就可以从主线里启动很多其他线程,也就有了多线程的并发。
1.3 协程
Go 语言中没有线程的概念,只有协程,也称为 goroutine。相比线程来说,协程更加轻量,一个程序可以随意启动成千上万个 goroutine。
goroutine 被 Go runtime 所调度,这一点和线程不一样。也就是说,Go 语言的并发是由 Go 自己所调度的,自己决定同时执行多少个 goroutine,什么时候执行哪几个。这些对于我们开发者来说完全透明,只需要在编码的时候告诉 Go 语言要启动几个 goroutine,至于如何调度执行,我们不用关心。
1.4 Goroutine 与 Channel 的协同工作
特性 | Goroutine | Channel |
---|---|---|
角色 | 执行单元,是并发任务的载体。 | 通信机制,是 Goroutine 之间连接的管道。 |
目的 | 实现任务的并行执行。 | 实现 Goroutine 间的安全通信 和同步。 |
如何创建 | go function() |
ch := make(chan T) |
核心操作 | 函数执行 | ch <- value (发送), <-ch (接收), close(ch) (关闭) |
关系 :Goroutine 是生产者和消费者,Channel 是传送带。 多个 Goroutine 可以向同一个 Channel 发送数据,也可以从同一个 Channel 接收数据,从而构建出复杂的并发流程。
1.5 并发编程建议
- 避免数据竞争 : 使用channel或
sync.Mutex
保护共享数据。 - 合理使用Goroutine : 避免启动过多goroutine,可通过
sync.Pool
复用资源。 - 优雅退出 : 使用
context.Context
管理goroutine的生命周期。
二、 协程Goroutine:轻量级线程
Goroutine 是Go语言中并发执行的基本单位,可以把它想象成一个非常轻量级的"线程",由 Go 运行时管理,而不是操作系统。与操作系统线程相比,Goroutine 的创建成本极低,初始栈大小只有几KB,并且可以按需增长。一个 Go 程序可以轻松地创建成千上万甚至上百万个 Goroutine。Goroutine具有以下特点:
轻量级
:启动一个goroutine仅需几KB的栈空间,且栈空间可动态增长。高效调度
:由Go运行时调度,而非操作系统内核线程,支持大规模并发。
2.1 Goroutine 的声明与启动
启动一个 Goroutine 非常简单,只需要在函数调用前加上关键字 go
即可。
语法:
go
go function_name(arguments)
AI写代码go
运行
1
或者
go
go func(parameters) { ... }(arguments) // 启动一个匿名函数作为 Goroutine
AI写代码go
运行
1
核心特点:
- 非阻塞 :
go
关键字不会等待函数执行完毕,它会立即返回,程序继续向下执行。主 Goroutine(main
函数)不会等待其他 Goroutine 执行结束。 - 调度 :Goroutine 的调度由 Go 运行时在用户态完成,它会在多个操作系统线程上多路复用(Multiplex)Goroutine,实现高效的并发。
2.2 Goroutine 的使用案例
示例 1:基本使用
go
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello, Goroutine!")
}
func main() {
go sayHello() // 启动一个goroutine
time.Sleep(1 * time.Second) // 等待goroutine执行完成
fmt.Println("Main function")
}
AI写代码go
运行
12345678910111213
解析:
- 使用
go
关键字启动一个goroutine。 time.Sleep
用于等待goroutine执行完成,实际开发中建议使用sync.WaitGroup
或channel
来同步。
示例 2:基本使用
go
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond) // 模拟耗时操作
fmt.Println(s)
}
}
func main() {
// 启动一个新的 Goroutine 来执行 say 函数
go say("world")
// main 函数所在的 Goroutine 继续执行 say 函数
say("hello")
// 程序运行到这里可能会直接结束,因为 main Goroutine 不会等待 "world" Goroutine
// 在这个例子中,由于 "hello" 和 "world" 的循环次数和耗时相同,它们会交替执行。
// 如果我们把 main 中的 say 循环次数减少,就可能看不到 "world" 的全部输出。
}
AI写代码go
运行
1234567891011121314151617181920
可能的输出:
hello
world
hello
world
hello
world
hello
world
hello
AI写代码
123456789
注意 :main
函数本身也是一个 Goroutine,我们称之为主 Goroutine。当 main
函数执行完毕时,整个程序会立即退出,无论其他 Goroutine 是否已经执行完毕。这是初学者最常遇到的问题。
示例 3:等待 Goroutine 结束(使用 sync.WaitGroup
)
为了确保 main
函数能等待所有 Goroutine 执行完成,我们需要使用同步机制。sync.WaitGroup
是最常用的工具之一。
go
package main
import (
"fmt"
"sync"
"time"
)
// 使用 WaitGroup 来等待一组 Goroutine 完成
var wg sync.WaitGroup
func worker(id int) {
// 在函数退出时,通知 WaitGroup 这个 Goroutine 已经完成
defer wg.Done()
fmt.Printf("Worker %d starting\n", id)
time.Sleep(time.Second) // 模拟工作
fmt.Printf("Worker %d done\n", id)
}
func main() {
// 启动 3 个 worker Goroutine
for i := 1; i <= 3; i++ {
// 启动 Goroutine 前,增加 WaitGroup 的计数器
wg.Add(1)
go worker(i)
}
// 等待所有 Goroutine 完成(即 WaitGroup 计数器归零)
wg.Wait()
fmt.Println("All workers finished.")
}
AI写代码go
运行
1234567891011121314151617181920212223242526
输出:
bash
Worker 3 starting
Worker 1 starting
Worker 2 starting
(等待约1秒后...)
Worker 2 done
Worker 1 done
Worker 3 done
All workers finished.
AI写代码
12345678
sync.WaitGroup
的三个方法:
Add(int)
:增加计数器,表示需要等待的 Goroutine 数量。Done()
:减少计数器,通常在 Goroutine 的defer
语句中调用。Wait()
:阻塞,直到计数器归零。
三、 Channel:管道
如果说 Goroutine 是 Go 并发的"执行体",那么 Channel 就是它们之间的"通信管道"。Channel 允许一个 Goroutine 向另一个 Goroutine 发送类型化的值,从而实现安全的数据交换。Channel是Go语言中用于goroutine间通信的管道,分为以下两种:
- 无缓冲Channel:发送和接收操作会阻塞,直到另一方准备好。
- 有缓冲Channel:发送操作在缓冲区未满时不会阻塞,接收操作在缓冲区非空时不会阻塞。
Go 的并发哲学是: "不要通过共享内存来通信,而要通过通信来共享内存。
" Channel 就是这一哲学的完美体现。其实就是提倡通过 channel 发送接收消息的方式进行数据传递,而不是通过修改同一个变量。所以在数据流动、传递的场景中要优先使用 channel,它是并发安全的,性能也不错。
channel 为什么是并发安全的呢?是因为 channel 内部使用了互斥锁来保证并发的安全。
3.1 Channel 的声明与创建
语法:
go
var ch chan T // 声明一个元素类型为 T 的 Channel
ch = make(chan T) // 创建一个无缓冲 Channel
ch = make(chan T, capacity) // 创建一个缓冲 Channel,容量为 capacity
AI写代码go
运行
123
T
是 Channel 可以传递的数据类型,例如int
,string
,struct
等。make
是创建 Channel 的唯一方式。只声明不创建的 Channel 值为nil
,操作它会引发 panic。
3.2 Channel 的操作
Channel 有三个核心操作:发送、接收和关闭。
-
发送 :
ch <- value
- 将
value
发送到 Channelch
中。 - 如果 Channel 是无缓冲的,发送操作会阻塞,直到有另一个 Goroutine 从该 Channel 接收数据。
- 如果 Channel 是缓冲的且未满,发送操作会成功并将数据放入缓冲区;如果缓冲区已满,发送操作会阻塞,直到有空间可用。
- 将
-
接收 :
value := <-ch
- 从 Channel
ch
中接收一个数据,并将其赋值给value
。 - 如果 Channel 是无缓冲的,接收操作会阻塞,直到有另一个 Goroutine 向该 Channel 发送数据。
- 如果 Channel 是缓冲的且有数据,接收操作会立即成功;如果缓冲区为空,接收操作会阻塞,直到有数据可读。
value, ok := <-ch
:这是一种更安全的接收方式。如果 Channel 已被关闭且没有数据了,ok
会返回false
,可以用来判断 Channel 是否已关闭。
- 从 Channel
-
关闭 :
close(ch)
- 关闭一个 Channel。只有发送者才应该关闭 Channel,而不是接收者。
- 向一个已关闭的 Channel 发送数据会引发 panic。
- 从一个已关闭的 Channel 接收数据,会立即返回该 Channel 缓冲区中剩余的数据(如果是缓冲 Channel)。当数据全部接收完毕后,后续的接收操作会立即返回该元素类型的零值,而不会阻塞。
3.3 Channel 的使用示例
示例 1:无缓冲 Channel
go
package main
import "fmt"
func main() {
ch := make(chan int) // 创建无缓冲channel
go func() {
ch <- 42 // 发送数据到channel
}()
value := <-ch // 从channel接收数据
fmt.Println("Received:", value)
}
AI写代码go
运行
12345678910
解析:
- 发送操作
ch <- 42
会阻塞,直到有goroutine接收数据。 - 接收操作
<-ch
会阻塞,直到有数据发送到channel。
示例 2:无缓冲 Channel
无缓冲 Channel 也称为同步 Channel,因为发送和接收操作会同步进行,双方必须同时准备好才能完成数据传递。
go
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func main() {
wg.Add(1)
// 创建一个无缓冲的 string 类型 Channel
ch := make(chan string)
// 启动一个 Goroutine 作为接收者
go func() {
defer wg.Done()
// 从 ch 接收数据,这个操作会阻塞,直到 main Goroutine 发送数据
msg := <-ch
fmt.Println("Received:", msg)
}()
// main Goroutine 作为发送者
fmt.Println("Sending a message...")
ch <- "Hello, Channel!" // 这个操作会阻塞,直到接收者准备好
wg.Wait()
fmt.Println("Message sent and received.")
}
AI写代码go
运行
123456789101112131415161718192021222324
输出:
erlang
Sending a message...
Received: Hello, Channel!
Message sent and received.
AI写代码
123
执行流程:
main
Goroutine 启动接收者 Goroutine。- 接收者 Goroutine 执行到
msg := <-ch
,因为ch
为空,所以它阻塞。 main
Goroutine 继续执行,打印 "Sending a message..."。main
Goroutine 执行ch <- "Hello, Channel!"
。此时接收者已经准备好,所以数据被传递过去,发送操作完成,不再阻塞。- 接收者 Goroutine 被唤醒,接收到数据,打印 "Received: Hello, Channel!"。
- 接收者 Goroutine 调用
wg.Done()
。 main
Goroutine 在wg.Wait()
处不再阻塞,继续执行,打印 "Message sent and received."。
示例 3:缓冲 Channel
go
package main
import "fmt"
func main() {
ch := make(chan int, 2) // 创建缓冲大小为2的channel
ch <- 1
ch <- 2 // 缓冲区未满,不会阻塞
fmt.Println("Sent values to channel")
value := <-ch
fmt.Println("Received:", value)
}
AI写代码go
运行
12345678910
解析:
- 缓冲区大小为2,发送操作不会阻塞,直到缓冲区满。
- 接收操作会从缓冲区中取出数据。
示例 4:缓冲 Channel
缓冲 Channel 是一个异步 Channel,它有一个固定大小的"队列"。发送操作只有在队列满时才会阻塞,接收操作只有在队列空时才会阻塞。
go
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个缓冲容量为 2 的 Channel
ch := make(chan string, 2)
// 因为缓冲区未满,这两个发送操作都不会阻塞
ch <- "buffered"
fmt.Println("Sent 'buffered'")
ch <- "channel"
fmt.Println("Sent 'channel'")
// 启动一个 Goroutine 来接收数据
go func() {
time.Sleep(1 * time.Second) // 模拟处理延迟
msg1 := <-ch
fmt.Println("Received:", msg1)
}()
// 这个发送操作会阻塞,因为缓冲区已满,需要等待接收者取走一个数据
fmt.Println("Trying to send 'hello'...")
ch <- "hello" // 这里会阻塞约1秒
fmt.Println("Sent 'hello'")
// 接收剩余数据
msg2 := <-ch
fmt.Println("Received:", msg2)
}
AI写代码go
运行
123456789101112131415161718192021222324252627
输出:
vbnet
Sent 'buffered'
Sent 'channel'
Trying to send 'hello'...
(等待约1秒后...)
Received: buffered
Sent 'hello'
Received: channel
AI写代码
1234567
示例 5:使用 for...range
遍历 Channel 和 close
当 Channel 被关闭后,for...range
循环会自动终止,这是处理多个数据流的常用模式。
go
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func worker(jobs <-chan int, results chan<- int) {
defer wg.Done()
for j := range jobs { // 循环会一直从 jobs 接收,直到 jobs 被关闭
fmt.Printf("Processing job %d\n", j)
// 模拟耗时工作
// time.Sleep(time.Second)
results <- j * 2 // 将结果发送到 results Channel
}
}
func main() {
const numJobs = 5
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 启动 3 个 worker Goroutine
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(jobs, results)
}
// 发送 5 个任务到 jobs Channel
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // 关闭 jobs Channel,告诉所有 worker 没有新任务了
// 等待所有 worker 完成
wg.Wait()
// 接收所有结果
// 由于我们不知道 results 何时关闭,这里用 for 循环接收固定次数
fmt.Println("Results:")
for i := 1; i <= numJobs; i++ {
fmt.Println(<-results)
}
}
AI写代码go
运行
1234567891011121314151617181920212223242526272829303132333435363738
可能的输出:
makefile
Processing job 1
Processing job 2
Processing job 3
Processing job 4
Processing job 5
Results:
2
4
6
8
10
AI写代码
1234567891011
在这个经典的"工作池"模式中:
main
Goroutine 创建任务和结果 Channel。- 启动多个 worker Goroutine,它们都在
for j := range jobs
处等待任务。 main
Goroutine 将所有任务发送到jobs
Channel,然后立即关闭它。- 关闭
jobs
后,worker 们仍然可以从其中接收剩余的任务。当所有任务都被处理完毕后,for...range
循环会自动结束,worker Goroutine 随之退出。 main
Goroutine 等待所有 worker 完成后,再从results
Channel 中收集所有结果。
四、综合案例
4.1 多个Goroutine协同工作
以下案例展示了5个goroutine生成随机数并通过channel传递,另一个goroutine实时接收并求和:
go
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// 生成随机数的函数
// 注意:WaitGroup 应该以指针方式传递,否则会得到一个拷贝
func generateNumbers(ch chan int, wg *sync.WaitGroup) {
defer wg.Done() // 生成完成后,通知WaitGroup
for i := 0; i < 5; i++ {
num := rand.Intn(100)
ch <- num
fmt.Printf("Generated: %d\n", num)
time.Sleep(time.Millisecond * 500)
}
}
// 求和的函数
func sumNumbers(ch chan int, done chan bool) {
sum := 0
// for range 循环会在 ch 被关闭且数据被接收完后自动退出
for num := range ch {
sum += num
fmt.Printf("Current Sum: %d\n", sum)
}
fmt.Println("Final Sum:", sum)
// 求和完成后,通过 done channel 通知 main
done <- true
}
func main() {
rand.Seed(time.Now().UnixNano())
ch := make(chan int)
// 用于等待生产者
var producerWg sync.WaitGroup
// 用于通知消费者已完成
done := make(chan bool)
// 启动5个生成随机数的goroutine
producerWg.Add(5)
for i := 0; i < 5; i++ {
go generateNumbers(ch, &producerWg)
}
// 启动1个求和的goroutine
go sumNumbers(ch, done)
// 关键点:启动一个goroutine来等待所有生产者完成,然后关闭channel
go func() {
producerWg.Wait() // 等待所有 generateNumbers 完成
close(ch) // 生产者全部完成,立即关闭 ch
}()
// 主goroutine等待消费者完成
// <-done 会一直阻塞,直到 sumNumbers 往 done 里写入数据
<-done
fmt.Println("All goroutines finished")
}
AI写代码go
运行
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
解析:
- 使用
sync.WaitGroup
等待所有goroutine完成。 - 生成随机数的goroutine将数据发送到channel,求和goroutine从channel接收数据并累加。
- 使用
close(ch)
关闭channel,通知接收方数据发送完毕。
这种方式也彻底解决了死锁问题,从而构建一个健壮且易于理解的并发模型。上面代码也遵循了 Go 并发编程的最佳实践:
- 职责分离: WaitGroup 只负责等待生产者,done channel 只负责通知消费者完成,ch 只负责数据传递。
- 明确的关闭时机: channel 的关闭者(匿名 Goroutine)不关心消费者,只关心生产者是否全部完成。一旦完成,就立即关闭。
- 清晰的退出路径: 每个 Goroutine 都有明确的退出条件和方式,避免了循环等待。