Использование оператора блокировки в цикле в С#

Давайте возьмем образец класса SomeThread, где мы пытаемся запретить методы DoSomething после того, как свойство Running установлено в false, и Dispose вызывается классом OtherThread, потому что, если они вызывается после метода Dispose, это мир конец, как мы его знаем.

Похоже, есть шанс, что что-то зло произойдет из-за цикла. В тот момент, когда он запускает следующий цикл и до того, как блокировка будет предпринята перед вызовом методов DoSomething, Running может быть изменен на false, а Disposed вызывается до того, как он ударит блокировку. В этом случае жизнь не будет хорошей.

Я искал способы справиться с этим при использовании цикла в простом методе поддержки. Для записи я рассмотрел шаблон Double Lock Check, но, похоже, он не рекомендуется для С#.

Предупреждение:. Это упрощенный пример, чтобы попытаться сосредоточиться на проблеме с циклом и блокировкой в ​​одном. Если я не уточню достаточно места, пожалуйста, дайте мне знать, и я сделаю все возможное, чтобы заполнить любые детали.

public class SomeThread : IDisposable
{
    private object locker = new object();
    private bool running = false;

    public bool Running 
    { 
        get
        {
            lock(locker)
            {
                return running;
            }
        }
        set
        {
            lock(locker)
            {
                running = value;
            }
        }
    }

    public void Run()
    {
        while (Running)
        {
            lock(locker)
            {
                DoSomething1();
                DoSomething2();
            }
        }
    }

    private void DoSomething1()
    {
        // something awesome happens here
    }

    private void DoSomething2()
    {
        // something more awesome happens here
    }

    public void Dispose()
    {
        lock (locker)
        {   
            Dispose1();
            Dispose2();
        }
    }

    private void Dispose1()
    {
        // something awesome happens here
    }

    private void Dispose2()
    {
        // something more awesome happens here
    }

}

public class OtherThread
{
    SomeThread st = new SomeThread();

    public void OnQuit()
    {
        st.Running = false;
        st.Dispose();

        Exit();
    }
}

Ответ 1

Сделайте шаг назад.

Начните с указания всех желаемых и нежелательных характеристик, прежде чем начинать писать решение. Некоторые из них сразу приходят в голову:

  • "Работа" выполняется в потоке W. "UI" выполняется в потоке U.
  • Работа выполнена в "единицах работы". Каждая единица работы "короткая" по продолжительности, для некоторого определения "короткая". Позвольте вызвать метод, который выполняет работу M().
  • Работа выполняется непрерывно W, в цикле, пока U не сообщит ей, чтобы она остановилась.
  • U вызывает метод очистки D(), когда выполняется вся работа.
  • D() никогда не должен запускаться до или во время работы M().
  • Exit() должен вызываться после D(), в потоке U.
  • U никогда не должен блокировать "длительное" время; для него приемлемо блокировать "короткое" время.
  • Нет взаимоблокировок и т.д.

Подводит ли это итог проблемному пространству?

Во-первых, я отмечаю, что на первый взгляд кажется, что проблема в том, что U должен быть вызывающим элементом D(). Если бы W был вызывающей стороной D(), вам не пришлось бы волноваться; вы просто сигнализируете W выйти из цикла, а затем W будет вызывать D() после цикла. Но это просто торгует одной проблемой для другой; предположительно в этом сценарии, U должен дождаться, когда W вызовет D(), прежде чем U вызовет Exit(). Поэтому перемещение вызова к D() из U в W не облегчает задачу.

Вы сказали, что не хотите использовать блокировку с двойным проверкой. Вы должны знать, что с CLR v2 считается, что проверенный двойной блокировкой шаблон является безопасным. Гарантии модели памяти были усилены в версии 2. Поэтому, вероятно, вам будет безопасно использовать блокировку с двойной проверкой.

UPDATE: вы запросили информацию о (1), почему двойной флажок блокировки в v2, но не в v1? и (2) почему я использовал ласковое слово "возможно"?

Чтобы понять, почему блокировка с двойной проверкой небезопасна в модели памяти CLR v1, но безопасна в модели памяти CLR v2, прочтите следующее:

http://web.archive.org/web/20150326171404/https://msdn.microsoft.com/en-us/magazine/cc163715.aspx

Я сказал "возможно", потому что, как Джо Даффи мудро говорит:

как только вы рискуете даже немного наружу из пределов немногих "благословенных" беззалоговые практики [...] вы открывая себя до худшего вида условий гонки.

Я не знаю, планируете ли вы правильно использовать блокировку с двойным проверкой или планируете написать свой собственный умный, сломанный вариант блокировки с двойным проверкой, который на самом деле умещается на машинах IA64. Следовательно, это, вероятно, сработает для вас, если ваша проблема действительно подходит для двойной проверки блокировки, и вы правильно пишете код.

Если вам это интересно, вы должны прочитать статьи Джо Даффи:

http://www.bluebytesoftware.com/blog/2006/01/26/BrokenVariantsOnDoublecheckedLocking.aspx

и

http://www.bluebytesoftware.com/blog/2007/02/19/RevisitedBrokenVariantsOnDoubleCheckedLocking.aspx

И этот вопрос SO имеет хорошее обсуждение:

Необходимость модификатора volatile в двойной проверенной блокировке в .NET

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

Существует механизм ожидания завершения одного потока, который завершается до завершения - thread.Join. Вы можете присоединиться к потоку пользовательского интерфейса к рабочему потоку; когда рабочий поток отключается, поток пользовательского интерфейса снова просыпается и выполняет удаление.

UPDATE: добавлена ​​информация о подключении.

"Join" в основном означает, что "поток U говорит, что поток W выключается, а U идет спать до тех пор, пока это не произойдет". Краткое описание метода выхода:

// do this in a thread-safe manner of your choosing
running = false; 
// wait for worker thread to come to a halt
workerThread.Join(); 
// Now we know that worker thread is done, so we can 
// clean up and exit
Dispose(); 
Exit();   

Предположим, вы не хотели использовать "Join" по какой-то причине. (Возможно, рабочий поток должен продолжать работать, чтобы сделать что-то еще, но вам все равно нужно знать, когда это делается с использованием объектов.) Мы можем создать собственный механизм, который работает как Join, используя команды wait. Теперь вам нужны два механизма блокировки: один, который позволяет U посылать сигнал W, который говорит "перестать работать сейчас", а затем другой, который ждет, пока W заканчивает последний вызов M().

Что я сделаю в этом случае:

  • сделать флаговый флажок "running". Используйте любой удобный для вас механизм, чтобы сделать его потокобезопасным. Я бы лично начал с блокировки, посвященной этому; если вы решите позже, что вы можете пойти с блокированными операциями блокировки, то вы всегда можете это сделать позже.
  • сделать AutoResetEvent действующим в качестве затвора в распоряжении.

Итак, краткий эскиз:

поток пользовательского интерфейса, логика запуска:

running = true
waithandle = new AutoResetEvent(false)
start up worker thread

поток пользовательского интерфейса, логика завершения работы:

running = false; // do this in a thread-safe manner of your choosing
waithandle.WaitOne(); 

// WaitOne is robust in the face of race conditions; if the worker thread
// calls Set *before* WaitOne is called, WaitOne will be a no-op.  (However,
// if there are *multiple* threads all trying to "wake up" a gate that is
// waiting on WaitOne, the multiple wakeups will be lost. WaitOne is named
// WaitOne because it WAITS for ONE wakeup. If you need to wait for multiple
// wakeups, don't use WaitOne.

Dispose();
waithandle.Close();
Exit();    

рабочий поток:

while(running) // make thread-safe access to "running"
    M();
waithandle.Set(); // Tell waiting UI thread it is safe to dispose

Обратите внимание, что это зависит от того, что M() коротка. Если M() занимает много времени, вы можете долго ждать, чтобы выйти из приложения, что кажется плохим.

Это имеет смысл?

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

ОБНОВЛЕНИЕ: Некоторые дополнительные вопросы:

Это хорошая идея подождать без тайм-аута?

В самом деле, обратите внимание, что в моем примере с Join и моим примером с WaitOne я не использую варианты для них, которые ждут определенного количества времени, прежде чем сдаваться. Скорее, я призываю, что мое предположение заключается в том, что рабочий поток отключается чисто и быстро. Это правильная вещь?

Это зависит! Это зависит от того, насколько плохо работает рабочая нить и что она делает, когда она плохо себя ведет.

Если вы можете гарантировать, что работа короткая по длительности, для любого "короткого" означает для вас, вам не нужен тайм-аут. Если вы не можете этого гарантировать, я бы предложил сначала переписать код, чтобы вы могли это гарантировать; жизнь становится намного проще, если вы знаете, что код будет быстро завершен, когда вы его попросите.

Если вы не можете, то что делать? Предположение об этом сценарии состоит в том, что работник плохо себя ведет и не прерывается своевременно, когда его просят. Итак, теперь мы должны спросить себя: "Рабочий ли медленный по дизайну, багги или враждебен?"

В первом сценарии рабочий просто делает то, что занимает много времени и по какой-то причине не может быть прервано. Какая правильная вещь здесь? Понятия не имею. Это ужасная ситуация. Вероятно, рабочий не закрывается быстро, потому что это опасно или невозможно. В этом случае, что вы собираетесь делать, когда тайм-аут истекает??? У вас есть что-то, что опасно или невозможно закрыть, и оно не закрывается своевременно. Ваш выбор кажется (1) ничего не делать, (2) делать что-то опасное или (3) делать что-то невозможное. Вероятно, выбор третий. Выбор один эквивалентен ожиданию навсегда, который мы уже отвергли. Это оставляет "сделать что-то опасное".

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

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

Если код враждебный и активно сопротивляется закрытию, то вы уже потеряли. Вы не можете остановить поток обычными средствами, и вы даже не можете прерывать его. Нет никакой гарантии, что прекращение враждебной нити фактически завершает ее; владелец враждебного кода, который вы безрассудно запустили в вашем процессе, может выполнять всю свою работу в конечном блоке или в другой ограниченной области, которая предотвращает исключения прерываний потока.

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

Короче говоря, нет хорошего ответа на вопрос "что мне делать, если это займет слишком много времени?" Вы находитесь в ужасной ситуации, если это происходит, и нет простого ответа. Лучше всего, чтобы упорно трудиться, чтобы гарантировать, что Вы не попасть в него в первую очередь; только запускайте совлокальный, безопасный, безопасный код, который всегда закрывается автоматически и быстро, когда его спрашивают.

Что делать, если рабочий выдает исключение?

ОК, так что, если это произойдет? Опять же, лучше не быть в этой ситуации в первую очередь; напишите рабочий код, чтобы он не выбрасывал. Если вы не можете этого сделать, у вас есть два варианта: обработать исключение или не обрабатывать исключение.

Предположим, вы не обрабатываете исключение. По моему мнению CLR v2, необработанное исключение в рабочем потоке завершает работу всего приложения. Причина в том, что в прошлом, что бы произошло, вы бы начали кучу рабочих потоков, все они бросали исключения, и у вас закончилось бы запущенное приложение без каких-либо рабочих нитей, не выполняющих работу, и не сообщая об этом пользователю. Лучше заставить автора кода обрабатывать ситуацию, когда рабочий поток уменьшается из-за исключения; это старое средство эффективно скрывает ошибки и упрощает запись хрупких приложений.

Предположим, что вы справляетесь с этим исключением. Что теперь? Что-то бросило исключение, которое по определению является неожиданным условием ошибки. Теперь у вас нет никакой информации о том, что любая из ваших данных согласована или любой из ваших программных инвариантов поддерживается в любой из ваших подсистем. Итак, что ты собираешься делать? На данный момент вряд ли что-нибудь безопасное.

Вопрос: "что лучше для пользователя в этой неудачной ситуации?" Это зависит от того, что делает приложение. Вполне возможно, что самое лучшее, что нужно сделать в этот момент - просто агрессивно закрыть и сказать пользователю, что что-то неожиданное не удалось. Это может быть лучше, чем пытаться запутываться и, возможно, ухудшать ситуацию, скажем, случайно уничтожая пользовательские данные при попытке очистки.

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

В принципе, оба вопроса: "Что мне делать, когда мои подсистемы не ведут себя?" Если ваши подсистемы ненадежны, либо сделайте их надежными, либо разработайте политику для того, как вы справляетесь с ненадежной подсистемой, и реализуйте эту политику. Это неопределенный ответ, который я знаю, но что, поскольку иметь дело с ненадежной подсистемой, это по сути своей ужасная ситуация. Как вы справляетесь с этим, зависит от характера ее ненадежности и последствий этой ненадежности для ценных данных пользователя.

Ответ 2

Проверьте Running снова внутри блокировки:

while (Running)
{
    lock(locker)
    {
        if(Running) {
            DoSomething1();
            DoSomething2();
        }
    }
}

Вы могли бы даже переписать это как while(true)...break, что, вероятно, было бы предпочтительнее.

Ответ 3

Вместо использования bool для Running, почему бы не использовать Enum с состояниями Stopped, Starting, Running и Stopping?

Таким образом, вы выходите из цикла, когда Running получает значение Stopping и выполняет свое Disposing. После этого Running устанавливается на Stopped. Когда OnQuit() видит, что Running установлен на Stopped, он будет продолжать работу и выйти.

Изменить: здесь код, быстрый и грязный, не проверенный и т.д.

public class SomeThread : IDisposable 
{ 
    private object locker = new object(); 
    private RunState running = RunState.Stopped;

    public enum RunState
    {
        Stopped,
        Starting,
        Running,
        Stopping,
    }

    public RunState Running  
    {  
        get 
        { 
            lock(locker) 
            { 
                return running; 
            } 
        } 
        set 
        { 
            lock(locker) 
            { 
                running = value; 
            } 
        } 
    } 

    public void Run() 
    { 
        while (Running == RunState.Running) 
        { 
            lock(locker) 
            { 
                DoSomething1(); 
                DoSomething2(); 
            } 
        } 

        Dispose();

    } 

    private void DoSomething1() 
    { 
        // something awesome happens here 
    } 

    private void DoSomething2() 
    { 
        // something more awesome happens here 
    } 

    public void Dispose() 
    { 
        lock (locker) 
        {    
            Dispose1(); 
            Dispose2(); 
        } 
        Running = RunState.Stopped;
    } 

    private void Dispose1() 
    { 
        // something awesome happens here 
    } 

    private void Dispose2() 
    { 
        // something more awesome happens here 
    } 

} 

public class OtherThread 
{ 
    SomeThread st = new SomeThread(); 

    public void OnQuit() 
    { 
        st.Running = SomeThread.RunState.Stopping; 
        while (st.Running == SomeThread.RunState.Stopping) 
        {                 
            // Do something while waiting for the other thread.
        }  

        Exit(); 
    } 
}