Предотвращение Lazy <T>исключений кэширования при вызове делегата Async - PullRequest
4 голосов
/ 01 июня 2019

Мне нужен простой AsyncLazy<T>, который ведет себя точно так же, как Lazy<T>, но правильно поддерживает обработку исключений и избегает их кэширования.

В частности, проблема, с которой я сталкиваюсь, такова:

Я могу написать кусок кода как таковой:

public class TestClass
{
    private int i = 0;

    public TestClass()
    {
        this.LazyProperty = new Lazy<string>(() =>
        {
            if (i == 0)
                throw new Exception("My exception");

            return "Hello World";

        }, LazyThreadSafetyMode.PublicationOnly);
    }

    public void DoSomething()
    {
        try
        {
            var res = this.LazyProperty.Value;
            Console.WriteLine(res);
            //Never gets here
        }
        catch { }
        i++;       
        try
        {
            var res1 = this.LazyProperty.Value;
            Console.WriteLine(res1);
            //Hello World
        }
        catch { }

    }

    public Lazy<string> LazyProperty { get; }

}

Обратите внимание на использование LazyThreadSafetyMode.PublicationOnly .

Если метод инициализации вызывает исключение в каком-либо потоке, исключение распространяется из свойства Value в этом потоке. исключение не кэшируется.

Затем я вызываю его следующим образом.

TestClass _testClass = new TestClass();
_testClass.DoSomething();

и он работает точно так, как вы ожидаете, когда первый результат опущен, потому что возникает исключение, результат остается не кэшированным, а последующая попытка прочитать значение завершается успешно, возвращая «Hello World».

К сожалению, однако, если я изменю свой код на что-то вроде этого:

public Lazy<Task<string>> AsyncLazyProperty { get; } = new Lazy<Task<string>>(async () =>
{
    if (i == 0)
        throw new Exception("My exception");

    return await Task.FromResult("Hello World");
}, LazyThreadSafetyMode.PublicationOnly);

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

Это в некоторой степени имеет смысл, потому что я подозреваю, что исключение никогда не всплывает за пределы задачи, однако я не могу определить способ уведомления Lazy<T>, что инициализация задачи / объекта завершилась неудачно и не должна кэшироваться.

Кто-нибудь может предоставить любой ввод?

EDIT:

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

Так что, если я изменю свою подпись собственности на что-то вроде этого (согласно предложению Ивана)

this.LazyProperty = new Lazy<Task<string>>(() =>
{
    if (i == 0)
        throw new NotImplementedException();

    return DoLazyAsync();
}, LazyThreadSafetyMode.PublicationOnly);

и затем вызывайте его следующим образом.

await this.LazyProperty.Value;

код работает.

Однако, если у вас есть такой метод

this.LazyProperty = new Lazy<Task<string>>(() =>
{
    return ExecuteAuthenticationAsync();
}, LazyThreadSafetyMode.PublicationOnly);

, который затем сам вызывает другой метод Async.

private static async Task<AccessTokenModel> ExecuteAuthenticationAsync()
{
    var response = await AuthExtensions.AuthenticateAsync();
    if (!response.Success)
        throw new Exception($"Could not authenticate {response.Error}");

    return response.Token;
}

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

Вот полный пример для воспроизведения проблемы:

this.AccessToken = new Lazy<Task<string>>(() =>
{
    return OuterFunctionAsync(counter);
}, LazyThreadSafetyMode.PublicationOnly);

public Lazy<Task<string>> AccessToken { get; private set; }

private static async Task<bool> InnerFunctionAsync(int counter)
{
    await Task.Delay(1000);
    if (counter == 0)
        throw new InvalidOperationException();
    return false;
}

private static async Task<string> OuterFunctionAsync(int counter)
{
    bool res = await InnerFunctionAsync(counter);
    await Task.Delay(1000);
    return "12345";
}

try
{
    var r = await this.AccessToken.Value;
}
catch (Exception ex) { }

counter++;

try
{
    //Retry is never performed, cached task returned.
    var r1 = await this.AccessToken.Value;

}
catch (Exception ex) { }

Ответы [ 2 ]

4 голосов
/ 01 июня 2019

Проблема в том, как Lazy<T> определяет «не удалось», что мешает тому, как Task<T> определяет «не удалось».

Чтобы инициализация Lazy<T> завершилась неудачно, она должна вызвать исключение. Это вполне естественно и приемлемо, хотя и неявно синхронно.

Для Task<T>, чтобы "потерпеть неудачу", исключения захвачены и помещены в задачу. Это нормальный шаблон для асинхронного кода.

Объединение двух причин вызывает проблемы. Часть Lazy<T> в Lazy<Task<T>> будет «терпеть неудачу» только в том случае, если исключения возбуждаются напрямую, а шаблон async в Task<T> не распространяет исключения напрямую. Таким образом, фабричные методы async всегда будут (синхронно) «успешными», так как они возвращают Task<T>. На данный момент часть Lazy<T> фактически завершена; генерируется его значение (даже если Task<T> еще не завершено).

Вы можете создать свой собственный тип AsyncLazy<T> без особых проблем. Вам не нужно принимать зависимость от AsyncEx только для этого одного типа:

public sealed class AsyncLazy<T>
{
  private readonly object _mutex;
  private readonly Func<Task<T>> _factory;
  private Lazy<Task<T>> _instance;

  public AsyncLazy(Func<Task<T>> factory)
  {
    _mutex = new object();
    _factory = RetryOnFailure(factory);
    _instance = new Lazy<Task<T>>(_factory);
  }

  private Func<Task<T>> RetryOnFailure(Func<Task<T>> factory)
  {
    return async () =>
    {
      try
      {
        return await factory().ConfigureAwait(false);
      }
      catch
      {
        lock (_mutex)
        {
          _instance = new Lazy<Task<T>>(_factory);
        }
        throw;
      }
    };
  }

  public Task<T> Task
  {
    get
    {
      lock (_mutex)
        return _instance.Value;
    }
  }

  public TaskAwaiter<T> GetAwaiter()
  {
    return Task.GetAwaiter();
  }

  public ConfiguredTaskAwaitable<T> ConfigureAwait(bool continueOnCapturedContext)
  {
    return Task.ConfigureAwait(continueOnCapturedContext);
  }
}
4 голосов
/ 01 июня 2019

Чтобы помочь вам понять, что происходит здесь, есть простая программа:

static void Main()
{
    var numberTask = GetNumberAsync( 0 );

    Console.WriteLine( numberTask.Status );
    Console.ReadLine();
}


private static async Task<Int32> GetNumberAsync( Int32 number )
{
    if ( number == 0 )
        throw new NotSupportedException();

    await Task.Delay( 1000 );

    return number;
}

Попробуйте, и вы увидите, что вывод программы будет Faulted. Метод всегда возвращает результат, который представляет собой Task, который захватил исключение.

Почему происходит захват? Это происходит из-за async модификатора метода. Под прикрытием фактического выполнения метода используется AsyncMethodBuilder, который фиксирует исключение и устанавливает его в результате выполнения задачи.

Как мы можем это изменить?

private static Task<Int32> GetNumberAsync( Int32 number )
{
    if ( number == 0 )
        throw new NotSupportedException();

    return GetNumberReallyAsync();

    async Task<Int32> GetNumberReallyAsync()
    {
        await Task.Delay( 1000 );

        return number;
    }
}

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

Итак, чтобы ваш пример работал так, как вы хотите, вам нужно удалить async и ждать:

public Lazy<Task<string>> AsyncLazyProperty { get; } = new Lazy<Task<string>>(() =>
{
    if (i == 0)
        throw new Exception("My exception");

    return Task.FromResult("Hello World");
}, LazyThreadSafetyMode.PublicationOnly);
...