并发
并发指在同一时间可以执行多个任务。并发编程含义比较广泛,包含多线程编程、多进程编程及分布式程序等。本章讲解的并发含义属于多线程编程。
Go 语言通过编译器运行时( runtime ),从语言上支持了并发的特性。 Go 的并发通过goroutine 特性完成。 goroutine 类似于线程,但是可以根据需要创建多个goroutine并发工作。 goroutine 是由 Go 语言的运行时调度完成,而线程是由操作系统调度完成。
9.1 轻量级线程
goroutine 概念类 于线程,但 goroutine由Go 程序运行时的调度和管理。 Go 程序会智能地将goroutine 任务合理地分配给每个CPU。
Go 程序从 main 包的 main() 函数开始,在程序启动时,程序就会为 main() 函数创建一个默认的 goroutine。
9.1.1 普通函数创建goroutine
使用go关键字为一个函数创建一个goroutine。一个函数可以被创建多个goroutine。一个goroutine必定对应一个函数。
-
格式
写法如下:
go 函数名(参数列表)
使用go关键字创建goroutine时,被调用函数的返回值会被忽略。
-
例子
使用go关键字,将running()函数并发执行,每秒打印一次计数器,而main的goroutine则等待用户输入,两个行为可以同时进行。参考如下代码:
func running() { var times int for { times++ fmt.Println("tick", times) //超时1秒 time.Sleep(time.Second) } } func main() { // 并发执行程序 go running() // 接收命令行输入,不做任何事情 var input string fmt.Scanln(&input) }
这个例子中, Go 程序在启动时,运行时(runtime )会默认为 main函数创建一个 goroutine。main 函数的 goroutine 中执行到 running 语句时,归属于 running函 数的 goroutine被创建, running函数开始在自己的goroutine 中执行。此时, main继续执行,两个 goroutine通过 Go 程序的调度机制同时运作。
9.1.2 使用匿名函数创建goroutine
-
格式
使用匿名函数或闭包创建 goroutine 时,除了 将函数定义部分写在 go 的后面之外,还需要加上匿名函数的调用参数,格式如下:
go func (参数列表){
函数体
}(调用参数列表)
-
例子
在main() 函数中创建 匿名函数并为匿名函数启动 goroutine 匿名函数没有参数。代码将并行执行定时打印计数的效果。参见下面的代码:
func main() { go func() { var times int for { times++ fmt.Println("tick", times) time.Sleep(time.Second) } }() var input string fmt.Scanln(&input) //使用外部输入打断协程 }
9.1.3 调整并发的运行性能(GOMAXPROCS)
Go 程序运行时( runtime )实现了一个小型的任务调度器。 这套调度器的工作原理类似于操作系统调度线程, Go 程序调度器可以高效地将 CPU资源分配给每一个任务。 Go语言中,可以通过 runtim e.GOMAXPROCS ()函数做到,格式为:
runtime.GOMAXPROCS(逻辑CPU数量)
这里的逻辑CPU数量有以下几种数值:
- <1:不修改任何数值
- =1:单核心执行
- 大于1: 多核并发执行
GOMAXPROCS 同时是一个环境变量,在应用程序启动前设置环境变量可以起到相同的作用。
9.1.4 理解并发和并行
- 并发:把任务在不同的时间点交给处理器运行。在同一时间点,任务不会同时运行。
- 并行:把每个任务分配给每一个处理器独立完成。在同一时间点,任务一定时同时运行。
GO GOMAXPROCS 数量与任务数量相等时,可以做到并行执行,但一般情况下都是并发执行。
9.2 通道—在多个goroutine间通信的管道
函数与函数间需要交换数据才能体现并发执行函数的意义。虽然可以使用共享内存进行数据交换,但是共享内存在不同的 goroutine中容易发生竞态问题。为 了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。
Go 语言提倡使用通信的方法代替共享内存,这里通信的方法就是使用通道( channel),如下图所示。
9.2.1 通道的特性
通道(channel)是一种特殊的类型。在任何时候,同时只能有一个goroutine访问通道进行发送和获取数据。goroutine间通过通道就可以通信。
通道像一个传送带或者队列,总是遵循先入先出 First In First Out 的规则,保证收发数据的顺序。
9.2.2 声明通道类型
通道的元素类型就是在其内部传输的数据类型,声明如下:
var 通道变量 chan 通道类型
-
通道类型: 通道内的数据类型
-
通道变量: 保存通道的变量
chan 类型的空值是 nil ,声明后需要配合 make 后才能使用。
9.2.3 创建通道
通道是引用类型,需要使用make创建,格式如下:
通道实例 := make(chan 数据类型)
例如:
ch1 := make(chan int) //创建一个整型类型的通道
ch2 := make(chan interface{}) //创建一个空接口类型的通道,可以存放任何格式
type Equip struct{}
ch3 := make(chan *Equip) //创建Equip指针类型的通道,可以存放任意格式
9.2.4 使用通道发送数据
通道创建后,就可以使用通道进行发送和接收操作。
-
通道发送数据的格式
通道发送使用特殊符号”<-“,将数据通过通道发送的格式为:
通道变量 <- 值
-
通过通道发送数据的例子
使用make创建一个通道后,就可以使用符号发送数据机,代码如下:
ch := make(chan interface{}) //创建一个空接口通道 ch <- 0 //0放入通道 ch <- "hello" //字符串放入通道
-
发送将持续阻塞直到数据被接收
将数据往通道中发送时,如接收方一直没有接收,,那么发送操作将持续阻塞。Go运行时智能地发现一些永远无法成功的语句并作出提示:
运行代码,报错:
fatal error: all goroutines are asleep - deadlock!
意思是:运行发现所有的 goroutine (包括 main)都处于等待 goroutine。就是说所有 goroutine 中的 channel 并没有形成发送和接收到应的代码。
9.2.5 使用通道接收数据
通道接收同样使用符号”<-“,通道接收有如下特性:
- 通道的收发操作在不同的两个goroutine间进行。
- 接收将持续阻塞直到发送方发送数据
- 每次接收一个元素
通道的数据接收一共有以下4种写法。
-
阻塞接收数据
阻塞模式接收数据时,将接收变 作为“ <-"操作符的左值,格式如下
data : = <- ch
执行该语句时将会阻塞,直到接收到数据并赋值给data变量
-
非阻塞接收数据
使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下:
data, ok := <-ch
data :表示接收到的数据。未接收到数据时,date为通道类型的零值
ok: 表示是否接收到数据。
非阻塞的通道接收方法可能造成高的 CPU 占用,因此使用非常少。如果需要实现接口超时检测,可以配合 select 和计时器channel 进行。
-
接收任意的数据,忽略接收的数据
阻塞接收数据后,忽略从通道返回的数据,格式如下:
<-ch
-
循环接收
通道的数据接收可以借用 for range 语句进行多个元素的接收操作,格式如下:
for data : = range ch { }
通道ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过 for 遍历获得的变量只有一个,即上面例子中的 data。
例子如下:
func main() { ch := make(chan int) go func() { for i := 3; i >= 0; i--{ ch <- i time.Sleep(time.Second) } }() for data := range ch { //遍历通道,接收到数值0退出 fmt.Println(data) if data == 0 { break } } }
9.2.6 单向通道
Go 的通道可在声明时约束其操作方向 ,如只发送或是只接收。这种被约束方向的通道被称做单向通道。
-
单向通道的声明格式
只能发送的通道类型为chan<-,只能接收的通道类型为<-chan,格式如下:
var 通道实例 chan<- 元素类型
var 通道实例 <-chan 元素类型
-
例子
示例代码如下:
ch := make(chan int) var chSendOnly chan<- int = ch var chRecvOnly <-chan int = ch
-
time包中的单向通道
time包中的计时器会返回一个timer实例,代码如下:
timer := timer.NewTimer(time.Second)
timer的Timer类型定义如下:
type Timer struct { C <-chan Timer //C通道的类型就是 种只能接收的单向通道。 r runtimeTimer }
9.2.7 带缓冲的通道
在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无需等待接收方接收即可完成发送过程,并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时不会发生阻塞,直到通道中无数据可读,通道将会再次阻塞。
-
创建带缓冲的通道
格式如下:
通道实例 := make(chan 通道类型,缓冲大小)
举例如下:
func main() { ch := make(chan int, 3) //创建一个3个元素缓冲大小的整型通道 fmt.Println(len(ch)) //发送3个整型元素到通道 ch <- 1 ch <- 2 ch <- 3 fmt.Println(len(ch)) }
-
阻塞条件
带缓冲的通道在很多特性上和无缓冲通道类似。无缓冲通道可看做长度永远为0 的带缓冲通道。因此根据这特性,带缓冲通道在下面的情况下依然会发生阻塞:
(1)带缓冲通道被填满时,尝试再次发送数据时会发生阻塞
(2)带缓冲通道为空时,尝试接收数据时会发生阻塞
9.2.8 通道的多路复用
多路复用通常表示在一个信道上传输多路信号或数据流的过程和技术。
Go语言提供了select关键字,可以同时响应多个通道的操作,select的每个case都会对应一个通道的收发过程。当收发完成时,就会触发case中响应的语句。多个操作在每次select中挑选一个响应。格式如下:
select {
case 操作1:
响应操作1
case 操作2:
响应操作2
...
default:
没有操作情况
}
操作1,操作2 包含通道收发语句,请参考下表
操作 | 语句示例 |
---|---|
接收任意数据 | case <-ch: |
接收变量 | case d: = <-ch: |
发送数据 | case ch<-100: |
9.2.9 关闭通道后继续使用通道
通道是一个引用对象,和map类似。map在没有任何外部引用时,Go程序在运行时会自动对内存进行垃圾回收(Garbage Collection GC)。同理,通道也可以被垃圾回收,也可以主动被关闭。
-
格式
使用close()来关闭一个通道:
close(ch)
-
给被关闭通道发送数据将触发panic
被关闭的通道不会被置为nil。如果尝试对已经关闭的通道进行发送,将会触发宕机。
提示错误:panic: send on closed channel
-
从已关闭的通道接收数据时将不会发生阻塞
从已关闭的通道接收数据或正在接收数据时,将会接收到通道类型的零值,然后停止阻塞并返回。
代码示例如下:
func main() { ch := make(chan int, 2) ch <- 0 ch <- 1 close(ch) //关闭缓冲 for i := 0; i < cap(ch)+1; i++ { v, ok := <-ch fmt.Println(v, ok) } } /* 代码运行结果: 0 true 1 true 0 false */
结果前两行正确输出带缓冲通道的数据,表明缓冲通道在关闭后依然可以访问内部的数据。
运行结果第三行的 ”0,false “, 表示在通道关闭状态下取出的值。0表示这个通道的默认值, false表示没有获取成功,因为此时通道已经空了。
9.3 同步
在某些轻量级的场合,原子访问(atomic 包)、互斥锁( sync .Mute )以及等待组( sync. WaitGroup ) 能最大程度满足需求。
9.3.1 竞态检测
当多线程并发运行的程序竞争访问和修改同一块资源时,会发生竞态问题。
使用atomic包进行变量操作,示例如下:
var (
seq int64 //序列号
)
func GenId() int64 {
//尝试原子的增加序列号
return atomic.AddInt64(&seq, 1) //
// return seq //此处会有竞争态问题
}
func main() {
for i := 0; i < 10; i++ {
go GenId()
}
}
9.3.2 互斥锁(sync.Mutex)
互斥锁时一种常用的控制共享资源访问的方法。在Go程序中的使用非常简单,参见下面的代码:
var (
count int
countGuard sync.Mutex
)
func GetCount() int {
countGuard.Lock()
defer countGuard.Unlock() //defer延迟在函数退出时解除锁定
return count
}
func SetCount(c int) {
countGuard.Lock()
count = c
countGuard.Unlock()
}
func main() {
SetCount(1) //可以进行并发安全的设置
fmt.Println(GetCount()) //可以进行并发安全的获取
}
9.3.3 读写互斥锁(sync.RWMutex)
在读多写少的环境中,可以优先使用读写互斥锁,sync包中的RWMutex提供了读写互斥锁的封装。
我们将互斥锁的例子改写成读写互斥锁,代码如下:
var (
count int
countGuard sync.RWMutex
)
func GetCount() int {
countGuard.RLock()
defer countGuard.RUnlock() //defer延迟在函数退出时解除锁定
return count
}
9.3.4 等待组(sync.WaitGroup)
除了可以使用通道和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步。
等待组有以下几个方法可用,如下表所示:
方法名 | 功能 |
---|---|
(wg *WaitGroup) Add(delta int) | 等待组的计数器+1 |
(wg *WaitGroup) Done() | 等待组的计数器-1 |
(wg *WaitGroup) Wait() | 当等待组的计数器不为0阻塞直到变0 |
等待组内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减。当我们添加了N个并发任务进行工作时,就将等待组的计数器值增加N。每个任务完成时,这个值减 1。同时,在另外 goroutine 中等待这个等待组的计数器值为0时,表示所有任务已经完成。 以下代码演示了这一过程
func main() {
var wg sync.WaitGroup
var urls = []string{
"http://www.github.com",
"http://www.baidu.com",
"http://www.golangtc.com",
}
for _, url := range urls {
wg.Add(1) //每开始一个任务等待组加1
go func(url string) {
defer wg.Done()
_, err := http.Get(url)
fmt.Println(url, err)
}(url) //通过参数传递url地址
}
wg.Wait() //等待所有任务完成
fmt.Println("over")
}