函数——go世界中的一等公民
作者:Destiny池鱼
来源:SegmentFault思否社区
函数的本质
在go的世界中,函数是一等公民,可以给变量赋值,可以作为参数传递,也可以直接赋值。
package main
import (
"fmt"
"time"
)
func A() {
// ...
fmt.Println("this is a")
}
func B(f func()) {
// ...
}
func C() func() {
return A
}
var f func() = C()
func main() {
time.Sleep(time.Minute)
v := C()
v()
}
在go语言中将这样的变量、参数、返回值,即在堆空间和栈空间中绑定函数的值,称为function value
函数的指令在编译期间生成,使用go tool compile -S main.go可以获取汇编代码, 以OSX 10.15.6,go 1.14为例,将看到下述汇编代码(下面只引用部分)
...
"".B STEXT nosplit size=1 args=0x8 locals=0x0
0x0000 00000 (main.go:9) TEXT "".B(SB), NOSPLIT|ABIInternal, $0-8
0x0000 00000 (main.go:9) PCDATA $0, $-2
0x0000 00000 (main.go:9) PCDATA $1, $-2
0x0000 00000 (main.go:9) FUNCDATA $0, gclocals·2a5305abe05176240e61b8620e19a815(SB)
0x0000 00000 (main.go:9) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:9) FUNCDATA $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:11) PCDATA $0, $-1
0x0000 00000 (main.go:11) PCDATA $1, $-1
0x0000 00000 (main.go:11) RET
0x0000 c3
...
运行时将存放在__TEXT段中,也就是存放在代码段中,读写权限为rx/rwx, 通过vmmap [pid]可以获取运行时的内存分布
==== Non-writable regions for process 13443
REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
__TEXT 0000000001000000-0000000001167000 [ 1436K 1436K 0K 0K] r-x/rwx SM=COW .../test
使用otool -v -l [file]可以看到下述内容(下面只引用了一部分)
...
Load command 1
cmd LC_SEGMENT_64
cmdsize 632
segname __TEXT
vmaddr 0x0000000001000000
vmsize 0x0000000000167000
fileoff 0
filesize 1470464
maxprot rwx
initprot r-x
nsects 7
flags (none)
Section
sectname __text
segname __TEXT
addr 0x0000000001001000
size 0x000000000009c365
offset 4096
align 2^4 (16)
reloff 0
nreloc 0
type S_REGULAR
attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS
reserved1 0
reserved2 0
...
所以如果要问函数在go语言里的本质是什么,那么其实就是指向__TEXT段内存地址的一个指针
函数调用的过程
在go语言中,每一个goroutine持有一个连续栈,栈基础大小为2kb,当栈大小超过预分配大小后,会触发栈扩容,也就是分配一个大小为当前栈2倍的新栈,并且将原来的栈拷贝到新的栈上。使用连续栈而不是分段栈的目的是,利用局部性优势提升执行速度,原理是CPU读取地址时会将相邻的内存读取到访问速度比内存快的多级cache中,地址连续性越好,L1、L2、L3 cache命中率越高,速度也就越快。
在go中,和其他一些语言有所不同,函数的返回值、参数都是由被caller保存。每次函数调用时,会在caller的栈中压入函数返回值列表、参数列表、函数返回时的PC地址,然后更改bp和pc为新函数,执行新函数,执行完之后将变量存到caller的栈空间中,利用栈空间中保存的返回地址和caller的栈基地址,恢复pc和sp回到caller的执行过程。
对于栈变量的访问是通过bp+offset的方式来访问,而对于在堆上分配的变量来说,就是通过地址来访问。在go中,变量被分配到堆上还是被分配到栈上是由编译器在编译时根据逃逸分析决定的,不可以更改,只能利用规则尽量让变量被分配到栈上,因为局部性优势,栈空间的内存访问速度快于堆空间访问。
方法的本质
go里面其实方法就是语法糖,请看下述代码,两个Println打印的结果是一样的,实际上Method就是将receiver作为函数的第一个参数输入的语法糖而已,本质上和函数没有区别
type T struct {
name string
}
func (t T) Name() string {
return "Hi! " + t.name
}
func main() {
t := T{name: "test"}
fmt.Println(t.Name()) // Hi! test
fmt.Println(T.Name(t)) // Hi! test
}
闭包的本质
前面已经提到在go语言中将这在堆空间和栈空间中绑定函数的值,称为function value。这也就是闭包在go语言中的实体。一个最简单的funcval实际上是通过二级指针指向__TEXT代码段上函数的结构体。
那我们来看下面这个闭包,也就是main函数中的变量f
func getFunc() func() int {
a := 0
return func() int {
a++
return a
}
}
func main() {
f := getFunc()
for i := 0; i < 10; i++ {
fmt.Println(f())
}
}
上面这段代码执行完后会输出1~10,也就是说f在执行的时候所使用的a会累计,但是a并不是一个全局变量,为什么f就变成了一个有状态的函数呢?其实这也就是go里面的闭包了。那我们来看go是如何实现闭包的。
首先来解释一下闭包的含义,闭包在实现上一个结构体,需要存储函数入口和关联环境,关联环境包含约束变量(函数内部变量)和自由变量(函数外部变量,在函数外被定义,但是在函数内被引用),和函数不同的事,在捕获闭包时才能确定自由变量,当脱离了捕捉变量的上下文时,也能照常运行。基于闭包可以很容易的定义异步调用的回调函数。
在 go 语言中,闭包的状态是通过捕获列表实现的。具体来说,有自由变量的闭包funcval的分配都在堆上,(没有自由变量的funcval在__DATA数据段上,和常量一样),funcval中除了包含地址以外,还会包含所引用的自由变量,所有自由变量构成捕获列表。对于会被修改的值,捕获的是值的指针,对于不会被修改的值,捕获的是值拷贝。