谢谢知乎不完善的文章编写系统把我赶到了简书:)
老规矩吧,废话也懒得说了。知乎白瞎了我一篇文章,现在也不说多废话。
目录
1.再探协程
什么是协程序,上一篇文章仅仅是一笔带过,只说了他是一个比线程更加轻量级的东西;就好像一个函数。可是它到底是什么?它有没有内存自己的内存组织?谁来调度它?
Coroutine的实现,通常是对某个语言做相应的提议,然后通过后成编译器标准,然后编译器厂商来实现该机制。
Coroutine,翻译成”协程",初始碰到的人马上就会跟上面两个概念联系起来。
直接先说区别,Coroutine是编译器级的,Process和Thread是 操作系统级的。Coroutine的实现,通常是对某个语言做相应的提议,然后通过后成编译器标准,然后编译器厂商来实现该机制。
Process和Thread看起来也在语言层次,但是内生原理却是操作系统先有这个东西,然后通过一定的API暴露给用户使用,两者在这里有不同。Process和Thread是os通过调度算法,保存当前的上下文,然后从上次暂停的地方再次开始计算,重新开始的地方不可预期,每次CPU计算的指令数量和代码跑过的CPU时间是相关的,跑到os分配的qpu时间到达后就会被os强制挂起。
Coroutine是编译器的魔术,通过插入相关的代码使得代码段能够实现分段式的执行,重新开始的地方是yield关键字指定的,- 次-定会跑到一-个yield对应的地方。
我们可以说:对于协程的调度,我们确实必须保存它的上下文,因为这是每个正常中断再运行的函数所必须的;但这种操作,在go语言中,全权交给了语言本身;至于内存的组织,应当是保存在所属进程的栈中。
我们为什么需要协程?
(1)POSIX线程API是现有Unix进程模型的逻辑扩展,因此,线程可以获得与进程相同的许多控件。线程有自己的信号掩码,可以分配CPU资源,可以放在cgroups中,可以查询它们使用哪些资源。所有这些控件都是Go程序使用goroutines所不需要的功能,这增加了开销;当程序中有100,000个线程时,这些开销就会迅速增加。
(2)另一个问题是,基于Go模型下,操作系统无法做出明智的调度决策。例如,Go垃圾收集器要求在运行收集时停止所有线程,并且内存必须处于一致的状态。这涉及到等待正在运行的线程到达一个我们知道的内存一致的点。
发现了一张特别形象的动图
2.CSP并发模型
(1)csp的提出
csp,communicating sequential processes,通信顺序进程。1978年由C.A.R.Hoare首次在《Communications of the ACM》中提出。
本文认为输入和输出是程序设计的基本基元,通信顺序过程的并行组合是程序设计的基本方法。当与Dijkstra的守卫命令相结合时,这些概念是惊人的通用的。它们的用法可以通过各种熟悉的编程练习的示例解决方案来说明。
文中提到的Dijkstra的守卫命令则来自另外一篇论文《Guarded Commands, Nondeterminacy and Formal Derivation of Programs》。文中指出:
所谓的“保护命令”,是用来作为替代和重复构造的构建块,这些替代和重复构造块允许设置为不确定的程序组件;对于这些组件,至少所引发的活动(但也可能是最终状态)不一定由初始状态确定。
说白了就是我们都知道一堆线程进行并发执行,最终结果是不确定的;为了维护内存或全局变量的一致完整性,我们加入“保护命令”,就好像互斥锁。也有点像 if 判断语句。
文中的语法是这样的:
简而言之,一个右箭头 → 就能概括全部:
对于:A → B;其中 A 是保护命令,是一个布尔类型的表达式;B是受保护命令的列表,大于等于一个。
说回Hoare的csp论文。
文中提到了几点关于csp并发模型设计的原则,个人觉得很有意思:
(1)采用Dijkstra的保护命令(在符号上稍作改变)作为顺序控制结构,并将其作为输入和控制非决定语句的唯一手段;
(2)所有进程同时启动,并行命令只有在它们全部完成时才结束。它们之间无法通过更新全局变量进行通信;
(3)输入和输出命令作为程序原语;它们用于并发进程之间的通信;
(4)当一个进程将另一个进程命名为输出目的地,而第二个进程将第一个进程命名为输入源时,就会发生这种通信;通常,一个输入会等待输入,这种等待往往是随机的;
(5)输入命令可能出现在保护中;
(6)输入内容可以进行模式匹配;
对于以上的基本原则,熟悉go语言并发协程之间通信方式的朋友一定都可以在go语言模式下找到对应的。比如对于第(4、6)点,可以看作是构造了一个通道var x chan int;对于第6点,这个通道只能输入int类型;而对于第4点,可以看作是在项目中,一个协程输入 x ← 2,另一个输出 ← x,这是异步的,所以上文说“等待”。
理论上,Channel 和 Process 之间没有从属关系。Process 可以订阅任意个 Channel,Process 没必要拥有标识符,只有 Channel 需要,因为只向 Channel 发消息。不过具体实现也能把 Process 作为一个实体暴露出来。
当然要想深入学习csp模型,还是要跟着正规军;大部分是围绕着集合论等数学手段;
官方教程:http://usingcsp.com/cspbook.pdf
程序员视角审视csp,这里讨论了JAVA实现CSP:https://www.cs.kent.ac.uk/projects/ofa/jcsp/cpa2007-jcsp.pdf
(2)csp的精髓
csp模型的精髓,一句话:不要用共享内存的方式进行通信,要以通信方式共享内存!
《七周七并发模型》中有特别形象的例子:
如果你和我一样是个车迷,很可能只会关注车辆本身,而忽略了它所要行驶的道路。大家都在喋喋不休地争论涡轮增压与自然吸气孰优孰劣,让中置发动机布局与前置发动机布局较高下,却忘记了最重要的方面其实与车辆本身无关。你能去往何方、能多快到达目的地,首要的决定因素是道路网络而不是车辆本身。
消息传递系统( message passing system)与之类似,决定其特性和功能的首要因素并不是用于传递消息的代码或者消息的内容,而是消息的传输通道。
这句话深刻的嵌入到go语言的设计当中。
一般线程同步在线程间交换的信息仅仅是控制信息,比如某个A线程释放了锁,B线程能获取到锁并开始运行,这个不涉及数据的交换。数据的交换主要还是通过共享内存(共享变量或者队列)来实现,为了保证数据的安全和正确性,共享内存就必需要加锁等线程同步机制。而线程同步使用起来特别麻烦,容易造成死锁,且过多的锁会造成线程的阻塞以及这个过程中上下文切换带来的额外开销。
而在GO语言中,要传递某个数据给另一个goroutine(协程),可以把这个数据封装成一个对象,然后把这个对象的指针传入某个channel中,另外一个goroutine从这个channel中读出这个指针,并处理其指向的内存对象。
3.go并发模型
根据上篇文章,通常有3个线程模型。(1)一个是N:1,其中几个用户空间线程在一个OS线程上运行。这样做的优点是可以非常快速地切换上下文,但是不能利用多核系统;(2)另一个是1:1,其中一个执行线程与一个操作系统线程匹配。它利用了机器上的所有核心,但是上下文切换很慢,因为它必须通过操作系统进行捕获。
(1)架构
Go试图通过使用M:N调度器来同时利用这两个方面。它将任意数量的goroutine调度到任意数量的OS线程上。您可以快速切换上下文,并利用系统中的所有核心。这种方法的主要缺点是它增加了调度器的复杂性。为了完成调度任务,Go调度程序使用3个主要实体:
三角形表示一个OS线程。它是由操作系统管理的执行线程,其工作方式与标准POSIX线程非常相似。在运行时代码中,它被称为M for machine。
圆圈代表goroutine。它包G括堆栈、指令指针和其他对调度goroutines很重要的信息,比如它可能阻塞的任何通道。在运行时代码中,它被称为G。
矩形表示调度的上下文。您可以将它看作是在单个线程上运行Go代码的调度程序的本地化版本。这是让我们从N:1调度器过渡到M:N调度器的重要部分。在运行时代码中,它被称为P。
他们的组织结构如下:
上下文的数量在启动时设置为GOMAXPROCS环境变量的值,或者通过运行时函数GOMAXPROCS()设置。通常情况下,这在程序执行期间不会改变。上下文的数量是固定的,这意味着在任何时候都只有GOMAXPROCS在运行Go代码。我们可以使用它来调优Go进程对单个计算机的调用,比如在4核PC上运行Go代码的4个线程。
灰色的goroutines没有运行,但是已经准备好了。它们被安排在名为runqueue的列表中。每当goroutine执行go语句时,goroutine就被添加到运行队列的末尾。一旦上下文运行了goroutine直到调度点,它就会从运行队列中取出goroutine,设置堆栈和指令指针并开始运行goroutine。为了降低互斥争用,每个上下文都有自己的本地运行队列。
(2)调度
(a)阻塞
我们需要阻塞的一个例子是当我们调用一个系统调用。由于线程不能执行在被阻塞在syscall上,所以我们需要传递上下文,以便它能够继续调度。
这里我们看到一个线程放弃了它的上下文,以便另一个线程可以运行它。调度程序确保有足够的线程来运行所有上下文。上面例子中的M1可能只是为了处理这个syscall而创建的,也可能来自线程缓存。syscalling线程将保持使系统调用的goroutine,因为它在技术上仍然在执行,尽管在操作系统中被阻塞了。
当syscall返回时,为了返回运行的goroutine,M0线程必须尝试获取一个上下文。
正常的操作模式是从其他线程窃取上下文;如果它不能窃取一个,它将把goroutine放到一个全局运行队列中,把自己放到线程缓存中,然后休眠。全局运行队列是上下文在其本地运行队列用完时从中提取的运行队列。上下文还会定期检查全局运行队列中的goroutines。否则,全球运行队列上的goroutines可能会因为饥饿而停止运行。
(b)偷取G
当上下文耗尽了要调度的goroutines时。如果上下文的运行队列上的工作量不平衡,就会发生这种情况。这可能导致上下文最终耗尽它的运行队列,而系统中仍然有工作要做。要继续运行Go代码,上下文可以将goroutines从全局运行队列中取出,但是如果其中没有goroutines,它就必须从其他地方获取它们。
某个地方是另一个环境。当一个上下文运行完时,它将尝试从另一个上下文窃取大约一半的运行队列。这确保在每个上下文中始终有工作要做,这又确保所有线程都在以最大容量工作。