go并发
1.相关概念
进程/线程
进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的一个独立单位。
线程是进程的一个执行实体,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
一个进程可以创建和撤销多个线程,同一个进程中的多个线程之间可以并发执行。
并发/并行
多线程程序在单核心的 cpu 上运行,称为并发;多线程程序在多核心的 cpu 上运行,称为并行。
并发与并行并不相同,并发主要由切换时间片来实现“同时”运行,并行则是直接利用多核实现多线程的运行,Go程序可以设置使用核心数,以发挥多核计算机的能力。
协程/线程
协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
线程:一个线程上可以跑多个协程,协程是轻量级的线程。
2.Goroutine
1) goroutine 其实就是线程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,而且Go语言内部也实现了 goroutine 之间的内存共享。
一个函数可以被创建多个 goroutine,一个 goroutine 必定对应一个函数。
//go 关键字放在方法调用前新建一个 goroutine 并执行方法体
//使用 go 关键字创建 goroutine 时,被调用函数的返回值会被忽略。如果需要在 goroutine 中返回数据,请使用后面介绍的通道(channel)特性,通过通道把数据从 goroutine 中作为返回值传出。
go GetThingDone(param1, param2);
//新建一个匿名方法并执行
go func(param1, param2) {
}(val1, val2)
//直接新建一个 goroutine 并在 goroutine 中执行代码块
go {
//do someting...
}
goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。
3.channel
1)使用make进行创建
channel 是类型相关的,也就是说,一个 channel 只能传递一种类型的值,这个类型需要在声明 channel 时指定。
通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。
ci := make(chan int)
cs := make(chan string)
cf := make(chan interface{})
2)无缓冲通道
a.发送数据
//通道变量 <- 值
// 创建一个空接口通道
ch := make(chan interface{})
// 将0放入通道中
ch <- 0
// 将hello字符串放入通道中
ch <- "hello"
b.发送阻塞
package main
func main() {
// 创建一个整型通道
ch := make(chan int)
// 尝试将0通过通道发送
ch <- 0
}
//报错:fatal error: all goroutines are asleep - deadlock!
运行时发现所有的 goroutine(包括main)都处于等待 goroutine。
c.接受数据
//1.阻塞接收数据
//直到接收数据并赋值给data
data := <-ch
//2.非阻塞接收数据
//data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。ok:表示是否接收到数据
//非阻塞的通道接收方法可能造成高的 CPU 占用,因此使用非常少。
data,ok := <-ch
//3.接收任意数据,忽略接收的数据
//执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。
//这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步
func main() {
// 构建一个通道
ch := make(chan int)
// 开启一个并发匿名函数
go func() {
fmt.Println("start goroutine")
ch <- 0 // 通过通道通知main的goroutine
fmt.Println("exit goroutine")
}()
fmt.Println("wait goroutine")
<-ch // 等待匿名goroutine
fmt.Println("all done")
}
//4.循环接收
func main() {
ch := make(chan int) // 构建一个通道
go func() { // 开启一个并发匿名函数
for i := 3; i >= 0; i-- {
ch <- i // 发送3到0之间的数值
time.Sleep(time.Second) // 每次发送完时等待
}
}()
for data := range ch { // 遍历接收通道数据
fmt.Println(data) // 打印通道数据
if data == 0 { // 当遇到数据0时, 退出接收循环
break
}
}
}
d.单向通道
//声明
ch := make(chan int)
// 声明一个只能发送的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能接收的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch
e.关闭
close(ch)
//带返回值
x, ok := <-ch
3)带缓冲通道
只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。
a.创建
//通道实例 := make(chan 通道类型, 缓冲大小)
ch := make(chan int, 3)
fmt.Println(len(ch)) // 查看当前通道的大小,此时为0
4)超时机制
select 机制不是专门为超时而设计的,却能很方便的解决超时问题,因为 select 的特点是只要其中有一个 case 已经完成,程序就会继续往下执行,而不会考虑其他 case 的情况。
select 有比较多的限制,其中最大的一条限制就是每个 case 语句里必须是一个 IO 操作
select {
case <-chan1:
// 如果chan1成功读到数据,则进行该case处理语句
case chan2 <- 1:
// 如果成功向chan2写入数据,则进行该case处理语句
default:
// 如果上面都没有成功,则进入default处理流程
}
在一个 select 语句中,Go语言会按顺序从头至尾评估每一个发送和接收的语句。
如果其中的任意一语句可以继续执行(即没有被阻塞),那么就从那些可以执行的语句中任意选择一条来使用。
如果没有任意一条语句可以执行(即所有的通道都被阻塞),那么有如下两种可能的情况:
- 如果给出了 default 语句,那么就会执行 default 语句,同时程序的执行会从 select 语句后的语句中恢复;
- 如果没有 default 语句,那么 select 语句将被阻塞,直到至少有一个通信可以进行下去。
func main() {
ch := make(chan int)
quit := make(chan bool)
//新开一个协程
go func() {
for {
select {
case num := <-ch:
fmt.Println("num = ", num)
case <-time.After(3 * time.Second):
fmt.Println("超时")
quit <- true
}
}
}() //别忘了()
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(time.Second)
}
<-quit
fmt.Println("程序结束")
}
4.并发通信模型
在工程上,有两种最常见的并发通信模型:共享数据和消息。
共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式,比如内存数据块、磁盘文件、网络数据等。在实际工程应用中最常见的无疑是内存了,也就是常说的共享内存。
5.锁
1)atmoic包
AddInt64 ()同步整型值的加法,强制同一时刻只能有一个 gorountie 运行并完成这个加法操作
atomic.AddInt64(&counter, 1) //安全的对counter加1
LoadInt64 ()安全地读
StoreInt64 ()安全地写一个整型值
2)sync 包
Go语言包中的 sync 包提供了两种锁类型:sync.Mutex 和 sync.RWMutex。
Mutex (互斥锁)
Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。
(调用 Lock() 方法加锁,Unlock()解锁)
RWMutex (读写互斥锁)
RWMutex 是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占。
(调用 RLock() 方法加锁,RUnlock()解锁)
任何一个 Lock() 或 RLock() 均需要保证对应有 Unlock() 或 RUnlock() 调用与之对应,否则可能导致等待该锁的所有 goroutine 处于饥饿状态,甚至可能导致死锁。
经典使用模式
package main
import (
"fmt"
"sync"
)
var (
// 逻辑中使用的某个变量
count int
// 与变量对应的使用互斥锁
countGuard sync.Mutex
)
func GetCount() int {
// 锁定
countGuard.Lock()
// 在函数退出时解除锁定
defer countGuard.Unlock()
return count
}
func SetCount(c int) {
countGuard.Lock()
count = c
countGuard.Unlock()
}
func main() {
// 可以进行并发安全的设置
SetCount(1)
// 可以进行并发安全的获取
fmt.Println(GetCount())
}
一旦 countGuard 发生加锁,如果另外一个 goroutine 尝试继续加锁时将会发生阻塞,直到这个 countGuard 被解锁。
3)死锁
死锁是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
死锁的条件:
-
互斥条件
线程对资源的访问是排他性的,如果一个线程对占用了某资源,那么其他线程必须处于等待状态,直到该资源被释放。
-
请求和保持条件
线程 T1 至少已经保持了一个资源 R1 占用,但又提出使用另一个资源 R2 请求,而此时,资源 R2 被其他线程 T2 占用,于是该线程 T1 也必须等待,但又对自己保持的资源 R1 不释放。
-
不剥夺条件
线程已获得的资源,在未使用完之前,不能被其他线程剥夺,只能在使用完以后由自己释放。
-
环路等待条件
在死锁发生时,必然存在一个“进程 - 资源环形链”,即:{p0,p1,p2,…pn},进程 p0(或线程)等待 p1 占用的资源,p1 等待 p2 占用的资源,pn 等待 p0 占用的资源。
死锁的解决办法:
- 如果并发查询多个表,约定访问顺序;
- 在同一个事务中,尽可能做到一次锁定获取所需要的资源;
- 对于容易产生死锁的业务场景,尝试升级锁颗粒度,使用表级锁;
- 采用分布式事务锁或者使用乐观锁。
4)活锁
当多个相互协作的线程都对彼此进行相应而修改自己的状态,并使得任何一个线程都无法继续执行时,就导致了活锁。
活锁通常发生在处理事务消息中,如果不能成功处理某个消息,那么消息处理机制将回滚事务,并将它重新放到队列的开头。这样,错误的事务被一直回滚重复执行,这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误认为是可修复的错误。
要解决这种活锁问题,需要在重试机制中引入随机性。
5)饥饿
饥饿是指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况。饥饿通常意味着有一个或多个贪婪的并发进程,它们不公平地阻止一个或多个并发进程,以尽可能有效地完成工作,或者阻止全部并发进程。
与死锁不同的是,饥饿锁在一段时间内,优先级低的线程最终还是会执行的,比如高优先级的线程执行完之后释放了资源。
6.sync.WaitGroup
主线程为了等待goroutine都运行完毕,不得不在程序的末尾使用time.Sleep()
来睡眠一段时间,等待其他线程充分运行。但是对于实际生活的大多数场景来说,1秒是不够的,并且大部分时候我们都无法预知for循环内代码运行时间的长短。
WaitGroup 对象内部有一个计数器,最初从0开始,它有三个方法:Add(), Done(), Wait() 用来控制计数器的数量。
Add(n) 把计数器设置为n
Done() 每次把计数器-1
Wait() 会阻塞代码的运行,直到计数器地值减为0。如果此时 wg 维护的计数为零,则此 wg.Wait() 此操作为一个空操作(noop)
WaitGroup对象不是一个引用类型,在通过函数传值的时候需要使用地址:*wg sync.WaitGroup
import(
"fmt"
"net/http"
"sync"
)
func main(){
var wg sync.WaitGroup
var urls=[]string{
"https://www.github.com/",
"https://www.baidu.com/",
"https://www.qq.com/",
}
for _,url := range urls{
wg.Add(1)
go func(url string){
defer wg.Done()
_,err := http.Get(url) //使用http访问提供的地址
fmt.Println(url,err) //打印地址和可能发生的错误
}(url)
}
wg.Wait() //等待所有的任务完成后停止阻塞
fmt.Printf("over")
}
7.runtime.Gosched()
这个函数的作用是让当前goroutine让出CPU,好让其它的goroutine获得执行的机会。同时,当前的goroutine也会在未来的某个时间点继续运行。
//未使用runtime.Gosched()
package main
import (
"fmt"
//"runtime"
)
func say(s string) {
for i := 0; i < 2; i++ {
fmt.Println(s, i)
}
}
func main() {
go say("world")
say("hello")
}
//执行输出:
//hello 0
//hello 1
//主线程运行太快
//使用runtime.Gosched()
package main
import (
"fmt"
"runtime"
)
func say(s string) {
for i := 0; i < 2; i++ {
runtime.Gosched()
fmt.Println(s, i)
}
}
func main() {
go say("world")
say("hello")
}
8.资源争用
package main
import (
"fmt"
"runtime"
"sync"
)
var (
count int32
wg sync.WaitGroup
)
func incCount(){
defer wg.Done() //计数器-1
for i:=0;i<2;i++{
value:=count
runtime.Gosched() //让出CPU时间片
value++
count=value
}
}
func main(){
wg.Add(2) //计数器设为2
go incCount()
go incCount()
wg.Wait() //wait()会阻塞代码的运行,直到计数器地值减为0
fmt.Println(count)
}
- g1 读取到 count 的值为 0;
- 然后 g1 暂停了,切换到 g2 运行,g2 读取到 count 的值也为 0;
- g2 暂停,切换到 g1,g1 对 count+1,count 的值变为 1;
- g1 暂停,切换到 g2,g2 刚刚已经获取到值 0,对其 +1,最后赋值给 count,其结果还是 1;
- 可以看出 g1 对 count+1 的结果被 g2 给覆盖了,两个 goroutine 都 +1 而结果还是 1。
通过上面的分析可以看出,之所以出现上面的问题,是因为两个 goroutine 相互覆盖结果。
9.GOMAXPROCS
Go语言程序运行时(runtime)实现了一个小型的任务调度器
一般情况下,可以使用 runtime.NumCPU() 查询 CPU 数量,并使用 runtime.GOMAXPROCS() 函数进行设置
runtime.GOMAXPROCS(runtime.NumCPU())
Go语言在 GOMAXPROCS 数量与任务数量相等时,可以做到并行执行,但一般情况下都是并发执行。
10.time.Sleep和time.Tick
Sleep是使用睡眠完成定时任务,需要被调度唤醒。
Tick函数是使用channel阻塞当前协程,完成定时任务的执行。
for range time.Tick(30 * time.Millisecond) {
repaint()
}
time.Tick()返回的是一个channel,每隔指定的时间会有数据从channel中出来,for range不仅能遍历map,slice,array还能取出channel中数据,range前面可以不用变量接收,所以可以简写成上面的形式。
for {
time.Sleep(30 * time.Millisecond)
repaint()
}