设计一个按优先数调度算法实现处理器调度的程序_Go 中的调度 I

Go 中的调度

https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html[1]

如果您计算机专业英文阅读能力不错,建议您阅读原文。

这是一个由三部分组成的系列文章中的第一篇,它将提供对Go调度程序背后的机制和语义的理解。本篇着重于操作系统调度程序。

三部分系列的索引:

  • Go 中的调度 : 第一部分 - 操作系统调度[1]

  • Go 中的调度 : 第二部分 - Go 调度[2]

  • Go 中的调度 : 第一部分 - 并发[3]

简介

Go 调度器的设计与表现使你的多线程 Go 程序变得更加高效与完美。这得益于 Go 调度对操作系统(OS)调度器的机械同情。但是,如果你的多线程 Go 程序的设计和表现对调度程序的工作方式没有机械上的同情,那么这就不重要了。在正确设计多线程软件上,对操作系统调度和 Go 调度如何运行的一般性和代表性的理解非常重要。

本篇文章将重点讨论调度程序的高级机制和语义。我会提供足够的详细信息,以使您可视化工作方式,以便做出更好的工程决策。即使需要为多线程应用程序做出许多工程决策,但其机制和语义仍是您所需基础知识的关键部分。

OS 调度

操作系统调度在软件中是挺复杂的。其必须考虑所运行的硬件的布局与体系。这包括但不限于多处理器,多核,CPU 缓存以及 NUMA[4]。没有这些知识,调度器不会太高效。所幸无需深挖这些问题你仍然可以形成 OS 调度器如何运转的心里模型。

你的程序实际上只是一系列需要顺序的执行的机器指令。为此操作系统引入了线程的概念。线程的工作是解释并顺序的执行其所分配的指令集。线程一直运行到没有更多指令可执行为止。因此,我称呼线程为“执行路径”。

你所运行的每一个程序会创建一个进程并且每个进程都一个初始化的线程。线程能够创建更多的线程。所有的这些线程都是相互独立运行的,并且调度策略是在线程级别而非进程级别制定的。线程可以并发(每个线程占用一个独立的核)或者并行(每一个在不同的核上同时运行)运行。线程还维护着它们自己的状态,以让指令能够安全,隔离,独立的运行。

如果有线程可以执行,OS 调度器负责确保核不空闲。其也创造出一种同一时间所有可执行的线程都在运行的假象。在创造这个假象的过程中,调度器需要将高优先级的线程运行低优先级的线程之前。但是,低优先级的线程不能在执行时间上饿死。调度器也需要通过快速与明智决策来尽可能的最小化调度等待时间。

实现这一目标的算法有很多,很幸运有数十年的工作与行业经验可以依靠。为了更好的理解这些,最好定义并描述一些比较重要的概念。

指令执行

程序计数器[5](PC),有时候也叫指令计数器(IP),让线程记住下条待执行指定的追踪。在大多数处理器中,PC 指向下条指令而非当前指令。

图 1

6610eeeb650e024c5ebd3c4f59c56c3f.png

https://www.slideshare.net/JohnCutajar/assembly-language-8086-intermediate[6]

如果你曾经看过 go 程序的栈追踪,你可能留意过这些每行结尾的不大的十六进制数。在清单1中找下+0x39 和 +0x72。 

代码清单1

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

十六进制数字+ 0x39表示示例函数内一条指令的PC偏移量,该偏移量比该函数的起始指令低57(以10为基)字节。在下面的清单3中,您可以从二进制文件中看到示例函数的objdump。找到底部列出的第12条指令。请注意,该指令上方的代码行是对panic的调用。

请看下面清单2中的程序,它导致了清单1中的堆栈跟踪。

清单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

记住:PC是下条指令而非当前指令。清单3是基于 amd64 指令的一个很好的示例,该 Go 程序的线程负责按顺序执行。

线程状态

另一个重要的概念是线程状态,它决定了调度程序在线程中扮演的角色。线程可能是这三种状态之一:等待,可运行,执行中。

等待:意指线程停止且等待某事以继续。可能的原因如:等待硬件(磁盘,网络),操作系统(系统调用)或同步调用(原子操作,互斥锁)。这些延迟[7]类型是低性能的根本原因。

可运行:意旨线程需要CPU时间来执行其相关机器指令。如果有大量线程需要CPU时间,那么这些线程需等待更久才能获取CPU时间。而且,随着更多线程争夺时间,任何给定线程获得的时间都将缩短。这种调度延迟也是引起低性能的原因之一。

执行中:意指线程已经在核上运行其机器指令。相关应用的工作即将完成。这是都希望有的。

任务类型

线程可以做的任务类型有两种,一种是计算密集型(CPU-Bound),一种是 IO 密集型(IO-Bound)。

计算密集型:该工作永远不会造成线程可能处于等待状态的情况。这是不断进行计算的工作。计算 π 的第 N 位的线程是计算密集型的。

IO密集型:该工作会使线程进入等待状态。这项工作包括请求网络资源或在操作系统中进行系统调用。需要访问数据库的线程是IO密集型的。I 包括同步(互斥量,原子操作),这些事件会导致线程等待作为该类别的一部分。

上下文切换

如果你是在 Linux,MacOS 或 Windows 上运行操作系统的,那么你正在运行一个抢占式调度的系统。这意味着一些重要的事情。首先,这意味着给定任意时间点选择要运行的线程时,调度器无法预测。线程优先级与事件(例如在网络上接收数据)使调度程序选择做什么与何时执行无法确定。

其次,这意味着你绝不能基于自己有幸经历但无法保证每次都能发生的某些感知行为来编写代码。自己思考很容易,因为我曾经看到过这以1000次相同的的方式发生,这是有保证行为。如果你需要在你的应用中做决定,你需要控制线程的同步与编排。

在一个核上切换线程的物理行为叫做上下文切换。当调度器将一个正在运行的线程从核上取下并用一个可运行状态的线程替换时,发生上下文切换。从可运行队列中选中的线程被移动到一个可执行状态中。所选的线程也可以放回可运行状态(如果其仍能运行的话),或者放到等待状态(如果其因为一个IO密集型请求而被替换的话)。

因为在内核上交换线程需要花费时间,上下文切换被认为是非常昂贵的。上下文切换期间的延迟等待量取决于不同的因素,所以花费大约1000到1500纳秒[8]是有道理的。考虑到硬件应该能够合理的执行(平均)12条指令每纳秒[9]每核,一个上下文切换可花费大约12k到18k的指令延迟。总之,您的程序在上下文切换期间损失了执行大量指令的能力。

如果你的程序专注于于IO密集型任务,那么上下文切换将会是有利的。一旦一个线程切换到等待状态,那么另一个可执行状态的线程将会替换它。这会让内核一直运行。这也是调度的最重要方面之一。如果有任务(处于可运行状态的线程)需要完成时,不要让内核空闲。

如果你的程序专注于CPU密集型任务,那么上下文切换将会是一个性能噩梦。因为线程总是有任务要做,所以上下文切换会将正在执行的任务停止。这种情况与IO密集型的工作负载形成鲜明的对比。

少即是多

早期当处理器只有一个核时,调度并不太复杂。因为你只有一个核的处理器,那么在任何给定的时间只有一个线程可执行。其想法是定义一个调度周期[10],并试着在该调度周期时间段内执行所有可执行的线程。没问题:将调度周期除以需要执行的线程数。

例如,如果定义调度周期为1000毫秒(1秒),然后你有10个线程,那么每个线程有100毫秒的执行时间。如果你有100个线程,则每个线程只获得10毫秒的执行时间。但是,要是你有1000个线程该怎么办?给每个线程1毫秒的执行时间片是没用的,因为花费在上下文切换上的时间占比将会与应用运行所花费时间密切相关。

你需要为给定时间片应该多短设置限制。在最后一个场景中,如果最小时间片是10毫秒,并且有1000个线程,那么调度周期需要增加到10000毫秒(10秒)。要是有10000个线程呢,现在你需要一个100000毫秒(100秒)的调度周期。在此简单示例中,如果每个线程用了其整个的时间片,那么在10000个线程,最小时间片10毫秒的情况下,所有线程运行一次需要花费100秒。

留意下,这是非常简单的世界观。当指定调度策略[11]时,调度器有很多东西需要考虑和处理的。你在你的应用中控制你所使用的线程数。当有更多线程要考虑,以及IO密集型任务出现时,将会变得更加复杂与不确定行为。调度与执行需要从长计议。

此即为“少即是多”的游戏规则。处于可运行状态的线程越少则意味着调度开销越少以及每个线程所获取的时间片越多。处于可运行状态的线程越多意味着每个线程所获取的时间片越少。这也意味着随着时间的推移你完成的工作也越少。

找到平衡

你需要在内核数和你应用获得最大吞吐量时所需线程数之间找到平衡。当提及这个平衡的管理时,线程池是非常好的答案。在第二部分中我会说明对go来说这不再必要。我认为这是go做的非常漂亮的一件事情:让多线程应用开发更容易。

在先前的go编程中,我在NT系统上用C++和C#写代码。在NT系统上,对于写多线程软件来说,IOCP(IO Completion Ports IO完成端口)线程池至关重要。作为工程师,你需要确定所需的线程池数以及任意给定线程池的最大线程数,以便最大程度地提高所给定内核数的吞吐量。

当写和数据库通信的web服务时,每个内核3个线程的最佳数量似乎总是在NT上提供最佳吞吐量。换句话说,每个内核3个线程可以最大程度地减少上下文切换的延迟成本,同时可以最大程度地延长内核的执行时间。创建IOCP线程池时,我知道对于主机上标识的每个内核,最少要有1个线程,最多要有3个线程。

如果每个内核使用2个线程,那么完成所有工作将花费更长的时间,因为当完成工作时仍有空闲时间。如果每个内核使用4个线程,同样需要花费更长时间,因为在上下文切换中有更多的延迟。无论出于何种原因,每个内核3个线程的平衡似乎始终是NT系统上的最佳数量。

要是你的服务正在执行许多不同类型的工作该咋办?这可能会产生不同且不一致的延迟。也许它还会产生许多需要处理的不同的系统级别的事件。可能在找不到在所有不通工作负载下都始终有效的最佳数字。当涉及到使用线程池来调整服务性能时,找到正确一致性配置会变得非常复杂。

缓存总线

从主内存中访问数据是如此高的延迟(大约100到300时钟周期)以至于处理器和内核需要局部缓存将数据与需要数据的硬件线程紧密联系。从缓存中访问数据有更低的延迟(大约3到40个时钟周期),这依赖于所访问的缓存。今天,性能的一方面因素是你可以如何高效的获取数据到处理器中来降低这些数据访问的延迟。写可变状态的多线程应用需要考虑缓存系统机制。

图2

cefb9f49449abe3253eda9966725968d.png

处理器和内存使用缓存总线[12]交换数据。一条缓存线是一个用于在内存和缓存系统之间进行交换的64字节的内存块。每个内核都获得了其所需缓存线的副本,这意味着硬件使用价值语义[13]。这就是为什么多线程应用程序中的内存突变会造成性能方面的噩梦。

当并行运行的多个线程访问同样的数据值或者甚至数据值相邻时,这些线程会访问相同的缓存线,运行在任何内核上的任何线程将会获得这这条缓存线的它自己的副本。

图3

ca9d2b19c109c0ffe9b3393d41facf8e.png

如果一个给定内核上的线程改变了该缓存线的副本,那么通过硬件逻辑,所有相同缓存线的其他副本都会被标记为脏数据。当一个线程尝试读或写一个脏缓存线时,需要主内存访问(大约100到300时钟周期)来获取一个新的缓存线副本。

对于2核的处理器来说这可能并不是大问题,但是要是32核的处理器并行运行32个线程且都访问和修改同一个缓存线的数据呢?要是2个处理器16个核的系统呢?这会变得很糟糕,因为处理器到处理器的通信增加了延迟。应用将会遍历内存,性能会变得糟糕,甚至你都不理解这是为啥。

这被称为缓存一致性问题[14],并且还引入了像错误共享这样的问题。当写可能会修改共享状态的多线程应用时,缓存系统必须考虑这个。

调度策略场景

假设我要求你给予我所给你的高级信息写一个操作系统调度器。思考一下这样一个你必须考虑的场景。记住,当制定一个调度策略时,这是调度器必须考虑的许多有趣的事情之一。

你启动你的应用然后主线程被创建并且在内核1上执行。随着线程执行其指令,因需要数据,缓存总线被检索。现在线程决定创建一个新的线程来做一些并行处理。下面是问题。

一旦线程被创建并且准备运行,需要考虑的有:

  1. 上下文切换会将主线程从内核1上取下吗?这样做有益于性能,因为新线程需要已经缓存好的相同数据的机会非常好。但是主线程并没有得到其整个的时间片。

  2. 线程是否在等待主线程的时间片完成之前等待核心1可用?线程未运行,但一旦启动,将消除获取数据的延迟。

  3. 线程是否等待内核下次可用?这将意味着为所选内核的缓存总线被刷新,回收以及重复,从而引起延迟。然而,线程将会更快启动,并且主线程会完成其时间片。

玩的开心不?当制定调度策略时,这些有意思的问题都是操作系统调度器需要考虑的。幸运的是,我不是制定这些策略的人。我只能告诉你的是,如果有内核空闲,其将会被使用。你希望线程可以在运行时运行。

总结

文章的第一部分提供了有关编写多线程应用程序时必须考虑的相关线程和OS调度程序的见解。这些也是Go调度器要考虑的事情。在下一篇文章中,我会描述Go调度器的语义以及它们和上面这些信息是如何关联的。最后,通过运行几个程序,你会看到所有这些操作。

参考链接:

[1]. https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part1.html

[2]. https://www.ardanlabs.com/blog/2018/12/scheduling-in-go-part2.html

[3]. https://www.ardanlabs.com/blog/2018/12/scheduling-in-go-part3.html

[4]. http://frankdenneman.nl/2016/07/06/introduction-2016-numa-deep-dive-series

[5]. https://en.wikipedia.org/wiki/Program_counter

[6]. https://www.slideshare.net/JohnCutajar/assembly-language-8086-intermediate

[7]. https://en.wikipedia.org/wiki/Latency_(engineering)

[8]. https://eli.thegreenplace.net/2018/measuring-context-switching-and-memory-overheads-for-linux-threads/

[9]. https://www.youtube.com/watch?v=jEG4Qyo_4Bc&feature=youtu.be&t=266

[10]. https://lwn.net/Articles/404993/

[11]. https://blog.acolyer.org/2016/04/26/the-linux-scheduler-a-decade-of-wasted-cores/

[12]. https://www.youtube.com/watch?v=WDIkqP4JbkE

[13]. https://www.ardanlabs.com/blog/2017/06/design-philosophy-on-data-and-semantics.html

[14]. https://youtu.be/WDIkqP4JbkE

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值