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


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

однако мне интересно о ++i.насколько я могу судить, это сводится к одной инструкции по сборке, такой как "добавить r1, r1, 1", и поскольку это только одна инструкция, она будет бесперебойной с помощью контекстного переключателя.

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

15 88

15 ответов:

вы неправильно расслышали. Вполне может быть, что "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++ в некоторых операционных системах) - это то, что вам нужно изучить (a).


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

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

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

Они оба потокобезопасны.

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

i++

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

++i

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, что неверно.

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

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

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

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

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

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


чтобы уточнить комментарий" обновление однословного типа данных":

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

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

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

Примечание в моем первоначальном ответе также говорилось, что архитектура Intel 64 bit была нарушена при работе с 64-битными данными. Это не так, поэтому я отредактировал ответ, но мое редактирование заявило, что чипы PowerPC были сломаны. это верно при чтении непосредственных значений (т. е. констант) в регистры (см. два раздела "указатели загрузки" в листинге 2 и листинге 4) . Но есть инструкция по загрузке данных из памяти в один цикл (lmw), поэтому я удалил эту часть моего ответа.

на x86/Windows В C / C++, вы не должны предполагать, что это потокобезопасно. Вы должны использовать InterlockedIncrement() и InterlockedDecrement() Если Вам требуются атомарные операции.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

вот он в 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));

    }
}

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

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

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