乐趣区

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

小白一枚,最近在研究 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 线程进入休眠

退出移动版