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

Go语言Goroutine的本质剖析

2022-02-256.9k 阅读

一、Goroutine 简介

在 Go 语言中,Goroutine 是一种轻量级的并发执行单元。与传统线程相比,Goroutine 的创建和销毁成本极低,这使得我们可以轻松创建数以万计的并发任务。Goroutine 基于 Go 语言的运行时(runtime)进行调度,它的调度模型与操作系统原生线程调度模型有很大的区别,这也是其高效并发的关键所在。

Go 语言通过 go 关键字来创建一个 Goroutine。例如:

package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello from Goroutine")
}

func main() {
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("Main function")
}

在上述代码中,go hello() 语句创建了一个新的 Goroutine 来执行 hello 函数。main 函数继续执行,并不会等待 hello 函数执行完毕。time.Sleep 是为了确保 hello 函数所在的 Goroutine 有足够的时间执行。

二、Goroutine 的调度模型:M:N 模型

  1. M:N 模型概述 Goroutine 采用的是 M:N 调度模型,即多个 Goroutine(N 个)映射到多个操作系统线程(M 个)上。传统的线程模型通常是 1:1 模型,即一个用户线程对应一个操作系统线程。而 M:N 模型允许在少量的操作系统线程上高效地调度大量的 Goroutine。

  2. Go 运行时的组件

    • Goroutine(G):代表一个轻量级的执行单元,每个 Goroutine 有自己独立的栈空间(初始栈通常较小,可动态增长)。
    • 操作系统线程(M):操作系统提供的线程,负责执行实际的指令。
    • 调度器(Scheduler):Go 运行时的核心组件,负责在 M 上调度 G。调度器维护了多个队列,用于管理处于不同状态的 Goroutine。

三、Goroutine 的生命周期

  1. 创建 当使用 go 关键字创建一个 Goroutine 时,调度器会为其分配一个唯一的标识符,并将其放入调度器的全局队列或某个本地队列中。例如:
package main

import (
    "fmt"
)

func createGoroutine() {
    go func() {
        fmt.Println("Newly created Goroutine")
    }()
}

func main() {
    createGoroutine()
    fmt.Println("Main function after creating Goroutine")
}
  1. 运行 调度器从队列中取出一个 Goroutine,并将其绑定到一个操作系统线程(M)上,开始执行其函数体。在执行过程中,Goroutine 可能会进行系统调用、I/O 操作或者主动让出执行权。

  2. 阻塞 当 Goroutine 进行系统调用、I/O 操作或者遇到 channel 操作等会导致阻塞的情况时,调度器会将其从当前的操作系统线程(M)上解绑,并将其放入阻塞队列。同时,调度器会寻找其他可运行的 Goroutine 并分配到该操作系统线程上执行。例如:

package main

import (
    "fmt"
    "time"
)

func blockedGoroutine() {
    fmt.Println("Goroutine starts")
    time.Sleep(2 * time.Second)
    fmt.Println("Goroutine wakes up")
}

func main() {
    go blockedGoroutine()
    fmt.Println("Main function")
    time.Sleep(3 * time.Second)
}

在这个例子中,blockedGoroutine 函数中的 time.Sleep 会导致该 Goroutine 阻塞 2 秒。在这期间,调度器可以调度其他 Goroutine 到操作系统线程上执行。

  1. 结束 当 Goroutine 执行完其函数体或者调用 return 语句时,调度器会回收相关资源,并将其从队列中移除。例如:
package main

import (
    "fmt"
)

func endingGoroutine() {
    fmt.Println("Goroutine starts")
    fmt.Println("Goroutine ends")
}

func main() {
    go endingGoroutine()
    fmt.Println("Main function")
}

四、Goroutine 与系统调用

  1. 系统调用的影响 当一个 Goroutine 进行系统调用时,它会阻塞当前绑定的操作系统线程(M)。为了避免整个线程池被阻塞,Go 运行时采用了一些特殊的处理机制。例如,当一个 Goroutine 进行 I/O 操作时,调度器会将该 Goroutine 从当前的 M 上解绑,并将 M 标记为不可用。然后,调度器会从其他队列中寻找可运行的 Goroutine 并分配到其他可用的 M 上执行。

  2. 非阻塞系统调用 Go 语言的一些标准库函数提供了非阻塞的系统调用方式。例如,net 包中的 DialTimeout 函数可以设置连接超时时间,避免无限期阻塞。

package main

import (
    "fmt"
    "net"
    "time"
)

func nonBlockingDial() {
    conn, err := net.DialTimeout("tcp", "google.com:80", 2*time.Second)
    if err != nil {
        fmt.Println("Dial error:", err)
        return
    }
    defer conn.Close()
    fmt.Println("Connected successfully")
}

func main() {
    go nonBlockingDial()
    fmt.Println("Main function")
    time.Sleep(3 * time.Second)
}

五、Goroutine 与 Channel

  1. Channel 作为通信机制 Channel 是 Go 语言中用于 Goroutine 之间通信的重要机制。它可以实现同步和数据传递。例如,我们可以创建一个有缓冲的 Channel 来在两个 Goroutine 之间传递数据:
package main

import (
    "fmt"
)

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

func receiver(ch chan int) {
    for num := range ch {
        fmt.Println("Received:", num)
    }
}

func main() {
    ch := make(chan int, 5)
    go sender(ch)
    go receiver(ch)
    fmt.Println("Main function")
    select {}
}

在上述代码中,sender 函数向 ch Channel 发送数据,receiver 函数从 ch Channel 接收数据。select {} 是为了防止 main 函数退出,确保两个 Goroutine 有足够的时间执行。

  1. Channel 与同步 Channel 还可以用于同步 Goroutine 的执行。例如,我们可以使用一个无缓冲的 Channel 来确保某个 Goroutine 在另一个 Goroutine 完成特定操作后再继续执行:
package main

import (
    "fmt"
)

func first(ch chan struct{}) {
    fmt.Println("First Goroutine starts")
    // 模拟一些工作
    fmt.Println("First Goroutine finishes")
    ch <- struct{}{}
}

func second(ch chan struct{}) {
    <-ch
    fmt.Println("Second Goroutine starts after first finishes")
}

func main() {
    ch := make(chan struct{})
    go first(ch)
    go second(ch)
    fmt.Println("Main function")
    select {}
}

六、Goroutine 的调度策略

  1. 全局队列与本地队列 Go 运行时的调度器维护了一个全局队列和每个操作系统线程(M)的本地队列。新创建的 Goroutine 通常会被放入调度器的全局队列或者某个 M 的本地队列中。调度器优先从本地队列中获取 Goroutine 进行调度,如果本地队列为空,则从全局队列中获取。

  2. 抢占式调度 Go 1.14 引入了更完善的抢占式调度机制。在之前的版本中,Goroutine 主要是协作式调度,即只有当 Goroutine 主动让出执行权(例如通过 runtime.Gosched 函数或者进行系统调用等)时,调度器才能调度其他 Goroutine。而抢占式调度允许调度器在某些情况下强制暂停一个正在运行的 Goroutine,从而为其他 Goroutine 提供执行机会。这大大提高了并发任务的响应性。

七、Goroutine 的内存管理

  1. 栈空间管理 每个 Goroutine 都有自己独立的栈空间。初始时,栈空间通常较小(例如 2KB),随着 Goroutine 的执行,如果栈空间不足,Go 运行时会自动扩展栈空间。当栈空间中的数据不再被使用时,运行时会回收这些空间,以避免内存浪费。

  2. 垃圾回收与 Goroutine Go 语言的垃圾回收器(GC)会自动管理内存,回收不再使用的对象。Goroutine 中的对象同样受 GC 管理。当一个 Goroutine 结束且其内部的对象不再被其他对象引用时,GC 会回收这些对象占用的内存。例如:

package main

import (
    "fmt"
    "runtime"
)

func memoryUsage() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
    fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
    fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
    fmt.Printf("\tNumGC = %v\n", m.NumGC)
}

func bToMb(b uint64) uint64 {
    return b / 1024 / 1024
}

func main() {
    memoryUsage()
    go func() {
        data := make([]byte, 1024*1024)
        fmt.Println("Goroutine allocated 1MB")
    }()
    time.Sleep(1 * time.Second)
    memoryUsage()
}

在上述代码中,我们通过 runtime.MemStats 来查看内存使用情况。在创建一个分配了 1MB 内存的 Goroutine 前后,观察内存统计信息的变化。

八、Goroutine 的性能优化

  1. 减少不必要的 Goroutine 创建 虽然 Goroutine 的创建成本低,但过多的 Goroutine 会增加调度器的负担,导致性能下降。例如,在一个循环中创建大量短生命周期的 Goroutine 可能不是一个好的做法。我们可以使用工作池(worker pool)模式来复用 Goroutine。
package main

import (
    "fmt"
    "sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for j := range jobs {
        fmt.Printf("Worker %d started job %d\n", id, j)
        result := j * 2
        fmt.Printf("Worker %d finished job %d with result %d\n", id, j, result)
        results <- result
    }
}

func main() {
    const numJobs = 5
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup

    numWorkers := 3
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    go func() {
        wg.Wait()
        close(results)
    }()

    for r := range results {
        fmt.Println("Received result:", r)
    }
}

在这个工作池的例子中,我们创建了固定数量的 Goroutine 来处理任务,避免了每次任务都创建新的 Goroutine。

  1. 优化 Channel 操作 合理使用有缓冲和无缓冲的 Channel 可以提高性能。无缓冲的 Channel 用于同步,而有缓冲的 Channel 可以减少阻塞。同时,避免在 Channel 操作中出现不必要的锁争用。

  2. 避免过度同步 虽然同步机制(如互斥锁、读写锁等)在多 Goroutine 编程中是必要的,但过度使用会导致性能瓶颈。尽量使用 Channel 等通信机制来代替锁,以实现更高效的并发。

九、Goroutine 在实际项目中的应用场景

  1. Web 服务器 在 Web 开发中,Goroutine 可以高效地处理大量的并发请求。每个请求可以在一个独立的 Goroutine 中处理,避免单个请求阻塞整个服务器。例如,使用 Go 语言的 net/http 包创建的 Web 服务器,每个 HTTP 请求默认在一个新的 Goroutine 中处理。
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, World!")
}

func main() {
    http.HandleFunc("/", handler)
    fmt.Println("Server listening on :8080")
    http.ListenAndServe(":8080", nil)
}
  1. 分布式系统 在分布式系统中,Goroutine 可以用于实现节点之间的通信、任务分发等功能。例如,一个分布式爬虫系统可以使用 Goroutine 来并发地爬取不同的网页。

  2. 数据处理与分析 在数据处理和分析场景中,Goroutine 可以并行处理数据,提高处理速度。例如,对一个大型数据集进行排序或者统计操作时,可以将数据分块,每个块在一个独立的 Goroutine 中处理,最后合并结果。

十、Goroutine 与其他并发模型的比较

  1. 与线程的比较

    • 创建和销毁成本:Goroutine 的创建和销毁成本比线程低得多。线程的创建需要操作系统资源的分配,而 Goroutine 由 Go 运行时调度,创建和销毁非常轻量级。
    • 调度方式:线程通常由操作系统内核调度,采用抢占式调度。Goroutine 由 Go 运行时调度,虽然现在也支持抢占式调度,但早期是协作式调度,并且其调度模型是 M:N,与线程的 1:1 模型不同。
    • 内存占用:每个线程通常需要较大的栈空间(例如数 MB),而 Goroutine 的初始栈空间很小(例如 2KB),且可动态扩展。
  2. 与进程的比较

    • 资源隔离:进程具有更强的资源隔离性,每个进程有自己独立的地址空间。Goroutine 共享所属进程的地址空间,通过 Channel 等机制实现数据安全共享。
    • 通信成本:进程间通信通常需要更复杂的机制,如管道、共享内存、消息队列等。Goroutine 之间通过 Channel 通信,相对简单高效。
    • 启动成本:进程的启动成本比 Goroutine 高得多,因为进程需要加载可执行文件、初始化内存等一系列操作。

十一、Goroutine 的常见问题与解决方法

  1. 死锁问题 死锁是多 Goroutine 编程中常见的问题。当两个或多个 Goroutine 相互等待对方释放资源时,就会发生死锁。例如:
package main

import (
    "fmt"
)

func main() {
    ch := make(chan int)
    go func() {
        ch <- 1
    }()
    <-ch
    fmt.Println("This will never be printed")
}

在这个例子中,主 Goroutine 和新创建的 Goroutine 都在等待对方,导致死锁。解决死锁问题的关键是确保资源的获取和释放顺序合理,或者使用 select 语句来处理 Channel 操作,设置超时等。

  1. 数据竞争问题 当多个 Goroutine 同时访问和修改共享数据时,如果没有适当的同步机制,就会发生数据竞争。例如:
package main

import (
    "fmt"
    "sync"
)

var count int

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

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

在这个例子中,多个 Goroutine 同时修改 count 变量,导致结果不准确。可以使用互斥锁(sync.Mutex)来解决数据竞争问题:

package main

import (
    "fmt"
    "sync"
)

var count int
var mu sync.Mutex

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

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Println("Expected count: 10000, Actual count:", count)
}
  1. Goroutine 泄漏问题 当一个 Goroutine 无限期阻塞且没有正确清理资源时,就会发生 Goroutine 泄漏。例如,一个 Goroutine 等待一个永远不会关闭的 Channel:
package main

import (
    "fmt"
    "time"
)

func leakedGoroutine() {
    ch := make(chan int)
    go func() {
        <-ch
        fmt.Println("This will never be printed")
    }()
}

func main() {
    leakedGoroutine()
    time.Sleep(1 * time.Second)
    fmt.Println("Main function")
}

为了避免 Goroutine 泄漏,要确保所有的 Goroutine 最终都会结束,或者在适当的时候取消它们。可以使用 context 包来实现取消功能。例如:

package main

import (
    "context"
    "fmt"
    "time"
)

func cancelableGoroutine(ctx context.Context) {
    ch := make(chan int)
    go func() {
        select {
        case <-ctx.Done():
            fmt.Println("Goroutine cancelled")
            return
        case <-ch:
            fmt.Println("Received data")
        }
    }()
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()
    cancelableGoroutine(ctx)
    time.Sleep(2 * time.Second)
    fmt.Println("Main function")
}

十二、总结

Goroutine 是 Go 语言实现高效并发编程的核心机制。通过深入理解其本质,包括调度模型、生命周期、与系统调用和 Channel 的关系等,我们能够编写出高效、健壮的并发程序。在实际应用中,需要注意避免常见问题,如死锁、数据竞争和 Goroutine 泄漏等。同时,合理优化 Goroutine 的使用,以充分发挥 Go 语言的并发优势。无论是在 Web 开发、分布式系统还是数据处理等领域,Goroutine 都展现出了强大的性能和灵活性,为开发者提供了一种简洁而高效的并发编程方式。

在未来,随着硬件性能的不断提升和应用场景的日益复杂,Goroutine 的调度和管理机制可能会进一步优化,以适应更多的并发需求。开发者需要持续关注 Go 语言的发展,不断提升自己的并发编程能力,以应对日益增长的业务挑战。

希望通过本文对 Goroutine 的本质剖析,能帮助读者在 Go 语言的并发编程之路上更加得心应手,编写出更优秀的程序。