¿Cómo puedo diagnosticar puntos muertos asíncronos / en espera?


24

Estoy trabajando con una nueva base de código que hace un uso intensivo de async / wait. La mayoría de las personas en mi equipo también son bastante nuevas en async / wait. En general, tendemos a mantener las mejores prácticas según lo especificado por Microsoft , pero generalmente necesitamos que nuestro contexto fluya a través de la llamada asincrónica y estamos trabajando con bibliotecas que no lo hacen ConfigureAwait(false).

Combina todas esas cosas y nos toparemos con bloqueos asincrónicos descritos en el artículo ... semanalmente. No aparecen durante las pruebas unitarias, porque nuestras fuentes de datos simuladas (generalmente a través de Task.FromResult) no son suficientes para desencadenar el punto muerto. Por lo tanto, durante el tiempo de ejecución o las pruebas de integración, algunas llamadas de servicio simplemente salen a almorzar y nunca regresan. Eso mata a los servidores y, en general, hace un desastre.

El problema es que rastrear dónde se cometió el error (por lo general, simplemente no ser asíncrono hasta el final) generalmente implica la inspección manual del código, que lleva mucho tiempo y no es automatizable.

¿Cuál es una mejor manera de diagnosticar qué causó el punto muerto?


1
Buena pregunta; Me lo he preguntado yo mismo. ¿Has leído la colección de asyncartículos de este chico ?
Robert Harvey

@RobertHarvey: tal vez no todos, pero he leído algunos. Más "Asegúrate de hacer estas dos / tres cosas en todas partes o de lo contrario tu código morirá una muerte horrible en tiempo de ejecución".
Telastyn

¿Estás abierto a dejar asincrónico o reducir su uso a los puntos más beneficiosos? Async IO no es todo o nada.
usr

1
Si puede reproducir el punto muerto, ¿no puede simplemente mirar el seguimiento de la pila para ver la llamada de bloqueo?
svick

2
Si el problema "no es asíncrono por completo", eso significa que la mitad del punto muerto es un punto muerto tradicional y debe ser visible en el seguimiento de la pila del hilo de contexto de sincronización.
svick

Respuestas:


4

Ok, no estoy seguro si lo siguiente será de alguna ayuda para usted, porque hice algunas suposiciones en el desarrollo de una solución que puede o no ser cierta en su caso. Tal vez mi "solución" es demasiado teórica y solo funciona para ejemplos artificiales: no he hecho ninguna prueba más allá de las siguientes.
Además, vería lo siguiente más una solución alternativa que una solución real, pero teniendo en cuenta la falta de respuestas, creo que aún podría ser mejor que nada (seguí mirando su pregunta esperando una solución, pero al no ver una publicación, comencé a jugar alrededor con el problema).

Pero suficiente dijo: Digamos que tenemos un servicio de datos simple que se puede utilizar para recuperar un número entero:

public interface IDataService
{
    Task<int> LoadMagicInteger();
}

Una implementación simple usa código asincrónico:

public sealed class CustomDataService
    : IDataService
{
    public async Task<int> LoadMagicInteger()
    {
        Console.WriteLine("LoadMagicInteger - 1");
        await Task.Delay(100);
        Console.WriteLine("LoadMagicInteger - 2");
        var result = 42;
        Console.WriteLine("LoadMagicInteger - 3");
        await Task.Delay(100);
        Console.WriteLine("LoadMagicInteger - 4");
        return result;
    }
}

Ahora, surge un problema, si estamos usando el código "incorrectamente" como se ilustra en esta clase. Fooaccede incorrectamente en Task.Resultlugar de obtener awaitel resultado como lo Barhace:

public sealed class ClassToTest
{
    private readonly IDataService _dataService;

    public ClassToTest(IDataService dataService)
    {
        this._dataService = dataService;
    }

    public async Task<int> Foo()
    {
        var result = this._dataService.LoadMagicInteger().Result;
        return result;
    }
    public async Task<int> Bar()
    {
        var result = await this._dataService.LoadMagicInteger();
        return result;
    }
}

Lo que necesitamos (usted) ahora es una forma de escribir una prueba que tenga éxito al llamar Barpero falla al llamar Foo(al menos si entendí la pregunta correctamente ;-)).

Dejaré hablar el código; esto es lo que se me ocurrió (usando las pruebas de Visual Studio, pero también debería funcionar usando NUnit):

DataServiceMockutiliza TaskCompletionSource<T>. Esto nos permite establecer el resultado en un punto definido en la ejecución de la prueba que conduce a la siguiente prueba. Tenga en cuenta que estamos utilizando un delegado para devolver el TaskCompletionSource a la prueba. También puede poner esto en el método Initialize de la prueba y usar las propiedades.

TaskCompletionSource<int> tcs = null;
this._dataService.LoadMagicIntegerMock = t => tcs = t;

Task<int> task = null;
TaskTestHelper.AssertDoesNotBlock(() => task = this._instance.Foo());

tcs.TrySetResult(42);

var result = task.Result;
Assert.AreEqual(42, result);

this._end = true;

Lo que sucede aquí es que primero verificamos que podemos dejar el método sin bloquear (esto no funcionaría si alguien accediera Task.Result; en este caso, tendríamos un tiempo de espera ya que el resultado de la tarea no está disponible hasta después de que el método haya regresado )
Luego, establecemos el resultado (ahora el método puede ejecutarse) y verificamos el resultado (dentro de una prueba unitaria podemos acceder a Task.Result ya que realmente queremos que ocurra el bloqueo).

Clase de prueba completa: BarTesttiene éxito y FooTestfalla según lo deseado.

[TestClass]
public class UnitTest1
{
    private DataServiceMock _dataService;
    private ClassToTest _instance;
    private bool _end;

    [TestInitialize]
    public void Initialize()
    {
        this._dataService = new DataServiceMock();
        this._instance = new ClassToTest(this._dataService);

        this._end = false;
    }
    [TestCleanup]
    public void Cleanup()
    {
        Assert.IsTrue(this._end);
    }

    [TestMethod]
    public void FooTest()
    {
        TaskCompletionSource<int> tcs = null;
        this._dataService.LoadMagicIntegerMock = t => tcs = t;

        Task<int> task = null;
        TaskTestHelper.AssertDoesNotBlock(() => task = this._instance.Foo());

        tcs.TrySetResult(42);

        var result = task.Result;
        Assert.AreEqual(42, result);

        this._end = true;
    }
    [TestMethod]
    public void BarTest()
    {
        TaskCompletionSource<int> tcs = null;
        this._dataService.LoadMagicIntegerMock = t => tcs = t;

        Task<int> task = null;
        TaskTestHelper.AssertDoesNotBlock(() => task = this._instance.Bar());

        tcs.TrySetResult(42);

        var result = task.Result;
        Assert.AreEqual(42, result);

        this._end = true;
    }
}

Y una pequeña clase de ayuda para probar los puntos muertos / tiempos de espera:

public static class TaskTestHelper
{
    public static void AssertDoesNotBlock(Action action, int timeout = 1000)
    {
        var timeoutTask = Task.Delay(timeout);
        var task = Task.Factory.StartNew(action);

        Task.WaitAny(timeoutTask, task);

        Assert.IsTrue(task.IsCompleted);
    }
}

Buena respuesta. Estoy planeando probar su código yo mismo cuando tenga algo de tiempo (en realidad no sé con certeza si funciona o no), pero felicitaciones y un voto positivo por el esfuerzo.
Robert Harvey

-2

Aquí hay una estrategia que utilicé en una aplicación enorme y muy, muy multiproceso:

Primero, necesita cierta estructura de datos alrededor de un mutex (desafortunadamente) y no hacer un directorio de llamadas de sincronización. En esa estructura de datos, hay un enlace a cualquier mutex previamente bloqueado. Cada mutex tiene un "nivel" que comienza en 0, que se asigna cuando se crea el mutex y nunca puede cambiar.

Y la regla es: si un mutex está bloqueado, solo debe bloquear otros mutex en un nivel inferior. Si sigues esa regla, entonces no puedes tener puntos muertos. Cuando encuentra una infracción, su aplicación aún está funcionando correctamente.

Cuando encuentra una violación, hay dos posibilidades: puede haber asignado los niveles incorrectamente. Bloqueaste A seguido de B, por lo que B debería haber tenido un nivel más bajo. Entonces arreglas el nivel e intentas de nuevo.

La otra posibilidad: no puedes arreglarlo. Algunos códigos tuyos bloquean A seguido de bloquear B, mientras que otros códigos bloquean B seguido de bloquear A. No hay forma de asignar los niveles para permitir esto. Y, por supuesto, este es un punto muerto potencial: si ambos códigos se ejecutan simultáneamente en subprocesos diferentes, existe la posibilidad de un punto muerto.

Después de introducir esto, hubo una fase bastante corta en la que los niveles tuvieron que ser ajustados, seguida de una fase más larga donde se encontraron posibles puntos muertos.


44
Lo siento, ¿cómo se aplica eso al comportamiento asíncrono / espera? Realmente no puedo inyectar una estructura de administración de mutex personalizada en la Biblioteca de tareas paralelas.
Telastyn

-3

¿Está utilizando Async / Await para poder paralelizar llamadas costosas como a una base de datos? Dependiendo de la ruta de ejecución en la base de datos, esto podría no ser posible.

La cobertura de prueba con async / await puede ser un desafío y no hay nada como el uso real de producción para encontrar errores. Un patrón que puede considerar es pasar un ID de correlación y registrarlo en la pila, luego tiene un tiempo de espera en cascada que registra el error. Este es más un patrón SOA, pero al menos le daría una idea de dónde viene. Usamos esto con Splunk para encontrar puntos muertos.

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.