Потоки та файли. Як простіше вчинити в цьому випадку

Розглянувши методи стиснення, об'єднання, кешування та створення паралельних з'єднань, розумно було б зайнятися наступним питанням: Яка частина сторінки має завантажуватися разом із основним HTML-файлом, а яка – лише із зовнішніми файлами?

Було зібрано тестове оточення як однієї сторінки, на яку застосовані різні оптимізаційні техніки (заодно було отримано реальне прискорення для завантаження довільної сторінки і показано, як ці техніки реально впливають швидкість завантаження сторінки).

Крім того, було проведено теоретичні викладки для визначення оптимального розподілу завантаження на стадіях з урахуванням усіх аспектів.

Реальна ситуація

Рис. 29. Діаграма завантаження (незміненого) сайту WebHiTech.ru

Основна ідея варіації потоку завантаження полягала у створенні мінімальної кількості білих місць на діаграмі завантаження. Як видно із рис. 29, близько 80% при завантаженні сторінки становлять простої з'єднань (природно, що цей графік не відображає реальне завантаження відкритих у браузері каналів завантаження, проте, при уточненні картини ситуація практично не змінюється). Паралельні завантаження починаються лише після проходження «вузького місця», яке закінчується (в даному випадку) після попереднього завантаження сторінки – після CSS-файлу.

Для оптимізації швидкості завантаження нам потрібно зменшити кількість файлів (вертикальні стрілки), що завантажуються паралельно, і зрушити їх максимально вліво (горизонтальна стрілка). Зменшення "білих місць" (фактично, зменшення простою каналів завантаження), за ідеєю, має збільшити швидкість завантаження за рахунок її розпаралелювання. Давайте подивимося, чи це так і як цього досягти.

Крок перший: проста сторінка

Спочатку бралася звичайна сторінка, на якій використовувалося лише gzip-стиск HTML-файла. Це найпростіше, що можна зробити для прискорення завантаження сторінки. Ця оптимізація бралася за основу, з якою порівнювалося все інше. Для тестів препарувалася головна сторінка конкурсу WebHiTech (http://webhitech.ru/) з невеликою кількістю додаткових картинок (щоб було більше зовнішніх об'єктів і розмір сторінки збільшувався).

На самому початку (head) сторінки заміряється початковий час, а за подією window.onload (зауважимо, що тільки по ньому, бо тільки воно гарантує, що вся сторінка повністю знаходиться в клієнтському браузері) - кінцеве, потім обчислюється різниця. Але це дуже простий приклад, перейдемо до наступних кроків.

Крок другий: зменшуємо зображення

Для початку мінімізуємо всі вихідні зображення (основні прикладні техніки вже були освітлені у другому розділі). Вийшло досить смішно: сумарний розмір сторінки зменшився на 8%, і швидкість завантаження зросла на 8% (тобто вийшло пропорційне прискорення).

Додатково з мінімізацією картинок було зменшено таблицю стилів (через CSS Tidy) і сам HTML-файл (прибрано зайві прогалини та переклади рядків). Скриптів на сторінці не було, тому загальний час завантаження не змінився. Але це ще не кінець і ми переходимо до третього кроку.

Крок третій: все-в-одному

Можна використовувати data:URI і впровадити всі зображення у відповідні HTML/CSS-файли, зменшивши таким чином розмір сторінки (за рахунок gzip-стиснення, за великим рахунком, тому що таблиця стилів перед цим не стискалася) ще на 15%, проте , час завантаження при цьому зменшилося всього на 4% (при включеному кешуванні, зменшилася кількість запитів з 304-відповіддю). При завантаженні сторінки вперше покращення набагато стабільніші: 20%.

CSS-файл, звичайно, теж був включений в HTML, тому при завантаженні всієї сторінки здійснювався лише один запит до сервера (для відображення цілої сторінки з кількома десятками об'єктів).

Крок четвертий: нарізаємо потік

Можна спробувати розподілити початковий монолітний файл на кілька (5- 10) рівних частин, які потім збиралися і впроваджувалися у document.body.innerHTML. Тобто. сам початковий HTML-файл дуже малий (фактично містить тільки передзавантажувач) і завантажується дуже швидко, а після цього стартує паралельне завантаження ще безлічі однакових файлів, які використовують канал завантаження максимально щільно.

Однак, як показали дослідження, витрати на XHR-запити та складання innerHTML на клієнті сильно перевершують виграш від такого розпаралелювання. У результаті, сторінка завантажуватиметься в 2-5 разів довше, розмір при цьому змінюється не сильно.

Можна спробувати використовувати замість XHR-запитів класичні iframe, щоб уникнути частини витрат. Це допомагає, але не дуже. Сторінка все одно завантажуватиметься в 2-3 рази довше, ніж хотілося б.

І трохи до питання застосування фреймів: часто найчастіше використовувані частини сайту роблять саме на них, щоб знизити розмір даних, що передаються. Як уже згадано вище, основна частина затримок відбувається через велику кількість зовнішніх об'єктів на сторінці, а не через розмір зовнішніх об'єктів. Тому на даний момент ця технологія далеко не така актуальна, як у 90-ті роки минулого століття.

Також варто згадати, що при використанні iframe для навігації по сайту виникає проблема оновлення цієї самої навігації (наприклад, якщо ми хочемо виділити якийсь пункт меню як активний). Коректне вирішення цієї проблеми вимагає від користувача увімкненого JavaScript, і воно досить нетривіальне з технічного боку. Загалом, якщо без фреймів можна обійтися при проектуванні сайту, то їх не потрібно використовувати.

Крок п'ятий: алгоритмічне кешування

Проаналізувавши ситуацію з першими трьома кроками, ми бачимо, що частина прискорення може бути досягнута, якщо надати браузеру можливість самому завантажувати зовнішні файли як окремі об'єкти, а не як код JSON, який потрібно якось перетворити. Додатково до цього спливають аспекти кешування: адже швидше завантажити половину сторінки, а для другої половини перевірити запити зі статус-кодами 304, що об'єкти не змінилися. Завантаження всієї сторінки клієнтом вперше у разі буде трохи повільніше (звісно, ​​рішення із цього приводу залежатиме від кількості постійних користувачів ресурсу).

В результаті вдалося зменшити час завантаження ще на 5%, підсумкове прискорення (у разі повного кешу) досягло 20%, розмір сторінки зменшився на 21%. Можливе винесення не більше 50% від розміру сторінки завантаження зовнішніх об'єктів, при цьому об'єкти повинні бути приблизно рівного розміру (розбіжність не більше 20%). У такому разі швидкість завантаження сторінки для користувачів з повним кешем буде найбільшою. Якщо сторінка оптимізується під користувачів із порожнім кешем, то найкращий результат досягається лише при включенні всіх зовнішніх файлів у вихідний HTML.

Підсумкова таблиця

Нижче наведено всі результати оптимізації окремої взятої сторінки. Завантаження тестувалося на з'єднанні 100 Кб/с, загальне числопервісних об'єктів: 23.

Номер кроку

Опис

Загальний розмір (кб)

Час завантаження (мс)

1 Звичайна сторінка. Нічого не стисло (тільки html віддається через gzip) 63 117
2 HTML/CSS файли та картинки мінімізовані 58 108
3 Один-єдиний файл. Зображення вставлені через data:URI 49 104
4 HTML-файл паралельно завантажує 6 частин з даними та збирає їх на клієнті 49 233
4.5 HTML-файл завантажує 4 iframe 49 205
5 Варіант #3, тільки JPEG-зображення (приблизно однакові за розміром) винесені у файли та завантажуються через (new Image()).src у head сторінці 49 98

Таблиця 5. Різні способи паралельного завантаження об'єктів на сторінці

Крок шостий: балансуємо стадії завантаження

Отже, як найкраще балансувати завантаження сторінки між її стадіями? Де «золота середина», що забезпечує оптимум завантаження? Почнемо з припущення, що ми вже виконані всі поради щодо зменшення обсягу даних. Це можна зробити завжди, це досить просто (у більшості випадків потрібні лише невеликі зміни у конфігурації сервера). Також припустимо, що статика віддається вже з заголовками, що кеширують (щоб повертати 304-відповіді в тому випадку, якщо ресурсний файл фізично не змінився з моменту останнього відвідування).

Що далі? Подальші дії залежить від структури зовнішніх файлів. При великій (більше двох) числі файлів, що підключаються до сторінки, необхідно об'єднати файли стилів та файли скриптів. Прискорення попереднього завантаження сторінки буде наявним.

Якщо обсяг скриптів навіть після стиснення досить великий (більше 10 Кб), варто їх підключити перед закриваючим., або взагалі завантажувати по комбінованій події window.onload (динамічного завантаження скриптів присвячено початок сьомого розділу). Тут ми, фактично, переносимо частину завантаження з другої стадії до четвертої, прискорюється лише «візуальне» завантаження сторінки.

Загальна кількість картинок має бути мінімальною. Однак тут теж дуже важливо рівномірно розподілити їх обсяг по третій стадії завантаження. Досить часто одне зображення в 50-100 Кб гальмує завершення завантаження, розбиття його на 3-4 складові здатне прискорити загальний процес. Тому при використанні великої кількості фонових зображень краще розбивати їх на блоки по 10-20, які будуть завантажуватись паралельно.

Крок сьомий: балансуємо кешування

Якщо все ж таки на сторінці присутні більше 10 зовнішніх об'єктів у третій стадії (картинок та різних мультимедійних файлів), тут вже варто вводити додатковий хост для збільшення кількості паралельних потоків. І тут витрати на DNS-запрос окупляться зниженням середнього часу встановлення з'єднання. 3 хоста варто вводити вже після 20 об'єктів тощо. Усього не більше 4 (як показало дослідження робочої групи Yahoo! після 4 хостів витрати, швидше за все, зростуть, ніж знизяться).

Питання, скільки обсягу сторінки включати в сам HTML-файл (коду у вигляді CSS, JavaScript або data: URI), а скільки залишати на зовнішніх об'єктах, вирішується дуже просто. Баланс у разі приблизно дорівнює співвідношенню кількості постійних і одноразових відвідувань. Наприклад, якщо 70% користувачів заходять на сайт протягом тижня, то приблизно 70% сторінки має бути у зовнішніх об'єктах і лише 30% — у HTML-документі.

Коли сторінку мають побачити лише один раз, логічно буде включити все до самої сторінки. Однак тут уже набувають чинності психологічні моменти. Якщо у середнього користувача сторінка при цьому завантажуватиметься більше 3-4 секунд (враховуючи час на DNS-запит та з'єднання з сервером), то буде необхідно розбиття на дві частини: початкова версія, яка відобразиться досить швидко, та решта сторінки.

Дуже важливо розуміти, яка стадія завантаження при цьому оптимізується і що бачить реальний користувач (з чистим кешем і, можливо, нешвидким каналом). Докладніше про аналіз процесу завантаження сторінки на конкретних прикладах розповідається у восьмому розділі.

Висновок

Ось так, на прикладі звичайної сторінки (вже досить добре зробленої, варто відзначити) ми домоглися прискорення її завантаження ще на 15-20% (і це без урахування застосування gzip-стиснення для HTML, яке в даному випадку дає приблизно 10% від загальної швидкості ). Найбільш важливі методи вже наведені вище, зараз можна згадати, що при оптимізації швидкості роботи сторінки краще завжди покладатися на внутрішні механізми браузера, а не намагатися їх емулювати на JavaScript (в даному випадку йдеться про штучну «нарізку» потоку). Можливо, у майбутньому клієнтські машини стануть досить потужними (або JavaScript-движки — краще оптимізованими), щоб такі методи запрацювали. Зараз вибір один — алгоритмічне кешування.

Суть проблеми.

Ваша запущена програма в якийсь момент починає активно вантажити CPU, вас кличе тестер і просить полагодити це!

Які звичайні дії програмістів у разі?

  • Просять локалізувати, якщо виходить, вирішити проблему питання часу.
  • Починається додавання логів, лічильників проходів тощо. Все віддається тестеру або замовнику з вимогою відтворити та повернути балку на аналіз. Добре, якщо відтворити вдасться і все стане зрозумілим.
  • Припустити час, коли все працювало і за змінами в системі контролю версій шукати можливі причини.


Як простішевчинити в цьому випадку?

означає, що якийсь потік(и) обробки даних прокинувся\запустився, і став активно виконувати свою роботу або іноді просто зациклився. Дізнавшись стек виконання в момент навантаження, можна з високою часткоюймовірності зрозуміти причину такої поведінки.

Як же його можна дізнатися, адже ми не перебуваємо під налагодженням?Особисто я користуюся утилітою Process Explorer дає можливість побачити список потоків та їх стек . Програма встановлення не вимагає.

Для демонстрації я запустив свою програму з ім'ям процесу " Qocr.Application.Wpf.exe", в яке додав фейковийкод нескінченного циклу. Тепер давайте знайдемо причину завантаження ядра без налагодження. Для цього я йду у створення процесу , далі:

  1. Переходимо на вкладку Threadsі бачимо, що є 1 потік, який вантажить на 16% CPU.
  2. Виділяємо цей потік і тиснемо Stack,відкрилося вікно "Stack for thread ID".
  3. У вікні бачимо, що наш потік тут був створений Qocr.Application.Wpf.exe!<>с. b__36_1+0x3a і зараз викликає GetDirectoriesз методу InitLanguages().

Продемонструю дії вище на зображенні зі стрілками:

Відкривши вихідний код програми та перейшовши до методу InitLanguagesможна побачити мій фейковий код. Знаючи цю інформацію, а саме місце відставки, можна вже вживати заходів.

Код стека (з прикладу вище) викликає нескінченний цикл (Можна перевірити):

Private void InitLanguages() ( new Thread (() => ( while (true ) ( var dir = Directory .GetDirectories(@"C:\" ); ) ; )).Start(); )

Ложка дьогтю в бочці з медом.

Два моменти, які варто знати, якщо вирішите скористатися способом вище:
  1. Потоки створені CLR(створені в коді .NETдодатки) після зупинки не продовжують виконання. Внаслідок чого потік зупиняється і залишається висіти до перезапуску програми.
  2. Якщо стек виконання не містить корисної інформації, то варто зробити зупинку і перегляд стека кілька разів. Імовірність натрапити на місце зациклювання дуже велика.
  • Переклад
  • Tutorial

Від перекладача: ця стаття є сьомою у циклі перекладів офіційного посібника з бібліотеки SFML. Попередню статтю можна знайти Цей цикл статей ставить за мету надати людям, які не знають мову оригіналу, можливість ознайомитися з цією бібліотекою. SFML - це проста та кросплатформова мультимедіа бібліотека. SFML забезпечує простий інтерфейс для розробки ігор та інших мультимедійних програм. Оригінальну статтю можна знайти. Почнемо.

Що таке потік?

Більшість із вас вже знає, що таке потік, проте пояснимо, що це таке, для новачків у цій темі.

Потік - це сутність послідовність інструкцій, які виконуються паралельно коїться з іншими потоками. Кожна програма створює щонайменше один потік: основний, який запускає функцію main(). Програма, яка використовує лише головний потік, є однопотоковою; якщо додати один або більше потоків, вона стане багатопотоковою.

Так, коротше, потоки – це спосіб зробити кілька речей одночасно. Це може бути корисно, наприклад, для відображення анімації та обробки введення даних під час завантаження зображень або звуків. Потоки також широко використовується в мережевому програмуванні, під час очікування на отримання даних буде продовжуватися оновлення та малювання програми.

Потоки SFML чи std::thread?

У своїй останній версії (2011) стандартна бібліотека C++ надає набір класів для роботи з потоками . Під час написання SFML стандарт C++11 ще не був написаний і не було ніякого стандартного способу створення потоків. Коли SFML 2.0 був випущений, було багато компіляторів, які не підтримували цей новий стандарт.

Якщо ви працюєте з компілятором, який підтримує новий стандарт і містить заголовковий файл, забудьте про класи потоків SFML і використовуйте стандартні класи C++ замість них. Але, якщо ви працюєте з компілятором, що не підтримує цей стандарт, або плануєте поширювати ваш код і хочете досягти повної портованості, потокові класи SFML є хорошим вибором

Створення потоків за допомогою SFML

Вистачить розмов, давайте подивимося на код. Клас, що дає можливість створювати потоки за допомогою SFML, називається sf:: Thread, і ось як це (створення потоку) виглядає в дії:

#include #include void func() ( // ця функція запускається коли викликається thread.launch() for (int i = 0; i< 10; ++i) std::cout << "I"m thread number one" << std::endl; } int main() { // создание потока с функцией func в качестве точки входа sf::Thread thread(&func); // запуск потока thread.launch(); // главные поток продолжает быть запущенным... for (int i = 0; i < 10; ++i) std::cout << "I"m the main thread" << std::endl; return 0; }
У цьому коді функції main та func виконуються паралельно після виклику thread.launch(). Результатом цього є те, що текст, який виводиться обома функціями, змішується в консолі.

Крапка входу в потік, тобто. функція, яка виконуватиметься, коли потік запускається, має бути передана конструктору sf::Thread . sf::Thread намагається бути гнучким і приймати різні точки входу: non-member функції або методи класів, функції з аргументами або без них, функтори тощо. Наведений вище приклад показує, як використовувати функцію-член, кілька інших прикладів.

  • non-member функція з одним аргументом:

    Void func(int x) ( ) sf::Thread thread(&func, 5);

  • метод класу:

    Class MyClass (public: void func() ()); MyClass об'єкт; sf::Thread thread(&MyClass::func, &object);

  • Функтор (функціональний об'єкт):

    Struct MyFunctor ( void operator()() ( ) ); sf::Thread thread(MyFunctor());

Останній приклад, який використовує функтор, є найбільш потужним, оскільки він може приймати будь-які типи функторів і тому робить клас sf:: Thread сумісним з багатьма типами функцій, які безпосередньо не підтримуються. Ця функція є особливо цікавою з лямбда-виразами C++11 або std::bind.

// з лямбда-функцією sf:: Thread thread(()( std::cout<< "I am in thread!" << std::endl; });
// з std::bind void func(std::string, int, double) ( ) sf::Thread thread(std::bind(&func, "hello", 24, 0.5));
Якщо ви хочете використати sf::Thread всередині класу, не забудьте, що він не має стандартного конструктора. Тому, ви повинні ініціалізувати його у конструкторі вашого класу у списку ініціалізації:

Class ClassWithThread ( public: ClassWithThread() : m_thread(&ClassWithThread::f, this) ( ) private: void f() ( ... ) sf::Thread m_thread; );
Якщо вам потрібно створити екземпляр sf::Thread після ініціалізації об'єкта, ви можете створити його в купі.

Запуск потоку

Після того, як ви створили екземпляр sf::Thread , ви повинні запустити його за допомогою функції.

Sf::Thread thread(&func); thread.launch();
launch викликає функцію, яку ви передали в конструктор нового потоку, і відразу завершує свою роботу, так що викликаючий потік може відразу ж продовжити виконання.

Зупинка потоків

Потік автоматично завершує свою роботу, коли функція, яка є точкою входу для цього потоку, повертає своє значення. Якщо ви хочете чекати завершення потоку з іншого потоку, ви можете викликати його функцію wait.

Sf::Thread thread(&func); // запуск потоку thread.launch(); ... // виконання блокується доти, доки потік не завершиться thread.wait();
Функція очікування також неявно викликається деструктором sf:: Thread, так що потік не може залишатися запущеним (і безконтрольним) після того, як його екземпляр sf:: Thread знищується. Пам'ятайте про це, коли ви керуєте вашими потоками (дивіться минулу секцію статті).

Призупинення потоку

У SFML немає функції, яка б надавала спосіб призупинення потоку; єдиний спосіб призупинення потоку – зробити це з коду самого потоку. Іншими словами, ви можете лише призупинити поточний потік. Щоб це зробити, ви можете викликати функцію sf::sleep:

Void func() ( ... sf::sleep(sf::milliseconds(10)); ... )
sf::sleep має один аргумент – час призупинення. Цей час може бути виражений у будь-якій одиниці, як було показано у статті про .

Зверніть увагу, що ви можете призупинити будь-який потік за допомогою цієї функції, навіть головний потік.

Sf::sleep є найбільш ефективним способом призупинити потік: протягом припинення потоку, він (потік) практично не споживає ресурсів процесора. Призупинення, засноване на активному очікуванні, на зразок порожнього циклу while, споживає 100% ресурсів центрального процесора і робить… нічого. Однак майте на увазі, що тривалість припинення є просто підказкою; реальна тривалість припинення (більше або менше зазначеного вами часу) залежить від ОС. Так що не покладайтеся на цю функцію за дуже точного відліку часу.

Захист даних, що розділяються

Всі потоки в програмі поділяють деяку пам'ять, вони мають доступ до всіх змінних в області їхньої видимості. Це дуже зручно, але також небезпечно: з моменту паралельного запуску потоку змінні або функції можуть використовуватися одночасно різними потоками. Якщо операція не є потокобезпечною, це може призвести до невизначеної поведінки (тобто це може призвести до збою або пошкодження даних).

Існує кілька програмних інструментів, які можуть допомогти вам захистити дані, що розділяються, і зробити ваш код потокобезпечним, їх називають примітивами синхронізації. Найбільш поширеними є наступні примітиви: м'ютекси, семафори, умовні змінні та спін-блокування. Всі вони - варіанти однієї і тієї ж концепції: вони захищають шматок коду, даючи лише певному потоку право отримувати доступ до даних та блокуючи інші.

Найбільш поширеним (і використовуваним) примітивом є м'ютекс. М'ютекс розшифровується як "Взаємний виняток". Це гарантія, що лише один потік може виконувати код. Подивимося, як м'ютекси працюють, на прикладі нижче:

#include #include sf::Mutex mutex; void func() ( mutex.lock(); for (int i = 0; i< 10; ++i) std::cout << "I"m thread number one" << std::endl; mutex.unlock(); } int main() { sf::Thread thread(&func); thread.launch(); mutex.lock(); for (int i = 0; i < 10; ++i) std::cout << "I"m the main thread" << std::endl; mutex.unlock(); return 0; }
Цей код використовує загальний ресурс (std::cout), і, як бачимо, це призводить до небажаних результатів. Виведення потоків змішалося в консолі. Щоб переконатися в тому, що висновок правильно надрукується замість того, щоб бути безладно змішаним, ми захищаємо відповідні області коду мьютексом.

Перший потік, який досягає виклику mutex.lock() блокує м'ютекс і отримує доступ до коду, який друкує текст. Коли інші потоки досягають виклику mutex.lock(), м'ютекс вже заблокований, та інші потоки призупиняють своє виконання (це схоже на виклик sf::sleep, потік сну не споживає час центрального процесора). Коли перший потік розблокує м'ютекс, другий потік продовжує виконання, блокує м'ютекс і друкує текст. Це призводить до того, що текст консолі друкується послідовно і не змішується.

М'ютекс - це не тільки примітив, який ви можете використовувати для захисту даних, що розділяються, ви можете використовувати його в багатьох інших випадках. Однак, якщо ваш додаток робить складні речі при роботі з потоками, і ви відчуваєте, що можливостей м'ютексів недостатньо - не соромтеся шукати іншу бібліотеку, яка має великий функціонал.

Захист мьютексів

Не хвилюйтеся: м'ютекси вже є безпечними, немає необхідності їх захищати. Але вони небезпечні щодо винятків. Що відбувається, якщо виняток викидається, коли заблоковано м'ютекс? Він ніколи не може бути розблокований і залишатиметься заблокованим вічно. Усі потоки, які намагаються розблокувати заблокований м'ютекс, будуть заблоковані назавжди. У деяких випадках, ваша програма буде "заморожена".

Щоб бути впевненим, що м'ютекс завжди розблокований в середовищі, в якому він може викинути виняток, SFML надає RAII клас, що дозволяє обернути м'ютекс в клас sf::Lock. Блокування відбувається у конструкторі, розблокування відбувається у деструкторі. Просто та ефективно.

Sf::Mutex mutex; void func() ( sf::Lock lock(mutex); // mutex.lock() functionThatMightThrowAnException(); // mutex.unlock(), якщо функція викине виняток ) // mutex.unlock()
Пам'ятайте, що sf::Lock може також бути використаний у функціях, які мають безліч значень, що повертаються.

Sf::Mutex mutex; bool func() ( sf::Lock lock(mutex); // mutex.lock() if (!image1.loadFromFile("...")) return false; // mutex.unlock() if (!image2. loadFromFile("...")) return false; // mutex.unlock() if (!image3.loadFromFile("...")) return false; // mutex.unlock() return true; ) // mutex .unlock()

Поширені помилки

Річ, що часто не береться до уваги: ​​потік не може існувати без відповідного екземпляра

Це четверта стаття із серії "Подолання кордону Windows", в рамках якої я розповідаю про обмеження, що існують для фундаментальних ресурсів у Windows. Цього разу, я збираюся обговорити з вами обмеження на максимальну кількість потоків та процесів, які підтримується Windows. Тут я коротко опишу різницю між потоком і процесом, обмеження потоку опитування (від англ. survey thread), після чого ми поговоримо про обмеження, пов'язані з процесами. Насамперед я вирішив розповісти про обмеження потоків, оскільки кожен активний процес має принаймні один потік (процес, який завершився, але посилання на який зберігається в обробнику, наданому іншим процесом, не має жодного потоку), так що обмеження процесів безпосередньо залежить від основних обмежень, що з потоками.

На відміну від деяких варіантів UNIX, більшість ресурсів Windows не мають фіксованого обмеження, закладеного в операційну систему на етапі складання, а скоріше отримують обмеження на підставі наявних у ОС базових ресурсів, про які я розповідав раніше. Процеси та потоки, наприклад, вимагають для себе фізичної пам'яті, віртуальної пам'яті та пам'яті пулу, так що кількість процесів і потоків, які можуть бути створені на даній системі Windows, в кінцевому рахунку, визначається одним із цих ресурсів, залежно від того, яким Таким чином, ці процеси або потоки були створені і яке з обмежень базових ресурсів буде досягнуто першим. Тому я рекомендую вам, щоб ви прочитали мої попередні статті, якщо ви досі цього не зробили, тому що далі я звертатимуся до таких понять, як зарезервована пам'ять, виділена пам'ять та системне обмеження пам'яті, про які я говорив у попередніх своїх статтях :

Процеси та потоки
Процес Windows за своєю сутністю є контейнером, в якому зберігається код команд із файлу, що виконується. Він є об'єктом процесу ядра і Windows використовує цей об'єкт процесу і пов'язані з ним структури даних для зберігання та супроводження інформації про виконуваний код програми. Наприклад, процес має віртуальний адресний простір, в якому зберігаються його приватні та загальні дані і в якому відображаються образ і пов'язані з ним бібліотеки DLL. Windows за допомогою інструментів діагностики записує інформацію про використання процесом ресурсів для забезпечення обліку та виконання запитів та реєструє посилання процесу на об'єкти операційної системи у таблиці дескриптора процесу. Процеси працюють з контекстом безпеки, який називається маркером, який ідентифікує обліковий запис користувача, групи облікового запису та привілеї, призначені процесу.

Процес включає один або більше потоків, які фактично виконують код в процесі (технічно, виконуються не процеси, а потоки) і представлені в системі у вигляді об'єктів потоків ядра. Є кілька причин, чому програми створюють потоки на додаток до їх початкового початкового потоку: 1) процеси, що володіють інтерфейсом користувача, зазвичай створюють потоки для того, щоб виконувати свою роботу і при цьому зберігати чуйність основного потоку до команд користувача, пов'язаними з введенням даних і керуванням вікнами; 2) програми, які хочуть використовувати кілька процесорів для масштабування продуктивності або які хочуть продовжувати працювати, в той час як потоки зупиняють свою роботу, очікуючи синхронізації операцій введення/виводу, створюють потоки, щоб отримати додаткову вигоду від багатопоточної роботи.

Обмеження потоків
Крім основної інформації про потік, включаючи дані про стан регістрів ЦП, присвоєний потоку пріоритет і інформацію про використання потоком ресурсів, кожен поток має виділену йому частину адресного простору процесу, звана стеком, яку потік може використовувати як робочу пам'ять по ходу виконання коду програми, для передачі параметрів функцій, зберігання локальних змінних та адрес результатів роботи функцій. Таким чином, щоб уникнути нераціональної витрати віртуальної пам'яті системи, спочатку розподіляється лише частина стека, або частина її передається потоку, а залишок просто резервується. Оскільки стеки в пам'яті зростають по низхідній, система розміщує так звані "сторожові" сторінки (від англ. guard pages) пам'яті поза виділеною частиною стека, які забезпечують автоматичне виділення додаткової пам'яті (називається розширенням стека), коли вона буде потрібна. На наступній ілюстрації показано, як виділена область стека поглиблюється і як сторожові сторінки переміщуються в міру розширення стека в 32-бітному адресному просторі:

Структури Portable Executable (PE) образів, що виконуються, визначають обсяг адресного простору, який резервується і спочатку виділяється для стека потоку. За замовчуванням компонувальник резервує 1Мб і виділяє одну сторінку (4Кб), але розробники можуть змінювати ці значення або змінюючи значення PE, коли вони організують зв'язок зі своєю програмою, або шляхом виклику окремого потоку функції CreateTread . Ви можете використовувати утиліту, таку як Dumpbin , яка йде в комплекті з Visual Studio, щоб переглянути налаштування програми, що виконується. Ось результати запуску Dumpbin з опцією /headers для програми, що виконується, згенерованої новим проектом Visual Studio:

Перевівши числа з шістнадцяткової системи обчислення, можна побачити, що розмір резерву стека становить 1Мб, а виділена область пам'яті дорівнює 4Кб; використовуючи нову утиліту від Sysinternals під назвою MMap, ви можете підключитися до цього процесу і подивитися його адресний простір, і тим самим побачити спочатку виділену сторінку пам'яті стеку процесу, сторожову сторінку та іншу частину зарезервованої пам'яті стека:

Оскільки кожен потік споживає частину адресного простору процесу, процеси мають базове обмеження кількості потоків, яке вони можуть створити, рівне розміру їх адресного простору, поділеного на розмір стека потоку.

Обмеження 32-бітових потоків
Навіть якби у процесу взагалі не було ні коду, ні даних і весь адресний простір міг би бути використаний під стеки, то 32-бітний процес із встановленим за замовчуванням адресним простіром у 2 б міг би створити максимум 2048 потоків. Ось результати роботи програми Testlimit, запущеної в 32-бітній Windows з параметром -t (створення потоків), що підтверджують наявність цього обмеження:

Ще раз, так як частина адресного простору вже використовувалася під код і початкову динамічну пам'ять, не всі 2Гб були доступні для стеків потоків, так що загальна кількість створених потоків не змогла досягти теоретичної межі 2048 потоків.

Я спробував запустити Testlimit з додатковою опцією, що надає програмі розширений адресний простір, сподіваючись, що якщо йому дадуть більше 2Гб адресного простору (наприклад, в 32-бітових системах це досягається шляхом запуску програми з опцією /3GB або /USERVA для Boot.ini, або ж еквівалентною опцією BCD на Vista і пізніше збільшення доступу), воно буде його використовувати. 32-бітовим процесам виділяється 4Гб адресного простору, коли вони запускаються на 64-бітному Windows, так скільки ж потоків зможе створити 32-бітний Testlimit, запущений на 64-бітному Windows? Якщо ґрунтуватися на тому, що ми вже обговорили, відповідь має бути 4096 (4Гб розділені на 1Мб), проте на практиці це число значно менше. Ось 32-розрядний Testlimit, запущений на 64-розрядній Windows XP:

Причина цієї невідповідності криється в тому факті, що коли ви запускаєте 32-бітний додаток на 64-бітній Windows, він фактичний є 64-бітним процесом, який виконує 64-бітний код від імені 32-бітних потоків, і тому в пам'яті для кожного потоку резервуються області під 64-бітові та 32-бітові стеки потоків. Для 64-бітного стека резервується 256Кб (винятки становлять ОС, що вийшли до Vista, у яких вихідний розмір стека 64-бітових потоків становить 1Мб). Оскільки кожен 32-бітний потік починає своє існування в 64-бітному режимі і розмір стека, який виділяється при старті, перевищує розмір сторінки, в більшості випадків ви побачите, що під 64-бітний стек потоку виділяється як мінімум 16Кб. Ось приклад 64-бітних і 32-бітних стеків 32-бітного потоку (32-бітний стек позначений як "Wow64"):

32-бітний Testlimit зміг створити в 64-бітній Windows 3204 потоку, що пояснюється тим, що кожен потік використовує 1Мб + 256Кб адресного простору під стек (повторююсь, винятком є ​​версії Windows до Vista, де використовується 1Мб+ 1Мб). Однак, я отримав інший результат, запустивши 32-розрядний Testlimit на 64-розрядній Windows 7:

Відмінності між результатами на Windows XP і Windows 7 викликані безладною природою схеми розподілу адресного простору в Windows Vista, Address Space Layout Randomization (ASLR), що призводить до деякої фрагментації. Рандомізація завантаження DLL, стека потоку та розміщення динамічної пам'яті допомагає покращити захист від шкідливого ПЗ. Як ви можете побачити на наступному знімку програми VMMap, у тестовій системі є ще 357Мб доступного адресного простору, але найбільший вільний блок має розмір 128Кб, що менше 1Мб, необхідний для 32-бітного стека:

Як я вже зазначав, розробник може повторно встановити заданий за замовчуванням розмір резерву стека. Однією з можливих причин цього може бути прагнення уникнути марної витрати адресного простору, коли заздалегідь відомо, що стеком потоку завжди буде використовуватися менше, ніж встановлений за замовчуванням 1Мб. PE-образ Testlimit за замовчуванням використовує розмір резерву стека 64Кб, і коли ви вказуєте разом параметром -t параметр -n, Testlimit створює потоки зі стеками розміром 64Кб. Ось результат роботи цієї утиліти на системі з 32-бітною Windows XP і 256Мб RAM (я спеціально провів цей тест на слабкій системі, що підкреслити це обмеження):

Тут слід зазначити, що сталася інша помилка, з чого випливає, що в цій ситуації причиною не є адресний простір. Фактично, 64Кб-стеки повинні забезпечити приблизно 32000 потоків (2Гб/64Кб = 32768). То яке ж обмеження виявилося в даному випадку? Якщо подивитися на можливих кандидатів, включаючи виділену пам'ять та пул, то жодних підказок у знаходженні відповіді на це питання вони не дають, оскільки всі ці значення нижчі за їхні межі:

Відповідь ми можемо знайти у додатковій інформації про пам'ять у відладчику ядра, який вкаже нам обмеження, пов'язане з доступною резидентною пам'яттю, весь обсяг якої був вичерпаний:

Доступна резидентна пам'ять - це фізична пам'ять, що виділяється для даних або коду, які обов'язково повинні бути в оперативній пам'яті. Розміри невивантажуваного пулу та невивантажуваних драйверів обчислюються незалежно від цього, також як, наприклад, пам'ять, зарезервована в RAM для операцій вводу/виводу. У кожного потоку є обидва стеки режиму користувача, про це я вже говорив, але у них також є стек привілейованого режиму (режиму ядра), який використовується тоді, коли потоки працюють в режимі ядра, наприклад, виконуючи системні виклики. Коли потік активний, його стек ядра закріплений у пам'яті, отже потік може виконувати код у ядрі, якого потрібні сторінки неможливо знайти.

Базовий стек ядра займає 12Кб у 32-бітній Windows та 24Кб у 64-бітній Windows. 14225 потоків вимагають для себе приблизно 170Мб резидентної пам'яті, що відповідає обсягу вільної пам'яті на цій системі з вимкненим Testlimit:

Як тільки досягається межа доступної системної пам'яті, багато базових операцій починають завершуватися з помилкою. Наприклад, ось помилка, яку я отримав, двічі клікнувши на ярлику Internet Explorer, розташованому на робочому столі:

Як і очікувалося, працюючи на 64-бітній Windows з 256Мб RAM, Testlimit зміг створити 6600 потоків - приблизно половину від того, скільки потоків ця утиліта змогла створити в 32-бітній Windows з 256Мб RAM - до того, як вичерпалася доступна пам'ять:

Причиною, за якою раніше я вживав термін "базовий" стек ядра, є те, що потік, який працює з графікою та функціями управління вікнами, отримує "великий" стек, коли він виконує перший виклик, розмір якого дорівнює (або більше) 20Кб на 32-бітної Windows та 48Кб на 64-бітній Windows. Потоки Testlimit не викликають жодного подібного API, тому вони мають базові стеки ядра.
Обмеження 64-бітних потоків

Як і у 32-бітних потоків, у 64-бітних потоків за замовчуванням є резерв в 1Мб для стека, але 64-бітні мають набагато більше користувальницького адресного простору (8Тб), так що воно не повинно стати проблемою, коли справа доходить до створення великого кількості потоків. І все ж таки очевидно, що резидентна доступна пам'ять, як і раніше, є потенційним обмежувачем. 64-бітна версія Testlimit (Testlimit64.exe) змогла створити з параметром -n і без нього приблизно 6600 потоків на системі з 64-бітною Windows XP і 256Мб RAM, рівно стільки ж, скільки створила 32-бітна версія, тому що була досягнута межа резидентної доступної пам'яті. Однак, на системі з 2Гб оперативної пам'яті Testlimit64 зміг створити лише 55000 потоків, що значно менше кількості потоків, яку могла б створити ця утиліта, якби обмеженням виступила резидентна доступна пам'ять (2Гб/24Кб = 89000):

В даному випадку причиною є виділений початковий стек потоку, який призводить до того, що в системі закінчується віртуальна пам'ять і з'являється помилка, пов'язана з браком обсягу підкачування файлу. Як тільки обсяг виділеної пам'яті досягає розміру оперативної пам'яті, швидкість створення нових потоків суттєво знижується, тому що система починає "пробуксовувати", раніше створені стеки потоків починають вивантажуватися у файл підкачування, щоб звільнити місце для стеків нових потоків, і файл підкачування повинен збільшуватися. З увімкненим параметром -n результати ті ж, оскільки таким самим залишається початковий обсяг виділеної пам'яті стека.

Обмеження процесів
Число процесів, що підтримуються Windows, очевидно, має бути меншим, ніж число потоків, тому що кожен процес має один потік і сам по собі процес призводить до додаткової витрати ресурсів. 32-бітний Testlimit, запущений на системі з 64-бітною Windows XP та 2Гб системної пам'яті створює близько 8400 процесів:

Якщо подивитися на результат роботи відладника ядра, стає зрозуміло, що в даному випадку досягається обмеження резидентної доступної пам'яті:

Якби процес використав резидентну доступну пам'ять для розміщення тільки стека потоку привілейованого режиму, Testlimit зміг би створити набагато більше, ніж 8400 потоків на системі з 2Гб. Кількість резидентної доступної пам'яті на цій системі без запущеного Testlimit дорівнює 1,9 Гб:

Шляхом розподілу обсягу резидентної пам'яті, використовуваної Testlimit (1,9Гб), на кількість створених ним процесів отримуємо, що у кожен процес відводиться 230Кб резидентної пам'яті. Так як 64-бітний стек ядра займає 24 Кб, ми отримуємо, що зникли безвісти приблизно 206Кб для кожного процесу. Де ж решта використовуваної резидентної пам'яті? Коли процес створено, Windows резервує достатній обсяг фізичної пам'яті, щоб забезпечити мінімальний робочий набір сторінок (англ. working set). Це робиться для того, щоб гарантувати процесу, що будь-якій ситуації в його розпорядженні буде достатня кількість фізичної пам'яті для збереження такого обсягу даних, який необхідний для мінімального робочого набору сторінок. За замовчуванням розмір робочого набору сторінок часто становить 200Кб, що можна легко перевірити, додавши у вікні Process Explorer стовпець Minimum Working Set:

6Кб, що залишилися, - це резидентна доступна пам'ять, що виділяється під додаткову несторінкову пам'ять (від англ. nonpageable memory), в якій зберігається сам процес. Процес у 32-бітній Windows використовує трохи менше резидентної пам'яті, оскільки його привілейований стік потоку менший.

Як і у випадку зі стеками потоку режиму користувача, процеси можуть перевизначати встановлений для них за замовчуванням розмір робочого набору сторінок за допомогою функції SetProcessWorkingSetSize . Testlimit підтримує параметр -n, який, разом із параметром -p, дозволяє встановлювати для дочірніх процесів головного процесу Testlimit мінімально можливий розмір робочого набору сторінок, що дорівнює 80Кб. Оскільки дочірнім процесам потрібен час, щоб скоротити їх робочі набори сторінок, Testlimit, після того, як він більше не зможе створювати процеси, зупиняє роботу та намагається її продовжити, даючи його дочірнім процесам шанс виконатися. Testlimit, запущений з параметром -n на системі з Windows 7 і 4Гб RAM вже іншого, відмінного від обмеження доступної резидентної пам'яті, межі - обмеження виділеної системної пам'яті:

На знімку знизу ви можете побачити, що відладчик ядра повідомляє не тільки про те, що було досягнуто межу виділеної системної пам'яті, але і про те, що після досягнення цього обмеження мали місце тисячі помилок розподілу пам'яті, як віртуальної, так і пам'яті, виділеної під вивантажуваний пул (межа виділеної системної пам'яті фактично було досягнуто кілька разів, оскільки, коли траплялася помилка, пов'язана з браком обсягу файлу підкачування, цей обсяг збільшувався, відсуваючи це обмеження):

До запуску Testlimit середній рівень виділеного об'єму пам'яті дорівнював приблизно 1,5Гб, так що потоки зайняли близько 8Гб виділеної пам'яті. Отже, кожен процес споживав приблизно 8 Гб/6600 чи 1,2 Мб. Результат виконання команди! vm відладника ядра, яка показує розподіл власної пам'яті (від англ. private memory) для кожного процесу, підтверджує вірність цього обчислення:

Початковий обсяг виділеної пам'яті під стек потоку, описаний раніше, незначно впливає на інші запити на надання пам'яті, необхідної для структур даних адресного простору процесу, записів таблиці сторінок, таблиці дескрипторів, об'єктів процесу і потоку, і власних даних, які процес створює під час своєї ініціалізації.

Скільки процесів та потоків буде достатньо?
Таким чином, відповіді на питання "скільки потоків підтримує Windows?" і "скільки процесів ви можете одночасно запустити на Windows?" взаємопов'язані. Крім нюансів методів, якими потоки визначають розмір їх стека і процеси визначають їх мінімальний робочий набір сторінок, двома головними чинниками, визначальним відповіді ці питання кожної конкретної системи, є обсяг фізичної пам'яті і обмеження виділеної системної пам'яті. У будь-якому випадку, якщо програма створює достатню кількість потоків або процесів, щоб наблизитися до цих меж, то її розробнику слід переглянути проект цієї програми, оскільки завжди існують різні способи досягти того ж результату з розумним числом процесів. Наприклад, основною метою при масштабуванні програми є прагнення зберегти число виконуваних потоків рівним числу ЦП, і один із способів досягти цього полягає в переході від використання синхронних операцій введення/виводу до асинхронних з використанням портів завершення, що має допомогти зберегти відповідність числа запущених потоків з числом ЦП.