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

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

< Лекция 5 || Лекция 6: 12345 || Лекция 7 >
Аннотация: В этой лекции рассмотрен ряд проблем, характерных для параллельных вычислений.

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

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

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

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

Под гонкой условий (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

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

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

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

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

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

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

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

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

< Лекция 5 || Лекция 6: 12345 || Лекция 7 >
Алексей Рыжков
Алексей Рыжков

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

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

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

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