Итак,
мы с вами уже познакомились с двумя способами синхронизации в .Net приложениях.
Внимательный читатель спросит, откуда два. Ведь мы смотрели только ключевое
слово lock. И
будет неправ, т.к. мы еще использовали метод Join,
который приостанавливает выполнение текущего потока, до тех пор, пока не
завершится поток, для которого и вызван Join. Во
многих случаях этот метод удобнее использовать по сравнению с другими способами
синхронизации. Теперь же, давайте познакомимся с другими способами, которые
предоставляет нам с вами .Net.
Interlocked
Для начала давайте посмотрим первый пример на гонки, в котором в результате большого количества инкрементов (++) и равного ему количества декрементов (--), мы получали при каждом запуске разные значения. Давайте заменим стандартные операции, на операции из класса Interlocked:
static void Inc()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Increment(ref value);
}
}
static void Dec()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Decrement(ref value);
}
}
Thread inc = new Thread(new ThreadStart(Inc));
Thread dec = new Thread(new ThreadStart(Dec));
inc.Start();
dec.Start();
inc.Join();
dec.Join();
Console.WriteLine(value);
Console.ReadKey();
}
}
Для этого воспользуемся следующим алгоритмом:
1. Вводим дополнительную переменную, которая будет хранить 0, если наш аналог кретической секции свободен.
2. Пытаемся записать в нашу переменную 1, с проверкой, какое значение в ней было ранее. Если и ранее в переменной было 1, то ждем и выполняем этот пункт еще раз. Если в переменной был до присвоения 0, то переходим к шагу 3.
3. Делаем полезную работу.
4. Присваиваем в переменную 0.
Этот алгоритм на C#, оформим в виде вот такого метода:
// Присвоение в переменную 1, с проверкой, что в не было ранее
while (1 == Interlocked.Exchange(ref lockVariable, 1))
{
// Если была 1, то ждем
Thread.Sleep(10);
}
// Полезная работа
action();
// Освобождаем секцию записав в переменную 0
Interlocked.Exchange(ref lockVariable, 0);
}
for (int i = 0; i < 1000000; i++)
{
MyLock(() => { value = value + 1; });
}
}
static void Dec()
{
for (int i = 0; i < 1000000; i++)
{
MyLock(() => { value = value - 1; });
}
}
После запуска получаем на консоли 0. Т.е. наша самодельная критическая секция работает. Давайте, только перед тем как переходить к следующему объекту синхронизации, сравним время работы lock и нашего метода:
Класс Monitor
Mutex
Interlocked
Первым объектом синхронизации будет специальный класс,
который позволяет выполнять атомарные математические операции над числами так, чтобы
во время их выполнения у процесса не отбирали процессор. Таким классом является
Interlocked. Он
предоставляет методы сложения двух чисел (Add), инкремента (Increment),
декремента (Decrement),
замена одного значения другим (Exchange),
проверки на равенство двух значений, с заменой одного из этих двух значений
третьим, причем, кто будет заменен, зависит от результата сравнения (CompareExchange).
Давайте рассмотрим несколько примеров.Для начала давайте посмотрим первый пример на гонки, в котором в результате большого количества инкрементов (++) и равного ему количества декрементов (--), мы получали при каждом запуске разные значения. Давайте заменим стандартные операции, на операции из класса Interlocked:
class Program
{
static int value = 0;
static void Inc()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Increment(ref value);
}
}
static void Dec()
{
for (int i = 0; i < 1000000; i++)
{
Interlocked.Decrement(ref value);
}
}
static void Main(string[] args)
{Thread inc = new Thread(new ThreadStart(Inc));
Thread dec = new Thread(new ThreadStart(Dec));
inc.Start();
dec.Start();
inc.Join();
dec.Join();
Console.WriteLine(value);
Console.ReadKey();
}
}
Как видно, мы просто заменили ++ на вызов метода Increment, а -- на вызов Decrement. Благодаря такой
замене, операции будут выполняться неразрывно, а следовательно, так же как и в
примере с lock,
запуская эту программу мы будем всегда видеть 0. Лично мне кажется, в данном
случае, по сравнению с применением lock, программа читается легче.
Перед тем, как переходить ко второму примеру, пара слов про
метод Exchange. Он
принимает два параметра. В качестве первого по ссылке переменную, значение
которой необходимо заменить. В качестве второго параметра, по значению, то, чем
необходимо заменить. Метод возвращает значение, которое было в первом параметре
до замены. Давайте попробуем на этом методе реализовать свой аналог конструкции
lock.Для этого воспользуемся следующим алгоритмом:
1. Вводим дополнительную переменную, которая будет хранить 0, если наш аналог кретической секции свободен.
2. Пытаемся записать в нашу переменную 1, с проверкой, какое значение в ней было ранее. Если и ранее в переменной было 1, то ждем и выполняем этот пункт еще раз. Если в переменной был до присвоения 0, то переходим к шагу 3.
3. Делаем полезную работу.
4. Присваиваем в переменную 0.
Этот алгоритм на C#, оформим в виде вот такого метода:
static int lockVariable = 0;
static void MyLock(Action action)
{// Присвоение в переменную 1, с проверкой, что в не было ранее
while (1 == Interlocked.Exchange(ref lockVariable, 1))
{
// Если была 1, то ждем
Thread.Sleep(10);
}
// Полезная работа
action();
// Освобождаем секцию записав в переменную 0
Interlocked.Exchange(ref lockVariable, 0);
}
Ну и соответственно, изменим наши методы инкремента и
декремента, заменим в них Interlocked инкремент и декремент на сложение, но при этом обернем их в
нашу критическую секцию:
static int value = 0;
static void Inc()
{for (int i = 0; i < 1000000; i++)
{
MyLock(() => { value = value + 1; });
}
}
static void Dec()
{
for (int i = 0; i < 1000000; i++)
{
MyLock(() => { value = value - 1; });
}
}
После запуска получаем на консоли 0. Т.е. наша самодельная критическая секция работает. Давайте, только перед тем как переходить к следующему объекту синхронизации, сравним время работы lock и нашего метода:
Даже время практически одинаковое. Разница порядка 8%.
Класс Monitor
Класс Monitor ближайший родственник ключевого слова lock. Он также позволяет организовывать
критическую секцию, в которую одновременно может зайти не более чем один поток,
но отличия конечно имеются.
Итак класс Monitor имеет два метода, которые позволяют его использовать
аналогично lock: Enter (захватывающий критическую
секцию или ожидающий, пока она освободится) и Exit (освобождающий критическую секцию).
Ну и то, чего нет у lock:
TryEnter (пытается захватить критическую секцию и возвращает,
получилось или нет), Wait (освобождает блокировку для других потоков, но потом
пытается захватить ее вновь), Pulse и PulseAll (уведомляет поток/потоки в очереди готовности о изменении
статуса объекта синхронизации), IsEntered
(как следует из названия, показывает состояние критической секции).
Давайте сначала посмотрим пример аналогичный lock, а потом, как при помощи
Wait можем бороться с
тупиками (deadlock).
Итак, простой пример на Monitor:
static int value = 0;
static object syncObject = new object();
static void Inc()
{
for (int i = 0; i < 1000000; i++)
{
Monitor.Enter(syncObject);
value = value + 1;
Monitor.Exit(syncObject);
}
}
static void Dec()
{
for (int i = 0; i < 1000000; i++)
{
Monitor.Enter(syncObject);
value = value - 1;
Monitor.Exit(syncObject);
}
}
Если посмотреть, на этот пример, то видно, что
применение lock получается
существенно короче. Но даже если вам не нравиться синтаксис lock и вы
хотите вызывать методы, надо понимать, что для однозначного соответствия между lock и
применением Monitor код должен иметь следующий вид:
Или, иными словами, если в критической секции реализованной
при помощи lock произойдет ошибка, секция освободиться без дополнительных
усилий с нашей стороны. При использовании Monitor освобождение критической секции в
исключительной ситуации это уже наша забота. Таким образом, для простых случаев
лучше использовать lock.
Ну а теперь к тому, как можно попробовать бороться с тупиками при помощи Monitor:
static int valueA = 0;
static int valueB = 0;
static object mySectionA = new object();
static object mySectionB = new object();
static void Inc()
{
for (int i = 0; i < 1000000; i++)
{
Monitor.Enter(mySectionA);
valueA = valueA + 1;
while (!Monitor.TryEnter(mySectionB))
{
Monitor.Wait(mySectionA, 10);
}
valueB = valueB - 1;
Monitor.Exit(mySectionB);
Monitor.Exit(mySectionA);
}
}
static void Dec()
{
for (int i = 0; i < 1000000; i++)
{
Monitor.Enter(mySectionB);
valueB = valueB + 1;
Monitor.Enter(mySectionA);
valueA = valueA - 1;
Monitor.Exit(mySectionA);
Monitor.Exit(mySectionB);
}
}
Обратите внимание, на метод Inc. Он отличается от метода Dec тем,
что захватив секцию А, он не просто пытается захватить секцию B, а делает это с проверкой: получилось
или нет. Если получилось, то идет обычное выполнение. Если не получилось, то мы
освобождаем ненадолго блокировку секции А, потом ее опять захватываем и снова
проверяем получается ли захватить секцию B.
Перед тем как мы пойдем дальше, давайте я еще раз
сформулирую: в случае, если просто необходимо обеспечить блокировки критических
секций, правильнее будет использовать lock. Если же стоит сложная задача параллельной обработки, с
возможностью бороться с тупиками и т.д., то можно использовать и Monitor. На мой же взгляд,
основное достоинство lock
в том, что при его применении, вы не сможете оставить критическую секцию
захваченной забыв написать ее освобождение, т.к. это будет синтаксическая
ошибка. В случае применения класса Monitor, такая возможность есть, забыв в одной из веток обработки
вызвать Monitor.Exit вы подарите себе много
часов преинтереснейшей отладки.
Mutex
На первый взгляд, класс Mutex очень похож на класс Monitor. А отличие
заключается в том, что Monitor является static классом, а Mutex нет (т.е. для работы с ним, нам придется создавать объекты
производные от него), а также названием методов. Для входа в критическую секцию
у Mutex используется метод WaitOne, для освобождения метод Releasemutex. Но эти отличия
поверхностны, давайте посмотрим пример на применение Mutex как
аналога Monitor, а
потом разберемся, в чем же состоит основное отличие.
Итак, пример использования Mutex по
аналогии с Monitor:
static int value = 0;
static Mutex mut = new Mutex();
static void Inc()
{
for (int i = 0; i < 1000000; i++)
{
mut.WaitOne();
value = value + 1;
mut.ReleaseMutex();
}
}
static void Dec()
{
for (int i = 0; i < 1000000; i++)
{
mut.WaitOne();
value = value - 1;
mut.ReleaseMutex();
}
}
Как видим, кроме необходимости создавать объект и работать с
методами через объектную переменную, а не напрямую через класс разницы никакой.
Первое отличие мы увидим, запустив программу. Она будет работать примерно в 100
раз медленнее. Понятно, что если бы только для того чтобы вместо статической
реализации использовать объекты, на таки жертвы по времени никто бы не пошел.
Поэтому о главном отличии Mutex и Monitor:
Mutex можно
использовать для синхронизации между процессами. Т.е. если вы создадите Mutex и присвоите ему
строковое имя (есть такая версия перегруженного конструктора), то он будет
доступен всем процессам запущенным на компьютере.
В качестве первого пример, больше для подтверждения факта
единственности мутекса в операционной системе, давайте посмотрим пример
простого приложения, которое будет проверять при запуске, а не запущено ли оно
уже. Для получения такого приложения, я в классе App приложения
перегружу методы OnStartup и OnExit:
public partial class App : Application
{
Mutex mut = null;
protected override void OnStartup(StartupEventArgs e)
{
if (Mutex.TryOpenExisting("MutexName", out mut))
{
MessageBox.Show("Приложение
уже запущено");
mut = null;
this.Shutdown();
}
mut = new Mutex(true, "MutexName");
}
protected override void OnExit(ExitEventArgs e)
{
if (mut != null)
{
mut.Dispose();
}
}
}
Как видно, в данном примере, приложение пытается получить из
системы мутекс c именем «MutexName».
Если он есть, то закрываем приложение, не забыв уведомить пользователя о том,
что приложение уже запущено. Если мутекса нет, то создаем его, не забыв
освободить по окончании работы приложения. К сведению, если забыть освободить
мутекс, то тоже ничего смертельного не произойдет, он все равно освободится
сборщиком мусора по окончанию работы приложения, но лучше перестраховаться.
Ну и собственно пример, как мы можем использовать мутекс для
синхронизации между процессами.
Возьмем два простых приложения. Первое будет записывать
случайные числа в файл, второе считывать их из этого файла и выводить на экран.
Ну а третье приложение, будет запускать первые два.
Код приложения записывающего файл:
static void Main(string[] args)
{
Random rnd = new Random();
for (int i = 0; i < 100000; i++)
{
StreamWriter sw = new StreamWriter("1.txt", true);
sw.WriteLine(rnd.Next(100000));
sw.Close();
}
}
Код приложения считывающего файл:
static void Main(string[] args)
{
int i = 0;
while (i < 100000)
{
if (File.Exists("1.txt"))
{
StreamReader sr = new StreamReader("1.txt");
while (!sr.EndOfStream)
{
Console.WriteLine(sr.ReadLine());
i++;
}
sr.Close();
File.Delete("1.txt");
}
}
}
Код приложения запускающего первые два:
static void Main(string[] args)
{
Process writer = new Process();
writer.StartInfo.FileName = "FileWriter.exe";
Process reader = new Process();
reader.StartInfo.FileName = "FileReader.exe";
writer.Start();
reader.Start();
writer.WaitForExit();
reader.WaitForExit();
Console.ReadKey();
}
При попытке все это запустить, практически сразу мы
увидим падение одного из приложений, т.к. оно не может получить доступ к файлу:
Это происходит из-за того, что операционная система не дает
двум процессам одновременно работать с одним файлом. Давайте изменим
приложения, добавив один общий для всех Mutex, при помощи которого они будут синхронизироваться.
Начнем с изменения приложения запуска. В нем создадим мутекс
для синхронизации:
static void Main(string[] args)
{
Mutex mut = new Mutex(false, "MutexForFile");
Process writer = new Process();
writer.StartInfo.FileName = "FileWriter.exe";
Process reader = new Process();
reader.StartInfo.FileName = "FileReader.exe";
writer.Start();
reader.Start();
writer.WaitForExit();
reader.WaitForExit();
Console.ReadKey();
}
В приложении для записи и в приложении для чтения, также
создадим мутексы и организуем при их помощи критические секции:
static void Main(string[] args)
{
Mutex mut = Mutex.OpenExisting("MutexForFile");
Random rnd = new Random();
for (int i = 0; i < 100000; i++)
{
mut.WaitOne();
StreamWriter sw = new StreamWriter("1.txt", true);
int j = rnd.Next(100000);
sw.WriteLine(j);
Console.WriteLine("Записано {0}", j);
sw.Close();
mut.ReleaseMutex();
Thread.Sleep(100);
}
}
static void Main(string[] args)
{
Mutex mut = Mutex.OpenExisting("MutexForFile");
int i = 0;
while (i < 100000)
{
mut.WaitOne();
if (File.Exists("1.txt"))
{
StreamReader sr = new StreamReader("1.txt");
while (!sr.EndOfStream)
{
Console.WriteLine(sr.ReadLine());
i++;
}
sr.Close();
File.Delete("1.txt");
}
mut.ReleaseMutex();
}
}
Запускаем. Все великолепно работает:
Т.е. применение класса Mutex оправдано только в том случае, если
вы хотите синхронизировать разные процессы, т.к. в рамках одного процесса (для
синхронизации потоков), мутексы будут работать на порядки медленнее чем Monitor или
lock.
Что-то, получилось достаточно много, давайте на этом
закончим, а в следующий раз поговорим о событиях синхронизации.
в Linux Mint почему то Mutex.TryOpenExisting("MyMutex", out mut) всегда возвращает false, хотя мутекс создан. И значит этот способ определения работающей программы не дает гарантии
ОтветитьУдалитьСтатья писалась 4 года назад и для WIndows. Возможно на других платформах эти вещи работают немного по другому.
УдалитьТаки да, в линуксе не поддерживаются имена в мутексах
Удалить