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

Go语言中的并发编程模型与Goroutine调度

2023-04-156.5k 阅读

Go语言并发编程基础

在Go语言中,并发编程是其核心特性之一,使得开发者能够轻松地编写高效、并行执行的程序。Go语言的并发编程模型基于CSP(Communicating Sequential Processes)理论,通过 goroutinechannel 来实现。

Goroutine

goroutine 是Go语言中实现并发的轻量级线程。与传统线程相比,goroutine 的创建和销毁开销极小,使得可以在程序中创建大量的 goroutine 而不会带来过高的资源消耗。

创建一个 goroutine 非常简单,只需要在函数调用前加上 go 关键字:

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}

在上述代码中,go say("world") 创建了一个新的 goroutine 来执行 say("world") 函数,而主 goroutine 继续执行 say("hello")。这两个 goroutine 并发执行,输出结果可能是 helloworld 交替出现。

Channel

channel 是Go语言中用于 goroutine 之间通信的机制,它提供了一种类型安全的方式来传递数据。channel 可以看作是一个管道,数据可以从一端发送,从另一端接收。

创建一个 channel 如下:

ch := make(chan int)

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

ch <- 10

channel 接收数据也使用 <- 操作符:

value := <-ch

下面是一个使用 channel 进行 goroutine 间通信的示例:

package main

import (
    "fmt"
)

func sum(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v
    }
    c <- sum
}

func main() {
    s := []int{7, 2, 8, -9, 4, 0}

    c := make(chan int)
    go sum(s[:len(s)/2], c)
    go sum(s[len(s)/2:], c)
    x, y := <-c, <-c

    fmt.Println(x, y, x+y)
}

在这个例子中,两个 goroutine 分别计算切片的前半部分和后半部分的和,然后通过 channel 将结果发送回主 goroutine,主 goroutine 接收两个结果并计算总和。

Go语言并发编程模型深度解析

CSP模型在Go中的实现

CSP模型强调通过通信来共享数据,而不是通过共享数据来通信。在Go语言中,goroutine 是顺序执行的实体,channel 是它们之间通信的媒介。

当一个 goroutinechannel 发送数据时,它会阻塞,直到另一个 goroutine 从该 channel 接收数据。反之,当一个 goroutinechannel 接收数据时,它也会阻塞,直到有其他 goroutine 向该 channel 发送数据。这种同步机制确保了数据的安全传递,避免了传统并发编程中常见的竞态条件(race condition)问题。

例如,下面的代码展示了如何使用 channel 来同步两个 goroutine

package main

import (
    "fmt"
)

func worker(done chan bool) {
    fmt.Println("Worker started")
    // 模拟一些工作
    fmt.Println("Worker finished")
    done <- true
}

func main() {
    done := make(chan bool)
    go worker(done)
    <-done
    fmt.Println("Main function received done signal")
}

在这个例子中,worker goroutine 在完成工作后向 done channel 发送一个信号,主 goroutine 在接收到这个信号后才继续执行,从而确保了 worker goroutine 的工作完成。

单向Channel

在Go语言中,channel 可以被声明为单向的,即只用于发送或只用于接收。单向 channel 可以增强代码的可读性和安全性,明确 channel 的使用意图。

声明一个只写 channel

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

这里 ch chan<- int 表示 ch 是一个只写 channel,只能向其发送数据。

声明一个只读 channel

func receiveData(ch <-chan int) {
    for value := range ch {
        fmt.Println("Received:", value)
    }
}

这里 ch <-chan int 表示 ch 是一个只读 channel,只能从其接收数据。

Goroutine调度原理

调度器架构

Go语言的调度器采用了M:N调度模型,即多个 goroutine 映射到多个操作系统线程上。调度器的架构主要由三个组件组成:M(Machine)、G(Goroutine)和 P(Processor)。

  1. M(Machine):代表操作系统线程,一个 M 对应一个操作系统线程。M 负责执行 goroutine,它从 P 的本地运行队列或全局运行队列中获取 goroutine 并执行。
  2. G(Goroutine):代表Go语言中的轻量级线程,每个 G 都有自己的栈、程序计数器和局部变量等。G 可以处于不同的状态,如 running(运行中)、runnable(可运行,等待被调度执行)、blocked(阻塞,如等待 channel 操作、系统调用等)。
  3. P(Processor)Pgoroutine 运行的上下文,它包含了一个本地运行队列,用于存放可运行的 goroutineP 还负责管理 MG 之间的关系,一个 P 可以绑定到一个 M 上,使得 M 可以执行 P 本地队列中的 goroutine

调度流程

  1. 初始化:在程序启动时,调度器会初始化一定数量的 MP 和一个全局运行队列。每个 P 会有一个本地运行队列。
  2. 创建Goroutine:当使用 go 关键字创建一个 goroutine 时,它会被放入到某个 P 的本地运行队列中。如果本地运行队列已满,goroutine 会被放入全局运行队列。
  3. 调度执行M 从绑定的 P 的本地运行队列中获取 goroutine 并执行。如果本地运行队列为空,M 会尝试从全局运行队列或其他 P 的本地运行队列中窃取 goroutine(工作窃取算法)。
  4. 阻塞与唤醒:当一个 goroutine 执行 channel 操作、系统调用等导致阻塞时,它会从 running 状态转换为 blocked 状态,对应的 M 会释放 P,然后 M 可能会去执行其他的 goroutine 或者休眠。当阻塞的条件解除(如 channel 操作完成、系统调用返回),被阻塞的 goroutine 会被重新放入某个 P 的本地运行队列,等待再次被调度执行。

下面通过一个简单的例子来理解调度过程:

package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        for i := 0; i < 5; i++ {
            fmt.Println("Goroutine 1:", i)
            time.Sleep(100 * time.Millisecond)
        }
    }()

    go func() {
        for i := 0; i < 5; i++ {
            fmt.Println("Goroutine 2:", i)
            time.Sleep(100 * time.Millisecond)
        }
    }()

    time.Sleep(1 * time.Second)
}

在这个例子中,两个 goroutine 并发执行。它们可能会在不同的 M 上执行,通过 P 的调度,在本地运行队列和全局运行队列之间流转,以实现高效的并发执行。

调度器的优化与改进

工作窃取算法

工作窃取算法是Go语言调度器中的一个重要优化机制。当一个 M 的本地运行队列为空时,它会随机选择一个其他的 P,并从其本地运行队列中窃取一半的 goroutine 到自己的本地运行队列。这样可以确保在多个 goroutine 并发执行时,各个 M 都有工作可做,提高了系统资源的利用率。

例如,假设有三个 PP1P2P3)和两个 MM1M2)。P1 绑定到 M1P2 绑定到 M2P3 暂时没有绑定到 M。如果 M1 的本地运行队列为空,而 P2 的本地运行队列中有大量 goroutineM1 会从 P2 的本地运行队列中窃取部分 goroutine 到自己的本地运行队列,从而继续执行任务。

减少锁的竞争

在调度器中,锁的使用会影响性能,因为锁的竞争会导致线程的阻塞和上下文切换。Go语言的调度器通过一些设计来减少锁的竞争。

例如,每个 P 都有自己的本地运行队列,goroutine 的创建和调度主要在本地运行队列中进行,只有在需要访问全局运行队列或者进行工作窃取时才需要获取全局锁。这样大部分的调度操作都可以在无锁的情况下进行,提高了并发性能。

并发编程中的常见问题与解决方法

竞态条件

竞态条件是并发编程中常见的问题,当多个 goroutine 同时访问和修改共享资源时,可能会导致数据不一致的问题。

例如,下面的代码存在竞态条件:

package main

import (
    "fmt"
    "sync"
)

var counter int

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        counter++
    }
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Println("Final counter:", counter)
}

在这个例子中,多个 goroutine 同时对 counter 进行自增操作,由于没有同步机制,最终的 counter 值可能不是预期的 10000。

解决竞态条件的方法有多种,在Go语言中,可以使用 sync.Mutex 来保护共享资源:

package main

import (
    "fmt"
    "sync"
)

var counter int
var mu sync.Mutex

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        mu.Lock()
        counter++
        mu.Unlock()
    }
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Println("Final counter:", counter)
}

通过 mu.Lock()mu.Unlock() 来确保在同一时间只有一个 goroutine 可以访问 counter,从而避免了竞态条件。

死锁

死锁是另一个常见的并发问题,当两个或多个 goroutine 相互等待对方释放资源时,就会发生死锁。

例如,下面的代码会导致死锁:

package main

import (
    "fmt"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        ch1 <- 1
        <-ch2
    }()

    go func() {
        ch2 <- 2
        <-ch1
    }()
}

在这个例子中,两个 goroutine 分别向 ch1ch2 发送数据,然后等待从对方的 channel 接收数据,形成了死锁。

避免死锁的方法包括合理设计 goroutine 之间的通信逻辑,确保 channel 的发送和接收操作不会相互阻塞。例如,可以调整上述代码为:

package main

import (
    "fmt"
)

func main() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        ch1 <- 1
        <-ch2
    }()

    go func() {
        <-ch1
        ch2 <- 2
    }()

    // 防止主函数退出
    select {}
}

在这个修改后的代码中,goroutine 的通信顺序被调整,避免了死锁的发生。

总结并发编程与Goroutine调度要点

在Go语言的并发编程中,goroutinechannel 是实现高效并发的核心工具。理解CSP模型以及调度器的工作原理对于编写正确、高效的并发程序至关重要。

同时,要注意避免并发编程中的常见问题,如竞态条件和死锁。通过合理使用同步机制(如 sync.Mutex)和精心设计 goroutine 之间的通信逻辑,可以编写出健壮、高性能的并发程序。在实际应用中,根据具体的业务需求和场景,灵活运用这些并发编程技术,充分发挥Go语言在并发处理方面的优势。