在工程中,有一类问题看起来非常简单,但一旦放进并发 + 动态数据的环境中,就会变成隐藏极深的 bug 温床。
这篇文章记录我在实际项目中,总结出的一个非常小、但非常稳健的经验:
当你需要在一个会被更新的列表中轮询返回元素时,
用「先递增下标,再对当前长度取模」的方式,可以天然规避并发越界问题。
问题背景
我遇到的真实场景是这样的:
-
有一个 list,表示可用资源 / worker / 节点 / 策略集合
-
这个 list会被动态更新**
-
这个list是放在redis等缓存系统中的
- 增加元素
- 删除元素
- 重建列表
-
同时,系统需要维护一个 index
-
每次调用时,轮流返回 list 中的一个元素(Round-Robin)
这是一个在负载均衡、调度、资源分发中非常常见的模式。
最直观的写法(也是最危险的)
很多人(包括一开始的我)会写出类似这样的代码:
go
item := list[index]
index++
if index >= len(list) {
index = 0
}
return item
在单线程、列表大小固定的情况下,这段代码完全没问题。
但一旦引入两个现实条件,它就开始变得不安全:
list的大小是会变化的- 这个逻辑可能运行在并发环境中
问题本质:你依赖了"过去是合法的状态"
上述写法隐含了一个非常危险的假设:
"index 在我访问 list[index] 的时候,一定是合法的"
但在工程中,这个假设并不成立:
- index 是基于 旧的 len(list) 计算出来的
- 在访问的这一刻,list 可能已经被更新
- len(list) 可能变小
- 结果就是:index out of range panic
而这种 panic:
- 出现概率低
- 依赖时序
- 压测、测试环境很难复现
- 一旦出现就是线上事故
我的解决方案:用数学不变量替代状态判断
最终我采用了一种更"无状态依赖"的写法:
先递增 index,再对当前 list 的长度取模
核心代码
go
index = (index + 1) % len(list)
item := list[index]
return item
注意这个顺序是关键的:
- 先更新 index
- 再基于当前 len(list) 做取模
- 得到的 index 一定合法
为什么这个写法是并发安全的?
数学层面保证不越界
无论 len(list) 是多少(只要 > 0):
text
(index + 1) % len(list) ∈ [0, len(list)-1]
这不是"经验正确",而是数学上必然正确。
不依赖历史状态是否合法
这个设计的核心思想是:
我不关心 index 之前是不是合法的
我只关心"现在这一刻"的 list 长度
每一次访问,都重新建立合法性。
这在并发环境中极其重要。
天然适配"列表大小变化"
- list 变长 → 轮询自然覆盖新元素
- list 变短 → index 自动被压缩到合法区间
- list 被整体替换 → 不需要额外修正 index
完全不需要 if / 边界修复逻辑
一个完整、安全的轮询函数
go
func next(list []Item, idx *int) Item {
if len(list) == 0 {
return nil
}
*idx = (*idx + 1) % len(list)
return list[*idx]
}
只要你保证:
- list 的读是并发安全的(如 RWLock / 原子替换)
- idx 的修改是串行或受保护的
这段逻辑本身不会制造新的并发风险。
对比两种思路的本质差异
传统写法
go
item := list[index]
index++
if index >= len(list) {
index = 0
}
特点:
- 假设 index 在访问前是合法的
- 依赖历史状态
- 在动态数据结构中不可靠
取模写法
go
index = (index + 1) % len(list)
item := list[index]
特点:
- 不信任历史状态
- 每一步都重新建立合法性
- 用数学约束代替逻辑修补