理解golang调度之一 :操做系统调度

前言

这一部分有三篇文章,主要是讲解go调度器的一些内容html

三篇文章分别是:golang

简介

golang调度器的设计行为可以使你的多线程go程序更有效率、性能更好,这要归功于golang调度器对于操做系统调度器的支持。对于一个golang开发者来讲,同时深入理解操做系统调度和golang调度器工做原理,可以让你的golang程序设计和开发走到正确道路上。web

操做系统调度器

操做系统调度器十分复杂,它必需要考虑到它所运行的底层硬件层级结构,包括但不限于处理器数和内核数,cpu cache和NUMA(非统一内存访问架构)。若是不考虑这些因素,调度器就没办法尽量有效的工做。好事情是,你没必要深刻理解这些底层内容也能开发出好的程序。算法

你的程序其实就是一堆按顺序执行的机器指令。为了能让其正常干活,操做系统使用了线程的概念。线程会处理并执行分配给它的一系列的机器指令。线程会一直执行这些机器指令,直到没有指令再去给线程执行了。这也是为何把线程称做"a path of execution"。数据库

你运行的每一个程序都会建立一个进程而且每一个进程都会有一个初始线程。线程可以建立更多的线程。这些不一样的线程独立运行而且调度行为是线程级别作决定的,而不是在进程级别。线程可以并发的执行(并发是说一个单独内核上每一个线程会轮询占用一段cpu时间),而不是并行执行(在不一样内核上同时执行)。线程同时会维持它本身的状态,而且可以在本地安全、独立地执行他本身的指令。这也说明了为何线程是cpu调度的最小单位。编程

操做系统调度器,负责确保在有线程可以运行的时候内核不会空闲下来。它必需要制造出这样一种错觉——全部可以跑的线程此时都在同时执行。为了制造这种错觉,调度器须要优先执行高优先级的线程,可是它也必须保证低优先级的线程不会饿死(永远没有执行机会)。调度器也必须经过作出更聪明的决定将调度延时尽量的压倒最少,。c#

幸运的是计算机发展了这么长时间,许多算法的应用使得调度器更加高效。为了可以理解上面的事情,须要解释一些重要的概念。缓存

执行指令

程序计数器(PC),有时候也叫作指令指针(IP),可以让你找到下一个要执行的指令在哪。大部分的处理器里,PC指向下一个指令,而不是当前的指令。安全

若是你曾经注意到go程序的追踪栈,你会注意到这些每一行末尾的16进制数字。例如Listing 1里的+0x39和+0x72bash

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
复制代码

这些数字表明了PC值,也就是从各自函数开始的偏移量。+0x39 PC偏移量表明了程序在还未panic的时候,线程在example方法执行的下一条指令。+0x72 PC偏移量表明若是example函数回到main函数里,main里的下一条指令。重要的是,指向指令的前一个指针告诉了你现正在执行什么指令

看一下致使Listing 1 panic的程序

Listing 2
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表明了PC偏移量,在example函数里也就是距离函数开头57(10进制)bytes的位置。下面的Listing 3里,你能够经过二进制文件看到example函数的objdump。找到最下面的第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 3很好的说明了amd64下面,go线程是如何执行指令序列的。

线程状态

另外一个重要概念就是“线程状态”,线程状态说明了调度器该如何处理此时的线程。线程有三个状态:等待、可运行、执行中。

等待(Waiting):

此时意味着线程中止而且等待被唤醒。可能发生的缘由有,等待硬件(硬盘、网络),操做系统(系统调用) 或者是同步调用(atomic,mutexes)。这些状况是致使性能问题的根源

可运行(Runnable):

此时线程想要占用内核上的cpu时间来执行分配给线程的指令。若是你有许多线程想要cpu时间,线程必需要等一段时间才能取到cpu时间。随着更多线程争用cpu时间,线程分配的cpu时间会更短。这种状况下的调度延时也会形成性能问题。

执行中(Executing):

此时线程已经置于内核中,而且正在执行它的机器指令。应用程序的相关内容正在被处理。这种状态是咱们所但愿的

工做类型

线程有两种工做类型。第一种叫CPU密集型,第二种叫IO密集型

CPU密集型(cpu-bound):

这种工做下,线程永远不会被置换到等待(waiting)状态。这种通常是进行持续性的cpu计算工做。好比计算Pi这种的就是cpu密集型工做

IO密集型(io-bound)

这种工做会让线程进入到等待(waiting)状态。这种状况线程会持续的请求资源(好比网络资源)或者是对操做系统进行系统调用。线程须要访问数据库的状况就是IO密集型工做。同时我会把同步事件(例如mutexes、atomic),这种须要线程等待的状况纳入此类工做。

上下文切换(Context Switch)

若是你的程序运行在Linux、Mac或者是Windows上面,你的调度器则是抢占式的。这意味着一些重要的事情。第1、它意味着调度器不会预先知道此时此刻会运行哪一个线程。线程优先级加上事务(例如接受网络数据)让调度器没法肯定哪一个时间执行哪一个线程。

第2、你永远不能按照历史经验去看,你以前幸运跑出来的代码其实不能保证每次都按你所想去执行。若是你的代码1000次都是按照一样方式执行,你很容易觉得下次也保证按照同样方式执行。若是你的程序须要肯定性的话,你必定要控制线程的同步和编排。

在内核上切换线程的物理行为叫作上下文切换(context switch)。上下文切换发生在这样的状况,调度器从内核换下正在执行的线程,替换上可执行的线程。线程是从运行队列中取出,并设置成执行中(Executing)的状态。从内核上下来的线程会置成可运行状态,或者是等待状态。

上下文切换的代价是昂贵的,由于它须要花时间去交换线程,从内核上拿下来再放上去。上下文切换的延时受到不少因素影响,可是一般状况下,它会有1000--1500纳秒的延时。考虑到硬件上每一个内核上平均每纳秒执行12个指令,一次上下文切换会花费你12k--18k个指令延时。这本质上来讲,你的程序在上下文切换过程当中失去了执行大量指令的机会。

若是你的程序集中于IO密集型(cpu-bound)的工做,上下文切换会相对有利。一旦一个线程进入到等待(waiting)状态。另外一个处于可运行(Runnable)状态的线程会取代它的位置。这会使得内核始终是处于工做状态。这是调度器调度的一个重要方面,若是有事作(有线程处于可运行状态)就不容许内核闲下来。

若是你的程序集中于cpu密集型(cpu-bound)的工做,那么上下文切换会是性能的噩梦。由于线程要一直作事情,上下文切换会中止正在处理的工做。这种状况和IO密集型形工做成鲜明对比。

少便是多(Less Is More)

在早期时候,处理器仅仅只有一个内核,调度器并不十分复杂。由于你有一个单独的处理器,一个单独的内核,因此任什么时候间只能跑一个线程。方法是定义一个调度期(scheduler period) 而后尝试在一个调度期内去执行全部可运行(Runnable)的线程。这样没问题:把调度期按照须要执行的线程数量去分每一小段。

举例,若是你定义了你的调度期是10ms 而且你有两个线程,那每一个线程会分到5ms。5个线程的话,每一个线程就是2ms。可是若是你有100个线程会怎么样?每一个线程时间片是10us(微秒), 这就会没法工做,由于你须要大量时间去进行上下文切换(context switches)。

在最后一个场景,若是最小的时间切片是2ms 而且你有100个线程,调度期须要增长到2000ms也就是2s。要是若是你有1000个线程呢,如今调度期须要20s,也就是你要花20s才能跑完全部的线程若是每一个线程都能跑满它的时间切片。

上面发生的是显而易见的事情。调度器在作决定的时候还要考虑到更多的因素。你控制了应用程序里的线程数量,当有更多线程的时候,而且是IO密集(IO-Bound)工做,就会有更多的混乱和不肯定行为发生,调度和执行就花费更多时间。

这也是为何说游戏规则就是“少便是多(Less is More)”,可运行线程越少意味着调度时间越少,线程获得的时间越多。更多的线程就意味着每一个线程得到的时间就越少,分配的时间内作的事情也就越少。

找到平衡点

你须要在内核数量和你的线程数量二者间,找到一个可以让你的程序得到最好吞吐量的平衡点。想要去找到这样的平衡点,线程池是一个很好的选择。

使用go以前,做者使用C++和c#在NT上。在那个操做系统里,使用IOCP(IO Completion Ports) 线程池对于写多线程软件十分重要。做为一个工程师,你须要计算出你要用多少个线程池,以及每一个线程池的最大线程数,从而在肯定了内核数的系统里最大化你的吞吐量。

当写web服务时候,你须要和数据库通讯。3是一个魔法数字,每一个内核设置3个线程彷佛在NT上有最好的吞吐量。换句话说,每内核3线程可以最小化上下文切换的延时,最大化在内核上的执行时间。当你建立一个IOPC线程池,我知道我能够在主机上设置每一个内核1--3个线程数量。

若是我使用2个线程每一个内核,完成工做的时间会变长,由于原本须要有工做去作的内核会有空闲时间。若是我每一个内核用4个线程,也会花更长时间,由于我须要花更多时间进行上下文切换。平衡数字3,无论是什么缘由,彷佛在NT上都是一个神奇的数字。

当你的服务须要处理许多不一样类型的工做会如何呢。那会有不一样而且不一致的延迟。可能它会产生许多须要去处理的不一样系统级别的事件。这种状况,你不可能去找到一个魔法数字,能让你在全部时间全部不一样的工做状况下都有优秀的性能。当你使用线程池的时候,找到一个合适的配置会十分复杂。

缓存行(Cache Lines)

从主存访问数据有很高的延迟(大概100~300个时钟周期),所以处理器和内核会有缓存,可以让线程访问到更近的数据。从缓存访问数据的延迟很是低(大概3~40个时钟周期) 根据不一样的缓存访问方式。衡量性能的一个方面就是,处理器经过减小数据访问延时而获取数据的效率。编写多线程的应用程序须要考虑到机器的缓存系统。

处理器和主存使用缓存行(cache lines)进行数据交换。一个缓存行是一个64 byte的内存块,它在内存和缓存系统之间进行交换。每一个内核会分配它本身须要的cache副本,也就是意味着硬件使用的是值语义(区别于指针语义)。这也是为何多线程中的内存突变会形成严重的性能问题。

当多线程并行运行,正在访问相同数据,甚至是相邻的数据单元,他们会访问相同的缓存行。任何内核上运行的任何线程可以从相同的缓存行获取各自的拷贝。

若是给一个内核,他上面的线程修改它的cache行副本,而后会经过硬件的神奇操做,同一cache行的全部其余副本都会被标记为无效。当一个线程尝试读写无效cache行,须要从新访问主存去获取新的cache行副本(大约要100~300个时钟周期)

也许在2核的处理器上这不是大问题,可是若是是一个32核处理器并行跑32个线程,而且同时访问和修改一个相同的cache行呢?因为处理器处处理器之间的通讯延迟增长,状况会更糟。程序内存会发生颠簸,而且性能不好,并且极可能你也不知道为何会这样。

这就是cache的一致性问题( cache-coherency problem )或者是说是共享失败(false sharing)。当编写改变共享状态的多线程应用时,cache系统必需要考虑在内。

调度决策场景

想象一下,我已经要求你根据我给你的高级信息编写OS调度程序。 想一想你必须考虑的这种状况。

你启动了你的应用程序,主线程已经在core1上启动。当线程正在执行,他须要去检索cache行由于须要访问数据。这个Thread如今决定为了某些并发处理建立一个新的线程。那么问题来了。

一旦线程建立好,而且准备要运行了,那么调度器是否应该:

  1. 从core1上换下main主线程?这样作有助于提升性能,由于这个新线程须要的相同数据被缓存的可能性很是大。可是主线程并无获得它的所有时间片。
  2. 线程是否要一直等待直到main主线程完成它的时间后core1可用?线程并无在运行,可是一旦运行它获取数据的延时将会消除。
  3. 线程等待下一个可用的core?这意味着所选择的core的cache行会经历冲刷、检索、复制,从而致使延迟。可是线程会更快的启动,而且主线程会完成它的时间片。

这些都是调度器在作决定时须要考虑到的一些有趣问题。我能告诉你的事情就是,若是有空闲的内核,它将会被使用。你但愿当线程可以运行的时候它就会运行。

结论

这是第一部分,为你提供了一些多线程编程时要考虑到线程和OS调度器的一些理解。这同时也是golang调度器须要考虑的事情。下面一部分,我会描述Go调度器的一些相关知识。




原文连接:www.ardanlabs.com/blog/2018/0…
相关文章
相关标签/搜索