C# 闭包捕获变量的经典问题分析

问题描述

在编写异步代码时,我们经常会遇到这样的情况:使用 for 循环创建多个异步任务,期望每个任务处理循环中的不同值,但最终输出结果却与预期不符。

错误示例

csharp 复制代码
internal class CommonTestCode 
{
    public static void Print() 
    {
        for (int i = 0; i < 100; i++) 
        {
            Task.Run(() => 
            {
                DoTask(i);
            });
        }
    }
​
    private static void DoTask(int i)
    {
        Console.WriteLine(i);
    }
}
​
internal class Program 
{
    private static void Main(string[] args) 
    {
        CommonTestCode.Print();
        Console.ReadLine();
    }
}

预期输出

我们期望看到 0-99 的无序数字输出。

实际输出

erlang 复制代码
3
3
100
6
8
6
6
8
13
13
13
100
100
100
...

大部分输出是 100,只有少量中间数字。

错误原因分析

1. 闭包捕获的是变量引用,而非当前值

在 C# 中,lambda 表达式(如 () => DoTask(i))会捕获变量的引用,而不是循环当时变量的具体值。

2. for 循环变量的作用域

for 循环中,迭代变量 i 是在循环外部 声明的。这意味着所有异步任务共享同一个 i 的引用。

3. 异步任务执行时机滞后

Task.Run 会将任务放入线程池队列 等待执行,而 for 循环本身是同步执行的,速度非常快。当循环执行完成时(i 从 0 递增到 100),大部分异步任务可能还未开始执行。

当这些任务最终执行 DoTask(i) 时,它们访问的 i 引用指向的是循环结束后的值 100 ,因此会打印大量 100

解决方案:捕获变量的副本

要解决这个问题,需要在循环内部创建一个局部变量副本,让每个异步任务捕获该副本的引用:

ini 复制代码
for (int i = 0; i < 100; i++) 
{
    int current = i; // 创建当前迭代的副本
    Task.Run(() => 
    {
        DoTask(current); // 捕获副本,而非原始 i
    });
}

优化后输出

优化后,每个异步任务都会捕获独立的 current 变量,其值为循环当时 i 的具体值,最终会打印出 0-99 的无序数字。

原理总结

概念 描述
闭包 捕获外部变量的匿名函数或 lambda 表达式
变量捕获 lambda 表达式捕获变量的引用,而非值
迭代变量作用域 for 循环变量在循环外部声明,所有迭代共享
异步执行 线程池任务执行时机晚于同步循环完成
解决方案 在循环内部创建局部变量副本,让每个任务捕获独立副本

C# 5.0+ 的 foreach 优化

值得注意的是,C# 5.0 对 foreach 循环进行了优化,将迭代变量的作用域调整到了循环内部。因此,在 foreach 循环中使用 lambda 表达式时,无需手动创建副本:

scss 复制代码
// C# 5.0+ 中,foreach 循环无需手动创建副本
foreach (var item in collection)
{
    Task.Run(() => DoTask(item)); // 自动捕获当前迭代的 item 值
}

for 循环仍保持原有行为,需要手动创建副本。

最佳实践

  1. for 循环中创建异步任务时,始终创建变量副本
  2. 了解闭包捕获的是引用,而非值
  3. 区分 forforeach 循环的变量作用域差异
  4. 使用 C# 7.0+ 的本地函数可以更清晰地处理变量捕获

本地函数优化方案

C# 7.0 引入了本地函数,可以更清晰地处理变量捕获:

scss 复制代码
for (int i = 0; i < 100; i++) 
{
    ProcessItem(i);
​
    // 本地函数,自动捕获参数的副本
    void ProcessItem(int current)
    {
        Task.Run(() => DoTask(current));
    }
}

结论

闭包变量捕获是 C# 中一个容易出错的特性,特别是在异步编程中。通过理解其工作原理,并采用正确的解决方案(创建变量副本),我们可以避免这类问题,写出更可靠的异步代码。

记住:for 循环中使用 lambda 表达式时,始终创建迭代变量的本地副本!

相关推荐
码事漫谈6 小时前
告别数据孤岛与AI“水土不服”:金仓多模融合时序库如何让数据真正服务于业务
后端
IT_陈寒6 小时前
Redis的持久化配置把我坑惨了:你以为数据安全了?
前端·人工智能·后端
星栈7 小时前
Node 接口该写同步还是异步?
后端·node.js
红烧大青虫7 小时前
HarmonyOS应用开发实战:小事记 - UIAbility 的冷启动/热启动/后台启动三种场景与 launchParam 解析
后端·华为·harmonyos·鸿蒙系统
码事漫谈7 小时前
AI Token 缓存:命中省 10 倍,不命中白扔钱
后端
65岁退休Coder7 小时前
LangChain v1.3.4 笔记 - 04 Agent 中间件
后端
神奇小汤圆8 小时前
一个接口多个实现,Spring 怎么"适配多场景"?
后端
花开彼岸天~8 小时前
鸿蒙原生开发手记:徒步迹 - 自定义组件开发规范
后端·华为·harmonyos·鸿蒙系统
songroom9 小时前
Kimi K3:Rust封装XTP接口详细教程实践
开发语言·后端·rust
kebeiovo10 小时前
游戏服务端开发:Actor模型详解(Go语言)
开发语言·后端·golang