Goroutines和Channels
并发程序指同时进行多个任务的程序,随着硬件的发展,并发程序变得越来越重要。web服务器会一次处理成千上万的请求。平板电脑和手机app在渲染用户画面同时还会后台执行各种计算任务和网络请求。即使是传统的批处理问题-读取数据,计算,写输出-现在也会用并发来隐藏掉I/O的操作延迟以充分利用现代计算机设备的多个核心。计算机的性能每年都在以非线性的速度增长。
golang中并发程序可以用俩种手段来实现,本章讲解goroutine和channe,其支持“顺序通信进程”(communicating sequential processes)或被简称为CSP。CSP是一种现代的并发编程模型,在这种编程模型中值会在不同的运行实例(goroutine)中传递,尽管大多数情况下任然是被限制在单一实例中。
Goroutine
goroutine和线程
goroutine和操作系统的线程区别可以忽略不记,俩者的区别实际上只是一个量上的区别,但是量变会引起质变的道理同样适用于goroutine和线程。
动态栈
每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。
而每一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB,一个goroutine的栈和操作系统线程一样,会保存其活跃或挂起的函数调用的本地变量。
但是和OS线程不太一样的是一个goroutine的栈大小并不是固定的,栈的大小会根据需要动态地伸缩。而goroutine栈的最大值有1GB,比传统的固定大小的线程栈要大得多,尽管一般情况下,大多goroutine都不需要这么大得栈。
goroutine调度
OS线程会被操作系统内核调度,每几毫秒,一个硬件计时器会中断处理器,这会调用一个叫做scheduler的内核函数,这个函数会挂起当前执行的线程并保存内存中它的寄存器内容,并检查线程列表并决定下一次哪个线程可以被运行,并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程。因为操作系统线程是被内核所调度,所以从一个线程恢复执行该线程的现场并开始执行线程。
因为操作系统线程是被内核所调度,所以从一个线程向另一个“移动”需要完整的上下文切换,也就是说保存一个用户线程的状态到内存,恢复另一个线程到寄存器,然后更新调度器的数据结构。这几步操作很慢,因为其局限性很差需要几次内存访问,并且会增加运行的cpu周期。
Go的运行时包含了其自己的调度器,这个调度器使用了一些技术手段,比如m:n调度,因为其会在n个操作系统线程上多工调度m个goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只会关注单独Go程序中的goroutine。
和操作系统的线程调度不同的是,go调度器并不是用一个硬件定时器而是被go语言”建筑“本身进行调度的。例如当一个goroutine调用了time.Sleep或者被channel调用或者mutex操作阻塞时,调度器会使其进入休眠并开始执行另一个goroutine直到时机到了再去唤醒第一goroutine。因为这种调度方式不需要进入内核的上下文,所以重新调度一个goroutine比调度一个线程代价要低得多。
并发版本的hello world
使用 sync.mutex 实现
func main() {
var mu sync.mutex
mu.Lock()
go func() {
fmt.Println("hello world")
mu.Unlock()
}()
mu.Lock()
}
在第二次执行Lock() 锁时,由于锁已经被占用所以main函数会阻塞,此时main函数的阻塞会驱使goroutine继续,当goroutine执行完毕时,一个锁会打开,第二锁会继续锁住,main函数会继续执行。
使用无缓冲通道channel 实现
使用互斥锁是比较低级的做法,现在使用无缓冲的管道实现。
func main() {
done := make(chan int)
go func() {
fmt.Println("hello world")
<- done
}
done <- 1
}
// 调换位置也可以实现
上面中,在最后一条语句向done发送一条1,由于done是无缓冲的,所以它会一直等待done接收才会向下执行。
根据go语言内存模型规范,对于无缓冲Channel进行的接收,发生在对该Channel进行的发送完成之前,因此,后台线程 <-done 接收操作完成之后, main线程的 done <- 1 发送操作才可能完成(从而退出main, 退出程序), 而此时打印工作已经完成了。
使用有缓存的通道
上面无缓冲的通道虽然可以正常同步,但是对管道的缓存大小敏感,如果管道有缓存的话,就无法保证main退出之前后台线程能正常打印了。
更好的做法是管道的发送和接收方向调换一下,这样可以避免同步事件受管道缓存大小的影像。
func main() {
done := make(chan int, 1)
go func() {
fmt.Println("hello world")
done <- 1
}
<-done
}
对于带缓冲的Channel,对于Channel 的第K个接收完成操作发生在第K+C个发送操作完成之前,其中C是Channel的缓存太小,虽然管道是带缓存的,main 线程接收完成是在后台线程发送开始但还未完成的时刻,此时打印工作也是已经完成的。
N个缓存的通道
基于带缓存的管道,我们可以很容易将打印线程扩展到N个。下面的例子是开启10个后台线程分别打印
func main() {
done := make(chan int, 10)
// 开N个后台打印缓存
for i := 0; i < cap(done); i++ {
go func() {
fmt.Println("hello world")
done <- 1
}()
}
// 等待N 个后台线程完成
for i := 0; i < cap(done); i++ {
<- done
}
}
使用 sync.WaitGroup 来实现
func main() {
var wg sync.WaitGroup
// 开N个后台打印线程
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
fmt.Println("hello world")
wg.Done()
}()
// 等待N个后台线程完成
wg.Wait()
}
}
其中 wg.Add(1) 用于增加等待事件的个数,必须确保在后台线程启动之前启动,当后台线程完成打印工作之后,调用 wg.Done() 表示完成一个事件。main 函数的wg.Wait() 是等待全部的事件完成。