前言:在使用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
所以,如果在设计访问很频繁的大结构体的时候,可以通过调整字段的顺序,减少内存占用。
以上就是本次文章的所有内容了,如果你对我们的文章有任何见解,欢迎在下方留言交流哦。