Погано документовані особливості Linux

Привздохнув, промовила:
«Як же довго я спала!»
imageКолись, вперше зустрівши Unix, я був зачарований логічною стрункістю і завершеністю системи. Кілька років після цього я люто вивчав пристрій ядра і системні виклики, читаючи все, що вдавалося дістати. Потроху моє захоплення зійшло нанівець, знайшлися більш нагальні справи і ось, починаючи з якогось часу я став виявляти то одну то іншу фічу про які я раніше не знав. Процес природний, однак дуже часто такі казуси об'єднує одне — відсутність авторитетного джерела документації. Часто відповідь знаходиться у вигляді третього зверху коментаря stackoverflow, часто доводиться зводити разом два-три джерела, щоб отримати відповідь на саме той який задавав питання. Я хочу привести тут невелику колекцію таких погано документованих особливостей. Жодна з них не нова, деякі навіть дуже не нові, але на кожну я вбив свого часу декілька годин і часто досі не знаю систематичного опису.

Всі приклади відносяться до Linux, хоча багато з них справедливі для інших *nix систем, я просто взяв за основу саму активно розвивається ОС, до того ж ту, яка у мене перед очима і де я можу швидко перевірити пропонований код.

Зверніть увагу, у заголовку я написав «погано документовані» а не «маловідомі», тому тих хто в курсі прошу викладати в коментарях посилання на членороздільну документацію, я з задоволенням додам в кінці списку.

Повертається звільнена пам'ять назад в ОС?
Це питання, поставлене цілком мною шановним колегою, став спусковим гачком для цієї публікації. Цілих півгодини після цього я змішував його з брудом і обзивав порівняльними епітетами, пояснюючи що ще класики вчили — пам'ять в Unix виділяється через системний виклик sbrk(), який просто збільшує верхній ліміт доступних адрес; виділяється зазвичай великими шматками; що звичайно технічно можливо знизити ліміт і повернути пам'ять в ОС для інших процесів, однак для аллокатора дуже накладно відстежувати всі використовувані і невикористовувані фрагменти, тому повернення пам'яті не передбачено by design. Цей класичний механізм чудово працює в більшості випадків, виняток — сервер годинами/місяцями тихо сидить без діла, раптом запитувач багато сторінок для обробки якоїсь події і знову тихо засинаючий (але в цьому випадку виручає своп). Після чого, задовольнивши своє ЧСВ, я як чесна людина пішов підтвердити в інтернетах свою думку і з подивом виявив, що Linux починаючи з 2.4 може використовувати sbrk() так і mmap() для виділення пам'яті, в залежності від потрібного розміру. Причому пам'ять аллоцированная через mmap() цілком собі повертається в ОС після виклику free()/delete. Після такого удару мені залишалося тільки один два — смиренно вибачитися і з'ясувати чому ж точно дорівнює цей таємничий межа. Оскільки ніякої інформації так і не знайшов, довелося міряти руками. Виявилося, на моїй системі (3.13.0) — всього 120 байт. Код лінійки для бажаючих переміряти — тут.

Який мінімальний інтервал який процес/потік може проспати?
Той же Моріс Бах навчав: планувальник (scheduler) процесів у ядрі активується за будь-якого переривання; отримавши управління, планувальник проходить за списком сплячих процесів і переводить ті з них які прокинулися (отримали запитані дані з файлу або сокета, закінчився інтервал sleep() і т. д.) в список «ready to run», після чого виходить з переривання назад в поточний процес. Коли відбувається переривання системного таймера, яке траплялося коли-то раз в 100 мс, потім, за збільшення швидкості CPU, раз в 10 мс, планувальник ставить поточний процес в кінець списку «ready to run» і запускає перший процес з початку цього списку. Таким чином, якщо я викликав sleep(0) або взагалі заснув на мить з будь-якого приводу, так що мій процес був переставлений з списку «ready to run» в список «preempted», у нього немає жодних шансів заробити знову раніше ніж через 10 мс, навіть якщо він взагалі один в системі. В принципі, ядро можна перебудувати зменшивши цей інтервал, однак це викликає невиправдано великі витрати CPU, так що це не вихід. Це добре відоме обмеження довгі роки отруювало життя розробникам швидко-реагуючих систем, саме воно значною мірою стимулювало розробку real-time systems і неблокирующих (lockfree) алгоритмів.

І от якось я повторив це експеримент (мене насправді цікавили більш тонкі моменти типу розподілу ймовірностей) і раптом побачив що процес прокидається після sleep(0) через 40 mks, в 250 разів швидше. Те ж саме після викликів yield(), std::mutex::lock() і всіх інших блокуючих викликів. Що ж відбувається?!

Пошук досить швидко привів до Completely Fair Scheduler введеному починаючи з 2.6.23, однак я довго не міг зрозуміти як саме цей механізм приводить до такого швидкого перемикання. Як я з'ясував врешті-решт, відмінність полягає саме в самому алгоритмі default class scheduler, того під яким запускаються всі процеси за замовчуванням. На відміну від класичної реалізації, у цій кожен працюючий процес/потік має динамічний пріоритет, так що у працюючого процесу пріоритет поступово знижується відносно інших очікують виконання. Таким чином, планувальник може прийняти рішення про запуск іншого процесу негайно, не чекаючи закінчення фіксованого інтервалу, а сам алгоритм перебору процесів тепер Про(1), суттєво легше і може виконуватися частіше.

Це зміна веде до дивно далекосяжних наслідків, фактично зазор між real-time і звичайною системою майже зник, пропонована затримка в 40 мікросекунд реально досить мала для більшості прикладних задач, те ж саме можна сказати про неблокирующие алгоритми — класичні блокуючі структури даних на мьютексах стали дуже навіть конкурентоспроможні.

А що таке взагалі ці класи планувальника (scheduling policies)?
Ця тема більш-менш описана, повторюватися не буду, і тим не менш, відкриємо один і другий авторитетні книги на відповідній сторінці та порівняємо між собою. В наявності майже дослівне в деяких місцях повторення один одного, а так само деякі розбіжності з тим, що говорить man-s2 sched_setscheduler. Проте симптом.

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

iBolit# ./sche-d0-i0-b0-f1-r2-f3-i0-i0-i0-d0
6 SCHED_FIFO[3]
5 SCHED_RR[2]
4 SCHED_FIFO[1]
1 SCHED_OTHER[0]
2 SCHED_IDLE[0]
3 SCHED_BATCH[0]
7 SCHED_IDLE[0]
8 SCHED_IDLE[0]
9 SCHED_IDLE[0]
10 SCHED_OTHER[0]

Число на початку рядка показує порядок в якому потоки створювалися. Як бачимо два пріоритетних класу SCHED_FIFO і SCHED_RR завжди мають пріоритет перед трьома звичайними класами SCHED_OTHER, SCHED_BATCH і SCHED_IDLE, і між собою ранжуються строго по пріоритету, то що і вимагалося. Але ось наприклад те, що всі три юзер-класу на старті рівноправні взагалі ніде не згадано, навіть SCHED_IDLE, який набагато вражений в правах у порівнянні з дефолтними SCHED_OTHER, запускається вперед, якщо стоїть у черзі на мьютексе першим. Ну принаймні в цілому все працює, а от у Solaris у цьому місці взагалі діркаКілька років тому я прогнав цей тест під Solaris і виявив що пріоритети потоків повністю ігноруються, потоки пробуджуються в абсолютно довільному порядку. Я тоді зв'язався з тех. підтримкою Sun, але отримав на подив невиразний і беззмістовний відповідь (до цього вони охоче з нами співпрацювали). Через два тижні Sun не стало. Я щиро сподіваюся що не мій запит послужив причиною цього.
Для тих хто хоче сам пограться з пріоритетами і класами, вихідний код там же.

Затримані TCP пакети
Якщо попередні приклади можна вважати приємним сюрпризом, то ось цей ось приємним назвати важко.
Історія почалася кілька років тому, коли ми раптом виявили, що один з наших серверів, що посилає клієнтам безперервний потік даних, відчуває періодичні затримки в 40 мілісекунд. Це траплялося нечасто, однак дозволити собі таку розкіш ми не могли, тому був виконаний ритуальний танець зі сниффером і подальшим аналізом. Увага, при обговоренні в інтернеті цю проблему як правило пов'язують з алгоритмом Нагла (Nagle algorithm), невірно, за нашими результатами проблема виникає на Linux при взаємодії delayed ACK slow start. Давайте згадаємо іншого класика, Річарда Стівенса, щоб освіжити пам'ять.
delayed ACK — це алгоритм затримує відправку ACK на отриманий пакет на кілька десятків мілісекунд в розрахунку що негайно буде посланий пакет і ACK можна буде вмонтувати в нього з очевидною метою — зменшити трафік порожніх датаграм по мережі. Цей механізм працює в інтерактивній TCP сесії і в 1994 році, коли вийшла TCP/IP Illustrated, був вже стандартною частиною TCP/IP стека. Що важливо для розуміння подальшого, затримка може бути перервана зокрема прибуттям наступного пакета даних, у цьому випадку кумулятивний ACK на обидві дейтаграми вирушає негайно.
slow start — не менш старий алгоритм покликаний захистити проміжні маршрутизатори від надто агресивного джерела. Посилає сторона на початку сесії може послати тільки один пакет і повинна дочекатися ACK від одержувача, після цього може послати два, чотири і т. д., поки не упреться в інші механізми регулювання. Цей механізм очевидно працює у випадку обьемного трафіку і, що істотно, він включається до початку сесії і після кожної вимушеної ретрансляції втраченої дейтаграми.
TCP сесії можна розділити на два великих класи — інтерактивні (типу telnet), та об'ємні (bulk traffic, типу ftp). Легко помітити, що вимоги до регулюючим трафік алгоритмам в цих випадках часто протилежні, зокрема вимоги «затримати ACK» і «дочекатися ACK» очевидно суперечать одне одному. У випадку стабільної TCP сесії рятує умова згадане вище — отримання наступного пакета перериває затримку і ACK на обидва сегмента надсилається не чекаючи попутного пакета з даними. Однак, якщо раптом один з пакетів втрачається, посилає сторона негайно ініціює slow start — посилає один датаграмму і чекає відповіді, приймаюча сторона отримує один датаграмму і затримує ACK, оскільки дані у відповідь не надсилаються, весь обмін підвисає на 40 мс. Voilà.
Ефект виникає саме в Linux — Linux TCP з'єднання, в інших системах я такого не бачив, схоже щось у них в реалізації. І як з цим боротися? Ну, в принципі Linux пропонує (нестандартну) опцію TCP_QUICKACK, яка відключає delayed ACK, проте ця опція нестійка, відключається автоматично, так що зводити прапорець доводиться перед кожним read()/write(). Є ще /proc/sys/net/ipv4, зокрема /proc/sys/net/ipv4/tcp_low_latency, але робить вона те що я підозрюю вона повинна робити — невідомо. Крім того цей прапорець буде ставитися до всіх TCP з'єднань на даній машині, недобре.
Які будуть пропозиції?

З темряви століть
І наостанок, самий перший казус в історії Linux, просто для повноти картини.
З самого початку в Linux був присутній нестандартний системний виклик clone(). Він працює так само як і fork(), тобто створює копію поточного процесу, але при цьому адресний простір залишається у спільному користуванні. Неважко здогадатися, для чого він був придуманий і дійсно, це витончене рішення відразу висунуло Linux в перші ряди серед ОС по реалізації багатопоточності. Проте завжди є один нюанс…

Справа в тому що при клонуванні процесу також клонуються всі файлові дескриптори, у тому числі і сокети. Якщо раніше була відпрацьована схема: відкривається сокет, передається в інші потоки, всі дружньо співпрацюють надсилаючи та отримуючи дані, один з потоків вирішує закрити сокет, всі інші відразу бачать що сокет закрився, на іншому кінці з'єднання (у разі TCP) теж бачать що сокет закритий; то що виходить тепер? Якщо один з потоків вирішує закрити свій сокет, інші потоки про це нічого не знають, оскільки вони насправді окремі процеси і у них свої власні копії цього сокета, і продовжують працювати. Більше того, інший кінець з'єднання теж вважає з'єднання відкритим. Справа минуле, але коли-то це нововведення поламало патерн багатьом мережевим програмістам, так і коду довелося переписати під Linux неабияк.

Література
  1. Maurice J. Bach. The Design of the UNIX Operating System.
  2. Robert Love. Linux Kernel Development
  3. Daniel P. Bovet, Marco Cesati. Understanding the Linux Kernel
  4. Richard Stevens. TCP/IP Illustrated, Volume 1: The Protocols
  5. Richard Stevens. Unix Network Programming
  6. Richard Stevens. Advanced Programming in the UNIX Environment
  7. Uresh Vahalia. UNIX Internals: The New Frontiers
Тут могла б бути ваша посилання на порушені теми
А ще мені дійсно цікаво, скільки ж все таки я проспав і наскільки відстав від життя. Дозвольте включити невелике опитування.
Наскільки маловідомі порушені питання?

/>
/>


<input type=«radio» id=«vv65987»
class=«radio js-field-data»
name=«variant[]»
value=«65987» />
Все тривіально і загальновідомо, саме так все і повинно працювати.
<input type=«radio» id=«vv65989»
class=«radio js-field-data»
name=«variant[]»
value=«65989» />
В принципі все відомо, але це все дійсно відхилення від класичного Unix
<input type=«radio» id=«vv65991»
class=«radio js-field-data»
name=«variant[]»
value=«65991» />
Дізнався щось нове. Цікаво
<input type=«radio» id=«vv65993»
class=«radio js-field-data»
name=«variant[]»
value=«65993» />
Ніколи нічого подібного не чув
<input type=«radio» id=«vv65995»
class=«radio js-field-data»
name=«variant[]»
value=«65995» />
Використовую високорівневі бібліотеки і фреймворки. Такі проблеми ніколи не виникають
<input type=«radio» id=«vv65997»
class=«radio js-field-data»
name=«variant[]»
value=«65997» />
якою мовою це написано? Використовуйте <....> і проблем не буде
<input type=«radio» id=«vv65999»
class=«radio js-field-data»
name=«variant[]»
value=«65999» />
Що таке Linux?

Проголосувало 34 людини. Утрималося 11 осіб.


Тільки зареєстровані користувачі можуть брати участь в опитуванні. Увійдіть, будь ласка.


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.