标签 fmt 下的文章

通过实例理解Go逃逸分析

本文永久链接 – https://tonybai.com/2021/05/24/understand-go-escape-analysis-by-example

翻看了一下自己的Go文章归档,发现自己从未专门写过有关Go逃逸分析(escape analysis)的文章。关于Go变量的逃逸分析,大多数Gopher其实并不用关心,甚至可以无视。但是如果你将Go应用于性能敏感的领域,要完全压榨出Go应用的性能,那么理解Go逃逸分析就大有裨益了。在本文,我们就一起来理解一下Go的逃逸分析。

1. 逃逸分析(escape analysis)要解决的问题

C/C++语言出身的程序员对堆内存(heap)和栈内存(stack)都有着“泾渭分明”的理解。在操作系统演化出现进程虚拟内存地址(virtual memory address)的概念后,如下图所示,应用程序的虚拟内存地址空间就被划分为堆内存区(如图中的heap)和栈内存区(如图中的stack):


图:一个进程的虚拟内存地址空间(图来自https://dave.cheney.net/2014/06/07/five-things-that-make-go-fast)

在x86平台linux操作系统下,如上图,一般将栈内存区放在高地址,栈向下延伸;而堆内存去放在低地址,堆向上延伸,这样做的好处就是便于堆和栈可动态共享那段内存区域。

这是否意味着所有分配在堆内存区域的内存对象地址一定比分配在栈内存区域的内存对象地址要小呢?在C/C++中是这样的,但是在Go语言中,这是不一定的,因为go堆内存所使用的内存页(page)与goroutine的栈所使用的内存页是交织在一起的

无论是栈内存还是堆内存,对于应用而言都是合法可用的内存地址空间。之所以将其区分开,是因为应用程序的内存分配和管理的需要。

栈内存上的对象的存储空间是自动分配和销毁的,无需开发人员或编程语言运行时过多参与,比如下面的这段C代码(用C代码更能体现栈内存与堆内存的差别):

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cstack.c

#include <stdio.h>

void bar() {
    int e = 31;
    int f = 32;
    printf("e = %d\n", e);
    printf("f = %d\n", f);
}

void foo() {
    int c = 21;
    int d = 22;
    printf("c = %d\n", c);
    printf("d = %d\n", d);
}

int main() {
    int a = 11;
    int b = 12;
    printf("a = %d\n", a);
    printf("b = %d\n", b);
    foo();
    bar();
}

上面这段c程序算上main函数共有三个函数,每个函数中都有两个整型变量,C编译器自动为这些变量在栈内存上分配空间,我们无需考虑它什么时候被创建以及何时被销毁,我们只需在特定的作用域(其所在函数内部)使用它即可,而无需担心其内存地址不合法,因此这些被分配在栈内存上的变量也被称为“自动变量”。但是如果将其地址返回到函数的外部,那么函数外部的代码通过解引用而访问这些变量时便会出错,如下面示例:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cstack_coredump.c

#include <stdio.h>

int *foo() {
    int c = 11;
    return &c;
}

int main() {
    int *p = foo();
    printf("the return value of foo = %d\n", *p);
}

如代码所示,在上面这个例子中,我们将foo函数内的自动变量c的地址通过函数返回值返回给foo函数的调用者(main)了,这样当我们在main函数中引用该地址输出该变量值的时候,我们就会收到异常,比如在ubuntu上运行上述程序,我们会得到如下结果(在macos上运行,gcc会给出相同的警告,但程序运行不会dump core):

# gcc cstack_dumpcore.c
cstack_dumpcore.c: In function ‘foo’:
cstack_dumpcore.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
     return &c;
            ^~
# ./a.out
Segmentation fault (core dumped)

这样一来我们就需要一种内存对象,可以在全局(跨函数间)合法使用,这就是堆内存对象。但是和位于栈上的内存对象由程序自行创建销毁不同,堆内存对象需要通过专用API手工分配和释放,在C中对应的分配和释放方法就是malloc和free:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/c/cheap.c

#include <stdio.h>
#include <stdlib.h>

int *foo() {
    int *c = malloc(sizeof(int));
    *c = 12;
    return c;
}

int main() {
    int *p = foo();
    printf("the return value of foo = %d\n", *p);
    free(p);
}

在这个示例中我们使用malloc在foo函数中分配了一个堆内存对象,并将该对象返回给main函数,main函数使用完该对象后调用了free函数手工释放了该堆内存块。

显然和自动变量相比,堆内存对象的生命周期管理将会给开发人员带来很大的心智负担。为了降低这方面的心智负担,带有GC(垃圾回收)的编程语言出现了,比如Java、Go等。这些带有GC的编程语言会对位于堆上的对象进行自动管理。当某个对象不可达时(即没有其对象引用它时),它将会被回收并被重用。

但GC的出现虽然降低了开发人员在内存管理方面的心智负担,但GC不是免费的,它给程序带来的性能损耗是不可忽视的,尤其是当堆内存上有大量待扫描的堆内存对象时,将会给GC带来过大的压力,从而使得GC占用更多本应用于处理业务逻辑的计算和存储资源。于是人们开始想方法尽量减少在堆上的内存分配,可以在栈上分配的变量尽量留在栈上。

逃逸分析(escape analysis)就是在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法。一个理想的逃逸分析算法自然是能将那些人们认为需要分配在栈上的变量尽可能保留在栈上,尽可能少的“逃逸”到堆上的算法。但这太过理想,各种语言都有自己的特殊情况,各种语言的逃逸算法的精确度实际都会受到这方面的影响。

2. Go语言的逃逸分析

Go从诞生那天起,逃逸分析就始终伴随其左右。正如上面说到的逃逸分析的目标,Go编译器使用逃逸分析来决定哪些变量应该在goroutine的栈上分配,哪些变量应该在堆上分配。

截至目前,Go一共有两个版本的逃逸分析实现,分水岭在Go 1.13版本。Go 1.13版本之前是Go逃逸分析的第一版实现,位于Go源码的src/cmd/compile/internal/gc/esc.go中(以go 1.12.7版本为例),代码规模2400多行;Go 1.13版本中加入了由Matthew Dempsky重写的第二版逃逸分析,并默认开启,可以通过-gcflags=”-m -newescape=false”恢复到使用第一版逃逸分析。之所以重写,主要是考虑第一版代码的可读性和可维护性问题,新版代码主要位于Go项目源码的src/cmd/compile/internal/gc/escape.go中,它将逃逸分析代码从上一版的2400多行缩减为1600多行,并作了更为完整文档和注释。但注意的是新版代码在算法精确性上并没有质的变化

但即便如此,经过了这么多年的“修修补补”,Dmitry Vyukov 2015年提出的那些“Go Escape Analysis Flaws”多数已经fix了。Go项目中内置了对逃逸分析的详尽的测试代码(位于Go项目下的test/escape*.go文件中)。

在新版逃逸分析实现的注释中($GOROOT/src/cmd/compile/internal/gc/escape.go),我们可以大致了解逃逸分析的实现原理。注释中的原理说明中提到了算法基于的两个不变性:

  1. 指向栈对象的指针不能存储在堆中(pointers to stack objects cannot be stored in the heap);
  2. 指向栈对象的指针不能超过该栈对象的存活期(即指针不能在栈对象被销毁后依旧存活)(pointers to a stack object cannot outlive that object)。

源码注释中也给出Go逃逸分析的大致原理和过程。Go逃逸分析的输入是Go编译器解析了Go源文件后所获得的整个程序的抽象语法树(Abstract syntax tree,AST):

源码解析后得到的代码AST的Node切片为xtop:

// $GOROOT/src/cmd/compile/internal/gc/go.go
var xtop []*Node

在Main函数中,xtop被传入逃逸分析的入口函数escapes:

// $GOROOT/src/cmd/compile/internal/gc/main.go

// Main parses flags and Go source files specified in the command-line
// arguments, type-checks the parsed Go package, compiles functions to machine
// code, and finally writes the compiled package definition to disk.
func Main(archInit func(*Arch)) {
    ... ...
    // Phase 6: Escape analysis.
    // Required for moving heap allocations onto stack,
    // which in turn is required by the closure implementation,
    // which stores the addresses of stack variables into the closure.
    // If the closure does not escape, it needs to be on the stack
    // or else the stack copier will not update it.
    // Large values are also moved off stack in escape analysis;
    // because large values may contain pointers, it must happen early.
    timings.Start("fe", "escapes")
    escapes(xtop)
    ... ...
}

下面是escapes函数的实现:

// $GOROOT/src/cmd/compile/internal/gc/esc.go
func escapes(all []*Node) {
    visitBottomUp(all, escapeFuncs)
}

// $GOROOT/src/cmd/compile/internal/gc/scc.go
// 强连接node - 一个数据结构
func visitBottomUp(list []*Node, analyze func(list []*Node, recursive bool)) {
    var v bottomUpVisitor
    v.analyze = analyze
    v.nodeID = make(map[*Node]uint32)
    for _, n := range list {
        if n.Op == ODCLFUNC && !n.Func.IsHiddenClosure() {
            v.visit(n)
        }
    }
}

// $GOROOT/src/cmd/compile/internal/gc/escape.go

// escapeFuncs performs escape analysis on a minimal batch of
// functions.
func escapeFuncs(fns []*Node, recursive bool) {
    for _, fn := range fns {
        if fn.Op != ODCLFUNC {
            Fatalf("unexpected node: %v", fn)
        }
    }

    var e Escape
    e.heapLoc.escapes = true

    // Construct data-flow graph from syntax trees.
    for _, fn := range fns {
        e.initFunc(fn)
    }
    for _, fn := range fns {
        e.walkFunc(fn)
    }
    e.curfn = nil

    e.walkAll()
    e.finish(fns)
}

根据注释,escapes的大致原理是(直译):

  • 首先,构建一个有向加权图,其中顶点(称为”location”,由gc.EscLocation表示)代表由语句和表达式分配的变量,而边(gc.EscEdge)代表变量之间的赋值(权重代表寻址/取地址次数)。
  • 接下来,遍历(visitBottomUp)该有向加权图,在图中寻找可能违反上述两个不变量条件的赋值路径。 违反上述不变量的赋值路径。如果一个变量v的地址是储存在堆或其他可能会超过它的存活期的地方,那么v就会被标记为需要在堆上分配。
  • 为了支持函数间的分析,算法还记录了从每个函数的参数到堆的数据流以及到其结果的数据流。算法将这些信息称为“参数标签(parameter tag)”。这些标签信息在静态调用时使用,以改善对函数参数的逃逸分析。

当然即便看到这,你可能依旧一头雾水,没关系,这里不是讲解逃逸分析原理,如果想了解原理,那就请认真阅读那2400多行代码。

注:有一点需要明确,那就是静态逃逸分析也无法确定的对象会被放置在堆上,后续精确的GC会处理这些对象,这样最大程度保证了代码的安全性。

3. Go逃逸分析的示例

Go工具链提供了查看逃逸分析过程的方法,我们可以通过在-gcflags中使用-m来让Go编译器输出逃逸分析的过程,下面是一些典型的示例。

1) 简单原生类型变量的逃逸分析

我们来看一个原生整型变量的逃逸分析过程,下面是示例的代码:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/int.go
     1  package main
     2
     3  import "testing"
     4
     5  func foo() {
     6      a := 11
     7      p := new(int)
     8      *p = 12
     9      println("addr of a is", &a)
    10      println("addr that p point to is", p)
    11  }
    12
    13  func bar() (*int, *int) {
    14      m := 21
    15      n := 22
    16      println("addr of m is", &m)
    17      println("addr of n is", &n)
    18      return &m, &n
    19  }
    20
    21  func main() {
    22      println(int(testing.AllocsPerRun(1, foo)))
    23      println(int(testing.AllocsPerRun(1, func() {
    24          bar()
    25      })))
    26  }

我们通过-gcflags “-m -l”来执行逃逸分析,之所以传入-l是为了关闭inline,屏蔽掉inline对这个过程以及最终代码生成的影响:

// go 1.16版本 on MacOS
$go build -gcflags "-m -l" int.go
# command-line-arguments
./int.go:7:10: new(int) does not escape
./int.go:14:2: moved to heap: m
./int.go:15:2: moved to heap: n
./int.go:23:38: func literal does not escape

逃逸分析的结果与我们手工分析的一致:函数bar中的m、n逃逸到heap(对应上面输出的有moved to heap: xx字样的行),这两个变量将在heap上被分配存储空间。而函数foo中的a以及指针p指向的内存块都在栈上分配(即便我们是调用的new创建的int对象,Go中new出来的对象可不一定分配在堆上,逃逸分析的输出日志中还专门提及new(int)没有逃逸)。我们执行一下该示例(执行时同样传入-l关闭inline):

$go run -gcflags "-l" int.go
addr of a is 0xc000074860
addr that p point to is 0xc000074868
addr of a is 0xc000074860
addr that p point to is 0xc000074868
0
addr of m is 0xc0000160e0
addr of n is 0xc0000160e8
addr of m is 0xc0000160f0
addr of n is 0xc0000160f8
2

首先,我们看到未逃逸的a和p指向的内存块的地址区域在0xc000074860~0xc000074868;而逃逸的m和n被分配到了堆内存空间,从输出的结果来看在0xc0000160e0~0xc0000160e8。我们可以明显看到这是两块不同的内存地址空间;另外通过testing包的AllocsPerRun的输出,我们同样印证了函数bar中执行了两次堆内存分配动作。

我们再来看看这个代码对应的汇编代码:

$go tool compile -S int.go |grep new
    0x002c 00044 (int.go:14)    CALL    runtime.newobject(SB)
    0x004d 00077 (int.go:15)    CALL    runtime.newobject(SB)
    rel 45+4 t=8 runtime.newobject+0
    rel 78+4 t=8 runtime.newobject+0

我们看到在对应源码的14和15行,汇编调用了runtime.newobject在堆上执行了内存分配动作,这恰是逃逸的m和n声明的位置。从下面newobject代码的实现我们也能看到,它实际上在gc管理的内存上执行了malloc动作:

// $GOROOT/src/runtime/malloc.go

// implementation of new builtin
// compiler (both frontend and SSA backend) knows the signature
// of this function
func newobject(typ *_type) unsafe.Pointer {
    return mallocgc(typ.size, typ, true)
}

2) 切片变量自身和切片元素的逃逸分析

了解过切片实现原理的gopher都知道,切片变量实质上是一个三元组:

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

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

其中这个三元组的第一个字段array指向的是切片底层真正存储元素的指针。这样当为一个切片变量分配内存时,便既要考虑切片本身(即上面的slice结构体)在哪里分配,也要考虑切片元素的存储在哪里分配。我们看下面示例:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/slice.go

     1  package main
     2
     3  import (
     4      "reflect"
     5      "unsafe"
     6  )
     7
     8  func noEscapeSliceWithDataInHeap() {
     9      var sl []int
    10      println("addr of local(noescape, data in heap) slice = ", &sl)
    11      printSliceHeader(&sl)
    12      sl = append(sl, 1)
    13      println("append 1")
    14      printSliceHeader(&sl)
    15      println("append 2")
    16      sl = append(sl, 2)
    17      printSliceHeader(&sl)
    18      println("append 3")
    19      sl = append(sl, 3)
    20      printSliceHeader(&sl)
    21      println("append 4")
    22      sl = append(sl, 4)
    23      printSliceHeader(&sl)
    24  }
    25
    26  func noEscapeSliceWithDataInStack() {
    27      var sl = make([]int, 0, 8)     28      println("addr of local(noescape, data in stack) slice = ", &sl)
    29      printSliceHeader(&sl)
    30      sl = append(sl, 1)
    31      println("append 1")
    32      printSliceHeader(&sl)
    33      sl = append(sl, 2)
    34      println("append 2")
    35      printSliceHeader(&sl)
    36  }
    37
    38  func escapeSlice() *[]int {
    39      var sl = make([]int, 0, 8)     40      println("addr of local(escape) slice = ", &sl)
    41      printSliceHeader(&sl)
    42      sl = append(sl, 1)
    43      println("append 1")
    44      printSliceHeader(&sl)
    45      sl = append(sl, 2)
    46      println("append 2")
    47      printSliceHeader(&sl)
    48      return &sl
    49  }
    50
    51  func printSliceHeader(p *[]int) {
    52      ph := (*reflect.SliceHeader)(unsafe.Pointer(p))
    53      println("slice data =", unsafe.Pointer(ph.Data))
    54  }
    55
    56  func main() {
    57      noEscapeSliceWithDataInHeap()
    58      noEscapeSliceWithDataInStack()
    59      escapeSlice()
    60  }

对上述示例运行逃逸分析:

$go build -gcflags "-m -l" slice.go
# command-line-arguments
./slice.go:51:23: p does not escape
./slice.go:27:15: make([]int, 0, 8) does not escape
./slice.go:39:6: moved to heap: sl
./slice.go:39:15: make([]int, 0, 8) escapes to heap

我们从输出的信息中看到:

  • 位于39行的escapeSlice函数中的sl逃逸到堆上了;
  • 位于39行的escapeSlice函数中的切片sl的元素也逃逸到堆上了;
  • 位于27行的切片sl的元素没有逃逸。

由于很难看到三个函数中各个切片的元素是否逃逸,我们通过运行该示例来看一下:

$go run -gcflags " -l" slice.go
addr of local(noescape, data in heap) slice =  0xc00006af48
slice data = 0x0
append 1
slice data = 0xc0000160c0
append 2
slice data = 0xc0000160d0
append 3
slice data = 0xc0000140c0
append 4
slice data = 0xc0000140c0

addr of local(noescape, data in stack) slice =  0xc00006af48
slice data = 0xc00006af08
append 1
slice data = 0xc00006af08
append 2
slice data = 0xc00006af08

addr of local(escape) slice =  0xc00000c030
slice data = 0xc00001a100
append 1
slice data = 0xc00001a100
append 2
slice data = 0xc00001a100

注:我们利用reflect包的SliceHeader输出切片三元组中的代表底层数组地址的字段,这里是slice data。

我们看到:

  • 第一个函数noEscapeWithDataInHeap声明了一个空slice,并在后面使用append向切片附加元素。从输出结果来看,slice自身是分配在栈上的,但是运行时在动态扩展切片时,选择了将其元素存储在heap上;
  • 第二个函数noEscapeWithDataInStack直接初始化了一个包含8个元素存储空间的切片,切片自身没有逃逸,并且在附加(append)的元素个数小于等于8个的时候,元素直接使用了为其分配的栈空间;但如果附加的元素超过8个,那么运行时会在堆上分配一个更大的空间并将原栈上的8个元素复制过去,后续该切片的元素就都存储在了堆上。这也是为什么强烈建议在创建 slice 时带上预估的cap参数的原因,不仅减少了堆内存的频繁分配,在切片变量未逃逸的情况下,在cap容量之下,所有元素都分配在栈上,这将提升运行性能。
  • 第三个函数escapeSlice则是切片变量自身以及其元素的存储都在堆上。

3) fmt.Printf系列函数让变量逃逸到堆(heap)上了?

很多人在go项目的issue中反馈fmt.Printf系列函数让变量逃逸到堆上了,情况真的是这样么?我们通过下面示例来看一下:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf1.go
     1  package main
     2
     3  import "fmt"
     4
     5  func foo() {
     6      var a int = 66666666
     7      var b int = 77
     8      fmt.Printf("a = %d\n", a)
     9      println("addr of a in foo =", &a)
    10      println("addr of b in foo =", &b)
    11  }
    12
    13  func main() {
    14      foo()
    15  }

注:println和print两个预定义函数并没有像fmt.Printf系列函数的“副作用”,不会影响变量的逃逸性。所以这里使用println来输出变量的实际分配内存地址。

对上面的代码运行逃逸分析:

$go build -gcflags "-m -l" printf1.go
# command-line-arguments
./printf1.go:8:12: ... argument does not escape
./printf1.go:8:13: a escapes to heap

我们看到逃逸分析输出第8行的变量“a escapes to heap”,不过这个“逃逸”有些奇怪,因为按照之前的经验,如果某个变量真实逃逸了,那么逃逸分析会在其声明的那行输出:“moved to heap: xx”字样。而上面这个输出既不是在变量声明的那一行,也没有输出“moved to heap: a”字样,变量a真的逃逸了么?我们运行一下上面示例,看看变量a的地址究竟是在堆上还是栈上:

$go run -gcflags "-l" printf1.go
a = 66666666
addr of a in foo = 0xc000092f50
addr of b in foo = 0xc000092f48

我们看到变量a的地址与未逃逸的变量b的地址都在同一个栈空间,变量a并未逃逸!如果你反编译为汇编,你肯定也看不到runtime.newobject的调用。

那么“./printf1.go:8:13: a escapes to heap”这句的含义究竟是什么呢?显然逃逸分析在这一行是对进入fmt.Printf的数据流的分析,我们修改一下go标准库源码,然后build -a重新编译一下printf1.go,看看在fmt.Printf内部变量的分布情况:

// $GOROOT/src/fmt/print.go

func Printf(format string, a ...interface{}) (n int, err error) {
    // 添加下面四行代码
    for i := 0; i < len(a); i++ {
        println(a[i])
        println(&a[i])
    }
    return Fprintf(os.Stdout, format, a...)
}

重新编译printf1.go并运行编译后的可执行文件(为了避免):

$go build -a -gcflags "-l" printf1.go
$./printf1
(0x10af200,0xc0000160c8)
0xc00006cf58
a = 66666666
addr of a in foo = 0xc00006cf50
addr of b in foo = 0xc00006cf48

我们看到fmt.Printf的实参a在传入后被装箱到一个interface{}类型的形参变量中,而这个形参变量自身则是被分配在栈上的(0xc00006cf58),而通过println输出的该interface{}类型形参变量的类型部分和值部分分别指向0x10af200和0xc0000160c8。显然值部分是在堆内存上分配的。那么“./printf1.go:8:13: a escapes to heap”是否指的是装箱后的值部分在堆上分配呢?这里也不确定。

我们再来看一个例子来对比一下:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf2.go
     1  package main
     2
     3  import "fmt"
     4
     5  func foo() {
     6      var a int = 66666666
     7      var b int = 77
     8      fmt.Printf("addr of a in bar = %p\n", &a)
     9      println("addr of a in bar =", &a)
    10      println("addr of b in bar =", &b)
    11  }
    12
    13  func main() {
    14      foo()
    15  }

在printf2.go这个例子中,与printf1.go不同的是我们在foo函数中使用fmt.Printf输出的是变量a的地址:&a。我们运行一下新版逃逸分析:

// go 1.16

$go build -gcflags "-m -l" printf2.go
# command-line-arguments
./printf2.go:6:6: moved to heap: a
./printf2.go:8:12: ... argument does not escape

我们看到位于第6行声明的变量a居然真的逃逸到了堆上。我们运行一下printf2.go:

$go build -a -gcflags "-l" printf2.go
$./printf2
(0x10ab4a0,0xc0000160c8)
0xc00006cf58
addr of a in bar = 0xc0000160c8
addr of a in bar = 0xc0000160c8
addr of b in bar = 0xc00006cf48

我们看到变量a的地址果然与位于栈上的变量b相差很大,应该就是在堆上,那么这样看那些在go项目中提issue的gopher所言不虚。变量a的地址以实参的形式传入fmt.Printf后被装箱到一个interface{}形参变量中,而从结果来看,fmt.Printf真的要求装箱的形参变量的值部分要在堆上分配,但根据逃逸分析不变性,堆上的对象不能存储一个栈上的地址,而这次存储的是a的地址,于是将a判定为逃逸,于是a自身也就被分配到了堆上(0xc0000160c8)。

我们用go 1.12.7运行一下老版的逃逸分析:

// go 1.12.7
$go build -gcflags "-m -l" printf2.go
# command-line-arguments
./printf2.go:8:40: &a escapes to heap
./printf2.go:8:40: &a escapes to heap
./printf2.go:6:6: moved to heap: a
./printf2.go:8:12: foo ... argument does not escape
./printf2.go:9:32: foo &a does not escape
./printf2.go:10:32: foo &b does not escape

老版的逃逸分析给出了更详细的输出,比如:“&a escapes to heap”,其所指想必就是&a被装箱到堆内存上;而println输出&a则无需&a被装箱。但此后对变量a的最终判定为逃逸。

Go核心团队成员Keith Randall对逃逸分析输出的日志给过一个解释,大致意思是:当逃逸分析输出“b escapes to heap”时,意思是指存储在b中的值逃逸到堆上了(当b为指针变量时才有意义),即任何被b引用的对象必须分配在堆上,而b自身则不需要;如果b自身也逃逸到堆上,那么逃逸分析会输出“&b escapes to heap”。

这个问题目前已经没有fix,其核心问题在8618这个issue中。

5. 手动强制避免逃逸

对于printf2.go中的例子,我们确定一定以及肯定:a不需要逃逸。但若使用fmt.Printf,我们无法阻拦a的逃逸。那是否有一种方法可以干扰逃逸分析,使逃逸分析认为需要在堆上分配的内存对象而我们确定认为不需要逃逸的对象避免逃逸呢?在Go运行时代码中,我们发现了一个函数:

// $GOROOT/src/runtime/stubs.go
func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0) // 任何数值与0的异或都是原数
}

并且在Go标准库和运行时实现中,该函数得到大量使用。该函数的实现逻辑使得我们传入的指针值与其返回的指针值是一样的。该函数只是通过uintptr做了一次转换,而这次转换将指针转换成了数值,这“切断”了逃逸分析的数据流跟踪,导致传入的指针避免逃逸。

我们看一下下面例子:

// github.com/bigwhite/experiments/blob/master/go-escape-analysis/go/printf3.go
package main

import (
    "fmt"
    "unsafe"
)

func noescape(p unsafe.Pointer) unsafe.Pointer {
    x := uintptr(p)
    return unsafe.Pointer(x ^ 0)
}

func foo() {
    var a int = 66666666
    var b int = 77
    fmt.Printf("addr of a in bar = %p\n", (*int)(noescape(unsafe.Pointer(&a))))
    println("addr of a in bar =", &a)
    println("addr of b in bar =", &b)
}

func main() {
    foo()
}

对该代码实施统一分析:

$go build -gcflags "-m -l" printf3.go

# command-line-arguments
./printf3.go:8:15: p does not escape
./printf3.go:16:12: ... argument does not escape

我们看到a这次没有逃逸。运行一下编译后的可执行文件:

$./printf3
(0x10ab4c0,0xc00009af50)
0xc00009af58
addr of a in bar = 0xc00009af50
addr of a in bar = 0xc00009af50
addr of b in bar = 0xc00009af48

我们看到a没有像printf2.go那样被放在堆上,这次和b一样都是在栈上分配的。并且在fmt.Printf执行的过程中a的栈地址始终是有效的。

曾有一篇通过逃逸分析优化性能的论文《Escape from Escape Analysis of Golang》使用的就是上述noescape函数的思路,有兴趣的童鞋可以自行下载阅读。

6. 小结

通过这篇文章,我们了解到了逃逸分析要解决的问题、Go逃逸分析的现状与简单原理、一些Go逃逸分析的实例以及对逃逸分析输出日志的说明。最后,我们给出一个强制避开逃逸分析的方案,但要谨慎使用。

日常go开发过程,绝大多数情况无需考虑逃逸分析,除非性能敏感的领域。在这些领域,对系统执行热点路径做一次逃逸分析以及相应的优化,可能回带来程序性能的一定提升。

本文涉及的源码可以在这里下载:https://github.com/bigwhite/experiments/blob/master/go-escape-analysis


“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.17版本支持

Go官博今晨发表了Go核心团队两位大神Ian Lance Taylor和Go语言之父之一的Robert Griesemer撰写的文章“The Next Step for Generics”,该文介绍了Go泛型(Go Generics)的最新进展和未来计划。

2019年中旬,在Go 1.13版本发布前夕的GopherCon 2019大会上,Ian Lance Taylor代表Go核心团队做了有关Go泛型进展的介绍。自那以后,Go团队对原先的Go Generics技术草案做了进一步精化,并编写了相关工具让社区gopher体验满足这份设计的Go generics语法,返回建议和意见。经过一年多的思考、讨论、反馈与实践,Go核心团队决定在这份旧设计的基础上另起炉灶,撰写了一份Go Generics的新技术提案:“Type Parameters”。与上一份提案最大的不同在于使用扩展的interface类型替代“Contract”用于对类型参数的约束。

parametric polymorphism((形式)参数多态)是Go此版泛型设计的基本思想。和Go设计思想一致,这种参数多态并不是通过像面向对象语言那种子类型的层次体系实现的,而是通过显式定义结构化的约束实现的。基于这种设计思想,该设计不支持模板元编程(template metaprogramming)和编译期运算。

注意:虽然都称为泛型(generics),但是Go中的泛型(generics)仅是用于狭义地表达带有类型参数(type parameter)的函数或类型,这与其他编程语言中的泛型(generics)在含义上有相似性,但不完全相同。

从目前的情况来看,该版设计十分接近于最终接受的方案,因此作为Go语言鼓吹者这里就和大家一起看看最早将于Go 1.17版本(2021年8月)中加入的Go泛型支持究竟是什么样子的。由于目前关于Go泛型的资料仅限于这份设计文档以及一些关于这份设计的讨论贴,本文内容均来自这些资料。另外最终加入Go的泛型很可能与目前设计文档中提到的有所差异,请各位小伙伴们了解。

1. 通过为type和function增加类型参数(type parameters)的方式实现泛型

Go的泛型主要体现在类型和函数的定义上。

  • 泛型函数(generic function)

Go提案中将带有类型参数(type parameters)的函数称为泛型函数,比如:

func PrintSlice(type T)(s []T) {
    for _, v := range s {
        fmt.Printf("%v ", v)
    }
    fmt.Print("\n")
}

其中,函数名PrintSlice与函数参数列表之间的type T即为类型参数列表。顾名思义,该函数用于打印元素类型为T的切片中的所有元素。使用该函数的时候,除了要传入要打印的切片实参外,还需要为类型参数传入实参(一个类型名),这个过程称为泛型函数的实例化。见下面例子:

// https://go2goplay.golang.org/p/rDbio9c4AQI
package main

import "fmt"

func PrintSlice(type T)(s []T) {
    for _, v := range s {
        fmt.Printf("%v ", v)
    }
    fmt.Print("\n")
}

func main() {
    PrintSlice(int)([]int{1, 2, 3, 4, 5})
    PrintSlice(float64)([]float64{1.01, 2.02, 3.03, 4.04, 5.05})
    PrintSlice(string)([]string{"one", "two", "three", "four", "five"})
}

运行该示例:

1 2 3 4 5
1.01 2.02 3.03 4.04 5.05
one two three four five

但是这种每次都显式指定类型参数实参的使用方式显然有些复杂繁琐,给开发人员带来心智负担和不好的体验。Go编译器是聪明的,大多数使用泛型函数的场景下,编译器都会根据函数参数列表传入的实参类型自动推导出类型参数的实参类型(type inference)。比如将上面例子改为下面这样,程序依然可以输出正确的结果。

// https://go2goplay.golang.org/p/UgHqZ7g4rbo
package main

import "fmt"

func PrintSlice(type T)(s []T) {
    for _, v := range s {
        fmt.Printf("%v ", v)
    }
    fmt.Print("\n")
}

func main() {
    PrintSlice([]int{1, 2, 3, 4, 5})
    PrintSlice([]float64{1.01, 2.02, 3.03, 4.04, 5.05})
    PrintSlice([]string{"one", "two", "three", "four", "five"})
}
  • 泛型类型(generic type)

Go提案中将带有类型参数(type parameters)的类型定义称为泛型类型,比如我们定义一个底层类型为切片类型的新类型:Vector:

type Vector(type T) []T

该Vector(切片)类型中的元素类型为T。和泛型函数一样,使用泛型类型时,我们首先要对其进行实例化,即显式为类型参数赋一个实参值(一个类型名):

//https://go2goplay.golang.org/p/tIZN2if1Wxo

package main

import "fmt"

func PrintSlice(type T)(s []T) {
    for _, v := range s {
        fmt.Printf("%v ", v)
    }
    fmt.Print("\n")
}

type Vector(type T) []T

func main() {
    var vs = Vector(int){1, 2, 3, 4, 5}
    PrintSlice(vs)
}

泛型类型的实例化是必须显式为类型参数传参的,编译器无法自行做类型推导。如果将上面例子中main函数改为如下实现方式:

func main() {
    var vs = Vector{1, 2, 3, 4, 5}
    PrintSlice(vs)
}

则Go编译器会报如下错误:

type checking failed for main
prog.go2:15:11: cannot use generic type Vector(type T) without instantiation

这个错误的意思就是:未实例化(instantiation)的泛型类型Vector(type T)无法使用。

2. 通过扩展了的interface类型对类型参数进行约束和限制

1) 对泛型函数中类型参数的约束与限制

有了泛型函数,我们来实现一个“万能”加法函数:

// https://go2goplay.golang.org/p/t0vXI6heUrT
package main

import "fmt"

func Add(type T)(a, b T) T {
    return a + b
}

func main() {
    c := Add(5, 6)
    fmt.Println(c)
}

运行上述示例:

type checking failed for main
prog.go2:6:9: invalid operation: operator + not defined for a (variable of type T)

什么情况!这么简单的一个函数,Go编译器居然报了这个错误:类型参数T未定义“+”这个操作符运算

在此版Go泛型设计中,泛型函数只能使用类型参数所能实例化出的任意类型都能支持的操作。比如上述Add函数的类型参数type T没有任何约束,它可以被实例化为任何类型。那么这些实例化后的类型是否都支持“+”操作符运算呢?显然不是。因此,编译器针对示例代码中的第六行报了错!

对于像上面Add函数那样的没有任何约束的类型参数实例,Go允许对其进行的操作包括:

  • 声明这些类型的变量;
  • 使用相同类型的值为这些变量赋值;
  • 将这些类型的变量以实参形式传给函数或从作为函数返回值;
  • 取这些变量的地址;
  • 将这些类型的值转换或赋值给interface{}类型变量;
  • 通过类型断言将一个接口值赋值给这类类型的变量;
  • 在type switch块中作为一个case分支;
  • 定义和使用由该类型组成的复合类型,比如:元素类型为该类型的切片;
  • 将该类型传递给一些内置函数,比如new。

那么,我们要让上面的Add函数通过编译器的检查,我们就需要限制其类型参数所能实例化出的类型的范围。比如:仅允许实例化为底层类型(underlying type)为整型类型的类型。上一版Go泛型设计中使用Contract来定义对类型参数的约束,不过由于Contract与interface在概念范畴上有交集,让Gopher们十分困惑,于是在新版泛型设计中,Contract这个关键字被移除了,取而代之的是语法扩展了的interface,即我们使用interface类型来修饰类型参数以实现对其可实例化出的类型集合的约束。我们来看下面例子:

// https://go2goplay.golang.org/p/kMxZI2vIsk-
package main

import "fmt"

type PlusableInteger interface {
    type int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64
}

func Add(type T PlusableInteger)(a, b T) T {
    return a + b
}

func main() {
    c := Add(5, 6)
    fmt.Println(c)
}

运行该示例:

11

如果我们在main函数中写下如下代码:

    f := Add(3.65, 7.23)
    fmt.Println(f)

我们将得到如下编译错误:

type checking failed for main
prog.go2:20:7: float64 does not satisfy PlusableInteger (float64 not found in int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64)

我们看到:该提案扩展了interface语法,新增了类型列表(type list)表达方式,专用于对类型参数进行约束。以该示例为例,如果编译器通过类型推导得到的类型在PlusableInteger这个接口定义的类型列表(type list)中,那么编译器将允许这个类型参数实例化;否则就像Add(3.65, 7.23)那样,推导出的类型为float64,该类型不在PlusableInteger这个接口定义的类型列表(type list)中,那么类型参数实例化将报错!

注意:定义中带有类型列表的接口将无法用作接口变量类型,比如下面这个示例:

// https://go2goplay.golang.org/p/RchnTw73VMo
package main

type PlusableInteger interface {
    type int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64
}

func main() {
    var n int = 6
    var i PlusableInteger
    i = n
    _ = i
}

编译器会报如下错误:

type checking failed for main
prog.go2:9:8: interface type for variable cannot contain type constraints (int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64)

我们还可以用interface的原生语义对类型参数进行约束,看下面例子:

// https://go2goplay.golang.org/p/hyTbglTLoIn
package main

import (
    "fmt"
    "strconv"
)

type StringInt int

func (i StringInt) String() string {
    return strconv.Itoa(int(i))
}

type Stringer interface {
    String() string
}

func Stringify(type T Stringer)(s []T) (ret []string) {
    for _, v := range s {
        ret = append(ret, v.String())
    }
    return ret
}

func main() {
    fmt.Println(Stringify([]StringInt{1, 2, 3, 4, 5}))
}

运行该示例:

[1 2 3 4 5]

如果我们在main函数中写下如下代码:

func main() {
    fmt.Println(Stringify([]int{1, 2, 3, 4, 5}))
}

那么我们将得到下面的编译器错误输出:

type checking failed for main
prog.go2:27:2: int does not satisfy Stringer (missing method String)

我们看到:只有实现了Stringer接口的类型才会被允许作为实参传递给Stringify泛型函数的类型参数并成功实例化。

我们还可以结合interface的类型列表(type list)和方法列表一起对类型参数进行约束,看下面示例:

// https://go2goplay.golang.org/p/tchwW6mPL7_d
package main

import (
    "fmt"
    "strconv"
)

type StringInt int

func (i StringInt) String() string {
    return strconv.Itoa(int(i))
}

type SignedIntStringer interface {
    type int, int8, int16, int32, int64
    String() string
}

func Stringify(type T SignedIntStringer)(s []T) (ret []string) {
    for _, v := range s {
        ret = append(ret, v.String())
    }
    return ret
}

func main() {
    fmt.Println(Stringify([]StringInt{1, 2, 3, 4, 5}))
}

在该示例中,用于对泛型函数的类型参数进行约束的SignedIntStringer接口既包含了类型列表,也包含方法列表,这样类型参数的实参类型既要在SignedIntStringer的类型列表中,也要实现了SignedIntStringer的String方法。

如果我们将上面的StringInt的底层类型改为uint:

type StringInt uint

那么我们将得到下面的编译器错误输出:

type checking failed for main
prog.go2:27:14: StringInt does not satisfy SignedIntStringer (uint not found in int, int8, int16, int32, int64)

2) 引入comparable预定义类型约束

由于Go泛型设计选择了不支持运算操作符重载,因此,我们即便对interface做了语法扩展,依然无法表达类型是否支持==!=。为了解决这个表达问题,这份新设计提案中引入了一个新的预定义类型约束:comparable。我们看下面例子:

// https://go2goplay.golang.org/p/tea39NqwZGC
package main

import (
    "fmt"
)

// Index returns the index of x in s, or -1 if not found.
func Index(type T comparable)(s []T, x T) int {
    for i, v := range s {
        // v and x are type T, which has the comparable
        // constraint, so we can use == here.
        if v == x {
            return i
        }
    }
    return -1
}

type Foo struct {
    a string
    b int
}

func main() {
    fmt.Println(Index([]int{1, 2, 3, 4, 5}, 3))
    fmt.Println(Index([]string{"a", "b", "c", "d", "e"}, "d"))
    pos := Index(
        []Foo{
            Foo{"a", 1},
            Foo{"b", 2},
            Foo{"c", 3},
            Foo{"d", 4},
            Foo{"e", 5},
        }, Foo{"b", 2})
    fmt.Println(pos)
}

运行该示例:

2
3
1

我们看到Go的原生支持比较的类型,诸如整型、字符串以及由这些类型组成的复合类型(如结构体)均可以直接作为实参传给由comparable约束的类型参数。comparable可以看成一个由Go编译器特殊处理的、包含由所有内置可比较类型组成的type list的interface类型。我们可以将其嵌入到其他作为约束的接口类型定义中:

type ComparableStringer interface {
    comparable
    String() string
}

只有支持比较的类型且实现了String方法,才能满足ComparableStringer的约束。

3) 对泛型类型中类型参数的约束

和对泛型函数中类型参数的约束方法一样,我们也可以对泛型类型的类型参数以同样方法做同样的约束,看下面例子:

// https://go2goplay.golang.org/p/O-YpTcW-tPu

// Package set implements sets of any comparable type.
package main

// Set is a set of values.
type Set(type T comparable) map[T]struct{}

// Make returns a set of some element type.
func Make(type T comparable)() Set(T) {
    return make(Set(T))
}

// Add adds v to the set s.
// If v is already in s this has no effect.
func (s Set(T)) Add(v T) {
    s[v] = struct{}{}
}

// Delete removes v from the set s.
// If v is not in s this has no effect.
func (s Set(T)) Delete(v T) {
    delete(s, v)
}

// Contains reports whether v is in s.
func (s Set(T)) Contains(v T) bool {
    _, ok := s[v]
    return ok
}

// Len reports the number of elements in s.
func (s Set(T)) Len() int {
    return len(s)
}

// Iterate invokes f on each element of s.
// It's OK for f to call the Delete method.
func (s Set(T)) Iterate(f func(T)) {
    for v := range s {
        f(v)
    }
}

func main() {
    s := Make(int)()

    // Add the value 1,11,111 to the set s.
    s.Add(1)
    s.Add(11)
    s.Add(111)

    // Check that s does not contain the value 11.
    if s.Contains(11) {
        println("the set contains 11")
    }
}

运行该示例:

the set contains 11

这个示例定义了一个数据结构:Set。该Set中的元素是有约束的:必须支持可比较。对应到代码中,我们用comparable作为泛型类型Set的类型参数的约束。

4) 关于泛型类型的方法

泛型类型和普通类型一样,也可以定义自己的方法。但泛型类型的方法目前不支持除泛型类型自身的类型参数之外的其他类型参数了。我们看下面例子:

// https://go2goplay.golang.org/p/JipsxG7jeCN

// Package set implements sets of any comparable type.
package main

// Set is a set of values.
type Set(type T comparable) map[T]struct{}

// Make returns a set of some element type.
func Make(type T comparable)() Set(T) {
    return make(Set(T))
}

// Add adds v to the set s.
// If v is already in s this has no effect.
func (s Set(T)) Add(v T) {
    s[v] = struct{}{}
}

func (s Set(T)) Method1(type P)(v T, p P) {

}

func main() {
    s := Make(int)()
    s.Add(1)
    s.Method1(10, 20)
}

在这个示例中,我们新定义的Method1除了在参数列表中使用泛型类型Set的类型参数T之外,又接受了一个类型参数P。执行该示例:

type checking failed for main
prog.go2:18:24: methods cannot have type parameters

我们看到编译器给出错误:泛型类型的方法不能再有其他类型参数。目前提案仅是暂时不支持额外的类型参数(如果支持,会让语言规范和实现都变得异常复杂),Go核心团队也会听取社区反馈的意见,直到大家都认为支持额外类型参数是有必要的,那么后续会重新添加。

5) type *T Constraint

上面我们一直采用的对类型参数的约束形式是:

type T Constraint

假设调用泛型函数时某类型A要作为T的实参传入,A必须实现Constraint(接口)。

如果我们将上面对类型参数的约束形式改为:

type *T Constraint

那么这将意味着类型A要作为T的实参传入,*A必须满足Constraint(接口)。并且Constraint中的所有方法(如果有的话)都仅能通过*A实例调用。我们来看下面示例:

// https://go2goplay.golang.org/p/g3cwgguCmUo
package main

import (
    "fmt"
    "strconv"
)

type Setter interface {
    Set(string)
}

func FromStrings(type *T Setter)(s []string) []T {
    result := make([]T, len(s))
    for i, v := range s {
        result[i].Set(v)
    }
    return result
}

// Settable is a integer type that can be set from a string.
type Settable int

// Set sets the value of *p from a string.
func (p *Settable) Set(s string) {
    i, _ := strconv.Atoi(s) // real code should not ignore the error
    *p = Settable(i)
}

func main() {
    nums := FromStrings(Settable)([]string{"1", "2"})
    fmt.Println(nums)
}

运行该示例:

[1 2]

我们看到Settable的方法集合是空的,而*Settable的方法集合(method set)包含了Set方法。因此,*Settable是满足Setter对FromStrings函数的类型参数的约束的。

而如果我们直接使用type T Setter,那么编译器将给出下面错误:

type checking failed for main
prog.go2:30:22: Settable does not satisfy Setter (missing method Set)

如果我们使用type T Setter并结合使用FromStrings(*Settable),那么程序运行会panic。

https://go2goplay.golang.org/p/YLe2d78aSz-

3. 性能影响

根据这份技术提案中关于泛型函数和泛型类型实现的说明,Go会使用基于接口的方法来编译泛型函数(generic function),这将优化编译时间,因为该函数仅会被编译一次。但是会有一些运行时代价。

对于每个类型参数集,泛型类型(generic type)可能会进行多次编译。这将延长编译时间,但是不会产生任何运行时代价。编译器还可以选择使用类似于接口类型的方法来实现泛型类型,使用专用方法访问依赖于类型参数的每个元素。

4. 小结

Go泛型方案的即将定型即好也不好。Go向来以简洁著称,增加加泛型,无论采用什么技术方案,都会增加Go的复杂性,提升其学习门槛,代码可读性也会下降。但在某些场合(比如实现container数据结构及对应算法库等),使用泛型却又能简化实现。

在这份提案中,Go核心团队也给出如下期望:

We expect that most packages will not define generic types or functions, but many packages are likely to use generic types or functions defined elsewhere

我们期望大多数软件包不会定义泛型类型或函数,但是许多软件包可能会使用在其他地方定义的泛型类型或函数。

并且提案提到了会在Go标准库中增加一些新包,已实现基于泛型的标准数据结构(slice、map、chan、math、list/ring等)、算法(sort、interator)等,gopher们只需调用这些包提供的API即可。

另外该提案的一大优点就是与Go1兼容,我们可能永远不会使用Go2这个版本号了。

go核心团队提供了可实践该方案语法的playground:https://go2goplay.golang.org/,大家可以一边研读技术提案,一边编写代码进行实验验证。


我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网上线了,感谢小伙伴们学习支持!

我爱发短信:企业级短信平台定制开发专家 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

微信赞赏:
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