Имплементация интерфейса IAsyncDisposable

Интерфейс IAsyncDisposable служит для асинхронного освобождения неуправляемых ресурсов (unmanaged resources). Появился он в версии .NET Core 3.0. В .NET классы, владеющие неуправляемыми ресурсами, обычно реализуют интерфейс IDisposable, чтобы обеспечить механизм синхронного освобождения неуправляемых ресурсов (см. подробности в посте Имплементация интерфейса IDisposable). Однако в некоторых случаях классам необходимо предоставлять асинхронный механизм освобождения неуправляемых ресурсов в дополнение к синхронному (или вместо него). Предоставление такого механизма позволяет потребителю выполнять ресурсоемкие операции удаления не блокируя основной поток приложения с графическим интерфейсом в течение длительного времени.

Метод IAsyncDisposable.DisposeAsync этого интерфейса возвращает ValueTask, представляющий асинхронную операцию освобождения ресурсов. Класс, владеющий неуправляемыми ресурсами, реализует этот метод, а потребитель класса вызывает метод DisposeAsync объекта, когда он больше не нужен. Чтобы ресурсы освобождались даже в случае исключения следует поместить код, использующий объект IAsyncDisposable, в оператор using (в C#, начиная с версии 8.0), или вызвать метод DisposeAsync внутри finally оператора try/finally.

Приведём пример кода, поясняющий вышеописанное:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class SomeClass : IAsyncDisposable, IDisposable
{
var asyncDisposeObj = new FileStream("SomeFile.txt", FileMode.OpenOrCreate, FileAccess.Write);
var syncDisposeObj = new HttpClient();

// other usefull code

public async ValueTask DisposeAsync()
{
await DisposeAsyncCore();

Dispose(disposing: false);
GC.SuppressFinalize(this);
}

public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool disposing)
{
if (disposing)
{
syncDisposeObj?.Dispose();
(asyncDisposeObj as IDisposable)?.Dispose();
}

DisposeThisObject();
}

protected virtual async ValueTask DisposeAsyncCore()
{
if (asyncDisposeObj is not null)
{
await asyncDisposeObj.DisposeAsync().ConfigureAwait(false);
}

if (syncDisposeObj is IAsyncDisposable disposable)
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
else
{
syncDisposeObj.Dispose();
}

DisposeThisObject();
}

void DisposeThisObject()
{
asyncDisposeObj = null;
syncDisposeObj = null;
}
}

А где-то в приложении класс можно использовать так:

1
2
3
4
5
static async Task Main()
{
await using var someClass = new SomeClass();
// other usefull code
}

Код выше показал, в общих чертах, паттерн Async Dispose Pattern. Как следует из кода, asyncDisposeObj ссылается на ресурс который следует утилизировать асинхронно, а syncDisposeObj - синхронно. Теперь синхронная и асинхронная утилизация ресурсов могут выполняться в одно время, так что важно рассматривать эти процессы совместно. Для синхронной и асинхронной утилизации ресурсов класс реализует интерфейсы IAsyncDisposable и IDisposable. Как показано в посте Имплементация интерфейса IDisposable IDisposable должен определять метод Dispose() без параметров, виртуальный метод Dispose(bool) и опциональный финализатор (деструктор). Наш пример не требует опционального финализатора (деструктора). А для IAsyncDisposable требуется определить методы без параметров DisposeAsync() и DisposeAsyncCore(). Оба пути утилизации (синхронный и асинхронный) могут сработать, поэтому они оба должны быть готовы утилизировать ресурсы. В методе Dispose(bool) не только вызывается Dispose() для синхронного ресурса syncDisposeObj, но и произвоится попытка вызвать Dispose() для асинхронного asyncDisposeObj. Отметьте, что Dispose(bool) также вызывает DisposeThisObject, который содержит тот же код, что и аналогичный код для асинхронного пути во избежание дублирования.

Методы Dispose() и DisposeAsync() являются членами интерфейсов, а методы Dispose(bool) и DisposeAsyncCore() - конвенциями (условленными договоренностями). Оба последних метода виртуальные. Это является частью паттерна, когда производный класс может реализовать утилизацию ресурсов, переопределяя эти методы и вызывая их через base.Dispose(bool) и base.DisposeAsyncCore(), чтобы гарантировать освобождение ресурсов по всей иерархии наследования.

Оба метода Dispose() и DisposeAsync() вызывают Dispose(bool), но DisposeAsync() устанавливает флаг disposing в false. Напомню, что disposing = true это флаг утилизации управляемых ресурсов. Метод Dispose(bool) - это синхронный путь, а метод DisposeAsync() вызывает DisposeAsyncCore() для утилизирования асинхронных ресурсов. Как и Dispose(true) метод DisposeAsyncCore() пытается высвободить все управляемые ресурсы. Асинхронный случай очевиден, однако синхронный имеет пару особенностей. Что если синхронный объект сейчас или в будущем реализует IAsyncDisposable? Тогда попытка вызова DisposeAsync() является наилучшим выбором в случае асинхронного пути выполнения кода. Иначе вызовется синхронный путь выполнения с методом Dispose().

В конце отметим, что метод Main использует конструкцию await using при создании экземпляра класса реализующего интерфейсы IAsyncDisposable и IDisposable. Это гарантирует вызов метода DisposeAsync() по завершению выполнения метода Main.

Имплементация интерфейса IDisposable

Интерфейс IDisposable служит для освобождения неуправляемых ресурсов (unmanaged resources). Сборщик мусора в .NET автоматически не освобождает неуправляемую память. Поэтому разработан шаблон освобождения неуправляемых ресурсов (Dispose Pattern), таких как файловые дескрипторы, указатели на блоки неуправляемой памяти, дескрипторы реестра и т.п. Для освобождения неуправляемых ресурсов объект должен реализовывать интерфейс IDisposable.

Интерфейс IDisposable требует имплементации метода без параметров Dispose(), а незапечатанные (non-sealed) классы дополнительно должны перегружать метод Dispose(bool). Чтобы обеспечить правильное освобождение ресурсов, метод Dispose должен быть идемпотентным (т.е. чтобы его можно было без вреда вызывать несколько раз). Все последующие после первого вызовы Dispose() ничего не должны делать.

Стандартная реализация публичного невиртуального метода Dispose():

1
2
3
4
5
public void Dispose()
{
Dispose(true); // Dispose of unmanaged resources
GC.SuppressFinalize(this); // Suppress finalization
}

Перегруженный метод Dispose(bool) выполняет фактическую очистку всех объектов, поэтому сборщику мусора больше не нужно вызывать финализатор (деструктор) объектов. Таким образом, вызов метода SuppressFinalize предотвращает запуск финализатора (деструктора) сборщиком мусора. Если класс не имеет финализатора (деструктора), вызов GC.SuppressFinalize не будет оказывать никакого влияния.

В перегруженном методе Dispose(bool) аргумент метода указывает исходит ли вызов из метода Dispose (тогда его значение true) или из финализатора (тогда его значение false).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;

if (disposing)
{
// TODO: dispose managed state (managed objects).
}

// TODO: free unmanaged resources (unmanaged objects) and override a finalizer below.
// TODO: set large fields to null.

_disposed = true;
}

Метод немедленно возвращается, если очиска ресурсов уже имела место до этого. Отмечу, что блок выполняющий очистку неуправляемых ресурсов выполняется при любом значении аргумента disposing у метода. А вот блок освобождающий управляемые ресурсы запустится только в случае аргумента disposing равного true. Управляемые ресурсы могут состоять из объектов, реализующих IDisposable интерфейс (тогда у них просто надо каскадно вызвать Dispose() методы), либо из объектов, потребляющих большие объемы памяти или ограниченные ресурсы (тогда надо назначить ссылкам на них значение null, что освобождает их быстрее, чем если бы они были очищены в произвольный момент).

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

1
2
3
4
~A()
{
Dispose(false);
}

Разработчик отвечает за то, чтобы путь с аргументом false не взаимодействовал с управляемыми объектами, которые уже могли быть удалены. Это важно, потому что порядок в котором сборщик мусора удаляет управляемые объекты во время финализации недетерминирован.

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

Когда класс реализует интерфейс IDisposable, это означает, что где-то есть неуправляемые ресурсы от которых следует избавиться, когда вы закончите использовать объект этого класса. Ресурсы инкапсулированы внутри класса и вам не нужно явно удалять их. Простой вызов Dispose() или обертывание класса в using(...) {} позволит избавиться от любых неуправляемых ресурсов автоматически.

Таким образом, вот общий шаблон для реализации Dispose Pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System;

class ClassWithFinalizer : IDisposable
{
// To detect redundant calls
private bool _disposedValue;

~ClassWithFinalizer() => Dispose(false);

// Public implementation of Dispose pattern callable by consumers.
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

// Protected implementation of Dispose pattern.
protected virtual void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
// TODO: dispose managed state (managed objects)
}

// TODO: free unmanaged resources (unmanaged objects) and override finalizer
// TODO: set large fields to null
_disposedValue = true;
}
}
}

Перевод статьи Стефэна Тоуба "Обработка задач по мере их завершения"

Данный пост является переводом статьи Stephen Toub “Processing tasks as they complete”.

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

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

1
2
3
4
5
6
List<Task<T>> tasks = …;
foreach(var t in tasks) {
try { Process(await t); }
catch(OperationCanceledException) {}
catch(Exception exc) { Handle(exc); }
}

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

Есть много способов реализовать подобное решение. Один из подходов включает простое использование метода ContinueWith у Task, например:

1
2
3
4
5
6
7
8
List<Task<T>> tasks = …;
foreach(var t in tasks)
t.ContinueWith(completed => {
switch(completed.Status) {
case TaskStatus.RanToCompletion: Process(completed.Result); break;
case TaskStatus.Faulted: Handle(completed.Exception.InnerException); break;
}
}, TaskScheduler.Default);

Это решение устраняет дополнительное ограничение, заключавшееся в том, что исходное решение заставляло обработку всех продолжений выполняться последовательно (и в исходном SynchronizationContext, если он был), тогда как данное решение позволяет выполнять обработку параллельно и в ThreadPool. Если вы хотите использовать данный подход, но также заставить задачи выполняться последовательно, можно сделать это, подставив сериализующий планировщик для метода ContinueWith (то есть планировщик, который заставит продолжения выполняться исключительно с оглядкой друг на друга). Например, вы можете использовать ConcurrentExclusiveSchedulerPair:

1
2
3
4
5
6
7
8
9
List<Task<T>> tasks = …;
var scheduler = new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler;
foreach(var t in tasks)
t.ContinueWith(completed => {
switch(completed.Status) {
case TaskStatus.RanToCompletion: Process(completed.Result); break;
case TaskStatus.Faulted: Handle(completed.Exception.InnerException); break;
}
}, scheduler);

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

1
var scheduler = TaskScheduler.FromCurrentSynchronizationContext();

Подход, основанный на методе ContinueWith, заставляет вас использовать модель, основанную на обратных вызовах (callbacks), для выполнения вашей обработки результатов. Если вы хотите, чтобы обработка выполнялась последовательно по мере завершения задач, но с использованием async/await, а не с использованием ContinueWith, это также возможно.

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

1
2
3
4
5
6
7
8
List<Task<T>> tasks = …;
while(tasks.Count > 0) {
var t = await Task.WhenAny(tasks);
tasks.Remove(t);
try { Process(await t); }
catch(OperationCanceledException) {}
catch(Exception exc) { Handle(exc); }
}

Функционально это норм, и пока количество задач невелико, производительность тоже должна быть в порядке. Однако, если количество задач становится велико, то это может привести к непренебрежимому снижению производительности. Здесь мы фактически создали алгоритм O(N^2): для каждой задачи мы ищем в списке задачу для ее удаления, что является операцией O(N), и мы регистрируем продолжение для каждой задачи, что тоже является операцией O(N). Например, если бы у нас было 10 000 задач, в течение всей этой операции мы бы в конечном итоге зарегистрировали и отменили регистрацию более 50 миллионов продолжений как части вызовов WhenAny. Правда не всё так плохо как кажется, поскольку WhenAny разумно управляет своими ресурсами, например: не регистрируя продолжения уже завершенных задач; останавливаясь, как только находит завершенную задачу; повторно используя один и тот же объект продолжения для всех задач и т. д. Тем не менее, здесь есть работа, которую мы можем избежать, если профилирование сочтёт этот код проблематичным.

Альтернативный подход заключается в создании нового метода «комбинатора», специально предназначенного для этой цели. При работе с коллекцией экземпляров задач типа Task<T> метод WhenAny возвращает Task<Task<T>>; это задача, которая завершится, когда завершится первая из поставленных на выполнение задач, и результатом задачи Task<Task<T>> будет первая завершившаяся задача коллекции. В нашем случае нам нужна не только первая задача, но и все задачи, упорядоченные по мере их завершения. Мы можем представить это с помощью Task<Task<T>>[]. Воспринимайте это как массив корзин, куда мы будем помещать входящие задачи по мере их завершения, по одной задаче на корзину. Таким образом, если вы хотите, чтобы первая задача была завершена, вы можете дождаться (await) первой корзины этого массива, а если вы хотите, чтобы шестая задача была завершена, вы можете дождаться (await) шестой корзины этого массива.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static Task<Task<T>> [] Interleaved<T>(IEnumerable<Task<T>> tasks)
{
var inputTasks = tasks.ToList();

var buckets = new TaskCompletionSource<Task<T>>[inputTasks.Count];
var results = new Task<Task<T>>[buckets.Length];
for (int i = 0; i < buckets.Length; i++)
{
buckets[i] = new TaskCompletionSource<Task<T>>();
results[i] = buckets[i].Task;
}

int nextTaskIndex = -1;
Action<Task<T>> continuation = completed =>
{
var bucket = buckets[Interlocked.Increment(ref nextTaskIndex)];
bucket.TrySetResult(completed);
};

foreach (var inputTask in inputTasks)
inputTask.ContinueWith(continuation,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);

return results;
}

Итак, что здесь происходит? Сначала мы преобразуем наше перечисление задач в список List<Task<T>>; это делается для того, чтобы любые задачи, которые могут быть созданы ленивым отложенным способом путем перечисления перечисляемого, были материализованы один раз. Затем мы создаем экземпляры TaskCompletionSource<Task<T>> для представления корзин, по одной корзине на каждую задачу, которая в конечном итоге будет завершена. Затем мы цепляем продолжение к каждой входящей задаче: это продолжение получит следующую доступную корзину и сохранит в ней только что выполненную задачу. С помощью такого комбинатора можно переписать исходный код следующим образом:

1
2
3
4
5
6
7
List<Task<T>> tasks = …;
foreach(var bucket in Interleaved(tasks)) {
var t = await bucket;
try { Process(await t); }
catch(OperationCanceledException) {}
catch(Exception exc) { Handle(exc); }
}

Чтобы закрыть данное обсуждение, давайте посмотрим на то, что это даёт на практике. Рассмотрим:

1
2
3
4
5
6
7
8
9
10
11
12
var tasks = new[] { 
Task.Delay(3000).ContinueWith(_ => 3),
Task.Delay(1000).ContinueWith(_ => 1),
Task.Delay(2000).ContinueWith(_ => 2),
Task.Delay(5000).ContinueWith(_ => 5),
Task.Delay(4000).ContinueWith(_ => 4),
};
foreach (var bucket in Interleaved(tasks)) {
var t = await bucket;
int result = await t;
Console.WriteLine(“{0}: {1}”, DateTime.Now, result);
}

У нас есть массив задач Task, каждая из которых завершится через N секунд и вернет целое число N (например, первая задача в массиве завершится через 3 секунды и вернет число 3). Затем мы перебираем эти задачи, используя наш самодельный метод Interleaved, распечатывая результаты по мере их получения. Когда я запускаю этот код, я вижу следующий вывод:

1
2
3
4
5
8/2/2012 7:37:48 AM: 1
8/2/2012 7:37:49 AM: 2
8/2/2012 7:37:50 AM: 3
8/2/2012 7:37:51 AM: 4
8/2/2012 7:37:52 AM: 5

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

Для контраста рассмотрим тот же пример, но без использования Interleaved:

1
2
3
4
5
6
7
8
9
10
11
var tasks = new[] { 
Task.Delay(3000).ContinueWith(_ => 3),
Task.Delay(1000).ContinueWith(_ => 1),
Task.Delay(2000).ContinueWith(_ => 2),
Task.Delay(5000).ContinueWith(_ => 5),
Task.Delay(4000).ContinueWith(_ => 4),
};
foreach (var t in tasks) {
int result = await t;
Console.WriteLine(“{0}: {1}”, DateTime.Now, result);
}

При запуске этого варианта видим:

1
2
3
4
5
8/2/2012 7:42:08 AM: 3
8/2/2012 7:42:08 AM: 1
8/2/2012 7:42:08 AM: 2
8/2/2012 7:42:10 AM: 5
8/2/2012 7:42:10 AM: 4

А теперь взгляните на время. Поскольку идёт обработка задач по порядку, мы не можем распечатать результаты для задачи 1 или задачи 2 до тех пор, пока задача 3 не будет завершена (поскольку она была перед ними в массиве). Точно так же мы не можем распечатать результат для задачи 4, пока задача 5 не будет завершена.

Параллельная обработка задач по мере их завершения с помощью Task.WhenAny

Читаю я как-то главу 6.8 “Handling Parallel Tasks as They Complete” книги Joe Mayo “C# Cookbook” и встречаю такой абзац:

You thought calling Task.WhenAny would be an efficient use resources for processing results as they complete, but cost and performance are terrible.

Минуточку, уважаемый Джо! А тут я попрошу вас поподробнее. У меня на проде используется этот Task.WhenAny… Но в подробности Джо не пускается, рекомендуя вместо Task.WhenAny использовать Task.WhenAll, утверждая, что у Task.WhenAll сложность О(1), т.к. вместо N последовательных операций выполняется как-бы одна (все N операций выполняются параллельно).

Далее Джо утверждает, что в паттерне:

1
2
3
4
5
6
while(tasks.Any())
{
var currentFinishedTask = await Task.WhenAny(tasks);
tasks.Remove(currentFinishedTask);
// Handle currentFinishedTask result
}

всё происходит не так как ты ожидаешь:

The reality is that each subsequent loop starts a brand-new set of tasks. This is how async works - you can await a task multiple times, but each await starts a new task. That means the code continiously starts new instances of remaining tasks on every loop. This looping pattern, with Task.WhenAny, doesn’t result in the O(1) performance you might have expected, like with Task.WhenAll, but rather O(N^2).

А это уже плохой сигнал, как говорит Егор Иванов :( Далее Джо пугает, что это скажется не только на плохой производительности приложения, но и может породить избыточную сетевую активность и нагрузить конечный сервер бесполезной работой. А представляете как эти избыточные сетевые запросы отразятся и сколько будут стоить в облачном сервисе?!? Короче Джо признаёт Task.WhenAny антипаттерном в том виде, что он приведён выше, если он только не используется для малого числа задач. Джо советует использовать Task.WhenAny только для случая, когда нас интересует только задача победитель, а на остальные можно забить и не дожидаться/продолжать их обработку. Тогда Task.WhenAny тоже будет иметь производительность со сложностью O(1).

Ну и завершает главу Джо таким абзацем:
Task.WhenAny runs those tasks in parallel and the first task to complete comes back. The code processes that task and returns. The side effect in this solution is that tasks other the first that returned continue running. However, you don’t have access to them because only one task is returned. For this scenario, you don’t care about those tasks but should stop them to avoid using more resources than necessary. You can learn how to do that in the next section on cancelling tasks.

Вот такие пироги! Побочный эффект, понимаешь! Сейчас проверим, что за побочный эффект. Для этого наваяем такой класс:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ExperimentalTask
{
public int TimeInSeconds {get; private set;}
public ExperimentalTask(int timeInSeconds)
{
TimeInSeconds = timeInSeconds;
}

public async Task WaitFor()
{
Console.WriteLine($"Enter in Time={TimeInSeconds} sec.");
await Task.Delay(TimeInSeconds * 1000);
Console.WriteLine($"Finished for Time={TimeInSeconds} sec.");
}
}

и задействуем его таким кодом:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Console.WriteLine("Experiment is starting");

var tasks = new List<Task>();

for(var i = 1; i <= 5; i += 1)
{
tasks.Add(new ExperimentalTask(i).WaitFor());
}

var stopwatch = System.Diagnostics.Stopwatch.StartNew();

while(tasks.Any())
{
Console.WriteLine($"tasks.Count={tasks.Count}");
var currentFinishedTask = await Task.WhenAny(tasks);
tasks.Remove(currentFinishedTask);
}

Console.WriteLine("Experiment finished");
stopwatch.Stop();
Console.WriteLine($"Elapsed time: {stopwatch.Elapsed}\n");

Запустим приложение и увидим такие результаты:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Experiment is starting
Enter in Time=1 sec.
Enter in Time=2 sec.
Enter in Time=3 sec.
Enter in Time=4 sec.
Enter in Time=5 sec.
tasks.Count=5
Finished for Time=1 sec.
tasks.Count=4
Finished for Time=2 sec.
tasks.Count=3
Finished for Time=3 sec.
tasks.Count=2
Finished for Time=4 sec.
tasks.Count=1
Finished for Time=5 sec.
Experiment finished
Elapsed time: 00:00:05.0092732

Так, а где побочный эффект? Где “the code continiously starts new instances of remaining tasks on every loop”? Про какую реальность утверждает Джо “The reality is that each subsequent loop starts a brand-new set of tasks”? При исследовании Task.WhenAny паттерна на сайте Майкрософт Обработка асинхронных задач по мере завершения (C#) находим примечание:

Внимание!
Можно использовать WhenAny в цикле, как описано в примере, для решения проблем, которые включают небольшое число задач. Однако когда требуется обработка большого числа задач, другие методы будут более эффективны. Дополнительные сведения и примеры см. в разделе Обработка задач по мере их завершения.

Предлагаю углубиться в эту тему через мой перевод этой статьи.

Вызов синхронного кода из асинхронного

Бывает возникает потребность асинхронно использовать синхронный метод.
Вот какой приём нам может в этом помочь, если требуется возвращать из метода значение:

1
2
3
4
5
async Task<bool> EnvelopeMethodAsync()
{
bool result = MethodSync();
return await Task.FromResult(result);
}

Если же метод должен возвращать Task и внутри вызывать void, тогда:

1
2
3
4
5
async Task EnvelopeMethodAsync()
{
MethodSync();
await Task.CompletedTask;
}

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

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

Особенности работы ValueTask

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

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

Начиная с .NET Core 2.0 в C# появились две структуры: ValueTask и ValueTask<T>, которые можно возвращать из асинхронных методов. Они размещаются в стэке и разработаны для увеличения производительности асинхронных методов в случаях, когда издержки на выделение памяти в куче критичны для производительности. Рантайм их не кэширует, что уменьшает выделение памяти и упрощает управление ею. Казалось бы, заменяй возвращаемый методом тип с Task на ValueTask (либо Task<T> на ValueTask<T>) и радуйся жизни, но есть пару но… как обычно…

Важным условие использования структур ValueTask/ValueTask<T> является обязательность ожидания их await-ом из асинхронного метода и невозможность их повторного вызова. Это может иметь большое значение, если вы разрабатываете библиотеки переиспользуемые другими разработчиками, т.к. у них сразу исключаются сценарии одновременной параллельной работы с тасками. Тут обычным таскам нет альтернативы.

Структура ValueTask<TResult> способна обернуть как TResult, так и Task<TResult>. Её можно возвращать из async метода, причём если этот метод выполнится синхронно, то никакого объекта в куче размещать не будет. В случае асинхронного выполнения объект Task<TResult> будет размещён в куче, а ValueTask<TResult> обернёт его, чтобы минимизировать размер структуры и оптимизировать случай успешного исполнения. Аsync метод, который завершается исключением, тоже будет размещать Task<TResult> внутри, и ValueTask<TResult> обернёт его и не будет таскать с собой дополнительное поле для хранения Exception.

Поэтому важно осознавать существующие значительные ограничения в применении ValueTask/ValueTask<T> в сравнении с Task/Task<T>, если отклониться от обычного сценария простого ожидания await.

Следующие операции никогда не должны выполняться с ValueTask/ValueTask<T>:

  • Повторное ожидание ValueTask/ValueTask<T>
    Объект результата может уже быть утилизирован и использоваться в другой операции. Напротив, Task/Task<T> никогда не переходит из завершённого состояния в незавершённое, поэтому вы можете повторно ожидать его столько раз, сколько потребуется и получать один и тот же результат каждый раз.

  • Параллельное ожидание ValueTask/ValueTask<T>
    Объект результата ожидает обработки только одним callbackом от одного потребителя в один момент времени, и попытка его ожидания из разных потоков одновременно может легко привести к гонкам и трудноуловимым ошибкам программы. В сравнении с этим, Task/Task<T> обеспечивает любое количество параллельных awaitов.

Если из метода получили ValueTask/ValueTask<T>, но необходимо выполнить одну из двух операций вышеописанных операций, то можно использовать .AsTask(). После этого работать с полученной такской как обычно, однако больше вы не сможете использовать изначальный ValueTask/ValueTask<T>.

1
2
3
public ValueTask<int> SomeMethodAsync() {...};
...
Task<int> t = SomeMethodAsync().AsTask();

Короче говоря, при применении ValueTask/ValueTask<T> вы должны или await его непосредственно (возможно с .ConfigureAwait(false)) или вызвать .AsTask() и больше его не использовать.

Однако ValueTask/ValueTask<T> будет отличным выбором когда:

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

Использование let для упрощения LINQ запросов

Бывает при выполнении LINQ запросов требуются промежуточные преобразования с переменными запроса. Обычно это достигается с помощью LINQ оператора let.

Мощь этого оператора рассмотрим на следующем примере. Допустим у нас есть класс Employee вида:

1
2
3
4
5
6
7
8
9
public class Employee
{
public int ID { get; set; }
public string Name { get; set; }
public string Department { get; set; }
public string Address { get; set; }
public string PostalCode { get; set; }
public string Salary { get; set; }
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
var employeesWithAddress = 
(
from employee in Employees
let fullAddress = $"{employee.PostalCode}, {employee.Address}"
let _salaryOutOk = decimal.TryParse(employee.Salary, out salary)
select new
{
employee.ID,
employee.Name,
fullAddress,
salary
}
).ToList();

В коде выше понятно использование промежуточного преобразования с помощью оператора let для fullAddress. Для salary же происходит преобразование из строкового типа в decimal, причём результат преобразования попадает в out переменную salary, а переменной _salaryOutOk можно пренебречь.

Всё это делает код чище и яснее, иначе преобразование для fullAddress пришлось бы выносить прямо в проекцию select, а конструкцию decimal.TryParse вообще бы не удалось вынести в проекцию, так что альтернативы let нет.

Let в LINQ - это сила!

Особенности Distinct на коллекциях объектов ссылочной природы

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

Чтобы заставить оператор Distinct работать корректно потребуется создать класс, реализующий интерфейс IEqualityComparer<T> и работающий с нашим классом (закрытый им). Этот интерфейс содержит всего два метода: bool Equals(T x, T y) и int GetHashCode(T obj). Следовательно, реализовав эти два метода мы научим Distinct сравнивать объекты нашего класса, т.к. оператор Distinct имеет перегрузку, принимающую экземпляр, реализующий IEqualityComparer закрытый нашим классом.

Допустим наш класс определён следующим образом:

1
2
3
4
5
6
public class Employee
{
public int ID { get; set; }
public string Name { get; set; }
public string Department { get; set; }
}

Тогда класс реализующий корректное сравнение экземпляров нашего класса будет выглядеть так:

1
2
3
4
5
6
7
8
9
10
11
12
public class EmployeeComparer: IEqualityComparer<Employee>
{
public bool Equals(Employee x, Employee y)
{
return x.ID == y.ID;
}

public int GetHashCode(Employee obj)
{
return obj.GetHashCode();
}
}

Теперь при вызове оператора Distinct на нашей коллекции ему требуется в параметрах передать экземпляр нашего сравнивателя EmployeeComparer:

1
var uniqueEmployees = employees.Distinct(new EmployeeComparer()).ToList();

Такой запрос уже вернёт список уникальных экземпляров нашего класса.

Разработка обслуживаемых программ на языке С#

Прочёл и рекомендую к прочтению книгу Джуста Виссера “Разработка обслуживаемых программ на языке С#”. Книга содержит набор критериев, выработанных консультантами Software Improvement Group после анализа сотен реальных систем. Авторами были сформулированы 10 простых рекомендаций, позволяющих писать программное обеспечение, которое легко поддерживать и развивать.

Перечилю эти 10 рекомендаций/критериев:

  1. Короткие блоки кода (max. 15 строк кода). Короткие блоки проще понять, тестировать и переиспользовать

  2. Простые блоки кода (max. 4 точки ветвления). Это упрощает модификацию и тестирование

  3. Не дублируйте код. При дублировании исправлять придётся в нескольких местах.

  4. Уменьшайте размеры интерфейсов (передавайте в блоки кода max. 4 параметра). Объединяйте параметры в объекты. Небольшие размеры интерфесов упростят понимание и переиспользование кода.

  5. Разделяйте задачи на модули (большие модули образуют тесные связи). Разделяйте сферы ответственности модулей и скрывайте детали реализации за интерфейсами. Такой код проще модифицировать и контролировать.

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

  7. Сбалансируйте архитектуру компонентов (min 6, max 12, optimal 9 компонентов одинакового размера). Проще искать код и обеспечить изоляцию.

  8. Следите за размером базы кода. Удаляйте лишний код. Предотвращайте разрастание кода. Небольшой размер продукта, проекта, команды - важный фактор успеха.

  9. Автоматизируйте тестирование. Это делает разработку предсказуемой и менее рискованной.

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

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

SPEC-файл RPM-пакета

SPEC-файл описывает как сборку, так и развёртывание RPM-пакета. SPEC-файл состоит из преамбулы и тела. В преамбуле указаны основные константы нашего будущего RPM пакета. Некоторые параметры являются необходимыми, другие - опциональными.

Необходимыми параметра в преамбуле нижеуказанного SPEC-файла являются:

  • Name
  • Version
  • Release
  • Source0
  • Group

Важно, чтобы имя архива (в нашем случае LinuxInstaller-2.15.2.4.tar.gz) и указанные в SPEC-файле параметры Name, Version и Release соответствовали друг другу!

Наш SPEC-файл разворачивает бинарники из архива LinuxInstaller-2.15.2.4.tar.gz, размещённого в папке SOURCES, в папку BUILD, копирует их в новую папку BUILDROOT и упаковывает их в RPM-пакет, который появится в папке RPMS (подпапке x86_64) в виде rpm-файла LinuxInstaller-2.15.2-4.x86_64.rpm.
При этом папки BUILD и BUILDROOT очистятся в случае успешной сборки (см. секцию %clean).

SPEC-файл описывает, что rpm-пакет разворачивает содержимое в папку /tmp/LinuxInstaller-2.15.2.4/, запускает bash-скрипт install командой /bin/bash /tmp/%{name}-%{version}.%{release}/install и после установки удаляет папку /tmp/LinuxInstaller-2.15.2.4/ командой:

1
rm -rf /tmp/%{name}-%{version}.%{release}/

Обратите внимание, что с помощью преамбулы Requires rpm-пакет проверяет наличие установленных пакетов/служб bash и cups, причем cups версии не ниже 2.2.

Секция if проверяет ответ, возвращяемый bash-скриптом install и в случае ошибки очищает временную папку за собой:

1
2
3
4
5
if [ $? -ne 0 ] ; then
echo "INSTALLATOR REPORTED NON-ZERO STATUS. OK FOR UPDATE, ERROR OTHERWISE! CLEAN UP AND EXIT..."
rm -rf /tmp/%{name}-%{version}.%{release}/
exit 1
fi

Окончательный вид содержимого файла LinuxInstaller-2.15.2.4.spec:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Name:           LinuxInstaller
Version: 2.15.2
Release: 4
Summary: LinuxInstaller

License: GPLv3+
URL: https://example.com/%{name}
Source0: %{name}-%{version}.%{release}.tar.gz

Group: System Environment/Daemons
ExclusiveArch: x86_64

Requires: bash
Requires: cups >= 2.2

%description
LinuxInstaller

%prep
%setup -q -n %{name}-%{version}.%{release}

%build

%install
%{__rm} -rf %{buildroot}
mkdir -p %{buildroot}/tmp
cp -r %{_builddir}/%{name}-%{version}.%{release} %{buildroot}/tmp/

%post
/bin/bash /tmp/%{name}-%{version}.%{release}/install

if [ $? -ne 0 ] ; then
echo "INSTALLATOR REPORTED NON-ZERO STATUS. OK FOR UPDATE, ERROR OTHERWISE! CLEAN UP AND EXIT..."
rm -rf /tmp/%{name}-%{version}.%{release}/
exit 1
fi

rm -rf /tmp/%{name}-%{version}.%{release}/

%clean
%{__rm} -rf %{buildroot}
%{__rm} -rf %{_builddir}/%{name}-%{version}.%{release}

%files
/tmp/%{name}-%{version}.%{release}


%changelog
* Tue Nov 30 2021 Artem Osta
- First package release with update option