标签 Ubuntu 下的文章

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

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

使用Docker容器突破客户端6w可用端口的误区

本文永久链接 – https://tonybai.com/2021/12/14/the-misconception-of-using-docker-to-break-out-of-6w-ports-of-the-client

近期的一个项目刚刚完成了第一个版本的开发,经过一段时间的自测与集成测试,功能问题已经不是重点了。项目在初期设定了性能目标,压测与性能优化势在必行,因此这一阶段我们都在做压测前的准备,包括压测方案、环境部署、各种工具的开发等。在互联网大厂的一波接着一波的熏陶与教育下,但凡一个有点用户量的系统,交付前不压测与优化一下,似乎都不好意思上线^_^

压测准备阶段逃不过“模拟并发连接数量”这一环节,我们第一次压测设定的系统运行背景是100w的并发长连接。那么怎么构造出这么多的并发连接呢?有经验的朋友可能知道这句话中隐含的“难点”,那就是一个客户机最多向外面建立65535-1024+1=64512个连接。为什么会这样呢?这是因为一个TCP连接由一个四元组唯一确定,这个四元组是(源端口,源地址,目的地址,目的端口)。这个四元组中的源端口是一个16bit的短整型,它的表示范围是0~65535。但1024及以下的端口号通常为系统保留,因此用户可用的端口号仅剩下64512个。

当一个客户机向服务端建立TCP连接时,四元组中的目的地址、目的端口是固定的,客户机通常只有一个IP地址,这样源地址也是固定的,于是唯一的变数就是源端口了。而源端口在这种情况下仅有64512种变化,因此客户机向外建立的连接数量也就受到了限制。

于是有人想到了Docker容器。由于容器具有独立的网络命名空间以及独立的IP地址,这样容器可以向外建立的连接就不受到宿主机的限制,真的是这样么?

下面我们在一台宿主机上用多个容器模拟的“客户机”向该宿主机上的一个Server程序建立连接,我们看是否能突破6w壁垒。下面是server端程序的代码(仅作示例,勿要深究):

// https://github.com/bigwhite/experiments/tree/master/break-out-of-6w-ports/server/server.go

func main() {
    l, err := net.Listen("tcp", "0.0.0.0:9000")
    if err != nil {
        fmt.Println("error listening:", err.Error())
        return
    }
    defer l.Close()
    fmt.Println("listen ok")
    var mu sync.Mutex
    var count int

    for {
        conn, err := l.Accept()
        if err != nil {
            fmt.Println("error accept:", err)
            return
        }

        fmt.Printf("recv conn from [%s]\n", conn.RemoteAddr())
        go func(conn net.Conn) {
            var b = make([]byte, 10)
            for {
                _, err := conn.Read(b)
                if err != nil {
                    e, ok := err.(net.Error)
                    if ok {
                        if e.Timeout() {
                            continue
                        }
                    }

                    mu.Lock()
                    count--
                    mu.Unlock()
                    return
                }
            }
        }(conn)

        mu.Lock()
        count++
        mu.Unlock()
        fmt.Println("total count =", count)
    }
    select {}
}

这个server程序运行于宿主机上(宿主机的各个资源参数需要你自行调整,比如:/proc/sys/fs/file-max、/proc/sys/fs/nr_open等,可参考这里),并监听9000端口,每accept一个来自客户机的TCP连接,就会创建一个goroutine来处理这个TCP连接。

客户机模拟客户端连接的程序如下:

// https://github.com/bigwhite/experiments/tree/master/break-out-of-6w-ports/client/client.go

func main() {
    var count = 25000
    for i := 0; i < count; i++ {
        go func() {
            conn, err := net.Dial("tcp", "192.168.49.6:9000") // 192.168.49.6是宿主机地址
            if err != nil {
                fmt.Println("net.Dial error:", err)
                return
            }

            for {
                _, err := conn.Write([]byte("ping"))
                if err != nil {
                    fmt.Println("conn.Write error:", err)
                    return
                }
                time.Sleep(100 * time.Second)
            }
        }()
    }
    select {}
}

从代码中可以看到,每个客户机客户端程序会向服务端建立25000个TCP长连接。这里将client端放入基于alpine:3.14.2 image的容器中运行,容器中每个程序可以对外建立的连接数量我们可以通过下面命令的输出计算出来:

$ docker run alpine:3.14.2 cat /proc/sys/net/ipv4/ip_local_port_range
32768   60999

> 60999-32768+1
28232

代码中每个client建立25000个连接,在28232范围之内,正常建立全部连接不是问题。实际的试验结果也证明了这一点:我们启动server后,逐一用下面命令启动多个client:

$go build client.go
$docker run -v /Users/tonybai/Go/src/github.com/bigwhite/experiments/break-out-of-6w-ports/client/client:/root/client alpine:3.14.2 /root/client

创建三个client后,我们很快就能看到Server端完成了75000个连接的创建:

listen ok
recv conn from [172.17.0.2:50238]
... ...
recv conn from [172.17.0.4:35202]
total count = 74997
recv conn from [172.17.0.4:35282]
total count = 74998
recv conn from [172.17.0.4:33168]
total count = 74999
recv conn from [172.17.0.4:44703]
total count = 75000

我们看到,在同一个宿主机上利用容器充当客户端我们轻松突破客户端可用端口的限制

那么如果server程序在另外的一个主机上呢? 我们是否还可以这么顺利的建立如此多的连接呢?我们来试一下,执行的命令与过程与上面大致相同,但server端在建立64000左右连接后,无论再加入几个client向服务端建立连接,server端的总连接数也不会向上了。你或许怀疑server端程序有问题?其实不是,此时如果你在另外一台机器上向server建立连接,连接可以很快的建立成功。

问题还是出在了Docker所在的那台宿主机上了。为什么各个客户端建立不上连接了呢?从server端的一些输出日志可见端倪:

// 192.168.49.6是客户端所在宿主机的ip地址

recv conn from [192.168.49.6:11431]
total count = 64001
recv conn from [192.168.49.6:28365]
total count = 64002

我们看到无论docker容器内ip地址是多少,从宿主机连出来后的ip都是192.168.49.6(宿主机的ip地址),默认情况下,Docker容器访问宿主机外部的主机时,其源地址和端口都会被SNAT成宿主机的IP及某一个随机端口,下面是一个简略的SNAT转换表:

我们看到docker中的请求经过NAT后其源ip转换为宿主机的源ip地址192.168.49.6,源端口为宿主机的一个随机端口(1024~65535范围内)。客户端发出请求后,server端处理并返回响应,响应回到宿主机后,NAT会根据上面的转换表,根据nat后的源ip、nat后的源port、目的ip和目的port找到唯一的源ip和源port,并将替换数据包中相应的字段,这样数据包才能返回给对应的容器中的客户端程序。这样当目的ip、目的port以及nat后的源ip都是“固定值”的情况下,就只能要求nat后的源port不能重复,而nat后的源port的可选范围却只能为1024~65535,当nat后的源port耗尽,容器中的客户端程序就再也无法与server建立新连接了。

我们再重新审视一下nat转换表,nat后的源port是自动分配的,目的port是知名port,不能变化,剩下的只有nat后的源ip地址与目的ip地址是可变动的要素。每新增一种nat后的源ip或目的ip,都可以新增加64521(65535-1024+1)个到server端的TCP连接容量。

下面我们就以添加多个目的ip的方式为例,看看docker如何突破6w可用端口的约束。我们的server服务器是一台ubuntu 20.04的虚拟机,我们可以通过修改netplan配置的方式为enp0s8网卡(连接内部网络, ip为192.168.49.5)添加额外两个ip:192.168.49.15和192.168.49.25。

$ cat /etc/netplan/00-installer-config.yaml
# This is the network config written by 'subiquity'
network:
  ethernets:
    enp0s3:
      addresses: [10.0.2.15/24]
      gateway4: 10.0.2.2
      nameservers:
        addresses: [8.8.8.8,127.0.0.53]
      dhcp4: no
    enp0s8:
      addresses: [192.168.49.5/24,192.168.49.15/24,192.168.49.25/24]
      gateway4: 192.168.49.1
      nameservers:
        addresses: [8.8.8.8,127.0.0.53]
      dhcp4: no
  version: 2

执行sudo netplan apply后,我们可以看到enp0s8网口上配置的三个ip信息如下,

3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 08:00:27:f1:bb:67 brd ff:ff:ff:ff:ff:ff
    inet 192.168.49.5/24 brd 192.168.49.255 scope global enp0s8
       valid_lft forever preferred_lft forever
    inet 192.168.49.15/24 brd 192.168.49.255 scope global secondary enp0s8
       valid_lft forever preferred_lft forever
    inet 192.168.49.25/24 brd 192.168.49.255 scope global secondary enp0s8
       valid_lft forever preferred_lft forever
    inet6 fe80::a00:27ff:fef1:bb67/64 scope link
       valid_lft forever preferred_lft forever

现在我们将按下图所示通过docker向server建立75000个连接(每个容器建立25000个):

我们改造一下server程序,让其不仅输出RemoteAddr,还要输出LocalAddr:

// https://github.com/bigwhite/experiments/tree/master/break-out-of-6w-ports/server/server1.go

fmt.Printf("recv conn from [%s], localaddr: [%s]\n", conn.RemoteAddr(), conn.LocalAddr())

为了方便向client传入要连接的server的地址,我们也改造一下client:

// https://github.com/bigwhite/experiments/tree/master/break-out-of-6w-ports/client/client_with_remoteaddr.go

var remoteIP string

func init() {
    flag.StringVar(&remoteIP, "rip", "", "remoteIP")
}

func main() {
    flag.Parse()
    var count = 25000
    for i := 0; i < count; i++ {
        go func() {
            conn, err := net.Dial("tcp", remoteIP+":9000")
            if err != nil {
                fmt.Println("net.Dial error:", err)
                return
            }

            for {
                _, err := conn.Write([]byte("ping"))
                if err != nil {
                    fmt.Println("conn.Write error:", err)
                    return
                }
                time.Sleep(100 * time.Second)
            }
        }()
    }
    select {}
}

接下来我们就将新client放入容器中执行,并分别用三个remote ip向server建立连接:

$go build -o client client_with_remoteaddr.go

$docker run -v /Users/tonybai/Go/src/github.com/bigwhite/experiments/break-out-of-6w-ports/client/client:/root/client alpine:3.14.2 /root/client -rip 192.168.49.5

$docker run -v /Users/tonybai/Go/src/github.com/bigwhite/experiments/break-out-of-6w-ports/client/client:/root/client alpine:3.14.2 /root/client -rip 192.168.49.15

$docker run -v /Users/tonybai/Go/src/github.com/bigwhite/experiments/break-out-of-6w-ports/client/client:/root/client alpine:3.14.2 /root/client -rip 192.168.49.25

我们很快就在server的log中看到所有连接都建立成功了:

... ...
recv conn from [192.168.49.6:43505], localaddr: [192.168.49.25:9000]
total count = 74998
recv conn from [192.168.49.6:43483], localaddr: [192.168.49.25:9000]
total count = 74999
recv conn from [192.168.49.6:47790], localaddr: [192.168.49.25:9000]
total count = 75000

并且当我们以37816这个端口为例,我们查询一下日志:

$ grep 37816 server.log
recv conn from [192.168.49.6:37816], localaddr: [192.168.49.5:9000]
recv conn from [192.168.49.6:37816], localaddr: [192.168.49.15:9000]
recv conn from [192.168.49.6:37816], localaddr: [192.168.49.25:9000]

我们看到有三个来自192.168.49.6:37816的连接,但目的地址均不相同,这也印证了我们的分析是正确的。

以上就是对使用docker突破客户端可用端口的限制的误区的分析,所谓的误区即当客户端与server在同一台宿主机上可突破6w端口,就认为客户端与server在不同主机上时不需做任何改变也同样可以突破6w。上面的分析证实了我们要么增加服务端的ip,要么增加客户端的ip,或对两者的ip进行同时增加,后两个情况大家可以自行进行试验,这里就不赘述了。


“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

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