Примитивные примитивы синхронизации - безопасны?

На ограниченных устройствах я часто нахожу себя "поддельными" замками между двумя потоками с двумя bools. Каждый из них читается только одним потоком и только написан другим. Вот что я имею в виду:

bool quitted = false, paused = false;
bool should_quit = false, should_pause = false;

void downloader_thread() {
    quitted = false;
    while(!should_quit) {
        fill_buffer(bfr);
        if(should_pause) {
            is_paused = true;
            while(should_pause) sleep(50);
            is_paused = false;
        }
    }
    quitted = true;
}

void ui_thread() {
    // new Thread(downloader_thread).start();
    // ...
    should_pause = true;
    while(!is_paused) sleep(50);
        // resize buffer or something else non-thread-safe
    should_pause = false;
}

Конечно, на ПК я бы этого не делал, но на ограниченных устройствах, кажется, чтение значения bool было бы намного быстрее, чем получение блокировки. Конечно, я отменяюсь для более медленного восстановления (см. "sleep(50)" ), когда требуется изменение буфера.

Вопрос - полностью ли он потокобезопасен? Или есть скрытые gotchas, о которых мне нужно знать, когда притворяются такими замками? Или я не должен этого делать вообще?

Ответ 1

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

Просто потому, что кажется, что это сработает, это не значит, что так оно и будет. "Ограниченные" устройства, такие как неограниченный тип, становятся все более мощными. Например, я бы не стал делать ставку на поиск двухъядерного процессора в сотовом телефоне. Это означает, что я не стал бы парировать, что приведенный выше код будет работать.

Ответ 2

Использование значений bool для связи между потоками может работать так, как вы намереваетесь, но действительно есть две скрытые ошибки, как описано в в этом блоге Виталия Липчинского:

Когерентность кэша

ЦП не всегда извлекает значения памяти из ОЗУ. Быстрые тайники памяти на матрице являются одним из трюков, используемых разработчиками процессоров для работы над узким местом Itanium) эти кэши CPU не используются совместно или автоматически синхронизируются. Другими словами, ваши потоки могут видеть разные значения для одного и того же адреса памяти, если они работают на разных CPU.

Чтобы избежать этого, вам нужно объявить переменные как volatile (С++, С#, java), или явные изменчивые чтения/записи или использовать механизмы блокировки.

Оптимизация компилятора

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

Ответ 3

Что касается вызова сна, вы всегда можете просто заснуть (0) или эквивалентный вызов, который приостанавливает ваш поток, позволяя очередному по очереди.

Что касается остальных, это потокобезопасно, если вы знаете подробности реализации вашего устройства.

Ответ 4

Отвечая на вопросы.

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

is_paused = true;            
while(should_pause) sleep(50);            
is_paused = false;

но компилятор может изменить его таким образом:

sleep(50);
is_paused = false;

это, вероятно, не сработает даже с одним основным устройством, как говорили другие.

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

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

Ответ 5

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

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

Когда выполняется ui_thread, следующие строки кода могут выполняться в одном и том же временном листе.

// new Thread(downloader_thread).start();
// ...
should_pause = true;

Далее загружается файл downloader_thread и в нем время среза выполняются следующие строки:

quitted = false;
while(!should_quit)
{
    fill_buffer(bfr);

Планировщик запускает downloader_thread перед возвратом fill_buffer, а затем активирует выполняемый ui_thread.

while(!is_paused) sleep(50);
// resize buffer or something else non-thread-safe
should_pause = false;

Операция буфера изменения выполняется, когда загрузчик_thread находится в процессе заполнения буфера. Это означает, что буфер поврежден, и вы, скорее всего, потерпите крах в ближайшее время. Это не произойдет каждый раз, но тот факт, что вы заполняете буфер до того, как вы установите is_paused на true, делает его более вероятным, но даже если вы изменили порядок этих двух операций на downloader_thread, у вас все еще будет состояние гонки, но вы, скорее всего, зашлите в тупик, вместо того чтобы развращать буфер.

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

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