El identificador foreach y los cierres.

Resuelto xyz asked hace 15 años • 7 respuestas

En los dos fragmentos siguientes, ¿el primero es seguro o debes hacer el segundo?

Por seguro quiero decir, ¿se garantiza que cada subproceso llame al método en Foo desde la misma iteración del bucle en la que se creó el subproceso?

¿O debes copiar la referencia a una nueva variable "local" en cada iteración del bucle?

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();
}

Actualización: como se señala en la respuesta de Jon Skeet, esto no tiene nada que ver específicamente con el subproceso.

xyz avatar Feb 04 '09 23:02 xyz
Aceptado

Editar: todo esto cambia en C# 5, con un cambio en el lugar donde se define la variable (a los ojos del compilador). A partir de C# 5, son iguales .


Antes de C#5

El segundo es seguro; el primero no lo es.

Con foreach, la variable se declara fuera del bucle, es decir

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

Esto significa que solo hay 1 fen términos del alcance del cierre, y es muy probable que los subprocesos se confundan: llamar al método varias veces en algunas instancias y ninguna en otras. Puedes solucionar esto con una segunda declaración de variable dentro del bucle:

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

Esto tiene un tmpalcance de cierre separado en cada uno, por lo que no hay riesgo de que se produzca este problema.

Aquí hay una prueba simple del problema:

    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();
    }

Salidas (al azar):

1
3
4
4
5
7
7
8
9
9

Agregue una variable temporal y funciona:

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

(cada número una vez, pero por supuesto el orden no está garantizado)

Marc Gravell avatar Feb 04 '2009 17:02 Marc Gravell

Las respuestas de Pop Catalin y Marc Gravell son correctas. Todo lo que quiero agregar es un enlace a mi artículo sobre cierres (que habla tanto de Java como de C#). Sólo pensé que podría agregar un poco de valor.

EDITAR: Creo que vale la pena dar un ejemplo que no tenga la imprevisibilidad del subprocesamiento. Aquí hay un programa breve pero completo que muestra ambos enfoques. La lista de "malas acciones" se imprime 10 diez veces; la lista de "buenas acciones" cuenta del 0 al 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();
        }
    }
}
Jon Skeet avatar Feb 04 '2009 17:02 Jon Skeet

Su necesidad de usar la opción 2, crear un cierre alrededor de una variable cambiante usará el valor de la variable cuando se use la variable y no en el momento de la creación del cierre.

La implementación de métodos anónimos en C# y sus consecuencias (parte 1)

La implementación de métodos anónimos en C# y sus consecuencias (parte 2)

La implementación de métodos anónimos en C# y sus consecuencias (parte 3)

Editar: para que quede claro, en C# los cierres son " cierres léxicos ", lo que significa que no capturan el valor de una variable sino la variable misma. Eso significa que al crear un cierre para una variable cambiante, el cierre es en realidad una referencia a la variable, no una copia de su valor.

Edit2: se agregaron enlaces a todas las publicaciones del blog si alguien está interesado en leer sobre los aspectos internos del compilador.

Pop Catalin avatar Feb 04 '2009 16:02 Pop Catalin

Esta es una pregunta interesante y parece que hemos visto a personas responder de diversas maneras. Tenía la impresión de que la segunda forma sería la única segura. Preparé una prueba muy rápida:

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();
        }
    }
}

Si ejecuta esto, verá que la opción 1 definitivamente no es segura.

JoshBerke avatar Feb 04 '2009 17:02 JoshBerke

En su caso, puede evitar el problema sin utilizar el truco de copia asignando su ListOfFooa una secuencia de subprocesos:

var threads = ListOfFoo.Select(foo => new Thread(() => foo.DoSomething()));
foreach (var t in threads)
{
    t.Start();
}
Ben James avatar Dec 20 '2011 12:12 Ben James