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。
- M: 代表机器或者线程,它是OS内核线程的抽象。
- P: 是执行Goroutine需要的资源的集合。每个P都会有一个本地的Goroutine队列。
- 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。
- 使用通道来发送停止信号: 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{}{}
-
使用
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生命周期的推荐方式。