C# foreach 中启动 Task 时,是否需要复制循环变量?
结论
使用 C# 5 及之后的编译器时,示例中的 foreach 通常不需要增加 varCopy。每次迭代都有独立的循环变量,lambda 会捕获对应那次迭代的变量。这一规则对 string 和 CustomClass 都适用。
如果 CustomClass 确实声明为 class,那么 CustomClass varCopy = var; 只会复制对象引用,不会创建新的 CustomClass 实例,也不会调用构造函数。因此,不能把观察到的变慢直接归因于“大量实例化”。
目前没有编译器版本以及 populateList()、ProcessVar() 的实现,无法确定重复值的来源。
为什么有些建议要求复制循环变量?
lambda 捕获的是变量,创建时不会自动保存一份对象副本。
在 C# 5 之前的编译器中,foreach 的循环变量由各次迭代共享。任务可能等到循环继续执行后才读取这个变量,导致多个任务读到相同的值。在循环体内声明局部变量,可以让每次迭代捕获不同的变量。
从 C# 5 开始,foreach 的语义有所调整,每次迭代都有自己的循环变量。是否需要复制循环变量,取决于编译器采用的语言规则,与元素是字符串还是自定义类无关,也不能仅凭运行时版本判断。
这个结论只针对这里的 foreach,不要直接套用到 for 循环,或循环外声明并反复赋值的变量。
复制引用不等于复制对象
假设 CustomClass 是引用类型:
CustomClass varCopy = var;
赋值后,两个变量仍然指向同一个对象:
object.ReferenceEquals(var, varCopy) // true
通过任一变量修改对象的可变属性,另一个变量访问到的也是修改后的对象。这个赋值无法隔离并发修改。
string 也是引用类型,string varCopy = var; 不会复制字符串对象。字符串本身不可变,但这与循环变量的捕获规则是两回事。
只有显式创建对象,例如调用构造函数或合适的复制方法,才可能得到独立实例。如果对象内部还有可变的引用成员,也需要决定是否复制这些成员;只复制最外层对象未必足够。
如何整理任务代码?
如果需要兼容旧的 foreach 捕获行为,可以保留循环体内的局部变量。下面假设 ProcessVar 返回 Task,且代码位于异步方法中:
var tasks = new List<Task>();
foreach (CustomClass item in listCustom)
{
CustomClass itemCopy = item;
tasks.Add(Task.Run(() => ProcessVar(itemCopy)));
}
await Task.WhenAll(tasks);
这里的 itemCopy 仍然只是引用副本。使用 C# 5 及之后的编译器时,可以直接捕获 item。
Task.Run(() => ProcessVar(itemCopy)) 会通过相应重载跟踪返回的异步任务,通常不需要再包一层 async () => await ...。收集并等待任务,可以让调用方确认全部处理完成,并在等待时观察异常。
如果 ProcessVar 主要执行异步 I/O,通常可以直接调用它并收集返回的 Task,不必额外使用 Task.Run。如果其中有需要移出当前线程的 CPU 密集型工作,再根据执行环境考虑使用 Task.Run。
相同值和变慢该怎样排查?
可以依次检查以下几点:
- 确认编译器和语言版本,判断是否存在旧版
foreach捕获行为。 - 检查列表内容。字符串列表可能本来就有重复值;对象列表也可能多次包含同一个实例。独立的循环变量不会消除这些重复。
- 检查
populateList()。如果它反复修改同一个对象并加入列表,多个列表项会指向同一个实例,增加varCopy也无法解决。 - 检查
ProcessVar()的共享状态。静态字段、实例字段、共享缓冲区或其他并发修改,都可能让日志或处理结果看起来重复。 - 等待任务后再比较耗时。只测量提交任务的循环,得到的是任务提交耗时,不能代表全部处理耗时。应在相同输入和并发条件下,测量到
Task.WhenAll完成为止。
另外,Task 与线程并非一一对应。两个任务处理相同值,不足以证明线程或闭包捕获出了问题。数据量很大时,也应考虑限制并发数量,避免一次提交过多任务。
备注:内容仅供参考。
