所谓并发控制,说的是同一程序进程内不同线程间访问相同资源时的冲突处理,有时候也称为进程内同步。比如一个简单的内存累加计数操作,如果不进行同步,不同的线程可能就会获取到同样的数值,累加出相同的结果,最终结果也就不准确了。如下图所示,线程1和线程2按照图中的顺序操作变量n就会出现同步问题。进程内同步就是用来解决这种问题的。
在Go语言中,应用程序没有直接使用线程,使用的是一种轻量级的线程或者用户态线程:goroutine,很多时候也被称为协程,所以Go语言中进程内的同步就是协程之间的同步。在Go语言中进程内同步有多种不同的实现机制,主要包括sync
包下的工具和channel
(通道)。接下来,我将逐一介绍这些同步机制,它们的用途、原理和应用实例,让大家对Go的进程内同步有个清晰的认识。
sync包的同步机制
1. atomic原子操作
原子操作在Go中是通过atomic
包提供的,其底层是通过硬件CPU的支持实现的。原子操作能够确保变量的操作在计算机的最基本操作层面是不可分割的,从而避免竞态条件。
原子操作的优点是效率高,因为它们不需要复杂的锁机制。但缺点是原子操作只适用于简单的数据操作,对于复杂的同步需求,原子操作就不太适用了。
在Go语言中,atomic
包提供了一系列的函数来执行原子性的增加、减少、加载和存储等操作。
我们来看一个计数器的例子:
go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count int32
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for c := 0; c < 1000; c++ {
atomic.AddInt32(&count, 1)
}
}()
}
wg.Wait()
fmt.Println("Count:", count)
}
在这个例子中,我们创建了5个goroutine,每个都尝试对count
变量增加1000次。我们使用atomic.AddInt32
来确保增加操作的原子性。最后,我们使用WaitGroup
来等待所有goroutine完成后,并打印出最终的count
值,这个值应该是5000。
2. mutex互斥锁
互斥锁是一种常见的同步机制,用于保护共享资源,确保一次只有一个goroutine可以访问该资源。
Go的sync
包提供了Mutex
类型来实现互斥锁。通过Lock
和Unlock
方法控制锁的获取和释放。
互斥锁可以解决复杂的同步问题,但它可能会导致性能问题,比如锁竞争和死锁。适当地使用互斥锁是并发编程的一门艺术。
我们还是以计数器为例,来演示互斥锁的使用方法:
go
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var lock sync.Mutex
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for c := 0; c < 1000; c++ {
lock.Lock()
count++
lock.Unlock()
}
}()
}
wg.Wait()
fmt.Println("Count:", count)
}
可以看到我们只是修改了数字累加部分的代码,将原子操作替换为了锁操作。
互斥锁的用途十分广泛,我们再举个例子:在Web服务中,可能会有多个goroutine同时尝试写日志到同一个文件,使用互斥锁可以确保日志的顺序和完整性。
go
package main
import (
"fmt"
"log"
"os"
"sync"
"time"
)
// 日志文件的全局变量和互斥锁
var (
logFile *os.File
mutex sync.Mutex
)
// 初始化日志文件
func init() {
var err error
logFile, err = os.OpenFile("webserver.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Fatalf("error opening file: %v", err)
}
}
// 写日志的函数,这里使用互斥锁来同步
func logMessage(message string) {
mutex.Lock() // 在写入之前,锁定
defer mutex.Unlock() // 使用defer来确保互斥锁会被解锁
// 这里模拟写入日志需要一些时间
time.Sleep(time.Second)
logFile.WriteString(time.Now().Format("2006-01-02 15:04:05") + " - " + message + "\n")
}
func main() {
// 模拟web服务,启动5个goroutine尝试写日志
for i := 0; i < 5; i++ {
go func(id int) {
logMessage(fmt.Sprintf("Log entry from goroutine %d", id))
}(i)
}
// 等待足够的时间让goroutine完成日志写入
time.Sleep(10 * time.Second)
// 关闭日志文件
if err := logFile.Close(); err != nil {
log.Fatalf("error closing file: %v", err)
}
}
注意我们这里使用了init函数,他会在程序启动的时候执行,这里用来打开日志文件,并在程序的运行时间内一直可写。
3. WaitGroup
在上边累加计数的示例中我们都使用了WaitGroup
,这里再详细介绍下它的能力。
WaitGroup
是Go语言中用来等待一组goroutine执行完成的同步机制。在一些场景下,你可能需要启动多个goroutine去执行任务,而主goroutine需要等待这些任务都完成后才能继续执行。WaitGroup
可以用来等待这一组goroutine的结束。
WaitGroup
有三个主要的方法:Add
、Done
和Wait
。Add
用来增加等待的goroutine数量,Done
用来表示一个goroutine完成了它的工作,Wait
用来阻塞,直到所有的goroutine都调用了Done
。
WaitGroup
是一种简单有效的等待多个goroutine的方法。但是它不能被重用,一旦你用Wait
等待它,WaitGroup
就不能再添加新的goroutine了。
channel的同步机制
channel是Go语言中的通道,它可以用来在goroutine之间传递消息,确保数据的顺序和完整性。它也可以用来控制并发,比如限制并发的数量。
有两种类型的通道:
- 无缓冲:无缓冲通道是指在发送操作完成之前必须有相应的接收操作才能开始执行,否则发送操作会一直阻塞。
- 有缓冲:有缓冲通道有一个固定的存储空间,只有当缓冲区满时,发送操作才会阻塞;只有当缓冲区空时,接收操作才会阻塞。
使用方法:
- make:通过
make
函数可以创建一个通道,可以指定它的缓冲大小。 - ch <- 和 <- ch:使用
ch <-
可以向通道发送值,使用<- ch
可以从通道接收值。 - close:当通道不再需要发送数据时,可以使用
close
函数来关闭它。
通道的原理:
- 共享内存:通道背后是一块共享内存,无缓冲通道上的操作必须是发送和接收同时发生,而有缓冲通道则有一个环形队列存储数据。
- 锁:操作通道时,Go运行时会使用锁来保证操作的原子性和顺序性。
一个典型的应用是生产者-消费者模型,在这个模型中,生产者goroutine将产品发送到通道,消费者goroutine从通道接收产品并处理。下面我们来看这个例子:
go
package main
import (
"fmt"
"time"
)
func main() {
message := make(chan string, 2)
go func() {
for {
msg := <-message
fmt.Println(msg)
}
}()
message <- "buffered"
message <- "channel"
time.Sleep(time.Second)
message <- "example"
fmt.Println("All messages sent")
close(message)
}
在这个例子中,我们创建了一个有缓冲的通道message,它可以存储最多2个元素。我们启动一个goroutine来接收并打印从通道接收到的消息。因为通道是有缓冲的,所以发送操作不会立即阻塞,除非缓冲区已满。
结语
在Go语言的并发编程中,sync
包和channel
是两个非常重要的工具。它们通过不同的机制提供了强大的进程内同步功能。使用原子操作和互斥锁可以保护共享资源,使用WaitGroup
可以等待一组goroutine的完成,而通道则可以用来在goroutine之间传递消息和控制并发。正确地使用这些工具,可以让你的并发程序更加稳定和高效。
最后用一张图总结本文:
关注萤火架构,加速技术提升!