likes
comments
collection
share

年前最后一次上线因为for range的坑而失败了

作者站长头像
站长
· 阅读数 2

前言

之前就听说Go里面写循环要谨慎再谨慎,作为新晋Go菜鸟开发,一直以来在涉及到有使用循环的地方,都是会重点测试来保证不出问题,说来也奇怪,虽然一直在河边走,但是从来没有湿过脚,直到最近一次上线,有个逻辑我仅仅就删了四行代码,直接导致循环逻辑出错,从而上线失败。

痛定思痛,这里就好好的回顾一下这次遇到的for range的问题,以及其它for range的坑,希望自己在2024年可以不再犯这种低级失误。

正文

一. 问题说明

背景就是我们这边一个应用会部署在一个联邦集群,一个联邦集群下会有两个或者三个集群,我有一个逻辑就是会去查询出一个应用部署的集群的信息,实际的代码不方便展示出,这里用示例代码进行说明。

首先查询出来的每个集群,可以用下面结构体表示。

type Cluster struct {
    Name   string
}

然后为了方便展示,我需要将上述结构转换为下面的结构。

type ClusterDetail struct {
    Name  string
    Alias string
}

也就是为每个集群添加一个中文别名,所以我的场景就是我先查询出来一个应用部署的所有集群Cluster的切片,然后把集群Cluster的切片转换为ClusterDetail的指针的切片,就这么简单,下面先给出之前没有问题的逻辑的示例代码。

type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase1() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    clusterDetails := make([]*ClusterDetail, 0, len(clusters))
    for index, cluster := range clusters {
        clusterDetail := ClusterDetail{
            Name:  cluster.Name,
            Alias: "集群-" + cast.ToString(index),
        }
        clusterDetails = append(clusterDetails, &clusterDetail)
    }

    for _, clusterDetail := range clusterDetails {
        fmt.Printf("%v: %v\n", clusterDetail.Name, clusterDetail.Alias)
    }
}

运行出来的结果也是符合预期的,如下所示。

Cluster-0: 集群-0
Cluster-1: 集群-1
Cluster-2: 集群-2

然后呢,这次有一个需求之外的小改动,就是之前是:

[]Cluster -> []*ClusterDetail

现在要变成:

[]Cluster -> map[string]*Cluster

所以我的实现就变成下面这样了。

type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase2() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    clusterMap := make(map[string]*Cluster)
    for index, cluster := range clusters {
        clusterMap["集群-" + cast.ToString(index)] = &cluster
    }

    for alias, cluster := range clusterMap {
        fmt.Printf("%v:%v\n", alias, cluster.Name)
    }
}

运行结果如下所示。

集群-0:Cluster-2
集群-1:Cluster-2
集群-2:Cluster-2

毫无疑问,这是有问题的,你问我为什么不自测,其实我是自测了的,但是我拿来自测的应用,只有两个集群且其中一个还被隔离了,而上述逻辑在只有一个集群情况下,测出来的结果就是对的,又因为这个改动不是需求里面的,测试同学没有写案例,就最终导致上线以后发现情况不对造成回退。

二. 原因分析

稍微有点经验的人,都知道这个问题的具体原因,就是因为:Go中的for range的循环变量只会进行一次声明和内存地址分配

就像下面的循环变量cluster,只会进行一次声明和内存地址分配,在循环中只会更新cluster的值,而不会重新声明。

for index, cluster := range clusters {
    clusterMap["集群-" + cast.ToString(index)] = &cluster
}

这就导致对cluster取地址,取的永远都是同一个地址,并且当循结束时,这个地址的值为切片中的最后一个元素,这也就是为什么clusterMap的值全都一样。

那如何解决呢,其实很好解决,就是增加一个中间变量并且每次循环时将循环变量赋值给中间变量,取地址时取中间变量的地址,示例代码如下所示。

type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase3() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    clusterMap := make(map[string]*Cluster)
    for index, cluster := range clusters {
        clusterTemp := cluster
        clusterMap["集群-" + cast.ToString(index)] = &clusterTemp
    }

    for alias, cluster := range clusterMap {
        fmt.Printf("%v:%v\n", alias, cluster.Name)
    }
}

运行结果如下。

集群-0:Cluster-0
集群-1:Cluster-1
集群-2:Cluster-2

三. 举一反三

这次的问题很低级,背后的原因却很简单,但是为了不再犯相同的错误,我咨询了一波身边的Go语言牢手,又归纳了如下几种for range容易踩坑的场景,虽然场景各不相同,但是核心指导思想都是一样的,即:Go中的for range的循环变量只会进行一次声明和内存地址分配

1. 协程场景

首先看如下一个例子。

type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase4() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    for _, cluster := range clusters {
        go func() {
            time.Sleep(time.Second)
            fmt.Println(cluster.Name)
        }()
    }

    time.Sleep(time.Second * 2)
}

输出结果会是什么呢,毫无疑问是下面这样。

Cluster-2
Cluster-2
Cluster-2

这是因为for循环里面起的协程会先睡1秒再打印循环变量cluster,但是当1秒到时,main协程早就跑完循环了,此时循环变量cluster的值已经被更新为了切片中最后一个元素,所以三个子协程打印的内容都是一样的。还是熟悉的味道,解决方案如下。

type Cluster struct {
    Name string
}

type ClusterDetail struct {
    Name  string
    Alias string
}

func testCase5() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    for _, cluster := range clusters {
        clusterTemp := cluster
        go func() {
            time.Sleep(time.Second)
            fmt.Println(clusterTemp.Name)
        }()
    }

    time.Sleep(time.Second * 2)
}

输出结果如下。

Cluster-2
Cluster-1
Cluster-0

2. 闭包场景

首先看如下一个例子。

func testCase6() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    printClusterFuncts := make([]func(), 0, len(clusters))

    for index, cluster := range clusters {
        printClusterFuncts = append(printClusterFuncts, func() {
            fmt.Printf("%v: %v\n", index, cluster.Name)
        })
    }

    for _, printClusterFunct := range printClusterFuncts {
        printClusterFunct()
    }
}

输出结果长下面这样。

2: Cluster-2
2: Cluster-2
2: Cluster-2

都知道闭包有一个经典的定义:闭包=函数+引用环境,这里的函数就是for循环里创建的匿名函数,引用环境就是在匿名函数中引用了循环变量indexcluster,也就是所有闭包都引用了相同的局部变量。还是那个熟悉的味道,解决方案如下。

闭包说人话的理解:在匿名函数中引用了该匿名函数外的全局变量或局部变量

func testCase7() {
    cluster0 := Cluster{"Cluster-0"}
    cluster1 := Cluster{"Cluster-1"}
    cluster2 := Cluster{"Cluster-2"}

    clusters := []Cluster{cluster0, cluster1, cluster2}

    printClusterFuncts := make([]func(), 0, len(clusters))

    for index, cluster := range clusters {
        indexTemp := index
        clusterTemp := cluster
        printClusterFuncts = append(printClusterFuncts, func() {
            fmt.Printf("%v: %v\n", indexTemp, clusterTemp.Name)
        })
    }

    for _, printClusterFunct := range printClusterFuncts {
        printClusterFunct()
    }
}

输出结果如下。

0: Cluster-0
1: Cluster-1
2: Cluster-2

总结

使用for range时,要时刻谨记go中的forange的循环变量只会进行一次声明和内存地址分配,循环时,如果要保存每次循环的值,请不要直接保存循环变量,请一定增加一个中间变量并且每次循环时将循环变量赋值给中间变量,然后保存中间变量就好了。

然后还想说,要对代码心存敬畏之心,十分自信以为必不会出问题的地方,往往就是出问题的地方。


如果觉得本文对你有帮助,麻烦点个赞,添加个收藏并点个关注吧,谢谢。