Go并发模式(1):基础与并发调度

笔记内容来源于Google Go团队的两个Talk:

  1. Go Concurrency Patterns (YouTube Link)
  2. Advanced Go Concurrency Patterns (YouTube Link)

结合自己的一些理解写的笔记。

什么是并发?

并发是多个独立的计算流程组合进行的过程。并发正在影响软件开发过程,它应当是一种构建软件的途径,良好的语言与工具可以帮助我们比较容易地写出整洁的代码,与现实世界交互。

并发并不是并行

如果你只有一个CPU处理器,你也可以做到并发,在一段时间内做到做多件事情,但是不能称作并行。
并发更多地是指一种开发模式,一个良好设计的并发程序能够很好地在多核CPU甚至多个主机上分布式地工作。

并发:用于软件构建的model

一个良好的并发模型需要具备:

  • 易于理解
  • 易于使用
  • 易于推理、debug

写过Java或者类似传统的线程并发模型的都有体验,写并发程序的时候,Java能够提供的是比较基础的并发工具,并且大部分通过库的方式提供。你需要了解:Java内存模型,Java并发工具,锁,线程,Barrier,Semaphones…

Golang的并发起源于1978年Hoare的Communicating Sequential Process论文,后来多种语言发展了相似的并发特性,包括:Occam, Erlang, Newsqueak, Concurrent ML, Alef, Limbo等。Go与Newsqueak、Alef、Limbo相似,是这个分支的最新语言,特点是语言级别支持的channel。
而Erlang与初始的CSP的概念类似,通过name与process通信,但是模型是等价的。Erlang的process与Golang的channel的区别可以类比于通过文件写入文件与通过文件描述符(File Description)写入文件。

Golang的并发

Golang的并发由Goroutine和Channel两个重要的工具作为特征。
下面的程序可以非常方便地创建一个并发程序:

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func boring(msg string, c chan string) {
    for i := 0; ; i++ {
        c <- fmt.Sprintf("%s %d", msg, i) // Expression to be sent can be any suitable value.
        time.Sleep(time.Duration(rand.Intn(1e3)) * time.Millisecond)
    }
}

func main() {
    c := make(chan string)
    go boring("boring!", c)
    for i := 0; i < 5; i++ {
        fmt.Printf("You say: %q\n", <-c) // Receive expression is just a value.
    }
    fmt.Println("You're boring; I'm leaving.")
}

通过go关键字,我们创建了一个goroutine执行boring函数,同时传入了一个Channel c用于goroutine间的通信。执行main函数的goroutine从c接受消息,boring函数在另一个goroutine里发送信息。

默认的make(chan string)创造的channel是一个Buffer size为1的队列,当生产者通过c <-放入一个数据后,它会阻塞到另一个goroutine接收这个数据;同理,执行<- c的goroutine也会阻塞等待数据的放入后继续执行。
Channel的用途除了通讯更在于『同步』。

当然了,我们也可以创建一个buffer大于1的Channel,只有如果是chan <-时,在buffer满的时候会阻塞;如果是<- chan时,buffer为空时会阻塞。这样的channel相对较难debug。

Golang中意的方式是,不要通过共享内存的方式通信,而是通过通信的方式共享内存。(Don’t communicate by sharing memory; Share memory by communicating.)

Go并发调度机制

本节内容参考文章Go Scheduler .

用户空间的线程和内核空间的线程之间的映射关系有多种:

  1. N:1 多个用户级别的线程都在一个内核线程上运行。context切换会很快,但无法利用多核。
  2. 1:1 一个用户线程只在一个内核线程上跑,这样context切换很慢。
  3. M:N 多个用户线程在多个内核线程上跑,调度难度很大。

Go的调度器包含三个结构:M(内核级线程);P(调度的上下文);G(Goroutine,有自己的栈,instruction pointer等其它信息)。

《Go并发模式(1):基础与并发调度》 image

这里我们有两个内核级线程(M),每个M都有一个context P,也有一个正在运行的goroutine G。P的数量可以由GOMAXPROCS()设置,它代表了真正的并发度。
除了当前运行的goroutine,还有灰色的ready状态的goroutine等待被调度。P维护着这个队列,称为runqueue。
当执行 go func时,新的goroutine就被append到了runqueue里。当ontext运行到了调度的点后,会从runqueue pop一个goroutine,设置栈和instruction pointer,开始执行这个goroutine。
为了避免资源竞争,每个context P都有自己的run queue。为何要维护多个context P呢?是为了当一个OS线程M陷入阻塞时,P会从之前的M上脱离,转而在另一个M上运行。

《Go并发模式(1):基础与并发调度》 image

当系统调用(syscall)结束时,线程必须从其它OS线程中偷一个context P。如果偷不到,就把当前的goroutine扔到global的runqueue里,而自己放回到线程缓存中。contexts也会周期性地检查global的run queue,否则这些goroutine会永远无法被执行。

当一个context P执行完毕了自己runqueue里全部goroutine,当各个context下的负债不均匀,Golang也会通过steal的方式调度。

What’s Next

下一篇中,将介绍Golang团队分享中介绍的若干并发设计模式。

    原文作者:conndots
    原文地址: https://www.jianshu.com/p/4eaedf500783
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞