Я слышал, что i ++ не является потокобезопасным, а ++ i - потокобезопасным?

90

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

Однако меня интересует ++ i. Насколько я могу судить, это сократилось бы до одной инструкции сборки, такой как «добавить r1, r1, 1», и, поскольку это всего лишь одна инструкция, ее нельзя было бы прервать переключением контекста.

Может кто уточнить? Я предполагаю, что используется платформа x86.

самоз
источник
Просто вопрос. Какие сценарии потребуются для двух (или более) потоков доступа к такой переменной? Я здесь честно спрашиваю, а не критикую. Просто в этот час я ни о чем не могу думать.
OscarRyz
5
Переменная класса в классе C ++, поддерживающая счетчик объектов?
paxdiablo
1
Хорошее видео по этому поводу, которое я только что посмотрел сегодня, потому что другой парень сказал мне: youtube.com/watch?v=mrvAqvtWYb4
Йоханнес Шауб - litb
1
помечено как C / C ++; Java здесь не рассматривается, C # похож, но не имеет такой жестко определенной семантики памяти.
Тим Уиллискрофт,
1
@Oscar Reyes Допустим, у вас есть два потока, оба используют переменную i. Если поток один увеличивает поток только в определенной точке, а другой уменьшает поток только в другой точке, вам придется беспокоиться о безопасности потока.
samoz

Ответы:

158

Вы ошиблись. Вполне может быть, что "i++"это потокобезопасный для конкретного компилятора и конкретной архитектуры процессора, но это вообще не предусмотрено стандартами. Фактически, поскольку многопоточность не является частью стандартов ISO C или C ++ (a) , вы не можете считать что-либо поточно-ориентированным, исходя из того, до чего, по вашему мнению, он будет компилироваться.

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

load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory

что не было бы поточно-ориентированным на моем (воображаемом) процессоре, у которого нет инструкций по увеличению памяти. Или он может быть умным и скомпилировать его в:

lock         ; disable task switching (interrupts)
load r0,[i]  ; load memory into reg 0
incr r0      ; increment reg 0
stor [i],r0  ; store reg 0 back to memory
unlock       ; enable task switching (interrupts)

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

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

Такие вещи, как Java synchronizedи pthread_mutex_lock()(доступные для C / C ++ в некоторых операционных системах), это то, что вам нужно изучить (а) .


(а) Этот вопрос был задан до того, как были завершены стандарты C11 и C ++ 11. Эти итерации теперь включают поддержку потоковой передачи в спецификации языка, включая атомарные типы данных (хотя они и потоки в целом являются необязательными, по крайней мере, в C).

Paxdiablo
источник
8
+1 за то, что подчеркиваю, что это не проблема конкретной платформы, не говоря уже о четком ответе ...
RBerteig
2
Поздравляю с получением серебряного значка C :)
Йоханнес Шауб - литб
Я думаю, вам следует уточнить, что ни одна современная ОС не разрешает программам пользовательского режима отключать прерывания, а pthread_mutex_lock () не является частью C.
Бастьен Леонар
@Bastien, никакая современная ОС не могла бы работать на процессоре, у которого не было бы инструкции по увеличению памяти :-) Но ваша точка зрения связана с C.
paxdiablo
5
@Bastien: Бык. Процессоры RISC обычно не имеют инструкций по увеличению памяти. Трипплет загрузка / добавление / сохранение - это то, как вы это делаете, например, на PowerPC.
derobert
42

Вы не можете сделать однозначное заявление ни о ++ i, ни о i ++. Зачем? Рассмотрите возможность увеличения 64-битного целого числа в 32-битной системе. Если на базовой машине нет инструкции «загрузить, увеличить, сохранить», для увеличения этого значения потребуется несколько инструкций, любая из которых может быть прервана переключением контекста потока.

Кроме того, ++iне всегда «прибавлять единицу к значению». В таком языке, как C, при увеличении указателя фактически увеличивается размер объекта, на который он указывает. То есть, если iэто указатель на 32-байтовую структуру, ++iдобавляет 32 байта. В то время как почти все платформы имеют атомарную инструкцию «приращения значения по адресу памяти», не все имеют атомарную инструкцию «добавить произвольное значение к значению по адресу памяти».

Джим Мишель
источник
35
Конечно, если вы не ограничиваетесь скучными 32-битными целыми числами, на таком языке, как C ++, ++ i действительно может быть вызовом веб-службы, которая обновляет значение в базе данных.
Eclipse,
16

Оба они небезопасны для потоков.

ЦП не может выполнять вычисления непосредственно с памятью. Он делает это косвенно, загружая значение из памяти и выполняя математические вычисления с регистрами процессора.

я ++

register int a1, a2;

a1 = *(&i) ; // One cpu instruction: LOAD from memory location identified by i;
a2 = a1;
a1 += 1; 
*(&i) = a1; 
return a2; // 4 cpu instructions

++ я

register int a1;

a1 = *(&i) ; 
a1 += 1; 
*(&i) = a1; 
return a1; // 3 cpu instructions

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

Например, предположим, что существует два параллельных потока ++ i, каждый из которых использует регистры a1, b1 соответственно. И с переключением контекста, выполняемым следующим образом:

register int a1, b1;

a1 = *(&i);
a1 += 1;
b1 = *(&i);
b1 += 1;
*(&i) = a1;
*(&i) = b1;

В результате i не становится i + 2, а становится i + 1, что неверно.

Чтобы исправить это, современные ЦП предоставляют своего рода инструкции ЦП LOCK, UNLOCK в течение интервала, когда отключено переключение контекста.

В Win32 используйте InterlockedIncrement () для выполнения i ++ для обеспечения безопасности потоков. Это намного быстрее, чем полагаться на мьютекс.

йогмен
источник
6
«ЦП не может выполнять математические вычисления напрямую с памятью» - это неверно. Существуют процессоры, в которых вы можете выполнять математические вычисления «напрямую» над элементами памяти, без необходимости сначала загружать их в регистр. Например. MC68000
darklon
1
Команды LOCK и UNLOCK CPU не имеют ничего общего с переключениями контекста. Они блокируют строки кеша.
Дэвид Шварц
11

Если вы разделяете даже int между потоками в многоядерной среде, вам нужны надлежащие барьеры памяти. Это может означать использование взаимосвязанных инструкций (см., Например, InterlockedIncrement в win32) или использование языка (или компилятора), который дает определенные гарантии безопасности потоков. С переупорядочением инструкций на уровне ЦП, кешами и другими проблемами, если у вас нет этих гарантий, не предполагайте, что что-либо, совместно используемое между потоками, безопасно.

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

Затмение
источник
На самом деле неправильно в средах, где доступ к int (чтение / запись) является атомарным. Существуют алгоритмы, которые могут работать в таких средах, хотя отсутствие барьеров памяти может означать, что вы иногда работаете с устаревшими данными.
MSalters
2
Я просто говорю, что атомарность не гарантирует безопасность потоков. Если вы достаточно сообразительны, чтобы разрабатывать структуры данных или алгоритмы без блокировок, тогда вперед. Но вам все равно нужно знать, какие гарантии дает вам ваш компилятор.
Eclipse,
10

Если вам нужен атомарный прирост в C ++, вы можете использовать библиотеки C ++ 0x ( std::atomicтип данных) или что-то вроде TBB.

Было время, когда в руководстве по кодированию GNU говорилось, что обновление типов данных, которые умещаются одним словом, «обычно безопасно», но этот совет неверен для SMP-машин, неверен для некоторых архитектур и неверен при использовании оптимизирующего компилятора.


Чтобы прояснить комментарий «обновление однословного типа данных»:

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

Compare-and-swap правильно координирует между несколькими процессорами, но нет оснований полагать, что каждое присвоение переменной однословных типов данных будет использовать сравнение и-swap.

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

ПРИМЕЧАНИЕ. В моем первоначальном ответе также говорилось, что 64-разрядная архитектура Intel была нарушена при работе с 64-разрядными данными. Это неправда, поэтому я отредактировал ответ, но моя редакция утверждала, что чипы PowerPC сломаны. Это верно при чтении непосредственных значений (т. Е. Констант) в регистры (см. Два раздела под названием «Загрузка указателей» в листинге 2 и листинге 4). Но есть инструкция по загрузке данных из памяти за один цикл ( lmw), поэтому я удалил эту часть своего ответа.

Макс Либберт
источник
Чтение и запись являются атомарными на большинстве современных процессоров, если ваши данные естественным образом выровнены и имеют правильный размер, даже с SMP и оптимизирующими компиляторами. Однако есть много предостережений, особенно с 64-битными машинами, поэтому может быть сложно обеспечить соответствие ваших данных требованиям на каждой машине.
Дэн Олсон,
Спасибо за обновление. Правильно, чтение и запись являются атомарными, поскольку вы заявляете, что они не могут быть выполнены наполовину, но ваш комментарий подчеркивает, как мы подходим к этому факту на практике. То же самое и с барьерами памяти, они не влияют на атомарный характер операции, а влияют на то, как мы подходим к ней на практике.
Дэн Олсон,
4

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

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

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

xtofl
источник
4

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

При увеличении значения в памяти аппаратное обеспечение выполняет операцию «чтение-изменение-запись»: оно считывает значение из памяти, увеличивает его и записывает обратно в память. Аппаратное обеспечение x86 не имеет возможности увеличения непосредственно в памяти; ОЗУ (и кеши) может только читать и сохранять значения, но не изменять их.

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

Есть способ избежать этой проблемы; Процессоры x86 (и большинство многоядерных процессоров, которые вы найдете) способны обнаруживать этот вид конфликта в оборудовании и упорядочивать его, так что вся последовательность чтения-изменения-записи выглядит атомарной. Однако, поскольку это очень дорого, это делается только по запросу кода, на x86 обычно через LOCKпрефикс. Другие архитектуры могут делать это другими способами с аналогичными результатами; например, связанное с загрузкой / сохранение-условное и атомарное сравнение и замена (последние процессоры x86 также имеют этот последний).

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

Лучше всего использовать атомарные примитивы (если они есть в вашем компиляторе или библиотеках) или выполнять приращение непосредственно в сборке (используя правильные атомарные инструкции).

CesarB
источник
2

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

Изменить: я только что посмотрел этот конкретный вопрос, и приращение на X86 является атомарным в однопроцессорных системах, но не в многопроцессорных системах. Использование префикса блокировки может сделать его атомарным, но его гораздо удобнее использовать только InterlockedIncrement.

Дэн Олсон
источник
1
InterlockedIncrement () - это функция Windows; все мои Linux-машины и современные машины OS X основаны на x64, поэтому утверждение InterlockedIncrement () «намного более переносимо», чем код x86, скорее ложно.
Пит Киркхэм,
Он намного более портативен в том же смысле, что C намного более портативен, чем сборка. Цель здесь - изолировать себя от зависимости от конкретной сгенерированной сборки для конкретного процессора. Если вас беспокоят другие операционные системы, то InterlockedIncrement легко обернуть.
Дэн Олсон,
2

Согласно этому уроку сборки на x86, вы можете атомарно добавить регистр в ячейку памяти , поэтому потенциально ваш код может атомарно выполнить '++ i' ou 'i ++'. Но, как сказано в другом сообщении, C ansi не применяет атомарность к операции '++', поэтому вы не можете быть уверены в том, что сгенерирует ваш компилятор.

Селсо Либерадо
источник
1

Стандарт C ++ 1998 года ничего не говорит о потоках, в отличие от следующего стандарта (который должен выйти в этом или следующем году). Следовательно, вы не можете сказать ничего умного о поточной безопасности операций без ссылки на реализацию. Это не просто используемый процессор, а комбинация компилятора, ОС и модели потока.

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

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

Дэвид Торнли
источник
1

Бросить i в локальное хранилище потока; это не атомарно, но тогда это не имеет значения.


источник
1

AFAIK, согласно стандарту C ++ чтение / запись в объект intявляются атомарными.

Однако все, что это делает, - это избавление от неопределенного поведения, связанного с гонкой данных.

Но гонка данных все равно будет, если оба потока попытаются увеличить i.

Представьте себе следующий сценарий:

Пусть i = 0изначально:

Поток A считывает значение из памяти и сохраняет в собственном кэше. Поток A увеличивает значение на 1.

Поток B считывает значение из памяти и сохраняет в собственном кэше. Поток B увеличивает значение на 1.

Если бы это был один поток, вы бы попали i = 2в память.

Но с обоими потоками каждый поток записывает свои изменения, и поэтому поток A записывает i = 1обратно в память, а поток B записывает i = 1в память.

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

Для атомарного увеличения iвы можете использовать:

std::atomic<int>::fetch_add(1, std::memory_order_relaxed)

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

Моше Рабаев
источник
0

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

Не зная языка, ответ состоит в том, чтобы проверить это до чертиков.

Крис
источник
4
Вы не узнаете, является ли что-то потокобезопасным, тестируя это - проблемы с потоками могут быть одним из миллиона случаев. Вы найдете это в своей документации. Если ваша документация не гарантирует потокобезопасность, это не так.
Eclipse,
2
Согласитесь с @Josh здесь. Что-то является потокобезопасным только в том случае, если это может быть доказано математически посредством анализа базового кода. Никакое количество тестов не может приблизиться к этому.
Rex M
До последнего предложения это был отличный ответ.
Rob K,
0

Я думаю, что если выражение «i ++» является единственным в выражении, оно эквивалентно «++ i», компилятор достаточно умен, чтобы не сохранять временное значение и т. Д. Итак, если вы можете использовать их взаимозаменяемо (иначе вы выиграли бы не спрашивать, какой из них использовать), неважно, что вы используете, поскольку они почти одинаковы (кроме эстетики).

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

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

Фортран
источник
0

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

Вот это на Java:

public class IntCompareAndSwap {
    private int value = 0;

    public synchronized int get(){return value;}

    public synchronized int compareAndSwap(int p_expectedValue, int p_newValue){
        int oldValue = value;

        if (oldValue == p_expectedValue)
            value = p_newValue;

        return oldValue;
    }
}

public class IntCASCounter {

    public IntCASCounter(){
        m_value = new IntCompareAndSwap();
    }

    private IntCompareAndSwap m_value;

    public int getValue(){return m_value.get();}

    public void increment(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp != m_value.compareAndSwap(temp, temp + 1));

    }

    public void decrement(){
        int temp;
        do {
            temp = m_value.get();
        } while (temp > 0 && temp != m_value.compareAndSwap(temp, temp - 1));

    }
}
AtariPete
источник
Похоже на функцию test_and_set.
samoz
1
Вы написали "без блокировки", но "синхронизированный" не означает блокировку?
Кори Трейджер,