标签 调度器 下的文章

图解Go内存分配器

本文翻译自《A visual guide to Go Memory Allocator from scratch (Golang)》

当我刚开始尝试了解Go的内存分配器时,我发现这真是一件可以令人发疯的事情,因为所有事情似乎都像一个神秘的黑盒(让我无从下手)。由于几乎所有技术魔法都隐藏在抽象之下,因此您需要逐一剥离这些抽象层才能理解它们。

在这篇文章中,我们就来这么做(剥离抽象层去了解隐藏在其下面的技术魔法)。如果您想了解有关Go内存分配器的知识,那么本篇文章正适合您。

一. 物理内存(Physical Memory)和虚拟内存(Virtual Memory)

每个内存分配器都需要使用由底层操作系统管理的虚拟内存空间(Virtual Memory Space)。让我们看看它是如何工作的吧。

img{512x368}

物理存储单元的简单图示(不精确的表示)

单个存储单元(工作流程)的简要介绍:

  1. 地址线(address line, 作为开关的晶体管)提供了访问电容器的入口(数据到数据线(data line))。
  2. 当地址线中有电流流动时(显示为红色),数据线可能会写入电容器,因此电容器已充电,并且存储的逻辑值为“1”。
  3. 当地址线没有电流流动(显示为绿色)时,数据线可能不会写入电容器,因此电容器未充电,并且存储的逻辑值为“0”。
  4. 当处理器(CPU)需要从内存(RAM)中“读取”一个值时,会沿着“地址线”发送电流(关闭开关)。如果电容器保持电荷,则电流流经“ DATA LINE”(数据线)得到的值为1;否则,没有电流流过数据线,电容器将保持未充电状态,得到的值为0。

img{512x368}

物理内存单元如何与CPU交互的简单说明

数据总线(Data Bus):用于在CPU和物理内存之间传输数据。

让我们讨论一下地址线(Address Line)和可寻址字节(Addressable Bytes)。

img{512x368}

CPU和物理内存之间的地址线的表示

  1. DRAM中的每个“字节(BYTE)”都被分配有唯一的数字标识符(地址)。 但“物理字节的表示 != 地址线的数量”。(例如:16位Intel 8088,PAE)
  2. 每条“地址线”都可以发送1bit值,因此它可以表示给定字节地址中指定“bit”。
  3. 在图中,我们有32条地址线。因此,每个可寻址字节都将拥有一个“32bit”的地址。
[ 00000000000000000000000000000000 ] — 低内存地址
[ 11111111111111111111111111111111 ] — 高内存地址

4.由于每个字节都有一个32bit地址,所以我们的地址空间由2的32次方个可寻址字节(即4GB)组成。

因此,可寻址字节取决于地址线的总量,对于64位地址线(x86–64 CPU),其可寻址字节为2的64次方个,但是大多数使用64位指针的体系结构实际上使用48位地址线(AMD64 )和42位地址线(英特尔),理论上支持256TB的物理RAM(Linux 在x86–64上每个进程支持128TB以及4级页表(page table)和Windows每个进程则支持192TB)

由于实际物理内存的限制,因此每个进程都在其自己的内存沙箱中运行-“虚拟地址空间”,即虚拟内存

该虚拟地址空间中字节的地址不再与处理器在地址总线上放置的地址相同。因此,必须建立转换数据结构和系统,以将虚拟地址空间中的字节映射到物理内存地址上的字节。

虚拟地址长什么样呢?

img{512x368}

虚拟地址空间表示

因此,当CPU执行引用内存地址的指令时。第一步是将VMA(virtual memory address)中的逻辑地址转换为线性地址(liner address)。这个翻译工作由内存管理单元MMU(Memory Management Unit)完成。

img{512x368}

这不是物理图,仅是描述。为了简化,不包括地址翻译过程

由于此逻辑地址太大而无法单独管理(取决于各种因素),因此将通过页(page)对其进行管理。当必要的分页构造被激活后,虚拟内存空间将被划分为称为页的较小区域(大多数OS上页大小为4KB,可以更改)。它是虚拟内存中用于内存管理的最小单位。虚拟内存不存储任何内容,仅简单地将程序的地址空间映射到真实的物理内存空间上。

单个进程仅将VMA(虚拟内存地址)视为其地址。这样,当我们的程序请求更多“堆内存(heap memory)”时会发生什么呢?

img{512x368}

一段简单的用户请求更多堆内存的汇编代码

img{512x368}

增加堆内存

程序通过brk(sbrk/mmap等)系统调用请求更多内存。但内核实际上仅是更新了堆的VMA。

注意:此时,实际上并没有分配任何页帧,并且新页面也没有在物理内存存在。这也是VSZ与RSS之间的差异点。

二. 内存分配器

有了“虚拟地址空间”的基本概述以及堆内存增加的理解之后,内存分配器现在变得更容易说明了。

如果堆中有足够的空间来满足我们代码中的内存请求,则内存分配器可以在内核不参与的情况下满足该请求,否则它会通过系统调用brk扩大堆,通常会请求大量内存。(默认情况下,对于malloc而言,大量的意思是 > MMAP_THRESHOLD字节-128kB)。

但是,内存分配器的责任不仅仅是更新brk地址。其中一个主要的工作则是如何的降低内外部的内存碎片以及如何快速分配内存块。考虑按p1~p4的顺序,先使用函数malloc在程序中请求连续内存块,然后使用函数free(pointer)释放内存。

img{512x368}

外部内存碎片演示

在第4步,即使我们有足够的内存块,我们也无法满足对6个连续内存块分配的请求,从而导致内存碎片。

那么如何减少内存碎片呢?这个问题的答案取决于底层库使用的特定的内存分配算法。

我们将研究TCMalloc内存分配器,Go内存分配器采用的就是该内存分配器模型。

三. TCMalloc

TCMalloc(thread cache malloc)的核心思想是将内存划分为多个级别,以减少锁的粒度。在TCMalloc内部,内存管理分为两部分:线程内存和页堆(page heap)。

线程内存(thread memory)

每个内存页分为多级固定大小的“空闲列表”,这有助于减少碎片。因此,每个线程都会有一个无锁的小对象缓存,这使得在并行程序下分配小对象(<= 32k)非常高效。

img{512x368}

线程缓存(每个线程拥有此线程本地线程缓存)

页堆(page heap)

TCMalloc管理的堆由页集合组成,其中一组连续页的集合可以用span表示。当分配的对象大于32K时,将使用页堆进行分配。

img{512x368}

页堆(用于span管理)

如果没有足够的内存来分配小对象,内存分配器就会转到页堆以获取内存。如果还没有足够的内存,页堆将从操作系统中请求更多内存。

由于这种分配模型维护了一个用户空间的内存池,因此极大地提高了内存分配和释放的效率。

注意:尽管go内存分配器最初是基于tcmalloc的,但是现在已经有了很大的不同。

四. Go内存分配器

我们知道Go运行时会将Goroutines(G)调度到逻辑处理器(P)上执行。同样,基于TCMalloc模型的Go还将内存页分为67个不同大小级别。

如果您不熟悉Go调度程序,则可以在这里获取关于Go调度程序的相关知识。

img{512x368}

Go中的内存块的大小级别

Go默认采用8192B大小的页。如果这个页被分成大小为1KB的块,我们一共将拿到8块这样的页:

img{512x368}

将8 KB页面划分为1KB的大小等级(在Go中,页的粒度保持为8KB)

Go中的这些页面运行也通过称为mspan的结构进行管理。

选择要分配给每个尺寸级别的尺寸类别和页面计数(将页面数分成给定尺寸的对象),以便将分配请求圆整(四舍五入)到下一个尺寸级别最多浪费12.5%

mspan

简而言之,它是一个双向链表对象,其中包含页面的起始地址,它具有的页面的span类以及它包含的页面数。

img{512x368}

Go内存分配器中mspan的表示形式

mcache

与TCMalloc一样,Go为每个逻辑处理器(P)提供了一个称为mcache的本地内存线程缓存,因此,如果Goroutine需要内存,它可以直接从mcache中获取它而无需任何锁,因为在任何时间点只有一个Goroutine在逻辑处理器(P)上运行。

mcache包含所有级别大小的mspan作为缓存。

img{512x368}

Go中P,mcache和mspan之间的关系

由于每个P拥有一个mcache,因此从mcache进行分配时无需加锁。

对于每个级别,都有两种类型。
* scan —包含指针的对象。
* noscan —不包含指针的对象。

这种方法的好处之一是在进行垃圾收集时,GC无需遍历noscan对象。

什么Go mcache?

对象大小<= 32K字节的分配将直接交给mcache,后者将使用对应大小级别的mspan应对

当mcache没有可用插槽(slot)时会发生什么?

从mcentral mspan list中获取一个对应大小级别的新的mspan。

mcentral

mcentral对象集合了所有给定大小级别的span,每个mcentral是两个mspan列表。

  1. 空的mspanList — 没有空闲内存的mspan或缓存在mcache中的mspan的列表
  2. 非空mspanList – 仍有空闲内存的span列表。

当从mcentral请求新的Span时,它将从非空mspanList列表中获取(如果可用)。这两个列表之间的关系如下:当请求新的span时,该请求从非空列表中得到满足,并且该span被放入空列表中。释放span后,将根据span中空闲对象的数量将其放回非空列表。

img{512x368}

mcentral表示

每个mcentral结构都在mheap中维护。

mheap

mheap是在Go中管理堆的对象,且只有一个全局mheap对象。它拥有虚拟地址空间。

img{512x368}

mheap的表示

从上图可以看出,mheap具有一个mcentral数组。此数组包含每个大小级别span的mcentral。

central [numSpanClasses]struct {
      mcentral mcentral
        pad      [sys.CacheLineSize unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
}

由于我们对每个级别的span都有mcentral,因此当mcache从mcentral请求一个mspan时,仅涉及单个mcentral级别的锁,因此其他mache的不同级别mspan的请求也可以同时被处理。

padding确保将MCentrals以CacheLineSize字节间隔开,以便每个MCentral.lock获得自己的缓存行,以避免错误的共享问题。

那么,当该mcentral列表为空时会发生什么?mcentral将从mheap获取页以用于所需大小级别span的分配。

  • free [_MaxMHeapList]mSpanList:这是一个spanList数组。每个spanList中的mspan由1〜127(_MaxMHeapList-1)页组成。例如,free[3]是包含3个页面的mspan的链接列表。Free表示空闲列表,即尚未进行对象分配。它对应于忙碌列表(busy list)。

  • freelarge mSpanList:mspans列表。每个mspan的页数大于127。Go内存分配器以mtreap数据结构来维护它。对应busyLarge。

大小> 32k的对象是一个大对象,直接从mheap分配。这些较大的请求需要中央锁(central lock),因此在任何给定的时间点只能满足一个P的请求

五. 对象分配流程

  • 大小> 32k是一个大对象,直接从mheap分配。
  • 大小<16B,使用mcache的tiny分配器分配
  • 大小在16B〜32k之间,计算要使用的sizeClass,然后在mcache中使用相应的sizeClass的块分配
  • 如果与mcache对应的sizeClass没有可用的块,则向mcentral发起请求。
  • 如果mcentral也没有可用的块,则向mheap请求。mheap使用BestFit查找最合适的mspan。如果超出了申请的大小,则会根据需要进行划分,以返回用户所需的页面数。其余页面构成一个新的mspan,并返回mheap空闲列表。
  • 如果mheap没有可用的span,请向操作系统申请一组新的页(至少1MB)。

但是Go在OS级别分配的页面甚至更大(称为arena)。分配大量页面将分摊与操作系统进行对话的成本。

所有请求的堆内存都来自于arena。让我们看看arena是什么。

六. Go虚拟内存

让我们看一个简单go程序的内存。

func main(){
    for {}
}

img{512x368}

程序的进程状态

因此,即使是简单的go程序,占用的虚拟空间也是大约100MB而RSS只有696kB。让我们尝试首先找出这种差异的原因。

img{512x368}

map和smap统计信息

因此,内存区域的大小约为〜2MB, 64MB and 32MB。这些是什么?

Arena

原来,Go中的虚拟内存布局由一组arena组成。初始堆映射是一个arena,即64MB(基于go 1.11.5)。

img{512x368}

当前在不同系统上的arena大小。

因此,当前根据程序需要,内存以较小的增量进行映射,并且它以一个arena(〜64MB)开始。

这是可变的。早期的go保留连续的虚拟地址,在64位系统上,arena大小为512 GB。(如果分配足够大并且被mmap拒绝,会发生什么?)

这个arena集合是我们所谓的堆。Go以8192B大小粒度的页面管理每个arena。

img{512x368}

单个arena(64 MB)。

Go还有两个span和bitmap块。它们都在堆外分配,并存储着每个arena的元数据。它主要在垃圾收集期间使用(因此我们现在将其保留)。

我们刚刚讨论过的Go中的内存分配策略,但这些也仅是奇妙多样的内存分配的一些皮毛。

但是,Go内存管理的总体思路是使用不同的内存结构为不同大小的对象使用不同的缓存级别内存来分配内存。将从操作系统接收的单个连续地址块分割为多级缓存以减少锁的使用,从而提高内存分配效率,然后根据指定的大小分配内存分配,从而减少内存碎片,并在内存释放houhou有利于更快的GC。

现在,我将向您提供此Go Memory Allocator的全景图。

img{512x368}

运行时内存分配器的可视化全景图。


我的网课“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.0到Go 1.13

Go 1.13版本在2019.9.3正式发布!国外的Gopher Vincent Blanchon发表了一篇文章《Go: Retrospective》(科学上网阅读),对Go从1.0版本到1.13版本做了简要的回顾,这里是那篇文章的译文。

img{512x368}

对于每一位Go开发者来说,Go语言的演化历程是必须要知道的事情。了解这些横跨年份发布的大版本的主要变化将有助于Gopher理解Go语言的发展理念以及该语言每个版本的优势与不足。更多关于特定版本的变更细节,可以参考每个版本对应的Changelog

Go 1.0 – 2012.3月

伴随着Go语言的第一个版本,Go的缔造者还发布了一份兼容性文档。该文档保证未来的Go版本将保持向后兼容性(backward-compatible),即始终兼容已有的代码,保证已有代码在Go新版本下编译和运行的正确性。

Go 1.0版本还包含了go tool pprof命令,这是一个Google pprof C++ profiler的变体。Go 1.0还提供了go vet命令(之前的go tool vet),用于报告Go package中可能的错误。

Go 1.1 – 2013.5月

该版本主要专注于语言改善和性能提升(编译器、垃圾回收、map、goroutine调度)。这里是一个改善后的效果示意图:

img{512x368}
图来自https://dave.cheney.net/2013/05/21/go-11-performance-improvements

这个版本同时还嵌入了一个竞态探测器(race detector),这个工具对于Go这种原生并发的语言是十分必要的。在《Race Detector with ThreadSanitizer”》一文中,你可以找到有关race detector的更多详细信息。

在这个版本中的一个重点变动是Goroutine调度器被重写了,重写后的调度器性能大幅提升。

重写后的Go调度器的设计如下图:

img{512x368}
图来自 https://rakyll.org/scheduler/

M对应的是操作系统的线程。P表示一个处理器(P的数量不能超过GOMAXPROCS),每个P拥有一个本地goroutine队列。在1.1版本之前,P这个抽象并不存在。所有goroutine的调度通过全局互斥锁进行全局级别的管理。这次改进实现了”work-stealing”算法,允许某个P从其他P的队列中”偷goroutine”:

img{512x368}
图来自 https://rakyll.org/scheduler/

更多关于Go调度器调度原理以及”work-stealing”算法的信息,可以查看Jaana B. Dogan的文章《Go’s work-stealing scheduler》

Go 1.2 – 2013.12

在该版本中,Go test命令开始支持代码测试覆盖率统计了,并且通过go提供的新子命令: go tool cover可以查看代码测试覆盖率统计信息:

img{512x368}
图来自 https://blog.golang.org/cover

它还能提供代码覆盖信息:

img{512x368}
图来自 https://blog.golang.org/cover

Go 1.3 – 2014.6

该版本包含了栈管理的一个重要改进。在该版本中,栈内存分配采用连续段(contiguous segment)的分配模式以提升内存分配效率。这将为下一个版本将栈size降到2KB奠定基础。之前的分割栈分配方式(segment stack)存在频繁分配/释放栈段导致栈内存分配性能不稳定(较低)的问题,引入新机制后,分配稳定性和性能都有较大改善。

这里是一个json包的例子,图中显示json包对栈size的敏感度:

img{512x368}
图来自 contiguous stack

使用连续段的栈内存分配管理模式解决了一些程序性能低下的问题。下面是html/template包的性能对stack size的敏感度图:

img{512x368}

更多信息可参见[《How Does the Goroutine Stack Size Evolve?”》(https://medium.com/@blanchon.vincent/go-how-does-the-goroutine-stack-size-evolve-447fc02085e5)]。

这个版本还发布了sync.Pool。这个组件允许我们后面重用结构体,减少内存分配的次数。它也将成为Go生态圈中许多性能提升的源头,比如:标准库中的encoding/json、net/http或是Go社区中的zap等。

关于sync.Pool的更多信息,可以参考文章《Understand the Design of Sync.Pool》

Go开发组在该版本中对channel进行了优化改善,使其性能获得提升。下面是channel在Go 1.2和Go 1.3版本中的基准测试数据对比:

img{512x368}

Go 1.4 – 2014.12

在该版本中,Go提供了对Android的官方支持。使用golang.org/x/mobile包,gopher们可以使用Go编写简单的Android应用。

同时,之前版本中大量用C语言和汇编语言实现的运行时已经被翻译为Go,一个更为精确的垃圾回收器让堆内存分配减少了10~30%。

和版本自身无关的是,Go工程在本次发布后已经从Mercurial迁移到Git,从Google code迁移到github。

Go还发布了go generate命令,该命令可以通过扫码代码中的//go:generate指示器来生成代码,可以帮助Gopher简化代码生成工作。

更多关于这方面的信息可以参考Go blog和这篇文章《Generating code》

Go 1.5 – 2015.8

这个新版本推迟了两个月发布,目的是适应Go新的开发发布周期:每年二月和八月进行发布:

img{512x368}
图来自:https://github.com/golang/go/wiki/Go-Release-Cycle

在该版本中,垃圾回收器全面重构。由于引入并发回收器,回收阶段带来的延迟大幅减少。下面是来自一个生产环境服务器上的延迟数据,我们看到延迟从300ms降到了30ms:

img{512x368}
图片来自 https://blog.golang.org/ismmkeynote

这个版本还发布go tool trace命令,通过该命令我们可以实现执行器的跟踪(trace)。这些跟踪是在test执行、运行时生成的,跟踪信息可以通过浏览器呈现:

img{512x368}
图片来自原始Go Execution Tracer文档

Go 1.6 – 2016.2

这个版本的最显著变化是当使用HTTPS时,将默认支持HTTP/2。

垃圾回收器的延迟在该版本中进一步降低:

img{512x368}
图片来自https://blog.golang.org/ismmkeynote

Go 1.7 – 2016.8

这个版本发布了context包。该包用于处理timeout和取消任务。

更多关于context包的信息,可参考文章:《Context and Cancellation by Propagation》

编译器工具链的性能得到了较大幅度优化,编译速度更快,二进制文件size更小,有些时候幅度可达20~30%。

Go 1.8 – 2017.2

垃圾回收器的延迟在该版本中进一步改善,延迟时间已经全面降到毫秒级别以下:

img{512x368}
图片来自https://blog.golang.org/ismmkeynote

对延迟的优化还将继续。接下来版本的目标是将延迟降到100微秒左右。

这个版本还大幅提升了defer的性能:

img{512x368}
图片来自 https://medium.com/@blanchon.vincent/go-how-does-defer-statement-work-1a9492689b6e

更多关于defer的信息,可以参考文章How Does Defer statement Work?

Go 1.9 – 2017.8

该版本引入了alias语法。

type byte = uint8

这里byte是unit8的一个alias。

sync包增加了Map类型,该类型支持并发访问(原生map类型不支持)。

关于map的更多信息,参考文章“Concurrency Access with Maps”

Go 1.10 – 2018.2

在该版本中,test包引入了一个新的缓存机制,所有通过测试的结果都将被缓存下来。当test没有变化时,重复执行test会节省大量运行test的时间。

first run:
ok      /go/src/retro 0.027s
second run:
ok      /go/src/retro (cached)

go build命令也维护了一个已构建的包的缓存以加速构建性能。

该版本中垃圾回收器并没有显著性能提升。但是Go team为垃圾回收定义了一个新的SLO(Service-Level Objective):

img{512x368}
图片来自https://blog.golang.org/ismmkeynote

Go 1.11 – 2018.8

Go 1.11引入了一个重要的新功能:Go modules。Go module的引入是为了应对过去几年官方调查问卷结果中Go社区反馈的几个主要挑战:

img{512x368}
图片来自 https://blog.golang.org/survey2018-results

另外一个重要功能是一个试验功能:支持WebAssembly。允许开发人员将Go源码编译成一个兼容四个主流浏览器的二进制格式文件。

Go 1.12 – 2019.2

该版本中,go vet基于analysis包进行了重写,使得go vet更为灵活并支持Go开发人员编写自己的checker。

更多关于analyzer的信息可以参考文章《How to Build Your Own Analyzer》

Go 1.13 – 2019.8

在该版本中,sync.Pool得到了改善:当垃圾回收时,pool中对象不会被完全清理掉。它引入了一个cache,用于在两次GC之前清理pool中未使用的对象实例。

逃逸分析(escape analysis)被重新实现了,在该版本中,Go得意更少地在堆上分配内存了。下面是新旧逃逸分析的基准测试对比:

img{512x368}
图片来自 https://github.com/golang/go/issues/23109


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

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

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

我的联系方式:

微博: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