Как я могу диагностировать асинхронные / ожидающие тупики?

24

Я работаю с новой кодовой базой, которая интенсивно использует async / await. Большинство людей в моей команде также довольно плохо знакомы с async / await. Как правило, мы склонны придерживаться рекомендаций Best Practices, указанных Microsoft , но обычно нам нужен наш контекст для прохождения асинхронного вызова и работа с библиотеками, которые этого не делают ConfigureAwait(false).

Объедините все эти вещи, и мы столкнемся с асинхронными тупиками, описанными в статье ... еженедельно. Они не отображаются во время модульного тестирования, потому что наших поддельных источников данных (обычно через Task.FromResult) недостаточно, чтобы вызвать тупик. Таким образом, во время выполнения или интеграционных тестов, какой-то сервисный вызов просто уходит на обед и никогда не возвращается. Это убивает серверы и, как правило, создает беспорядок.

Проблема заключается в том, что отслеживание того, где была допущена ошибка (обычно просто не асинхронная до конца), обычно включает ручную проверку кода, которая занимает много времени и не может быть автоматизирована.

Как лучше диагностировать причину тупика?

Telastyn
источник
1
Хороший вопрос; Я задавался вопросом это сам. Вы читали сборник asyncстатей этого парня ?
Роберт Харви,
@RobertHarvey - возможно, не все, но я читал некоторые из них. Подробнее «Обязательно делайте эти две / три вещи везде, иначе ваш код умрет ужасной смертью во время выполнения».
Теластин
Готовы ли вы отказаться от асинхронного или сократить его использование до наиболее выгодных точек? Асинхронный ввод-вывод - это не все или ничего.
USR
1
Если вы можете воспроизвести тупик, разве вы не можете просто посмотреть трассировку стека, чтобы увидеть блокирующий вызов?
svick
2
Если проблема «не полностью асинхронна», то это означает, что половина взаимоблокировки является традиционной взаимоблокировкой и должна быть видна в трассировке стека потока контекста синхронизации.
свик

Ответы:

4

Хорошо, я не уверен, что следующее поможет вам, потому что я сделал некоторые предположения при разработке решения, которое может быть верным или нет в вашем случае. Возможно, мое «решение» слишком теоретическое и работает только для искусственных примеров - я не проводил никаких тестов, кроме приведенных ниже.
Кроме того, я бы увидел следующее скорее обходное решение, чем реальное решение, но, учитывая отсутствие ответов, я думаю, что оно все еще может быть лучше, чем ничего (я продолжал наблюдать за вашим вопросом, ожидая решения, но не видя сообщения, опубликованного, я начал играть вокруг с вопросом).

Но достаточно сказано: допустим, у нас есть простой сервис данных, который можно использовать для получения целого числа:

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

Простая реализация использует асинхронный код:

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;
    }
}

Теперь возникает проблема, если мы используем код «неправильно», как показано в этом классе. Fooневерный доступ Task.Resultвместо awaitрезультата, как Bar:

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;
    }
}

Нам (вам) сейчас нужен способ написать тест, который успешно Barвыполняется при вызове, но не при вызове Foo(по крайней мере, если я правильно понял вопрос ;-)).

Я позволю коду говорить; вот что я придумал (используя тесты Visual Studio, но он должен работать и с использованием NUnit):

DataServiceMockиспользует TaskCompletionSource<T>. Это позволяет нам установить результат в определенной точке в тестовом прогоне, что приводит к следующему тесту. Обратите внимание, что мы используем делегата, чтобы вернуть TaskCompletionSource обратно в тест. Вы также можете поместить это в метод Initialize теста и использовать свойства.

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;

Здесь происходит то, что мы сначала проверяем, что мы можем оставить метод без блокировки (это не будет работать, если кто-то получит доступ Task.Result- в этом случае мы столкнемся с таймаутом, поскольку результат задачи не будет доступен до тех пор, пока метод не будет возвращен ).
Затем мы устанавливаем результат (теперь метод может выполняться) и проверяем результат (внутри модульного теста мы можем получить доступ к Task.Result, поскольку мы фактически хотим, чтобы блокировка происходила).

Завершить тестовый класс - BarTestуспешно и FooTestнеудачно по желанию.

[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;
    }
}

И небольшой вспомогательный класс для проверки на наличие тупиков / тайм-аутов:

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);
    }
}
Матиас
источник
Хороший ответ. Я планирую попробовать ваш код сам, когда у меня есть время (я не знаю наверняка, работает ли он или нет), но слава и одобрение за усилия.
Роберт Харви
-2

Вот стратегия, которую я использовал в огромном и очень, очень многопоточном приложении:

Во-первых, вам нужна некоторая структура данных вокруг мьютекса (к сожалению) и не делать каталог синхронизирующих вызовов. В этой структуре данных есть ссылка на любой ранее заблокированный мьютекс. Каждый мьютекс имеет «уровень», начинающийся с 0, который вы назначаете при создании мьютекса и никогда не можете его изменить.

И правило таково: если мьютекс заблокирован, вы должны когда-либо блокировать другие мьютексы только на более низком уровне. Если вы следуете этому правилу, у вас не может быть тупиков. Когда вы обнаружите нарушение, ваше приложение все еще работает и работает нормально.

Когда вы обнаружите нарушение, у вас есть две возможности: вы могли ошибочно назначить уровни. Вы заблокировали A, а затем заблокировали B, поэтому B должен был иметь более низкий уровень. Таким образом, вы устанавливаете уровень и попробуйте снова.

Другая возможность: вы не можете это исправить. Некоторый ваш код блокирует A с последующей блокировкой B, в то время как другой код блокирует B с последующей блокировкой A. Нет способа назначить уровни, позволяющие это сделать. И, конечно, это потенциальная тупиковая ситуация: если оба кода работают одновременно в разных потоках, существует вероятность тупиковой ситуации.

После введения этого была довольно короткая фаза, где уровни должны были быть отрегулированы, сопровождаемая более длинной фазой, где были обнаружены потенциальные тупики.

gnasher729
источник
4
Извините, как это относится к асинхронному / ожидающему поведению? Я не могу реально внедрить пользовательскую структуру управления мьютексом в библиотеку параллельных задач.
Теластин
-3

Используете ли вы Async / Await, чтобы вы могли распараллеливать дорогостоящие вызовы, как в базе данных? В зависимости от пути выполнения в БД это может быть невозможно.

Тестовое покрытие с помощью async / await может быть сложным, и нет ничего лучше, чем реальное использование продукта для поиска ошибок Один шаблон, который вы можете рассмотреть, это передача идентификатора корреляции и запись его в стек, а затем каскадный таймаут, который регистрирует ошибку. Это скорее шаблон SOA, но, по крайней мере, он даст вам представление о том, откуда он берется. Мы использовали это со Splunk, чтобы найти тупики.

Роберт-Райан.
источник