На этом шаге мы рассмотрим пример синхронизации потоков.
Обычно разные потоки используют общие ресурсы. Скажем, в примере предыдущего шага два потока одновременно пытались изменить значение одного и того же статического поля. Чтобы понять, какие потенциальные проблемы могут возникнуть в подобных ситуациях, рассмотрим пример, не имеющий прямого отношения к программированию.
Предположим, что имеется банковский счет, на котором есть определенная сумма денег. Доступ к этому счету имеет несколько человек, которые могут снимать деньги со счета и пополнять счет. Пускай для определенности на счету находится сумма в 1000 денежных единиц (не важно каких). Два клиента пытаются одновременно выполнить такие операции:
В чем тут проблема? Проблема в том, что один процесс вмешался в работу другого процесса. Какой выход из ситуации? Можно заблокировать ресурс (в данном случае это счет): пока с ресурсом работает один процесс, другим процессам доступ к ресурсу запрещен. Как только первый процесс завершил работу с ресурсом, с ним могут работать другие процессы (но в том же режиме - пока процесс работает с ресурсом, другие процессы к ресурсу доступа не имеют).
Похожая ситуация может возникнуть в программировании при одновременном обращении нескольких потоков к одному общему ресурсу (например, объекту). Если мы хотим избежать проблем из-за одновременного доступа нескольких потоков к общему объекту, этот объект можно заблокировать на время выполнения потока. Для этого используют инструкцию 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 (с начальным нулевым значением).
Класс MyClass нам нужен для того, чтобы на его основе создать объект, за который будут конкурировать дочерние потоки. Дочерние потоки создаются на основе статического метода run(), описанного в главном классе (точнее, потоки создаются на основе анонимных методов, в которых вызывается метод run()). У метода два аргумента: аргумент type логического типа и аргумент obj, который является ссылкой на объект класса MyClass. При выполнении метода запускается конструкция цикла while, в котороq командой
val = obj.state;
obj.state = val;
Random rnd = new Random();
Инструкции {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.
На следующем шаге мы рассмотрим использование потоков.