golang学习笔记25-协程【最重要,GO流行的主要原因,推荐收藏】

本节内容较多,因为这是GO最重要的部分,所有知识点一起讲,思维更连贯。总共包含六个部分:协程的概念与原理,协程与主线程,多协程,同步原语WaitGroup,互斥锁,读写锁。
引入协程之前,至少要先了解程序,进程,线程的概念,这是操作系统的内容,但其实可以用一句话总结:程序是静态的代码,进程是动态的程序,有生命周期,线程是进程的子集

一、概念与原理

GO为什么适合高并发的任务?就是用了协程(goroutine)这个机制,协程又称为微线程,纤程,协程是一种用户态的轻量级线程。它的作用是在执行A函数的时候,可以随时中断,去执行B函数,然后中断继续执行A函数(可以自动切换),注意这一切换过程并不是函数调用(没有调用语句),过程很像多线程,然而协程中只有一个线程在执行(协程的本质是个单线程)。
协程的工作原理:
对于单线程下,我们不可避免程序中出现IO操作,但如果我们能在自己的程序中(即用户程序级别,而非操作系统级别)控制单线程下的多个任务能在一个任务遇到IO阻塞时就将寄存器(计算机组成原理的内容)的上下文和栈保存到其他地方,然后切换到另外一个任务去计算。在任务切回来的时候,恢复先前保存的寄存器上下文和栈,这样就保证了该线程能够最大限度地处于就绪态,即随时都可以被cpu执行的状态,相当于我们在用户程序级别将自己的IO操作最大限度地隐藏起来,从而可以迷惑操作系统,让其看到:该线程好像是一直在计算,IO比较少,从而会更多的将cpu的执行权限分配给我们的线程。注意:线程是CPU控制的,而协程是程序自身控制的,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级。

二、协程与主线程

请编写一个程序,完成如下功能:
(1)在主线程中,开启一个goroutine,该goroutine每隔1秒输出"goroutine"+数字,数字代表当前秒数。
(2)在主线程中也每隔一秒输出"main thread"+数字,数字代表当前秒数,输出10次后,退出程序
(3)要求主线程和goroutine同时执行
示例:

package main

import (
	"fmt"
	"time"
)

func main() {
	// 启动一个协程
	go func() {
		for i := 1; ; i++ {
			time.Sleep(time.Second)
			fmt.Printf("goroutine%d\n", i)
		}
	}()

	// 在主线程中输出 "main thread"
	for i := 1; i <= 10; i++ {
		time.Sleep(time.Second)
		fmt.Printf("main thread%d\n", i)
	}

	// 程序结束
	fmt.Println("main function finished")
}

显然,go是开启协程的关键字,非常优雅,而且细心的人应该注意到了goroutine中的for循环是死循环,可为什么程序还能正常退出?因为只要主线程完成了它的逻辑,整个程序就会立即终止,这个机制在云原生的视频(见本专栏简介)里叫做“主死从随”,就是”主人死了,随从也跟着死“。
运行后你会发现,每秒钟,协程和主线程几乎是同时打印了信息,而且主线程执行了10次,协程只执行了9次,原因前面讲过了。解决方法其实很简单,最后再用time.Sleep()阻塞一秒即可,因为这一秒足够让协程打印一行语句了。
整个程序的执行流程如下:
在这里插入图片描述

从图上你可以看出主线程和协程是同时进行的,但实际上它们是并发执行的(不是并行),引用百度百科原话:并发性是指在一段时间内宏观上有多个程序在同时运行,但在单处理机系统中,每一时刻却仅能有一道程序执行,故微观上这些程序只能是分时地交替执行。所以主线程和协程是通过不同的时间片交替执行的,而且交替顺序未必是固定的,比如这5毫秒协程执行,下一个3毫秒主线程执行,再下一个2毫秒又是主线程执行,只不过宏观上无法察觉而已,这就是所谓的”宏观并行,微观并发“。至于具体的执行顺序,这取决于GO运行时调度器,当然,在多核CPU上,它们可以实现并行执行。
细心的人可能会发现,go关键字是在主线程之前使用的,如果放在后面会怎么样?如果go放后面,那么协程很可能会失去并发执行的机会,因为主线程在前面的时候一定会被先执行直到它结束,如果后面没有多余的操作,比如同步或睡眠,协程将不会有机会执行。所以只有go关键字放主线程前面,GO才会同时调用协程和主线程。
总结一下:
1.只有go关键字放主线程前,GO才会使协程和主线程并发执行
2.主线程一旦结束,如果之后没有同步机制或睡眠等多余的操作,协程会立即结束,无论它是否执行完

三、多协程

在这一节,我们将去除主线程的代码,将协程代码嵌套在循环中,但是请注意:在上一节中,主线程中有sleep函数,它的作用其实是等待协程,所以如果协程想要执行,主线程是必须加上等待机制的,比如sleep函数,所以主线程的代码不是删除,而是更改为sleep操作,main中的代码如下:

func main() {
	// 启动多个协程
	for i := 1; i <= 5; i++ {
		go func() {
			fmt.Printf("goroutine%d\n", i)
		}()
	}
	time.Sleep(time.Second * 2)
}

运行上述代码后会发现,输出的i的顺序是乱的,而且i可能是重复的,也可能取到6。顺序乱是因为协程是并发执行的,即所有协程的工作方式是交替执行,不是顺序执行。所以这个是协程的特性,我们无法更改。i的数值出问题是因为每个协程和循环变量i形成了一个闭包,导致i一直在内存中,被所有协程共享,即对于i的每个值,任何协程都能引用。这可以通过定义局部变量i:=i,或对匿名函数传入参数i解决,这样的话,每个协程就只能引用属于自己的i,所以func可做如下修改:

go func(n int) {
	fmt.Printf("goroutine%d\n", n)
}(i)

四、同步原语WaitGroup

在前两节中,为了使协程能顺利执行,我们用的是sleep函数,但sleep函数的时间是不可控的,也就是说,你没法保证你设置的时长一定使协程顺利执行,这就引出了WaitGroup。WaitGroup是Go中的一个同步原语,用于等待一组协程完成。它通常用于协调多个协程的执行,确保主线程在所有协程完成之前不会退出。使用步骤如下:
1.创建WaitGroup实例:通过sync.WaitGroup{}创建一个WaitGroup的实例。
2.添加计数:使用Add(n int)方法设置需要等待的goroutine数量。每当启动一个新的 goroutine时,调用Add(1)
3.标记完成:在每个goroutine的工作完成后,调用Done()方法。这会将WaitGroup的计数减1
4.等待:主程序结束前使用Wait()方法,主线程会阻塞,直到所有的goroutine完成。
注:为了避免忘记在协程语句后添加Done()方法,建议在协程启动前用defer推迟Done()方法。
示例如下:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup //WaitGroup是一个结构体类型
	// 启动多个协程
	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			fmt.Printf("goroutine%d\n", n)
		}(i)
	}
	wg.Wait()
}

五、互斥锁

先来看这样一段代码:

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup
var sum, count int = 0, 20000

func add() {
	defer wg.Done()
	for i := 0; i < count; i++ {
		sum++
	}
}
func sub() {
	defer wg.Done()
	for i := 0; i < count; i++ {
		sum--
	}
}
func main() {
	wg.Add(2)
	go add()
	go sub()
	wg.Wait()
	fmt.Println("sum=", sum)
}

猜猜上述程序的运行结果是什么?如果你认为是0,说明你第二节没完全理解。我在第二节中把并发执行的概念说得很清楚了,放到这里就是,add和sub通过不同时间片交替执行。那么这里程序输出异常的关键是:for循环执行时间大于任意一个时间片(这就是为什么count设置得比较大)。比如add执行一半for循环,时间到了,调度器使其停止,又去调用sub,sub执行三分之一循环,时间又到了,又去执行add。这种情况显然是无法得到0的,这就引出了互斥锁的概念,这也是操作系统的内容。互斥锁就是用于解决资源竞争的机制,使一个资源在被一个进程占用完之前不会被其他进程占用。具体来说就是,资源被占用前,加锁,等该资源占用完后,解锁。示例如下:

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup
var sum, count int = 0, 20000
var lock sync.Mutex

func add() {
	defer wg.Done()
	for i := 0; i < count; i++ {
		lock.Lock()
		sum++
		lock.Unlock()
	}
}
func sub() {
	defer wg.Done()
	for i := 0; i < count; i++ {
		lock.Lock()
		sum--
		lock.Unlock()
	}
}
func main() {
	wg.Add(2)
	go add()
	go sub()
	wg.Wait()
	fmt.Println("sum=", sum)
}

六、读写锁

读写锁(Read-Write Lock)是一种更细粒度的锁机制,旨在提高并发性能,特别是在读操作远多于写操作的场景下。与普通的互斥锁(Mutex)不同,读写锁允许多个协程同时读取共享资源,但在写操作时会阻止其他协程的读取和写入。示例如下:

package main

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

var wg sync.WaitGroup
var count int = 5
var lock sync.RWMutex

func read() {
	defer wg.Done()
	lock.RLock()
	fmt.Println("开始读数据")
	time.Sleep(time.Second)
	fmt.Println("数据已读完")
	lock.RUnlock()
}
func write() {
	defer wg.Done()
	lock.Lock()
	fmt.Println("开始写数据")
	time.Sleep(time.Second * 2)
	fmt.Println("数据已写完")
	lock.Unlock()
}
func main() {
	wg.Add(count + 1)
	for i := 0; i < count; i++ {
		go read()
	}
	go write()
	wg.Wait()
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

技术卷

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值