Многопоточность в C#. Способы синхронизации потоков

В предыдущей части мы рассмотрели один из вариантов синхронизации потоков в многопоточном приложении — использование оператора lock. При этом в .NET имеются и другие способы синхронизации потоков о которых мы сегодня и поговорим.

Монитор (Monitor)

По сути, оператор lock, о котором шла речь в предыдущей части, это т.н. «синтаксический сахар», удобная обертка над классом Monitor. Вот так выглядел наш код с использованием оператора lock:

int x;
object locker = new();//создаем объект типа Lock

for (int i = 1; i < 6; i++)
{
    Thread thread = new(Func)
    {
        Name = i.ToString()
    };
    thread.Start();
}

void Func()
{
    lock (locker)
    {
        x = 0;
        for (int i = 0; i < 10; i++)
        {
            x++;
            Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
            Thread.Sleep(100);
        }
    }
}

С использованием Monitor, этот же код будет выглядеть следующим образом:

int x;
object locker = new();//создаем объект типа Lock
for (int i = 1; i < 6; i++)
{
    Thread thread = new(Func)
    {
        Name = i.ToString()
    };
    thread.Start();
}

void Func()
{
    bool lockWasTaken = false;
    try
    {
        Monitor.Enter(locker, ref lockWasTaken);
        x = 0;
        for (int i = 0; i < 10; i++)
        {
            x++;
            Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
            Thread.Sleep(100);
        }
    }
    finally
    {
        if (lockWasTaken)
            Monitor.Exit(locker);
    }

}

Здесь, метод Montor.Enter принимает два параметра:

  1. объект блокировки (locker)
  2. и значение типа bool, указывающее на результат блокировки (true означает, что блокировка успешно выполнена).

Вне зависимости от того, будут ли какие-либо исключительные ситуации (ошибки) в коде, в разделе finally блокировка снимается с использованием метода Exit и следующий поток получает доступ к критической секции для выполнения своей задачи.

Кроме методов Enter/Exit класс Monitor также содержит ряд полезных методов для синхронизации потоков.

Метод Описание
IsEntered Определяет, содержит ли текущий поток блокировку указанного объекта
TryEnter Пытается получить эксклюзивную блокировку указанного объекта
Wait Освобождает блокировку объекта и блокирует текущий поток до тех пор, пока тот не получит блокировку снова.
Pulse Уведомляет поток в очереди готовности об изменении состояния объекта с блокировкой
PulseAll Уведомляет все ожидающие потоки об изменении состояния объекта

 

Семафор (Semaphore)

Может потребоваться организовать не монопольный доступ к какому-либо ресурсу, а позволить нескольким потокам работать с пулом ресурсов. В этом случае мы можем использовать семафор. Семафор позволяет запускать в работу определенное количество потоков, а остальные — приостанавливать. Рассмотрим следующий пример:

for (int i = 1; i < 7; i++)
{
    Visitor visitor = new Visitor(i);
}

public class Visitor
{
    static Semaphore _semaphore = new Semaphore(3, 3);
    private readonly Thread myThread;
    public Visitor(int i)
    {
        myThread = new Thread(Fun)
        {
            Name = $"Посетитель #{i}"
        };
        myThread.Start();
    }
    public static void Fun()
    {
        _semaphore.WaitOne();
        Console.WriteLine($"{Thread.CurrentThread.Name} входит в клуб");
        Console.WriteLine($"{Thread.CurrentThread.Name} веселится");
        Thread.Sleep(1000); 
        Console.WriteLine($"{Thread.CurrentThread.Name} покидает клуб");
        _semaphore.Release();
    }
}

Здесь вся функциональность сосредоточена в классе Visitor. Так как семафор должны видеть все потоки, то поле _semaphore объявлено как static.

static Semaphore _semaphore = new Semaphore(3, 3);

Семафор создается с двумя параметрами (new Semaphore(3, 3)) — первый параметр показывает количество свободных мест, а второй — общую емкость. Таким образом мы изначально считаем, что можно сразу запустить три потока.

Далее, в методе Fun() потоки захватывают семафор (_semaphore.WaitOne()), выполняют работу (Thread.Sleep(1000)) и, затем освобождают очередь (_semaphore.Release()и уже следующий поток может выполнять работу. Вывод в консоли будет выглядеть следующим образом:

Посетитель #2 входит в клуб
Посетитель #2 веселится
Посетитель #4 входит в клуб
Посетитель #4 веселится
Посетитель #1 входит в клуб
Посетитель #1 веселится
Посетитель #4 покидает клуб
Посетитель #1 покидает клуб
Посетитель #2 покидает клуб
Посетитель #3 входит в клуб
Посетитель #5 входит в клуб
Посетитель #5 веселится
Посетитель #3 веселится
Посетитель #6 входит в клуб
Посетитель #6 веселится
Посетитель #5 покидает клуб
Посетитель #3 покидает клуб
Посетитель #6 покидает клуб
Стоит отметить, что не всегда потоки будут запускаться в том порядке в котором они созданы. То, в какой последовательности будут стартовать потоки — решает операционная система, что, собственно и продемонстрировано в выводе консоли выше

Мьютекс (Mutex)

Ещё один способ синхронизации потоков — использование мьютексов. В .NET работа с Mutex в чем-то схожа с предыдущими способами синхронизации потоков. С одной стороны — мьютекс, как и Monitor (а равно и оператор lock) позволяет дать доступ к общему ресурсу только одному потоку. С другой стороны — для управления мьютексами используются методы сходу с семафором —  WaitOne() и ReleaseMutex(). Так, наш пример с доступом к переменной можно переписать с использованием мьютекса следующим образом:

int x;
Mutex mutex = new Mutex(); 
object locker = new object();
for (int i = 1; i < 6; i++)
{
    Thread thread = new Thread(new ThreadStart(Func));
    thread.Name = i.ToString();
    thread.Start();
}

void Func()
{
    mutex.WaitOne();
    x = 0;
    for (int i = 0; i < 10; i++)
    {
        x++;
        Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
        Thread.Sleep(100);
    }
    mutex.ReleaseMutex();
}

Как в случае с семафором, при использовании мьютекса основную работу по синхронизации потоков выполняют методы WaitOne() и ReleaseMutex().

Метод WaitOne()приостанавливает выполнение потока до тех пор, пока не будет получен объект мьютекса mutex. После выполнения всех необходимых действий мьютекс становится не нужен и поток освобождает его с помощью метода ReleaseMutex().Таким образом, как только поток дойдет до вызова WaitOne(), он будет ожидать освобождения мьютекса и после его получения продолжит выполнять свою задачу.

Может возникнуть логичный вопрос: зачем нам мьютексы, если уже есть Monitor и даже удобная оболочка над ним в виде оператора lock

Ответ на этот вопрос дается разработчиками .NET следующим образом: мьютексы бывают двух типов: локальные мьютексы без имени и именованные системные мьютексы. Локальный мьютекс существует только в вашем процессе. Его может использовать любой поток в процессе, имеющий ссылку на объект Mutex, представляющий мьютекс. Именованные системные мьютексы видны во всей операционной системе и могут использоваться для синхронизации действий процессов.

То есть, в отличие от монитора (Monitor), мы можем использовать мьютексы для синхронизации потоков вообще во всей операционной системе. Одним из примеров использования мьютекса может выступать код проверки запуска приложения — когда нам надо обеспечить запуск только одного экземпляра приложения. Рассмотрим следующий пример:

bool firstInstance;
Mutex mutex = new Mutex(false, "my_named_mutex", out firstInstance);

if (!firstInstance)
{
    Console.WriteLine("Уже запущена копия приложения!"); 
    Console.ReadKey();
}
else
{

    int x;
    Mutex local_mutex = new Mutex();
    object locker = new object();
    for (int i = 1; i < 6; i++)
    {
        Thread thread = new Thread(new ThreadStart(Func));
        thread.Name = i.ToString();
        thread.Start();
    }
    Console.ReadKey();

    void Func()
    {
        local_mutex.WaitOne();
        x = 0;
        for (int i = 0; i < 10; i++)
        {
            x++;
            Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
            Thread.Sleep(100);
        }
        local_mutex.ReleaseMutex();
    }
}
GC.KeepAlive(mutex);//чтобы сборщик мусора не выкинул именованный мьютекс из памяти

Здесь первый мьютекс создается с использованием конструктора, который принимает три параметра:

public Mutex (bool initiallyOwned, string? name, out bool createdNew);
  • initiallyOwned — указывает, должен ли вызывающий поток быть изначальным владельцем мьютекса
  • name — произвольная строка, выступающая именем мьютекса.
  • createdNew — логическое значение, которое при возврате метода показывает, предоставлено ли вызывающему потоку изначальное владение мьютексом.

Таким образом, мы создаем именованный мьютекс, который может использоваться во всей операционной системе. При запуске первого экземпляра нашего приложения переменная firstInstance вернет нам true, а при всех последующих запусках экземпляров приложения — false. И, в зависимости от значения firstInstance мы либо выполняем код приложения с потоками, либо сообщаем, что один экземпляр приложения уже запущен.

Соответственно, второй мьютекс (local_mutex) используется только в нашем приложении и извне не виден никому, то есть работает как обычный монитор (или семафор со значениями в конструкторе (1, 1)). Теперь попробуйте запустить это приложение из проводника Windows и убедиться, что только один экземпляр приложения будет выводить результаты работы потоков:

 

AutoResetEvent

AutoResetEvent — это класс, который служит целям синхронизации потоков. Представляет событие синхронизации потоков, которое при срабатывании автоматически сбрасывается, освобождая один поток, находящийся в состоянии ожидания. Попробуем переписать наш пример синхронизации потоков с использованием класса AutoResetEvent:

internal class Program
{
    static int x = 0;
    static AutoResetEvent ResetEvent = new AutoResetEvent(true);
    static object locker = new object();
    static void Main(string[] args)
    {
        for (int i = 1; i < 6; i++)
        {
            Thread thread = new Thread(new ThreadStart(Func));
            thread.Name = i.ToString();
            thread.Start();
        }
    }

    public static void Func()
    {
        ResetEvent.WaitOne();
        x = 0;
        for (int i = 0; i < 10; i++)
        {
            x++;
            Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
            Thread.Sleep(100);
        }
        ResetEvent.Set();
    }
}

Разберем этот пример более подробно. Объект ResetEvent может находится в двух состояниях — сигнальном и несигнальном. По умолчанию мы создаем объект в сигнальном состоянии (new AutoResetEvent(true)).

Все потоки, которые мы создаем, стоят в очереди за этим объектом и получить его они могут только в том случае, если объект находится в сигнальном состоянии. Таким образом, раз мы создаем этот объект в уже сигнальном состоянии, то первый же созданный нами поток «ловит» этот этот сигнал и вызывает метод ResetEvent.WaitOne(), что означает, что наш объект ResetEvent переходит в несигнальное состояние — все потоки за текущим потоком снова встают в очередь и ждут, пока текущий поток не выполнит свою задачу и не «передаст сигнал», используя метод ResetEvent.Set(). И так происходит до тех пор, пока не пройдет вся очередь потоков.

Таким образом, класс AutoResetEvent позволяет управлять синхронизацией потоков, используя «сигналы» и его работа схожа с работой монитора или оператора lock.

Класс Lock

Начиная с .NET 9 в C# появился новый класс — Lock, который также можно использовать для определения областей кода, требующих взаимоисключающего доступа между потоками процесса. При этом, объекты типа Lock можно использовать различными способами.  Рассмотрим их на примере нашего небольшого приложения.

Использование Lock совместно с оператором lock

Самый простой и уже известный нам способ — использовать объект типа Lock с одноименным оператором:

int x;
Lock locker = new();//создаем объект типа Lock

for (int i = 1; i < 6; i++)
{
    Thread thread = new(Func)
    {
        Name = i.ToString()
    };
    thread.Start();
}

void Func()
{
    lock (locker)
    {
        x = 0;
        for (int i = 0; i < 10; i++)
        {
            x++;
            Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
            Thread.Sleep(100);
        }
    }
}

Как было сказано выше — в оператор lock мы можем передать любой объект ссылочного типа, поэтому этот способ использования класса Lock не содержит в себе, в принципе, ничего нового.

Использование метода Enter

Класс Lock содержит свои методы, которые можно использовать для синхронизации потоков и первый из них — метод Enter(). Вот как мы можем использовать этот метод:

int x;
Lock locker = new();//создаем объект типа Lock

for (int i = 1; i < 6; i++)
{
    Thread thread = new(Func)
    {
        Name = i.ToString()
    };
    thread.Start();
}

void Func()
{
    locker.Enter();
    try
    {
        x = 0;
        for (int i = 0; i < 10; i++)
        {
            x++;
            Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
            Thread.Sleep(100);
        }
    }
    finally 
    { 
        locker.Exit(); 
    }
}

Здесь метод Enter() обозначает начало критической секции. Опять же, доступ к этой секции получает в конкретный момент времени только один поток. Так как внутри критической секции может произойти всё, что угодно, то мы заключили код в блок try...finally и в finally мы вызываем противоположный метод Lock.Exit(), который снимает блокировку, позволяя, тем самым, другому потоку зайти в критическую секцию.

Использование метода EnterScope

Этот способ является предпочтительным, с точки зрения разработчиков, и предназначен, прежде всего для использования совместно с оператором using следующим образом:

int x;
Lock locker = new();//создаем объект типа Lock
for (int i = 1; i < 6; i++)
{
    Thread thread = new(Func)
    {
        Name = i.ToString()
    };
    thread.Start();
}
void Func()
{
    using (locker.EnterScope())
    {
        x = 0;
        for (int i = 0; i < 10; i++)
        {
            x++;
            Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
            Thread.Sleep(100);
        }
    }
}

Использование using гарантирует, что ресурсы будут освобождены (блокировка будет снята) даже в случае исключительно ситуации.

Использование метода TryEnter

Метод TryEnter() возвращает true, если блокировка вводится текущим потоком и, в зависимости от этого значения мы можем выстраивать логику программы. Например,

int x;
Lock locker = new();//создаем объект типа Lock
for (int i = 1; i < 6; i++)
{
    Thread thread = new(Func)
    {
        Name = i.ToString()
    };
    thread.Start();
}
void Func()
{
    if (locker.TryEnter())
    {
        //заходим в критическую секцию
        try
        {
            x = 0;
            for (int i = 0; i < 10; i++)
            {
                x++;
                Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}");
                Thread.Sleep(100);
            }
        }
        finally
        {
            locker.Exit(); //снимаем блокировку
        }
    }
    else
        Console.WriteLine($"Поток {Thread.CurrentThread.Name} не смог добраться до критической секции");
}

Так как у нас все потоки стартуют практически одновременно, то при использовании TryEnter() только один поток сможет зайти в критическую секцию, а все остальные, не дожидаясь её освобождения пройдут мимо. То есть в консоли мы можем увидеть вот такой вывод:

Поток 3 вывел число 1
Поток 1 не смог добраться до критической секции
Поток 2 не смог добраться до критической секции
Поток 4 не смог добраться до критической секции
Поток 5 не смог добраться до критической секции
Поток 3 вывел число 2
Поток 3 вывел число 3
Поток 3 вывел число 4
Поток 3 вывел число 5
Поток 3 вывел число 6
Поток 3 вывел число 7
Поток 3 вывел число 8
Поток 3 вывел число 9
Поток 3 вывел число 10

Итого

Сегодня мы рассмотрели различные варианты (способы) синхронизации потоков в .NET C# — мониторы, мьютексы, семафоры и класс, доступный в .NET 9 — Lock. Это далеко не все возможные способы синхронизации потоков, доступные в .NET, но выбор того или иного варианта синхронизации потоков зависит от условий конкретной задачи и остается на совести программиста. Наиболее популярными же способами синхронизации являются мониторы (класс Monitor) и использование оператора lock.

Подписаться
Уведомить о
guest
3 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии