Разработка многопоточных приложений на .Net под Windows: основы Михаил Дутиков CUSTIS ноябрь 2011 года Почему именно я об этом рассказываю? Речь пойдет о следующем: - Потоки в Windows, кванты, приоритеты, переключения контекста, планировщик. - Потоки в .Net. Явное создание и взаимодействие. - Архитектура многопоточных приложений (уровень классов). Управление состоянием. Изоляция, неизменяемость, синхронизация. - Синхронизация: объекты ядра, пользовательского режима, гибриды. Сценарии использования. - Потокобезопасный код. Пример агента и потокобезопасной обертки. - Пул потоков. - Таймеры; - Исключения; Процессы и потоки в Windows -- Капитан Очевидность Windows многозадачна -- в ней работает несколько процессов одновременно. Грубо говоря, процесс -- экземпляр программы. Процессы изолированы, не имеют доступа к адресным пространствам друг друга. Внутри процессов всю работу выполняют потоки. Поток, грубо говоря, -виртуальный процессор в том смысле, что исполняет код программы на настоящем (невиртуальном) процессоре. Процессорное время: планировщик - Как Windows распределяет процессорное время между потоками? - Использует вытесняющую многопоточность. Типичный сценарий: поток, если нет потоков более приоритетных, но есть потоки с равным ему приоритетом, исполняет код на ядре процессора в течение кванта времени. Затем это ядро достается другому потоку -- происходит переключение контекста. Процессорное время: планировщик • Планировщик игнорирует процессы, раздает ядра только потокам. • Планировщик гарантирует: поток с самым высоким приоритетом, готовый к работе, сейчас выполняется. • Потокам с одинаковым приоритетом при прочих равных достается примерно одинаково процессорного времени. Приоритет Число от 0 до 31. Диапазон от 1 до 15 -- динамический. От 16 до 31 -- реального времени. У процесса есть "класс приоритета": Idle (4), Below Normal (6), Normal (8), Above Normal (10), High (13), Realtime (24). Приоритет потока задается относительно класса того процесса, которому поток принадлежит. Относительный приоритет потока, где P - приоритет процесса: Idle (нет в .Net), Lowest (P-2), Below Normal (P-1), Normal (P), Above Normal (P+1), Highest (P+2), Time-critical (нет в .Net). Приоритеты Приоритеты: использовать? Если ими вообще пользоваться, то лучше избегать классов High и Realtime. Высокоприоритетным потокам лучше всего давать короткие задачи. Лучше снижать приоритеты потокам, чем повышать. Когда Windows повышает приоритеты потокам (самые важные случаи) - всем потокам в системе, которые не работали 4 секунды или больше, приоритет временно повышается до 15. - поток, ждавший и дождавшийся события через объект ядра (о них -дальше), получает +1 к приоритету. Бонусы Windows дает на время. Как она их отбирает? Переключение контекста При переключении контекста Windows: • сохраняет содержимое регистров и другие данные в контекст потока (в объекте ядра ОС); • переключает виртуальное адресное пространство, если новый поток из другого процесса; • загружает контекст того потока, который вот-вот получит процессорное время (наполняет регистры процессора и проч.); Переключение контекста: чем плохо? - само по себе переключение -- это чистый оверхед; - остывают процессорные кэши, падает производительность; Для борьбы с проблемой №2 Windows назначает потоку "идеальный процессор" при создании и запоминает, на каком процессоре поток выполнялся в последний раз. У этих двух процессоров больше шансов получить этот же поток снова, чем у остальных. Кванты Чему равен квант? На клиентских версиях Windows (XP, 2000, Vista, 7) по умолчанию -- примерно 2 интервала между срабатываниями системного таймера. На серверных версиях ОС - 12 интервалов. На большей части железа этот интервал составляет 10-15 мс. Почему такая разница для клиентских и серверных систем? - 2 способа поменять квант - - подхачить реестр Windows утраивает квант (т.н. quantum boost) для потоков, принадлежащих foreground-процессу (если выбрано "программ" в настройках системы -- см. картинку). Где прочесть больше о планировщике Windows и потоках с точки зрения ОС Каковы ваши вопросы по вводной части? Многопоточные приложения: зачем? • более эффективное использование ресурсов компьютера; • повышение производительности; • повышение юзабилити (в UI); • более удобная программная модель (редко); Многопоточные приложения: почему нет? Их гораздо сложнее разрабатывать чем однопоточные: - у них более сложный control flow; - их отладка мало что дает в смысле поиска багов; - их сложнее тестировать; - состояние системы/компонента/класса труднее держать консистентным, если оно меняется больше чем одним потоком одновременно; - добавляют уникальные классы проблем -- race condition, deadlock и проч.; Потоки в .Net: знакомимся ближе Поток -- объект ядра ОС. Помимо прочего хранит контекст исполнения. Поток в .Net -- скромная обертка над этим объектом. Потоку выделен стек пользовательского режима -- 1 Мб. И стек режима ядра -- 12 Кб (32-битная Windows), 24 Кб (64-битная). Еще есть другие структуры... Состояния: Unstarted, Running, WaitSleepJoin, Suspended, Stopped, Aborted. Создание потока: явное (пример) Foreground-поток. 1 Мб стека. Потоки: уничтожение 4 способа уничтожить поток: - выход из метода потока; - необработанные исключения (finally-блоки из стека отрабатывают); - Thread.Abort(); - завершение процесса (background-потоки); Поток как объект Что можно делать с потоком, созданным явно - Start(); - Join(); - Abort(); // не лучшая идея в общем случае - Priority; - CurrentCulture; - CurrentUICulture; - IsThreadPoolThread; - IsBackground; - ... (остальное не рассматриваем) - Thread.Sleep(...); // зависит от точности таймера, нагрузки на систему - Thread.ResetAbort(...); // нужны права - Thread.CurrentThread; - ... (остальное не рассматриваем) Потоки: взаимодействие (пример с багом) Архитектура: управление состоянием в многопоточной среде Общее состояние часто неизбежно. Три главных приема для управления состоянием: - неизменяемое общее состояние; - изоляция; - синхронизация (потоки координируют свои действия в отношении общего состояния); Архитектура: управление состоянием, неизменяемость "Неизменяемые" объекты могут быть: - поверхностно неизменяемыми; - глубоко неизменяемыми; Также полезны условно неизменяемые типы. Архитектура: управление состоянием, изоляция - процессы; - домены; - соглашения; Пример изоляции. Агент. Его свойства: 1) состояние изолировано 2) общается с миром при помощи асинхронных сообщений 3) слабо связан с другими агентами. Пример агента сегодня будет в виде асинхронного логгера. Архитектура: управление состоянием, синхронизация Синхронизация почти неизбежна. Дальше -- подробнее о синхронизации. Синхронизация: условная классификация Синхронизация контроля (как вариант: поток А ждет, пока поток Б сделает что-то нужное потоку А). Синхронизация доступа к данным. Синхронизация: объекты ядра Windows, общие сведения Как ждать событий от других потоков? Busy wait? while (!predicate) { BusyWait; } Для длительного ожидания нужны вещи поэффективнее! Как работают объекты ядра? 2 состояния: сигнальное и несигнальное. kernelObject.WaitSignal(...); Синхронизация: объекты ядра Windows, общие сведения Объекты ядра: - хороши для длительных периодов ожидания; - могут использоваться для межпроцессной синхронизации; - не слишком быстры, тяжеловесны (не забывайте о Dispose!); - нечестны (не гарантируют FIFO); Синхронизация: объекты ядра Windows -WaitHandle Базовый класс для всех .Net- оберток к объектам ядра, служащим для синхронизации. На вызове Wait*(...) может блокироваться любое число потоков. * -- waitHandles: не больше 64 штук. Синхронизация. Объекты ядра. Мьютекс Mutex = Mutual exclusion. Именно это основной сценарий использования. Пример использования: Синхронизация. Объекты ядра. Мьютекс. Чиним race condition Синхронизация. Объекты ядра. Семафор Служит для ограничения доступа к пулу ресурсов. Дает одновременный доступ не больше чем N интересующимся (N указывается при создании семафора). Остальные ждут. Потокобезопасный счетчик. - WaitOne(...); - Release(...); Не привязывается к потокам, как мьютекс. Синхронизация. Объекты ядра. Семафор: пример Синхронизация. Объекты ядра. ManualResetEvent Когда сигналит, пропускает любое количество ждущих потоков. Метафора для сигнального состояния -- распахнутые ворота. Синхронизация. Объекты ядра. AutoResetEvent Когда сигналит, пропускает строго один ждущий поток. Метафора для сигнального состояния -- шлюз офиса CUSTIS в Архангельском пер. Синхронизация. Объекты ядра. События и баги Значительная часть багов синхронизации, что я исправлял в productionкоде разных команд, была связана с событиями. События не запоминают, сколько раз вы вызываете Set(...). Если вызываете Set(...), то точно не известно, в какой момент времени будут отпущены все потоки, ждущие события. Bottom line: лучше используйте ManualResetEvent для однократного ожидания (Set => отпускаем всех ждущих, Reset не трогаем). Синхронизация. Объекты ядра. Потоки и ожидание Не обязательно создавать поток только чтобы ждать какого-то события (потоки дороги). Пул потоков это отлично сделает за вас. См. ThreadPool.RegisterWaitForSingleObject(...). К пулу мы еще сегодня вернемся :) Синхронизация. Пользовательский режим - Interlocked-методы; - Thread.VolatileRead(...); [не сегодня] - Thread.VolatileWrite(...); [не сегодня] - SpinLock; [не сегодня] Синхронизация. Пользовательский режим: Interlocked-методы Операции типа Compare-And-Swap дают возможность совершить операцию, предполагающую атомарное, потокобезопасное чтение из ячейки памяти и запись в нее. Синхронизация. Пользовательский режим: Interlocked-методы Под капотом -- это специальные процессорные инструкции. Относительно недороги. Их стоимость зависит от сложности системы кэшей: может отличаться на порядки для компьютеров разных архитектур. Работают быстрее объектов ядра. Синхронизация. Пользовательский режим: Interlocked-методы (чиним race condition) Синхронизация. Гибридные конструкции (usermode/kernel mode) Новые в .Net FW 4 обертки над объектам ядра: ManualResetEventSlim, SemaphoreSlim. Чем они лучше прежних? Еще мы поговорим о: Monitor, ReaderWriterLockSlim, CountdownEvent. Синхронизация. Гибридные конструкции. Monitor Служит для организации блоков взаимного исключения, обозначения набора операций, которые должны быть выполнены атомарно (с точки зрения других потоков, использующих тот же монитор). Синхронизация. Гибридные конструкции. Monitor (чиним race condition) Синхронизация. Гибридные конструкции. Monitor • Потоки, которым не удалось попасть в "критический регион", ждут своей очереди в течение указанного в Monitor.Enter(...) интервала. По умолчанию ждут бесконечно. • Синхронизация кэшей -- другие потоки видят все изменения, но только если используют тот же монитор. • Ожидание: Spin + AutoResetEvent. • Зачем Monitor.Enter и Exit нужен объект -- экземпляр System.Object? Синхронизация. Гибридные конструкции. Monitor. Ключевое слово lock (пример) Синхронизация. Гибридные конструкции. Monitor. Хорошо известные косяки • Синхронизация на разных объектах = отсутствие синхронизации. • Синхронизация с монитором на Value-типах. • Синхронизация с монитором на Domain-neutral-объектах (typeof(Int32), интернированных строках, просто строках между доменами и др.). • Синхронизация с монитором по ссылке на текущий объект (this) или ссылке на что-то еще не инкапсулированное (+ MethodImplAttribute). • При использовании нескольких мониторов возможны дедлоки. Синхронизация. Гибридные конструкции. Monitor. Deadlock Синхронизация. Гибридные конструкции. ReaderWriterLockSlim Умеет выдавать разделяемые блокировки на чтение, эксклюзивные -- на запись. За счет этого может значительно повысить масштабируемость класса/компонента. - Блокировка на чтение (EnterReadLock...); - Блокировка на запись (EnterWriteLock...); - Upgradeable-блокировка (только 1 поток) -- EnterUpgradeableReadLock; Проблемы Предпочитает писателей (никак не настраивается). Используйте RWLS, если изменения данных происходят намного реже, чем эти данные читаются. Скорость. Не используйте старый ReaderWriterLock: - он медленнее; - имеет проблемы с дизайном; - может приводить к дедлокам; - отпускает блокировки при апгрейде, нарушая атомарность; Синхронизация. Гибридные конструкции. CountdownEvent Потокобезопасный счетчик, который находится в сигнальном состоянии, когда равен нулю, в несигнальном -- в остальных случаях. Сам по себе -- не объект ядра, хотя и называется event-ом. Синхронизация. Потокобезопасные классы (архитектура) • Что такое потокобезопасный класс? • Самый простой, надежный и корректный способ сделать потокобезопасный класс -- добавить критические регионы (на одной критической секции) во все методы, которые меняют состояние класса. Так вы сделаете изменения состояния атомарными, выстроите их в очередь. • Это будет абсолютно корректно. Хотя можно ускорить дела, повысить масштабируемость, на этом шаге часто имеет смысл остановиться. • Такой подход (вернее, его абстрактный эквивалент) называют грубым блокированием (coarse-grained). От него иногда переходят к точечному блокированию (fine-grained) и совсем редко избавляются от блокировок с ожиданием вообще (lock-free). Синхронизация. Потокобезопасные классы (архитектура) На переходе к точечному блокированию, показывает мой опыт, появляется много багов. Например. Пример точечного блокирования. Lock-free сегодня не рассматриваем. Пример потокобезопасного кода. Логирующий агент, обертка AsyncLoggerNaive. AsyncLoggerCopying. Потокобезопасная обертка. Синхронизация: что было и не- Каковы ваши вопросы о синхронизации, потокобезопасном коде, архитектуре? Пул потоков CLR - Существует потому, что потоки дороги; - Принимает задачи в очередь к исполнению на своих потоках; - Рабочие потоки и потоки для callback-ов IO-операций; - Один пул на экземпляр CLR (в большинстве случаев -на процесс); Сам управляет жизненным циклом своих потоков: - когда перестает справляться с нагрузкой, создает дополнительные потоки (не больше 1 каждые 500мс); - когда обнаруживает, что работы нет уже некоторое время, уничтожает свои потоки, которые ничего не делают; Отправка задачи в пул (пример) ThreadPool.QueueUserWorkItem( delegate { BigInteger factorial = 1; for (int i = 2; i < 15000; i++) { factorial *= i; } }); Когда-нибудь один из потоков пула доберется до задачи, которую мы поставили в очередь, и выполнит ее. (Мы не обязательно вынуждаем пул создать поток, у него их может быть достаточно. Все потоки пула -background.) Пул потоков Пул потоков: ожидание (пример) Как пул ждет? Ожидание завершения задачи Неудобно. Приходится писать вручную. Нет нужных абстракций! (О Task Parallel Library -- в другой раз). Пул потоков: еще факты • При нормальном завершении процесс не ждет окончания работ, поставленных в пул. • Необработанное исключение в любой из задач и callback-ов пула ведет к завершению процесса. • Задачи, поставленные в пул, будут выполнены в случайном порядке. • Состояние пула можно посмотреть при помощи команды !threadpool в SOS или WinDbg. Пул потоков: плохие идеи и не• Регистрирация ожидания мьютекса -- не лучшая идея. • Ставить максимальное количество потоков в пуле -- дурная практика (из релиза к релизу это число росло -- было 25, 250 рабочих потоков на ядро, в .Net FW 4 это зависит от платформы -- 1023 или 32768). Если вам нужно менять максимум, вы что-то делаете не так :). • Установка минимального количества потоков не работала до .Net FW 4 (в 3.5 и более ранних версиях до сих пор исправляется хотфиксом). Ее иногда имеет смысл выполнять. Таймер (пример) Пул потоков или явное создание потоков: когда лучше пул? • Пул потоков может ждать события за вас, не создавайте потоки только чтобы ждать. • Пул потоков хорошо подходит для больших числом, но не слишком продолжительных вычислительных задач. В целом подходит для использования при параллелизме (обсудим в следующий раз). • Не создавайте потоки явно только чтобы они большую часть времени висели на операциях ввода-вывода или в качестве задач в пуле. Используйте модель асинхронного программирования (еще не добрались до нее). • Создавайте поток явно: - если вы попробовали пул, он вам не подошел. - для продолжительных вычислительных задач. - если хотите управлять приоритетом потока. - если вам нужен foreground-поток. - из архитектурных соображений (к примеру, когда делаете агента). Пул потоков или явное создание потоков: когда создавать потоки явно? Создавайте поток явно: • • • • • если вы попробовали пул, он вам не подошел. для продолжительных вычислительных задач. если хотите управлять приоритетом потока. если вам нужен foreground-поток. из архитектурных соображений (к примеру, когда делаете агента), но помните: одна из самых больших архитектурных ошибок server-side программистов именно в создании и поддержании слишком большого числа потоков. Каковы ваши вопросы о пуле потоков? Как быть с исключениями? Маршалинг исключений. Пример. Как быть с исключениями? Что если исключений вызывающая сторона не ждет? Исключения и параллелизм. Итого Что еще? Модель асинхронного программирования (APM) и ввод-вывод в Windows. Асинхрония в архитектуре. Отменяемые задачи. Параллелизм, закон Амдала. Task Parallel library. ParallelLINQ. Серверная сторона: общие советы по использованию потоков. Debug и Release -- большая разница. Баги в многопоточных приложениях. Как ловить с гарантией? Как писать тесты для потокобезопасных классов? Типичные ошибки разработчиков многопоточных приложений (torn reads, недостаток синхронизации, missed wakeups, deadlock и алгоритм банкира, блокировки и завершение процесса, очереди блокировок, лавины и др.). Потокобезопасные коллекции и типы в .Net FW4. Producer/Consumer. Многопоточность в GUI (на примере WPF-приложения). Продвинутая синхронизация (за исключением Interlocked-методов), модели памяти и lock-free алгоритмы. Не поговорили об оптимизации, о code motion, о том, что разные компиляторы себе позволяют (и чего позволить не могут). Литература "CLR via C# 2nd Edition" What Every Dev Must Know About Multithreaded Apps Большое спасибо всем! Особенно -- Леше, Денису, Игорю и Олегу.