Параллельные вычисления и многопоточное программирование

Потоки. Гонка данных и другие проблемы

Показывать лекцию целиком

Уже говорилось, что параллельное программирование сложнее последовательного. Более сложными становятся алгоритмы, усложняется их отладка, труднее понимать логику работы при параллельных вычислениях.

В знаменитой серии рассказов о роботах Айзека Азимова есть история про робота, управляющего работой других роботов, выполняющих горные работы. Периодически управляющий робот выходил из строя, отдавая безумные команды. Как результат, возникла исключительная ситуация, и испытателей, пытающихся отладить эту новую модель роботов, завалило в штольне. В критической ситуации испытатели нашли решение проблемы - они отстрелили несколько рабочих роботов. У управляющего робота уменьшилась степень параллелизма, у него восстановился нормальный режим работы. В этой поучительной истории все закончилось благополучно. Но какова же мораль? Программа, нормально работающая при пяти процессорах, может перестать работать корректно с увеличением числа процессоров до десяти.

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

Гонка условий или гонка данных

Под гонкой условий (Condition Race), которую также называют гонкой данных (Data Race) понимают ситуацию, когда два или более потока соперничают за обладание некоторым общим ресурсом. Чаще всего соперничество возникает из-за такого ресурса как оперативная память. Но таковым ресурсом может быть и внешняя память (работа с одним и тем же файлом, например), или некоторое устройство, подключенное к компьютеру.

Рассмотрим простой пример. Пусть два параллельно работающих потока выполняют некоторый фрагмент кода. Первый поток выполняет оператор присваивания:

Sum += vklad1;

Второй поток выполняет оператор присваивания:

Sum += vklad2;

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

Sum => R1        Sum => R2
Vklad1 => R3        Vklad2 => R4
R1 + R3 => R5        R2 + R4 => R6
R5 => Sum        R6 => Sum

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

Опасная работа с банковскими счетами

Давайте рассмотрим пример работы с банковскими счетами и посмотрим, какие опасности могут возникать при параллельных вычислениях из-за "гонки данных".

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

/// <summary>
    /// Семейный банковский счет
    /// </summary>
    class Account
    {
      protected static double sum;     //баланс - сумма на счете
      protected bool error;   // true - если последняя операция не выполнена 
      protected string message;         // сообщение о результате операции
      protected double positive, negative;  // приход и расход

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

В конструкторе класса предусмотрена проверка корректности создания счета:

/// <summary>
        /// Конструктор счета
        /// </summary>
        /// <param name="Init">начальный взнос</param>
        public Account(double Init)
       {
           if (Init > 100)
           {
               sum = Init;
               positive = Init;
               negative = 0;               
               error = false;
               message = "Создание счета прошло успешно";
           }
           else
           {
               sum = 0;
               positive = 0;
               negative = 0;
               error = true;
               message = "Начальный взнос должен быть > 100";
           }           
       }

Правила банка предусматривают внесение минимального взноса при создании счета. При нарушении этого правила счет не создается. Для доступа к закрытым полям счета предусмотрены открытые методы - свойства:

public bool Error
       {
           get {return error;}
       }
         public string Message
       {
           get {return message;}
       }
         public double Positive
         {
             get { return positive; }
         }
         public double Negative
         {
             get { return negative; }
         }
         public double Sum
         {
             get { return sum; }
         }

Вот как реализованы две основные операции - положить деньги на счет и снять деньги со счета:

/// <summary>
        /// Положить на счет
        /// </summary>
        /// <param name="s"> добавляемая сумма</param> 
        public virtual void Add(double s)
        {
            if (s > 0)
            {
                sum += s;
                error = false;
                message = " Операция начисления прошла успешно";
                positive += s; 
            }
            else
            {
                error = true;
                message = "При пополнении счета сумма должна быть положительной";
            }
        }
        /// <summary>
        /// Снять со счета
        /// </summary>
        /// <param name="s"> снимаемая сумма</param> 
        public virtual void Sub(double s)
        {
            if (s < 0)
            {
                error = true;
                message = "При снятии сумма должна быть положительной";                
            }
            else
                if( sum >= s)
                {
                    sum -= s;
                    error = false;
                    message = " Операция снятия прошла успешно";
                    negative += s;
                }
                else
                {
                    error = true;
                    message = "На счете нет запрашиваемой суммы";
                }                
        }

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

Что же произошло? Из-за чего возникли потери? Вот как выглядит модель работы одной семьи с семейным банковским счетом:

class Program
    {
        static Account account;
        static Two_resourses two_resourses;
        static double Init = 1000;
        static int n = 20000, m = 20000;
        static double husband_sum = 0;
        static double wife_sum = 0;
        static double daughter_sum = 0; 
        static void Main(string[] args)
        {
           Test_Unsafe();           
        }        
        static void Test_Unsafe()
        {
            account = new Account(Init);
            Go();            
        }

В классе Program вводится ряд переменных, смысл которых будет пояснен чуть позже. В начальной точке - процедуре Main вызывается метод Test_Unsafe, моделирующий работу со счетом. В этом методе создается новый счет и запускается метод Go(), в котором и разворачиваются основные события:

/// <summary>
        /// В трех разных потоках
        /// моделируется параллельная работа со счетом
        /// трех членов одной семьи - мужа, его жены и дочери        
        /// </summary>
        static void Go()
        {
            //Создание потоков для трех клиентов 
            Thread h = new Thread(Husband);
            Thread w = new Thread(Wife);
            Thread d = new Thread(Daughter);
            //запуск их методов на выполнение
            h.Start();
            d.Start();
            w.Start();
            //Пора подвести итоги работы
            d.Join();
            h.Join();
            w.Join();
            Console.WriteLine("Работа со счетом закончена" +
               "\r\n" + "Муж положил = " + husband_sum.ToString() +
               "\r\n" + "Дочь сняла = " + daughter_sum.ToString() +
                "\r\n" + "Жена сняла = " + wife_sum.ToString() +
                "\r\n" + "Баланс = " + account.Sum);
            if (husband_sum  != daughter_sum + wife_sum + account.Sum)
                Console.WriteLine("Опасные операции над счетом!");
        }

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

Давайте посмотрим, как муж работает со счетом:

static void Husband()
        {
            husband_sum = Init;
            for (int i = 0; i < n; i++)
            {
                account.Add(300);
                if (!account.Error)
                    husband_sum += 300; 
            } 
        }

Переменная husband_sum хранит сумму денег, положенных мужем на счет. Вначале она равна начальному взносу, а затем n раз пополняется. Пополнение происходит при условии, что операция внесения денег на счет прошла успешно.

Вот как жена работает с этим же счетом:

static void Wife()
        {
            for (int i = 0; i < m; i++)
            {
                account.Sub(400);
                if (!account.Error)
                    wife_sum += 400;
            }           
        }

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

Аналогично работает метод Daughter:

static void Daughter()
        {
            if (account == null) Thread.Sleep(0);            
            for (int i = 0; i < m; i++)
            {
                account.Sub(500);
                if (!account.Error)
                    daughter_sum += 500;
            }            
        }

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

husband_sum = wife_sum + daughter_sum + account.Sum

К сожалению, предложенное решение не обеспечивает корректную работу со счетом. "Гонка данных" приводит к тому, что снять денег со счета можно больше, чем туда положено. Приведу результаты одного сеанса работы со счетом:

(рис 5.1) Опасная работа со счетом. Сеанс 1

Как видите, результаты не являются корректными. Муж должен был положить 6001000, такую же сумму совместно должны были снять жена и дочь. Им, однако, повезло в отличие от банка. Совместно они сняли в полтора раза больше денег, чем их было на счете. Объяснение простое. Запросы на снятие денег жены и дочери выполнялись параллельно. В момент запроса на счету находилась сумма, достаточная для удовлетворения каждого запроса, поэтому им обеим разрешалось снять деньги, но только один из результатов снятия отражался в текущей сумме вклада. В данной ситуации одновременного снятия страдал банк. В другой ситуации - одновременного пополнения счета - страдали бы клиенты банка.

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

(рис 5.2) Опасная работа со счетом. Сеанс 2

Давайте рассмотрим, как можно справиться с "гонкой данных" и обеспечить корректную работу в ситуациях, подобных ситуации с банковским счетом.

Критические секции. Блокировка. Оператор lock

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

Рассмотрим простейший механизм блокировки, основанный на использовании оператора языка С# - оператора lock. Пусть в нашем приложении существует несколько критических секций, использующих один и тот же ресурс. Разные потоки могут входить в разные секции. Тем не менее, необходимо все их блокировать за исключением той, где работает активный поток, уже успевший захватить ресурс. Решение проблемы состоит в том, что создается некоторый объект, видимый во всех критических секциях. Обычно это объект универсального типа object с именем, например, locker. Затем каждая критическая секция закрывается оператором lock с ключом locker. Синтаксически конструкция блокировки выглядит так:

lock (locker)
{
  < Критическая секция>
}

Семантика конструкции такова. Будем полагать, что объект locker может находиться в двух состояниях - "открыт" или "закрыт". В исходном состоянии объект открыт. Когда некоторый поток достигает критической секции с ключевым объектом locker, то, если объект открыт, поток начинает выполнять критическую секцию, предварительно переключая объект locker в состояние "закрыт". При завершении критической секции объект locker переводится в состояние "открыт". Когда поток достигает критической секции с закрытым объектом locker, то он становится в очередь на выполнение критической секции. Как только locker открывается, первый по очереди поток входит в критическую секцию, блокируя ресурс для других потоков из очереди.

Такой способ блокировки позволяет справиться с проблемой "гонки данных" и написать потокобезопасное приложение для работы с банковскими счетами.

Безопасная работа с банковскими счетами

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

  • Внести изменения в класс Account;
  • Создать новый класс Account_new;
  • Изменить логику работы клиентов с банковским счетом;
  • Иной вариант решения проблемы.
  • Первый вариант нарушает один из основных принципов ООП - никогда не вносите изменения в уже работающий класс при появлении новых потребностей. Класс Account хорошо справляется со своими обязанностями при последовательном программировании, когда нет потоков и параллельных вычислений.

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

    Логику работы клиентов менять не следует. Гонка данных возникает в критических секциях, связанных с методами класса Account.

    Правильным решением проблемы является создание класса Safe_Account, наследующего от класса Account. В нашей задаче общим ресурсом является объект account, представляющий банковский счет, с которым работают несколько клиентов. Критические секции возникают во всех методах класса, которые изменяют значения полей объекта. Эти методы и требуют блокировки и соответственно переопределения в классе потомке. Вот как выглядит класс Safe_Account:

    class Safe_Account: Account
        {
            static object Locker = new object();        
           
           public Safe_Account(double Init): base (Init)
           {
              
           }       
            /// <summary>
            /// Положить на счет
            /// </summary>
            /// <param name="s"> добавляемая сумма</param> 
            public override void Add(double s)
            {
                lock (Locker)
                { 
                    if (s > 0)
                    {               
                        sum += s;
                        error = false;
                        message = " Операция начисления прошла успешно";
                        positive += s;
                    }
               
                    else
                    {
                      error = true;
                      message = "При пополнении сумма должна быть положительной";
                    } 
                }
            }
            /// <summary>
            /// Снять со счета
            /// </summary>
            /// <param name="s"> снимаемая сумма</param> 
            public override void Sub(double s)
            {
                lock (Locker)
                {
                    if (s < 0)
                    {
                        error = true;
                        message = "При снятии сумма должна быть положительной";
                    }
                    else
                        if (sum >= s)
                        {
                            sum -= s;
                            error = false;
                            message = " Операция снятия прошла успешно";
                            negative += s;
                        }
                        else
                        {
                            error = true;
                            message = "На счете нет запрашиваемой суммы";
                        }
                }                
            }        
        }

    В классе вводится статический объект Locker, который используется для блокировки критических секций, представленных методами Add и Sub - пополнения и снятия денег со счета.

    При работе с клиентами теперь необходимо использовать объект класса Safe_Account:

    static void Test_Safe()
            {
                account = new Safe_Account(Init);
                Go();
            }

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

    (рис 5.3) Безопасная работа с семейным счетом

    Как видите, теперь все хорошо. Гонка данных устранена. Денег снято ровно столько, сколько положено.

    Клинч

    Клинч, дедлок (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, блокирует любую работу с ресурсом. В то же время, если ресурс используется только для чтения, то возможно его одновременное использование. В этом случае гонка данных не приводит к ошибкам, а мягкие методы блокировки, которые рассмотрим чуть ниже, позволяют избежать клинча.
  • Применим один из этих приемов для устранения клинча в нашем примере с ювелирными украшениями. Пусть оба потока вызывают один и тот же метод покупки ювелирных изделий. В последнем фрагменте кода для второго потока, связанного с дочерью, показаны вызовы двух методов, один из которых закомментирован. Если снять комментарии с одного вызова и закомментировать другой вызов, то оба потока будут работать с одной критической секцией и клинча не будет. Приведу результаты работы в этой ситуации:

    (рис 5.4) Устранение клинча

    Мягкие методы блокировки. Модель "Читатели и Писатели"

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

    Класс ReaderWriterLockSlim

    Класс ReaderWriterLockSlim из пространства имен System.Threading предназначен для поддержки модели "Читатели и Писатели". Он позволяет выделить три группы пользователей ресурса - читателей, писателей и редакторов, - читающих, пишущих и редактирующих ресурс. Для каждой группы класс предлагает свой метод блокировки:

  • EnterReadLock(). Если при выполнении этого метода есть очередь потоков со статусом Write, то поток становится в очередь потоков со статусом Read. Потоки из этой очереди смогут начать выполняться, только когда нет ждущих "писателей". Если очередь состоит только из потоков со статусом Upgradeable, то поток входит в критическую секцию, поскольку разрешается в критической секции одновременное присутствие потоков со статусом Read и одного потока со статусом Upgradeable.
  • EnterWriteLock(). Если при выполнении этого метода есть очередь потоков со статусом Write, то поток становится в конец этой очереди. Если же очереди нет, то поток дожидается завершения работы потоков, находящихся в критической секции. После чего поток входит в критическую секцию и единолично работает с ресурсом.
  • EnterUpgradeableReadLock(). Если при выполнении этого метода есть очередь потоков со статусом Write, то поток становится в очередь потоков со статусом Upgradeable. Если последняя очередь пуста, то поток будет первым в этой очереди. Первый поток из этой очереди может начать свою работу, когда очередь потоков со статусом Write пуста.
  • Для разблокирования критической секции применяют три соответствующих метода:

  • ExitReadLock().
  • ExitWriteLock().
  • ExitUpgradeableReadLock().
  • Общая схема организации критической секции выглядит так:

    Enter-метод
    try {работа с ресурсом}
    finally {Exit-метод }

    Блок finally всегда будет выполняться, что гарантирует освобождение ресурса.

    Поскольку из-за блокировки совместно используемых ресурсов можно войти в ситуацию клинча, то часто применяют более мягкую схему блокировки, заменяя Enter-методы их аналогами:

  • bool TryEnterReadLock(int wait).
  • bool TryEnterWriteLock(int wait).
  • bool EnterUpgradeableReadLock(int wait).
  • В этом случае поток либо войдет в критическую секцию и начнет выполняться, возвращая в качестве результата Enter-метода значение true, либо выйдет из очереди по истечении времени ожидания wait, возвращая false. В любом случае зависание потока исключается.

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

    Пример. Разработка программного проекта

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

    Начнем построение нашей модели с создания класса Project:

    /// <summary>
        /// Программный проект, над которым работают
        /// (developers), (testers), (programmers)
        /// (писатели), (редакторы), (читатели) 
        /// </summary>
        public class Project
        {
            string program = "";
            string Program
            {
                get { return program; }
            }
            ReaderWriterLockSlim wer = new ReaderWriterLockSlim();

    Этот класс описывает создаваемый проект. Сам проект представлен строкой текста program, вначале пустой, но которая будет меняться в результате действий разработчиков и тестеров. Программистам при каждом обращении будет доступно для чтения текущее значение этой строки.

    Объект wer класса ReaderWriterLockSlim будет использоваться для организации блокировок при работе каждой из групп пользователей проекта.

    Добавим теперь в наш класс метод, который будут использовать разработчики, создавшие новую версию проекта:

    /// <summary>
            ///For Writers 
            /// </summary>
            /// <param name="prog">новая версия программы</param>
            public void WriteNewProgram(string prog)
            {
                wer.EnterWriteLock();
                try
                {
                    program = prog;
                    Thread.Sleep(10);
                    readers = wer.WaitingReadCount;
                }
                finally
                {
                    wer.ExitWriteLock();
                }
            }

    Параметр prog задает новую версию проекта. Вход в критическую секцию закрывается со статусом "write", что гарантирует отсутствие каких-либо других изменений переменной program до окончания работы метода и освобождения ресурса.

    Аналогично устроен метод, предназначенный для тестеров. Основное отличие в том, каким статусом закрывается критическая секция:

    /// <summary>
            ///For Editors 
            /// </summary>
            /// <param name="prog">новое изменение программы</param>
            public void EditNewProgram(string patch, ref int writers)
            {
                wer.EnterUpgradeableReadLock();
                try
                {
                    if(program != "")
                        program += patch;
                    Thread.Sleep(10);
                    if (wer.WaitingWriteCount > writers)
                        writers = wer.WaitingWriteCount;
                }
                finally
                {
                    wer.ExitUpgradeableReadLock();
                }
            }

    Методу передается два параметра. Первый из них - patch - отражает вносимые изменения в проект. Поскольку в задачу тестеров входит отслеживание процесса работы над проектом, то второй обновляемый параметр - writers - позволяет следить за числом писателей, стоящих в очереди. Он характеризует интенсивность работы разработчиков проекта.

    Метод, предназначенный для читателей проекта, имеет вид:

    /// <summary>
            ///For Readers 
            /// </summary>
            /// <param name="prog">чтение программы</param>
            public string ReadingProgram()
            {            
                string p;
                wer.EnterReadLock();
                try
                {
                    p = program;
                    Thread.Sleep(10);                
                }
                finally
                {
                    wer.ExitReadLock();
                }
                return p;
            }

    Построим теперь класс Model, моделирующий ход процесса работы над проектом. Вначале определим поля этого класса:

    /// <summary>
        /// Моделирование работы над программным проектом
        /// </summary>
        public class Model
        {
            int ndev, ntes, nprog, n;       
            string programs;
            int writers;
            Project project;
            const int rep = 5;
            const string nl = "\r\n";
            Random rnd = new Random();
            Thread[] threads;

    Целочисленные переменные ndev, ntes, nprog задают число участников каждой группы, работающей над проектом, n - суммарное число участников. Переменная programs будет содержать некоторую историю разработки проекта, а writers - характеризует интенсивность разработки. Каждый раз, когда одному из участников необходимо выполнить свою работу будет создаваться поток, выполняющий эту задачу. Можно было бы иметь одну переменную класса Thread, каждый раз формируя ее новое значение. Но для гарантирования синхронизации удобнее иметь массив потоков threads.

    Как обычно, для чтения закрытых полей создаются методы-свойства:

    public string Programs
            {
                get { return programs;}
            }
            public int Writers
            {
                get { return writers; }
            }

    Конструктор класса позволяет формировать значения полей класса:

    public Model(int nd, int nt, int np)
            {
                n = nd + nt + np;
                this.ndev = nd;
                this.ntes = nt;
                this.nprog = np;         
                programs = "";
                project = new Project();
                threads = new Thread[n * rep];            
            }

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

    /// <summary>
            /// Реализация сценария работы с проектом
            /// </summary>
            public void ModelWorkWithProject()
            {
                int num = 0;
                int version = 1;
                int patch = 1;
                for (int i = 0; i < n * rep; i++)
                {
                    //Генерация случайного события
                    num = rnd.Next(n);
                    if (num < ndev)
                    {
                        //Создается поток разработчиков
                        threads[i] = new Thread(() =>                        
                            {
                              project.WriteNewProgram(String.Format(
                              "version {0}  from Devoleper {1} ", version, num));
                            });
                        threads[i].Start();                    
                        version++;                    
                    }
                    else
                        if (num < ndev + ntes)
                        {
                            //Создается поток тестеров
                            threads[i] = new Thread(() =>
                            {
                                project.EditNewProgram(String.Format(
                                " patch {0}  from Tester {1}", patch, num), 
                            ref writers);
                            });
                            threads[i].Start();
                            patch++;
                        }
                        else                        
                            {
                                //Создается поток программистов
                                threads[i] = new Thread(() =>
                                {
                                   programs += nl + project.ReadingProgram();
                                });
                                threads[i].Start();
                            }
                    Thread.Sleep(5);
                }
                for (int i = 0; i < n * rep; i++)
                {
                    threads[i].Join();
                }
            }

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

    Заметьте, программисты, читающие изменяющееся во времени состояние проекта, сохраняют эту информацию в переменной programs, играющую роль своеобразного архива. Тестеры в переменной writers сохраняют информацию о максимальном числе писателей, стоящих в очереди на запись новой версии.

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

    static void Main(string[] args)
            {
                Model model = new Model(2, 3, 5);
                model.ModelWorkWithProject();
                Console.WriteLine(model.Programs);
                Console.WriteLine("писателей, ждущих в очереди - "
                    + model.Writers);
            }

    Создается объект model, моделирующий работу над проектом двух разработчиков, трех тестеров и пятерых программистов. Запускается соответствующий сценарий работы, в котором выполняется 50 заданий случайным образом распределенных между всеми участниками проекта. Все задания выполняются в отдельных потоках. При работе потоков применяется мягкая блокировка, при которой читатели (программисты в нашем примере) не блокируют друг друга. Вот как выглядят результаты одного сеанса работы нашего приложения:

    (рис 5.5) Результаты моделирования задачи о программном проекте

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

    Мониторы и семафоры. Обедающие философы.

    Блокировка критических секций позволяет справиться с проблемой гонки данных. В качестве средства блокировки мы рассмотрели оператор lock языка С#. Но Framework.Net включает несколько классов, позволяющих организовать блокировку. Обилие различных средств для решения одной задачи часто свидетельствует о том, что универсальное средство, подходящее для всех случаев жизни, отсутствует.

    Мониторы

    Оператор lock фактически является надстройкой над классом Monitor. Запись:

    lock(locker) {<критическая секция>}

    можно рассматривать как краткую форму следующей записи:

    Monitor.Enter(locker)
      try 
        {<критическая секция>}
    finally { Monitor.Exit(locker)}

    Статический метод Enter класса Monitor закрывает критическую секцию, погруженную в try-блок, ссылочным объектом locker. Семантика такая же, как и у оператора lock. Все остальные потоки, пытающиеся войти в критическую секцию, закрытую ключом locker, будут выстраиваться в очередь, ожидая, пока секция не будет открыта. При любом завершении try-блока выполняется блок finally, снимающий блокировку.

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

    public static bool TryEnter(
      Object obj,
      int millisecondsTimeout
    )

    Метод представляет функцию, возвращающую значение true, если за время ожидания, заданное параметром millisecondsTimeout, произошел вход в критическую секцию. Такая форма входа в критическую секцию нам уже знакома по описанию класса ReaderWriterLockSlim. Метод TryEnter перегружен и имеет еще две формы вызова, немногим отличающимся по семантике.

    У класса Monitor есть еще три важных метода - Wait, Pulse, PulseAll. Эти методы вызываются внутри критической секции. Они взаимосвязаны и позволяют двум или нескольким потокам синхронизировать свою работу, посылая друг другу уведомления.

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

    Метод Wait перегружен, и мы рассмотрим только основной его вариант, имеющий следующий синтаксис:

    public static bool Wait(object obj);

    Параметр obj задает синхронизирующий объект, закрывающий критическую секцию. Обычно метод не использует возвращаемое значение и вызывается как оператор, а не как функция. Точная семантика метода такова. Метод освобождает синхронизирующий объект, поток прерывает работу и становится в специальную очередь ожидания уведомления. Когда другой метод, захвативший освобожденный объект синхронизации, выполняет метод Pulse, то первый метод из очереди ожидания переводится в очередь готовых к исполнению потоков. Когда настает черед выполняться методу, то восстанавливается его состояние, восстанавливаются все ранее сделанные блокировки и метод продолжает работать.

    Метод Pulse имеет следующий синтаксис:

    public static void Pulse(object obj);

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

    Кооперация методов Wait - Pulse позволяет решать многие задачи взаимодействия потоков, которые невозможно решить другими способами.

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

    Пример кооперации двух потоков с использованием схемы Wait - Pulse

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

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

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

    /// <summary>
        /// Демонстрация взаимодействия двух потоков
        /// Кооперация потоков достигается 
        /// синхронной работой Wait - Pulse методов
        /// </summary>
        class MyMonitorSample
        {
            int[] resource;
            Random rnd = new Random();
            int n;
            int stop_index;
            int max = 0;
            bool finished;
            public int Max
            {
                get { return max; }
            }
            public MyMonitorSample(int n)
            {
                this.n = n;
                resource = new int[n];
                stop_index = -1;
                finished = false;
            }
            public void Init_Resource()
            {
                for(int i = 0; i < n; i++)
                {
                    resource[i] = rnd.Next(200);
                }
            }
            public int[] Resource
            {
                get { return resource; }
            }

    Оба потока будут работать над общей памятью объектов этого класса. Переменная resource - это обрабатываемый массив. Другие переменные необходимы для обмена информацией взаимодействующих потоков. Конструктор класса и методы-свойства выполняют типичную для них работу. Метод Init позволяет создать первоначальный массив, подлежащий обработке.

    Добавим теперь в класс метод, который будет выполняться в первом потоке, вычисляя максимальный элемент и приостанавливая свою работу всякий раз, когда очередной элемент меньше 100:

    /// <summary>
            /// Находит максимальный элемент
            /// при условии, что на элементах массива
            /// выполняется некоторое условие (> 100)
            /// Если условие не выполняется,
            /// поток приостанавливается, уступая место
            /// другому потоку, исправляющему ситуацию
            /// </summary>
            public void Max_resource()
            {
                lock (resource)
                {
                    for (int i = 0; i < n; i++)
                    {
                        if (resource[i] < 100)
                        {
                            stop_index = i;
                            Monitor.Pulse(resource);
                            Monitor.Wait(resource);
                        }
                        if (max < resource[i])
                            max = resource[i];
                    }
                    finished = true;
                    Monitor.Pulse(resource);                
                }
            }

    Обратите внимание на связку Monitor.Pulse и Monitor.Wait, - уведомляем другой поток, чтобы он мог перейти в состояние готовности, и переходим в режим ожидания. А вот как выглядит код другого потока, исправляющего "некорректные" элементы:

    /// <summary>
            /// Метод, исправляющий ситуацию
            /// Малые элементы заменяет большими
            /// </summary>
            public void ChangeSituation()
            {
                lock (resource)
                {
                    if (stop_index == -1)
                        Monitor.Wait(resource);                
                        do
                        {
                            resource[stop_index] = rnd.Next(100, 200);
                            Monitor.Pulse(resource);
                            Monitor.Wait(resource);
                        } while (!finished);
                }
            }

    Если метод первым начинает работу, то он входит в режим ожидания. В противном случае он исправляет элемент и выполняет связку методов Pulse - Wait. Возобновление работы происходит с проверки условия завершения обработки массива. Если первый поток закончил работу, то завершает работу и второй поток. Рекомендую внимательно изучить структуру взаимодействия потоков, поскольку она не столь проста, как кажется с первого взгляда.

    Приведу результаты одного сеанса работы:

    (рис 5.6) Результаты кооперативной работы двух потоков

    Семафоры

    Еще один способ блокировки предоставляют семафоры. У семафоров есть две важные особенности:

  • Семафоры более строгие, чем обычные способы блокировки. У них нет синхронизирующего объекта, и они никакой поток не допускают в критическую секцию, когда она занята.
  • Семафоры более мягкие, чем обычные способы блокировки. Они позволяют войти в критическую секцию нескольким потокам.
  • Семафоры позволяют моделировать ситуацию допуска клиентов на охраняемую территорию с ограниченным числом мест. Примерами могут служить такие места, как охраняемая стоянка для автомобилей, ресторан, спальный вагон. Если все места на стоянке заняты, то создается очередь и въехать на стоянку можно только после выезда автомобиля со стоянки.

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

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

    У класса SemaphoreSlim есть два основных метода - Wait и Release. Методы перегружены, но мы ограничимся описанием одной реализации этих методов.

    Пусть создан семафор sem, допускающий n клиентов. Тогда при каждом выполнении вызова sem.Wait() число клиентов, которым разрешен вход, будет уменьшаться на единицу. Как только это число становится равным нулю, образуется очередь клиентов.

    Метод Release выполняет обратную операцию, увеличивая число допускаемых клиентов на единицу, пока не будет достигнуто максимальное значение.

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

    Обедающие философы

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

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

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

    В чем трудность реализации подобной задачи. Все философы используют один и тот же способ еды - один и тот же метод. Разница лишь в используемых ресурсах - вилках, передаваемых как параметры методу. Начиная еду, для гарантированного успеха философу следовало бы заблокировать оба необходимых ему ресурса. Однако предлагаемые методы блокировки не позволяют блокировать ресурсы, они блокируют критическую секцию, - код, в котором используются ресурсы. Конечно, нетрудно заблокировать код полностью, заблокировав тем самым все вилки. Но это плохое решение, поскольку тогда в каждый момент есть спагетти может только один философ, в то время как, не нарушая правила, есть одновременно могут два философа, используя 4 из 5 вилок (в общем случае обедать одновременно могут N/2 философов).

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

    В нашем решении, также гарантирующем отсутствие клинча, используется другая стратегия - стратегия упорядочения использования ресурсов. Философы легко обучаются. Научим четных философов (философов с четными номерами) вначале брать вилку справа, а нечетных философов первой брать вилку слева. Такое решение позволяет избежать клинча. Действительно рассмотрим двух соседних философов, которые одновременно решили есть. Левый из них захватил левую вилку, правый - правую. Вилка между ними свободна и достанется одному из философов (тому, кто быстрее реагирует). Один из этой пары философов будет есть, его соседи ждать освобождения вилок. Ситуация клинча не возникает.

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

    Перейдем к построению программной модели для нашей задачи. Приведу вначале общее описание класса, моделирующего обед философов:

    public class ThinkWhenEat
        {
            int count;
            int repeat;
            SemaphoreSlim[] forks;
            Thread[] threads;
            string[] states;
            const int think_time = 100;
            const int wait_time = 50;
            const int eat_time = 20;
            public string[] States
            {
                get { return states; }
            }
            /// <summary>
            /// Конструктор
            /// </summary>
            /// <param name="n">число философов</param>
            public ThinkWhenEat( int n, int rep)
            {
                count = n;            
                repeat = rep;
                forks = new SemaphoreSlim[count];
                states = new string[count];
                threads = new Thread[count];
                for (int i = 0; i < count; i++)
                {
                    forks[i] = new SemaphoreSlim(1, 1);
                    states[i] = "";
                }         
            }

    Вилки, представляющие ресурсы, представим как массив объектов класса SemaphoreSlim. Каждая вилка - это своеобразный семафор, рассчитанный, заметьте, только на одного клиента. Если она поднята, то другой философ, претендующий на эту вилку, должен будет ждать. Аппетиты философов ограничены, - переменная repeat показывает, сколько раз философы могут приступать к еде, чтобы насытиться. Массив states будет сохранять историю состояний, в которых находились философы в процессе обеда.

    Добавим теперь в наш класс метод, инициализирующий параллельно протекающие процессы обеда каждого из философов:

    /// <summary>
            /// Каждому философу свой поток для обеда
            /// </summary>
            public void Dinner()
            {          
                    for (int j = 0; j < count; j++)
                    {
                        int index = j;
                        threads[index] = new Thread(DinnerForFhilosophers);
                        threads[index].Start(index);
                    }
            }

    Для каждого философа создается свой поток. Все потоки запускаются для параллельного выполнения. Все потоки выполняют один и тот же метод DinnerForFhilosophers. При запуске методу передается параметр, задающий номер философа.

    Метод DinnerForFhilosophers описывает сам процесс обеда. Вот его код:

    /// <summary>
            /// Обед философа
            /// </summary>
            /// <param name="index"></param>
            void DinnerForFhilosophers(object index)
            {
                int num = (int)index;
                SemaphoreSlim left_fork = forks[num];
                SemaphoreSlim right_fork = forks[(num + 1) % count];
                if (num % 2  == 1)
                { //change forks
                    SemaphoreSlim temp;
                    temp = left_fork;
                    left_fork = right_fork;
                    right_fork = temp;
                }
                for (int i = 0; i < repeat; i++)
                {
                    // think
                    states[num] += "thinks => ";
                    Thread.Sleep(think_time);
                    //wait
                    left_fork.Wait();
                     states[num] += "waits =>";
                    Thread.Sleep(wait_time);
                    //eat
                    right_fork.Wait();
                    states[num] += "eats => "; 
                    Thread.Sleep(eat_time);
                    //Освобождает вилки
                    left_fork.Release();
                    right_fork.Release();
                }
            }

    Вначале по номеру философа определяются нужные ему ресурсы. Затем происходит упорядочение ресурсов. Потом в цикле выполняются три этапа - думать, ждать и есть. Когда философ берет первую вилку (left_fork.Wait), он входит в состояние ожидания, а вилка становится недоступной для других философов. Когда философ берет вторую вилку (right_fork.Wait) он приступает к еде, а вилка становится недоступной для других философов. По окончании еды обе вилки освобождаются. Благодаря введенному правилу упорядочивания взятия вилок удается избежать клинча.

    Рассмотрим теперь код консольного проекта с процедурой Main, моделирующей роль хозяина, организующего обед философов:

    class ProgramPhilosophers
        {
            static void Main(string[] args)
            {
                const int dinner_time = 1000;
                const int n = 5, repeat = 5;
                ThinkWhenEat philosophers = new ThinkWhenEat(n, repeat);
                //Время философам обедать
                philosophers.Dinner();
                Thread.Sleep(dinner_time);
                //Обед закончен
                for (int i = 0; i < n; i++)
                    Console.WriteLine(philosophers.States[i]); 
            }
        }

    Константа dinner_time задает время, отведенное хозяином на обед. Основной поток создает объект класса ThinkWhenEat (думай, пока ешь) и отправляет философов обедать, засыпая на время. После истечения времени обеда в основном потоке подводятся итоги и на печать выводятся состояния философов. При данных параметрах на моем компьютере все философы успевают наесться. Вот как выглядят результаты выполнения проекта:

    (рис 5.7) Обедающие философы

    У жадного хозяина, который на обед отводит мало времени, не все философы успевают хоть что-нибудь съесть, не говоря уж о том, чтобы наесться:

    (рис 5.8) Голодные философы
    Вернуться к учебному плану