Асинхронные задачи и блокировки

У меня есть список элементов, которые должны быть обновлены двумя процессами. Первый - это поток пользовательского интерфейса (контролируемый пользователем), второй - фоновый процесс, который извлекает информацию из веб-службы.

Поскольку этот второй процесс связан с I/O, он подходит для задач async. Это приводит меня к нескольким вопросам:

  • Так как задачи async не запускаются в отдельных потоках, кажется, что мне не нужна какая-либо блокировка при обновлении этого списка, правильно?

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

  • Я говорю о приложении Windows Forms. Возможно, в будущем я хочу, чтобы это запускалось как консольное приложение. AFAIK, в консольных приложениях Задачи Async выполняются на отдельных потоках. Какую привилегированную идиому задавать задачу, если она работает в отдельном потоке? Таким образом, я могу установить блокировку, когда это необходимо.

  • Тот факт, что я не знаю, действительно ли мне нужен замок, заставляет меня удивляться, что это лучший дизайн или нет. Имеет ли смысл придерживаться Task.Run() даже для такого типа связанного кода IO?

Ответ 1

Так как задачи async не запускаются на отдельных потоках, кажется, мне не нужны любой вид блокировки при обновлении этого списка, правильно?

Нет гарантии, что вы не используете Task.Run и отмечаете его метод async. Связанные с IO асинхронные задачи скорее всего не используют какой-то поток за кулисами, но это не всегда так. Вы не должны полагаться на это для правильности вашего кода. Вы можете убедиться, что ваш код работает в потоке пользовательского интерфейса, обернув его другим методом async, который не использует ConfigureAwait(false). Вы всегда можете использовать параллельные коллекции, данные в рамках. Возможно, вам понадобится пакет ConcurrentBag или BlockingCollection.

AFAIK, в консольных приложениях Задачи Async выполняются на отдельных потоках. Какая привилегированная идиома задает задачу, если она работает на отдельном нить?

Это неверно. Операции async от самих себя не запускаются на отдельные потоки только потому, что они находятся в консольном приложении. Проще говоря, по умолчанию TaskScheduler в консольном приложении есть значение по умолчанию ThreadPoolTaskScheduler, которое будет останавливать любое продолжение в потоке threadpool, поскольку консоль не имеет такой сущности, называемой нитью ui. Как правило, все о SynchronizationContext

Тот факт, что я не знаю, действительно ли мне нужен замок, заставляет меня задаться вопросом это лучший дизайн или нет. Имело бы смысл придерживаться Task.Run() даже для такого типа привязанного кода IO?

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

Нет необходимости использовать поток threadpool для выполнения async IO. Весь смысл асинхронного ввода-вывода заключается в том, что вы можете освободить вызывающий поток, выполняющий IO, для обработки большей работы во время обработки запроса.

Ответ 2

Так как задачи async не запускаются в отдельных потоках, кажется, что мне не нужна никакая блокировка при обновлении этого списка, правильно?

Верно. Этот подход хорошо работает, если вы следуете функциональному шаблону (т.е. Каждая фоновая операция вернет свой результат, а не обновит общие данные). Итак, что-то вроде этого будет хорошо работать:

async Task BackgroundWorkAsync() // Called from UI thread
{
  while (moreToProcess)
  {
    var item = await GetItemAsync();
    Items.Add(item);
  }
}

В этом случае не имеет значения, как реализуется GetItemAsync. Он может использовать Task.Run или ConfigureAwait(false) все, что он хочет - BackgroundWorkAsync всегда будет синхронизироваться с потоком пользовательского интерфейса перед добавлением элемента в коллекцию.

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

"Асинхронные задачи" не запускаются вообще. Если это сбивает с толку, у меня есть асинхронное введение, которое может быть полезно.

Каждый асинхронный метод запускается синхронно. Когда он попадает в await, он (по умолчанию) фиксирует текущий контекст и позже использует это, чтобы возобновить выполнение метода. Итак, что происходит, когда он вызывается из потока пользовательского интерфейса, заключается в том, что метод async возобновляется в захваченном контексте пользовательского интерфейса. Консольные приложения не предоставляют контекст, поэтому метод async возобновляется в потоке пула потоков.

Какая предпочтительная идиома задает задачу, если она работает в отдельном потоке? Таким образом, я могу установить блокировку, когда это необходимо.

Я бы порекомендовал дизайн, который не задавал бы такие вопросы о потоке. Во-первых, вы можете просто использовать простой lock - они очень быстры, когда нет конкуренции:

async Task BackgroundWorkAsync() // Called from any thread
{
  while (moreToProcess)
  {
    var item = await GetItemAsync();
    lock (_mutex)
        Items.Add(item);
  }
}

В качестве альтернативы вы можете записать, что компонент зависит от предоставленного в определенный момент контекста и использовать что-то вроде AsyncContext из моей библиотеки AsyncEx для консольное приложение.

Ответ 3

Asyc-await фиксирует контекст синхронизации перед оператором await, а затем по умолчанию запускает продолжение в том же контексте после оператора await. Контекст синхронизации для потока пользовательского интерфейса связан только с одним потоком, поэтому в таком сценарии вы можете в конечном итоге всегда обновлять список из потока пользовательского интерфейса.

Но если кто-то меняет код на вызов ConfigureAwait(false) после того, как он ожидает продолжения, он не будет запускаться в исходном контексте синхронизации, и вы можете закончить обновление списка в одном из потоков пула потоков.

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

  • ИМХО гораздо лучше просто использовать синхронизированную коллекцию вместо того, чтобы полагаться на список, обновляемый из того же потока.

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

  • В этом случае я бы использовал синхронизированную коллекцию или SempahoreSlim. Для консольного приложения используется контекст синхронизации потока пула, и продолжения могут работать в любом потоке пула потоков.

  • Имеет смысл использовать async-wait для связанного кода IO, поскольку он не потребляет поток.

Я хотел бы использовать async-await и изменить использование потоковой коллекции или синхронизации с помощью SemaphoreSlim