关于并发:干货分享丨从MPG-线程模型探讨Go语言的并发程序

42次阅读

共计 7410 个字符,预计需要花费 19 分钟才能阅读完成。

摘要:Go 语言的并发个性是其一大亮点,明天咱们来带着大家一起看看如何应用 Go 更好地开发并发程序。

咱们都晓得计算机的外围为 CPU,它是计算机的运算和管制外围,承载了所有的计算工作。最近半个世纪以来,因为半导体技术的高速倒退,集成电路中晶体管的数量也在大幅度增长,这大大晋升了 CPU 的性能。驰名的摩尔定律——“集成电路芯片上所集成的电路的数目,每隔 18 个月就翻一番”,形容的就是该种情景。

过于密集的晶体管尽管进步了 CPU 的解决性能,但也带来了单个芯片发热过高和老本过高的问题,与此同时,受限于资料技术的倒退,芯片中晶体管数量密度的减少速度曾经放缓。也就是说,程序曾经无奈简略地依赖硬件的晋升而晋升运行速度。这时,多核 CPU 的呈现让咱们看到了晋升程序运行速度的另一个方向:将程序的执行过程分为多个可并行或并发执行的步骤,让它们别离在不同的 CPU 外围中同时执行,最初将各局部的执行后果进行合并失去最终后果。

并行和并发是计算机程序执行的常见概念,它们的区别在于:

· 并行 ,指两个或多个程序在 同一个时刻 执行;

· 并发 ,指两个或多个程序在 同一个时间段内 执行。

并行执行的程序,无论从宏观还是宏观的角度观察,同一时刻内都有多个程序在 CPU 中执行。这就要求 CPU 提供多核计算能力,多个程序被调配到 CPU 的不同的核中被同时执行。

并发执行的程序,仅须要在宏观角度观察到多个程序在 CPU 中同时执行。即便是单核 CPU 也能够通过分时复用的形式,给多个程序调配肯定的执行工夫片,让它们在 CPU 上被疾速轮换执行,从而在宏观上模拟出多个程序同时执行的成果。但从宏观角度来看,这些程序其实是在 CPU 中被串行执行。

Go 的 MPG 线程模型

Go 被认为是一门高性能并发语言,得益于它在原生态反对 协程并发。这里咱们首先理解过程、线程和协程这三者的分割和区别。

在多道程序零碎中,过程 是一个具备独立性能的程序对于某个数据汇合的一次动静执行过程,是操作系统进行资源分配和调度的根本单位,是利用程序运行的载体。

线程 则是程序执行过程中一个繁多的顺序控制流程,是 CPU 调度和分派的根本单位。线程是比过程更小的独立运行根本单位,一个过程中能够领有一个或者以上的线程,这些线程共享过程所持有的资源,在 CPU 中被调度执行,共同完成过程的执行工作。

在 Linux 零碎中,依据资源拜访权限的不同,操作系统会把内存空间分为内核空间和用户空间:内核空间的代码可能间接拜访计算机的底层资源,如 CPU 资源、I/O 资源等,为用户空间的代码提供计算机底层资源拜访能力;用户空间为下层应用程序的流动空间,无奈间接拜访计算机底层资源,须要借助“零碎调用”“库函数”等形式调用内核空间提供的资源。

同样,线程也能够分为内核线程和用户线程。内核线程 由操作系统治理和调度,是内核调度实体,它可能间接操作计算机底层资源,能够充分利用 CPU 多核并行计算的劣势,然而线程切换时须要 CPU 切换到内核态,存在肯定的开销,可创立的线程数量也受到操作系统的限度。用户线程 由用户空间的代码创立、治理和调度,无奈被操作系统感知。用户线程的数据保留在用户空间中,切换时毋庸切换到内核态,切换开销小且高效,可创立的线程数量实践上只与内存大小相干。

协程是一种用户线程,属于轻量级线程。协程的调度,齐全由用户空间的代码管制;协程领有本人的寄存器上下文和栈,并存储在用户空间;协程切换时毋庸切换到内核态拜访内核空间,切换速度极快。但这也给开发人员带来较大的技术挑战:开发人员须要在用户空间解决协程切换时上下文信息的保留和复原、栈空间大小的治理等问题。

Go 是为数不多在语言档次实现协程并发的语言,它采纳了一种非凡的两级线程模型:MPG 线程模型(如下图)。

MPG 线程模型

· M,即 machine,相当于内核线程在 Go 过程中的映射,它与内核线程一一对应,代表真正执行计算的资源。在 M 的生命周期内,它只会与一个内核线程关联。

· P,即 processor,代表 Go 代码片段执行所需的上下文环境。M 和 P 的联合可能为 G 提供无效的运行环境,它们之间的联合关系不是固定的。P 的最大数量决定了 Go 程序的并发规模,由 runtime.GOMAXPROCS 变量决定。

· G,即 goroutine,是一种轻量级的用户线程,是对代码片段的封装,领有执行时的栈、状态和代码片段等信息。

在理论执行过程中,M 和 P 独特为 G 提供无效的运行环境(如下图),多个可执行的 G 程序挂载在 P 的可执行 G 队列上面,期待调度和执行。当 G 中存在一些 I/O 零碎调用阻塞了 M 时,P 将会断开与 M 的分割,从调度器闲暇 M 队列中获取一个 M 或者创立一个新的 M 组合执行,保障 P 中可执行 G 队列中其余 G 失去执行,且因为程序中并行执行的 M 数量没变,保障了程序 CPU 的高利用率。

M 和 P 联合示意图

当 G 中零碎调用执行完结返回时,M 会为 G 捕捉一个 P 上下文,如果捕捉失败,就把 G 放到全局可执行 G 队列期待其余 P 的获取。新创建的 G 会被搁置到全局可执行 G 队列中,期待调度器散发到适合的 P 的可执行 G 队列中。M 和 P 联合后,会从 P 的可执行 G 队列中无锁获取 G 执行。当 P 的可执行 G 队列为空时,P 才会加锁从全局可执行 G 队列获取 G。当全局可执行 G 队列中也没有 G 时,P 会尝试从其余 P 的可执行 G 队列中“抄袭”G 执行。

goroutine 和 channel

并发程序中的多个线程同时在 CPU 执行,因为资源之间的相互依赖和竞态条件,须要肯定的并发模型合作不同线程之间的工作执行。Go 中提倡应用 CSP 并发模型 来控制线程之间的工作合作,CSP 提倡应用通信的形式来进行线程之间的内存共享。

Go 是通过 goroutine 和 channel 来实现 CSP 并发模型的:

· goroutine,即协程,Go 中的并发实体,是一种轻量级的用户线程,是音讯的发送和接管方;

· channel,即通道,goroutine 应用通道发送和接管音讯。

CSP 并发模型相似罕用的同步队列,它更加关注音讯的传输方式,解耦了发送音讯的 goroutine 和接管音讯的 goroutine,channel 能够独立创立和存取,在不同的 goroutine 中传递应用。

应用关键字 go 即可应用 goroutine 并发执行代码片段,模式如下:

go expression

而 channel 作为一种援用类型,申明时须要指定传输数据类型,申明模式如下:

var name chan T // 双向 channel
var name chan <- T // 只能发送音讯的 channel
var name T <- chan // 只能接管音讯的 channel

其中,T 即为 channel 可传输的数据类型。channel 作为队列,遵循音讯先进先出的程序,同时保障同一时刻只能有一个 goroutine 发送或者接管音讯。
应用 channel 发送和接管音讯模式如下:

channel <- val // 发送音讯
val := <- channel // 接管音讯
val, ok := <- channel // 非阻塞接管音讯

goroutine 向曾经填满信息的 channel 发送信息或从没有数据的 channel 接管信息会阻塞本身。goroutine 接管音讯时能够应用非阻塞的形式,无论 channel 中是否存在音讯都会立刻返回,通过 ok 布尔值判断是否接管胜利。
创立一个 channel 须要应用 make 函数对 channel 进行初始化,模式如下所示:

ch := make(chan T, sizeOfChan)

初始化 channel 时能够指定 channel 的长度,示意 channel 最多能够缓存多少条信息。上面咱们通过一个简略例子演示 goroutine 和 channel 的应用:

package main
import (
"fmt"
"time"
)
// 生产者
func Producer(begin, end int, queue chan<- int) {
for i:= begin ; i < end ; i++ {fmt.Println("produce:", i)
queue <- i
}
}
// 消费者
func Consumer(queue <-chan int) {
for val := range queue  { // 以后的消费者循环生产
fmt.Println("consume:", val)
}
}
func main() {queue := make(chan int)
defer close(queue)
for i := 0; i < 3; i++ {go Producer(i * 5, (i+1) * 5, queue) // 多个生产者
}
go Consumer(queue) // 单个消费者
time.Sleep(time.Second) // 防止主 goroutine 完结程序
}

这是一个简略的多生产者和单生产的代码例子,生产 goroutine 将生产的数字通过 channel 发送给生产 goroutine。上述例子中,生产 goroutine 应用 for:range 从 channel 中循环接管音讯,只有当相应的 channel 被内置函数 close 后,该循环才会完结。channel 在敞开之后不能够再用于发送音讯,然而能够持续用于接管音讯,从敞开的 channel 中接管音讯或者正在被阻塞的 goroutine 将会接管零值并返回。还有一个须要留神的点是,main 函数由主 goroutine 启动,当主 goroutine 即 main 函数执行完结,整个 Go 程序也会间接执行完结,无论是否存在其余未执行完的 goroutine。

  1. select 多路复用

当须要从多个 channel 中接管音讯时,能够应用 Go 提供的 select 关键字,它提供相似多路复用的能力,使得 goroutine 能够同时期待多个 channel 的读写操作。select 的模式与 switch 相似,然而要求 case 语句前面必须为 channel 的收发操作,一个简略的例子如下:

package main
import (
"fmt"
"time"
)
func send(ch chan int, begin int)  {
// 循环向 channel 发送音讯
for i :=begin ; i< begin + 10 ;i++{ch <- i}
}
func receive(ch <-chan int)  {
val := <- ch
fmt.Println("receive:", val)
}
func main()  {ch1 := make(chan int)
ch2 := make(chan int)
go send(ch1, 0)
go receive(ch2)
// 主 goroutine 休眠 1s,保障调度胜利
time.Sleep(time.Second)
for {
select {
case val := <- ch1: // 从 ch1 读取数据
fmt.Printf("get value %d from ch1n", val)
case ch2 <- 2 : // 应用 ch2 发送音讯
fmt.Println("send value by ch2")
case <-time.After(2 * time.Second): // 超时设置
fmt.Println("Time out")
return
}
}
}

在上述例子中,咱们应用 select 关键字同时从 ch1 中接收数据和应用 ch2 发送数据,输入的一种可能后果为:

get value 0 from ch1
get value 1 from ch1
send value by ch2
receive: 2
get value 2 from ch1
get value 3 from ch1
get value 4 from ch1
get value 5 from ch1
get value 6 from ch1
get value 7 from ch1
get value 8 from ch1
get value 9 from ch1
Time out

因为 ch2 中的音讯仅被接管一次,所以仅呈现一次“send value by ch2”,后续音讯的发送将被阻塞。select 语句别离从 3 个 case 中选取返回的 case 进行解决,当有多个 case 语句同时返回时,select 将会随机抉择一个 case 进行解决。如果 select 语句的最初蕴含 default 语句,该 select 语句将会变为非阻塞型,即当其余所有的 case 语句都被阻塞无奈返回时,select 语句将间接执行 default 语句返回后果。在上述例子中,咱们在最初的 case 语句应用了 <-time.After(2 * time.Second) 的形式指定了定时返回的 channel,这是一种无效从阻塞的 channel 中超时返回的小技巧

 2. Context 上下文

当须要在多个 goroutine 中传递上下文信息时,能够应用 Context 实现。Context 除了用来传递上下文信息,还能够用于传递终结执行子工作的相干信号,停止多个执行子工作的 goroutine。Context 中提供以下接口:

type Context interface {Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}}
  • Deadline 办法,返回 Context 被勾销的工夫,也就是实现工作的截止日期;
  • Done,返回一个 channel,这个 channel 会在当前工作实现或者上下文被勾销之后敞开,屡次调用 Done 办法会返回同一个 channel;
  • Err 办法,返回 Context 完结的起因,它只会在 Done 返回的 channel 被敞开时才会返回非空的值,如果 Context 被勾销,会返回 Canceled 谬误;如果 Context 超时,会返回 DeadlineExceeded 谬误。
  • Value 办法,可用于从 Context 中获取传递的键值信息。

在 Web 申请的处理过程中,一个申请可能启动多个 goroutine 协同工作,这些 goroutine 之间可能须要共享申请的信息,且当申请被勾销或者执行超时时,该申请对应的所有 goroutine 都须要疾速完结,开释资源。Context 就是为了解决上述场景而开发的,咱们通过上面一个例子来演示:

package main
import (
"context"
"fmt"
"time"
)
const DB_ADDRESS  = "db_address"
const CALCULATE_VALUE  = "calculate_value"
func readDB(ctx context.Context, cost time.Duration)  {fmt.Println("db address is", ctx.Value(DB_ADDRESS))
select {case <- time.After(cost): //  模仿数据库读取
fmt.Println("read data from db")
case <-ctx.Done():
fmt.Println(ctx.Err()) // 工作勾销的起因
// 一些清理工作
}
}
func calculate(ctx context.Context, cost time.Duration)  {fmt.Println("calculate value is", ctx.Value(CALCULATE_VALUE))
select {case <- time.After(cost): //  模仿数据计算
fmt.Println("calculate finish")
case <-ctx.Done():
fmt.Println(ctx.Err()) // 工作勾销的起因
// 一些清理工作
}
}
func main()  {ctx := context.Background(); // 创立一个空的上下文
// 增加上下文信息
ctx = context.WithValue(ctx, DB_ADDRESS, "localhost:10086")
ctx = context.WithValue(ctx, CALCULATE_VALUE, 1234)
// 设定子 Context 2s 后执行超时返回
ctx, cancel := context.WithTimeout(ctx, time.Second * 2)
defer cancel()
// 设定执行工夫为 4 s
go readDB(ctx, time.Second * 4)
go calculate(ctx, time.Second * 4)

// 充沛执行
time.Sleep(time.Second * 5)
}

在上述例子中,咱们模仿了一个申请中同时进行数据库拜访和逻辑计算的操作,在申请执行超时时,及时敞开尚未执行完结 goroutine。咱们首先通过 context.WithValue 办法为 context 增加上下文信息,Context 在多个 goroutine 中是并发平安的,能够平安地在多个 goroutine 中对 Context 中的上下文数据进行读取。接着应用 context.WithTimeout 办法设定了 Context 的超时工夫为 2s,并传递给 readDB 和 calculate 两个 goroutine 执行子工作。在 readDB 和 calculate 办法中,应用 select 语句对 Context 的 Done 通道进行监控。因为咱们设定了子 Context 将在 2s 之后超时,所以它将在 2s 之后敞开 Done 通道;然而预设的子工作执行工夫为 4s,对应的 case 语句尚未返回,执行被勾销,进入到清理工作的 case 语句中,完结掉以后的 goroutine 所执行的工作。预期的输入后果如下:

calculate value is 1234
db address is localhost:10086
context deadline exceeded
context deadline exceeded

应用 Context,可能无效地在一组 goroutine 中传递共享值、勾销信号、deadline 等信息,及时敞开不须要的 goroutine。

小结

本文咱们次要介绍了 Go 语言并发个性,次要蕴含:

  • Go 的 MPG 线程模型;
  • goroutine 和 channel;
  • select 多路复用;
  • Context 上下文。

除了反对 CSP 的并发模型,Go 同样反对传统的线程与锁并发模型,提供了互斥锁、读写锁、并发期待组、同步期待条件等一系列同步工具,这些同步工具的构造体位于 sync 包中,与其余语言的同步工具应用形式相差无几。Go 在语言档次反对协程并发,在并发性能上体现卓越,可能充沛开掘多核 CPU 的运算性能。心愿本节课的学习,可能无效晋升你对 Go 并发设计和编程的认知。

本文分享自华为云社区《如何应用 Go 更好地开发并发程序》,原文作者:aoho。

点击关注,第一工夫理解华为云陈腐技术~

正文完
 0