并发编程
进程与线程
进程
- 进程是操作系统资源分配的基本单位。
- 进程包含线程,线程在进程中运行。
- 每个进程拥有独立的内存空间,可以隔离和其它进程相互之间的耦合和影响。
- 进程对其所有线程的创建、运行、销毁进程总览。
线程
- 线程是进程的最小执行单元,是一种独立的、并发的顺序执行流。
- 线程是程序执行中的基本单元,它是执行代码的实际操作,线程之间可以并发运行。
- 一个进程可以有多个线程,并共享进程的资源,进程所有的任务都在线程中执行。
- CPU 高速切换进行不同线程进行调度。
- 多线程是并发的处理方式之一。
- 并发运行上下文切换时成本较高。
内核级
- 操作系统运行所需的线程,是操作系统的一部分。
- 操作系统的程序必须保证安全与稳定,因此其线程不能随便访问。
- 内核态下的线程可以访问计算机所有的软硬件资源。
用户级
- 用户应用程序运行所需的线程,是用户程序的一部分。
- 用户程序可以访问其自身线程及存储空间。
- 如果想要访问内核级线程,必须使用操作系统提供的线程函数库。
- 用户级访问内核级,必须将用户态切换成内核态。
协程
- 协程(Coroutine)是一种比线程更轻量的执行单元。
- 能够实现任务的并发执行,但不像线程那样需要操作系统进行调度,由程序逻辑自行调度。
- 减少上下文切换的开销,提高程序的并发性和性能。
- 适合用在需要大量并发任务的应用中,特别是在网络编程、Web服务器和数据处理等场景中。
- 多个协程可能从属于同一个线程,是一种多对一的关系。
- 协程与线程直接是映射关系。
- 同一个线程下的多个协程是串行执行方式,不能并发任务处理。
执行方式
串行
- 任务的执行必须在上一个任务完成后进行。
- 任务顺序的基础就是单个任务执行完成。
- 串行是任务执行的一种方式。
并行
- 并行任务的执行与完成与其他并行任务的执行和完成情况无关。
- 并行也是任务的一种执行方式,一般在多个 CPU 上进行任务的处理。
并发
- 并发任务的执行与完成与其他并发任务的执行和完成情况无关。
- 并发也是任务的一种执行方式,在单核 CPU 上高频切换任务进行执行。
- 高并发并非一定要使用多线程,也可使用多进程。
GMP
GMP(Goroutine-Machine-Processor) 模型是 Go 运行时实现高并发能力的核心机制,用于管理 Goroutines 和物理线程之间的调度,其设计目的是尽可能的利用多核 CPU 提高执行效率,高效管理大量的并发任务,提高系统的并发能力。
Goroutine
Goroutine 是 Go 对协程的优化实现,是语言层面的轻量级协程封装,由 Go 运行时管理。Goroutine 内存开销极小,多个 Goroutine 可复用在少量线程上,切换开销极低,在高并发场景下兼顾性能和易用性。
Goroutine 本质就是需要交给 CPU 执行的代码,其有自己的运行栈、任务函数、状态等。Goroutine 必须绑定一个 Processor 才能被调度执行。
Goroutine 的创建、销毁、调度都是用户态下完成,对内核是透明的,不需要内核态介入。
| 并发方式 | 弱依赖内核态 | 阻塞优化 | 并发 | 栈动态扩容 |
|---|---|---|---|---|
| 线程 | 是 | 是 | 是 | 否 |
| 协程 | 否 | 否 | 否 | 否 |
| Goroutine | 否 | 是 | 是 | 是 |
Processor
Processor 是负责调度和管理 Goroutines 的逻辑处理器,管理 Goroutines 队列和调度上下文,实现无锁调度,其默认数量等于当前系统 CPU 逻辑核心数。
简单来讲,Processor 是 Goroutine 和 Machine 的桥接器,负责调度 Goroutine 并将其分配给 Machine 进行执行,Processor 拥有每个被其调度的 Goroutine 的内存分配、执行时间等详细信息。
Machine
Machine 是操作系统线程的抽象,代表一个物理线程,通常与操作系统的线程直接对应,是真正执行任务的载体,Go 运行时通过 Machine 来执行 Goroutines。
基本原理
- CPU 通过系统调度器调度 Machine,Processor 可以创建多个,一个 Processor 同一时间只能对应一个 Machine,但 Machine 可以没有 Processor 对应,即空线程。
- 每个 Processor 都有自己的本地运行队列(Local Run Queue,即 LRQ),该队列装载 Goroutines 任务,LRQ 最多可装载 256 个 Goroutines 任务。
- 当 Processor 的 LRQ 列没有 Goroutines 任务时,会从全局运行队列(Global Run Queue,即 GRQ)中取 Goroutines 任务。
- 多个 Processor 共享 GRQ,当一个Processor从 GRQ 取 Goroutines 任务时,会对 GRQ 进行加锁,取完之后再解锁,主要是为了避免在取 Goroutines 任务时出现混乱。
- 当一个 Processor 的 LRQ 和 GRQ 都没有 Goroutines 任务时,会从其他的 Processor 的 LRQ 中窃取(Work Stealing)一半的 Goroutines 任务到自己的 LRQ。在窃取时,会对被窃取的 Processor 进行加锁,窃取完之后再解锁,目的是为了避免多个 Processor 同时窃取同一个 Processor 的 Goroutines 任务时而产生竞争态。
- 当产生一个新的 Goroutine 时,会被分配到一个 Processor,如果这个 Processor 的本地队列已满,调度器会将这个 Processor 一半的 Goroutines 放入 GRQ,如果 LRQ 和 GRQ 都满了,那么会创建一个新的 Processor 进行新的 Goroutine 的调度。
- 如果一个 Machine 阻塞了,那么对应的 Processor 可能会创建一个新的 Machine 或切换到另一个 Machine 上去处理新的 Goroutine 任务。
- 一个 Goroutine 最多占用 CPU 10ms,如果超过 10ms,那么必须让给下一个 Goroutine 任务。
- 以上过程是循环往复的,直到处理完所有 Goroutines 为止。
go
在 Go 语言中,可通过 go 关键字运行函数,以此开启一个 Goroutine:
package main
import "fmt"
func main() {
start() // ---> Blocking
go test() // ---> Non-blocking
}
func start () {
fmt.Println("start")
}
func test() {
fmt.Println("test")
}
Goroutines 是完全独立的,互不影响,函数自行运行,不会等待其他程序,考虑如下代码:
package main
func main() {
// func1
go func() {
fmt.Println(1)
}()
// func2
go func() {
fmt.Println(2)
}()
}
由于 Goroutines 是独立的,main 函数在内部的 Goroutines 完成之前已经退出,由于主函数已经完成,因此整个程序终止。
main 函数本质也是一个 Goroutine,在程序开始时隐式启动,因此在如上的程序中总共有三个 Goroutines:main、func1、func2。
Goroutine 的 Processor 的调度机制决定了每个 Goroutine 的执行时机。考虑如下代码:
package main
func main() {
for i := 0; i < 100; i++ {
go func() {
println(i)
}()
}
}
以上代码并不会按照预期的顺序输出i,并且可能存在重复。
为了解决上述问题,可使用闭包机制或 for range:
- closure.go
- range.go
package main
import (
"fmt"
"time"
)
func main() {
for i := 0; i < 5; i++ {
go func(i int) {
fmt.Println(ii)
}(i)
}
time.Sleep(time.Second * 5)
}
package main
import (
"fmt"
"time"
)
func main() {
// 需要 1.22 版本及以上
for i := range 5 {
go func() {
fmt.Println(i)
}()
}
time.Sleep(time.Second * 5)
}
waitGroup
在如上的示例中,为了等待 Goroutines 使用了 time.Sleep,这可以暂时解决问题,但并不是一种推荐的方式,因为 Goroutines 的运行时长是无法预测的。
一种更好的方式是使用 sync.waitGrop,其可以用于等待一组 Goroutines 任务的完成。
当计数为0时,所有 Goroutines 都会被释放掉。
package main
import (
"fmt"
"sync"
)
func main() {
var waitGroup sync.WaitGroup
for i := 0; i < 5; i++ {
waitGroup.Add(1)
go func(ii int) {
defer waitGroup.Done()
fmt.Println(ii)
}(i)
}
// 等待 waitGroup 计数为 0
waitGroup.Wait()
}
- 如果没有调用
waitGroup.Done,会产生死锁:当两个 Goroutines 都没有被释放,并且相互之间等待对方释放资源的时候就会死锁。 waitGroup.Wait会阻塞mian函数的退出,等待 Goroutines 计数为0时,解开阻塞。