Атомарность, изменчивость и неизменяемость – это разные вещи. Часть 2
В прошлый раз мы выяснили, что «атомарные» чтение и запись переменной означает, что в многопоточном окружении переменная никогда не будет содержать «частично измененное» значение. Состояние переменной изменяется из одного в другое напрямую без промежуточного состояния. Кроме того, я упомянул, что создание всех полей структуры только для чтения не влияет на атомарность; при копировании структуры копируется только четыре байта за раз независимо от того, помечены поля ключевым словом «readonly» или нет.
Однако существует более серьезная проблема, связанная с полями структур только для чтения помимо неатомарности. Да, при чтении полей структуры только для чтения из разных потоков без блокировок вы можете получить противоречивые результаты из-за гонок. Но все на самом деле еще хуже; вы можете получить неожиданный результат даже с одним потоком! По сути, поля структуры, доступные только для чтения эквивалентны выписыванию чека, по которому вы не сможете расплатиться.
Изначально я хотел написать объемную статью по этому поводу, однако потом я узнал, что Джо Даффи (Joe Duffy) уже проделал отличную работу. Посмотрите вот эту его статью.
Если коротко, то поскольку структура не «владеет» собственным хранилищем, поля структуры «только для чтения» означают, что компилятор всего лишь не даст автору кода изменять поля структуры напрямую за пределами конструктора. Реальная область памяти, в которой располагается экземпляр структуры, не должна быть «только для чтения» и может быть изменена. Давайте рассмотрим следующий пример:
struct S
{
readonly int x;
public S(int x) { this.x = x; }
public void M(ref S other)
{
int oldX = this.x;
other = new S(456);
int newX = this.x;
}
}
Поскольку «this.x» является полем только для чтения, вы можете подумать, что newX и oldX всегда будут содержать одинаковые значения. Но если вы вызовите этот метод так:
S s = new S(123);
s.M(ref s);
То, поскольку обе переменные «this» и «other» являются синонимами для переменной «s», то «s» может измениться!
Теперь давайте вернемся к атомарности; в прошлый раз я сказал, что мы обсудим способы обеспечения атомарности, помимо гарантированных спецификацией языка C#. В спецификации языка C# сказано, что атомарными являются чтение и запись переменной ссылочного типа, а также чтение и запись переменных встроенных значимых типов размером 4 байта и менее (int, uint, float, char, bool и т.д.) и всё.
Спецификация CLI на самом деле дает более строгие гарантии. CLI гарантирует, что чтение и запись переменных значимого типа размером равным (или меньшим) размеру указателя процессора являются атомарными; при запуске кода, написанного на языке C# на 64-разрядной операционной системе с 64-разрядной версией CLR, то чтение и запись 64-разрядных переменных типа double или long также гарантированно являются атомарными. Язык C# этого не гарантирует, но среда времени выполнения – гарантирует (в случае запуска кода, написанного на языке C# на некотором оборудовании, не реализующем спецификацию CLI, конечно же, вы не можете на это полагаться; свяжитесь с поставщиком этой среды времени выполнения, чтобы понять, какие гарантии она обеспечивает).
Еще одним тонким моментом относительно атомарности доступа является то, что процессор гарантирует атомарность только если переменная, с которой осуществляется чтение или запись, правильно выровнена (aligned) в памяти. В конечном итоге, любая переменная реализована как указатель на некоторую область памяти. В 32-разрядной операционной системе, для гарантии атомарности чтения и записи необходимо, чтобы адрес указателя нацело делился на 4, а для 64-разрядной операционной системы – на 8. Если вы сделаете, нечто безумное, типа:
int* pInt1 = &intArr[0];
byte* pByte = (byte*)pInt;
pByte += 6;
int* pInt2 = (int*) pByte;
*pInt2 = 0x000DECAF;
то никто не гарантирует, что запись, которая затронет половину значения из intArr[1] и половину значения из intArr[2] будет атомарной. Два элемента массива, расположенных под переменной, могут изменяться последовательно.
Теперь, CLR гарантирует, что все поля структур, чей размер равен (или меньше) размеру указателя, будут по умолчанию выровнены в памяти таким образом, чтобы не пересекать границы показанным выше способом. И это, таким образом, гарантирует, что операции чтения и записи будут атомарными. Однако язык C# позволяет вам сказать CLR о том, чтобы она не использовала правила упаковки структуры по умолчанию; я скажу вам, как задать байтовое представление структуры. (Возможно, у вас есть буфер, полученный из неуправляемого кода, и вы используете небезопасный код для получения указателя на структуру, который требует определенной структуры этих байт). Если вы скажите CLR поместить поле типа int внутри структуры и нарушите правила выравнивания, тогда доступ к этому полю не будет атомарным, и это будет вашей проблемой.
В следующий раз: что означает ключевое слово «volatile» и какое оно имеет отношение к «атомарности» в языке C#?