Golang底层原理剖析之闭包

简介: Golang底层原理剖析之闭包

function value

go中函数是头等对象,可以作为参数传递,可以作为返回值,也可以绑定到变量。go中称这样的参数,返回值或者变量为function value。

函数指令在编译期间生成,而function value本质是上一个指针,但是并不直接指向函数的指令入口。而是指向一个runtime.funcval结构体,这个结构体里只有一个地址,就是这个函数指令的入口地址

函数A被赋值给f1和f2两个变量,这种情况,编译器会做出优化,让f1和f2共用一个funcval结构体

如果函数A的指令在这,入口地址addr1,编译阶段,会在只读数据段分配一个funcval结构体,fn指向函数A指令入口,而它本身的起始地址,会在执行阶段赋给f1和f2。通过f1来执行函数,就会通过它存储的地址找到对应的funcval结构体,拿到函数入口地址,然后调转执行。

既然只要有函数入口地址就能调用 ,为什么要通过funcval结构体包装这个地址,然后使用一个二级指针来调用呢?这里主要是为了处理闭包的情况

闭包

定义

闭包的两个定义:

  • 第一:必须要有在函数外部定义,但在函数内部引用的“自由变量”
  • 第二:脱离了形成闭包的上下文,闭包也能照常使用这些自由变量

函数create的返回值是一个函数,但这个函数内部使用了外部定义的变量c,即使create执行结束,通过f1和f2依然能正常调用这个闭包函数。并使用定义在create函数内部的局部变量c,所以这里符合闭包的定义。通常称这个变量c为捕获变量。

闭包函数的指令自然也是在编译阶段生成,但因为每个闭包对象都要保存自己的捕获变量。所以要到执行阶段才创建对应的闭包对象。

到执行阶段,main函数栈帧有两个局部变量,然后是返回值空间。到create函数栈帧这里,有一个局部变量c=2。

create函数会在堆上分配一个funcval结构体,fn指向闭包函数入口,除此之外,还有一个捕获列表,这里只捕获一个变量c,然后这个结构体的起始地址就作为返回值写入返回值空间,所以f1被赋值为addr2,下面再次调用create函数,它就会再次创建一个funcval结构体,同样捕获变量c,然后这个起始地址addr3作为返回值写入,最终f2

被赋值为addr3。

通过f1和f2调用闭包函数,就会找到各自对应的funcval结构体,拿到同一个函数入口,但是通过f1调用时要使用addr2上面的捕获列表,f2调用时要使用addr3上面的捕获列表,这就是称闭包为有状态的函数的原因

71e862fd5ad14108934b5973eaea01d2.png

那究竟函数如何找到对应的捕获列表呢,go语言中通过一个function value调用函数时,会把对应的funcval结构体地址存入特定寄存器,例如amd64平台使用的是DX寄存器。这样在闭包函数中,就可以通过寄存器取出funcval结构体的地址,然后加上相应的偏移来找到每一个被捕获的变量。所以go语言中闭包就是有捕获列表的function value,而没有捕获列表的function value直接忽略这个寄存器的值就好了

捕获列表

被闭包捕获的变量,要在外层函数与闭包函数中表现一致,好像它们在使用同一个变量,为此,go语言的编译器针对不同情况做了不同的处理

被捕获的变量没有被修改的情况


最简单的情况就像上面那个例子,被捕获的变量除了初始化赋值外,在任何地方都没有被修改过,所以直接拷贝值到捕获列表就ok了

被捕获的变量被修改的情况

如果除了初始化赋值外还被修改过,那就要再做细分了,在这个例子中,被捕获的是局部变量i,而且除了初始化赋值(i:=0)外还被修改(i++)过


闭包函数指令入口addrf,main函数栈帧中,局部变量fs是一个长度为2的function value类型数组,返回值为零值,到create函数栈帧,由于被闭包捕获并修改,局部变量i改为堆分配。在栈上只存一个地址。

第一次for循环。在堆上创建funcval结构体,捕获i的地址,这样闭包函数就和外层函数操作同一个变量了,返回值第一个元素存储addr0,第一次for循环结束,i自增1

第二次for循环开始,再次堆分配一个funcval,捕获变量i的地址,第二个元素存储addr1,第二次循环结束,i再次自增1,create函数结束,把返回值拷贝到局部变量fs

通过fs[0]调用函数时,把addr0存入寄存器。闭包函数通过寄存器存储的地址加上偏移找到捕获变量i的地址。fs[1]同理,被捕获的地址都指向它,所以每次都会打印2。

闭包导致的局部变量堆分配,也是变量逃逸的一种场景。

有修改并被捕获的是参数

如果有修改并被捕获的是参数,涉及到函数原型,就不能像局部变量那样处理了。

参数依然通过调用者栈帧传入,但是编译器会把栈上这个参数拷贝到堆上一份。然后外层函数和闭包函数都使用堆上分配的这一个

被捕获的是返回值

如果被捕获的是返回值,处理方式又有些不同

调用者栈帧上依然会分配返回值的空间,不过闭包的外层函数会在堆上也分配一个,外层函数和闭包函数都使用堆上这个,但是在外层函数返回前,需要把堆上的返回值拷贝到栈上的返回值空间

总结

处理方式虽然多样,但是目标只有一个,就是保存捕获变量在外层函数和闭包函数中的一致性

测试代码

package main
import "fmt"
func main() {
  Test1()// 5 5 5 5 5 除了初始化赋值外还被闭包修改--->捕获变量i的地址
  //为什么Test1这里明明没有修改,但是不是值拷贝呢?
  //因为代码里取地址了,当然就不能捕获值,肯定要捕获地址
  //因为编译器认为闭包内用到的要和外层一致
  Test2()// 4 4 4 4 4 除了初始化赋值外还被外层函数修改--->捕获变量t的地址
  Test3()// 0 1 2 3 4 只被初始化赋值--->值拷贝t
}
func Test1() {
  fmt.Println("test1 start--------------")
  fs := Closure1()
  for _, f := range fs {
    f()
  }
  fmt.Println("test1 end--------------")
}
func Closure1() (fs [5]func()) {
  for i := 0; i < 5; i++ {
    fmt.Println("i address=",&i)
    fs[i] = func() {
      fmt.Println(i, &i)
    }
  }
  return
}
func Test2() {
  fmt.Println("test2 start--------------")
  fs := Closure2()
  for _, f := range fs {
    f()
  }
  fmt.Println("test2 end--------------")
}
func Closure2() (fs [5]func()) {
  t := 0
  for i := 0; i < 5; i++ {
    t = i
    fmt.Println("t address=",&t)
    fs[i] = func() {
      fmt.Println(t, &t)
    }
  }
  return
}
func Test3() {
  fmt.Println("test3 start--------------")
  fs := Closure3()
  for _, f := range fs {
    f()
  }
  fmt.Println("test3 end--------------")
}
func Closure3() (fs [5]func()) {
  for i := 0; i < 5; i++ {
    t := i
    fmt.Println("t address=",&t)
    fs[i] = func() {
      fmt.Println(t)
    }
  }
  return
}
test1 start--------------
i address= 0xc00000a098
i address= 0xc00000a098
i address= 0xc00000a098
i address= 0xc00000a098
i address= 0xc00000a098
5 0xc00000a098
5 0xc00000a098
5 0xc00000a098
5 0xc00000a098
5 0xc00000a098
test1 end--------------
test2 start--------------
t address= 0xc00000a0c0
t address= 0xc00000a0c0
t address= 0xc00000a0c0
t address= 0xc00000a0c0
t address= 0xc00000a0c0
4 0xc00000a0c0
4 0xc00000a0c0
4 0xc00000a0c0
4 0xc00000a0c0
4 0xc00000a0c0
test2 end--------------
test3 start--------------
t address= 0xc00000a0c8
t address= 0xc00000a0d0
t address= 0xc00000a0d8
t address= 0xc00000a0e0
t address= 0xc00000a0e8
0
1
2
3
4
test3 end--------------
进程 已完成,退出代码为 0


目录
相关文章
|
6月前
|
Java Go
Golang底层原理剖析之垃圾回收GC(二)
Golang底层原理剖析之垃圾回收GC(二)
114 0
|
6月前
|
存储 SQL 安全
Golang底层原理剖析之上下文Context
Golang底层原理剖析之上下文Context
143 0
|
19天前
|
存储 安全 测试技术
GoLang协程Goroutiney原理与GMP模型详解
本文详细介绍了Go语言中的Goroutine及其背后的GMP模型。Goroutine是Go语言中的一种轻量级线程,由Go运行时管理,支持高效的并发编程。文章讲解了Goroutine的创建、调度、上下文切换和栈管理等核心机制,并通过示例代码展示了如何使用Goroutine。GMP模型(Goroutine、Processor、Machine)是Go运行时调度Goroutine的基础,通过合理的调度策略,实现了高并发和高性能的程序执行。
77 29
|
16天前
|
负载均衡 算法 Go
GoLang协程Goroutiney原理与GMP模型详解
【11月更文挑战第4天】Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁开销小,适合高并发场景。其调度采用非抢占式和协作式多任务处理结合的方式。GMP 模型包括 G(Goroutine)、M(系统线程)和 P(逻辑处理器),通过工作窃取算法实现负载均衡,确保高效利用系统资源。
|
3月前
|
算法 NoSQL 关系型数据库
熔断原理与实现Golang版
熔断原理与实现Golang版
|
3月前
|
存储 关系型数据库 Go
SOLID原理:用Golang的例子来解释
SOLID原理:用Golang的例子来解释
|
3月前
|
存储 人工智能 Go
golang 反射基本原理及用法
golang 反射基本原理及用法
29 0
|
6月前
|
负载均衡 监控 Go
Golang深入浅出之-Go语言中的服务网格(Service Mesh)原理与应用
【5月更文挑战第5天】服务网格是处理服务间通信的基础设施层,常由数据平面(代理,如Envoy)和控制平面(管理配置)组成。本文讨论了服务发现、负载均衡和追踪等常见问题及其解决方案,并展示了使用Go语言实现Envoy sidecar配置的例子,强调Go语言在构建服务网格中的优势。服务网格能提升微服务的管理和可观测性,正确应对问题能构建更健壮的分布式系统。
453 1
|
6月前
|
JSON 监控 安全
Golang深入浅出之-Go语言中的反射(reflect):原理与实战应用
【5月更文挑战第1天】Go语言的反射允许运行时检查和修改结构,主要通过`reflect`包的`Type`和`Value`实现。然而,滥用反射可能导致代码复杂和性能下降。要安全使用,应注意避免过度使用,始终进行类型检查,并尊重封装。反射的应用包括动态接口实现、JSON序列化和元编程。理解反射原理并谨慎使用是关键,应尽量保持代码静态类型。
96 2
|
6月前
|
Java 编译器 Go
Golang底层原理剖析之内存逃逸
Golang底层原理剖析之内存逃逸
51 0