Есть ли общий способ преобразования критической секции в один или несколько семафоров?

Есть ли общий способ преобразования критической секции в один или несколько семафоров? То есть есть ли какое-то прямое преобразование кода, которое можно сделать для их преобразования?

Например, если у меня есть два потока, выполняющих защищенную и незащищенную работу, как показано ниже. Могу ли я преобразовать их в семафоры, которые можно сигнализировать, очищать и ждать?

void AThread()
{
  lock (this)
  {
    Do Protected Work
  }

  Do Unprotected work.
}

Вопрос пришел ко мне после того, как я подумал об операторе C# lock() и о том, могу ли я реализовать эквивалентную функциональность с помощью EventWaitHandle.


person Jeff    schedule 30.04.2013    source источник
comment
Что вы имеете в виду преобразовать в семафор? И нет, вы не можете надежно реализовать взаимное исключение, используя что-то вроде EventWaitHandle. Наконец, есть ли особая причина, по которой вы хотите избегать lock?   -  person Jim Mischel    schedule 01.05.2013
comment
Семафор не эквивалентен оператору lock. Это не повторный вход, всегда хороший способ вызвать взаимоблокировку. Как и ваш код блокировки (этот) тоже. Произвольная замена кода синхронизации, который работает без уважительной причины, никогда не является хорошей идеей.   -  person Hans Passant    schedule 01.05.2013
comment
Мне интересно, есть ли логический эквивалент между критическим разделом и сигнализацией семафора. Я думаю об этом как о теореме ДеМоргана; это означает, что я могу преобразовать (A && B) в (! (!A || !B)). Нет никакого нежелания использовать блокировку. Для меня это скорее теоретический вопрос, поэтому я поместил его в cstheory.   -  person Jeff    schedule 01.05.2013
comment
На первый взгляд критический раздел выглядит как семафор с максимальным числом 1. На основании этого ошибочного и неполного представления они выглядят взаимозаменяемыми. Но это не так. Поток не может освободить критическую секцию, которую он не получил раньше. Это не относится к семафору; поток может вызвать освобождение семафора, даже если он ранее не получил его. Это самое важное из нескольких отличий.   -  person Jim Mischel    schedule 02.05.2013


Ответы (3)


Да, есть общий способ преобразования секции lock в использование Semaphore с использованием того же блока try...finally, которому эквивалентен lock, с Semaphore с максимальным счетчиком 1, инициализированным для счетчика 1.

ИЗМЕНИТЬ (11 мая) недавнее исследование показало мне, что моя ссылка на попытку... наконец эквивалентность устарела. В результате приведенные ниже примеры кода необходимо будет соответствующим образом скорректировать. (конец редактирования)

    private readonly Semaphore semLock = new Semaphore(1, 1);
    void AThread()
    {
        semLock.WaitOne();
        try {
            // Protected code
        }
        finally {
            semLock.Release();
        }
        // Unprotected code
    }

Однако вы бы никогда этого не сделали. lock:

  • используется для ограничения доступа к ресурсам для одного потока за раз,
  • передает намерение, что ресурсы в этом разделе не могут быть одновременно доступны более чем одному потоку

И наоборот Semaphore:

  • предназначен для управления одновременным доступом к пулу ресурсов с ограничением на одновременный доступ.
  • передает намерение либо пула ресурсов, к которому может получить доступ максимальное число потоков, либо управляющего потока, который может освободить некоторое количество потоков для выполнения некоторой работы, когда он будет готов.
  • с максимальным количеством 1 будет работать медленнее, чем блокировка.
  • может быть выпущена любой веткой, а не только той, которая вошла в раздел (добавлено в редактирование)

Изменить: вы также упомянули EventWaitHandle в конце своего вопроса. Стоит отметить, что Semaphore — это WaitHandle, а не EventWaitHandle, а также из Документация MSDN для EventWaitHandle.Set:

Нет никакой гарантии, что каждый вызов метода Set освобождает поток от EventWaitHandle, режим сброса которого — EventResetMode.AutoReset. Если два вызова расположены слишком близко друг к другу, так что второй вызов происходит до освобождения потока, освобождается только один поток. Как будто второго звонка не было.

Деталь

Вы спрашивали:

Есть ли общий способ преобразования критической секции в один или несколько семафоров? То есть есть ли какое-то прямое преобразование кода, которое можно сделать для их преобразования?

Учитывая, что:

    lock (this) {
        //  Do protected work
    }
    //Do unprotected work

эквивалентен (см. ниже ссылку и примечания по этому поводу) к

** РЕДАКТИРОВАТЬ: (11 мая) в соответствии с приведенным выше комментарием этот пример кода необходимо настроить перед использованием в соответствии с эта ссылка

    Monitor.Enter(this);
    try {
        // Protected code
    }
    finally {
        Monitor.Exit(this);
    }
    // Unprotected code

Вы можете добиться того же, используя Semaphore, выполнив:

    private readonly Semaphore semLock = new Semaphore(1, 1);
    void AThread()
    {
        semLock.WaitOne();
        try {
            // Protected code
        }
        finally {
            semLock.Release();
        }
        // Unprotected code
    }

Вы также спросили:

Например, если у меня есть два потока, выполняющих защищенную и незащищенную работу, как показано ниже. Могу ли я преобразовать их в семафоры, которые можно сигнализировать, очищать и ждать?

Это вопрос, который я изо всех сил пытался понять, поэтому я извиняюсь. В вашем примере вы называете свой метод AThread. Для меня это не совсем AThread, это AMethodToBeRunByManyThreads!!

    private readonly Semaphore semLock = new Semaphore(1, 1);
    void MainMethod() {
        Thread t1 = new Thread(AMethodToBeRunByManyThreads);
        Thread t2 = new Thread(AMethodToBeRunByManyThreads);
        t1.Start();
        t2.Start();
        //  Now wait for them to finish - but how?
    }
    void AMethodToBeRunByManyThreads() { ... }

Таким образом, semLock = new Semaphore(1, 1); защитит ваш "защищенный код", но lock больше подходит для этого использования. Разница в том, что семафор позволяет подключиться третьему потоку:

    private readonly Semaphore semLock = new Semaphore(0, 2);
    private readonly object _lockObject = new object();
    private int counter = 0;
    void MainMethod()
    {
        Thread t1 = new Thread(AMethodToBeRunByManyThreads);
        Thread t2 = new Thread(AMethodToBeRunByManyThreads);
        t1.Start();
        t2.Start();
        //  Now wait for them to finish
        semLock.WaitOne();
        semLock.WaitOne();
        lock (_lockObject)
        {
            // uses lock to enforce a memory barrier to ensure we read the right value of counter
            Console.WriteLine("done: {0}", counter);  
        }
    }

    void AMethodToBeRunByManyThreads()
    {
        lock (_lockObject) {
            counter++;
            Console.WriteLine("one");
            Thread.Sleep(1000);
        }
        semLock.Release();
    }

Однако в .NET 4.5 для этого можно использовать Tasks и управлять синхронизацией основного потока.


Вот несколько мыслей:

lock(x) и Monitor.Enter — эквивалентность

Приведенное выше утверждение об эквивалентности не совсем точно. Фактически:

«[lock] точно эквивалентен [Monitor.Enter try ... finally] за исключением того, что x оценивается только один раз [by lock]» (ссылка: Спецификация языка C#)

Это мелочь, и, вероятно, не имеет значения для нас.

Возможно, вам придется быть осторожным с барьерами памяти и увеличивать поля, подобные счетчикам, поэтому, если вы используете семафор, вам все равно может понадобиться блокировка или блокировка, если вы уверены в ее использовании.

Остерегайтесь lock(this) и взаимоблокировок

Моим исходным источником для этого была бы статья Джеффри Рихтера "Безопасная синхронизация потоков". Это и общая лучшая практика:

  • Не блокируйте this, вместо этого создайте поле object в своем классе при создании экземпляра класса (не используйте тип значения, так как он все равно будет упакован)
  • Сделайте поле object доступным только для чтения (личное предпочтение, но оно не только передает намерение, но и предотвращает изменение вашего объекта блокировки другими участниками кода и т. д.)

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

Таким образом, ваш исходный код станет:

    private readonly object m_lockObject = new object();
    void AThread()
    {
        lock (m_lockObject) {
            //  Do protected work
        }
        //Do unprotected work
    }

(Примечание: обычно Visual Studio помогает вам в своих фрагментах, используя SyncRoot в качестве имени объекта блокировки)

Семафор и блокировка предназначены для разных целей

lock предоставляет потокам место в «очереди готовности» на основе FIFO (ref. Threading in C# — Джозеф Альбахари, часть 2: базовая синхронизация, раздел: блокировка). Когда кто-нибудь видит lock, он знает, что обычно внутри этого раздела находится общий ресурс, такой как поле класса, который должен изменяться только одним потоком за раз.

Семафор — это элемент управления, не относящийся к FIFO, для раздела кода. Он отлично подходит для сценариев издатель-подписчик (межпотоковое взаимодействие). Свобода, связанная с тем, что разные потоки могут передавать семафор тем, кто его приобрел, очень эффективна. Семантически это не обязательно означает, что «только один поток обращается к ресурсам внутри этого раздела», в отличие от lock.

Пример: чтобы увеличить счетчик класса, вы можете использовать lock, но не Semaphore.

    lock (_lockObject) {
        counter++;
    }

Но для увеличения только после того, как другой поток сказал, что это можно сделать, вы можете использовать Semaphore, а не lock, где поток A выполняет приращение, когда у него есть раздел семафора:.

    semLock.WaitOne();
    counter++;
    return;

И поток B освобождает семафор, когда он готов разрешить приращение:

    // when I'm ready in thread B
    semLock.Release();

(Обратите внимание, что это принудительно, в этом примере может быть более подходящим WaitHandle, такой как ManualResetEvent).

Производительность

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

Время для 100 итераций в тиках на маленькой ВМ (чем меньше, тем лучше):

  • 291.334 (Семафор)
  • 44.075 (Семафор Тонкий)
  • 4.510 (Монитор.Ввод)
  • 6,991 (замок)

Тактов в миллисекунду: 10000

class Program
{
    static void Main(string[] args)
    {
        Program p = new Program();
        Console.WriteLine("100 iterations in ticks");
        p.TimeMethod("Semaphore", p.AThreadSemaphore);
        p.TimeMethod("SemaphoreSlim", p.AThreadSemaphoreSlim);
        p.TimeMethod("Monitor.Enter", p.AThreadMonitorEnter);
        p.TimeMethod("Lock", p.AThreadLock);
        Console.WriteLine("Ticks per millisecond: {0}", TimeSpan.TicksPerMillisecond);
    }

    private readonly Semaphore semLock = new Semaphore(1, 1);
    private readonly SemaphoreSlim semSlimLock = new SemaphoreSlim(1, 1);
    private readonly object _lockObject = new object();
    const int Iterations = (int)1E6;
    int sharedResource = 0;

    void TimeMethod(string description, Action a)
    {
        sharedResource = 0;
        Stopwatch sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < Iterations; i++)
        {
            a();
        }
        sw.Stop();
        Console.WriteLine("{0:0.000} ({1})", (double)sw.ElapsedTicks * 100d / (double)Iterations, description);
    }

    void TimeMethod2Threads(string description, Action a)
    {
        sharedResource = 0;
        Stopwatch sw = new Stopwatch();
        using (Task t1 = new Task(() => IterateAction(a, Iterations / 2)))
        using (Task t2 = new Task(() => IterateAction(a, Iterations / 2)))
        {
            sw.Start();
            t1.Start();
            t2.Start();
            Task.WaitAll(t1, t2);
            sw.Stop();
        }
        Console.WriteLine("{0:0.000} ({1})", (double)sw.ElapsedTicks * (double)100 / (double)Iterations, description);
    }

    private static void IterateAction(Action a, int iterations)
    {
        for (int i = 0; i < iterations; i++)
        {
            a();
        }
    }

    void AThreadSemaphore()
    {
        semLock.WaitOne();
        try {
            sharedResource++;
        }
        finally {
            semLock.Release();
        }
    }
    void AThreadSemaphoreSlim()
    {
        semSlimLock.Wait();
        try
        {
            sharedResource++;
        }
        finally
        {
            semSlimLock.Release();
        }
    }
    void AThreadMonitorEnter()
    {
        Monitor.Enter(_lockObject);
        try
        {
            sharedResource++;
        }
        finally
        {
            Monitor.Exit(_lockObject);
        }
    }
    void AThreadLock()
    {
        lock (_lockObject)
        {
            sharedResource++;
        }
    }
}
person Andy Brown    schedule 06.05.2013

Трудно определить, что вы просите здесь.

Если вам просто нужно что-то, что вы можете подождать, вы можете использовать Монитор, который lock использует под капотом. То есть ваша последовательность lock выше расширяется до чего-то вроде:

void AThread()
{
    Monitor.Enter(this);
    try
    {
        // Do protected work
    }
    finally
    {
        Monitor.Exit(this);
    }
    // Do unprotected work
}

Кстати, lock (this) обычно плохая идея. Вам лучше создать объект блокировки:

private object _lockObject = new object();

Теперь, если вы хотите условно получить блокировку, вы можете использовать `Monitor.TryEnter:

if (Monitor.TryEnter(_lockObject))
{
    try
    {
        // Do protected work
    }
    finally
    {
        Monitor.Exit(_lockObject);
    }
 }

Если вы хотите подождать с тайм-аутом, используйте перегрузку TryEnter:

if (Monitor.TryEnter(_lockObject, 5000))  // waits for up to 5 seconds

Возвращаемое значение равно true, если блокировка была получена.

Мьютекс фундаментально отличается от EventWaitHandle или Semaphore тем, что только поток, захвативший мьютекс, может его освободить. Любой поток может установить или сбросить WaitHandle, а любой поток может сбросить Semaphore.

Я надеюсь, что это отвечает на ваш вопрос. Если нет, отредактируйте свой вопрос, чтобы предоставить нам более подробную информацию о том, что вы просите.

person Jim Mischel    schedule 01.05.2013
comment
Джим Мишель. Ради интереса я только что узнал, что эквивалентность блокировки в .NET 4 отличается, поэтому приведенный выше код потенциально должен быть обновлен. См. Блокировки и исключения не смешиваются - Эрик Липперт - person Andy Brown; 12.05.2013

Вам следует ознакомиться с библиотеками Wintellect Power Threading: https://github.com/Wintellect/PowerThreading

Одна из функций этих библиотек — создание универсальных абстракций, позволяющих заменять примитивы потоков.

Это означает, что на 1- или 2-процессорной машине, где вы видите очень мало конфликтов, вы можете использовать стандартную блокировку. Одна машина с 4 или 8 процессорами, где часто возникают конфликты, возможно, блокировка чтения/записи будет более правильной. Если вы используете примитивы, такие как ResourceLock, вы можете поменять местами:

  • Спин Блокировка
  • Монитор
  • Мьютекс
  • Читатель Писатель
  • Оптекс
  • семафор
  • ... и другие

Я написал код, который динамически, в зависимости от количества процессоров, выбирал определенные блокировки в зависимости от количества возможных конфликтов. Со структурой, найденной в этой библиотеке, это практично.

person Chris M.    schedule 06.05.2013