惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
A
About on SuperTechFans
IT之家
IT之家
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Blog — PlanetScale
Blog — PlanetScale
aimingoo的专栏
aimingoo的专栏
云风的 BLOG
云风的 BLOG
The GitHub Blog
The GitHub Blog
Vercel News
Vercel News
G
Google Developers Blog
J
Java Code Geeks
宝玉的分享
宝玉的分享
T
Tailwind CSS Blog
Cloudbric
Cloudbric
L
LINUX DO - 最新话题
MyScale Blog
MyScale Blog
H
Heimdal Security Blog
PCI Perspectives
PCI Perspectives
Attack and Defense Labs
Attack and Defense Labs
S
Security @ Cisco Blogs
Latest news
Latest news
I
Intezer
L
Lohrmann on Cybersecurity
C
CXSECURITY Database RSS Feed - CXSecurity.com
月光博客
月光博客
T
Threatpost
博客园 - 【当耐特】
S
Schneier on Security
P
Privacy International News Feed
G
GRAHAM CLULEY
T
Tenable Blog
AWS News Blog
AWS News Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
雷峰网
雷峰网
博客园 - Franky
Engineering at Meta
Engineering at Meta
美团技术团队
S
Secure Thoughts
T
Troy Hunt's Blog
Microsoft Security Blog
Microsoft Security Blog
SecWiki News
SecWiki News
V
Visual Studio Blog
人人都是产品经理
人人都是产品经理
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Cisco Talos Blog
Cisco Talos Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Martin Fowler
Martin Fowler
Webroot Blog
Webroot Blog
Google DeepMind News
Google DeepMind News
H
Hackread – Cybersecurity News, Data Breaches, AI and More

蛮荆

如何获取更多的免费服务器 Kubernetes 调度器队列 - 设计与实现 Kubernetes 调度器 - 核心流程 Kubernetes Networking Model & CNI Kubernetes 控制器管理总结 Kubernetes CronJob 设计与实现 Kubernetes Job 设计与实现 Kubernetes HPA 设计与实现 Kubernetes Deployment 滚动更新实现原理 Kubernetes GC 设计与实现 Kubernetes Pod 驱逐 - 设计与实现 Kubernetes Daemonset 设计与实现 Kubernetes ReplicaSet 设计与实现 Kubernetes EndPoint 设计与实现 Kubernetes Informer 设计与实现 降本增效之应用优化 (三) 日志存储与检索 Kubernetes Pod 设计与实现 - 创建流程 Kubernetes 探针设计与实现 Unix 编程艺术名句摘录 Kubernetes - CRI 概述 Golang 编译速度为什么这么快? Kubernetes Pod 设计与实现 - Pause 容器 Kubernetes - kube-proxy 代理模式工程优化 Kubernetes 应用最佳实践 - 优雅关闭长连接 Kubernetes Service 类型和会话亲和性 Kubernetes 为什么需要 Ingress Kubernetes 架构 - 控制平面和数据平面 降本增效之应用优化 (二) 大报表 Go 语言如何获取 CPU 利用率 降本增效之应用优化 (一) Redis 业务规则引擎演变过程简述 微服务中的熔断算法 漏桶算法和令牌桶算法 jsonparser 为什么比标准库的 encoding/json 快 10 倍 ? zap 高性能设计与实现 HTTP Router 算法演进 fastcache 高性能设计与实现 Web 常见的三个安全问题 ants Code Reading Go 线程安全 map 方案选型 布隆过滤器 死锁、活锁、饥饿、自旋锁 sync.Pool Code Reading Go 内存管理概述 Go netpoll Code Reading time/Timer Code Reading GMP Scheduler Code Reading Go channel 的 15 条规则和底层实现 为什么 Linux “一切皆文件” context.Context Code Reading runtime/HACKING.md Goland 最佳实践 互联网开发与金庸武学 为什么 Redis 6.0 引入多线程模型? Kubernetes 应用最佳实践 - 金丝雀发布 容器中如何正确配置 GOMAXPROCS ? singleflight Code Reading sync.Map Code Reading sync.Cond Code Reading sync.WaitGroup Code Reading sync.RWMutex Code Reading sync.Mutex Code Reading sync.Once Code Reading Go 无锁编程 sync/atomic Code Reading goroutine 交替打印奇偶数 GODEBUG Go 并发模式 Go 汇编 UUID 通用技术选型 Kubernetes 应用最佳实践 - 水平自动伸缩 Go 高性能 Tips fasthttp 为什么比标准库 net/http 快 10 倍 ? 技术文章配图指南 ChatGPT 初体验 Docker 网络原理概览 iptables 的五表五链 Kubernetes 应用最佳实践 - 亲和性和污点容忍度 Go 的反射与三大定律 Docker 官方提供的最佳实践 Go 语言内置的设计模式 HTTP1 到 HTTP3 的工程优化 Kubernetes 应用最佳实践 - Sidecar 模式 Kubernetes 应用最佳实践 - init 容器和钩子函数 为什么 recover 必须在 defer 中调用? 为什么 defer 的执行顺序和注册顺序不同? Go map 设计与实现 Go 切片扩容底层实现 Go 语言中的零拷贝 Go Delve 云原生和边缘计算简介 Kubernetes Pod 服务质量等级 Kubernetes 应用最佳实践 - 探针 Kubernetes 应用最佳实践 - 资源请求和限制 CDN 原理 Kubernetes 应用最佳实践 - 开篇 缓存策略和模式 Go 内存模型 Kubernetes 核心概念 软件版本语义化
goroutine 泄漏与检测
2023-06-11 · via 蛮荆

2023-06-11 Golang 并发编程 Go 源码分析 读代码

概述

Go 语言内置 GC,因此一般不会内存泄漏,但是 goroutine 可能会发生泄漏,泄漏的 goroutine 引用的内存同样无法被 GC 正常回收。

常见 goroutine 泄漏场景

下面总结一下开发中经常遇到的 goroutine 泄漏场景,本文示例代码只是为了演示,没有任何现实意义。

通道为 nil

nil 通道 上发送和接收操作将永久阻塞,会造成 goroutine 泄漏

最佳实践:

  1. 永远不要对 nil 通道 进行任何操作
  2. 直接使用 make() 初始化通道

接收通道为 nil

func main() {
	var ch chan bool

	go func() {
		defer func() { // defer 不会执行
			fmt.Println("goroutine ending") // 不会输出
		}()

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

		fmt.Println("range broken") // 执行不到这里
	}()
	
	time.Sleep(time.Second) // 假设主程序 1 秒后退出
}

// $ go run main.go
// 没有任何输出,goroutine 泄漏

发送通道为 nil

func main() {
	var ch chan bool

	go func() {
		defer func() { // defer 不会执行
			fmt.Println("goroutine ending") // 不会输出
		}()

		ch <- true

		fmt.Println("range broken") // 执行不到这里
	}()

	time.Sleep(time.Second) // 假设主程序 1 秒后退出
}

// $ go run main.go
// 没有任何输出,goroutine 泄漏

遍历未关闭通道

遍历 无缓冲 (阻塞) 并且未关闭 的通道时,如果通道一直未关闭, 将会永久阻塞,造成 goroutine 泄漏

遍历 缓冲 (非阻塞) 并且未关闭 的通道时,将通道内的所有缓存数据接收完毕后,如果通道一直未关闭,将会永久阻塞,造成 goroutine 泄漏

最佳实践:

  1. 确保 通道 可以正常关闭
  2. 确保 goroutine 可以正常退出

遍历无缓冲并且未关闭的通道

错误的做法

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

	go func() {
		defer func() { // defer 不会执行
			fmt.Println("goroutine ending") // 不会输出
		}()

		for v := range ch {
			fmt.Println(v)
			break
		}

		fmt.Println("range broken") // 执行不到这里
	}()

	time.Sleep(time.Second) // 假设主程序 1 秒后退出
}

// $ go run main.go
// 没有任何输出,goroutine 泄漏

正确的做法

参照最佳实践,对代码进行以下调整: 在 goroutine 外部关闭通道,防止 goroutine 内部遍历陷入无限阻塞。

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

	go func() {
		defer func() { // defer 正常执行
			fmt.Println("goroutine ending") // 正常输出
		}()

		for v := range ch { // 外部关闭通道后,for 循环结束
			fmt.Println(v) // 不会输出
		}

		fmt.Println("range broken") // 可以执行到这里
	}()

	close(ch) // 关闭通道,内存遍历循环立即结束

	time.Sleep(time.Second) // 假设主程序 1 秒后退出
}

// $ go run main.go
// 输出如下
/**
  range broken
  goroutine ending
*/

遍历缓冲并且未关闭的通道

错误的做法

func main() {
	ch := make(chan bool, 3)

	go func() {
		defer func() { // defer 不会执行
			fmt.Println("goroutine ending") // 不会输出
		}()

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

		fmt.Println("range broken") // 执行不到这里
	}()

	ch <- true
	ch <- false
	ch <- true

	time.Sleep(time.Second) // 假设主程序 1 秒后退出
}

// $ go run main.go
// 输出如下
/**
  true
  false
  true
  // 接收完缓冲区的 3 个值后, 后面不再有任何输出,goroutine 泄漏
*/

正确的做法

参照最佳实践,对代码进行以下调整: 在 goroutine 外部关闭通道,防止 goroutine 内部遍历陷入无限阻塞。

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

	go func() {
		defer func() { // defer 正常执行
			fmt.Println("goroutine ending") // 正常输出
		}()

		for v := range ch { // 外部关闭通道后,for 循环结束
			fmt.Println(v) // 不会输出
		}

		fmt.Println("range broken") // 可以执行到这里
	}()

	close(ch) // 关闭通道,内存遍历循环立即结束

	time.Sleep(time.Second) // 假设主程序 1 秒后退出
}

// $ go run main.go
// 输出如下
/**
  true
  false
  true
  range broken
  goroutine ending
*/

发送/接收 不同步

只有发送者,没有接收者

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

	go func() {
		ch <- true
	}()
}

只有接收者,没有发送者

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

	go func() {
		<-ch
	}()
}

资源无法释放

如果 goroutine 内的引用的资源长时间无法被释放,也会导致 goroutine 泄漏,典型的场景如 加锁/解锁 未同步、网络访问超时、写入大文件、数据库读写产生死锁 等。

互斥锁

func main() {
	var mu sync.Mutex

	go func() {
		mu.Lock()
	}()
	
	time.Sleep(time.Second)

	go func() {
		mu.Lock()
	}()
}

上述代码中,第一个 goroutine 加锁后并没有对应的解锁操作,导致第二个 goroutine 阻塞在加锁操作,发生泄漏。

通用的的工程实践是: 加锁操作完成后使用 defer 注册对应的解锁操作

func main() {
	var mu sync.Mutex

	go func() {
		mu.Lock()
		defer mu.Unlock()
	}()

	time.Sleep(time.Second)

	go func() {
		mu.Lock()
		defer mu.Unlock()
	}()
}

标准库 http.Client

标准库中的 http.Client 对象默认没有超时时间限制,如果我们直接调用的情况下,很可能发生死锁:

http.Get("https://go.dev")

正确的调用方法是: 创建对象时就设置超时时间:

client := http.Client{
    Timeout: 3 * time.Second,
}
client.Get("https://go.dev")

main 函数

func main() {
    go func() {
        time.Sleep(100 * time.Millisecond) // 模拟耗时操作
    }()
}

main 函数结束时不会考虑当前是否还有 goroutine 正在执行,上面的代码中, main 函数退出后,goroutine 发生泄漏。

通用的的工程实践是: 使用同步原语保证 main 程序结束前所有 goroutine 正常退出

os.Exit 方法

func main() {
	go func() {
		time.Sleep(1 * time.Second) // 模拟耗时操作
	}()

	go func() {
		os.Exit(1)
	}()

	time.Sleep(100 * time.Millisecond)
}

os.Exit 方法会直接结束程序,不会考虑当前是否还有 goroutine 正在执行,所以调用前要考虑后台运行的 goroutine 情况。

最佳实践

通过上面的这些例子,我们可以看到 goroutine 泄漏 的大部分场景是因为对 channel 的错误使用而导致的。

针对上面的问题,我们来总结一下 goroutine 的应用最佳实践。

异步调用方法的选择权交给调用方

  • 调用方可能并不知道方法内部使用了 goroutine, 所以是否需要异步由调用方来决定
  • 对于异步调用的方法,要设置自动退出机制,比如 信号, 超时控制

启动一个 goroutine 时

  • 永远不要启动无法控制退出的 goroutine
  • 永远不要启动无法确定何时退出的 goroutine
  • 启动 goroutine 时实现 panic recovery 机制,避免服务内部错误导致的不可用
  • 尽量避免在请求中直接启动 goroutine, 应该通过类似 生产者/消费者 模式处理,可以避免流量突增时创建大量 goroutine 导致的 OOM
  • goroutine 设计为只能通过 channel 通信退出

为什么 goroutine 不能被 kill ?

kill 一个 goroutine 在底层设计上存在很多挑战,例如:

  • 当前 goroutine 持有的资源如何处理?
  • 堆栈如何处理?
  • defer 语句还需要执行么?
  • 如果允许 defer 语句执行,但是 defer 语句可能阻塞 goroutine 退出 (形成死循环),这种场景如何处理?

goroutine 泄漏检测

针对上面提到的各种问题,是否可以实现一个 goroutine 泄漏检测 功能,如果可以的话,如何实现这个功能呢?

如果手动从零开始实现一个 goroutine 泄漏检测 功能,最简单直观的办法是抓取多次 stacktrace,解析出所有的 goroutine ID 对比差异,最终多出来的部分就是泄漏的 goroutine

开源的组件会如何实现这个功能呢?我们找一个成熟的开源组件一起来学习下,毕竟站在巨人的肩膀上可以看的更远。

goleak 组件

笔者选择由 Uber 开源的 goleak 作为研究 goroutine 泄漏检测 代码实现,版本为 v1.2.1

示例代码

package main

import (
	"testing"

	"go.uber.org/goleak"
)

func TestGoroutineLeak(t *testing.T) {
	defer goleak.VerifyNone(t)

	ch := make(chan int)

	go func() {
		_ = <-ch                                      // goroutine 阻塞造成的泄漏
		t.Error("It's not going to be executed here") // 代码不会执行到这里
	}()
}

测试失败,输出泄漏的 goroutine 信息:

$ go test -v -count=1 -run='TestGoroutineLeak' .

# 输出如下
=== RUN   TestGoroutineLeak
    main_test.go:18: found unexpected goroutines:
        [Goroutine 21 in state chan receive, with test.TestGoroutineLeak.func1 on top of the stack:
        goroutine 21 [chan receive]:
        ...
        ...
--- FAIL: TestGoroutineLeak (0.47s)
FAIL

goleak 源代码

配置对象

// 默认检测次数为 20 次
const _defaultRetries = 20

type opts struct {
	filters    []func(stack.Stack) bool // 过滤函数 (用来自定义过滤 goroutine)
	maxRetries int                      // 最大检测次数
	maxSleep   time.Duration            // 最长休眠时间 (默认 100 ms)
	cleanup    func(int)                // 清理函数 (检测结束时调用)
}

创建检测配置对象

buildOpts 函数通过经典的 FUNCTIONAL OPTIONS 模式创建一个 检测对象

func buildOpts(options ...Option) *opts {
	opts := &opts{
		maxRetries: _defaultRetries,        // 默认最大检测次数 20 次
		maxSleep:   100 * time.Millisecond, // 默认最长休眠时间 100 ms
	}
	
	// 过滤掉 4 种调用栈信息
	opts.filters = append(opts.filters,
		isTestStack,
		isSyscallStack,
		isStdLibStack,
		isTraceStack,
	)
	for _, option := range options {
		option.apply(opts)
	}
	return opts
}

配置对象

检测单个测试用例

VerifyNone 函数检测单个测试用例是否发生 goroutine 泄漏,常规用法是在测试用例函数中注册 defer 并调用检测函数,如 defer VerifyNone(t)

func VerifyNone(t TestingT, options ...Option) {
    // 创建检测配置对象
	opts := buildOpts(options...)             
	var cleanup func(int)
    // 重置清理函数
	cleanup, opts.cleanup = opts.cleanup, nil 

	...

	if err := Find(opts); err != nil {
        // 如果检测到 goroutine 泄漏, 直接报错
		t.Error(err)    
	}

	if cleanup != nil {
        // 如果没有检测到 goroutine 泄漏, 执行清理函数
		cleanup(0)  
	}
}

检测 goroutine 泄漏

Find 函数根据配置信息,查找泄漏的 goroutine 并返回对应的错误信息。

func Find(options ...Option) error {
	// 当前执行检测的 goroutine ID
	cur := stack.Current().ID()

    // 创建检测配置对象
	opts := buildOpts(options...)   

	...
	
	var stacks []stack.Stack
	retry := true
	for i := 0; retry; i++ {
		// 获取所有 goroutine
		// 然后过滤掉当前执行检测的 goroutine 和符合过滤条件的 goroutine
		stacks = filterStacks(stack.All(), cur, opts)

		if len(stacks) == 0 {
			// 如果没有 goroutine 了
			// 说明所有的 goroutine 均已正常退出,直接返回即可
			return nil
		}
		
		// 如果还有运行中的 goroutine,则休眠一会,继续检测
		retry = opts.retry(i)
	}

	// 代码执行到这里
	// 说明还有 goroutine 未退出,返回对应的 goroutine 信息
	return fmt.Errorf("found unexpected goroutines:\n%s", stacks)
}

goroutine 过滤

filterStacks 函数过滤掉符合条件的 goroutine

func filterStacks(stacks []stack.Stack, skipID int, opts *opts) []stack.Stack {
	// 高性能 Tips: 切片数据复用
	filtered := stacks[:0]
	for _, stack := range stacks {
		// 过滤掉当前执行检测的 goroutine
		if stack.ID() == skipID {
			continue
		}
		// 过滤掉符合配置中过滤函数的 goroutine
		if opts.filter(stack) {
			continue
		}
		filtered = append(filtered, stack)
	}
	return filtered
}

小结

通过对源代码的分析,我们可以得出 goleak 组件的实现原理: 定时获取所有 goroutine 并且进行过滤,达到最大检测次数后,最终过滤剩下的 goroutine 就被判定为泄漏

Reference

扩展阅读