Código asincrónico, variables compartidas, subprocesos de grupo de subprocesos y seguridad de subprocesos

8

Cuando escribo código asincrónico con async / await, generalmente ConfigureAwait(false)para evitar capturar el contexto, mi código salta de un subproceso de grupo de subprocesos al siguiente después de cada uno await. Esto plantea preocupaciones sobre la seguridad del hilo. ¿Es seguro este código?

static async Task Main()
{
    int count = 0;
    for (int i = 0; i < 1_000_000; i++)
    {
        Interlocked.Increment(ref count);
        await Task.Yield();
    }
    Console.WriteLine(count == 1_000_000 ? "OK" : "Error");
}

La variable ino está protegida y se accede a ella mediante múltiples subprocesos de grupo de subprocesos *. Aunque el patrón de acceso no es concurrente, debería ser teóricamente posible que cada subproceso incremente un valor almacenado localmente en caché i, lo que da como resultado más de 1,000,000 de iteraciones. Sin embargo, no puedo producir este escenario en la práctica. El código anterior siempre imprime OK en mi máquina. ¿Esto significa que el código es seguro para subprocesos? ¿O debería sincronizar el acceso a la ivariable usando a lock?

(* un cambio de hilo ocurre cada 2 iteraciones en promedio, de acuerdo con mis pruebas)

Theodor Zoulias
fuente
1
¿Por qué crees que iestá en caché en cada hilo? Vea este SharpLab IL para profundizar.
AndreasHassing
1
@AndreasHassing Mis inquietudes se plantean con declaraciones como esta: el compilador, CLR o CPU pueden introducir optimizaciones de almacenamiento en caché de modo que las asignaciones a las variables no sean visibles para otros hilos de inmediato. Parte 4: Enhebrado avanzado
Theodor Zoulias

Respuestas:

2

El problema con la seguridad del hilo es sobre la lectura / escritura de memoria. Incluso cuando esto podría continuar en un hilo diferente, aquí nada se ejecuta simultáneamente.

Jeroen van Langen
fuente
Teóricamente, un subproceso podría leer y escribir en una memoria caché local en lugar de la RAM principal, de esta manera falta una actualización realizada por otro hilo. La variable ino está declarada volatileni protegida por un bloqueo, por lo que, según tengo entendido, el compilador, el Jitter y el hardware (CPU) tienen permitido hacer una optimización como esta.
Theodor Zoulias
@TheodorZoulias Cambiar un hilo para reanudar una continuación no es lo mismo que el acceso concurrente. En el sharplab que está vinculado anteriormente, puede ver que toda la máquina de estado, que encapsula a los locales en campos privados, se pasa al hilo que ejecutará la continuación. Solo 1 hilo está accediendo ien un momento dado.
JohanP
@JohanP el campo private int <i>5__2en la máquina de estado no se declara volatile. Mis preocupaciones no son sobre un hilo que interrumpe otro hilo que está en medio de la actualización i. Esto es imposible de suceder en este caso. Mis preocupaciones son sobre un hilo que utiliza un valor obsoleto de i, almacenado en caché en el caché local del núcleo de la CPU, dejado allí desde un bucle anterior, en lugar de obtener un nuevo valor de ila RAM principal. Acceder a la memoria caché local es más barato que acceder a la RAM principal, por lo que con las optimizaciones activadas tales cosas son posibles (de acuerdo con lo que he leído).
Theodor Zoulias
@TheodorZoulias, ¿tiene la misma preocupación si este bucle no tiene asynccódigo?
JohanP
2
@TheodorZoulias El hilo A se ejecuta, en incrementos i. El código golpea await, el subproceso A pasa todo el estado al subproceso B y vuelve al grupo. El hilo B se incrementa i. Éxitos await. El subproceso B luego pasa todo el estado al subproceso C, vuelve al grupo, etc., etc. En ningún momento hay acceso concurrente i, no se necesita seguridad de subprocesos, no importa que haya ocurrido un interruptor de subprocesos, todos los El estado necesario se pasa al nuevo subproceso que ejecuta la continuación. No hay un estado compartido, por eso no necesita sincronización.
JohanP
0

Creo que este artículo de Stephen Toub puede arrojar algo de luz sobre esto. En particular, este es un pasaje relevante sobre lo que sucede durante un cambio de contexto:

Cada vez que el código espera a un esperable cuyo camarero dice que aún no está completo (es decir, el IsCompleted del camarero devuelve falso), el método debe suspenderse y se reanudará a través de una continuación del camarero. Este es uno de esos puntos asincrónicos a los que me referí anteriormente, y por lo tanto, ExecutionContext necesita fluir desde el código que emite la espera hasta la ejecución del delegado de continuación. Eso lo maneja automáticamente el Framework. Cuando el método asíncrono está a punto de suspenderse, la infraestructura captura un ExecutionContext. El delegado que se pasa al camarero tiene una referencia a esta instancia de ExecutionContext y la usará cuando reanude el método. Esto es lo que permite que la información importante "ambiental" representada por ExecutionContext fluya a través de las esperas.

Vale la pena señalar que el YieldAwaitabledevuelto por Task.Yield()siempre regresa false.

Daniel Crha
fuente
Gracias Daniel por la respuesta. Para ser sincero, me sorprendería que el flujo del ExecutionContexthilo a hilo sirviera también como mecanismo para invalidar las cachés locales del hilo. Pero tampoco es imposible.
Theodor Zoulias
Tal vez un experto como @RaymondChen podría afirmar si su respuesta es correcta o incorrecta. Creo que muy pocas personas en el mundo pueden servir como fuentes creíbles de información sobre este tema.
Theodor Zoulias
"Invalidar las memorias caché locales del subproceso" implicaría que cuando un subproceso realiza un cambio de contexto, de alguna manera también mantiene una memoria caché que es específica de este contexto. Eso significaría que estos datos almacenados en caché deben almacenarse en algo que se parezca a un contexto ... pero ¿por qué, cuando el contexto real está disponible para el hilo que tendrá que ejecutarlo? También provocaría el problema de determinar qué dos contextos son "iguales", pero solo representan un punto posterior en la ejecución. Por supuesto, no pretendo ser un experto, solo trato de razonar sobre el problema como un ejercicio mental.
Daniel Crha
Además, en caso de que me equivoque, podría invocar la ley de Cunningham: "La mejor manera de obtener la respuesta correcta en Internet es no hacer una pregunta, es publicar la respuesta incorrecta".
Daniel Crha
1
Pero un caché de hardware no es específico de subproceso. De hecho, incluso el código de un solo subproceso podría verse obligado a ceder debido a la multitarea preventiva desde el lado del sistema operativo, y podría reanudar la ejecución en un procesador diferente (y, por lo tanto, en un caché L1 y L2 diferente). Esta invalidación de caché no es específica de asynco await. La invalidación de la memoria caché durante un cambio de contexto afectaría el código único y multiproceso de la misma manera.
Daniel Crha