返回

Go八股之Goroutine

为什么需要 Goroutine

简单一句话,Goroutine 相比线程拥有更优越的开销性能。

这里先简单介绍下进程、线程、协程:

  • 进程:操作系统创建、资源分配的基本单位、同一个进程内的线程会共享资源。
  • 线程:操作系统创建、CPU 调度的基本单位、有独立的堆栈空间
  • 协程:可通过用户程序创建
    • 有栈协程:golang
    • 无栈协程:c++,rust 等

Goroutine 就是一个用户级线程,相比传统线程更加轻量(传统协程 1 MB,Goroutine 约 2 KB)。其不涉及内核态的切换,因此 golang 的并发性能很好。

如何关闭 Goroutine

  1. 关闭 channel

    1
    2
    
    // 根据第二个参数进行判别,当关闭 channel 时,根据其返回结果跳出
    msg, ok := <-ch
    
  2. 定期轮询 channel

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
    func main() {
    	ch := make(chan string, 6)
    	done := make(chan struct{})
    
    	go func() {
    		for {
    			select {
    			case ch <- "hello world":
    			case <-done:
    				close(ch)
    				return
    			}
    		}
    	}()
    
    	go func() {
    		time.Sleep(3 * time.Second)
    		done <- struct{}{}
    	}()
    
    	for i := range ch {
    		fmt.Println("接收到的值:", i)
    	}
    	fmt.Println("over")
    }
    

    变量 done 作为 channel 类型,用作信号量处理 Goroutine 的关闭。for-loop 结合 select 进行监听,处理完业务之后才会调用 close 关闭 channel。

  3. 使用 context

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    
    func main() {
    	ch := make(chan struct{})
    	ctx, cancel := context.WithCancel(context.Background())
    
    	go func(ctx context.Context) {
    		for {
    			select {
    			case <-ctx.Done():
    				ch <- struct{}{}
    				return
    			default:
    				fmt.Println("hello world")
    			}
    			time.Sleep(1 * time.Second)
    		}
    	}(ctx)
    
    	go func() {
    		time.Sleep(4 * time.Second)
    		cancel()
    	}()
    
    	<-ch
    	fmt.Println("over")
    }
    

    在 context 中,可以借助 ctx.Done 获取一个只读的 channel,可用来识别当前 channel 是否已被关闭。context 对于跨 Goroutine 控制灵活,可以调动 context.WithTimeout 根据时间,或者主动调用 cancel 方法手动关闭。

如何实现并行 Goroutine

通过设置最大的可同时使用的 CPU 核心数

1
2
// 设置并行 Goroutine 数量为 2
runtime.GOMAXPROCS(2)

为什么不能大量使用 Goroutine

  • 虽然 Goroutine 的初始栈(自动扩容)很小,但是大部分业务需要更多的栈空间,而频繁的扩容需要很大的成本。
  • Golang 的 GMP 调度模型中的 M 和 P 是有数量限制的,大量的 Goroutine 会导致过长的调度队列,从而影响性能。
  • 过多的 Goroutine 还会导致频繁的 GC,影响性能。

Goroutine A 能否停止另一个 Goroutine

不能。Goroutine 只有自己主动退出,不能被外界的 Goroutine 关闭。

父 Goroutine 退出,子 Goroutine 一定会退出嘛

  • 父 Goroutine 为 main 时,所有的子 Goroutine 都跟着父 Goroutine 退出
  • 父 Goroutine 不为 main 时,子 Goroutine 不会跟着父 Goroutine 退出

Goroutine 的状态流转

image-20250113212939765

状态含义
_Gidle空闲态G 刚刚创建,还未初始化
_Grunnable就绪态G 在运行队列,等待 M 取出(此时 M 有 P)
_Grunning运行态M 正在运行 G
_Gsyscall系统调用M 中运行的 G 发起系统调用(此时 M 无 P)
_Gwaiting阻塞态G 等待执行资源
_Gdead完成态G 已经执行完毕
_Gcopystack复制栈G 正获取一个新的栈空间,并将原内容复制过去

Goroutine 泄露

Goroutine 没有被正确的关闭或管理,会导致他们在程序运行过程中无法被回收,最终导致资源浪费和潜在的性能问题

泄露原因

  • Goroutine 内部进行 channel/mutex 等读写操作被一直阻塞
  • Goroutine 内的业务逻辑进入死循环,资源无法释放
  • Goroutine 内的业务逻辑进入长时间等待,又不断新增的 Goroutine 进入等待

泄露场景

  • 未初始化 channel
  • channel 发送未接收
  • channel 接收未发送
  • 资源连接未关闭
  • 未成功解锁
  • 死循环
  • sync.WaitGroup 使用不当

多个协程交替打印奇偶数字

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
	"fmt"
	"sync"
)

func main() {
	// 创建两个channel用于同步
	even := make(chan struct{})
	odd := make(chan struct{})

	// 创建WaitGroup等待所有协程完成
	var wg sync.WaitGroup
	wg.Add(2)

	// 打印偶数的协程
	go func() {
		defer wg.Done()
		for i := 2; i <= 10; i += 2 {
			<-even // 等待偶数信号
			fmt.Printf("偶数: %d\n", i)
			odd <- struct{}{} // 发送奇数信号
		}
		// 最后一次打印完需要再消费一次even channel,避免死锁
		<-even
	}()

	// 打印奇数的协程
	go func() {
		defer wg.Done()
		for i := 1; i <= 9; i += 2 {
			fmt.Printf("奇数: %d\n", i)
			even <- struct{}{} // 发送偶数信号
			<-odd              // 等待奇数信号
		}
		// 最后发送一次信号给偶数协程,让其能够退出
		even <- struct{}{}
	}()

	// 等待所有协程完成
	wg.Wait()

	// 关闭channel
	close(even)
	close(odd)
}
Licensed under CC BY-NC-SA 4.0
载入天数...载入时分秒...
使用 Hugo 构建
主题 StackJimmy 设计