Заборы с неатомиками в C11

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

Вот минимальный рабочий пример, который, к сожалению, приводит к неопределенному поведению в соответствии с параграфом 25 раздела 5.1.2.4 из-за гонки данных на ready:

#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>

int ready;  /* purposely NOT _Atomic */
int value;

void
p1()
{
  value = 1;
  atomic_thread_fence(memory_order_release);
  ready = 1;
}

void
p2(void *_ignored)
{
  while (!ready)
    ;
  atomic_thread_fence(memory_order_acquire);
  printf("%d\n", value);
}

int
main()
{
  thrd_t t;
  thrd_create(&t, p2, NULL);
  p1();
  thrd_join(&t, NULL);
}

Мой конкретный вопрос заключается в том, можно ли исправить приведенный выше код, чтобы гарантировать печать 1 без изменения ready на _Atomic. (Я мог бы сделать ready volatile, но не вижу в спецификации никаких указаний на то, что это поможет.)

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


person user3188445    schedule 08.02.2017    source источник
comment
Все операции синхронизации в C11 работают только на atomics (или mtx_t). Так что без атома это никогда не сработает. Когда у вас есть атом, состоянием которого вы управляете, вы можете спорить с отношением «происходит до того», какие эффекты становятся видимыми в каком потоке, даже для эффектов на объектах, не являющихся атомами. Но сделать ваши вещи совместимыми со старыми интерфейсами безнадежно с C11 atomics. Они не созданы для этого. Разрешение этим старым интерфейсам действовать неатомарно в отношении состояния вашей системы пробивает дыры в любом доказательстве непротиворечивости, которое вы можете придумать.   -  person Jens Gustedt    schedule 09.02.2017
comment
@JensGustedt Чтобы было ясно, мой вопрос был о заборах, а не об атомах. Заборы ограничивают порядок операций с неатомарной памятью. В этом конкретном примере ограждение освобождения происходит до ограждения захвата, поэтому нет гонки на value, только на ready. Так что, возможно, ответ заключается в том, чтобы использовать memory_order_acq_rel в обоих случаях?   -  person user3188445    schedule 10.02.2017
comment
Нет, я думаю, у вас неправильное представление об этом. Два забора в разных потоках синхронизируются только через атомар. Нет другого способа утверждать, что одно ограждение происходит раньше другого, не изменяя атомарный объект, модификация которого воспринимается другим.   -  person Jens Gustedt    schedule 10.02.2017
comment
@JensGustedt: если у вас есть код, который вызывает функцию для заполнения буфера, а затем выполняет какое-либо действие, чтобы указать, что его следует использовать асинхронно, есть ли какой-либо практический способ сделать такой код работоспособным без использования специальной атомарной версии функции который заполняет буфер или добавляет дополнительный шаг атомарного копирования? Если нет, то это кажется довольно бессмысленным упущением в Стандарте.   -  person supercat    schedule 10.02.2017
comment
@supercat, я не уверен, что полностью понимаю ваш вопрос. Дело в том, что вы не можете навязать поведение синхронизации для функции, которая ничего о ней не указывает. Функция могла быть скомпилирована еще до появления C11. Тем не менее, классический способ работы с унаследованным кодом в параллельной среде — использовать мьютексы вокруг их использования. То есть всегда гарантированно синхронизируется. Распараллеливание всегда имеет свою цену, нет никакого чудесного отсечения пальцев, которое делает существующий код (= написанный без каких-либо атомарных или блокирующих примитивов) параллельным и свободным от гонок.   -  person Jens Gustedt    schedule 10.02.2017
comment
@JensGustedt: Во многих встроенных реализациях изменчивые доступы ведут себя как глобальные ограничения записи. Это делает практичным вызов функции, которая заполняет буфер, а затем использует энергозависимую запись для запуска некоторого асинхронного процесса, который считывает буфер. Если бы функция, которая заполнила буфер, использовала изменчивые записи, они, естественно, были бы упорядочены относительно действия, которое запускает асинхронное считывание, но во многих случаях имело бы группы операций, которые являются асинхронными относительно друг друга операциями в той же группе, но глобально упорядоченными. относительно других групп...   -  person supercat    schedule 10.02.2017
comment
@supercat, я тоже не говорил, что все данные надо писать атомарно. Должна быть атомарная запись, которая следует за одним забором, видимым в другом потоке. Только увидев такое событие, вы можете сделать вывод, что ограждение приобретения действительно происходит после ограждения выпуска. В противном случае невозможно установить временной или причинный порядок.   -  person Jens Gustedt    schedule 10.02.2017
comment
... позволит гораздо больше полезных оптимизаций, чем использование volatile для всего, что вообще должно соблюдать какую-либо последовательность.   -  person supercat    schedule 10.02.2017
comment
@JensGustedt: В большинстве встроенных систем используются асинхронные механизмы, отличные от потоков, но с точки зрения многопоточности, если № 1 выполняет некоторые обычные записи, то какое-то ограждение записи и изменчивая запись, а № 2 выполняет изменчивое чтение, которое видит, что произошла изменчивая запись, затем какое-то ограничение чтения, за которым следует обычное чтение, существует ли какое-либо ограничение, которое обеспечило бы последовательность между обычными операциями записи и обычными операциями чтения.   -  person supercat    schedule 10.02.2017
comment
Он просто должен выполнить атомарную (изменчивую) запись после забора, а другой поток должен гарантировать, что он атомарно прочитает это записанное значение. Volatile только гарантирует, что запись не может быть оптимизирована, он ничего не применяет к синхронизации между потоками.   -  person Jens Gustedt    schedule 10.02.2017
comment
Интересно, разумно ли использовать atomic_int, если sizeof(atomic_int) == sizeof(int), а в противном случае использовать volatile (полагая, что вы используете какую-то архаичную одноядерную архитектуру)? Можно ли использовать между int * и atomic_int *, если они одного размера?   -  person user3188445    schedule 11.02.2017
comment
@JensGustedt: Помешает ли атомарная запись после забора компилятору отложить любую обычную запись до забора до точки после него? Любая реализация должна иметь возможность реализовать операцию, поведение которой эквивалентно вызову внешней функции с переменным числом переменных, о которой компилятор ничего не знает, но которая на самом деле ничего не делает. Компилятор не сможет зарегистрировать-кэшировать что-либо, достижимое любым указателем, который когда-либо был передан такой функции, таким образом достигая необходимой семантики.   -  person supercat    schedule 02.03.2017
comment
Проблема не в том, может ли компилятор переупорядочить записи перед забором с забором. Это невозможно из-за обычных правил последовательности. Проблема заключается в проблеме видимости забора в другом потоке. Ограждение гарантированно будет видимым только в том случае, если существует установленная цепочка зависимостей между ограждением выпуска в первом и ограждением получения во втором. Если зависимости нет, выполнение может сделать вид, что ограждение релиза еще не произошло. Такая зависимость может быть установлена ​​только операцией синхронизации, в данном случае атомарной записью, которую другой сознательно читает.   -  person Jens Gustedt    schedule 02.03.2017


Ответы (1)


Есть ли способ использовать заборы, чтобы рассуждать о поведении неатомарных операций в C11?

То, как вы используете заборы, является правильным, но если вы хотите иметь возможность рассуждать о поведении программы, вы должны убедиться, что между хранилищем (1) и ready и загрузкой (1) существует строгий порядок модификации между потоками. от него. Обычно здесь в игру вступает переменная atomic. В соответствии со стандартом C11 у вас есть гонка данных на ready (как вы указали), и неопределенное поведение - это то, что вы можете ожидать.

Мой конкретный вопрос заключается в том, можно ли исправить приведенный выше код, чтобы гарантировать печать 1 без изменения готовности к _Atomic. (Я мог бы подготовить volatile, но не вижу в спецификации никаких указаний на то, что это поможет.)

Ответ, соответствующий стандартам, - «нет», и, поскольку стандарт не поддерживает ваш случай, вы не найдете ничего, связанного с volatile в этом контексте.

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

Однако проблемы с использованием неатомарных типов в общем контексте сложны. Иногда люди считают, что если операции ЦП над типом, таким как int, неделимы, то его можно использовать вместо atomic_int. Это неверно, потому что «атомарность» — это понятие с более широкими разветвлениями:

  • неделимое чтение/запись — применяется к обычным типам на многих платформах.

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

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

Работает ли ваш код, зависит от платформы и компилятора, но по крайней мере объявите флаг ready volatile. Я выполнил тестовый прогон на X86_64 с gcc -O3 оптимизацией компилятора, и без volatile он попал в бесконечный цикл.
Также неплохо сравнить разницу между инструкциями, выдаваемыми компилятором для атомарного и неатомарного случая.

Связанный с этим вопрос заключается в том, безопасно ли в любом случае писать приведенный выше код, потому что любая машина, на которой будет выполняться мой код, имеет когерентность кеша?

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

person LWimsey    schedule 22.02.2017