>
>
>
V3147. Non-atomic modification of volat…


V3147. Non-atomic modification of volatile variable.

Анализатор обнаружил неатомарное изменение 'volatile' переменной, которое может привести к состоянию гонки.

Известно, что использование модификатора 'volatile' гарантирует, что все потоки будут видеть актуальное значение соответствующей переменной. Модификатор 'volatile' используется для того, чтобы указать CLR, что все операции присвоения этой переменной и все операции чтения из неё должны быть атомарными.

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

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

  • var++, ‑‑var, ...
  • var += smt, var *= smt, ...
  • ...

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

Рассмотрим использование 'volatile' переменной в качестве счетчика (counter++).

class Counter
{
  private volatile int counter = 0;
  ....
  public void increment()
  {
    counter++; // counter = counter + 1
  }
  ....
}

В IL коде операция инкремента раскрывается в следующие команды:

IL_0001:  ldarg.0
IL_0002:  ldarg.0
IL_0003:  volatile.
IL_0005:  ldfld      int32
modreq([mscorlib]System.Runtime.CompilerServices.IsVolatile)
VolatileTest.Test::val
IL_000a:  ldc.i4.1
IL_000b:  add
IL_000c:  volatile.
IL_000e:  stfld      int32
modreq([mscorlib]System.Runtime.CompilerServices.IsVolatile)
VolatileTest.Test::val

Здесь и кроется состояние гонки. Предположим, что 2 потока одновременно работают с одним и тем же экземпляром объекта типа Counter и выполняют инкремент переменной 'counter', изначально проинициализированной значением 10. При этом, оба потока будут работать одновременно, выполняя промежуточные действия над переменной counter, каждый на своём собственном стеке (назовём эти промежуточные значения temp1 и temp2):

[counter == 10, temp1 == 10] Поток N1 считывает значение 'counter' на свой стек. (операция ldfld в IL)

[counter == 10, temp1 == 11] Поток N1 изменяет значение temp1 на своём стеке. (операция add в IL)

[counter == 10, temp2 == 10] Поток N2 считывает значение 'counter' на свой стек. (операция ldfld в IL)

[counter == 11, temp1 == 11] Поток N1 записывает temp1 в 'counter'. (операция stfld в IL)

[counter == 11, temp2 == 11] Поток N2 изменяет значение temp2 на своём стеке. (операция add в IL)

[counter == 11, temp2 == 11] Поток N2 записывает temp2 в 'counter'. (операция stfld в IL)

Ожидалось значение переменной 'counter' равное 12 (а не 11), так как 2 потока выполнили инкремент над одной и той же переменной. Также возможна ситуация, когда потоки выполнят инкремент друг за другом, и в таком случае все будет так, как и ожидалось.

Чтобы избежать подобного поведения неатомарных операций для разделяемых переменных, можно использовать:

  • Блок 'lock'
  • Методы атомарных операций класса Interlocked из библиотеки System.Threading
  • Функциональность блокировок класса Monitor из библиотеки System.Threading

Пример корректного кода:

class Counter
{
  private volatile int counter = 0;
  ....
  public void increment()
  {
    Interlocked.Increment(ref counter);  
  }
  ....
}

Выявляемые диагностикой ошибки классифицируются согласно ГОСТ Р 71207–2024 как критические и относятся к типу: Ошибки при работе с многопоточными примитивами (интерфейсами запуска потоков на выполнение, синхронизации и обмена данными между потоками и пр.).

Данная диагностика классифицируется как: