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 表达式时,始终创建迭代变量的本地副本!

相关推荐
兔子零102431 分钟前
nginx 配置长跑(上):从一份 server 到看懂整套路由规则
后端·nginx
啥都学点的程序员34 分钟前
python项目调用shardingsphere时,多进程情况下,shardingsphere配置的连接数会乘以进程数
后端
Lear35 分钟前
Lombok全面解析:极致简化Java开发的神兵利器
后端
小周在成长35 分钟前
Java 单例设计模式(Singleton Pattern)指南
后端
啥都学点的程序员35 分钟前
小坑记录:python中 glob.glob()返回的文件顺序不同
后端
Airene36 分钟前
spring-boot 4 相比 3.5.x 的包依赖变化
spring boot·后端
用户35442543654037 分钟前
别再裸奔了!你的 Spring Boot @Async 正在榨干服务器资源
后端
虎子_layor37 分钟前
小程序登录到底是怎么工作的?一次请求背后的三方信任链
前端·后端
SimonKing1 小时前
学不动了,学不动,根本学不动!SpringBoot4.x又来了!
java·后端·程序员