Перейти до вмісту

Потоки і моделі конкурентності

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

Потоки розв’язують саме це: кілька ліній виконання в одному адресному просторі, дешеві у створенні й миттєві в обміні даними. Через це вони й небезпечні. Усе, що робить потоки зручними, водночас робить їх джерелом помилок, які не відтворюються.

Передумови. Адресний простір і дескриптор процесу (модуль 6), контекстне перемикання і планування (модуль 7).

Один процес із трьома потоками: спільні код, дані та купа, власні стек і регістри процес — один адресний простіркод (text) — спільнийдані та купа — спільніусе нижче кожен потік має власнепотік 1стекрегістри, лічильник командмаска сигналів, TLSпотік 2стекрегістри, лічильник командмаска сигналів, TLSпотік 3стекрегістри, лічильник командмаска сигналів, TLS
Спільна купа — це і вся перевага потоків, і вся їхня небезпека. Обмін даними безкоштовний саме тому, що ніхто не заважає двом потокам писати в одну змінну.

Спільними в них є код, глобальні та статичні дані, купа, таблиця відкритих файлів, поточний каталог, права доступу й обробники сигналів.

Власними лишаються стек, регістри з лічильником команд, маска заблокованих сигналів, локальна пам’ять потоку (TLS) та ідентифікатор.

Практичні наслідки видно одразу. Покажчик, отриманий в одному потоці, дійсний в іншому, на відміну від процесів. Файл, відкритий одним потоком, доступний усім. А от локальна змінна одного потоку для решти недосяжна, бо лежить у його власному стеку.

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

Процес Потік
Адресний простір власний спільний
Вартість створення висока низька
Обмін даними IPC, копіювання спільна пам’ять, безкоштовно
Ізоляція збоїв падіння не зачіпає інших падіння вбиває всіх
Перемикання перезавантаження таблиць сторінок, скидання TLB лише регістри

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

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

У моделі N:1 усі потоки програми відображені на один потік ядра. Перемикання виходять дуже дешевими, бо не виходять у ядро взагалі, зате один блокуючий системний виклик зупиняє всі потоки одразу, і паралелізму на кількох ядрах немає ніякого.

У 1:1 кожному потоку користувача відповідає потік ядра. Блокування одного не зачіпає інших, паралелізм справжній, а платити доводиться тим, що створення потоку проходить через ядро, та й кількість потоків обмежена ресурсами ядра. Так влаштовані Linux, Windows і macOS.

M:N мультиплексує M потоків користувача на N потоків ядра силами бібліотеки. Теоретично це найкраще з обох світів, а практично узгодити планувальник бібліотеки з планувальником ядра виявилося надзвичайно складно: Solaris і FreeBSD пробували і відмовилися.

Конкурентність без потоків

Section titled “Конкурентність без потоків”

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

Перший обхідний шлях — цикл подій: один потік, набір неблокуючих дескрипторів і epoll (модуль 13). Замість «потік чекає» маємо «дескриптор у наборі». Так працюють nginx, Node.js і Redis. Обмеження очевидне: одне обчислення, яке затягнулося, зупиняє весь цикл.

Другий — корутини й async/await, тобто функції, які вміють зупинитися й продовжитися пізніше. Компілятор перетворює таку функцію на скінченний автомат, стан якого лежить у купі, а не в окремому стеку, тож перемикання коштує як виклик функції, а не як системний виклик.

Третій — легкі потоки: горутини в Go, віртуальні потоки в Java, задачі в Erlang. Це власний планувальник бібліотеки, який мультиплексує тисячі легких потоків на кілька потоків ядра, тобто та сама модель M:N. Різниця в тому, що тепер її будують у межах одного середовища виконання, яке контролює і код, і всі блокуючі виклики, тому узгодження нарешті стало можливим.

Відповідей на це питання дві, і залежать вони від того, чим потоки зайняті.

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

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

Terminal window
ps -eLf | awk '$6 > 1' | head

Задачі з кількома потоками. Стовпці LWP і NLWP — ідентифікатор потоку і їхня кількість у процесі.

Terminal window
ls /proc/$$/task

Кожен потік процесу представлений тут каталогом. У однопотокової оболонки він один, і його ім’я збігається з PID.

Terminal window
cat /proc/self/status | grep -E 'Threads|SigBlk|Cpus_allowed_list'

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

Terminal window
top -H -p $(pgrep -n -f . )

-H показує окремі потоки замість сумарного рядка процесу. Видно, який саме потік споживає процесор.

Terminal window
grep -c ^processor /proc/cpuinfo; ulimit -s

Кількість логічних ядер і типовий розмір стека потоку. Друге число пояснює, чому десять тисяч потоків — погана ідея: помножте одне на друге.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

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

«Кожен потік має свою копію змінних». Свої в нього лише локальні змінні, бо вони лежать у стеку. Глобальні, статичні й усе, що в купі, спільні. Через це дві функції, кожна коректна сама по собі, разом дають неправильний результат (модуль 9).

«Потік упав, а програма працює далі». Не працює. SIGSEGV у будь-якому потоці завершує весь процес, тож коли потрібна ізоляція збоїв, потрібні процеси.

«async/await робить код паралельним». Не робить. У більшості середовищ async-код виконується в одному потоці, просто не блокується на очікуванні. Паралельними обчислення від цього не стають.

«Горутини — це не потоки». Це саме потоки, просто не потоки ядра. Модель M:N із власним планувальником — та сама ідея, яку відкинули в дев’яностих і повернули там, де середовище виконання контролює всі блокуючі виклики.

Перевір себе

1. Один потік звертається за недійсною адресою і отримує SIGSEGV. Що станеться з іншими потоками процесу?
2. Що з переліченого кожен потік має власне?
3. Чому модель N:1 не дає паралелізму на багатоядерному процесорі?
4. Сервер обробляє 10 000 одночасних зʼєднань. Чому потік на зʼєднання — погана архітектура?
5. Однопотоковий сервер на epoll обробляє тисячі зʼєднань. Він конкурентний чи паралельний?
6. Скільки потоків доцільно для суто обчислювальної задачі?

A3 — producer–consumer. Спершу з м’ютексом і умовною змінною, потім без блокувань на кільцевому буфері. Перша частина спирається на модуль 9, друга показує, чого коштує від блокувань відмовитись.