Go语言高并发系列二:Go语言并发基础

237 阅读9分钟

上一篇文章《Go语言高并发系列一:基础理论》介绍了进程、线程、协程各个级别的并发模式和GPM调度器,我们知道了Go语言并发的底层思想。

这篇文章我们来更深入的学习Go语言的并发相关内容,相关知识点会比较多,所以本文会稍稍较长。

channel

什么是channel?

channel在goroutine之间架起管道,实现goroutine间的同步和通信。
因为Go语言的并发模型是CSP,提倡通过通信来共享内存,而不是通过共享内存实现通信。可以说channel就是goroutine间的通信机制。

channel的一些特性:

  • channel是goroutine-safe的,可以多个goroutine同时访问。
  • channel可以在goroutine间传递数据
  • channel总是遵循先进先出的原则,保证收发数据的顺序。
  • channel可以导致goroutine的阻塞

简单说下channel结构

type hchan struct {
    // chan 里元素数量
    qcount   uint
    // chan 底层循环数组的长度
    dataqsiz uint
    // 指向底层循环数组的指针
    // 只针对有缓冲的 channel
    buf      unsafe.Pointer
    // chan 中元素大小
    elemsize uint16
    // chan 是否被关闭的标志
    closed   uint32
    // chan 中元素类型
    elemtype *_type // element type
    // 已发送元素在循环数组中的索引
    sendx    uint   // send index
    // 已接收元素在循环数组中的索引
    recvx    uint   // receive index
    // 等待接收的 goroutine 队列
    recvq    waitq  // list of recv waiters
    // 等待发送的 goroutine 队列
    sendq    waitq  // list of send waiters

    // 保护 hchan 中所有字段
    lock mutex
}

我们可以看到channel底层也是通过mutex来保证操作这些结构的并发安全。

创建一个channel

// 格式
var 变量 chan 元素类型

// 声明
var a chan int
var b chan string
var c chan byte
var d chan []string
var e chan []int

// 无缓冲
f := make(chan []int)
// 带缓冲
g := make(chan []int, 10)

两种channel

channel分为两种:无缓冲,带缓冲,。
不带缓冲的可以看做”同步模式“,带缓冲的可以看做”异步模式“。

无缓冲channel

无缓冲的channel里是不能存储数据的,数据的发送和接收必须同步进行,否则就会进入阻塞状态,直到数据被接收。
实验代码如下:

func produce(a chan<- int) {
   for i := 0; i < 3; i++ {
      a <- i
      fmt.Println("produce data: ", i)
   }
}

func consume(b <-chan int) {
    for true {
       val := <-b
       fmt.Println("consume data: ", val)
       <-time.After(time.Second)
    }
}

func main() {
   ch := make(chan int)

   go produce(ch)
   go consume(ch)

   <-time.After(3 * time.Second)
}

输出结果:

consume data:  0
produce data:  0
consume data:  1
produce data:  1
consume data:  2
produce data:  2

我们可以看到produce和consume是按顺序依次进行的,上一条数据被接收了,才能继续发送下一条数据。

带缓冲channel

带缓冲的channel里能存储1个或多条数据。
不要求数据的发送和接收必须同步进行。只有在channel没有可用可用缓冲区容纳新数据时,发送方才会阻塞。只有在没有数据可接收时,接收方才会阻塞。

我们只需要改动一行上面无缓冲channel的实验代码即可:

...

ch := make(chan int, 3)

...

输出结果:

produce data:  0
produce data:  1
produce data:  2
consume data:  0
consume data:  1
consume data:  2

我们可以看到produce程序不需要等待,直接把0、1、2写入了channel。

单向channel

有时候,我们有一些特殊的需求,要限制一个channel只能接收或者只能发送,我们称这种为单向channel。 声明方式如下:

send := make(chan<- int)  //只能发送
receive := make(<-chan int)  //只能接收

肯定有心细的同学已经发现了,我们上面的实验代码里procuce和consume的参数,就是定义的单向channel。

...
func produce(a chan<- int) {
...
func consume(b <-chan int) {
...

select

假如我们有多个channel需要等待。比如我们启动5个goroutine从网上下载内容,并发把结果发送到5个channel里,哪个先下好,就先处理哪个channel的数据。

如果我们依次去尝试获取channel的数据,在第一个就会被阻塞,无法同时查看后面4个的结果。这个时候就可以用select同时监听。格式如下:

select {
case <-ch1: 
    ...
case data := <-ch2:
    ...
case ch3 <- data: //等待data发送到ch3
    ...
default:
    默认操作
}

N个channel中,任意一个channel有数据产生,select都可以监听到,并执行相应的操作。当未监听到数据时,执行default,如果没有定义default,就会进入阻塞状态。

持续接收数据

我们有两种方式持续从channel接收数据,如果channel没有数据就阻塞等待。

range
示例代码如下:

ch := make(chan int)
go func() {
   for i := 0; i < 3; i++ {
      ch <- i
   }
   close(ch)
}()

for val := range ch {
   fmt.Println(val)
}

for循环
示例代码如下:

ch := make(chan int)
go func() {
   for i := 0; i < 3; i++ {
      ch <- i
   }
   close(ch)
}()

for {
   val, ok := <-ch
   if ok == false {
      break 
   }
   fmt.Println("get val: ", val)
}

需要注意的是,这两种遍历方式,发送者没有关闭channel或者在range关闭,都会导致死锁(deadlock)。

关闭

channel可以关闭。关闭方式:

close(ch)

判断channel是否关闭:

v, ok := <-ch  // 如果 ok==false,则channel已关闭

关闭nil channel会导致panic。
写入已经关闭的 channel 会导致panic。
读取已经关闭的 channel 会读到零值。

注意事项总结

使用channel过程中,有一些细节如果没有注意到的话,会导致panic或者死锁。
下面总结了几点注意事项:

  • 读、写、关闭未初始化的channel,会导致死锁或panic
var ch chan int
<-ch      //读取未初始化的channel
ch <- 1   //写入未初始化的channel
close(ch) //关闭未初始化的channel
  • 无缓冲channel,只有读 或者 只有写。会导致死锁
ch := make(chan int)
ch <- 4
ch := make(chan int)
<- ch
  • channel未正确的关闭,会导致死锁
ch := make(chan int)
go func() {
   for i := 0; i < 3; i++ {
      ch <- i
   }
   //close(ch) 此处不关闭channel
}()

for val := range ch {
   fmt.Println(val)
}
  • 未正确判断channel关闭状态,可能导致死循环
ch := make(chan int)
go func() {
   for i := 0; i < 3; i++ {
      ch <- i
   }
   close(ch)
}()

for {
   val := <-ch //正确方式:val,ok := <-ch 。判断ok==true时,break
   fmt.Println("get val: ", val)
}

sync并发控制

sync包为我们提供了并发编程控制需要的一些功能。下面讲解一些经常用到的功能。

sync.waitGroup

当我们有一组goroutine需要执行,在主goroutine需要等待它们全部执行完成时,可以使用 waitGroup来进行控制。
示例代码:

var wg sync.WaitGroup

for i := 0; i < 3; i++ {
   // 计数加 1
   wg.Add(1)
   go func(i int) {
      // 计数减 1
      defer wg.Done()
      time.Sleep(time.Duration(i) * time.Second)
      fmt.Println(fmt.Sprintf("goroutine%d 结束", i))
   }(i)
}

// 等待执行结束
wg.Wait()
fmt.Println("所有 goroutine 执行结束")

sync.Mutex

sync.Mutex:互斥锁 。一个互斥锁只能同时被一个goroutine锁定,其他goroutine将阻塞直到互斥锁被解锁,然后再重新争抢互斥锁。

什么时候会用到互斥锁呢?
当发生资源竞争的时候。比如同一内存块被多个goroutine同时访问,就可能造成无法预知的后果,需要用锁来进行控制。

下面举个累加求和的例子来感受一下:

var (
   sum = 0
   wg  sync.WaitGroup
)

func add() {
   defer wg.Done()

   for i := 0; i < 1000; i++ {
      sum += 1
   }
}

func main() {
   wg.Add(3)
   go add()
   go add()
   go add()

   wg.Wait()
   fmt.Println(sum)
}

输出结果:

2069

3个goroutine,每个加1000,最终结果应该是3000。为什么结果这么奇怪呢?
因为这里的sum对3个goroutine来说就是公共资源,它们同时对它进行加操作,造成了不可预知的后果。

下面我们来加上互斥锁,看看效果:

var (
   sum = 0
   wg  sync.WaitGroup
   m   sync.Mutex
)

func add() {
   defer wg.Done()

   m.Lock()
   defer m.Unlock()

   for i := 0; i < 1000; i++ {
      sum += 1
   }
}

func main() {
   wg.Add(3)
   go add()
   go add()
   go add()

   wg.Wait()
   fmt.Println(sum)
}

输出结果:

3000

sync.RWMutex

sync.RWMutex:读写锁。
读写锁相对于互斥锁最大的区别是:可以分别对读、写加锁。 一般用在读多写少的情况下,它的特点如下:

  • 同时只有一个goroutine获得写锁
  • 同时有任意多个goroutine获得读锁
  • 同时只能存在读锁或写锁(读锁与写锁互斥)

示例代码:


var m sync.RWMutex

func main() {
   for i := 0; i < 3; i++ {
      go read(1)
   }
   for j := 0; j < 3; j++ {
      go write(2)
   }
   for m := 0; m < 3; m++ {
      go read(3)
   }

   time.Sleep(10 * time.Second)
}

func read(i int) {
   m.RLock()
   defer m.RUnlock()

   fmt.Println("读: ", i)
   time.Sleep(1 * time.Second)
   fmt.Println("读结束")
}

func write(i int) {
   m.Lock()
   defer m.Unlock()

   fmt.Println("写: ", i)
   time.Sleep(1 * time.Second)
   fmt.Println("写结束")
}

输出结果:

读:  1
读:  1
读:  3
读结束
读结束
读结束
写:  2
写结束
读:  1
读:  3
读:  3
读结束
读结束
读结束
写:  2
写结束
写:  2
写结束

sync.Once

在实际工作中,我们会遇到让代码只执行一次的场景,比如说创建单例、只加载一次资源等。
此时我们可以用sync.Once来确保,即使在高并发的情况下,代码也只执行了一次。

var once sync.Once

func main() {
   once.Do(func() {
      fmt.Println("do once")
   })
}

sync.Map

Go语言中的 map 在并发情况下,只读是安全的,并发读写是不安全的。在并发场景下,我们可以使用并发读写安全的sync.Map。

var mm sync.Map

func main() {
   mm.Store("a", 1)
   
   v, ok := mm.Load("a")
   fmt.Println(v, ok)
   
   mm.Delete("a")
   
   mm.LoadOrStore("b", 2)
   
   //遍历,返回一个bool值,当返回false时,遍历立刻结束
   mm.Range(func(key, value interface{}) bool {
      return true
   })
   
}

sync.Cond

sync.Cond是用来发号施令,协调goroutine运行的工具。相比于sync.Mutex,它可以同时阻塞一组goroutine,直到sync.Cond主动通知它们运行。

举个栗子:

当一个goroutine正在读取数据,其他goroutine必须等待这个oroutine接收完数据,才能读取到正确的数据。这时如果使用互斥锁,只能有一个goroutine可以等待,并读取到数据,没办法通知其他oroutine也读取数据。

那有哪些办法呢?

  • 用一个全局变量标识第一个goroutine是否接收数据完毕,剩下的goroutine反复检查该变量的值,直到读取到数据。
  • 创建多个channel, 每个oroutine阻塞在一个channel上,接收数据的goroutine在数据接收完毕后,挨个通知。

我们用sync.Cond就可以解决这个问题,sync.Cond有三个方法:

  1. Wait,会自动释放锁c.L,然后阻塞当前goroutine。直到被广播或信号唤醒,然后重新给c.L加锁,并且继续执行 Wait 后面的代码。
  2. Signal,唤醒一个等待中的goroutine。
  3. Broadcast,唤醒所有等待中的goroutine。

示例代码:


func main() {

   cond := sync.NewCond(&sync.Mutex{})
   var wg sync.WaitGroup
   wg.Add(3)

   for i := 0; i < 2; i++ {
      go func(ii int) {
         defer wg.Done()

         fmt.Println(fmt.Sprintf("等待消息 :%d", ii))
         cond.L.Lock()
         defer cond.L.Unlock()

         cond.Wait()
         fmt.Println(fmt.Sprintf("收到消息 :%d", ii))
      }(i)
   }

   go func() {
      defer wg.Done()

      <-time.After(2 * time.Second)
      fmt.Println("数据读取完毕,请其他成员开始运行")
      cond.Broadcast()
   }()

   wg.Wait()
}

输出结果:

等待消息 :0
等待消息 :1
数据读取完毕,请其他成员开始运行
收到消息 :1
收到消息 :0

总结

本文主要讲了channel和sync包相关的知识点。
channel在goroutine之间架起管道,实现goroutine间的同步和通信。
sync包提供了丰富的并发控制工具。

下一篇文章我们来学习context相关知识。