Похоже, что удаление сгенерированных IAsyncEnumerable
IAsyncEnumerator
не запускает токен отмены базового IAsyncEnumerable
.
В частности, этот модульный тест не выполняет единственное утверждение:
[Test]
public async Task test()
{
await foreach (var value in TestData())
break;
}
private static async IAsyncEnumerable<int> TestData([EnumeratorCancellation] CancellationToken ct = default)
{
try
{
yield return 1;
await Task.CompletedTask;
yield return 2;
}
finally
{
Assert.That(ct.IsCancellationRequested, Is.True);
}
}
Я крайне удивлен таким поведением. Существует ли какой-либо стандартный подход к отмене CancellationToken TestData в этом сценарии? Я мог бы реализовать собственный оператор, но это кажется крайне неуклюжим, поэтому я надеюсь, что есть какой-нибудь лучший способ сделать это.
[Test]
public async Task this_test_passes()
{
await foreach (var value in CancelWhenUnsubscribed(TestData()))
break;
}
public static async IAsyncEnumerable<T> CancelWhenUnsubscribed<T>(IAsyncEnumerable<T> source, [EnumeratorCancellation] CancellationToken ct = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
await using var en = source.GetAsyncEnumerator(cts.Token);
using var _ = Disposable.Create(() => cts.Cancel());
while (await en.MoveNextAsync())
yield return en.Current;
}
Для контекста, почему я пытаюсь это сделать: я использую System.Interactive.Async для объединения нескольких последовательностей IAsyncEnumerable.
Его реализация работает таким образом, что при удалении объединенной последовательности он сначала ожидает завершения всех выполняемых задач MoveNext
и только затем удаляет и завершает объединенную последовательность.
В моем конкретном случае это означает, что мне нужно отменить базовый IAsyncEnumerables, когда объединенный объект завершается, иначе я столкнусь с квази-тупиками. В качестве простого примера, этот тест зависает:
[Test]
public async Task this_test_gets_stuck()
{
await foreach (var value in AsyncEnumerableEx.Merge([TestData(), TestData()]))
break;
}
private static async IAsyncEnumerable<int> TestData([EnumeratorCancellation] CancellationToken ct = default)
{
yield return 1;
await Task.Delay(TimeSpan.FromHours(100), ct);
yield return 2;
}
Токен отмены не передается TestData
, так что же удивлять? CancellationToken ct = default
означает, что ct
имеет значение по умолчанию: Нет. Отмена инициируется CancellationTokenSource, а не самим CancellationToken. CancellationToken используется только для наблюдения за отменой.
@MarcGravell Хорошая мысль. Поскольку TestData переписана компилятором, я полагаю, что в странном мире можно было бы переписать ее таким образом, чтобы cancelToken, на который ссылается функция, был бы заменен токеном, который связан с исходным аргументом, но также удален на выходе. Но, по общему признанию, если подумать о точной семантике, это, вероятно, было бы более удивительным / сделало бы невозможным различие между выходом и отменой (хотя я не уверен, что могу вспомнить много разумных реальных случаев использования, когда кто-то предпочел бы, чтобы токен не был отменен. на выходе)
@Bogey, в конечном счете, отмена существует, чтобы обеспечить отмену во время асинхронных операций; простое прекращение итерации происходит вне асинхронного потока и уже хорошо поддерживается через finally
Я не понимаю, почему ты удивляешься. Итератор TestData
не владеет CancellationToken
, поэтому он не обязан его отменять, даже если бы мог. На самом деле он не может его отменить по причинам, обсуждаемым в этом вопросе:
Если вы вызываете TestData
без передачи CancellationToken
и без использования метода расширения WithCancellation , то ct.CanBeCanceled будет false
. В этом случае было бы абсурдно ожидать автоматической отмены токена, который не может быть отменен.
[1/2] Скажем так, для меня это удивительно на интуитивном уровне. Если вы покажете, например. мой последний пример (слияние) для случайной выборки разработчиков, я не уверен, сколько из них заметят проблему/зависание вызова (я, по общему признанию, этого не заметил). Но вы правы в том смысле, что если подумать о том, как это точно преобразуется в скомпилированный сгенерированный код и работает с точки зрения задействованных IAsyncEnumerators, это становится несколько более понятным.
[2/2] Мое очень личное мнение таково, что такое поведение все еще немного неприятно, поскольку, по крайней мере, лично у меня нет абсолютно никаких случаев использования, в которых я бы не хотел отменять операцию при выходе (и полагаю, что это, вероятно, более желательное поведение). почти во всех случаях), так что это немного «ощущается» как нечто, для чего, возможно, должно существовать ясное, краткое и простое нестандартное решение - но это, конечно, то, что есть.
@Богги, мы можем только догадываться. Я лично предполагаю, что почти 100% случайно выбранных разработчиков, знакомых с CancellationToken
, ожидают, что он не будет автоматически отменен. Честно говоря, это больше похоже на личное заблуждение. Это нормально. У каждого есть несколько таких, в том числе и у меня.
Больше не читать последовательность: это не отмена. Это разные понятия, и, по моему мнению, они работают правильно. Более того, поскольку токен передается извне через
WithCancellation
, итератор буквально не может инициировать отмену: для этого вам нужен CTS, а не только CT. У вас уже естьfinally
— возможно, вы сможете создать свой собственный «связанный» CTS, который дополнительно будет запускаться сfinally
?