go并发编程

183 阅读12分钟

进程与线程

  • cpu与进程的关系
1.cpu不是同时在执行进程,而是同一时间段,只能执行一个进程,但是这个时间段很短,cpu在很多进程之间快速的切换执行
  • 进程的执行状态图 image.png

  • 进程间的切换过程图 image_1.png

  • 进程的上下文切换

1. cpu的上下文是指:cpu的寄存器和程序的计数器
2. cpu的寄存器:cpu的内置容量小,速度极快的缓存
3. 程序的计数器:用来存储cpu正在运行的指令位置或者即将执行的下一条指令的位置

image_2.png

  • 线程
 1.在早期操作系统都是以 进程 为单位进行独立运行,后续提出更小的能独立运行的基本单位线程
 2.现在操作系统:进程是最小的资源分配单位,线程是最小的运行单位一个进程下面具有一个或多个线程,每个线程都有独立的寄存器和栈,这样可以确保线程的控制是相对独立的

image_3.png

  • 线程的优缺点
优点:
  1.进程中可以存在多个线程
  2.多线程可以让进程具备多任务并行处理的能力
  3.线程的切换比进程切换的时候开销小,并且更加轻量
缺点:
  1.线程之间会共享进程的资源,会产生资源的争抢,需要锁机制来解决
  2.进程中一个线程崩溃,让导致其他线程崩溃
  • 进程与线程对比
 1.进程包含线程,一个进程可以包含多个线程
 2.进程拥有一个完整的资源平台,而线程拥有必不可少的资源,如:寄存器和栈
 3.线程和进程具有相同的切换状态
 4.线程的创建、终止、切换比进程快
 5.线程的切换上下文和进程相似

用户态和内核态

  • cpu的指令
1.主要功能就是提供软件对硬件系统的执行桥梁,每一条汇编语言都会对应一条cpu指令,而多个指令合到一起就是cpu指令集
2.而对CPU指令则会存在权限分级,限制操作硬件的指令;这样的设定就是为了避免不规范的CPU指令出现问题而影响整个计算机系统,甚至到崩溃

  • CPU对指令的权限划分为4个级别
 1.ring0 --- 权限级最高,可以运用所有CPU指令
 2.ring1
 3.ring2
 4.ring3 --- 权限最低仅仅为常规指令;不能使用操作硬件资源的CPU指令,比如IO读写、网卡、申请内存等
  • 用户态和内核态的理解

用户态和内核态概念上就是指令权限上的区别

 1.ring0 :就是内核态,完成在操作系统内核中运行;当一个任务(进度)执行系统调用在内核代码中执行时我们称之为内核态
 2.ring3 :就是用户态,在应用程序运行 ;当一个任务任务执行用户自己的代码时,则处于用户态

image_4.png

image_5.png

  • 内核空间和用户空间
 1.用户空间:就是用户进程所在内存区域
 2.内核空间:就是系统进程占据的内存空间
  • 空间与运行态的关系
对于用户态和内核态,主要是指进程在不同空间的运行的称呼
 1.用户态:当进程执行用户自己的代码的时候,(ring3)就是用户态,而用户态的程序只能访问用户空间
 2.内核态:当进程在执行系统调用而在内核代码中执行时,(ring0)就是内核态,而处于内核态的程序可以访问到内核空间和用户空间

GPM

  • 协程在程序阻塞的时候才会执行和切换的,这种阻塞是非密集型阻塞才会执行(io阻塞),只写一个for的死循环不符合非密集型阻塞,但是如果代码里面有些for{}的死循环,但是go func(){}还是会被执行,这是为什么???
// 在如下代码中
func main() {
  go func() {
    fmt.PrintLn("协程")
  }
  
  for { // 这个位置会阻塞main的程序,但是for是非密集型阻塞,不会进行协程的切换,但是为什么在执行这个程序的时候,会执行上面的协程呢?这个是1.14版本之后才有的情况
    
  }

}

// 原因:在go中新增的一个信号的内容
// 程序在编译==》运行==》结束   程序在监听整个运行期间,是否存在死循环,不是特意的去监听死循环的代码,而是监听超过某一个执行的时间,超过了底层就会自动去切换协程(切换的方式就是通过信号),处理的方法在 runtime.doSingPreempt()
  • GPM是go的调度器模型
// G :是指协程
// P : 是指调度器
// M : 是指用户态的线程
  • GPM的理解
 1.在cpu运行时,通过内核态的线程和用户态的线程进行绑定,而这种绑定就是通过系统的调度器来实现的,这个了解就好
 2.M和G的绑定,其实是M和P进行绑定,再通过P去取G给到M来执行
 3.在整个程序中,会存在两个队列(存储G的地方),一个是全局队列,一个是P的本地队列
 4.P的数量可以通过runtime.GOMAXPROCS设置,默认是cpu的核数
 5.P的本地队列可以最多存放256个G
  • GPM的具体实现
 1.go程序中,执行到要通过协程来执行代码时,go func(){} 会将G加入P的本地队列,如果P的队列满了,才会存入全局队列
 2.M会去P的队列里面将G取出,如果P的队列为空时,则会去别的p的队列中抢一半过来,存到自己的队列中,或者去全局队列里面取出一部分,放到自己队列里面,再将G给到M,在M中取执行。

image_6.png

image_7.png

image_8.png

image_9.png

image_10.png

image_11.png

  • GPM的好处
 1.协程的目的:可以理解为纯用户态的线程,其通过协作而不是抢占来进行切换。相对于进程或者线程,协程所有的操作都可以在用户态完成,创建和切换的消耗更低,而且协程的占用空间也很小 4K左右
 2.GPM的目的:通过调度器来绑定用户态的线程和协程,GMP这个模型实际上是在对自己创建的协程,做调度的切换,这个模型除了在go中引用以外也在系统里面的,这个模型在内核态的线程和用户态的线程绑定中使用。
 3.GPM的优势:最大的好处在于可以充分利用cpu自由的通过构建多个P,做好对多个协程的切换,该模型可以做到多对个协程/内核线程的切换而这个切换通过调度器对运行的程序监控来做到

channal应用

  • 1.通道同步
package main

import "fmt"

func main() {
  ch := make(chan int, 0)
  go revice(ch)

  ch <- 1 // 如果在 make的时候 chan有空间,这个位置则不会阻塞,当前状态会阻塞,因为make的时候容量为0
  fmt.Println("结束")
}

func revice(ch chan int)  {
  c := <-ch
  fmt.Println(c)
}

  • 任务定时和停止任务
package main

import (
  "fmt"
  "time"
)

var done = make(chan bool, 0) // 创建一个来判断是否关闭定时任务的chan
var i int // 只是该文件,到了一个条件来关闭chan

func main() {
  i = 0
  ticker(int(time.Second * 2), send)
}

// 定义一个定时任务的方法
func ticker(t int, f func())  {
  ticker := time.Tick(time.Duration(t))
  for {
    select {
    case <-done: // 如果done发生变化就执行这个,当close(done)也会执行这个
      return;
    case <-ticker: // 正常的定时任务方法
      f()
    }
  }
}

func send() {
  if i > 10 {
    close(done) // 根据条件去关闭done
  }
  i++
  fmt.Println("定时执行send")
}

  • 并发控制
package main

import (
  "fmt"
  "time"
)

var limit = make(chan int, 2)

func main() {

  for i := 0; i < 20; i++ {
    go func(i int) {
      limit <- 1 // 如果通道满了 , 这是会阻塞

      fmt.Println(i)
      time.Sleep(time.Millisecond * 1000)

      <- limit
    }(i)
  }

  time.Sleep(10e9)
}

  • 生产者与消费者--(扩展任务队列)
package main

import (
  "fmt"
  "sync"
  "time"
)

var wg sync.WaitGroup

func main() {
  task := make(chan int, 10)
  // 创建一个工作池 ==》 消费者
  workerPool(5, task)  // 创建的G要与任务的数的增加而增加
  // 生产者 =》 任务的投递
  for i := 0; i < 10; i++ {
    wg.Add(1)
    task <- i
  }

  //如果某一个任务超时了不好解决, 判断程序是否超时,超时的G,提示
  if !waitTimeout(1e9) {
    fmt.Println("waring, process job timeout")
  }

  fmt.Println("结束")
}

func waitTimeout(timeout time.Duration) bool {
  // 定义一个新的通道,作用就是判断程序是否超时
  done := make(chan struct{})
  // 不会阻塞运行
  go func() {
    wg.Wait()
    close(done)
  }()

  // 必须要有
  // select 会阻塞程序的运行-》它的特点就是执行一个准备好的通道
  select {
  case <- done: // 正常结束
    return true
  case <- time.After(timeout): // 超时了
    return false
  }
}

func workerPool(workNum int, task chan int) {
  // 通过 workNum 来创建协程的数量
  for i := 0; i < workNum; i++ {

    go func(id int) {
      // 执行工作者
      worker(id, task)
    }(i)
  }
}

// 工作者
func worker(id int, task chan int) {
  // 遍历 chan
  for job := range task {
    // 真正的执行逻辑
    Process(id, job)
  }
}

func Process(id int, job int) {
  defer wg.Done()

  if job%3 == 0 { // 模拟特殊的任务可能会出现阻塞的问题
    time.Sleep(3e9)
  }

  fmt.Println("worker - id ===>> ", id, " job ===>> ", job)
}

理解channel实现

  • channel的数据结构
// Channel的底层数据结构源码在runtime/chan.go:hchan
type hchan struct {
  qcount uint // 当前队列中剩余元素个数
  dataqsiz uint // 环形队列长度 = 可存放的元素个数(缓存区大小)
  buf unsafe.Pointer // 环形队列指针 = 缓存区指针
  elemsize uint16 // 元素大小
  closed uint32 // 标识关闭状态
  elemtype *_type // 元素类型
  sendx uint // 发送队列下标,元素存放的下标位置
  recvx uint // 读取队列下标,元素读取的下标位置
  recvq waitq // 等待读取消息的协程队列
  sendq waitq // 等待写入消息的协程队列
  lock mutex // 互斥锁,chan不允许并发读写
}

  • channel数据结构—缓存区
// chan内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。
ch := make(chan int, 5)
ch <- 1
ch <- 1
// qcount = 2
// dataqsiz = 5
// buf 指向队列的内存
// elemsize int所占的大小,因为创建的是 int类型的chan
// sendx = 2 因为现在添加了两个元素,下一个要添加的位置是第三位,下标从0开始所以是2
// recvx = 0 因为当前还没有被读取,第一位就有元素
// recvq 和 sendq 在下面解释
  • channel数据结构-等待队列
// 在chan中存在recvq与sendq队列,在一般情况下至少是有一个为空,唯一特例情况,同一个协程使用select语句向channel一边写一边读数据
// 重点:一般情况下一定会有一个是空的 *****
// 接下来是重点:(注意:channel的缓冲区是make创建时候定义的值)
// 1.当在channel中读取数据时,如果channel的缓冲区为0或者不存在时,当前的G会阻塞,会唤醒sendq(写入队列)中的G,并向recvq (读取队列)加入数据G,
// 2.当向channel中写入数据时,如果channel的缓冲区满了或者不存在时,当前的G会阻塞,会唤醒recvq(读取队列)中的G,并向sendq(写入的队列)加入G
// 被阻塞的goroutine将会挂在channel的等待队列中

image_12.png

  • channel数据结构-类型信息/锁
// 对于channel来说只能传递一种类型的值,类型信息存在hchan数据结构中
// elemtype代表类型,用于数据传递过程中的赋值
// elemsize代表类型大小,用于在buf中定位元素位置
// 在channel中还利用了锁机制,一个channel同时仅允许一个goroutine读写
  • channel写入时,具体流程
// 1.当recvq不为空时,证明:缓冲区没有数据或者没有缓冲区,所以从recvq中取出G,将数据写入G中,等待读取操作的channel来唤醒
// 2.当recvq为空时.证明:缓冲区里面有数据,当前判断缓存区的数据是否已经满了,如果满了,则阻塞当前G,将数据加入sendq中,等待channel读取发生阻塞时,唤醒当前sendq,将数据读出
 // 3.在2的基础上,如果说缓存区没有满,则将写入的数据放入缓冲区的尾部

image_13.png

  • 读取channel时,具体流程
// 1.先判断sendq是否存在数据,如果为空,则证明:缓冲区没有满,存在数据(如果满了或者不存在会向sendq写入数据),然后判断缓冲区是否有数据,没有则将数据写入到recvq中,等待被唤醒;缓冲区有数据,则从缓冲区头部取出数据
// 2.sendq不为空时,判断是否有缓冲区,如果没有,则在阻塞,并在sendq中取出数据
// 3.在2的基础上,如果缓冲区存在数据,则在缓冲区的头部取出一个数据,然后将sendq中取出一个数据放入缓冲区的尾部

image_14.png

select

  • 在go中不存在select的结构体,只有select中的case的结构体;在runtime/select.go:scase中定义表示case语句的数据结构:
type scase struct {
  c *hchan // 表示为当前case语句所操作的channel指针
  elem unsafe.Pointer // data element
}

  • 编译器对select的改写主要是发生在cmd/compile/internal/gc/select.go.walkselectcases函数中
  • 在编译期间go会对select进行优化,根据下面四种情况对select语句进行重写优化;
  • 第1种情况只有一个select也就是不含任何的case
// 经过cmd/compile/internal/gc/select.go:walkselectcases的调整,会直接将select{}转换为runtime/select.go:block函数
func walkselectcases(cases *Nodes) []*Node {
  ncas := cases.Len()
  if ncas == 0 {
    return []*Node{mkcall("block", nil, nil)}
  }
}
// 在runtime/select.go:block函数中比较简单,利用的就是runtime/proc.go:gopark函数让出当前G对处理器使用权并传递等待的原因waitReasonSelectNoCases
func block() {
  gopark(nil, nil, waitReasonSelectNoCases, traceEvGoStop, 1)
}

  • 第2种情况只有一个case, 在cmd/compile/internal/gc/select.go:walkselectcases中会判断ncase== 1,并对这种情况进行改写
func walkselectcases(cases *Nodes) []*Node {
  ncas := cases.Len()
  if ncas == 1 {
    。。。
  }
}
// 改写的方式比较简单,就是将select改写成if语句,并且在处理单操作select语句时候,会根据channel的接收情况生成不同语句;如果channel为空则直接休眠

image_15.png

  • 第3种情况;存在case对参数接收,并且还设有default;编译器对它的改写方式都是差不多的,和情况2差不多
  • 第4种情况;可以理解为是默认的情况,编译器会根据下面的流程处理select,存在多个case但是没有default
// 1. 将所有的case转换成包含channel以及类型等信息的runtime/select.go:scase
// 2. 调用runtime/select.go:selectgo函数从多个准备好的channel中获取一个一个runtime/select.go:scase
// 3. 利用for循环生成一组if,在语句中判断自己是否被选中的case

Mutex(互斥锁)实现分析

  • Mutex结构
// 互斥锁是并发程序对共享资源进行访问控制的主要手段,对此都提供的非常简洁的易用的Mutex,Mutex本质是一个结构体,对外提供两个方法Lock和Unlock,加锁和解锁
type Mutex struct {
  state int32 // 表示互斥锁的状态,是否被锁定
  sema uint32 // 表示信号量,协程阻塞等待该信号量,解锁的协程通过信号量从而唤醒等待信息号量的协程
}

  • Mutex内存布局

image_16.png

// Mutex.state 的结构:
// 1.Locked: 表示是否被锁定,0=为加锁 1=已加锁
// 2.Woken: 表示是否有协程已经被唤醒,0=没有协程被唤醒 1=已有协程被唤醒,正在加锁的过程
// 3.Starving: 表示是否是饥饿状态 0=非饥饿状态, 1=饥饿状态,饥饿状态说明协程阻塞超过了一定时间,比如1ms
// 4.Waiter: 表示阻塞等待锁的协程个数,协程解锁时通过此值来判断是否需要释放信号量
  • Mutex加锁解锁
// 加锁过程:
// 1.加锁时,判断locked是否等于1,如果等于0,则证明可以加锁,将locked变成1,其他不变
// 2.如果加锁时,locked已经等于1(当前其他的协程在加锁),会阻塞当前协程,将Waiter+1,等待locked变成0,变成0后会发出信号量,然后被阻塞的协程去加锁,将locked再次变成1,Waiter-1
  • 下图是正常加锁,没有阻塞的过程

image_17.png

  • 下图是加锁时,有阻塞的情况 image_18.png
// 解锁过程
// 分为两种情况,无阻塞和有阻塞(就是判断waiter是否>0)
// 1.无阻塞时,直接将locked变成0即可(waiter = 0)
// 2.有阻塞时,将locked变成0,再释放一个信号量,唤醒一个被阻塞的协程,将locked变成1,然后waiter-1(waiter >0)
  • 自旋过程
// 什么是自旋过程?
// 当go对协程加锁时,如果当前locked=1,证明该锁正在由其他协程使用,而这个时候协程并不会立即进入阻塞状态,而是持续的判断locked是否等于0一段时间,这个过程就是自旋过程
// 自旋的时间很短,但是如果在自旋的过程中,发现锁已经被释放,则立刻获取锁,此时即便有协程被唤醒也无法获取锁,只能再次阻塞
// 自旋的好处?
// 自旋的好处是,当加锁失败的时候就不会立即进入阻塞状态,有一定机会获取到锁,这样可以避免协程的切换

image_19.png

  • 自旋过程的条件
// 加锁程序会先判断是否可以自旋,因为无限制的自旋会给CPU带来比较大的压力
// 自旋必须满足的条件:
// 1.自旋次数足够小,一般为4次;
// 2.CPU核数大于1
// 3.协程调度器中的Process数量要大于 1
// 4.至少存在一个正在运行的处理器并且处理的运行队列为空

  • Mutex模式

sync.Mutex 有两种模式 — 正常模式和饥饿模式:

// 1.在正常模式下,锁的等待会按照先进先出的顺序获取锁,但是刚刚被唤醒的G与新创建的Goroutine竞争时,大概率会获取不到锁,为了减少这种情况的出现,一旦Goroutine超过 1ms 没有获取到锁,它就会将当前互斥锁切换饥饿模式,防止部分 Goroutine 被『饿死』
// 2.在饥饿模式中,互斥锁会直接交给等待队列最前面的 Goroutine。新的 Goroutine 在该状态下不能获取锁、也不会进入自旋状态,它们只会在队列的末尾等待。如果一个 Goroutine 获得了互斥锁并且它在队列的末尾或者它等待的时间少于 1ms,那么当前的互斥锁就会切换回正常模式
// 与饥饿模式相比,正常模式下的互斥锁能够提供更好地性能,饥饿模式的能避免 Goroutine 由于陷入等待无法获取锁而造成的高尾延时。

RWMutex(读写锁)实现分析

  • 读写锁rwmutex是互斥锁mutex的升级版本、在底层中也运用到了mutex进行实现、主要用于读大于写的
    场景

  • 而读写锁需要注意如下几个问题:冲突情况 image_20.png

  • Rwmutex的底层源码在sync/rwmutex.go:RWMutex 的结构体中进行定义

type RWMutex struct {
  w Mutex // 运用互斥锁的能力实现写锁
  writerSem uint32 // 写阻塞等待的信号量
  readerSem uint32 // 读阻塞等待的信号量
  readerCount int32 // 记录读的个数,有读锁 +1 ,解读锁 -1
  readerWait int32 // 表示当写锁操作被阻塞时等待的读操作个数
}
func (rw *RWMutex) RLock() // 堵锁
func (rw *RWMutex) RUnlock() // 解读锁
func (rw *RWMutex) Lock() // 写锁
func (rw *RWMutex) Unlock() // 解写锁
  • RWMutex 读写锁 – 简单流程
// RWMute.Lock() 写锁;主要操作
// 1. 获取互斥锁,
// 2. 判断是否存在读锁,有则需要等待读的结束

image_21.png

// RWMute.UnLock() 写解锁;主要操作
// 1. 唤醒因读锁定而被阻塞的协程
// 2. 解锁互斥锁

image_22.png

// RWMute.RLock() 读锁;主要操作
// 1. 增加读操作计数,readerCount++
// 2. 阻塞等待写操作结束(如果有的话)

image_23.png

// RWMute.RUnLock() 读解锁;主要操作
// 1. 减少读操作计数,readerCount--
// 2. 唤醒等待写操作的协程(如果有的话)

image_24.png

  • RWMutex 读写锁 – 了解情况

    1. 写操作对写操作的阻塞
// 读写锁中运用互斥锁mutex、写锁必须先获取互斥锁,如果其他协程获取到锁则会被阻塞
// 写锁的实现主要是依赖互斥锁来实现的

2.写操作对读操作的阻塞

// 关于RWMutex.readerCount是整数值int32,表示读锁数量,在不考虑写锁的情况,每次读锁都会readerCount++,解锁会readerCount—及范围就是2^23而go用sync/rwmutex.go:rwmutexMaxReaders表示

// 当写锁定进行时,先将readerCount减去rwmutexMaxReaders,从而readerCount为负值,读写进来后对readerCount++ ,判断为负说明有写锁在执行,会阻塞等待。而真正的读操作个数后续只需要再对readerCount + rwmutexMaxReaders 即可,因此 写操作利用readerCount为负值阻塞读操作

3.读操作对写操作的阻塞

// 读锁会讲RWMutex.readerCount ++,此时写操作会判断该值不为0,因此会阻塞等待读操作的结束读锁通过readerCount来组织写操作

4.为什么写操作与读操作不会被“饿死”

// 因为写操作必须等待所有的读操作结束才可以获取锁,写锁等待期间会存在新的读锁,因不断新增的读锁从而会导致写锁可能饿死

// RWMutex中运用readerWait解决这个问题,在有写锁的时候会在当前的时间点复制readerCount的值,标记在写之前的读锁数量,而写前面的读锁结束后会对 readerWait, readerCount 进行 -- ;当readerCount为0则唤醒写操作

image_25.png

image_26.png

代码中容易出现的坑

  • 协程超过上限 / 内存申请超标,协程的数量和后期内存并发的情况考虑下
  • 协程用协程池(channel的应用里面有)
  • 连接数 mysql和redis的链接数 为它们建立连接池
  • 1<< 3 这个代表 2的3次方 2<<3 = 2 * 2 ^3