Go语言的并发指的是能让某个函数独立于其他函数运行的能力。当一个函数创建为goroutine时,Go会将其视为一个独立的工作单元。这个单元会被调度到可用的逻辑处理器上执行。
Go语言运行时的调度器是一个复杂的软件,能管理被创建的所有goroutine并为其分配执行时间。这个调度器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行goroutine。调度器在任何给定的时间,都会全面控制哪个goroutine要在哪个逻辑处理器上运行。
Go语言的并发同步模型来自一个叫做通信顺序进程 (CSP)的范型。CSP是一种消息传递类型,通过在goroutine之间传递数据来传递消息,而不是对数据进行加锁来实现同步访问。用于在goroutine之间同步和传递数据的关键数据类型叫做通道。
一、并发与并行
操作系统会在物理处理器上调度线程来运行,而Go语言的运行时会在逻辑处理器上调度goroutine来运行。每个逻辑处理器都分别绑定到单个操作系统线程。这些逻辑处理器会用于执行所有被创建的goroutine。即便只有一个逻辑处理器,Go也可以以神奇的效率和性能,并发调度无数个goroutine。
下图展示了Go调度器如何管理goroutine。
可以看到操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一个goroutine并准备运行,这个goroutine就会被放到调度器的全局运行队列中。之后,调度器就将这些队列中的goroutine分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的goroutine会一直等待直到自己被分配的逻辑处理器执行。
有时,正在运行的goroutine需要执行一个阻塞的系统调用,如打开一个文件。当这类调用发生时,线程和goroutine会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个goroutine来执行。一旦被阻塞的系统调用执行完成并返回,对应的goroutine会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。
并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。
二、goroutine
下面来看一个样例,创建两个goroutine以并发形式分别显示大写和小写的英文字母。
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
//分配一个逻辑处理器给调度器使用
//GOMAXPROCS允许程序更改调度器可以使用的逻辑处理器的数量
runtime.GOMAXPROCS(1)
//wg用来等待程序完成、计数加2,表示要等待两个goroutine
var wg sync.WaitGroup
wg.Add(2)
fmt.Println("Start Goroutines")
//声明一个匿名函数,并创建一个goroutine
go func() {
//在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
//显示三次字母表
for count := 0; count < 3; count++ {
for char := 'a'; char <= 'z'; char++ {
fmt.Printf("%c ", char)
}
}
}()
go func() {
//在函数退出时调用Done来通知main函数工作已经完成
defer wg.Done()
for count := 0; count < 3; count++ {
for char := 'A'; char <= 'Z'; char++ {
fmt.Printf("%c ", char)
}
}
}()
//等待goroutine结束
fmt.Println("Waiting To Finish")
wg.Wait()
fmt.Println("\nTerminating Program")
}
三、竞争状态
如果两个或者多个goroutine在互相没有同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态。所以对一个共享资源的读和写操作必须是原子化的。
四、锁住共享资源
Go语言提供了传统的同步goroutine的机制,就是对共享资源加锁。如果需要顺序访问一个整型变量或者一段代码,atomic和sync包里的函数提供了很好的解决方案。
1、原子函数
原子函数能够以很底层的加锁机制来同步访问整型变量和指针。下面是一个使用原子函数修正竞争状态的示例。
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
var (
//counter是所有goroutine都要增加其值的变量
counter int64
//wg用来等待程序结束
wg sync.WaitGroup
)
func main() {
//计数加2,表示要等待两个goroutine
wg.Add(2)
//创建两个goroutine
go incCounter(1)
go incCounter(2)
//等待goroutiine结束
wg.Wait()
//显示最终的值
fmt.Println("Final Counter:", counter)
}
func incCounter(id int) {
defer wg.Done()
for count := 0; count < 2; count++ {
//安全地对counter加1
//AddInt方法强制同一时刻只能有一个goroutine运行并完成这个加法操作
//类似的函数还有LoadInt64,StoreInt64
atomic.AddInt64(&counter, 1)
//当前goroutine从线程退出,并放回到队列
runtime.Gosched()
}
}
2、互斥锁
互斥锁用于在代码上创建一个临界区,保证同一时间只有一个goroutine可以执行这个临界区代码。
func incCounter(id int) {
defer wg.Done()
for count := 0; count < 2; count++ {
//同一时刻只允许一个goroutine进入
mutex.Lock()
{
//捕获counter的值
value := counter
//当前goroutine从线程退出,并放回队列
runtime.Gosched()
//增加本地value值
value++
//保存回counter
counter = value
}
mutex.Unlock()
//释放锁,允许其他正在等待的goroutine
}
}
对counter的操作在Lock()和Unlock()函数调用定义的临界区里被保护起来。使用大括号不是必须的。同一时刻只有一个goroutine可以进入临界区。
五、通道
除了上述方法,你还可以使用通道,通过发送和接收需要共享的资源,在goroutine之间做同步。
使用make创建通道
//无缓冲的整型通道
unbuffere := make(chan int)
//有缓冲的字符串通道
buffered := make(chan string, 10)
向通道发送值
//有缓冲的字符串通道
buffered := make(chan string, 10)
//通过通道发送一个字符串
buffered <- "Gopher"
//从通道接受值
value := <-buffered
1、无缓冲的通道指接受前没有能力保存任何值的通道。这种类型的通道强制要求goroutine之间必须同时完成发送和接收。
2、有缓冲的通道是一种在被接收前能存储一个或多个值的通道。这种类型的通道并不强制要求goroutine之间必须同时完成发送和接收。