目录
7.1.下面代码可能会出现的结果是什么。解释原因以及如何修改。
前言
在我刚开始学习Go语言的时候,就了解到Go语言的优势,即天生适合高并发。那么GO语言为什么会适合高并发呢,还有GO语言的并发模式通常是怎么样的,本章节将介绍GO语言的轻量级协程Goroutine以及协程间的通信媒介Channel。
一、并发与并行
很多人在第一次接触并发编程的时候并没有弄清楚并发与并行的概念。
- 并行:多个处理器在同一时刻执行多个线程
- 并发:单个处理器在一段时间内切换执行多个线程
从概念上看并行与并发就有很大的不同,首先,并行的前提是必须有多个处理器,一个处理器是无法实现并行的。其次,并行强调的是同一时刻,而并发则是一个时间段内。并发其实是处理器在很短的时间内频繁切换执行多个线程,让人看起来多个线程在同时执行一样,但实际上每一个时刻只有一个线程在运行。
二、Goroutine
2.1. 什么是Goroutine?
Goroutine实际上是一种轻量级的用户态线程(与之对应的称为内核态线程),之所以说其轻量,是因为一个goroutine在启动后只有一个很小的栈空间(2kB),这个栈空间会根据需要动态扩张,最大能达到1GB,而内核态线程都是一个固定2MB的栈内存。因此相对于OS线程,用户可以轻易的在程序中开启成千上万个goroutine。
2.2.Goroutine的调度原理(GMP调度模型)
Go语言的设计者为我们设计好了一套goroutine的调度模型,使得开发人员在使用goroutine时不用耗费心思在如何调度线程与内存管理上,Go会自动地帮我们实现线程的调度,goroutine的调度模型为GMP模型。
- M(machine):是Go运行时对操作系统内核线程的虚拟,M是一个很大的结构,里面维护着小对象内存cache(mcache)、当前执行的goroutine,随机发生器等信息。M与os线程通常是以M:N的方式映射的,goroutine最终放在M上执行。
- P(processor):P维护着一组goroutine队列,P会存储当前正在运行的goroutine的上下文环境(函数指针,堆栈地址等)。P是调度的主要执行者,当某个goroutine阻塞时,p会把后面的goroutine队列挂到一个空闲的M上去执行。当P自己的goroutine队列消费完时,会去全局队列里取,如果全局队列取完则去其他P队列中取。可通过runtime.GOMAXPROCS()设置P的个数,最大为(256)
- G(goroutine),goroutine实现的核心结构,包含了栈,指令指针及channel等内容。
2.3.如何启动Goroutine
通过在函数名前加上go关键字来启动goroutine。注意,启动goroutine的前提是要有一个函数,且该函数不推荐有返回值,因为goroutine中函数一旦结束goroutine就会结束,若要返回值可通过通道传出。
func handlefunc(id int){
fmt.Printf("I am goroutine %d",i)
}
func main(){
go handlefunc(1)
}
三、共享内存带来的临界资源竞争现象
所谓临界资源,就是多个线程都能访问到的资源,最常见的的就是全局变量。当多个线程同时访问或修改一个全局变量时,就会出现临界资源竞争问题。
3.1. 临界资源竞争现象
如果有个线程在对一个全局变量进行写操作,另外又有一个线程又在对该变量进行读操作,而写操作可能会比读操作慢,那么思考一下会出现什么情况呢?观察以下代码:
var wg sync.WaitGroup
var ticket int32 = 10
func sellTickets(id int) {
defer wg.Done()
for {
if ticket > 0 {
time.Sleep(500 * time.Millisecond)
ticket--
fmt.Printf("售票口:%d售出一张票,还剩%d张票\n", id, ticket)
} else {
fmt.Printf("已售罄,售票口%d停止售票\n", id)
break
}
}
}
func main(){
wg.Add(4)
go sellTickets(1)
go sellTickets(2)
go sellTickets(3)
go sellTickets(4)
wg.Wait()
}
上述代码的输出如下:
售票口:4售出一张票,还剩7张票
售票口:1售出一张票,还剩9张票
售票口:3售出一张票,还剩8张票
售票口:2售出一张票,还剩6张票
售票口:2售出一张票,还剩4张票
售票口:4售出一张票,还剩3张票
售票口:1售出一张票,还剩5张票
售票口:3售出一张票,还剩2张票
售票口:4售出一张票,还剩1张票
售票口:2售出一张票,还剩0张票
已售罄,售票口:2停止售票
售票口:1售出一张票,还剩-1张票
已售罄,售票口:1停止售票
售票口:3售出一张票,还剩-2张票
已售罄,售票口:3停止售票
售票口:4售出一张票,还剩-3张票
已售罄,售票口:4停止售票
我们通过以下流程来分析为什么会出现负票的情况。
- 某一时刻,线程g1取到了ticket值大于0,在其未执行ticket-1操作时,cpu切换到其他线程去执行。
- 某一时刻,线程g2售出了最后一张ticket,并输出票已售罄,此时cpu再次切回g1线程
- g1线程继续执行ticket-1的操作,这时ticket的值已被g2修改为0了,不再是判断条件时那个ticket了,执行ticket-1后,输出还剩-1张票
- 其他线程同理输出了负票数。
3.2.互斥锁
我们要求在并发编程时,写操作必须发生在读操作之前,这里的之前并不是指时间上的顺序,而是指某一个操作必须在另外一个操作完成之前,这就是所谓的happen before。
对于临界资源出现的竞争现象,GO语言也像其他语言一样提供了锁的机制。(但是Go语言不推荐使用这种机制来实现共享内存)
我们可以通过sync包来声明并使用互斥锁来对临界资源进行保护。
func sellTickets(id int) {
defer wg.Done()
for {
lock.Lock()
if ticket > 0 {
time.Sleep(500 * time.Millisecond)
ticket--
fmt.Printf("售票口:%d售出一张票,还剩%d张票\n", id, ticket)
} else {
fmt.Printf("已售罄,售票口:%d停止售票\n", id)
lock.Unlock() // 退出时也要记得释放锁,否则其他线程将会被堵塞在获取锁处无法退出,导致死锁
break
}
lock.Unlock()
}
我们在读取全局变量ticket之前先上锁,这样即使系统给我们切换到其他线程去执行,也会堵塞在获取锁这,直到上锁的这个线程判断并修改完ticket后解锁,其他线程才能再次访问ticket变量。
3.3.读写锁
互斥锁是一个完全对立的事件,一旦上锁,其他线程要想访问该锁都必须等待解锁,但现实场景中往往读的次数要比写的次数多,如果每一次读操作都必须等到另外一个读操作读完释放锁再读,程序的效率无疑会十分低。因此就出现了读写锁。
读锁:如果某个线程上了读锁,那么当其他线程如果尝试获取读锁将不会被阻塞,如果要获取写锁,则需阻塞等待该锁被释放。
写锁:当一个线程获取写锁时,其他线程无论是获取读锁还是写锁,都会阻塞等待直到该锁被释放。
var wg sync.WaitGroup
var x = 10
func f1() {
defer wg.Done()
fmt.Println(x)
}
func f2() {
defer wg.Done()
x = x + 1
}
func main(){
for i := 0; i < 1000; i++ { // 模拟写操作
wg.Add(1)
go f2()
}
for i := 0; i < 10000; i++ { // 模拟读操作,比写操作大一个数量级
wg.Add(1)
go f1() // 最后输出894,是因为某些线程拿到了x但还没来得及修改,另外一个线程又给他改回去了
// 比如g1拿到893,这时候切到其他线程去运行了,运行到i=1010,这时候g1再运行,就把x修改成894了
}
wg.Wait()
}
上面代码模拟了现实场景中读比写多的操作情形,我们可以通过给其添加互斥锁来保护临界资源,但这样效率会有点低,下面我们通过读写锁来防止出现临界资源竞争现象。
func f1() {
defer wg.Done()
rwLock.RLock()
fmt.Println(x)
rwLock.RUnlock()
}
func f2() {
defer wg.Done()
rwLock.Lock()
x = x + 1
rwLock.Unlock()
}
3.4.sync.Map
我们知道,go语言中的map是引用类型的数据结构,但当期应用在并发编程时,map并不是并发安全的,如果有多个goroutine同时去读写一个全局的map,会出现问题。
var m = make(map[string]int, 10)
var wg sync.WaitGroup
func setValue(id int) {
m[strconv.Itoa(id)] = id
}
func getValue(key string) int {
return m[key]
}
func main(){
// 并发写map不安全,报错:fatal error: concurrent map writes
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
setValue(n)
key := strconv.Itoa(n)
fmt.Printf("key:%v,value:%v\n", key, getValue(key))
wg.Done()
}(i)
}
wg.Wait()
}
为了解决这种情况,sync包为我们提供了一种开箱即用的map结构,即sync.Map,该结构无需使用make为其分配空间,也无需定义其key-value的类型,我们可以直接通过Store()函数来设置key-value对,使用Load()函数来读取对应key的value值。
var m sync.Map
var wg sync.WaitGroup
func setValue(key string, value int) {
m.Store(key, value)
}
func main(){
for i := 0; i < 20; i++ {
wg.Add(1)
go func(n int) {
key := strconv.Itoa(n)
setValue(key, n)
value, _ := m.Load(key)
fmt.Printf("key:%v,value:%v\n", key, value)
wg.Done()
}(i)
}
wg.Wait()
}
四、Channel:通过通信方式实现共享内存
加锁实际上会很大程度影响到代码的效率,GO语言并不推崇这种做法。Go语言并发编程遵从一句话:"不要通过共享内存方式实现通信,而要通过通信实现共享内存!"由此,Go语言引入了一种数据类型:channel。
4.1.Channel的声明与初始化
channel是一种引用类型,可以通过make()函数来对其进行初始化。其中,make中的第二个参数为channel缓冲区的大小,我将在后面解释channel缓冲区。
ch := make(chan int,cap)
4.2.Channel的操作
我们可以把channel理解成一根水管,数据要么流入channel,要么从channel流出。Go语言使用"<-"符号来实现这两种操作。
ch <- 10 // 将数据放入channel
value := <-ch //从管道ch中取出一个值
4.3.Channel的缓冲区
我们可以把channel的缓冲区看成一个First In First Out 的队列。
当我们在定义channel的时候为其分配了缓冲区,那么发送方与接收方在channel的缓冲区未满的时候,都不会阻塞。而一旦缓冲区满了,发送方将会阻塞,直到接收方接收了一个值,使缓冲区腾出空间。
那如果我们在定义channel时没有为其分配缓冲区呢?我们可以将其想象成接力赛的接力棒,发送方在发送数据之前,必须有一个接收者在阻塞等待接收,否则就会进入死锁!使用没有缓冲区的channel时,发送方一直在等待接收方接收,接收方也一直在等待发送方发送,因此发送方与接收方就变成了同步操作。我们可以将无缓冲通道用在任何需要线程同步的场景!
func main(){
ch := make(chan int) // 定义一个没有缓冲区的通道
ch <-10 // 往里面放值,会报错:deadlock!!
}
在使用无缓冲通道时,必须保证至少有一个goroutine在发送值之前准备接收,否则就会报deadlock错误!
func main(){
ch := make(chan int) // 定义一个没有缓冲区的通道
go func(){
<-ch // 启动一个后台goroutine阻塞等待接收
}
ch <-10 // 这时候就不会报错,程序正常运行
}
4.4.Channel的关闭
如果接收方一直在阻塞等待接收值,那么接收方如何知道什么时候该退出阻塞不再等待值呢?一种最直观的方法,就是在发送方发送完所有数据后,发送一个标记到管道,接收方一旦收到这个标记,就知道发送方已经没有数据发送了,可以退出阻塞等待状态。这种方法需要额外发送一个标记,官方并不推荐,go语言给我们提供了channel的关闭功能。
我们可以通过close关键字来关闭一个channel。channel一旦关闭,发送方就不能再往该channel中发送任何值,否则会引发panic。
对于接收方来说,即使channel关闭,接收方仍然可以从channel中获取值,当channel缓冲区中有值时,接收方从缓冲区中获取值,当缓冲区中的值被拿完后,如果接收方仍然坚持从channel获取值,将获得对应类型的零值。
下面演示两种从通道中遍历取值的方式
方式一:for-range结构遍历channel取值。
func main(){
// 1.通道的定义
ch := make(chan int, 2)
// 通道的容量代表着通道缓冲区的大小
// 当通道中缓冲区满时,再往通道中写值,会阻塞,直到有goroutine读取值
// 2.往通道发送值
ch <- 10
ch <- 20
// 3.关闭通道
// 通道一旦关闭,就无法再往通道中发送值,但仍然能够读取缓冲区内的值
close(ch)
// 4.通道的读取
for x := range ch { // for-range 循环读取通道内容时,如果通道写完值不关闭,会一直堵塞,造成死锁
fmt.Println(x)
}
}
for-range 结构会在其内部判断通道是否关闭,如果未关闭,将会一直阻塞等待从通道中取值,因此在使用for-range结构时一定要确保发送方发送完成后关闭通道。
方式二:value,ok形式读取channel
func main(){
// 也可以通过 x,ok := <-chan的方式读取通道中的内容,当通道被关闭后,读取完缓冲区的内容后,ok会被赋值false
for {
data, ok := <-ch
if !ok {
fmt.Println("the chan is closed")
break
}
fmt.Println(data, ok)
}
}
使用value,ok的形式读取channel,需要我们人为去判断ok的值,如果channel被关闭且缓冲区内没有值,ok就会变为false。
五、管理goroutine
在go语言运行时,会启动一个主goroutine去运行main函数,一旦main函数执行完,主goroutine会结束,那么所有goroutine都会被强行结束。因此,我们在main函数中,必须想办法去管理我们启动的goroutine。所谓的管理gotourine,要达到以下两点要求:
- 保证所有goroutine能在main函数结束前退出
- 保证所有的goroutine都有一个退出方法(不能让其陷入死循环)
5.1.sync.WatiGroup管理goroutine
sync包中给我们提供了一个sync.WaitGroup结构,可以有效帮我们保证所有goroutine都在main函数结束之前退出。其原理也十分简单,其内部维护着一个计数器,每当我们启用一个goroutine,就使用wg.Add(1)使其内部计数器加1,在goroutine内部,函数执行完后,使用wg.Done()使其内部计数器减1。在main函数中,通过wg.wait()阻塞等待计数器归零。这样,只有所有goroutine都执行完毕并退出,main函数才会退出阻塞状态。
var wg sync.WaitGroup
func sendData(data chan<- int) { // 生产者,发送数据
defer wg.DOne()
for i := 0; i < 10; i++ {
data <- i
}
close(data)
}
func work(data <-chan int, result chan<- int) { // 消费者,负责从data中取数据,求平方后放入result通道
defer wg.Done()
for v := range data {
fmt.Printf("拿到数据:%v\n", v)
result <- v * v
}
close(result)
}
func main(){
data := make(chan int, 5) // 创建一个通道用于发送原始数据
result := make(chan int, 5) // 创建一个通道用于接收运算后的结果
wg.Add(2) //每启动一个goroutine,就要添加一个计数器
go sendData(data) // 启动一个goroutine在后台发送数据
go work(data, result, done) // 另外启动一个goroutine去拿数据做运算
for resultNum := range result {
fmt.Printf("主goroutine拿到结果:%v\n", resultNum)
}
wg.Wait() //等待wg内部计数器归零
}
上面这种模式往往被我们称之为生产者-消费者模式,即一个生产者负责生产任务,然后多个消费者线程负责取任务来解决问题。上面只启动了一个goroutine去执行work,通常我们往往会启动多个消费者去完成任务。但上述代码启动多个goroutine执行work时,会报错,原因在于:
func work(data <-chan int, result chan<- int) {
defer wg.Done()
for v := range data {
fmt.Printf("拿到数据:%v\n", v)
result <- v * v
}
close(result) //问题出在此处,如果某个goroutine发现data中没有任务的时候,将result关闭了,那么其它正在运行的goroutine就没办法再往result写值了
}
如何解决上面的问题呢?sync包中给我们提供了一个once结构,使用once.DO能让某个操作只执行一次(其内部维护了一个互斥锁),我们可以使用该方法防止出问题。需要注意的是:onec.Do()只能接收func()类型参数,因此如果我们的操作需要传参的话,就需要使用闭包。
var onec sync.Once
func work(data <-chan int, result chan<- int) {
defer wg.Done()
for v := range data {
fmt.Printf("拿到数据:%v\n", v)
result <- v * v
}
once.Do(func() { close(result) })
}
这样我们就可以在主goroutine中启动多个消费者goroutine, 从而让我们的程序效率更高。
5.2.信号量模式管理goroutine
信号量模式,是一种通过无缓冲通道实现的线程同步模式。我们可以在启动一个goroutine时为其传入一个无缓冲通道done,在goroutine结束前发送一个标记到done中,在main函数结尾阻塞等待所有goroutine往done中发送信号,直到收到所有goroutine发送的done信号,才可以退出阻塞状态。
var once sync.Once
func sendData(data chan<- int) {
for i := 0; i < 10; i++ {
data <- i
}
close(data)
}
func work(data <-chan int, result chan<- int, done chan bool) {
for v := range data {
fmt.Printf("拿到数据:%v\n", v)
result <- v * v
}
//once.Do(func() { close(result) })
close(result)
done <- true // 信号量
}
func main(){
data := make(chan int, 5)
result := make(chan int, 5)
done := make(chan bool)
go sendData(data)
go work(data, result, done)
go work(data, result, done)
for resultNum := range result {
fmt.Printf("主goroutine拿到结果:%v\n", resultNum)
}
<-done // 注意,信号量的接收数量要与发送数量一致,否则会导致某些goroutine进入deadlock!
<-done
}
5.3.select管理goroutine
go语言为我们提供了select关键字,select关键子与case配合使用,每个case必须是一个通信行为,select会阻塞等待直到其中一个case可以发生,如果同时有多个case可以发生,select会随机执行一个,如果设置了default字段,那么当所有case都在阻塞时,select将会执行default语句并退出阻塞等待状态。
func main(){
ch := make(chan int)
go func(){
select{
case <-ch:
default: // 如果没有case满足,就会走default,同时退出阻塞状态
}
}
ch <- 10
}
六、原子操作
所谓原子操作,就是不可再分开执行的操作,比如拷贝一个很大的切片,我们将其分段后分别拷贝,就不是原子操作,go语言标准库中atomic包提供了一些原子操作函数,可以保证临界资源在执行原子操作时不会出现竞争现象。
6.1.atomic包的使用
var wg.WaitGroup
var x int64 = 10
func f1(){
defer wg.Done()
atomic.AddInt64(x,1) //使用原子操作,能保证同一时刻只有一个goroutine操作x,保护了共享变量
}
func f2(){
defer wg.Done()
fmt.Println(x)
}
func main(){
for i:=0;i<1000;i++{
wg.Add(1)
go f1()
}
for i:=0;i<10000;i++{
wg.Add(1)
go f2()
}
wg.Wait()
}
七、练习
7.1.下面代码可能会出现的结果是什么。解释原因以及如何修改。
var wg sync.WaitGroup
func main(){
for i:=0;i<10000;i++{
wg.Add(1)
go func(){
defer wg.Done()
fmt.Println(i)
}
}()
wg.Wait()
}
/*
会出现很多重复的打印值!
*/
这是一道经典面试题。从题目代码可以看出,goroutine启动后执行的函数是一个闭包,它使用到了外部的变量i,因此延长了临时变量i的生命周期,导致某几个goroutine共享的同一个栈内存空间中i的值没有刷新,于是就输出了重复的值。
如何解决上述问题呢,就是往我们的匿名函数中去传参,使得每个goroutine都使用各自栈内存中的i,这样就互不影响了。
var wg sync.WaitGroup
func main(){
for i:=0;i<10000;i++{
wg.Add(1)
go func(i int){
defer wg.Done()
fmt.Println(i)
}
}(i)
wg.Wait()
}
7.2. 使用生产者-消费者模式完成以下需求。
- 启动一个goroutine作为生产者,负责产生一个int64位的随机数
- 启动24个goroutine作为消费者,负责获取随机数,计算其各位数之和
- 主goroutine负责读取结果
// 当数据类型占用内存较大时,往channel中传入结构体或指针是个不错的选择 type job struct { num int64 } func newJob(num int64) *job { return &job{ num: num, } } type result struct { job *job sum int64 } func newResult(job *job, sum int64) *result { return &result{ job: job, sum: sum, } } // 生产者 func producer(jobChan chan<- *job) { //chan<-为单向通道,意味着在该函数中该通道只能写入数据 for { data := rand.Int63() // 获取int64随机数 newJob := newJob(data) // 封装成任务 jobChan <- newJob // 放入任务队列 time.Sleep(500 * time.Millisecond) // 模拟生产者生产需要耗时 } } // 消费者 func worker(id int, jobChan <-chan *job, resultChan chan<- *result, done chan bool) { // <-chan为单向通道,意味着在该函数中该通道只能读取数据 fmt.Printf("the %v worker is star job\n", id) for newJob := range jobChan { // 从任务队列取任务 data := newJob.num sum := int64(0) for data > 0 { // 计算int64数字的各位数之和 sum += data % 10 data = data / 10 } newResult := newResult(newJob, sum) // 将任务与和封装成结果 resultChan <- newResult //将结果放入结果队列 } done <- true // 信号量,告诉主goroutine任务完毕 } func main() { jobChan := make(chan *job, 64) resultChan := make(chan *result, 64) done := make(chan bool) // 用于传递信号量 rand.Seed(time.Now().UnixNano()) // 随机数种子 go producer(jobChan) // 启动一个生产者 for i := 0; i < 128; i++ { go worker(i, jobChan, resultChan, done) //启动128个消费者 } for result := range resultChan { // 读取结果 fmt.Printf("主goroutine获取到结果,num:%v sum:%v\n", result.job.num, result.sum) } for i := 0; i < 128; i++ { // 接收信号量,保证所有goroutine完成任务 <-done } }
7.3.实现串行与并发的cache缓存器。
我们在写web服务的时候,通常需要去请求某个url,读取该网页的body,这是一个很耗时的操作,如果我们重复的去请求某个url,每次都直接去请求,无疑十分耗时,这时候就需要设计一个缓存器,缓存器会在第一次请求这个url的时候记录下该url与对应的response,下次再来请求同样的url就可以直接到cache中去找,这样十分节省时间,提高代码效率。
下面展示顺序执行的情况下缓存器的设计
type Func func(string) (interface{}, error) // 定义一个函数类型
// 封装一个result结构体
type result struct {
value interface{}
err error
}
func newResult(value interface{}, err error) *result {
return &result{
value: value,
err: err,
}
}
type memo struct { // 缓冲器中包含着一个缓冲区cache,还有一个函数,该函数往往是十分耗时的一项任务,用于第一次往缓冲区cache中写值
f Func
cache map[string]*result
}
func newMemo(f Func) *memo {
return &memo{
f: f,
cache: make(map[string]*result),
}
}
// Get 缓冲器的Get()方法,从缓冲器中获取内容
func (m *memo) Get(key string) (interface{}, error) {
res, ok := m.cache[key]
if !ok { //如果缓冲区内没有对应的信息,则先建立对应的词条信息
value, err := m.f(key)
tempResult := newResult(value, err)
m.cache[key] = tempResult
return value, err
}
return res.value, res.err // 有则直接返回,无需走m.f()函数,大量节省时间
}
// 模拟一个很耗时的函数
func getHttpBody(url string) (interface{}, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return ioutil.ReadAll(resp.Body)
}
func main(){
urls := []string{
"https://www.baidu.com",
"https://www.taobao.com",
"https://bilibili.com",
"https://www.baidu.com",
"https://www.taobao.com",
"https://bilibili.com",
}
/*------------------------顺序执行--------------------------------------*/
m := newMemo(getHttpBody)
begin := time.Now()
for _, url := range urls {
now := time.Now()
value, err := m.Get(url)
if err != nil {
fmt.Printf("get url failed,err:%v\n", err)
}
fmt.Printf("%v,%v,%vbytes\n", url, time.Since(now), len(value.([]byte)))
}
fmt.Printf("%v\n", time.Since(begin))
}
/*输出
https://www.baidu.com,283.183748ms,227bytes
https://www.taobao.com,791.56215ms,113794bytes
https://bilibili.com,681.47012ms,107155bytes
https://www.baidu.com,676ns,227bytes // 可以看到,当第二次访问这些url的时候,花费时间极少
https://www.taobao.com,302ns,113794bytes
https://bilibili.com,182ns,107155bytes
1.756318966s //总耗时
*/
可以看到,串行的缓存器在第一次访问某个url时,调用保存的f函数去请求url,并将结果缓存到cache中,下一次遇到同样的url直接从cache中取值,时间直接变为ns级,但是总的耗时还是很久的。那么我们如何将其改为并发的缓存器呢?考虑如下代码是否可行?
// 其他结构定义没变
func main(){
m := newMemo(getHttpBody)
begin := time.Now()
for _, url := range urls {
wg.Add(1)
go func(key string) { //每来一个url,开启一个goroutine去处理
now := time.Now()
value, err := m.Get(key)
if err != nil {
fmt.Printf("get url failed,err:%v\n", err)
}
fmt.Printf("%v,%v,%vbytes\n", key, time.Since(now), len(value.([]byte)))
wg.Done()
}(url)
}
wg.Wait()
fmt.Printf("%v\n", time.Since(begin))
}
上面这段代码乍一看没什么问题,运行后结果也没有问题,但是仔细分析能够发现这是一段非并发安全的代码。首先,每个goroutine都能够去修改一个公共的cache,这个cache实际上是一个map,上面说过map是非并发安全的,另外,这段代码还有一个问题,可能一个goroutine在执行http.Get()时,切到了另外一个goroutine,这时由于cache还没更新,这个新的goroutine又回去执行http.Get()这个方法,这无疑不是我们想要的结果。为此,我在这里想了两种解决方式:
第一种:使用sync.Map代替原来的cache数据结构。
type Func func(string) (interface{}, error) // 定义一个函数类型
// 封装一个result结构体
type result struct {
value interface{}
err error
}
func newResult(value interface{}, err error) *result {
return &result{
value: value,
err: err,
}
}
type memo struct { // 缓冲器中包含着一个缓冲区cache,还有一个函数,该函数往往是十分耗时的一项任务,用于第一次往缓冲区cache中写值
f Func
cache sync.Map
}
func newMemo(f Func) *memo {
return &memo{
f: f,
cache: sync.Map{}, // 使用sync.Map结构替换非并发安全的map
}
}
// Get 缓冲器的Get()方法,从缓冲器中获取内容
func (m *memo) Get(key string) (interface{}, error) {
res, ok := m.cache.Load(key)
if !ok { //如果缓冲区内没有对应的信息,则先建立对应的词条信息
value, err := m.f(key)
tempResult := newResult(value, err)
m.cache.Store(key, tempResult)
return value, err
}
re := res.(*result)
return re.value, re.err // 有则直接返回,无需走m.f()函数,大量节省时间
}
func main(){
m := newMemo(getHttpBody)
begin := time.Now()
for _, url := range urls {
wg.Add(1)
go func(key string) {
now := time.Now()
value, err := m.Get(key)
if err != nil {
fmt.Printf("get url failed,err:%v\n", err)
}
fmt.Printf("%v,%v,%vbytes\n", key, time.Since(now), len(value.([]byte)))
wg.Done()
}(url)
}
wg.Wait()
fmt.Printf("%v\n", time.Since(begin))
}
/*输出
https://www.baidu.com,170.345566ms,227bytes
https://www.baidu.com,170.968408ms,227bytes //计算的是每个goroutine访问的耗时,实际第二次只花了6ms,可以看到由于sync.Map内部维护了互斥锁,所以效率会低一点
https://bilibili.com,355.307754ms,97557bytes
https://www.taobao.com,563.330354ms,113635bytes
https://www.taobao.com,633.98485ms,113635bytes
https://bilibili.com,887.873061ms,97442bytes // 这里时间相差几百毫秒是因为中途该goroutine被cpu挂起去执行别的goroutine了
887.95682ms //可以看到,并发运行的总耗时是降低的
*/
第二种方式:使用信号量模式配合监控。
《GO圣经》里说过,要防止出现资源竞争现象,最好的解决方式有三种:
- 不要并发的修改公共资源
- 使用互斥量保护公共资源
- 规定只有一个goroutine能修改公共资源,这个goroutine我们往往称之为监控
在本例中,我们会出现竞争现象的,就只有cache这个非并发安全的map结构,那么我们是否可以在实例化一个缓存器的时候,开启一个监控goroutine,让该goroutine单独修改cache,这样就不会出现竞争问题了。
/*------------------不要让多个goroutine一起修改共享资源,而是让共享资源由一个单独的goroutine来修改,这个goroutine称之为监控-----------------------*/
/*使用监控goroutine,思路:
1.该监控监控谁,监控的是cache,故cache只能在该goroutine进行修改
2.谁来调用该goroutine?在这里是不是应该由memo来调用,因为只有memo会去请求修改cache
3.该goroutine要通知调用者什么信息,该例中,应该通知调用者result
根据上述思路,我们需要修改一下memo的结构:
1.我们希望给监控的是一个key值,因此memo内部必须维护一个key,但我们不能来一个key就new一个缓存器,这显然不符合实际
2.我们希望从监控中获取结果,因此我们必须要维护一个result
3.由于result是要从goroutine中传出的,我们得使用channel
4.于是我们可以定义一个结构体,结构体中包含了key以及channel,我们的memo只需要维护该结构体的channel,每次从channel中获取key和result即可。
*/
type Func func(string) (interface{}, error) // 定义一个函数类型
// 封装一个result结构体
type result struct {
value interface{}
err error
}
func newResult(value interface{}, err error) *result {
return &result{
value: value,
err: err,
}
}
type request struct {
key string
resp chan *result
}
func newRequest(key string) *request {
return &request{
key: key,
resp: make(chan *result),
}
}
type memo struct {
req chan *request
}
// 写一个监控
func (m *memo) monitor(f Func) {
// 监控的是cache,故在此处声明cache,且只有监控能改cache
cache := make(map[string]*result)
// 从request从读取内容
for req := range m.req {
res, ok := cache[req.key]
if !ok {
value, err := f(req.key)
tempResult := newResult(value, err)
cache[req.key] = tempResult
req.resp <- tempResult
continue
}
tempResult := newResult(res.value, res.err)
req.resp <- tempResult
}
}
func newMemo(f Func) *memo {
m := memo{
req: make(chan *request),
}
go m.monitor(f) // 创建一个缓存器的同时启动一个监控
return &m
}
func (m *memo) Get(key string) (interface{}, error) {
request := newRequest(key)
m.req <- request
res, _ := <-request.resp
return res.value, res.err
}
func main(){
m := newMemo(getHttpBody)
begin := time.Now()
for _, url := range urls {
wg.Add(1)
go func(key string) {
now := time.Now()
value, err := m.Get(key)
if err != nil {
fmt.Printf("get url failed,err:%v\n", err)
}
fmt.Printf("%v,%v,%vbytes\n", key, time.Since(now), len(value.([]byte)))
wg.Done()
}(url)
}
wg.Wait()
fmt.Printf("%v\n", time.Since(begin))
}
/*
结果分析
https://bilibili.com,618.060692ms,102637bytes
https://bilibili.com,618.147659ms,102637bytes // 可以看到,第二次访问该网站只需要0.08ms,比使用sync.Map效率更高
https://www.taobao.com,1.178294018s,113643bytes
https://www.taobao.com,1.238250418s,113643bytes
https://www.baidu.com,1.238349512s,227bytes
https://www.baidu.com,1.238339666s,227bytes
1.238544942s // 这里是网速慢了,不是并发变慢了,按当前网速串行需要3.7s,并发只需要1.2s
*/
总结
goroutine是Go语言独有的轻量级用户态线程,通过GMP模式进行调度。在并发编程时容易引发临界资源的竞争问题,为此sync包提供了上锁的机制,包括互斥锁与读写锁。但是Go语言推崇"不要通过共享内存实现通信,要通过通信实现共享内存!",为此,go语言提供了channel数据类型,channel可以分为带缓冲区与不带缓冲区两种,带缓冲区可以看成channel带了一个FIFO的队列,而不带缓冲区的channel常用于线程同步。我们可以通过sync.WaitGroup,信号量模式以及select关键字三种方式来管理我们的goroutine。我们还能通过sync/atomic包中的原子操作来保护我们的临界资源。
Goroutine与channel是go语言实现并发编程的重中之重,一定要熟练掌握。