递归函数与内存优化在Golang中的探讨

递归函数与内存优化在Golang中的探讨 我有一个递归函数,其调用次数可能高达 8 亿次。然而,调用的深度从未超过 150 层。

程序运行速度会变慢,资源管理器显示,随着调用次数接近 8 亿,内存使用量逐渐接近可用内存总量。

我的想法是,程序调用速度过快,以至于内存压缩无法及时从栈中回收内存(?)。机器内存为 64GB。

这说得通吗?如果每调用 5 万次就插入一个睡眠或类似操作,让它能跟上,会有帮助吗?

我正在学习使用性能分析器,但我猜测每次调用最多使用 5KB,在深度为 150 的情况下,总共也只有 0.75MB。

我还有一个映射,每调用 10 次就增加一个值。为它设置一个初始大小会有帮助吗?顺便说一下,这个映射目前的值结构体只包含 1 个布尔值(我知道即使使用值为 nil 的映射也可以进一步减少,我正准备这么做)。

感谢阅读。


更多关于递归函数与内存优化在Golang中的探讨的实战教程也可以访问 https://www.itying.com/category-94-b0.html

4 回复

看起来你应该让函数变成非递归的。递归会有栈开销。 如果性能受到影响,你应该尝试另一种实现。

更多关于递归函数与内存优化在Golang中的探讨的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


栈空间通常在函数调用后立即被回收。你所描述的情况听起来像是在进行分配,而这需要垃圾回收器来回收。

你可以搜索“逃逸分析”,并通过使用“-m”标志编译代码(或使用VS Code插件)来分析你的代码。

如果你能避免堆分配,就应该能解决你的问题(如果我的直觉没错的话)。

// 代码示例:使用逃逸分析标志编译
// go build -gcflags="-m" main.go

答案无疑是使用性能分析器/基准测试。为你的映射指定初始容量提示可以提高性能,但在你确定这就是问题所在之前,它可能作用不大。当你说“每调用 50,000 次就休眠或做点什么,让它跟上”时,听起来你正在使用 goroutine,这也会带来额外的开销。你尝试过将 goroutine 的数量限制在一个合理的范围内并通过通道进行通信吗?

另一个需要注意的问题是:映射不会收缩。看看这篇文章。我假设你并不依赖映射的收缩,但这一点值得考虑。另外——我假设你使用映射而不是切片是因为查找性能很重要。

我写了一些基础的基准测试,给你一些如何入门的思路。首先是代码:

package main

var chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
var stringKeys []string

func init() {
	// Create unique keys.
	for _, c := range chars {
		for _, c2 := range chars {
			for _, c3 := range chars {
				stringKeys = append(stringKeys, string(c)+string(c2)+string(c3))
			}
		}
	}
}

func main() {
}

func stringMap() int {
	var m = make(map[string]int)
	for i, v := range stringKeys {
		m[v] = i
	}
	return len(m)
}

func intMap() int {
	var m = make(map[int]int)
	for i := range stringKeys {
		m[i] = i
	}
	return len(m)
}

func stringMapCapacityHint() int {
	var m = make(map[string]int, len(stringKeys))
	for i, v := range stringKeys {
		m[v] = i
	}
	return len(m)
}

func intMapCapacityHint() int {
	var m = make(map[int]int, len(stringKeys))
	for i := range stringKeys {
		m[i] = i
	}
	return len(m)
}

type data struct {
	key   string
	value int
}

func sliceAppend() int {
	var s = make([]data, 0)
	for i, v := range stringKeys {
		s = append(s, data{v, i})
	}
	return len(s)
}

func slicePreallocate() int {
	var s = make([]data, len(stringKeys))
	for i, v := range stringKeys {
		s[i] = data{v, i}
	}
	return len(s)
}

以及基准测试:

package main

import "testing"

func BenchmarkStringMap(b *testing.B) {
	for n := 0; n < b.N; n++ {
		stringMap()
	}
}

func BenchmarkIntMap(b *testing.B) {
	for n := 0; n < b.N; n++ {
		intMap()
	}
}

func BenchmarkStringMapCapacityHint(b *testing.B) {
	for n := 0; n < b.N; n++ {
		stringMapCapacityHint()
	}
}

func BenchmarkIntMapCapacityHint(b *testing.B) {
	for n := 0; n < b.N; n++ {
		intMapCapacityHint()
	}
}

func BenchmarkSliceAppend(b *testing.B) {
	for n := 0; n < b.N; n++ {
		sliceAppend()
	}
}

func BenchmarkSlicePreallocate(b *testing.B) {
	for n := 0; n < b.N; n++ {
		slicePreallocate()
	}
}

…结果如下:

$ go test -bench=. -benchmem
goos: windows
goarch: amd64
pkg: mapperformance
cpu: Intel(R) Core(TM) i7-9700K CPU @ 3.60GHz
BenchmarkStringMap-8                          87          13981674 ns/op        15480435 B/op       4725 allocs/op
BenchmarkIntMap-8                            127           9309430 ns/op        10933689 B/op       4773 allocs/op
BenchmarkStringMapCapacityHint-8             206           5936893 ns/op         7241753 B/op          2 allocs/op
BenchmarkIntMapCapacityHint-8                255           4585321 ns/op         5038069 B/op         14 allocs/op
BenchmarkSliceAppend-8                       130           9029515 ns/op        16402408 B/op         29 allocs/op
BenchmarkSlicePreallocate-8                  841           1487319 ns/op         3375104 B/op          1 allocs/op
PASS
ok      mapperformance  11.421s

总之,开始使用 -benchmem 运行基准测试。并使用性能分析器。最后:是的,任何时候,只要你能为映射提供一个相关的容量提示进行初始化,就请这样做。

这是一个典型的递归深度与调用次数导致的栈内存和堆内存问题。让我们直接分析并给出解决方案。

1. 栈内存分析

你的计算基本正确:150层深度 × 5KB/层 ≈ 0.75MB。但Golang的栈管理机制可能导致问题:

// 示例递归函数
func recursiveFunc(n int, depth int) {
    if depth >= 150 || n <= 0 {
        return
    }
    
    // 局部变量占用栈空间
    var buffer [5120]byte // 约5KB
    buffer[0] = byte(n % 256)
    
    // 递归调用
    recursiveFunc(n-1, depth+1)
}

Golang使用分段栈(1.13前)或连续栈(1.14+),递归调用可能导致栈扩容/缩容开销。8亿次调用会产生大量栈分配操作。

2. 映射优化

映射的初始容量设置确实能显著减少内存分配:

// 优化前
var myMap = make(map[int]bool)

// 优化后 - 预估容量
var myMap = make(map[int]bool, 80000000) // 8千万容量,根据实际需求调整

// 每10次调用添加一个值
func addToMap(key int) {
    if key%10 == 0 {
        myMap[key] = true
    }
}

3. 递归转迭代

这是最有效的优化。将递归改为迭代栈:

// 递归版本
func recursiveProcess(n int) {
    if n <= 0 {
        return
    }
    // 处理逻辑
    recursiveProcess(n - 1)
}

// 迭代版本 - 显式管理栈
func iterativeProcess(n int) {
    type stackFrame struct {
        n int
        // 其他局部变量
        data [5120]byte
    }
    
    stack := make([]stackFrame, 0, 150) // 预分配栈容量
    stack = append(stack, stackFrame{n: n})
    
    for len(stack) > 0 {
        frame := stack[len(stack)-1]
        stack = stack[:len(stack)-1]
        
        if frame.n <= 0 {
            continue
        }
        
        // 处理逻辑
        frame.data[0] = byte(frame.n % 256)
        
        // 模拟递归调用
        stack = append(stack, stackFrame{n: frame.n - 1})
    }
}

4. 尾递归优化

Golang没有自动尾递归优化,但可以手动实现:

// 尾递归形式
func tailRecursive(n int, acc int) int {
    if n <= 0 {
        return acc
    }
    // 所有计算在递归调用前完成
    newAcc := acc + n
    return tailRecursive(n-1, newAcc)
}

// 手动优化为循环
func iterativeTail(n int, acc int) int {
    for n > 0 {
        acc = acc + n
        n--
    }
    return acc
}

5. 内存回收策略

添加睡眠不是解决方案。使用runtime.GC()或调整GC参数:

import "runtime"

// 定期手动触发GC
func processWithGC(n int) {
    for i := 0; i < n; i++ {
        // 处理逻辑
        
        if i%1000000 == 0 { // 每100万次触发一次GC
            runtime.GC()
        }
    }
}

// 设置GC百分比(环境变量)
// GOGC=50 表示堆增长50%后触发GC

6. 完整优化示例

package main

import (
    "runtime"
    "sync"
)

type ProcessState struct {
    mu    sync.RWMutex
    cache map[int]bool
}

func NewProcessState(estimatedSize int) *ProcessState {
    return &ProcessState{
        cache: make(map[int]bool, estimatedSize),
    }
}

func (ps *ProcessState) ProcessIterative(n int) {
    // 使用切片作为显式栈
    type frame struct {
        n     int
        depth int
        data  [5120]byte
    }
    
    stack := make([]frame, 0, 150)
    stack = append(stack, frame{n: n, depth: 0})
    
    callCount := 0
    for len(stack) > 0 {
        // 弹出栈帧
        f := stack[len(stack)-1]
        stack = stack[:len(stack)-1]
        
        if f.depth >= 150 || f.n <= 0 {
            continue
        }
        
        // 处理逻辑
        f.data[0] = byte(f.n % 256)
        
        // 每10次调用更新映射
        if callCount%10 == 0 {
            ps.mu.Lock()
            ps.cache[callCount] = true
            ps.mu.Unlock()
        }
        
        // 压入新栈帧
        stack = append(stack, frame{
            n:     f.n - 1,
            depth: f.depth + 1,
        })
        
        callCount++
        
        // 定期GC
        if callCount%1000000 == 0 {
            runtime.GC()
        }
    }
}

关键点:

  1. 递归转迭代消除栈深度问题
  2. 映射预分配减少堆分配
  3. 定期手动GC控制内存增长
  4. 使用sync.RWMutex保护并发访问

这些修改应该能解决你的内存问题。使用pprof验证内存分配和GC压力。

回到顶部