¿Por qué se bloquea esta acción asincrónica?


102

Tengo una aplicación .Net 4.5 de varios niveles que llama a un método que usa las palabras clave nuevas asyncy las awaitpalabras clave de C # que simplemente se cuelgan y no veo por qué.

En la parte inferior tengo un método asincrónico que amplía nuestra utilidad de base de datos OurDBConn(básicamente un contenedor para el subyacente DBConnectiony los DBCommandobjetos):

public static async Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
    string connectionString = dataSource.ConnectionString;

    // Start the SQL and pass back to the caller until finished
    T result = await Task.Run(
        () =>
        {
            // Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
            using (var ds = new OurDBConn(connectionString))
            {
                return function(ds);
            }
        });

    return result;
}

Luego tengo un método asincrónico de nivel medio que llama a esto para obtener algunos totales de ejecución lenta:

public static async Task<ResultClass> GetTotalAsync( ... )
{
    var result = await this.DBConnection.ExecuteAsync<ResultClass>(
        ds => ds.Execute("select slow running data into result"));

    return result;
}

Finalmente tengo un método de UI (una acción MVC) que se ejecuta sincrónicamente:

Task<ResultClass> asyncTask = midLevelClass.GetTotalAsync(...);

// do other stuff that takes a few seconds

ResultClass slowTotal = asyncTask.Result;

El problema es que se cuelga de esa última línea para siempre. Hace lo mismo si llamo asyncTask.Wait(). Si ejecuto el método SQL lento directamente, tarda unos 4 segundos.

El comportamiento que espero es que cuando llegue asyncTask.Result, si no está terminado, debe esperar hasta que lo esté, y una vez que lo esté, debe devolver el resultado.

Si paso con un depurador, la declaración SQL se completa y la función lambda finaliza, pero nunca se llega a la return result;línea de GetTotalAsync.

¿Alguna idea de lo que estoy haciendo mal?

¿Alguna sugerencia sobre dónde debo investigar para solucionar este problema?

¿Podría ser un punto muerto en alguna parte y, de ser así, hay alguna forma directa de encontrarlo?

Respuestas:


150

Sí, eso es un callejón sin salida. Y un error común con el TPL, así que no se sienta mal.

Cuando escribe await foo, el tiempo de ejecución, de forma predeterminada, programa la continuación de la función en el mismo SynchronizationContext en el que comenzó el método. En inglés, digamos que llamó a su ExecuteAsyncdesde el hilo de la interfaz de usuario. Su consulta se ejecuta en el hilo del grupo de subprocesos (porque llamó Task.Run), pero luego espera el resultado. Esto significa que el tiempo de ejecución programará su " return result;" línea para que se ejecute de nuevo en el hilo de la interfaz de usuario, en lugar de programarla de nuevo en el grupo de hilos.

Entonces, ¿cómo funciona este punto muerto? Imagina que solo tienes este código:

var task = dataSource.ExecuteAsync(_ => 42);
var result = task.Result;

Entonces, la primera línea inicia el trabajo asincrónico. La segunda línea bloquea el hilo de la interfaz de usuario . Entonces, cuando el tiempo de ejecución quiere ejecutar la línea "return result" en el hilo de la interfaz de usuario, no puede hacerlo hasta que se Resultcomplete. Pero, por supuesto, el Resultado no se puede dar hasta que se produzca la devolución. Punto muerto.

Esto ilustra una regla clave para usar el TPL: cuando lo usa .Resulten un subproceso de IU (o algún otro contexto de sincronización elegante), debe tener cuidado de asegurarse de que nada de lo que Task depende esté programado en el subproceso de IU. O de lo contrario ocurre la maldad.

Entonces, ¿Qué haces? La opción # 1 es usar await en todas partes, pero como dijiste, esa no es una opción. La segunda opción que está disponible para usted es simplemente dejar de usar await. Puede reescribir sus dos funciones para:

public static Task<T> ExecuteAsync<T>(this OurDBConn dataSource, Func<OurDBConn, T> function)
{
    string connectionString = dataSource.ConnectionString;

    // Start the SQL and pass back to the caller until finished
    return Task.Run(
        () =>
        {
            // Copy the SQL connection so that we don't get two commands running at the same time on the same open connection
            using (var ds = new OurDBConn(connectionString))
            {
                return function(ds);
            }
        });
}

public static Task<ResultClass> GetTotalAsync( ... )
{
    return this.DBConnection.ExecuteAsync<ResultClass>(
        ds => ds.Execute("select slow running data into result"));
}

¿Cual es la diferencia? Ahora no hay espera en ningún lugar, por lo que no se programa implícitamente nada en el hilo de la interfaz de usuario. Para métodos simples como estos que tienen un solo retorno, no tiene sentido hacer un var result = await...; return resultpatrón " "; simplemente elimine el modificador asíncrono y pase el objeto de tarea directamente. Es menos sobrecarga, al menos.

La opción n. ° 3 es especificar que no desea que sus esperas se programen nuevamente en el subproceso de la interfaz de usuario, sino que solo programe en el grupo de subprocesos. Haces esto con el ConfigureAwaitmétodo, así:

public static async Task<ResultClass> GetTotalAsync( ... )
{
    var resultTask = this.DBConnection.ExecuteAsync<ResultClass>(
        ds => return ds.Execute("select slow running data into result");

    return await resultTask.ConfigureAwait(false);
}

Esperar una tarea normalmente se programaría en el subproceso de la interfaz de usuario si estás en ella; esperando el resultado de ContinueAwaitignorará el contexto en el que se encuentre, y siempre programará el threadpool. La desventaja de esto es que tienes que esparcirlo por todas partes en todas las funciones de las que depende tu .Result, porque cualquier error .ConfigureAwaitpuede ser la causa de otro punto muerto.


6
Por cierto, la pregunta es sobre ASP.NET, por lo que no hay ningún hilo de interfaz de usuario. Pero el problema con los interbloqueos es exactamente el mismo, debido a ASP.NET SynchronizationContext.
svick

Eso explicó mucho, ya que tenía un código .Net 4 similar que no tenía el problema pero que usaba el TPL sin las palabras clave async/ await.
Keith


Si alguien está buscando el código de VB.net (como yo), se explica aquí: docs.microsoft.com/en-us/dotnet/visual-basic/programming-guide/…
MichaelDarkBlue


36

Este es el asyncescenario clásico de punto muerto mixto , como describo en mi blog . Jason lo describió bien: de forma predeterminada, se guarda un "contexto" en cada uno awaity se utiliza para continuar con el asyncmétodo. Este "contexto" es el actual a SynchronizationContextmenos que lo sea null, en cuyo caso es el actual TaskScheduler. Cuando el asyncmétodo intenta continuar, primero vuelve a entrar en el "contexto" capturado (en este caso, un ASP.NET SynchronizationContext). ASP.NET SynchronizationContextsolo permite un hilo en el contexto a la vez, y ya hay un hilo en el contexto: el hilo bloqueado Task.Result.

Hay dos pautas que evitarán este punto muerto:

  1. Use asynctodo el camino hacia abajo. Mencionas que "no puedes" hacer esto, pero no estoy seguro de por qué no. ASP.NET MVC en .NET 4.5 ciertamente puede admitir asyncacciones, y no es un cambio difícil de realizar.
  2. Utilice ConfigureAwait(continueOnCapturedContext: false)tanto como sea posible. Esto anula el comportamiento predeterminado de reanudar en el contexto capturado.

¿ ConfigureAwait(false)Garantiza que la función actual se reanude en un contexto diferente?
chue x

El marco MVC lo admite, pero esto es parte de una aplicación MVC existente con muchos JS del lado del cliente ya presentes. No puedo cambiar fácilmente a una asyncacción sin romper la forma en que esto funciona del lado del cliente. Sin embargo, ciertamente planeo investigar esa opción a más largo plazo.
Keith

Solo para aclarar mi comentario, tenía curiosidad de si usar ConfigureAwait(false)el árbol de llamadas habría resuelto el problema del OP.
chue x

3
@Keith: Hacer una acción MVC asyncno afecta en absoluto al lado del cliente. Explico esto en otra publicación de blog, asyncNo cambia el protocolo HTTP .
Stephen Cleary

1
@Keith: Es normal asyncque "crezca" a través del código base. Si su método de controlador puede depender de operaciones asincrónicas, entonces el método de la clase base debería regresar Task<ActionResult>. La transición de un proyecto grande a asyncsiempre es incómoda porque mezclar asyncy sincronizar código es difícil y complicado. El asynccódigo puro es mucho más simple.
Stephen Cleary

12

Estaba en la misma situación de punto muerto, pero en mi caso llamando a un método asíncrono desde un método de sincronización, lo que me funciona fue:

private static SiteMetadataCacheItem GetCachedItem()
{
      TenantService TS = new TenantService(); // my service datacontext
      var CachedItem = Task.Run(async ()=> 
               await TS.GetTenantDataAsync(TenantIdValue)
      ).Result; // dont deadlock anymore
}

¿Es este un buen enfoque, alguna idea?


Esta solución también funciona para mí, pero no estoy seguro de si es una buena solución o puede romperse en algún lugar. Cualquiera puede explicar eso
Konstantin Vdovkin

bueno, finalmente me fui con esta solución y está funcionando en un entorno productivo sin problemas .....
Danilow

1
Creo que está sufriendo un impacto en el rendimiento con Task.Run. En mi prueba, Task.Run casi duplica el tiempo de ejecución para una solicitud http de 100 ms.
Timothy Gonzalez

1
tiene sentido, está creando una nueva tarea para cerrar una llamada asíncrona, el rendimiento es la compensación
Danilow

Fantástico, esto también funcionó para mí, mi caso también fue causado por un método sincrónico que llamaba a uno asincrónico. ¡Gracias!
Leonardo Spina

4

Solo para agregar a la respuesta aceptada (no hay suficiente representante para comentar), tuve este problema al bloquear el uso task.Result, aunque todos los eventos que aparecen a awaitcontinuación ConfigureAwait(false), como en este ejemplo:

public Foo GetFooSynchronous()
{
    var foo = new Foo();
    foo.Info = GetInfoAsync.Result;  // often deadlocks in ASP.NET
    return foo;
}

private async Task<string> GetInfoAsync()
{ 
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

El problema en realidad reside en el código de la biblioteca externa. El método de la biblioteca asincrónica intentó continuar en el contexto de sincronización de llamada, sin importar cómo configuré la espera, lo que llevó a un punto muerto.

Por lo tanto, la respuesta fue lanzar mi propia versión del código de la biblioteca externa ExternalLibraryStringAsync, para que tuviera las propiedades de continuación deseadas.


respuesta incorrecta para propósitos históricos

Después de mucho dolor y angustia, encontré la solución enterrada en esta publicación de blog (Ctrl-f para 'punto muerto'). Gira en torno al uso task.ContinueWith, en lugar de al desnudo task.Result.

Ejemplo de interbloqueo anterior:

public Foo GetFooSynchronous()
{
    var foo = new Foo();
    foo.Info = GetInfoAsync.Result;  // often deadlocks in ASP.NET
    return foo;
}

private async Task<string> GetInfoAsync()
{ 
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

Evite el punto muerto como este:

public Foo GetFooSynchronous
{
    var foo = new Foo();
    GetInfoAsync()  // ContinueWith doesn't run until the task is complete
        .ContinueWith(task => foo.Info = task.Result);
    return foo;
}

private async Task<string> GetInfoAsync
{
    return await ExternalLibraryStringAsync().ConfigureAwait(false);
}

¿Para qué es el voto negativo? Esta solución me está funcionando.
Cameron Jeffers

Está devolviendo el objeto antes de que Taskse haya completado y no le está proporcionando a la persona que llama ningún medio para determinar cuándo ocurre realmente la mutación del objeto devuelto.
Servicio

hmm sí, ya veo. Entonces, ¿debería exponer algún tipo de método de "esperar hasta que la tarea se complete" que utilice un bucle while de bloqueo manual (o algo así)? ¿O incluir tal bloque en el GetFooSynchronousmétodo?
Cameron Jeffers

1
Si lo hace, se bloqueará. Debe realizar una asincronización completa devolviendo unTask lugar de bloquear.
Servicio

Desafortunadamente, esa no es una opción, la clase implementa una interfaz síncrona que no puedo cambiar.
Cameron Jeffers

0

respuesta rápida: cambie esta línea

ResultClass slowTotal = asyncTask.Result;

a

ResultClass slowTotal = await asyncTask;

¿por qué? no debe usar .result para obtener el resultado de las tareas dentro de la mayoría de las aplicaciones, excepto las aplicaciones de consola, si lo hace, su programa se bloqueará cuando llegue allí

también puede probar el siguiente código si desea utilizar .Result

ResultClass slowTotal = Task.Run(async ()=>await asyncTask).Result;
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.