не хватает одного параметра: static void Main(string[] args) |
Потоки. Гонка данных и другие проблемы
Клинч
Клинч, дедлок (deadlock), смертельные объятия - разные названия одной из самых серьезных проблем, возникающих при параллельном программировании. Клинч может возникнуть в ситуации, когда два или более параллельно выполняемых потока конкурируют за обладание двумя или более общими ресурсами. При клинче каждый из потоков успевает захватить один из общих ресурсов. Для окончания работы каждому потоку необходимы другие ресурсы, захваченные другими потоками. В результате, никто из потоков не может завершить свою работу, все стоят в очередях, которые не двигаются, - работа замирает - приложение "зависает". Это худшее, что может случиться с приложением.
Плохо, когда приложение завершается в результате возникновения исключительной ситуации, для которой не предусмотрена нормальная обработка. Еще хуже, когда приложение "нормально" завершается, но выдает некорректные результаты. Совсем плохо, когда приложение "зависает", не выдает результатов и завершить его приходится "грубым" способом с помощью внешнего прерывания (например, с помощью диспетчера задач).
Поясним ситуацию клинча на примере двух потоков P1 и P2 и двух ресурсов R1 и R2. Пусть поток P1, входя в критическую секцию, захватывает ресурс R1, блокируя его для использования потоком P2. Аналогично, работая параллельно, поток P2, входя в критическую секцию, захватывает ресурс R2, блокируя его для использования потоком P1. Потоку P1 в какой-то момент работы в критической секции становится необходимым ресурс R2, но этот ресурс заблокирован и поток становится в очередь, прерывая свое выполнение. Симметричная ситуация возникает с потоком P2. Как два боксера, войдя в клинч, не могут разойтись, без вмешательства судьи, так и потоки не смогут продолжить выполнение без внешнего вмешательства.
Блокировка позволяет спастись от "гонки данных". Обратная сторона блокировки в том, что, спасаясь от гонки, можно попасть в смертельные объятия, - "из огня да в полымя".
Кольцо и серьги
Давайте рассмотрим пример, демонстрирующий проявление ситуации "клинча". Продолжим наш пример с семьей, владеющей общим семейным счетом. Мать и дочь, сняв деньги со счета, решили истратить их разумным образом. Каждой из них понравился прекрасный гарнитур из кольца и сережек, продаваемых раздельно. Матери больше нравится кольцо, поэтому свою покупку она начинает с кольца, а дочь вначале решила купить сережки. Действуют они параллельно, независимо друг от друга. Построим модель этой ситуации. В нашей задаче есть два общих ресурса - объекты "кольцо" и "сережки". Вот структура, описывающая объекты этого класса:
struct Accessory { string name; int price; bool sold; string declaration; public Accessory(string name, int price, string declaration) { this.name = name; this.price = price; this.declaration = declaration; sold = false; } public bool Sold { set { sold = value; } get { return sold; } } public int Price { get { return price; } } public string Declaration { get { return declaration; } } }
У каждого объекта есть название, описание, цена, отметка о его продаже.
Построим теперь класс Garnitur, описывающий ювелирный гарнитур, состоящий из двух объектов:
class Garnitur { Accessory ring; Accessory earrings; public Garnitur(int price_R, int price_E, string dec_R, string dec_E) { ring = new Accessory( "ring", price_R, dec_R); earrings = new Accessory("earrings", price_E, dec_E); } object lock_ring = new object(); object lock_earrings = new object(); }
Объекты lock служат для блокировки критических секций, в которых идет работа с объектами из гарнитура.
Добавим в этот класс метод покупки гарнитура:
/// <summary> /// Покупка гарнитура /// начинается с покупки кольца /// </summary> /// <param name="Sum">сумма, отводимая для покупки</param> /// <param name="ring_success">true при успехе покупки кольца</param> /// <param name="earrings_success">true при успехе покупки серьг</param> /// <param name="compare_ring_earrings">описание гарнитура </param> public void Buy_RingAndEarRings(int Sum, out bool ring_success, out bool earrings_success, ref string compare_ring_earrings) { lock (lock_ring) { //Покупка кольца if (!ring.Sold && Sum >= ring.Price) { ring.Sold = true; ring_success = true; Sum -= ring.Price; } else ring_success = false; //Пьем кофе Thread.Sleep(10); lock (lock_earrings) { //сравнение кольца и сережек compare_ring_earrings += ring.Declaration + " - " + earrings.Declaration; //Покупка сережек if (!earrings.Sold && Sum >= earrings.Price) { earrings.Sold = true; earrings_success = true; } else earrings_success = false; //Пьем кофе Thread.Sleep(10); } } }
Тело этого метода представляет критическую секцию, в которой идет работа с двумя ресурсами - ring и earrings. Вход в критическую секцию закрыт объектом lock_ring. Начать работу эта часть может только при условии, что объект lock_ring открыт. В первой части критической секции идет работа с одним объектом ring, во второй части используются два объекта. Вход во вторую часть закрыт объектом lock_earrings. Начать работу эта часть может только при условии, что объект lock_earrings открыт.
Содержательно метод моделирует покупку украшений. После каждой покупки выполняется перерыв "на чашечку кофе", что гарантирует, что в это время могут начать работать другие неспящие потоки.
В класс также включен метод, представляющий двойника рассмотренного нами метода. Разница в том, что здесь приоритет отдается покупке сережек, а не кольцу. Закрытие каждой части метода двойственно - первая часть закрывается объектом lock_earrings, вторая - lock_ring. Два эти метода позволяют моделировать ситуацию "клинч".
Построим теперь завершающий фрагмент нашей модели, где создаются два потока, использующие общие ресурсы. В ходе работы модели, возникает ситуация клинча, приводящая к зависанию приложения. Вот как выглядит соответствующий код:
/// <summary> /// моделирование ситуации Clinch /// </summary> static void Clinch_new() { Garnitur garnitur = new Garnitur(100000, 150000, "прекрасное кольцо из гарнитура", "замечательные серьги из гарнитура"); bool wife_ring_success = false, wife_earrings_success = false; bool daughter_ring_success = false, daughter_earrings_success = false; string wife_compare = "Жена: ", daughter_compare = "Дочь: "; //Создание потоков //Анонимные методы вызывают методы класса Garnitur Thread wbp = new Thread(() => { garnitur.Buy_RingAndEarRings(wife_sum, out wife_ring_success, out wife_earrings_success, ref wife_compare); }); Thread dpb = new Thread(() => { garnitur.Buy_EarRingsAndRing(daughter_sum, out daughter_ring_success, out daughter_earrings_success, ref daughter_compare); }); //Запуск потоков. Возможен клинч!! wbp.Start(); dpb.Start(); //основной поток приостанавливается wbp.Join(); dpb.Join(); //Обработка результатов работы потоков Console.WriteLine("Смертельных объятий нет"); if (wife_ring_success) Console.WriteLine("Кольцо купила жена"); if (wife_earrings_success) { Console.WriteLine("Серьги купила жена"); Console.WriteLine(wife_compare); } if (daughter_ring_success) Console.WriteLine("Кольцо купила дочь"); if (daughter_earrings_success) { Console.WriteLine("Серьги купила дочь"); Console.WriteLine(daughter_compare); } }
Общая схема понятна. Вначале создается объект garnitur, определяющий общие ресурсы, потом создаются два потока. При их создании используются анонимные методы, которым передается объект garnitur, вызывающий методы класса Garnitur. Потоки запускаются на выполнение, основной поток приостанавливается до завершения работы дочерних потоков. После возобновления работы основной поток выводит на консоль результаты работы. Однако в приведенном варианте результатов не дождаться, поскольку при запуске потоков возникает клинч и приложение зависает. Продемонстрировать работу зависшего приложения трудно, поэтому придется поверить на слово или самим запустить этот проект.
Давайте еще раз посмотрим, отчего в данной ситуации возникает клинч. Два потока при запуске начинают выполнять методы Buy_RingAndEarRings и Buy_EarRingsAndRing. Оба метода могут одновременно начать выполняться, поскольку на входе они блокируются разными ключами - объектами lock_ring и lock_earrings. Из-за введенных задержек ни один из методов не успевает закончить работу до начала работы другого метода. По этой причине, когда методам требуется второй заблокированный общий ресурс, они оба приостанавливают свою работу. Дочерние потоки приостановили свою работу, основной поток ждет их завершения - приложение зависло.
Как избавиться от смертельных объятий
Если блокировка позволяет избавиться от гонки данных, то для спасения от клинча нужно корректно организовать работу потоков, использующих несколько общих ресурсов. Пусть есть несколько критических секций, использующих несколько общих ресурсов (в нашем предыдущем ресурсе это два метода Buy_RingAndEarRings и Buy_EarRingsAndRing и общий объект garniture, поля которого задают общие ресурсы). В этой ситуации возможны разные способы избежать клинча. Перечислим некоторые из них:
- Каждая критическая секция захватывает все общие ресурсы. Это означает, что вход в каждую критическую секцию закрывается одним ключом. Это гарантирует, что в каждый момент времени исполняться будет только одна критическая секция и клинча не будет. Недостатком такого подхода является увеличение общего времени ожидания. Во многих ситуациях неразумно, когда все ресурсы принадлежат одному владельцу, а он не пользуется ими одновременно.
- Если в критических секциях работа с ресурсами ведется последовательно, а не одновременно, то ресурс следует освобождать, как только работа с ним закончена. Это общее правило работы с разделяемыми ресурсами. Во многих случаях оно позволяет избавиться от клинча. Оно не всегда работает, поскольку часто необходимы одновременно несколько ресурсов в каждой из критических секций.
- Клинч не возникает, если есть только одна критическая секция. В нашем примере, клинч не будет возникать, если оба потока будут вызывать один и тот же метод, а не два разных метода. По сути, это также захват всех ресурсов, означающий, ожидание в очереди всех потоков, пока не отработает поток, вошедший в критическую секцию.
- В основном потоке можно использовать другую форму оператора Join для организации ожидания. Наряду с формой void Join() существует форма оператора ожидания bool Join( int t), позволяющая ожидать завершения работы запущенного потока в течение времени, заданного параметром t. Если поток нормально завершается, то функция Join возвращает значение true. Если же истекло время, отпущенное на ожидание, то функция возвращает значение false. Используя этот механизм, основной поток может корректно обработать возникшую ситуацию, возможно связанную с клинчем. В любом случае приложение не зависнет.
- Применение мягких методов блокировки, когда блокируется только запись, но не чтение ресурса. Блокировка, использующая оператор lock, блокирует любую работу с ресурсом. В то же время, если ресурс используется только для чтения, то возможно его одновременное использование. В этом случае гонка данных не приводит к ошибкам, а мягкие методы блокировки, которые рассмотрим чуть ниже, позволяют избежать клинча.
Применим один из этих приемов для устранения клинча в нашем примере с ювелирными украшениями. Пусть оба потока вызывают один и тот же метод покупки ювелирных изделий. В последнем фрагменте кода для второго потока, связанного с дочерью, показаны вызовы двух методов, один из которых закомментирован. Если снять комментарии с одного вызова и закомментировать другой вызов, то оба потока будут работать с одной критической секцией и клинча не будет. Приведу результаты работы в этой ситуации: