Код не выполняется в итераторе IAsyncEnumerable, после блокировки в конце - PullRequest
2 голосов
/ 03 мая 2020

Возникает странная проблема при перечислении IAsyncEnumerable, когда оператор System.Linq.Asyn c Take присоединен к Это. В моем итераторе у меня есть блок try-finally с некоторыми значениями, полученными внутри блока try, и некоторый код очистки внутри блока finally. Код очистки находится внутри блока lock. Проблема в том, что любой код, следующий за блоком lock, не выполняется. Никаких исключений не выдается, код просто игнорируется, как будто его там нет. Вот программа , которая воспроизводит это поведение:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public class Program
{
    static async Task Main()
    {
        await foreach (var item in GetStream().Take(1))
        {
            Console.WriteLine($"Received: {item}");
        }
        Console.WriteLine($"Done");
    }

    static async IAsyncEnumerable<int> GetStream()
    {
        var locker = new object();
        await Task.Delay(100);
        try
        {
            yield return 1;
            yield return 2;
        }
        finally
        {
            Console.WriteLine($"Finally before lock");
            lock (locker) { /* Clean up */ }
            Console.WriteLine($"Finally after lock");
        }
    }
}

Вывод:

Received: 1
Finally before lock
Done

Текст "Наконец, после блокировки" не напечатано в консоли!

Это происходит только с подключенным оператором Take. Без оператора текст печатается как положено.

Это ошибка в библиотеке System.Linq.Async, ошибка в компиляторе C# или что-то еще?

В качестве обходного пути я в настоящее время я использую вложенный блок try-finally внутри finally, который работает, но это неудобно:

finally
{
    try
    {
        lock (locker) { /* Clean up */ }
    }
    finally
    {
        Console.WriteLine($"Finally after lock");
    }
}

. NET Core 3.1.3,. NET Framework 4.8.4150.0, C# 8, System.Linq.Asyn c 4.1.1, Visual Studio 16.5.4, консольное приложение

1 Ответ

3 голосов
/ 04 мая 2020

Не стал бы утверждать, что я полностью понимаю проблему и как ее исправить (и чья это вина), но вот что я обнаружил:

Прежде всего, блок finally переводится на следующий IL:

  IL_017c: ldarg.0      // this
  IL_017d: ldfld        bool TestAsyncEnum.Program/'<GetStream>d__1'::'<>w__disposeMode'
  IL_0182: brfalse.s    IL_0186
  IL_0184: br.s         IL_0199
  IL_0186: ldarg.0      // this
  IL_0187: ldnull
  IL_0188: stfld        object TestAsyncEnum.Program/'<GetStream>d__1'::'<>s__2'

  // [37 17 - 37 58]
  IL_018d: ldstr        "Finally after lock"
  IL_0192: call         void [System.Console]System.Console::WriteLine(string)
  IL_0197: nop

  // [38 13 - 38 14]
  IL_0198: nop

  IL_0199: endfinally
} // end of finally

Как видите, сгенерированный компилятором код имеет следующее ветвление IL_017d: ldfld bool TestAsyncEnum.Program/'<GetStream>d__1'::'<>w__disposeMode', которое будет запускать код после оператора lock, только если сгенерированный перечислитель не находится в disposeMode.

System.Linq.Async имеет два оператора, которые внутренне используют AsyncEnumerablePartition - Skip и Take. Разница в том, что когда Take завершает работу, он не запускает базовый перечислитель до завершения, а Skip выполняет (я немного пояснил здесь, причина не смотрел в базовую реализацию), поэтому, когда код удаления запускается для Take case disposeMode установлен в true и эта часть кода не выполняется.

Вот класс (основанный на том, что происходит в nuget) для воспроизведения проблемы:

public class MyAsyncIterator<T> : IAsyncEnumerable<T>, IAsyncEnumerator<T>
{
    private readonly IAsyncEnumerable<T> _source;
    private IAsyncEnumerator<T>? _enumerator;
     T _current = default!;
    public T Current => _current;

    public MyAsyncIterator(IAsyncEnumerable<T> source)
    {
        _source = source;
    }

    public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = new CancellationToken()) => this;

    public async ValueTask DisposeAsync()
    {
        if (_enumerator != null)
        {
            await _enumerator.DisposeAsync().ConfigureAwait(false);
            _enumerator = null;
        }
    }

    private int _taken;
    public async ValueTask<bool> MoveNextAsync()
    {
        _enumerator ??= _source.GetAsyncEnumerator();

        if (_taken < 1 && await _enumerator!.MoveNextAsync().ConfigureAwait(false))
        {
            _taken++; // COMMENTING IT OUT MAKES IT WORK
            _current = _enumerator.Current;
            return true;
        }

        return false;
    }
}

И использование в вашем коде await foreach (var item in new MyAsyncIterator<int>(GetStream()))

Я бы сказал, что это какая-то проблема с компилятором в крайнем случае, потому что кажется, что он странным образом обрабатывает весь код после блоков finally, например, если вы добавляете Console.WriteLine("After global finally"); в конец GetStream не будет напечатано и в том случае, если итератор не «завершен». Ваш обходной путь работает, потому что WriteLine находится в блоке finally.

Отправленный вопрос на github , посмотрим, что скажет команда tnet.

...