Страницы

Поиск по вопросам

понедельник, 30 декабря 2019 г.

Необходимость блокировки при считывании/записи переменной

#c_sharp #многопоточность


Нужна ли блокировка lock в C# при считывании / записи переменной?

Пример кода:

Int i = 0;

Timer timer = new Timer();
timer.Interval = 500;
timer.backFunc += readVar;

Thread th = new Thread();
th.backFunc += setVar;

th.Run();
timer.Run();    

void readVar()
{
    Console.WriteLine(i);
}

void setVar()
{
    i++;
}

    


Ответы

Ответ 1



Обновление: Смотрите. Есть простое правило: если доступ к переменной происходит из нескольких потоков (всё равно, чтение или запись), то это чтение нужно окружать lock'ом. Несоблюдение этого правила может привести к немедленно видимому неправильному результату, но (что гораздо хуже!) может и появлению неправильного результата очень редко! Таким образом, у вас могут появиться трудновоспроизводимые вылеты программы. Это правило действует даже для случая, когда переменная «маленькая» (например, int), и может быть записана «в один присест». О точных причинах смотрите этот ответ ниже и другие ответы, а также дискуссию в комментариях. В вашем случае колбеки от таймера (модифицирующие) приходят не том же потоке, что считывающий код, поэтому есть необходимость в блокировке. Это правило не абсолютное: существуют случаи, когда на самом деле можно избежать блокировок. Но для этого необходимо знать тонкости о работе современных процессоров, оптимизаторов и конкретного оборудования, и очень легко допустить ошибку. Поэтому мой совет — не старайтесь любой ценой избегать блокировок, игра часто не стоит свеч. Хуже того, тестирование часто не покажет вам проблемы с кодом. Важный случай, когда блокировки не нужны — использование TPL и async/await для конкурентного доступа без выгрузки работы в дополнительный поток. Пример: int x = 0; async Task RunVariableChange(int howManyTimes) { for (int i = 0; i < howManyTimes; i++) { await Task.Delay(500); x++; } } async Task ReadVariable(CancellationToken ct) { try { while (true) { await Task.Delay(750, ct); Console.WriteLine(x); } } catch (TaskCanceledException) { // ничего не делать, это ожидаемое исключение } } // использование (*) var cts = new CancellationTokenSource(); var readTask = ReadVariable(cts.Token); await RunVariableChange(20); cts.Cancel(); await readTask; Если код (*) бежит в UI-потоке, то все случаи доступа к переменной происходят в нём, и нужды в блокировке нет. По сути у нас тут нет многопоточного кода! (Однако, блокировки всё ещё нужны, если Task запускается не в UI-потоке, например, через Task.Run.) Кроме того, использование локальной переменной в async-методе безопасно даже если этот метод «перепрыгивает» из потока в поток, поскольку каждый await является точкой синхронизации. (Поэтому старайтесь использовать «функциональный» подход: работайте не с разделяемыми, а с локальными переменными, получайте данные на вход как параметры, и возвращайте результат как return-значение функции: это уменьшит потребность в блокировках.) Старый ответ: Да. Если код модифицирует переменную, то многопоточный доступ требует защиты memory barrier'ом, например, lock'ом. Иначе как оптимизатор, так и кэш процессора вправе считать, что переменная не меняется. Пример с кэшем процессора: допустим, поток #1 выполняется на процессоре #1, а поток #2 — на процессоре #2. Поток #1 записывает новое значение переменной. Это значение попадает в кэш процессора #1, но не «проваливается» сразу в память, т. к. синхронизация с памятью — процесс очень медленный. Теперь, поток #2 читает значение, из кэша процессора #2, а там оно старое! Принудительный «сброс» кэша процессора в память — один из эффектов, которые вызывает lock. Почему же при этом такой эффект редко виден на деле? Дело в том, что сброс кэша иногда может происходить по внешним причинам, например, при переключении контекста. Я надеюсь, я правильно понял назначение вашего кода. Мне пришлось угадывать, поскольку ни Thread, ни Timer не содержат event'а backFunc.

Ответ 2



Почему-то предыдущие ответы сразу ушли в дебри моделей памяти, которые все равно толком никто не понимает. ИМХО, есть гораздо более простая причина, почему в случае инкремента переменной нужна синхронизация. По факту, i++ не является атомарной операцией, а является тройкой - read-modify-write. Перепишите метод setVal следующим образом: void SetVal() { var tempI = i; // пустота! i = tempI + 1; } Теперь должно быть понятным, что при наличии более одного потока (а наличие таймера говорит о наличии более одного потока), ОС-ка может переключить контекст исполнения в строке с комментом пустота. В результате, будет следующее: Начальный момент времени i == 42! Thread 1: Прочитал i, записал в tempI 42 Контекст переключился на другой поток Thread 2: Прочитал i, записал в tempI 42 Записал в i tempI + 1, т.е. i теперь равна 43. Контекст переключился на первый поток Thread 1: (tempI у этого потока хранит все то же старое значение, 42) Записал в i tempI + 1, т.е. i равна 43! Вот так мы потеряли один инкремент. Теперь наша задача сделать инкремент, т.е. операцию read-modify-write - атомарной (т.е. неделимой с точки зрения процесса исполнения). Также нам нужно сделать и так, чтобы потоки каждый раз вычитывали наиболее актуальные значения, но, в простых случаях (без всякой lock-free магии, о которой, судя по вопросу, пока не время заморачиваться) эти две проблемы будут решены одним махом. Большинство современных сред исполнения содержат библиотеки для атомарного увелчения целых чисел. Так, в .NET Framework есть класс Interlocked, который содержит ряд методов, в частности, предложенный Increment(ref int). Альтернативой является обернуть доступ к разделяемой переменной некоторым примитивом синхронизации, например, конструкцией lock. Вот пример, который показывает, почему синхронизация важна: private static int _lockLessCount; private static int _lockFreeCount; private static int _lockBasedCount; private static object _countLock = new object(); static void Main(string[] args) { int numberOfIterations = 1000000; var t1 = Task.Run(() => { for (int n = 0; n < numberOfIterations; n++) _lockLessCount++; }); var t2 = Task.Run(() => { for (int n = 0; n < numberOfIterations; n++) _lockLessCount++; }); Task.WaitAll(t1, t2); var t3 = Task.Run( () => { for (int n = 0; n < numberOfIterations; n++) Interlocked.Increment(ref _lockFreeCount); }); var t4 = Task.Run( () => { for (int n = 0; n < numberOfIterations; n++) Interlocked.Increment(ref _lockFreeCount); }); Task.WaitAll(t3, t4); var t5 = Task.Run( () => { for (int n = 0; n < numberOfIterations; n++) lock (_countLock) { _lockBasedCount++; } }); var t6 = Task.Run( () => { for (int n = 0; n < numberOfIterations; n++) lock (_countLock) { _lockBasedCount++; } }); Task.WaitAll(t5, t6); Console.WriteLine("Iterations: {0}", numberOfIterations); Console.WriteLine("LockLessCount: {0}", _lockLessCount); Console.WriteLine("LockFreeCount: {0}", _lockFreeCount); Console.WriteLine("LockBasedCount: {0}", _lockBasedCount); Console.WriteLine("Done!"); } Если бы синхронизация была не нужна, то при запуске мы бы увидели одинаковые значения, но это не так. Вот результат вывода на моем локальном компьюетере: Iterations: 1000000 LockLessCount: 1611802 LockFreeCount: 2000000 LockBasedCount: 2000000 Done! Как видно, при "простом" доступе, мы потеряли почти 400 тысяч значений!

Ответ 3



Нет, в Вашем коде совершенно не требуется lock. Хотя одну операцию всё же придётся изменить - метод setVar должен выглядеть так: void setVar() { Interlocked.Increment(ref i); } С этим Ваш код защищён от гонок. Единственным недостатком такого подхода является следующее: допустим setVar исполняется в потоке 1(П1), тогда как readVar в потоке 2(П2). Допустим, что setVar был вызван уже 5 раз и менял значения i на следующее [1,2,3,4,5]. Теперь readVar получает свой квант времени и может прочитать любое значение из вышеперечисленных. Единственное ограничение состоит в том, что readVar не может прочитать значение, которые было раньше, чем уже прочитанное в этом потоке. К примеру, в первый вызов readVar прочитал 3, это значит, что в следующем вызове readVar может быть прочитано [3,4,5] и никакого другого значения. Но это всё в теории, на процессорах x86 и amd64, readVar всегда прочитает последнее значение. На ARM не всегда. Больше .NET нет нигде, насколько я знаю. lock сериализует доступ к переменной, поэтому он гарантирует, что при чтении всегда будет получено последнее записанное под lock значение(если конечно все операции записи находятся под lock'ом). Это, разумеется, не бесплатно и в общем случае код использующий lock может быть медленее. Но понятнее.

Ответ 4



в последнем примере от Sergey Teplyakov еще попробовал volatule и получил: Iterations: 1000000 LockLessCount: 1483306 LockFreeCount: 2000000 LockBasedCount: 2000000 LockVolatileCount: 65866 Done! И еще попробовал Брэкпойнт на Console.WriteLine("Done!") и увидел: LockLessCount: 2000000 LockVolatileCount: 137415 IMHO: Дебаггером такие ошибки не обнаркжить. Volatile похоже помогает только для чтения в другом потоке

Комментариев нет:

Отправить комментарий