go--并发学习

一、Goroutine:轻量级并发单元

  1. 基本概念

    • Goroutine是Go的轻量级线程,由Go运行时(Runtime)管理,而非操作系统线程。

    • 启动成本极低(初始栈仅2KB,可动态扩展),可轻松创建数百万个Goroutine。

  2. 与OS线程的区别:

    • 内存占用:OS线程栈通常MB级,Goroutine栈KB级。

    • 调度方式:OS线程由内核调度(上下文切换成本高),Goroutine由Go运行时调度(用户态调度,无系统调用)。

    • 协作式调度:Goroutine主动让出CPU(如通过channel、time.Sleep等),避免抢占式调度的复杂性。

二、GMP模型

Go的调度器采用M:N 模型,将M个Goroutine映射到N个OS线程上,核心组件如下:

  • G(Goroutine):保存执行栈、状态等信息。
  • M(Machine):对应一个OS线程,负责执行G代码。由操作系统管理。
  • P(Processor):代表了M所需的上下文环境,也是处理用户级代码逻辑的处理器,维护本地Goroutine队列(Local Queue),一个P绑定一个M。它负责衔接M和G的调度上下文,将等待执行的G与M对接。
    在这里插入图片描述

但为何go的并发如此高效呢?那么就要从它调度的策略去了解了

调度策略

调度流程

  • 创建一个G对象,加入到本地队列或者全局队列
  • 如果有空闲的P,则创建一个M
  • M会启动一个底层线程,循环执行能找到的G任务
  • G任务的执行顺序是先从本地队列找,本地没有则从全局队列找(一次性转移(全局G个数/P个数)个,再去其它P中找(一次性转移一半)。
  • G任务执行是按照队列顺序(即调用go的顺序)执行的。

调度层级结构

三级队列体系:

  • P的本地队列(Local Queue):每个P维护一个最多256个G的队列(无锁环形队列),优先处理本地任务。

  • 全局队列(Global Queue):所有P共享的队列,当本地队列满时,新G会进入全局队列(需要加锁访问)。

  • 等待队列(Channel Wait Queue):当G因Channel操作阻塞时,会被挂起到对应的发送/接收队列。

G的分配策略

  • 新G的分配

    • 新创建的G(如go func())优先放入当前P的本地队列。
    • 若本地队列已满,则将本地队列中前一半的G(共128个)移动到全局队列,腾出空间存放新G。
  • 唤醒的G

    • 因Channel或time.Sleep阻塞后被唤醒的G,会重新放入原P的本地队列(避免迁移成本)。

Work Stealing(任务窃取)

目的:

  • 触发条件

    • 当P的本地队列为空时,P会尝试从以下位置窃取G:

      • 全局队列:定期检查全局队列(约每61次调度一次),避免全局队列饥饿。

      • 其他P的本地队列:随机选择一个P,从其本地队列尾部窃取一半的G(减少竞争)。

系统调用处理

  • 阻塞型系统调用(如文件I/O)

    1. 当G发起阻塞系统调用时,M会与当前P解绑。

    2. P被释放后,可能被其他空闲的M获取(或创建新M),继续执行其他G。

    3. 系统调用完成后,M尝试为G寻找可用的P:

      • 成功:将G放入P的本地队列。

      • 失败:将G放入全局队列,M进入休眠状态。

  • 非阻塞型系统调用(如网络I/O通过netpoller):

    • 通过异步I/O机制(如epoll/kqueue)挂起G,M继续执行其他G,无需释放P。

自旋线程(Spinning Thread)

什么是自旋状态

指当一个goroutine尝试获取一个已经被其他goroutine持有的锁时,它会进入一种持续检查锁状态的状态,而不是立即进入阻塞状态。这种机制被称为自旋锁(Spinlock)

  • 工作原理

    • 当一个goroutine请求自旋锁时,如果锁已被其他goroutine占用,那么当前goroutine会不断检查锁的状态,直到锁被释放,而非立即休眠。
  • 优点

    • 自旋线程数量上限为GOMAXPROCS(P的数量),避免CPU空转浪费。
    • 自旋锁避免了阻塞和上下文切换,从而减少了系统开销‌
    • 在锁持有时间非常短的情况下,自旋锁可以提高效率‌
  • 缺点

    • 如果锁被持有的时间较长,goroutine会不断检查锁的状态,这会浪费CPU资源‌
  • 适用场景

    • 快速响应新任务(如Channel消息到达或定时器触发),减少任务延迟

抢占式调度(Preemption)

该策略并非go诞生就采取的,在go1.14之前是采取协作式调度,但是协作式调度有一个缺陷——Goroutine需要主动让出CPU(如通过channel、time.Sleep或系统调用),否则会独占线程导致其他Goroutine“饿死”。

协作式调度缺陷

Goroutine必须主动触发调度点(如函数调用、channel操作)才能让出CPU。
若一个Goroutine执行无函数调用的死循环,其他Goroutine将被无限阻塞

func main() {
    go func() {
        for { /* 无函数调用、无channel操作、无系统调用 */ } // 阻塞其他Goroutine
    }()
    time.Sleep(time.Second)
    fmt.Println("永远不会执行") // 在Go 1.13及之前无法输出
}

为了解决这个问题,go在1.14后通过基于信号的异步抢占实现了更公平的调度。
目标则是:

  • 强制长时间运行的Goroutine让出CPU,避免“饿死”其他任务。

  • 支持高并发下的公平性,提升程序响应速度。

抢占式调度原理

通过异步信号和栈扩张检查两种机制实现:

基于信号的异步抢占(Go 1.14+)
  • 核心流程
    1. 监控线程sysmon检测长时间运行的G:

      • sysmon是Go运行时(Runtime)的后台线程,无需绑定P。

      • 每隔约10ms检查所有运行中的Goroutine。

      • 若某个G在同一P上运行超过10ms,标记其为“可抢占”。

  1. 发送抢占信号SIGURG:

    • 向目标G所在的OS线程(M)发送SIGURG信号。

    • SIGURG是用户态信号,默认被Go运行时保留,不影响应用程序逻辑。

  2. 信号处理函数触发调度:

    • OS线程收到SIGURG后,中断当前执行的G,跳转到Go的信号处理函数。

    • 保存当前G的上下文(寄存器、栈指针等),并修改其PC(Program Counter)为异步抢占函数。

  3. 强制让出CPU:

    • 异步抢占函数调用调度逻辑,将当前G的状态从Grunning改为Grunnable。

    • 将该G放回全局队列或原P的本地队列,等待下次调度。

  • 技术细节

    1. sysmon内部循环:

      • 记录所有P的G任务计数schedtick,schedtick会在每执行一个G任务后递增。
      • 如果检查到 schedtick一直没有递增,说明P一直在执行同一个G任务,如果超过一定的时间(10ms),在G任务的栈信息里面加一个标记。
      • G任务在执行的时候,如果遇到非内联函数调用,就会检查一次标记,然后中断自己,把自己加到队列末尾,执行下一个G。
      • 如果没有遇到非内联函数(有时候正常的小函数会被优化成内联函数)调用,会一直执行G任务,直到goroutine自己结束;如果goroutine是死循环,并且GOMAXPROCS=1,阻塞。
    2. 信号选择:使用SIGURG而非其他信号(如SIGSEGV),因为:

      • 该信号通常不被应用程序使用,避免冲突。

      • Linux/BSD等系统支持实时信号(Real-time Signal),适合异步处理。

栈扩张检查(Stack Expansion Preemption)
  • 原理

    • 当Goroutine执行函数调用时,Go会检查栈空间是否需要扩展。

    • 在栈扩展的路径中,插入抢占检查逻辑,若发现G需要被抢占,则主动让出CPU。

  • 流程

    • 函数调用触发栈检查(如调用新函数、局部变量分配大量栈空间)。

    • 检查当前G是否被标记为“可抢占”。

    • 若可抢占,调用调度器gopark挂起当前G。

局限性
  1. 非完全抢占:

    • 无法抢占所有计算密集型代码(如无函数调用的循环)。

    • 需开发者避免编写长时间独占CPU的代码。

  2. 信号处理依赖OS支持:

    • Windows系统在Go 1.14-1.19中不支持异步抢占,需等待OS线程自然让出(如通过time.Sleep)。

    • Go 1.20后Windows通过SuspendThread实现类似机制。

  3. 调试干扰:

    • 抢占可能影响调试器(如GDB)对程序的控制流跟踪。

三、Channel:通信与同步

channel一个类型管道,通过它可以在goroutine之间发送和接收消息。它是Golang在语言层面提供的goroutine间的通信方式。Golang并发的核心哲学是不要通过共享内存进行通信。

分类

  • 无缓冲通道:发送方和接收方必须同时就绪,否则被加入等待队列。
    在这里插入图片描述

  • 缓冲通道:缓冲区满时发送方阻塞,空时接收方阻塞。
    在这里插入图片描述

通道实例 := make(chan 数据类型) // 不带缓冲的chan
通道实例 := make(chan 数据类型,数量) // 带缓冲的chan

细节

无缓冲的通道又称为阻塞的通道。我们来看一下下面的代码:

func main() {
	ch := make(chan int)
	ch <- 10
	fmt.Println("发送成功")
}

上面这段代码能够通过编译,但是执行的时候会出现以下错误:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        .../src/main.go:8 +0x54

为什么会出现deadlock错误呢?

因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲的通道只有在有人接收值的时候才能发送值。简单来说就是无缓冲的通道必须有接收才能发送。

func recv(c chan int) {
	ret := <-c
	fmt.Println("接收成功", ret)
}
func main() {
	ch := make(chan int)
	go recv(ch) // 启用goroutine从通道接收值
	ch <- 10
	fmt.Println("发送成功")
}

无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道

死锁

该死锁是channel自己的报错,不是说go不允许死锁出现。

需要有读写两个协程共同工作时,才能避免死锁出现。当只有一个协程进行写或读的操作,都会触发死锁。

即使有读写两个协程,如果其中一个执行完,另一个还没有,此时没有关闭管道的话,也是会报死锁错误,如以下代码:

var wg sync.WaitGroup

func main() {
	pipline := make(chan int)
	wg.Add(2)
	go func() {
		defer wg.Done()
		for i := 0; i < 10; i++ {
			time.Sleep(time.Second)
			pipline <- i
		}
	}()

	go func() {
		defer wg.Done()
		for v := range pipline {
			fmt.Println("v:", v)
		}
	}()

	wg.Wait()

}

解决方法很简单,只要在发送完数据后,手动关闭信道,告诉 range 信道已经关闭,无需等待就行。

func main() {
	pipline := make(chan int)
	wg.Add(2)
	go func() {
		defer wg.Done()
		for i := 0; i < 10; i++ {
			time.Sleep(time.Second)
			pipline <- i
		}
		close(pipline) // 关闭chan,循环chan的协程就可以退出循环了,否则因为chan的sendq为空陷入deadlock
	}()

	go func() {
		defer wg.Done()
		for v := range pipline {
			fmt.Println("v:", v)
		}
	}()

	wg.Wait()

}

基本操作

ch := make(chan 数据类型)
// 向通道发送数据
ch <-// 从通道接收数据
<- ch

示例展示:

func main() {
	// 案例1
	ch := make(chan int, 10)
	fmt.Println(len(ch), cap(ch))

	ch <- 1
	ch <- 2
	ch <- 3

	fmt.Println(<-ch) // FIFO
	fmt.Println(<-ch)
	fmt.Println(<-ch)

	// 案例2
	ch2 := make(chan interface{}, 3)
	ch2 <- 100
	ch2 <- "hello"
	ch2 <- Stu{"yuan", 22}

	fmt.Println(<-ch2)
	fmt.Println(<-ch2)
	fmt.Println(<-ch2)

管道的关闭与循环

当向通道中发送完数据时,我们可以通过close函数来关闭通道。但为什么需要关闭通道呢?

原来是不close掉channel是会发生死锁的,原因是遍历一个未关闭的 channel 时,遍历操作会一直尝试从 channel 中读取数据。如果 channel 中没有数据且未关闭,range 操作会阻塞,等待新的数据写入。而由于channel没有写入的协程且没关闭,会一直阻塞形成死锁。
以下是示例展示:

package main

import (
	"fmt"
	"time"
)

func main() {

	ch := make(chan int, 10)
	ch <- 1
	ch <- 2
	ch <- 3

	// 方式1
	go func() {
		time.Sleep(time.Second * 10)
		ch <- 4

	}()

	for v := range ch {

		fmt.Println(v, len(ch))
		// 读取完所有值后,ch的sendq中没有groutine
		if len(ch) == 0 { // 如果现有数据量为0,跳出循环
			break
		}
	}

	close(ch)
	for i := range ch {
		fmt.Println(i)
	}

}

如何判断通道是否关闭

我们可以在读取的时候使用多重返回值的方式,如果返回值是 false 则表示 ch 已经被关闭:

x, ok := <-ch

chan结构细节

通道的结构hchan,源码在src/runtime/chan.go下:

   type hchan struct {
       qcount   uint           // total data in the queue 当前队列里还剩余元素个数
       dataqsiz uint           // size of the circular queue 环形队列长度,即缓冲区的大小,即make(chan T,N) 中的N
       buf      unsafe.Pointer // points to an array of dataqsiz elements 环形队列指针
       elemsize uint16 //每个元素的大小
       closed   uint32 //标识当前通道是否处于关闭状态,创建通道后,该字段设置0,即打开通道;通道调用close将其设置为1,通道关闭
       elemtype *_type // element type 元素类型,用于数据传递过程中的赋值
       sendx    uint   // send index 环形缓冲区的状态字段,它只是缓冲区的当前索引-支持数组,它可以从中发送数据
       recvx    uint   // receive index 环形缓冲区的状态字段,它只是缓冲区当前索引-支持数组,它可以从中接受数据
       recvq    waitq  // list of recv waiters 等待读消息的goroutine队列
       sendq    waitq  // list of send waiters 等待写消息的goroutine队列
    
       // lock protects all fields in hchan, as well as several
       // fields in sudogs blocked on this channel.
       //
       // Do not change another G's status while holding this lock
       // (in particular, do not ready a G), as this can deadlock
       // with stack shrinking.
       lock mutex //互斥锁,为每个读写操作锁定通道,因为发送和接受必须是互斥操作
  }
  
  // sudog 代表goroutine
   type waitq struct {
        first *sudog
        last  *sudog
  }

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值