Go 语言闭包:理解函数值、捕获列表与一致性维护以及它是如何让程序变慢的

2024/05/19 Go 共 3171 字,约 10 分钟

这个看了,但是现在忘记了。。。所以需要记录一下 https://www.bilibili.com/video/BV1hv411x7we 另外注意,本文讨论的 Go 版本是 1.17 之前,从 Go1.17 开始,x86-64 下的 Go 编译器开始使用基于寄存器的调用约定

要点前置

  1. 函数存储机制

    • Go 语言中,函数是头等对象,可以作为参数、返回值或赋值给变量。
    • Function value 是一个指向 runtime.funcval 结构体的指针,该结构体包含函数指令的入口地址。
  2. 函数值优化

    • 当一个函数被赋值给多个变量时,编译器会优化,让这些变量共用同一个 funcval 结构体。
  3. 闭包的定义与特性

    • 闭包是有捕获列表的 Function Value。
    • 闭包在定义阶段共用代码段,实例化后在堆区创建独立的闭包实例,拥有独立的捕获值。
    • 闭包也被称为有状态的函数。
  4. 闭包的捕获列表定位

    • 调用闭包时,func value 结构体的地址存入寄存器,通过寄存器加上偏移找到捕获的变量。
  5. 编译器对捕获列表的不同处理

    • 如果捕获变量未被修改,直接拷贝值到捕获列表。
    • 如果捕获变量被修改,根据变量类型(局部变量、函数参数、返回值)采取不同的处理策略。
  6. 局部变量修改

    • 局部变量被修改时,会在堆上分配,栈上存变量地址,闭包通过地址访问变量。
  7. 函数参数修改

    • 如果参数被修改并被捕获,参数会在堆上拷贝一份,外层函数和闭包都使用堆上的参数。
  8. 返回值修改

    • 如果返回值被修改,外层函数和闭包会在堆上共享一个返回值空间,外层函数返回前将堆上值拷贝到栈上。
  9. 保持一致性

    • 所有这些机制的目的是为了保持外层函数与闭包函数中捕获变量的一致性。

函数是如何存储的

Go 语言中,函数是头等对象,可以用作参数,返回值,也可以赋值给其他对象。Go 语言称这样的参数、返回值或变量为 function value。函数的指令在编译期间生成,而 function value 本质上是一个指针,但是并不直接指向函数指令入口,而是指问一个 runtime.funcval 结构体,这个结构体里只有一个地址,就是这个函数指令的入口地址。

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

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

既然只要有函数的入口地址就可以调用,那为啥还需要一个 funcval 结构体包装这个函数地址?然后使用一个二级指针来调用呢?这里主要是为了处理闭包的情况。

闭包

闭包是有捕获列表的 Function Value。定义阶段的闭包对象共用一个代码段,实例化之后的闭包对象分别在堆区创建自己的闭包实例,拥有自己独立的捕获值。所以闭包也可以称为有状态的函数。

那闭包是如何找到对应的捕获列表呢?

Go 中,通过 function value 调用闭包函数的时候,会把对应的 func value 的结构体的地址,存入到对应的寄存器,例如 amd64 平合使用的是 DX 寄存器。然后就可以通过寄存器取到 func value 结构体的地址,然后加上相应的偏移来找到每一个被捕获的变量。而没有捕获列表的 Function Value 直接忽略这个寄存器的值就好了

针对这个捕获列表也是比较难处理的。为此,Go 语言的编译器对于不同情况,做了不同的处理。

如果捕获变量没有被修改过

如果捕获变量,初始化之后就没有被修改过,那么直接拷贝值到捕获列表就 OK 了。

但是其他被修改的情况呢?

如果捕获变量被修改

局部变量被修改

下面这个例子,main 函数调用 create 函数,由于被闭包捕获,create 函数中的局部变量 i 会被改为堆分配(注意点,这种情况局部变量会逃逸到堆上),在栈上只存一个&i,也就是 i 的地址

第一次循环的时候,遇到闭包,会在堆上创建一个 func value 结构体,捕获列表里面存放的是&i,这个时候就可以和 create 函数操作同一个变量了。

函数参数被修改

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

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

如果被捕获的是返回值

处理方式又有些不同,调用者栈帧上依然会分配返回值空间,不过闭包的外层函数会在堆上也分配一个,外层函数和闭包函数都使用堆上这一个。但是在外层函数返回前,需要把堆上的返回值拷贝到栈上的返回值空间(这个也可以理解哈)

搞这么多,目标只有一个,就是保持捕获变量在外层函数与闭包函数中的一致性

拓展:Go 函数指针是如何让你的程序变慢的?

这篇文章来自于腾讯,分析地很好,非常值得细看,和上面的内容结合起来分析,太爽了。 https://mp.weixin.qq.com/s/bcmvPbWV7nBi7wIfr-MR8w

第一篇文章深入探讨了 Go 语言中闭包的实现机制,包括函数值、闭包的定义、闭包如何捕获和访问外层变量,以及编译器如何处理闭包中的逃逸分析。文章还详细解释了 Go 语言中闭包的性能影响,以及如何通过不同的策略来优化闭包的性能。

第二篇文章则聚焦于 Go 语言中函数指针的性能影响,分析了为什么 Go 中的函数指针调用可能会比 C/C++ 慢,以及函数指针如何影响逃逸分析导致性能问题。文章提供了一些优化技巧,比如使用 switch 语句和 noescape 函数来减少函数指针带来的性能开销。

第二篇文章《Go 函数指针是如何让你的程序变慢的?》主要探讨了在 Go 语言中使用函数指针可能带来的性能问题,并提供了一些优化建议。以下是对文章内容的详细解读:

背景

文章开头提到,在进行 Go 代码的微观优化时,作者发现 Go 的函数调用机制,尤其是当有指针类型参数时,可能会导致性能比 C/C++ 慢。Go 中使用的是“函数值”而不是“函数指针”,但为了与其他语言比较,文章中仍使用“函数指针”这一术语。

函数调用的实现方式

文章通过汇编语言层面的分析,比较了 C 语言和 Go 语言在函数调用方面的实现差异:

  • C 语言中的函数指针:展示了 C 语言中普通函数调用、生成函数指针、通过函数指针间接调用的汇编代码,指出其简洁性。
  • Go 中的函数及函数指针调用:Go 语言从 Go 1.17 开始使用基于寄存器的调用约定,与 C 语言类似,但在通过函数指针间接调用时,Go 生成的汇编代码更为复杂。

函数指针的性能影响

文章深入分析了 Go 中函数指针调用为何会比 C 复杂,并指出这会导致额外的性能开销:

  • 函数指针的实现:Go 中函数指针并不直接指向函数地址,而是指向包含函数地址的数据结构。这是为了支持闭包和匿名函数。
  • 逃逸分析对性能的影响:函数指针的调用可能影响逃逸分析,导致本可以在栈上分配的对象逃逸到堆上,尤其是当函数参数包含指针类型时。

优化

文章提供了两种优化策略:

  1. switch 语句:当函数指针数量不多时,使用 switch 语句直接调用可以消除闭包和变量逃逸的开销。
  2. noescape 函数:Go 源码中提供了 noescape 函数,通过对指针进行位运算来欺骗逃逸分析,但需要谨慎使用,确保不会在函数内部保存指针的地址。

结论

文章最后指出,Go 语言实现函数指针的方式在性能方面的影响比 C/C++ 要大,主要因为增加了一次寻址和可能导致变量逃逸。作者强调,虽然函数指针可能影响性能,但在大多数日常代码中,代码的可读性和可维护性更为重要,只有在需要深度优化时,才需要特别注意函数指针的使用。

文章通过深入分析 Go 语言中函数指针的性能问题,提醒开发者在进行性能优化时需要考虑函数指针的使用,并提供了实用的优化建议。这对于需要进行性能调优的 Go 语言开发者来说,是一篇非常有价值的参考文章。

文档信息

Search

    Table of Contents