这是我参与「第五届青训营 」伴学笔记创作活动的第 3 天。
Go 语言通过协程(Goroutine)实现并发。协程是与 “进程” 和 “线程“ 类似的概念,但相比后两者,协程要更加的轻量,线程的栈是 MB 级别的,而协程的栈是 KB 级别的,协程在执行和调度上的资源消耗都比线程小得多。
一个简单的例子
在 Go 中使用协程非常简单,只需要在被调用的函数前加上 go 关键字,例如:
go fun(a, b, c)
这条简单的语句创建了一个协程负责执行 fun() 函数。更具体一点的例子,假如我们需要创建 5 个协程,分别打印数字 0 ~ 4:
func printNum(num int) {
fmt.Println(num)
}
func main() {
for i := 0; i < 5; i++ {
go printNum(i)
}
// 让main()函数等待协程运行结束(简单粗暴)
time.Sleep(time.Second)
}
这里用 Sleep() 函数强制让 main() 协程休眠 1 秒,它的作用是等待子协程的运行,这是因为 main() 协程执行到这里就没事情做了,接下来就会退出程序,导致子协程还没来得及运行就被终止,看不到输出结果,所以这里简单粗暴地让 main() 休眠了 1 秒给子协程充足的时间执行并退出。
运行输出:
4
0
1
3
2
可以发现输出是无序的,这是因为 5 个协程是并发运行的。
既然协程指定的是函数,那么自然的也可以指定匿名函数,这种写法是相当常见的:
func main() {
for i := 0; i < 5; i++ {
go func(num int) {
fmt.Println(num)
}(i)
}
time.Sleep(time.Second)
}
实现并发同步
在前面的例子中,为了让主协程等待子协程运行结束,我们简单粗暴地让主协程等待了 1 秒,实际开发中我们很难去精确估计子协程运行需要多长时间,更优雅的方法应该是让子协程在结束之前通知并唤醒主协程。
在 Go 中可以使用 sync.WaitGroup 实现并发任务的同步,它一共有三个方法:
Add(delta int):计数器 +delta- Done():计数器 -1
- Wait():阻塞直到计数器归零
sync.WaitGroup 实际上维护了一个计数器,记录了当前还需要等待多少个协程执行结束,当调用 Wait() 时协程会进入休眠状态,直到计数器归零才会被唤醒。
func main() {
var wg sync.WaitGroup
wg.Add(5) // 需要等待5个子协程,因此计数器+5
for i := 0; i < 5; i++ {
go func(num int) {
defer wg.Done() // 每个协程运行结束时,让计数器-1
go func(num int) {
fmt.Println(num)
}(i)
}(i)
}
// 主协程进入阻塞,等待wg计数器归零
wg.Wait()
}
协程间通信
共享内存
协程间通信的方式之一是直接共享内存,简单来说就是多个协程共享一些变量,这种通信方式需要注意并发安全,在访问临界资源前可能需要上锁。
func main() {
var (
x int = 0
wg sync.WaitGroup
lock sync.Mutex
)
wg.Add(5)
for i := 0; i < 5; i++{
go func(n int) {
defer wg.Done()
for i := 0; i < n; i++ {
lock.Lock() // 可以试着注释掉lock这两行,看看结果
x++
lock.Unlock()
}
}(1000)
}
wg.Wait()
fmt.Println(x)
}
使用通道
协程间通信的方式另一种方式是使用通道(channel),通过使用通道间接地共享内存,这也是Go所提倡的方式。
声明一个名为 ch 的传递 int 类型变量的通道:
var ch chan int
声明后的通道还需要使用 make() 函数初始化才能使用,例如:
ch1 := make(chan, int) //无缓冲通道
ch2 := make(chan, int, 3) //缓冲区大小为3的通道
缓冲区大小即通道最多存放多少个元素,当缓冲区大小为 3 时,通道最多存放 3 个元素。
- 当通道填满时,继续向通道传入数据则协程会被阻塞,直到通道中有元素被取走从而腾出空间。
- 当通道为空时,试图从通道读取的协程会被阻塞,直到传入新元素或通道被关闭。
无缓冲通道会使协程同步化,因此又称为同步通道。
通过操作符 <- 用来收发通道中的数据:
channel <- x // 将x发送到通道中
x := <- channel // 从通道中取出一个值并赋值给x
举个例子:
func main() {
chan1 := make(chan int) // 无缓冲通道
chan2 := make(chan int, 5) // 有缓冲通道,缓冲大小为5
// 第一个协程负责生产数字
go func() {
defer close(chan1)
for i := 1; i <= 10; i++ {
chan1 <- i
}
}()
// 第二个协程负责将第一个协程生产的数字取平方
// 另外,通道也可以使用range读取
go func() {
defer close(chan2)
for i := range chan1 {
chan2 <- i * i
}
}()
// 主协程负责打印第二个协程生产的平方数
for i := range chan2 {
fmt.Println(i)
}
// 协程的通道可以理解为函数的返回值
}
简单来说,协程中的通道就类似于函数的返回值,利用通道我们可以让原本孤立运行的协程间进行数据交换。