从一个场景说起

最近我正在做一个项目,需要根据多个user_sec_id(即用户唯一标识),批量爬取某视频网站的数据进行分析,因为没有时间成本从零进行爬虫设计,我选择了一个Api服务商提供数据服务。

编程语言方面,我选择直接用Golang进行爬虫,在Api服务器商那边充完钱后,我写了下面逻辑的代码,在运行3分钟后,我拿到了2w条数据(大概花了10块钱)。

// 根据sec_ids批量获取视频数据
func (yc *TkAppV3Client) SearchVideoByUserSecIdsAndStoreBatch(secIds []string) {
    var wt sync.WaitGroup
    for _, secId := range secIds {
       wt.Add(1)
       go func() {
           // 处理单个sec_id的爬取逻辑
          yc.SearchVideoByUserSecIdsAndStore(secId) // goland提醒处
          defer wt.Done()
       }()
    }
    wt.Wait()
}

本来可以开开心心收工的,然而,等我保存完csv,打开一看后傻眼了:我明明设置了要爬取9个sec_id的数据,但是2w多条数据都是同一个sec_id,而且都是切片内的最后一个元素

我很震惊,尝试从头到尾过了一遍逻辑,愣是没发现问题。我一开始有一种假设:单个sec_id的爬取逻辑中,我使用时间戳+rand随机数作为文件名进行刷盘。难道是因为rand底层不是并发安全的,导致所有goroutine的文件名都一样?

然而,查证和测试后发现我想多了,不会出现这种情况。这时候我发现Goland提示我,这段代码可能存在问题(上面注释标记出),但当时我以为只是 IDE “小题大做”,就没有细看。

后面发现是我知识点的缺漏,导致踩了golang中的一个并发的陷阱,也导致了我损失了10块钱。当然还好及时止损,ctrl+c了,如果数据量更大的话就不止这个数字了,同理如果在工作业务中出现这种情况,那更是不可估量的损失。

在这篇博客中,我将以一个开发者的视角,探讨在 Go 语言中处理并发任务时常见的一个问题——闭包捕获变量。希望通过这篇博客,让大家既能轻松理解问题的本质,又能掌握解决方法。

Go语言中的并发陷阱:闭包捕获变量

经过一段上网搜寻和分析,我终于发现了上面这段代码中存在的问题,请听我娓娓道来。

这段代码非常简洁,逻辑也很直观:我们遍历 secIds,对每个用户 ID 启动一个 goroutine 并行爬取视频。sync.WaitGroup 用来保证所有任务都执行完毕。

乍看之下,这个代码似乎没有什么问题——然而,当我们运行这段代码时,却发现每次 goroutine 里打印出来的 secId 总是同一个值(ps:真的要爬数据之前,一定要先搭建个框架测试一下,不能和我一样为了图快速而直接运行)

问题:闭包捕获变量导致的诡异行为

这是为什么呢?要理解这个问题,我们首先得聊聊 Go 的闭包机制。

在 Go 语言1.22之前,for 循环内部的变量(如这里的 secId),会在所有 goroutine 中共享。也就是说,当你在 for 循环里启动 goroutine 时,这些 goroutine 中的 secId 并不是独立的,而是指向同一个地址。

回到我们代码的例子,secId 是在for 循环体中定义的,它的值会在每次循环迭代中更新。而所有 goroutine 中的 secId 变量其实是同一个指针,它指向的值会被 for 循环的迭代不断覆盖。而goroutine是异步启动的,这意味着外部的循环可能跑完了这些go routine才被开启,这也就是为什么每个 goroutine 最终读取到的 secId,是 secIds 列表中的最后一个值。

底层剖析:变量的作用域与闭包捕获

要更深入理解这个问题,先看看 Go 是如何处理 for 循环变量的。对于代码:

for _, userId := range userIds {
    go func() {
        fmt.Println(userId)
    }()
}

Go 会将 userId 分配到 for 循环外部的一个固定位置。当循环开始时,userId 会更新为当前迭代的值,而这个更新后的 userId 会在所有的 goroutine 中被访问。

在 Go 的编译器实现中,for 循环的变量是在循环外定义的,只是循环每次迭代会将新值赋给这个同一个变量。这就是为什么闭包会捕获到的是同一个变量,而不是每次迭代的新值。

解决方案

1. 传递变量,避免闭包捕获

要让每个 goroutine 拥有自己独立的 userId,我们可以通过将 userId 作为参数传递给匿名函数的方式来解决。匿名函数参数会创建一个新的变量,而不是引用 for 循环外的 userId。

修改代码如下:

func (client *MyClient) FetchVideos(userIds []string) {
    var wg sync.WaitGroup
    for _, userId := range userIds {
        wg.Add(1)
        go func(id string) { // 将 userId 作为参数传递给匿名函数
            defer wg.Done()
            client.fetchVideoByUser(id)
        }(userId) // 在这里传入当前的 userId 值
    }
    wg.Wait()
}

在这个例子中,我们将 userId 传递给了匿名函数的参数 id,这样每个 goroutine 中的 id 都是独立的,并且是当前循环的 userId 值。

理解这背后的关键点

  1. 闭包捕获的是变量引用:在 Go 中,闭包会捕获变量的引用,而不是值。这意味着它捕获的是变量的地址,而不管这个地址指向的值在未来如何变化。
  2. 匿名函数的参数会生成新的变量:在每个 goroutine 中传入 userId 的值,匿名函数会创建一个新的 id 变量,这样每个 goroutine 中 id 的值都独立于 userId。

2. 修改go版本

实际上,go1.22之后就解决的这个问题,也就是说如果go的版本的使用1.22之后,就无需这么修改。注意,这个go版本需要显式在go mod文件中定义,而不是本机上的go版本是多少就一定会是多少。

例如,我本机的go版本是1.23,然而我还是会出现这个问题,检查了一下之后发现是因为我的go mod上写的go版本是1.20。

延伸思考:为什么 Go 选择这种设计?

有的开发者可能会疑惑:为什么 Go 不像其他语言一样,为每次 for 循环迭代生成一个独立的变量?

这是因为 Go 的设计哲学倾向于简洁和高效。闭包捕获变量的引用,避免了为每次循环都重新分配内存,这样在某些场景下能提升性能。然而,这种设计也要求开发者对闭包有更清晰的认识,避免在并发编程中出现这种“共享变量”的问题。

像我经过了这件事情之后,肯定会去重新学习一下并发编程和函数闭包的相关知识点,避免再出现这种低级错误,这10块钱也算是买个教训了。

小结

通过上面的分析,我们了解了 Go 中闭包捕获变量的特性,并知道如何解决这个并发陷阱。关键在于理解变量的作用域闭包的行为

• Go 中闭包会捕获变量的引用,而不是每次的值。
• 在 for 循环中启动 goroutine 时,循环变量会被共享,导致意想不到的结果。
• 通过将变量作为参数传递给匿名函数,我们可以避免这种共享,从而实现正确的并发行为。

希望通过这个分析,大家在写 Go 的并发代码时能够更从容,少走一些坑。