一架梯子,一头程序猿,仰望星空!
Golang程序设计教程(2024版) > 内容正文

学习Goroutines


1 Goroutine简介

1.1 并发与并行的基本概念

并发(Concurrent)和并行(Parallel)是多线程编程中常用的两个概念。它们都是用来描述可能同时发生的事件或程序的执行情况。

  • 并发 指的是多个任务在同一时间段内处理,但任一时刻只有一个任务在执行。多个任务之间会快速切换,给用户一个同时执行的错觉。并发适用于单核处理器。
  • 并行 指的是多个任务在同一时刻真正的同时执行,这就要求多核处理器的支持。

Go语言是以并发设计为主要目的之一的语言,它通过Goroutines和Channels来实现高效的并发编程模型。Go的运行时管理着Goroutines,能够在多个系统线程上调度这些Goroutines来实现并行处理。

1.2 Go语言中的Goroutine

Goroutine是Go语言中实现并发编程的核心概念。它是由Go的运行时(runtime)管理的一个轻量级线程。从用户的角度来看,它类似于线程,但是它消耗的资源更少,启动更快。

Goroutines的特点包括:

  • 轻量级:相比于传统的线程,Goroutines的栈内存占用更小,并且栈的大小可根据需要动态伸缩。
  • 低开销:Goroutines的创建和销毁开销远小于传统线程。
  • 简单的通信机制:通过Channels提供了一种简单有效的Goroutines之间的通信机制。
  • 非阻塞性的设计:Goroutines在某些操作上不会阻塞其他Goroutines的运行,例如,当一个Goroutine在等待I/O操作时,其他Goroutine仍然可以继续执行。

2 创建和管理Goroutine

2.1 如何创建一个Goroutine

在Go语言中,你可以很简单地通过使用go关键字来创建一个Goroutine。当你在一个函数调用之前加上go关键字时,这个函数就会在新的Goroutine中异步执行。

让我们看一个简单的例子:

package main

import (
	"fmt"
	"time"
)

// 定义一个打印Hello的函数
func sayHello() {
	fmt.Println("Hello")
}

func main() {
	// 使用go关键字启动新的Goroutine
	go sayHello()
	
	// 主Goroutine等待一段时间,使sayHello有机会执行
	time.Sleep(1 * time.Second)
	fmt.Println("Main function")
}

在上述代码中,sayHello()函数将在一个新的Goroutine中异步执行。这意味着main()函数不会等待sayHello()执行完成就继续执行。因此,我们需要time.Sleep来暂停主Goroutine,以便sayHello中的打印语句有机会被执行。这只是为了演示,在实际开发中,我们通常会使用channels或其他同步方法来协调不同Goroutines之间的执行。

**注意:**在实际应用中,不应该使用time.Sleep()来等待一个Goroutine完成,因为这不是一种可靠的同步机制。

2.2 Goroutine的调度机制

在Go中,Goroutine的调度是由Go运行时(runtime)的调度器完成的,它负责在可用的逻辑处理器上合理地分配执行时间。Go的调度器使用了M:N调度技术(多个Goroutine映射到多个OS线程),其目的是为了在多核心处理器上获得更好的性能。

GOMAXPROCS和逻辑处理器

GOMAXPROCS是一个定义了运行时调度器可用的最大CPU核数的环境变量,默认值是机器上的CPU核心数。Go运行时会为每个逻辑处理器分配一个OS线程。通过设置GOMAXPROCS,我们可以限制运行时使用的核心数。

import "runtime"

func init() {
    runtime.GOMAXPROCS(2)
}

上述代码设置了最多两个核心来调度Goroutines,即使在拥有更多核心的机器上运行程序。

调度器的工作方式

调度器使用了三个重要的实体:M(机器),P(处理器),和G(Goroutine)。其中,M对应于内核线程,P代表了调度时的上下文,而G则是具体的Goroutine。

  1. M: 代表机器或者线程,它是OS内核线程的抽象。
  2. P: 是执行Goroutine需要的资源的集合。每个P都会有一个本地的Goroutine队列。
  3. G: 代表Goroutine,它包括了Goroutine的执行栈、指令集等信息。

Go调度器的工作原则是:

  • M必须有P才能执行G。如果没有P,M就会被放回到线程缓存中。
  • G在没有其他的G阻塞时尽可能运行在同一个M上,这有助于G的本地数据保持'热',使得CPU缓存能够更有效率。
  • 当一个G阻塞时,如发起系统调用,M和P会分离,P会寻找新的M或者唤醒一个新的M来服务于其它的G。
go func() {
    fmt.Println("Hello from Goroutine")
}()

以上代码演示了启动一个新的Goroutine,这会让调度器将这个新的G加入到队列中等待执行。

Goroutine的抢占式调度

Go在早期使用的是协作式调度,这意味着Goroutines在长时间执行且不主动放弃控制权的情况下会导致其他Goroutine饿死。现在,Go的调度器实现了抢占式调度,使得长时间运行的G可以被暂停,以给其它G一个执行的机会。

2.3 Goroutine的生命周期管理

为了确保你的Go应用程序的健壮性和性能,理解并正确管理Goroutine的生命周期是至关重要的。启动Goroutine很简单,但如果没有适当的管理,它们可能会导致内存泄露、竞态条件等问题。

安全地启动Goroutine

在启动一个Goroutine之前,确保了解它的工作负载和运行特性。Goroutine应该有清晰的开始和结束,避免创建没有终止条件的"Goroutine孤儿"。

func worker(done chan bool) {
    fmt.Println("Working...")
    time.Sleep(time.Second) // simulate expensive task
    fmt.Println("Done Working.")
    done <- true
}

func main() {
	// 这里使用了go的channel机制,你可以简单的把channel理解为一个简单的消息队列,通过 "<-" 操作符可以读写队列数据
    done := make(chan bool, 1)
    go worker(done)
    
    // Wait for the goroutine to finish
    <-done
}

上面的代码展示了通过通道done等待Goroutine结束的一种方法。

提示:这个例子使用了go的channel机制,后续的章节会详细讲解.

停止Goroutine

通常情况下,整个程序的结束会隐式地结束所有的Goroutines。然而,在长期运行的服务中,我们可能需要主动停止Goroutine。

  1. 使用通道来发送停止信号: Goroutine可以通过轮询通道来检查是否有停止信号。
stop := make(chan struct{})

go func() {
    for {
        select {
        case <-stop:
            fmt.Println("Got the stop signal. Shutting down...")
            return
        default:
            // execute normal operation
        }
    }
}()

// Send stop signal
stop <- struct{}{}
  1. 使用context包来管理生命周期:
ctx, cancel := context.WithCancel(context.Background())

go func(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("Got the stop signal. Shutting down...")
            return
        default:
            // execute normal operation
        }
    }
}(ctx)

// when you want to stop the goroutine
cancel()

使用context包可以让你更灵活地控制Goroutine,它提供了超时和取消能力。在大型应用或者微服务中,context是控制Goroutine生命周期的推荐方式。