Skip to main content

并发编程

进程与线程

进程

  1. 进程是操作系统资源分配的基本单位。
  2. 进程包含线程,线程在进程中运行。
  3. 每个进程拥有独立的内存空间,可以隔离和其它进程相互之间的耦合和影响。
  4. 进程对其所有线程的创建、运行、销毁进程总览。

线程

  1. 线程是进程的最小执行单元,是一种独立的、并发的顺序执行流。
  2. 线程是程序执行中的基本单元,它是执行代码的实际操作,线程之间可以并发运行。
  3. 一个进程可以有多个线程,并共享进程的资源,进程所有的任务都在线程中执行。
  4. CPU 高速切换进行不同线程进行调度。
  5. 多线程是并发的处理方式之一。
  6. 并发运行上下文切换时成本较高。
内核级
  1. 操作系统运行所需的线程,是操作系统的一部分。
  2. 操作系统的程序必须保证安全与稳定,因此其线程不能随便访问。
  3. 内核态下的线程可以访问计算机所有的软硬件资源。
用户级
  1. 用户应用程序运行所需的线程,是用户程序的一部分。
  2. 用户程序可以访问其自身线程及存储空间。
  3. 如果想要访问内核级线程,必须使用操作系统提供的线程函数库。
  4. 用户级访问内核级,必须将用户态切换成内核态。

协程

  1. 协程(Coroutine)是一种比线程更轻量的执行单元。
  2. 能够实现任务的并发执行,但不像线程那样需要操作系统进行调度,由程序逻辑自行调度。
  3. 减少上下文切换的开销,提高程序的并发性和性能。
  4. 适合用在需要大量并发任务的应用中,特别是在网络编程、Web服务器和数据处理等场景中。
  5. 多个协程可能从属于同一个线程,是一种多对一的关系。
  6. 协程与线程直接是映射关系。
  7. 同一个线程下的多个协程是串行执行方式,不能并发任务处理。

执行方式

串行

  1. 任务的执行必须在上一个任务完成后进行。
  2. 任务顺序的基础就是单个任务执行完成。
  3. 串行是任务执行的一种方式。

并行

  1. 并行任务的执行与完成与其他并行任务的执行和完成情况无关。
  2. 并行也是任务的一种执行方式,一般在多个 CPU 上进行任务的处理。

并发

  1. 并发任务的执行与完成与其他并发任务的执行和完成情况无关。
  2. 并发也是任务的一种执行方式,在单核 CPU 上高频切换任务进行执行。
  3. 高并发并非一定要使用多线程,也可使用多进程。

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。

基本原理

  1. CPU 通过系统调度器调度 Machine,Processor 可以创建多个,一个 Processor 同一时间只能对应一个 Machine,但 Machine 可以没有 Processor 对应,即空线程。
  2. 每个 Processor 都有自己的本地运行队列(Local Run Queue,即 LRQ),该队列装载 Goroutines 任务,LRQ 最多可装载 256 个 Goroutines 任务。
  3. 当 Processor 的 LRQ 列没有 Goroutines 任务时,会从全局运行队列(Global Run Queue,即 GRQ)中取 Goroutines 任务。
  4. 多个 Processor 共享 GRQ,当一个Processor从 GRQ 取 Goroutines 任务时,会对 GRQ 进行加锁,取完之后再解锁,主要是为了避免在取 Goroutines 任务时出现混乱。
  5. 当一个 Processor 的 LRQ 和 GRQ 都没有 Goroutines 任务时,会从其他的 Processor 的 LRQ 中窃取(Work Stealing)一半的 Goroutines 任务到自己的 LRQ。在窃取时,会对被窃取的 Processor 进行加锁,窃取完之后再解锁,目的是为了避免多个 Processor 同时窃取同一个 Processor 的 Goroutines 任务时而产生竞争态。
  6. 当产生一个新的 Goroutine 时,会被分配到一个 Processor,如果这个 Processor 的本地队列已满,调度器会将这个 Processor 一半的 Goroutines 放入 GRQ,如果 LRQ 和 GRQ 都满了,那么会创建一个新的 Processor 进行新的 Goroutine 的调度。
  7. 如果一个 Machine 阻塞了,那么对应的 Processor 可能会创建一个新的 Machine 或切换到另一个 Machine 上去处理新的 Goroutine 任务。
  8. 一个 Goroutine 最多占用 CPU 10ms,如果超过 10ms,那么必须让给下一个 Goroutine 任务。
  9. 以上过程是循环往复的,直到处理完所有 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:mainfunc1func2

Goroutine 的 Processor 的调度机制决定了每个 Goroutine 的执行时机。考虑如下代码:

package main

func main() {
for i := 0; i < 100; i++ {
go func() {
println(i)
}()
}
}

以上代码并不会按照预期的顺序输出i,并且可能存在重复。

为了解决上述问题,可使用闭包机制或 for range

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)
}

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()
}
  1. 如果没有调用 waitGroup.Done,会产生死锁:当两个 Goroutines 都没有被释放,并且相互之间等待对方释放资源的时候就会死锁。
  2. waitGroup.Wait 会阻塞 mian 函数的退出,等待 Goroutines 计数为 0 时,解开阻塞。