标签 mmap 下的文章

Go 1.19新特性前瞻

本文永久链接 – https://tonybai.com/2022/06/12/go-1-19-foresight

美国时间2022年5月7日,Go 1.19版本开发分支进入新特性冻结(freeze)阶段,即只能修Bug,不能再向Go 1.19版本中增加新特性了。由于上一个版本Go 1.18因引入泛型改动较大,推迟了一个月发布,这直接导致了Go 1.19版本的开发周期被缩短。

虽然开发周期少了近一个月,但Go 1.19版本仍然会按计划在2022年8月份发布。而Go 1.19的第一个beta版也于今天凌晨发布了。Go 1.19版本都有哪些重要变化呢,我通过这篇文章带大家先睹为快。

注1:版本特性变化以最终发布为准!
注2:本文仅是前瞻,不会过于深入细节。细节待Go 1.19正式发布后再聊。

泛型问题的fix

尽管Go核心团队在Go 1.18泛型上投入了很多精力,但Go 1.18发布后泛型这块依然有已知的天生局限,以及后续逐渐发现的一些问题,而Go 1.19版本将继续打磨Go泛型,并重点fix Go 1.18中发现的泛型问题。目前Go 1.19开发版本中大约有5-6个泛型问题待解决。之前谈到的可能放开一些泛型约束,在Go 1.19估计不会如期兑现了。

不过可以确定的是Go 1.19将包含Go语法规范中的一处关于泛型的修正,即由下面表述:

The scope of an identifier denoting a type parameter of a function or declared by a method receiver is the function body and all parameter lists of the function.(译文:一个用于表示函数的类型参数或由方法接收器声明的类型参数的标识符的作用域范围包括函数体和函数的所有形式参数列表。)

改为下面更新版的表述:

The scope of an identifier denoting a type parameter of a function or declared by a method receiver starts after the function name and ends at the end of the function body.(译文:一个用于表示函数的类型参数或由方法接收器声明的类型参数的标识符的作用域始于函数名,终止于函数体末尾。)

这样一个改动,使得原本在当前版本Go编译器(Go 1.18.x)下编译报错的源码,在Go 1.19版本中可以正常编译通过:

type T[T any] struct {}
func (T[T]) m() {} // error: T is not a generic type

修订Go memory model

Go memory model是Go文档中最抽象的一篇,没有之一!随着Go的演进,原先的Go memory model描述有很多地方不够正式,也缺少对一些同步机制的说明,如atomic等。

这次修订,参考了Hans-J. Boehm和Sarita V. Adve在“Foundations of the C++ Concurrency Memory Model,(PLDI 2008)”中对C++ memory model的描述方式,对Go memory model做了更正式的整体描述,增加了对multiword竞态、runtime.SetFinalizer、更多sync类型、atomic操作以及编译器优化方面的描述。

修订go doc comment格式

Go内置了将comment直接提取为包文档的能力,这与其他语言通过第三方工具生成文档不同。go doc comment为Gopher提供了很大便利。但go doc comment设计于2009年,有些过时。对很多呈现形式的支持不够或缺少更为精确的格式描述,这次Russ Cox主导了go doc comment的修订,增加了对超链、列表、标题、标准库API引用等格式支持,修订后的go doc comment并非markdown语法,但从markdown语法中做了借鉴,同时兼容老comment格式。下面是Russ Cox提供的一些新doc comment的渲染后的效果图:



同时,Go团队还提供了go/doc/comment包,gopher使用它可以轻松解析go doc comment。

runtime.SetMemoryLimit

在Go 1.19中,一个新的runtime.SetMemoryLimit函数以及一个GOMEMLIMIT环境变量被引入。有了这个memory软限制,Go运行时将通过限制堆的大小,以及更积极地将内存返回给底层os,来试图维持这个内存限制,以尽量避免Go程序因分配heap过多,超出系统内存资源限制而被kill。

默认memory limit是math.MaxInt64。一旦通过SetMemoryLimit自行设定limit,那么Go运行时将尊重这个memory limit,通过调整GC回收频率以及及时将内存返还给os来保证go运行时掌控的内存总size在limit之下。

注意:limit限制的是go runtime掌控的内存总量,对于开发者自行从os申请的内存(比如通过mmap)则不予考虑。limit的具体措施细节可以参考该proposal design文档

另外要注意的是:该limit不能100%消除out-of-memory的情况。

Go 1.19在启动时将默认提高打开文件的限值

经调查,一些系统对打开的文件数量设置了一个人为的soft限制, 主要是为了与使用select和其硬编码的最大文件描述符(由 fd_set 的大小限制)的代码兼容。通常限制为1024,有的更小,比如256。这样即便是gofmt这样的简单程序,当它们并行地遍历一个文件树时,也很容易遇到打开文件描述符超量的错误。

Go不使用select,所以它不应该受这些限制的影响。于是对于导入os包的go程序,Go将在1.19中默认提高这些限制值到hard limit。

Go 1.19 race detector将升级到v3版thread sanitizer

升级后的新版race detector的race检测性能相对于上一版将提升1.5倍-2倍,内存开销减半,并且没有对goroutine的数量的上限限制。

注:thread sanitizer检测数据竞态的工作原理:记录每一个内存访问的信息,并检测线程对这块内存的访问是否存在竞争。基于这种原理,我们也可以知道一旦开启race detect,Go程序的执行效率将受到很大影响,运行的开销将大幅增加。v3版thread sanitizer虽然得到了优化,但对程序的总体影响还是存在的并且依旧很大。

Go 1.19增加”unix” build tag

Go 1.19将增加”unix”构建标签:

//go:build unix

等价于

//go:build aix || linux || darwin || dragonfly || freebsd || openbsd || netbsd || solaris

不过要注意,”*_unix.go”还保留原语义,不能被识别,以便向后兼容现有文件,尤其是go标准库之外的使用

标准库的一些变化

net软件包将使用EDNS

在Go 1.19中,net软件包将使用EDNS来增加DNS数据包的大小,以遵守现代DNS标准和实现。这应该有助于解决一些DNS服务器的问题。

flag包增加TextVar函数

Go flag包增加TextVar函数,这样flag包便可以与任何实现了encoding.Text{Marshaler,Unmarshaler}的Go类型集成。比如:

flag.TextVar(&ipaddr, "ipaddr", net.IPv4(192, 168, 0, 1), "what server to connect to?") // 与net.IPv4类型
flag.TextVar(&start, "start", time.Now(), "when should we start processing?") // 与time.Time类型

其它

  • 在linux上,Go正式支持64位龙芯cpu架构 (GOOS=linux, GOARCH=loong64)。
  • 当Go程序空闲时,Go GC进入到周期性的GC循环的情况下(2分钟一次),Go运行时现在会在idle的操作系统线程上安排更少的GC worker goroutine,减少空闲时Go应用对os资源的占用。
  • Go行时将根据goroutine的历史平均栈使用率来分配初始goroutine栈,避免了一些goroutine的最多2倍的goroutine栈空间浪费。
  • sync/atomic包增加了新的高级原子类型Bool, Int32, Int64, Uint32, Uint64, Uintptr和Pointer,提升了使用体验。
  • Go 1.19中Go编译器使用jump table重新实现了针对大整型数和string类型的switch语句,平均性能提升20%左右。

小结

相对于Go 1.18,Go 1.19的确是一个“小版本”。但Go 1.19对memory model的更新、SetMemoryLimit的加入、go doc comment的修订以及对go runtime的持续打磨依然可以让gopher们产生一丝丝“小兴奋”,尤其是SetMemoryLimit的加入,是否能改善Go应用因GC不及时被kill的情况呢,让我们拭目以待。

Go 1.19的里程碑在这里,所有feature和fix大家可以在该里程碑中看到。


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与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内存管理

本文翻译自《Visualizing memory management in Golang》

img{512x368}

“内存管理”系列的一部分

在这个由多部分组成的系列文章中,我旨在揭示内存管理背后的概念,并对某些现代编程语言的内存管理机制做更深入的探究。我希望该系列文章可以使您对这些语言在内存管理方面正在发生的事情能有所了解。

在本章中,我们将研究Go编程语言(Golang)的内存管理。和C/C++、Rust等一样,Go是一种静态类型的编译型语言。因此,Go不需要VM,Go应用程序二进制文件中嵌入了一个小型运行时(Go runtime),可以处理诸如垃圾收集(GC),调度和并发之类的语言功能。

如果您还没有阅读本系列的第一部分,请先阅读它,因为在那篇文章中我解释了栈(stack)和堆(heap)内存之间的区别,这对于理解本文很有用。

这篇文章基于Go 1.13的默认官方实现,有些概念细节可能会在Go的未来版本中发生变化

Go内部内存结构

首先,让我们看看Go内部的内存结构是什么样子的。

Go运行时将Goroutines(G)调度到逻辑处理器(P)上执行。每个P都有一台逻辑机器(M)。在这篇文章中,我们将使用P、M和G。如果您不熟悉Go调度程序,请先阅读《Go调度程序:Ms,Ps和Gs》

img{512x368}

Goroutine调度原理

每个Go程序进程都由操作系统(OS)分配了一些虚拟内存,这是该进程可以访问的全部内存。在这个虚拟内存中实际正在使用的内存称为Resident Set(驻留内存)。该空间由内部内存结构管理,如下所示:

img{512x368}

Go内部内存结构原理图

这是一个简化的视图,基于Go使用的内部对象。实际上,Go将内存划分和分组为页(page),就像这篇文章描述的那样。

这与我们在前几章中看到的JVMV8的内存结构完全不同。如您所见,这里没有分代内存。这样做的主要原因是TCMalloc(线程缓存Malloc),Go自己的内存分配器正是基于该模型实现的。

让我们看看Go独特的内存构造是什么样子的:

页堆page heap(mheap)

这里是Go存储动态数据(在编译时无法计算大小的任何数据)的地方。它是最大的内存块,也是进行垃圾收集(GC)的地方。

驻留内存(resident set)被划分为每个大小为8KB的页,并由一个全局mheap对象管理。

大对象(大小> 32kb的对象)直接从mheap分配。这些大对象申请请求是以获取中央锁(central lock)为代价的,因此在任何给定时间点只能满足一个P的请求。

mheap通过将页归类为不同结构进行管理的:

  • mspan:mspan是mheap中管理的内存页的最基本结构。这是一个双向链接列表,其中包含起始页面的地址,span size class和span中的页面数量。像TCMalloc一样,Go将内存页按大小分为67个不同类别,大小从8字节到32KB,如下图所示

img{512x368}

mspan结构

每个span存在两个,一个span用于带指针的对象(scan class),一个用于无指针的对象(noscan class)。这在GC期间有帮助,因为noscan类查找活动对象时无需遍历span。

  • mcentral:mcentral将相同大小级别的span归类在一起。每个mcentral包含两个mspanList:

    • empty:双向span链表,包括没有空闲对象的span或缓存mcache中的span。当此处的span被释放时,它将被移至non-empty span链表。
    • non-empty:有空闲对象的span双向链表。当从mcentral请求新的span,mcentral将从该链表中获取span并将其移入empty span链表。

如果mcentral没有可用的span,它将向mheap请求新页。

  • arena:堆在已分配的虚拟内存中根据需要增长和缩小。当需要更多内存时,mheap从虚拟内存中以每块64MB(对于64位体系结构)为单位获取新内存, 这块内存被称为arena。这块内存也会被划分页并映射到span。

  • mcache:这是一个非常有趣的构造。mcache是提供给P(逻辑处理器)的高速缓存,用于存储小对象(对象大小<= 32Kb)。尽管这类似于线程堆栈,但它是堆的一部分,用于动态数据。所有类大小的mcache包含scan和noscan类型mspan。Goroutine可以从mcache没有任何锁的情况下获取内存,因为一次P只能有一个锁G。因此,这更有效。mcache从mcentral需要时请求新的span。

这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。这与分配给每个P的mcache不是一回事。

Go内存使用(栈与堆)

现在我们已经清楚了内存的组织方式,现在让我们看看程序执行时Go是如何使用Stack和Heap的。

我们使用下面的这个Go程序,代码没有针对正确性进行优化,因此可以忽略诸如不必要的中间变量之类的问题,因此,重点是可视化栈和堆内存的使用情况。

package main

import "fmt"

type Employee struct {
    name   string
    salary int
    sales  int
    bonus  int
}

const BONUS_PERCENTAGE = 10

func getBonusPercentage(salary int) int {
    percentage := (salary * BONUS_PERCENTAGE) / 100
    return percentage
}

func findEmployeeBonus(salary, noOfSales int) int {
    bonusPercentage := getBonusPercentage(salary)
    bonus := bonusPercentage * noOfSales
    return bonus
}

func main() {
    var john = Employee{"John", 5000, 5, 0}
    john.bonus = findEmployeeBonus(john.salary, john.sales)
    fmt.Println(john.bonus)
}

与许多垃圾回收语言相比,Go的一个主要区别是许多对象直接在程序栈上分配。Go编译器使用一种称为“逃逸分析”的过程来查找其生命周期在编译时已知的对象,并将它们分配在栈上,而不是在垃圾回收的堆内存中。在编译过程中,Go进行了逃逸分析,以确定哪些可以放入栈(静态数据),哪些需要放入堆(动态数据)。我们可以通过运行带有-gcflags '-m'标志的go build命令来查看分析的细节。对于上面的代码,它将输出如下内容:

❯ go build -gcflags '-m' gc.go
# command-line-arguments
temp/gc.go:14:6: can inline getBonusPercentage
temp/gc.go:19:6: can inline findEmployeeBonus
temp/gc.go:20:39: inlining call to getBonusPercentage
temp/gc.go:27:32: inlining call to findEmployeeBonus
temp/gc.go:27:32: inlining call to getBonusPercentage
temp/gc.go:28:13: inlining call to fmt.Println
temp/gc.go:28:18: john.bonus escapes to heap
temp/gc.go:28:13: io.Writer(os.Stdout) escapes to heap
temp/gc.go:28:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape

让我们将其可视化。单击下方图片下载幻灯片,然后翻阅幻灯片,以查看上述程序是如何执行的以及如何使用栈和堆存储器的:

img{512x368}

可视化程序执行过程中栈和堆的使用

正如你看到的:

  • main函数被保存栈中的“main栈帧”中
  • 每个函数调用都作为一个栈帧块被添加到栈中
  • 包括参数和返回值在内的所有静态变量都保存在函数的栈帧块内
  • 无论类型如何,所有静态值都直接存储在栈中。这也适用于全局范畴
  • 所有动态类型都在堆上创建,并且被栈上的指针所引用。小于32Kb的对象由P的mcache分配。这同样适用于全局范畴
  • 具有静态数据的结构体保留在栈上,直到在该位置将任何动态值添加到该结构中为止。该结构被移到堆上。
  • 从当前函数调用的函数被推入堆顶部
  • 当函数返回时,其栈帧将从栈中删除
  • 一旦主过程(main)完成,堆上的对象将不再具有来自Stack的指针的引用,并成为孤立对象

您可以看到,栈是由操作系统自动管理的,而不是Go本身。因此,我们不必担心栈。另一方面,堆并不是由操作系统自动管理的,并且由于其具有最大的内存空间并保存动态数据,因此它可能会成倍增长,从而导致我们的程序随着时间耗尽内存。随着时间的流逝,它也变得支离破碎,使应用程序变慢。解决这些问题是垃圾收集的初衷。

Go内存管理

Go的内存管理包括在需要内存时自动分配内存,在不再需要内存时进行垃圾回收。这是由标准库完成的(译注:应该是运行时完成的)。与C/C++不同,开发人员不必处理它,并且Go进行的基础管理得到了高效的优化。

内存分配

许多采用垃圾收集的编程语言都使用分代内存结构来使收集高效,同时进行压缩以减少碎片。正如我们前面所看到的,Go在这里采用了不同的方法,Go在构造内存方面有很大的不同。Go使用线程本地缓存(thread local cache)来加速小对象分配,并维护着scan/noscan的span来加速GC。这种结构以及整个过程避免了碎片,从而在GC期间无需做紧缩处理。让我们看看这种分配是如何发生的。

Go根据对象的大小决定对象的分配过程,分为三类:

微小对象(Tiny)(size <16B):使用mcache的微小分配器分配大小小于16个字节的对象。这是高效的,并且在单个16字节块上可完成多个微小分配。

img{512x368}

微小分配

小对象(尺寸16B〜32KB):大小在16个字节和32k字节之间的对象被分配在G运行所在的P的mcache的对应的mspan size class上。

img{512x368}

小对象分配

在微小型和小型对象分配中,如果mspan的列表为空,分配器将从mheap获取大量的页面用于mspan。如果mheap为空或没有足够大的页面满足分配请求,那么它将从操作系统中分配一组新的页(至少1MB)。

大对象(大小> 32KB):大于32 KB的对象直接分配在mheap的相应大小类上(size class)。如果mheap为空或没有足够大的页面满足分配请求,则它将从操作系统中分配一组新的页(至少1MB)。

img{512x368}

大对象分配

注意:您可以在此处找到以幻灯片形式记录的GIF图像

垃圾收集(GC)

现在我们知道Go如何分配内存了,让我们再看看它是如何自动回收堆内存的,这对于应用程序的性能非常重要。当程序尝试在堆上分配的内存大于可用内存时,我们会遇到内存不足的错误(out of memory)。不当的堆内存管理也可能导致内存泄漏。

Go通过垃圾回收机制管理堆内存。简单来说,它释放了孤儿对象(orphan object)使用的内存,所谓孤儿对象是指那些不再被栈直接或间接(通过另一个对象中的引用)引用的对象,从而为创建新对象的分配腾出了空间。

Go 1.12版本开始,Go使用了非分代的、并发的、基于三色标记和清除的垃圾回收器。收集过程大致如下所示,由于版本之间的差异,我不想做细节的描述。但是,如果您对此感兴趣,那么我推荐这个很棒的系列文章

当完成一定百分比(GC百分比)的堆分配,GC过程就开始了。收集器将在不同工作阶段执行不同的工作:

  • 标记设置(mark setup, stw):GC启动时,收集器将打开写屏障(write barrier),以便可以在下一个并发阶段维护数据完整性。此步骤需要非常小的暂停(stw),因此每个正在运行的Goroutine都会暂停以启用此功能,然后继续。

  • 标记(并发执行的):打开写屏障后,实际的标记过程将并行启动,这个过程将使用可用CPU能力的25%。对应的P将保留,直到该标记过程完成。这个过程是使用专用的Goroutines完成的。在这个过程中,GC标记了堆中的活动对象(被任何活动的Goroutine的栈中引用的)。当采集花费更长的时间时,该过程可以从应用程序中征用活动的Goroutine来辅助标记过程。这称为Mark Assist

  • 标记终止(stw):标记一旦完成,每个活动的Goroutine都会暂停,写入屏障将关闭,清理任务将开始执行。GC还会在此处计算下一个GC目标。完成此操作后,保留的P的会释放回应用程序。

  • 清除(并发):当完成收集并尝试分配后,清除过程开始将未标记为活动的对象回收。清除的内存量与分配的内存量是同步的(即回收后的内存马上可以被再分配了)。

让我们在一个Goroutine中看看这个过程。为了简洁起见,将对象的数量保持较小。单击下面图片,可下载幻灯片,然后翻阅幻灯片查看该过程:

img{512x368}

xx

  • 我们以一个Goroutine为例,实际过程是对所有活动Goroutine都进行的。首先打开写屏障。
  • 标记过程选择GC root并将其着色为黑色,并以深度优先的树状方式遍历该该根节点里面的指针,将遇到的每个对象都标记为灰色
  • 当它到达noscan span中的某个对象或某个对象不再有指针时,它完成了这个根节点的标记操作并选取下一个GC root对象
  • 当扫描完所有GC root节点之后,它将选取灰色对象,并以类似方式继续遍历其指针
  • 如果在打开写屏障时,指向对象的指针发生任何变化,则该对象将变为灰色,以便GC对其进行重新扫描
  • 当不再有灰色对象留下时,标记过程完成,并且写屏障被关闭
  • 当分配开始时(因为写屏障关闭了),清除过程也会同步进行

我们看到这里有一些停止世界(stop)的过程,但是通常这个过程非常快,在大多数情况下可以忽略不计。对象的着色在span的gcmarkBits属性中进行。

结论

这篇文章为您提供了Go内存结构和内存管理的概述。这里不是全面详尽的说明,有许多更高级的概念,实现细节在各个版本之间都在不断变化。但是对于大多数Go开发人员来说,这些信息就已经足够了,我希望它能帮助您编写出更好的、性能更高的应用程序,牢记这些,将有助于您避免下一个内存泄漏问题。

参考文献

  • blog.learngoprogramming.com https://blog.learngoprogramming.com/a-visual-guide-to-golang-memory-allocator-from-ground-up-e132258453ed
  • www.ardanlabs.com https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
  • povilasv.me https://povilasv.me/go-memory-management/
  • medium.com/a-journey-with-go https://medium.com/a-journey-with-go/go-memory-management-and-allocation-a7396d430f44
  • medium.com/a-journey-with-go https://medium.com/a-journey-with-go/go-how-does-the-garbage-collector-mark-the-memory-72cfc12c6976
  • hub.packtpub.com https://hub.packtpub.com/implementing-memory-management-with-golang-garbage-collector/
  • making.pusher.com https://making.pusher.com/golangs-real-time-gc-in-theory-and-practice/
  • segment.com/blog https://segment.com/blog/allocation-efficiency-in-high-performance-go-services/
  • go101.org https://go101.org/article/memory-block.html

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

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

著名云主机服务厂商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