标签 函数 下的文章

Go 1.17新特性详解:使用基于寄存器的调用惯例

本文永久链接 – https://tonybai.com/2021/08/20/using-register-based-calling-convention-in-go-1-17

除了Go语言特性go module有重要变化之外,Go编译器与Go运行时也都有着优化与改进,这两方面的变化对Go程序的构建与运行影响巨大。在这个系列的最后一篇中,我们来看看编译器与运行时中那些值得关注的变化。

1. 使用基于寄存器的调用惯例替代基于堆栈的调用惯例

所谓“调用惯例(calling convention)”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。

Go 1.17版本之前,Go采用基于栈的调用约定,即函数的参数与返回值都通过栈来传递,这种方式的优点是实现简单,不用担心底层cpu架构寄存器的差异,适合跨平台;但缺点就是牺牲了一些性能,我们都知道寄存器的访问速度要远高于内存。

大多数平台上的大多数语言实现都使用基于寄存器的调用约定,通过寄存器而不是内存传递函数参数和返回结果,并指定一些寄存器为调用保存寄存器,允许函数在不同的调用中保持状态。

于是Go在1.17版本决定向这些语言看齐,在amd64架构下率先实现了从基于堆栈的调用惯例到基于寄存器的调用惯例切换

在Go 1.17的版本发布说明文档中有提到:切换到基于寄存器的调用惯例后,一组有代表性的Go包和程序的基准测试显示,Go程序的运行性能提高了约5%,二进制文件大小典型减少约2%。

我们来实测一下,下面采用的是之前进阶专栏中的一个多种方法进行字符串连接的benchmark测试,在Go 1.16.5和Go 1.17下面分别运行Benchmark结果如下:

Go 1.16.5:

$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/demo
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
BenchmarkConcatStringByOperator-8                       12132355            91.51 ns/op
BenchmarkConcatStringBySprintf-8                         2707862           445.1 ns/op
BenchmarkConcatStringByJoin-8                           24101215            50.84 ns/op
BenchmarkConcatStringByStringsBuilder-8                 11104750           124.4 ns/op
BenchmarkConcatStringByStringsBuilderWithInitSize-8     24542085            48.24 ns/op
BenchmarkConcatStringByBytesBuffer-8                    14425054            77.73 ns/op
BenchmarkConcatStringByBytesBufferWithInitSize-8        20863174            49.07 ns/op
PASS
ok      github.com/bigwhite/demo    9.166s

Go 1.17:

$go test -bench .
goos: darwin
goarch: amd64
pkg: github.com/bigwhite/demo
cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz
BenchmarkConcatStringByOperator-8                       13058850            89.47 ns/op
BenchmarkConcatStringBySprintf-8                         2889898           410.1 ns/op
BenchmarkConcatStringByJoin-8                           25469310            47.15 ns/op
BenchmarkConcatStringByStringsBuilder-8                 13064298            92.33 ns/op
BenchmarkConcatStringByStringsBuilderWithInitSize-8     29780911            41.14 ns/op
BenchmarkConcatStringByBytesBuffer-8                    16900072            70.28 ns/op
BenchmarkConcatStringByBytesBufferWithInitSize-8        27310650            43.96 ns/op
PASS
ok      github.com/bigwhite/demo    9.198s

我们看到,相对于Go 1.16.5跑出的结果,Go 1.17在每一个测试项上都有小幅的性能提升,有些性能提升甚至达到10%左右。这种新版本带来的性能的“自然提升”显然是广大Gopher想看到的。

我们再来看看编译后的Go二进制文件的Size变化。以一个自有的1w行左右代码的Go程序为例,分别用Go 1.16.5和Go 1.17进行编译,得到的结果如下:

-rwxr-xr-x   1 tonybai  staff  7264432  8 13 18:31 myapp-go1.16.5*
-rwxr-xr-x   1 tonybai  staff  6934352  8 13 18:32 myapp-go1.17*

我们看到Go 1.17编译后的二进制文件大小相较于Go 1.16.5版本的减少了约4%。

另外Go 1.17发布说明也提到了:改为基于register的调用惯例后,绝大多数程序不会受到影响。只有那些之前就已经违反unsafe.Pointer的使用规则的代码可能会受到影响,比如不遵守unsafe规则通过unsafe.Pointer访问函数参数,或依赖一些像比较函数代码指针的未公开的行为。

除了改为基于寄存器的调用惯例之外,Go 1.17编译器还支持包含闭包的函数的内联(inline)了!这样一来,一个带有闭包的函数可能会在函数被内联的每个地方产生一个不同的闭包代码指针,因此,Go函数的值不能直接比较

2. 引入//go:build形式的构建约束指示符,以替代原先易错的// +build形式

Go 1.17之前,我们可以通过在源码文件头部放置+build构建约束指示符来实现构建约束,但这种形式十分易错,并且它并不支持&&和||这样的直观的逻辑操作符,而是用逗号、空格替代,下面是原+build形式构建约束指示符的用法及含义:

这种与程序员直觉“有悖”的形式让Gopher们十分痛苦,于是Go 1.17回归“正规”,引入了//go:build形式的构建约束指示符,这样一方面是与源文件中的其他指示符保持形式一致,比如: //go:nosplit、//go:norace、//go:noinline、//go:generate等。另外一方面,新形式将支持&&和||逻辑操作符,对于程序员来说,这样的形式就是自解释的,我们无需再像上面那样列出一个表来解释每个指示符组合的含义了,如下代码所示:

//go:build linux && (386 || amd64 || arm || arm64 || mips64 || mips64le || ppc64 || ppc64le)
//go:build linux && (mips64 || mips64le)
//go:build linux && (ppc64 || ppc64le)
//go:build linux && !386 && !arm

考虑到兼容性,Go命令可以识别这两种形式的构建约束指示符,但推荐Go 1.17之后都用新引入的这种形式。

gofmt可以兼容处理两种形式,处理原则是:如果一个源码文件只有// +build形式的指示符,gofmt会将与其等价的//go:build行加入。否则,如果一个源文件中同时存在这两种形式的指示符行,那么//+build行的信息将被//go:build行的信息所覆盖。

go vet工具也会检测源文件中同时存在的不同形式的构建指示符语义不一致的情况,比如针对下面这段代码:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/runtime/buildtag.go

//go:build linux && !386 && !arm
// +build linux

package main

import "fmt"

func main() {
    fmt.Println("hello, world")
}

go vet会提示如下问题:

./buildtag.go:2:1: +build lines do not match //go:build condition

3. 运行时栈跟踪输出信息的格式更“可读”

之前写过一篇文章《记一次go panic问题的解决过程》,在那篇文章中,我们探讨了如何解读panic发生后输出的函数栈跟踪信息。

下面的代码示例用于对比运行时栈输出信息的差异:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/runtime/stacktrace.go

package main

type myStruct struct {
    m int
    s string
    p *float64
}

func foo(a int, b string, c []byte, f *myStruct) (int, error) {
    panic("mypanic")
}

func main() {
    f := 3.14
    ms := myStruct{
        m: 17,
        s: "myStruct",
        p: &f,
    }
    a := 11
    b := "hello"
    c := []byte{'a', 'b', 'c'}
    foo(a, b, c, &ms)
}

在这个示例程序中,我们在foo函数中“故意”panic,以便go运行时在程序退出前输出栈跟踪信息(注意编译时关闭内联优化)。针对这个示例程序,Go 1.17之前的版本输出的栈跟踪信息是这样的(go 1.16.5版本):

$go build -gcflags '-N -l' -o stacktrace-go1.16.5 stacktrace.go
$./stacktrace-go1.16.5
panic: mypanic

goroutine 1 [running]:
main.foo(0xb, 0x1073f53, 0x5, 0xc000046715, 0x3, 0x3, 0xc000046758, 0x0, 0x0, 0x0)
    /Users/tonybai/Go/src/github.com/bigwhite/experiments/go1.17-examples/runtime/stacktrace.go:10 +0x4a
main.main()
    /Users/tonybai/Go/src/github.com/bigwhite/experiments/go1.17-examples/runtime/stacktrace.go:23 +0x148

上面输出信息中foo函数后面括号中的各个值与foo函数原型完全对不上。要想知道这些数值的含义究竟是什么,可以参考我上面提到的那篇文章,这里不赘述。

使用Go 1.17版本编译后会是什么样子呢?我们再来看一下:

go 1.17:

$go build -gcflags '-N -l' -o stacktrace-go1.17 stacktrace.go
$./stacktrace
panic: mypanic

goroutine 1 [running]:
main.foo(0xb, {0x10608d4, 0x5}, {0xc00004270d, 0x3, 0x3}, 0xc000042750)
    /Users/tonybai/Go/src/github.com/bigwhite/experiments/go1.17-examples/runtime/stacktrace.go:10 +0x59
main.main()
    /Users/tonybai/Go/src/github.com/bigwhite/experiments/go1.17-examples/runtime/stacktrace.go:23 +0x10f

对照着该示例程序中foo函数的原型:

func foo(a int, b string, c []byte, f *myStruct) (int, error)

这回一目了然了!我们看到Go 1.17改进了当发送未捕获的panic或当runtime.Stack被调动时,运行时输出的栈跟踪信息的格式。Go 1.17版本之前,函数参数被打印成基于内存布局的十六进制值的形式,就像前面那个难于解读的输出信息。Go 1.17版,源码中函数的每个参数都被单独打印,用逗号分隔。聚合类型(结构体、数组、字符串、切片、接口和complex)的参数用大括号分隔。需要注意的是,只存在于寄存器中而没有存储到内存中的参数的值可能是不准确的。函数的返回值(通常是不准确的)不再被打印了。

通过上的输出,我们还可以清晰的看到stringbyte切片以及结构体在内存中的表示方式,string本质上是一个拥有两个字段的结构,而切片则是一个三元组表示的结构。

3. 小结

上面是Go 1.17编译器与运行时的主要改动,通过使用寄存器的调用惯例,我们的Go程序可以轻松获得5%左右的性能提升,可执行程序的Size也会得到减小。Go 1.17对运行时栈输出信息的“可读化”改进进一步提升了开发体验。

除此之外,Go的标准库随着新版本的发布都会有大量的改动,但每个开发人员对标准库的关注点差别很大,因此,在这个系列中不会详细做说明了,大家还是参考Go 1.17的发布说明文档各取所需吧^_^。

本文所涉及的源码可以在这里 – https://github.com/bigwhite/experiments/tree/master/go1.17-examples/


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强。在2021年上半年,部落将策划两个专题系列分享,并且是部落独享哦:

  • Go技术书籍的书摘和读书体会系列
  • Go与eBPF系列

欢迎大家加入!

Go技术专栏“改善Go语⾔编程质量的50个有效实践”正在慕课网火热热销中!本专栏主要满足广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订
阅!

img{512x368}

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go中被闭包捕获的变量何时会被回收

本文永久链接 – https://tonybai.com/2021/08/09/when-variables-captured-by-closures-are-recycled-in-go

1. Go函数闭包

Go语言原生提供了对闭包(closure)的支持。在Go语言中,闭包就是函数字面值。Go规范中是这样诠释闭包的:

函数字面值(function literals)是闭包:它们可以引用其包裹函数(surrounding function)中定义的变量。然后,这些变量在包裹函数和函数字面值之间共享,只要它们可以被访问,它们就会继续存在。

闭包在Go语言中有着广泛的应用,最常见的就是与go关键字一起联合使用创建一个新goroutine,比如下面标准库中net/http包中的一段代码:

// $GOROOT/src/net/http/fileTransport.go

00 func (t fileTransport) RoundTrip(req *Request) (resp *Response, err error) {
01     rw, resc := newPopulateResponseWriter()
02     go func() {
03         t.fh.ServeHTTP(rw, req)
04         rw.finish()
05     }()
06     return <-resc, nil
07 }

上面这段代码中的RoundTrip方法就是使用go关键字结合闭包创建了一个新的goroutine,并且在这个goroutine中运行的函数还引用了本属于其外部包裹函数的变量:t、rw和req,或者说两者共享这些变量。

原本仅在RoundTrip方法内部使用的变量一旦被“共享”给了其他函数,那么它就无法在栈上分配了,逃逸到堆上是确定性事件。

那么问题来了!这些被引用或叫被闭包捕获的分配在堆上的外部变量何时能被回收呢?也许上面的例子还十分容易理解,当新创建的goroutine执行完毕后,这些变量就可以回收了。那么下面的闭包函数呢?

func foo() func(int) int {
    i := []int{0: 10, 1: 11, 15: 128}
    return func(n int) int {
        n+=i[0]
        return n
    }
}

在这个foo函数中,被闭包函数捕获的长度为16的切片变量i何时可以被回收呢?

注:我们定义闭包时,喜欢用引用外部包裹函数的变量这种说法,但在Go编译器的实现代码中,使用的是capture var,翻译过来就是“被捕获的变量”,所以这里也用了“捕获”一词来表示那些被闭包共享使用的外部包裹函数甚至是更外层函数中的变量。

foo函数的返回值类型是一个函数,也就是说foo函数的本地变量i被foo返回的新创建的闭包函数所捕获,i不会被回收。通常一个堆上的内存对象有明确的引用它的对象或指向它的地址的指针,该对象才会继续存活,当其不可达(unreachable)时,即再没有引用它的对象或指向它的指针时才会被GC回收。

那么,变量i究竟是被谁引用了呢?变量i将在何时被回收呢?

我们先回头看一个非闭包的一般函数:

func f1() []int {
    i := []int{0: 10, 1: 11, 15: 128}
    return i
}

func f2() {
    sl := f1()
    sl[0] = sl[0] + 10
    fmt.Println(sl)
}

func main() {
    f2()
}

我们看到f1将自己的局部切片变量i返回后,该变量被f2函数中的sl所引用,f2函数执行完成后,切片变量i将变成unreachable,GC将回收该变量对应的堆内存。

如果换成闭包函数,比如前面的foo函数,我们很大可能是这么来用的:

// https://github.com/bigwhite/experiments/tree/master/closure/closure1.go

 1 package main
 2
 3 import "fmt"
 4
 5 func foo() func(int) int {
 6     i := []int{0: 10, 1: 11, 15: 128}
 7     return func(n int) int {
 8         n += i[0]
 9         return n
10     }
11 }
12
13 func bar() {
14     f := foo()
15     a := f(5)
16     fmt.Println(a)
17 }
18
19 func main() {
20     bar()
21     g := foo()
22     b := g(6)
23     fmt.Println(b)
24 }

在这里例子中,只要闭包函数中引用了foo函数的本地变量。这突然让我想起了“在Go中,函数也是一等公民的特性”。难道是闭包函数这一对象引用了foo函数的本地变量? 那么闭包函数在内存布局上是如何引用到foo函数的本地整型切片变量i的呢?闭包函数在内存布局中被映射为什么了呢?

如果一门编程语言对某种语言元素的创建和使用没有限制,我们可以像对待值(value)一样对待这种语法元素,那么我们就称这种语法元素是这门编程语言的“一等公民”。

2. Go闭包函数对象

要解答这个问题,我们只能寻求Go汇编的帮助。我们生成上面的closure1.go的汇编代码(我们使用go 1.16.5版本Go编译器):

$go tool compile -S closure1.go > closure1.s

在汇编代码中,我们找到closure1.go中第7行创建一个闭包函数所对应的汇编代码:

// https://github.com/bigwhite/experiments/tree/master/closure/closure1.s

    0x0052 00082 (closure1.go:7)    LEAQ    type.noalg.struct { F uintptr; "".i []int }(SB), CX
    0x0059 00089 (closure1.go:7)    MOVQ    CX, (SP)
    0x005d 00093 (closure1.go:7)    PCDATA  $1, $1
    0x005d 00093 (closure1.go:7)    NOP
    0x0060 00096 (closure1.go:7)    CALL    runtime.newobject(SB)
    0x0065 00101 (closure1.go:7)    MOVQ    8(SP), AX
    0x006a 00106 (closure1.go:7)    LEAQ    "".foo.func1(SB), CX
    0x0071 00113 (closure1.go:7)    MOVQ    CX, (AX)
    0x0074 00116 (closure1.go:7)    MOVQ    $16, 16(AX)
    0x007c 00124 (closure1.go:7)    MOVQ    $16, 24(AX)
    0x0084 00132 (closure1.go:7)    PCDATA  $0, $-2
    0x0084 00132 (closure1.go:7)    CMPL    runtime.writeBarrier(SB), $0
    0x008b 00139 (closure1.go:7)    JNE 165
    0x008d 00141 (closure1.go:7)    MOVQ    ""..autotmp_7+16(SP), CX
    0x0092 00146 (closure1.go:7)    MOVQ    CX, 8(AX)
    0x0096 00150 (closure1.go:7)    PCDATA  $0, $-1
    0x0096 00150 (closure1.go:7)    MOVQ    AX, "".~r0+40(SP)
    0x009b 00155 (closure1.go:7)    MOVQ    24(SP), BP
    0x00a0 00160 (closure1.go:7)    ADDQ    $32, SP
    0x00a4 00164 (closure1.go:7)    RET
    0x00a5 00165 (closure1.go:7)    PCDATA  $0, $-2
    0x00a5 00165 (closure1.go:7)    LEAQ    8(AX), DI
    0x00a9 00169 (closure1.go:7)    MOVQ    ""..autotmp_7+16(SP), CX
    0x00ae 00174 (closure1.go:7)    CALL    runtime.gcWriteBarrierCX(SB)
    0x00b3 00179 (closure1.go:7)    JMP 150
    0x00b5 00181 (closure1.go:7)    NOP

汇编总是晦涩难懂。我们重点看第一行:

    0x0052 00082 (closure1.go:7)    LEAQ    type.noalg.struct { F uintptr; "".i []int }(SB), CX

我们看到对应到Go源码中创建闭包函数的第7行,这行汇编代码大致意思是将一个结构体对象的地址放入CX。我们把这个结构体对象摘录出来:

struct {
    F uintptr
    i []int
}

这个结构体对象是哪里来的呢?显然是Go编译器根据闭包函数的“特征”创建出来的。其中的F就是闭包函数自身的地址,毕竟是函数,这个地址与一般函数的地址应该是在一个内存区域(比如rodata的只读数据区),那么整型切片变量i呢?难道这就是闭包函数所捕获的那个Foo函数本地变量i。没错!正是它。如果不信,我们可以再定义一个捕获更多变量的闭包函数来验证一下。

下面是一个捕获3个整型变量的闭包函数的生成函数:

// https://github.com/bigwhite/experiments/tree/master/closure/closure2.go

func foo() func(int) int {
    var a, b, c int = 11, 12, 13
    return func(n int) int {
        a += n
        b += n
        c += n
        return a + b + c
    }
}

其对应的汇编代码中那个闭包函数结构为:

0x0084 00132 (closure2.go:10)   LEAQ    type.noalg.struct { F uintptr; "".a *int; "".b *int; "".c *int }(SB), CX

将该结构体提取出来,即:

struct {
    F uintptr
    a *int
    b *int
    c *int
}

到这里,我们证实了引用了包裹函数本地变量的正是闭包函数自身,即编译器为其在内存中建立的闭包函数结构体对象。通过unsafe包,我们甚至可以输出这个闭包函数对象。以closure2.go为例,我们来尝试一下,如下面代码所示。

// https://github.com/bigwhite/experiments/tree/master/closure/closure2.go

func foo() func(int) int {
    var a, b, c int = 11, 12, 13
    return func(n int) int {
        a += n
        b += n
        c += n
        return a + b + c
    }
}

type closure struct {
    f uintptr
    a *int
    b *int
    c *int
}

func bar() {
    f := foo()
    f(5)
    pc := *(**closure)(unsafe.Pointer(&f))
    fmt.Printf("%#v\n", *pc)
    fmt.Printf("a=%d, b=%d,c=%d\n", *pc.a, *pc.b, *pc.c)
    f(6)
    fmt.Printf("a=%d, b=%d,c=%d\n", *pc.a, *pc.b, *pc.c)
}

在上面代码中,我们参考汇编的输出定义了closure这个结构体来对应内存中的闭包函数对象(每种闭包对象都是不同的,一个技巧就是参考汇编输出的对象来定义),通过unsafe的地址转换,我们将内存中的闭包对象映射到closure结构体实例上。运行上面程序,我们可以得到如下输出:

$go run closure2.go
main.closure{f:0x10a4d80, a:(*int)(0xc000118000), b:(*int)(0xc000118008), c:(*int)(0xc000118010)}
a=16, b=17,c=18
a=22, b=23,c=24

在上面的例子中,闭包函数捕获了外部变量a、b和c,这些变量实质上被编译器创建的闭包内存对象所引用。当我们调用foo函数时,闭包函数对象创建(其地址赋值给变量f)。这样,f对象一直引用着变量a、b和c。只有当f被回收,a、b和c才会因unreachable而被回收。

如果我们在闭包函数中仅仅是对捕获的外部变量进行只读操作,那么闭包函数对象不会存储这些变量的指针,而仅会做一份值拷贝。当然,如果某个变量被一个函数中创建的多个闭包所捕获,并且有的只读,有的修改,那么闭包函数对象还是会存储该变量的地址的。

了解了闭包函数的本质,我们再来看本文标题中的问题就容易多了。其答案就是在捕捉变量的闭包函数对象被回收后,如果这些被捕捉的变量没有其他引用,它们将变为unreachable的,后续就会被GC回收了

3. 小结

我们回顾一下文章开头引用的Go语言规范中对闭包诠释中提到的一句话:“只要它们可以被访问,它们就会继续存在”。现在看来,我们可以将其理解为:只要闭包函数对象存在,其捕获的那些变量就会存在,就不会被回收

闭包函数的这种机制决定了我们在日常使用过程中也要时刻考虑着闭包函数所捕获的变量可能的“延迟回收”。如果某个场景下,闭包引用的变量占用内存较大,且闭包函数对象被创建出的数量很多且因业务需要延迟很久才会被执行(比如定时器场景),这就会导致堆内存可能长期处于高水位,我们要考虑内存容量是否能承受这样的水位,如果不能,则要考虑更换实现方案了。

本文涉及的所有代码可以从这里下载:https://github.com/bigwhite/experiments/tree/master/closure

4. 参考资料

  • 深入理解函数闭包 – https://zhuanlan.zhihu.com/p/56750616
  • Go语言高级编程 – https://github.com/chai2010/advanced-go-programming-book/blob/master/ch3-asm/ch3-06-func-again.md#366-闭包函数

“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强。在2021年上半年,部落将策划两个专题系列分享,并且是部落独享哦:

  • Go技术书籍的书摘和读书体会系列
  • Go与eBPF系列

欢迎大家加入!

Go技术专栏“改善Go语⾔编程质量的50个有效实践”正在慕课网火热热销中!本专栏主要满足广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订
阅!

img{512x368}

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats