Подтвердить что ты не робот

Как я могу заставить `await...` работать с `yield return` (т.е. Внутри метода итератора)?

У меня есть код, похожий на:

IEnumerable<SomeClass> GetStuff()
{
    using (SqlConnection conn = new SqlConnection(connectionString))
    using (SqlCommand cmd = new SqlCommand(sql, conn)
    {
        conn.Open();
        SqlDataReader reader = cmd.ExecuteReader();
        while (reader.Read())
        {
            SomeClass someClass = f(reader); // create instance based on returned row
            yield return someClass;
        }
    } 
}

Кажется, я мог бы выиграть, используя reader.ReadAsync(). Однако, если я просто изменяю одну строку:

        while (await reader.ReadAsync())

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

async Task<IEnumerable<SomeClass>> GetStuff()

Однако выполнение этого делает GetStuff() непригодным, потому что:

Тело GetStuff() не может быть блоком итератора, потому что Task<IEnumerable<SomeClass>> не является типом интерфейса итератора.

Я уверен, что мне не хватает ключевой концепции с асинхронной моделью программирования.

Вопросы:

  • Можно ли использовать ReadAsync() в моем итераторе? Как?
  • Как я могу думать о асинхронной парадигме по-другому, чтобы понять, как она работает в этом типе ситуации?
4b9b3361

Ответ 1

Проблема в том, что вы просите, на самом деле не имеет большого смысла. IEnumerable<T> - это синхронный интерфейс, а возврат Task<IEnumerable<T>> не поможет вам, потому что какой-то поток должен блокировать ожидание каждого элемента, независимо от того, что.

То, что вы действительно хотите вернуть, - это асинхронная альтернатива IEnumerable<T>: что-то вроде IObservable<T>, блок потока данных из потока данных TPL или IAsyncEnumerable<T>.

Используя TPL Dataflow, один из способов сделать это:

ISourceBlock<SomeClass> GetStuff() {
    var block = new BufferBlock<SomeClass>();

    Task.Run(async () =>
    {
        using (SqlConnection conn = new SqlConnection(connectionString))
        using (SqlCommand cmd = new SqlCommand(sql, conn))
        {
            await conn.OpenAsync();
            SqlDataReader reader = await cmd.ExecuteReaderAsync();
            while (await reader.ReadAsync())
            {
                SomeClass someClass;
                // Create an instance of SomeClass based on row returned.
                block.Post(someClass);
            }
            block.Complete();
        } 
    });

    return block;
}

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

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

Ответ 2

Нет, вы не можете использовать async с блоком итератора. Как говорит svick, для этого вам понадобится что-то вроде IAsyncEnumerable.

Если у вас есть возвращаемое значение Task<IEnumerable<SomeClass>>, это означает, что функция возвращает единственный объект Task, который после завершения предоставит вам полностью сформированный IEnumerable (нет места для асинхронности Task в этом перечисляемом). Когда объект задачи будет завершен, вызывающий должен иметь возможность синхронно перебирать все элементы, которые он возвращал в перечислимом.

Вот решение, которое возвращает Task<IEnumerable<SomeClass>>. Вы можете получить большую часть преимуществ async, выполнив что-то вроде этого:

async Task<IEnumerable<SomeClass>> GetStuff()
{
    using (SqlConnection conn = new SqlConnection(""))
    {
        using (SqlCommand cmd = new SqlCommand("", conn))
        {
            await conn.OpenAsync();
            SqlDataReader reader = await cmd.ExecuteReaderAsync();
            return ReadItems(reader).ToArray();
        }
    }
}

IEnumerable<SomeClass> ReadItems(SqlDataReader reader)
{
    while (reader.Read())
    {
        // Create an instance of SomeClass based on row returned.
        SomeClass someClass = null;
        yield return someClass;
    }
}

... и пример использования:

async void Caller()
{
    // Calls get-stuff, which returns immediately with a Task
    Task<IEnumerable<SomeClass>> itemsAsync = GetStuff();
    // Wait for the task to complete so we can get the items
    IEnumerable<SomeClass> items = await itemsAsync;
    // Iterate synchronously through the items which are all already present
    foreach (SomeClass item in items)
    {
        Console.WriteLine(item);
    }
}

Здесь у вас есть часть итератора и асинхронная часть в отдельных функциях, которая позволяет использовать как синтаксис async, так и yield. Функция GetStuff асинхронно приобретает данные, а ReadItems затем синхронно считывает данные в перечислимую.

Обратите внимание на вызов ToArray(). Что-то вроде этого необходимо, потому что функция перечислителя выполняется лениво, и поэтому ваша функция асинхронизации может иначе использовать соединение и команду перед тем, как все данные будут прочитаны. Это связано с тем, что блоки using охватывают продолжительность выполнения Task, но вы должны повторить его after. Задача завершена.

Это решение не использует ReadAsync, но использует OpenAsync и ExecuteReaderAsync, что, вероятно, дает вам большую пользу. По моему опыту, ExecuteReader будет занимать больше времени и приносит наибольшую пользу асинхронному использованию. К моменту, когда я прочитал первую строку, SqlDataReader уже имеет все остальные строки, а ReadAsync просто возвращается синхронно. Если это так и для вас, то вы не получите существенной выгоды, перейдя в систему на основе push, например IObservable<T> (которая потребует значительных изменений в вызывающей функции).

Для иллюстрации рассмотрим альтернативный подход к той же проблеме:

IEnumerable<Task<SomeClass>> GetStuff()
{
    using (SqlConnection conn = new SqlConnection(""))
    {
        using (SqlCommand cmd = new SqlCommand("", conn))
        {
            conn.Open();
            SqlDataReader reader = cmd.ExecuteReader();
            while (true)
                yield return ReadItem(reader);
        }
    }
}

async Task<SomeClass> ReadItem(SqlDataReader reader)
{
    if (await reader.ReadAsync())
    {
        // Create an instance of SomeClass based on row returned.
        SomeClass someClass = null;
        return someClass;
    }
    else
        return null; // Mark end of sequence
}

... и пример использования:

async void Caller()
{
    // Synchronously get a list of Tasks
    IEnumerable<Task<SomeClass>> items = GetStuff();
    // Iterate through the Tasks
    foreach (Task<SomeClass> itemAsync in items)
    {
        // Wait for the task to complete. We need to wait for 
        // it to complete before we can know if it the end of
        // the sequence
        SomeClass item = await itemAsync;
        // End of sequence?
        if (item == null) 
            break;
        Console.WriteLine(item);
    }
}

В этом случае GetStuff немедленно возвращается с перечислимым, где каждый элемент в перечисляемом является задачей, которая будет представлять объект SomeClass при завершении. Этот подход имеет несколько недостатков. Во-первых, перечислимое возвращается синхронно, поэтому в момент его возвращения мы на самом деле не знаем, сколько строк находится в результате, поэтому я сделал его бесконечной последовательностью. Это совершенно законно, но имеет некоторые побочные эффекты. Мне нужно было использовать null, чтобы сигнализировать конец полезных данных в бесконечной последовательности задач. Во-вторых, вы должны быть осторожны в том, как вы его повторяете. Вам нужно перебрать его вперед, и вам нужно подождать каждую строку перед повторением в следующую строку. Вы должны также избавиться от итератора только после завершения всех задач, чтобы GC не собирал соединение до того, как оно закончилось. По этим причинам это не безопасное решение, и я должен подчеркнуть, что я включил его для иллюстрации, чтобы ответить на ваш второй вопрос.

Ответ 3

Говоря строго о async-итераторе (или есть возможность) в контексте SqlCommand, в моем опыте я заметил, что синхронная версия кода значительно превосходит ее async. Как в скорости, так и в памяти.

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

Не поймите меня неправильно, асинхронная/ждущая парадигма в среде .NET феноменально простая, мощная и полезная, учитывая правильные обстоятельства. Однако после многих трудностей я не уверен, что доступ к базе данных является правильным вариантом использования. Если, конечно, вам не нужно выполнять несколько команд одновременно, и в этом случае вы можете просто использовать TPL, чтобы отключить команды в унисон.

Мой предпочтительный подход скорее состоит в том, чтобы принять следующие соображения:

  • Сохраняйте, чтобы единицы SQL работали небольшими, простыми и сложными (т.е. делали ваши SQL-исполнения "дешевыми" ).
  • Избегайте выполнения работы на SQL Server, которая может быть направлена ​​вверх на уровень приложения. Прекрасным примером этого является сортировка.
  • Самое главное, протестируйте свой код SQL по шкале и просмотрите план вывода/выполнения статистики IO. Запрос, который быстро запускается с записью 10k, может (и, вероятно, будет) вести себя совершенно по-другому, когда есть записи 1M.

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

В этой теме есть фантастическая статья евангелиста Microsoft Рика Андерсона. Имейте это в виду (с 2009 года), но все же очень важно.