понедельник, 21 мая 2012 г.

Часть 4. Способы синхронизации потоков в .Net

Итак, мы с вами уже познакомились с двумя способами синхронизации в .Net приложениях. Внимательный читатель спросит, откуда два. Ведь мы смотрели только ключевое слово lock. И будет неправ, т.к. мы еще использовали метод Join, который приостанавливает выполнение текущего потока, до тех пор, пока не завершится поток, для которого и вызван Join. Во многих случаях этот метод удобнее использовать по сравнению с другими способами синхронизации. Теперь же, давайте познакомимся с другими способами, которые предоставляет  нам с вами .Net.



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.
Что-то, получилось достаточно много, давайте на этом закончим, а в следующий раз поговорим о событиях синхронизации.

3 комментария:

  1. в Linux Mint почему то Mutex.TryOpenExisting("MyMutex", out mut) всегда возвращает false, хотя мутекс создан. И значит этот способ определения работающей программы не дает гарантии

    ОтветитьУдалить
    Ответы
    1. Статья писалась 4 года назад и для WIndows. Возможно на других платформах эти вещи работают немного по другому.

      Удалить
    2. Таки да, в линуксе не поддерживаются имена в мутексах

      Удалить