【Goland】:协程和通道

目录

[1. 协程](#1. 协程)

[1.1 概念](#1.1 概念)

[1.2 常规协程(Coroutine)](#1.2 常规协程(Coroutine))

[1.3 Go中的协程(Goroutine)](#1.3 Go中的协程(Goroutine))

[1.4 GMP模型](#1.4 GMP模型)

[1.4.1 获取和设置P的数量](#1.4.1 获取和设置P的数量)

[1.4.2. 协程的生命周期](#1.4.2. 协程的生命周期)

[1.4.3 协程的调度流程](#1.4.3 协程的调度流程)

[1.4.4 调度类型](#1.4.4 调度类型)

[2. 通道](#2. 通道)

[2.1 概念](#2.1 概念)

[2.2 发送与接收 channel](#2.2 发送与接收 channel)

[2.3 关闭 channel](#2.3 关闭 channel)

[2.4 遍历 channel](#2.4 遍历 channel)

[2.5 只读/只写channel](#2.5 只读/只写channel)

[2.6. select语句](#2.6. select语句)


1. 协程

1.1 概念

1. 进程(Process)

  • 定义 :程序在操作系统中运行的实例,是 资源分配和调度的基本单位

  • 特点

    • 独立的内存空间(代码段、数据段、堆、栈)。

    • 进程之间相互独立,数据共享需要 进程间通信(IPC),如管道、消息队列、共享内存。

    • 创建/销毁/切换开销 最大(需要保存和恢复完整上下文)。

  • 适用场景:任务之间相互独立,可靠性要求高,例如:浏览器主进程和渲染进程、数据库服务进程。

2. 线程(Thread)

  • 定义:操作系统调度的最小执行单元,属于某个进程。

  • 特点

    • 同一进程的线程共享 代码段、数据段、打开文件 等资源。

    • 每个线程有自己的 栈空间和寄存器

    • 切换开销比进程小,但需要处理 同步互斥(避免资源竞争)。

  • 适用场景:计算密集型或 I/O 密集型的并行任务,如 Web 服务器中多线程处理请求。

3. 协程(Coroutine,Go 中的 Goroutine)

  • 定义:用户态的轻量级线程,由语言运行时调度。

  • 特点

    • 创建/销毁/切换成本非常低(比线程轻得多)。

    • 通常运行在单个或少量线程中(M:N 调度模型)。

    • 协作式调度(由语言运行时决定何时切换),而不是操作系统。

    • Go 里使用 Channel 实现安全通信。

  • 适用场景 :高并发场景,如网络服务器(Go 的 net/http)、聊天系统、游戏服务端。

4. 并发(Concurrency) vs 并行(Parallelism)

  • 并发

    • 单核 CPU 通过 时间片切换 执行多个任务,看起来"同时"在跑。

    • 强调 任务的交替推进

    • 举例:一个人同时和多个人聊天,快速在不同对话窗口切换。

  • 并行

    • 多核 CPU 真正同时执行多个任务。

    • 强调 任务的同时运行

    • 举例:多个人同时做不同的菜。

示例:

在Go中,通过在函数或方法的调用前加上go关键字即可创建一个go协程,并让其运行对应的函数或方法。

复制代码
package main

import (
	"fmt"
	"time"
)

func printMsg(msg string) {
	for i := 0; i < 3; i++ {
		fmt.Println(msg, ":", i)
		time.Sleep(time.Millisecond * 500)
	}
}

func main() {
	// 启动一个协程
	go printMsg("goroutine 1")

	// 主协程继续执行
	printMsg("main")

	// 等一会儿让子协程跑完
	time.Sleep(time.Second * 3)
}
结果:
main : 0
goroutine 1 : 0
goroutine 1 : 1
main : 1
goroutine 1 : 2
main : 2
  • 在Go中,当程序启动时会自动创建一个主协程来执行main函数,该协程与其他新创建的协程没有本质的区别,但主协程执行完毕后整个程序会退出,即使其他协程还未执行完毕,也会跟着退出。
  • 如果一个协程在执行过程中触发了panic异常,但没有对其进行捕获,那么会导致整个程序崩溃,因此在协程中也需要通过recover函数对panic进行捕获。

1.2 常规协程(Coroutine)

协程是一种 用户态调度的轻量级执行单元,比线程更轻。

  • 线程由操作系统内核调度;

  • 协程则由 用户态的运行时库或框架 调度;

  • 协程在本质上是 运行在线程里的函数,但它可以在执行过程中主动挂起、切换到其他协程。

特点:

并发而非并行

  • 因为所有协程都绑定在同一个线程上,所以某一时刻只有一个协程在跑。

  • 多个协程通过切换实现"看似同时",实际上仍是顺序执行。

  • 所以它实现的是 并发 ,而不是 并行

阻塞传递问题

  • 如果某个协程调用了一个 阻塞的系统调用 (例如阻塞的 readsleep 等),那么它所依赖的线程就会挂起;

  • 由于线程被挂起,运行在该线程上的所有协程也都无法继续执行;

  • 这就是"阻塞上升"的问题 → 一个协程阻塞,整个协程组阻塞。

1.3 Go中的协程(Goroutine)

Go语言中的协程(Goroutine)与常规的协程(Coroutine)的实现方式有所不同,Go中的协程不是与一个线程强绑定的,而是由Go调度器动态的将协程绑定到可用的线程上执行。

  • 由于Go协程与线程之间的绑定是动态的,因此各个协程之间既能做到并发,也能做到并行。
  • 当一个Go协程因为某些原因陷入阻塞,那么Go调度器会将该协程与其绑定的线程进行解绑,将线程的资源释放出来,使得线程可以与其他可调度的协程进行绑定。

1.4 GMP模型

GMP(Goroutine-Machine-Processor)模型是Go运行时系统中用于实现并发执行的模型,负责管理和调度协程的执行。G、M和P的含义分别如下:

流程图如下:

  • G(Goroutine):Go 的协程,包含自己的执行栈和状态;生命周期可跨 M,M 不记录 G 状态。

  • M(Machine):对应系统线程;需绑定 P 才能执行 G;调度 G 时由绑定的 P 指定。

  • P(Processor):调度器资源抽象;每个 P 有本地队列,管理可执行的 G;M 必须绑定 P 才能运行 G。

1. G 的存放队列

  • **本地队列(local queue):**每个 P 对应一个;接近无锁访问;调度优先级最高。

  • **全局队列(global queue):**所有 P 共享,用于存放本地队列满或调度不均衡的 G。

  • **wait 队列(wait queue):**存放 IO 阻塞后就绪的 G。

  • G 的创建与投递

    • 新创建的 G 优先投递到当前 P 的本地队列。

    • 本地队列满 → 投递到全局队列。

2. P 获取可调度 G 的流程

  1. 从当前 P 的本地队列获取 G。

  2. 从全局队列获取 G。

  3. 从 wait 队列获取 IO 就绪的 G。

  4. 从其他 P 的本地队列窃取一半 G(work-stealing),实现负载均衡。

  5. 每调度 61 次,P 会优先从全局队列获取一个 G,防止全局队列 G 饥饿。

说明:本地队列访问接近无锁,但因 work-stealing 存在部分锁操作。

1.4.1 获取和设置P的数量

  • GOMAXPROCS 表示 Go 运行时可以同时运行的 OS 线程数,即同时可执行的 M 的上限。

  • 本质上,它限制了 并行执行的 P 数量,因为每个 M 必须绑定一个 P 才能运行 G。

    package main

    import (
    "fmt"
    "runtime"
    )

    func main() {
    cpuNum := runtime.NumCPU() // 获取本地机器的逻辑CPU数
    fmt.Printf("cpuNum = %d\n", cpuNum) // cpuNum = 10

    复制代码
      runtime.GOMAXPROCS(6)         // 设置可同时执行的最大CPU数
      num := runtime.GOMAXPROCS(0)  // 获取可同时执行的最大CPU数
      fmt.Printf("num = %d\n", num) // num = 6

    }

  1. P 数量与 GOMAXPROCS 相关

    1. Go 会创建与 GOMAXPROCS 相同数量的 P(Processor)。

    2. 每个 P 对应一个本地队列,用于调度 G。

  2. M 的运行受 P 限制

    1. M 线程必须绑定 P 才能运行 G。

    2. 同时运行的 M 数量不会超过 GOMAXPROCS

  3. 调度影响

    1. 增大 GOMAXPROCS → 可以利用更多 CPU 核心并行运行 G,提高吞吐量。

    2. 减小 GOMAXPROCS → 并行度降低,但可能减少上下文切换开销。

1.4.2. 协程的生命周期

状态 描述
_Gidle 协程刚创建,还未初始化。
_Grunnable 协程已初始化并放入可运行队列,等待调度。
_Grunning 协程正在被调度执行中。
_Gsyscall 协程正在执行系统调用(阻塞可能发生)。
_Gwaiting 协程处于等待状态,被挂起,需要特定事件或信号唤醒。
_Gdead 协程已经执行完毕,生命周期结束。

流程图如下:

1.4.3 协程的调度流程

1. 协程类型

类型 描述
普通 G 用户通过 go 创建的协程,需要被调度执行。
g0 每个 M 都有一个 g0,是特殊的调度协程;负责调度普通 G 并管理 M 的执行。
monitor G 全局监控协程,不通过 P,而直接绑定 M;负责监控各 P 的状态,触发抢占调度。

2. g0 的调度流程(每个 M 都有 g0)

  1. **获取可调度的 G:**g0 通过 P 的本地队列、全局队列或 work-stealing 获取一个普通 G。

  2. 切换执行权

    • 将 G 状态切换为 _Grunning

    • 调用 gogo 函数,将执行权从 g0 交给 G。

  3. **执行 G 的逻辑:**G 持续执行,直到被阻塞、IO 操作、channel 操作或者被抢占等条件触发调度结束。

  4. 调度结束

    • 调用 mcall 函数,将执行权交还给 g0。

    • 更新 G 的状态(如 _Grunnable_Gdead)。

1.4.4调度类型

调度类型 描述 触发条件
主动调度 当前 G 主动让出执行权,将自己投递到全局队列等待下一次调度。 用户调用 runtime.Gosched()
被动调度 当前 G 因阻塞而暂停执行。 等待锁、channel 条件、IO 阻塞等。
正常调度 当前 G 执行完自己的代码逻辑,调度结束。 代码逻辑执行完毕。
抢占调度 monitor G 强制将当前 P 与 M 解绑,使 P 可以调度其他 G,M 继续执行系统调用。 满足抢占条件(如长期运行超过时间片)。

示意图如下:

2. 通道

2.1 概念

  • Channel 是 Go 提供的 类型安全的管道,用于在不同 goroutine 之间传递数据。

  • 数据通过 channel 发送(send)接收(receive),实现协程间同步和通信。

    ch := make(chan int) // 创建一个整型无缓冲 channel
    ch := make(chan int, 5) // 创建一个整型带缓冲 channel,缓冲区大小为5

**无缓冲 channel:**发送方必须等待接收方接收数据后才能继续,实现同步通信。

**带缓冲 channel:**发送方可以在缓冲区未满时直接发送数据,缓冲区满后才阻塞。

2.2 发送与接收 channel

channel的读写:

复制代码
ch <- 10        // 发送数据到 channel
val := <-ch     // 从 channel 接收数据

例如,下面程序中定义了一个容量为5的channel,并启动了一个协程不断向该channel中写入数据,而在主协程中每隔1秒从该channel中读取一次数据。如下:

复制代码
package main

import (
	"fmt"
	"time"
)

func WriteNum(intChan chan int) {
	num := 0
	for {
		intChan <- num // 向channel中写入数据
		fmt.Printf("write a num: %d\n", num)
		num++
	}
}

func ReadNum(intChan chan int) {
	for {
		time.Sleep(time.Second)
		num := <-intChan // 从channel中读取数据
		fmt.Printf("read a num: %d\n", num)
	}
}

func main() {
	intChan := make(chan int, 5)

	go WriteNum(intChan)

	ReadNum(intChan)
}

2.3 关闭 channel

  • close(ch) 用于关闭 channel,关闭后不能再发送,但可以接收剩余数据。切可以从该channel中读取数据

  • 接收者可以用两值形式判断 channel 是否关闭:

    • 第一个值是从channel中读取到的数据,

    • 第二个值表示本次对channel进行的读操作是否成功,

    • 如果channel已关闭并且channel中没有数据可读,那么第二个值将会返回false,否则为true。

      package main

      import "fmt"

      func main() {
      charChan := make(chan int, 10)
      for i := 0; i < 10; i++ {
      charChan <- 'a' + i
      }
      close(charChan) // 关闭channel

      复制代码
      for {
      	ch, ok := <-charChan
      	if !ok {
      		fmt.Println("没有数据了")
      		break
      	}
      	fmt.Printf("read a char: %c\n", ch)
      }

      }

2.4 遍历 channel

遍历 channel 通常是用 for-range 循环来实现的,不过需要注意 channel 必须关闭,否则循环会永久阻塞。

  • 在对channel进行读操作时,要确保有协程会对channel进行对应的写操作,否则会造成死锁(deadlock)。

  • 如果去掉上述代码中关闭channel的操作,那么for range循环在读取完channel中的数据后不会自动结束迭代,而会继续进行读操作,但此时没有任何协程会再对该channel进行写操作,因此会造成死锁(deadlock)。

    package main

    import "fmt"

    func main() {
    ch := make(chan int, 5)

    复制代码
      // 启动一个 goroutine 发送数据
      go func() {
      	for i := 0; i < 5; i++ {
      		ch <- i
      	}
      	close(ch) // 遍历 channel 前必须关闭
      }()
    
      // 遍历 channel
      for v := range ch {
      	fmt.Println(v)
      }

    }

2.5 只读/只写channel

类型 描述 使用场景
chan<- T 只写 channel,只能发送 发送数据给其他 goroutine
<-chan T 只读 channel,只能接收 从其他 goroutine 接收数据
chan T 可读可写 channel 常规使用
复制代码
package main

import "fmt"

// 函数内只能发送数据到 channel。
// 编译器会报错,如果尝试从 channel 接收。
func sendData(ch chan<- int) {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	close(ch)
}

// 函数内只能从 channel 接收数据。
// 编译器会报错,如果尝试向 channel 发送。
func receiveData(ch <-chan int) {
	for v := range ch {
		fmt.Println(v)
	}
}

func main() {
	ch := make(chan int)
	go sendData(ch)
	receiveData(ch)
}

2.6. select语句

  • select语句可以同时监听多个channel的操作,它会选择一个已经就绪的操作,并执行相应的分支代码。

  • 如果有多个操作就绪,select语句会随机选择其中一个操作执行,如果没有操作就绪,则会执行default分支。

  • 需要注意的是,如果没有操作就绪,并且select语句中没有default分支,则select语句会阻塞,直到至少有一个操作就绪。

    select {
    case v := <-ch1:
    fmt.Println("从 ch1 接收:", v)
    case ch2 <- 10:
    fmt.Println("向 ch2 发送: 10")
    default:
    fmt.Println("没有 case 就绪,执行 default")
    }

常见使用场景

(1)多路监听

复制代码
package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	// 模拟两个数据源
	go func() {
		time.Sleep(1 * time.Second)
		ch1 <- "来自 ch1 的消息"
	}()
	go func() {
		time.Sleep(2 * time.Second)
		ch2 <- "来自 ch2 的消息"
	}()

	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-ch1:
			fmt.Println("接收:", msg1)
		case msg2 := <-ch2:
			fmt.Println("接收:", msg2)
		}
	}
}

(2) 超时控制

复制代码
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)

	go func() {
		// 模拟耗时任务
		time.Sleep(3 * time.Second)
		ch <- 42
	}()

	select {
	case v := <-ch:
		fmt.Println("收到数据:", v)
	case <-time.After(2 * time.Second): // 超时 2s
		fmt.Println("等待超时,退出")
	}
}

(3) 退出控制

复制代码
package main

import (
	"fmt"
	"time"
)

func worker(quit chan struct{}) {
	for {
		select {
		case <-quit: // 收到退出信号
			fmt.Println("worker 收到退出信号,结束")
			return
		default:
			fmt.Println("worker 工作中...")
			time.Sleep(time.Second)
		}
	}
}

func main() {
	quit := make(chan struct{})

	go worker(quit)

	time.Sleep(3 * time.Second) // 主 goroutine 等一会
	close(quit)                 // 通知 worker 退出

	time.Sleep(1 * time.Second) // 给 worker 一点收尾时间
}
相关推荐
林太白5 分钟前
Nuxt3 功能篇
前端·javascript·后端
疯狂的代M夫25 分钟前
C++对象的内存布局
开发语言·c++
得物技术29 分钟前
营销会场预览直通车实践|得物技术
后端·架构·测试
mit6.8241 小时前
Linux下C#项目构建
开发语言·c#
群联云防护小杜1 小时前
从一次 DDoS 的“死亡回放”看现代攻击链的进化
开发语言·python·linq
Ice__Cai1 小时前
Flask 入门详解:从零开始构建 Web 应用
后端·python·flask·数据类型
霸敛1 小时前
好家园房产中介网后台管理完整(python+flask+mysql)
开发语言·python·flask
武子康1 小时前
大数据-74 Kafka 核心机制揭秘:副本同步、控制器选举与可靠性保障
大数据·后端·kafka
紫穹1 小时前
006.LangChain Prompt Template
后端
whitepure1 小时前
万字详解JavaObject类方法
java·后端