MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go语言中的并发模型与goroutine调度机制

2022-03-025.6k 阅读

Go语言并发编程基础

Go语言以其简洁高效的并发编程模型而闻名。在Go语言中,并发编程主要通过goroutine和channel来实现。

goroutine简介

goroutine是Go语言中实现并发的轻量级线程。与传统的操作系统线程相比,goroutine的创建和销毁成本极低,允许在一个程序中轻松创建数以万计的并发任务。 创建一个goroutine非常简单,只需在函数调用前加上go关键字。例如:

package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello, world!")
}

func main() {
    go hello()
    time.Sleep(time.Second)
}

在上述代码中,go hello()创建了一个新的goroutine来执行hello函数。main函数不会等待hello函数执行完毕,而是继续执行后续代码。这里通过time.Sleepmain函数等待一秒,确保hello函数所在的goroutine有足够时间执行。

channel简介

channel是Go语言中用于在goroutine之间进行通信和同步的机制。它就像一个管道,数据可以在不同的goroutine之间通过channel进行传递。 创建一个channel的语法如下:

ch := make(chan int)

这里创建了一个可以传递int类型数据的channel。可以使用<-操作符来发送和接收数据。例如:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int)

    go func() {
        ch <- 42
    }()

    value := <-ch
    fmt.Println("Received:", value)
}

在这段代码中,一个匿名的goroutine向ch channel发送了一个值42,而主goroutine从ch中接收这个值并打印。

Go语言并发模型

Go语言的并发模型基于CSP(Communicating Sequential Processes)模型,该模型强调通过通信来共享内存,而不是通过共享内存来通信。

CSP模型在Go语言中的体现

在Go语言中,goroutine代表顺序执行的进程,而channel则是用于这些进程之间通信的通道。通过在goroutine之间传递数据,避免了共享内存带来的并发问题,如竞态条件(race condition)。 例如,假设有两个goroutine需要协作完成一项任务,一个goroutine生成数据,另一个goroutine处理数据:

package main

import (
    "fmt"
)

func producer(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func consumer(ch chan int) {
    for value := range ch {
        fmt.Println("Consumed:", value)
    }
}

func main() {
    ch := make(chan int)

    go producer(ch)
    go consumer(ch)

    select {}
}

在上述代码中,producer goroutine生成数据并发送到ch channel,consumer goroutine从ch channel接收数据并处理。for value := range ch这种方式可以优雅地处理channel关闭的情况,当producer goroutine调用close(ch)关闭channel后,consumer goroutine的循环会自动结束。

基于CSP模型的优势

  1. 简化并发编程:与传统的基于共享内存的并发编程模型相比,CSP模型使得代码逻辑更加清晰。不需要手动管理锁来保护共享资源,减少了死锁和竞态条件的发生。
  2. 提高程序的可维护性:通过将并发任务分离为独立的goroutine,并使用channel进行通信,代码结构更加模块化,易于理解和维护。

goroutine调度机制

goroutine的高效运行离不开Go语言的调度器。Go调度器采用了M:N调度模型,将多个goroutine映射到多个操作系统线程上。

M:N调度模型

  1. M:N调度模型的概念:在M:N调度模型中,M代表操作系统线程(通常称为内核线程,KSE - Kernel Scheduler Entity),N代表用户级线程(即goroutine)。Go调度器负责将N个goroutine高效地映射到M个操作系统线程上执行。
  2. Go调度器的组件:Go调度器主要由三个组件组成:G(goroutine)、M(操作系统线程)和P(处理器)。
    • G(goroutine):代表一个轻量级的执行单元,每个goroutine都有自己的栈空间、程序计数器和局部变量等。
    • M(操作系统线程):是操作系统内核提供的线程,负责执行实际的CPU指令。
    • P(处理器):P是Go调度器中的一个关键概念,它包含了运行goroutine所需的资源,如本地goroutine队列等。一个P与一个M绑定,M在P上运行goroutine。

goroutine调度流程

  1. 创建goroutine:当使用go关键字创建一个新的goroutine时,该goroutine会被放入全局goroutine队列或者某个P的本地goroutine队列中。
  2. 调度器的工作:调度器会不断地从P的本地goroutine队列或者全局goroutine队列中获取goroutine,并将其分配给与P绑定的M来执行。
  3. goroutine的状态转换:goroutine在执行过程中有多种状态,如_Gidle(空闲状态)、_Grunnable(可运行状态)、_Grunning(运行状态)、_Gsyscall(执行系统调用状态)、_Gwaiting(等待状态)等。当一个goroutine执行系统调用时,它会进入_Gsyscall状态,此时与之关联的M可能会被调度器重新分配去执行其他goroutine。当系统调用完成后,该goroutine会回到_Grunnable状态,等待再次被调度执行。
  4. 抢占式调度:Go 1.14版本引入了基于信号的抢占式调度。在之前的版本中,goroutine的调度主要依赖于协作式调度,即当一个goroutine执行到某些特定的点(如系统调用、channel操作等)时,才会主动让出CPU。而基于信号的抢占式调度允许在运行时向正在执行的goroutine发送信号,强制其暂停,从而实现更公平的调度,避免某个goroutine长时间占用CPU资源。

深入理解goroutine调度细节

本地队列与全局队列

  1. 本地队列:每个P都有一个本地goroutine队列,该队列的大小是有限的(通常为256)。当一个新的goroutine创建时,如果P的本地队列未满,它会被优先放入本地队列中。这样做的好处是,与该P绑定的M可以直接从本地队列中获取goroutine执行,减少了锁的竞争,提高了调度效率。
  2. 全局队列:当P的本地队列已满时,新创建的goroutine会被放入全局队列中。全局队列由调度器统一管理,当所有P的本地队列都为空时,调度器会从全局队列中取出一部分goroutine,平均分配到各个P的本地队列中。

调度器的实现原理

  1. 调度器的核心数据结构:调度器维护了一些关键的数据结构来实现高效的调度。例如,schedt结构体包含了全局队列、所有P的列表等信息。pstruct结构体则定义了P的相关属性,如本地队列、正在运行的goroutine等。
  2. 调度循环:调度器的主循环(schedinit函数初始化后启动)不断地从P的本地队列或全局队列中获取可运行的goroutine,并将其分配给M执行。在调度过程中,调度器会根据goroutine的状态和优先级等因素进行合理的调度决策。
  3. 系统调用处理:当一个goroutine执行系统调用时,与之关联的M会进入syscall状态。如果此时有其他可运行的goroutine,调度器会将M从当前P上分离,并分配给其他P使用,以充分利用CPU资源。当系统调用完成后,对应的goroutine会被重新放入可运行队列等待调度。

影响goroutine调度的因素

系统调用

如前所述,系统调用会使goroutine进入_Gsyscall状态,导致与之关联的M可能被重新分配。不同类型的系统调用对调度的影响也有所不同。例如,网络I/O系统调用可能会使goroutine等待较长时间,此时调度器会更倾向于将M分配给其他可运行的goroutine。

package main

import (
    "fmt"
    "syscall"
    "time"
)

func main() {
    go func() {
        fmt.Println("Starting syscall")
        syscall.Sleep(2 * 1e9)
        fmt.Println("Finished syscall")
    }()

    for i := 0; i < 5; i++ {
        fmt.Println("Main goroutine:", i)
        time.Sleep(500 * 1e6)
    }
}

在这段代码中,子goroutine执行syscall.Sleep进行系统调用,在其等待期间,主goroutine可以继续执行。

内存分配

内存分配也会对goroutine调度产生影响。Go语言的垃圾回收(GC)机制与goroutine调度紧密相关。当进行大规模内存分配时,可能会触发垃圾回收,而垃圾回收过程可能会暂停所有的goroutine(STW - Stop The World),以确保内存状态的一致性。虽然Go语言的垃圾回收算法在不断优化,尽量减少STW的时间,但内存分配仍然是一个需要关注的因素。

package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        data := make([]byte, 1024*1024*100) // 分配大量内存
        fmt.Println("Memory allocated")
        time.Sleep(2 * time.Second)
    }()

    for i := 0; i < 5; i++ {
        fmt.Println("Main goroutine:", i)
        time.Sleep(500 * time.Millisecond)
    }
}

在这个例子中,子goroutine分配了大量内存,可能会对主goroutine的调度产生一定影响。

channel操作

channel的发送和接收操作会阻塞goroutine,从而影响调度。当一个goroutine向一个已满的channel发送数据,或者从一个空的channel接收数据时,它会进入等待状态(_Gwaiting),调度器会将其从运行队列中移除,直到channel有可用空间或数据时,该goroutine才会被重新唤醒并放入可运行队列。

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int, 1)

    go func() {
        ch <- 1
        fmt.Println("Sent value to channel")
    }()

    fmt.Println("Before receiving from channel")
    value := <-ch
    fmt.Println("Received:", value)
}

在这段代码中,子goroutine向ch channel发送数据后,主goroutine从ch接收数据,在此过程中,如果channel状态不满足操作条件,相应的goroutine会被阻塞。

优化goroutine调度性能

合理设置处理器数量

可以通过runtime.GOMAXPROCS函数来设置程序运行时可用的处理器数量。合理设置处理器数量可以充分利用多核CPU的性能。例如:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    numCPUs := runtime.NumCPU()
    runtime.GOMAXPROCS(numCPUs)
    fmt.Println("Using", numCPUs, "CPUs")
}

在上述代码中,获取系统的CPU核心数,并将其设置为Go程序运行时可用的处理器数量。

减少系统调用和内存分配

尽量减少不必要的系统调用和大规模内存分配。对于频繁的I/O操作,可以使用异步I/O或连接池等技术来减少系统调用的次数。在内存分配方面,尽量复用已有的内存对象,避免频繁创建和销毁大对象。

优化channel设计

合理设计channel的容量和使用方式。如果channel的容量设置过小,可能会导致频繁的阻塞和唤醒,影响调度性能。对于需要高性能的场景,可以考虑使用无缓冲的channel来实现更精确的同步。同时,避免在channel操作中引入不必要的锁竞争。

总结与实践建议

通过深入了解Go语言的并发模型和goroutine调度机制,开发者可以编写更高效、更健壮的并发程序。在实际开发中,需要根据具体的业务场景和性能需求,合理运用goroutine、channel以及调度器相关的知识。例如,在高并发的网络编程中,充分利用goroutine的轻量级特性来处理大量的并发连接;在数据处理任务中,通过合理设计channel来实现不同阶段的数据传递和同步。同时,注意优化调度性能,减少系统调用、内存分配等对调度的不利影响,以充分发挥Go语言并发编程的优势。