标签 Golang 下的文章

惊!Go在十亿次循环和百万任务中表现不如Java,究竟为何?

本文永久链接 – https://tonybai.com/2024/12/02/why-go-sucks

编程语言比较的话题总是能吸引程序员的眼球!

近期外网的两篇编程语言对比的文章在国内程序员圈里引起热议。一篇是由Ben Dicken (@BenjDicken) 做的语言性能测试,对比了十多种主流语言在执行10亿次循环(一个双层循环:1万 * 10 万)的速度;另一篇则是一个名为hez2010的开发者做的内存开销测试,对比了多种语言在处理百万任务时的内存开销。

下面是这两项测试的结果示意图:


10亿循环测试结果


百万任务内存开销测试结果

我们看到:在这两项测试中,Go的表现不仅远不及NonGC的C/Rust,甚至还落后于Java,尤其是在内存开销测试中,Go的内存使用显著高于以“吃内存”著称的Java。这一结果让许多开发者感到意外,因为Go通常被认为是轻量级的语言,然而实际的测试结果却揭示了其在高并发场景下的“内存效率不足”。

那么究竟为何在这两项测试中,Go的表现都不及预期呢?在这篇文章中,我将探讨可能的原因,以供大家参考。

我们先从十亿次循环测试开始。

1. 循环测试跑的慢,都因编译器优化还不够

下面是作者给出的Go测试程序

// why-go-sucks/billion-loops/go/code.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 <= r < 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            a[i] = a[i] + j%u // Simple sum
        }
        a[i] += r // Add a random value to each element in array
    }
    fmt.Println(a[r]) // Print out a single element from the array
}

这段代码通过命令行参数获取一个整数,然后生成一个随机数,接着通过两层循环对一个数组的每个元素进行累加,最终输出该数组中以随机数为下标对应的数组元素的值。

我们再来看一下”竞争对手”的测试代码。C测试代码如下:

// why-go-sucks/billion-loops/c/code.c

#include "stdio.h"
#include "stdlib.h"
#include "stdint.h"

int main (int argc, char** argv) {
  int u = atoi(argv[1]);               // Get an input number from the command line
  int r = rand() % 10000;              // Get a random integer 0 <= r < 10k
  int32_t a[10000] = {0};              // Array of 10k elements initialized to 0
  for (int i = 0; i < 10000; i++) {    // 10k outer loop iterations
    for (int j = 0; j < 100000; j++) { // 100k inner loop iterations, per outer loop iteration
      a[i] = a[i] + j%u;               // Simple sum
    }
    a[i] += r;                         // Add a random value to each element in array
  }
  printf("%d\n", a[r]);                // Print out a single element from the array
}

下面是Java的测试代码:

// why-go-sucks/billion-loops/java/code.java

package jvm;

import java.util.Random;

public class code {

    public static void main(String[] args) {
        var u = Integer.parseInt(args[0]); // Get an input number from the command line
        var r = new Random().nextInt(10000); // Get a random number 0 <= r < 10k
        var a = new int[10000]; // Array of 10k elements initialized to 0
        for (var i = 0; i < 10000; i++) { // 10k outer loop iterations
            for (var j = 0; j < 100000; j++) { // 100k inner loop iterations, per outer loop iteration
                a[i] = a[i] + j % u; // Simple sum
            }
            a[i] += r; // Add a random value to each element in array
        }
        System.out.println(a[r]); // Print out a single element from the array
    }
}

你可能不熟悉C或Java,但从代码的形式上来看,C、Java与Go的代码确实处于“同等条件”。这不仅意味着它们在相同的硬件和软件环境中运行,更包括它们采用了相同的计算逻辑和算法,以及一致的输入参数处理等方面的相似性。

为了确认一下原作者的测试结果,我在一台阿里云ECS上(amd64,8c32g,CentOS 7.9)对上面三个程序进行了测试(使用time命令测量计算耗时),得到一个基线结果。我的环境下,C、Java和Go的编译器版本如下:

$go version
go version go1.23.0 linux/amd64

$java -version
openjdk version "17.0.9" 2023-10-17 LTS
OpenJDK Runtime Environment Zulu17.46+19-CA (build 17.0.9+8-LTS)
OpenJDK 64-Bit Server VM Zulu17.46+19-CA (build 17.0.9+8-LTS, mixed mode, sharing)

$gcc -v
使用内建 specs。
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
目标:x86_64-redhat-linux
配置为:../configure --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-bootstrap --enable-shared --enable-threads=posix --enable-checking=release --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-linker-hash-style=gnu --enable-languages=c,c++,objc,obj-c++,java,fortran,ada,go,lto --enable-plugin --enable-initfini-array --disable-libgcj --with-isl=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/isl-install --with-cloog=/builddir/build/BUILD/gcc-4.8.5-20150702/obj-x86_64-redhat-linux/cloog-install --enable-gnu-indirect-function --with-tune=generic --with-arch_32=x86-64 --build=x86_64-redhat-linux
线程模型:posix
gcc 版本 4.8.5 20150623 (Red Hat 4.8.5-44) (GCC)

测试步骤与结果如下:

Go代码测试:

$cd why-go-sucks/billion-loops/go
$go build -o code code.go
$time ./code 10
456953

real    0m3.766s
user    0m3.767s
sys 0m0.007s

C代码测试:

$cd why-go-sucks/billion-loops/c
$gcc -O3 -std=c99 -o code code.c
$time ./code 10
459383

real    0m3.005s
user    0m3.005s
sys 0m0.000s

Java代码测试:

$javac -d . code.java
$time java -cp . jvm.code 10
456181

real    0m3.105s
user    0m3.092s
sys 0m0.027s

从测试结果看到(基于real时间):采用-O3优化的C代码最快,Java落后一个身位,而Go则比C慢了25%,比Java慢了21%

注:time命令的输出结果通常包含三个主要部分:real、user和sys。real是从命令开始执行到结束所经过的实际时间(墙钟时间),我们依次指标为准。user是程序在用户模式下执行所消耗的CPU时间。sys则是程序在内核模式下执行所消耗的CPU时间(系统调用)。如果总时间(real)略低于用户时间(user),这表明程序可能在某些时刻被调度或等待,而不是持续占用CPU。这种情况可能是由于输入输出操作、等待资源等原因。如果real时间显著小于user时间,这种情况通常发生在并发程序中,其中多个线程或进程在不同的时间段执行,导致总的用户CPU时间远大于实际的墙钟时间。sys时间保持较低,说明系统调用的频率较低,程序主要是执行计算而非进行大量的系统交互。

这时作为Gopher的你可能会说:原作者编写的Go测试代码不够优化,我们能优化到比C还快

大家都知道原代码是不够优化的,随意改改计算逻辑就能带来大幅提升。但我们不能忘了“同等条件”这个前提。你采用的优化方法,其他语言(C、Java)也可以采用。

那么,在不改变“同等条件”的前提下,我们还能优化点啥呢?本着能提升一点是一点的思路,我们尝试从下面几个点优化一下,看看效果:

  • 去除不必要的if判断
  • 使用更快的rand实现
  • 关闭边界检查
  • 避免逃逸

下面是修改之后的代码:

// why-go-sucks/billion-loops/go/code_optimize.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, _ := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    u := int32(input)
    r := int32(rand.Uint32() % 10000)   // Use Uint32 for faster random number generation
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            a[i] = a[i] + j%u // Simple sum
        }
        a[i] += r // Add a random value to each element in array
    }
    z := a[r]
    fmt.Println(z) // Print out a single element from the array
}

我们编译并运行一下测试:

$cd why-go-sucks/billion-loops/go
$go build -o code_optimize -gcflags '-B' code_optimize.go
$time ./code_optimize 10
459443

real    0m3.761s
user    0m3.759s
sys 0m0.011s

对比一下最初的测试结果,这些“所谓的优化”没有什么卵用,优化前你估计也能猜测到这个结果,因为除了关闭边界检查,其他优化都没有处于循环执行的热路径之上

注:rand.Uint32() % 10000的确要比rand.Intn(10000)快,我自己的benchmark结果是快约1倍。

那Go程序究竟慢在哪里呢?在“同等条件”下,我能想到的只能是Go编译器后端在代码优化方面优化做的还不够,相较于GCC、Java等老牌编译器还有明显差距。

比如说,原先的代码中在内层循环中频繁访问a[i],导致数组访问的读写操作较多(从内存加载a[i],更新值后写回)。GCC和Java编译器在后端很可能做了这样的优化:将数组元素累积到一个临时变量中,并在外层循环结束后写回数组,这样做可以减少内层循环中的内存读写操作,充分利用CPU缓存和寄存器,加速数据处理

注:数组从内存或缓存读,而一个临时变量很大可能是从寄存器读,那读取速度相差还是很大的。

如果我们手工在Go中实施这一优化,看看能达到什么效果呢?我们改一下最初版本的Go代码(code.go),新代码如下:

// why-go-sucks/billion-loops/go/code_local_var.go 

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 <= r < 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        temp := a[i]
        for j := int32(0); j < 100000; j++ { // 100k inner loop iterations, per outer loop iteration
            temp += j % u // Simple sum
        }
        temp += r // Add a random value to each element in array
        a[i] = temp
    }
    fmt.Println(a[r]) // Print out a single element from the array
}

编译并运行测试:

$go build -o code_local_var code_local_var.go
$time ./code_local_var 10
459169

real    0m3.017s
user    0m3.017s
sys 0m0.007s

我们看到,测试结果直接就比Java略好一些了。显然Go编译器没有做这种优化,从code.go的汇编也大致可以看出来:


使用lensm生成的汇编与go源码对应关系

而Java显然做了这类优化,我们在原Java代码版本上按上述优化逻辑修改了一下:

// why-go-sucks/billion-loops/java/code_local_var.java

package jvm;

import java.util.Random;

public class code {

    public static void main(String[] args) {
        var u = Integer.parseInt(args[0]); // 获取命令行输入的整数
        var r = new Random().nextInt(10000); // 生成随机数 0 <= r < 10000
        var a = new int[10000]; // 定义长度为10000的数组a

        for (var i = 0; i < 10000; i++) { // 10k外层循环迭代
            var temp = a[i]; // 使用临时变量存储 a[i] 的值
            for (var j = 0; j < 100000; j++) { // 100k内层循环迭代,每次外层循环迭代
                temp += j % u; // 更新临时变量的值
            }
            a[i] = temp + r; // 将临时变量的值加上 r 并写回数组
        }
        System.out.println(a[r]); // 输出 a[r] 的值
    }
}

但从运行这个“优化”后的程序的结果来看,其对java代码的提升幅度几乎可以忽略不计:

$time java -cp . jvm.code 10
450375

real    0m3.043s
user    0m3.028s
sys 0m0.027s

这也直接证明了即便采用的是原版java代码,java编译器也会生成带有抽取局部变量这种优化的可执行代码,java程序员无需手工进行此类优化。

像这种编译器优化,还有不少,比如大家比较熟悉的循环展开(Loop Unrolling)也可以提升Go程序的性能:

// why-go-sucks/billion-loops/go/code_loop_unrolling.go

package main

import (
    "fmt"
    "math/rand"
    "os"
    "strconv"
)

func main() {
    input, e := strconv.Atoi(os.Args[1]) // Get an input number from the command line
    if e != nil {
        panic(e)
    }
    u := int32(input)
    r := int32(rand.Intn(10000))        // Get a random number 0 <= r < 10k
    var a [10000]int32                  // Array of 10k elements initialized to 0
    for i := int32(0); i < 10000; i++ { // 10k outer loop iterations
        var sum int32
        // Unroll inner loop in chunks of 4 for optimization
        for j := int32(0); j < 100000; j += 4 {
            sum += j % u
            sum += (j + 1) % u
            sum += (j + 2) % u
            sum += (j + 3) % u
        }
        a[i] = sum + r // Add the accumulated sum and random value
    }

    fmt.Println(a[r]) // Print out a single element from the array
}

运行这个Go测试程序,性能如下:

$go build -o code_loop_unrolling code_loop_unrolling.go
$time ./code_loop_unrolling 10
458908

real    0m2.937s
user    0m2.940s
sys 0m0.002s

循环展开可以增加指令级并行性,因为展开后的代码块中可以有更多的独立指令,比如示例中的计算j % u、(j+1) % u、(j+2) % u和(j+3) % u,这些计算操作是独立的,可以并行执行,打破了依赖链,从而更好地利用处理器的并行流水线。而原版Go代码中,每次迭代都会根据前一次迭代的结果更新a[i],形成一个依赖链,这种顺序依赖性迫使处理器只能按顺序执行这些指令,导致流水线停顿。

不过其他语言也可以做同样的手工优化,比如我们对C代码做同样的优化(why-go-sucks/billion-loops/c/code_loop_unrolling.c),c测试程序的性能可以提升至2.7s水平,这也证明了初版C程序即便在-O3的情况下编译器也没有自动为其做这个优化:

$time ./code_loop_unrolling 10
459383

real    0m2.723s
user    0m2.722s
sys 0m0.001s

到这里我们就不再针对这个10亿次循环的性能问题做进一步展开了,从上面的探索得到的初步结论就是Go编译器优化做的还不到位所致,期待后续Go团队能在编译器优化方面投入更多精力,争取早日追上GCC/Clang、Java这些成熟的编译器优化水平。

下面我们再来看Go在百万任务场景下内存开销大的“问题”。

2. 内存占用高,问题出在Goroutine实现原理

我们先来看第二个问题的测试代码:

package main

import (
    "fmt"
    "os"
    "strconv"
    "sync"
    "time"
)

func main() {
    numRoutines := 100000
    if len(os.Args) > 1 {
        n, err := strconv.Atoi(os.Args[1])
        if err == nil {
            numRoutines = n
        }
    }

    var wg sync.WaitGroup
    for i := 0; i < numRoutines; i++ {
        wg.Add(1)
        go func() {
            time.Sleep(10 * time.Second)
            wg.Done()
        }()
    }
    wg.Wait()
}

这个代码其实就是根据传入的task数量启动等同数量的goroutine,然后每个goroutine模拟工作负载sleep 10s,这等效于百万长连接的场景,只有连接,但没有收发消息。

相对于上一个问题,这个问题更好解释一些。

Go使用的groutine是一种有栈协程,文章中使用的是每个task一个goroutine的模型,且维护百万任务一段时间,这会真实创建百万个goroutine(G数据结构),并为其分配栈空间(2k起步),这样你可以算一算,不考虑其他结构的占用,仅每个goroutine的栈空间所需的内存都是极其可观的:

mem = 1000000 * 2000 Bytes = 2000000000 Bytes = 2G Bytes

所以启动100w goroutine,保底就2GB内存出去了,这与原作者测试的结果十分契合(原文是2.5GB多)。并且,内存还会随着goroutine数量增长而线性增加。

那么如何能减少内存使用呢?如果采用每个task一个goroutine的模型,这个内存占用很难省去,除非将来Go团队对goroutine实现做大修。

如果task是网络通信相关的,可以使用类似gnet这样的直接基于epoll建构的框架,其主要的节省在于不会启动那么多goroutine,而是通过一个goroutine池来处理数据,每个池中的goroutine负责一批网络连接或网络请求。

在一些Gopher的印象中,Goroutine一旦分配就不回收,这会使他们会误认为一旦分配了100w goroutine,这2.5G内存空间将始终被占用,真实情况是这样么?我们用一个示例程序验证一下就好了:

// why-go-sucks/million-tasks/million-tasks.go

package main

import (
    "fmt"
    "log"
    "os"
    "os/signal"
    "runtime"
    "sync"
    "syscall"
    "time"
)

// 打印当前内存使用情况和相关信息
func printMemoryUsage() {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)

    // 获取当前 goroutine 数量
    numGoroutines := runtime.NumGoroutine()

    // 获取当前线程数量
    numThreads := runtime.NumCPU() // Go runtime 不直接提供线程数量,但可以通过 NumCPU 获取逻辑处理器数量

    fmt.Printf("======>\n")
    fmt.Printf("Alloc = %v MiB", bToMb(m.Alloc))
    fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
    fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
    fmt.Printf("\tNumGC = %v", m.NumGC)
    fmt.Printf("\tNumGoroutines = %v", numGoroutines)
    fmt.Printf("\tNumThreads = %v\n", numThreads)
    fmt.Printf("<======\n\n")
}

// 将字节转换为 MB
func bToMb(b uint64) uint64 {
    return b / 1024 / 1024
}

func main() {
    const signal1Goroutines = 900000
    const signal2Goroutines = 90000
    const signal3Goroutines = 10000

    // 用于接收退出信号
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    // 控制 goroutine 的退出
    signal1Chan := make(chan struct{})
    signal2Chan := make(chan struct{})
    signal3Chan := make(chan struct{})

    var wg sync.WaitGroup
    ticker := time.NewTicker(5 * time.Second)
    go func() {
        for range ticker.C {
            printMemoryUsage()
        }
    }()

    // 等待退出信号
    go func() {
        count := 0
        for {
            <-sigChan
            count++
            if count == 1 {
                log.Println("收到第一类goroutine退出信号")
                close(signal1Chan) // 关闭 signal1Chan,通知第一类 goroutine 退出
                continue
            }
            if count == 2 {
                log.Println("收到第二类goroutine退出信号")
                close(signal2Chan) // 关闭 signal2Chan,通知第二类 goroutine 退出
                continue
            }
            log.Println("收到第三类goroutine退出信号")
            close(signal3Chan) // 关闭 signal3Chan,通知第三类 goroutine 退出
            return
        }
    }()

    // 启动第一类 goroutine(在收到 signal1 时退出)
    log.Println("开始启动第一类goroutine...")
    for i := 0; i < signal1Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case <-signal1Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第一类goroutine(900000) ok")

    time.Sleep(time.Second * 5)

    // 启动第二类 goroutine(在收到 signal2 时退出)
    log.Println("开始启动第二类goroutine...")
    for i := 0; i < signal2Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case <-signal2Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第二类goroutine(90000) ok")

    time.Sleep(time.Second * 5)

    // 启动第三类goroutine(随程序退出而退出)
    log.Println("开始启动第三类goroutine...")
    for i := 0; i < signal3Goroutines; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // 模拟工作
            for {
                select {
                case <-signal3Chan:
                    return
                default:
                    time.Sleep(10 * time.Second) // 模拟一些工作
                }
            }
        }(i)
    }
    log.Println("启动第三类goroutine(90000) ok")

    // 等待所有 goroutine 完成
    wg.Wait()
    fmt.Println("所有 goroutine 已退出,程序结束")
}

这个程序我就不详细解释了。大致分三类goroutine,第一类90w个,在我发送第一个ctrl+c信号后退出,第二类9w个,在我发送第二个ctrl+c信号后退出,最后一类1w个,随着程序退出而退出。

在我的执行环境下编译和执行一下这个程序,并结合runtime输出以及使用top -p pid的方式查看其内存占用:

$go build million-tasks.go
$./million-tasks 

2024/12/01 22:07:03 开始启动第一类goroutine...
2024/12/01 22:07:05 启动第一类goroutine(900000) ok
======>
Alloc = 511 MiB TotalAlloc = 602 MiB    Sys = 2311 MiB  NumGC = 9   NumGoroutines = 900004  NumThreads = 8
<======

2024/12/01 22:07:10 开始启动第二类goroutine...
2024/12/01 22:07:11 启动第二类goroutine(90000) ok
======>
Alloc = 577 MiB TotalAlloc = 668 MiB    Sys = 2553 MiB  NumGC = 9   NumGoroutines = 990004  NumThreads = 8
<======

2024/12/01 22:07:16 开始启动第三类goroutine...
2024/12/01 22:07:16 启动第三类goroutine(90000) ok
======>
Alloc = 597 MiB TotalAlloc = 688 MiB    Sys = 2593 MiB  NumGC = 9   NumGoroutines = 1000004 NumThreads = 8
<======

======>
Alloc = 600 MiB TotalAlloc = 690 MiB    Sys = 2597 MiB  NumGC = 9   NumGoroutines = 1000004 NumThreads = 8
<======
... ...

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 1000004 NumThreads = 8
<======

100w goroutine全部创建ok后,我们查看一下top输出:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875556   2.5g    988 S  54.0  8.2   0:30.92 million-tasks

我们看到RES为2.5g,和我们预期的一致!

接下来,我们停掉第一批90w个goroutine,看RES是否会下降,何时会下降!

输入ctrl+c,停掉第一批90w goroutine:

^C2024/12/01 22:10:15 收到第一类goroutine退出信号
======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 723198  NumThreads = 8
<======

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 723198  NumThreads = 8
<======

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 536 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 10  NumGoroutines = 100004  NumThreads = 8
<======
... ...

但同时刻的top显示RES并没有变化:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812   2.5g    988 S   0.0  8.2   0:56.38 million-tasks

等待两个GC间隔的时间后(大约4分),Goroutine的栈空间被释放:

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 12  NumGoroutines = 100004  NumThreads = 8
<======

top显示RES从2.5g下降为大概700多MB(RES的单位是KB):

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 764136    992 S   0.0  2.4   1:01.87 million-tasks

接下来,我们再停掉第二批9w goroutine:

^C2024/12/01 22:16:21 收到第二类goroutine退出信号
======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 100004  NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 10004   NumThreads = 8
<======

======>
Alloc = 465 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 13  NumGoroutines = 10004   NumThreads = 8
<======

此时,top值也没立即改变:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 764136    992 S   0.0  2.4   1:05.99 million-tasks

大约等待一个GC间隔(2分钟)后,top中RES下降:

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
<======

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
<======

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10004   NumThreads = 8
<======

RES变为不到700M:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 5800 root      20   0 3875812 699156    992 S   0.0  2.2   1:06.75 million-tasks

第三次按下ctrl+c,程序退出:

^C2024/12/01 22:18:46 收到第三类goroutine退出信号
======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10003   NumThreads = 8
<======

======>
Alloc = 458 MiB TotalAlloc = 695 MiB    Sys = 2606 MiB  NumGC = 14  NumGoroutines = 10003   NumThreads = 8
<======

所有 goroutine 已退出,程序结束

我们看到Go是会回收goroutine占用的内存空间的,并且归还给OS,只是这种归还比较lazy。尤其是,第二次停止goroutine前,go程序剩下10w goroutine,按理论来讲需占用大约200MB的空间,实际上却是700多MB;第二次停止goroutine后,goroutine数量降为1w,理论占用应该在20MB,但实际却是600多MB,我们看到go运行时这种lazy归还OS内存的行为可能也是“故意为之”,是为了避免反复从OS申请和归还内存。

3. 小结

本文主要探讨了Go语言在十亿次循环和百万任务的测试中的表现令人意外地逊色于Java和C语言的原因。我认为Go在循环执行中的慢速表现,主要是其编译器优化不足,影响了执行效率。 而在内存开销方面,Go的Goroutine实现是使得内存使用量大幅增加的“罪魁祸首”,这是由于每个Goroutine初始都会分配固定大小的栈空间。

通过本文的探讨,我的主要目的是希望大家不要以讹传讹,而是要搞清楚背后的真正原因,并正视Go在某些方面的不足,以及其当前在某些应用上下文中的局限性。 同时,也希望Go开发团队在编译器优化方面进行更多投入,以提升Go在高性能计算领域的竞争力。

本文涉及的源码可以在这里下载。

4. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

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

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

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

WebRTC第一课:网络架构与NAT工作原理

本文永久链接 – https://tonybai.com/2024/11/27/webrtc-first-lesson-network-architecture-and-how-nat-work

2023年下旬,OpenAI与Livekit的合作在科技圈引起了不小的轰动。这两家公司联手,通过WebRTC技术和大型语言模型(LLM)的结合,使AI模型具有了看、听和说话的能力。这一举动不仅彰显了WebRTC在现代通信技术中的重要地位,也为我们揭示了AI与实时通信融合的无限可能。WebRTC技术在大流行后再一次进入技术人的视野,恰好在我们今年打造的产品中,WebRTC也是技术栈的核心。

在去年9月份,我写了一篇WebRTC入门科普的文章:使用Go和WebRTC data channel实现端到端实时通信,在那篇文章中,我对WebRTC技术做了一些概述说明,并通过一个基于Go语言的实例,展示了如何实现端到端的实时通信。大家可以通过那篇文章了解WebRTC技术的基础概念和核心架构

WebRTC端到端实时通信的效果主要取决于两个重要因素,一个通信质量,一个是音视频编解码质量。对于入门类文章来说,改进通信质量或音视频编解码质量还为时尚早,我们亟需了解的是其背后的原理。

在这篇文章以及后续的几篇文章中,我们先来关注一下“WebRTC网络通信”。我们会先了解一下WebRTC网络架构,然后对WebRTC中的难点,诸如NAT打洞、基于信令与ICE的建连等做深入分析。

在这篇文章中,我们先来学习NAT的工作原理,探讨不同类型的NAT网络行为是如何影响点对点通信的,为后续理解WebRTC的NAT打洞(也称为NAT穿透)和端到端建连做准备。

1. WebRTC网络架构

我们知道WebRTC(Web Real-Time Communication)是一种支持网页浏览器/应用进行端到端实时语音对话或视频对话的技术,其中支持端到端建立连接和后续数据传输的网络架构是十分重要的,这也是理解WebRTC技术栈的一个重点。下面是WebRTC网络架构图的示意图:

这个架构包含以下主要组件:

  • 浏览器/App:这是进行WebRTC的端(Peer),可以是浏览器,也可以是App。WebRTC API在浏览器中实现,使得web应用能够直接访问媒体设备和建立点对点连接。App也可以利用WebRTC的实现(比如Pion)与对端进行RTC通信。

  • 信令服务器(Signaling Server):虽然不是WebRTC标准的一部分,但对于WebRTC建立连接至关重要,任何non-trivial的基于WebRTC的系统都会有专属的信令服务器,它会帮助通信双方交换会话描述协议(SDP)信息和ICE候选地址。这个在使用Go和WebRTC data channel实现端到端实时通信一文中介绍过,在后续的文章中还会有系统说明。

  • STUN服务器(Session Traversal Utilities for NAT):该服务器可以帮助客户端发现自己的公网IP地址和端口,这个服务器是NAT打洞时所必须的。

  • TURN服务器(Traversal Using Relays around NAT):当通过常规方式点对点连接失败时(通常是NAT打洞失败),可以使用TURN服务器作为媒体数据的中继服务器使用。两端发送的数据都会要通过TURN的中继才能转发给对端。

  • ICE框架(Interactive Connectivity Establishment):综合使用各种NAT打洞技术来协助两端建立连接,这个我们会在后面文章中系统说明。

我们看到这个架构略有些复杂,但该架构允许WebRTC应用在复杂的网络环境中建立直接的点对点连接,即使客户端位于NAT或防火墙后面

2. 网络世界的真相:我们都在NAT后面

在理想的网络世界中,每个设备都有一个唯一的公网IP地址,可以直接相互连接和通信。这种理想状态下,网络是完全开放和对等的,没有任何障碍阻止设备之间的直接交互。

但现实情况却很骨感,出于IPv4地址空间的限制(IPv4地址的数量不够了)、网络管理以及网络安全的考虑,大多数设备都隐藏在NAT(网络地址转换)后面。

NAT(网络地址转换)技术于1994年由Egevang等人在RFC 1631中提出,旨在作为缓解IPv4地址不足问题的临时技术方案。通过将私有IP地址和端口映射到公共IP地址和端口,NAT使得在私有网络中的设备可以使用公共地址(共享一个或多个)访问互联网。


NAT转换示意图(来自维基百科)

这种技术的出现大大缓解了IPv4地址的紧张状况,并成为当时乃至现在(IPv6的推广与使用未及预期)网络地址管理的重要手段。不过,NAT技术的广泛应用也意味着大部分设备只有私网IP,无法从外网直接访问,这一定程度上限制了端到端的直接通信。另外,由于不同类型的NAT行为有所不同,进一步增加了端到端网络连接的复杂性。

注:私网IP地址是指在局域网(LAN)中使用的IP地址,这些地址不能在公共互联网(公网)中被路由。私网IP地址主要用于内部网络中设备之间的通信,通常用于家庭、企业或组织的网络。根据IETF的标准,私网IP地址的范围包括(以CIDR(无类域间路由)格式表示):10.0.0.0/8、172.16.0.0/12和192.168.0.0/16。

于是便有了NAT打洞技术(NAT hole punching)。NAT打洞技术为在NAT环境中实现设备间直接通信提供了一种有效的解决方案,它允许位于不同NAT后面的设备建立直接的点对点(P2P)连接,而无需手工配置端口转发。在P2P文件共享、VoIP通信、在线游戏、即时通信以及视频会议系统等领域,NAT打洞都有着广泛的应用。

不过,要理解NAT打洞,我们需要要先得弄清楚NAT的工作原理、主要的NAT类型以及它们的行为差异。

3. NAT的工作原理与主要类型的行为差异

我们先来看看NAT工作原理。

3.1 NAT的工作原理

网络地址转换(NAT)的核心工作原理还是比较好理解的,就是在请求数据包通过NAT设备时修改其源IP和源端口信息。以下图为例,即将内网主机通过X1:x1发往外网主机Y1:y1的网络请求中的源IP和源端口从X1:x1修改为X1′:x1′后,再发给目的端点(Y1:y1)。NAT设备会维护一个映射表(也叫会话表),记录X1:x1与X1′:x1′的映射关系。当请求的应答包回来时(Y1:y1 -> X1′:x1′),NAT设备将根据映射表将X1′:x1′再替换回X1:x1,这样应答包就可以正常回到X1:x1了。

为了管理资源(每个NAT的对外端口数量有限),NAT设备会内置超时机制,它会为每个会话/映射设置一个生存时间(TTL)。如果在TTL内没有新的数据包,这个映射会被删除。

随着NAT技术的演进,同时考虑到网络管理和网络安全因素,NAT设备出现了不同的映射表管理方式,与之相对应的就出现了不同的NAT类型与行为特征。接下来,我们就来看看NAT的主要类型、映射表的管理方式以及它们的行为差异。

注:NAT打洞的主流方式是通过UDP包,因此下面的关于NAT类型和行为的描述都是基于UDP的。UDP是面向数据报的传输层协议,相对于面向连接的TCP,它更加灵活,延迟更低,在实时性要求更高的场景,比如视频会议、语音通信等。

3.2 NAT的主要类型

根据2003年RFC 3489中对市面上NAT实现类型的归纳,NAT可以分为完全锥形(Full Cone)、受限锥形(Restricted Cone)、端口受限锥形(Port Restricted Cone)和对称型(Symmetric)四种主要类型。下面我们分别来说一下这四种类型的NAT映射表构成以及行为特征。

3.2.1 完全锥型NAT

完全锥型是最宽松的NAT类型,它在NAT映射表中只存储了一个四元组:(内网ip(internal_ip), 内网端口(internal_port), 映射ip(external_ip), 映射端口(external_port))。我们看下图中的完全锥形类型的NAT::

在上图中,内部地址X1:x1被映射到了外部地址E:x1′,所有从X1:x1发到外部的数据都由E:x1′向外发送。相应的外部应答(比如: Y1:y1 -> E:x1′)的流量会在NAT设备上被转换回到X1:x1的流量。

那么为什么这个类型的NAT会被称为完全锥形呢?这就要从NAT设备对外部流量的限制来说了。对于NAT设备来说,一旦建立了X1:x1->E:x1′的映射规则,就好比X1在该NAT设备上“打了一个洞”,内部来自X1:x1的流量可以从该洞发送出去,但NAT设备是否允许外部流量从该洞回到X1:x1以及允许哪些流量从该洞返回到X1:x1就决定了该NAT的类型

如果任何外部主机都可以通过上面的洞E:x1′向内部主机X1:x1发送数据包,那么这个NAT就是完全锥形。我们再用一副示意图来说明一下完全锥形NAT的行为特点:

这里故意将右侧的外部主机排列成像圆锥的形状,位于锥子范围内的所有主机都能通过图中的“洞”将数据包发到内部主机X1:x1上。

很显然完全锥形NAT虽然管理简单,也具有很好的开放性,但它在安全性上较弱,外部很容易利用NAT上的洞向内部主机上的服务发起攻击。

为此,后面的几种NAT都是为了增强NAT安全性的,而且一个比一个更严格,我们先来看受限锥形NAT。

3.2.2 受限锥型NAT

受限锥形NAT又被称为IP受限锥型NAT,它在完全锥形NAT的映射表的基础上,增加了“IP白名单(如下图中的allowed_external_ips)”,下面是受限锥形NAT的示意图:

我们看到:X1主机通过X1:x1在向Y1:y1和Y2:y2分别发起请求后,NAT设备上便有了一条映射规则(一个洞):X1:x1被映射到了外部地址E:x1′,但映射表也记录了两个请求的目标主机的IP,记录在规则的allowed_external_ips中。

规则中的这个allowed_external_ips对后续外部主机通过E:x1′向X1:x1发送数据包会做出限制,如下图:

我们看到:只有在“白名单”中的IP对应的主机(如Y1、Y2)才可以在“洞”建立后,通过E:x1′向内部主机X1:x1成功发送数据包,其它主机发送的数据包都会被拦截和丢弃。

我们看到上述的限制是限制是基于IP地址的,而位于Y1和Y2上的服务,可以使用任意端口向E:x1′成功发送数据包,这显然也是一个安全隐患。于是便有了下面限制更为严格的端口受限锥形NAT。

3.2.3 端口受限锥型NAT

端口受限锥型NAT在受限锥形NAT映射表的基础上,又进一步限制了端口,通过allowed_external_endpoints实现对外部端点的限制,如面示意图:

注:IP:port合在一起称为一个端点(endpoint)。

我们看到:X1主机通过X1:x1在向Y1:y1和Y2:y2分别发起请求后,NAT设备上便有了一条映射规则(一个洞):X1:x1被映射到了外部地址E:x1′,映射表同时记录了两个请求的目标主机的端点(ip:port),记录在规则的allowed_external_endpoints中。

规则中的这个allowed_external_pointss对后续外部主机通过E:x1′向X1:x1发送数据包会做出更严格的限制,如下图:

我们看到:只有在“allowed_external_endpoints”中的端点(如Y1:y1、Y2:y2)发起的请求才可以在“洞”建立后,通过E:x1′向内部主机X1:x1成功发送数据包,其它主机或Y1、Y2上其他端口发送的数据包都会被拦截和丢弃。

下面我们再来看看最严格的NAT类型:对称型NAT。

3.2.4 对称型NAT

和上面由X1:x1发出的数据包(无论目的端点是什么)在NAT上只建立一条映射规则不同,在对称型NAT中,由X1:x1向不同端点发送数据会在NAT上建立多条映射规则,如下图所示:

我们看到每条规则中还包含了目的端点的信息(dest_ip和dest_port),也正因为如此,对称型NAT对外部请求的限制也是最严格的,如下图所示:

我们看到:只有来自规则中dest_ip和dest_port组成的端点的数据包才能进入内部,即只有那些收到数据的外部主机才能够“顺原路返回”地回送数据

3.3 新的分类

上面提到的四种NAT类型是stun的RFC在早期对NAT实现的分类(基于UDP传输),一直沿用至今,也是目前使用最多的一种分类方法。不过这种分类方法将NAT的两个正交的行为混在一起说了,即分配行为Assignment Behavior和过滤行为Filtering Behavior。其实我们更多关注的使其过滤行为的特征。

在较新的RFC 4787中,NAT的行为被细分为两个独立的维度:分配行为(Assignment Behavior)和过滤行为(Filtering Behavior)。这两种行为的各自分类描述了NAT在处理映射和过滤时的不同方式。以下是这两种行为的分类及其对应的早期NAT类型的对照。

3.3.1 分配行为(Assignment Behavior)

分配行为定义了NAT设备如何为内网设备的流量分配外部端口和地址。RFC 4787将其分为三类:

  • Endpoint-Independent Mapping(端点独立映射)

不管内网设备与哪个外部设备通信,只要内网设备使用同一个源IP和源端口,NAT都会为其分配相同的外部IP和端口。这意味着内网设备的源IP和源端口在外部网络上呈现为固定的外部IP和端口组合,独立于目标设备的地址和端口,即独立于目标设备的端点。

端点独立映射这种类别对应的早期NAT类型是完全锥形NAT(Full Cone NAT),前面我们讲过,在完全锥形NAT中,无论内网设备的通信目标是什么,NAT设备都会使用相同的外部端口映射,因此完全符合端点独立映射的定义。

  • Address-Dependent Mapping(地址依赖映射)

在地址依赖映射中,内网设备的外部端口是根据其通信的目标IP地址来分配的。如果内网设备改变了目标IP地址,即使源IP和源端口不变,NAT设备也会为其分配一个新的外部端口。

在分配行为上,我们似乎无法找到与早期分类一模一样的类型,它有些类似于对称NAT,但对称NAT不仅考虑目标IP,还考虑目标端口。

  • Address and Port-Dependent Mapping(地址和端口依赖映射)

在这种映射行为中,内网设备的外部端口是根据其通信的目标IP地址和目标端口组合来分配的。如果内网设备改变了目标IP或端口,即使源IP和源端口不变,NAT设备仍会分配一个新的外部端口。该类型对应的是早期NAT类型中的对称NAT(Symmetric NAT)。对称NAT的行为正是基于目标IP和端口的组合来分配外部端口,因此它完全符合地址和端口依赖映射的定义。

3.3.2 过滤行为(Filtering Behavior)

过滤行为描述了NAT设备如何决定是否允许外部设备通过NAT与内网设备通信。RFC 4787将其分为三类:

  • Endpoint-Independent Filtering(端点独立过滤)

这种类型的NAT设备不考虑外部设备的地址或端口,只要内网设备先发起了一个会话,任何外部设备都可以通过NAT设备与内网设备的相同源IP和源端口通信。这和早期NAT类型中的完全锥形NAT完全契合。

  • Address-Dependent Filtering(地址依赖过滤)

这种类型的NAT设备只允许内网设备已经与之通信的外部IP地址与其通信。换句话说,只有内网设备先与某个特定的外部IP地址通信后,该外部IP地址的设备才能通过NAT与内网设备通信。这和早期NAT类型中的限制锥形NAT(Restricted Cone NAT)在过滤行为的特征上是一致的。

  • Address and Port-Dependent Filtering(地址和端口依赖过滤)

这种类型的NAT设备要求外部设备的IP地址和端口都与内网设备已经建立连接的目标IP地址和端口匹配,才能允许通信。这意味着,只有内网设备先与某个特定的外部IP和端口组合通信后,该组合才能与内网设备通信。这与早期NAT类型中的端口限制锥形NAT(Port Restricted Cone NAT)以及对称NAT的行为特征是一致的。

可以看出,RFC 4787中的分类方法更为细致地将NAT的分配行为和过滤行为分开讨论,使得对NAT的行为理解更加明确。这种分类不仅帮助理解了不同NAT类型的工作机制,也为更精确地描述NAT行为提供了标准化的术语。

当然对于NAT打洞来说,我们更关心的显然是过滤行为

4. 小结

好了,这篇文章到这里就告一段落了。

在这篇文章中,我们探讨了WebRTC网络架构和NAT的工作原理。

我们首先了解了WebRTC的网络架构,包括信令服务器、STUN服务器、TURN服务器和ICE框架等组件和其作用。

然后,我们讨论了为什么需要NAT,包括解决IPv4地址短缺、提高安全性和简化网络管理等原因。

接着,我们详细解释了NAT的工作原理,以及完全圆锥型、受限圆锥型、端口受限圆锥型和对称型这四种主要的早期NAT类型。

最后,我们介绍了RFC 4787中对NAT行为的新分类方法,将NAT行为分为分配行为和过滤行为两个维度。

了解WebRTC网络架构以及NAT工作原理是理解NAT打洞机制以及WebRTC端到端建立连接过程的前提,在后续文章中,我们会继续WebRTC网络部分的内容,比如NAT打洞以及端到端建连过程。

5. 参考资料


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

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

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

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

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! 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