标签 Pointer 下的文章

Go GC如何检测内存对象中是否包含指针

本文永久链接 – https://tonybai.com/2022/02/21/how-gc-detect-pointer-in-mem-obj

众所周知,Go是带垃圾回收(GC)的编程语言,开发者通常不需要考虑对内存的管理,降低了心智负担。Go程序运行的时候,GC在背后默默辛劳地为开发者“擦屁股”:把无法reach到的内存对象定期地释放掉以备后续重用。

GC只关心指针,只要被扫描到的内存对象中有指针,它就会“顺藤摸瓜”,把该内存对象所在的“关系网”摸个门儿清,而那些被孤立在这张“网”之外的内存对象就是要被“清扫”的对象。

那么GC在扫描时如何判断某个内存对象中是否有指针呢?这篇文章我们就来说说这事儿!

内存对象中有指针与无指针的差别

Gopher Academy Blog 2018年发表的一篇文章《Avoiding high GC overhead with large heaps》中作者曾用两个例子来对比了内存对象中有指针与无指针时GC的行为差别。我们摘录一下其中的这两个例子,第一个例子如下:

// demo1.go
func main() {
    a := make([]*int, 1e9) 

    for i := 0; i < 10; i++ {
        start := time.Now()
        runtime.GC()
        fmt.Printf("GC took %s\n", time.Since(start))
    }

    runtime.KeepAlive(a)
}

程序中调用runtime.KeepAlive函数用于保证在该函数调用点之前切片a不会被GC释放掉。

我们看到:demo1中声明了一个包含10亿个*int的切片变量a,然后调用runtime.GC函数手工触发GC过程,并度量每次GC的执行时间,我们看看这个程序的执行结果(virtualbox 虚拟机ubuntu 20.04/go 1.18beta2):

$ go run demo1.go
GC took 698.46522ms
GC took 325.315425ms
GC took 321.959991ms
GC took 326.775531ms
GC took 333.949713ms
GC took 332.350721ms
GC took 328.1664ms
GC took 329.905988ms
GC took 328.466344ms
GC took 330.327066ms

我们看到,每轮GC调用都相当耗时。我们再来看第二个例子:

// demo2.go
func main() {
    a := make([]int, 1e9) 

    for i := 0; i < 10; i++ {
        start := time.Now()
        runtime.GC()
        fmt.Printf("GC took %s\n", time.Since(start))
    }

    runtime.KeepAlive(a)
}

这个例子仅是将切片的元素类型由*int改为了int。我们运行一下这第二个例子:

$ go run demo2.go
GC took 3.486008ms
GC took 1.678019ms
GC took 1.726516ms
GC took 1.13208ms
GC took 1.900233ms
GC took 1.561631ms
GC took 1.899654ms
GC took 7.302686ms
GC took 131.371494ms
GC took 1.138688ms

在我们的实验环境中demo2中每轮GC的性能是demo1的300多倍!两个demo源码唯一的不同就是切片中的元素类型,demo1中的切片元素类型为int型指针。GC每次触发后都会全量扫描切片中存储的这10亿个指针,这就是demo1 GC函数执行时间很长的原因。而demo2中的切片元素类型为int,从demo2的运行结果来看,GC根本没有搭理demo2中的a,这也是demo2 GC函数执行时间较短的原因(我测试了一下:在我的环境中,即便不声明切片a,只是执行10次runtime.GC函数,该函数的平均执行时间也在1ms左右)。

通过以上GC行为差异,我们知道GC可以通过切片a的类型知晓其元素是否包含指针,进而决定是否对其进行进一步扫描。下面我们就来看看GC是如何检测到某一个内存对象中包含指针的。

运行时类型信息(rtype)

Go是静态语言,每个变量都有自己的归属的类型,当变量被在堆上分配时,堆上的内存对象也就有了自己归属的类型。Go编译器在编译阶段就为Go应用中的每种类型建立了对应的类型信息,这些信息体现在runtime._rtype结构体中,Go reflect包的rtype结构体等价于runtime._rtype:

// $GOROOT/src/reflect/type.go

// rtype is the common implementation of most values.
// It is embedded in other struct types.
//
// rtype must be kept in sync with ../runtime/type.go:/^type._type.
type rtype struct {
    size       uintptr
    ptrdata    uintptr // number of bytes in the type that can contain pointers
    hash       uint32  // hash of type; avoids computation in hash tables
    tflag      tflag   // extra type information flags
    align      uint8   // alignment of variable with this type
    fieldAlign uint8   // alignment of struct field with this type
    kind       uint8   // enumeration for C
    // function for comparing objects of this type
    // (ptr to object A, ptr to object B) -> ==?
    equal     func(unsafe.Pointer, unsafe.Pointer) bool
    gcdata    *byte   // garbage collection data
    str       nameOff // string form
    ptrToThis typeOff // type for pointer to this type, may be zero
}

在这个结构体类型中的gcdata字段是为GC服务的,我们看看它究竟是什么!怎么看呢?由于reflect.rtype类型是非导出类型,我们需要对本地的Go语言源码做一些hack,我在reflect包的type.go文件中rtype结构体的定义之前添加一行代码:

type Rtype = rtype

我们用Go 1.9版本引入的类型别名(type alias)机制将rtype导出,这样我们就可以在标准库外面使用reflect.Rtype了。

有童鞋可能会问:改了本地Go标准库源码后,Go编译器就会使用最新源码来编译我们的Go示例程序么?Go 1.18之前的版本都不会!大家可以自行试验一下,也可以通过《Go语言精进之路vol1》第16条“理解包导入”一章了解有关于Go编译器构建过程的详尽描述。

下面我们来获取一个切片的类型对应的rtype,看看其中的gcdata究竟是啥?

// demo4.go

package main

import (
    "fmt"
    "reflect"
    "unsafe"
)

type tflag uint8
type nameOff int32 // offset to a name
type typeOff int32 // offset to an *rtype

type rtype struct {
    size       uintptr
    ptrdata    uintptr // number of bytes in the type that can contain pointers
    hash       uint32  // hash of type; avoids computation in hash tables
    tflag      tflag   // extra type information flags
    align      uint8   // alignment of variable with this type
    fieldAlign uint8   // alignment of struct field with this type
    kind       uint8   // enumeration for C
    // function for comparing objects of this type
    // (ptr to object A, ptr to object B) -> ==?
    equal     func(unsafe.Pointer, unsafe.Pointer) bool
    gcdata    *byte   // garbage collection data
    str       nameOff // string form
    ptrToThis typeOff // type for pointer to this type, may be zero
}

func bar() []*int {
    t := make([]*int, 8 )
    return t
}

func main() {
    t := bar()
    v := reflect.TypeOf(t)

    rtyp, ok := v.(*reflect.Rtype)
    if !ok {
        println("error")
        return
    }

    r := (*rtype)(unsafe.Pointer(rtyp))
    fmt.Printf("%#v\n", *r)
    fmt.Printf("*gcdata = %d\n", *(r.gcdata))
}

bar函数返回一个堆上分配的切片实例t,我们通过reflect.TypeOf获取t的类型信息,通过类型断言我们得到该类型的rtype信息:rtyp,不过gcdata也是非导出字段并且是一个指针,我们要想对其解引用,我们这里又在本地定义了一个本地rtype类型,用于输出gcdata指向的内存的值。

运行这个示例:

$go run demo4.go
main.rtype{size:0x18, ptrdata:0x8, hash:0xaad95941, tflag:0x2, align:0x8, fieldAlign:0x8, kind:0x17, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(nil), gcdata:(*uint8)(0x10c1b58), str:3526, ptrToThis:0}
*gcdata = 1

我们看到gcdata指向的一个字节的内存的值为1(二进制为0b00000001)。好了,不卖关子了!gcdata所指的这个字节每一bit上的值代表一个8字节的内存块是否包含指针。这样的一个字节就可以标识在一个64字节的内存块中,每个8字节的内存单元是否包含指针。如果类型长度超过64字节,那么用于表示指针地图的gcdata指向的有效字节个数也不止1个字节。

读过我的“Go语言第一课”专栏的童鞋都知道,切片类型在runtime层表示为下面结构:

// $GOROOT/src/runtime/slice.go

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

这里切片类型结构内存对齐后的size为24,小于64个字节,因此Go用一个字节就可以表示切片类型的指针地图。而*gcdata=1,即最低位上的bit为1,表示切片类型的第一个8字节中存储着一个指针。配合下面的示意图理解起来更easy一些:

我们也可以进一步查看切片中各元素是否包含指针,由于该切片的元素就是指针类型,所以每个元素的rtype.gcdata指向的bitmap的值都应该是1,我们来验证一下:

//demo5.go
... ...
func main() {
    t := bar()
    v := reflect.ValueOf(t)

    for i := 0; i < len(t); i++ {
        v1 := v.Index(i)
        vtyp := v1.Type()

        rtyp, ok := vtyp.(*reflect.Rtype)
        if !ok {
            println("error")
            return
        }

        r := (*rtype)(unsafe.Pointer(rtyp))
        fmt.Printf("%#v\n", *r)
        fmt.Printf("*gcdata = %d\n", *(r.gcdata))
    }
}

这个例子输出了每个切片元素的bitmap,结果如下:

$go run demo5.go

gomain.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1
main.rtype{size:0x8, ptrdata:0x8, hash:0x2522ebe7, tflag:0x8, align:0x8, fieldAlign:0x8, kind:0x36, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x1002c40), gcdata:(*uint8)(0x10c1be0), str:566, ptrToThis:0}
*gcdata = 1

输出结果与预期相符。

我们再来看一个例子,一个用单字节bitmap无法表示的类型:

// demo6.go
... ...
type S struct {  // 起始地址
    a  uint8     // 0
    b  uintptr   // 8
    p1 *uint8    // 16
    c  [3]uint64 // 24
    d  uint32    // 48
    p2 *uint64   // 56
    p3 *uint8    // 64
    e  uint32    // 72
    p4 *uint64   // 80
}

func foo() *S {
    t := new(S)
    return t
}

func main() {
    t := foo()
    println(unsafe.Sizeof(*t)) // 88
    typ := reflect.TypeOf(t)
    rtyp, ok := typ.Elem().(*reflect.Rtype)

    if !ok {
        println("error")
        return
    }
    fmt.Printf("%#v\n", *rtyp)

    r := (*rtype)(unsafe.Pointer(rtyp))
    fmt.Printf("%#v\n", *r)
    fmt.Printf("%d\n", *(r.gcdata))
    gcdata1 := (*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(r.gcdata)) + 1))
    fmt.Printf("%d\n", *gcdata1)
}

在这个例子中,我们定义了一个很大的结构体类型S,其size为88,用一个字节无法表示出其bitmap,于是Go使用了两个字节,我们输出这两个字节的bitmap:

$go run demo6.go
88
reflect.rtype{size:0x58, ptrdata:0x58, hash:0xcdb468b2, tflag:0x7, align:0x8, fieldAlign:0x8, kind:0x19, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x108aea0), gcdata:(*uint8)(0x10c135b), str:3593, ptrToThis:19168}
main.rtype{size:0x58, ptrdata:0x58, hash:0xcdb468b2, tflag:0x7, align:0x8, fieldAlign:0x8, kind:0x19, equal:(func(unsafe.Pointer, unsafe.Pointer) bool)(0x108aea0), gcdata:(*uint8)(0x10c135b), str:3593, ptrToThis:19168}
132
5

我们将结果转换成一幅示意图,如下图:

理解上面这个结构体size以及各字段起始地址的前提是理解内存对齐,这个大家可以在我的博客内搜索以前撰写的有关内存对齐的相关内容,当然也可以参考我在专栏第17讲讲解结构体类型时对Go内存对齐的系统讲解。


img{512x368}

img{512x368}

img{512x368}
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

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

Go 1.17新特性详解:支持将切片转换为数组指针

本文永久链接 – https://tonybai.com/2021/08/18/go-language-specs-changes-in-go-1-17

Go属于那种极简的语言,从诞生到现在语言自身特性变化很小,不会像其他主流语言那样走“你有的我也要有”的特性融合路线。因此新语言特性对于Gopher来说属于“稀缺品”,属于“供不应求”那类事物^_^。这也直接导致了每次Go新版本发布,我们都要首先看看语言特性是否有变更,每个新加入语言的特性都值得我们去投入更多关注,去深入研究。下面我们就来深入Go 1.17版本中语言规范的一些变化!

1. 支持将切片转换为数组指针

在Go 1.17版本之前,我们可以将数组转换为切片,数组将成为转换后的切片底层存储数组,因此,通过切片可以直接改变数组中的元素,就像下面代码这样:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func array2slice() {
    var a = [5]int{11, 12, 13, 14, 15}
    var b = a[0:len(a)] // or var b = a[:]
    b[1] += 10
    fmt.Printf("%v\n", b) // [11 22 13 14 15]
}

但反过来则不行,Go不支持将切片再转换回数组类型,编译器会报下面错误信息:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2array() {
    var b = []int{11, 12, 13}
    var a = [3]int(b) // cannot convert b (type []int) to type [3]int
    fmt.Printf("%v\n", a)
}

那么在Go中我们就没法将切片转换为数组了么?也不是绝对的。我们可以通过unsafe包以hack的方式实现这样的转换,如下面代码所示:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2arrayWithHack() {
    var b = []int{11, 12, 13}
    var a = *(*[3]int)(unsafe.Pointer(&b[0]))
    a[1] += 10
    fmt.Printf("%v\n", b) // [11 12 13]
}

上面代码中,我们实际上得到是切片底层数组的一份拷贝,修改该拷贝中的元素值,切片中的元素将不会受到影响。如果想通过数组修改切片中元素,我们还得通过获取数组指针的方式,如下面代码所示。

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2arrayptrWithHack() {
    var b = []int{11, 12, 13}
    var p = (*[3]int)(unsafe.Pointer(&b[0]))
    p[1] += 10
    fmt.Printf("%v\n", b) // [11 22 13]
}

但是使用unsafe,一如其名,其安全性没有编译器和runtime层的保证,只能由开发者自己保证,Gopher在通常情况下应该避免使用。

于是在2009年末,也就是Go语言宣布开源后不久(那时Go 1.0版本尚未发布),Roger Peppe便提出一个issue(那时go的开发还没有如今这么规范,没有proposal流程):“spec: use (*[4]int)(x) to convert slice x into array pointer”。最初该issue的提出仅仅是因为语法层面缺失了从切片到数组的转换语法,同时希望这种转换以及转换后的数组使用时的下标边界能得到编译器和runtime的协助检查。这个issue得到了当时Go核心开发组成员的支持,Russ Cox还提出将Roger Peppe提议的语法形式做如下变动:

从
b := a.[0:4]

变为 

b := (*[4]int)(a[0:4])

但不知何故,该issue始终没有被纳入Go主干中,直到Go 1.17版本,该issue又被重新提出来了。Go 1.17直接支持将切片转换为数组指针,我们可以在Go 1.17中编写和运行如下面这样的代码,而无需再借助unsafe的hack:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2arrayptr() {
    var b = []int{11, 12, 13}
    var p = (*[3]int)(b)
    p[1] = p[1] + 10
    fmt.Printf("%v\n", b) // [11 22 13]
}

Go通过运行时对这类切片到数组指针的转换代码做检查,如果发现越界行为,就会通过运行时panic予以处理。Go运行时实施检查的一条原则就是“转换后的数组长度不能大于原切片的长度”,注意这里是切片的长度(len),而不是切片的容量(cap),于是下面的转换有些合法,有些非法:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go

var b = []int{11, 12, 13}
var p = (*[4]int)(b) // cannot convert slice with length 3 to pointer to array with length 4
var p = (*[0]int)(b) // ok,*p = []
var p = (*[1]int)(b) // ok,*p = [11]
var p = (*[2]int)(b) // ok,*p = [11, 12]
var p = (*[3]int)(b) // ok,*p = [11, 12, 13]
var p = (*[3]int)(b[:1]) // cannot convert slice with length 1 to pointer to array with length 3

关于这个语言特性的应用场合,目前还待Go社区挖掘,不过已经有人提出提出利用该特性优化go编译器的可行性评估了。

2. unsafe包新增了两个“语法糖”函数

Go 1.17中增加了两个“语法糖”函数:AddSlice。这两个函数原型如下:

// $GOROOT/src/unsafe.go
func Add(ptr Pointer, len IntegerType) Pointe
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

之所以这两个函数能进入unsafe包,和其他已经存在于unsafe包中的函数的目的是一样的,那就是将Go开发人员一些经常使用的“代码片段模式”升级为unsafe包内置的函数,这样不仅可以降低开发人员误用的比例,还可以让Go runtime提供一些检查,增加类型安全性。

unsafe.Add函数

由于go原生不允许指针加减操作,因此我们在特定场景下不得不使用unsafe包来做指针加减,比如下面代码:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/unsafe/add/main.go
const intLen = unsafe.Sizeof(int(8))

func foo() {
    var a = [5]int{11, 12, 13, 14, 15}
    for i := 0; i < 5; i++ {
        p := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&a[0])) + uintptr(uintptr(i)*intLen)))
        *p = *p + 10
    }
    fmt.Println(a)// [21 22 23 24 25]
}

上面代码中间变量p声明同时赋值那行是在Go 1.17之前unsafe包最常见的一种用法和代码模式。大家都这么用,但用起来还那么繁琐,于是便有了unsafe.Add。如果用unsafe.Add改造上面代码,便能简略一些,如下面代码所示:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/unsafe/add/main.go
const intLen = unsafe.Sizeof(int(8))

func bar() {
    var a = [5]int{11, 12, 13, 14, 15}
    for i := 0; i < 5; i++ {
        p := (*int)(unsafe.Add(unsafe.Pointer(&a[0]), uintptr(i)*intLen))
        *p = *p + 10
    }
    fmt.Println(a)
}

本质上unsafe.Add(ptr, len) 就等价于unsafe.Pointer(uintptr(ptr) + uintptr(len))。在之前版本中,runtime的stubs.go中也有个类似的实现:

$GOROOT/src/runtime/stubs.go

  // Should be a built-in for unsafe.Pointer?
  //go:nosplit
  func add(p unsafe.Pointer, x uintptr) unsafe.Pointer {
      return unsafe.Pointer(uintptr(p) + x)
  }

Go 1.17有了这个Add函数后,建议大家就多多使用该函数,而尽量不要自己去拼那个“大长串”了。

unsafe.Slice函数

unsafe.Slice函数支持基于一个数组创建一个切片,该数组将作为切片的底层存储,它也可以理解为等价于下面常用“代码片段”语法糖函数:

func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

<=>

(*[len]ArbitraryType)(unsafe.Pointer(ptr))[:]

下面是unsafe.Slice的一个应用例子:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/unsafe/slice/main.go
func main() {
    var a = [5]int{11, 12, 13, 14, 15}
    s1 := a[:]
    s2 := unsafe.Slice(&a[0], 5)

    fmt.Println(s1) // [11 12 13 14 15]
    fmt.Println(s2) // [11 12 13 14 15]
    fmt.Printf("the type of s2 is %T\n", s2)

    s2[2] += 10
    fmt.Println(a)  // [11 12 23 14 15]
    fmt.Println(s1) // [11 12 23 14 15]
    fmt.Println(s2) // [11 12 23 14 15]
}

我们看到基于unsafe.Slice与基于数组进行切片得到的两个切片一样的,它们的底层数组都是数组a。因此,无论通过修改哪个切片元素,都会反映到另外一个切片中并反映到底层数组上。

3. 小结

在本文中,我们了解到了Go 1.17新增的很少的语言特性,这些个性更多从语言的易用性、安全性等方面考虑才添加的,相较于以往版本,这些新增特性算是不少了。如果要期待语言特性的巨大变更,那还是一起等Go 1.18吧。Go 1.18保证让你爽歪歪。泛型(类型参数)的加入必然让go代码变得比以前更烧脑一些。

本文涉及代码可以在这里下载:https://github.com/bigwhite/experiments/tree/master/go1.17-examples/lang


“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语言进阶课 AI原生开发工作流实战 从 0 开始构建 Agent Harness Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系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