foreach标识符和闭包


79

在接下来的两个摘要中,第一个是安全的还是第二个必须做?

安全地说,每个线程是否保证从创建线程的同一循环迭代中调用Foo上的方法?

还是必须将引用复制到新变量“ local”到循环的每次迭代中?

var threads = new List<Thread>();
foreach (Foo f in ListOfFoo)
{      
    Thread thread = new Thread(() => f.DoSomething());
    threads.Add(thread);
    thread.Start();
}

--

var threads = new List<Thread>();
foreach (Foo f in ListOfFoo)
{      
    Foo f2 = f;
    Thread thread = new Thread(() => f2.DoSomething());
    threads.Add(thread);
    thread.Start();
}

更新:正如Jon Skeet的答案中指出的那样,这与线程无关。


实际上,我认为它与线程有关,就像您没有使用线程一样,您将调用正确的委托。在乔恩·斯凯特(Jon Skeet)的无线程示例中,问题在于存在2个循环。这里只有一个,所以应该没有问题...除非您不确切知道何时执行代码(这意味着您是否使用线程-Marc Gravell的回答很好地显示了这一点)。
user276648 2011年


@ user276648不需要线程。将委托的执行推迟到循环之后才足以实现此行为。
宾基

Answers:


103

编辑:在C#5中,所有这些都发生了变化,更改了定义变量的位置(在编译器看来)。从C#5开始,它们是相同的


在C#5之前

第二个是安全的;第一个不是。

使用foreach,变量在循环外部声明-即

Foo f;
while(iterator.MoveNext())
{
     f = iterator.Current;
    // do something with f
}

这意味着f就闭包范围而言只有1 ,并且线程很可能会感到困惑-在某些实例上多次调用该方法,而在其他实例上根本不调用该方法。你可以用第二个变量声明,解决这个问题的内部循环:

foreach(Foo f in ...) {
    Foo tmp = f;
    // do something with tmp
}

然后,tmp在每个关闭范围中都有一个单独的位置,因此没有发生此问题的风险。

这是问题的简单证明:

    static void Main()
    {
        int[] data = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
        foreach (int i in data)
        {
            new Thread(() => Console.WriteLine(i)).Start();
        }
        Console.ReadLine();
    }

输出(随机):

1
3
4
4
5
7
7
8
9
9

添加一个临时变量,它可以工作:

        foreach (int i in data)
        {
            int j = i;
            new Thread(() => Console.WriteLine(j)).Start();
        }

(每个号码一次,但是当然不能保证顺序)


神圣的牛...那个旧职位让我头疼很多。我一直希望foreach变量在循环内起作用。那是WTF的主要经验之一。
克里斯,2012年

实际上,这被认为是foreach循环中的一个错误,并已在编译器中修复。(与for循环不同,在该循环中,变量具有整个循环的单个实例。)
Ihar Bury 2013年

@Orlangur我多年来一直与Eric,Mads和Anders进行过直接对话。编译器遵循规范,因此是正确的。规格做出了选择。简单地说:该选择已更改。
马克·格雷夫

此答案适用于C#4,但不适用于更高版本:“在C#5中,foreach的循环变量在逻辑上将位于循环内部,因此闭包每次都将关闭该变量的新副本。” (Eric Lippert
道格拉斯

@Douglas赞成,我们在进行这些操作时一直在进行更正,但这是一个常见的绊脚石,所以:还有很多事情要做!
Marc Gravell

37

Pop Catalin和Marc Gravell的答案是正确的。我要添加的只是指向我有关闭包的文章的链接(该文章讨论了Java和C#)。只是认为这可能会增加一些价值。

编辑:我认为值得举一个例子,它没有线程的不可预测性。这是一个简短但完整的程序,显示了两种方法。“不良行为”列表将打印出10十次;“好的操作”列表从0到9。

using System;
using System.Collections.Generic;

class Test
{
    static void Main() 
    {
        List<Action> badActions = new List<Action>();
        List<Action> goodActions = new List<Action>();
        for (int i=0; i < 10; i++)
        {
            int copy = i;
            badActions.Add(() => Console.WriteLine(i));
            goodActions.Add(() => Console.WriteLine(copy));
        }
        Console.WriteLine("Bad actions:");
        foreach (Action action in badActions)
        {
            action();
        }
        Console.WriteLine("Good actions:");
        foreach (Action action in goodActions)
        {
            action();
        }
    }
}

1
谢谢-我附加了一个问题,说的不是线程。
xyz

这也是您网站上的视频中的一场谈话csharpindepth.com/Talks.aspx
missaghi

是的,我似乎还记得我在那里使用过线程版本,反馈建议之一是避免使用线程-使用上面的示例更清楚。
乔恩·斯基特

很高兴知道视频正在被收看:)
Jon Skeet

即使知道变量存在于for循环之外,这种行为也让我感到困惑。例如,在您的关闭行为示例stackoverflow.com/a/428624/20774中,变量存在于关闭外部,但已正确绑定。为什么有什么不同?
James McMahon

17

您需要使用选项2,在更改变量周围创建闭包,将在使用变量时而不是在闭包创建时使用变量的值。

C#中匿名方法的实现及其后果(第1部分)

C#中匿名方法的实现及其后果(第2部分)

C#中匿名方法的实现及其后果(第3部分)

编辑:明确地说,在C#中,闭包是“词法闭包”,这意味着它们不捕获变量的值,而是变量本身。这意味着在为更改的变量创建闭包时,闭包实际上是对变量的引用,而不是其值的副本。

Edit2:如果有人有兴趣阅读有关编译器内部的信息,请添加指向所有博客文章的链接。


我认为这适用于值和引用类型。
leppie

3

这是一个有趣的问题,似乎我们已经看到人们以各种方式回答。我的印象是第二种方法是唯一安全的方法。我打了一个真实的快速证明:

class Foo
{
    private int _id;
    public Foo(int id)
    {
        _id = id;
    }
    public void DoSomething()
    {
        Console.WriteLine(string.Format("Thread: {0} Id: {1}", Thread.CurrentThread.ManagedThreadId, this._id));
    }
}
class Program
{
    static void Main(string[] args)
    {
        var ListOfFoo = new List<Foo>();
        ListOfFoo.Add(new Foo(1));
        ListOfFoo.Add(new Foo(2));
        ListOfFoo.Add(new Foo(3));
        ListOfFoo.Add(new Foo(4));


        var threads = new List<Thread>();
        foreach (Foo f in ListOfFoo)
        {
            Thread thread = new Thread(() => f.DoSomething());
            threads.Add(thread);
            thread.Start();
        }
    }
}

如果运行此选项,将会看到选项1绝对不安全。


1

在您的情况下,可以通过将您ListOfFoo的线程映射到一系列线程来避免问题,而无需使用复制技巧:

var threads = ListOfFoo.Select(foo => new Thread(() => foo.DoSomething()));
foreach (var t in threads)
{
    t.Start();
}


-5
Foo f2 = f;

指向与

f 

因此,什么也没有损失,也没有收获...


1
这不是魔术。它只是捕获环境。这里和for循环的问题是,捕获变量被突变(重新分配)。
leppie

1
leppie:编译器会为您生成代码,通常很难看出这是什么代码。如果有的话,这就是编译器魔术定义。
康拉德·鲁道夫

3
@leppie:我和Konrad在这里。编译器的长度感觉就像魔术一样,尽管语义已明确定义,但并没有很好地理解。对于任何不被理解为与魔术可比的事物,古老的说法是什么?
乔恩·斯基特

2
@Jon Skeet您的意思是“任何足够先进的技术都无法与魔术区别开” en.wikipedia.org/wiki/Clarke%27s_three_laws :)
AwesomeTown

2
它没有指向参考。这是一个参考。它指向相同的对象,但是是不同的引用。
mqp,2009年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.