文章目录
一、Go并发
goroutine:语言级别的并发实现。goroutine是用户态线程,由goruntime 调度,而线程由 OS 调度。
go提供channel
在多个go routine
间通信。go routine
和channel
是 csp
模型的实现基础。
写Java并发代码,我们要自己搞一个线程池,自己包装任务,耗时耗力,极易出错。而使用go并发模型, 码农只需要定义很多个任务,让go帮我们实现任务自动分配到CPU执行。
作为现代化的语言,Go在语言层面上已经内置了调度和上下文切换机制。写go并发代码,不需要自己写进程、协程、线程,只需要写任务,开启go routine去执行即可。
上个例子:
func main() {
go hello() // go routine创建和启动需要时间
fmt.Println("after all done")
time.Sleep(time.Second)
}
func hello(){
fmt.Println("hello")
}
多个goroutine同时执行可能需要同步,再看个使用了并发控制的goroutine
代码。
var wg sync.WaitGroup
func main() {
for i := 0; i < 10; i++ {
wg.Add(1)
go hello()
}
wg.Wait()
fmt.Println("all done")
}
func hello(){
defer wg.Done()
fmt.Println("hello")
}
二、go routine 和线程
GPM 调度: GPM是Go runtime在应用层面【区别于OS内核】实现的线程调度系统。
- G: goroutine ,GPM中的Goroutine除了自身信息,还有绑定的P信息。
- P: Processor,管理一组Go routine队列,保存着Go routine的上下文信息,比如堆栈、函数指针,etc。
- P 会对 自己的 Goroutine做出调度,比如,当一个 goroutine 阻塞在一个M1 (machine)上太久时,P会启动一个新的 M2(machine),然后将 该go routine 切换到 M2上。再比如,占用CPU太久的goroutine会被暂停,让出CPU给后面的go routine。
- P与M是一一对应的关系,P上面挂载着一组G 在M上执行。
- P个数通过
runtime.GOMAXPROCS
设定(最大256,默认为机器CPU核数)。P太多,切换频繁不一定能提升性能,可能适得其反
- M :machine , go routine 对OS 内核线程的虚拟。 M与内核是一一对应的关系,一个 go routine最终还是要 machine 完成。
Q: 从调度角度看,为啥Goroutine有优势?
A:
- Goroutine是 Go routine去调度的,java等语言,Thread是由OS 内核去调度的。GPM是一种
m:n
调度技术,i.e.,“复用m个go routine到 n 个OS 线程”。Goroutine调度在用户态下完成,不会在内核态和用户态间频繁切换,比如内存分配和释放,是在用户态下维持一个内存池,不直接malloc
,所以go routine调度成本更低。 - GPM能充分利用到CPU多核的资源,将任务近似平均分配到多个CPU上
- Go routine本身超轻量
- 以上构成了 go routine的优势。
三、channel
独立地并发执行函数,意义其实不大,go routine之间还需要实现 通信(共享信息)。channel是 go实现 goroutine之间通信和共享的实现。
与java不同的是,go routine 采用了一种 csp ,communicating sequtail processes
的并发模型,提倡通过 通信实现共享内存,而不是通过共享内存实现通信。这是Go并发模型的核心设计思想。
Go 的channel 可以理解为一个 具有元素类型的队列,始终FIFO保证收发数据有序。
3.1 channel类型 和 操作
channel 是一种 引用类型,声明格式和 基本操作如下:
var chan1 chan int
// var chan2 chan bool
// var chans chan []int
fmt.Println(chan1) //nil
chan1 = make(chan int)
fmt.Println(chan1) //0xc00001a0c0 十六进制的地址
//通道有 SEND RECEIVE CLOSE 三种操作
// SEND
chan1 <- 10 // 把10 发送给 chann1 这个通道
// RECEIVE
x:=<- chan1 // 从chann1中接收值并赋值给 x
<- chan1 // 从chann1 中接收值,但是忽略了 结果
fmt.Println(x)
// CLOSE
close(chan1)
收发没啥好说的,但是 关闭通道需要注意一下:
- 只有在通知接收方goroutine所有的数据都已经接收完毕之后,才需要关闭通道。通道可以被gc ,它和 关闭文件不大一样:操作结束后,打开的文件必须要关闭,但是通道 不需要。
- 关闭后的通道有这些特点:
- 关了再关就会 panic
- 关了再发也会panic
- 关了再收就会一直拿到值,通道为空;之后,将一直拿通道的零值。
3.2 无缓冲通道 【阻塞通道】
无缓冲通道,也叫 阻塞通道、同步通道。
看这个例子:
func fn5(){
var chan1 =make(chan int) // 创建无缓冲的通道
chan1 <- 10 //fatal error: all goroutines are asleep - deadlock!
fmt.Println("send done")
}
这里会 阻塞到 chan1 <- 10
这一行,形成死锁
【这里其实还是很好奇,为啥会报 死锁的 错误,不知道 go底层是怎么设计的】。
这是因为: chan1
是一个没有缓冲的通道,无缓冲通道只在有接收方时才能发。
如何解决这个问题呢?
1、使用缓冲通道替代
2、起另一个 go routine 去接收
我们这里使用第 2 种方法:
func fn7(){
var ch1 = make(chan int)
go recv(ch1)
ch1 <- 10
time.Sleep(1) //阻塞,不让主 goroutine 结束
}
func recv(ch chan int){
x:= <- ch
fmt.Println("recving x:", x)
}
来分析下 接收者和发送者的行为:
- 假如 接收 操作先开始,会阻塞在 接收操作上,直到发送操作也开始
- 假如 发送 操作先开始,会阻塞在 发送操作上,直到接收操作也开始
- 因此,接收 、操作实际上是互相约束的(同步的),这就是为啥 阻塞通道也叫做 同步通道了。
3.3 缓冲通道
在初始化 channel的时候,指定channel的容量即是 缓冲通道。
func fn(){
var ch1 = make(chan int, 1)
ch1 <- 10
// ch1 <- 20 // 假如没有这行,也会引发fatal error: all goroutines are asleep - deadlock!
fmt.Println("chann....")
time.Sleep(1)
}
3.4 for range从通道循环取值
向channel发完数据,可以关闭它。从channel取数据,会先取完channel中的值,然后一直取零值,那如何判断一个channel已经关闭呢?
第一种:
i,ok :=<- ch1
若 ch1 已经关闭,则 OK 为false
第二种:
遍历channel,假如 channel遍历了,循环就会退出来
举个栗子:
func fn(){
ch1 := make(chan int)
ch2 := make(chan int)
// 第一个 go routine:写入数据
go func () {
for i := 0; i < 100; i++ {
ch1 <- i
}
close(ch1)
}()
// 第二个 go routine: 读出 第一个go routine的数据,写入其中
go func(){
for{
i,ok :=<- ch1 //如果ch1 已经被关闭,则 ok 会被赋值 false
if(!ok){
break
}
ch2 <- i
}
close(ch2)
}()
// 遍历 channel
for i:= range ch2{
fmt.Println(i)
}
}
3.5 单向通道
一个通道,我们可能会在多个任务(多个go routine)间传递,我们可能不希望不同函数都向它 传入、传出数据,这个时候就可以使用 单向通道了。
单向通道的语法:
in chan <- int
:只写通道
out <-chan int
:只读通道
这里的语法容易看花眼,这样记忆就行了:
chan 在左就是写, chan 在右就是读
另外,在函数传参及任何赋值操作中可以将双向通道转换为单向通道,但反过来是不可以的。
本质上,单向通道并不是一个独立的类型,依然只是个 通道,只是在作为函数参数时,限制了是 单向还是双向而已。
func fn() {
ch1 := make(chan int)
ch2 := make(chan int)
go counter(ch1)
go squarer(ch2, ch1)
printer(ch2)
}
// in 是一个只写的 channel
func counter(in chan<- int) {
for i := 0; i < 10; i++ {
in <- i
}
close(in)
}
// in 是只 写的
// out 是只读的
func squarer(in chan<- int, out <-chan int) {
for v := range out {
in <- v * v
}
close(in)
}
//打印
func printer(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
}
3.6 go routine pool
go routine pool,也就是复用go routine,防止出现 泄露和暴涨。
举个栗子:
func fn(){
jobs:=make(chan int,100)
results := make(chan int,100)
// 启动了 三个 go routine,实际上只有三个 go routine在干活
// 我们并不是显式地看到一个 pool (集合)
for i := 0; i < 3; i++ {
go worker(i, jobs,results)
}
//提交任务
for i := 0; i < 5; i++ {
jobs <- i
}
close(jobs)
for i := 0; i < 5; i++ {
<-results
}
}
// 这是一个 简单版的 go routine pool
// Java的线程pool 底层 是一个 集合,go routine的pool 倒不一样
func worker(id int,jobs<- chan int, result chan <- int){
for v := range jobs {
fmt.Printf("worker:%v start job at %v \n", id,v)
time.Sleep(time.Second)
fmt.Printf("worker:%v ends job at %v \n", id,v)
result <- v*2
}
}
突然发现,如果要把goroutine
和 java中线程模型去做个类比:
- go func() 中的 func() 其实像是 Java中的
Runnable
goroutine pool
和java 中的 线程池也不是直接类比的,Java 的线程池提交一个任务是ThreadPool.submit()
,意如其字,goroutine pool
不是通过一个pool
去限制goroutine 数量,而是通过限制go func
次数去限制 go routine数量(毕竟go func
就启动了一个goroutine
),任务提交是通过channel
做的。
3.7 select 多路复用
如果我们有多个channel需要从中拿数据,我们可能会这样写:
for{
// 尝试从ch1接收值
data, ok := <-ch1
// 尝试从ch2接收值
data, ok := <-ch2
…
}
这样写当然能实现功能,但 读前一个Channel如果没有数据将会阻塞,性能因此很差。
为了应对这种场景,go提供了select
关键字,语法如下:
select{
case <-ch1:
...
case data := <-ch2:
...
case ch3<-data:
...
default:
默认操作
}
比如这个例子:
func fn() {
ch:= make(chan int,1)
for i := 0; i < 10; i++ {
select {
case x:=<- ch :
fmt.Println(x)
case ch <- i:
fmt.Println("insert ", i)
}
}
}
每个case会对应一个通道的通信(接收或发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句.
select
有哪些特点:
select
可处理一个多个channel- 如果有多个case满足条件,select 会随机执行其中一个
- 空
select
可以阻塞当前 go routine 【?测试起来似乎并不是这样,而是会报错】
3.8 并发安全
线程安全问题中也同样存在,其实就是多个线程(协程)同时操作一个变量,后一个的效果可能覆盖前一个。专业说法叫“竞态”。
var x int // x 是 线程不安全的
var wg sync.WaitGroup
func do(){
for i := 0; i < 10000; i++ {
x= x+ i
}
wg.Done()
}
func fn(){
wg.Add(2)
go do()
go do()
wg.Wait() //阻塞在此,直到 wg 计数=0
fmt.Println(x) //多次运行,结果可能不一样的
}
3.9 互斥锁
互斥锁是一种常见的控制共享资源访问的做法,同一个时间点,只有一个线程(协程)能够进入临界区。【java中同理,也不是啥新鲜东西】
var x int // x 是 线程不安全的
var wg sync.WaitGroup
var lock sync.Mutex
func do(){
for i := 0; i < 10000; i++ {
lock.Lock()
x= x+ i
lock.Unlock()
}
wg.Done()
}
func fn(){
wg.Add(2)
go do()
go do()
wg.Wait() //阻塞在此,直到 wg 计数=0
fmt.Println(x) //多次运行,结果可能不一样的
}
3.10 读写锁
这个就一句话:读写互斥,写写互斥,读读不斥。读写锁适用于读多写少的场景。读写锁和互斥锁大同小异:
sync.RWMutex
3.11 sync.WaitGroup
这个是 go routine 同步工具。基本可以类比java中的的CountdownLatch
或者CyclicBarrier
。
sync.WaitGroup
内部维护着一个计数器,通过三个方法去操作计数器的计数:
- add (n) --> 计数器 加 n
- done --> 计数器 -1
- wait --> 阻塞,一直到计数器为0
sync.WaitGroup
是一个结构体,传递的时候要传递指针。这个倒是需要注意的。
3.12 sync.Once
某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。针对这种只执行一次的场景【其实是个常见场景】,sync.Once
就是go的解决方案。
sync.Once
只有这一个方法:
func (o *Once) Do(f func()) {}
Do 方法的传参,是个无参的函数,如果我们要执行一次的函数是个带参数的,则可利用闭包来适配一下。
先举个栗子:
func fn() {
// 日志 “enter into loadIcons” 并没有打印 100次,
// 更没有只打印一次(说明实际上 loadIcons 运行了很多次,而不是一次)
// 说明: 多个goroutine并发调用Icon函数时不是并发安全的。
for i := 0; i < 100; i++ {
go icon("left")
}
time.Sleep(time.Second)
}
var icons map[string]string
func icon(name string) string {
if icons == nil { // 如果发现 当前的 icons 缓存中没有内容,就加载
loadIcons()
}
return icons[name]
}
func loadIcons() {
fmt.Println("enter into loadIcons ")
icons = make(map[string]string)
icons["left"] = "left.jpg"
icons["right"] = "right.jpg"
}
假如我们使用sync.runOnce()
会怎样呢?
看这里:
func fn(){
// enter into loadIcons 这句日志只执行一次
for i := 0; i < 100; i++ {
go icon("left")
}
time.Sleep(time.Second)
}
var runOnce sync.Once //注意 sync.Once是个类型!
var icons map[string]string
func icon(name string) string {
runOnce.Do(loadIcons)
return icons[name]
}
func loadIcons() {
fmt.Println("enter into loadIcons ")
icons = make(map[string]string)
icons["left"] = "left.jpg"
icons["right"] = "right.jpg"
}
感叹一下: go提供了简便的工具,让并发编程变得 平易近人,只说这一点,真的要比java强了不少。
3.13 并发安全的单例模式
type singleton struct{}
var s *singleton
var once sync.Once
func getInstance() *singleton{
once.Do(func(){
s = &singleton{}
})
return s
}
// 这个例子来证实确实是只有一个 实例
func fn(){
type void struct{}
var member void
set:=make(map[*singleton]void)
for i := 0; i < 1000; i++ {
go func () {
s:=getInstance()
set[s] = member
}()
}
time.Sleep(time.Second)
l:=len(set)
fmt.Println(l) // 1
}
3.14 sync.map
map
不是并发安全容器,go提供了sync.map
这个并发安全的类型。
3.15 原子操作
互斥锁虽然能保证安全,但是加锁、解锁会涉及到go routine上下文的切换,如果是基础类型的安全性,我们可以使用原子操作类来保证并发安全,原子操作是 发生在用户态,性能比加锁更好。
go中的标准库sync/atomic
提供了原子类型。
go 的这个原子类和Java的原子类实在是有异曲同工之妙。
下面这个例子,将 基于锁、基于原子操作、普通操作(不能保证线程安全)三种做了个比较:
type Counter interface{
inc()
load() int64
}
// 普通counter
type CommonCounter struct{
c int64
}
func (c CommonCounter) inc(){
c.c = c.c + 1
}
func(c CommonCounter) load() (int64){
return c.c
}
// 原子操作counter
type AtomicCounter struct{
c int64
}
func (a *AtomicCounter) inc(){
atomic.AddInt64(&a.c,1)
}
func(a *AtomicCounter) load() (int64){
return atomic.LoadInt64(&a.c)
}
// 互斥锁版
type MutexCounter struct{
c int64
lock sync.Mutex
}
func (m *MutexCounter)inc(){
lock.Lock()
m.c++
defer lock.Unlock()
}
func(m *MutexCounter)load() (int64){
lock.Lock()
defer lock.Unlock()
return m.c
}
func test(counter Counter){
var wg sync.WaitGroup
start:= time.Now().Unix()
for i := 0; i < 100000000; i++ {
wg.Add(1)
go func () {
counter.inc()
wg.Done()
}()
}
wg.Wait()
fmt.Println("cost time:", time.Now().Unix() - start)
}
func fn(){
// c1:= CommonCounter{}
// test(c1)
// c2:= AtomicCounter{}
// test(&c2)
c3:= MutexCounter{}
test(&c3)
}
3.16 examples
// 使用goroutine和channel实现一个计算int64随机数各位数和的程序。
// 开启一个goroutine循环生成int64类型的随机数,发送到jobChan
// 开启24个goroutine从jobChan中取出随机数计算各位数的和,将结果发送到resultChan
// 主goroutine从resultChan取出结果并打印到终端输出
func foo() {
var jobChan = make(chan int64, 10)
var resultChan = make(chan int64, 10)
//启动一个 go routine
go func() {
for {
jobChan <- rand.Int63()
time.Sleep(time.Millisecond)
}
}()
var wg sync.WaitGroup
wg.Add(20)
// 开启 20 个 go routine ,这形成了实质上的 goroutine pool
for i := 0; i < 20; i++ {
defer wg.Done()
go func() {
for { // 特别要注意,这个地方必须是一个 死循环:
// 如果不是,那么这个 go routine 任务很快就执行完,然后go routine 就退出了(?)
a := <-jobChan
s := strconv.Itoa(int(a))
arr := strings.Split(s, "")
var sum = 0
for _, v := range arr {
num, _ := strconv.Atoi(v)
sum = sum + num
}
resultChan <- int64(sum)
}
}()
}
for v := range resultChan {
fmt.Println("result ends at:", v)
}
wg.Wait()
}