Andriy Shyrokoryadov

.Net developer, data scientist

Многопоточность - синхронизация потоков - вопрос №19 на собеседование C# / .NET

Текст к видео "Многопоточность - синхронизация потоков" на канале YouTube

Как уже было сказано в предыдущих видео в многопоточной среде существует постоянная конкуренция между потоками за ресурсы (процессорное время) и данные, над которыми работают потоки. Если такие данные одновременно изменяются в нескольких потоках, то очень тяжело получить истинное состояние таких данных. Возможно в момент получения данных, они уже не актуальны, так как один из существующих потоков их уже изменил. То есть возникает требование, чтобы в ситуации, когда один из потоков изменяет или получает данные, другие потоки к этим данным не имели доступа. Перефразируя вышесказанное: в любой момент времени к определённым данным должен иметь доступ только один поток из всего множества потоков в приложении. Я представлю вам в данном видео несколько возможностей достижения этой цели в языке C#.

Прежде чем я начну своё объяснение и приведу какие-либо примеры из кода, я хотел бы чтобы мы представили железную дорогу и поезда. Пусть железнодорожный путь – это будут наши данные, а поезда — это потоки в приложении. На каждый путь в определенную единицу времени может въехать один поезд. Однако если нет никакой регулировки, поезда могли бы сталкиваться на одном пути или наезжать, друг на друга постоянно создавая аварийные ситуации. Для регулировки движения поездов на железной дороге используются семафоры. Семафор указывает машинисту может ли он въехать на данный путь на своем поезде. Если на пути есть другой поезд, то семафор показывает запрещающий сигнал и въезд невозможен. Если путь свободен – то семафор покажет разрешающий сигнал и движение, т.е. въезд на путь, разрешено.

То есть в зависимости от сигнала семафора, поездам либо разрешен въезд на путь или нет – другими словами могут ли наши потоки получить доступ к данным или нет. Если с данными работает поток А, то семафор находится в положении «закрыто», и, скажем, поток Б (и любой другой поток) к этим данным не получит доступ. Если с нашими данными не работает не один из потоков, то наш семафор находится в положении «открыто».

А теперь вернемся к программированию. Всё методы синхронизации потоков и их доступа к данным, о которых я планирую сегодня рассказать, основаны на принципе действия семафора. Отличаются они синтаксисом, а также каждый их этих методов имеет свою специфику.

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

private object _syncObject = new object();
public void MethodWithLock()
{
	//код перед словом lock
	lock(_syncObject)
	{
	//код в критической секции
	//к данной секции имеет доступ только один поток в каждый момент времени
	}
	//код после слова lock
}

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

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

  • если мы используем слово lock довольно часто, то скорей всего наше многопоточное приложении, которое по своей идее должно быть быстрым, будет медленным. Ничего удивительного. Из-за избытка lock наш код будет больше похож на синхронный код, где каждый поток большую часть времени проводит в ожидании на освобождение очередного lock’a.
  • Еще один важный момент. Не рекомендуется использовать в качестве объекта-параметра ключевого слова lock статический объект. Я еще не выпустил видео на тему статических классов, свойств и полей. Я планирую это сделать в ближайшем будущем – не забудьте подписаться на канал чтобы увидеть это видео. Итак, что касается статического объекта-параметра, то его использование очень снизит производительность нашего кода. Это следует из свойств статических членов. На данный момент просто запомните, что lock(staticObject) – это очень, очень плохая идея. А когда мы будем разговаривать о статических членах, то мы разберем эту ситуацию, и всё станет на свои места.

Если посмотреть, что из себя представляет ключевое слово lock технически, то можно сказать, что это обёртка над специализированным классом Monitor, который определен в пространстве имен System.Threading. Когда мы декларируем инструкции в рамках lock’а, то на самом деле компилятор создаёт 2 инструкции: Monitor.Enter(syncObject) и Monitor.Exit(syncObject). Использование ключевого слова lock просто упрощает синтаксис языка, так как класс Monitor под прикрытием слова lock используется довольно часто.

Ниже пример кода функциональности lock, но без слова lock:

private object _syncObject = new object();
public void MethodWithLock()
{
	//код перед словом критической секцией кода
	Monitor.Enter(_syncObject);
	//код в критической секции
	//к данной секции имеет доступ только один поток в каждый момент времени
	Monitor.Exit(_syncObject);
	//код после критической секции
}

Ключевое слово lock работает в рамках одного приложения или процесса, так называемого AppDomain. Если у вас возникла необходимость использовать что-то подобное, но в пространстве нескольких приложений, то есть можно сказать в пространстве целой операционной системы, то необходимо использовать класс Mutex. Данный класс также является частью пространства имён System.Threading. Также как и в случае класса Monitor / lock есть 2 метода для начала и конца критической секции: WaitOne() / WaitAny() / WaitAll() и ReleaseMutex(). Естественно, если критическая секция находится только в рамках одного приложения, то не следует использовать Mutex – это было бы использованием тяжелой артиллерии чтобы убить муху. Использование в данном случае функциональности Monitor / lock будет достаточно.

Иногда у Вас есть необходимо сделать так, чтобы доступ к критической части был не только у одного потока, а у нескольких. В этом случае вам необходим класс Semaphore, который также определен в пространстве имен System.Threading. Кстати, обратите внимание на название – напоминает пример с железнодорожными путями, использованный в начале лекции. Данный класс работает подобно классу Mutex, но позволяет определить сколько потоков могут иметь одновременный доступ к критической секции. Методы для входа и выхода из критической секции такие же как в случае класса Mutex: WaitOne() / WaitAny() / WaitAll() и ReleaseMutex(). Количество потоков, имеющих доступ к критической секции, указывается в конструкторе класса Semaphore.

Подведем итоги:

  • Lock / Monitor – работает в рамках приложения, в определённый момент времени к критической секции имеет доступ только один поток.
  • Mutex - работает в рамках операционной системы, в определённый момент времени к критической секции имеет доступ только один поток.
  • Semaphore - работает в рамках операционной системы, в определённый момент времени к критической секции имеет доступ несколько потоков, их количество определено в конструкторе класса Semaphore.

Если мы обратим внимание на простые переменные, а не код в критической секции, то мы можем посредством атрибутов обозначить переменные, которые для каждого потока будут свои. Об атрибутах я уже говорил в одном из моих видео. Если мы обозначим переменную, атрибутом ThreadStatic, то каждый поток будет иметь свой экземпляр такой переменной. Например, для каждого потока будет свой экземпляр переменной x:

[ThreadStatic]
private static int x

Если в потоке А переменная х будет изменена, то это не будет иметь никакого влияние на переменную х в потоке Б. Каждый поток имеет свой экземпляр переменной. Использование данного атрибута не совсем подпадает в категорию синхронизации потоков, но я все равно хотел сказать пару слов об использовании данного атрибута, так как это полезно знать и на собеседование, и в практической деятельности.