Этот код зависает в режиме выпуска, но отлично работает в режиме отладки

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

public static void Main(string[] args)
{            
   bool isComplete = false;

   var t = new Thread(() =>
   {
       int i = 0;

        while (!isComplete) i += 0;
   });

   t.Start();

   Thread.Sleep(500);
   isComplete = true;
   t.Join();
   Console.WriteLine("complete!");
}

Ответ 1

Я предполагаю, что оптимизатор одурачен отсутствием ключевого слова "volatile" в переменной isComplete.

Конечно, вы не можете добавить его, потому что это локальная переменная. И, конечно, поскольку это локальная переменная, она не нужна вообще, потому что локали хранятся в стек, и они, естественно, всегда "свежие".

Однако, после компиляции он больше не является локальной переменной. Поскольку он обращается к анонимному делегату, код разбивается и преобразуется в класс-помощник и поле участника, что-то вроде:

public static void Main(string[] args)
{
    TheHelper hlp = new TheHelper();

    var t = new Thread(hlp.Body);

    t.Start();

    Thread.Sleep(500);
    hlp.isComplete = true;
    t.Join();
    Console.WriteLine("complete!");
}

private class TheHelper
{
    public bool isComplete = false;

    public void Body()
    {
        int i = 0;

        while (!isComplete) i += 0;
    }
}

Теперь я могу представить, что JIT-компилятор/оптимизатор в многопоточной среде при обработке класса TheHelper может фактически кэшировать значение false в каком-либо регистровом или стеке в начале метод Body() и никогда не обновлять его до тех пор, пока метод не завершится. Это потому, что нет НИКАКОЙ ГАРАНТИИ, что метод thread & НЕ завершится до того, как будет выполнен "= true", поэтому, если нет гарантии, то почему бы не кэшировать его и не получить повышение производительности чтения объекта кучи один раз вместо его чтения на каждой итерации.

Именно поэтому существует ключевое слово volatile.

Чтобы этот вспомогательный класс был правильный немного лучше 1) в многопоточных средах, он должен иметь:

    public volatile bool isComplete = false;

но, конечно, так как это автогенерированный код, вы не можете его добавить. Лучшим подходом было бы добавить некоторые lock() вокруг чтения и записи на isCompleted или использовать некоторые другие готовые к использованию средства синхронизации или потоки/задания, вместо того, чтобы пытаться сделать это bare-metal (что он не будет bare-metal, так как он С# на CLR с GC, JIT и (..)).

Разница в режиме отладки происходит, вероятно, потому, что в режиме отладки многие оптимизации исключены, поэтому вы можете отлаживать код, который вы видите на экране. Поэтому while (!isComplete) не оптимизирован, поэтому вы можете установить там точку останова, и поэтому isComplete не агрессивно кэшируется в регистре или стеке при запуске метода и считывается с объекта в куче на каждой итерации цикла.

BTW. Это только мои догадки об этом. Я даже не пытался его скомпилировать.

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

1) см. ниже комментарии Эрика Липперта о volatile и/или этой очень интересной статье, показывающей уровни сложности, связанные с обеспечением того, чтобы код, основанный на volatile, был безопасно..uh, good..uh, скажем, ОК.

Ответ 2

Ответ quetzalcoatl верен. Чтобы пролить больше света на него:

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

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

Как это решить? Не пишите многопоточные программы. Многопоточность невероятно трудно, даже для экспертов. Если вам нужно, используйте механизмы самого высокого уровня для достижения своей цели. Решение здесь заключается не в том, чтобы сделать переменную изменчивой. Решением здесь является написать отменную задачу и использовать механизм отмены параллельной библиотеки задач. Пусть TPL беспокоится о правильной правильной логике потоков и правильной отправке аннулирования по потокам.

Ответ 3

Я подключился к запущенному процессу и обнаружил (если я не ошибался, я не очень практиковал с этим), что метод Thread переводится на это:

debug051:02DE04EB loc_2DE04EB:                            
debug051:02DE04EB test    eax, eax
debug051:02DE04ED jz      short loc_2DE04EB
debug051:02DE04EF pop     ebp
debug051:02DE04F0 retn

eax (который содержит значение isComplete) загружается в первый раз и никогда не обновляется.

Ответ 4

Не совсем ответ, но пролить свет на проблему:

Проблема заключается в том, что i объявляется внутри тела лямбда, и он читается только в выражении присваивания. В противном случае код отлично работает в режиме выпуска:

  • i, объявленный вне тела лямбда:

    int i = 0; // Declared outside the lambda body
    
    var t = new Thread(() =>
    {
        while (!isComplete) { i += 0; }
    }); // Completes in release mode
    
  • i не читается в выражении присваивания:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { i = 0; }
    }); // Completes in release mode
    
  • i также читается где-то еще:

    var t = new Thread(() =>
    {
        int i = 0;
        while (!isComplete) { Console.WriteLine(i); i += 0; }
    }); // Completes in release mode
    

Моя ставка - это оптимизация компилятора или JIT, относящаяся к i. Кто-то умнее меня, вероятно, сможет пролить свет на этот вопрос.

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