序
Go语言在服务端程序的开发中能够以最简单高效的方式来解决问题,它用轻量级方法来处理并发,使程序性能大幅提高。对于服务端程序的开发工作中,开发人员最在意的并发及同步安全问题,Go语言提供了自已独特的解决方案。
并发的单位
进程
进程是操作系统结构的基础,操作系统进行资源分配和调度的基本单位。应用程序在运行中使用和维护各类资源的容器。
线程
线程是操作系统能够进行运算调度的最小单位。线程包含在进程中,进程实际分配CPU时间片进行运作的单位。
协程
协程类似子例程,是一种比线程更轻量级的单位。它在线程分配到的CPU时间片基础上由语言(虚拟机)进行调度控制。这样就避免了线程切换过程中的资源损耗,从而大大提高了程序的性能。
Go语言的并发实现使用的是goroutine,属于协程的一种。
1.goroutine
goroutine的简单使用
使用go关键字调用goroutine
例1-1 goroutine的伪代码
//调用命名函数 go myFunc() … //调用匿名函数 go func() { // TODO }() … |
例1-2 一个完整的调用:
package main
import ( "fmt" "time" )
func myGoroutine(c int) { for i:=0; i<5; i++ { fmt.Printf("myGoroutine-%c %d\n",c,i) time.Sleep(time.Microsecond) } }
func main() { go myGoroutine('A') go myGoroutine('B') // 注意这个sleep time.Sleep(time.Second) } |
例1-2中简单的定义了一函数,每隔一毫秒打出一行日志,在main函数中由A、B两个协程并发运行,分别打印5个A、5个B,共10行日志。
值得注意的是main函数中最后的sleep,如果没有这个语句,打印的日志会不完整(实际上最多打出一行,或者完全不输出日志)。这是因为goroutine是由Go语言内部调度的,执行完main函数,程序退出,所有协程就中断了。
为了解决这个问题,先简单地让程序在此休眠1秒,让2个协程能够运行完成,就可以看到期望的效果:打印10行日志。但是这也有个问题,在双核以上CPU的计算机上,这2个协程仅需5ms就运行完成了,后面sleep的995ms就浪费了。实际生产中,也很难知道每个协程具体运行所需要的时间。那么,如何让goroutine完整执行,又避免浪费呢?
WaitGroup
WaitGroup相当于是带阻塞的计数器。初始为零,调用Add加上需要的值,在关键节点调用Done来减1,Wait用于阻塞,等到计数器为零时,解除阻塞。
例1-3 使用WaitGroup等待goroutine完成
package main
import ( "fmt" "sync" "time" )
func myGoroutine(c int,wg *sync.WaitGroup) { for i:=0;i<5;i++ { fmt.Printf("myGoroutine-%c %d\n",c,i) time.Sleep(time.Microsecond) } (*wg).Done() }
func main() { var wg sync.WaitGroup wg.Add(2) go myGoroutine('A',&wg) go myGoroutine('B',&wg) //改用WaitGroup wg.Wait() } |
2.原子函数
当程序并发运行时,经常会遇到共享的内存区域,比如页面访问量的计数。并发中的每个协程需要从内存取出当前计数值,进行+1运算,然后写回内存(其实还会涉及CPU缓存的问题,在此先不考虑)。如下例所示,程序中有个共享的变量用于存放计数的值,有1,000,000个协程并发对它进行+1操作。
运行例2-1的代码,最后的计数一般达不到1,000,000,因为并发运行过程中,有一部分自增操作的取值不是前一个操作的运算结果。
例2-1 共享内存区域冲突
package main
import ( "fmt" "sync" )
func main() { var wg sync.WaitGroup wg.Add(1000000) cnt := 0 for i:=0; i<1000000; i++ { go func(){ cnt++ wg.Done() }() }
wg.Wait() println("count=",cnt) } |
这时,可以使用actomic包中的原子函数,来保证每个自增操作的运行从读取内存到写回内存是原子操作,每个自增操作读取到的值都是前一个操作的运算结果。
例2-2 原子自增
package main
import ( "fmt" "sync" )
func main() { var wg sync.WaitGroup wg.Add(1000000) var cnt int32 cnt = 0 for i:=0; i<1000000; i++ { go func(){ atomic.AddInt32(&cnt,1) wg.Done() }() }
wg.Wait() println("count=",cnt) } |
例2-2中使用了atomic.AddInt32原子自增函数,所有自增的并发不会互相冲突了。
3.互斥锁
Go语言中还提供了互斥锁,可以创建一个临界区,用以保证同一时间只有一个协程可以访问这个临界区,从而达到并发同步的目的。
例3-1 临界区代码块
package main
import ( "fmt" "sync" )
func main() { var wg sync.WaitGroup wg.Add(1000000) var cnt int32 cnt = 0 var mutexLock sync.Mutex for i:=0; i<1000000; i++ { go func(){ mutexLock.Lock() { cnt++ } mutexLock.Unlock() wg.Done() }() }
wg.Wait() println("count=",cnt) } |
4.通道
使用原子函数或者互斥锁都可以实现并发中的同步,但是高并发中的关键资源的无序竞争会降低资源利用率,也有可能引起协程饥饿的情况发生。为了让资源的竞争有序起来,使用Go语言中的通道作为通信机制,可以提高资源利用率。
通道类似于队列,在通道的右端发送数据(channel <- sendBuff),通道的左端接收数据(recvBuff := <- channel),通信数据遵循队列的FIFO规则。
Go语言的通道默认只能往里发一个元素,在该元素被读取之前,发送端会阻塞。可以根据实际需要指定缓冲区的大小,发送端可以一直往通道发送数据,直到缓冲区被放满才会发生阻塞。
例4-1 倒茶与喝茶
package main
import ( "fmt" "sync" )
func main() {
var wg sync.WaitGroup wg.Add(2)
// 缓冲区大小为3,一共可以放置4杯茶 ch := make(chan int,3)
go drinkTea(ch,&wg) go makeTea(ch,&wg)
wg.Wait() fmt.Println("done.") }
// 每10毫秒倒好一杯茶 func makeTea(ch chan int,wg *sync.WaitGroup) { for i :='A';i<='Z';i++ { fmt.Printf("make=%c\n",i) ch <- int(i) time.Sleep(10*time.Millisecond) } fmt.Println("make is done.") (*wg).Done() close(ch) }
// 每50毫秒喝掉一杯茶 func drinkTea(ch chan int,wg *sync.WaitGroup) { for c := range ch { fmt.Printf("drink=%c\n",c) time.Sleep(50*time.Millisecond) } fmt.Println("drink is done.") (*wg).Done() }
|
如例4-1所示,生产的速度远大于消费的速度,在通道缓冲区满了之后,生产协程会发生阻塞,直到通道中的元素被消费,才会再生产。
另外,值得一提的是,通道可以关闭。关闭了的通道不能接收生产,但缓冲区的元素还可以被消费直到通道中没有任何元素,才会被真正回收。
总结
并发间的安全一直都是开发人员最关注的问题之一,比如事务的ACID原则。在正确处理好临界资源,让各个并发单位都能正常执行,保证数据准确,并在此基础上提高程序运行效率。Go语言提供了很简单、直接的方法解决并发间同步问题,因此受到很多服务端程序的亲睐,成为Web服务端开发语言的新星。