GO语言并发编程入门:Goroutine、Channel、Context、并发安全、GMP调度模型

1.GO并发介绍

并发:多线程程序在一个核的cpu上运行。 并行:多线程程序在多个核的cpu上运行。 由上可知并发不是并行,并行是直接利用多核实现多线程的运行,并发则主要由切换时间片来实现”同时”运行,go可以设置使用核数,以发挥多核计算机的能力。

Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。Go语言的并发编程特点主要体现在Goroutine协程和Channel通道的使用上。

  • Goroutine协程:Goroutine是Go语言中的并发执行单位。它是一种轻量级的协程,由负责整个Go程序的执行的底层系统组件Go运行时(Go runtime)调度和管理。在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。与传统的线程、协程相比,Goroutine的创建和销毁代价非常小,可以高效地创建大量的Goroutine,Goroutine 奉行通过通信来共享内存,而不是共享内存来通信。Goroutine4~5KB的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是go高并发的根本原因。
  • Channel通道:通道是Goroutine之间进行安全通信和数据共享的机制。它提供了同步和互斥的功能,确保数据的有序传输和访问。通过通道,不同的Goroutine可以安全地进行数据交换和共享状态。

Goroutine是一种特殊的协程,这是因为普通的协程和操作系统线程是多对一的关系,而在Go语言中,Goroutine和操作系统线程是多对多的关系。具体来说:

  • 一个操作系统线程(OS Thread)可以对应多个Goroutine。
  • Go程序可以同时使用多个操作系统线程,这使得Go语言能够充分利用多核处理器的计算能力。
  • Go运行时调度器(Go Scheduler)负责将多个Goroutine调度到少量的操作系统线程上执行,并处理它们之间的通信。

2.GO并发编程

2.1 父子协程

在Go语言中,可以通过创建协程(Goroutine)来实现并发执行的任务。当父协程创建一个子协程时,父协程和子协程是相互独立的并发执行单元。父协程可以继续执行其他操作,而不需要等待子协程完成。子协程会在创建后立即开始执行,与父协程并发执行。父协程和子协程之间不存在直接的调用关系,它们是相互独立的执行流程。父协程的结束不会影响子协程的执行。即使父协程结束,子协程仍然可以继续执行,直到完成或被终止。父协程和子协程之间是独立的执行上下文,彼此之间的运行状态不会相互影响。 然而,需要注意的是,如果主协程(即main函数所在的协程)结束了,整个程序会终止,所有的协程也会被强制结束。这意味着如果主协程提前结束,尚未完成的子协程也会被中止。因此,在使用协程进行并发编程时,我们需要确保主协程不会过早地结束,以确保子协程能够完成任务,可以考虑采用以下方法:

  1. 使用time.Sleep使协程睡眠确保并发子协程完成
  2. 使用sync.WaitGroup等待组确保并发子协程完成

2.1.1 使用time.Sleep使协程睡眠确保并发子协程完成

time包提供了时间相关的功能,其中最常用的是time.Sleep函数,它可以让当前的Goroutine休眠一段时间。通过结合Goroutine和time.Sleep,我们可以实现协程的并发执行。

go

复制代码
package main
import (
	"fmt"
	"time"
)
func main() {
	go task("Task 1")  // 启动协程1
	go task("Task 2")  // 启动协程2
	// 主协程休眠一段时间,确保协程有足够的时间执行
	time.Sleep(3 * time.Second)
}
func task(name string) {
	for i := 0; i < 5; i++ {
		fmt.Println(name+":", i) // 打印任务名称和当前迭代值
		time.Sleep(500 * time.Millisecond)
	}
}

在上面的示例中,我们通过启动两个协程(task1和task2)来实现并发执行。主协程(main函数)休眠3秒钟,确保协程有足够的时间执行。这样,我们就实现了协程的并发执行。

2.1.2 使用sync.WaitGroup等待组确保并发子协程完成

sync.WaitGroup文档介绍:draveness.me/golang/docs… sync包提供了一些同步原语,如WaitGroup等待组,它可以用来等待一组协程的完成。通过WaitGroup,类似于操作系统中的PV信号量,可以实现协程的并发执行和同步等待。

go

复制代码
package main
import (
	"fmt"
	"sync"
)
func main() {
	var wg sync.WaitGroup
	wg.Add(2) // 设置等待组的计数器为2,表示有两个协程需要等待
	go func() {//开启协程1
		defer wg.Done() // 协程完成后调用Done方法,减少等待组的计数器
		task("Task 1") 
	}()
	go func() {//开启协程1
		defer wg.Done() // 协程完成后调用Done方法,减少等待组的计数器
		task("Task 2") 
	}()
	wg.Wait() // 等待所有协程完成,完成后才结束main协程
}
func task(name string) {
	for i := 0; i < 5; i++ {
		fmt.Println(name+":", i) // 打印任务名称和当前迭代值
	}
}

在上述示例中,我们使用sync包中的WaitGroup来实现协程的并发执行和同步等待。通过调用wg.Add方法设置等待组的计数器为2,然后在每个协程中使用defer wg.Done()来减少计数器。最后,通过wg.Wait()等待所有协程完成。

2.2 Channel实现并发与协程通信

Channel文档介绍:draveness.me/golang/docs… 在并发编程中,单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义,对共享资源的正确访问需要精确的控制。 在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而Go语言却另辟蹊径,它将共享的值通过Channel传递(实际上多个独立执行的线程很少主动共享资源)。channel像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序,可以让一个goroutine发送特定值到另一个goroutine的通信机制。在任意给定的时刻,最好只有一个Goroutine能够拥有该资源,数据竞争从设计层面上就被杜绝了。 这也是Go语言作者提出的并发编程哲学:不要通过共享内存来通信,而应通过通信来共享内存。

虽然我们在 Go 语言中也能使用共享内存加互斥锁进行通信,但是 Go 语言提供了一种不同的并发模型,即通信顺序进程(Communicating sequential processes,CSP)。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Goroutine 之间会通过 Channel 传递数据。

CSP(Communicating sequential processes)是一种并发编程模型,它强调通过通信来实现并发。在CSP中,程序被分解成一组独立的进程,这些进程通过通道进行通信。通道是一种同步的通机制,它允许进程之间传递数据。CSP模型的一个重要特点是,进程之间的通信是通过发送和收消息来实现的,而不是通过共享内存。 CSP模型的一个优点是,它可以避免一些常见的并发编程问题,例如死锁和竞态条件。这是因为CSP模型中的进程是独立的,们不会相互干扰或阻塞彼此。此外,CSP模型还可以使并发程序更易于理解和调试,因为它们的行为是通过进程之间的通信来定义的。

在这里插入图片描述 上图中的两个 Goroutine,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信。

2.2.1 使用Channel实现协程并发并进行协程通信

go

复制代码
package main
import "fmt"
func main() {
	ch := make(chan int) // 创建一个int类型的通道

	go func() {
		ch <- 10 // 发送数据到通道
	}()

	data := <-ch // 从通道接收数据
	fmt.Println(data)
}

在上述示例中,我们通过make函数创建了一个无缓冲的int类型的通道ch,无缓冲的通道只有在有人接收值的时候才能发送值。就。因此,在匿名函数中使用ch <- 10将数据10发送到通道,就必须还要通过data := <-ch从通道中接收数据并打印。

2.2.2 使用通道实现生产者消费者模式

生产者消费者模式是并发编程中的常见模式,其中生产者生成数据并将其放入通道,而消费者从通道中取出数据并进行处理。以下是多对多的生产者消费者模式

go

复制代码
package main

import (
"fmt"
"sync"
)

// producer 向通道发送数据
func producer(ch chan<- int, id int) {
	for i := 0; i < 5; i++ {
	ch <- i * id
	}
}
// consumer 从通道接收数据
func consumer(ch <-chan int, id int) {
	for i := range ch {
	fmt.Printf("消费者 %d 接收到数据: %d\n", id, i)
	}
}

func main() {
	ch := make(chan int, 10)
	// wg 用于等待所有协程完成
	var wg sync.WaitGroup
	// producerWg 用于等待所有生产者协程完成,根据该等待组判断何时关闭通道
	var producerWg sync.WaitGroup
	
	// 启动多个生产者协程
	for i := 0; i < 3; i++ {
		producerWg.Add(1) // 增加生产者等待组计数器
		wg.Add(1) // 增加总等待组计数器
		go func() {
		producer(ch, i+1)
		wg.Done() // 减少总等待组计数器
		producerWg.Done() // 减少生产者等待组计数器
	}()
}

	// 启动多个消费者协程
	for i := 0; i < 3; i++ {
		wg.Add(1) // 增加总等待组计数器
		go func() {
		consumer(ch, i+1)
		wg.Done() // 减少总等待组计数器
	}()
	}
	// 等待所有生产者协程完成后关闭通道
	producerWg.Wait()
	close(ch)
	// 等待所有协程完成
	wg.Wait()
}

在上面的示例中,我们创建了一个int类型的通道ch,并将其作为参数传递给生产者和消费者协程。生产者协程通过ch <- i将数据发送到通道,并打印相关信息。消费者协程使用num := range ch循环接收通道中的数据,并进行处理。通过关闭通道close(ch)来通知消费者协程数据已经全部发送完毕。

2.2.3 select多路复用

在某些场景下我们需要同时从多个通道接收数据,Go内置了select关键字,可以同时响应多个通道的操作。

  • select可以同时监听一个或多个channel,直到其中一个channel ready
  • 如果多个channel同时ready,则随机选择一个执行
  • 可以用于判断管道是否存满
go

复制代码
package main
import (
    "fmt"
    "time"
)
func main() {
    // 创建两个缓冲区大小为 1 的通道
    ch1 := make(chan int, 1)
    ch2 make(chan int, 1)
    // 向 ch1 发送数据的 goroutine
    go func() {
        time.Sleep(2 * time.Second)
        ch1 <- 1
    }()
    // 向 ch2 发送数据的 goroutine
    go func() {
        time.Sleep(1 * time.Second)
        ch2 <- 2
    }()
    // 无限循环,等待从通道接收数据
    for {
        select {
        case x := <-ch1:
            fmt.Println("Received from ch1:", x)
            return
        case x := <-ch2:
            fmt.Println("Received from ch2:", x)
            return
        default:
            // 如果两个通道都已满,则打印一条消息并等待 500 毫秒
            fmt.Println("All channels are full")
            time.Sleep(500 * time.Millisecond)
            break
        }
    }
}

2.3 并发安全性与实现

在并发编程中,要注意并发安全性。并发安全性指的是在并发环境下,多个协程访问共享资源时,能够正确地进行同步和互斥,避免数据竞争和不一致的结果。

在Go语言中,可以通过以下方式实现并发安全:

  1. 使用互斥锁(Mutex):互斥锁是一种最基本的同步原语,它可以保证同一时刻只有一个 goroutine 可以访问共享资源在访问共享资源之前,需要先获取互斥锁,访问完成后再释放互斥锁。这样可以避免多个 goroutine 同访问共享资源导致的数据竞争问题。
  2. 使用读写锁(RWMutex):读写锁是一种特殊的互斥锁它可以同时支持多个 goroutine 对共享资源进行读操作,但只能有一个 goroutine 进行写操作。在读写锁中,读操作和写操作是互斥的,但多个读操作间是不互斥的。这样可以提高并发性能,减少锁的竞争。
  3. 使用原子操作(Atomic):原子操作是一种特殊的操作它可以保证在多个 goroutine 同时访问同一个变量时,对该变量的读写操作是原子的。原子操作可以避免数据竞争问题,但只适用于简单的数据类型,如整数、指针等。
  4. 使用通道(Channel):通道是一种特殊数据结构,它可以在多个 goroutine 之间传递数据,并且保证传递的数据是并发安全的。在使用通道时需要注意通道的缓冲区大小和通道的方向,以避免死锁和数据竞争问题。
  5. 使用 sync 包中的其他同步原语:Go语言的标准库中提供了许多同步原语,如条件变(Cond)、信号量(Semaphore)等,可以根据具体的需求选择合适的同步原语来实现并发安全。

2.3.1 使用互斥锁(Mutex)

Mutex文档介绍:draveness.me/golang/docs…

go

复制代码
package main

import (
	"fmt"
	"sync"
)
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
    for i := 0; i < 5000; i++ {
        lock.Lock() // 加锁
        x = x + 1
        lock.Unlock() // 解锁
    }
    wg.Done()
}
func main() {
    wg.Add(2)
    go add()
    go add()
    wg.Wait()
    fmt.Println(x)
} 

2.3.2 使用读写锁(RWMutex)

RWMutex文档介绍:draveness.me/golang/docs… 读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

go

复制代码
package main

import (
	"fmt"
	"sync"
)
var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)
func write() {
    rwlock.Lock() // 加写锁
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
    rwlock.Unlock()                   // 解写锁
    wg.Done()
}
func read() {
    rwlock.RLock()               // 加读锁
    time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
    rwlock.RUnlock()             // 解读锁
    wg.Done()
}
func main() {
    start := time.Now()
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go write()
    }
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go read()
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

2.3.3 使用原子操作(Atomic)

go

复制代码
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

var counter int64 // 定义一个int64类型的计数器变量

func main() {
	var wg sync.WaitGroup
	// 启动10个goroutine,每个goroutine调用atomic.AddInt64方法自增计数器
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			atomic.AddInt64(&counter, 1)
		}()
	}
	wg.Wait()
	fmt.Println("Counter value:", counter)
}

2.3.4 使用通道(Channel)定义goroutine 池限制并发度

go

复制代码
package main

import (
	"fmt"
	"time"
)
// 定义go协程池结构体
type GoPool struct {
	MaxLimit  int           // 最大并发度
	tokenChan chan struct{} // 控制并发执行的令牌通道
}
func main() {
	gopool := NewGoPool(3) // 最大并发度为 3
	defer gopool.Wait()
	//开启10个线程,但最大并发度为3
	for i := 0; i < 10; i++ {
		taskID := i
		gopool.Submit(func() {
			// 模拟任务处理逻辑
			fmt.Printf("任务 %d 开始执行\n", taskID)
			time.Sleep(time.Second)
			fmt.Printf("任务 %d 完成\n", taskID)
		})
	}
}

// NewGoPool 创建一个 GoPool 对象,max 设置最大并行度
func NewGoPool(max int) *GoPool {
	p := &GoPool{}
	p.MaxLimit = max
	p.tokenChan = make(chan struct{}, p.MaxLimit)
	for i := 0; i < p.MaxLimit; i++ {
		p.tokenChan <- struct{}{} // 初始化 tokenChan
	}
	return p
}

// Submit 提交任务
// 每个任务都会异步执行,并且会在执行完后释放一个令牌
func (gp *GoPool) Submit(fn func()) {
	token := <-gp.tokenChan // 如果没有可用令牌,则会阻塞

	go func() {
		fn()
		gp.tokenChan <- token // 执行完成后释放令牌,以便其他任务可以获得执行机会
	}()
}

// Wait 等待所有任务执行完成
func (gp *GoPool) Wait() {
	for i := 0; i < gp.MaxLimit; i++ {
		<-gp.tokenChan // 等待所有令牌被释放
	}

	close(gp.tokenChan) // 关闭令牌通道,释放资源
}

func (gp *GoPool) Size() int {
	return len(gp.tokenChan) // 返回当前令牌通道中的可用令牌数量
}

这段代码创建了一个名为 "GoPool" 的结构体,它实现了一个简易的 goroutine 池来限制并发度。结构体包含最大并发度和控制同一时间能够执行 goroutine 数量的令牌通道。以下是函数及其功能:

  • NewGoPool(max int) *GoPool:创建一个新的 GoPool 对象,max 用于设置最大并行度。该函数返回了 GoPool 结构体的指针。
  • Submit(fn func()):向 goroutine 池的任务队列提交一个新的任务,fn 代表但不限于该任务函数。每个任务都会异步执行,并且在执行完成后释放一个令牌。
  • Wait():等待所有提交的任务执行完成。 这等待所有令牌被释放以及 TaskQueue 中的所有任务被处理。
  • Size():返回当前令牌通道中的可用令牌数量。

main 函数演示了如何使用 GoPool 来异步运行一组任务。具体而言,使用 for 循环提交了十个匿名函数。 这些函数模拟处理逻辑,并在开始和结束时打印与任务 ID 相关的消息。在这种情况下,goroutine 池的最大并行度设置为3,这意味着在任何给定时间,只能有三个任务同时运行。

2.3.5 使用 sync 包中的其他同步原语(以信号量semaphore为例)

semaphore文档介绍:draveness.me/golang/docs…

go

复制代码
package main
import (
	"fmt"
	"olang.org/x/sync/semaphore"
	"sync"
)
var sem *semaphore.Weighted = semaphore.NewWeighted(1) // 创建一个权重为1的信号量
func main() {
	var wg sync.WaitGroup

	// 启动10个goroutine,每个goroutine获取信号量并输出信息
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			sem.Acquire(nil, 1) // 获取信号量
			defer sem.Release(1) // 释放信号量
			fmt.Println("Goroutine", id, "is running")
		}(i)
	}
	wg.Wait()
}

2.4 recovery恢复程序运行

在 Go 语言中,recovery 是一种机制,用于在程序发生 panic 时恢复程序的执行。当程序发生 panic 时,recovery 可以获 panic,并在程序崩溃前进行一些处理,例如输出日志、释放资源等。 在Go语言中,可以使用panic()函数来抛出一个异常,从而在协程中添加错误。例如,我们可以在子协程中添加一个除数为0的错误,如下所示:

go

复制代码
package main

import (
    "fmt"
    "time"
)
func childRoutine() {
    defer func() {
        if r := recover(); r != nil {
            // 处理子协程中的错误
            fmt.Println("子协程出现错误:", r)
        }
    }()
    // 子协程中的代码
    a, b := 10, 0
    c := a / b // 除数为0,会抛出异常
    fmt.Println("子协程执行完毕", c)
}
func main() {
    // 启动子协程
    go childRoutine()
    // 主协程中的代码
    time.Sleep(time.Millisecond * )
    fmt.Println("主协程执行完毕")
}

在这个示例中,我们在子协程中定义了两个变量ab,并将b的值设置为0然后,我们尝试将a除以b,这会抛出一个异常。在defer语句中,我们使用recover()函数捕获这个异常,并在控制台输出错误信息。

当我们运行这个程序时,子协程会抛出一个异常,但是由于我们使用了recover()函数来捕获异常,程序不会崩溃,而是会在控制台输出错误信息,并继续执行其他协程。这样,我们就可以在协程中添加错误,避免程序崩溃,更好地管理协程并处理异常,确保程序的稳定性。

3.Context上下文

Context文档介绍:draveness.me/golang/docs… 在这里插入图片描述

在 Goroutine 构成的树形结构中对信号进行同步以减少计算资源的浪费是 context.Context 的最大作用。Go 服务的每一个请求都是通过单独的 Goroutine 处理的,HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。

如下图所示,我们可能会创建多个 Goroutine 来处理一次请求,而 context.Context 的作用是在不同 Goroutine 之间同步请求特定数据、取消信号以及处理请求的截止日期。 在这里插入图片描述

  • context 主要用于控制协程的生命周期和传递上下文信息。通过 context,我们可以在协程之间传递一些额外的信息,例如请求 ID、超时时间、取消信号等。同时,context 还提供了一种机制来取消或超时协程,以避免因为某个协程长时间阻塞而导致整个程序变得不可用。
  • channel 主要用于协程的通信和同步。通过 channel,我们可以在协程之间传递数据和信号,例如任务、消息、事件等。同时, 还提供了一种机制来同步协程,以避免因为协程之间的竞争而导致数据不一致或死锁等问题。

以下是 context 在 Go 并发编程中的一些应用:

  1. 传递元数据:context 可以用于在多个 Goroutine 之间传递请求范围的元数据,例如请求 ID、认证令牌等。
  2. 取消操作:context 可以用于在多个 Goroutine 之间传递取消信号。当一个操作需要被取消时,可以使用 context 通知所有相关的 Goroutine 停止工作。
  3. 超时控制:context 可以用于设置操作的超时时间。当操作超过指定的时间限制时,context 会自动发送取消信号,使得相关的 Goroutine 可以及时停止工作。

以下是实现了这三种功能的简单代码示例

go

复制代码
package main
import (
	"context"
	"fmt"
	"time"
)
type keyType string
const key keyType = "value"
// worker 是一个 Goroutine,它会不断检查 context 中的元数据值
// 当元数据值达到 5 或超过 2 秒时,Goroutine 将被取消。
func worker(ctx context.Context) {
	select {
	case <-ctx.Done():
		fmt.Println("已超时,结束协程")
		return
	default:
		id := ctx.Value(key).(int) // 从 context 中获取数据值
		if id >= 5 {
			fmt.Println("id超过5,结束协程")
			return
		}
		fmt.Printf("接收到查询id为%d\n", id)
		time.Sleep(500 * time.Millisecond)
	}
}
func main() {
	// 创建一个带有超时功能的 context,超时时间为 2 秒
	//context.Background() 作为根 context,返回回一个空的 context
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	// 使用 context.WithValue 为 context 添加一个名为 value 的元数据,初始值为 0
	ctx = context.WithValue(ctx, key, 0)
	// 逐渐增加 context 中的元数据值,并在循环结束后等待 3 秒,以确保 Goroutine 有足够的执行。
	for i := 1; i <= 10; i++ {
		time.Sleep(300 * time.Millisecond)
		ctx = context.WithValue(ctx, key, i) // 更新 context 中的元数据值
		// 启动 worker Goroutine
		go worker(ctx)
	}
	time.Sleep(3 * time.Second)
}

4. GMP调度模型

GMP模型是一个高效的并行计算模型,它是Go语言运行时系统的一部分,负责将Go程序中的goroutine分配给多个处理器,以实现并行计算,从而提高程序的性能。 GMP调度模型支持任务窃取(task stealing)机制。当一个处理器空闲时,它可以从其他处理器的工作队列中窃取一个gor进行处理。这种机制可以使得goroutine的负载更加均衡,从而提高程序的性能。 GMP调度模型支持动态调整处理器数量的功能。当程序的负载发生变化时,调度器可以动态地增加或减少处理器的数量,以适应程序的需求。这种机制可以使得计算资源的利用更加高效。

GMP介绍文档:www.topgoer.cn/docs/golang…

4.1GMP 模型介绍

M(machine)

  • M代表着真正的执行计算资源,可以认为它就是os thread(系统线程)。
  • M是真正调度系统的执行者,每个M就像一个勤劳的工作者,总是从各种队列中找到可运行的G,而且这样M的可以同时存在多个。
  • M在绑定有效的P后,进入调度循环,而且M并不保留G状态,这是G可以跨M调度的基础。

P(processor)

  • P表示逻辑processor,是线程M的执行的上下文。
  • P的最大作用是其拥有的各种G对象队列、链表、cache和状态。

G(goroutine)

  • 调度系统的最基本单位goroutine,存储了goroutine的执行stack信息、goroutine状态以及goroutine的任务函数等。
  • 在G的眼中只有P,P就是运行G的“CPU”。
  • 相当于两级线程 在这里插入图片描述
  • 全局队列(Global Queue):存放等待运行的 G。
  • P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
  • P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
  • M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

4.2调度器的设计策略

  1. 复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
    • work stealing 机制:​当本线程无可用的G时,先从全局队列中取,如果全局队列中也没有的话,就取其他线程绑定的P偷取P,而不是销毁线程
    • hand off 机制:​ 当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。
  2. 利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。
  3. 抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
  4. 全局 G 队列:在新的调度器中依然有全局 G 队列,当绑定的P中没有G可以执行的时候,就去全局G队列中找

4.3 go func () 调度流程

在这里插入图片描述

  1. 我们通过 go func () 来创建一个 goroutine;
  2. 有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;
  3. G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,则首先从全局获取,若全局也为空就会向其他的 MP 组合偷取一个可执行的 G 来执行;
  4. 一个 M 调度 G 执行的过程是一个循环机制;
  5. 当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;
  6. 当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。

4.4调度器的生命周期

在这里插入图片描述

  • M0:m0就是进程启动后的初始线程
  • G0:代表着初始线程的stack
作者:Pistachiout
链接:https://juejin.cn/post/7265939798794764322
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

GO语言并发编程入门:Goroutine、Channel、Context、并发安全、GMP调度模型
标签: