Ідентифікатор 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();
}

Оновлення: Як зазначено у відповіді Джона Скіта, це не має нічого конкретного спільного з різьбленням.


Насправді я відчуваю, що це пов’язано з потоковими потоками, так як якщо б ви не використовували потокову роботу, ви зателефонували правильному представнику. У зразку Джона Скіта без різьблення проблема полягає в тому, що є 2 петлі. Ось лише один, тому проблеми не повинно бути ... якщо ви не знаєте точно, коли буде виконаний код (мається на увазі, якщо ви використовуєте різьбу - відповідь Марка Гравелла це чудово показує).
user276648


@ user276648 Для цього не потрібна різьба. Для отримання такої поведінки було б достатньо відкласти виконання делегатів до закінчення циклу.
binki

Відповіді:


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

Додайте змінну temp, і вона працює:

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

(кожен номер один раз, але, звичайно, замовлення не гарантується)


Свята корово ... той старий пост врятував мені багато головного болю. Я завжди очікував, що змінна foreach буде встановлена ​​в циклі. Це був один із основних досвідів WTF.
Кріс

Насправді це вважалося помилкою у foreach-loop і виправленим у компіляторі. (На відміну від for-loop, де у змінної є один екземпляр для всього циклу.)
Ihar Bury

@Orlangur Я роками спілкувався з цим Еріком, Мадсом та Андерсом. Компілятор дотримувався специфікації, тому мав рацію. Специфікація зробила вибір. Просто: цей вибір було змінено.
Марк Гравелл

Ця відповідь стосується C # 4, але не для пізніших версій: "У C # 5 змінна циклу foreach буде логічно знаходитись всередині циклу, і тому закриття щоразу закриватимуться перед новою копією змінної." ( Ерік Ліпперт )
Дуглас,

@Douglas da, я виправляв їх, як ми йдемо, але це був звичайний камінь спотикання, тож: є ще чимало!
Марк Гравелл

37

Відповіді Попа Каталіна та Марка Гравелла є вірними. Все, що я хочу додати, - це посилання на мою статтю про закриття (де йдеться як про Java, так і про C #). Просто думав, що це може додати трохи цінності.

EDIT: Я думаю, варто навести приклад, який не має непередбачуваності потоків. Ось коротка, але повна програма, що демонструє обидва підходи. Список "поганих дій" друкується 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

Так, я, здається, пам’ятаю, що використовував там потокову версію, і однією з пропозицій зворотного зв’язку було уникати потоків - зрозуміліше використовувати приклад, наведений вище.
Джон Скіт,

Приємно знати, що відео переглядають :)
Джон Скіт,

Навіть розуміння того, що змінна існує поза forциклом, ця поведінка мене бентежить. Наприклад, у вашому прикладі поведінки закриття, stackoverflow.com/a/428624/20774 , змінна існує за межами закриття, проте прив'язується правильно. Чому це відрізняється?
Джеймс Макмехон,

17

Вам потрібно використовувати варіант 2, створення закриття навколо змінної змінної використовуватиме значення змінної, коли використовується змінна, а не під час створення закриття.

Впровадження анонімних методів в C # та його наслідки (частина 1)

Впровадження анонімних методів у C # та його наслідки (частина 2)

Впровадження анонімних методів у C # та його наслідки (частина 3)

Змінити: щоб було зрозуміло, у C # закриття - це " лексичні закриття ", що означає, що вони не фіксують значення змінної, а саму змінну. Це означає, що під час створення закриття змінної змінної закриття фактично є посиланням на змінну, а не копією її значення.

Edit2: додано посилання на всі дописи блогу, якщо комусь цікаво прочитати про внутрішні компоненти компілятора.


Я думаю, що це стосується типів значення та посилань.
леппі

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 полягає в тому, що змінна захоплення отримує мутацію (перепризначення).
леппі

1
leppie: компілятор генерує код для вас, і непросто загалом зрозуміти, що це за код саме. Це визначення магії компілятора , якщо коли - небудь був один.
Конрад Рудольф

3
@leppie: Я тут з Конрадом. Довжини компілятор йде до відчувати себе , як за помахом чарівної палички, і хоча семантика чітко визначені , вони не зрозумілі. Яка стара приказка про те, що щось незрозуміле можна порівняти з магією?
Джон Скіт,

2
@Jon Skeet Ви маєте на увазі "будь-яку досить просунуту технологію
неможливо

2
Це не вказує на посилання. Це посилання. Він вказує на той самий об’єкт, але це інше посилання.
mqp
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.