Тупик при доступе к StackExchange.Redis

Я столкнулся с ситуацией тупика при вызове StackExchange.Redis.

Я не знаю точно, что происходит, что очень расстраивает, и я был бы признателен за любые материалы, которые могут помочь решить проблему или решить эту проблему.


Если у вас тоже есть эта проблема и вы не хотите читать все это;Я предлагаю вам попробовать установить PreserveAsyncOrder на false.

ConnectionMultiplexer connection = ...;
connection.PreserveAsyncOrder = false;

Выполнение этого, вероятно, решит ту тупиковую ситуацию, в которой этот Q & A, и может также повысить производительность.


Наша настройка

  • Код запускается как консольное приложение или роль Azure Worker.
  • Он предоставляет REST api, используя HttpMessageHandler, чтобы точка входа была асинхронной.
  • Некоторые части кода имеют сходство потоков (принадлежат и должны управляться одним потоком).
  • Некоторые части кода асинхронны.
  • Мы выполняем sync-over-async и async-over -sync анти-шаблоны. (смешивание await и Wait()/Result).
  • Мы используем методы async при доступе к Redis.
  • Мы используем StackExchange.Redis 1.0.450 для .NET 4.5.

Тупик

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

Интересно, что после возникновения тупика любой вызов Redis будет зависать, но только если эти вызовы сделаны из входящего запроса API, которые запускаются в пуле потоков.

Мы также вызываем Redis из фоновых потоков с низким приоритетом, и эти вызовы продолжают функционировать даже после возникновения взаимоблокировки.

Кажется, что тупик произойдет только при вызове Redis в потоке пула потоков. Я больше не думаю, что это связано с тем, что эти вызовы выполняются в потоке пула потоков. Скорее, кажется, что любой асинхронный Redis вызывает без продолжения или с сохранением синхронизации, будет продолжать работать даже после возникновения ситуации взаимоблокировки. (Смотрите, что я думаю, происходит ниже)

Связанные

  • StackExchange.Redis Deadlocking

    Тупик вызван смешением await и Task.Result (sync-over-async, как и мы). Но наш код запускается без контекста синхронизации, поэтому здесь не применяется, правильно?

  • Как безопасно смешивать синхронизацию и асинхронный код?

    Да, мы не должны этого делать. Но мы это делаем, и нам придется продолжать делать это некоторое время. Множество кода, который необходимо перенести в мир асинхронных сетей.

    Опять же, у нас нет контекста синхронизации, поэтому это не должно вызывать взаимоблокировки, правильно?

    Настройка ConfigureAwait(false) до того, как любой await не повлияет на это.

  • Исключение ожидания после асинхронных команд и Task.WhenAny ждет в StackExchange.Redis

    Это проблема захвата потока. Какова нынешняя ситуация на этом? Это может быть проблема здесь?

  • Анективный вызов StackExchange.Redis висит

    Из ответа Marc:

    ... смешивание Ожидание и ожидание - не очень хорошая идея. В дополнение к взаимоблокировкам это "синхронизация через async" - анти-шаблон.

    Но он также говорит:

    SE.Redis обходит синхронный контекст внутри (нормальный для кода библиотеки), поэтому он не должен иметь тупик

    Итак, из моего понимания StackExchange.Redis должно быть агностично, используем ли мы анти-шаблон sync-over-async. Это просто не рекомендуется, поскольку это может быть причиной взаимоблокировок в другом коде.

    В этом случае, однако, насколько я могу судить, тупик действительно находится внутри StackExchange.Redis. Пожалуйста, поправьте меня, если я ошибаюсь.

Результаты отладки

Я обнаружил, что тупик, похоже, имеет источник в ProcessAsyncCompletionQueue на строке 124 CompletionManager.cs.

Фрагмент этого кода:

while (Interlocked.CompareExchange(ref activeAsyncWorkerThread, currentThread, 0) != 0)
{
    // if we don't win the lock, check whether there is still work; if there is we
    // need to retry to prevent a nasty race condition
    lock(asyncCompletionQueue)
    {
        if (asyncCompletionQueue.Count == 0) return; // another thread drained it; can exit
    }
    Thread.Sleep(1);
}

Я обнаружил, что во время тупика; activeAsyncWorkerThread является одним из наших потоков, который ждет завершения вызова Redis. (наш поток = поток пула потоков, управляющий нашим кодом). Таким образом, цикл выше считается продолженным навсегда.

Не зная подробностей, это наверняка не так; StackExchange.Redis ожидает поток, который, по его мнению, является активным асинхронным рабочим потоком, в то время как он фактически является потоком, который совершенно противоположный этому.

Интересно, связано ли это с проблемой захвата нити (чего я не совсем понимаю)?

Что делать?

Главный вопрос, который я пытаюсь выяснить:

  • Может ли смешивание await и Wait()/Result быть причиной взаимоблокировок даже при работе без контекста синхронизации?

  • Мы сталкиваемся с ошибкой/ограничением в StackExchange.Redis?

Возможное исправление?

Из моих отладочных данных кажется, что проблема заключается в том, что:

next.TryComplete(true);

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

Не зная деталей и просто размышляя об этом "факте", тогда было бы логично временно освободить активный рабочий поток асинхронного потока во время вызова TryComplete.

Я думаю, что что-то вроде этого может работать:

// release the "active thread lock" while invoking the completion action
Interlocked.CompareExchange(ref activeAsyncWorkerThread, 0, currentThread);

try
{
    next.TryComplete(true);
    Interlocked.Increment(ref completedAsync);
}
finally
{
    // try to re-take the "active thread lock" again
    if (Interlocked.CompareExchange(ref activeAsyncWorkerThread, currentThread, 0) != 0)
    {
        break; // someone else took over
    }
}

Думаю, моя лучшая надежда состоит в том, что Марк Гравелл прочитает это и предоставит некоторую обратную связь: -)

Отсутствует контекст синхронизации = контекст синхронизации по умолчанию

Я написал выше, что наш код не использует контекст синхронизации . Это только отчасти верно: код запускается как консольное приложение или роль Azure Worker. В этих средах SynchronizationContext.Current - null, поэтому я написал, что мы работаем без контекста синхронизации.

Однако после чтения It All About SynchronizationContext Я узнал, что на самом деле это не так:

По соглашению, если текущий поток SynchronizationContext имеет значение NULL, то он неявно имеет стандартный SynchronizationContext.

Контекст синхронизации по умолчанию не должен быть причиной взаимоблокировок, поскольку контекст синхронизации на основе UI (WinForms, WPF) может - потому что он не подразумевает сближение потоков.

То, что я думаю, происходит

Когда сообщение завершено, его источник завершения проверяется на предмет того, считается ли он безопасным. Если это так, действие завершения выполняется в строке, и все в порядке.

Если это не так, идея состоит в том, чтобы выполнить действие завершения для недавно выделенного потока пула потоков. Это тоже прекрасно работает, если ConnectionMultiplexer.PreserveAsyncOrder false.

Однако, когда ConnectionMultiplexer.PreserveAsyncOrder является true (значение по умолчанию), то эти потоки пула потоков будут сериализовывать свою работу с использованием очереди завершения и гарантируя, что максимум один из них является активным рабочим потоком асинхронного пользователя в любое время.

Когда поток становится активным асинхронным рабочим потоком, он будет продолжаться до тех пор, пока он не исчерпал очередь завершения.

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

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

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

Итак, может быть, сделать вывод, что небезопасно смешивать await с Result/Wait(), когда PreserveAsyncOrder есть true, независимо от того, являемся ли мы работает без контекста синхронизации?

(По крайней мере, пока мы не сможем использовать .NET 4.6 и новый TaskCreationOptions.RunContinuationsAsynchronously, предположим)

Ответ 1

Это обходные пути, которые я нашел для этой проблемы взаимоблокировки:

Обходной путь №1

По умолчанию StackExchange.Redis гарантирует, что команды будут завершены в том же порядке, в котором получаются сообщения с результатами. Это может вызвать тупик, как описано в этом вопросе.

Отключите это поведение, установив PreserveAsyncOrder в false.

ConnectionMultiplexer connection = ...;
connection.PreserveAsyncOrder = false;

Это позволит избежать взаимоблокировок, а также повысить производительность.

Я призываю всех, кто сталкивается с проблемами взаимоблокировки, попробовать это обходное решение, так как он настолько чист и прост.

Вы потеряете гарантию того, что продолжения асинхронного вызова будут вызваны в том же порядке, в котором выполняются базовые операции Redis. Тем не менее, я действительно не понимаю, почему это то, на что вы бы положились.


Обходной путь №2

Тупик возникает, когда активный рабочий поток async в StackExchange.Redis завершает команду и когда задача завершения выполняется в строке.

Можно предотвратить выполнение задачи встроенной с помощью пользовательского TaskScheduler и убедиться, что TryExecuteTaskInline возвращает false.

public class MyScheduler : TaskScheduler
{
    public override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
        return false; // Never allow inlining.
    }

    // TODO: Rest of TaskScheduler implementation goes here...
}

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

Если ваш планировщик задач будет использовать свои собственные потоки (не из пула потоков), тогда может быть хорошей идеей разрешить встраивание, если текущий поток не из пула потоков. Это будет работать, потому что активный рабочий поток async в StackExchange.Redis всегда является потоком пула потоков.

public override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
    // Don't allow inlining on a thread pool thread.
    return !Thread.CurrentThread.IsThreadPoolThread && this.TryExecuteTask(task);
}

Еще одна идея - прикрепить планировщик ко всем его потокам, используя потоковое локальное хранилище.

private static ThreadLocal<TaskScheduler> __attachedScheduler 
                   = new ThreadLocal<TaskScheduler>();

Убедитесь, что это поле назначено, когда поток запускается и очищается по завершении:

private void ThreadProc()
{
    // Attach scheduler to thread
    __attachedScheduler.Value = this;

    try
    {
        // TODO: Actual thread proc goes here...
    }
    finally
    {
        // Detach scheduler from thread
        __attachedScheduler.Value = null;
    }
}

Затем вы можете разрешить вложение задач до тех пор, пока они выполняются в потоке, который "принадлежит" настраиваемому планировщику:

public override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
    // Allow inlining on our own threads.
    return __attachedScheduler.Value == this && this.TryExecuteTask(task);
}

Ответ 2

Я угадываю много, основываясь на подробной информации выше и не зная исходного кода, который у вас есть. Похоже, вы можете столкнуться с некоторыми внутренними и настраиваемыми ограничениями в .NET. Вы не должны нажимать на них, поэтому я предполагаю, что вы не располагаете объектами, так как они перемещаются между потоками, что не позволит вам использовать оператор using для чистоты их жизни.

Здесь указаны ограничения на HTTP-запросы. Подобно старой проблеме WCF, когда вы не выбрали соединение, а затем все подключения WCF завершились с ошибкой.

Максимальное количество одновременных HttpWebRequests

Это больше отладочная помощь, так как я сомневаюсь, что вы действительно используете все TCP-порты, но хорошо знаете, как найти, сколько открытых портов у вас есть и где.

https://msdn.microsoft.com/en-us/library/aa560610(v=bts.20).aspx