随着硬件的发展,并发程序变得越来越重要。Web服务器会一次处理成千上万的请求。平板电脑和手机app在渲染用户画面同时还会在后台执行各种计算任务和网络请求。即使是传统的批处理问题--读取数据,计算,写输出--现在也会用并发来隐藏掉I/O的操作延迟以充分利用现代计算机设备的多个核心。
1. 并发与并行
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,通过cpu时间片轮转使多个进程快速交替的执行。
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。
2. go 并发编程
go 的并发编程采用的 CSP (Communicating Sequential Process) 模型,主要基于协程 goroutine 和通道 channel .
2.1 go协程
2.1.1 协程基本概念
goroutine是Go语言中的一种轻量级线程,用于实现并发。与传统的线程相比,goroutine的创建和销毁的代价更小,并且可以高效地复用系统资源。
Goroutine 是 Go 语言支持并发的核心,在一个Go程序中同时创建成百上千个goroutine是非常普遍的,一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。区别于操作系统线程由系统内核进行调度, goroutine 是由Go运行时(runtime)负责调度。例如Go运行时会智能地将 m个goroutine 合理地分配给n个操作系统线程,实现类似m:n的调度机制,不再需要Go开发者自行在代码层面维护一个线程池。
在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能------goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可以了,就是这么简单粗暴。
在 go 语言中,并发编程使用关键字 go 即可快速启动一个并发运行的 goroutine. 只需在函数调⽤语句前添加 go 关键字,就可创建并发执⾏单元,但go关键字后面必须跟一个函数,不能是语句或其他,函数的返回值将被忽略。
2.1.1 启动方式
Goroutine启动的两种方式:有名函数和匿名函数
go
匿名函数
package main
import "time"
func main() {
go func() {
sum := 0
for i := 0; i < 1000; i++ {
sum += i
}
println(sum)
}()
//设置等待时间,防止main函数提前退出
time.Sleep(5 * time.Second)
}
有名函数
package main
import "time"
func sum() {
sum := 0
for i := 0; i < 1000; i++ {
sum += i
}
println(sum)
}
func main() {
go sum()
//设置等待时间,防止main函数提前退出
time.Sleep(5 * time.Second)
}
2.1.2 goroutine特性
- Go的执行是非阻塞的,不会等待。
- Go后面的函数的返回值会被忽略。调度器不能保证多个goroutine的执行次序。
- 没有父子goroutine的概念,所有的goroutine是平等地被调度和执行的。
- Go程序在执行时会单独为main函数创建一个goroutine,遇到其他go关键字时再去创建其他的goroutine。
- Go 没有暴露 goroutine id 给用户,所以不能在一个goroutine里面显式地操作另一个goroutine,不过runtime 包提供了一些函数访问和设置 goroutine的相关信息。
2.2 channel
2.2.1 通道基本知识
单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。虽然可以使用共享内存进行数据交换,但是共享内存在不同的 goroutine 中容易发生竞态问题。为了保证数据交换的正确性,很多并发模型中必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。
Go语言采用的并发模型是CSP(Communicating Sequential Processes)
,提倡通过通信实现共享内存 而不是通过共享内存而实现通信 。如果说 goroutine 是Go程序并发的执行体,channel
就是它们之间的连接。channel
是可以让一个 goroutine 发送特定值到另一个 goroutine 的通信机制。
Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
golang 提供了通道类型 chan,用于在并发操作时的通信,它本身就是并发安全的. 通过 chan 可以创建无缓冲、缓冲通道,单向通道,满足不同需求.chan是引用类型的数据,在作为参数传递的时候,传递的是内存地址
-
无缓冲通道: 要求接收和发送数据的 goroutine 同时准备好,否则将会阻塞.
-
有缓冲通道: 给予通道一个容量值,只要有值便可以接受数据,有空间便可以发送数据,可以不阻塞的完成.
-
单向通道: 默认情况通道是双向的,可以接收及发送数据. 也可以创建单向通道,只能收或者发数据. 如下是单向接受通道
声明的通道类型变量需要使用内置的make
函数初始化之后才能使用。具体格式如下:
go
通道 := make(chan 元素类型, [缓冲大小])
单向 channel 变量的声明非常简单,只能写入数据的通道类型为`chan<-`,只能读取数据的通道类型为`<-chan`,格式如下:
var 通道实例 chan<- 元素类型 // 只能写入数据的通道
var 通道实例 <-chan 元素类型 // 只能读取数据的通道
ch := make(chan int)
// 声明一个只能写入数据的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能读取数据的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch
2.2.2 通道发送和接收数据
接收和发送的语法:
kotlin
data := <- a // read from channel a
a <- data // write to channel a
在通道上箭头的方向指定数据是发送还是接收。
2.2.3 发送和接收默认是阻塞的
一个通道发送和接收数据,默认是阻塞的。当一个数据被发送到通道时,在发送语句中被阻塞,直到另一个Goroutine从该通道读取数据。相对地,当从通道读取数据时,读取被阻塞,直到一个Goroutine将数据写入该通道。
这些通道的特性是帮助Goroutines有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。
示例代码:
go
package main
import "fmt"
func main() {
var ch1 chan bool //声明,没有创建
fmt.Println(ch1) //<nil>
fmt.Printf("%T\n", ch1) //chan bool
ch1 = make(chan bool) //是引用类型的数据
fmt.Println(ch1)
go func() {
for i := 0; i < 10; i++ {
fmt.Println("子goroutine中,i:", i)
}
// 循环结束后,向通道中写数据,表示要结束了。。
ch1 <- true
fmt.Println("----结束---")
}()
data := <-ch1 // 从ch1通道中读取数据
fmt.Println("data-->", data)
fmt.Println("----main over---")
}
运行结果:
在上面的程序中,我们先创建了一个chan bool通道。然后启动了一条子Goroutine,并循环打印10个数字。然后我们向通道ch1中写入输入true。然后在主goroutine中,我们从ch1中读取数据。这一行代码是阻塞的,这意味着在子Goroutine将数据写入到该通道之前,主goroutine将不会执行到下一行代码。因此,我们可以通过channel实现子goroutine和主goroutine之间的通信。当子goroutine执行完毕前,主goroutine会因为读取ch1中的数据而阻塞。从而保证了子goroutine会先执行完毕。这就消除了对时间的需求。在之前的程序中,我们要么让主goroutine进入睡眠,以防止主要的Goroutine退出。要么通过WaitGroup来保证子goroutine先执行完毕,主goroutine才结束。
再一个例子,这个程序将打印一个数字的各位数的平方和。
go
package main
import (
"fmt"
)
func calcSquares(number int, squareop chan int) {
sum := 0
for number != 0 {
digit := number % 10
sum += digit * digit
number /= 10
}
squareop <- sum
}
func calcCubes(number int, cubeop chan int) {
sum := 0
for number != 0 {
digit := number % 10
sum += digit * digit * digit
number /= 10
}
cubeop <- sum
}
func main() {
number := 589
sqrch := make(chan int)
cubech := make(chan int)
go calcSquares(number, sqrch)
go calcCubes(number, cubech)
squares, cubes := <-sqrch, <-cubech
fmt.Println("Final output", squares+cubes)
}
运行结果:
2.2.4 死锁
使用通道时要考虑的一个重要因素是死锁。如果Goroutine在一个通道上发送数据,那么预计其他的Goroutine应该接收数据。如果这种情况不发生,那么程序将在运行时出现死锁。
类似地,如果Goroutine正在等待从通道接收数据,那么另一些Goroutine将会在该通道上写入数据,否则程序将会死锁。
示例代码:
go
package main
func main() {
ch := make(chan int)
ch <- 5
}
2.2.4 关闭通道
关闭 channel
关闭 channel 非常简单,直接使用Go语言内置的 close() 函数即可。 在介绍了如何关闭 channel 之后,我们就多了一个问题:如何判断一个 channel 是否已经被关闭?我们可以在读取的时候使用多重返回值的方式:
x, ok := <-ch
这个用法与 map 中的按键获取 value 的过程比较类似,只需要看第二个 bool 返回值即可,如果返回值是 false 则表示 ch 已经被关闭。
2.4 select多路复用
在某些场景下我们可能需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以被接收那么当前 goroutine 将会发生阻塞。Go 语言内置了select
关键字,使用它可以同时响应多个通道的操作。
Select 的使用方式类似于之前学到的 switch 语句,它也有一系列 case 分支和一个默认的分支。每个 case 分支会对应一个通道的通信(接收或发送)过程。select 会一直等待,直到其中的某个 case 的通信操作完成时,就会执行该 case 分支对应的语句。具体格式如下:
go
select {
case <-ch1:
//...
case data := <-ch2:
//...
case ch3 <- 10:
//...
default:
//默认操作
}
Select 语句具有以下特点。
- 可处理一个或多个 channel 的发送/接收操作。
- 如果多个 case 同时满足,select 会随机选择一个执行。
- 对于没有 case 的 select 会一直阻塞,可用于阻塞 main 函数,防止退出。
3. 总结
- goroutine是Go语言中的一种轻量级线程,用于实现并发编程。通过关键字"go"可以创建goroutine,并在其中并发执行任务。goroutine的创建和销毁的代价较小,可以高效地复用系统资源,同时可以创建成百上千甚至更多的goroutine。通过goroutine,可以同时执行多个任务,提高系统的吞吐量和响应速度。
- 通道(channel)是用于实现goroutine之间通信和同步的一种特殊类型。通道可以看作是一种类型安全的消息队列,用于在goroutine之间传递数据。通道可以通过内置的make函数创建,可以是有缓冲的或无缓冲的。无缓冲通道保证发送和接收的同步,发送和接收操作会阻塞,直到发送者和接收者都准备好。有缓冲通道可以在一定程度上解耦发送和接收操作,发送操作只有在通道缓冲区已满时才会阻塞,接收操作只有在通道缓冲区为空时才会阻塞。通道的发送和接收操作默认是阻塞的,可以通过select语句结合default子句实现非阻塞的发送和接收操作。通道可以实现线程安全的数据传递和同步,避免了共享内存带来的竞争条件和死锁问题。