Go并发解析

对go的基础学习中,也了解了些相关热点问题。

发现篇非常好的文章详细介绍了go中的并发设计原理、内存模型。写些读后感。

并发编程

上下文

上下文简单的理解为代码运行的环境,上下文 context.Context 在 Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体。context.Context是 Go 语言在 1.7 版本中引入标准库的接口,该接口定义了四个需要实现的方法,其中包括:

方法概述
Deadline返回 context.Context 被取消的时间,也就是完成工作的截止日期;
Done返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel;
Err返回 context.Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值;如果 context.Context 被取消,会返回 Canceled 错误;如果 context.Context 超时,会返回 DeadlineExceeded 错误;
Value如果 context.Context 超时,会返回 DeadlineExceeded 错误;从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;

Go 语言中的 context.Context 的主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用,例如使用context.WithCancel 函数能够从 context.Context 中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Goroutine 都会同步收到这一取消信号,父上下文取消时子上下文也会被取消。虽然它也有传值的功能,但是这个功能我们还是很少用到。

在真正使用传值的功能时我们也应该非常谨慎,使用 context.Context 传递请求的所有参数一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

channel

channel常用来做协程间的信息同步,是个建立时设定缓冲区(默认是0)用于共享消息先入先出的队列。

type hchan struct {
	qcount   uint	// Channel 中的元素个数;
	dataqsiz uint	// Channel 中的循环队列的长度;
	buf      unsafe.Pointer // Channel 的缓冲区数据指针;
	elemsize uint16
	closed   uint32
	elemtype *_type
	sendx    uint	// Channel 的发送操作处理到的位置
	recvx    uint	// Channel 的发送操作处理到的位置
	recvq    waitq
	sendq    waitq

	lock mutex
}

具体细节网上资料很详细,这里记录几个易错点:

  • 当一个channel被关闭后,再向该channel发送数据将导致panic异常。当一个被关闭的channel中已经发送的数据都被成功接收后,后续的接收操作将不再阻塞,它们会立即返回一个零值。关闭上面例子中的naturals变量对应的channel并不能终止循环,它依然会收到一个永无休止的零值序列,然后将它们发送给打印者goroutine。

    没有办法直接测试一个channel是否被关闭,但是接收操作有一个变体形式:它多接收一个结果,多接收的第二个结果是一个布尔值ok,ture表示成功从channels接收到值,false表示channels已经被关闭并且里面没有值可接收。使用这个特性,我们可以修改squarer函数中的循环代码,当naturals对应的channel被关闭并没有值可接收时跳出循环,并且也关闭squares对应的channel.

    go func() {
      for {
            x, ok := <-naturals
            if !ok {
            break // channel was closed and drained
        }
        squares <- x * x
      }
      close(squares)
    }()
    

    因为上面的语法是笨拙的,而且这种处理模式很常见,因此Go语言的range循环可直接在channels上面迭代。使用range循环是上面处理模式的简洁语法,它依次从channel接收数据,当channel被关闭并且没有值可接收时跳出循环。(channel不关闭 将一直堵塞在range处)

     go func() {
        for x := range naturals {
            squares <- x * x
        }
        close(squares)
      }()
    
  • chan要用make生成,对空值的ch调用会造成堵塞

    例如var tick <-chan time.Time tick就是一个空值的channel(nil)对其写入或读取操作都会造成堵塞

  • 函数形参的单向channel 就是普通 channel 由编辑器检查传输方向来确保单向而实现的,传参会进行隐式转换 双向变为单向 单向不可变为双向

    naturals := make(chan int) 无缓存 缓存为0

    naturals := make(chan int, 3) 缓存为3 cap(naturals)=3 len(naturals)=0

    举例: out in 为形参名

    参数:只发送 chfunc count (out chan <- int) {}
    参数:只接收 chfunc counter (in <- chan int) {}
    参数:普通双向func counter (chan int) {}

共享变量

Go 语言也有类似unix中实现的那些共享变量的内容。在 sync 包中提供了用于同步的一些基本原语,包括常见的 sync.Mutexsync.RWMutexsync.WaitGroupsync.Oncesync.Cond

只要在go build,go run或者go test命令后面加上-race的flag,就会使编译器创建一个你的应用的“修改”版或者一个附带了能够记录所有运行期对共享变量访问工具的test,并且会记录下每一个读或者写共享变量的goroutine的身份信息。另外,修改版的程序会记录下所有的同步事件,比如go语句,channel操作,以及对(*sync.Mutex).Lock(*sync.WaitGroup).Wait等等的调用。

这些互斥锁与c中的一样,且都不支持重入锁,再次上锁会死锁。这些锁主要是应对竞争条件下对变量的读写,介绍几个常见的锁用法:

  • sync.Mutex互斥锁

    var mu sync.Mutex 
        mu.Lock()
        mu.Unlock()
    // 锁记得要释放
    
  • sync.RWMutex读写锁

    // 其允许多个只读操作并行执行,但写操作会完全互斥。
    var mu sync.RWMutex
        mu.RLock() // readers lock
        mu.RUnlock()
    // 调用了RLock和RUnlock方法来获取和释放一个读取或者共享锁。(上锁时,RLock不堵塞)
        mu.Lock()
        mu.Unlock()
    // 调用mu.Lock和mu.Unlock方法来获取和释放一个写或互斥锁。(上锁时其他任何锁堵塞)
    
  • sync.Once单次锁

    // 一般用于初始化,只上锁一次
    var loadIconsOnce sync.Once
    var icons map[string]image.Image
    // Concurrency-safe.
    func Icon(name string) image.Image {
        loadIconsOnce.Do(loadIcons)	//loadIcons为调用的其他函数
        return icons[name]
    }
    
  • sync.WaitGroup 等待开锁组

    // 递增的计数器,这个计数器需要在多个goroutine操作时做到安全并且提供在其完成之前一直等待。
    var wg sync.WaitGroup // number of working goroutines
    wg.Add(1)
    wg.Done()
    wg.Wait() //堵塞等待上锁组内全部done 
    

gorutine与线程

在学习协程 时关于线程 与 cpu处理之间有认知不太清晰的地方,看看这位大佬的介绍就能有个理性的认知

协程英文名Coroutine。但在 Go 语言中,协程的英文名是:gorutine。它常常被用于进行多任务,即并发作业。

协程的特点:

  • 多个协程可由一个或多个线程管理,协程的调度发生在其所在的线程中。
  • 可以被调度,调度策略由应用层代码定义,即可被高度自定义实现。
  • 执行效率高。
  • 占用内存少。
线程协程
数据存储内核态的内存空间一般是线程提供的用户态内存空间
切换操作操作最终在内核层完成,应用层需要调用内核层提供的 syscall 底层函数应用层使用代码进行简单的现场保存和恢复即可
任务调度由内核实现,抢占方式,依赖各种锁由用户态的实现的具体调度器进行。例如 go 协程的调度器
语音支持程度绝大部分编程语言部分语言:Lua,Go,Python …
实现规范按照现代操作系统规范实现无统一规范。在应用层由开发者实现,高度自定义,比如只支持单线程的线程。不同的调度策略,等等

OS线程会被操作系统内核调度。每几毫秒,一个硬件计时器会中断处理器,这会调用一个叫作scheduler的内核函数。这个函数会挂起当前执行的线程并将它的寄存器内容保存到内存中,检查线程列表并决定下一次哪个线程可以被运行,并从内存中恢复该线程的寄存器信息,然后恢复执行该线程的现场并开始执行线程。因为操作系统线程是被内核所调度,所以从一个线程向另一个“移动”需要完整的上下文切换,也就是说,保存一个用户线程的状态到内存,恢复另一个线程的到寄存器,然后更新调度器的数据结构。这几步操作很慢,因为其局部性很差需要几次内存访问,并且会增加运行的cpu周期。

Go的运行时包含了其自己的调度器,这个调度器使用了一些技术手段,比如m:n调度,因为其会在n个操作系统线程上多工(调度)m个goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的goroutine(译注:按程序独立)。

和操作系统的线程调度不同的是,Go调度器并不是用一个硬件定时器,而是被Go语言“建筑”本身进行调度的。例如当一个goroutine调用了time.Sleep,或者被channel调用或者mutex操作阻塞时,调度器会使其进入休眠并开始执行另一个goroutine,直到时机到了再去唤醒第一个goroutine。因为这种调度方式不需要进入内核的上下文,所以重新调度一个goroutine比调度一个线程代价要低得多。

go拥有其协程调度器,调度器的三个基本对象:

  • G (Goroutine),代表协程,也就是每次代码中使用 go 关键词时候会创建的一个对象

  • M (Work Thread),工作线程

  • P (Processor),代表一个处理器,又称上下文

每一个运行的 M 都必须绑定一个 P,线程M 创建后会去检查并执行G (goroutine)对象。每一个 P 保存着一个协程G 的队列。除了每个 P 自身保存的 G 的队列外,调度器还拥有一个全局的 G 队列。M 从队列中提取 G,并执行P 的个数就是GOMAXPROCS(最大256),启动时固定的,一般不修改。( go 1.5 版本之前的 GOMAXPROCS 默认是 1,go 1.5 版本之后的 GOMAXPROCS 默认是 Num of cpu)M 的个数和 P 的个数不一定一样多(会有休眠的M 或 P不绑定M )(最大10000)。P 是用一个全局数组(255)来保存的,并且维护着一个全局的 P 空闲链表

img

GOMAXPROCS 就是 go 中 runtime 包的一个函数。它设置了 P 的最多的个数。这也就直接导致了 M 最多的个数是多少,而 M 的个数就决定了各个 G 队列能同时被多少个 M 线程来进行调取执行!如下演示

for{
   gofmt.Print(0)
   fmt.Print(1)
 }
// $ GOMAXPROCS=1gorun hacker-cliché.go111111111111111111110000000000000000000011111...
// $ GOMAXPROCS=2gorun hacker-cliché.go010101010101010101011001100101011010010100110...

在第一次执行时,最多同时只能有一个goroutine被执行。初始情况下只有main goroutine被执行,所以会打印很多1。过了一段时间后,GO调度器会将其置为休眠,并唤醒另一个goroutine,这时候就开始打印很多0了,在打印的时候,goroutine是被调度到操作系统线程上的。在第二次执行时,我们使用了两个操作系统线程,所以两个goroutine可以一起被执行,以同样的频率交替打印0和1。我们必须强调的是goroutine的调度是受很多因子影响的,而runtime也是在不断地发展演进的,所以这里的你实际得到的结果可能会因为版本的不同而与我们运行的结果有所不同。