【Go 实践学习】内存泄漏情景及pprof工具使用(上半篇)


什么是内存泄漏?

golang本身是拥有GC机制的,GC机制帮我们去处理掉那些我们程序之后不在使用的内存,以缓解程序在运行期间里,内存占用率不断上升,最终达到临界值而出现内存溢出(OOM)的问题。
所谓内存泄漏直白来讲即:程序主观上不再想去使用的内存,但是客观上却又持续占有,而是GC系统无法通过三色算法去回收的问题。
所以我们在开发中也并能完全的信任GC,而不去管程序中内存的使用。因此,保持一个良好的开发习惯是非常重要的。

夫人,你也不想你的代码周末的时候突然在线上挂掉吧

两类内存泄漏

如果非要把内存泄漏在进行详细的归类的话,那么还能再分成两类:暂时性内存泄漏与永久性内存泄漏

暂时性内存泄漏

所谓暂时性内存泄漏,即该释放的内存资源没有释放掉,但是这个资源并不是无法释放,而是在之后的更晚的时候才能释放掉。这种情况一般发生在stringslice或者一些存在浅拷贝的引用下出现的资源共享,或者是defer导致的资源没法及时释放。
比如接下来会提到的一个经典slice切片取值导致的暂时性内存泄漏

package main

import (
	"fmt"
)

func main() {
	var sliceG []int
	sliceG = f1()

	fmt.Println(len(sliceG), cap(sliceG)) // 1 99999
}

func f1() []int {
	slice1 := make([]int, 100000)
	slice1[6] = 1
	return slice1[3:10]
}

可以看到,虽然slice1不再使用了,但是切片的浅拷贝的资源共享,导致slice1的底层资源并没有及时得到释放,需要等到sliceG释放才能随着GC被释放。

永久性内存泄漏

永久性内存泄漏即在进程后续生命周期内,泄露的内存永远不会被回收,比如创建的 goroutine 出现死循环或者select case无法得到退出信号,导致的无法退出的情况,从而使协程栈及引用内存永久泄露问题。

比如在Web服务中,如果在某些服务中存在这样永久性内存泄漏,那将是致命的,很有可能在某个安然入睡的夜晚,线上的项目就突然挂掉了。比如下面这个代码,没有正确的设置关闭任务,而出现协程泄漏的情况

package main

import (
	"fmt"
	"net/http"
	"time"
)

var i int = 0

// 处理请求的函数
func handler(w http.ResponseWriter, r *http.Request) {
	// 创建一个停止信号通道
	stop := make(chan struct{})
	// 启动一个新的 Goroutine
	go workFunc(stop)
	// 向客户端响应
	fmt.Fprintf(w, "Task started.")

	// 注意:这里的 close(stop) 被注释掉了,Goroutine 将永远不会停止
	// defer close(stop)
}

func workFunc(stop chan struct{}) {
	// 模拟持续运行的任务
	groutineId := i
	i++
	fmt.Println("Working...")

	time.Sleep(1 * time.Second)

	fmt.Println("Task finished.")
	for {
		select {
		case <-stop:
			// 收到停止信号,退出 Goroutine
			fmt.Println("Goroutine stopped.")
			return
		default:
			// 模拟一些工作
			time.Sleep(1 * time.Second)
			fmt.Printf("goroutine %d Leaking...\n", groutineId)
		}
	}
}

func main() {
	http.HandleFunc("/", handler)

	port := ":8080"
	fmt.Printf("Starting server on http://localhost%s\n", port)
	err := http.ListenAndServe(port, nil)
	if err != nil {
		fmt.Println("Error starting server:", err)
	}
}

每当有用户访问页面时就会创建一个新的协程,但是这个协程的设计了一个for - loop select case监听机制,却永远监听不到停止指令,出现协程泄漏。而协程泄漏又会导致协程申请的资源也无法释放掉,从而使整个系统的内存占用不断升高,最终出现OOM错误。


常见的内存泄漏及解决办法

go101内存泄漏论坛上看了看了一些内存泄漏的场景,在此总结一些,并举例方便大家去理解这些案例

浅拷贝共享底层资源,导致无关内存无法释放

子切片导致的内存泄漏

func main() {
	// 场景1:浅拷贝 共享底层 导致大内存无法释放
	s := scene1()
	fmt.Println(len(s), cap(s)) // 2 1000
}
func scene1() []int {
	sliceBase := make([]int, 1000)
	sliceSmall := sliceBase[0:2]
	return sliceSmall
}

scene1sliceBasesliceSmall引用了底层数据,并且sliceSmall被传给main函数中,使得sliceBase申请的资源没法被立即释放,从而增大程序对空间的不必要消耗。

解决方案:
在确定要选择的大小,可以提前创建好对应的空间,使用深拷贝的策略来避免这种空间共享问题。

func main() {
	// 场景1:子切片导致的内存泄漏
	s = solveScene1()
	fmt.Println(len(s), cap(s))  // 2 2
}

func solveScene1() []int {
	sliceBase := make([]int, 1000)
	sliceSmall := make([]int, 2)
	copy(sliceSmall, sliceBase[0:2])
	return sliceSmall
}

子字符串导致的内存泄漏

golang中,子字符串会与原始字符串进行底层内存的共享,这样的设计虽然可以很好的节省CPU和内存的资源,但是使用不当就会造成内存泄漏的问题。

func main() {
	// 场景2:子字符串导致的内存泄漏
	str := scene2()
	fmt.Println(str)
}

func scene2() string {
	str := "123456789"
	res := str[:3]
	return res
}

上述代码中,虽然将str[:3]这个字串作为返回结果,但是它实际上还会影响str这个包含9个字节的字符串无法被回收。


即使scene2str已经不再指向字符串,但是mainstr指向了共享的空间地址,使得一些空间无法被GC回收,同时也无法再被系统使用了。这种操作积攒多了,就会影响golang的内存性能。

解决方案1:
通过标准 Go 编译器进行优化,以避免不必要的重复, 使用一字节内存的小额外成本,避免不确定性的内存损失。

func solve1Scene2() string {
	str := "123456789"
	res := (" " + str[:3])[1:]
	return res
}

编译器优化可能会使这个方法变得无效

解决方案2:
string 转换到 []byte 转换到 string

func solve2Scene2() string {
	str := "123456789"
	res := string([]byte(str[:3]))
	return res
}

通过转换byte切片,在转换到string的额外步骤,构建一个具有新空间的字符串,从而避免了共享内存带来的内存泄漏。

解决方案3:
学过Java的同学可能会比较亲切下面这种方式,通过strings.Builder构建一个新的字符串,有点类似与方案2,但速度和内存占用相对更好。

func solve3Scene2() string {
	str := "123456789"

	// 使用strings.Builder
	var b strings.Builder
	b.Grow(3)
	b.WriteString(str[:3])

	res := b.String()
	return res
}

这种方案虽然可以很好的解决上述问题,但是它的代码量实在不敢恭维。因此方案4,由strings包封装的Repeat方法,可能是最佳选择了
解决方案4:

func solve4Scene2() string {
	str := "123456789"
	
	res := strings.Repeat(str[:3], 1)
	return res
}

代码量只需一行,基本无痛。
所以以后大家如果要从一个大字符串取其中相对少量的子字符串可以根据自己程序需要的情况,优先使用上面这些方法来避免临时性内存泄漏。

子切片未重置指针索引

这种情况和子切片共享内存空间两者产生的机制类似,但是不同点在于这种情况会导致更严重的内存泄漏问题。

type Node struct {
	Next *Node
	Val  int
}



func scene3() []*Node {
	s := []*Node{new(Node), new(Node), new(Node), new(Node)}
	//  s[1:3:3]  low:high:max
	res := s[1:3:3]
	return res
}

假设这个指针节点指向是一些链表,当返回的子切片共享空间占用整个大切片时,将会导致切片中的其余节点在程序的后期,即使不使用这些节点,也不会回收到这些指针节点,直到子切片被回收。

上述代码中s[1:3:3]是切片的另一中取子切片的方法,s[low:high:max] 。使用 s[low:high:max] 语法时,可以同时指定切片的长度和容量。切片的长度由 high - low 决定,而容量则由 max - low 决定。这种语法允许限制切片的最大容量,从而避免切片意外增长到不期望的大小。

这种子切片的共享在go101给出的解决方案如下
解决方案1:

func solve1Scene3() []*Node {
	s := []*Node{new(Node), new(Node), new(Node), new(Node)}
	
	s[0], s[3] = nil, nil
	res := s[1:3:3]
	return res
}

将指针置为nil,但是感觉不是太理解,这样的操作会把程序变得复杂化,我觉得按照自己的理解,应该使用类似前面子切片的操作解决最好

解决方案2:

func solve2Scene3() []*Node {
	s := []*Node{new(Node), new(Node), new(Node), new(Node)}

	s[0], s[3] = nil, nil
	res := make([]*Node, 2)
	copy(res, s[1:3:3])
	return res
}

挂起的 goroutines 导致的内存泄漏

Goroutine内存泄漏通常发生在协程启动后未能正确退出或被适当清理,导致其持续占用内存。以下是几个常见的例子:

死循环导致的内存泄漏

当一个goroutine陷入死循环,且没有任何退出条件时,就会导致内存泄漏。

package main

import (
    "time"
)

func leakyGoroutine() {
    for {
        // 模拟一些工作
        time.Sleep(1 * time.Second)
    }
}

func main() {
    for i := 0; i < 10; i++ {
        go leakyGoroutine()
    }
    // 主程序运行10秒后退出
    time.Sleep(10 * time.Second)
}

此时for循环设计为死循环,开启的leakyGoroutine将会因为死循环而永远无法停止

阻塞的通道读取

如果一个goroutine正在等待从通道中读取数据,而这个通道永远不会发送数据,goroutine就会被永远阻塞,导致内存泄漏。

package main

func leakyGoroutine(ch chan int) {
    <-ch // 永远阻塞
}

func main() {
    ch := make(chan int)
    for i := 0; i < 10; i++ {
        go leakyGoroutine(ch)
    }

    // 主程序运行10秒后退出
    time.Sleep(10 * time.Second)
}

未关闭的通道

如果一个goroutine正在从一个通道中读取数据,并且该通道在发送数据后未能正确关闭,这可能导致读取操作永远不会结束,进而导致内存泄漏。

package main

import (
    "fmt"
    "time"
)

func leakyGoroutine(ch chan int) {
    for range ch {
        // 处理接收到的数据
    }
    fmt.Println("Goroutine exited")
}

func main() {
    ch := make(chan int)
    for i := 0; i < 10; i++ {
        go leakyGoroutine(ch)
    }

    // 向通道发送数据但未关闭
    for i := 0; i < 5; i++ {
        ch <- i
    }

    // 主程序运行10秒后退出
    time.Sleep(10 * time.Second)
}

leakyGoroutine中的for range循环将永远等待新的数据,因为通道没有关闭,goroutine无法退出。

总结

以上就是今天探讨的内容,本文详细介绍了Golang中内存泄漏的概念及其两种主要类型:暂时性内存泄漏和永久性内存泄漏,并通过多个实例说明了这些内存泄漏的常见原因及解决方法。虽然Golang拥有GC机制,但作为开发者仍需保持良好的编码习惯,避免内存泄漏对程序性能的影响。
在下一篇中,我们将详细的介绍pprof内存分析工具,是如何排查一些常见的内存泄漏问题。

本文是经过个人查阅相关资料后理解的提炼,可能存在理论上理解偏差的问题,如果您在阅读过程中发现任何问题或有任何疑问,请不吝指出,我将非常感激并乐意与您讨论。谢谢您的阅读!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值