Golang 源码分析 - 调度器的实现分析
2017-04-08
为什么要要读 golang 的源码
大家都知道 golang 1.5 是一个里程碑版本,因为这个版本实现了 golang 的自举,也就是说 golang 整个语言的运行时用自己 golang 来实现了,除了少量的汇编语言,实现自举充分说明了 golang 的稳定性。所以我读 golang 的源码的源码不仅能够学习 golang 语言,还能学习到 google 的对 golang 的设计,学习 golang 能够将汇编,编译原理,链接,操作系统 这四个方面的知识连成一个整体,对自己本身系统知识的了解和熟悉也能有较大的提升。
golang 的精髓包含两个方面
- golang 的内存管理和垃圾回收
- golang 的协程序调度
golang 在语言层面就实现了协程的支持,这样能大大减轻程序员在并发编程上的心智负担。很好的驾驭了多核云时代。使用 golang 实现的明星项目就不少了,
- 容器相关docker,kubernetes
- etcd/consul
- 数据库相关:influxdb,TiDB
- 区块链技术:fabric,chain
更加详细的信息可以戳这里 基本上当前热门和有趣的项目大部分都是 golang 开发的,可以说我们正在经历一个基础设施有 c 到 golang 重写的过程中。
Golang 语言运行时有两个皇冠上的明珠,其一为内存管理和垃圾收集,内存管理是基于 google tcmalloc 算法实现的,其二就是 golang 的 goroutine 设计和调度。我们这次先分析 golang 的 goroutine 和调度器。
现代操作系统的调度
cpu 中一些基本的概念
我们先来看看现代计算机的一般结构
注意上图中的一些重要的组件
- pc: 程序计数器,主要标示下一条指令的位置,同时注意 pc 不能通过普通命令如:mov 等来改变这个,只能通过 jmp, call/ret, int 这些命令来修改它
- 由于寄存器是有限的,当 cpu 完成指令时,需要的输入或者输出超出了寄存器的个数时,必需配合内存来工作,这时需要时使用栈标示寄存器
- 由于外设是通过 int 中断来和 cpu 通讯,或者程序内存错误如除 0 等,这些情况都会修改标志寄存器
kernel 是如何调度线程的
Goroutine 介绍
使用过 go 语言的同学都知道在 go 中实现一个 goroutine 是非常简单的,只需要使用关键字 go
就能运行一个 goroutine,那到底 goroutine 是什么?又是如何是实现的呢?这篇文章先试图讲清楚这两个问题,至于之后的具体实现和源码分析,我们随后写 blog 来阐述。
goroutine 翻译为 协程
字面意思是协同的程序?确实如此,就是协同的工作的程序。
咱们先看看协同的是啥,就是说有多个 goroutine 时,可以大家一起协同工作,那程序指啥呢?指的就是协同工作的内容了,咱们现在程序是运行在 cpu 上,所以就是一段 cpu 指令,对应就是咱们程序中的代码,因为咱们程序的代码分为 code
data
,就是 code
也就是咱们具体写的各个函数,因为咱们的 function 进过编译器编译后生成的咱可执行文件的 text
segment,即 cpu 可执行的执行。
为什么需要协程
调度器和 kernel 直接的矛盾
实现 Goroutine 需要解决的问题
在上文中我讲了程序是啥,那么咱们这节就来看看要让 goroutine run 起来需要解决的问题有哪些?
code
运行起来后在电脑中最关键的数据有什么- 咱们的 goroutine 运行在操作系统上面临的问题有哪些
- 咱们自身解决了上面的问题后,是不是引入新的问题
第一问题就得我们了解电脑的结构和运行是的原理,现代电脑都是 冯诺曼 体系的,而今天要回答这个问题,我们先得了解两个重要的组件,cpu 和 内存。有这些基础的同学都知道,cpu 和内存有几个重要的概念,寄存器 和 stack。 寄存器是 cpu 运行的重要组件可以和 cpu 来交换数据,而寄存是有限的,但是内存就要大的多,所以又变化而来了 stack 来和 cpu 交换数据。
- 当单个 goroutine 结束后,调度器如何调度下个 goroutine
- 当 goroutine 中存在系统调用并且系统调用阻塞了后,将会被 kernel 将这个线程挂起来,这样调度器就失去了调度当机会这样当情况怎么处理
- 若是采用新启动一个线程来调度,大规模当网络程序如何处理
- 当两个 goroutine 有交换数据当需求时,如何实现生产者和消费者当问题。
第二个问题:
- 我们运行在用户态,是没有中断或系统调用这样的机制来打断代码执行的,那么,一旦我们的 schedule()代码把控制权交给了任务的代码,我们下次的调度在什么时候发生?答案是,不会发生,只有靠任务主动调用 schedule(),我们才有机会进行调度,所以,这里的任务不能像线程一样依赖内核调度从而毫无顾忌的执行,我们的任务里一定要显式的调用 schedule(),这就是所谓的协作式(cooperative)调度。(虽然我们可以通过注册信号处理函数来模拟内核里的时钟中断并取得控制权,可问题在于,信号处理函数是由内核调用的,在其结束的时候,内核重新获得控制权,随后返回用户态并继续沿着信号发生时被中断的代码路径执行,从而我们无法在信号处理函数内进行任务切换)
- 堆栈。和内核调度线程的原理一样,我们也需要为每个任务单独分配堆栈,并且把其堆栈信息保存在任务属性里,在任务切换时也保存或恢复当前的SS:ESP。任务堆栈的空间可以是在当前线程的堆栈上分配,也可以是在堆上分配,但通常是在堆上分配比较好:几乎没有大小或任务总数的限制、堆栈大小可以动态扩展(gcc有split stack,但太复杂了)、便于把任务切换到其他线程。
Metadata 组织
既然是协作式的,那我们就得知道怎么样来协作,协作的隐含的一个意思就是可调度,既然要可调度,就得又调度的依据即数据,应为没有数据就没有调度的依据,而一般调度数据是通过埋点收集起来,goroutine 也不例外,通过自身 runtime 的数据来组织这些需要的数据。
通过上一节的说明我们大概知道了一个程序要运行起来几个重要的状态信息
- 寄存器的相关状态,如 PC,SP,Flag 等
- 程序运行起来后的 stack 状态
这两个数据是 goroutine 能调度运行的关键
syscall 和 network 的问题
goroutine 程序也是运行在操作系统系统之上,那么操作系统上的进程或者线程都是受操作系统管控的,我们知道和操作系统通讯只有两个方式一个中断,如 io,设备的中断,一个系统调用,其实这也是利用的中断。当操作系统陷入中断时就陷入内核这时,不是在用户态了,也就不受用户的管控,如果这是因为一个长时间的 io,操作系统会将这个线程或者进程调度让出,这是 golang 用户台的调度也就没有机会运行了。对于 network stack 上问题就更加突出,应为使用 golang 大都是网络程序,有着成千上万的网络链接。解决上面的两个 golang 主要使用了两个方式
- 监控 syscall,若是耗时的系统调用使用单独的线程服务
- 使用非阻塞的方式,在 network stack 使用的 epoll/kqueue 非阻塞网络技术
goroutine 之间的数据同步问题
引入 goroutine 时,当两个或者多个 goroutine 需要通讯时,如生产者和消费这样的场景是很常见,当消费者向生产者要数据时,生产者的数据还没有准备好,这是就应该让消费者挂起来,反正亦然。对于者问题,golang 使用 channel 来解决