Шаг 243.
Язык программирования C#. Начала.
Многопоточное программирование. Синхронизация потоков

    На этом шаге мы рассмотрим пример синхронизации потоков.

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

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

Итог операции должен быть нулевой: сколько денег снято, столько же и поступает на счет. Но теперь рассмотрим технологию процесса. Деньги снимаются и зачисляются через удаленные терминалы. Операция сводится к приему и выдаче наличных и изменению записи о состоянии счета. При внесении наличных на счет сначала выполняется запрос о получении текущего состояния счета, затем об увеличении этого значения на 100 (сумма, перечисляемая на счет) и, наконец, о выполнении записи о новом состоянии счета. При снятии наличных также считывается текущая сумма на счете, эта сумма уменьшается на 100 (то, что снимает со счета клиент), и новое значение записывается как состояние счета. Например, если сначала выполняется операция по зачислению средств, то будет прочитано значение 1000 (начальное состояние счета), вычислена сумма 1100 и это значение записано в качестве новой суммы средств на счете. Далее со счета снимаются наличные: считывается сумма 1100 на счете, вычисляется новое значение 1000, и оно записывается как текущее состояние счета. Если поменять порядок выполнения операций (сначала снять деньги, а потом внести такую же сумму на счет), конечный результат не изменится. Но давайте представим, что оба процесса (снятие наличных и перечисление наличных на счет) выполняются практически одновременно. Скажем, при зачислении денег на счет считывается сумма 1000 и до того, как будет записано новое состояние счета, выполняется снятие денег со счета. Второй процесс также считает текущую сумму 1000. Первый процесс вычислит новое значение 1100, а второй процесс вычислит новое значение 900. Дальше все зависит от того, какой процесс быстрее сделает запись о новом состоянии счета. Если первый процесс окажется быстрее, то сначала будет записано значение 1100, а затем второй процесс исправит это значение на 900. Если задачу быстрее решит второй процесс, то итоговая сумма окажется равной 1100.

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

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

lock (объект) {
  // Команды
}

    Пример, в котором используется блокировка объекта во время доступа к нему потоков, представлен в примере ниже.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using System.Threading;

namespace pr243_1
{
    // Класс для создания объекта: 
    class MyClass {
        // Открытое целочисленное поле: 
        public int state = 0;
    }

    // Класс с главным методом: 
    class Program
    {
        // Метод для выполнения в потоке: 
        static void run(bool type, MyClass obj) {
            // Целочисленные переменные: 
            int val, k = 1;
            // Объект для генерации случайных чисел:
            Random rnd = new Random();
            // Конструкция цикла: 
            while(k <= 5) {
                // Блокировка объекта: 
                lock(obj) {
                    // Считывание значения поля: 
                    val = obj.state;
                    // Отображение прочитанного значения:
                    Console.WriteLine("{0,4}: прочитано значение {1,2}", 
                        Thread.CurrentThread.Name, val);
                    // Случайная задержка в выполнении потока: 
                    Thread.Sleep(rnd.Next(1000, 3001));
                    // Новое значение переменной: 
                    if (type) val++; else val--;
                    // Полю объекта присваивается новое значение: 
                    obj.state = val;
                    // Отображение нового значения поля:
                    Console.WriteLine("{0,4}: присвоено значение {1,2}", 
                        Thread.CurrentThread.Name, obj.state);
                    // Новое значение индексной переменной: 
                    k++;
                }
            }
        }

        // Главный метод:
        static void Main()
        {
            // Создание объекта:
            MyClass obj = new MyClass();
            // Объект первого потока:
            Thread up = new Thread(() => run(true, obj));
            // Название первого потока: 
            up.Name = "UP";
            // Создание объекта второго потока:
            Thread down = new Thread(() => run(false, obj));
            // Название второго потока: 
            down.Name = "DOWN";
            // Запуск первого потока на выполнение: 
            up.Start();
            // Запуск второго потока на выполнение: 
            down.Start();
        }
    }
}
Архив проекта можно взять здесь.

    Результат выполнения программы представлен ниже:


Рис.1. Результат работы приложения

    В программе описан класс MyClass, у которого есть целочисленное открытое поле state (с начальным нулевым значением).


Нестатическое поле state инициализировано начальным нулевым значением непосредственно в инструкции объявления поля в классе. Это означает, что у всех объектов класса MyClass при создании поле state будет получать начальное нулевое значение.

    Класс MyClass нам нужен для того, чтобы на его основе создать объект, за который будут конкурировать дочерние потоки. Дочерние потоки создаются на основе статического метода run(), описанного в главном классе (точнее, потоки создаются на основе анонимных методов, в которых вызывается метод run()). У метода два аргумента: аргумент type логического типа и аргумент obj, который является ссылкой на объект класса MyClass. При выполнении метода запускается конструкция цикла while, в котороq командой

  val = obj.state; 
в переменную val записывается текущее значение поля state объекта obj (объект, переданный методу в качестве аргумента). Это значение отображается в консольном окне. Затем выполняется задержка в выполнении потока. Время задержки является случайной величиной в диапазоне от 1 до 3 секунд. Далее, в зависимости от значения первого логического аргумента метода, значение переменной val увеличивается или уменьшается на единицу, это новое значение присваивается полю state объекта obj (команда
  obj.state = val;
), после чего новое значение поля отображается в консольном окне.


Для генерирации случайных чисел командой
  Random rnd = new Random(); 
создается объект rnd класса Random. Результатом выражения rnd.Next(1000, 3001) является целое случайное число в диапазоне возможных значений от 1000 до 3000.

    Инструкции {0,4} и {1,2} в строке форматирования в методе WriteLine() означают, что для отображения первого (после строки форматирования) аргумента выделяется не менее 4 позиций, а для отображения второго аргумента выделяется не менее 2 позиций. Значением выражения Thread.CurrentThread.Name является имя потока, в котором вычисляется это выражение. Здесь уместно напомнить, что статическое свойство CurrentThread класса Thread результатом возвращает ссылку на объект потока, в котором вычисляется значение свойства.


    Но самое важное - это то, что все описанные выше действия выполняются внутри блока на основе lock-инструкции: блок начинается с выражения lock(obj), означающего, что на время выполнения команд в следующем после этого выражения блоке объект obj блокируется для доступа из других потоков.

    В главном методе программы создается объект obj класса MyClass. Также создаются объекты up и down класса Thread. В обоих случаях объекты потоков создаются на основе лямбда-выражений. В потоке up выполняется метод run() с аргументами true и obj. В потоке down выполняется метод run() с аргументами false и obj. Поэтому при выполнении потока up выполняются операции по увеличению значения поля state объекта obj. Напротив, поток down выполняет операции по уменьшению значения поля state объекта obj.

    Командами

  up.Name = "UP";
и
  down.Name = "DOWN";
задаются названия для потоков, а командами
  up.Start();
и
  down.Start(); 
потоки запускаются на выполнение.

    Что можно сказать о результате выполнения программы? Сообщения от потоков выводятся парами, а конечное значение поля state объекта obj совпадает с начальным нулевым значением этого поля. Это последствия блокирования объекта obj при работе с ним потоков.

    Для сравнения интересно посмотреть, каким может быть результат выполнения программы, если не использовать блокировку объекта obj.

    Для этого в программном коде в примере выше следует закомментировать инструкцию lock(obj) (с открывающей фигурной скобкой) и закрывающую фигурную скобку блока в теле метода run(). Соответствующие места в программном коде выделены жирным шрифтом. Возможный результат выполнения программы в этом случае может выглядеть так, как показано ниже:


Рис.2. Результат выполнения программы при снятой блокировке

    Общий вывод состоит в том, что условие равенства начального и конечного значений поля state объекта obj не выполнено. И это является следствием нескоординированной работы потоков с общим ресурсом, которым в данном случае является объект obj.

    На следующем шаге мы рассмотрим использование потоков.




Предыдущий шаг Содержание Следующий шаг