rsj217

rsj217 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织编辑
编辑

Python & Go & Elixir

个人动态

rsj217 赞了文章 · 2018-12-21

Golang - 调度剖析【第一部分】

简介

首先,Golang 调度器的设计和实现让我们的 Go 程序在多线程执行时效率更高,性能更好。这要归功于 Go 调度器与操作系统(OS)调度器的协同合作。不过在本篇文章中,多线程 Go 程序在设计和实现上是否与调度器的工作原理完全契合不是重点。重要的是对系统调度器和 Go 调度器,它们是如何正确地设计多线程程序,有一个全面且深入的理解。

本章多数内容将侧重于讨论调度器的高级机制和语义。我将展示一些细节,让你可以通过图像来理解它们是如何工作的,可以让你在写代码时做出更好的决策。因为原理和语义是必备的基础知识中的关键。

系统调度

操作系统调度器是一个复杂的程序。它们要考虑到运行时的硬件设计和设置,其中包括但不限于多处理器核心、CPU 缓存和 NUMA,只有考虑全面,调度器才能做到尽可能地高效。值得高兴的是,你不需要深入研究这些问题,就可以大致上了解操作系统调度器是如何工作的。

你的代码会被翻译成一系列机器指令,然后依次执行。为了实现这一点,操作系统使用线程(Thread)的概念。线程负责顺序执行分配给它的指令。一直执行到没有指令为止。这就是我将线程称为“执行流”的原因。

你运行的每个程序都会创建一个进程,每个进程都有一个初始线程。而后线程可以创建更多的线程。每个线程互相独立地运行着,调度是在线程级别而不是在进程级别做出的。线程可以并发运行(每个线程在单个内核上轮流运行),也可以并行运行(每个线程在不同的内核上同时运行)。线程还维护自己的状态,以便安全、本地和独立地执行它们的指令。

如果有线程可以执行,操作系统调度器就会调度它到空闲的 CPU 核心上去执行,保证 CPU 不闲着。它还必须模拟一个假象,即所有可以执行的线程都在同时地执行着。在这个过程中,调度器还会根据优先级不同选择线程执行的先后顺序,高优先级的先执行,低优先级的后执行。当然,低优先级的线程也不会被饿着。调度器还需要通过快速而明智的决策尽可能减少调度延迟。

为了实现这一目标,算法在其中做了很多工作,且幸运的是,这个领域已经积累了几十年经验。为了我们能更好地理解这一切,接下来我们来看几个重要的概念。

执行指令

程序计数器(PC),有时称为指令指针(IP),线程利用它来跟踪下一个要执行的指令。在大多数处理器中,PC指向的是下一条指令,而不是当前指令。
图片描述
如果你之前看过 Go 程序的堆栈跟踪,那么你可能已经注意到了每行末尾的这些十六进制数字。如下:

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 值与相应函数顶部的偏移量。+0x39PC 偏移量表示在程序没中断的情况下,线程即将执行的下一条指令。如果控制权回到主函数中,则主函数中的下一条指令是0+x72PC 偏移量。更重要的是,指针前面的指令是当前正在执行的指令。

下面是对应的代码
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表示示例函数内的一条指令的 PC 偏移量,该指令位于函数的起始指令后面第57条(10进制)。接下来,我们用 objdump 来看一下汇编指令。找到第57条指令,注意,runtime.gopanic那一行。

$ 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(+0x39)

记住: PC 是下一个指令,而不是当前指令。上面是基于 amd64 的汇编指令的一个很好的例子,该 Go 程序的线程负责顺序执行。

线程状态

另一个重要的概念是线程状态,它描述了调度器在线程中的角色。
线程可以处于三种状态之一: 等待中(Waiting)待执行(Runnable)执行中(Executing)

等待中(Waiting):这意味着线程停止并等待某件事情以继续。这可能是因为等待硬件(磁盘、网络)、操作系统(系统调用)或同步调用(原子、互斥)等原因。这些类型的延迟是性能下降的根本原因。

待执行(Runnable):这意味着线程需要内核上的时间,以便执行它指定的机器指令。如果有很多线程都需要时间,那么线程需要等待更长的时间才能获得执行。此外,由于更多的线程在竞争,每个线程获得的单个执行时间都会缩短。这种类型的调度延迟也可能导致性能下降。

执行中(Executing):这意味着线程已经被放置在一个核心上,并且正在执行它的机器指令。与应用程序相关的工作正在完成。这是每个人都想要的。

工作类型

线程可以做两种类型的工作。第一个称为 CPU-Bound,第二个称为 IO-Bound

CPU-Bound:这种工作类型永远也不会让线程处在等待状态,因为这是一项不断进行计算的工作。比如计算 π 的第 n 位,就是一个 CPU-Bound 线程。

IO-Bound:这是导致线程进入等待状态的工作类型。比如通过网络请求对资源的访问或对操作系统进行系统调用。

上下文切换

诸如 Linux、Mac、 Windows 是一个具有抢占式调度器的操作系统。这意味着一些重要的事情。首先,这意味着调度程序在什么时候选择运行哪些线程是不可预测的。线程优先级和事件混在一起(比如在网络上接收数据)使得无法确定调度程序将选择做什么以及什么时候做。

其次,这意味着你永远不能基于一些你曾经历过但不能保证每次都发生的行为来编写代码。如果应用程序中需要确定性,则必须控制线程的同步和协调管理。

在核心上交换线程的物理行为称为上下文切换。当调度器将一个正在执行的线程从内核中取出并将其更改状态为一个可运行的线程时,就会发生上下文切换。

上下文切换的代价是高昂的,因为在核心上交换线程会花费很多时间。上下文切换的延迟取决于不同的因素,大概在在 50 到 100 纳秒之间。考虑到硬件应该能够合理地(平均)在每个核心上每纳秒执行 12 条指令,那么一次上下文切换可能会花费 600 到 1200 条指令的延迟时间。实际上,上下文切换占用了大量程序执行指令的时间。

如果你在执行一个 IO-Bound 程序,那么上下文切换将是一个优势。一旦一个线程更改到等待状态,另一个处于可运行状态的线程就会取而代之。这使得 CPU 总是在工作。这是调度器最重要的之一,最好不要让 CPU 闲下来。

而如果你在执行一个 CPU-Bound 程序,那么上下文切换将成为性能瓶颈的噩梦。由于线程总是有工作要做,所以上下文切换阻碍了工作的进展。这种情况与 IO-Bound 类型的工作形成了鲜明对比。

少即是多

在早期处理器只有一个核心的时代,调度相对简单。因为只有一个核心,所以物理上在任何时候都只有一个线程可以执行。其思想是定义一个调度程序周期,并尝试在这段时间内执行所有可运行线程。算法很简单:用调度周期除以需要执行的线程数。

例如,如果你将调度器周期定义为 10ms(毫秒),并且你有 2 个线程,那么每个线程将分别获得 5ms。如果你有 5 个线程,每个线程得到 2ms。但是,如果有 1000 个线程,会发生什么情况呢?给每个线程一个时间片 10μs (微秒)?错了,这么干是愚蠢的,因为你会花费大量的时间在上下文切换上,而真正的工作却做不成。

你需要限制时间片的长度。在最后一个场景中,如果最小时间片是 2ms,并且有 1000 个线程,那么调度器周期需要增加到 2s(秒)。如果有 10000 个线程,那么调度器周期就是 20s。在这个简单的例子中,如果每个线程使用它的全时间片,那么所有线程运行一次需要花费 20s。

要知道,这是一个非常简单的场景。在真正进行调度决策时,调度程序需要考虑和处理比这更多的事情。你可以控制应用程序中使用的线程数量。当有更多的线程要考虑,并且发生 IO-Bound 工作时,就会出现一些混乱和不确定的行为。任务需要更长的时间来调度和执行。

这就是为什么游戏规则是“少即是多”。处于可运行状态的线程越少,意味着调度开销越少,每个线程执行的时间越长。完成的工作会越多。如此,效率就越高。

寻找一个平衡

你需要在 CPU 核心数和为应用程序获得最佳吞吐量所需的线程数之间找到平衡。当涉及到管理这种平衡时,线程池是一个很好的解决方案。将在第二部分中为你解析,Go 并不是这样做的。

CPU 缓存

从主存访问数据有很高的延迟成本(大约 100 到 300 个时钟周期),因此处理器核心使用本地高速缓存来将数据保存在需要的硬件线程附近。从缓存访问数据的成本要低得多(大约 3 到 40 个时钟周期),这取决于所访问的缓存。如今,提高性能的一个方面是关于如何有效地将数据放入处理器以减少这些数据访问延迟。编写多线程应用程序也需要考虑 CPU 缓存的机制。

图片描述

数据通过cache lines在处理器和主存储器之间交换。cache line是在主存和高速缓存系统之间交换的 64 字节内存块。每个内核都有自己所需的cache line的副本,这意味着硬件使用值语义。这就是为什么多线程应用程序中内存的变化会造成性能噩梦。

当并行运行的多个线程正在访问相同的数据值,甚至是相邻的数据值时,它们将访问同一cache line上的数据。在任何核心上运行的任何线程都将获得同一cache line的副本。

图片描述

如果某个核心上的一个线程对其cache line的副本进行了更改,那么同一cache line的所有其他副本都必须标记为dirty的。当线程尝试对dirty cache line进行读写访问时,需要向主存访问(大约 100 到 300 个时钟周期)来获得cache line的新副本。

也许在一个 2 核处理器上这不是什么大问题,但是如果一个 32 核处理器在同一cache line上同时运行 32 个线程来访问和改变数据,那会发生什么?如果一个系统有两个物理处理器,每个处理器有16个核心,那又该怎么办呢?这将变得更糟,因为处理器到处理器的通信延迟更大。应用程序将会在主存中周转,性能将会大幅下降。

这被称为缓存一致性问题,还引入了错误共享等问题。在编写可能会改变共享状态的多线程应用程序时,必须考虑缓存系统。

调度决策场景

假设我要求你基于我给你的信息编写操作系统调度器。考虑一下这个你必须考虑的情况。记住,这是调度程序在做出调度决策时必须考虑的许多有趣的事情之一。

启动应用程序,创建主线程并在核心1上执行。当线程开始执行其指令时,由于需要数据,正在检索cache line。现在,线程决定为一些并发处理创建一个新线程。下面是问题:

  1. 进行上下文切换,切出核心1的主线程,切入新线程?这样做有助于提高性能,因为这个新线程需要的相同部分的数据很可能已经被缓存。但主线程没有得到它的全部时间片。
  2. 新线程等待核心1在主线程完成之前变为可用?线程没有运行,但一旦启动,获取数据的延迟将被消除。
  3. 线程等待下一个可用的核心?这意味着所选核心的cache line将被刷新、检索和复制,从而导致延迟。然而,线程将启动得更快,主线程可以完成它的时间片。

有意思吗?这些是系统调度器在做出调度决策时需要考虑的有趣问题。幸运的是,不是我做的。我能告诉你的就是,如果有一个空闲核心,它将被使用。你希望线程在可以运行时运行。

结论

本文的第一部分深入介绍了在编写多线程应用程序时需要考虑的关于线程和系统调度器的问题。这些是 Go 调度器也要考虑的事情。在下一篇文章中,我将解析 Go 调度器的语义以及它们如何与这些信息相关联,并通过一些示例程序来展示。

查看原文

赞 60 收藏 43 评论 5

rsj217 收藏了文章 · 2018-12-21

Golang - 调度剖析【第一部分】

简介

首先,Golang 调度器的设计和实现让我们的 Go 程序在多线程执行时效率更高,性能更好。这要归功于 Go 调度器与操作系统(OS)调度器的协同合作。不过在本篇文章中,多线程 Go 程序在设计和实现上是否与调度器的工作原理完全契合不是重点。重要的是对系统调度器和 Go 调度器,它们是如何正确地设计多线程程序,有一个全面且深入的理解。

本章多数内容将侧重于讨论调度器的高级机制和语义。我将展示一些细节,让你可以通过图像来理解它们是如何工作的,可以让你在写代码时做出更好的决策。因为原理和语义是必备的基础知识中的关键。

系统调度

操作系统调度器是一个复杂的程序。它们要考虑到运行时的硬件设计和设置,其中包括但不限于多处理器核心、CPU 缓存和 NUMA,只有考虑全面,调度器才能做到尽可能地高效。值得高兴的是,你不需要深入研究这些问题,就可以大致上了解操作系统调度器是如何工作的。

你的代码会被翻译成一系列机器指令,然后依次执行。为了实现这一点,操作系统使用线程(Thread)的概念。线程负责顺序执行分配给它的指令。一直执行到没有指令为止。这就是我将线程称为“执行流”的原因。

你运行的每个程序都会创建一个进程,每个进程都有一个初始线程。而后线程可以创建更多的线程。每个线程互相独立地运行着,调度是在线程级别而不是在进程级别做出的。线程可以并发运行(每个线程在单个内核上轮流运行),也可以并行运行(每个线程在不同的内核上同时运行)。线程还维护自己的状态,以便安全、本地和独立地执行它们的指令。

如果有线程可以执行,操作系统调度器就会调度它到空闲的 CPU 核心上去执行,保证 CPU 不闲着。它还必须模拟一个假象,即所有可以执行的线程都在同时地执行着。在这个过程中,调度器还会根据优先级不同选择线程执行的先后顺序,高优先级的先执行,低优先级的后执行。当然,低优先级的线程也不会被饿着。调度器还需要通过快速而明智的决策尽可能减少调度延迟。

为了实现这一目标,算法在其中做了很多工作,且幸运的是,这个领域已经积累了几十年经验。为了我们能更好地理解这一切,接下来我们来看几个重要的概念。

执行指令

程序计数器(PC),有时称为指令指针(IP),线程利用它来跟踪下一个要执行的指令。在大多数处理器中,PC指向的是下一条指令,而不是当前指令。
图片描述
如果你之前看过 Go 程序的堆栈跟踪,那么你可能已经注意到了每行末尾的这些十六进制数字。如下:

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 值与相应函数顶部的偏移量。+0x39PC 偏移量表示在程序没中断的情况下,线程即将执行的下一条指令。如果控制权回到主函数中,则主函数中的下一条指令是0+x72PC 偏移量。更重要的是,指针前面的指令是当前正在执行的指令。

下面是对应的代码
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表示示例函数内的一条指令的 PC 偏移量,该指令位于函数的起始指令后面第57条(10进制)。接下来,我们用 objdump 来看一下汇编指令。找到第57条指令,注意,runtime.gopanic那一行。

$ 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(+0x39)

记住: PC 是下一个指令,而不是当前指令。上面是基于 amd64 的汇编指令的一个很好的例子,该 Go 程序的线程负责顺序执行。

线程状态

另一个重要的概念是线程状态,它描述了调度器在线程中的角色。
线程可以处于三种状态之一: 等待中(Waiting)待执行(Runnable)执行中(Executing)

等待中(Waiting):这意味着线程停止并等待某件事情以继续。这可能是因为等待硬件(磁盘、网络)、操作系统(系统调用)或同步调用(原子、互斥)等原因。这些类型的延迟是性能下降的根本原因。

待执行(Runnable):这意味着线程需要内核上的时间,以便执行它指定的机器指令。如果有很多线程都需要时间,那么线程需要等待更长的时间才能获得执行。此外,由于更多的线程在竞争,每个线程获得的单个执行时间都会缩短。这种类型的调度延迟也可能导致性能下降。

执行中(Executing):这意味着线程已经被放置在一个核心上,并且正在执行它的机器指令。与应用程序相关的工作正在完成。这是每个人都想要的。

工作类型

线程可以做两种类型的工作。第一个称为 CPU-Bound,第二个称为 IO-Bound

CPU-Bound:这种工作类型永远也不会让线程处在等待状态,因为这是一项不断进行计算的工作。比如计算 π 的第 n 位,就是一个 CPU-Bound 线程。

IO-Bound:这是导致线程进入等待状态的工作类型。比如通过网络请求对资源的访问或对操作系统进行系统调用。

上下文切换

诸如 Linux、Mac、 Windows 是一个具有抢占式调度器的操作系统。这意味着一些重要的事情。首先,这意味着调度程序在什么时候选择运行哪些线程是不可预测的。线程优先级和事件混在一起(比如在网络上接收数据)使得无法确定调度程序将选择做什么以及什么时候做。

其次,这意味着你永远不能基于一些你曾经历过但不能保证每次都发生的行为来编写代码。如果应用程序中需要确定性,则必须控制线程的同步和协调管理。

在核心上交换线程的物理行为称为上下文切换。当调度器将一个正在执行的线程从内核中取出并将其更改状态为一个可运行的线程时,就会发生上下文切换。

上下文切换的代价是高昂的,因为在核心上交换线程会花费很多时间。上下文切换的延迟取决于不同的因素,大概在在 50 到 100 纳秒之间。考虑到硬件应该能够合理地(平均)在每个核心上每纳秒执行 12 条指令,那么一次上下文切换可能会花费 600 到 1200 条指令的延迟时间。实际上,上下文切换占用了大量程序执行指令的时间。

如果你在执行一个 IO-Bound 程序,那么上下文切换将是一个优势。一旦一个线程更改到等待状态,另一个处于可运行状态的线程就会取而代之。这使得 CPU 总是在工作。这是调度器最重要的之一,最好不要让 CPU 闲下来。

而如果你在执行一个 CPU-Bound 程序,那么上下文切换将成为性能瓶颈的噩梦。由于线程总是有工作要做,所以上下文切换阻碍了工作的进展。这种情况与 IO-Bound 类型的工作形成了鲜明对比。

少即是多

在早期处理器只有一个核心的时代,调度相对简单。因为只有一个核心,所以物理上在任何时候都只有一个线程可以执行。其思想是定义一个调度程序周期,并尝试在这段时间内执行所有可运行线程。算法很简单:用调度周期除以需要执行的线程数。

例如,如果你将调度器周期定义为 10ms(毫秒),并且你有 2 个线程,那么每个线程将分别获得 5ms。如果你有 5 个线程,每个线程得到 2ms。但是,如果有 1000 个线程,会发生什么情况呢?给每个线程一个时间片 10μs (微秒)?错了,这么干是愚蠢的,因为你会花费大量的时间在上下文切换上,而真正的工作却做不成。

你需要限制时间片的长度。在最后一个场景中,如果最小时间片是 2ms,并且有 1000 个线程,那么调度器周期需要增加到 2s(秒)。如果有 10000 个线程,那么调度器周期就是 20s。在这个简单的例子中,如果每个线程使用它的全时间片,那么所有线程运行一次需要花费 20s。

要知道,这是一个非常简单的场景。在真正进行调度决策时,调度程序需要考虑和处理比这更多的事情。你可以控制应用程序中使用的线程数量。当有更多的线程要考虑,并且发生 IO-Bound 工作时,就会出现一些混乱和不确定的行为。任务需要更长的时间来调度和执行。

这就是为什么游戏规则是“少即是多”。处于可运行状态的线程越少,意味着调度开销越少,每个线程执行的时间越长。完成的工作会越多。如此,效率就越高。

寻找一个平衡

你需要在 CPU 核心数和为应用程序获得最佳吞吐量所需的线程数之间找到平衡。当涉及到管理这种平衡时,线程池是一个很好的解决方案。将在第二部分中为你解析,Go 并不是这样做的。

CPU 缓存

从主存访问数据有很高的延迟成本(大约 100 到 300 个时钟周期),因此处理器核心使用本地高速缓存来将数据保存在需要的硬件线程附近。从缓存访问数据的成本要低得多(大约 3 到 40 个时钟周期),这取决于所访问的缓存。如今,提高性能的一个方面是关于如何有效地将数据放入处理器以减少这些数据访问延迟。编写多线程应用程序也需要考虑 CPU 缓存的机制。

图片描述

数据通过cache lines在处理器和主存储器之间交换。cache line是在主存和高速缓存系统之间交换的 64 字节内存块。每个内核都有自己所需的cache line的副本,这意味着硬件使用值语义。这就是为什么多线程应用程序中内存的变化会造成性能噩梦。

当并行运行的多个线程正在访问相同的数据值,甚至是相邻的数据值时,它们将访问同一cache line上的数据。在任何核心上运行的任何线程都将获得同一cache line的副本。

图片描述

如果某个核心上的一个线程对其cache line的副本进行了更改,那么同一cache line的所有其他副本都必须标记为dirty的。当线程尝试对dirty cache line进行读写访问时,需要向主存访问(大约 100 到 300 个时钟周期)来获得cache line的新副本。

也许在一个 2 核处理器上这不是什么大问题,但是如果一个 32 核处理器在同一cache line上同时运行 32 个线程来访问和改变数据,那会发生什么?如果一个系统有两个物理处理器,每个处理器有16个核心,那又该怎么办呢?这将变得更糟,因为处理器到处理器的通信延迟更大。应用程序将会在主存中周转,性能将会大幅下降。

这被称为缓存一致性问题,还引入了错误共享等问题。在编写可能会改变共享状态的多线程应用程序时,必须考虑缓存系统。

调度决策场景

假设我要求你基于我给你的信息编写操作系统调度器。考虑一下这个你必须考虑的情况。记住,这是调度程序在做出调度决策时必须考虑的许多有趣的事情之一。

启动应用程序,创建主线程并在核心1上执行。当线程开始执行其指令时,由于需要数据,正在检索cache line。现在,线程决定为一些并发处理创建一个新线程。下面是问题:

  1. 进行上下文切换,切出核心1的主线程,切入新线程?这样做有助于提高性能,因为这个新线程需要的相同部分的数据很可能已经被缓存。但主线程没有得到它的全部时间片。
  2. 新线程等待核心1在主线程完成之前变为可用?线程没有运行,但一旦启动,获取数据的延迟将被消除。
  3. 线程等待下一个可用的核心?这意味着所选核心的cache line将被刷新、检索和复制,从而导致延迟。然而,线程将启动得更快,主线程可以完成它的时间片。

有意思吗?这些是系统调度器在做出调度决策时需要考虑的有趣问题。幸运的是,不是我做的。我能告诉你的就是,如果有一个空闲核心,它将被使用。你希望线程在可以运行时运行。

结论

本文的第一部分深入介绍了在编写多线程应用程序时需要考虑的关于线程和系统调度器的问题。这些是 Go 调度器也要考虑的事情。在下一篇文章中,我将解析 Go 调度器的语义以及它们如何与这些信息相关联,并通过一些示例程序来展示。

查看原文

rsj217 收藏了文章 · 2018-03-25

Java 泛型总结(一):基本用法与类型擦除

简介

Java 在 1.5 引入了泛型机制,泛型本质是参数化类型,也就是说变量的类型是一个参数,在使用时再指定为具体类型。泛型可以用于类、接口、方法,通过使用泛型可以使代码更简单、安全。然而 Java 中的泛型使用了类型擦除,所以只是伪泛型。这篇文章对泛型的使用以及存在的问题做个总结,主要参考自 《Java 编程思想》。

这个系列的另外两篇文章:

基本用法

泛型类

如果有一个类 Holder 用于包装一个变量,这个变量的类型可能是任意的,怎么编写 Holder 呢?在没有泛型之前可以这样:

public class Holder1 {
    private Object a;

    public Holder1(Object a) {
        this.a = a;
    }

    public void set(Object a) {
        this.a = a;
    }
    public Object get(){
        return a;
    }

    public static void main(String[] args) {
        Holder1 holder1 = new Holder1("not Generic");
        String s = (String) holder1.get();
        holder1.set(1);
        Integer x = (Integer) holder1.get();
    }

}

Holder1 中,有一个用 Object 引用的变量。因为任何类型都可以向上转型为 Object,所以这个 Holder 可以接受任何类型。在取出的时候 Holder 只知道它保存的是一个 Object 对象,所以要强制转换为对应的类型。在 main 方法中, holder1 先是保存了一个字符串,也就是 String 对象,接着又变为保存一个 Integer 对象(参数 1 会自动装箱)。从 Holder 中取出变量时强制转换已经比较麻烦,这里还要记住不同的类型,要是转错了就会出现运行时异常。

下面看看 Holder 的泛型版本:

public class Holder2<T> {

    private T a;
    public Holder2(T a) {
        this.a = a;
    }

    public T get() {
        return a;
    }

    public void set(T a) {
        this.a = a;
    }

    public static void main(String[] args) {
        Holder2<String> holder2 = new Holder2<>("Generic");
        String s = holder2.get();

        holder2.set("test");
        holder2.set(1);//无法编译   参数 1 不是 String 类型

    }

}

Holder2 中, 变量 a 是一个参数化类型 TT 只是一个标识,用其它字母也是可以的。创建 Holder2 对象的时候,在尖括号中传入了参数 T 的类型,那么在这个对象中,所有出现 T 的地方相当于都用 String 替换了。现在的 get 的取出来的不是 Object ,而是 String 对象,因此不需要类型转换。另外,当调用 set 时,只能传入 String 类型,否则编译无法通过。这就保证了 holder2 中的类型安全,避免由于不小心传入错误的类型。

通过上面的例子可以看出泛使得代码更简便、安全。引入泛型之后,Java 库的一些类,比如常用的容器类也被改写为支持泛型,我们使用的时候都会传入参数类型,如:ArrayList<Integer> list = ArrayList<>();

泛型方法

泛型不仅可以针对类,还可以单独使某个方法是泛型的,举个例子:

public class GenericMethod {
    public <K,V> void f(K k,V v) {
        System.out.println(k.getClass().getSimpleName());
        System.out.println(v.getClass().getSimpleName());
    }

    public static void main(String[] args) {
        GenericMethod gm = new GenericMethod();
        gm.f(new Integer(0),new String("generic"));
    }
}

代码输出:
    Integer
    String

GenericMethod 类本身不是泛型的,创建它的对象的时候不需要传入泛型参数,但是它的方法 f 是泛型方法。在返回类型之前是它的参数标识 <K,V>,注意这里有两个泛型参数,所以泛型参数可以有多个。

调用泛型方法时可以不显式传入泛型参数,上面的调用就没有。这是因为编译器会使用参数类型推断,根据传入的实参的类型 (这里是 integerString) 推断出 KV 的类型。

类型擦除

什么是类型擦除

Java 的泛型使用了类型擦除机制,这个引来了很大的争议,以至于 Java 的泛型功能受到限制,只能说是”伪泛型“。什么叫类型擦除呢?简单的说就是,类型参数只存在于编译期,在运行时,Java 的虚拟机 ( JVM ) 并不知道泛型的存在。先看个例子:

public class ErasedTypeEquivalence {
    public static void main(String[] args) {
        Class c1 = new ArrayList<String>().getClass();
        Class c2 = new ArrayList<Integer>().getClass();
        System.out.println(c1 == c2);
    }
}

上面的代码有两个不同的 ArrayListArrayList<Integer>ArrayList<String>。在我们看来它们的参数化类型不同,一个保存整性,一个保存字符串。但是通过比较它们的 Class 对象,上面的代码输出是 true。这说明在 JVM 看来它们是同一个类。而在 C++、C# 这些支持真泛型的语言中,它们就是不同的类。

泛型参数会擦除到它的第一个边界,比如说上面的 Holder2 类,参数类型是一个单独的 T,那么就擦除到 Object,相当于所有出现 T 的地方都用 Object 替换。所以在 JVM 看来,保存的变量 a 还是 Object 类型。之所以取出来自动就是我们传入的参数类型,这是因为编译器在编译生成的字节码文件中插入了类型转换的代码,不需要我们手动转型了。如果参数类型有边界那么就擦除到它的第一个边界,这个下一节再说。

擦除带来的问题

擦除会出现一些问题,下面是一个例子:

class HasF {
    public void f() {
        System.out.println("HasF.f()");
    }
}
public class Manipulator<T> {
    private T obj;

    public Manipulator(T obj) {
        this.obj = obj;
    }

    public void manipulate() {
        obj.f(); //无法编译 找不到符号 f()
    }

    public static void main(String[] args) {
        HasF hasF  = new HasF();
        Manipulator<HasF> manipulator = new Manipulator<>(hasF);
        manipulator.manipulate();

    }

}

上面的 Manipulator 是一个泛型类,内部用一个泛型化的变量 obj,在 manipulate 方法中,调用了 obj 的方法 f(),但是这行代码无法编译。因为类型擦除,编译器不确定 obj 是否有 f() 方法。解决这个问题的方法是给 T 一个边界:

class Manipulator2<T extends HasF> {
    private T obj;
    public Manipulator2(T x) { obj = x; }
    public void manipulate() { obj.f(); }
}

现在 T 的类型是 <T extends HasF>,这表示 T 必须是 HasF 或者 HasF 的导出类型。这样,调用 f() 方法才安全。HasF 就是 T 的边界,因此通过类型擦除后,所有出现 T
地方都用 HasF 替换。这样编译器就知道 obj 是有方法 f() 的。

但是这样就抵消了泛型带来的好处,上面的类完全可以改成这样:

class Manipulator3 {
    private HasF obj;
    public Manipulator3(HasF x) { obj = x; }
    public void manipulate() { obj.f(); }
}

所以泛型只有在比较复杂的类中才体现出作用。但是像 <T extends HasF> 这种形式的东西不是完全没有意义的。如果类中有一个返回 T 类型的方法,泛型就有用了,因为这样会返回准确类型。比如下面的例子:

class ReturnGenericType<T extends HasF> {
    private T obj;
    public ReturnGenericType(T x) { obj = x; }
    public T get() { return obj; }
}

这里的 get() 方法返回的是泛型参数的准确类型,而不是 HasF

类型擦除的补偿

类型擦除导致泛型丧失了一些功能,任何在运行期需要知道确切类型的代码都无法工作。比如下面的例子:


public class Erased<T> {
    private final int SIZE = 100;
    public static void f(Object arg) {
    if(arg instanceof T) {} // Error
    T var = new T(); // Error
    T[] array = new T[SIZE]; // Error
    T[] array = (T)new Object[SIZE]; // Unchecked warning
    }
}

通过 new T() 创建对象是不行的,一是由于类型擦除,二是由于编译器不知道 T 是否有默认的构造器。一种解决的办法是传递一个工厂对象并且通过它创建新的实例。

interface FactoryI<T> {
    T create();
}
class Foo2<T> {
    private T x;
    public <F extends FactoryI<T>> Foo2(F factory) {
    x = factory.create();
    }
    // ...
}
class IntegerFactory implements FactoryI<Integer> {
    public Integer create() {
    return new Integer(0);
    }
}
class Widget {
    public static class Factory implements FactoryI<Widget> {
        public Widget create() {
            return new Widget();
        }
    }
}
public class FactoryConstraint {
    public static void main(String[] args) {
        new Foo2<Integer>(new IntegerFactory());
        new Foo2<Widget>(new Widget.Factory());
    }
}

另一种解决的方法是利用模板设计模式:

abstract class GenericWithCreate<T> {
    final T element;
    GenericWithCreate() { element = create(); }
    abstract T create();
}
class X {}
class Creator extends GenericWithCreate<X> {
    X create() { return new X(); }
    void f() {
    System.out.println(element.getClass().getSimpleName());
    }
}
public class CreatorGeneric {
    public static void main(String[] args) {
        Creator c = new Creator();
        c.f();
    }
}

具体类型的创建放到了子类继承父类时,在 create 方法中创建实际的类型并返回。

总结

本文介绍了 Java 泛型的使用,以及类型擦除相关的问题。一般情况下泛型的使用比较简单,但是某些情况下,尤其是自己编写使用泛型的类或者方法时要注意类型擦除的问题。接下来会介绍数组与泛型的关系以及通配符的使用,有兴趣的读者可进入下一篇:Java 泛型总结(二):泛型与数组

参考

  • Java 编程思想

如果我的文章对您有帮助,不妨点个赞支持一下(^_^)

查看原文

rsj217 赞了回答 · 2017-11-22

MySQL表500万+,又不让分表

1 确保命中索引。
2 不要一次查太多数据,分页查询。
3 换更好的机器,上SSD。

关注 6 回答 2

rsj217 回答了问题 · 2017-10-31

tornado的多进程模式如何实现共享同一个ioloop?

sockets = tornado.netutil.bind_sockets(8888)
tornado.process.fork_processes(0)
server = HTTPServer(app)
server.add_sockets(sockets)
IOLoop.current().start()

关注 2 回答 3

rsj217 回答了问题 · 2017-10-31

tornado 异步得到结果后write报错Cannot write() after finish()

使用了yield tornado.gen.Task 调用协程的方式,就不需要再使用tornado.web.asynchronous了。

关注 2 回答 1

rsj217 回答了问题 · 2017-10-30

解决关于python3 统计文件行数的问题

line不是保留字,是for in循环的循环变量(准确来说是迭代的过程)。

xfile 是文件对象,在python中是可以迭代的,迭代的step以行为单位。如果代码:

for line in xfile:
    print(line)
    time.sleep(1)

那么你就能看见一行行逐渐被打印出来。

关注 2 回答 2

rsj217 回答了问题 · 2017-10-17

yield AsyncHTTPClient().fetch(url)调用其他服务,出现599 timeout

这个要配合异步装饰器使用的,比如 tornado.gen.coroutine 可以参考 异步客户端AsyncHTTPClient

关注 2 回答 1

rsj217 赞了回答 · 2017-10-12

解决python如何优雅地均匀地分割字符串

    s = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    n = 3
    print([s[i:i+n] for i in xrange(0, len(s), n)])
    #['ABC', 'DEF', 'GHI', 'JKL', 'MNO', 'PQR', 'STU', 'VWX', 'YZ']

关注 6 回答 4

rsj217 回答了问题 · 2017-08-25

解决python 包被安装在当前用户目录下,如何解决这种情况

virtualenv 管理你的python环境吧,配合 pip 管理包也简单。可以参考 Python 第三方库安装技巧 包含了如何在 虚拟环境中安装 windows的exe python包

关注 2 回答 2

认证与成就

  • 获得 271 次点赞
  • 获得 21 枚徽章 获得 2 枚金徽章, 获得 7 枚银徽章, 获得 12 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2013-04-30
个人主页被 3.7k 人浏览