Go的并发

并发和并行

  • 并发:同一时间段内执行多个任务
  • 并行:同一时刻执行多个任务

goroutine类似于线程,属于用户态的线程,我们可以根据需要创建成千上万个goroutine并发工作

goroutine是由Go语言的运行时(runtime)调度完成,而线程是由操作系统调度完成

channel在多个goroutine间进行通信

goroutine

goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的

Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU

Go语言在语言层面已经内置了调度和上下文切换的机制

当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数

使用goroutine

  1. Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine
  2. 一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数

启动单个goroutine

func hello() {
  fmt.Println("Hello")
}
func main() {
  go hello()
  fmt.Println("main goroutine done!")
  time.Sleep(time.Second)
}
  • Go程序就会为main()函数创建一个默认的goroutine
  • main()结束后,在main()函数中启动的goroutine会一同结束
  • 先打印"main goroutine done!"是因为创建hello函数的goroutine需要一点时间

启动多个goroutine

使用sync.WaitGroup来实现goroutine的同步

var wg sync.WaitGroup
func hello(i int) {
  defer wg.Done()       // goroutine结束就登记-1
  fmt.Println("Hello:", i)
}
func main() {
  for i:= 0; i < 10; i++ {
    wg.Add(1)          // 启动一个goroutine就+1
    go hello(i)
  }
  wg.Wait()            // 等所有登记的goroutine都结束
}

每次打印的数字的顺序都不一致,这是因为10个goroutine是并发执行的,而goroutine的调度是随机的

goroutine与线程

可增长的栈

  1. OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB)

  2. 一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB)

  3. goroutine的栈大小限制可以达到1GB

  4. 在Go语言中一次创建十万左右的goroutine也是可以的

goroutine调度

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统

  • G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息
  • P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务
  • M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的

P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M

goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多

GOMAXPROCS

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数

GOMAXPROCS是m:n调度中的n

Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数

Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数

func a() {
        for i := 1; i < 10; i++ {
                fmt.Println("A:", i)
        }
}

func b() {
        for i := 1; i < 10; i++ {
                fmt.Println("B:", i)
        }
}

func main() {
        runtime.GOMAXPROCS(2)
        go a()
        go b()
        time.Sleep(time.Second)
}

将逻辑核心数设为2,此时两个任务并行执行

Go语言中的操作系统线程和goroutine的关系

  • 一个操作系统线程对应用户态多个goroutine
  • go程序可以同时使用多个操作系统线程
  • goroutine和OS线程是多对多的关系,即m:n

channel类型

channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制

Go 语言中的通道(channel)是一种特殊的类型,一种引用类型

var 变量 chan 元素类型
var ch1 chan int   // 声明一个传递整型的通道
var ch2 chan bool  // 声明一个传递布尔型的通道
var ch3 chan []int // 声明一个传递int切片的通道

创建channel

通道是引用类型,通道类型的空值是nil

声明的通道后需要使用make函数初始化之后才能使用

make(chan 元素类型, [缓冲大小])
ch4 := make(chan int)
ch5 := make(chan bool)
ch6 := make(chan []int)

channel操作

ch := make(chan int)
ch <- 10 // 把10发送到ch中
x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果
close(ch)
  • 对一个关闭的通道再发送值就会导致panic
  • 对一个关闭的通道进行接收会一直获取值直到通道为空
  • 一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值
  • 关闭一个已经关闭的通道会导致panic

无缓冲通道

func recv(c chan int) {
  ret := <-c
  fmt.Println("接收成功:", ret)
}
func main() {
  ch := make(chan int)
  go recv(ch)    // 先有接收者,ch <- 10才能发送
  ch <- 10
  fmt.Println("发送成功")
}

无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道

有缓冲通道

func main() {
  ch := make(chan int, 1)
  ch <- 10
  fmt.Println("发送成功")
}

可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,虽然我们很少会这么做

就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个

for range从通道中取值

func main() {
  ch1 := make(chan int)
  ch2 := make(chan int)
  // 开启goroutine将0到100的数发乳ch1中
  go func() {
    for i := 0; i < 100; i++ {
      ch1 <- i
    }
    close(ch1)
  }()
  // 开启goroutine从ch1取值,并将该值的平方发到ch2中
  go func() {
    for {
      i, ok := <-ch1  // 通道关闭后再取值时ok为false
      if !ok{
        break
      }
      ch2 <- i*i
    }
    close(ch2)
  }()
  // 在主goroutine中取ch2的值并打印
  for i := range ch2{     // ch2关闭后会退出for range循环
    fmt.Println(i)
  }
}

有两种方式在接收值的时候判断该通道是否被关闭,不过我们通常使用的是for range的方式。使用for range遍历通道,当通道被关闭的时候就会退出for range

单项通道

有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收

func counter(out chan<- int) {
 for i := 0; i < 100; i++ {
   out <- int
 }
 close(out)
}
func squarer(out chan<- int, in <-chan int) {
 for i := range in {
   out <-  i*i
 }
 // in <- 5      // 会报错,in只能发送,不能接收
 close(out)
}
func printer(in <-chan int) {
 for i := range in {
   fmt.Println(i)
 }
}
func main() {
 ch1 := make(chan int)
 ch2 := make(chan int)
 go count(ch1)
 go count(ch2, ch1)
 printer(ch2)
}
  • chan<- int是一个只能发送的通道,可以发送但是不能接收
  • <-chan int是一个只能接收的通道,可以接收但是不能发送

在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,但反过来是不可以的

通道总结

channel常见的异常总结

channelnil非空空的满了没满
接收阻塞接收值阻塞接收值接收值
发送阻塞发送值发送值阻塞发送值
关闭pannic关闭成功,读完数据后返回零值关闭成功,返回零值关闭成功,读完数据后返回零值关闭成功,读完数据后返回零值

worker pool(goroutine池)

在工作中我们通常会使用可以指定启动的goroutine数量–worker pool模式,控制goroutine的数量,防止goroutine泄漏和暴涨

func worker(id int, jobs <-chan int, results chan<- int) {
  for j := range jobs {
    fmt.Printf("worker:%d start job:%d\n", id, j)
    time.Sleep(time.Second)
    fmt.Printf("worker:%d end job:%d\n", id, j)
    results <- j * 2
  }
}
func main() {
  jobs := make(chan int, 100)
  results := make(chan int, 100)
  // 开启3个goroutine
  for w := 0; w < 3; w++ {
    go worker(w, jobs, results)
  }
  // 5个任务
  for j := 0; j < 5; j++ {
    jobs <- j
  }
  close(jobs)
  // 输出结果
  for a := 0; a < 5; a++ {
    <-results
  }
}

select多路复用

在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现

for{
  // 尝试从ch1接收值
  data, ok1 := <-ch1
  if ok1 {
    fmt.Println(data)
  }
  // 尝试从ch2接收值
  data, ok2 := <-ch2
  if ok2 {
    fmt.Println(data)
  }
  …
}

这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多 X

select的使用类似于switch语句,它有一系列case分支和一个默认的分支。每个case会对应一个通道的通信(接收或发送)过程

select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句

/*
select{
    case <-ch1:
        ...
    case data := <-ch2:
        ...
    case ch3<-data:
        ...
    default:
        默认操作
}
*/

func main() {
  ch := make(chan int, 1)
  for i := 0; i < 10; i ++ {
    select {
      case x := <-ch:
      fmt.Println(x)
      case ch <- i:
    }
  }
}
  • 可处理一个或多个channel的发送/接收操作
  • 如果多个case同时满足,select会随机选择一个
  • 对于没有caseselect{}会一直等待,可用于阻塞main函数

并发安全和锁

var x int64
var wg sync.WaitGroup
func add() {
  for i := 0; i < 5000; i++ {
    x += 1
  }
  wg.Done()
}
func main() {
  wg.Add(2)
  go add()
  go add()
  wg.Wait()
  fmt.Println(x)
}
// 会发现每次的输出都不一样

互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。 使用互斥锁来修复上面代码的问题

var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
  for i := 0; i < 5000; i++ {
    lock.Lock()    // 加锁
    x += 1
    lock.Unlock()  // 解锁
  }
  wg.Done()
}
func main() {
  wg.Add(2)
  go add()
  go add()
  wg.Wait()
  fmt.Println(x)
}

使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的

读写互斥锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型

读写锁分为两种:读锁和写锁

当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待

当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待

var x int64
var wg sync.WaitGroup
var lock sync.Mutex
var rwlock sync.RWMutex
func write() {
  rwlock.Lock()  // 加写锁
  x += 1
  time.Sleep(10 * time.Millisecond)   // 假设写操作用10毫秒
  rwlock.Unlock()
  wg.Done()
}
func read() {
  rwlock.Lock()  // 加读锁
  time.Sleep(time.Millisecond)   // 假设读操作用1毫秒
  rwlock.Unlock()
  wg.Done()
}

func main() {
  start := time.Now()
  for i := 0; i < 10; i++ {
    wg.Add(1)
    go write()
  }
  for i := 0; i < 1000; i++ {
    wg.Add(1)
    go read()
  }
  wg.Wait()
  end := time.Time()
  fmt.Println(end.Sub(start))
}

sync.WaitGroup

在代码中生硬的使用time.Sleep肯定是不合适的,Go语言中可以使用sync.WaitGroup来实现并发任务的同步

方法名功能
(wg * WaitGroup) Add(delta int)计数器+delta
(wg *WaitGroup) Done()计数器-1
(wg *WaitGroup) Wait()阻塞直到计数器变为0

sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成

var wg sync.WaitGroup
func hello() {
  defer wg.Done()
  fmt.Println("Hello Groutine")
}
func main() {
  wg.Add(1)
  go hello()   // 启动一个goroutine去执行hello函数
  fmt.Println("main gotoutine done")
  wg.Wait()
}

sync.WaitGroup是一个结构体,传递的时候要传递指针

sync.Once

在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等

func (o *Once) Do(f func()) {}

如果要执行的函数f需要传递参数就需要搭配闭包来使用

var icons map[string]image.Image
func loadIcons() {
  icons = map[string]image.Image{
    "left": loadIcon("left.png"),
    "up": loadIcon("up.png"),
    "right": loadIcon("right.png"),
    "down": loadIcon("down.png"),
  }
}
// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
  if icons == nil{
    loadIcons()
  }
  return icons[name]
}

多个goroutine并发调用Icon函数时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons函数可能会被重排为以下结果

func loadIcons() {
        icons = make(map[string]image.Image)
        icons["left"] = loadIcon("left.png")
        icons["up"] = loadIcon("up.png")
        icons["right"] = loadIcon("right.png")
        icons["down"] = loadIcon("down.png")
}

在这种情况下就会出现即使判断了icons不是nil也不意味着变量初始化完成了。考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons的时候不会被其他的goroutine操作,但是这样做又会引发性能问题

使用sync.Once

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
        icons = map[string]image.Image{
                "left":  loadIcon("left.png"),
                "up":    loadIcon("up.png"),
                "right": loadIcon("right.png"),
                "down":  loadIcon("down.png"),
        }
}

// Icon 是并发安全的
func Icon(name string) image.Image {
        loadIconsOnce.Do(loadIcons)
        return icons[name]
}

并发安全的单例模式

package singletion
import "sync"
type singleton struct {}
var instance * singleton
var once sync.Once
func GetInstance() *singleton {
  coce.Do(func() {
    instance = &singleton{}
  })
  return instance
}

sync.Once其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次

sync.Map

go中的map不是并发安全的

var m = make(map[string]int)
func get(key string) int {
  return m[key]
}
func set(key string, value int) {
  m[key] = value
}
func main() {
  wg := sync.WaitGroup{}
  for i := 0; i < 20; i++ {
    wg.Add(1)
    go func(n int) {
      key := strconv.Iton(n)
      set(key, n)
      fmt.Printf("k=:%v, v:=%v\n", key, get(key))
      wg.Done()
    }(i)
  }
  wg.Wait()
}

上面的代码开启少量几个goroutine的时候可能没什么问题,当并发多了之后执行上面的代码就会报fatal error: concurrent map writes错误

像这种场景下就需要为map加锁来保证并发的安全性了,Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如StoreLoadLoadOrStoreDeleteRange等操作方法

var m = sync.Map{}
func main() {
  wg := sync.WaitGruop{}
  for i := 0; i < 20; i++ {
    wg.Add(1)
    go func(n int){
      key := strconv.Itoa(n)
      value, _ := m.Load(key)
      fmt.Printf("k=:%v, v:=%v\n", key, value)
      wg.Done
    }(i)
  }
  wg.Wait()
}

原子操作

代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好。Go语言中原子操作由内置的标准库sync/atomic提供

atomic包

方法解释
func LoadInt32(addr *int32) (val int32) func LoadInt64(addr *int64) (val int64) func LoadUint32(addr *uint32) (val uint32) func LoadUint64(addr *uint64) (val uint64) func LoadUintptr(addr *uintptr) (val uintptr) func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)读取操作
func StoreInt32(addr *int32, val int32) func StoreInt64(addr *int64, val int64) func StoreUint32(addr *uint32, val uint32) func StoreUint64(addr *uint64, val uint64) func StoreUintptr(addr *uintptr, val uintptr) func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)写入操作
func AddInt32(addr *int32, delta int32) (new int32) func AddInt64(addr *int64, delta int64) (new int64) func AddUint32(addr *uint32, delta uint32) (new uint32) func AddUint64(addr *uint64, delta uint64) (new uint64) func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)修改操作
func SwapInt32(addr *int32, new int32) (old int32) func SwapInt64(addr *int64, new int64) (old int64) func SwapUint32(addr *uint32, new uint32) (old uint32) func SwapUint64(addr *uint64, new uint64) (old uint64) func SwapUintptr(addr *uintptr, new uintptr) (old uintptr) func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool) func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool) func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool) func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool) func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)比较并交换操作

例子

type Count interface {
  Inc()
  Load() int64
}
// 普通版
type CommonCounter struct {
  counter int64
}
func (c CommonCounter) Inc(){
  c.counter++
}
func (c CommonCounter) Load(){
  return c.counter
}

// 互斥锁版
type MutexCounter struct {
  counter int64
  lock sync.Mutex
}
func (m *MutexCounter) Inc() {
  m.lock.Lock()
  defer m.lock.Lock()
  m.counter++
}

// 原子操作版
type AtomicCounter struct {
  counter int64
}

func (a *AtomicCounter) Inc() {
        atomic.AddInt64(&a.counter, 1)
}

func (a *AtomicCounter) Load() int64 {
        return atomic.LoadInt64(&a.counter)
}

func test(c Counter) {
        var wg sync.WaitGroup
        start := time.Now()
        for i := 0; i < 1000; i++ {
                wg.Add(1)
                go func() {
                        c.Inc()
                        wg.Done()
                }()
        }
        wg.Wait()
        end := time.Now()
        fmt.Println(c.Load(), end.Sub(start))
}

func main() {
        c1 := CommonCounter{} // 非并发安全
        test(c1)
        c2 := MutexCounter{} // 使用互斥锁实现并发安全
        test(&c2)
        c3 := AtomicCounter{} // 并发安全且比互斥锁效率更高
        test(&c3)
}

atomic包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好