一文读懂什么是Go语言goroutine

1. 进程、线程和协程的区别

  • 进程: 进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

进程就是应用程序的启动实例。比如我们运行一个游戏,打开一个软件,就是开启了一个进程。

  • 线程: 线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。

  • 协程: 协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

协程,是一种比线程更加轻量级的存在。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态中执行)。

  • 线程和协程的区别

    1. 线程切换需要陷入内核,然后进行上下文切换,而协程在用户态由协程调度器完成,不需要陷入内核,这样代价就小了。
    2. 协程的切换时间点是由调度器决定,而不是由系统内核决定的,尽管它们的切换点都是时间片超过一定阈值,或者是进入 I/O 或睡眠等状态时。
    3. 基于垃圾回收的考虑,Go 实现了垃圾回收,但垃圾回收的必要条件是内存位于一致状态,因此就需要暂停所有的线程。如果交给系统去做,那么会暂停所有的线程使其一致。对于 Go 语言来说,调度器知道什么时候内存位于一致状态,所以也就没有必要暂停所有运行的线程。

2. 介绍一下 Goroutine

Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。

协程(goroutine) 轻量级线程, goroutine 是由 Go 的运行时(runtime)调度和管理的。Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。它在语言层面已经内置了调度和上下文切换的机制。

goroutine 是 Go 并发设计的核心,也叫协程,它比线程更加轻量,因此可以同时运行成千上万个并发任务。在 Go 语言中,每一个并发的执行单元叫作一个 goroutine。我们只需要在调用的函数前面添加 go 关键字,就能使这个函数以协程的方式运行。

3. context 包结构原理和用途

Context(上下文)是 Golang 应用开发常用的并发控制技术 ,它可以控制一组呈树状结构的 goroutine,每个 goroutine 拥有相同的上下文。Context 是并发安全的,主要是用于控制多个协程之间的协作、取消操作。

Context 只定义了接口,凡是实现该接口的类都可称为是一种 context。contex理分析

  • 「Deadline」 方法:可以获取设置的截止时间,返回值 deadline 是截止时间,到了这个时间,Context 会自动发起取消请求,返回值 ok 表示是否设置了截止时间。
  • 「Done」 方法:返回一个只读的 channel ,类型为 struct {}。如果这个 chan 可以读取,说明已经发出了取消信号,可以做清理操作,然后退出协程,释放资源。
  • 「Err」 方法:返回 Context 被取消的原因。
  • 「Value」 方法:获取 Context 上绑定的值,是一个键值对,通过 key 来获取对应的值。

4. goroutine 调度

GMP 是 Go 语言运行时(runtime)层面的实现,是 go 语言自己实现的一套调度系统。区别于操作系统调度 OS 线程。

  1. G 很好理解,就是个 goroutine 的,里面除了存放本 goroutine 信息外 还有与所在 P 的绑定等信息。

  2. P 管理着一组 goroutine 队列,P 里面会存储当前 goroutine 运行的上下文环境(函数指针,堆栈地址及地址边界),P 会对自己管理的 goroutine 队列做一些调度(比如把占用 CPU 时间较长的 goroutine 暂停、运行后续的 goroutine 等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他 P 的队列里抢任务。

  3. M(machine)是 Go 运行时(runtime)对操作系统内核线程的虚拟, M 与内核线程一般是一一映射的关系, 一个 groutine 最终是要放到 M 上执行的;

    P 与 M 一般也是一一对应的。他们关系是: P 管理着一组 G 挂载在 M 上运行。当一个 G 长久阻塞在一个 M 上时,runtime 会新建一个 M,阻塞 G 所在的 P 会把其他的 G 挂载在新建的 M 上。当旧的 G 阻塞完成或者认为其已经死掉时 回收旧的 M。

    P 的个数是通过 runtime.GOMAXPROCS 设定(最大 256),Go1.5 版本之后默认为物理线程数。 在并发量大的时候会增加一些 P 和 M,但不会太多,切换太频繁的话得不偿失。

    单从线程调度讲,Go 语言相比起其他语言的优势在于 OS 线程是由 OS 内核来调度的,goroutine 则是由 Go 运行时(runtime)自己的调度器调度的,这个调度器使用一个称为 m:n 调度的技术(复用 / 调度 m 个 goroutine 到 n 个 OS 线程)。 其一大特点是 goroutine 的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的 malloc 函数(除非内存池需要改变),成本比调度 OS 线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干 goroutine 均分在物理线程上, 再加上本身 goroutine 的超轻量,以上种种保证了 go 调度方面的性能。

5. 如何避免 Goroutine 泄露和泄露场景

gorouinte 里有关于 channel 的操作,如果没有正确处理 channel 的读取,会导致 channel 一直阻塞住,goroutine 不能正常结束

  • channel 操作不当:

    package main

    import (
    "fmt"
    "time"
    )

    func main() {
    // 问题场景: 在 Goroutine 中创建 channel,但没有相应的 channel 读取操作
    go func() {
    ch := make(chan int)
    ch <- 1 // 这里会导致 Goroutine 永久阻塞
    }()

      time.Sleep(5 * time.Second)
      fmt.Println("Program ended")
    

    }

在上面的代码中,我们创建了一个 Goroutine,它向一个无人接收的 channel 写入数据。这会导致该 Goroutine 永久阻塞,从而造成 Goroutine 泄露。

解决方法如下:

package main

import (
	"fmt"
	"time"
)

func main() {
	// 解决方法: 确保 channel 的读取操作与写入操作相匹配
	ch := make(chan int)

	go func() {
		x := <-ch // 从 channel 中读取数据
		fmt.Println("Received value:", x)
	}()

	ch <- 1 // 向 channel 中写入数据

	time.Sleep(5 * time.Second)
	fmt.Println("Program ended")
}

在这个解决方案中,我们在创建 Goroutine 之前先创建了 channel,并在 Goroutine 中读取 channel 中的数据。这样可以确保 Goroutine 能够正常退出,避免泄露。

  • context 使用不当:

    package main

    import (
    "context"
    "fmt"
    "time"
    )

    func main() {
    // 错误示例: 在 Goroutine 中使用 context,但没有正确地取消 context
    ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)

      go func() {
      	doSomething(ctx)
      }()
    
      time.Sleep(5 * time.Second)
      fmt.Println("Program ended")
    

    }

    func doSomething(ctx context.Context) {
    for {
    // 不检查 ctx.Done() 就一直执行
    time.Sleep(time.Second)
    fmt.Println("Doing something...")
    }
    }

在上面的代码中,我们创建了一个 Goroutine,并向其传递了一个 context。但是,我们没有在 doSomething 函数中正确地处理 context 的取消。这意味着,即使 context 已经超时,Goroutine 仍然会一直执行下去,而不会退出。,从而造成 Goroutine 泄露。

解决方法如下:

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	// 解决方法: 在 Goroutine 中正确地处理 context 的取消
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	go func() {
		doSomething(ctx)
	}()

	time.Sleep(5 * time.Second)
	fmt.Println("Program ended")
}

func doSomething(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("Context canceled, exiting Goroutine")
			return
		default:
			// 执行一些操作
			time.Sleep(time.Second)
			fmt.Println("Doing something...")
		}
	}
}

在解决方案中,我们在 doSomething 函数中使用 select 语句来检查 context 是否已被取消。如果 context 已被取消,Goroutine 就会安全地退出,避免 Goroutine 泄露。

6. waitgroup 用法和原理

waitgroup 内部维护了一个计数器,当调用 wg.Add(1) 方法时,就会增加对应的数量;当调用 wg.Done() 时,计数器就会减一。直到计数器的数量减到 0 时,就会调用

runtime_Semrelease 唤起之前因为 wg.Wait() 而阻塞住的 goroutine。

使用方法:

  1. main 协程通过调用 wg.Add (delta int) 设置 worker 协程的个数,然后创建 worker 协程;

  2. worker 协程执行结束以后,都要调用 wg.Done ();

  3. main 协程调用 wg.Wait () 且被 block,直到所有 worker 协程全部执行结束后返回。

    实现原理:

    • WaitGroup 主要维护了 2 个计数器,一个是请求计数器 v,一个是等待计数器 w,二者组成一个 64bit 的值,请求计数器占高 32bit,等待计数器占低 32bit。

    • 每次 Add 执行,请求计数器 v 加 1,Done 方法执行,请求计数器减 1,v 为 0 时通过信号量唤醒 Wait ()。

相关推荐
Chrikk5 小时前
Go-性能调优实战案例
开发语言·后端·golang
幼儿园老大*5 小时前
Go的环境搭建以及GoLand安装教程
开发语言·经验分享·后端·golang·go
canyuemanyue5 小时前
go语言连续监控事件并回调处理
开发语言·后端·golang
杜杜的man5 小时前
【go从零单排】go语言中的指针
开发语言·后端·golang
有梦想的咸鱼_9 小时前
go实现并发安全hashtable 拉链法
开发语言·golang·哈希算法
童先生13 小时前
Go 项目中实现类似 Java Shiro 的权限控制中间件?
开发语言·go
杜杜的man14 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*14 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
半桶水专家14 小时前
go语言中package详解
开发语言·golang·xcode
llllinuuu14 小时前
Go语言结构体、方法与接口
开发语言·后端·golang