1.3 Channel(通道) 特性
这张图片讲解了 Go 语言中的 Channel(通道) 特性,特别是无缓冲通道与有缓冲通道的区别。
1. Channel 基本概念
在 Go 语言中,Channel 是用于 Goroutine 之间进行通信的工具。可以使用 make() 函数创建 Channel,语法如下:
make(chan 元素类型, [缓冲大小])
- 无缓冲通道:
make(chan int),没有缓冲区,发送和接收必须同步进行。 - 有缓冲通道:
make(chan int, 2),有缓冲区,可以存储一定数量的元素,发送和接收可以异步进行。
2. 无缓冲通道(左图)
- 无缓冲通道无法存储数据,发送方和接收方必须 同步。
- Goroutine 1 向 Channel 发送数据时,必须等待 Goroutine 2 准备好接收数据,数据才能传递。
- 在这种情况下,发送和接收操作是阻塞的。如果没有对应的接收方,发送操作会一直阻塞,直到有 Goroutine 准备接收数据为止。
- 适合用于 Goroutine 之间的同步操作。
图示解释:
- 左侧图中,Goroutine 1 和 Goroutine 2 之间通过一个无缓冲的 Channel 进行通信,发送方和接收方必须同时准备好。
- 图中展示了一个箭头,表示数据从 Goroutine 1 直接传递给 Goroutine 2。
3. 有缓冲通道(右图)
- 有缓冲通道可以存储一定数量的数据,发送方不需要等待接收方立即接收。
- 在缓冲区未满时,发送操作不会阻塞;只有当缓冲区满了,发送操作才会阻塞。
- 同样,接收方只有在缓冲区为空时才会阻塞,其他情况下可以直接从缓冲区取数据。
- 适合用于需要异步通信的场景,提高 Goroutine 之间的解耦和效率。
图示解释:
- 右侧图中,Goroutine 1 和 Goroutine 2 之间通过一个有缓冲的 Channel 进行通信。
- Channel 中有一个缓冲区,包含两个存储格子。发送方可以存储数据,而不必等待接收方立即接收。
- 图中显示了数据进入缓冲区,然后再从缓冲区传递给接收方 Goroutine 2。
总结:
- 无缓冲通道:发送和接收必须同时发生,适合用于 Goroutine 同步。
- 有缓冲通道:允许发送方和接收方异步工作,提高并发性能,适合异步通信。
这两种通道在并发编程中各有优势,可以根据具体需求选择合适的类型。
1.4并发安全LOCK
这段代码展示了 Go 语言中 并发安全问题 以及使用 sync.Mutex 来解决数据竞争的情况。
1. 代码概述
-
x是全局变量,所有 Goroutine 都会对其进行修改。 -
lock是一个sync.Mutex类型的互斥锁,用于保护对共享变量x的并发访问。 -
代码包含两个函数:
addWithLock()使用互斥锁来保护对x的修改。addWithoutLock()不使用锁,直接对x进行修改。
-
在
Add()函数中,分别启动 5 个 Goroutine 来调用这两个函数,测试加锁与不加锁情况下的执行结果。
2. 代码解析
全局变量定义
var (
x int64
lock sync.Mutex
)
x是一个全局整数变量,用于存储计算结果。lock是一个互斥锁,用于保证对x的并发操作是安全的。
addWithLock() 函数
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}
lock.Lock()加锁,防止其他 Goroutine 同时修改x。x += 1是对共享变量x的修改操作。lock.Unlock()解锁,允许其他 Goroutine 继续操作。- 使用互斥锁确保了
x的修改是线程安全的。
addWithoutLock() 函数
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x += 1
}
}
- 这个函数没有加锁,直接修改
x。 - 在并发环境下,这种情况下会出现 数据竞争,导致
x的结果不可预测。
Add() 主函数
func Add() {
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
println("WithoutLock:", x)
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock:", x)
}
-
第一部分:
- 重置
x = 0。 - 启动 5 个 Goroutine,每个 Goroutine 调用
addWithoutLock(),并对x进行修改。 - 使用
time.Sleep(time.Second)等待 Goroutine 完成(这不是最佳的同步方式,但在示例中用于简单处理并发)。 - 打印出结果
WithoutLock: x。
- 重置
-
第二部分:
- 重置
x = 0。 - 启动 5 个 Goroutine,每个 Goroutine 调用
addWithLock(),并对x进行修改。 - 使用
time.Sleep(time.Second)等待 Goroutine 完成。 - 打印出结果
WithLock: x。
- 重置
3. 运行结果分析
WithoutLock: 8382
WithLock: 10000
-
WithoutLock:
- 理论上应该执行
5 * 2000 = 10000次加法操作。 - 但是由于没有加锁,多个 Goroutine 同时修改
x时会产生 数据竞争,导致结果错误。 - 在本例中输出
8382,说明出现了数据丢失的情况。
- 理论上应该执行
-
WithLock:
- 使用了互斥锁,保证了每次对
x的修改都是线程安全的。 - 输出结果为
10000,符合预期。
- 使用了互斥锁,保证了每次对
4. 并发安全问题解释
- 在并发环境下,多个 Goroutine 同时对共享变量进行操作时,如果没有合适的同步机制(如锁、原子操作等),就会出现 数据竞争 问题。
- 数据竞争会导致程序的执行结果不可预测,出现数据丢失、错误计算等情况。
- 通过
sync.Mutex互斥锁,确保了在任何时刻只有一个 Goroutine 能访问和修改共享变量,从而避免了数据竞争。
5. sync.Mutex 使用总结
Lock():获取锁。如果锁已经被其他 Goroutine 持有,当前 Goroutine 会阻塞,直到锁被释放。Unlock():释放锁。只有持有锁的 Goroutine 才能释放锁,否则会导致程序 panic。
6. 注意事项
- 死锁:如果一个 Goroutine 获得锁后没有调用
Unlock()释放锁,会导致其他 Goroutine 永远阻塞,出现死锁问题。 - 性能问题:频繁使用锁会降低并发性能,特别是在锁竞争激烈的情况下。
总结
- 使用锁可以解决并发安全问题,但也会带来性能开销。
- 在实际应用中,需要根据具体场景选择合适的并发控制策略,如锁、原子操作、Channel 等。
- 这段代码很好地展示了数据竞争的危害,以及使用互斥锁来保护共享资源的重要性。
1.5WaitGroup

这段代码展示了 Go 语言中 Goroutine 的并发执行,并指出了常见的并发陷阱 —— 循环变量捕获问题。
代码解析
hello() 函数
func hello(i int) {
println("hello goroutine:", i)
}
- 这是一个简单的函数,接受一个整数参数
i并打印hello goroutine: i。
HelloGoRoutine() 函数
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
- 使用
for循环启动了 5 个 Goroutine。 go关键字启动了匿名函数,每次循环传递当前的i值作为参数j给匿名函数。time.Sleep(time.Second)用于等待所有 Goroutine 执行完毕(不推荐这种方式,通常使用sync.WaitGroup更合适)。
输出解释
hello goroutine: 4
hello goroutine: 1
hello goroutine: 0
hello goroutine: 2
hello goroutine: 3
为什么输出顺序不一致?
- Goroutine 是并发执行的,启动 Goroutine 后并不会等待其执行完毕,主 Goroutine 会继续向下执行。
- 因为 Goroutine 的调度顺序是不确定的,所以
hello goroutine: i的打印顺序也是不确定的。 - 在不同的运行环境和 CPU 调度下,打印的顺序可能会有所不同。
为什么没有出现循环变量捕获问题?
- 在这段代码中,匿名函数使用了参数
(j int),并将i的值在启动 Goroutine 时通过参数传递给j。 - 这样可以避免循环变量捕获问题。因为
j是函数参数,它的值在函数调用时已经固定,不会受到循环中i值变化的影响。
循环变量捕获问题的解释
如果我们改成如下代码,会出现循环变量捕获问题:
for i := 0; i < 5; i++ {
go func() {
hello(i) // 直接使用循环变量 i
}()
}
- 在这种情况下,匿名函数直接使用了循环变量
i,而不是通过参数传递。 - 当 Goroutine 实际执行时,循环可能已经结束,
i的值变为5。 - 结果可能会打印出相同的值
hello goroutine: 5,因为所有 Goroutine 都捕获了同一个循环变量i。
正确的 Goroutine 启动方式
通过将循环变量作为参数传递给匿名函数,可以避免这个问题:
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i) // 将 i 作为参数传递
}
time.Sleep() 的问题
time.Sleep(time.Second)是用来等待 Goroutine 执行完毕的,但这种方式并不可靠。- 在高并发场景下,可能会出现 Goroutine 尚未执行完,主 Goroutine 已经结束的情况。
- 推荐使用
sync.WaitGroup来等待所有 Goroutine 完成。
使用 sync.WaitGroup 的改进版
func HelloGoRoutine() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(j int) {
defer wg.Done()
hello(j)
}(i)
}
wg.Wait()
}
- 使用
sync.WaitGroup来跟踪 Goroutine 的完成状态。 wg.Add(1)为每个启动的 Goroutine 增加计数。defer wg.Done()在 Goroutine 完成时减少计数。wg.Wait()阻塞主 Goroutine,直到所有 Goroutine 完成。
改进后的输出示例
hello goroutine: 0
hello goroutine: 1
hello goroutine: 2
hello goroutine: 3
hello goroutine: 4
- 由于使用了
WaitGroup,输出结果更加稳定,所有 Goroutine 都会执行完毕,且不会出现循环变量捕获问题。
总结
- 在 Go 中启动 Goroutine 时,避免直接捕获循环变量,最好通过参数传递。
- 使用
sync.WaitGroup可以更安全、可靠地等待所有 Goroutine 执行完毕。 - Go 语言中的并发编程需要特别注意数据竞争和变量捕获等常见问题,以确保程序的正确性和稳定性。