Go 中的并发 | Go 主题月

1,414 阅读7分钟

前言

又是快乐的周五,按照往常的习惯,周五是不可能好好的工作,不摸它一天的🐟都对不起这个好日子,但是平白无故的摸鱼又有一定的罪恶感,而且还需要防范主管的巡视,但是!!!!

事情总会有漏洞,比如说当我在写文章,虽然也是在干着跟工作无关的内容,但是!!却可以心安理得的,因为这是一个学习的过程,而只有更好的学习,才可以回报公司滴!!(必须这样想)那么话不多说,开始今天的内容。

并发与并行

在聊并发和并行的操作的时候,必须了解什么是操作系统的进城,线程以及Go中的goroutine。

这里有我之前写的一篇小文章(juejin.cn/post/695249…)

68747470733a2f2f7261772e6769746875622e636f6d2f666f7268617070792f412d44657461696c65642d43706c7573706c75732d436f6e63757272656e63792d5475746f7269616c2f6d61737465722f696d616765732f63686170746572312f636f6e63757272656e742d76732d706172616c6c656c2e.png

再聊到Go中的并发的时候,要先了解一下并发和并行的区别。上面这张图已经说明了并发和并行一个问题,其实可以这么理解——

并发:在一个时间段内完成处理一些事情

并行:在一个时间点内同时做一些事情

通过上图也是看得出来,其实并发就是一个咖啡机交替性的去处理两个等待队列的事情,只不过因为处理的非常的快,所以很多时候,用户感觉跟并行一样。

2017-06-14-11-32-03.png

上图展示了一个包含了所有可以分配的资源的进程。其中可以看到一个线程就是一个执行的空间,它被操作系统所调用,用来执行相对于的一些函数等。

操作系统会在物理层面去调用线程来运行,而在Go中,则是由逻辑处理器来(也就是函数),来控制和调度goroutine,其中在Go语言运行的时候,默认的,会为每一个物理处理器分配一个逻辑处理器,这些逻辑处理器是用来并发的调度所有的被创建出来的goroutine的。

我们知道对于多线程来说,线程的调度是一件很耗能的事情,但是在Go中goroutine却不存在这样的问题,虽然本质上来说都是让一个goroutine暂时挂起,然后让其他的goroutine继续去工作,但是因为逻辑处理器在调度goroutine的时候,并不需要进入到线程调度中的内核上下文中去,所以这里所需要的代价就小了很多。

GMP模型

说到goroutine就必须说道Go语言中的GMP模型

N672WgcJ81.jpg

从上图我们可以看到M指的是执行的线程,P指的是逻辑处理器,G指的就是goroutine,当Go语言运行的时候,逻辑处理器会绑定到一个操作系统的线程上去,然后当goroutine可以运行的时候,会被放入逻辑处理器的执行队列中,通过逻辑处理器,将一个个goroutine供给给线程去执行。

当执行到这个goroutine发生阻塞的时候(例如一些打开文件什么的),逻辑处理器会从该线程中分离分离出来,然后到一个新的线程上去,继续运行整个服务,而原来的线程则会继续阻塞,等待系统的调用返回。

注意:如果goroutine进行的是一次IO网络调用的话,虽然也是会发生阻塞,但跟上面的情况又有些不一样。

首先goroutine会和逻辑处理器分离开来,然后所有的goroutine会移植到网路轮询器上面去运行,当goroutine完成了对于网络的读或者写的时候,它才被重新放回逻辑处理器的队列上去。

深入理解goroutine

让我们先来看一段代码

func main()  {
	var w sync.WaitGroup // 用来等待程序的完成
	
	w.Add(2) //数字为2,表示有两个goroutine在运行
	go func() {
		defer w.Done()//函数运行结束的时候,通知main函数,想当与Add(-1)
		for i:=0;i<3 ;i++  {
			for char:='A';char<'A'+26 ;char++  {
				fmt.Printf("%c",char)
                                //打印大写字母A~Z
			}
		}
	}()
	go func() {
		defer w.Done()
		for i:=0;i<3 ;i++  {
			for char:='a';char<'a'+26 ;char++  {
				fmt.Printf("%c",char)
                                //打印小写字母a~z
			}
		}
	}()
	w.Wait()//等待goroutine的结束,在add不为0的时候阻塞
}

首先我们要知道,main函数其实也是一个goroutine,不过它是最主要的一个,当main函数退出的时候,里面的goroutine就算没有执行完成,也会被系统给回收,所以这里我们需要用到sync.WaitGroup来等待goroutine的完成后,main函数再退出。

而当我们执行上面那段代码的时候,结果是这样的

abcdefghijABCDEFGHIJKLMklmnopqrstuvwxyzabNOPQRSTUVWX......

产生这样的结果的原因,是因为这两个goroutine是在并行执行的,因为在运行的Go的时候,默认情况下,我的电脑是一台八核的电脑,他会启用八条进程(每个进程内有一条线程)同时的来调度Go函数,也就是会有多个逻辑处理器,同时来处理这些函数,而这也就是它并行的原因。

注意:只有当存在多个逻辑处理器,并且让每个goroutine运行在一个独立的逻辑处理器上面的时候,goroutine才会并行运行。

当逻辑处理器只有一个的时候,又会出现什么情况?我们同样的使用上面的代码,不过需要加多一行,来限制它的逻辑处理器数量(也就是会有多少个线程操作这次Go程序

func main()  {
	var w sync.WaitGroup // 用来等待程序的完成

	runtime.GOMAXPROCS(1)// 限制执行的线程只有一条
	w.Add(2) //数字为2,表示有两个goroutine在运行
	go func() {
		defer w.Done()//函数运行结束的时候,通知main函数
		for i:=0;i<3 ;i++  {
			for char:='A';char<'A'+26 ;char++  {
				fmt.Printf("%c",char)
			}
		}
	}()
	go func() {
		defer w.Done()
		for i:=0;i<3 ;i++  {
			for char:='a';char<'a'+26 ;char++  {
				fmt.Printf("%c",char)
			}
		}
	}()
	w.Wait()//等待goroutine的结束
}

它的执行结果是这样的

abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLM

我们可以看到,它是先输出了三次小写的a-z,然后再输出大写的A~Z,这是因为只有一个逻辑处理器的时候,第一个goroutine很快执行完了,然后它的输出被放到储存的栈里面去,我们知道栈是后进先出的,所以它会先输出三遍第二个的goroutine的小写字母,然后在输出大写字母。

当时,当一个goroutine持续的时间比较长的时候又会怎么样呢,同样的我们也是使用上面的代码,只不过我们把循环加大到30000次,执行的结果就会变成这样

前面忽略一部分
abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkABCDEFGHIJKLMNOPQRSTUVW
中间再忽略一部分
stuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuv

我们可以看到它在打印了很长的一段小写字母的a~z之后,它会切换到去打印大写字母,然后又切换回来打印小写字母。

原因是逻辑处理器为了避免一个goroutine在执行的时候,占用的时间过长,当这个goroutine运行的时间过于漫长的时候,它会停止当前的goroutine,转而给其他goroutine运行的机会,所以就会有上面的情况出现。

最后

goroutine 可以说是整个Go语言的核心,但是它也非常的难以理解,例如我们知道goroutine是有伸缩性的,最大它可以渠道2G差不多,而这也就会扩展出非常多的线程,但是,当goroutine执行完成被回收的时候,这些扩展出去的线程是怎么处理的,Go的垃圾回收机制没有回收线程的这一个说法。(这点我还不知道—_-)

还有我上面所提到的所有对于goroutine的运用,都是没有存在读写操作的,那么在存在读写操作的时候,Go语言又会通过怎么样的形式,来破解那些并发的问题?(这点我会在下一篇文章说明)

最后最后,也是最重要的,我就快升级了!!!求看到了这里的看官大老爷给个赞呀!!!

下载.jpg