golang学习笔记(二)—— 深入golang中的协程

23次阅读

共计 4495 个字符,预计需要花费 12 分钟才能阅读完成。

小白一枚,最近在研究 golang,记录自己学习过程中的一些笔记,以及自己的理解。

go 中协程的实现
go 中协程的 sync 同步锁
go 中信道 channel
go 中的 range
go 中的 select 切换协程
go 中带缓存的 channel
go 中协程调度

原文的地址为:https://github.com/forthealll…
欢迎 star
介绍 go 中的协程之前,首先看以下 go 中的 defer 函数,defer 函数不是普通的函数,defer 函数会在普通函数返回之后执行。defer 函数中可以释放函数内部变量、关闭数据库连接等等操作,举例来说:
func print(){
fmt.Println(2);
}
func main() {
defer print();
fmt.Println(1);
}

上述的例子中先输出 1 后输出 2,说明 defer 确实是在普通函数调用结束之后执行的。
go 中使用协程的方式来处理并发,协程可以理解成更小的线程,占用空间小且线程上下文切换的成本少。
可以再为具体的描述以下协程的好处,协程比线程更加轻量,使用 4K 栈的内存就可以创建它们,可以用很小的内存占用就可以处理大量的任务。
在 go 中,携程是通过 go 关键字来调用,从关键字可以看出,golang 的一个十分重要的特点就是协程,有句话叫“协程在手,说 go 就 go”。
1、go 中协程的实现
下面我们来看一个例子:
func printOne(){
fmt.Println(1);
}
func printTwo(){
fmt.Println(2);
}
func printThree(){
fmt.Println(3);
}

func main() {
go printOne();
go printTwo();
go printThree();
}
执行上述的 main 函数,我们发现并没有像我们想的那样输出有 123 的输出,原因在于虽然协程是并发的,但是如果在协程调用前退出了调用协程的函数后,协程会随着程序的消亡而消亡。
因此我们可以在 main 函数中,将主函数挂起,增加等待协程调用的事件。
func main() {
go printOne();
go printTwo();
go printThree();
time.Sleep(5 * 1e9);
}
这样会有相应的 go 关键字修饰的协程函数的调用。我们来看分别执行 3 次的结果。

第一次 132
第二次 321
第三次 312

我们发现因为协程是并发执行的,我们无法确定其调用的顺序,因此 每次的调用主函数的返回结果都是不确定的。
从协程的上述例子中,我们可以看出使用协程的时候必须还要考虑两个问题:

如何控制协程的调用顺序,特别是当不同的协程同时访问同一个资源。
如何实现不同协程间的通信

问题 1,可以通过 sync 的同步锁来实现,问题 2,go 中提供了 channel 来实现不同协程间的通信。
2、go 中协程的 sync 同步锁
go 中 sync 包提供了 2 个锁,互斥锁 sync.Mutex 和读写锁 sync.RWMutex. 我们用互斥锁来解决上述的同步问题,改写上述的例子:
func printOne(m *sync.Mutex){
m.Lock();
fmt.Println(1);
defer m.Unlock();
}

func printTwo(m *sync.Mutex){
m.Lock();
fmt.Println(2);
defer m.Unlock();
}

func printThree(m *sync.Mutex){
m.Lock();
fmt.Println(3);
defer m.Unlock();
}

func main() {
m:= new(sync.Mutex);
go printOne(m);
go printTwo(m);
go printThree(m);
time.Sleep(5 * 1e9);
}

通过互斥锁,可以发现每次运行,确实都依次输出了 1,2,3
3、go 中信道 channel
go 中有一种特殊的类型通道 channel,可以通过 channel 来发送类型化的数据,实现在协程之间的通信,通过通道的通信方式也保证了同步性。
channel 的声明方式很简单:
var ch1 chan string
ch1 = make(chan string)

我们用 ch 表示通道,通道的符号包括了流向通道 (发送): ch <- int1 和从通道流出 (接收) int2 = <- ch。
同时 go 中也支持声明单向通道:
var ch1 chan int // 普通的 channel
var ch2 chan <- int // 只用于写 int 数据
var ch3 <- chan int // 只用于读 int 数据

上述定义的都是不带缓存区,或者说长度为 1 的 channel, 这种 channel 的特点就是:
一旦有数据被放入 channel,那么该数据必须被取走才能让另一条数据放入,这就是同步的 channel,channel 的发送者和接受者在同一时间只交流一条数据,然后必须等待另一边完成相应的发送和接受动作。
我们还是用上述的输出 123 的例子,用同步 channel 来实现同步的输出。
func printOne(cs chan int){
fmt.Println(1);
cs <- 1
}
func printTwo(cs chan int){
<-cs
fmt.Println(2);
defer close(cs);
}

func main() {
cs := make(chan int);
go printOne(cs);
go printTwo(cs);
time.Sleep(5 * 1e9);
}

上述的例子中会依次输出 12,这样我们通过同步 channel 的方式实现了同步的输出。
我们前面讲到用为了等待 go 协程执行完成,我们在 main 函数中用 time.sleep 来挂起主函数,其实 main 函数本身也可以看成一个协程,如果使用 channel,就不用在 main 函数中用 time.sleep 来挂起。
我们改写上述的例子:
func printOne(cs chan int){
fmt.Println(1);
cs <- 1
}
func main() {
cs := make(chan int);
go printOne(cs);
<-cs;
close(cs);
}
上述的例子中,会输出 1,我们并没有在主函数中通过 time.sleep 的方式来挂起,转而用一个等待写入的 channel 来代替。
注意:通道可以被显式的关闭,当需要告诉接受者不会种子提供新的值的时候,就需要关闭通道。
4、go 中的 range
上面我们也讲到要及时的关闭 channel,但是持续的访问数据源并检查 channel 是否已经关闭,并不高效。go 中提供了 range 关键字。
range 关键字在使用 channel 的时候,会自动等待 channel 的动作一直到 channel 关闭。通俗点将就是可以 channel 可以自动开关。
同样的来举例:
func input(cs chan int,count int){
for i:=1;i<=count;i++ {
cs <- i
}
}
func output(cs chan int){
for s:= range cs {
fmt.Println(s);
}
}
func main() {
cs := make(chan int);
go input(cs,5);
go output(cs);
time.Sleep(3*1e9)
}

上述的例子会依次的输出 1,2,3,4,5. 通过使用 range 关键字,当 channel 被关闭时,接受者的 for 循环也就自动停止了。
5、go 中的 select 切换协程
从不同的并发执行过程中获取值可以通过关键字 select 来完成,它和 switch 控制语句非常相似,也被称为通信开关。
首先要明确 select 做了什么??
select 中存在着一种轮询机制,select 监听进入通道的数据,也可以是通道发送值的时候,监听到相应的行为后就执行 case 里面的操作。
select 的声明:
select {
case u:= <- ch1:

case v:= <- ch2;

}

同样的来看一下具体使用 select 的例子:
func channel1(cs chan int,count int){
for i:=1;i<=count;i++ {
cs <- i
}
}
func channel2(cs chan int,count int){
for i:=1;i<=count;i++ {
cs <- i
}
}
func selectTest(cs1 ,cs2 chan int){
for i:=1;i<10;i++ {
select {
case u:=<-cs1:
fmt.Println(u);
case v:=<-cs2:
fmt.Println(v);
}
}
}
func main() {
cs1 := make(chan int);
cs2 := make(chan int);
go channel1(cs1,5);
go channel2(cs2,3);
go selectTest(cs1,cs2);
time.Sleep(3*1e9)
}

输出结果为:1,2,1,2,3,3,4,5 总共 8 个数据。且因为没有做同步控制,因此运行几次后的输出结果是不相同的。

6、go 中带缓存的 channel
前面讲到的都是不带缓存的 channel 或者说长度为 1 的 channel,实际上 channel 也是可以带缓存的,我们可以在声明的时候执行 channel 的长度。
ch = make(chan string,3)

比如上述的例子中,指定了 ch 这个 channel 的长度为 3,长度不为 1 的 channel,就可以称之为带缓存的 channel.
带缓存的 channel 可以连续写入,直到长度占满为止。
ch <- 1
ch <- 2
ch <- 3

7、go 中协程调度
讲到并发,就要提到 go 中的协程调度。go 中的 runtime 包,提供了调度器的功能。runtime 包提供了以下几个方法:

Gosched:让当前线程让出 cpu 以让其它线程运行, 它不会挂起当前线程,因此当前线程未来会继续执行
NumCPU:返回当前系统的 CPU 核数量
GOMAXPROCS:设置最大的可同时使用的 CPU 核数
Goexit:退出当前 goroutine(但是 defer 语句会照常执行)
NumGoroutine:返回正在执行和排队的任务总数
GOOS:目标操作系统

对于多核 CPU 的机器,go 可以显示的指定编译器将 go 的协程调度到多个 CPU 上运行
import “runtime”

cpuNum:=runtime.NumCPU;
runtime.GOMAXPROCS(cpuNum)

来聊聊 GO 中的调度原理,首先定义以下模型的概念:
M:内核中的线程的数目 G:go 中的协程,并发的最小单元,在 go 中通过 go 关键字来创建 P:处理器,即协程 G 的上下文,每个 P 会维护一个本地的协程队列。
接着来看解释 GO 中协程调度的经典图:

我们来解释上图:

P 是处理器的个数,我们经常将调度器的 GOMAXPROCS 设置成 CPU 的个数,因此这里 P 一般来说是机器 CPU 的个数。
M 是线程,在 P 处理器上关联一个线程,P 和 M 的一组配对组成了局部的协程队列
G 就是协程,需要被添加到由 P 和 M 组成的局部队列中依次处理
除了局部的协程外,在全局还维护了一个协程队列。
如果局部协程队列中处理完了所有队列,且没有新队列,那么 M 线程会取消对于 CPU 的占用,M 线程进入休眠

正文完
 0