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

Go中启动Goroutine的几种方式

2021-11-271.9k 阅读

直接启动 Goroutine

在 Go 语言中,最基本的启动 Goroutine 的方式就是使用 go 关键字。这种方式简单直接,能够快速地将一个函数作为一个独立的并发执行单元启动。

基础示例

假设有一个简单的函数 printNumbers,它会打印从 1 到 5 的数字:

package main

import (
    "fmt"
)

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

要以 Goroutine 的方式运行这个函数,只需要在调用函数时加上 go 关键字:

package main

import (
    "fmt"
    "time"
)

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

func main() {
    go printNumbers()
    time.Sleep(2 * time.Second)
}

在上述 main 函数中,go printNumbers() 启动了一个新的 Goroutine 来执行 printNumbers 函数。time.Sleep 在这里是为了防止 main 函数过早退出,因为 main 函数一旦结束,整个程序就会终止,即使还有其他 Goroutine 在运行。

原理剖析

当使用 go 关键字启动一个 Goroutine 时,Go 运行时系统会在一个新的逻辑线程(轻量级线程)中调度执行这个函数。与操作系统线程不同,Goroutine 非常轻量级,创建和销毁的开销极小。Go 运行时使用一个称为 M:N 调度模型来管理 Goroutine。其中,M 代表操作系统线程,N 代表 Goroutine。多个 Goroutine 可以复用少量的操作系统线程,通过协作式调度(co - operative scheduling)来实现并发执行。

在这个示例中,printNumbers 函数的代码在一个新的 Goroutine 中运行,与 main 函数所在的 Goroutine 并发执行。Go 运行时会在合适的时机切换上下文,使得各个 Goroutine 都有机会执行。

注意事项

  1. 资源竞争:由于多个 Goroutine 可能同时访问共享资源,因此可能会出现资源竞争问题。例如,如果 printNumbers 函数中访问了一个共享变量,并且多个 Goroutine 同时对其进行读写操作,就可能导致数据不一致。
  2. Goroutine 的生命周期:如果 main 函数结束,所有正在运行的 Goroutine 都会被强制终止,而不会等待它们完成。因此,需要确保在 main 函数退出之前,相关的 Goroutine 已经完成了必要的工作,或者通过合适的同步机制(如 sync.WaitGroup)来等待 Goroutine 完成。

使用匿名函数启动 Goroutine

匿名函数在 Go 中是一种非常灵活的编程结构,它可以在需要的地方直接定义和使用,而不需要提前命名。在启动 Goroutine 时,使用匿名函数可以让代码更加简洁,并且可以方便地传递参数。

基本示例

假设我们要打印出传入的字符串,并且以 Goroutine 的方式执行。可以使用匿名函数来实现:

package main

import (
    "fmt"
    "time"
)

func main() {
    message := "Hello, Goroutine!"
    go func() {
        fmt.Println(message)
    }()
    time.Sleep(2 * time.Second)
}

在这个例子中,go func() { fmt.Println(message) }() 启动了一个匿名函数作为 Goroutine。匿名函数可以访问其外部作用域中的变量,这里的 message 变量就是在匿名函数外部定义的。

带参数的匿名函数

如果匿名函数需要接受参数,可以在定义匿名函数时指定参数列表:

package main

import (
    "fmt"
    "time"
)

func main() {
    for i := 1; i <= 3; i++ {
        go func(num int) {
            fmt.Println("Number:", num)
        }(i)
    }
    time.Sleep(2 * time.Second)
}

在上述代码中,我们在 for 循环中启动了多个匿名函数作为 Goroutine,并将循环变量 i 作为参数传递给匿名函数。这样每个 Goroutine 都会打印出不同的数字。

原理剖析

匿名函数在启动 Goroutine 时,其原理与普通函数启动 Goroutine 类似。只不过匿名函数没有提前定义的名称,它在运行时被动态创建。匿名函数捕获其外部作用域中的变量时,遵循闭包的规则。在带参数的匿名函数中,参数会在 Goroutine 启动时被求值并传递,这确保了每个 Goroutine 能正确获取到不同的参数值。

注意事项

  1. 变量捕获问题:在 for 循环中启动匿名函数时,如果不小心,可能会遇到变量捕获的问题。例如:
package main

import (
    "fmt"
    "time"
)

func main() {
    var goroutines []func()
    for i := 0; i < 3; i++ {
        goroutines = append(goroutines, func() {
            fmt.Println(i)
        })
    }
    for _, g := range goroutines {
        go g()
    }
    time.Sleep(2 * time.Second)
}

在这个例子中,预期每个 Goroutine 会打印出 012,但实际上,所有 Goroutine 都会打印出 3。这是因为匿名函数捕获的是 i 的引用,而不是 i 的值。当 Goroutine 实际执行时,for 循环已经结束,i 的值已经变为 3。要解决这个问题,可以像前面带参数的匿名函数示例那样,将 i 作为参数传递给匿名函数。 2. 资源管理:与普通函数启动 Goroutine 一样,匿名函数启动的 Goroutine 也需要注意资源竞争和生命周期管理的问题。

通过函数指针启动 Goroutine

在 Go 语言中,函数也是一种类型,我们可以获取函数的指针,并通过函数指针来启动 Goroutine。这种方式在一些场景下,例如函数作为参数传递或者动态选择要执行的函数时,非常有用。

基本示例

首先定义一个函数:

package main

import (
    "fmt"
    "time"
)

func printMessage(message string) {
    fmt.Println(message)
}

然后通过函数指针来启动 Goroutine:

package main

import (
    "fmt"
    "time"
)

func printMessage(message string) {
    fmt.Println(message)
}

func main() {
    var fPtr func(string) = printMessage
    go fPtr("Hello from function pointer")
    time.Sleep(2 * time.Second)
}

main 函数中,我们定义了一个函数指针 fPtr,并将其指向 printMessage 函数。然后使用 go fPtr("Hello from function pointer") 来启动一个 Goroutine 执行 printMessage 函数。

动态选择函数

通过函数指针,我们可以根据不同的条件动态选择要执行的函数:

package main

import (
    "fmt"
    "time"
)

func printHello() {
    fmt.Println("Hello")
}

func printWorld() {
    fmt.Println("World")
}

func main() {
    condition := true
    var fPtr func()
    if condition {
        fPtr = printHello
    } else {
        fPtr = printWorld
    }
    go fPtr()
    time.Sleep(2 * time.Second)
}

在这个例子中,根据 condition 的值,我们动态地选择 printHelloprintWorld 函数,并通过函数指针启动 Goroutine 执行相应的函数。

原理剖析

函数指针本质上是指向函数代码起始地址的指针。当通过函数指针启动 Goroutine 时,Go 运行时会根据指针找到对应的函数,并在新的 Goroutine 中执行该函数。这种方式与直接使用函数启动 Goroutine 的底层原理相同,只是在获取函数的方式上有所不同。通过函数指针,我们可以在运行时动态地决定要执行的函数,增加了程序的灵活性。

注意事项

  1. 空指针检查:在使用函数指针启动 Goroutine 之前,一定要确保函数指针不为空。否则,在执行 go fPtr() 时会导致运行时错误。
  2. 类型匹配:函数指针的类型必须与实际要执行的函数类型完全匹配,包括参数列表和返回值类型。如果类型不匹配,编译器会报错。

使用结构体方法启动 Goroutine

在 Go 语言中,结构体方法是与结构体类型相关联的函数。通过将方法作为 Goroutine 启动,可以方便地管理与结构体实例相关的并发操作。

基本示例

定义一个结构体和其方法:

package main

import (
    "fmt"
    "time"
)

type Printer struct {
    message string
}

func (p Printer) Print() {
    fmt.Println(p.message)
}

然后使用结构体方法启动 Goroutine:

package main

import (
    "fmt"
    "time"
)

type Printer struct {
    message string
}

func (p Printer) Print() {
    fmt.Println(p.message)
}

func main() {
    printer := Printer{message: "Hello from struct method"}
    go printer.Print()
    time.Sleep(2 * time.Second)
}

main 函数中,我们创建了一个 Printer 结构体实例 printer,并通过 go printer.Print() 启动一个 Goroutine 来执行 Print 方法。

指针接收器与值接收器

在 Go 中,结构体方法可以使用指针接收器或值接收器。当使用指针接收器时,结构体实例的指针会被传递给方法,这意味着方法可以修改结构体的状态。当使用值接收器时,会传递结构体的一个副本。

使用指针接收器的示例:

package main

import (
    "fmt"
    "time"
)

type Counter struct {
    value int
}

func (c *Counter) Increment() {
    c.value++
    fmt.Println("Incremented value:", c.value)
}

启动 Goroutine:

package main

import (
    "fmt"
    "time"
)

type Counter struct {
    value int
}

func (c *Counter) Increment() {
    c.value++
    fmt.Println("Incremented value:", c.value)
}

func main() {
    counter := &Counter{}
    go counter.Increment()
    time.Sleep(2 * time.Second)
}

在这个例子中,Increment 方法使用指针接收器,这样可以修改 Counter 结构体实例的 value 字段。

原理剖析

当使用结构体方法启动 Goroutine 时,Go 运行时会根据接收器的类型(指针或值)来决定传递给方法的参数。如果是指针接收器,传递的是结构体实例的指针;如果是值接收器,传递的是结构体的副本。然后在新的 Goroutine 中执行该方法。这种方式使得与结构体相关的并发操作可以方便地与结构体的状态管理结合起来。

注意事项

  1. 指针接收器的使用场景:如果方法需要修改结构体的状态,一定要使用指针接收器。否则,修改的只是结构体副本的状态,而不是原始结构体的状态。
  2. 并发安全:如果多个 Goroutine 同时访问和修改同一个结构体的状态,需要考虑并发安全问题。可以使用 sync.Mutex 等同步机制来保护结构体的状态。

在 Go 中使用通道(Channel)与 Goroutine 结合启动

通道(Channel)是 Go 语言中用于在 Goroutine 之间进行通信和同步的重要机制。结合通道来启动 Goroutine 可以实现更加复杂和安全的并发编程。

基本示例:简单的发送与接收

首先创建一个通道,并在 Goroutine 中向通道发送数据,在主 Goroutine 中从通道接收数据:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
        close(ch)
    }()
    value := <-ch
    fmt.Println("Received:", value)
}

在这个例子中,我们创建了一个整型通道 ch。在匿名函数启动的 Goroutine 中,向通道 ch 发送值 42,然后关闭通道。在主 Goroutine 中,从通道 ch 接收数据并打印。

基于通道的工作池模式

工作池模式是一种常见的并发编程模式,通过通道和 Goroutine 可以很方便地实现。假设有一个任务队列,多个 Goroutine 从队列中取出任务并执行:

package main

import (
    "fmt"
    "time"
)

func worker(id int, taskCh <-chan int, resultCh chan<- int) {
    for task := range taskCh {
        fmt.Printf("Worker %d started task %d\n", id, task)
        result := task * task
        fmt.Printf("Worker %d finished task %d, result: %d\n", id, task, result)
        resultCh <- result
    }
}

func main() {
    taskCh := make(chan int)
    resultCh := make(chan int)

    numWorkers := 3
    for i := 1; i <= numWorkers; i++ {
        go worker(i, taskCh, resultCh)
    }

    tasks := []int{1, 2, 3, 4, 5}
    for _, task := range tasks {
        taskCh <- task
    }
    close(taskCh)

    for i := 0; i < len(tasks); i++ {
        result := <-resultCh
        fmt.Println("Main received result:", result)
    }
    close(resultCh)
    time.Sleep(2 * time.Second)
}

在这个例子中,我们定义了一个 worker 函数,它从 taskCh 通道接收任务,执行任务并将结果发送到 resultCh 通道。在 main 函数中,我们创建了多个 worker Goroutine,向 taskCh 通道发送任务,然后从 resultCh 通道接收结果。

原理剖析

通道是一种类型安全的管道,用于在 Goroutine 之间传递数据。当一个 Goroutine 向通道发送数据(ch <- value)时,它会阻塞,直到另一个 Goroutine 从通道接收数据(value := <-ch)。这种同步机制确保了数据的安全传递,避免了资源竞争。在工作池模式中,任务通道 taskCh 用于分发任务给各个 worker Goroutine,结果通道 resultCh 用于收集 worker Goroutine 的执行结果。

注意事项

  1. 通道的关闭:一定要注意及时关闭通道,避免出现死锁。例如,在向通道发送完所有数据后,要关闭发送端的通道,这样接收端才能通过 for... range 循环或 ok - idiom 检测到通道关闭并退出接收操作。
  2. 缓冲区大小:通道可以有缓冲区,例如 ch := make(chan int, 5) 创建了一个缓冲区大小为 5 的通道。有缓冲区的通道在发送数据时,如果缓冲区未满,不会立即阻塞;而无缓冲区的通道在发送数据时,会立即阻塞,直到有接收者准备好接收数据。在设计并发程序时,要根据具体需求合理设置通道的缓冲区大小。

使用 sync.WaitGroup 与 Goroutine 配合启动

sync.WaitGroup 是 Go 标准库中用于等待一组 Goroutine 完成的同步工具。它可以确保在所有相关的 Goroutine 完成工作之前,主 Goroutine 不会提前退出。

基本示例

假设我们有多个 Goroutine 执行一些任务,需要等待它们全部完成后再进行下一步操作:

package main

import (
    "fmt"
    "sync"
)

func task(wg *sync.WaitGroup, id int) {
    defer wg.Done()
    fmt.Printf("Task %d started\n", id)
    // 模拟任务执行
    fmt.Printf("Task %d finished\n", id)
}

func main() {
    var wg sync.WaitGroup
    numTasks := 3
    for i := 1; i <= numTasks; i++ {
        wg.Add(1)
        go task(&wg, i)
    }
    wg.Wait()
    fmt.Println("All tasks completed")
}

在这个例子中,sync.WaitGroup 被用来等待所有 task Goroutine 完成。在启动每个 Goroutine 之前,调用 wg.Add(1) 增加等待组的计数。在 task 函数中,使用 defer wg.Done() 来标记任务完成,减少等待组的计数。wg.Wait() 会阻塞主 Goroutine,直到等待组的计数变为 0,即所有任务都已完成。

嵌套使用 WaitGroup

在一些复杂的场景中,可能会有多层 Goroutine 的启动和等待。例如,一个主 Goroutine 启动多个子 Goroutine,每个子 Goroutine 又启动多个孙 Goroutine:

package main

import (
    "fmt"
    "sync"
)

func grandChildTask(wg *sync.WaitGroup, id int) {
    defer wg.Done()
    fmt.Printf("Grand - child task %d started\n", id)
    // 模拟任务执行
    fmt.Printf("Grand - child task %d finished\n", id)
}

func childTask(wg *sync.WaitGroup, id int) {
    var childWg sync.WaitGroup
    numGrandChildren := 2
    for i := 1; i <= numGrandChildren; i++ {
        childWg.Add(1)
        go grandChildTask(&childWg, i)
    }
    childWg.Wait()
    fmt.Printf("Child task %d finished\n", id)
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    numChildren := 2
    for i := 1; i <= numChildren; i++ {
        wg.Add(1)
        go childTask(&wg, i)
    }
    wg.Wait()
    fmt.Println("All tasks completed")
}

在这个例子中,childTask 函数启动了多个 grandChildTask Goroutine,并使用一个内部的 sync.WaitGroup 来等待它们完成。main 函数使用一个外部的 sync.WaitGroup 来等待所有 childTask Goroutine 完成。

原理剖析

sync.WaitGroup 内部维护一个计数器,Add 方法用于增加计数器的值,Done 方法用于减少计数器的值,Wait 方法会阻塞调用者,直到计数器的值变为 0。通过这种方式,它可以有效地协调多个 Goroutine 的执行顺序,确保在所有相关的 Goroutine 完成工作后,程序再继续执行后续的逻辑。

注意事项

  1. 计数器的正确使用Add 的调用次数必须与 Done 的调用次数匹配,否则 Wait 可能会永远阻塞或提前返回。
  2. 避免重复添加:不要在已经启动的 Goroutine 中再次调用 Add 方法增加计数器,除非你有明确的逻辑来处理这种情况,否则可能会导致计数器的值不正确,进而影响程序的正确性。

使用 context 与 Goroutine 配合启动

context 包在 Go 中用于管理 Goroutine 的生命周期,特别是在处理取消、超时等场景时非常有用。通过 context,我们可以在需要时优雅地取消一个或多个 Goroutine 的执行。

基本示例:取消 Goroutine

package main

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

func task(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Task cancelled")
            return
        default:
            fmt.Println("Task is running")
            time.Sleep(1 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    go task(ctx)
    time.Sleep(3 * time.Second)
    cancel()
    time.Sleep(2 * time.Second)
}

在这个例子中,我们使用 context.WithCancel 创建了一个可取消的 context,并将其传递给 task Goroutine。在 task 函数中,通过 select 语句监听 ctx.Done() 通道,当该通道接收到数据时,说明 context 被取消,任务应该停止执行。在 main 函数中,等待 3 秒后调用 cancel() 取消 context

超时控制

context 还可以用于设置 Goroutine 的执行超时:

package main

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

func task(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println("Task timed out or cancelled")
        return
    case <-time.After(5 * time.Second):
        fmt.Println("Task completed within time limit")
    }
}

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

在这个例子中,我们使用 context.WithTimeout 创建了一个带有超时时间为 3 秒的 contexttask 函数在执行时会通过 select 语句监听 ctx.Done() 通道,如果在 3 秒内 context 因超时而取消,ctx.Done() 通道会接收到数据,任务会停止执行。

原理剖析

context 是一个树形结构,每个 context 可以派生多个子 contextcontext 携带了一些元数据,如取消信号、超时时间等。当一个 context 被取消或超时,其所有的子 context 也会被取消。在 Goroutine 中,通过监听 ctx.Done() 通道来感知 context 的取消信号,从而决定是否停止执行。

注意事项

  1. 及时取消:在使用 context 取消 Goroutine 时,要确保在不需要 Goroutine 继续执行时及时调用取消函数,避免资源浪费。
  2. 传递 context:在启动多个 Goroutine 并使用 context 管理时,要确保将 context 正确地传递给每个相关的 Goroutine,否则可能无法实现预期的取消或超时控制。

通过以上多种方式,可以在 Go 语言中灵活地启动和管理 Goroutine,根据不同的应用场景选择合适的方式,能够编写出高效、健壮的并发程序。每种方式都有其特点和适用场景,在实际编程中需要根据具体需求进行权衡和选择。同时,要注意并发编程中的常见问题,如资源竞争、死锁等,合理使用同步机制来确保程序的正确性和稳定性。