go调度: 第一部分-OS调度(操作系统调度)

开场白

这个是三篇博客中的第一篇, 用来提供go调度背后的机制和语法. 这篇博客主要关注操作系统调度.

三篇博客的顺序是:

1) go调度: 第一部分 - 操作系统调度

2) go调度: 第二部分 - go调度器调度

3) go调度: 第三部分 - 并发

介绍

go调度器的设计和行为使得你的多线程go程序更加高效. 这个要感谢go的调度器符合操作系统的调度器的特性. 但是, 如果你的多线程go程序没有按照go调度器想要的方式设计, 那么你的go程序的多线程性能会大打折扣. 通过学习和理解操作系统调度器和go调度器的特性, 你可以设计出更加合理高效的go程序.

这多篇博客主要关注调度器的上层语法和特性. 我将会提供足够的细节让你可以看到go调度器是怎么工作的, 然后你就可以决定如何写出更好的程序. 虽然写多线程程序时, 做出决定会涉及别的很多细节, 但是背后的机理是你最需要掌握的知识.

操作系统调度器

操作系统调度器是操作系统中很复杂的一部分. 调度器会考虑硬件的布局和设置, 这其中包括是否有多个处理器, 处理器是否为多核, CPU缓存, 处理器是否为非均匀访问模型, 当然, 这些只是考虑的其中一部分. 不了解这些信息, 调度器没办法做到足够的高效. 但是对于go程序开发者的你来说, 你不了解这些处理器的特性, 也可以很好地了解操作系统调度器是如何工作的.

你的程序只是一串需要一个接着一个顺序执行的二进制机器码. 为了实现这个效果, 操作系统使用线程的概念. 顺序执行一系列指令是线程的工作. 线程会一致执行下去直到没有指令可以执行. 这个是线程名字的来源, 一个执行的线路”.

当你启动程序时, 会创建一个进程, 每个进程会有一个初始的线程. 线程有能力创建更多的线程. 这些线程的运行是独立的, 操作系统在线程级进行调度, 而不是进程级. 线程可以并发地执行(轮流在一个核上执行), 或者并行地执行(同时在不同的核上执行). 线程维持着自己的状态从而可以安全, 本地, 独立地执行自己的指令.

操作系统调度器用来保证如果当前有线程需要执行, 就会使用核执行这些线程. 而且操作系统会产生所有线程都在并行执行的错觉. 同时, 调度器需要优先执行优先级比较高的线程, 并且, 优先级比较低的线程不能被饿死(一直得不到执行). 操作系统调度器不能花费太多的决策时间, 免得影响程序的正常运行.

设计出很好的调度器算法是件很困难的事, 不过经过数十年的努力, 和大量的行业经验作为参考, 现在的操作系统调度器已经很棒了. 为了更好地理解这些, 我们需要定义一些概念:

执行指令

程序计数器(PC), 有时候也会叫做指令指针(IP), 用来让Thread能够跟踪下一条执行的指令. 在绝大多数处理器中, PC寄存器指向下一条需要执行的指令, 而不是当前执行的指令.

如果你看过go程序的堆栈, 你可能注意到每一行后面的那些小的十六进制数字. 例如Listing 1中的+0x39+0x72.

Listing 1

goroutine 1 [running]:
   main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
       stack_trace/example1/example1.go:13 +0x39                 <- LOOK HERE
   main.main()
       stack_trace/example1/example1.go:8 +0x72                  <- LOOK HERE

这些数字表示下一条需要执行的指令与函数开头指令的程序计数的差值. 如果程序没有崩溃, 那么example函数中下一条执行的指令与example函数开头的程序计数的差值是+0x39. +0x72main函数中, 如果命令执行返回main函数, 下一条执行的指令与main函数开头的程序计数差值. 更重要的是, 程序计数器前面的那条指令就是当前正在执行的指令.

看一下导致Listing 1中的堆栈的Listing 2的程序代码

Listing 2

https://github.com/ardanlabs/gotraining/blob/master/topics/go/profiling/stack_trace/example1/example1.go

07 func main() {
08     example(make([]string, 2, 4), "hello", 10)
09 }

12 func example(slice []string, str string, i int) {
13    panic("Want stack trace")
14 }

+0x39使用10进制表示是57, 也就是说, example函数的开始算起, 57个字节. 使用objdump来对二进制程序进行处理, 获取example函数的汇编表示. 查看其中的第12条指令, 也就是最后一条指令, 注意这条指令的上面一条指令就是对panic的调用.

Listing 3

$ go tool objdump -S -s "main.example" ./example1
TEXT main.example(SB) stack_trace/example1/example1.go
func example(slice []string, str string, i int) {
  0x104dfa0		65488b0c2530000000	MOVQ GS:0x30, CX
  0x104dfa9		483b6110		CMPQ 0x10(CX), SP
  0x104dfad		762c			JBE 0x104dfdb
  0x104dfaf		4883ec18		SUBQ $0x18, SP
  0x104dfb3		48896c2410		MOVQ BP, 0x10(SP)
  0x104dfb8		488d6c2410		LEAQ 0x10(SP), BP
	panic("Want stack trace")
  0x104dfbd		488d059ca20000	LEAQ runtime.types+41504(SB), AX
  0x104dfc4		48890424		MOVQ AX, 0(SP)
  0x104dfc8		488d05a1870200	LEAQ main.statictmp_0(SB), AX
  0x104dfcf		4889442408		MOVQ AX, 0x8(SP)
  0x104dfd4		e8c735fdff		CALL runtime.gopanic(SB)
  0x104dfd9		0f0b			UD2              <--- LOOK HERE PC(+0x39)

注意: PC指向下一条指令, 而不是当前执行的指令. Listing 3amd64指令集说明go程序的线程负责顺序执行指令的很好的例子.

线程状态

另一个重要的概念是线程状态, 这个可以用来说明线程调度器在线程运行过程中所起的作用. 线程可以处于三种状态: 等待状态, 可执行状态, 和正在执行状态.

等待状态: 这个意味着线程停下来, 等待能够继续执行的条件. 可能的等待原因包括: (1) 等待硬件(硬盘, 网络), 操作系统(系统调用), 或者同步操作(原子操作, ). 这些类型的延迟是性能糟糕的一个根本原因.

可执行状态: 这个状态表示线程在等待处理器时间来执行指令. 如果你有很多个线程在等待处理器时间, 那么线程可能需要等待比较久的时间才能获得处理器时间. 另外, 如果等待处理器时间的线程很多的话, 每个线程所能够获取的时间也会比较短. 这类型的调度延迟也可以成为性能糟糕的一个原因.

执行状态: 这个状态表示线程获取了处理器时间, 并且正在执行指令. 应用的工作正在被执行, 这是每个应用都想要处于的状态.

工作类型

线程可以做两种类型的工作, 分别是 CPU-Bound(CPU密集型) IO-Bound(IO密集型).

CPU-Bound: 这种类型的工作不会使线程处于等待状态, 这类工作不断进行着计算的任务. 用来计算Pi的第N位的工作是CPU密集型的.

IO-Bound: 这种类型的工作, 会使得线程进入等待的状态, 这些工作包括通过网络获取资源, 使用系统调用. 需要访问数据库的线程一般是IO密集型的. 在我看来, 使用同步事件(, 原子变量), 从而导致线程处于等待状态的线程也是IO密集型的.

上下文切换

Linux, MacWindows都运行一个抢占式的调度器. 这个包含以下信息:

第一, 这意味着, 我们不能确定在某个时间调度器会选择哪个线程进行执行. 线程优先级加上事件(例如网络上接收到数据) 使得我们不可能确定调度器会做什么, 以及什么时候会做.

第二, 你不能根据一些观察的行为, 而不是保证的行为去写代码. 例如, 我发现一件事在1000次执行中都是相同的结果, 那么我就认为这个是保证的. 这种想法是不合理的. 如果你想写出正确的多线程应用, 那么你应该使用线程的特性以及各种同步机制.

在一个处理核上由执行一个线程改为执行另外一个线程, 这个被成为上下文切换. 上下文切换发生在, 调度器将一个正在执行的线程改为可执行的线程状态, 然后将一个可执行的线程切换成正在执行的线程状态. 这个正在执行的线程, 之后可以被切换到可执行的状态(如果这个线程在上下文切换时, 还是可以继续执行), 或者进入等待状态(如果它触发了一个IO密集型的请求).

上下文切换被视为一个昂贵的操作, 因为它要花费时间让一个线程停在在一个核上执行, 然后让另外一个线程在这个核上执行. 这个延迟时间依赖于不同的因素, 但是一般需要10001500纳秒. 假设你的硬件单核每纳秒可以执行12条指令(平均来说), 那么一个上下文切换将会产生12K18K条指令的延迟. 大体上, 在一个上下文切换的过程中, 你的程序将会失去执行大量指令的机会.

如果你的程序是IO密集型的, 那么上下文切换将会带来好处. 如果一个线程进入等待状态, 那么其他处于可执行状态的线程就可以使用CPU核进行处理.  这个运行CPU核总是在工作. 这个是调度器最重要的方面, 如果当前有工作可以做(有线程处于可执行状态), 那么就不会让核空闲.

如果你的程序是CPU密集型的, 那么上下文切换将会对处理性能产生很大影响. 每次上下文切换都占用了本来可以出来处理指令的时间. 这个与IO密集型的应用是完全不同的.

少意味着多

在处理器只有单核的时期, 操作系统调度器还相对简单. 因为你只有一个单核的处理器, 任何时间只能有一个线程在执行. 主要做的是定义一个调度周期, 然后在这个调度周期中, 运行所有的可执行线程. 做法很简单, 拿调度周期除以可执行的线程数.

举个例子, 如果你定义调度周期的时间为10ms(毫秒), 然后你有2个线程, 那么每个线程得到5ms的运行时间. 如果你有5个线程, 那么每个线程得到2ms的运行时间. 但是, 如果你有1000个线程怎么办? 给每个线程10μs(微妙)的运行时间是不能够正常工作的, 因为你将会花费太多时间在上下文切换上.

根据上面的说明, 你需要限制每个时间片的最多时间. 在上面的场景中, 如果最多时间片是2ms, 你有1000个线程, 那么调度周期需要增长到2000ms或者2s. 但是, 如果有20000个线程, 那么你需要的调度周期变成了20s. 如果每个可执行线程都使用它的所有时间片的话, 这个将会花费20s来使所有可以执行的线程都运行一次.

注意到, 这个是简化的调度世界模型. 这里面还有很多别的因素需要考虑, 从而制定比较合理的调度决策. 你需要限制你程序中使用的线程数, 如果线程数太多的话, 那么你的程序将会变成IO密集型的, 这里面将会有很多不确定的行为. 调度花费的时间变得很多, 从而影响程序的正常执行.

这个就是所说的少意味着多”. 比较少的线程处于可执行状态, 可以减少调度时间, 从而实际执行工作的时间变得更长. 相对的, 如果处于可执行状态的线程比较多的话, 就会花费大量的调度时间, 从而实际执行工作的时间就会变得更短.

查找平衡

这里有一个你需要找寻的核数和线程数的平衡, 在这个平衡点, 你将会获得最好的吞吐量. 当涉及管理这个平衡时, 线程池是一个很好的答案. 在第二部分, 我将向你展示, go, 线程池是不必要的, go中实现的这点, 可以简化多线程应用的开发.

go语言编程之前, 我在NT平台使用C++C#编程. 在这种操作系统平台, 使用IOCP(IO完成端口)线程池对于实现高性能的多线程应用是至关重要的. 作为一个程序员, 你需要决定使用多少个线程池, 每个线程池中有多少个线程, 从而基于给定的核数来最大化你的程序的吞吐量.

当写与数据库交互的网络服务时, 每个核对应三个线程似乎很神奇地总是可以在NT平台中获取最好的吞吐量. 换句话说, 每个核三个线程最小化了上下文切换的延迟消耗, 同时最大化了在每个核上的执行时间. 当创建IOCP线程池时, 我知道在给定机器上, 从最小1个线程每个核到最大3个线程每个核来进行测试.

如果我使用2个线程每个核, 完成工作所需的时间将会更多, 因为有些时间核是空闲的. 当我使用4个线程每个核时, 我花费了更多的时间在上下文切换上. 3个线程每核在NT平台上是一个很神奇的数字.

如果你的服务在做很多不同的工作, 怎么办? 这个会导致不同的不一致的延迟. 也许它会创建很多待处理的系统级别的事件. 也许找到一个神奇的数字来处理不同的负载是不可能的. 当使用线程池来调整服务的性能, 这将会是件很复杂的事, 很难找到合适的配置.

Cache Lines

从主内存中访问数据有很大的延迟(大约100200个时钟周期), 处理器和核会有本地缓存来使硬件线程可以更快访问. 从缓存中访问数据花费的时间更少, 根据访问的缓存类型, 大约需要340个时钟周期. 现在, 性能的一方面考虑就是如何让处理器更高效的得到数据, 从而减少这些数据访问的延迟. 写会修改状态的多线程程序时, 需要考虑缓存系统的机制.

处理器和主内存之间的数据交互通过cache lines来完成. 每个cache line是一个用于主内存和缓存系统交互的64字节的内存块. 每个核会获取自己需要的cache line的拷贝, 这里说明硬件中是使用值语义的. 这个就是为什么在多线程应用中对内存修改会严重影响性能.

当多个并发执行的线程访问同样的数据, 甚至相邻的数据时, 它们将会访问同一个cache line中的数据. 运行在任何核上的线程将会得到相同的cache line的一个拷贝.

如果一个核上的一个线程修改了cache line在自己核上的拷贝, 然后通过神奇的硬件, 所有核上的相同cache line都会标识为已修改的. 当一个线程想要读写一个已修改的cache line, 这个核需要访问主内存(大约100300个时钟周期)来获取cache line的一个新的拷贝.

在双核处理器中, 这个也许不是个大问题, 但是如果32核处理器上运行32个访问并修改同一个cache line上的数据时, 这个就是个比较严重的问题了. 如果是两个物理处理器, 每个处理器有16个核, 这个问题将会更加严重, 因为处理器之间的通信的延迟更高. 应用将会因为内存访问的缘故而严重影响性能, 但是, 你很有可能不能理解为什么会出现这种问题.

这个被称作缓存一致性问题, 并且引申出了虚假共享. 当写会修改共享状态的多线程应用的时候, 需要考虑一下缓存系统.

调度决定场景

假设, 我要求你根据我提供给你的信息来写一个操作系统调度器. 假设这是你必须考虑的一个场景. 记住, 这是调度器在做调度决定时必须考虑的一个有趣的事.

你启动了一个程序, 并且主线程被创建, 运行在核1. 当线程开始执行它的指令, cache line被获取, 然后得到了数据. 这时, 这个线程决定创建一个新的线程用于同步处理. 现在就有下面的问题.

当一个线程被创建, 并且开始执行, 调度器应该:

  1. 将主线程上下文切换, 让主线程暂时不使用核1?这么做确实可以提升性能, 因为新线程需要的相同数据已经被很好的缓存. 但是这样主线程就不能够使用它的完全时间片.
  2. 新线程等待主线程使用完它的时间片? 这个新线程就需要等待主线程完结, 从而有可用的数据进行处理.
  3. 新线程等待可用的核? 这个就意味着对应的核的cache line数据需要被刷新到内存, 然后获取需要的数据, 这部分数据会出现重复, 造成延迟. 然而, 这样新的线程可以更快地启动, 同时主线程可以完成它的时间片.

有意思吗? 操作系统调度器需要考虑很多这种有意思的问题来做调度决策. 幸运的是, 大多数人不需要考虑这个决策. 我可以告诉你们的就是, 如果当前有空闲的核, 那么它会被使用, 因为你想可以执行的线程得到执行.

结论

这个博客的第一部分告诉你在写多线程应用时, 需要考虑的有关线程和操作系统调度器的知识. go调度器自然考虑了这些问题. 在博客的第二部分, 我将描述go调度器的特性, 以及它们如何和这些信息相关联. 在最后, 通过运行一系列的程序, 你将看到所有的这些行为.

原文地址: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html

猜你喜欢

转载自www.cnblogs.com/albizzia/p/10908935.html