Часто различные потоки в приложении используют разделяемые (общие для всех) ресурсы. Например, несколько потоков могут использовать один и тот же файл для записи лога или несколько потоков используют список List
для чтения/записи элементов и так далее. Как только два и более потоков обращаются к одному и тому же ресурсу приложения, у них возникает конкуренция и предугадать в какой последовательности сработают потоки — не возможно. Итог работы будет зависеть от самой операционной системы и того, как система будет выделять ресурсы потокам. Чтобы поведение многопоточного приложения было предсказуемым нам необходимо каким-либо образом разрешить конкурентный доступ потоков к общим ресурсам. Для этого используются различные способы синхронизации потоков
Пример работы нескольких потоков
Рассмотрим следующий пример:
int x; for (int i = 1; i < 6; i++) { Thread thread = new(Func) { Name = i.ToString() }; thread.Start(); } void Func() { x = 0; for (int i = 0; i < 10; i++) { x++; Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}"); Thread.Sleep(100); } }
В этом примере создаются пять потоков, каждый из которых получает собственное имя и, затем, выполняет метод Func()
в котором обращается к общей для всех потоков переменной x
, увеличивая её значение на 1
. По сути, мы ожидаем того, что каждый поток от 1 до 5 выведет в консоль значение x
от 1 до 10. По факту же — мы не можем угадать в какой последовательности будут выводиться значения x
, так как очередность выполнения функции потоками определяет за нас операционная система. Чтобы наши потоки работали так как мы того ожидаем, мы может синхронизировать их работу.
Оператор lock
Оператор lock
имеет следующий синтаксис:
lock (x) { // Your code... }
Основная идея использования оператора lock
заключается в том, что блок кода заключенный в этот оператор в один момент времени может выполняться только одним потоком (т.н. идея критической секции). Остальные потоки будут ожидать пока текущий поток не выполнит код и блокировка не будет снята. При этом, в качестве x
у оператора lock
может выступать только объект ссылочного типа.
Попробуем переписать наш пример следующим образом:
int x; object locker = new object(); for (int i = 1; i < 6; i++) { Thread thread = new(Func) { Name = i.ToString() }; thread.Start(); } void Func() { lock (locker) { x = 0; for (int i = 0; i < 10; i++) { x++; Console.WriteLine($"Поток {Thread.CurrentThread.Name} вывел число {x}"); Thread.Sleep(100); } } }
Теперь, если запустить приложение, то мы увидим, что каждый поток последовательно выводит значения x
от 1 до 10. Здесь следует отметить следующий момент:
lock
может выступать любой объект ссылочного типа, НЕ СТОИТ использовать в операторе ключевое слово this
.То есть, в нашем случае, вот такая конструкция lock
сработала бы без проблем (при условии, что мы работаем с не статическими методами):
lock (this) { <--тут выполнение кода--> }
но таким же образом (с использованием this
) могут одновременно происходить блокировки и в других частях программы, что может привести к взаимному блокированию нескольких потоков и программа «зависнет». Поэтому лучше потратить несколько секунд времени и одну строку кода и определить для оператора lock
свой объект-заглушку.
Смысл использования критических секций
В целом, идея использования lock
показана в примере выше — мы запрещаем всем прочим потокам работать с общим ресурсом до тех пор, пока текущий поток не выйдет из критической секции. В связи с этим, может возникнуть резонный вопрос — какой смысл использовать критические секции, если они тормозят работу всех потоков? Если одна задача выполняется 1 секунду, то 10 таких задач в 10 потоках тоже будут выполняться тоже за 1 секунду, а, используя lock
мы, фактически, возвращаемся к однопоточной схеме и ждем выполнение всех потоков 10 секунд. На первый взгляд, может показаться именно так, но не все так просто.
Во-первых, использование lock
(критических секций) дает нам гарантию того, что состояние общего ресурса не будет нарушено и потоки отработают так, как мы того от них ожидаем. Например, один поток пишет в список имена файлов из директории, а второй — выводит этот список на экран. Если потоки не синхронизировать, то может случится ситуация, когда второй поток выведет не полный список файлов, т.е. выдаст недостоверную информацию, в то время, как первый поток будет продолжать работать, но уже вхолостую (второй-то уже всю свою работы выполнил — список на экран вывел).
Во-вторых, мы можем «завернуть» в lock
не весь код метода, а только его критическую часть, т.е. ту, в которой используется разделяемый ресурс. В этом случае, потери времени, связанные с синхронизацией будут меньше и те же 10 однотипных задач могут выполняться не 10, а, скажем, 5 или 3 секунды. Например, каждый поток скачивает из сети файлы различного объема и увеличивает счётчик полученных файлов на 1 при каждой удачной загрузке и выводит имя файла на экран. Загрузку файла из сети можно не блокировать — у каждого потока свой файл, а вот наращивание счётчика на 1 и вывод на экран можно «завернуть» в lock
.
Итого
Сегодня мы познакомились с тем, как можно организовать синхронизацию нескольких потоков в приложении с использованием оператора lock
. На этом тема синхронизации потоков не завершается и в следующих разделах мы рассмотрим другие способы синхронизации.