当前位置: 首页 > 科技观察

Golang并发原理分析

时间:2023-03-14 20:41:49 科技观察

并发与并行并发:在一段时间内执行两个或多个任务。我们不必关心这些任务是否在某个时间点同时执行,它们可能同时执行也可能不同时执行,我们只关心它们是否在一段时间内执行,甚至在很短的时间内(一秒或两秒)解决两个或两个或更多任务。并行性:同时执行两个或多个任务。并发指的是逻辑的概念,而并行强调的是物理运行状态。并发性“包含”并行性。(详见:RobPike的PPT)Go的CSP并发模型Go实现了两种形式的并发。第一个是公认的:多线程共享内存。其实就是Java或者C++等语言的多线程开发。另一个是Go语言特有的,Go语言推荐的:CSP(communictingsequentialprocesses)并发模型。CSP并发模型是在1970年左右提出的,是一个比较新的概念。与传统的多线程通过共享内存进行通信不同,CSP讲究“以通信方式共享内存”。请记住下面这句话:“不要通过共享内存来通信;而是通过通信来共享内存。”“不要通过共享内存来交流,而是通过交流来共享内存。”普通的线程并发模型,就像Java、C++、Python一样,线程之间的通信是通过共享内存进行的。一个很典型的方式就是通过锁来访问共享数据(比如数组,Map,或者某个结构或对象)。因此,在很多情况下,衍生出一种方便的数据结构,称为“线程安全数据结构”。例如Java提供的“java.util.concurrent”包中的数据结构。Go中也实现了传统的线程并发模型。Go的CSP并发模型是通过goroutine和channel来实现的。Goroutine是Go语言中的并发执行单元。有点抽象,其实类似于传统的“线程”概念,可以理解为“线程”。Channel是Go语言中各个并发结构(goroutine)之间的通信机制。通俗地说,就是goroutine之间进行通信的“管道”,有点类似于linux中的pipeline。生成goroutine的方式很简单:Go,生成:gof();通讯机制channel也很方便,使用channel<-data传输数据,<-channel取数据。在通信过程中,数据传输channel<-data和datafetching<-channel难免会成对出现,因为这边传输数据,那里拿数据,就会实现两个goroutine之间的通信。而且不管是passed还是fetched,都必须阻塞,直到另一个goroutinepassed或者fetched。有两个goroutine,其中一个向通道发起值传输操作。(goroutine是一个矩形,channel是一个箭头)左边的goroutine开始阻塞,等待有人接收。这时右边的goroutine发起接收操作,右边的goroutine也开始阻塞,等待别人发送。这时候两个goroutine都找到了对方,于是两个goroutine开始传递和接收。这是GolangCSP并发模型的最基本形式。Go并发模型的实现原理是从线程开始的。无论语言层面的并发模型如何,在操作系统层面都必须以线程的形式存在。操作系统根据资源访问权限的不同可分为用户空间和内核空间;内核空间主要操作和访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用提供最基本的基础资源。,用户空间是上层应用程序的固定活动空间。用户空间不能直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”调用内核空间提供的资源。我们现在的计算机语言可以看作是一种狭义的“软件”。其中所谓的“线程”往往是用户态线程,与操作系统本身的内核态线程(简称KSE)还是有区别的。线程模型的实现可以分为以下几种方式:用户级线程模型如图所示,多个用户态线程对应一个内核线程,线程的创建、终止、切换或同步等工作程序线程必须自己完成。结束。内核级线程模型该模型直接调用操作系统的内核线程,所有线程的创建、终止、切换、同步等操作均由内核完成。C++就是这个。二级线程模型该模型是介于用户级线程模型和内核级线程模型之间的线程模型。这种模型的实现非常复杂,类似于内核级线程模型,一个进程可以对应多个内核级线程,但是进程中的线程并不一一对应于内核线程;这个线程模型会先创建多个内核级线程Thread,然后用自己的用户级线程来对应创建的多个内核级线程。自身的用户级线程需要自己的程序进行调度,内核级线程则交给操作系统内核进行调度。Go语言的线程模型是一种特殊的二级线程模型。我们暂时称它为“MPG”模型。Go的线程实现模型MPGM指的是Machine,一个M直接对应一个内核线程。P是指“处理器”,代表M所需要的上下文环境,也是处理用户级代码逻辑的处理器。G指的是Goroutine,本质上其实是一个轻量级的线程。三者的关系如下图所示:上图是两个线程(内核线程)的情况。一个M对应一个内核线程,一个M还连接一个上下文P。一个上下文P相当于一个“处理器”,一个上下文连接一个或多个Goroutine。P(Processor)的个数在启动时设置为环境变量GOMAXPROCS的值,或者在运行时调用函数runtime.GOMAXPROCS()。固定数量的Processor意味着任何时候只有固定数量的线程在运行go代码。Goroutine就是我们要并发执行的代码。图中,P正在执行的Goroutine是蓝色的;待执行的Goroutine是灰色的,灰色的Goroutine组成一个队列runqueues。三者关系的宏观图是:放弃P(Processor),你可能会想,我们为什么需要context,能不能直接去掉context,让Goroutine的runqueues挂在M上?答案是否定的,需要context的目的是让我们在遇到内核线程阻塞的时候可以直接放过其他线程。一个非常简单的例子是系统调用sysall。一个线程不能同时执行代码和系统调用被阻塞。这时候线程M需要放弃当前上下文P,这样才能调度其他Goroutines执行。如上图左图,M0中的G0执行了一个syscall,然后创建了一个M1(可能是自己存在的,不是创建的),(转右图)然后M0丢弃P,等待M1的返回值syscall,M1AcceptingP,会继续执行Goroutine队列中的其他Goroutine。当系统调用结束时,M0将“窃取”上下文。如果不成功,M0将其GouroutineG0放入全局运行队列,然后将自己放入线程池或进入睡眠状态。全局运行队列是每个P在运行自己的本地Goroutine运行队列后用于拉取新的goroutines的地方。P也会周期性的检查globalrunqueue上的goroutines,否则globalrunqueue上的goroutines可能会执行不完而饿死。均衡分配工作根据上面的说法,上下文P会周期性的去检查全局goroutine队列中的goroutines,这样当它消费自己的Goroutine队列的时候就有事情做。如果全局goroutine队列中的goroutine没有了怎么办?从其他正在运行的P的runqueue中窃取即可。每个P中的不同Goroutine导致运行效率和时间不同。在一个有很多P和M的环境中,如果有一个P运行自己的Goroutine就没什么事了,因为可能其他P有很多Longgoroutine队列需要平衡才能运行。如何解决?Go的做法也直截了当,偷了其他P的一半!