分类 技术志 下的文章

为什么这个T类型实例无法调用*T类型的方法

本文永久链接 – https://tonybai.com/2022/02/27/go-addressable

近期在“Go语言第一课”专栏后台看到一位学员的一则留言,如下图:

由于有课程上下文,所以我这里将问题的上下文重新描述一下。

专栏的第25讲,我们学习了Go语言提供的一个“语法糖”,比如下面这个例子:

type T struct {
    a int
}

func (t T) M1() {
    t.a = 10
}

func (t *T) M2() {
    t.a = 11
}

func main() {
    var t1 T
    t1.M1()
    t1.M2()

    var t2 = &T{}
    t2.M1()
    t2.M2()
}

Go语言的类型有方法集合(method set)的概念,以上面例子来说,类型T的方法集合为{M1},而类型*T的方法集合为{M1, M2}。不过方法集合仅用于判断某类型是否实现某接口类型。当我们通过类型实例来调用方法时,Go会提供“语法糖”。上面这个例子先声明了类型T的变量t1,我们看到它不仅可以调用其方法集合中receiver参数类型为T的方法M1,它还可以直接调用不属于其方法集合的、receiver参数类型为*T的方法M2。T类型的实例t1之所以可以调用receiver参数类型为*T的方法M2都是Go编译器在背后自动进行转换的结果,即t1.M2()这种用法是Go提供的“语法糖”:Go判断t1的类型为T,与方法M2的receiver参数类型*T不一致后,会自动将t1.M2()转换为(&t1).M2()。

同理,类型为*T的实例t2,它不仅可以调用receiver参数类型为*T的方法M2,还可以调用receiver参数类型为T的方法M1,这同样是因为Go编译器在背后做了转换:Go判断t2的类型为*T,与方法M1的receiver参数类型T不一致后,会自动将t2.M1()转换为(*t2).M1()。

好了,问题来了!我们参考本文开头处那位学员的留言给出另外一个例子:

func main() {
    T{}.M2() // 编译器错误:cannot call pointer method M2 on T
    (&T{}).M1()  // OK
    (&T{}).M2()  // OK
}

在这个例子中,我们通过T{}对T进行实例化后并调用receiver参数类型为*T的M2方法,但编译器报了错误:cannot call pointer method M2 on T

前后两个例子,同样是基于T类型实例,一个可以使用“语法糖”调用M2方法,一个则不行。why?

其实答案就在于:上面的“语法糖”使用有一个前提,那就是T类型的实例需要是可被取地址的,即Go语言规范中的addressable

什么是addressable呢?Go语言规范中的原话是这样的:

“For an operand x of type T, the address operation &x generates a pointer of type *T to x. The operand must be addressable, that is, either a variable, pointer indirection, or slice indexing operation; or a field selector of an addressable struct operand; or an array indexing operation of an addressable array. As an exception to the addressability requirement, x may also be a (possibly parenthesized) composite literal. ”

翻译过来,大致是说:下面情况中的&x操作后面的操作数x是可被取地址的:

  • 一个变量。比如:&x
  • 指针解引用(pointer indirection)。比如:&*x
  • 切片下标操作。比如:&sl[2]
  • 可被取地址的结构体(struct)的字段。比如:&Person.Name
  • 可被取地址的数组的下标操作。比如:&arr[1]
  • 如果T是一个复合类型,那么&T{}是一个例外,是合法的。

不过,Go语言规范中并没有明确说明哪些情况的操作数或值是不可被取地址的。Go 101作者老貘在其“非官方Go FAQ”中,对不可被取地址的情况做了梳理,这里我们也借鉴一下:

  • 字符串中的字节元素
s := "hello"
println(&s[1]) // invalid operation: cannot take address of s[1] (value of type byte)
  • map键值对中的值元素
m := make(map[string]int)
m["hello"] = 5
println(&m["hello"]) // invalid operation: cannot take address of m["hello"] (map index expression of type int)

for k, v := range m {
    println(&k) // ok, 键元素是可以取地址的
    _ = v
}
  • 接口值的动态值(类型断言的结果)
var a int = 5
var i interface{} = a
println(&(i.(int))) // invalid operation: cannot take address of i.(int) (comma, ok expression of type int)
  • 常量(包括具名常量和字面量)
const s = "hello" // 具名常量

println(&s) // invalid operation: cannot take address of s (untyped string constant "hello")
println(&("golang")) // invalid operation: cannot take address of "golang" (untyped string constant)
  • 包级函数
func Foo() {}
func foo() {}

func main() {
    f := func() {} 

    println(&f) //ok, 局部匿名函数可取地址
    println(&Foo) // invalid operation: cannot take address of Foo (value of type func())
    println(&foo) // invalid operation: cannot take address of foo (value of type func())
}
  • 方法(用做函数值)
type T struct {
    a int
}

func (T) M1() {}

func main() {
    var t T
    println(&(t.M1)) // invalid operation: cannot take address of t.M1 (value of type func())
    println(&(T.M1)) // invalid operation: cannot take address of T.M1 (value of type func(T))
}
  • 中间结果值
    • 函数调用
    • 显式值转换
    • channel接收操作
    • 子字符串操作
    • 子切片操作
    • 加减乘除法操作
// 函数调用
func add(a, b int) int {
    return a + b
}

println(&(add(5, 6)))  // invalid operation: cannot take address of add(5, 6) (value of type int)

// 显示值转换

var b byte = 12
println(&int(b)) // invalid operation: cannot take address of int(b) (value of type int)

// channel接收操作

var c = make(chan int)
println(&(<-c)) // invalid operation: cannot take address of <-c (comma, ok expression of type int)

// 子字符串操作

var s = "hello"
println(&(s[1:3])) // invalid operation: cannot take address of s[1:3] (value of type string)

// 子切片操作

var sl = []int{1, 2, 3, 4, 5}
println(&(sl[1:3])) // invalid operation: cannot take address of sl[1:3] (value of type []int)

// 加减乘除操作

var a, b int = 10, 20
println(&(a + b)) // invalid operation: cannot take address of a + b (value of type int)
println(&(a - b)) // invalid operation: cannot take address of a - b (value of type int)
println(&(a * b)) // invalid operation: cannot take address of a * b (value of type int)
println(&(a / b)) // invalid operation: cannot take address of a / b (value of type int)

最后貘兄在非官方Go FAQ中也提到了&T{}是一个例外(貘兄认为是一个语法糖,&T{}被编译器替换为tmp := T{}; (&tmp)),但不代表T{}是可被取地址的。事实告诉我们:T{}不可被取地址。这也是文章开头处那个留言中问题的答案。


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于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 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语言第一课 Go语言精进之路1 Go语言精进之路2 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