Атомарность, изменчивость(*) и неизменяемость – это разные вещи. Часть 1

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

Прежде всего, что мы подразумеваем под «атомарностью»? Это слово образовано от греческого ἄτομος, что означает «не делимый на более мелкие части», и «атомарные» операции всегда, либо выполняются, либо нет, но никогда не могут быть выполняться наполовину. В секции 5.5 спецификации языка C# четко сказано, какие операции являются «атомарными». Атомарными операциями являются операции чтения и записи переменных любого ссылочного типа или любых встроенных типов данных, размером четыре байта или менее, таких как int, short и т.д. Чтения и запись переменных значимых типов, размером более четырех байт, таких как double, long, decimal, не являются гарантированно атомарными в языке C#.

Что же означает, что чтение и запись переменной типа int является атомарной? Предположим, у вас есть статические переменные типа int. Значение переменной X равно 2, Y = 1, Z = 0. Потом, в одном из потоков, встречается:

Z = X;

А в другом потоке:

X = Y;

Каждый поток выполняет одно чтение и одну запись. Каждое чтение и запись сами по себе являются атомарными. Чему будет равно значение Z? Без синхронизации произойдет соревнование (race). Если выиграет первый поток, тогда Z будет равно 2. Если победит второй поток, тогда Z будет равно 1. Z будет содержать одно из двух значений, но вы точно не сможете сказать, какое именно.

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

 struct MyLong 
{
    public readonly int low;
    public readonly int high;
    public MyLong(low, high) 
    {
        this.low = low;
        this.high = high;
    }
}

Не обращайте пока внимание на зло, в виде открытых полей. Предположим, у нас есть поля Q, R и S типа MyLong, инициализированные (0x01234567, 0x0BADF00D), (0x0DEDBEEF, 0x0B0B0B0B) и (0, 0), соответственно. В двух потоках происходит следующее:

S = Q;

и

Q = R;

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

S.low = Q.low;

S.high = Q.high;

и

Q.low = R.low;

Q.high = R.high;

Вы не можете написать этот код, поскольку модификация полей только для чтения за пределами конструктора запрещена. Однако CLR следит за следованием этому правилу, и она же может его и нарушить! (Мы вернемся к данному вопросу в следующий раз; все на самом деле значительно сложнее, чем можно себе представить). Значимые типы копируются по значению; именно поэтому они так и называются. При копировании значимых типов CLR не вызывает их конструктор, она просто переносит определенное количество байт атомарными блоками. На практике может оказаться, что JIT-компилятор будет содержать специальные регистры для переноса блоков большего размера, но язык C# этого не гарантирует. Язык C# гарантирует атомарность только для блоков, не превышающих 4 байта.

Теперь, вследствие гонок вначале может выполниться S.low = Q.low, затем Q.low = R.low, затем Q.high = R.high, а затем S.high = Q.high. В таком случае S будет равно (0x0DEDBEEF, 0x0BADF00D), хотя это значение не равно ни одному из исходных значений. Как сказала бы Гермиона Грейнджер, если бы она была программистом, значения расщепились.

(И, конечно же, показанный выше порядок не гарантирован. CLR может копировать блоки в любом порядке; например, вначале могут копироваться старшие четыре байта, а потом младшие.)

Имя структуры «MyLong» я выбрал не случайно; на самом деле тип long на 32-разрядных процессорах реализован именно в виде двух полей типа int, доступных только для чтения. Каждая операция над типом long выполняется в два этапа, каждый из которых оперирует блоком из 32 бит. То же самое происходит и с типом double, то же самое происходит с любым типом, размер которого превышает 32 бита. При попытке чтения или записи типов long или double из разных потоков на 32-разрядной операционной системе без какой-либо синхронизации для обеспечения атомарности, существует высокая вероятность расщепить эти данные.

Только указанные выше операции – чтение и запись переменных правильного размера, гарантированно являются атомарными без дополнительных блокировок и примитивов синхронизации. В частности, операции «инкремента» и «декремента» не являются атомарными. Когда вы пишите:

i++;

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

Существует множество техник преобразования неатомарных операций в атомарные; самый простой способ – обернуть каждый доступ к переменной в lock, что предотвратит одновременный доступ к ней со стороны двух потоков. Кроме того, можно воспользоваться семейством Interlocked-функций, которые обеспечивают атомарное увеличение, сравнение и обмен (compare-and-exchange) и т.п.

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

Оригинал статьи


(*) К сожалению, англоязычные термины volatility и immutability переводятся достаточно похожими русскоязычными терминами «изменчивость» и «неизменяемость» соответственно. Но нужно четко понимать, что эти понятия существенно отличаются и «изменяемость» в них означает совершенно разные вещи. Примеч. перев.