在早期,CPU都是以单核的形式顺序执行机器指令。Go语言的祖先C语言正是这种顺序编程语言的表明。顺序编程语言中的顺序是指:全部的指令都是以串行的方式执行,在相同的时刻有且仅有一个CPU在顺序执行程序的指令。编程
随着处理器技术的发展,单核时代以提高处理器频率来提升运行效率的方式遇到了瓶颈,目前各类主流的CPU频率基本被锁定在了3GHZ附近。单核CPU的发展的停滞,给多核CPU的发展带来了机遇。相应地,编程语言也开始逐步向并行化的方向发展。Go语言正是在多核和网络化的时代背景下诞生的原生支持并发的编程语言。缓存
常见的并行编程有多种模型,主要有多线程、消息传递等。从理论上来看,多线程和基于消息的并发编程是等价的。因为多线程并发模型能够天然对应到多核的处理器,主流的操做系统所以也都提供了系统级的多线程支持,同时从概念上讲多线程彷佛也更直观,所以多线程编程模型逐步被吸纳到主流的编程语言特性或语言扩展库中。而主流编程语言对基于消息的并发编程模型支持则相比较少,Erlang语言是支持基于消息传递并发编程模型的表明者,它的并发体之间不共享内存。Go语言是基于消息并发模型的集大成者,它将基于CSP模型的并发编程内置到了语言中,经过一个go关键字就能够轻易地启动一个Goroutine,与Erlang不一样的是Go语言的Goroutine之间是共享内存的。安全
Goroutine是Go语言特有的并发体,是一种轻量级的线程,由go关键字启动。在真实的Go语言的实现中,goroutine和系统线程也不是等价的。尽管二者的区别实际上只是一个量的区别,但正是这个量变引起了Go语言并发编程质的飞跃。网络
首先,每一个系统级线程都会有一个固定大小的栈(通常默承认能是2MB),这个栈主要用来保存函数递归调用时参数和局部变量。固定了栈的大小致使了两个问题:一是对于不少只须要很小的栈空间的线程来讲是一个巨大的浪费,二是对于少数须要巨大栈空间的线程来讲又面临栈溢出的风险。针对这两个问题的解决方案是:要么下降固定的栈大小,提高空间的利用率;要么增大栈的大小以容许更深的函数递归调用,但这二者是无法同时兼得的。相反,一个Goroutine会以一个很小的栈启动(多是2KB或4KB),当遇到深度递归致使当前栈空间不足时,Goroutine会根据须要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)。由于启动的代价很小,因此咱们能够轻易地启动成千上万个Goroutine。多线程
Go的运行时还包含了其本身的调度器,这个调度器使用了一些技术手段,能够在n个操做系统线程上多工调度m个Goroutine。Go调度器的工做和内核的调度是类似的,可是这个调度器只关注单独的Go程序中的Goroutine。Goroutine采用的是半抢占式的协做调度,只有在当前Goroutine发生阻塞时才会致使调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个runtime.GOMAXPROCS
变量,用于控制当前运行正常非阻塞Goroutine的系统线程数目。并发
在Go语言中启动一个Goroutine不只和调用函数同样简单,并且Goroutine之间调度代价也很低,这些因素极大地促进了并发编程的流行和发展。编程语言
所谓的原子操做就是并发编程中“最小的且不可并行化”的操做。一般,若是多个并发体对同一个共享资源进行的操做是原子的话,那么同一时刻最多只能有一个并发体对该资源进行操做。从线程角度看,在当前线程修改共享资源期间,其它的线程是不能访问该资源的。原子操做对于多线程并发编程模型来讲,不会发生有别于单线程的意外状况,共享资源的完整性能够获得保证。函数
通常状况下,原子操做都是经过“互斥”访问来保证的,一般由特殊的CPU指令提供保护。固然,若是仅仅是想模拟下粗粒度的原子操做,咱们能够借助于sync.Mutex
来实现:性能
import (
"sync"
)
var total struct {
sync.Mutex
value int
}
func worker(wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i <= 100; i++ {
total.Lock()
total.value += i
total.Unlock()
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go worker(&wg)
go worker(&wg)
wg.Wait()
fmt.Println(total.value)
}
复制代码
在worker
的循环中,为了保证total.value += i
的原子性,咱们经过sync.Mutex
加锁和解锁来保证该语句在同一时刻只被一个线程访问。对于多线程模型的程序而言,进出临界区先后进行加锁和解锁都是必须的。若是没有锁的保护,total
的最终值将因为多线程之间的竞争而可能会不正确。ui
用互斥锁来保护一个数值型的共享资源,麻烦且效率低下。标准库的sync/atomic
包对原子操做提供了丰富的支持。咱们能够从新实现上面的例子:
import (
"sync"
"sync/atomic"
)
var total uint64
func worker(wg *sync.WaitGroup) {
defer wg.Done()
var i uint64
for i = 0; i <= 100; i++ {
atomic.AddUint64(&total, i)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go worker(&wg)
go worker(&wg)
wg.Wait()
}
复制代码
atomic.AddUint64
函数调用保证了total
的读取、更新和保存是一个原子操做,所以在多线程中访问也是安全的。
原子操做配合互斥锁能够实现很是高效的单件模式。互斥锁的代价比普通整数的原子读写高不少,在性能敏感的地方能够增长一个数字型的标志位,经过原子检测标志位状态下降互斥锁的使用次数来提升性能。
type singleton struct {}
var (
instance *singleton
initialized uint32
mu sync.Mutex
)
func Instance() *singleton {
if atomic.LoadUint32(&initialized) == 1 {
return instance
}
mu.Lock()
defer mu.Unlock()
if instance == nil {
defer atomic.StoreUint32(&initialized, 1)
instance = &singleton{}
}
return instance
}
复制代码
咱们能够将通用的代码提取出来,就成了标准库中sync.Once
的实现:
type Once struct {
m Mutex
done uint32
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 1 {
return
}
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
复制代码
基于sync.Once
从新实现单件模式:
var (
instance *singleton
once sync.Once
)
func Instance() *singleton {
once.Do(func() {
instance = &singleton{}
})
return instance
}
复制代码
sync/atomic
包对基本的数值类型及复杂对象的读写都提供了原子操做的支持。atomic.Value
原子对象提供了Load
和Store
两个原子方法,分别用于加载和保存数据,返回值和参数都是interface{}
类型,所以能够用于任意的自定义复杂类型。
var config atomic.Value // 保存当前配置信息
// 初始化配置信息
config.Store(loadConfig())
// 启动一个后台线程, 加载更新后的配置信息
go func() {
for {
time.Sleep(time.Second)
config.Store(loadConfig())
}
}()
// 用于处理请求的工做者线程始终采用最新的配置信息
for i := 0; i < 10; i++ {
go func() {
for r := range requests() {
c := config.Load()
// ...
}
}()
}
复制代码
这是一个简化的生产者消费者模型:后台线程生成最新的配置信息;前台多个工做者线程获取最新的配置信息。全部线程共享配置信息资源。
若是只是想简单地在线程之间进行数据同步的话,原子操做已经为编程人员提供了一些同步保障。不过这种保障有一个前提:顺序一致性的内存模型。要了解顺序一致性,咱们先看看一个简单的例子:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {}
print(a)
}
复制代码
咱们建立了setup
线程,用于对字符串a
的初始化工做,初始化完成以后设置done
标志为true
。main
函数所在的主线程中,经过for !done {}
检测done
变为true
时,认为字符串初始化工做完成,而后进行字符串的打印工做。
可是Go语言并不保证在main
函数中观测到的对done
的写入操做发生在对字符串a
的写入的操做以后,所以程序极可能打印一个空字符串。更糟糕的是,由于两个线程之间没有同步事件,setup
线程对done
的写入操做甚至没法被main
线程看到,main
函数有可能陷入死循环中。
在Go语言中,同一个Goroutine线程内部,顺序一致性内存模型是获得保证的。可是不一样的Goroutine之间,并不知足顺序一致性内存模型,须要经过明肯定义的同步事件来做为同步的参考。若是两个事件不可排序,那么就说这两个事件是并发的。为了最大化并行,Go语言的编译器和处理器在不影响上述规定的前提下可能会对执行语句从新排序(CPU也会对一些指令进行乱序执行)。
所以,若是在一个Goroutine中顺序执行a = 1; b = 2;
两个语句,虽然在当前的Goroutine中能够认为a = 1;
语句先于b = 2;
语句执行,可是在另外一个Goroutine中b = 2;
语句可能会先于a = 1;
语句执行,甚至在另外一个Goroutine中没法看到它们的变化(可能始终在寄存器中)。也就是说在另外一个Goroutine看来, a = 1; b = 2;
两个语句的执行顺序是不肯定的。若是一个并发程序没法肯定事件的顺序关系,那么程序的运行结果每每会有不肯定的结果。好比下面这个程序:
func main() {
go println("你好, 世界")
}
复制代码
根据Go语言规范,main
函数退出时程序结束,不会等待任何后台线程。由于Goroutine的执行和main
函数的返回事件是并发的,谁都有可能先发生,因此何时打印,可否打印都是未知的。
用前面的原子操做并不能解决问题,由于咱们没法肯定两个原子操做之间的顺序。解决问题的办法就是经过同步原语来给两个事件明确排序:
func main() {
done := make(chan int)
go func(){
println("你好, 世界")
done <- 1
}()
<-done
}
复制代码
当<-done
执行时,必然要求done <- 1
也已经执行。根据同一个Gorouine依然知足顺序一致性规则,咱们能够判断当done <- 1
执行时,println("你好, 世界")
语句必然已经执行完成了。所以,如今的程序确保能够正常打印结果。
固然,经过sync.Mutex
互斥量也是能够实现同步的:
func main() {
var mu sync.Mutex
mu.Lock()
go func(){
println("你好, 世界")
mu.Unlock()
}()
mu.Lock()
}
复制代码
能够肯定后台线程的mu.Unlock()
必然在println("你好, 世界")
完成后发生(同一个线程知足顺序一致性),main
函数的第二个mu.Lock()
必然在后台线程的mu.Unlock()
以后发生(sync.Mutex
保证),此时后台线程的打印工做已经顺利完成了。
前面函数章节中咱们已经简单介绍过程序的初始化顺序,这是属于Go语言面向并发的内存模型的基础规范。
Go程序的初始化和执行老是从main.main
函数开始的。可是若是main
包里导入了其它的包,则会按照顺序将它们包含进main
包里(这里的导入顺序依赖具体实现,通常多是以文件名或包路径名的字符串顺序导入)。若是某个包被屡次导入的话,在执行的时候只会导入一次。当一个包被导入时,若是它还导入了其它的包,则先将其它的包包含进来,而后建立和初始化这个包的常量和变量。而后就是调用包里的init
函数,若是一个包有多个init
函数的话,实现多是以文件名的顺序调用,同一个文件内的多个init
则是以出现的顺序依次调用(init
不是普通函数,能够定义有多个,因此不能被其它函数调用)。最终,在main
包的全部包常量、包变量被建立和初始化,而且init
函数被执行后,才会进入main.main
函数,程序开始正常执行
要注意的是,在main.main
函数执行以前全部代码都运行在同一个Goroutine中,也是运行在程序的主系统线程中。若是某个init
函数内部用go关键字启动了新的Goroutine的话,新的Goroutine和main.main
函数是并发执行的。
由于全部的init
函数和main
函数都是在主线程完成,它们也是知足顺序一致性模型的。
go
语句会在当前Goroutine对应函数返回前建立新的Goroutine. 例如:
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
复制代码
执行go f()
语句建立Goroutine和hello
函数是在同一个Goroutine中执行, 根据语句的书写顺序能够肯定Goroutine的建立发生在hello
函数返回以前, 可是新建立Goroutine对应的f()
的执行事件和hello
函数返回的事件则是不可排序的,也就是并发的。调用hello
可能会在未来的某一时刻打印"hello, world"
,也极可能是在hello
函数执行完成后才打印。
Channel通讯是在Goroutine之间进行同步的主要方法。在无缓存的Channel上的每一次发送操做都有与其对应的接收操做相配对,发送和接收操做一般发生在不一样的Goroutine上(在同一个Goroutine上执行2个操做很容易致使死锁)。无缓存的Channel上的发送操做总在对应的接收操做完成前发生.
var done = make(chan bool)
var msg string
func aGoroutine() {
msg = "你好, 世界"
done <- true
}
func main() {
go aGoroutine()
<-done
println(msg)
}
复制代码
可保证打印出“hello, world”。该程序首先对msg
进行写入,而后在done
管道上发送同步信号,随后从done
接收对应的同步信号,最后执行println
函数。
若在关闭Channel后继续从中接收数据,接收者就会收到该Channel返回的零值。所以在这个例子中,用close(c)
关闭管道代替done <- false
依然能保证该程序产生相同的行为。
var done = make(chan bool)
var msg string
func aGoroutine() {
msg = "你好, 世界"
close(done)
}
func main() {
go aGoroutine()
<-done
println(msg)
}
复制代码
对于从无缓冲Channel进行的接收,发生在对该Channel进行的发送完成以前。
基于上面这个规则可知,交换两个Goroutine中的接收和发送操做也是能够的(可是很危险):
var done = make(chan bool)
var msg string
func aGoroutine() {
msg = "hello, world"
<-done
}
func main() {
go aGoroutine()
done <- true
println(msg)
}
复制代码
也可保证打印出“hello, world”。由于main
线程中done <- true
发送完成前,后台线程<-done
接收已经开始,这保证msg = "hello, world"
被执行了,因此以后println(msg)
的msg已经被赋值过了。简而言之,后台线程首先对msg
进行写入,而后从done
中接收信号,随后main
线程向done
发送对应的信号,最后执行println
函数完成。可是,若该Channel为带缓冲的(例如,done = make(chan bool, 1)
),main
线程的done <- true
接收操做将不会被后台线程的<-done
接收操做阻塞,该程序将没法保证打印出“hello, world”。
对于带缓冲的Channel,对于Channel的第K
个接收完成操做发生在第K+C
个发送操做完成以前,其中C
是Channel的缓存大小。 若是将C
设置为0天然就对应无缓存的Channel,也即便第K个接收完成在第K个发送完成以前。由于无缓存的Channel只能同步发1个,也就简化为前面无缓存Channel的规则:对于从无缓冲Channel进行的接收,发生在对该Channel进行的发送完成以前。
咱们能够根据控制Channel的缓存大小来控制并发执行的Goroutine的最大数目, 例如:
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func() {
limit <- 1
w()
<-limit
}()
}
select{}
}
复制代码
最后一句select{}
是一个空的管道选择语句,该语句会致使main
线程阻塞,从而避免程序过早退出。还有for{}
、<-make(chan int)
等诸多方法能够达到相似的效果。由于main
线程被阻塞了,若是须要程序正常退出的话能够经过调用os.Exit(0)
实现。
前面咱们已经分析过,下面代码没法保证正常打印结果。实际的运行效果也是大几率不能正常输出结果。
func main() {
go println("你好, 世界")
}
复制代码
刚接触Go语言的话,可能但愿经过加入一个随机的休眠时间来保证正常的输出:
func main() {
go println("hello, world")
time.Sleep(time.Second)
}
复制代码
由于主线程休眠了1秒钟,所以这个程序大几率是能够正常输出结果的。所以,不少人会以为这个程序已经没有问题了。可是这个程序是不稳健的,依然有失败的可能性。咱们先假设程序是能够稳定输出结果的。由于Go线程的启动是非阻塞的,main
线程显式休眠了1秒钟退出致使程序结束,咱们能够近似地认为程序总共执行了1秒多时间。如今假设println
函数内部实现休眠的时间大于main
线程休眠的时间的话,就会致使矛盾:后台线程既然先于main
线程完成打印,那么执行时间确定是小于main
线程执行时间的。固然这是不可能的。
严谨的并发程序的正确性不该该是依赖于CPU的执行速度和休眠时间等不靠谱的因素的。严谨的并发也应该是能够静态推导出结果的:根据线程内顺序一致性,结合Channel或sync
同步事件的可排序性来推导,最终完成各个线程各段代码的偏序关系排序。若是两个事件没法根据此规则来排序,那么它们就是并发的,也就是执行前后顺序不可靠的。
解决同步问题的思路是相同的:使用显式的同步。
转自Go语言高级编程