Go语言并发机制是Go语言得以在后端开发中大显身手的核心机制。
Go语言的并发机制在相当大的程度上比Java简单,比Python略微强大。这篇博文是简介,后面还会接着出详解。
Go语言并发底层理论基础基于图灵奖得主C.A.R Hoare的主要贡献之一CSP(通信顺序过程)。大部分介绍Go语言并发机制的文章都以简述草草了事,因为这部分比较困难,它在我们学校里面是面向学硕的一门课程,而对工程师而言只要会用就可以了。之后我可能出相关文章。
Go语言并发机制
并发是编程语言中一个重要的概念,当前主要的编程语言都在语言层面支持并发,Go语言中的并发编程比其他语言都要简单,这是Go语言在后端开发逐渐流行起来的原因。
并行与并发
- 并行:同一时刻执行多个任务。
- 并发:同一个时间段内执行多个任务
Go语言的并发通过goroutine(直译应该是Go程)实现。goroutine是用户态的轻量级线程,因此上下文切换要比线程的上下文切换开销要小很多。
一谈到并发,自然就会有通信、调度、共享资源竞争这些需要解决的问题。
Go语言提供channel在多个goroutine中进行通信。goroutine和channel是Go语言秉承CSP(Communicating Sequence Process)实现并发模式的基础。Go语言的并发机制主要基于CSP,小部分实现也采用了CSP的竞品CCS(Calculus of Communicating System)。
在实际的项目中,你可以创建成千上万个goroutine,这些goroutine的调度将由Go语言运行时调度自动完成,因而开销更小且更加灵活,而线程调度则需要操作系统代为完成,用户态到核心态的切换将耗费可观的时间。
共享资源竞争问题,需要使用锁以及相关机制保证并发安全,Go语言的sync包中提供了并发实现的一系列工具,其中包括了用于原子操作的sync/atomic包。
goroutine
在Java/C++中实现并发编程的时候,程序员需要自己维护一个线程池,并且需要手动去包装一个又一个的任务然后自己去调度线程执行任务并维护上下文切换。这一切将会耗费程序员大量的时间和心智,而且很容易发生错误。因此如果编程语言能在语言层面上提供一种机制自动分配定义好的任务到CPU上实现并发执行,将能大大减少程序员的工作量,而goroutine就是这样一种机制。
goroutine的概念类似于coroutine协程而不是线程(参考文献第二篇说是线程,这是一个广泛存在的错误)。也就是说,它由Go程序自身来完成调度,而不需要交由操作系统切换到核心态处理。goroutine的调度由Go运行时调度器使用m:n调度技术进行调度,比线程调度成本低很多。在Go语言中,你不需要自己去写进程、线程、协程,你只需要使用go关键字发起一个goroutine就可以了,简单如此,因而称Go语言为现代化的编程语言。
go func_name(para_list)
goroutine和coroutine的区别
C#、Lua、Python都支持coroutine。coroutine和goroutine在名称上类似,都属于轻量级用户态线程,由程序逻辑调度,可以在函数或者语句在独立环境中运行,但它们有一些区别:
goroutine可能发生并行执行,但coroutine始终顺序执行。goroutine间使用channel进行通信,coroutine使用yield和resume操作进行通信coroutine的运行机制属于协作式任务处理,goroutine则属于抢占式任务处理。
特别注明:Go语言中main函数也是作为goroutine运行的,下面有实例。
package main
import (
"fmt"
)
func print10(i int) {
fmt.Println("goroutine", i)
}
func main() {
for i:=0; i<10; i++ {
go print10(i)
fmt.Println("goroutine", i, "Done")
}
}
结果1:
goroutine 0 Done
goroutine 0
goroutine 1 Done
goroutine 1
goroutine 2
goroutine 2 Done
goroutine 3 Done
goroutine 4 Done
goroutine 3
goroutine 4
goroutine 5 Done
goroutine 6 Done
goroutine 7 Done
goroutine 8 Done
goroutine 9 Done
结果2:
goroutine 0
goroutine 0 Done
goroutine 1 Done
goroutine 2 Done
goroutine 3 Done
goroutine 1
goroutine 4 Done
goroutine 2
goroutine 3
goroutine 4
goroutine 5 Done
goroutine 6 Done
goroutine 6
goroutine 5
goroutine 7 Done
goroutine 8 Done
goroutine 7
goroutine 9 Done
goroutine 9
goroutine 8
结果说明什么呢?
Go语言中main函数也是作为goroutine运行的,而且如果main函数退出了,整个程序将退出,当前没有获得调度的goroutine也不会再运行了。main函数作为goroutine时,它的优先级也不会比其他goroutine高。
但是这显然不符合我们的期望,我们希望所有的协程都完成自己的工作之后程序再退出。这时候我们可以使用WaitGroup。
sync.WaitGroup可以用来实现简单的goroutine同步,实例如下:
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func print10(i int) {
defer wg.Done()
fmt.Println("goroutine", i)
}
func main() {
for i:=0; i<10; i++ {
wg.Add(1)
go print10(i)
fmt.Println("goroutine", i, "Done")
}
wg.Wait()
}
结果
goroutine 0 Done
goroutine 1 Done
goroutine 2 Done
goroutine 0
goroutine 3
goroutine 1
goroutine 3 Done
goroutine 4 Done
goroutine 5 Done
goroutine 6 Done
goroutine 2
goroutine 7 Done
goroutine 7
goroutine 4
goroutine 5
goroutine 6
goroutine 8
goroutine 8 Done
goroutine 9 Done
goroutine 9
打印的顺序不是按序的,这10个goroutine都被加入到了一个WaitGroup中,它们的调度是随机的。
wg.Wait()运行后,它会调度goroutine运行,等待组内所有的goroutine运行结束后返回。
channel
通道(channel)是Go语言中定义的用来传递数据的一种新的数据结构。它的概念和其他地方的通道一样,只不过Go在语言层面就提供了变量级别的实现。
通道可用于两个goroutine之间传递一个指定类型的值来进行同步运行和通信。操作符<-用于指定通道的方向。如果没有指定方向,默认为双向通道。
单纯地将函数并发执行是没有意义的。函数与函数之间需要交换数据才能体现并发执行函数的意义。虽然可以使用共享内存进行数据交换(其他语言中一种最高效的线程通信方式),但这种方式在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,加锁势必会造成性能问题。
Go语言的并发模型基于CSP,CSP提倡通过通信共享内存,而不是通过共享内次你实现通信。如果说goroutine是Go程序并发的执行体,channel是它们之间的连接。channel是可以让一个goroutine发送特定类型值(类型在通道定义的时候指定)到另一个goroutine的通信机制。
Go语言的通道(channel)是一种特殊的类型。通道像是一个传送带或者队列,总是遵循先入先出(First In First Out, FIFO)规则,保证数据收发的顺序。
channel声明语法如下
var channel_name chan elem_type
实例
var ch1 chan int // 声明传递整型数据的通道
var ch2 chan bool // 声明传递布尔类型的通道
var ch3 chan []int // 声明传递int形切片的通道
注:channel本身属于引用类型,空值对应<nil>。
package main
import "fmt"
func main() {
var ch1 chan int
fmt.Println(ch1)
}
// <nil>
channel初始化
声明的channel需要使用make函数初始化,语法如下:
make(chan elem_type, [缓冲区大小])
var ch1 chan int
ch1 = make(chan int)
等价于下面:
ch1 := make(chan int)
注:没有定义缓冲区大小的channel是没有缓冲区的,无缓冲区的channel又被称为阻塞通道,在没有变量接收值的情况下向channel发送数据会引起死锁(deadlock)。
无缓冲的channel上的发送操作会被阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。如果接收方先执行接收操作,那么接收方被阻塞,直到另一个goroutine在channel上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道又被称为同步通道。
channel操作
channel有发送、接收、关闭三种操作。发送和接收都是使用<-符号。
// 定义通道
ch := make(chan int)
// 发送
//一个值到通道中 需要满足通道所接收的数据类型 这里是整型
ch <- 10
// 接收
// 从通道中接收一个值并赋值给一个变量
x := <- ch
// 关闭
close(ch)
通道关闭需要注意的事情
单向通道
将通道作为函数参数在多个函数间传递时,我们可能需要限定不同函数使用通道的方式,比如只能发送或者接收。Go语言中提供了单向通道来处理这种情况。它体现在函数参数中指明了该函数使用通道的方式,使用<-标识数据传输的方向。
// 只能向通道发送数据
func func_name(ch chan<- int)
// 只能从通道中取数据
func func_name(ch <-chan int)
实例:
package main
import "fmt"
func counter(ch chan<- int) {
for i:=0; i<10; i++ {
ch <- i
}
close(ch)
}
func square(ch1 chan<- int, ch2 <-chan int) {
for i := range ch2 {
ch1 <- i*i
}
close(ch1)
}
func printer(ch <- chan int) {
for i := range ch {
fmt.Println(i)
}
}
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go counter(ch1)
go square(ch2, ch1)
printer(ch2)
}
结果如下:
0
1
4
9
16
25
36
49
64
81
注:因为通道先进先出的特性,即使goroutine是随机执行的,但输出结果仍是顺序的。
select多路复用
在某些场景下我们需要从多个通道接收数据。通道在接收数据时,如果没有数据接收会阻塞等待。
// Go语言中for语句替代了while语句
// for {} 相当于 while (true) {}
for {
data, ok := <- ch1
data, ok := <- ch2
...
data, ok := <- chn
}
这种实现方式逻辑是对的,但轮询操作在很多情况下都得尝试很多次才能取得成功,因而性能不高。为了应对这种场景,Go语言内置了select来实现类似于swich的分支选择,它和switch写法类似,不同在于case语句是某种通信操作。
语法如下:
select {
case communication_clause1:
statement1
case communication_clause2:
statement2
...
[default]:
[statementd]
}
- 每一个case必须是一个通信语句
- 所有通道表达式都会被求值
- 所有被发送的表达式都会被求值
- 如果任意一个case通信可以进行,它就执行,其他被忽略
- 如果有多个case都可以运行,select会随机公平地选出一个执行,其他case则不会被执行。
- 在没有任何case可以运行的情况下;如果存在default语句,则执行该语句;如果没有default语句,则阻塞,直到某个case可以通信,Go不会重新对通道进行求值。
实例:
package main
import (
"fmt"
)
func main() {
var c1, c2 chan int
var a = 1
c1 = make(chan int, 10)
c2 = make(chan int, 10)
select {
case c1 <- a:
fmt.Println("Send", a, "to c1")
case c2 <- a:
fmt.Println("Send", a, "to c2")
case b := <- c1:
fmt.Println("Received", b, "from c1")
case b := <- c2:
fmt.Println("Received", b, "from c2")
default:
fmt.Println("No communication")
}
}
// 程序执行的结果有以下两种:
// Send 1 to c1
// Send 2 to c2
// 以上两种随机出现
// 不会有其他种类 因为前两个case一定可以执行 后两个case一定不能执行(通道为空,不能发送) default不会执行
实例:双goroutine通道循环取值
package main
import "fmt"
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
// 创建一个匿名函数 直接开启goroutine
go func() {
for i:=0; i<10; i++ {
ch1 <- i
}
close(ch1) // 通道使用完 记得关闭
} ()
// 创建一个匿名函数 直接开启goroutine
go func() {
for i:=0; i<10; i++ {
i, ok := <- ch1
if !ok {
break
}
ch2 <- i * i
}
close(ch2) // 通道使用完 记得关闭
} ()
// 遍历通道取值
for i := range ch2 {
fmt.Println(i)
}
}
结果如下:
0
1
4
9
16
25
36
49
64
81
本文介绍了Go语言并发机制的核心概念和技术细节,包括goroutine、channel及select等关键组件的作用和使用方法。通过实例展示了如何利用这些机制进行高效并发编程。

1125

被折叠的 条评论
为什么被折叠?



