El identificador foreach y los cierres


79

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

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

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

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ñaló en la respuesta de Jon Skeet, esto no tiene nada que ver específicamente con el enhebrado.


En realidad, siento que tiene que ver con el subproceso, ya que si no estuviera usando el subproceso, llamaría al delegado correcto. En la muestra de Jon Skeet sin enhebrar, el problema es que hay 2 bucles. Aquí hay solo uno, por lo que no debería haber ningún problema ... a menos que no sepa exactamente cuándo se ejecutará el código (es decir, si usa subprocesos, la respuesta de Marc Gravell lo muestra perfectamente).
user276648

posible duplicado de Acceso al cierre modificado (2)
nawfal

@ user276648 No requiere subprocesamiento. Aplazar la ejecución de los delegados hasta después del ciclo sería suficiente para obtener este comportamiento.
binki

Respuestas:


103

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


Antes de C # 5

El segundo es seguro; el primero no lo es.

Con foreach, la variable se declara fuera del ciclo, 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 de cierre, y es muy probable que los subprocesos se confundan, llamando al método varias veces en algunas instancias y no en absoluto en otras. Puede solucionar esto con una segunda declaración de variable dentro del ciclo:

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

Esto luego tiene un tmpalcance separado en cada cierre, por lo que no hay riesgo de 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 pedido no está garantizado)


Santo cielo ... ese viejo mensaje me salvó mucho dolor de cabeza. Siempre esperé que la variable foreach tuviera un alcance dentro del ciclo. Esa fue una gran experiencia de WTF.
chris

En realidad, eso se consideró un error en foreach-loop y se corrigió en el compilador. (A diferencia del bucle for donde la variable tiene una instancia única para todo el bucle).
Ihar Bury

@Orlangur He tenido conversaciones directas con Eric, Mads y Anders sobre esto durante años. El compilador siguió las especificaciones, así que tenía razón. La especificación tomó una decisión. Simplemente: esa elección fue cambiada.
Marc Gravell

Esta respuesta se aplica hasta C # 4, pero no para versiones posteriores: "En C # 5, la variable de ciclo de un foreach estará lógicamente dentro del ciclo y, por lo tanto, los cierres se cerrarán con una copia nueva de la variable cada vez". ( Eric Lippert )
Douglas

@Douglas sí, he ido corrigiendo esto a medida que avanzamos, pero era un obstáculo común, así que: ¡quedan bastantes!
Marc Gravell

37

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 de Java y C #). Solo pensé que podría agregar un poco de valor.

EDITAR: Creo que vale la pena dar un ejemplo que no tenga la imprevisibilidad del subproceso. 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 de 0 a 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
Gracias, agregué la pregunta para decir que no se trata realmente de hilos.
xyz

También fue en una de las charlas que tiene en video en su sitio csharpindepth.com/Talks.aspx
missaghi

Sí, creo recordar que usé una versión de subprocesos allí, y una de las sugerencias de comentarios fue evitar los subprocesos; es más claro usar un ejemplo como el anterior.
Jon Skeet

Es bueno saber que los videos se están viendo :)
Jon Skeet

Incluso entender que la variable existe fuera del forciclo, este comportamiento me confunde. Por ejemplo, en su ejemplo de comportamiento de cierre, stackoverflow.com/a/428624/20774 , la variable existe fuera del cierre pero se une correctamente. ¿Por qué es esto diferente?
James McMahon

17

Su necesidad de usar la opción 2, crear un cierre alrededor de una variable cambiante utilizará el valor de la variable cuando se utilice 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 en sí. Eso significa que cuando se crea 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 componentes internos del compilador.


Creo que eso se aplica a los tipos de valor y referencia.
leppie

3

Esta es una pregunta interesante y parece que hemos visto a personas responder de diversas maneras. Tenía la impresión de que el segundo camino sería el único seguro. Azoté una prueba realmente 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.


1

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

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


-5
Foo f2 = f;

apunta a la misma referencia que

f 

Así que nada perdido y nada ganado ...


1
No es magia. Simplemente captura el medio ambiente. El problema aquí y con los bucles for es que la variable de captura se muta (reasigna).
leppie

1
leppie: el compilador genera código para usted, y no es fácil ver en general qué código es exactamente. Esta es la definición de magia de compilador si alguna vez hubo una.
Konrad Rudolph

3
@leppie: Estoy con Konrad aquí. Las longitudes del compilador va a sentir como magia, y aunque la semántica están claramente definidos que no están bien entendidos. ¿Cuál es el viejo dicho de que todo lo que no se comprende bien es comparable a la magia?
Jon Skeet

2
@Jon Skeet ¿Quieres decir que "cualquier tecnología lo suficientemente avanzada es indistinguible de la magia" en.wikipedia.org/wiki/Clarke%27s_three_laws :)
AwesomeTown

2
No apunta a una referencia. Es una referencia. Apunta al mismo objeto, pero es una referencia diferente.
mqp
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.