¿Qué sucede exactamente cuando un hilo espera una tarea dentro de un ciclo while?

10

Después de lidiar con el patrón asíncrono / espera de C # por un tiempo, de repente me di cuenta de que realmente no sé cómo explicar lo que sucede en el siguiente código:

async void MyThread()
{
    while (!_quit)
    {
        await GetWorkAsync();
    }
}

GetWorkAsync()se supone que devuelve un objeto de espera Taskque puede o no causar un cambio de hilo cuando se ejecuta la continuación.

No estaría confundido si la espera no estuviera dentro de un bucle. Naturalmente, esperaría que el resto del método (es decir, continuación) se ejecute potencialmente en otro hilo, lo cual está bien.

Sin embargo, dentro de un bucle, el concepto de "el resto del método" me resulta un poco confuso.

¿Qué sucede con "el resto del bucle" si el hilo se enciende en continuación vs. si no se cambia? ¿En qué hilo se ejecuta la siguiente iteración del bucle?

Mis observaciones muestran (no verificadas de manera concluyente) que cada iteración comienza en el mismo hilo (el original) mientras que la continuación se ejecuta en otro. ¿Puede esto ser realmente? En caso afirmativo, ¿es este un grado de paralelismo inesperado que debe tenerse en cuenta frente a la seguridad de subprocesos del método GetWorkAsync?

ACTUALIZACIÓN: Mi pregunta no es un duplicado, como lo sugieren algunos. El while (!_quit) { ... }patrón de código es simplemente una simplificación de mi código real. En realidad, mi hilo es un ciclo de larga duración que procesa su cola de entrada de elementos de trabajo a intervalos regulares (cada 5 segundos por defecto). La verificación de la condición de abandono real tampoco es una simple verificación de campo como lo sugiere el código de muestra, sino más bien una verificación del controlador de eventos.

aoven
fuente
1
Consulte también ¿Cómo ceder y esperar implementar el flujo de control en .NET? para obtener una gran información sobre cómo se conecta todo esto.
John Wu
@ John Wu: Todavía no he visto ese hilo SO. Hay muchas pepitas de información interesantes allí. ¡Gracias!
aoven

Respuestas:

6

De hecho, puedes verlo en Try Roslyn . Su método de espera se reescribe en void IAsyncStateMachine.MoveNext()la clase asíncrona generada.

Lo que verás es algo como esto:

            if (this.state != 0)
                goto label_2;
            //set up the state machine here
            label_1:
            taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter);
            label_2:
            if (!OuterClass._quit)
            {
               taskAwaiter = GetWorkAsync().GetAwaiter();
               //state machine stuff here
            }
            goto label_1;

Básicamente, no importa en qué hilo estés; la máquina de estado puede reanudarse correctamente reemplazando su ciclo con una estructura if / goto equivalente.

Dicho esto, los métodos asíncronos no necesariamente se ejecutan en un hilo diferente. Vea la explicación de Eric Lippert "No es mágico" para explicar cómo puede trabajar async/awaiten un solo hilo.

Mason Wheeler
fuente
2
Parece que estoy subestimando el grado de reescritura que hace el compilador en mi código asíncrono. ¡En esencia, no hay un "bucle" después de la reescritura! Esa fue la parte que faltaba para mí. ¡Impresionante y gracias por el enlace 'Pruebe Roslyn' también!
aoven
GOTO es la construcción del bucle original . No olvidemos eso.
2

En primer lugar, Servy ha escrito un código en una respuesta a una pregunta similar, en la que se basa esta respuesta:

/programming/22049339/how-to-create-a-cancellable-task-loop

La respuesta de Servy incluye un ContinueWith()ciclo similar que utiliza construcciones TPL sin el uso explícito de las palabras clave asyncy await; así que para responder a su pregunta, considere cómo se vería su código cuando su ciclo se desenrolla usandoContinueWith()

    private static Task GetWorkWhileNotQuit()
    {
        var tcs = new TaskCompletionSource<bool>();

        Task previous = Task.FromResult(_quit);
        Action<Task> continuation = null;
        continuation = t =>
        {
            if (!_quit)
            {
                previous = previous.ContinueWith(_ => GetWorkAsync())
                    .Unwrap()
                    .ContinueWith(_ => previous.ContinueWith(continuation));
            }
            else
            {
                tcs.SetResult(_quit);
            }
        };
        previous.ContinueWith(continuation);
        return tcs.Task;
    }

Esto lleva algún tiempo para entenderlo, pero en resumen:

  • continuationrepresenta un cierre para la "iteración actual"
  • previousrepresenta el que Taskcontiene el estado de la "iteración anterior" (es decir, sabe cuándo finaliza la 'iteración' y se utiliza para iniciar la siguiente ...)
  • Suponiendo que GetWorkAsync()devuelve a Task, eso significa ContinueWith(_ => GetWorkAsync())que devolverá a, Task<Task>por lo tanto, la llamada Unwrap()a obtener la 'tarea interna' (es decir, el resultado real de GetWorkAsync()).

Entonces:

  1. Inicialmente no hay una iteración previa , por lo que simplemente se le asigna un valor de Task.FromResult(_quit) - su estado comienza como Task.Completed == true.
  2. El continuationse ejecuta por primera vez usandoprevious.ContinueWith(continuation)
  3. el continuationcierre se actualiza previouspara reflejar el estado de finalización de_ => GetWorkAsync()
  4. Cuando _ => GetWorkAsync()se completa, "continúa con" _previous.ContinueWith(continuation), es decir, continuationvolver a llamar al lambda
    • Obviamente en este punto, previousse ha actualizado con el estado de _ => GetWorkAsync()modo que continuationse llama al lambda cuando GetWorkAsync()regresa.

La continuationlambda siempre verifica el estado de _quitesto, si _quit == falseno hay más continuaciones, y TaskCompletionSourcese establece en el valor de _quit, y todo se completa.

En cuanto a su observación con respecto a la continuación que se ejecuta en un hilo diferente, eso no es algo que la palabra clave async/ awaitharía por usted, según este blog "Las tareas (todavía) no son hilos y la sincronización no es paralela" . - https://blogs.msdn.microsoft.com/benwilli/2015/09/10/tasks-are-still-not-threads-and-async-is-not-parallel/

Sugeriría que realmente vale la pena echar un vistazo más de cerca a su GetWorkAsync()método con respecto al enhebrado y la seguridad del hilo. Si sus diagnósticos revelan que se ha estado ejecutando en un subproceso diferente como consecuencia de su código asincrónico / de espera repetido, entonces algo dentro o relacionado con ese método debe estar causando que se cree un nuevo subproceso en otro lugar. (Si esto es inesperado, ¿tal vez hay un .ConfigureAwaitlugar?)

Ben Cottrell
fuente
2
El código que he mostrado está (muy) simplificado. Dentro de GetWorkAsync () hay varias esperas adicionales. Algunos de ellos acceden a la base de datos y la red, lo que significa una verdadera E / S. Según tengo entendido, el cambio de hilo es una consecuencia natural (aunque no es obligatorio) de tales esperas, porque el hilo inicial no establece ningún contexto de sincronización que regiría dónde deberían ejecutarse las continuaciones. Entonces se ejecutan en un hilo de grupo de subprocesos. ¿Está mal mi razonamiento?
aoven
@aoven Buen punto - No consideré los diferentes tipos de SynchronizationContext- eso es ciertamente importante ya que .ContinueWith()usa el SynchronizationContext para despachar la continuación; de hecho, explicaría el comportamiento que está viendo si awaitse llama en un hilo ThreadPool o un hilo ASP.NET. Una continuación ciertamente podría ser enviada a un hilo diferente en esos casos. Por otro lado, recurrir awaita un contexto de subproceso único, como un WPF Dispatcher o el contexto Winforms, debería ser suficiente para garantizar que la continuación ocurra en el original. hilo
Ben Cottrell