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

Go深入理解goroutine的生命周期

2022-07-032.2k 阅读

goroutine的启动

在Go语言中,启动一个goroutine非常简单,只需要在函数调用前加上go关键字。例如:

package main

import (
    "fmt"
    "time"
)

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

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

在上述代码中,go hello()语句启动了一个新的goroutine来执行hello函数。main函数在启动goroutine后并不会等待hello函数执行完毕,而是继续执行后续的代码。这里通过time.Sleep(time.Second)main函数等待1秒钟,以确保hello函数有足够的时间执行并打印出信息。如果没有这行代码,main函数可能会在hello函数执行之前就结束,导致程序提前退出。

goroutine的调度

Go语言的运行时系统(runtime)包含一个高效的goroutine调度器。这个调度器采用M:N调度模型,即多个goroutine映射到多个操作系统线程上。

M:N调度模型

传统的线程模型有1:1(一个用户态线程映射到一个内核线程)和N:1(多个用户态线程映射到一个内核线程)。而Go语言的M:N模型结合了两者的优点。在这种模型下,多个goroutine可以复用少量的操作系统线程,并且每个goroutine都可以独立地被调度执行。

Go语言运行时调度器主要由三个组件构成:G(代表goroutine)、M(代表操作系统线程)和P(代表处理器,Processor)。

  • G:每个goroutine都有一个对应的G结构体,它包含了goroutine的栈、程序计数器以及其他与执行相关的状态信息。
  • M:每个M代表一个操作系统线程。M负责执行G,从P的本地运行队列或者全局运行队列中获取G来执行。
  • PP用于管理一组G,它包含一个本地运行队列,G会优先被放入本地运行队列中。同时,P还负责维护一个运行时的上下文,如内存分配状态等。

调度流程

  1. 创建:当使用go关键字启动一个goroutine时,会创建一个新的G结构体,并将其放入P的本地运行队列或者全局运行队列中。
  2. 调度执行MP的本地运行队列中获取G。如果本地运行队列为空,M会尝试从全局运行队列或者其他P的本地运行队列中偷取G来执行。当M获取到G后,就开始执行G中的函数。
  3. 暂停与恢复:当一个G执行系统调用(如I/O操作)时,对应的M会阻塞。此时,调度器会将该G从当前M上分离,并将其放入一个等待队列中。同时,调度器会创建一个新的M(如果有可用的P)来继续执行其他G。当系统调用完成后,G会被重新放入P的运行队列中,等待被调度执行。

goroutine的结束

正常结束

当一个goroutine中的函数执行完毕,该goroutine就会正常结束。例如:

package main

import (
    "fmt"
)

func count() {
    for i := 0; i < 5; i++ {
        fmt.Println(i)
    }
}

func main() {
    go count()
    // 这里为了演示,不使用time.Sleep,假设main函数有其他代码执行,不会立即退出
}

在上述代码中,count函数在循环结束后,对应的goroutine就会正常结束。

异常结束

如果在goroutine中发生了未处理的恐慌(panic),该goroutine会异常结束。例如:

package main

import (
    "fmt"
)

func divide(a, b int) {
    if b == 0 {
        panic("division by zero")
    }
    result := a / b
    fmt.Println(result)
}

func main() {
    go divide(10, 0)
    // 假设main函数有其他代码执行,不会立即退出
}

divide函数中,当b为0时会发生恐慌,导致该goroutine异常结束。需要注意的是,如果没有在divide函数中捕获这个恐慌,它会向上传播。如果在整个程序中都没有捕获这个恐慌,那么整个程序将会崩溃。

控制goroutine的生命周期

使用channel同步

通过channel可以方便地控制goroutine的生命周期。例如,我们可以使用一个done channel来通知goroutine结束。

package main

import (
    "fmt"
)

func worker(done chan struct{}) {
    fmt.Println("Worker started")
    // 模拟一些工作
    for i := 0; i < 5; i++ {
        fmt.Println("Working:", i)
    }
    fmt.Println("Worker done")
    done <- struct{}{}
}

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

在上述代码中,worker函数在完成工作后,会向done channel发送一个信号。main函数通过阻塞在<-done处等待这个信号,当接收到信号后就知道worker goroutine已经完成工作。

使用context控制

Go 1.7引入了context包,它提供了一种优雅的方式来管理goroutine的生命周期。context可以用于取消操作、设置截止时间等。

package main

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

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Worker received cancel signal")
            return
        default:
            fmt.Println("Worker working")
            time.Sleep(time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    go worker(ctx)
    time.Sleep(5 * time.Second)
}

在上述代码中,通过context.WithTimeout创建了一个带有超时时间的contextworker函数通过监听ctx.Done() channel来判断是否收到取消信号。当main函数中设置的超时时间到达后,context会自动取消,worker函数会收到取消信号并结束。

goroutine生命周期中的资源管理

栈空间管理

每个goroutine都有自己的栈空间。Go语言的栈空间是动态增长和收缩的。初始时,goroutine的栈空间比较小(通常为2KB左右),随着函数调用的深入,如果栈空间不够,运行时会自动扩展栈空间。当栈空间中的某些部分不再使用时,运行时也会收缩栈空间以节省内存。 例如,在一个递归函数中,如果递归深度不断增加,goroutine的栈空间会相应地增长:

package main

import (
    "fmt"
)

func recursive(n int) {
    if n == 0 {
        return
    }
    fmt.Println("Recursive call:", n)
    recursive(n - 1)
}

func main() {
    go recursive(1000)
    // 假设main函数有其他代码执行,不会立即退出
}

在上述代码中,recursive函数会递归调用自身1000次,随着递归的进行,goroutine的栈空间会不断增长以满足函数调用的需求。

内存泄漏问题

如果在goroutine中持有了一些资源(如文件描述符、数据库连接等),并且在goroutine结束时没有正确释放这些资源,就可能导致内存泄漏。 例如,下面的代码模拟了一个打开文件但未关闭的情况:

package main

import (
    "fmt"
    "os"
)

func readFile() {
    file, err := os.Open("test.txt")
    if err != nil {
        fmt.Println("Error opening file:", err)
        return
    }
    // 这里没有关闭文件
    // 假设这里有读取文件的逻辑
}

func main() {
    go readFile()
    // 假设main函数有其他代码执行,不会立即退出
}

在上述代码中,readFile函数打开了一个文件,但没有关闭文件。如果这个goroutine一直存在或者反复执行,就会导致文件描述符资源泄漏。为了避免这种情况,应该在函数结束时关闭文件:

package main

import (
    "fmt"
    "os"
)

func readFile() {
    file, err := os.Open("test.txt")
    if err != nil {
        fmt.Println("Error opening file:", err)
        return
    }
    defer file.Close()
    // 这里有读取文件的逻辑
}

func main() {
    go readFile()
    // 假设main函数有其他代码执行,不会立即退出
}

通过使用defer关键字,确保在函数结束时文件会被关闭,从而避免了资源泄漏。

goroutine生命周期相关的常见问题与优化

资源竞争问题

当多个goroutine同时访问和修改共享资源时,就可能发生资源竞争问题。例如:

package main

import (
    "fmt"
)

var counter int

func increment() {
    counter++
}

func main() {
    for i := 0; i < 1000; i++ {
        go increment()
    }
    // 假设main函数有其他代码执行,不会立即退出
    fmt.Println("Counter:", counter)
}

在上述代码中,多个goroutine同时调用increment函数对counter进行自增操作。由于多个goroutine可能同时读取和修改counter的值,会导致最终的counter值并不是预期的1000。为了解决这个问题,可以使用互斥锁(sync.Mutex):

package main

import (
    "fmt"
    "sync"
)

var counter int
var mu sync.Mutex

func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}

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

在上述改进后的代码中,通过mu.Lock()mu.Unlock()来保护对counter的访问,确保同一时间只有一个goroutine可以修改counter的值。同时,使用sync.WaitGroup来等待所有goroutine执行完毕,以保证counter的值是正确计算后的结果。

调度性能优化

在高并发场景下,goroutine的调度性能可能会成为瓶颈。为了优化调度性能,可以考虑以下几点:

  1. 减少系统调用:尽量避免在goroutine中频繁执行系统调用,因为系统调用会导致goroutine阻塞,从而影响调度效率。例如,可以使用内存缓存来减少对磁盘I/O的依赖。
  2. 合理设置P的数量P的数量会影响到调度器的性能。可以通过runtime.GOMAXPROCS函数来设置P的数量。一般来说,将P的数量设置为CPU核心数是一个不错的选择,但在某些特定场景下,可能需要根据实际情况进行调整。例如,在一个I/O密集型的应用中,可以适当增加P的数量,以充分利用CPU资源来处理其他goroutine。
package main

import (
    "fmt"
    "runtime"
)

func main() {
    numCPUs := runtime.NumCPU()
    runtime.GOMAXPROCS(numCPUs)
    fmt.Println("Number of CPUs:", numCPUs)
    // 启动goroutine进行工作
}
  1. 避免长时间运行的goroutine:长时间运行的goroutine可能会占用M资源,导致其他goroutine无法及时调度执行。可以将长时间运行的任务拆分成多个小任务,以提高调度的灵活性。例如,在处理一个大文件时,可以将文件分成多个块,每个块由一个goroutine处理。

goroutine与其他并发模型的对比

与线程的对比

  1. 资源消耗:线程的创建和销毁开销较大,每个线程都需要占用较大的栈空间(通常为几MB)。而goroutine的创建和销毁开销很小,初始栈空间也很小(通常为2KB左右),并且栈空间可以动态增长和收缩。因此,在高并发场景下,goroutine可以创建数以万计甚至更多,而线程的数量则会受到系统资源的限制。
  2. 调度方式:线程的调度由操作系统内核完成,上下文切换开销较大。而goroutine由Go语言运行时调度器管理,采用M:N调度模型,上下文切换开销较小,并且可以更灵活地在多个操作系统线程上复用。
  3. 编程模型:使用线程进行并发编程时,需要手动管理锁、条件变量等同步机制,容易出现死锁、资源竞争等问题。而在Go语言中,通过channel和sync包等机制,使得并发编程更加简洁和安全,降低了编程的复杂度。

与进程的对比

  1. 资源隔离:进程具有很强的资源隔离性,每个进程都有自己独立的地址空间。而goroutine共享同一个地址空间,它们之间的通信通过channel等方式进行。因此,进程间通信相对复杂,需要使用管道、共享内存等机制,而goroutine间通信更加便捷。
  2. 启动开销:进程的启动开销较大,需要分配独立的地址空间、加载程序代码等。而goroutine的启动开销很小,只需要创建一个G结构体并将其放入运行队列即可。
  3. 适用场景:进程适用于需要强隔离性的场景,如不同服务之间的隔离。而goroutine适用于在同一应用程序内部实现高并发处理,如Web服务器处理多个请求、数据处理管道等场景。

总结

深入理解goroutine的生命周期对于编写高效、健壮的Go语言程序至关重要。从启动、调度、结束,到资源管理以及控制其生命周期,每个环节都有其独特的机制和注意事项。通过合理运用这些知识,可以避免常见的并发问题,如资源竞争、内存泄漏等,同时优化程序的性能。与其他并发模型相比,goroutine在资源消耗、调度方式和编程模型等方面都具有显著的优势,使其成为Go语言实现高并发的核心特性之一。在实际开发中,应根据具体的业务场景,充分发挥goroutine的优势,构建出高性能、可扩展的应用程序。