关于Golang的四个小秘密

前言:在使用Golang开发项目的过程中,我们的工程师遇到了4个看似不起眼的小问题,但是排查起来确实耗费了不少时间。快来看看你是不是也遇到过吧,希望这篇文章能帮助到你。

PS:"360技术"是360团队的线下技术聚合平台,致力于提供有价值的技术干货,点关注哦!

循环变量怎么变?

请看下面的代码,你觉得输出结果会是什么?Values是“1 2 3”,Addresses是三个不同的地址?

package main


import "fmt"


func main() {
    in := []int{1, 2, 3}


    var out []*int
    for _, v := range in {
        out = append(out, &v)
    }


    fmt.Println("Values:", *out[0], *out[1], *out[2])
    fmt.Println("Addresses:", out[0], out[1], out[2])
}

运行一下发现结果并不是那样,而是:

Values: 3 3 3
Addresses: 0xc000086010 0xc000086010 0xc000086010

为什么out切片里的值都是3?每次迭代都是append变量v的地址到out,从上面的输出结果看出,每个元素的地址是同一个,说明每次迭代用的都是同一个变量v,只是被赋了新的值而已,for循环执行完的时候,goroutine里的变量v刚好处于切片in里的某个值,于是就出现了上面的这种结果。

怎么修复呢?最简单的方法只需添加一行代码,把循环变量copy到一个新的变量:

package main


import "fmt"


func main() {
    in := []int{1, 2, 3}


    var out []*int
    for _, v := range in {
        v := v     // 把v的值复制给新的变量v
        out = append(out, &v)
    }


    fmt.Println("Values:", *out[0], *out[1], *out[2])
    fmt.Println("Addresses:", out[0], out[1], out[2])
}

这样便会得到我们预想的输出:

Values: 1 2 3
Addresses: 0xc000096010 0xc000096018 0xc000096020

虽然“v:=v”看起来不那么易读,但是确实很有效。相当于创建了另一个名为v的变量实例,当然也可以换成其他名称。

还有一种改法,按照切片in中索引取地址append到out里,for-range部分修改如下:

for i := range in {
    out = append(out, &in[i])
}

同样的情况也会出现在循环中使用goroutine的时候:

package main


import (
    "fmt"
    "sync"
)


func main() {
    in := []int{1, 2, 3}


    var out []*int
    wg := sync.WaitGroup{}
    for _, v := range in {
        wg.Add(1)
        go func() {
            defer wg.Done()
            out = append(out, &v)
        }()
    }
    wg.Wait()


    fmt.Println("Values:", *out[0], *out[1], *out[2])
    fmt.Println("Addresses:", out[0], out[1], out[2])
}

输出结果将会是:

Values: 3 3 3
Addresses: 0xc000014098 0xc000014098 0xc000014098

因为闭包只绑定到变量v,整个for循环中变量v对应的又都是同一个内存地址,每次循环只是改了这个地址上的值,所以总会是打印 in 的最后一个元素的值。解决方法是将 v 拷贝到一个新的变量:

for _, v := range in {
    wg.Add(1)
    item := v
    go func() {
        defer wg.Done()
        out = append(out, &item)
    }()
}

或者作为参数传到goroutine中:

for _, v := range in {
    wg.Add(1)
    go func(value int) {
        defer wg.Done()
        out = append(out, &value)
    }(v)
}

当心:=的作用域

golang 有两个赋值运算符“=”和“:=”,区别不再多说。尽管“:=”很方便,但是在不同作用域和多返回值的情况下会出现预料之外的结果。请看下面一段代码:

package main


import (
    "fmt"
)


func getUsers() ([]string, error) {
    return []string{"小赵","小钱","小孙","小李"}, nil
}


func main() {
    var users []string


    users, err := getUsers()
    if err != nil {
        panic("ERROR!")
    }


    for _, user:= range users {
        fmt.Println(user)
    }
}

这个例子中,我们从某处拿到用户名并打印,输出结果是:

小赵小钱小孙小李

注意 := 的用法:

users, err := getUsers()

users已经在之前声明了,但是仍然可以用 := 声明并赋值,这是因为err并没有提前声明。然后,再稍微修改一下代码:

package main


import (
    "fmt"
    "os"
)


func getUsers() ([]string, error) {
    return []string{"小赵", "小钱", "小孙", "小李"}, nil
}


func main() {
    var users = make([]string, 0)


    envUsers := os.Getenv("USERS")
    if envUsers == "" {
        fmt.Println("Get users from db")
        users, err := getUsers()
        if err != nil {
            panic("ERROR!")
        }
        fmt.Println("Users total: ", len(users))
    }


    for _, user := range users {
        fmt.Println(user)
    }
}

你感觉这次会输出什么呢?结果是:

Get users from db
Users total:  4

这就是 := 的作用域问题。Golang中用“{}”定义一个作用域,在这里 if 语句创建了一个新的作用域,当使用:= 时,会把 users和err都当做这个作用域里新的变量,当作用域关闭的时候,users和err也会被丢弃。

这段代码也有一些实际的应用场景,例如在初始化流程中,通过环境变量控制系统的某些行为,上面的实现会导致系统初始化数据错误。那怎么解决这个问题呢?

func main() {
    var users = make([]string, 0)
    var err error     // 在这里声明 err 变量,下面就不用 := 赋值了


    envUsers := os.Getenv("USERS")
    if envUsers == "" {
        fmt.Println("Get users from db")
        users, err = getUsers()
        if err != nil {
            panic("ERROR!")
        }
        fmt.Println("Users total: ", len(users))
    }


    for _, user := range users {
        fmt.Println(user)
    }
}

得到的结果将会是我们期望的:   

Get users from dbUsers total:  4小赵小钱小孙小李

尤其在修改旧代码的时候,往往会忽略代码的作用域,这一点是需要特别注意的。

能无限地使用goroutine吗?

请看下面一段代码:

package main
import (
    "fmt"
    "sync"
    "time"
)
type A struct {
    id int
}
func main() {
    start := time.Now()
    channel := make(chan A, 100)
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        for a := range channel {
            process(a)
        }
    }()
    for i := 0; i < 100; i++ {
        channel <- A{id: i}
    }
    close(channel)
    wg.Wait()
    cost:= time.Since(start)
    fmt.Printf("Took %s\n", cost)
}
func process(a A) {
    fmt.Printf("Start processing %v\n", a)
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Finish processing %v\n", a)
}

这段代码里,我们定义了一个channel,遍历这个channel,从中获取数据并进行比较耗时的处理。这段代码运行了10秒左右,假如有10万条数据,那会运行将近3个小时。下面我们再使用goroutine修改下代码:

package main
import (
    "fmt"
    "sync"
    "time"
)
type A struct {
    id int
}
func main() {
    start := time.Now()
    channel := make(chan A, 100)
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        for a := range channel {
            wg.Add(1)
            go func(a A) {
                defer wg.Done()
                process(a)
            }(a)
        }
    }()
    for i := 0; i < 100; i++ {
        channel <- A{id: i}
    }
    close(channel)
    wg.Wait()
    cost := time.Since(start)
    fmt.Printf("Took %s\n", cost)
}
func process(a A) {
    fmt.Printf("Start processing %v\n", a)
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Finish processing %v\n", a)
}

为了发挥go的并发处理的优势,提高处理速度,在每个循环里调度了一个 goroutine 去处理数据。这样的确速度快了10倍。但是如果channel里数据量较大,假如有10万条数据,也能这样处理吗?答案是:要视情况而定。

想弄明白为什么,就要先知道当调用一个goroutine时都发生了些什么。简单来说,golang的runtime会分配一个包含了这个goroutine所有相关数据的对象,当执行完这个goroutine的时候,才会被释放。一个goroutine对象至少会占用2k的内存空间,但是在64位的机器上也能达到1GB。越多的goroutine意味着越多的内存占用,另外goroutine实际是在cpu上执行的,更少的cpu核数,也会导致更多的对象占用内存等待被执行。

所以解决办法是,增加一个协程池,控制goroutine并发数量,保持内存在一个可控的范围内。

package main


import (
    "fmt"
    "sync"
    "time"
)


type A struct {
    id int
}


func main() {
    start := time.Now()
    workerPoolSize := 100
    channel := make(chan A, 100)
    var wg sync.WaitGroup


    wg.Add(1)
    go func() {
        defer wg.Done()
        for i := 0; i < workerPoolSize; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                for a := range channel {
                    process(a)
                }
            }()
        }
    }()


    // Feeding the channel
    for i := 0; i < 100000; i++ {
        channel <- A{id: i}
    }
    close(channel)
    wg.Wait()
    cost := time.Since(start)
    fmt.Printf("Took %s\n", cost)
}


func process(a A) {
    fmt.Printf("Start processing %v\n", a)
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("Finish processing %v\n", a)
}

这段代码里workerPoolSize既是goroutine的最大并发数量。可以把channel当做一个队列,每个goroutine都是一个消费者。golang允许多个goroutine监听同一个channel,但是channel里的每个元素都只会被处理一次。

workerPoolSize 应该设置成可配置的(例如在环境变量里,或者配置文件里),这样可以根据服务器性能不同,控制并发数量,以提高资源利用率。workerPoolSize应该小于或等于可分配总内存和单个goroutine大小的比值。

结构体成员的排列顺序会影响内存空间吗?

这就要说到struct的内存对齐了。例如下面两个struct:

type BadOrderedUser struct {
     IsLocked     bool   // 1 byte
     Name         string // 16 byte
     ID           int32  // 4 byte
}


type OrderedUser struct {
     Name        string
     ID          int32
     IsLocked    bool
}

表面上来看这俩个结构体应该都是21 bytes大小,然而实际没这么简单。在64位系统上, BadOrderedUser占用了32 bytes的内存空间,OrderedUser却只占用了 24 bytes。为什么呢?

CPU访问内存时,并不是逐个字节访问,而是以字长为单位访问,比如64位的CPU,字长为8字节,那么CPU访问内存的单位也是8字节。这么设计的目的,是减少 CPU 访问内存的次数,加大 CPU 访问内存的吞吐量。比如同样读取 16 个字节的数据,一次读取 8 个字节那么只需要读取 2 次。CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数。例如下图:

变量 a、b 各占据 5 字节的空间,内存对齐后,a、b 占据 8 字节空间,CPU 读取 b 变量的值只需要进行一次内存访问。如果不进行内存对齐,CPU 读取 b 变量的值需要进行 2 次内存访问。第一次访问得到 b 变量的前3 个字节,第二次访问得到 b 变量的后2个字节。

go中的结构体内存布局和c结构体布局类似,每个成员的内存分布是连续的,所以在内存对齐过程中,成员的排列顺序不同,上一个成员因偏移量而浪费的大小也不同,导致最后结构体占用的内存空间不同。 一个结构体实例所占据的空间等于各成员占据空间之和,再加上内存对齐的空间大小。

下面代码可以查看结构体占用的空间大小,以及偏移量等信息:

package main


import (
    "fmt"
    "reflect"
    "unsafe"
)


type BadOrderedUser struct {
    IsLocked bool   // 1 byte
    Name     string // 16 byte
    ID       int32  // 4 byte
}


type OrderedUser struct {
    Name     string
    ID       int32
    IsLocked bool
}


func main() {
    fmt.Printf("BadOrderedUser size: %d\n", unsafe.Sizeof(BadOrderedUser{}))   // BadOrderedUser 占用空间大小
    typ := reflect.TypeOf(BadOrderedUser{})
    for i := 0; i < typ.NumField(); i++ {                                           // 每个字段的偏移量,大小和内存对齐倍数
        field := typ.Field(i)
        fmt.Printf("%s at offset %v, size=%d, align=%d\n",
            field.Name, field.Offset, field.Type.Size(), field.Type.Align())
    }


    fmt.Printf("OrderedUser size: %d\n", unsafe.Sizeof(OrderedUser{}))    // OrderedUser占用空间大小
    typ = reflect.TypeOf(OrderedUser{})
    for i := 0; i < typ.NumField(); i++ {
        field := typ.Field(i)
        fmt.Printf("%s at offset %v, size=%d, align=%d\n",
            field.Name, field.Offset, field.Type.Size(), field.Type.Align())
    }
}

运行结果为:

BadOrderedUser size: 32
IsLocked at offset 0, size=1, align=1
Name at offset 8, size=16, align=8
ID at offset 24, size=4, align=4
OrderedUser size: 24
Name at offset 0, size=16, align=8
ID at offset 16, size=4, align=4
IsLocked at offset 20, size=1, align=1

所以,如果在设计访问很频繁的大结构体的时候,可以通过调整字段的顺序,减少内存占用。

以上就是本次文章的所有内容了,如果你对我们的文章有任何见解,欢迎在下方留言交流哦。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值