Книга: Конкурентность в C#. Асинхронное, параллельное и многопоточное программирование. 2-е межд. изд.
Назад: Глава 10. Отмена
Дальше: Глава 12. Синхронизация

Глава 11. ООП, хорошо сочетающееся с функциональным программированием

Современные программы требуют асинхронного программирования; в наши дни серверы должны масштабироваться лучше, чем когда-либо, а приложения для конечного пользователя должны реагировать на действия пользователя так быстро, как никогда. Разработчикам приходится изучать асинхронное программирование, и в реальной работе они часто обнаруживают, что этот стиль программирования часто вступает в конф­ликт с традиционным объектно-ориентированным программированием, к которому они привыкли.

Главная причина заключается в том, что асинхронное программирование является функциональным. Я не имею в виду, что «оно работает»; речь идет о функциональном стиле программирования, в отличие от процедурного стиля. Многие разработчики изучали основы функционального программирования в вузе и с тех пор практически не взаимодействовали с ним. Если от кода вида (car(cdr '(3 5 7))) вы поеживаетесь, то, возможно, вы принадлежите к этой категории. Не бойтесь! Современное асинхронное программирование не так уж сложно, стоит к нему привыкнуть.

Главное преимущество async заключается в том, что вы можете мыслить процедурными категориями, но программировать асинхронно. Это существенно упрощает написание и понимание асинхронных методов. Но во внутренней реализации асинхронный код остается функциональным по своей природе, и это создает некоторые проблемы, когда разработчики пытаются втиснуть async-методы в классические объектно-ориентированные архитектуры. В рецептах этой главы рассматриваются проблемные области, в которых асинхронный код конфликтует с объектно-ориентированным программированием.

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

11.1. Асинхронные интерфейсы и наследование

Задача

В интерфейсе или базовом классе метода имеется метод, который требуется сделать асинхронным.

Решение

Ключом к пониманию этой задачи и ее решения станет понимание того, что async относится к подробностям реализации. Ключевое слово async может применяться только к методам с реализациями; невозможно применить его к абстрактным методам или методам интерфейсов (если они не имеют реализаций по умолчанию). Тем не менее вы можете определить метод с такой же сигнатурой, как у async-метода, но без ключевого слова async.

Вспомните, что ожидание допускают типы, а не методы. Вы можете использовать await с объектом Task, возвращенным методом, независимо от того, был метод реализован с ключевым словом async или нет. Таким образом, интерфейс или абстрактный метод может просто вернуть Task (или Task<T>), а возвращаемое значение этого метода может допускать ожидание.

Следующий пример определяет интерфейс с асинхронным методом (без ключевого слова async), реализацию этого интерфейса (с async), и независимый метод, который потребляет метод этого интерфейса (посредством await):

interface IMyAsyncInterface

{

  Task<int> CountBytesAsync(HttpClient client, string url);

}

 

class MyAsyncClass : IMyAsyncInterface

{

  public async Task<int> CountBytesAsync(HttpClient client, string url)

  {

    var bytes = await client.GetByteArrayAsync(url);

    return bytes.Length;

  }

}

 

async Task UseMyInterfaceAsync(HttpClient client,

   IMyAsyncInterface service)

{

  var result = await service.CountBytesAsync(client,

     "");

  Trace.WriteLine(result);

}

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

Асинхронная сигнатура метода означает лишь то, что реализация может быть асинхронной. Фактическая реализация может быть синхронной, если нет реальной асинхронной работы, которую нужно было бы выполнять. Например, тестовая заглушка может реализовать тот же интерфейс (без async), используя нечто вроде FromResult:

class MyAsyncClassStub : IMyAsyncInterface

{

  public Task<int> CountBytesAsync(HttpClient client, string url)

  {

    return Task.FromResult(13);

  }

}

Пояснение

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

Дополнительная информация

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

11.2. Асинхронное конструирование: фабрики

Задача

Вы программируете тип, который требует выполнения некоторой асинхронной работы в конструкторе.

Решение

Конструкторы не могут объявляться с async; кроме того, они не могут содержать ключевое слово await. Конечно, использование await в конструкторе могло бы быть полезным, но это привело бы к существенному изменению языка C#.

Одна из возможностей — использовать конструктор в паре с инициализирующим async-методом, чтобы тип использовался следующим образом:

var instance = new MyAsyncClass();

await instance.InitializeAsync();

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

Вот более качественное решение, которое основано на применении паттерна асинхронного фабричного метода:

class MyAsyncClass

{

  private MyAsyncClass()

  {

  }

 

  private async Task<MyAsyncClass> InitializeAsync()

  {

    await Task.Delay(TimeSpan.FromSeconds(1));

    return this;

  }

 

  public static Task<MyAsyncClass> CreateAsync()

  {

    var result = new MyAsyncClass();

    return result.InitializeAsync();

  }

}

Конструктор и метод InitializeAsync объявлены приватными, чтобы они не могли использоваться в другом коде; экземпляры могут создаваться только одним способом — статическим фабричным методом CreateAsync. Вызывающий код не может обратиться к экземпляру до того, как инициа­лизация будет завершена.

В другом коде экземпляр может создаваться следующим образом:

MyAsyncClass instance = await MyAsyncClass.CreateAsync();

Пояснение

Главное преимущество этого паттерна заключается в том, что другой код никак не сможет получить неинициализированный экземпляр MyAsyncClass. Именно по этой причине я предпочитаю этот паттерн и использую его везде, где только возможно.

К сожалению, в некоторых сценариях этот способ не работает — в частности, когда в коде используется провайдер внедрения зависимостей. Ни одна заметная библиотека внедрения зависимостей или инверсии управления не работает с async-кодом. Если вы окажетесь в одной из таких ситуаций, существует пара альтернатив, которые также стоит рассмотреть.

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

Пример того, как поступать не следует:

class MyAsyncClass

{

  public MyAsyncClass()

  {

    InitializeAsync();

  }

 

  // ПЛОХОЙ КОД!!

  private async void InitializeAsync()

  {

    await Task.Delay(TimeSpan.FromSeconds(1));

  }

}

На первый взгляд решение может показаться разумным: вы получаете обычный конструктор, который запускает асинхронную операцию; при этом у него есть ряд недостатков, обусловленных использованием async void. Первая проблема заключается в том, что при завершении конструктора экземпляр все еще продолжает асинхронно инициализироваться, и не существует очевидного способа определить, когда завершится асинхронная инициализация. Вторая проблема связана с обработкой ошибок: любые исключения, выданные из InitializeAsync, не могут быть перехвачены секциями catch, окружающими конструирование объекта.

Дополнительная информация

В рецепте 11.3 рассматривается паттерн асинхронной инициализации — способ выполнения асинхронного конструирования, который работает с контейнерами внедрения зависимостей/инверсии управления.

В рецепте 14.1 рассматривается асинхронная отложенная инициализация; это решение приемлемо, если экземпляр на концептуальном уровне представляет общий ресурс или сервис.

11.3. Асинхронное конструирование: паттерн асинхронной инициализации

Задача

Вы программируете тип, требующий выполнения некоторой асинхронной работы в его конструкторе, но не можете воспользоваться паттерном асинхронной фабрики (рецепт 11.2), так как экземпляр создается с применением отражения (например, библиотеки внедрения зависимостей/инверсии управления, связывания данных, Activator.CreateInstance и т.д.).

Решение

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

Task Initialization { get; }

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

/// <summary>

/// Помечает тип как требующий асинхронной инициализации

/// и предоставляет результат этой инициализации.

/// </summary>

public interface IAsyncInitialization

{

  /// <summary>

  /// Результат асинхронной инициализации этого экземпляра.

  /// </summary>

  Task Initialization { get; }

}

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

class MyFundamentalType : IMyFundamentalType, IAsyncInitialization

{

  public MyFundamentalType()

  {

    Initialization = InitializeAsync();

  }

 

  public Task Initialization { get; private set; }

 

  private async Task InitializeAsync()

  {

    // Провести асинхронную инициализацию этого экземпляра.

    await Task.Delay(TimeSpan.FromSeconds(1));

  }

}

Если вы используете библиотеку внедрения зависимостей/инверсии управления, то экземпляр этого типа может быть создан и инициализирован кодом следующего вида:

IMyFundamentalType instance =

   UltimateDIFactory.Create<IMyFundamentalType>();

var instanceAsyncInit = instance as IAsyncInitialization;

if (instanceAsyncInit != null)

  await instanceAsyncInit.Initialization;

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

class MyComposedType : IMyComposedType, IAsyncInitialization

{

  private readonly IMyFundamentalType _fundamental;

  public MyComposedType(IMyFundamentalType fundamental)

  {

    _fundamental = fundamental;

    Initialization = InitializeAsync();

  }

 

  public Task Initialization { get; private set; }

 

  private async Task InitializeAsync()

  {

    // Асинхронно ожидать инициализации фундаментального экземпляра

    //  при необходимости.

    var fundamentalAsyncInit = _fundamental as IAsyncInitialization;

    if (fundamentalAsyncInit != null)

      await fundamentalAsyncInit.Initialization;

 

    // Выполнить собственную инициализацию (синхронно или асинхронно).

    ...

  }

}

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

Пояснение

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

Как было сказано при описании асинхронных интерфейсов (рецепт 11.1), асинхронная сигнатура метода означает лишь то, что метод может быть асинхронным. Код MyComposedType.InitializeAsync является хорошим примером: если экземпляр IMyFundamentalType не реализует IAsyncInitialization, а MyComposedType не имеет собственной асинхронной инициализации, то его метод InitializeAsync завершается синхронно.

Код, который проверяет, реализует ли экземпляр IAsyncInitialization и инициализирует его, получается немного громоздким — и ситуация только усугубляется для составных типов, зависящих от большого количества компонентов. Вы можете легко создать вспомогательный метод для упрощения кода:

public static class AsyncInitialization

{

  public static Task WhenAllInitializedAsync(params object[] instances)

  {

    return Task.WhenAll(instances

        .OfType<IAsyncInitialization>()

        .Select(x => x.Initialization));

  }

}

Вызовите InitializeAllAsync и передайте любые экземпляры, которые требуется инициализировать; метод проигнорирует экземпляры, не реализующие IAsyncInitialization. Код инициализации для составного типа, зависящего от трех внедренных экземпляров, будет выглядеть примерно так:

private async Task InitializeAsync()

{

// Асинхронно ожидать инициализации всех 3 экземпляров, если потребуется.

await AsyncInitialization.WhenAllInitializedAsync(_fundamental,

     _anotherType, _yetAnother);

 

// Выполнить собственную инициализацию (синхронно или асинхронно).

...

}

Дополнительная информация

В рецепте 11.2 рассматриваются асинхронные фабрики как механизм выполнения асинхронного конструирования без предоставления доступа к неинициализированным экземплярам.

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

В рецепте 11.1 рассматриваются асинхронные интерфейсы.

11.4. Асинхронные свойства

Задача

Имеется свойство, которое вам хотелось бы объявить как асинхронное. Свойство не задействовано в связывании данных.

Решение

Эта проблема часто встречается при преобразовании существующего кода для использования async; в таких ситуациях создается свойство, get-метод которого вызывает асинхронный метод. Вообще, такого понятия, как «асинхронное свойство», не существует. Ключевое слово async не может использоваться со свойством, и это хорошо. Get-методы свойств должны возвращать текущие значения; они не должны запускать фоновые операции:

// Чего хотелось бы (не компилируется).

public int Data

{

  async get

  {

    await Task.Delay(TimeSpan.FromSeconds(1));

    return 13;

  }

}

Когда вы обнаруживаете, что вашему коду нужно «асинхронное свойство», вашему коду в действительности требуется нечто иное. Решение зависит от того, должно ли значение свойства вычисляться один раз или несколько; у вас есть выбор между следующими семантиками:

• Значение асинхронно вычисляется каждый раз при чтении.

• Значение асинхронно вычисляется один раз и кэшируется для будущих обращений.

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

// В виде асинхронного метода.

public async Task<int> GetDataAsync()

{

  await Task.Delay(TimeSpan.FromSeconds(1));

  return 13;

}

Вы можете получить Task<int> непосредственно от свойства, как показано в следующем коде:

// Это "асинхронное свойство" является асинхронным методом.

public Task<int> Data

{

  get { return GetDataAsync(); }

}

 

private async Task<int> GetDataAsync()

{

  await Task.Delay(TimeSpan.FromSeconds(1));

  return 13;

}

И все же я не рекомендую применять этот подход. Если при каждом обращении к свойству будет запускаться новая асинхронная операция, это «свойство» в действительности должно быть оформлено в виде метода. Тот факт, что код оформлен в виде асинхронного метода, более ясно показывает, что каждый раз запускается новая асинхронная операция, поэтому API не вводит пользователя в заблуждение. В рецептах 11.3 и 11.6 используются свойства, возвращающие задачи, но эти свойства относятся к экземпляру в целом; они не запускают новую асинхронную операцию при каждом чтении.

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

// Как кэшированное значение

public AsyncLazy<int> Data

{

  get { return _data; }

}

 

private readonly AsyncLazy<int> _data =

    new AsyncLazy<int>(async () =>

    {

      await Task.Delay(TimeSpan.FromSeconds(1));

      return 13;

    });

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

int value = await instance.Data;

В данном случае синтаксис свойства уместен, потому что вычисление происходит только один раз.

Пояснение

Один из самых важных вопросов, которые следует задать себе: должно ли чтение свойства запускать новую асинхронную операцию. Если ответ будет положительным, используйте асинхронный метод вместо свойства. Если свойство должно работать как кэш с отложенным вычислением, используйте асинхронную инициализацию (см. рецепт 14.1). В этом рецепте я не рассматриваю свойства, используемые в связывании данных; они будут рассматриваться в рецепте 14.3.

Если вы преобразуете синхронное свойство в «асинхронное свойство», следующий пример показывает, как это делать не следует:

private async Task<int> GetDataAsync()

{

  await Task.Delay(TimeSpan.FromSeconds(1));

  return 13;

}

 

public int Data

{

  // ПЛОХОЙ КОД!!

  get { return GetDataAsync().Result; }

}

Раз уж речь зашла о свойствах в async-коде, стоит задуматься над тем, как состояние соотносится с асинхронным кодом. Это особенно актуально при преобразовании синхронной кодовой базы в асинхронную. Возьмем любое состояние, доступ к которому осуществляется через API (например, через свойства): для каждой составляющей состояния спросите себя: что считать текущим состоянием объекта с незавершенной асинхронной операцией? Правильного ответа не существует, но важно продумать то, какие семантики вам нужны и как их документировать.

Для примера возьмем объект Stream.Position, представляющий текущее смещение указателя в потоке. С синхронным API при вызове Stream.Read  или Stream.Write чтение/запись завершается, а Stream.Position обновляется новой позицией перед возвращением управления методом Read или Write. Для синхронного кода семантика ясна.

Теперь возьмем Stream.ReadAsync и Stream.WriteAsync: когда должно обновляться значение Stream.Position? При завершении операции чтения/записи или до того, как это фактически произойдет? Если оно обновляется перед завершением операции, то будет ли оно обновлено синхронно к моменту возвращения управления ReadAsync/WriteAsync или же вскоре после этого?

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

Дополнительная информация

В рецепте 14.1 подробно рассматривается асинхронная отложенная инициализация.

В рецепте 14.3 рассматриваются «асинхронные свойства», которые должны поддерживать связывание данных.

11.5. async-события

Задача

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

Решение

Определить, когда обработчики async void вернули управление, невозможно, поэтому вам потребуется механизм обнаружения факта завершения асинхронных обработчиков. На платформе Universal Windows появилась концепция так называемых объектов отложенного выполнения (deferrals), которые могут использоваться для отслеживания асинхронных обработчиков. Асинхронный обработчик создает объект отложенного выполнения перед первым ключевым словом await и позднее уведомляет объект отложенного выполнения при завершении. Синхронным обработчикам использовать объекты отложенного выполнения не нужно.

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

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

public class MyEventArgs : EventArgs, IDeferralSource

{

  private readonly DeferralManager _deferrals = new DeferralManager();

 

  ... // Ваши конструкторы и свойства

 

  public IDisposable GetDeferral()

  {

    return _deferrals.DeferralSource.GetDeferral();

  }

 

  internal Task WaitForDeferralsAsync()

  {

    return _deferrals.WaitForDeferralsAsync();

  }

}

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

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

public event EventHandler<MyEventArgs> MyEvent;

 

private async Task RaiseMyEventAsync()

{

  EventHandler<MyEventArgs> handler = MyEvent;

  if (handler == null)

    return;

  var args = new MyEventArgs(...);

  handler(this, args);

  await args.WaitForDeferralsAsync();

}

После этого асинхронные обработчики событий смогут использовать объект отложенного выполнения в блоке using. Объект отложенного выполнения уведомит DeferralManager о своем освобождении:

async void AsyncHandler(object sender, MyEventArgs args)

{

  using IDisposable deferral = args.GetDeferral();

  await Task.Delay(TimeSpan.FromSeconds(2));

}

Происходящее несколько отличается от того, как работают объекты отложенного выполнения в Universal Windows. В Universal Windows API каждое событие, которому понадобятся объекты отложенного выполнения, определяет собственный тип объекта отложенного выполнения, и этот тип содержит явно определенный метод Complete (вместо того чтобы реализовать IDisposable).

Пояснение

На логическом уровне в .NET используются две разновидности событий с очень сильно различающейся семантикой. Я называю их событиями уведомлений и командными событиями; это не официальная терминология, а просто некие термины, которые я выбрал для ясности. События уведомлений используются для оповещения других компонентов о некоторой ситуации. Уведомление является чисто односторонним; отправителя события не интересует, есть ли у этого события получатели. С уведомлениями отправитель и получатель могут быть полностью изолированы друг от друга. Большинство событий относится к категории событий уведомления; одним из примеров такого рода является щелчок на кнопке.

С другой стороны, командное событие инициируется для реализации некоторой функциональности по поручению компонента-отправителя. Командные события не являются «событиями» в подлинном смысле этого термина, хотя они часто реализуются в виде событий .NET. Отправитель команды должен дождаться, пока она будет обработана получателем, прежде чем двигаться дальше. Если события используются для реализации паттерна «Посетитель», то это командные события. События жизненного цикла тоже являются командными событиями; к этой категории также относятся события жизненного цикла страниц ASP.NET и многие события UI-фреймворков (например, событие Xamarin Application.PageAppearing). Любое событие UI-фреймворка, который в действительности является реализацией, также является командным событием (например, BackgroundWorker.DoWork).

События уведомлений не требуют специального кода, чтобы сделать возможным использование асинхронных обработчиков; обработчики событий могут быть объявлены asyncvoid и при этом будут нормально работать. Когда обработчик события выдает событие, асинхронные обработчики событий не завершаются немедленно, но это неважно, потому что они являются событиями уведомлений. Таким образом, если ваше событие является событием уведомления, то для поддержки асинхронных обработчиков, по сути, не придется делать ничего.

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

 

lemur.tiff

Тип DeferralManager находится в пакете Nito.AsyncEx.

Дополнительная информация

В главе 2 рассматриваются основы асинхронного программирования.

11.6. Асинхронное освобождение

Задача

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

Решение

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

Интерпретация освобождения как отмены уже встречалась в Windows; такие типы, как файловые потоки и сокеты, отменяют все существующие операции чтения и записи при закрытии. Определив собственный тип CancellationTokenSource и передавая этот маркер внутренним операциям, можно сделать нечто очень похожее в .NET. В следующем коде Dispose отменит операции, но не ожидает завершения этих операций:

class MyClass : IDisposable

{

  private readonly CancellationTokenSource _disposeCts =

      new CancellationTokenSource();

 

  public async Task<int> CalculateValueAsync()

  {

    await Task.Delay(TimeSpan.FromSeconds(2), _disposeCts.Token);

    return 13;

  }

 

  public void Dispose()

  {

    _disposeCts.Cancel();

  }

}

Этот пример демонстрирует основной паттерн, относящийся к Dispose. В реальном приложении следовало бы включить проверку того, что объект еще не был освобожден, а также предоставить пользователю возможность передать собственный маркер CancellationToken (с использованием приема из рецепта 10.8):

public async Task<int> CalculateValueAsync(CancellationToken

   cancellationToken)

{

  using CancellationTokenSource combinedCts = CancellationTokenSource

      .CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);

  await Task.Delay(TimeSpan.FromSeconds(2), combinedCts.Token);

  return 13;

}

При вызове Dispose будут отменены все существующие операции в вызывающем коде:

async Task UseMyClassAsync()

{

  Task<int> task;

  using (var resource = new MyClass())

  {

    task = resource.CalculateValueAsync(default);

  }

 

  // Выдает OperationCanceledException.

  var result = await task;

}

Для некоторых типов реализация Dispose как запроса на отмену работает вполне нормально (например, HttpClient обладает такой семантикой). Однако другим типам необходимо знать, когда будут завершены все операции. Для таких типов необходима некоторая разновидность асинхронного освобождения.

Асинхронное освобождение впервые появилось в C# 8.0 и .NET Core 3.0. В BCL появился новый интерфейс IAsyncDisposable, который является асинхронным аналогом IDisposable. В языке одновременно была введена команда awaitusing — асинхронный аналог using. Таким образом, типы, которые собирались выполнить асинхронную работу при освобождении, теперь получили такую возможность:

class MyClass : IAsyncDisposable

{

  public async ValueTask DisposeAsync()

  {

    await Task.Delay(TimeSpan.FromSeconds(2));

  }

}

Возвращаемым типом DisposeAsync является ValueTask, а не Task, но стандартные ключевые слова async и await работают с ValueTask ничуть не хуже, чем с Task.

Типы, реализующие IAsyncDisposable, обычно потребляются с await:

await using (var myClass = new MyClass())

{

  ...

} // Здесь вызывается DisposeAsync (с ожиданием)

Если нужно обойти контекст с использованием ConfigureAwait(false), это возможно, но решение получается более громоздким, потому что переменная должна быть объявлена за пределами команды awaitusing:

var myClass = new MyClass();

await using (myClass.ConfigureAwait(false))

{

  ...

} // Здесь вызывается DisposeAsync (с ожиданием)

   с ConfigureAwait(false).

Пояснение

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

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

Дополнительная информация

В рецепте 10.8 рассматриваются связанные маркеры отмены.

В рецепте 11.1 рассматриваются асинхронные интерфейсы.

В рецепте 2.10 рассматривается реализация методов, возвращающих ValueTask.

В рецепте 2.7 рассматривается обход контекста с использованием ConfigureAwait(false).

Назад: Глава 10. Отмена
Дальше: Глава 12. Синхронизация