关于程序员:如何使用-Go-更好地开发并发程序

1次阅读

共计 7358 个字符,预计需要花费 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 线程模型(如下图)。

image.png

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 的高利用率。

image.png

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。

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 ch1\n”, 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 中超时返回的小技巧。

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 并发设计和编程的认知。

正文完
 0