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 i
no 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 i
variable usando a lock
?
(* un cambio de hilo ocurre cada 2 iteraciones en promedio, de acuerdo con mis pruebas)
fuente
i
está en caché en cada hilo? Vea este SharpLab IL para profundizar.Respuestas:
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.
fuente
i
no está declaradavolatile
ni 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.i
en un momento dado.private int <i>5__2
en la máquina de estado no se declaravolatile
. Mis preocupaciones no son sobre un hilo que interrumpe otro hilo que está en medio de la actualizacióni
. Esto es imposible de suceder en este caso. Mis preocupaciones son sobre un hilo que utiliza un valor obsoleto dei
, 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 dei
la 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).async
código?i
. El código golpeaawait
, el subproceso A pasa todo el estado al subproceso B y vuelve al grupo. El hilo B se incrementai
. Éxitosawait
. El subproceso B luego pasa todo el estado al subproceso C, vuelve al grupo, etc., etc. En ningún momento hay acceso concurrentei
, 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.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:
Vale la pena señalar que el
YieldAwaitable
devuelto porTask.Yield()
siempre regresafalse
.fuente
ExecutionContext
hilo a hilo sirviera también como mecanismo para invalidar las cachés locales del hilo. Pero tampoco es imposible.async
oawait
. La invalidación de la memoria caché durante un cambio de contexto afectaría el código único y multiproceso de la misma manera.