以goroutine为例看协程的相关概念

基本上是网上相关文章的梳理,初衷主要是想了解下golang中的goroutine到底是怎么回事,以及相关的起源和概念。后来发现本质上应该是对于golang scheduler的理解,因为goroutine是golang scheduler实现的一个重要模块。这一篇入门吧,基本理解到还行,如果想深入细致了解还是应该看源码,就像参考的那些比较好的链接中的那样。

补充 同步 异步 阻塞 非阻塞

同步与异步区别,主要关注的是消息通信机制

所谓同步调用 就是由调用者主动等待这个调用的结果。发出一个调用,在没有得到结果之前,该调用就不返回。一旦调用返回,就得到返回值了。

所谓异步调用 调用在发出之后,这个调用结果就直接返回了。当一个异步调用过程在发出之后,调用者不会立即得到结果,而是在调用发出后,被调用者通过状态、通知来通知调用者,或者通过函数回调来处理这个调用。

阻塞与非阻塞 关注的是:程序在等待调用结果(消息 返回值)时的状态

阻塞调用 是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。

非阻塞调用 是指,在不能立刻得到结果之前,该调用不会阻塞当前线程,当前线程还会继续执行下去。

注意!!! 阻塞与非阻塞与是否同步和是否异步无关。

进程 线程 协程

基本理解

大致上看有这么几个区别:

进程:独立的栈空间,独立的堆空间,进程之间调度由os完成。

线程:独立的栈空间,共享堆空间,内核线程之间调度由os完成。

协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。

这个帖子中排行第一的评论整理得比较通俗,便于理解入门,先整理如下:

首先是并发的起源,最初的动机就是想在宏观上,让多个程序能在同一时间执行,之后就是cpu分片,程序内部多个独立的逻辑流,宏观上多个逻辑流是一同执行的,当然也可以是多个cpu并行。

进一步的问题,多个逻辑流之间的切换怎么办,我的逻辑a计算到一半,逻辑b进来,那么逻辑a的中间结果怎么保存?所以同一个cpu中的多个并发执行的逻辑自然需要进行上下文切换。于是就需要进程的概念了,通过虚拟内存,进程表,等等内容来管理程序的运行和切换。

硬件进一步发展,一台电脑多个cpu于是一个cpu跑一个进程,这个就是并行了,是从时间意义上的完全的共同执行。

由于涉及到并行问题,那自然会有调度问题,怎么调度才能让cpu的利用率更高?这个就是内核应该程序要考虑的事。实质上就是某种权衡把,因为调度也是要开销的,所以就看这种调度是否值得去做。

课本上还是讲得挺明白,由于为了满足上述并发的需求,进程最为一个可拥有资源的,可独立调度和分派的基本单位而存在,但是进程的创建,撤销,切换其实都是需要不少开销的,如果进程切换过于频繁,系统资源就会被频繁开销所占去。于是控制粒度再进一步细化,即把“拥有资源”和“独立调度”两个属性分开来。线程仅仅拥有很小的一部分资源,共享线程的资源。其开销显著地小于进程的切换,操作系统的书里,都说的很细致,就不再赘述。

把调度的那部分功能从内核中拿出来,在进程中自己去实现一个逻辑流调度的功能,这样既可以实现并发的优势,又可以避免反复的系统调用,减少线程切换造成的开销,这就叫做用户态线程,相当于是调度功能的更细粒度的实现。

用户态线程需要考虑的问题:1、遇到阻塞式I/O会导致整个进程被挂起 2、由于缺乏时钟中断(具体查看相关内容 时钟中断的时候cpu可以用来进行进程切换)如果一种实现使得每个线程需要自己通过调用某个方法,主动交出控制权。那么我们就称这种用户态线程是协作式的,就是所谓的协程

具体在这篇文章可以看到,通过这个文章应该清楚这几个问题:协程是如何被提出的,为何开始的时候没有被普遍采用,以及后来又是如何兴起的。以及对于“协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制”的理解,以及协程在不同语言中的大概的实现方式。

关于采用协程的优势,参考这里,原文中也列出了python实现协程思想的一个模型。

  • 协程之间的切换是由程序自身控制的,没有线程切换的额外开销,和多线程相比,线程数目越多,协程的性能优势就越明显。
  • 不需要多线程的锁机制(因为只有一个线程),怎么利用多核CPU?最简单的方式是多进程+协程,比如在Golang程序开始的时候往往需要制定下并发进程的数目,类似这样:
1
2
3
4
5
6
if *maxProcs < 1 {
numProcs = runtime.NumCPU()
} else {
numProcs = *maxProcs
}
runtime.GOMAXPROCS(numProcs)

golang的协程模型的实现

其实核心就是调度器应该如何实现,这的确是一个比较复杂的问题。这里主要参考这个。其中的内容是在Golang的1.1版本的基础上经行的分析,后面的版本可能有些地方已经进行了改进。

Golang runtime的时候 scheduler 需要完成哪些工作?

首先要明确下,我们为什么需要scheduler。既然os本身可以调度线程,为何还需要在用户空间实现一个调度器?

POSIX Thread API 实际上是对于已经存在的Unix process model 的一个逻辑扩展。这使得在控制threads的时候,用些地方和控制processes比较类似。threads可以有它们自己的signal mask,有cpu亲和力(CPU affinity),可以被cgroup机制进行控制,并且可以查询到它们都使用了哪些资源。所有的对于threads的这些额外的控制特性都会增加额外的开销。对于Golang使用goroutine来说,这些特性是不需要的。

从Golang本身的角度来说,如果让os去做schedule,这样粒度不是很细,schedule的时机选择也并不是最佳的,因为os无法知道golang在runtime时候的一些更进一步的信息。比如,Golang的GC在启动的时候需要保证以下两个方面

1、所有的threads都停止

2、memory必须达到一致的状态(这里的memory consistency到底指的什么?)这就需要golang在runtime的时候等到所有的运行的threads都到达memory consistent的时候才能启动GC。

可以想象到,如多有许多threads在某种程度上“随机”的时间点被进行调度(os的方式下 根据时钟中断?),就会经常需要去等待这些threads,等它们达到一个consistent state。如果是golang 自身实现的调度器,它可以在所有threads达到memory consistency的状态的时候才决定进行调度,因此在调度时机的选取上,这样更为高效。即是说,当我们准备进行Garbage Collection的时候,只需要等待那些正在被运行的进程停下来就可以了。

(gopher china 2016 Dave slide )每个goroutine至少会占用2k的内存空间,2048 * 1,000,000 goroutines == 2Gb,也就是说,2G内存的机器,最多可以承担100万的goroutine,所以在每次使用go关键字的时候,明确goroutine会怎样退出,如果无法明确的回答这个问题,可能会导致潜在的内存泄露,当然,有些goroutine会一直运行,直到main函数运行结束,这也是gc优化的一个技巧。

所以:

Never start a goroutine without knowing how it will stop

Golang 中的调度器模型基本介绍

通常情况下有三种线程模型:

  • N:1 N个用户级线程以及一个内核级线程。这种模式下,用户级线程之间的切换可以很快,但是不能很好的利用多核的优势。
  • 1:1 一个用户级线程对应一个内核级线程。可以利用多核的优势,但是线程切换比较慢,因为需系统调用,要进行trap操作。

Golang中的调度器采用 M:N 的方式。既要利用多核cpu系统的特性,同时还要增强上下文切换的速度。缺点就是,这会使得调度器的实现变得复杂。

可以看到golang scheduler中包含以下的基本元素:

三角形M 代表一个os thread,这个thread被os管理,工作方式就像通常的POSIX thread那样。

 

圆形G 代表一个goroutine,它有自己的stack,instruction pointer,程序计数器,以及它所在的M等信息,以及一些调度goroutine所必须的一些资源,这些信息就是goroutine要放弃cpu的时候所需要保存的信息,比如正在阻塞的channel等等,下次被调度到的时候,这些信息要被重新load到对应的cpu寄存器中。

矩形P 代表一个用于调度的上下文context,从理解上,可以认为这是一个运行在单独thread中的scheduler或者理解成一个局部的Processor处理器。这个组件很关键,是从N:1调度器到M:N调度的器的关键部分。

上图是一个一般情况,可以看到有两个内核thread(M),每一个内核thread都持有一个context(P),每一个内核线程还运行着一个goroutine,为了运行goroutines,内核线程必须要持有一个context。

context的数量是在goroutine运行的时候由 GOMAXPROCS 这个环境变量设置进去的,通过runtime的GOMAXPROCS()函数可以设置这个值。通常情况下,这个值在程序运行的过程中是不变的,即使说,GOMAXPROCS 是实际负责运行Golang代码的组件,可以通过其数目来调整实际的GO process的数目。(GOMAXPROCS 就是图中的那个P 到底是什么东西?理解上感觉像是个容器一样?里面装着的golang 实际运行代码可以更换 但是离不开这个运行的环境)

上图中灰色标记的goroutine并没有在运行,它们是准备被调度的(not running but ready to scheduled)。它们被分配在一个个的list中,这些list叫做runqueues。新的goroutine会被添加到runqueues的尾部。在调度点的时候,当context需要运行一个goroutine,一个goroutine就会从list中弹出来,之后设置好对应的stack以及instruction pointer之后这个goroutine就开始运行了。

为了减少并发冲突,每个context都有它们自己的一个runqueue(旧的版本貌似只有一个全局的runqueue),当然以上情况只是最一般的情况,实际比这要更复杂一些。

发生系统调用syscall的情况

为什么必须要一个context(即使图中的P)?为何不能直接让requeues在thread上运行?当某个正在running的线程陷入阻塞的时候,这个p可以临时转移到新的线程上去。

比如某个gourutine正在进行系统调用,因为线程不能一边执行代码,一边阻塞在系统调用上,所以p可以带着gourutine转移到其他的os线程上去,如下图

这里我们可以看到,原来的内核线程M0放弃了它本身的context,M0陷入了阻塞,之后这个context又与新的内核线程M1绑定在了一起。调度器可以保证有足够的thread来运行这些context。原来的M0仍然持有者之前的那个goroutine,因为本质上来说,它还是执行着的,虽然被os阻塞。

当syscall的结果返回,M0必须要想办法持有一个context来运行之前的goroutine。因为按照之前分析的,如果goroutine想要运行,必须要有一个context作为支撑。通常的方式是steal一个context过来,如果暂时没有可用的context,当前的这个goroutine可能会被放到全局的runqueue中,这个thread会把自己放到thread cache中变成sleep状态。

当context的local runqueue中已经没有goroutine了,它们可能会从global runqueue中获取一个goroutine过来。context也可能会周期性地检查runqueue看其中是否还有goroutine。否则全局runqueue中的goroutine可能会永远无法运行,会被"饿死"(context的local runqueue一直有goroutine在运行)。

对于syscall的处理方式决定了golang在运行起来的时候,本身就是多线程的,即使GOMAXPROCS的数目被设置为1,因为在发生syscall的时候,p会在一个新启动的thread上继续运行。可以知道在golang中不会直接让用户去创建一个os层面的thread,这个工作完全是由runtime根据实际情况来决定何时创建thread。用户能创建的仅仅只goroutine,让用户管理的资源越少,操作的东西越简单,用户就会越happy。

steal work

work stealing的策略应该也是一种调度算法,大致上是这样:如果context上的goroutine数目不平衡,golang的调度器也会进行相应的处理,就是所谓的"steal work"。一种情况是从全局requeue中获取goroutine继续运行,另一种情况是从其他的context中steal一些过来,比如从其他的context的local requeue中steal一半数目的goroutine过来,就像下图中的这样。这样可以保证每个context都有一些工作需要完成,这样也可以保证所有的threads都发挥出了它们最大的性能。

调度时间点的选取

这个参考的这篇,通过之前的分析可以看到,采用自己实现的调度器的一个重要方面就是由runtime自己决定调度的时机,那么具体情况是怎么样的?

这里罗列写可能的基本情况:

  • runtime.park函数被调用,可能会使得goroutine变为waiting状态,放弃cpu。channel读写操作的时候,定时器中,网络poll都可能会调用runtime.park函数。
  • runtime·gosched函数也可以让当前goroutine放弃cpu,但和park完全不同;gosched是将goroutine设置为runnable状态,然后放入到调度器全局等待队列(global runqueue)。
  • 有些系统调用会触发重新调度。比如之前提到的syscall的情况,runtime的时候会有一个goroutine负责系统监控,对goroutine进行扫描,如果发现某一个goroutine处在syscall的状态下,就会像前面分析的那样,创建一个新的M,把那个P抢过来,让这个P开始运行goroutine。等到系统调用结束,原来的goroutine发现自己这边没有P,无法执行,就会被放到全局的requeue上,之后原先的线程也会变为sleep的状态。

总结

threads粒度太粗,会有诸多额外开销 -> goroutine不需要这些开销,它们需要更细粒度的控制 -> golang中scheduler的模型(几种线程模型 M P G 含义 )-> (M P G 优点) 某个G陷入阻塞的时候 P可以带着其他的G转移到其他的M上,当原先的G系统调用完成以后,会从另外一个地方steal一个P回来 -> 提高资源利用率

要通过golang scheduler的基本模型理解其本质的东西,即最终目的是要使得所有资源的利用率最大,使用到其他地方,应该也要有些启发。比如k8s的调度的策略。

实际实现当然是一个很复杂的过程,比如这篇从更细节的层面分析了golang的调度器,也比较有参考价值。

感觉要想深入了解,还是应该把这些本质的东西弄清楚一点。比较推荐这个可以按照大牛的思路一块块地了解相关内容。

还有一个深入了解语言层面的方式,比如好多人会发帖,说这个语言怎么样,有什么的缺陷,等等,可以顺藤摸瓜,顺着这些人的思路走下去,看看到底细节上是怎样的,这样也能提升好多。

比如可以参考这个

参考资料

golang与jvm中并发模型的探讨

http://www.nyankosama.com/2015/04/03/java-goroutine/

协程的一些介绍

http://www.cnblogs.com/wonderKK/p/4062591.html http://blog.youxu.info/2014/12/04/coroutine/

zhihu相关帖子

http://www.zhihu.com/question/20511233

https://www.zhihu.com/question/20862617

http://www.zhihu.com/question/32218874

协程的过去现在未来(从cobol到协程的整个发展演变 比较经典) http://www.tuicool.com/articles/BNvUfeb

http://www.liaoxuefeng.com/wiki/001374738125095c955c1e6d8bb493182103fac9270762a000/0013868328689835ecd883d910145dfa8227b539725e5ed000

大牛的博客 里面有许多关于golang的文章 比如gc之类的

http://morsmachine.dk/

牛人的golang学习笔记(从源码角度分析)

https://github.com/qyuhen/book

关于golang的调度器(也写得比较通俗)

http://skoo.me/go/2013/11/29/golang-schedule/

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
当我们需要进行并发编程时,通常会使用线程或进程来实现。但是,线程和进程的开销比较大,而且容易发生死锁、竞态条件等问题。Golang 提供了程(Goroutine)和通道(Channel)等并发机制,可以轻松实现高效的并发编程。 程(Goroutine)是一种轻量级线程,由 Golang 运行时管理。与线程相比,程的开销非常小,可以轻松创建数以千计的程,并发执行任务。程之间通过通道(Channel)进行通信,以实现数据共享和同步。 通道(Channel)是一种特殊的数据类型,用于程之间的通信。通道可以用于发送和接收数据,通道的发送和接收操作是原子性的,因此可以保证数据的同步和安全。通道有两种类型:有缓冲通道和无缓冲通道。有缓冲通道可以缓存一定数量的数据,但发送和接收操作可能会发生阻塞;无缓冲通道必须有发送和接收操作同时进行,否则会发生阻塞。 以下是一个简单的示,演示了如何使用程和通道实现并发处理任务: ```go func worker(id int, jobs <-chan int, results chan<- int) { for j := range jobs { fmt.Printf("worker %d started job %d\n", id, j) time.Sleep(time.Second) fmt.Printf("worker %d finished job %d\n", id, j) results <- j * 2 } } func main() { jobs := make(chan int, 100) results := make(chan int, 100) for w := 1; w <= 3; w++ { go worker(w, jobs, results) } for j := 1; j <= 5; j++ { jobs <- j } close(jobs) for r := 1; r <= 5; r++ { <-results } } ``` 在这个示中,我们创建了一个有缓冲通道 `jobs` 和一个无缓冲通道 `results`。然后创建了 3 个程 `worker`,用于处理任务。在 `main` 函数中,我们向 `jobs` 通道中发送了 5 个任务,然后关闭了 `jobs` 通道。接下来,我们从 `results` 通道中接收了 5 个结果。在 `worker` 函数中,我们使用 `range` 循环从 `jobs` 通道中接收任务,然后处理任务并将结果发送到 `results` 通道中。 通过程和通道的组合,我们可以轻松实现并发处理任务,并且避免了线程和进程的开销和安全问题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值