Andriy Shyrokoryadov

.Net developer, data scientist

Многопоточность - введение - вопрос №17 на собеседование C# / .NET

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

Одним из требований к современным приложениям является респонсивность. То есть независимо от того, что происходит с приложением, оно всегда должно реагировать на действия пользователя и, в некоторых случаях, информировать о своем состоянии, например показывая определенные сообщения в соответствующих местах графического интерфейса пользователя. Наверное, каждый из нас сталкивался с тем, что некоторые приложения в системе Windows иногда зависают, щелчки мыши на графическом интерфейсе или попытки ввода текста не помогают, приложение перестаёт отвечать. То есть становится не респонсивным. В некоторых, не совсем корректно сделанных приложениях, приложение может зависать при выполнении операций, которые требуют большего времени выполнения, например получение ответа от удаленной услуги или операции на базе данных, также часто операции с файлами могут длиться долго (в зависимости от размера файла). Программист в своей работе должен стремиться к тому, чтобы приложение всегда оставалось респонсивным, независимо от характера и длительности выполняемых операций. Для выполнения данной задачи служит многозадачность или многопоточность.

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

Иногда возникает необходимость принятия решения что выбрать для разделения работы приложения – процесс или поток. Да - да, в рамках одного приложения можно создавать не только потоки, но и процессы. Для принятия такого решения нам нужно понимать какие у них отличие и сходства. По факту отличий значительно больше, а сходство заключается в том, что и поток, и процесс служат для разделения работы.

  • поток следует выбрать для относительно легковесных операций, а процесс для «тяжелых» операций, которые можно «подогнать» под отдельное приложение;
  • каждый процесс имеет свое определенное место в памяти, а поток пользуется памятью процесса, в рамках которого они созданы;
  • коммуникация между отдельными процессами относительно медленная, по сравнению с коммуникацией между потоками в рамках одно процесса, который пользуются одинаковым пространством памяти (это следует из предыдущего пункта);
  • также из второго пункта следует, что у нескольких процессов нет общей памяти, а у потоков – есть. В случае потоков – это, может быть как преимуществом, так и недостатком, всё зависит от того, как мы будем рассматривать эту проблему.

В большинстве случаев в Вашей практике Вы скорей всего будет создавать новые потоки, а не процессы и в этом видео, а может также и в последующих видео, мы сконцентрируемся на вопросах связанных с созданием новых потоков. Однако я хотел бы привести Вам пример приложений, которые пользуются процессами. С этими приложениями Вы с большой долей вероятности знакомы. Это браузеры FireFox и Google Chrome. Если Вы запустите данное приложение и откроете несколько закладок с веб страницами, а потом запустите «Диспетчер задач» и посмотрите сколько у Вас запущенных приложений FireFox или Google Chrome, то скорей всего их будет ровно столько, сколько открытых закладок или больше. Дело в том, что в этих браузерах, каждая закладка открывается, как отдельный процесс. Скорей всего это сделано для того, чтобы в случае проблем с одной из веб-страниц, остальные страницы работали без проблем. То есть, если у Вас зависнет страница А, страницы Б и В по-прежнему будут работать.

В настоящее время большая часть компьютеров, если не все, обладают многоядерными процессорами, что в свою очередь позволяет выполнять операции в потоках параллельно. Означает ли это, что число одновременно созданных потоков может быть меньше или равно число ядер процессора? Нет, потоков может быть значительно больше, однако в данном случае уже будет иметь место конкуренция между потоками за процессорное время. То есть используя специальные алгоритмы время процессора будет разделено между потоками и процессор будет постоянно переключаться между потоками по чуть-чуть выполняя каждый их них. Время переключения между потоками настолько ничтожно (его обычно называют slice time), что как правило им пренебрегают и его не учитывают. В этом заключается явление конкуренции между потоками за процессорное время. С точки зрения обычного пользователя может казаться что все потоки выполняются одинаково и прогрессируют в равной степени, но по факту в определенную единицу времени, пусть это будет миллисекунда, выполняется только один поток только одним процессором.

Итак, приступим к рассмотрению потоков в языке C#. При использовании потоков мы скорей всего столкнемся с двумя проблемами – конкуренцией между потоками и синхронизацией между потоками.

Если речь идет о конкуренции, то здесь необходимо понимать, что у нас нет полного контроля в приложении над выполнением задач в потоке. Если мы создали поток с определёнными операциями и запустили его, это не означает что тут же началось выполнение этих задач. Это означает, что мы создали поток и «попросили» ОС выполнить его как можно скорее. Планировщик процессорного время через некоторое время (такты, миллисекунды) отдаст управление нашему потоку на некоторое время, но это не будет выполнено сразу же после запуска выполнения нашего нового потока. Представим ситуацию:

  • один метод, который изменяет один объект;
  • два потока работают с этим методом и с тем же объектом;
  • потоки запущены одновременно.

Первый поток начал изменять объект, и планировщик переключил управление на второй поток. Что в итоге мы имеем – второй поток получил метод с объектом который частично изменил свое состояние (в первом потоке) и может работать неправильно. То есть велика вероятность ошибки, а искать ошибки в многопоточном приложении – дело неблагодарное. Предыдущий пример — это пример конкуренции потоков за объект. Создавая такой код, необходимо понимать какие операции являются атомарными, а какие могут быть разделены и выполнены неодновременно. Например, выполнение следующей команды может быть разделено:

var i = 0;
var j = i++;

Представим ситуацию: между прибавлением 1 к значению переменной i и присваиванием этого значения переменной j, другой поток может изменить значение переменной i и к переменной j будет приписано совершенно другое значение, не то что мы ожидали. То есть эта операция не атомарная, её можно разделить на 2 операции: изменение значения и присваивание. Такие неатомарные операции в многозадачной среде являются источником ошибок.

Чтобы разрешить ситуацию в предыдущем примере необходимо использовать специальный синтаксис, связанный со статическими методами Interlocked.Increment и Interlocked.Decrement.

Так мы подошли ко второй проблеме – синхронизации потоков и взаимная работа потоков. В многопоточной среде, в которой все ресурсы общедоступны для каждого потока и их использование и изменение происходит практически одновременно, велика вероятность возникновения ошибок, которые тяжело отследить. Также при непродуманном программировании многопоточного приложения возможно возникновение таких негативных явлений, как race condition, live lock и dead lock. Естественно, в языке C# существует специальные конструкции целью которых является упрощение многопоточного программирования:

  • выражение lock;
  • мониторы;
  • специальные атрибуты;
  • специальный синтаксис для явного и неявного создания потоков;
  • специальные библиотеки для параллелизации вычислений;
  • специальные коллекции для работы в многопоточной среде;

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