文章目录
- 概念
- 构建go协程
- 使用普通函数构建goroutine
- 使用匿名函数构建
- go并发与并行
- go并发
- go并行
- go并发通信
- go三种并发通信方式
- 1. 使用共享变量+锁
- 2. channel通道机制(无缓冲通道举例)
- 创建通道
- 将数据放入通道
- 接收通道中的数据
- 特性:
- 接收方式
- 举例:阻塞接收
- 举例:循环接收
- 通道类型
- 有缓冲通道
- 单向通道
- 3. 互斥锁、读写锁
- 不加锁
- 互斥锁
- 读写锁
- 读锁实例
- 读写锁实例
概念
Go 语言的并发通过 goroutine 特性完成。goroutine 类似于线程,但是可以根据需要创建多个 goroutine 并发工作。
Go 语言还提供 channel 在多个 goroutine 间进行通信
goroutine 是一种非常轻量级的实现,可在单个进程里执行成千上万的并发任务,它是Go语言并发设计的核心。
构建go协程
使用普通函数构建goroutine
package main
import (
"fmt"
"time"
)
//函数定义:构建一个无限循环打印
func running() {
var times int
// 构建一个无限循环
for {
times++
fmt.Println("tick", times)
// 延时1秒
time.Sleep(time.Second)
}
}
func main() {
// 使用go关键字,开启一个协程,并发执行程序
go running()
// 接受命令行输入, 不做任何事情
var input string
fmt.Scanln(&input) //这里会等待用户输入,当用户输入后,main函数执行结束
//所有 goroutine 在 main() 函数结束时会一同结束。
}
使用匿名函数构建
package main
import (
"fmt"
"time"
)
func main() {
//使用匿名函数,不需要加函数名字
go func() {
var times int
for {
times++
fmt.Println("tick", times)
time.Sleep(time.Second)
}
}() //直接调用
var input string
fmt.Scanln(&input)
}
go并发与并行
go并发
Go语言实现多核多线程并发运行是非常方便的,下面举个例子:
package main
import (
"fmt"
)
func main() {
//开启五个goroutine
for i := 0; i < 5; i++ {
go AsyncFunc(i)
}
var input string
fmt.Scanln(&input) //这里会等待用户输入,当用户输入后,main函数执行结束
}
//函数加和
func AsyncFunc(index int) {
sum := 0
for i := 0; i < 10000000000; i++ {
sum += 1
}
fmt.Printf("线程%d, sum为:%d\n", index, sum)
}
上面例子中会创建五个goroutine然后并发执行,顺序是不一定的。
go并行
官方给出的答案是,这是当前版本的 Go 编译器还不能很智能地去发现和利用多核的优势。虽然我们确实创建了多个 goroutine,并且从运行状态看这些 goroutine 也都在并行运行,但实际上所有这些 goroutine 都运行在同一个 CPU 核心上。
我们可以使用设置环境变量来控制使用多少个核心runtime.GOMAXPROCS(16) 这里我们说明程序可以使用计算机的16个核心,但是具体使用多少,要看操作系统分配
package main
import (
"fmt"
"runtime"
)
func main() {
cpuNum := runtime.NumCPU() //获得当前设备的cpu核心数
fmt.Println("cpu核心数:", cpuNum)
runtime.GOMAXPROCS(cpuNum) //设置需要用到的cpu数量
}
go并发通信
go三种并发通信方式
- 使用共享变量(通常和锁结合使用)
- 通道(消息队列),channel(go中主要方式)
- 锁:互斥锁,读写锁
在工程上,有两种最常见的并发通信模型:共享数据和消息。
1. 使用共享变量+锁
package main
import (
"fmt"
"runtime"
"sync"
)
//共享变量
var counter int = 0
//协程函数:加锁
func Count(lock *sync.Mutex) {
lock.Lock()
counter++
fmt.Println(counter)
lock.Unlock()
}
func main() {
//创建锁对象
lock := &sync.Mutex{}
//创建十个协程并执行
for i := 0; i < 10; i++ {
go Count(lock)
}
//在 main 函数中,使用 for 循环来不断检查 counter 的值(同样需要加锁)。当其值达到 10 时,说明所有 goroutine 都执行完毕了,这时主函数返回,程序退出
for {
lock.Lock()
c := counter
lock.Unlock()
runtime.Gosched() //让goroutin协程暂停
if c >= 10 {
break
}
}
}
但是我们发现,实现一个如此简单的功能,需要很冗长的代码。想象一下,在一个大的系统中具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分支,那将是一场噩梦。这噩梦就是众多 C/C++ 开发者正在经历的,其实 Java 和 C# 开发者也好不到哪里去。
2. channel通道机制(无缓冲通道举例)
Go语言既然以并发编程作为语言的最核心优势,当然不至于将这样的问题用这么无奈的方式来解决。Go语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。Go语言提倡使用通信的方法代替共享内存。
Go语言提供的消息通信机制被称为 channel。
通道的特性:
Go语言中的通道(channel)是一种特殊的类型。在任何时候同时只能有一个 goroutine 访问通道进行发送和获取数据
通道其实是一个消息队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。不同的goroutine从里面放数据和取数据
创建通道
ch1 := make(chan int) // 创建一个整型类型的通道
ch2 := make(chan interface{}) // 创建一个空接口类型的通道, 可以存放任意格式
type Equip struct{ /* 一些字段 */ }
ch2 := make(chan *Equip) // 创建Equip指针类型的通道, 可以存放*Equip
将数据放入通道
把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞
// 创建一个空接口通道
ch := make(chan interface{})
// 将0放入通道中
ch <- 0
// 将hello字符串放入通道中
ch <- "hello"
接收通道中的数据
注意数据放入通道后要有相应的接收代码,如果只把数据放入通道而没有接收,则会运行期间报错。也就是说所有 goroutine 中的 channel 并没有形成发送和接收对应的代码。
特性:
- 通道的收发操作在不同的两个 goroutine 间进行。
由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行。 - 接收将持续阻塞直到发送方发送数据。如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。
- 每次接收一个元素。通道一次只能接收一个数据元素。
接收方式
- 阻塞:data := <-ch
- 非阻塞:data, ok := <-ch
- 阻塞接收任意数据忽略返回值 <-ch
- 使用for range通道循环接收for data := range ch { }
举例:阻塞接收
阻塞接收
package main
import (
"fmt"
)
func main() {
// 构建一个通道
ch := make(chan int)
// 开启一个并发匿名函数,并直接调用
go func() {
fmt.Println("start goroutine")
// 将数据0放入通道中,通过通道通知main的goroutine
ch <- 0
fmt.Println("exit goroutine")
}()
fmt.Println("wait goroutine")
// 阻塞接收,等待通道中的数据。等待匿名goroutine
<-ch
fmt.Println("all done")
}
package main
import (
"fmt"
)
func printer(c chan int) {
// 开始无限循环等待数据
for {
// 从channel中取一个数据
data := <-c
// 如果该数据为0则跳出循环
if data == 0 {
break
}
// 打印数据
fmt.Println(data)
}
// 数据放入channel,通知main已经结束循环(我搞定了!)
c <- 0
}
func main() {
// 创建一个channel
c := make(chan int)
//执行goroutine,传入channel
go printer(c)
//main函数将数据1-10以此放入channel
for i := 1; i <= 10; i++ {
c <- i
}
// 最后放一个0进入channel
c <- 0
// 等待printer结束(搞定喊我!)
<-c
}
举例:循环接收
package main
import (
"fmt"
)
func main() {
// 构建一个通道
ch := make(chan int)
// 开启一个并发匿名goroutine
go func() {
// 从3循环到0
for i := 3; i >= 0; i-- {
// 将数据放入通道,3,2,1,0,每次通道中放入一个数后,当没有接收的时候会阻塞,等待其他协程从通道取数据
ch <- i
}
}()
// main函数遍历接收通道数据,每次接收到一个数之后,就会阻塞,等待下一次通道中还有数据
for data := range ch {
// 打印通道数据
fmt.Println(data)
// 当遇到数据0时, 退出循环接收
if data == 0 {
break
}
}
}
通道类型
- 无缓冲通道(上面例子中都是无缓冲通道):必须有发送有接收,不然就报错。并且有阻塞,发送一个数据后必须有接收数据接收,才能再次向通道中存入下一个数据。
- 有缓冲通道(可以存放多个数据)
- 单向通道
有缓冲通道
和创建无缓冲通道一样,但是需要指明通道大小参数
其实无缓冲通道可以看成缓冲大小为0的有缓冲通道。
所以有缓冲通道的阻塞条件为:
- 带缓冲通道被填满时,尝试再次发送数据时发生阻塞。
- 带缓冲通道为空时,尝试接收数据时发生阻塞。
package main
import "fmt"
func main() {
// 创建一个3个元素缓冲大小的整型通道
ch := make(chan int, 3)
// 查看当前通道的大小
fmt.Println(len(ch)) //0
// 发送3个整型元素到通道
ch <- 1
ch <- 2
ch <- 3
// 查看当前通道的大小
fmt.Println(len(ch))//3
}
package main
import (
"fmt"
)
func main() {
// 构建一个缓冲区为3的通道
ch := make(chan int, 3)
// 开启一个并发匿名goroutine
go func() {
// 从3循环到0
for i := 3; i >= 0; i-- {
// 将数据放入通道,3,2,1,0先放入通道,当通道满了就阻塞
ch <- i
}
}()
// 从通道中取数据,当没有数据就阻塞
for data := range ch {
// 打印通道数据
fmt.Println(data)
// 当遇到数据0时, 退出循环接收
if data == 0 {
break
}
}
}
问题:为什么Go语言对通道要限制长度而不提供无限长度的通道?
我们知道通道(channel)是在两个 goroutine 间通信的桥梁。使用 goroutine 的代码必然有一方提供数据,一方消费数据。当提供数据一方的数据供给速度大于消费方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀直到应用崩溃。因此,限制通道的长度有利于约束数据提供方的供给速度,供给数据量必须在消费方处理量+通道长度的范围内,才能正常地处理数据。
单向通道
ch := make(chan int)
// 声明一个只能写入数据的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能读取数据的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch
上面的例子中,chSendOnly 只能写入数据,如果尝试读取数据
另一种创建方式
ch := make(<-chan int)
var chReadOnly <-chan int = ch
<-chReadOnly
3. 互斥锁、读写锁
sync 包提供了两种锁类型:互斥锁sync.Mutex 和 读写锁sync.RWMutex。
不加锁
package main
import (
"fmt"
)
func main() {
var count = 0
//var wg sync.WaitGroup //声明互斥锁变量
n := 10
//wg.Add(n)
//开启n个协程对count进行累加
for i := 0; i < n; i++ {
go func() {
//defer wg.Done()
//1万叠加
for j := 0; j < 1000; j++ {
count++
}
}()
}
var input string
fmt.Scanln(&input)
fmt.Println(count)
}
我们想要的共享变量累加结果应该是10000.但是实际上每次运行到不了10000,因为多个线程在累加时出现了对count的竞争,也就是当一个线程做累加时,同时另一个线程也拿到count做累加。导致结果错误
互斥锁
Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。
package main
import (
"fmt"
"sync"
)
func main() {
var count = 0
var mu sync.Mutex //声明互斥锁变量
n := 10
//开启n个协程对count进行累加
for i := 0; i < n; i++ {
go func() {
//1万叠加
for j := 0; j < 1000; j++ {
//对共享变量进行加锁然后操作
mu.Lock()
count++
mu.Unlock()
}
}()
}
var input string
fmt.Scanln(&input)
fmt.Println(count)
}
用上面加锁之后,结果就正确了
读写锁
RWMutex 相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁(调用 RLock() 方法;而写锁(调用 Lock() 方法)会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占。从 RWMutex 的实现看,RWMutex 类型其实组合了 Mutex。
Mutex在大量并发的情况下,会造成锁等待,对性能的影响比较大。
读写锁主要遵循以下规则 :
- 读写锁的读锁可以重入,在已经有读锁的情况下,可以任意加读锁。读加锁,可读
- 在读锁没有全部解锁的情况下,写操作会阻塞直到所有读锁解锁。读加锁,不可写
- 写锁定的情况下,其他协程的读写都会被阻塞,直到写锁解锁。写加锁,不可读写
写锁:Lock/Unlock
读锁:RLock/RUnlock
读锁实例
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var m sync.RWMutex
//开启三个协程
go read(&m, 1)
go read(&m, 2)
go read(&m, 3)
time.Sleep(2 * time.Second)
}
func read(m *sync.RWMutex, i int) {
fmt.Println(i, "reader start")
//给reading加读锁,并且停一秒
m.RLock()
fmt.Println(i, "reading")
time.Sleep(1 * time.Second)
m.RUnlock()//解开读锁
fmt.Println(i, "reader over")
}
/*
1 reader start
1 reading
2 reader start
3 reader start
3 reading
2 reading
2 reader over
1 reader over
3 reader over
*/
可以看到,在一个协程拿读锁的时候,其他协程也可以进来读。
读写锁实例
package main
import (
"fmt"
"sync"
"time"
)
var count = 0
func main() {
var m sync.RWMutex
//开启三个写线程
for i := 1; i <= 3; i++ {
go write(&m, i)
}
//开启三个读协程
for i := 1; i <= 3; i++ {
go read(&m, i)
}
time.Sleep(1 * time.Second)
fmt.Println("final count:", count)
}
//读开始的时候,对count上读锁
func read(m *sync.RWMutex, i int) {
fmt.Println(i, "reader start")
m.RLock()
fmt.Println(i, "reading count:", count)
time.Sleep(1 * time.Millisecond)
m.RUnlock()
fmt.Println(i, "reader over")
}
//写开始的时候,将count上写锁
func write(m *sync.RWMutex, i int) {
fmt.Println(i, "writer start")
m.Lock()
count++
fmt.Println(i, "writing count", count)
time.Sleep(1 * time.Millisecond)
m.Unlock()
fmt.Println(i, "writer over")
}
/*
3 reader start
3 reading count: 0
2 reader start
2 reading count: 0
3 writer start //3写锁之前只有2和3读锁上锁了,在此处阻塞等待
2 writer start
1 writer start
1 reader start
3 reader over
2 reader over
3 writing count 1 //此时23读锁释放,3写锁可以进入
3 writer over
1 reading count: 1 //3写锁结束,1读锁才可以进入,读
1 reader over
2 writing count 2
2 writer over
1 writing count 3
1 writer over
final count: 3
*/
解析:1.读线程上读锁的时候,写线程要写该共享变量会阻塞等待。等到所有读线程执行完了,读锁释放。写锁才可以获得。
2. 当有一个线程上写锁的时候,必须等到写完之后,其他线程才进去写。