深入研究Go运行时的调度程序

Go 对并发的内置支持是其最重要的功能之一,使其成为构建高性能和可扩展系统的流行选择。在本文中,我们将探讨 Golang 中的并发概念,包括其核心原语,例如goroutine和Channels,以及它们如何使开发人员能够轻松构建并发程序。

首先,让我们从一些概念开始,以确保我们都处于相同的上下文中!

并发与并行
Go 中的并发是函数彼此独立运行的能力。

正如罗布·派克曾经说过的:
并发是指同时处理很多事情。并行性是指同时做很多事情

这是并发和并行之间的主要区别,如果您对每个特征都有一个良好的思维模型,那么区分它们将是一个简单的任务!

在深入讨论区别之前,您应该对进程和线程有一个清晰的了解。

  • 您可以将进程想象成一个容器,它保存应用程序在运行时使用和维护的所有资源。
  • 线程是由操作系统调度的执行路径,用于运行您在函数中编写的代码。

操作系统安排线程针对处理器运行,无论它们属于哪个进程。

  • 操作系统调度线程针对物理处理器运行,Go 运行时调度 goroutine 针对逻辑处理器运行。
  • 每个逻辑处理器单独绑定到单个操作系统线程,这些逻辑处理器用于执行创建的所有 goroutine。
  • 即使使用单个逻辑处理器,也可以安排数十万个 goroutine 并发运行,并具有惊人的效率和性能。

并发不是并行。只有当多段代码同时执行时才能实现并行性体质不同处理器

并行性是指同时做很多事情。并发是指同时管理很多事情

  • 并发是指不同的(不相关/相关)任务可以在同一资源(CPU、机器、集群)上以重叠的时间范围执行,而并行是指相关任务或具有多个子任务的任务并行执行(相同的启动时间) - 可能相同的完成 - 时间)
  • 在许多情况下,并发性可以胜过并行性,因为对操作系统和硬件的压力要小得多,这使得系统可以执行更多操作。这种“少即是多”的哲学是该语言的口头禅。

如果要并行运行 goroutine,则必须使用多个逻辑处理器。

  • 当有多个逻辑处理器时,调度器会在逻辑处理器之间均匀分配goroutine
  • 但要拥有真正的并行性,您仍然需要在具有多个物理处理器的机器上运行程序
  • 如果没有,那么即使 Go 运行时使用多个线程,goroutines 也将在单个物理处理器上并发运行。

Goroutine
当一个函数被创建为 goroutine 时,它​​被视为一个独立的工作单元,被调度,然后在可用的逻辑处理器上执行。
Goroutine 极其轻量级且高效,开销极小,这意味着您可以创建数千个 Goroutine,而不会对程序的性能产生任何重大影响。
Goroutine 是一个以go关键字为前缀的简单函数

func main(){
    go SayHi("Hi")
    time.Sleep(time.Millisecond * 1000)
}
func SayHi(message string) {
    fmt.Println(mesage)
}

协程goroutine 是程序的一个单元,可以在同一个程序中多次暂停和恢复,在调用之间保持协程的状态,python中的生成器函数是协程的一种实现

虽然子例程是顺序执行的程序单元,但调用是独立的,因此子例程的内部状态不会在调用之间共享,子例程在执行所有指令时完成,之后无法恢复

深入探讨 Go 的运行时调度程序
深入了解 Go 的运行时调度程序可以让您清楚地了解 Golang 中如何处理并发,因此让我们揭示一些运行时行为

Go 运行时调度程序是一个复杂的软件,它管理所有创建的并需要处理器时间的 goroutine。调度程序位于操作系统的顶层,将操作系统的线程绑定到逻辑处理器,而逻辑处理器又执行 goroutine。调度程序控制与在任何给定时间哪些 goroutine 在哪些逻辑处理器上运行相关的一切。

goroutine的执行步骤

  1. 当 goroutine 创建并准备运行时,它们被放置在调度程序的全局运行队列中。
  2. 不久之后,它们被分配给逻辑处理器并放入该逻辑处理器的本地运行队列中。
  3. 从那里开始,goroutine 等待轮到逻辑处理器来执行。

有时,正在运行的 goroutine 可能需要执行阻塞系统调用,例如打开文件:

  • 当发生这种情况时,线程和 goroutine 与逻辑处理器分离,并且线程继续阻塞等待系统调用返回。
  • 同时,如果有一个没有线程的逻辑处理器。因此调度程序创建一个新线程并将其附加到逻辑处理器。

然后调度器会从本地运行队列中选择另一个goroutine来执行。一旦系统调用返回,goroutine 就会被放回本地运行队列,并且线程会被放在一边以供将来使用。

  • 如果 Goroutine 需要进行网络调用,则过程有点不同。在 I/O 这种情况下,goroutine 与逻辑处理器分离并移至运行时集成网络轮询器
  • 一旦轮询器指示读或写操作准备就绪,goroutine 就会被分配回逻辑处理器来处理该操作。对于可以创建的逻辑处理器的数量,调度程序没有内置限制。但运行时默认限制每个程序最多10,000个线程

在本节中,我们将研究分配来处理下面每种情况的逻辑处理器的数量,并了解这如何影响整体行为。

package main

import (
    "fmt"
   
"runtime"
   
"sync"
)

func main() {
    runtime.GOMAXPROCS(1)
// allocate only one logical processor

    var wg sync.WaitGroup
    wg.Add(2)
// add 2 to wait for 2 goroutines

    fmt.Println(
"Starting go routines")
   
// creating an anoymous function that print the alphabets
    go func() {
        defer wg.Done()

        for count := 0; count < 3; count++ {
            for char := 'a'; char < 'a'+26; char++ {
                fmt.Printf(
"%c ", char)
            }
        }
    }()

    go func() {
        defer wg.Done()

        for count := 0; count < 3; count++ {
            for char := 'A'; char < 'A'+26; char++ {
                fmt.Printf(
"%c ", char)
            }
        }
    }()

    fmt.Println(
"Waiting to finish")
    wg.Wait()

    fmt.Println(
"\n Terminating program...")
}

如果您尝试运行上面的代码,您将看到以下输出:

Create Goroutines
Waiting To Finish
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A B C D E F G H I J K L M
N O P Q R S T U V W X Y Z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
a b c d e f g h i j k l m n o p q r s t u v w x y z a b c d e f g h i j k l m
n o p q r s t u v w x y z a b c d e f g h i j k l m n o p q r s t u v w x y z
Terminating Program

  • waitGroup是一个计数信号量,可用于维护运行的goroutines的记录,每次wg.Done调用时,计数器都会减少,直到1达到该值0,然后main才能安全终止。
  • defer关键字用于调度执行 defer 函数内部的其他函数在函数返回时调用,基于调度器的内部算法,正在运行的 goroutine 可以在完成工作之前停止并重新调度以再次运行

调度程序这样做是为了防止任何单个 goroutine 劫持逻辑处理器。它将停止当前正在运行的 Goroutine,并给另一个可运行的 Goroutine 有机会运行

如果我们尝试将逻辑处理器的数量从 1 更改为 2,您认为会发生什么:

package main

import (
    "fmt"
   
"runtime"
   
"sync"
)

func main() {
    runtime.GOMAXPROCS(2)
    var wg sync.WaitGroup
    wg.Add(2)
    fmt.Println(
"Starting goroutines")
    go func() {
        defer wg.Done()

        for count := 0; count < 3; count++ {
            for char := 'a'; char < 'a'+26; char++ {
                fmt.Printf(
"%c ", char)
            }
        }
    }()

    go func() {
        defer wg.Done()

        for count := 0; count < 3; count++ {
            for char := 'A'; char < 'A'+26; char++ {
                fmt.Printf(
"%c ", char)
            }
        }
    }()

    fmt.Println(
"Waiting To Finish")
    wg.Wait()

    fmt.Println(
"\n Terminating program...")
}

也许你会看到输出看起来像这样

Starting goroutines
Waiting To Finish
A B C a D E b F c G d H e I f J g K h L i M j N k O l P m Q n R o S p T
q U r V s W t X u Y v Z w A x B y C z D a E b F c G d H e I f J g K h L
i M j N k O l P m Q n R o S p T q U r V s W t X u Y v Z w A x B y C z D
a E b F c G d H e I f J g K h L i M j N k O l P m Q n R o S p T q U r V
s W t X u Y v Z w x y z
Terminating Program...

需要注意的是,使用多个逻辑处理器并不一定意味着更好的性能,需要进行基准测试来了解程序在运行时更改任何配置参数时的执行情况

另一个更好地演示使用单个逻辑处理器的想法的好例子是做一些繁重的工作

package main

import (
    "fmt"
   
"runtime"
   
"sync"
)
var wg sync.WaitGroup
func main() {
    runtime.GOMAXPROCS(1)
    wg.Add(2)

    fmt.Println(
"\nStarting goroutines")
    go printPrime(
"A")
    go printPrime(
"B")
    fmt.Println(
"\nWaiting to finish")
    wg.Wait()

    fmt.Println(
"Terminating...")
}
func printPrime(prefix string) {
   
// printPrime displays prime numbers for the first 5000 numbers
    defer wg.Done()
next:
    for outer := 2; outer < 5000; outer++ {
        for inner := 2; inner < outer; inner++ {
            if outer%inner == 0 {
                continue next
            }
        }
        fmt.Printf(
"%s:%d\n", prefix, outer)
    }
    fmt.Printf(
"%s completed", prefix)
}

输出:

Starting Goroutines
Waiting To Finish
B:2
B:3
...
B:4583
B:4591
A:3 ** Goroutines Swapped
A:5
...
A:4561
A:4567
B:4603 ** Goroutines Swapped
B:4621
...
Completed B
A:4457 ** Goroutines Swapped
A:4463
...
A:4993
A:4999
Completed A
Terminating Program

Goroutine B 首先开始显示素数。一旦 Goroutine B 打印素数 4591,调度程序就会将该 Goroutine 换成 Goroutine A。然后,Goroutine A 在线程上获得一些时间,并再次换出 B Goroutine。B goroutine 可以完成它的所有工作。一旦 Goroutine B 返回,您会看到 Goroutine A 被归还线程以完成其工作。

请记住,只有当有多个逻辑处理器并且有一个物理处理器可用于同时运行每个 goroutine 时,goroutine 才能并行运行。

CSP
并发同步来自称为通信顺序进程或 CSP 的范例。CSP 是一种消息传递模型,它通过在 goroutine 之间通信数据而不是锁定数据来同步访问。goroutine 之间同步和传递消息的关键数据类型称为通道

CSP 提供了一个思考并发的模型,使其变得不那么困难

只需将程序拆开并让各个部分相互对话

结论
Go 的运行时调度程序在处理并发方面非常智能,其算法旨在处理软件的工作负载并将其分配到可用线程之间,这对于您的系统资源和整体性能非常有效!