Golang并发编程
Go语言提供channel在多个goroutine间进行通信,goroutine的概念类似于线程,但goroutine有Go程序运行时进行调度和管理。
Go程序从main包的main()函数开始,在程序启动时,Go程序会为main()函数创建一个默认的goroutine。
并发 是指在同一时间段内,处理多个任务,但不一定是同时执行。
并行 是指在同一时刻,同时执行多个任务,需要多个处理器。
1.轻量级线程goroutine
Go程序中使用go关键字为一个函数创建一个goroutine
。
创建一个goroutine的写法:
go 函数名(参数列表)
// 创建一个goroutine的方法很简单,只需要在函数名前声明 `go` 语句,被调用的函数也可以是匿名函数。
案例1 :使用go语句并发执行running()函数,每隔一秒打印计数器。
package main
import (
"fmt"
"time"
)
func running() {
var times int
for {
times++
fmt.Println("tick", times)
time.Sleep(time.Second)
}
}
func main() {
fmt.Println("程序开始执行!")
go running()
fmt.Println("程序执行结束!")
}
程序运行结果:
可以发现上面程序并没有执行running()函数!这是为啥!
改进后的程序:
package main
// 省略 *****
func main(){
fmt.Println("程序开始执行!")
go running()
var input string
fmt.Scanln(&input)
fmt.Println("我的输入:",input)
fmt.Println("程序执行结束!")
}
执行结果:
这是因为第一个程序中在running()函数执行main()函数就已经执行完毕了,主线程退出了导致running()函数还没来得及执行。所以第二个程序进行了改进,定义了一个等待用户输入的操作, 导致主线程处于阻塞状态,这时候running()函数就可以开始执行了,等待主程序退出running()函数才会退出执行。
所有的goroutine在main()函数结束时会一同结束。
sync.WaitGroup
是Go语言中用来同步协程(goroutine)的一个重要工具。它可以用于等待一组协程完成执行。
一个 sync.WaitGroup 主要有三个方法:
- Add(delta int): 用于增加等待组的计数器。delta 参数可以是正数也可以是负数,用来表示要等待的协程数量。在启动一个新协程时,应该在 Add 前调用,以便将计数器增加。
- Done(): 用于减少等待组的计数器,相当于完成了一个协程的工作。通常在协程的末尾使用。
- Wait(): 会阻塞调用它的协程,直到等待组的计数器归零。这表示所有被等待的协程都已经完成了工作。
案例2 :
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup // sync.WaitGroup 是 Go 语言中用来同步协程(goroutine)的一个重要工具
fmt.Println("Start tasks!")
// 创建10个goroutine
for i := 1; i <= 10; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
fmt.Printf("Task %d is complete\n", i)
}(i)
}
wg.Wait()
fmt.Println("All tasks are complete")
}
2.通道(channel)
channel是Go语言在语言级别提供的goroutine间的通信方式。借助于通道,我们可以轻松地在两个或多个goroutine之间传递消息。与此同时,通道是一种进程内通信方式,这意味着它的行为类似于函数调用时的参数传递,可以传递对象的引用或者指针等。这使得通道成为协调并发程序的强大工具,也保证了数据的安全性和同步性。通道像一个传送带或者队列,总是遵循先入先出的规则,保证收发数据的顺序。
2.1 基本语法
一般channel的声明形式为:
var chanName chan ElementType
由于chan是一个引用型数据类型,所以在定义完通道变量后需要使用make给变量分配内存空间
// 向channel写人数据
ch <-value
向channel写人数据通常会导致程序阻塞,直到有其他goroutine从这个channel中读取数据。
//从channel中读取数据
value := <-ch
如果channel之前没有写人数据,那么从channel中读取数据也会导致程序阳塞,直到channel中被写人数据为止。
2.2 通道的选择
select 语句允许在多个通道操作上等待。它会阻塞,直到其中一个通道可以执行操作。
package main
import (
"fmt"
"time"
)
func main() {
var a chan int
var b chan int
a = make(chan int)
b = make(chan int)
go func() {
select {
case msg1 := <-a:
fmt.Println("Received", msg1)
case msg2 := <-b:
fmt.Println("Received", msg2)
default:
fmt.Println("调用结束")
}
}()
a <- 21
time.Sleep(time.Second)
}
比如上面的例子中,第一个case试图从a读取个数据并直接忽略读到的数据,而第二个case则是试图向b中写人一个整型数1,如果这两者都没有成功,则到达default语句。
2.3 通道的阻塞
通道在发送和接收数据时可能会发生阻塞,取决于通道的状态:
- 如果发送者将数据发送到一个已满的通道,将会阻塞,直到通道中有空间可以容纳数据。
- 如果接收者试图从一个空的通道中接收数据,也会阻塞,直到通道中有数据可用。
这种特性保证了在并发编程中的安全性。
2.4 单向通道
可以将通道限制为只能发送或只能接收。这样可以在编程中增强程序的安全性和可读性。
- 只读通道
func process(ch <-chan int) {
// 只能从通道 ch 中读取数据
value := <-ch
fmt.Println(value)
}
这里的process函数接受一个只读通道作为参数,这确保了在函数内部只会从通道中读取数据,而不会进行写入。
- 只写通道
func generate(ch chan<- int) {
// 只能向通道 ch 中发送数据
ch <- 42
}
在这个例子中,generate 函数接受一个只写通道作为参数,这意味着在函数内部只能向通道发送数据,而不能从中读取。
单向通道的使用也可以帮助避免误用通道。例如,如果一个函数只需要从通道接收数据,但错误地尝试在其中发送数据,编译器将会报错,这样可以在编译阶段捕获到潜在的错误。
2.5 带缓冲的通道
带缓冲的通道允许在通道满之前发送数据,或者在通道空之前接收数据:
ch := make(chan int, 5) // 创建一个容量为 5 的整型通道
3. 使用共享变量实现并发
3.1 竞态
在串行程序中,程序中的各个步骤都是按照从上到下的顺序执行的。
竟态是指在多个goroutine 按某些交错顺序执行时程序无法给出正确的结果。
当一个程序有两个或者多个goroutine时,每个goroutine 内部的各个步骤也是顺序执行的,但我们无法知道一个goroutine中的事件x和另外一个goroutine中的事件y的先后顺序。
下面用一个简单的银行账户程序解释数据竞态:
var ws sync.WaitGroup
// 用一个全局变量表示一个银行账户
var balance int
// 向银行账户存款
func Deposit(amount int) {
time.Sleep(time.Second)
balance = balance + amount
defer ws.Done()
}
// 查询银行账户余额
func Balance() {
fmt.Println("账户余额:", balance,"元")
}
func main() {
for i := 1; i <= 1000; i++ {
ws.Add(1)
go Deposit(1)
}
ws.Wait()
Balance()
}
在上面程序中,使用一个循环,用于启动1000个新的goroutine来执行Deposit
函数,实现并发存款。 所有存款操作完成后,调用 Balance()
函数查询账户余额并打印出来。总的来说,这个程序模拟了多个并发存款操作,然后在所有存款完成后查询了账户余额。使用 sync.WaitGroup
保证了在查询余额之前所有存款操作都已经完成。
直觉来看,执行1000次存款后账户余额应该是1000元 ,但是账户的实际余额是小于1000元,这是因为存款操作的时候可能出现同时写入一个银行账户,即程序在运行中同时写入一个共享变量导致账户余额的丢失,在实际场景中是很严重的。
程序中的这种状况是竟态中的一种,称为数据竞态 (data race)。数据竞态发生于两个goroutine 并发读写同一个变量并且至少其中一个是写人时。
3.2 互斥锁
避免数据竞态的办法是允许多个 goroutine 访问同一个变量,但在同一时间只有goroutine 可以访问。这种方法称为互斥机制。
互斥锁模式应用非常广泛,所以 sync 包有一个单独的 Mutex类型来支持这种模式。
在使用互斥锁之前需要先定义一个sync.Mutex
类型的变量,用于获取令牌进行上锁,Lock()方法用于上锁,Unlock()方法用于解锁,使用起来非常的简单。
var ws sync.WaitGroup
var lock sync.Mutex
var balance int
func Deposit(amount int) {
lock.Lock() //上锁
balance = balance + amount
defer func() {
lock.Unlock() // 在函数调用结束的时候解锁
ws.Done()
}()
}
func Balance() {
fmt.Println("账户余额:", balance)
}
func main() {
for i := 1; i <= 1000; i++ {
ws.Add(1)
go Deposit(1)
}
ws.Wait()
Balance()
}
// 这时候无论程序怎么运行结果都是 :1000
一个goroutine 在每次访问银行的变量 (此处仅有 balance)之前,它都必须先调用互斥的 Lock 方法来获取一个互斥锁。如果其他 goroutine 已经取走了互斥锁,那么操作会一直且塞到其他 goroutine 调用 Unlock 之后(此时斥锁再度可用)。
3.3 读写互斥锁
读写锁是一种在并发编程中用于提高性能的机制。与互斥锁不同,读写锁允许多个线程同时读取共享资源,但在写操作时会互斥排斥其他读写操作。这在许多实际场景下非常适用,因为通常情况下对共享资源的读取远远多于写入。
在Go语言中,我们可以使用sync包中的RWMutex类型来实现读写锁。通过合理地使用读写锁,我们可以在保证数据一致性的前提下,最大程度地提高程序的并发性能,从而更好地满足实际需求。
银行注意到查询账户余额的需求远远超过存款操作的需求,且用户在查询余额时往往会以并发的方式进行。也就是说,在调用Balance()
函数时会存在多个并发调用的情况。然而,当其他用户正在查询余额的同时,可能会有用户进行存款操作。在这种情况下,执行Deposit()
操作有可能会对程序进行锁定,并且暂时阻塞其他goroutine
的运行。为了解决这个问题,我们需要一种特殊类型的锁,它允许多个只读操作同时进行,但写操作则需要获得完全独享的访问权限。这种锁被称为多读单写锁,而在Go语言中,sync.RWMutex
正是提供了这样的功能:
var ws sync.WaitGroup
var lock sync.Mutex
var rwlock sync.RWMutex
var balance int
func Deposit(amount int) {
lock.Lock()
balance = balance + amount
defer func() {
lock.Unlock()
ws.Done()
}()
}
func Balance() {
ws.Add(1)
rwlock.RLock()
fmt.Println("账户余额:", balance)
rwlock.RUnlock()
defer func() {
ws.Done()
}()
}
func main() {
for i := 1; i <= 10; i++ {
ws.Add(1)
go Deposit(1)
}
for i := 1; i <= 1000; i++ {
go Balance()
}
ws.Wait()
}
需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。
3.4 延迟初始化sync.Once
sync.Once
是 Go 语言中的一个同步原语,用于在程序运行期间只执行一次特定操作。它通过一个特定的函数只被调用一次来保证某些操作在并发环境下的唯一性。
使用 sync.Once
的主要步骤如下:
- 创建一个
sync.Once
对象。 - 定义一个需要在程序生命周期内只执行一次的函数。
- 使用
Once.Do()
方法来调用这个函数。
示例代码如下:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
for i := 0; i < 3; i++ {
once.Do(func() {
fmt.Println("This will only be printed once")
})
}
}
在上述代码中,sync.Once
保证了函数只会在第一次调用时执行,后续的调用将被忽略。
需要注意的是,sync.Once
对象应该作为函数的局部变量,而不是全局变量。这样可以保证每次需要执行的函数在不同的上下文中可以有不同的行为。
3.4 sync.Map
sync.Map
是 Go 语言标准库 sync
包提供的一个并发安全的 map 类型。相比于普通的 map
,sync.Map
具有更好的并发性能,可以在多个 goroutine 之间安全地进行读写操作。
sync.Map
的特点如下:
- 无需初始化:不需要使用
make
函数初始化,可以直接声明并使用。 - 并发安全:
sync.Map
提供了并发安全的读写操作,可以在多个 goroutine 中同时对其进行读写,无需额外的锁机制。 - 动态增长:
sync.Map
内部会自动扩容以容纳更多的元素。
使用示例:
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// 存入键值对
m.Store("key1", "value1")
m.Store("key2", "value2")
// 获取值
val1, ok1 := m.Load("key1")
val2, ok2 := m.Load("key2")
fmt.Println(val1, ok1) // 输出: value1 true
fmt.Println(val2, ok2) // 输出: value2 true
// 删除键值对
m.Delete("key1")
// 检查键是否存在
_, ok3 := m.Load("key1")
fmt.Println(ok3) // 输出: false
}
在上述示例中,我们首先创建了一个 sync.Map
对象m。然后使用Store
方法存储键值对,使用Load
方法获取值,使用Delete
方法删除键值对,最后使用Load
方法检查键是否存在。需要注意的是,sync.Map 的键和值可以是任意类型。
3.5 原子操作
原子操作是一种在并发编程中确保多个线程安全地访问共享数据的机制。它能够保证某个操作在多线程环境下要么完全执行成功,要么完全不执行,不会出现中间状态或冲突。原子操作是不可分割的,要么全部执行成功,要么全部不执行,不存在执行过程中的中断。
在 Go 语言中,可以使用 sync/atomic
包提供的原子操作函数来实现原子操作。
以下是一些常用的原子操作函数:
AddInt32
,AddInt64
:对一个整数进行原子的加法操作。SwapInt32
,SwapInt64
:对一个整数进行原子的交换操作。CompareAndSwapInt32
,CompareAndSwapInt64
:比较并交换操作,当旧值等于预期值时才会进行交换。
这些原子操作函数可用于确保在并发环境下对共享变量的安全访问,避免数据竞争和不一致的状态。需要注意的是,原子操作函数只能用于基本数据类型,如整数和指针,不能用于复杂的数据结构。如果需要在复杂数据结构上进行原子操作,可能需要使用互斥锁来实现。