Главная » C# » Синхронизация потоков в Visual C# (Sharp)

0

Если несколько потоков разделяют состояние (например, переменную), то может возникнуть проблема параллельного использования данных. Совместное использание состояния независимыми потоками не представляет проблемы, если все поки обращаются к данным только для чтения. Но что бы произошло, если бы на многоядерной машине (см. рис.  13.3) поток одного ядра читает состояние объекта, а поток другого ядра модифицирует это же состояние? Какое состояние прочитает первый поток, до или после модификации? Будет ли прочитанное состояние дейсительным? Скорее всего, нет, и поэтому доступ потоков к состоянию необходимо синхронизировать.

Рассмотрим пример простой коллекции .NET. Следующий исходный код выполнтся  в  вызывающем  потоке,  создает  коллекцию  списков,  после  чего  добавляет в коллекцию два члена:

List<int> elements = new List<int>(); elements.Add(10);

elements.Add(20);

Следующим шагом мы определяем исходный код для потока, который обрабатывт в цикле элементы коллекции:

Thread threadl = new Thread)

О  =>  {

Thread.Sleep(1000);

foreach (int item in elements) { Console.WriteLinef"Item (" + item + ")"); Thread.Sleep(1000);

. }

}) ;

Этот поток обрабатывает в цикле элементы коллекции, а два метода Thread, sleep () переводят поток в состояние сна на  1000 миллисекунд, или 1 секунду. Переводом потока в состояние сна создается искусственная ситуация, когда другой поток давляет в коллекцию элемент, в то время как коллекция обрабатывается в цикле первым потоком.

Исходный код потока для добавления элемента в коллекцию выглядит так:

Thread thread2 = new Thread)

О  => {

Thread.Sleep(1500); elements . Add (30)

});

Оба потока запускаются следующим образом:

threadl.Start(); thread2.Start();

Исполнение этих потоков сгенерирует исключение, но не сразу же после их запуа. Сначала вызывающий поток создает и запускает потоки threadl и thread2. Пок threadl переходит в режим сна на 1 секунду, а поток thread2 — на 1,5 секуы. После выхода из режима сна поток threadl обрабатывает один элемент коллекции, после чего опять переходит в режим сна на 1 секунду. Но перед тем как поток threadl снова выйдет из режима сна, просыпается поток thread2 и добавлт элемент в коллекцию. Когда поток threadl снова просыпается и пытается обротать элемент коллекции в следующей итерации, генерируется исключение invalidOperationException.

Исключение  invalidOperationException указывает, что при обработке элементов в цикле, добавление новых элементов в коллекцию не позволяется. Классам коекции не очень по душе идея обрабатывать в цикле элементы коллекции и в то же самое  время  добавлять  в  нее  новые  элементы.  Нельзя  сказать,  что  они  не  правы в этом нерасположении, т. к. такие действия могут дать непредсказуемые результаты.

Проблемой данного исходного кода является использование коллекций в многопочном  контексте.  В  примере  в  коллекцию  добавляется  элемент,  в то  время  как

элементы коллекции обрабатываются в цикле. Одним из решений было бы сделать кию состояния  коллекции и обрабатывать в цикле эту копию, а элементы добавлять в оригинал состояния коллекции. Широко рекомендуемым подходом к этому решению является использование класса System.Collections .ObjectModel .ReadOnlyCollection, как показано в следующем примере:

using System.Collections.ObjectModel;

List<int> elements = new List<int>(); elements.Add(10);

elements.Add(20);

Thread threadl = new Thread)

{) => {

Thread.Sleep(lOOO);

foreach (int item in new ReadOxilyCollectiori<int>(elements)) { Console.WriteLine("Item (" + item + ")"); Thread.Sleep(1000);

}

}) ;

Thread thread2 = new Thread(

О  =>  {

Thread.Sleep(1500); elements.Add(30);

}) ;

threadl.Start(); thread2.Start();

Добавленный  код  (выделен  жирным  шрифтом)  создает  экземпляр  типа   Sys- tem. collections.ReadOniyCoiiection, которому передается  список элементов. Тип ReadOniyCoiiection предоставляет базовый класс для общей коллекции, доупной только для чтения. После этого итератор foreach проходится в цикле по коллекции, доступной только для чтения, но основанной на первоначальной  коекции. Но исполнение и этого кода также вызовет то же самое исключение. Приной этому является тот факт, что класс ReadOniyCoiiection не создает копию коллекции, а только маскирует ее. Маска запрещает добавление элементов в коекцию, но поскольку другой поток идет напрямик и модифицирует первоначалую коллекцию, доступная только для чтения  коллекция также подвергается этой модификации.

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

Мы имеем дело с классической проблемой считывания/записывания, в которой ои потоки заинтересованы только в чтении данных, в то время как другие хотят лишь модифицировать их. Одним из способов решения этой проблемы является синхронизация читателей и писателей с помощью исключающей  блокировки (exclusive  lock) с тем,  чтобы в любое время только один  поток мог иметь доступ к данным, не важно — для считывания или записывания.

Использование исключающих блокировок

Исключающую блокировку .NET можно рассматривать как инструкцию, что толо один поток может исполнять определенный фрагмент кода. Если к одному фрагменту коду пытаются получить доступ два потока, то доступ будет предостаен только одному из них, а второму придется ждать, пока первый не завершит иолнение кода. Важно понимать, что  исключающая  блокировка управляет достом к коду, а не к данным, но код может обращаться к данным. А т. к. только один поток может иметь доступ к коду, то подразумевается, что лишь один поток может обращаться к данным.

Далее приведен пример кода с использование исключающих блокировок:

List<int> elements = new List<int>(); elements.Add(10);

elements.Add(20);

Thread threadl = new Thread(

О  =>  {

Thread.Sleep(1000);

lock (elements) {

foreach (int item in elements) { Console.WriteLine("Item (" + item + ")"); Thread.Sleep(1000);

}

}

}) ;

Thread thread2 = new Thread(

О  =>  {

Thread.Sleep(1500);

lock (elements)  {

threadl.Start(); thread2.Start();

}

}) ;

elements.Add(30);

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

Оператор  lock имеет параметр,  который указывает область действия блокировки. В обоих потоках этой областью является elements. Данная общая область синхризирует доступ к коду. В любой момент времени код внутри блока lock может исполняться только одним потоком. Таким образом, мы получили желаемую воожность, когда только один поток может иметь доступ к коду для чтения  или записи коллекции. Исполнение программы протекает следующим образом:

1. Оба потока находятся в состоянии ожидания.

2. После 1 секунды поток thread l захватывает блокировку, т. к. она была свободной.

3. Поток threadl исполняет свой код.

4. Когда поток thread l начинает исполнение синхронизированного кода, никакой другой код не может захватить блокировку, ассоциированную  с переменными элементами.

5. Когда поток thread2 просыпается после 1,5 секунды, он пытается захватить блокировку, но не может этого сделать, т. к. она все еще удерживается потоком threadl. Поэтому ПОТОК thread2 ДОЛЖеН ОЖИДатЬ.

6. По истечении еще полторы секунды, поток threadl освобождает блокировку и завершает исполнение синхронизованного кода, что позволяет потоку thread2 добавить элемент в коллекцию. На этот раз никаких исключений не создается.

Параметр, передаваемый оператору lock, не обязательно должен быть  ресурсом, над которым выполняются манипуляции в блоке кода. Это может быть экземпляр любого объекта, можно даже использовать объект syncRoot, как показано в слующем коде:

object _syncRoot = new ObjectO;

lock( _syncRoot) {

}

Исключающие блокировки необходимо использовать как для чтения, так и для записи  объектов.  Не думайте,  что  исключающие блокировки  необходимы только

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

List<int> elements = new List<int>(); elements.Add(lO);

elements.Add(20);

Thread threadl = new Thread(

О  => {

Thread.Sleep(1000);

foreach (int item in elements) { Console.WriteLineC’Item (" + item + ")"); Thread.Sleep(1000);

}

}) ;

Thread thread2 = new Thread(

() => {

Thread.Sleep(1500); lock (elements) {

elements.Add(30);

}

}) ;

threadl.Start(); thread2.Start();

Возвратимся к коду, который работал. Кажется, что там все в порядке. Но все в пядке только в аспекте работоспособности кода. В аспекте же эффективности иолнения, все далеко не в порядке, т. к. операция чтения коллекции заставляет зисывающий код простаивать без надобности.

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

Синхронизация клонированием

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

и  не  заставлять  ждать  пишущий  поток.  Следуя  этому  подходу,  предыдущий  код можно переписать таким образом:

L i s t < i n t >    element s    =    ne w   L i s t < i n t > ( ) ; elements.Add(10) ;

elements.Add(20);

Threa d   t h r e a d l    =    ne w   Thread (

О  => {

Thread.Sleep(lOOO); int[] items;

lock (elements) {

items = elements.ToArray();

}

foreach  (int item in items) { Console.WriteLine("Item (" + item + ")"); Thread.Sleep(lOOO);

}

}) ;

Thread thread2 = new Thread(

О  =>  {

Thread.Sleep(1500); lock (elements) {

elements. Add (30) ;

}

}) ;

threadl.Start() ; thread2.Start();

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

Но как копирование коллекции может быть эффективным, если процесс копировия занимает время? Ответ заключается в том, что это более эффективно не в теинах обычного времени, а в терминах квантов времени.

Возьмем, например, загрузку текста в текстовый редактор. Когда  редактор Microsoft Word  выполняет эту операцию, то он сразу же выводит на экран первую загруженную страницу, позволяя сразу же приступить к работе. А в это время опация загрузки остальных страниц выполняется в фоне обработки текста. Этот же

эффект дает и подход с клонированием коллекции, который становится даже более эффективным на машине с многоядерным процессором.

Как общее правило при работе с потоками, используйте как можно меньше блоковок, но применяйте их всегда, когда необходимо. Блокируйте как можно мение фрагменты кода. Блокировки синхронизируют доступ к ресурсам, поэтому только один поток может исполнять заблокированный фрагмент кода. Чем меньше времени фрагмент кода заблокирован, тем быстрее будет весь код.

Источник: Гросс  К. С# 2008:  Пер. с англ. — СПб.:  БХВ-Петербург, 2009. — 576 е.:  ил. — (Самоучитель)

По теме:

  • Комментарии