背景

不要通过共享内存来通信,而要通过通信来共享内存

协程

golang 中提供 goroutine(协程)机制,写成和操作系统线程并无一对一关系,协程工作在相同的地址空间,go 中通过 channel 来同步协程,协程不是由操作系统内核来控制,而是完全由程序来控制

channel 中如果ch中的数据无人接收,就无法再给通道传入其他数据,即 channel 只能接收一个数据,再接收就会被阻塞,被等待输出

无缓冲的 channel 通道在接收或者释放数据时候所在协程会被阻塞,有缓冲通道在接收或者释放数据时候所在协程不会被阻塞

竞态与竞态条件

golang 中 2 或多个协程竞争同一资源时候称为竞态,如果这些协程对访问同一资源的顺序敏感,那么就称这些写成存在竞态条件

关于信道和锁机制

在 golang 中信道地位很高,面对并发编程有限考虑使用信道,如果信道没法解决的,那只能依靠 golang 中的锁机制了

实际研发过程

实际业务研发中,errgroup 和 context 用的会比较多,channel 会用的比较少,channel 用到地方再任务分发时候会比较多

基本概念

并行与并发

并行是相对于多核 CPU 来说,并发是程序在调度器的作用下通过切换时间片来运行并发程序。在多核 CPU 下,并发程序可以以并行的方式运行。下面函数决定了 runtime 调度器逻辑处理器的数量,runtime 逻辑处理器可能会和操作系统的线程进行绑定,在多核 cpu 下,线程会在多个 cpu 内并行运行,这样就能让 goroutine 实现多核 cpu 的并行。而 GOMAXPROCS 可以设置 runtime 逻辑处理器的数量,一个逻辑处理器是一个队列,其中可以塞多个 goroutine

// 设置多核 CPU
runtime.GOMAXPROCS(1)

用并发实现并行,执行效率不一定就比非并发程序执行效率高,是因为并发程序中进行1.协程上下文切换2.数据同步,这些都是需要消耗资源的

简单理解:多核 cpu 去跑多个线程为并行,单核 cpu 去跑多线程即并发

死锁概念

一个或多个 goroutine 被阻塞后永远无法解除阻塞状态就被称为死锁。引发死锁的程序可能会非常简单,死锁虽然很难杜绝但是可以通过一些简单的规则来让我们创建出不会死锁的代码

// 死锁代码
func main() {
	c := make(chan int)
	<- c 
}

在使用 golang 的互斥锁时候是有可能触发死锁的,比如说 1 协程用互斥锁 A 锁住 m 变量,然后 1 协程执行,发现 n 变量被 B 互斥锁锁住了,要想使用 n 变量就要等待 2 协程释放互斥锁 B,但是 2 协程也很难,因为 2 协程此时正等待 m 变量的释放,这样它才能释放 n…

goroutine 泄漏(非正常退出协程)

如果你启动了一个goroutine,但并没有按照预期的一样退出,直到程序结束,此goroutine才结束,这种情况就是 goroutine 泄露,当 goroutine 泄露发生时,该 goroutine 的栈一直被占用而不能释放,goroutine 里的函数在堆上申请的空间也不能被垃圾回收器回收。这样,在程序运行期间,内存占用持续升高,可用内存越来也少,最终将导致系统崩溃

goroutine 泄漏主要原因分为两类:

  • chan 阻塞
  • goroutine 陷入死循环

并发安全

并发安全的核心就是保证读操作之前再没有其他未预期的写操作时,才能保证这次读操作是有效的

data race 数据竞争实际上是两个或多个 goroutine 同时访问同一个资源产生的数据竞争问题,go 可以使用如下命令来检测代码是否存在 data race,不建议在生产环境使用

// 编译代码成可执行程序验证是否有 data race
go build -race go文件

// 单元测试时候检测是否有 data race
go test -race go文件
goroutine 基本使用

chan

下面展示的是一个很常规的使用 goroutine 协程配合 channel 的例子

// 经常见到的使用 go 关键词还有 chan 变量的例子
func main() {
  c := make(chan int)
  for i := 0; i < 5; i++ {
    // 循环开启 5 个 生产者,共用 c 一个通道空间
    go foo(i, c)
  }
  // 1 个消费者,消费 c 空间
  for i := 0 ; i < 5; i++ {
    fmt.Println(<-c)
  }
}

func foo(i int, c chan int) {
  time.Sleep(time.Second)
  c <- i
}

select

select case 常常作为选择判断当前时候该 “select” 哪个通道,也就是哪个 case 来进行操作,因为当下时刻可能会有一些通道的数据还没有填充,但是通道接收者又等着接收数据,因此会被阻塞

select {
  case 通过操作:
  	// ...
  case 通道操作:
  	// ...
}

close

通过 close 方法来关闭通道,这样通道就无法再写入任何值,如果再写入就会 panic,读取已经关闭的通道会获得一个与通道类型对应的零值

// 关闭通道 c
close(c)
v, ok := <- c
if !ok {
  fmt.Println("通道已经关闭了!")
}
sync 包

互斥锁 sync.Mutex

基本使用

和 channel 不同,互斥锁并没有内置于 golang 语言中,而是通过 sync 包提供,互斥锁可以添加在 struct 字段中也可以添加在方法中

下方提供一个使用 golang 互斥锁的简单小例子

var mu sync.Mutex

func main() {
  // 锁住
  mu.Lock()
  // 释放锁
  defer mu.Unlock()
}

隐患

互斥锁可能导致锁定较长时间导致其他线程无法执行,并且也可能会有死锁现象出现

所以我们有一些小建议:

  • 尽可能简化互斥锁保护的代码
  • 对每一份共享状态只使用一个互斥锁

读写锁 sync.RWMutex

读写锁要求比互斥锁要求要低一些,互斥锁要求读取数据和写入数据都是阻塞的,但是读写锁在拥有读锁时候多协程读取数据时候不会造成阻塞,但是此时写入数据会造成阻塞

即:读锁 Rlock 不会阻塞读锁 Rlock,但是会阻塞写锁 Lock;写锁 Lock 会阻塞写锁 Lock,也会阻塞读锁 RLock;也就是说读锁之间不是互斥的,但是写锁和谁都互斥

对于读很多的场景可以考虑使用读写锁

  • 读锁

    RLock 方法和 Runlock 方法

  • 写锁

    其实这里的写锁就和前面互斥锁很像了。Lock 方法和 Unlock 方法

等待组 sync.WaitGroup

waitgroup 可以等待一组协程全部运行完毕之后,主协程才会继续被放行,否则只要有一个旁路协程没有完成,主协程就会陷入阻塞

var wg sync.WaitGroup

wg.Add(2)

// 开启 2 个协程,每个协程函数中最末尾有一个 wg.Done()
go ...
go ...

// 只有当 wg 经过了 2 次 Done 之后,这个 Wait 才能走通,否则等待上方协程继续执行
wg.Wait()

sync.Once

sync.Once 可以让方法之执行一次,什么叫做只执行一次呢?比如说 init 函数是在文件包首次被加载时候执行,只执行一次,那么 sync.Once 是在代码运行中需要的时候只执行一次

其中主要有 Do 方法和 doSlow 方法,传递参数是要执行的函数

我们看下面的例子,多协程中once.Do(onceBody)只执行了一次

func main() {
	var once sync.Once
	onceBody := func() {
		fmt.Println("Only once")
	}
	done := make(chan bool)
	for i := 0; i < 10; i++ {
		go func() {
			once.Do(onceBody)
			done <- true
		}()
	}
	for i := 0; i < 10; i++ {
		<-done
	}
}

# Output:
Only once
sync.atomic 包

原子加 atomic.AddInt64

可以替代 + 操作,但是这里是原子的操作,如果把下方中的atomic.AddInt64(&count, 1)替换成count++那么多协程时候会出现 data race,最终 count 的值会比预期要小,因为count++是不安全的,如果用原子操作就可以达到安全的目的。并且 atomic 包中的使用效率还很高

相比 mutex 来讲,atomic 直接对内存加锁,更底层更高效,相比 mutex,atomic 更加轻量级,并且

func main() {
   var wg sync.WaitGroup
   count := int64(0)
   t := time.Now()
   for i := 0; i < 100000; i++ {
      wg.Add(1)
      go func(i int) {
         // 原子操作 count++
         atomic.AddInt64(&count, 1)
         wg.Done()
      }(i)
   }
   wg.Wait()
   fmt.Printf("test2 花费时间:%d, count的值为:%d \n",time.Now().Sub(t),count)
}

原子赋值 atmoic.StoreInt64

原子赋值操作

func main() {
    var addr int64
    atomic.StoreInt64(&addr,10)
    fmt.Println(addr)
}

原子取值 atomic.LoadInt64

原子取数据

func main() {
    var addr int64 = 10
    addr = atomic.LoadInt64(&addr)
    fmt.Println(addr)
}

原子值 atomic.Value

可以发现上面的 atomic 包下面的方法都是指定类型的操作,但是如果想操作一些通用类型,比如接口,可以使用 atomic.Value 中的 Store 和 Load 来操作了

Store

atomic.Value 中有一个 Store 方法还有一个 Load 方法,值得注意是不能存储 nil,如果存储了 nil 会直接 panic

Store 方法得存储同样数据类型的数据,否则后面存储和前面不同类型的数据时会报出 panic

另外 atomic.Store 如果尝试用来存储引用类型,比如切片,那么维护的就是引用类型的指针,下面打个比方

// 引用类型
func foo(s []uint32) {
	var v atomic.Value
	v.Store(s)
	s[0] = 999
	fmt.Println(s)
	fmt.Println(v.Load())
}

// ================== 假如我们会调用 foo 函数,传递一个 []uint32{0,1,2} 进去,我们会发现如下结果
[999, 1, 2]
[999, 1, 2]

Load

然后 Load 可以取出最近一次 store 的值,并且如果从没有 Store 过,Load 就是返回一个 nil