Всегда ли секция блокировки гарантирует безопасность резьбы?


Я пытаюсь понять потокобезопасный доступ к полям. Для этого я реализовал некоторый тестовый образец:

class Program
{   
    public static void Main()
    {
        Foo test = new Foo();
        bool temp;

        new Thread(() => { test.Loop = false; }).Start();

        do
        {
            temp = test.Loop;
        }
        while (temp == true);
    }
}

class Foo
{
    public bool Loop = true;
}

Как и ожидалось, иногда это не заканчивается. Я знаю, что эту проблему можно решить либо с помощью ключевого слова volatile, либо с помощью блокировки. Я считаю, что я не автор класса Foo, поэтому я не могу сделать поле изменчивым. Я попробовал использовать lock:

public static void Main()
{
    Foo test = new Foo();
    object locker = new Object();
    bool temp;

    new Thread(() => { test.Loop = false; }).Start();

    do
    {
        lock (locker)
        {
            temp = test.Loop;
        }
    }
    while (temp == true);
}

Это, кажется, решает проблему. Просто чтобы убедиться, что я переместил цикл внутри блока блокировки:

lock(locker)
{
    do
    {
        temp = test.Loop;
    }
    while (temp == true);
}

И... программа этого не делает больше не прекращается.

Это совершенно сбивает с толку меня. разве блокировка не обеспечивает потокобезопасный доступ? Если нет, то как безопасно получить доступ к энергонезависимым полям? я мог бы использовать VolatileRead (), но он не подходит ни для одного случая, как не примитивный тип или свойства. Я рассматривал этот монитор.Enter делает работу , я прав? Я не понимаю, как это могло сработать.

5 3

5 ответов:

Этот фрагмент кода:

do
{
    lock (locker)
    {
        temp = test.Loop;
    }
}
while (temp == true);

Работает из-за побочного эффекта lock: он вызывает "забор памяти" . Фактическая блокировка здесь неуместна. Эквивалентный код:

do
{
   Thread.MemoryBarrier();   
   temp = test.Loop;       
}
while (temp == true);

И проблема, которую вы пытаетесь решить здесь, не совсем потокобезопасна, она связана с кэшированием переменной (устаревшие данные).

Он больше не завершается, потому что вы также обращаетесь к переменной вне блокировки.

В

new Thread(() => { test.Loop = false; }).Start();

Вы пишете в переменную вне замка. Эта запись не гарантированно будет видна.

Два одновременных доступа к одному и тому же местоположению, по крайней мере один из которых является записью, являются гонкой данных. Не делай этого.

Блокировка обеспечивает потокобезопасность для 2 или более блоков кода в разных потоках, использующих блокировку. Ваше назначение цикла внутри нового объявления потока не заключено в блокировку. Это означает, что там нет безопасности резьбы.

В общем, нет, lock не является чем-то, что волшебным образом сделает весь код внутри него потокобезопасным.

Простое правило: если у вас есть некоторые данные, которые совместно используются несколькими потоками, но вы всегда обращаетесь к ним только внутри блокировки (используя один и тот же объект блокировки), то этот доступ является потокобезопасным.

Как только вы оставите этот "простой" код и начнете задавать вопросы типа "Как я мог бы использовать volatile/VolatileRed() здесь безопасно?"или "Почему этот код, который не использует блокировку должным образом , кажется работающим?", все быстро усложняется. И вам, вероятно, следует избегать этого, если вы не готовы потратить много времени на изучение модели памяти C#. И даже тогда ошибки, которые проявляются только один раз в миллион запусков или только на определенных процессорах (ARM), очень легко сделать.

Блокировка работает только тогда, когда весь доступ к полю контролируется блокировкой. В вашем примере заблокировано только чтение, но поскольку записи нет, потокобезопасности нет.

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

Если у вас нет способа изменить Foo, единственный способ получить потокобезопасность-это фактически заблокировать все вызовы на одном экземпляре Foo. Однако это обычно не рекомендуется, так как все методы на объекте будут заблокированы.

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

Чтобы достичь потокобезопасности, Foo, вероятно, должен выглядеть примерно так:

class Program
{   
    public static void Main()
    {
        Foo test = new Foo();
        test.Run();

        new Thread(() => { test.Loop = false; }).Start();

        do
        {            
            temp = test.Loop;
        }
        while (temp == true);
    }
}

class Foo
{
    private volatile bool _loop = true;
    private object _syncRoot = new object();

    public bool Loop
    {
        // All access to the Loop value, is controlled by a lock on an instance-scoped object. I.e. when one thread accesses the value, all other threads are blocked.
        get { lock(_syncRoot) return _loop; }
        set { lock(_syncRoot) _loop = value; }
    }

    public void Run()
    {
        Task(() => 
        {
            while(_loop) // _loop is volatile, so value is not cached
            {
                // Do something
            }
        });
    }
}