Подтвердить что ты не робот

(C/С++) Почему он в/допустим для синхронизации одного считывателя и одного писателя с глобальной переменной?

Предположим, что существует структура данных, такая как std::vector и глобальная переменная int syncToken, инициализированная нулем. Также дано, ровно два потока как читатель/писатель, почему следующий (псевдо) код (в) действителен?

void reader_thread(){
    while(1){
        if(syncToken!=0){
            while(the_vector.length()>0){
                 // ... process the std::vector 
            }
            syncToken = 0;  // let the writer do it work
        }
        sleep(1);
    }
}

void writer_thread(){
    while(1){
        std::string data = waitAndReadDataFromSomeResource(the_resource);
        if(syncToken==0){
            the_vector.push(data);
            syncToken = 1;  // would syncToken++; be a difference here?
        }
        // drop data in case we couldn't write to the vector
    }
}

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

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

Заранее, я хочу указать, что я прошу указать конкретный пример использования/пример/доказательство/подробное объяснение, которое демонстрирует именно то, что не синхронизируется. Даже пример кода C, который позволяет примерному счетчику вести себя не монотонно, будет просто отвечать на вопрос "да/нет", но не почему! Меня интересует, почему. Итак, если вы представите пример, демонстрирующий, что у него есть проблема, мне интересно, почему.

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

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

В качестве справки рассмотрим соответствующую часть кода ассемблера, созданного gcc:

; just the declaration of an integer global variable on a 64bit cpu initialized to zero
syncToken:
.zero   4
.text
.globl  main
.type   main, @function

; writer (Cpu/Thread B): if syncToken == 0, jump not equal to label .L1
movl    syncToken(%rip), %eax
testl   %eax, %eax
jne .L1

; reader (Cpu/Thread A): if syncToken != 0, jump to Label L2
movl    syncToken(%rip), %eax
testl   %eax, %eax
je  .L2

; set syncToken to be zero
movl    $0, syncToken(%rip)

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

Предположим, что оба потока выполняются на своем собственном ядре ЦП, так как Thread A работает на ядре A, Thread B работает на ядре B. Инициализация является глобальной и выполняется до начала обоих потоков, поэтому мы можем игнорировать инициализацию и предполагать начало обоих потоков с syncToken = 0;

Пример:

  • Cpu A: movl syncToken (% rip),% eax
  • Cpu A: контекстный переключатель (сохранение всех регистров)
  • Cpu B: movl syncToken (% rip),% eax
  • Cpu B: testl% eax,% eax
  • Cpu B: jne.L1; это false = > execute writer, если block
  • Cpu B: контекстный переключатель
  • Cpu A: контекстный переключатель в поток (восстановление всех регистров)
  • Cpu A: testl% eax,% eax
  • Cpu A: je.L2; это false = > не выполняется, если блок

Честно говоря, я построил пример, который хорошо работает, но он демонстрирует, что я не вижу способа, почему переменная должна выйти из синхронизации, так что оба потока выполняют блок if одновременно. Моя точка зрения: хотя контекстный переключатель приведет к несогласованности между% eax и фактическим значением syncToken в ОЗУ, код должен делать правильную вещь и просто не выполнять блок if, если это не единственный поток, разрешенный для запуска он.

ОБНОВЛЕНИЕ 2 Можно предположить, что syncToken будет использоваться только как в коде, как показано. Никакая другая функция (например, waitAndReadDataFromSomeResource) не может использовать ее каким-либо образом.

ОБНОВЛЕНИЕ 3 Отпустите еще один шаг, задав несколько разных вопросов: возможно ли синхронизировать два потока, один читатель, один сценарий с использованием int syncToken, чтобы потоки не выходили из синхронизации все время, одновременно выполняя блок if? Если да - это очень интересно ^^ Если нет - почему?

4b9b3361

Ответ 1

Короткий ответ: Нет, этот пример не синхронизирован должным образом и не будет (всегда) работать.

Для программного обеспечения обычно понимается, что работа иногда, но не всегда - это то же самое, что и сломанное. Теперь вы можете спросить что-то вроде "будет ли это работать для синхронизации контроллера прерывания с задачей переднего плана на 32-битном микроконтроллере ACME с XYZ-компилятором на уровне оптимизации -O0", и ответ, возможно, будет да. Но в общем случае ответ отрицательный. На самом деле вероятность того, что эта работа в любой реальной ситуации будет низкой, потому что пересечение "использует STL" и "достаточно простое аппаратное обеспечение и компилятор для просто работать", вероятно, пуст.

Как указывали другие комментарии/ответы, это также технически Undefined Behavior (UB). Реальные реализации могут свободно работать с UB. Поэтому, поскольку он не является "стандартным", он все еще может работать, но он не будет строго соответствовать или переноситься. Независимо от того, работает ли это, зависит от конкретной ситуации, основанной в основном на процессоре и компиляторе, а также на ОС.

Что работает

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

Однако, пока переменные обращения были синхронными и утверждения выполнялись в "наивном" порядке программы, логика кажется правильной. Писатель_thread() не получает доступ к вектору, пока не "сам" его (syncToken == 0). Аналогично, reader_thread() не получает доступ к вектору до его владения (syncToken == 1). Даже без атомной записи/чтения (скажем, это была 16-битная машина, а syncToken - 32 бита), это все равно "Работа".

Примечание 1: шаблон if (flag) {... flag = x} неатомный тест-набор. Обычно это будет состояние гонки. Но в этом конкретном случае эта гонка является ступенчатой. В общем случае (например, более одного читателя или писателя) это тоже проблема.

Примечание 2: syncToken ++ менее вероятен быть атомарным, чем syncToken = 1. Обычно это было бы еще одним нарушением неправильного поведения, поскольку оно связано с чтением-модификацией-записью. В этом конкретном случае это не должно иметь значения.

Что не так.

  • Что делать, если записи в syncToken не синхронны с другими потоками? Что делать, если записи в syncToken относятся к регистру, а не к памяти? В этом случае вероятность того, что reader_thread() никогда не будет выполняться вообще, потому что она не увидит набор syncToken. Несмотря на то, что syncToken является нормальной глобальной переменной, он может быть записан только в память, когда waitAndReadDataFromSomeResource() вызывается или просто случайно, когда давление в регистре оказывается достаточно высоким. Но поскольку функция writer_thread() является бесконечным циклом while и никогда не завершается, вполне возможно, что это никогда не произойдет. Чтобы обойти это, syncToken должен быть объявлен изменчивым, заставляя каждую запись и чтение переходить в память.

    Как упоминалось в других комментариях/ответах, проблема кеширования может быть проблемой. Но для большинства архитектур в обычной системной памяти этого не было бы. Аппаратное обеспечение через протоколы когерентной синхронизации, такие как MESI, гарантирует, что все кэши на всех процессорах поддерживают согласованность. Если syncToken записывается в кеш L1 на процессоре P1, когда P2 пытается получить доступ к одному и тому же местоположению, аппаратное обеспечение замаскирует грязную строку кэша из P1 до того, как P2 загрузит его. Поэтому для нормальной кэш-когерентной системной памяти это, вероятно, "ОК".

    Однако этот сценарий не совсем удален, если записи были на устройстве или в памяти IO, где кеши и буферы не синхронизируются автоматически. Например, для синхронизации внешней памяти шины требуется команда PowerPC EIEIO, а записи, размещенные в PCI, могут быть буферизованы мостами и должны программно очищаться. Если либо вектор, либо syncToken не были сохранены в обычной кэш-когерентной системной памяти, это также может вызвать проблему синхронизации.

  • Более реалистично, если синхронизация не является проблемой, тогда будет выполняться переупорядочение оптимизатором компилятора. Оптимизатор может решить, что, поскольку the_vector.push(data) и syncToken = 1 не имеют зависимости, сначала можно перемещать syncToken = 1. Очевидно, что это ломает вещи, позволяя reader_thread() взаимодействовать с вектором одновременно с writer_thread().

    Просто объявить syncToken как volatile будет недостаточно. Волатильный доступ гарантируется только при заказе других волатильных доступов, но не между изменчивыми и энергонезависимыми доступами. Поэтому, если вектор также нестабилен, это все равно будет проблемой. Поскольку вектор, вероятно, является классом STL, не очевидно, что объявление его волатильности будет даже работать.

  • Предположим теперь, что проблемы синхронизации и оптимизаторы компилятора были избиты. Вы просматриваете код ассемблера и ясно видите, что все теперь отображается в правильном порядке. Конечная проблема заключается в том, что современные процессоры имеют привычку выполнять и увольнять инструкции из-за порядка. Поскольку никакая зависимость между последней инструкцией во всех the_vector.push(data) компиляторах и syncToken = 1, то процессор может решить сделать movl $0x1, syncToken(%rip), прежде чем другие инструкции, которые являются частью the_vector.push(data), закончены, например, для сохранения новое поле длины. Это независимо от того, каким порядком кода на языке ассемблера кажется.

    Обычно ЦП знает, что команда №3 зависит от результата команды №1, поэтому он знает, что # 3 необходимо выполнить после # 1. Возможно, инструкция №2 не зависит от того и может быть до или после любого из них. Это планирование происходит динамически во время выполнения на основе любых ресурсов ЦП в настоящий момент.

    Что не так, так это то, что нет явной зависимости между инструкциями, которые обращаются к файлу_vector и тем, кто обращается к syncToken. Тем не менее программа по-прежнему неявно требует, чтобы они были заказаны для правильной работы. CPU не знает этого.

    Единственный способ предотвратить переупорядочение - использовать заграждение памяти, барьер или другую инструкцию синхронизации, специфичную для конкретного процессора. Например, команда intel mfence или PPC sync может быть вставлена ​​между касанием the_vector и syncToken. Просто какая команда или серия инструкций и где они должны быть размещены, очень специфичны для модели и ситуации ЦП.

В конце дня было бы гораздо проще использовать "правильные" примитивы синхронизации. Вызовы библиотеки синхронизации также обрабатывают помехи компилятора и ЦП в правильных местах. Кроме того, если вы сделали что-то вроде следующего, оно будет работать лучше и не нужно будет отбрасывать данные (хотя сон (1) все еще неактивен - лучше использовать переменную условия или семафор):

void reader_thread(){
    while(1){
        MUTEX_LOCK()
        if(the_vector.length()>0){
            std::string data = the_vector.pop();
            MUTEX_UNLOCK();

            // ... process the data
        } else {
            MUTEX_UNLOCK();
        }
        sleep(1);
    }
}

void writer_thread(){
    while(1){
        std::string data = waitAndReadDataFromSomeResource(the_resource);
        MUTEX_LOCK();
        the_vector.push(data);
        MUTEX_UNLOCK();
    }
}

Ответ 2

Основная проблема заключается в том, что вы предполагаете, что обновления для syncToken являются атомарными с обновлениями к вектору, которых они не являются.

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

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

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

void reader_thread(){
    while(1){
        if(syncToken!=0){
            while(the_vector.length()>0){
                 // ... process the std::vector 
            }
            syncToken = 0;  // let the writer do it work
        }
        sleep(1);

Этот sleep вызовет флеш-память памяти, поскольку она переходит в ОС, но нет гарантии порядка флеш-памяти или в каком порядке ее будет видеть поток писем.

    }
}

void writer_thread(){
    while(1){
        std::string data = waitAndReadDataFromSomeResource(the_resource);

Это может вызвать флеш-память. С другой стороны, это может быть не так.

        if(syncToken==0){
            the_vector.push(data);
            syncToken = 1;  // would syncToken++; be a difference here?
        }
        // drop data in case we couldn't write to the vector
    }
}

Использование syncToken++ не помогло бы (в общем случае) не помогать, так как это выполняет чтение/изменение/запись, поэтому, если другой конец делает модификацию в одно и то же время, вы можете получить какой-либо результат из он.

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

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

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

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

Как правило, это может быть довольно сложно получить право, и вам будет лучше использовать мьютексы или семафоры, чтобы обеспечить синхронизацию, если производительность не очень важна.

Как отмечал Андерс, компилятор по-прежнему может повторно заказать доступ к syncToken с доступом к the_vector (если он может определить, что делают эти функции, что, возможно, с помощью std::vector) - добавление памяти барьеры остановят это переупорядочение. Выполнение syncToken volatile также остановит переупорядочение, но не будет устранять проблемы с когерентностью памяти в многоядерной системе, и это не позволит вам безопасно читать/изменять/записывать одну и ту же переменную из 2 потоков.

Ответ 3

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

Современный процессорный дизайн - это упражнение в отношении латентности. Самая серьезная проблема с задержкой при длительной съемке - это скорость памяти. Типичное время доступа к ОЗУ (доступный вид) колеблется около 100 наносекунд. Современное ядро ​​может легко выполнить тысячу инструкций в это время. Процессоры заполнены до краев трюками, чтобы справиться с этой огромной разницей.

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

Проблема с задержкой памяти устраняется путем предоставления кэшам процессора. Локальные копии данных в памяти. Сидит физически близко к исполнительному устройству и, следовательно, имеет меньшую задержку. Современные ядра имеют 64 КБ кэша L1, самые маленькие и, следовательно, самые близкие и, следовательно, самые быстрые. Больший и медленный кэш L2, как правило, 256 КБ. И еще более крупный и медленный L3-кеш, тип 4 МБ, который делится между всеми ядрами на чипе.

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

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

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

Избегая таких отвратительных неприятных ошибок, необходимо использовать ограждения, специальные инструкции, гарантирующие синхронизацию доступа к памяти. Они дороги, они заставляют процессор останавливаться. Они завернуты в С++ с помощью std:: atomic.

Однако они могут решить только часть проблемы, отметить еще одну нежелательную черту вашего кода. Пока вы не можете получить syncToken, ваш код вращается во время цикла. Сжигание 100% ядра и не выполнение работы. Это нормально, если еще один поток не слишком долго держится за него. Это не нормально, когда он начинает принимать микросекунды. Затем вам нужно задействовать операционную систему, она должна приостановить поток, чтобы другой поток другой программы мог получить полезную работу. Обернуто std:: mutex и друзьями.

Ответ 4

Говорят, что причины такого кода С++ не являются потокобезопасными:

  • Компилятор может изменить порядок инструкций. (Это было не так, как вы продемонстрировали в ассемблере, но с разными настройками компилятора может произойти переупорядочение. Чтобы предотвратить переупорядочение, сделайте syncToken неустойчивым.
  • Процессор кэширует синхронизацию. Процессор чтения потока видит новый syncToken, но старый вектор.
  • Аппаратное обеспечение процессора может изменить порядок инструкций. Плюс инструкции по сборке могут быть не атомарными. Но вместо этого внутренне они могли быть кучей микрокода, который, в свою очередь, мог быть переупорядочен. То есть, собранная вами сборка может отличаться от реального микрокода, выполняемого процессором. Таким образом, синхронные обновления и векторные обновления могут быть смешаны.

Можно предотвратить все эти следующие потокобезопасные шаблоны.

На конкретном процессоре или конкретном поставщике с конкретным компилятором ваш код может работать нормально. Это может даже работать на всех платформах, на которые вы нацеливаетесь. Но он не переносится.

Ответ 5

Учитывая

  • что syncToken имеет тип int и
  • вы используете syncToken!=0 и syncToken==0 в качестве условий синхронизации (скажем, в ваших условиях) и
  • назначения копирования syncToken = 1 и syncToken = 0 для обновления условий синхронизации

заключение

  • нет, это неверно

потому что

  • syncToken!=0, syncToken==0, syncToken = 1 и syncToken = 0 не атомные

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

С++ предоставляет средства в библиотеке STL для обработки потоков, мьютексов, задач и т.д. Я рекомендую прочитать их. Вы, вероятно, найдете простые примеры в Интернете.


В вашем случае (я думаю, довольно похоже) вы могли бы ссылаться на этот ответ: fooobar.com/questions/417135/...

Ответ 6

Этот тип синхронизации не правильный. Например: Для проверки этого условия "syncToken == 0" cpu может выполнять несколько последовательных языковых инструкций последовательно,

MOV DX, @syncToken CMP DX, 00; Сравните значение DX с нулем JE L7; Если да, то перейдите на ярлык L7

Аналогично, для изменения значения переменной syncToken cpu может выполнять несколько команд языка ассемблера последовательно.

В случае многопоточной операционной системы во время выполнения могут выполняться потоки pre-empt (Context switch).

Теперь рассмотрим, Thread A, выполняет это условие "syncToken == 0", а ОС переключает контекст, как указано ниже.

сборка lang instr 1 сборка lang instr 2 Переключатель контекста в Thread B сборка lang instr 3 сборка lang instr 4

И Thread B выполняет это условие "syncToken = 1", а ОС переключает контекст, как указано ниже, сборка lang instr 1 сборка lang instr 2 сборка lang instr 3 Контекстный переключатель в Thread A сборка lang instr 4

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

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

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

Ответ 7

Вы предполагаете, что значение SyncToken записывается и считывается из памяти даже в то время, когда вы меняете его или читаете. Это не. Он кэшируется в CPU и не может быть записан в память.

Если вы подумаете об этом, поток писателей подумает, что SyncToken равен 1 (так как он установил его таким образом), и поток читателей будет считать, что SyncToken равен 0 (поскольку он установил его таким образом), и никто не будет работать до тех пор, пока Кэш CPU сбрасывается. (может занять вечность, кто знает).

Определение его как изменчивого/атомарного/блокированного предотвратит этот эффект кеширования и заставит ваш код работать так, как вы его предполагали.

Edit:

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

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

Я бы рекомендовал использовать boost lock-free для таких задач.