Опубликован: 23.04.2013 | Доступ: свободный | Студентов: 854 / 184 | Длительность: 12:54:00
Лекция 6:

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

< Лекция 5 || Лекция 6: 12345 || Лекция 7 >

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

Блокировка критических секций позволяет справиться с проблемой гонки данных. В качестве средства блокировки мы рассмотрели оператор 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. Голодные философы
< Лекция 5 || Лекция 6: 12345 || Лекция 7 >
Алексей Рыжков
Алексей Рыжков

не хватает одного параметра:

static void Main(string[] args)
        {
            x = new int[n];
            Print(Sample1,"original");
            Print(Sample1P, "paralel");
            Console.Read();
        }

Никита Белов
Никита Белов

Выставил оценки курса и заданий, начал писать замечания. После нажатия кнопки "Enter" окно отзыва пропало, открыть его снова не могу. Кнопка "Удалить комментарий" в разделе "Мнения" не работает. Как мне отредактировать недописанный отзыв?