Потокобезопасный асинхронный код в C # - PullRequest
4 голосов
/ 31 августа 2010

Я задал вопрос ниже пару недель назад.Теперь при рассмотрении моего вопроса и всех ответов мне в глаза бросилась очень важная деталь: во втором примере кода не выполняется DoTheCodeThatNeedsToRunAsynchronously() в основном потоке (UI)?Разве таймер не подождет секунду, а затем опубликует событие в главном потоке?Тогда это будет означать, что код, который необходим для асинхронного запуска, вообще не выполняется асинхронно?!

Оригинальный вопрос:


Недавно я сталкивался с проблемой несколько раз и решал ее по-разному, всегда не зная, является ли она поточно-безопасной или нет: мне нужно выполнить часть кода C # асинхронно.( Редактировать: я забыл упомянуть, что использую .NET 3.5! )

Этот фрагмент кода работает с объектом, который предоставляется кодом основного потока. (Правка. Предположим, что объект сам по себе является потокобезопасным.) Я представлю вам два способа, которыми я пытался (упрощенно), и у есть четыре вопроса :

  1. Как лучше всего добиться того, чего я хочу?Это один из двух или другой подход?
  2. Является ли один из двух способов не поточно-безопасным (боюсь, что оба ...) и почему?
  3. Первый подход создает поток и передает ему объект в конструкторе.Это то, как я должен передать объект?
  4. Второй подход использует таймер, который не предоставляет такую ​​возможность, поэтому я просто использую локальную переменную в анонимном делегате.Это безопасно или теоретически возможно, что ссылка в переменной изменяется до того, как она будет оценена кодом делегата?( Это очень общий вопрос, когда используются анонимные делегаты ).В Java вы вынуждены объявить локальную переменную как final (т.е. она не может быть изменена после назначения).В C # такой возможности нет, не так ли?

Подход 1: Тема

new Thread(new ParameterizedThreadStart(
    delegate(object parameter)
    {
        Thread.Sleep(1000); // wait a second (for a specific reason)

        MyObject myObject = (MyObject)parameter;

        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();

    })).Start(this.MyObject);

Есть одна проблема, с которой я столкнулсяподход: мой основной поток может произойти сбой, но процесс все еще сохраняется в памяти из-за потока зомби.


Подход 2: Таймер

MyObject myObject = this.MyObject;

System.Timers.Timer timer = new System.Timers.Timer();
timer.Interval = 1000;
timer.AutoReset = false; // i.e. only run the timer once.
timer.Elapsed += new System.Timers.ElapsedEventHandler(
    delegate(object sender, System.Timers.ElapsedEventArgs e)
    {
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
    });

DoSomeStuff();
myObject = that.MyObject; // hypothetical second assignment.

Локальная переменная myObject - это то, о чем я говорю в вопросе 4. Я добавил второе назначение в качестве примера.Представьте, что таймер истекает после второго назначения, будет ли код делегата работать на this.MyObject или that.MyObject?

Ответы [ 7 ]

5 голосов
/ 31 августа 2010

Является ли любой из этих фрагментов кода безопасным, имеет отношение к структуре MyObject экземпляров. В обоих случаях вы делите переменную myObject между потоками переднего плана и фона. Ничто не мешает потоку переднего плана изменять myObject во время работы фонового потока.

Это может быть или не быть безопасным и зависит от структуры MyObject. Однако, если вы специально не запланировали это, то это, безусловно, небезопасная операция.

4 голосов
/ 31 августа 2010

Я рекомендую использовать Task объекты и реструктурировать код так, чтобы фоновая задача возвращала вычисленное значение вместо изменения какого-либо общего состояния.

У меня есть блогзапись , в которой рассматриваются пять различных подходов к фоновым задачам (Task, BackgroundWorker, Delegate.BeginInvoke, ThreadPool.QueueUserWorkItem и Thread), с плюсами и минусами каждого.

Комуконкретно ответьте на свои вопросы:

  1. Как лучше всего добиться того, чего я хочу?Это один из двух или другого подхода? Лучшее решение - использовать объект Task вместо определенного Thread или обратного вызова таймера.См. Мой пост в блоге по всем причинам, но в итоге: Task поддерживает возврат результата , обратных вызовов при завершении , правильная обработка ошибок и интеграцияс универсальной системой отмены в .NET.
  2. Один из двух способов не является поточно-ориентированным (я боюсь оба ...) и почему? Как другиезаявил, что это полностью зависит от того, является ли MyObject.ChangeSomeProperty потокобезопасным.При работе с асинхронными системами легче рассуждать о безопасности потоков, когда каждая асинхронная операция не изменяет общее состояние и скорее возвращает результат .
  3. .Первый подход создает поток и передает ему объект в конструкторе.Это то, как я должен передать объект? Лично я предпочитаю использовать лямбда-привязку, которая более безопасна для типов (не требует приведения).
  4. Во втором подходе используется таймерчто не дает такой возможности, поэтому я просто использую локальную переменную в анонимном делегате.Это безопасно или теоретически возможно, что ссылка в переменной изменяется до того, как она будет оценена кодом делегата? Лямбда (и выражения делегата) связываются с переменными , а не с значениями, поэтому ответ да: ссылка может измениться, прежде чем она будет использована делегатом.Если ссылка может измениться, то обычное решение заключается в создании отдельной локальной переменной, которая используется только лямбда-выражением,

как таковое:

MyObject myObject = this.MyObject;
...
timer.AutoReset = false; // i.e. only run the timer once.
var localMyObject = myObject; // copy for lambda
timer.Elapsed += new System.Timers.ElapsedEventHandler(
  delegate(object sender, System.Timers.ElapsedEventArgs e)
  {
    DoTheCodeThatNeedsToRunAsynchronously();
    localMyObject.ChangeSomeProperty();
  });
// Now myObject can change without affecting timer.Elapsed

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

Мое рекомендуемое решение (с использованием Task) будет выглядеть примерно так:

var ui = TaskScheduler.FromCurrentSynchronizationContext();
var localMyObject = this.myObject;
Task.Factory.StartNew(() =>
{
  // Run asynchronously on a ThreadPool thread.
  Thread.Sleep(1000); // TODO: review if you *really* need this   

  return DoTheCodeThatNeedsToRunAsynchronously();   
}).ContinueWith(task =>
{
  // Run on the UI thread when the ThreadPool thread returns a result.
  if (task.IsFaulted)
  {
    // Do some error handling with task.Exception
  }
  else
  {
    localMyObject.ChangeSomeProperty(task.Result);
  }
}, ui);

Обратите внимание, что, поскольку поток пользовательского интерфейса вызывает MyObject.ChangeSomeProperty, этот метод не должен быть потокобезопасным.Конечно, DoTheCodeThatNeedsToRunAsynchronously все еще должен быть потокобезопасным.

3 голосов
/ 31 августа 2010

"Thread-safe" - хитрый зверь. При обоих подходах проблема заключается в том, что «MyObject», используемый вашим потоком, может быть изменен / прочитан несколькими потоками таким образом, что состояние выглядит несовместимым или поведение потока не соответствует фактическому состоянию.

Например, скажите, что ваш MyObject.ChangeSomeproperty() ДОЛЖЕН быть вызван до MyObject.DoSomethingElse(), иначе он скинет. При любом из ваших подходов ничто не мешает любому другому потоку вызвать DoSomethingElse() до завершения потока, который вызовет ChangeSomeProperty().

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

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

Лично я бы не использовал второй подход. Если у вас возникли проблемы с потоками "зомби", задайте для IsBackground значение true в потоке.

3 голосов
/ 31 августа 2010

Ваша первая попытка довольно хорошая, но поток продолжал существовать даже после выхода из приложения, поскольку вы не установили для свойства IsBackground значение true ... вот упрощенная (и улучшенная) версияВаш код:

MyObject myObject = this.MyObject;
Thread t = new Thread(()=>
    {
        Thread.Sleep(1000); // wait a second (for a specific reason)
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
    });
t.IsBackground = true;
t.Start();

Что касается безопасности потоков: трудно сказать, правильно ли работает ваша программа, когда несколько потоков выполняются одновременно, потому что вы не демонстрируете нам какие-либо спорные моменты в своем примере. очень возможно, что у вас возникнут проблемы с параллелизмом, если ваша программа конфликтует по MyObject.

В Java есть ключевое слово final, а в C # - соответствующее ключевое слово readonly, нони final, ни readonly не гарантируют, что состояние изменяемого объекта будет согласованным между потоками.Единственное, что делают эти ключевые слова, это гарантируют, что вы не измените ссылку, на которую указывает объект.Если два потока имеют конфликт чтения / записи для одного и того же объекта, то вам следует выполнить какой-либо тип синхронизации или элементарные операции с этим объектом для обеспечения безопасности потока.

Обновить

ОК, еслиВы изменяете ссылку, на которую указывает myObject, тогда ваша конкуренция теперь составляет myObject.Я уверен, что мой ответ не будет соответствовать вашей реальной ситуации на 100%, но, учитывая приведенный вами пример кода, я могу сказать вам, что произойдет:

Вы будете не будьте уверены, какой объект будет изменен: это может быть that.MyObject или this.MyObject.Это верно независимо от того, работаете ли вы с Java или C #.Планировщик может запланировать выполнение вашего потока / таймера до, после или во время второго назначения.Если вы рассчитываете на определенный порядок исполнения, вам нужно сделать что-то , чтобы обеспечить этот порядок исполнения.Обычно это что-то является связью между потоками в форме сигнала: ManualResetEvent, Join или что-то еще.

Вот пример соединения:

MyObject myObject = this.MyObject;
Thread task = new Thread(()=>
    {
        Thread.Sleep(1000); // wait a second (for a specific reason)
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
    });
task.IsBackground = true;
task.Start();
task.Join(); // blocks the main thread until the task thread is finished
myObject = that.MyObject; // the assignment will happen after the task is complete

Вот пример ManualResetEvent:

ManualResetEvent done = new ManualResetEvent(false);
MyObject myObject = this.MyObject;
Thread task = new Thread(()=>
    {
        Thread.Sleep(1000); // wait a second (for a specific reason)
        DoTheCodeThatNeedsToRunAsynchronously();
        myObject.ChangeSomeProperty();
        done.Set();
    });
task.IsBackground = true;
task.Start();
done.WaitOne(); // blocks the main thread until the task thread signals it's done
myObject = that.MyObject; // the assignment will happen after the task is done

Конечно, в этом случае бессмысленно даже создавать множественныепотоки, так как вы не позволите им работать одновременно.Один из способов избежать этого - не изменять ссылку на myObject после запуска потока, тогда вам не нужно будет Join или WaitOne для ManualResetEvent.

Итак, это приводит меня к вопросу: почему вы назначаете новый объект для myObject?Является ли это частью цикла for, который запускает несколько потоков для выполнения нескольких асинхронных задач?

2 голосов
/ 31 августа 2010

На мой взгляд, более серьезной проблемой безопасности нитей может быть 1-секундный сон.Если это требуется для синхронизации с какой-либо другой операцией (давая время для ее завершения), тогда я настоятельно рекомендую использовать правильный шаблон синхронизации, а не полагаться на режим сна. Monitor.Pulse или AutoResetEvent - это два распространенных способа достижения синхронизации.Оба должны быть использованы осторожно, так как легко представить тонкие условия гонки.Тем не менее, использование Sleep для синхронизации - это состояние гонки, которое должно произойти.

Кроме того, если вы хотите использовать поток (и не имеете доступа к библиотеке параллельных задач в .NET 4.0), тогда ThreadPool.QueueUserWorkItem предпочтительнее для краткосрочных задач,Потоки потоков также не будут вешать приложение, если оно умирает, если нет какой-либо тупиковой ситуации, препятствующей потере не фонового потока.

2 голосов
/ 31 августа 2010

Как лучше всего добиться того, чего я хочу? Это один из двух или другой подход?

Оба выглядят хорошо, но ...

Не является ли один из двух способов поточно-ориентированным (боюсь, что оба ...) и почему?

... они не являются поточно-ориентированными , если MyObject.ChangeSomeProperty() не является поточно-ориентированными.

Первый подход создает поток и передает ему объект в конструкторе. Так я должен передать объект?

Да. Использование замыкания (как в вашем втором подходе) также хорошо, с дополнительным преимуществом, что вам не нужно делать приведение.

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

Конечно, если вы добавите myObject = null; непосредственно после установки timer.Elapsed, код в вашей ветке потерпит неудачу. Но почему вы хотите это сделать? Обратите внимание, что изменение this.MyObject не повлияет на переменную, захваченную в вашем потоке.


Итак, как сделать этот потокобезопасным? Проблема в том, что myObject.ChangeSomeProperty(); может работать параллельно с другим кодом, который изменяет состояние myObject. Для этого есть два основных решения:

Опция 1 : Выполнить myObject.ChangeSomeProperty() в главном разделе пользовательского интерфейса. Это самое простое решение, если ChangeSomeProperty быстро. Вы можете использовать Dispatcher (WPF) или Control.Invoke (WinForms), чтобы вернуться к потоку пользовательского интерфейса, но самый простой способ - использовать BackgroundWorker:

MyObject myObject = this.MyObject;
var bw = new BackgroundWorker();

bw.DoWork += (sender, args) => {
    // this will happen in a separate thread
    Thread.Sleep(1000);
    DoTheCodeThatNeedsToRunAsynchronously();
}

bw.RunWorkerCompleted += (sender, args) => {
    // We are back in the UI thread here.

    if (args.Error != null)  // if an exception occurred during DoWork,
        MessageBox.Show(args.Error.ToString());  // do your error handling here
    else
        myObject.ChangeSomeProperty();
}

bw.RunWorkerAsync(); // start the background worker

Опция 2 : сделать код в ChangeSomeProperty() поточно-ориентированным, используя ключевое слово lock (внутри ChangeSomeProperty, а также внутри любого другого метода, изменяющего или считывающего то же поле поддержки).

1 голос
/ 31 августа 2010

Одна вещь, не упомянутая до сих пор: Выбор методов многопоточности в значительной степени зависит от того, что именно делает DoTheCodeThatNeedsToRunAsynchronously().

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

Некоторые механизмы потоков .NET, такие как ThreadPool.QueueUserWorkItem(), предназначены для использованиянедолговечные нити.Они избегают затрат на создание потока, используя «переработанные» потоки, но количество потоков, которые он будет перерабатывать, ограничено, поэтому долго выполняющаяся задача не должна захватывать потоки ThreadPool.

Другие вариантырассмотрим использование:

  • ThreadPool.QueueUserWorkItem() - удобное средство для запуска небольших задач в потоке ThreadPool

  • System.Threading.Tasks.Taskэто новая функция в .NET 4, которая позволяет выполнять небольшие задачи в асинхронном / параллельном режиме.

  • Delegate.BeginInvoke() и Delegate.EndInvoke() (BeginInvoke() будет выполнять код асинхронно, но важно, чтобы вы также вызывали EndInvoke(), чтобы избежать потенциальных утечек ресурсов.также, как я полагаю, основан на потоках ThreadPool.

  • System.Threading.Thread, как показано в вашем примере. Потоки обеспечивают максимальный контроль, но также являются более дорогостоящими, чем другие методы - поэтому ониидеально подходит для длительных задач или многопоточности, ориентированной на детали.

В целом, я лично предпочел использовать Delegate.BeginInvoke()/EndInvoke() - похоже, он обеспечивает хороший баланс между контролем и простотой использования.

...