Go 并发编程

并发编程

1.1 并发与并⾏

并⾏与并发是两个不同的概念,普通解释:

  • 并发:交替做不同事情的能⼒
  • 并⾏:同时做不同事情的能⼒

如果站在程序员的⻆度去解释是这样的:

  • 并发:不同的代码块交替执⾏
  • 并⾏:不同的代码块同时执⾏

并发和并⾏都是为了提⾼机器硬件利⽤率,提升应⽤运⾏效率。并⾏和并发就是为达⽬的的两个⼿段。

并⾏可以提升CPU核⼼数,并发则是通过系统设计,⽐如分时复⽤系统,多进程,多线程的开发⽅法,

不过在对并发的⽀持上,Go语⾔是天⽣最好的,因为它设计的理念就是并发,⽽且是在语⾔层⾯实现的

并发。

1.2 Goroutine

如果对线程和进程有所了解的话,我们可以这样给进程和线程下⼀个专业点的定义。

  • 线程是最⼩的执⾏单位
  • 进程是最⼩的资源申请单位

在Go语⾔当中,有⼀个存在是⽐线程还要⼩的执⾏单位,那就是Goroutine,翻译上习惯叫例程或协

程,不过我们遵循原汁原味还是直接⽤Goroutine来称呼它。Go语⾔开发者为了实现并⽀持

Goroutine,特意花了⼤⼒⽓来开发⼀个语⾔层⾯的调度算法。

具体调度算法详情介绍可以查看英⽂原⽂:调度算法链接

简单理解的话,⾸先要明确Go语⾔调度算法中提到的三个字⺟,M,P,G,分别代表线程,上下⽂以

及Goroutine。

在操作系统层⾯线程仍然是最⼩的执⾏单位,在进程调度的时候,CPU需要在不同进程间切换,此时需

要保留运⾏的上下⽂信息,也就是前⾯提到的P,⾄于G则是代表了Go语⾔当中的Goroutine。

每个线程M有⼀个⾃⼰的上下⽂P,同⼀时刻,每个线程内部可以执⾏⼀个Groutine代码,如上图所

示,每个线程有⼀个⾃⼰的调度队列,这个队列⾥存放的就是若⼲个Goroutine。

当线程发⽣系统调⽤时,该线程会被阻塞,此时为了提⾼运⾏效率,Goroutine调度算法会将该阻塞的

线程Goroutine队列转移到其他线程上。当线程执⾏完系统调⽤后,⼜会从其他线程的队列中"借"⼀些

Goroutine过来。

1.3 Goroutine启动

在⼀台主机上,线程启动的数量是有上限的,这个上限并⾮可以启动的上限,⽽是不影响系统性能的上

限。Goroutine在并发上则没有这⽅⾯的顾虑,可以随⼼所欲的启动,不⽤担⼼数量的问题,当然这归

功于Go语⾔的调度算法。

那么Goroutine如何启动呢?其实在我们之前写的代码中,都存在⼀个Goroutine,就是我们的主函

数,我们习惯叫他main-goroutine。如果我们想再启动⼀个Goroutine,也⾮常容易,直接关键字go再

加上函数调⽤就⾏了。

go 复制代码
go call_func()
go 复制代码
package main

import "fmt"

func main() {
	fmt.Println("begin call goroutine")
	//启动goroutine
	go func() {
		fmt.Println("I am a goroutine!")
	}()
	fmt.Println("end call goroutine")
}

执⾏这个代码,我们⼤概率是看不到"I am a goroutine!"这句话的,因为go func()这⾥创建的Goroutine

或者尚未创建成功时,main-goroutine已经结束执⾏了,main-goroutine结束执⾏,也就代表着进程

退出,那么不会有任何代码被执⾏了!那么⼤家考虑⼀下,如何解决这个问题呢?

1.5 Go语⾔运⾏时

所谓运⾏时,就是运⾏的时刻,Go语⾔的运⾏时就是描述与进程运⾏相关的信息,我们可以使⽤

runtime包来显示⼀些运⾏时的信息。

1.5.1 GOMAXPROCS

go 复制代码
func GOMAXPROCS(n int) int
//当n<=1时,查看当前进程可以并⾏的goroutine最⼤数量(CPU核⼼数)
//当n>1时,代表设置可并⾏的最⼤goroutine数量

这个函数可以帮我们查看或设置当前进程的最⼤CPU核⼼数。

2. 同步

同步在不同的语境代表不同的含义,在数据库中是指数据的同步,在分布式系统中是指系统内的数据⼀

致,⽽在语⾔层⾯的同步是指运⾏时步调⼀致,避免竞争,有先有后。

2.1 如何做到同步

为了提⾼CPU的使⽤效率,我们需要启动多个Goroutine,⽽多个Goroutine⽐线程的颗粒度还⼩,他

们之间必然存在争抢同⼀资源的现象,就像我们在线程中要控制同步⼀样,多个Goroutine在访问同⼀

共享资源时,我们仍然要控制同步。

如何做到最直接的同步,可以借鉴我们⽣活中的例⼦,在⽕⻋上我们去卫⽣间的时候,都会把⻔锁上,

这样别⼈就没法进来了。在这个例⼦中,我们和其他⼈就是Goroutine,⽽卫⽣间就是那个共享资源,

我们不允许发⽣⼤家⼀起进⼊使⽤的情况,⽽解决这个问题的关键就是锁!

那么Go语⾔给我们提供了哪些同步机制呢?主要有如下⽅式:

  • WaitGroup 计数等待法
  • Once 执⾏⼀次
  • Mutex 互斥锁
  • RWMutex 读写锁
  • Cond 条件变量
  • channel 通道,Go语⾔的最⼤特性

在上述⽅法中,除了channel,其余的同步⽅式都在Go语⾔的sync包当中。

2.2 WaitGroup

Go语⾔的同步⽅式很多,WaitGroup的实现⽅式很巧妙,主要只有3个API。

go 复制代码
//增加计数
func (wg *WaitGroup) Add(delta int)
//减少计数
func (wg *WaitGroup) Done()
//阻塞等待计数变为0
func (wg *WaitGroup) Wait()

它的核⼼思想是当启动⼀个Goroutine时,使⽤Add添加⼀个计数器,⽽Wait的功能是阻塞等待计数器

归0,Done的作⽤则是Goroutine运⾏结束后执⾏此句话清掉计数器的⼀个计数。

利⽤WaitGroup的特性,我们可以优雅的实现⼀个例⼦:启动10个Goroutine,让他们顺序退出,

main-goroutine等待所有Goroutine退出后才可退出。

go 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

var w sync.WaitGroup

func main() {
	for i := 0; i < 10; i++ {
		w.Add(1) //添加⼀个要监控的Goroutine数量
		go func(num int) {
			time.Sleep(time.Second * time.Duration(num))
			fmt.Printf("I am %d Goroutine\n", num)
			w.Done() //释放⼀个
		}(i)
	}
	w.Wait() //阻塞等待
}
go 复制代码
I am 0 Goroutine
I am 1 Goroutine
I am 2 Goroutine
I am 3 Goroutine
I am 4 Goroutine
I am 5 Goroutine
I am 6 Goroutine
I am 7 Goroutine
I am 8 Goroutine
I am 9 Goroutine

2.3 Mutex互斥锁

提到互斥锁,我们通常会提到⼀个临界区的概念,Goroutine在准备访问共享数据时,我们就认为它进

⼊了临界区。

使⽤互斥锁的核⼼思想就是进⼊临界区之前先要申请锁,申请到锁的Goroutine继续执⾏,⽽没有申请

到的Goroutine则阻塞等待别⼈释放这个mutex,这样就可以有效的控制Goroutine之间竞争的问题。

下述代码就是⼀个存在数据修改竞争的例⼦,循环1000次对⼀个数据⾃增,⽬标的输出结果是1000,

但执⾏下⾯的代码很难获得1000。

go 复制代码
package main

import (
	"fmt"
	"sync"
)

var x = 0

func increment(wg *sync.WaitGroup) {
	x = x + 1
	wg.Done()
}
func main() {
	var w sync.WaitGroup
	for i := 0; i < 1000; i++ {
		w.Add(1)
		go increment(&w)
	}
	w.Wait()
	fmt.Println("final value of x", x)
}
go 复制代码
final value of x 974

上述代码如果在进⼊临界区前使⽤mutex,就可以很好的解决该问题。代码主要修改increment函数即

可:

go 复制代码
func increment(wg *sync.WaitGroup) {
 mutex.Lock() //上锁
 x = x + 1 //临界区
 mutex.Unlock() //释放锁
 wg.Done()
}

修改后再执⾏代码,就可以很好的看到效果。

go 复制代码
final value of x 1000

2.4 RWMutex

RWMutex我们可以称其为读写锁,它算是mutex的改进版,因为mutex的特点是排他性,只要有⼀个

上锁成功了,其余⼈都不可以使⽤。但在实际开发过程中,经常会出现多个Goroutine去访问相同的共

享资源,只不过这些Goroutine中有些是读数据,有些是写数据。开发者肯定明⽩,读数据不会对数据

造成影响,这样理论上来说,⼀个读的Goroutine上锁了,其余的读Goroutine理应也可以访问,这样

就出现了读写锁。对于读写锁来说,关键是掌握它的原则:

  • 读共享
  • 写独占
  • 写优先级⾼
go 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

var rwlock sync.RWMutex
var wg sync.WaitGroup
var x = 0

func go_reader(num int) {
	for {
		rwlock.RLock()
		fmt.Printf("I am %d reader goroutine x = %d\n", num, x)
		time.Sleep(time.Millisecond * 2)
		rwlock.RUnlock()
	}
	wg.Done()
}
func go_writer(num int) {
	for {
		rwlock.Lock()
		x += 1
		fmt.Printf("I am %d writer goroutine x = %d\n", num, x)
		time.Sleep(time.Millisecond * 2)
		rwlock.Unlock()
	}
	wg.Done()
}
func main() {
	wg.Add(10)
	for i := 0; i < 7; i++ {
		go go_reader(i)
	}
	for i := 0; i < 3; i++ {
		go go_writer(i)
	}
	wg.Wait()
}

2.5 Once

在很多时候,我们会有⼀个需求,那就是虽然多个Goroutine都要运⾏⼀段代码,但我们却希望这段代

码只能被⼀个Goroutine运⾏,也就是说只被允许运⾏⼀次。在Go语⾔当中,就给我们提供了这样的机

制 -- Once。

go 复制代码
package main

import (
	"fmt"
	"sync"
)

func main() {
	var count int
	var once sync.Once
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			once.Do(func() {
				count += 1 //确保只执⾏⼀次
			})
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("Count is %d\n", count)
}
go 复制代码
Count is 1
相关推荐
自不量力的A同学6 分钟前
Redisson 4.2.0 发布,官方推荐的 Redis 客户端
数据库·redis·缓存
Exquisite.9 分钟前
Mysql
数据库·mysql
全栈前端老曹34 分钟前
【MongoDB】深入研究副本集与高可用性——Replica Set 架构、故障转移、读写分离
前端·javascript·数据库·mongodb·架构·nosql·副本集
R1nG86337 分钟前
CANN资源泄漏检测工具源码深度解读 实战设备内存泄漏排查
数据库·算法·cann
阿钱真强道1 小时前
12 JetLinks MQTT直连设备事件上报实战(继电器场景)
linux·服务器·网络·数据库·网络协议
逍遥德1 小时前
Sring事务详解之02.如何使用编程式事务?
java·服务器·数据库·后端·sql·spring
笨蛋不要掉眼泪1 小时前
Redis哨兵机制全解析:原理、配置与实战故障转移演示
java·数据库·redis·缓存·bootstrap
Coder_Boy_2 小时前
基于SpringAI的在线考试系统-整体架构优化设计方案
java·数据库·人工智能·spring boot·架构·ddd
fen_fen10 小时前
Oracle建表语句示例
数据库·oracle
砚边数影12 小时前
数据可视化入门:Matplotlib 基础语法与折线图绘制
数据库·信息可视化·matplotlib·数据可视化·kingbase·数据库平替用金仓·金仓数据库