Golang中如何实现强制栈分配和局部性/非逃逸的语言特性
Golang中如何实现强制栈分配和局部性/非逃逸的语言特性 在优化Go应用程序时,一种方法是在调试器的帮助下减少堆分配的数量。这有点像猜谜游戏,而且在阅读代码时,变量是分配在栈上还是堆上也不明确。我想到一个实验性的语言特性或许可以解决这个问题。
示例:
// v1 根据上下文可能分配在栈上或堆上
v1 := Point{1, 2}
// v2 总是分配在栈上,但不允许逃逸
local v2 := Point{1, 2}
关键点在于“不允许逃逸”。Go已经使用逃逸分析来决定哪些可以分配在栈上。这是许多语言中常见的优化技术。添加 local 关键字后,如果检测到变量逃逸(原则上,如果当前作用域消失后,变量或其内存区域仍可能被引用),将在编译时抛出错误。
这个特性有点类似于Rust中生命周期(lifetimes)的做法。生命周期更强大,但也给程序员带来了更大的负担。非逃逸变量可以让程序员在需要最佳性能的关键位置,以一种可预测的方式“选择退出”垃圾回收,而不是像打地鼠一样在调试器中折腾。
顺便说一下,非逃逸与别名(non-aliasing)不同。可以与唯一性类型进行比较。
非逃逸变量仍然可以通过引用在调用栈中向下传递,但不能向上传递(不能在没有拷贝的情况下返回(这种拷贝可能会被编译器优化掉,就像在C语言中那样))。
还有其他与逃逸分析相关的优化技术没有被这个想法考虑进去,例如标量替换(scalar replacement)。
另一种选择退出GC的方式是C#中的值类型(Java尚未添加)。这有点临时性。值类型是隐式复制的,就像在Rust和C中一样(其中一些复制指令可以被编译器优化)。
一个可能的原型实现是制作一个能编译到Go的Go解析器,但在抽象语法树上添加新关键字(编译后移除)以及逃逸分析。
让我知道你的想法。
更多关于Golang中如何实现强制栈分配和局部性/非逃逸的语言特性的实战教程也可以访问 https://www.itying.com/category-94-b0.html
这里有一个关于因性能问题而从Go切换到Rust的轶事:https://blog.discord.com/why-discord-is-switching-from-go-to-rust-a190bbca2b1f
当然,并不是说我的建议能解决他们的问题。而且他们在Go GC方面的问题可能已经在Go中得到了修复。
更多关于Golang中如何实现强制栈分配和局部性/非逃逸的语言特性的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
你也不关心并行化吗?
你为什么这么说?
我仍然认为在语言中添加关键字是解决这个问题的错误方法(我甚至不确定是否同意值逃逸到堆上是一个问题)。我觉得Go语言不应该提供那种控制。Go的设计初衷是易于阅读。它设计时包含了垃圾回收机制,这样程序员就不需要管理内存。我认为添加一个关键字来控制内存分配违背了这个理念。
持有不同意见是可以的。😊 你也不喜欢并行化吗?😉
bs必须逃逸到堆上,因为谁知道r会用它做什么。
嗯,对于非逃逸变量,必须施加限制。主要是任何函数都不允许使其逃逸,但可以为其创建别名。这些静态限制将使得确定它不会逃逸成为可能。是的,在另一种语言中,如果接口使变量逃逸或保持局部性,则必须对其进行注解。要么这样,要么,正如你指出的,如果无法证明其局部性(例如第三方代码等),就假定它会逃逸。
如果你的示例中的
point逃逸到了堆上,我很想看看示例代码,并可能提出改进建议来解决这个问题。
抱歉,我没有任何示例代码。这只是一个思想实验。
你为什么这么说?
呵呵。因为并行化作为一种语言特性,其存在的唯一原因就是优化。
我觉得Go语言不应该提供那种控制。Go的设计初衷是易于阅读。
嗯,需求是发明之母。
所有语言都在发展。我们拭目以待。
它设计时包含了垃圾回收,这样程序员就不需要管理内存。
如果开发者通过减少堆内存来优化他们的Go程序,情况就已经不是这样了。这里有一篇文章(你可能不同意^^):Golang内存管理:Go服务中的分配效率
当然,是否需要这样严格的内存控制取决于你工作的领域。你可能会争辩说这些人一开始就不应该使用Go,而应该使用C、C++或Rust。
很抱歉我不同意,但我不喜欢一个仅仅为了便于优化而存在的语言特性。
我猜您知道这一点,但如果编译器无法证明值的生命周期受限于其所在函数的生命周期,或者如果它们被认为太大而无法在栈上存活,那么这些值就会逃逸到堆上。如果您曾经将一个值传递给接口函数,该值就会逃逸到堆上,例如:
// MyReadAll 是 Go 语言 io/ioutil.ReadAll 的一个糟糕(可能功能失常)的实现。
// 对我的例子来说这并不重要。
func MyReadAll(r io.Reader) (bs []byte, err error) {
bs = make([]byte, 4096)
var total, next int
for {
if next, err = r.Read(bs[total:cap(bs)]); err != nil {
break
}
bs = append(bs[:cap(bs)], 0) // go 默认的容量增长
bs = bs[:total+next]
}
if err == io.EOF {
err = nil
}
return
}
bs 必须逃逸到堆上,因为谁知道 r 会用它做什么。
如果您的示例中的指针逃逸到了堆上,我很乐意看看示例代码,并可能提出改进建议来解决这个问题。
如果这对您来说是一个持续存在的问题来源,我建议发明一个魔法注释,并编写一个工具,使用 go/* 包来解析您的源代码以及 Go 逃逸分析器的输出,以识别这种情况何时发生。
olleharstedt:
因为并行化是一种语言特性,它存在的唯一原因就是优化。 😄
我不太确定我是否同意这一点。例如,C++语言和内存模型直到C++11才承认线程的存在,但在此之前,人们已经使用库来实现这个功能多年(甚至几十年?)。在Go语言中,go关键字启动的是一个并发例程(如果GOMAXPROCS环境变量等于1,那么就没有并行性,但程序/库仍然可以使用go关键字)。也许go关键字最终是为了性能,但我认为它是一种描述一段代码可以独立于主例程流程运行的方式。
olleharstedt:
嗯,需求是发明之母。😊 所有语言都在发展。我们拭目以待。
确实如此,但Go语言让我觉得有趣的是,设计者如何努力不因为其他语言有某个特性就采纳它(例如 https://www.youtube.com/watch?v=rFejpH_tAHM&t=66s),即使这个特性很有用。
我所质疑的是“需求”。我不认为解决方案是一个新的关键字,我也不认为问题是某个值逃逸到了堆上。问题很可能始于期望某个功能以特定速度运行,但开发后发现并未达到。经过调试,发现是一个或多个值逃逸到堆上拖慢了速度。我认为解决方案不是用一个关键字来锁定变量,然后在它必须逃逸时报错,我认为这可以通过一个linter规则来实现。
我并不反对优化。我反对仅仅为了优化而添加语言特性。如果一个或多个变量要逃逸到堆上,我会尝试将它们放入一个在调用链更早位置就逃逸的结构体中,这样我之后可以重复使用它。所以,虽然它仍然导致一次分配,但不会导致n次分配。这通常会导致代码更丑陋,不那么符合Go的风格,但如果性能至关重要,我要么选择更丑的代码,要么重构以消除问题(例如,我为什么要对一个其值会逃逸到堆上的函数进行n次调用?)。
我浏览了你链接的文章,它看起来很有趣。稍后我会更仔细地阅读。😊
Go语言目前没有提供强制栈分配或显式声明非逃逸变量的语法特性,但可以通过逃逸分析的行为和编译器指令来间接控制。以下是几种在Go中影响变量分配位置的方法:
1. 利用逃逸分析的已知规则
Go编译器通过逃逸分析自动决定变量分配在栈还是堆上。以下情况会导致变量逃逸到堆:
- 返回局部变量的地址
- 将指针存入全局变量或闭包
- 发送指针到channel
- 存储指针到切片、map或接口中
- 变量大小未知或动态变化
示例:
// 栈分配
func stackAllocated() int {
x := 42 // 栈分配
return x
}
// 堆分配(逃逸)
func heapAllocated() *int {
x := 42 // 逃逸到堆
return &x
}
2. 使用编译器指令控制逃逸分析
Go 1.13+ 支持 //go:noinline 和 //go:nosplit 等编译器指令,但注意没有直接控制分配的指令。不过可以通过函数内联影响分析:
//go:noinline
func noInline() *int {
x := 42
return &x // 强制逃逸(因为函数不内联)
}
func inlineable() int {
x := 42
return x // 可能内联,x保持在栈上
}
3. 通过代码模式强制栈分配
避免指针操作和接口使用可以增加栈分配的可能性:
type Point struct{ X, Y int }
// 栈分配友好版本
func processPoint(p Point) Point { // 值传递
p.X++
return p // 返回副本,无逃逸
}
// 可能导致堆分配的版本
func processPointer(p *Point) *Point { // 指针传递
p.X++
return p // 返回指针,可能逃逸
}
4. 使用性能分析工具验证
通过 -gcflags="-m" 查看逃逸分析结果:
go build -gcflags="-m -m" main.go
输出示例:
./main.go:10:6: can inline stackAllocated
./main.go:10:9: x does not escape
./main.go:15:9: &x escapes to heap
5. 实验性方法:通过汇编控制
极端情况下可通过汇编确保栈分配:
//go:nosplit
func allocateOnStack() {
var buffer [1024]byte
// 使用buffer...
_ = buffer
}
限制与注意事项
- Go语言设计上不提供显式控制分配位置的语法,这保持了语言的简洁性
- 逃逸分析是编译器优化,不同Go版本行为可能变化
- 过度优化可能降低代码可读性,建议只在性能关键路径使用这些模式
虽然Go没有local关键字,但通过理解逃逸分析规则和采用适当的编码模式,可以有效控制内存分配行为。对于大多数应用,Go的自动内存管理已经足够高效,手动优化应基于性能分析数据。

