Как я могу заставить `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()
в моем итераторе? Как?
- Как я могу думать о асинхронной парадигме по-другому, чтобы понять, как она работает в этом типе ситуации?
Ответы
Ответ 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 года), но все же очень важно.