Go并发详解 | 青训营笔记

1,038 阅读2分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 6 天

(内容根据字节跳动青训营课程内容以及自己的理解编写)

近期将日更这几个主题的文章,欢迎关注!

  • channel通信
  • Kitex
  • Gorm
  • Hertx
  • go的测试环节
  • go的内存管理
  • go的性能优化及工具

Lock

信号量(持有,才能执行操作)锁

var lock sync.Mutex

func main() {
   sum := 0
   for i := 0; i < 10; i++ {
      add(&sum, &i)
   }
}
func add(sum, val *int) {
   lock.Lock()
   *sum += *val
   lock.Unlock()
}

WaitGroup

可以用WaitGroup阻塞住任务(go协程)直到任务完成

image.png

例子:

func main() {
   var wg sync.WaitGroup
   wg.Add(5)

   for i := 0; i < 5; i++ {
      go func(i int) {
         defer wg.Done()
         fmt.Println(i)
      }(i)
   }
   wg.Wait()
}

image.png

goroutine协程基本概念

Go 语言支持并发,我们只需要通过 go 关键字来开启 goroutine 即可。

image.png

goroutine 是轻量级线程,goroutine 的调度是由 Golang 运行时进行管理的。

计算机相关的专业有门必修课叫《操作系统》

里面大家会学到OS进程和线程的相关知识,现在可以初步理解为线程是将执行进程变成执行轻量级线程,减少切换损耗的。协程同理:(goroutine和OS线程是多对多的关系,即m:n)

image.png

两个优点:

  1. 协程是用户态的,线程是内核态的(可以理解为需要更高的权限(要切换成内核态)才能调用线程,而协程不需要,所以go语言可以直接调用协程)
  2. 协程是栈KB级别的,线程是栈MB级别的,所以可以轻松开上万个协程

地鼠文档的一张图:(很多经典的话)

image.png

并且从中摘录一段话:

可增长的栈

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。

goroutine调度

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

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

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

P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

协程的基本定义(有无缓冲区)和使用

其实有无缓冲区相当于同步和异步的区别,就像你去拿快递,快递是可以放在快递点的,所以你随时去拿就行,这就是异步的。通道里面缓冲区的消息下一个通道随时来取就行了,有点类似于消息队列MQ的实现

func main() {
   // 定义有缓冲区的通道
   ch1 := make(chan int)
   ch2 := make(chan int)

   // 定义有缓冲区的通道
   ch3 := make(chan int)
   ch4 := 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 {
            ch2 <- i * i
         } else {
            break
         }
      }
      close(ch2)
   }()

   // 主goroutine 从ch2中取值 打印输出
   // for x := chan 有值取值,通道关闭时跳出goroutine
   for i := range ch2 { // 这一段是主协程,ch2关闭时关闭
      fmt.Println(i)
   }

}

image.png

Goroutine池

这个直接看官网

image.png www.topgoer.cn/docs/golang…

CSP(Communication Sequential Processes)

如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

什么场景下用channel合适呢?

  1. 通过全局变量加锁同步来实现通讯,并不利于多个协程对全局变量的读写操作。
  2. 加锁虽然可以解决goroutine对全局变量的抢占资源问题,但是影响性能,违背了原则。
  3. 总结:为了解决上述的问题,我们可以引入channel,使用channel进行协程goroutine间的通信。

image.png

记住这句话:

提倡通过通信共享内存而不是通过共享内存而实现通信

channel队列,先入先出,能保证消息接收的顺序

channel操作

image.png

这段代码会编译成功,但是执行报错

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

因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。

上面的代码会阻塞在ch <- 10这一行代码形成死锁,除非等有一个通道来接受了,同理,你如果没拿到快递也会一直等,也会阻塞

只读通道和只写通道


// chan<-就是只读通道,<-chan就是只写通道 chan就是又可读又可写
func counter(in chan<- int) {
   defer close(in)
   for i := 0; i < 100; i++ {
      in <- i
   }
}

func square(in chan<- int, out <-chan int) {
   defer close(in)
   for i := range out {
      in <- i * i
   }
}

// 输出是只写通道
func output(out <-chan int) {
   for i := range out {
      fmt.Println(i)
   }
}

// 改写成单向通道
func main() {
   ch1 := make(chan int)
   ch2 := make(chan int)
   go counter(ch1)
   go square(ch2, ch1)
   output(ch2)
}

image.png

runtime包

1.刷新,再次分配任务:

runtime.Gosched()

func main() {

   go func(s string) {
      for i := 0; i < 2; i++ {
         fmt.Println(s)
      }
   }("world")
   // 主协程
   for i := 0; i < 2; i++ {
      // 切一下,再次分配任务
      runtime.Gosched()
      fmt.Println("hello")
   }

}

image.png

2.退出当前的协程:

runtime.Goexit()

这个没啥好演示的

3.分配几个核来执行代码:

runtime.GOMAXPROCS

image.png 比如你单核执行两个任务和两个核执行任务,会有区别的

资料

地鼠中文文档