1. 并发 vs 并行
并发:多线程程序在一个核的cpu上运行
并行:多线程程序在多个核的cpu上运行
Go 可以充分发挥多核优势,高效运行
2. Goroutine
协程:用户态,轻量级线程,栈KB级别
线程:内核态,线程跑多个协程,栈MB级别
每一个并发的执行单元叫作一个goroutine。假设一个程序有两个函数,一个函数做计算,另一个输出结果,两个函数没有相互之间的调用关系。一个线性的程序会先调用其中的一个函数,然后再调用另一个。如果程序中包含多个goroutine,对两个函数的调用则可能发生在同一时刻。
通过go关键字就能启动一个Goroutine
time.Sleep()time包下的Sleep函数,可以使当前协程暂停一段时间
3. CSP(Communicating Sequential Processes)
Go语言提倡通过通信共享内存
4. Channel
如果说goroutine是Go语言程序的并发体的话,那么channel则是它们之间的通信机制。一个channel可以让一个goroutine通过它给另一个goroutine发送值信息。
channel有发送和接受两个主要操作,都是通信行为。一个发送语句将一个值从一个goroutine通过channel发送到另一个执行接收操作的goroutine。发送和接收两个操作都使用<-运算符。
//创建 make(chan 元素类型,[缓冲大小])
ch := make(chan int) //无缓存
ch2 := make(chan int,2)
ch <- x //发送
x = <- ch //接收
4.1 无缓存Channel
无缓存Channel的发送和接收操作将导致两个goroutine做一次同步操作,因此也被称为同步Channels。
无缓存Channels的发送操作将导致发送者goroutine阻塞,直到另一个goroutine在相同的Channels上执行接收操作,当发送的值通过Channels成功传输之后,两个goroutine可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者goroutine也将阻塞,直到有另一个goroutine在相同的Channels上执行发送操作。
4.2 有缓存Channel
带缓存的Channel内部持有一个元素队列。队列的最大容量是在调用make函数创建channel时通过第二个参数指定的。
向缓存Channel的发送操作就是向内部缓存队列的尾部插入元素,接收操作则是从队列的头部删除元素。如果内部缓存队列是满的,那么发送操作将阻塞直到因另一个goroutine执行接收操作而释放了新的队列空间。相反,如果channel是空的,接收操作将阻塞直到有另一个goroutine执行发送操作而向队列插入元素。
4.3 举例:生产者消费者模型
并发编程中最常见的例子就是生产者消费者模式,该模式主要通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。简单地说,就是生产者生产一些数据,然后放到成果队列中,同时消费者从成果队列中来取这些数据。这样就让生产消费变成了异步的两个过程。当成果队列中没有数据时,消费者就进入饥饿的等待中;而当成果队列中数据已满时,生产者则面临因产品挤压导致CPU被剥夺的下岗问题。
5. 并发安全Lock
先看一个例子:对变量执行2000次+1操作,5个协程并发执行
var (
x int64
lock sync.Mutex
)
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}
func addWithoutLock() {
for i := 0; i < 2000; i++ {
x += 1
}
}
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)
}
运行结果:
对于上面的例子,预期结果应该是10000,但是未加锁的结果有概率不是10000。多个协程读取和访问一个共享数据时,就会发生这样的问题,为此就需要用到锁。
Go中sync包下的Mutex与RWMutex提供了互斥锁与读写锁两种实现,且提供了非常简单易用的API,加锁只需要Lock(),解锁也只需要Unlock()。需要注意的是,Go所提供的锁都是非递归锁,也就是不可重入锁,所以重复加锁或重复解锁都会导致fatal。锁的意义在于保护不变量,加锁是希望数据不会被其他协程修改。
6. WaitGroup
sync.WaitGroup是sync包下提供的一个结构体,WaitGroup即等待执行,使用它可以很轻易的实现等待一组协程的效果。该结构体只对外暴露三个方法。
Add方法用于指明要等待的协程的数量 func (wg *WaitGroup) Add(delta int)
Done方法表示当前协程已经执行完毕 func (wg *WaitGroup) Done()
Wait方法等待子协程结束,否则就阻塞 func (wg *WaitGroup) Wait()
WaitGroup使用起来十分简单,属于开箱即用。其内部的实现是计数器+信号量,程序开始时调用Add初始化计数,每当一个协程执行完毕时调用Done,计数就-1,直到减为0,而在此期间,主协程调用Wait会一直阻塞直到全部计数减为0,然后才会被唤醒。
看一个简单的使用案例:
func main(){
var wait sync.WaitGroup
// 指定子协程的数量
wait.Add(1)
go func() {
fmt.Println(1)
// 执行完毕
wait.Done()
}()
// 等待子协程
wait.Wait()
fmt.Println(2)
}
这段代码永远都是先输出1再输出2,主协程会等待子协程执行完毕后再退出。
再回到上文中2.Goroutine中打印Hello Goroutine的例子中,用WaitGroup实现协程的同步阻塞。
func ManyGoWait() {
var wg sync.WaitGroup
wg.Add(5) //计数器+5
//开启协程
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done() //每个协程执行完后,通过done对计数器减少1
hello(j)
}(i)
}
wg.Wait() //最后用主协程阻塞,计数器为0,退出主协程
}