Skip to content

Latest commit

 

History

History
72 lines (43 loc) · 21.9 KB

01-01-Threads-Introduction.md

File metadata and controls

72 lines (43 loc) · 21.9 KB

Потоки и планирование потоков

Что такое поток? Давайте дадим краткое определение. По своей сути поток это:

  • Средство параллельного относительно других потоков исполнения кода;
  • Имеющего общий доступ ко всем ресурсам процесса.

Очень часто часто слышишь такое мнение, что потоки в .NET — они какие-то абсолютно свои. И наши .NET потоки являются чем-то более облегчённым чем в Windows или Linux. Но на самом деле потоки в .NET являются самыми обычными потоками операционной системы (хоть thread id и скрыто так, что сложно достать). И если Вас удивляет, почему я буду рассказывать не-.NET вещи в книге про многопоточку в .NET, скажу вам так: если нет понимания уровня операционной системы, можно забыть о хорошем понимании того, как и почему именно так работает код: почему мы должны ставить volatile, использовать Interlocked и SpinWait. Дальше обычного lock дело не уйдёт. И очень даже зря: ведь при этом можно упустить множество интересных техник. Поэтому давайте коротко пробежимся по этому слою.

Параллельное и псевдопараллельное исполнение кода

По сути поток — это средство эмуляции параллельного исполнения относительно других потоков. Почему эмуляция? Потому, что поток как бы странно и смело это ни звучало — это чисто программная вещь, которая идёт из операционной системы. А операционная система создаёт этот слой эмуляции для нас. Процессор при этом о потоках ничего не знает вообще. Задача процессора — последовательно исполнять код. Поэтому с точки зрения процессора есть только один поток: последовательное исполнение потока команд. А для того чтобы возникли потоки необходимо это эмулировать. "Но как же так?", -- скажите вы, -- "во многих магазинах и на различных сайтах я вижу запись "Intel Xeon 8 ядер 16 потоков". Говоря по-правде это -- либо скудность в терминологии либо -- чисто маркетинговый ход. На самом же деле внутри одного большого процессора есть в данном случае 8 ядер и каждое ядро состоит из двух логических процессоров, которые это ядро эмулирует. Такое поведение доступно при наличии в процессоре технологии Hyper-Threading, когда каждое ядро эмулирует поведение двух процессоров (но не потоков). Делается это для повышения производительности.

[>]: По сути поток — это средство эмуляции параллельного исполнения относительно других потоков

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

Любая программа не так часто что-то делает на процессоре непрерывно (CPU-bound код). Чаще всего она взаимодействует с окружающим её миром: различным оборудованием (I/O bound код). Это и работа с жёстким диском и вывод на экран и работа с клавиатурой и мышью, взаимодействие с сетью. Поэтому чтобы процессор не простаивал, пока оборудование сделает то, чего хочет от него программа, поток можно на это время установить в состояние ожидания сигнала от операционной системы, чтобы оборудование сделало то, что от него просили. А в это время можно запланировать в работу другие потоки. Простейший пример этого -- вызов метода Console.ReadKey().

[>]: У нас состояние ожидания называется блокировкой. Имея ввиду, что блокируется ожидающий поток, но чаще всего не даётся никаких комментариев, что это значит. А значит это то, что поток более не участвует в планировании планировщиком потоков. Т.е. полностью исключается и не влияет на производительность: как будто и нет его.

Давайте порассуждаем. Вот, к примеру, у вас процессор с одним ядром. Простенький такой процессор. Однако на нём опять же для простоты изначально существует всего один поток. Этот поток, исполняясь, доходит до места, в котором он запрашивает у операционной системы создание ещё одного потока. Ядро одно, процессор ничего не знает о том как исполнять код параллельно с другим на другом ядре. Однако, если мыслить "по-человечески", нашим восприятием, то скорость исполнения команд на процессоре слишком велика чтобы это заметить человеку, правильно? Значит можно разделить исполнение первого потока и второго потока таким образом, что они будут по очереди исполняться на одном ядре: друг за другом по чуть-чуть и для пользователя это будет выглядеть как параллельная работа.

Как это организовать, если сами вы не просите операционную систему прервать исполнение вашего кода? Мы же не пишем вот так:

SomeMethod1();

OS.AskToSwitchToAnotherThread();

SomeMethod2();

OS.AskToSwitchToAnotherThread();

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

Знание №1. Состояние исполнения кода процессором определяют регистры процессора. Именно они хранят информацию: где процессор находится в потоке команд и над какими данными происходит действия. Также определенные регистры задают для процессора знания, какие области памяти отвечают за текущую модель виртуальной памяти: процессы (программы) же изолированы между собой и у каждого своя изолированная область. Поэтому чтобы "переключить" исполнение процессором кода одного потока на другой, необходимо сохранить где-то значения всех регистров для текущего потока, условного "первого", а потом -- восстановить из другого места значения регистров другого потока, ранее таким же образом "поставленного на паузу". Таким же образом произойдёт смена изолированной области памяти если предыдущий и последующий потоки относились к разным процессам: её положение также определяется регистрами. Назовём такую структуру Контекстом Потока.

Знание №2. У процессора есть специальные часы: системный таймер. Этот таймер выдаёт импульсы на процессор с определенной частотой, вызывая при этом код планировщика потоков операционной системы (написать такое на языке Assembler проще, чем код нового сервиса на .NET). Вызов производится при этом таким образом, будто это вы сами вызвали код планировщика, а вовсе не операционная система (с некоторыми не важными здесь оговорками). Задача этого метода -- сохранить положение текущего места исполнения потока и модели памяти, т.е. значения всех регистров в Контекст Потока. По этой структуре если её восстановить обратно в регистры вы полностью восстановите этот поток: где процессор исполнял код, с какими локальными переменными тот работал и прочее-прочее-прочее. Поэтому задача планировщика работы потоков операционной системы - по кругу исполнять код каждого из потоков, но по чуть-чуть: чтобы снаружи это выглядело как будто всё работает параллельно. Это "чуть-чуть" у различных операционных систем может быть разным: от 10 мс до примерно 120 мс в общей ситуации.

Например, в серверных операционных системах подразумевается, что на одном сервере крутится мало серверного программного обеспечения и потому каждому потоку выделяются длинные кванты: по 100мс на поток. И именно поэтому нет смысла создавать большое количество активных потоков, работающих в параллель (CPU bound). Скорость от этого выше не станет.

Другое дело если у вас на процессоре много ядер. Каждое из ядер исполняет код истинно параллельно: это же физические ядра, а не эмуляция от операционной системы. Именно поэтому при росте количества ядер всё работает сильно быстрее. Однако, конечно же, когда операционная система планирует 100500 потоков на 32 ядрах, она их делит между потоками как и в случае одного ядра.

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

Простой вывод, который можно пока что сделать: поскольку системный таймер может сработать в абсолютно любое время и прервать код потока на абсолютно любое время (всё зависит от количества потоков на одном ядре), код вашей программы может прерваться в абсолютно любом месте на время от 20 мс до... неизвестно, скольки. Я знаю плачевную ситуацию, когда на одном ядре работало слишком много активных потоков и тогда каждый из них работал всего 100 мс каждые 10-15 секунд. Как результат -- летели таймауты по сети просто от того, что код работал без своих задержек, но его обрывала операционноая система чтобы дать другим потокам "отработать своё". Едва ли можно говорить о том, что здесь код работал быстрее, чем если бы он работал в один поток. Поэтому в дополнение очень важно понимать, что есть CPU-bound код, а есть I/O-bound.

CPU-bound код, исполняясь не обращается ни к каким устройствам: ни к сети ни к диску ни к чему-либо ещё. Он либо математический либо обрабатывает какие-то графы или парсит данные. В общем такой код использует только процессор.

I/O bound код, исполняясь, обращается к различным устройствам и потому постоянно находится с точки зрения CPU в блокировке: ожидая некоторое устройство либо сигнал от другого потока. С точки зрения операционной системы это прекрасный шанс дать другому потоку выполнить код: текущему же процессор пока не нужен. А потому на время блокировки поток исчезает из планирования и на время такого исчезновения CPU не тратит ни единого такта на такие потоки. Именно для этого блокировки и необходимы. Гляньте в "Диспетчер задач" ОС Windows или в htop ОС Linux. Потоков в системе может быть несколько тысяч, но одновременно активных, рабочих потоков не так много. Команда htop у меня выдаёт несколько сотен потоков и около 10 активных.

Выводы

Подводя черту можно сказать следующее:

  • многопоточный код может работать не только на многоядерных системах. Слабенький, одноядерный процессор тоже может исполнять многопоточный код. Но не потому что это он так умеет делать, а потому, что это функция операционной системы;
  • операционная система использует системный таймер процессора, который, подавая напряжение на ножку процессора (грубо говоря. сейчас таймер интегрирован с процессором) запускает код планировщика потоков;
  • в зависимости от операционной системы и режима её работы планировщик потоков операционной системы меняет активный поток: меняет Контекст Потока, куда помимо прочих регистров входят пара CS:EIP, хранящая указатель на текущую исполняемую инструкцию и регистры GDTR, LDTR и IDTR, указывающие на места хранения специальных таблиц, отвечающие в том числе за дерево менеджмента виртуальной памяти;
  • т.к. программа чаще всего работает с оборудованием: ожидает подключения к порту, скачивает данные, отправляет ответ, ожидает ввода пользователя и прочие подобные операции, множество потоков необходимо чтобы ждать ответа от оборудования, который может идти очень долго;
  • пока одни потоки ждут оборудования, другие -- работают. Поэтому в один момент времени работает не так много потоков и программа вполне активно может работать, используя сотню потоков и 8 ядер.
  • одако если поток не обращается к оборудованию, он вырабатывает полностью отданный ему квант. Т.е. от 20 мс до 120 мс в зависимости от операционной системы. Если каждый поток ядра CPU вырабатывает свой квант, это и есть 100% загрузка CPU.

Поэтому:

  • использование блокировки потока -- прекрасная техника, которая позволяет работать всем потокам максимально эффективно;
  • исполнение кода может быть прервано в абсолютно любой момент. Например посередине исполнение кода: a = b;, когда b уже считано, а вот в a ещё не записали;
  • прерванный код может продолжить исполнение в любой момент: как через 20мс, так и через 1000 мс, т.е. через 1 секунду. Всё зависит от типа ОС и от того, сколько активных потоков в системе;
  • поэтому не стоит закладываться на короткие таймауты. Они могут сработать на ровном месте: просто от того, что система была нагружена чем-то ещё;
  • именно поэтому нельзя чтобы на одном сервере работало много сервисов/какого-то другого ПО. Это создаст риски увеличения времени разрыва исполнения потоков одних сервисов потоками других чтобы обеспечить псевдопараллельное исполнение кода.
  • также поэтому нет смысла делать много CPU-bound потоков. Код от этого работать быстрее не станет: эти потоки будут делить те же самые ядра.