Lock c как работает
Перейти к содержимому

Lock c как работает

  • автор:

Name already in use

docs / docs / csharp / language-reference / statements / lock.md

  • Go to file T
  • Go to line L
  • Copy path
  • Copy permalink
  • Open with Desktop
  • View raw
  • Copy raw contents Copy raw contents

Copy raw contents

Copy raw contents

lock statement — ensure exclusive access to a shared resource.

The lock statement acquires the mutual-exclusion lock for a given object, executes a statement block, and then releases the lock. While a lock is held, the thread that holds the lock can again acquire and release the lock. Any other thread is blocked from acquiring the lock and waits until the lock is released. The lock statement ensures that a single thread has exclusive access to that object.

The lock statement is of the form

where x is an expression of a reference type. It’s precisely equivalent to

Since the code uses a try. finally block, the lock is released even if an exception is thrown within the body of a lock statement.

You can’t use the await operator in the body of a lock statement.

When you synchronize thread access to a shared resource, lock on a dedicated object instance (for example, private readonly object balanceLock = new object(); ) or another instance that is unlikely to be used as a lock object by unrelated parts of the code. Avoid using the same lock object instance for different shared resources, as it might result in deadlock or lock contention. In particular, avoid using the following types as lock objects:

  • this , as it might be used by the callers as a lock.
  • xref:System.Type instances, as those objects might be obtained by the typeof operator or reflection.
  • string instances, including string literals, as string literals might be interned.

Hold a lock for as short time as possible to reduce lock contention.

The following example defines an Account class that synchronizes access to its private balance field by locking on a dedicated balanceLock instance. Using the same instance for locking ensures that the balance field can’t be updated simultaneously by two threads attempting to call the Debit or Credit methods simultaneously.

Lock c как работает

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

Здесь у нас запускаются пять потоков, которые вызывают метод Print и которые работают с общей переменной x. И мы предполагаем, что метод выведет все значения x от 1 до 5. И так для каждого потока. Однако в реальности в процессе работы будет происходить переключение между потоками, и значение переменной x становится непредсказуемым. Например, в моем случае я получил следующий консольный вывод (он может в каждом конкретном случае различаться):

Решение проблемы состоит в том, чтобы синхронизировать потоки и ограничить доступ к разделяемым ресурсам на время их использования каким-нибудь потоком. Для этого используется ключевое слово lock . Оператор lock определяет блок кода, внутри которого весь код блокируется и становится недоступным для других потоков до завершения работы текущего потока. Остальный потоки помещаются в очередь ожидания и ждут, пока текущий поток не освободит данный блок кода. В итоге с помощью lock мы можем переделать предыдущий пример следующим образом:

Для блокировки с ключевым словом lock используется объект-заглушка, в данном случае это переменная locker . Обычно это переменная типа object. И когда выполнение доходит до оператора lock, объект locker блокируется, и на время его блокировки монопольный доступ к блоку кода имеет только один поток. После окончания работы блока кода, объект locker освобождается и становится доступным для других потоков.

Синхронизация потоков

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

Чтобы проиллюстрировать проблему, связанную с параллелизмом, давайте рассмотрим следующий пример:

Прежде чем посмотреть на тестовые запуски, давайте еще раз проясним проблему. Первичный поток внутри этого домена приложений начинает свое существование, порождая десять вторичных рабочих потоков. Каждый рабочий поток должен вызвать метод ThreadNumbers() на одном и том же экземпляре MyTheard. Учитывая, что никаких мер для блокировки разделяемых ресурсов этого объекта (консоли) не предпринималось, есть хороший шанс, что текущий поток будет отключен, прежде чем метод ThreadNumbers() сможет напечатать полные результаты. Поскольку в точности не известно, когда это может случиться (и может ли вообще), будут получаться непредвиденные результаты. Например, может появиться следующий вывод:

Блокировка ресурсов

Ясно, что здесь присутствует определенная проблема. Как только каждый поток требует от MyTheard печати числовых данных, планировщик потоков меняет их местами в фоновом режиме. В результате получается несогласованный вывод. Для решения подобных проблем в C# используется синхронизация.

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

Средство блокировки встроено в язык C#. Благодаря этому все объекты могут быть синхронизированы. Синхронизация организуется с помощью ключевого слова lock. Она была предусмотрена в C# с самого начала, и поэтому пользоваться ею намного проще, чем кажется на первый взгляд. В действительности синхронизация объектов во многих программах на C# происходит практически незаметно.

Ниже приведена общая форма блокировки:

где lockObj обозначает ссылку на синхронизируемый объект. Если же требуется синхронизировать только один оператор, то фигурные скобки не нужны. Оператор lock гарантирует, что фрагмент кода, защищенный блокировкой для данного объекта, будет использоваться только в потоке, получающем эту блокировку. А все остальные потоки блокируются до тех пор, пока блокировка не будет снята. Блокировка снимается по завершении защищаемого ею фрагмента кода.

Блокируемым считается такой объект, который представляет синхронизируемый ресурс. В некоторых случаях им оказывается экземпляр самого ресурса или же произвольный экземпляр объекта, используемого для синхронизации. Следует, однако, иметь в виду, что блокируемый объект не должен быть общедоступным, так как в противном случае он может быть заблокирован из другого, неконтролируемого в программе фрагмента кода и в дальнейшем вообще не разблокируется.

В прошлом для блокировки объектов очень часто применялась конструкция lock (this). Но она пригодна только в том случае, если this является ссылкой на закрытый объект. В связи с возможными программными и концептуальными ошибками, к которым может привести конструкция lock (this), применять ее больше не рекомендуется. Вместо нее лучше создать закрытый объект, чтобы затем заблокировать его.

Давайте модифицируем предыдущий пример добавив в него синхронизацию:

Как только поток войдет в контекст lock, маркер блокировки (в данном случае — текущий объект) станет недоступным другим потокам до тех пор, пока блокировка не будет снята по выходе из контекста lock. Таким образом, если поток А захватит маркер блокировки, другие потоки не смогут войти ни в один из контекстов, использующих тот же маркер, до тех пор, пока поток А не освободит его.

Чтобы блокировать код в статическом методе, нужно объявить приватную статическую переменную-член, которая будет служить в качестве маркера блокировки. Если теперь запустить приложение, можно увидеть, что каждый поток получил возможность выполнить свою работу до конца:

.NET: Инструменты для работы с многопоточностью и асинхронностью. Часть 2

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

Содержание

О разделяемых ресурсах

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

Опасаясь возможных проблем, вы заставили потоки работать с разными файлами. По файлу на поток. Вам кажется, что в программе нет ни одного общего ресурса.

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

Прочитав пример#1, вы решили разместить файлы на двух разных удаленных машинах с двумя физически разными железяками и операционными системами. Держим 2 разных соединения по FTP или NFS.

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

Лишившись немалой части волос в попытках доказать возможность написания многопоточной программы вы отказываетесь вообще от файлов и раскладываете вычисления в два разных объекта, ссылки на каждый из которых доступны лишь одному потоку.

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

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

Возможные проблемы при работе в многопоточной среде

Ошибки в работе ПО можно разбить на несколько групп:

  1. Программа не выдает результат. Падает или зависает.
  2. Программа выдает неверный результат.
  3. Программа выдает верный результат, но не удовлетворяет тому или иному не функциональному требованию. Работает слишком долго или потребляет слишком много ресурсов.

Deadlock

Deadlock — взаимная блокировка. Существует много разных вариаций. Наиболее частой можно считать следующую:

Пока Thread#1 что-то делал, Thread#2 заблокировал ресурс B, немного позднее Thread#1 заблокировал ресурс A и пытается заблокировать ресурс B, к сожалению это никогда не произойдет, т.к. Thread#2 освободит ресурс B лишь после того как заблокирует ресурс А.

Race-Condition

Race-Condition — состояние гонки. Ситуация, в которой поведение и результат вычислений, выполняемых программой, зависит от работы планировщика потоков среды выполнения.
Неприятность этой ситуации заключается как раз в том, что ваша программа может не работать лишь один раз из ста или даже из миллиона.

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

Кроме этих двух проблем приводящих к явным ошибкам в работе программы есть еще те, что, возможно, и не приведут к некорректному результату вычислений, но для его получения будет потрачено больше времени или вычислительной мощности. Двумя такими проблемами являются: Busy Wait и Thread Starvation.

Busy-Wait

Busy-Wait — проблема, при которой программа потребляет ресурсы процессора не для вычислений, а для ожидания.

Часто такая проблема в коде выглядит примерно так:

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

Возможно, кто-то из читателей предложит решить проблему с полной загрузкой одного ядра бесполезным ожиданием добавлением в цикл конструкции вроде Thread.Sleep(1). Это действительно решит проблему, но создаст другую: время реагирование на изменение будет в среднем равен половине миллисекунды, что может и не много, но катастрофически больше, чем могло бы быть используй вы примитивы синхронизации семейства ResetEvent.

Thread Starvation

Thread-Starvation — проблема, при которой в программе слишком много одновременно работающих потоков. При чем речь именно о тех потоках, что заняты расчетами, а не просто ожиданием ответа от какого-либо IO. При этой проблеме теряется весь возможный выигрыш в производительности от использования потоков, т.к. Процессор тратит очень много времени на переключение контекстов.
Такие проблемы удобно искать с помощью различных профилировщиков, ниже пример скриншота из профилировщика dotTrace запущенного в режиме Timeline.


(Картинка кликабельна)

В программе что не страдает от потокового голода, розового цвета на графиках, отражающих потоки не будет. Кроме этого, в категории Subsystems видно, что программа 30.6% ждала CPU.

Когда такая проблема диагностирована, решается она довольно просто: вы запустили слишком много потоков в один момент времени, запустите меньше или не все сразу.

Средства синхронизации

Interlocked

Это, пожалуй, самый легковесный способ синхронизации. Interlocked представляет собой набор простых атомарных операций. Атомарной называется операция в момент выполнения которой не может ничего произойти. В .NET Interlocked представлен одноименным статическим классом с рядом методов, каждый из которых реализует одну атомарную операцию.

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

Класс Interlocked предоставляет методы Increment/Decrement, нетрудно догадаться, что они делают. Их удобно использовать, если вы обрабатываете данные в несколько потоков и что-то считаете. Такой код будет работать значительно быстрее классического lock. Если для ситуации описанной в прошлом абзаце использовать Interlocked, то программа в любой ситуации будет стабильно выдавать значение 10 миллионов.

Метод CompareExchange выполняет, на первый взгляд довольно неочевидную функцию, но все ее наличие позволяет реализовывать множество интересных алгоритмов, в первую очередь семейства lock-free.

Метод принимает три значения: первое передается по ссылке и это то значение, которое будет изменено на второе, если в момент сравнения location1 совпадает с comparand, то оригинальное значение location1 будет возвращено. Звучит довольно запутано, потому проще написать код, который выполняет те же операции, что и CompareExchange:

Только реализация в классе Interlocked будет атомарной. То есть, напиши мы такой код сами, могла бы произойти ситуация, когда условие location1 == comparand уже выполнилось, но к моменту выполнения выражения location1 = value другой поток изменил значение location1 и оно будет утеряно.

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

Напишем простой класс с одним событием MyEvent:

Соберем проект в конфигурации Release и откроем сборку при помощи dotPeek c включенной опцией Show Compiler Generated Code:

Здесь видно, что за кулисами, компилятор сгенерировал довольно изощренный алгоритм. Этот алгоритм защищает от ситуации потери подписки на событие, когда несколько потоков одновременно на это событие подписываются. Давайте распишем метод add подробнее, вспомнив что за кулисами делает метод CompareExchange

Так уже немного понятнее, хотя, наверное, все еще нуждается в объяснении. Словами бы я описал этот алгоритм следующим образом:

Если MyEvent все еще такой как был на момент как мы начали выполнять Delegate.Combine, то запиши в него то, что вернет Delegate.Combine, а если нет, то не беда, давай попробуем еще раз и будем повторять пока не выйдет.

Так ни одна подписка на событие не будет потеряна. Подобную проблему вам придется решать, если вдруг захотите реализовать динамический потокобезопасный lock-free массив. Если несколько потоков рвануться добавлять в него элементы, то важно чтобы все они в итоге были добавлены.

Monitor.Enter, Monitor.Exit, lock

Это самые часто используемые конструкции для синхронизации потоков. Реализуют идею критической секции: то есть код, написанный между вызовами Monitor.Enter, Monitor.Exit на одном ресурсе может быть выполнен в один момент времени лишь одним потоком. Оператор lock является синтаксическим сахаром вокруг вызовов Enter/Exit обернутых в try-finally. Приятной особенностью реализации критической секции в .NET является возможность повторного входа в нее для одного и того же потока. Это значит, что подобный код выполнится без проблем:

Вряд ли, конечно, кто-то будет писать именно так, но, если размазать этот код на несколько методов по глубине call-stack эта особенность может сэкономить вам несколько if-ов. Для того, чтобы такой трюк стал возможным разработчикам .NET пришлось добавить ограничение — в качестве объекта синхронизации может использоваться лишь экземпляр ссылочного типа, а в каждый объект неявно добавить несколько байт, куда будет записан идентификатор потока.

Такая особенность работы критической секции в c# накладывает одно интересное ограничение на работу оператора lock: внутри оператора lock нельзя использовать оператор await. Сначала это вызвало у меня удивление, ведь аналогичная конструкция try-finally Monitor.Enter/Exit компилируется. В чем же дело? Здесь необходимо еще раз внимательно перечитать прошлый абзац, а затем добавить к нему некоторые знания о принципе работы async/await: код после await совершенно не обязательно будет выполнен на том же потоке, что и код до await, это зависит от контекста синхронизации и наличия или отсутствия вызова ConfigureAwait. Из этого следует, что Monitor.Exit может выполниться на потоке отличном от Monitor.Enter, что приведет к выбросу SynchronizationLockException. Если не верите, то можете выполнить в консольном приложении следующий код: он сгенерирует SynchronizationLockException.

Примечательно, что в WinForms или WPF приложении этот код отработает корректно, если его вызвать из главного потока Т.к. там будет контекст синхронизации, который реализует возврат в UI-Thread после выполнения await. В любом случае не стоит играться с критической секцией в контексте кода, содержащего оператор await. В этих случаях лучше использовать примитивы синхронизации, что будут рассмотрены позднее.

Рассказывая о работе критической секции в .NET, стоит упомянуть про еще одну особенность ее реализации. Критическая секция в .NET работает в двух режимах: режиме spin-wait и режиме ядра. Алгоритм spin-wait удобно представить в виде следующего псевдокода:

Эта оптимизация направлено на наиболее быстрый захват критической секции в короткое время из расчета, что если ресурс сейчас и занят, то вот-вот он сейчас освободиться. Если этого не происходит за короткий промежуток времени, то поток уходит в ожидание в режиме ядра, что, как и возврат из него, занимает время. Разработчики .NET максимально оптимизировали сценарий коротких блокировок, к сожалению, если много потоков начнут разрывать критическую секцию между собой, то это может привести к высокой и внезапной загрузки CPU.

SpinLock, SpinWait

Monitor.Wait, Monitor.Pulse[All]

Эту пару методов стоит рассматривать вместе. С их помощью, можно реализовывать различные Producer-Consumer сценарии.

Producer-Consumer — паттерн многопроцессного/многопоточного проектирования предполагающий наличие одного или нескольких потоков/процессов, производящих данные и один или несколько процессов/потоков эти данные обрабатывающие. Как правило использует общую коллекцию.

Оба эти метода могут быть вызваны лишь при наличии у вызывающего их потока в данный момент блокировки. Метод Wait отпустит блокировку и повиснет до тех пор, пока другой поток не вызовет Pulse.

Для демонстрации работы я написал небольшой пример:


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

Разбор:Установил задержку в 100мс при старте второго потока, специально чтобы гарантировать, что его выполнение начнется позднее.
— T1:Line#2 поток стартует
— T1:Line#3 поток входит в критическую секцию
— T1:Line#6 поток засыпает
— T2:Line#3 поток стартует
— T2:Line#4 зависает в ожидании критической секции
— T1:Line#7 отпускает критическую секцию и зависает в ожидании выхода Pulse
— T2:Line#8 входит в критическую секцию
— T2:Line#11 оповещает T1 при помощи метода Pulse
— T2:Line#14 выходит из критической секции. До этих пор T1 не может продолжить выполнение.
— T1:Line#15 выходит из ожидания
— T1:Line#16 выходит из критической секции

В MSDN есть важная ремарка, касательно использования методов Pulse/Wait, а именно: Monitor не хранит информацию о состоянии, а значит, если вызов метода Pulse до вызова метода Wait может привести к дедлоку. Если такая ситуация возможна, то лучше использовать один из классов семейства ResetEvent.

Прошлый пример наглядно демонстрирует принцип работы методов Wait/Pulse класса Monitor, но все еще оставляет вопросы по поводу случаев, когда его стоит применять. Неплохим примером может быть такая реализация BlockingQueue<T>, с другой стороны реализация BlockingCollection<T> из System.Collections.Concurrent использует для синхронизации SemaphoreSlim.

ReaderWriterLockSlim

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

Идея: много потоков может читать, лишь один писать. Как только поток заявляет о желании писать, новые чтения не могут быть начаты, а будут ожидать завершения записи. Так же есть понятие upgradeable-read-lock, который можно использовать если в процессе чтения понимаете, о необходимости что-то записать, такой lock будет преобразован в write-lock за одну атомарную операцию.

В пространстве имен System.Threading так же есть класс ReadWriteLock, но он крайне не рекомендован для новой разработки. Slim версия позволит избежать ряда случаев, приводящих к дедлокам, к тому же позволяет быстро захватить блокировку, т.к. поддерживает синхронизацию в режиме spin-wait перед уходом в режим ядра.

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

Интерфейс класса ReaderWriterLockSlim прост и понятен, но его использование вряд ли можно назвать удобным:

Мне нравится оборачивать его использование в класс, что позволяет использовать его куда удобнее.
Идея: сделать методы Read/WriteLock что возвращают объект с методом Dispose, тогда это позволит использовать их в using и по количеству строчек это вряд ли будет отличаться от обычного lock.

Такой трюк позволит далее просто писать:

Семейство ResetEvent

К этому семейству я отношу классы ManualResetEvent, ManualResetEventSlim, AutoResetEvent.
Классы ManualResetEvent, его Slim версия и класс AutoResetEvent могут находится в двух состояниях:
— Взведенное (non-signaled), в этом состоянии все потоки, вызвавшие WaitOne, зависают, до перехода события в спущенное(signaled) состояние.
— Спущенное состояние(signaled), в этом состоянии отпускаются все потоки, зависшие на вызове WaitOne. Все новые вызовы WaitOne на событии в спущенном состоянии проходят условно-мгновенно.

Класс AutoResetEvent от класса ManualResetEvent отличает то, что он автоматически переходит во взведенное состояние, после того как отпустит ровно один поток. Если несколько потоков будут висеть в ожидании AutoResetEvent, то вызов Set отпустит лишь один произвольный, в отличии от ManualResetEvent. ManualResetEvent отпустит все потоки.

Рассмотрим пример работы AutoResetEvent:

В примере видно, что событие переходит во взведенное состояние(non-signaled) автоматически лишь отпустив поток, зависший на вызове WaitOne.

Класс ManualResetEvent, в отличии от ReaderWriterLock, не помечен как устаревший и не рекомендованный к использованию после появления своей Slim версии. Slim версию этого класса эффективно использовать для коротких ожиданий, т.к. оно происходит в режиме Spin-Wait, обычная версия подходит для долгих.

Кроме классов ManualResetEvent и AutoResetEvent также существует класс CountdownEvent. Этот класс удобен для реализации алгоритмов, где за частью, что удалось распараллелить, следует часть сведения результатов воедино. Такой подход известен как fork-join. Работе этого класса посвящена отличная статья, потому не буду здесь разбирать его подробно.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *