Планировщик
Предпосылки
процессы (состояния RUNNING/SLEEPING, context switch), потоки (CLONE_VM, общее адресное пространство), виртуальная память (CR3, TLB flush при переключении).
← Файловые системы | Права доступа и capabilities →
Файловая система решила задачу хранения данных: программа вызывает write(fd, buf, size), а ядро превращает это в блоки на диске. Но до сих пор остаётся вопрос, который мы обходили стороной: если на машине 400 потоков и 4 ядра, кто решает, какой поток выполняется прямо сейчас?
В первой заметке о задачах ОС мы сказали, что планировщик «принудительно забирает процессор у одной программы и передаёт другой — даже если та не хочет отдавать». Пришло время разобраться, как именно он это делает.
Три конкурирующие цели
Планировщик процессов (process scheduler) распределяет процессорное время между потоками. У этой задачи три цели, и они конфликтуют друг с другом.
Справедливость (fairness): каждый поток должен получить процессорное время, пропорциональное его приоритету. Ни один поток не должен голодать — бесконечно ждать, пока другие используют CPU.
Латентность (latency): интерактивные потоки — терминал, GUI (Graphical User Interface), веб-сервер, ожидающий ввод пользователя — должны получать процессор быстро после пробуждения. Пользователь нажал клавишу — терминал обязан ответить за миллисекунды, а не ждать, пока 399 других потоков доработают свои кванты.
Пропускная способность (throughput): вычислительные задачи — компиляция, рендеринг, machine learning — хотят максимум процессорного времени без частых переключений, потому что каждое переключение — потерянные микросекунды.
Оптимизировать все три одновременно невозможно. Частое переключение улучшает латентность, но снижает пропускную способность из-за накладных расходов. Редкое переключение повышает throughput, но интерактивные задачи простаивают. Вся история планировщиков — поиск баланса между этими тремя силами.
Переключение контекста
Прежде чем разбирать алгоритмы планирования, нужно понять, что именно происходит в момент переключения. Планировщик передаёт процессор от одного потока к другому через переключение контекста (context switch) — операцию, состоящую из нескольких шагов.
Первый шаг — сохранение состояния текущего потока. Процессор помещает значения регистров общего назначения (rax, rbx, rcx, …, r15), указатель инструкций (rip), указатель стека (rsp), регистр флагов (rflags) и регистры FPU (Floating Point Unit) / SSE (Streaming SIMD Extensions, где SIMD — Single Instruction, Multiple Data) / AVX (Advanced Vector Extensions) в структуру task_struct текущего потока. Это примерно 200 байт на x86-64 — без AVX-512, где объём регистров достигает 2 КБ.
Второй шаг — переключение стека ядра. Каждый поток имеет собственный стек ядра (обычно 16 КБ, задаётся константой THREAD_SIZE). Планировщик записывает текущий указатель стека и загружает стек нового потока.
Третий шаг зависит от того, принадлежат ли потоки одному процессу. Если оба потока работают в одном адресном пространстве (флаг CLONE_VM при создании), переключение завершено — CR3 не меняется, таблица страниц остаётся прежней. Если потоки принадлежат разным процессам, ядро загружает новое значение в регистр CR3, указывая процессору на другую таблицу страниц. Смена CR3 сбрасывает TLB (Translation Lookaside Buffer) — кэш, хранящий трансляции виртуальных адресов в физические. После сброса каждый первый доступ к памяти проходит через полную аппаратную трансляцию (page walk), что стоит десятки наносекунд на каждое обращение.
переключение контекста:
поток A (выполняется) поток B (ожидает)
rax, rbx, ..., rip task_struct B
| |
v |
save -> task_struct A |
|
switch kernel stack: A.stack -> B.stack |
|
if (разные процессы): |
CR3 = B.pgd ---> TLB flush |
|
restore <- task_struct B |
| v
v
поток B (выполняется) поток A (ожидает)
Стоимость на практике: переключение между потоками одного процесса занимает 1-3 мкс (нет смены CR3, нет TLB flush). Между потоками разных процессов — 3-10 мкс. На машине с KPTI (Kernel Page Table Isolation, защита от Meltdown) добавляются дополнительные переключения таблиц страниц, увеличивая стоимость на 20-30%.
Эти числа объясняют, почему переключение — не бесплатная операция и почему планировщик не может переключать потоки после каждой инструкции. На сервере, где происходит 50 000 переключений контекста в секунду, при средней стоимости 5 мкс на переключение накладные расходы составляют 250 мс процессорного времени в секунду — четверть одного ядра уходит только на переключения.
Когда происходит переключение
Планировщик забирает процессор у потока в двух ситуациях.
Добровольная уступка
Поток вызывает блокирующий системный вызов: read() на пустом сокете, sleep(), wait() для ожидания завершения дочернего процесса, futex_wait() для ожидания мьютекса. Ядро переводит поток в состояние SLEEPING и вызывает планировщик, чтобы тот выбрал следующий поток для выполнения. Когда данные в сокете появятся или таймер сработает — ядро разбудит поток и вернёт его в очередь на выполнение.
Большинство потоков на типичном сервере спят. Из 400 потоков на машине одновременно готовы к исполнению (runnable) обычно 5-20 — остальные ожидают сетевого ввода, таймеров или блокировок. Веб-сервер проводит в ожидании сети 99%+ времени.
Вытеснение
Аппаратный таймер (programmable interrupt timer) генерирует прерывание с частотой 250-1000 Гц (в зависимости от конфигурации ядра, параметр CONFIG_HZ). При каждом тике таймера процессор прерывает текущий поток и передаёт управление обработчику прерывания в ядре. Обработчик вызывает планировщик, который проверяет: стоит ли заменить текущий поток? Если да — происходит переключение контекста. Если нет — текущий поток продолжает работу.
Это и есть вытеснение (preemption): поток теряет процессор не потому, что сам решил отдать, а потому, что ядро забрало.
Вытесняющее и кооперативное планирование
Различие между этими двумя моделями принципиально. При кооперативном планировании (cooperative scheduling) поток работает, пока сам не вернёт управление — через блокирующий вызов или явный yield(). Ранние версии Windows (до Windows 95) и Mac OS (до Mac OS X) использовали кооперативную модель. Последствие: одна зависшая программа блокирует всю систему, потому что ядро не может отобрать процессор.
При вытесняющем планировании (preemptive scheduling) ядро прерывает поток принудительно через аппаратный таймер. Linux использует вытесняющую модель. Зависший поток получит свой тик таймера, ядро увидит, что он исчерпал отведённое время, и переключит на другой.
У вытеснения есть критическое следствие для программиста: поток может быть прерван между любыми двумя инструкциями. Не между вызовами функций, не между строками исходного кода — между любыми двумя машинными инструкциями. Операция x += 1 на уровне машинного кода — это три инструкции: mov (загрузить значение из памяти в регистр), add (прибавить единицу), mov (записать результат обратно). Планировщик может прервать поток после чтения, но до записи. Если другой поток в этот момент изменит ту же переменную — результат непредсказуем. Именно поэтому существуют гонки данных (race conditions) и именно поэтому нужны примитивы синхронизации: мьютексы, атомарные операции, барьеры памяти.
Наивный подход: round-robin
Простейший алгоритм — круговой обход (round-robin): каждый поток получает фиксированный квант времени (time slice), например 10 мс, после чего процессор передаётся следующему в очереди.
На 4 ядрах с 400 потоками арифметика неутешительна: 400 потоков / 4 ядра = 100 потоков на ядро. Один полный круг: 100 * 10 мс = 1000 мс. Поток нажатия клавиши в терминале, пробудившись после ожидания ввода, встаёт в конец очереди из 99 потоков. В худшем случае он ждёт 990 мс — почти секунду — прежде чем получит процессор. Терминал, который реагирует на нажатие клавиши через секунду, непригоден для работы.
Уменьшить квант? При кванте 1 мс полный круг — 100 мс, латентность терпимая. Но переключение контекста стоит 1-10 мкс, и при кванте 1 мс накладные расходы составляют 0.1-1% процессорного времени. При 0.1 мс — уже 1-10%. Вычислительные задачи теряют ощутимую долю производительности на переключениях.
Round-robin не может дать и справедливость, и низкую латентность, и высокий throughput. Фиксированный квант — слишком грубый инструмент.
CFS: Completely Fair Scheduler
С версии ядра 2.6.23 (2007, автор Ingo Molnar) до версии 6.5 включительно Linux использовал CFS (Completely Fair Scheduler, полностью справедливый планировщик). В ядре 6.6 (октябрь 2023) CFS заменён на EEVDF — об этом ниже. Но принципы CFS остаются фундаментом: EEVDF развивает ту же идею виртуального времени, добавляя к ней дедлайны. Поэтому сначала разберём CFS.
Идея CFS — отказаться от фиксированных квантов и вместо этого отслеживать, сколько процессорного времени каждый поток уже получил.
vruntime: виртуальное время выполнения
Центральное понятие CFS — vruntime (virtual runtime, виртуальное время выполнения). Это число наносекунд, которое поток «считает» за собой как использованное процессорное время. Для потока с обычным приоритетом vruntime растёт с той же скоростью, что и реальное время: 1 мс на процессоре = +1 мс к vruntime.
Правило CFS простое: всегда выполнять поток с наименьшим vruntime. Поток, получивший меньше всего процессорного времени, — самый «обделённый», и справедливость требует дать ему CPU в первую очередь.
Пока поток выполняется — его vruntime растёт. Пока поток спит (ожидает I/O, таймер, блокировку) — vruntime не изменяется. Это создаёт разрыв: вычислительный поток, непрерывно работающий на CPU, накапливает высокий vruntime. Интерактивный поток, проводящий 99% времени в ожидании ввода, сохраняет низкий vruntime.
Красно-чёрное дерево
CFS хранит все готовые к исполнению (runnable) потоки в красно-чёрном дереве (BST, самобалансирующийся вариант), упорядоченном по vruntime. Поток с минимальным vruntime находится в самом левом узле дерева. Выбор следующего потока — обход до крайнего левого узла — занимает O(1), потому что ядро кеширует указатель на этот узел. Добавление и удаление потока стоят O(log n).
vruntime
|
+--------+--------+
| |
[vrt=50] [vrt=120]
/ \ / \
[vrt=30] [vrt=70] [vrt=100] [vrt=200]
^
|
самый левый -- следующий на выполнение
При 400 потоках на машине runnable в каждый момент обычно 5-20. Дерево из 20 элементов — высота 4-5, операции над ним занимают десятки наносекунд. Стоимость решения планировщика пренебрежимо мала по сравнению со стоимостью самого переключения контекста.
Когда переключать
CFS не переключает поток после фиксированного кванта. Вместо этого при каждом тике таймера (или при пробуждении потока) CFS сравнивает vruntime текущего потока с vruntime самого «обделённого» потока в дереве. Если разница превышает порог — происходит переключение.
Порог определяется двумя параметрами: sched_latency и sched_min_granularity (эти параметры действуют в ядрах до 6.5; в EEVDF они заменены на base_slice — см. раздел ниже). Параметр sched_latency (по умолчанию около 6 мс для небольшого числа потоков) задаёт целевой период, за который каждый runnable-поток должен получить хотя бы один квант процессора. Идеальный квант: sched_latency / число_runnable_потоков.
4 runnable-потока + sched_latency 6 мс = квант ~1.5 мс на каждый. 8 потоков — ~0.75 мс. Но при 100 runnable-потоках квант стал бы 0.06 мс (60 мкс) — слишком мало, накладные расходы на переключение сожрут полезное время. Поэтому существует sched_min_granularity (~0.75 мс по умолчанию) — минимальный квант, ниже которого CFS не переключает. При 100 runnable-потоках каждый получит 0.75 мс, а полный «круг» составит 75 мс.
В ядрах до 6.5 текущие значения этих параметров на конкретной машине можно посмотреть в /proc/sys/kernel/sched_latency_ns и /proc/sys/kernel/sched_min_granularity_ns.
Интерактивная отзывчивость
Представим сценарий: на сервере параллельно идёт компиляция ядра Linux (make -j4, 4 потока, каждый непрерывно потребляет CPU) и открыт терминал, в котором пользователь набирает команды.
Поток терминала спит в системном вызове read(), ожидая ввода с клавиатуры. Его vruntime не растёт. Четыре потока компиляции работают непрерывно, их vruntime увеличивается каждую наносекунду.
Пользователь нажимает клавишу. Контроллер клавиатуры генерирует аппаратное прерывание. Ядро обрабатывает прерывание, помещает символ в буфер терминала, будит поток терминала — переводит его из SLEEPING в RUNNING и вставляет в красно-чёрное дерево планировщика. vruntime этого потока значительно ниже, чем у любого из потоков компиляции — он спал секунды, пока они работали.
CFS видит: в дереве появился поток с vruntime намного меньше, чем у текущего. Разница превышает порог. CFS немедленно вытесняет текущий поток компиляции и передаёт процессор терминалу. На практике время от прерывания клавиатуры до начала исполнения потока терминала составляет единицы микросекунд.
Терминал обрабатывает символ, отправляет echo на экран и снова вызывает read() — засыпает. CFS возвращает процессор потокам компиляции. Весь эпизод занял десятки микросекунд — потоки компиляции потеряли ничтожную долю времени, а пользователь получил мгновенную реакцию.
Именно поэтому терминал остаётся отзывчивым во время тяжёлой компиляции. CFS не назначает интерактивным потокам специальный приоритет — механизм vruntime делает это автоматически: кто мало работал, тот получает процессор первым.
При пробуждении после длительного сна vruntime потока может оказаться настолько низким, что поток получит непропорционально большой квант. Чтобы этого избежать, CFS ограничивает vruntime при пробуждении: устанавливает его не ниже min_vruntime текущей очереди минус sched_latency. Поток получает приоритет, но не бесконечный.
Приоритеты: nice и весовые коэффициенты
Не все потоки равнозначны. Фоновая индексация файлов не должна забирать столько же CPU, сколько веб-сервер, обслуживающий клиентов. В Unix-системах приоритет обычных (не реального времени) потоков задаётся значением nice — целым числом от -20 (высший приоритет) до +19 (низший).
Название отражает «вежливость»: поток с nice +19 максимально «вежлив» и уступает процессор остальным. Поток с nice -20 «невежлив» и берёт максимум. Значение по умолчанию — 0.
CFS преобразует nice в весовые коэффициенты (weights). Каждое значение nice соответствует числовому весу из таблицы ядра sched_prio_to_weight. Ключевое соотношение: каждый шаг nice отличается примерно в 1.25 раза. nice 0 соответствует вес 1024, nice -1 — примерно 1277, nice +1 — примерно 820.
Вес влияет на скорость роста vruntime. Для потока с весом w вычисление vruntime выглядит так:
delta_vruntime = delta_exec * (NICE_0_WEIGHT / weight)
где delta_exec — реально проведённое на CPU время, а NICE_0_WEIGHT = 1024 (вес nice 0). Поток с высоким весом (низкий nice, высокий приоритет) накапливает vruntime медленнее, поэтому дольше остаётся «обделённым» в глазах CFS и получает больше процессорного времени.
Конкретный пример: два потока, один с nice 0 (вес 1024), другой с nice +5 (вес ~335). Отношение весов: 1024 / 335 = ~3.06. CFS распределит процессорное время в соотношении примерно 3:1 — первый поток получит ~75% CPU, второй ~25%. При этом оба потока считают, что система «справедлива»: каждый получает время пропорционально своему весу.
Только root может устанавливать отрицательные значения nice (повышать приоритет). Обычный пользователь может только увеличивать nice (понижать приоритет) своих процессов. Утилита nice задаёт приоритет при запуске: nice -n 15 make -j4 запустит компиляцию с пониженным приоритетом. Утилита renice меняет nice работающего процесса: renice -n 10 -p 1234.
EEVDF: наследник CFS
Начиная с ядра 6.6 (октябрь 2023, автор Peter Zijlstra) планировщик обычных задач (SCHED_NORMAL) — EEVDF (Earliest Eligible Virtual Deadline First, выбор по ближайшему допустимому виртуальному дедлайну). Модель “nice → weight → vruntime” остаётся основой и в EEVDF. Меняется не базовая идея справедливости, а способ выбора следующего runnable-потока и latency knobs, которыми эта справедливость настраивается. EEVDF сохраняет ключевую идею CFS — виртуальное время выполнения и красно-чёрное дерево, — но добавляет к vruntime второе измерение: виртуальный дедлайн (virtual deadline).
В CFS единственным критерием выбора было vruntime: запускается поток с наименьшим vruntime. Это создавало проблему: коротким интерактивным задачам и длинным вычислительным задачам назначался квант одинаковой природы, а настройки справедливости и латентности задавались эвристиками (sched_latency, sched_min_granularity). EEVDF убирает эти эвристики и вычисляет для каждого потока виртуальный дедлайн: VD = eligible_time + slice / weight. Планировщик выбирает поток с наименьшим VD среди допустимых (eligible) — тех, чей vruntime не опережает справедливое значение.
Ключевое отличие: поток с коротким slice получает более ранний дедлайн и обслуживается раньше — без специальных эвристик для интерактивности. Интерактивный поток, которому нужны считанные микросекунды CPU, автоматически получает приоритет над вычислительным потоком, запросившим длинный квант. Параметр base_slice (по умолчанию 3 мс) заменяет sched_min_granularity; длину кванта конкретного потока можно задать через системный вызов sched_setattr().
На практике для большинства серверных и десктопных нагрузок поведение EEVDF близко к CFS. Разница заметна на нагрузках со смешанными латентными и вычислительными задачами, где EEVDF обеспечивает более предсказуемую латентность без ручной настройки.
Политики реального времени
Планировщик EEVDF (до версии 6.6 — CFS) управляет обычными потоками с политикой SCHED_NORMAL (она же SCHED_OTHER). Но некоторые задачи требуют гарантий, которые CFS не может дать: аудио-обработка, промышленные контроллеры, телекоммуникационное оборудование. Для них Linux предоставляет политики реального времени (real-time scheduling policies).
SCHED_FIFO (First In, First Out): поток получает процессор и работает, пока не заблокируется или пока его не вытеснит поток с более высоким RT-приоритетом (RT — Real-Time). Временных квантов нет — поток SCHED_FIFO может занимать ядро неограниченно долго. RT-приоритеты: от 1 (низший) до 99 (высший).
SCHED_RR (Round Robin): как SCHED_FIFO, но потоки с одинаковым RT-приоритетом чередуются по кругу с квантом ~100 мс.
Критическое правило: RT-потоки всегда вытесняют потоки SCHED_NORMAL, независимо от nice. Поток SCHED_FIFO с приоритетом 1 (минимальным) вытеснит любой поток SCHED_NORMAL, даже с nice -20. Это два разных класса планирования с абсолютным порядком: RT > NORMAL.
приоритет (выше = больше CPU)
+---------------------------+
| SCHED_FIFO / SCHED_RR | RT приоритеты 1..99
| (всегда вытесняют NORMAL) |
+---------------------------+
| |
+---------------------------+
| SCHED_NORMAL (EEVDF/CFS) | nice -20..+19
| (vruntime, weights, VD) |
+---------------------------+
Ошибка в RT-потоке опасна: бесконечный цикл в потоке SCHED_FIFO с приоритетом 99 заблокирует ядро полностью — ни один другой поток не получит процессорного времени на этом ядре. Для защиты ядро резервирует 5% процессорного времени для не-RT задач (/proc/sys/kernel/sched_rt_runtime_us, по умолчанию 950000 мкс из каждых 1000000, то есть RT может использовать 95% ядра, а 5% остаётся для SCHED_NORMAL).
Многоядерность: очереди и балансировка
На многоядерной машине планировщик работает не с одной глобальной очередью, а с отдельной очередью (runqueue) для каждого логического ядра. Каждая очередь содержит своё красно-чёрное дерево и свой min_vruntime. Поток принадлежит очереди одного ядра — не всех сразу.
Это решение обусловлено масштабируемостью: глобальная очередь на 64-ядерной машине потребовала бы блокировки при каждом решении планировщика. При 100 000 переключений в секунду на каждом ядре — 6.4 миллиона захватов блокировки в секунду. Раздельные очереди позволяют каждому ядру принимать решения локально, без координации.
Но раздельные очереди создают другую проблему: дисбаланс. Если на ядре 0 в очереди 20 runnable-потоков, а на ядре 1 — два, ядро 1 простаивает, пока ядро 0 перегружено. Для этого ядро периодически (каждые ~4 мс по таймеру и при каждом уходе ядра в idle) запускает балансировщик нагрузки (load balancer), который перемещает потоки из перегруженных очередей в недогруженные. Балансировщик учитывает топологию: миграция между ядрами одного физического процессора (общий L2/L3) дешевле, чем между ядрами разных сокетов.
Привязка к процессорам
По умолчанию планировщик может перемещать поток между любыми ядрами. При миграции данные, которые поток загрузил в L1/L2 кэш исходного ядра, бесполезны — на новом ядре кэш холодный. Первые обращения к памяти пойдут в L3 или RAM, что в 10-50 раз медленнее L1.
CPU affinity (привязка к процессору) позволяет ограничить множество ядер, на которых может работать поток. Системный вызов sched_setaffinity() и утилита taskset задают маску допустимых ядер.
taskset -c 0,1 ./worker # поток работает только на ядрах 0 и 1
taskset -c 2-3 ./io_handler # поток работает только на ядрах 2 и 3
На NUMA-системах (Non-Uniform Memory Access), где каждая группа ядер имеет «свою» оперативную память, привязка особенно важна. Доступ к локальной памяти NUMA-узла занимает ~80 нс, к памяти удалённого узла — ~140 нс. Без привязки планировщик может перенести поток на ядро, память которого находится на другом NUMA-узле, и все обращения к данным потока станут на 70% дороже.
Ядро само старается сохранять потоки на одних и тех же ядрах (cache affinity heuristic — эвристика привязки к кешу), но под нагрузкой балансировщик может мигрировать потоки ради равномерного распределения. Явная привязка через taskset или sched_setaffinity() переопределяет эвристику.
Задача: на 8-ядерном сервере с NUMA (2 узла по 4 ядра) PostgreSQL работает медленнее, чем ожидалось. perf stat показывает высокий процент remote NUMA accesses. Как исправить?
Частая ошибка: привязать все процессы PostgreSQL к одному NUMA-узлу через
taskset -c 0-3. Если БД использует больше памяти, чем доступно на одном узле, ядро начнёт выделять страницы на удалённом узле, и проблема вернётся.Правильный подход: использовать
numactl --interleave=allпри запуске PostgreSQL. Это распределяет выделение памяти равномерно по всем NUMA-узлам, обеспечивая предсказуемое среднее время доступа. Для shared buffers, которые доступны всем процессам PostgreSQL, interleave даёт лучший результат, чем привязка к одному узлу.
Наблюдение за планировщиком
Поведение планировщика можно наблюдать через несколько инструментов.
Команда vmstat 1 показывает столбец cs (context switches) — количество переключений контекста в секунду. На типичном сервере под нагрузкой это 10 000-100 000 переключений в секунду. Аномально высокое значение (миллионы) указывает на contention (борьбу за ресурс): потоки постоянно просыпаются, получают процессор, почти сразу блокируются и засыпают.
Файл /proc/<pid>/status содержит два счётчика для конкретного процесса: voluntary_ctxt_switches (поток сам уступил CPU — заблокировался на I/O или мьютексе) и nonvoluntary_ctxt_switches (ядро вытеснило поток по таймеру). Высокое значение nonvoluntary переключений означает, что поток интенсивно вычисляет и не успевает завершить работу до истечения кванта — CPU-bound нагрузка (ограниченная процессором). Высокое voluntary — поток часто блокируется, I/O-bound нагрузка (ограниченная вводом-выводом).
Утилита ps с нужными полями показывает класс планировщика и приоритет каждого процесса:
ps -eo pid,ni,cls,comm --sort=-pcpu | head -20
Столбец CLS отображает политику: TS — SCHED_NORMAL (timesharing), FF — SCHED_FIFO, RR — SCHED_RR. Столбец NI — значение nice.
Команда chrt -p <pid> показывает текущую политику планирования и приоритет конкретного процесса. chrt -f -p 50 <pid> переключит процесс на SCHED_FIFO с приоритетом 50 (требует root).
Для более глубокого анализа — perf sched record записывает все события планировщика (переключения, пробуждения, миграции) в трейс, который потом можно визуализировать через perf sched timehist или perf sched map. Трейс показывает, какой поток на каком ядре работал, когда был вытеснен и чем, сколько времени провёл в очереди ожидания. Это позволяет обнаружить проблемы, невидимые через агрегированные счётчики: например, поток регулярно мигрирует между ядрами двух разных NUMA-узлов, теряя кэш при каждой миграции.
Следствие: от планировщика к синхронизации
Планировщик может прервать поток между любыми двумя машинными инструкциями. Поток не знает, что его прервали, — после возобновления он продолжает с того же регистра и того же адреса. Для однопоточных программ это прозрачно. Для многопоточных — источник всех проблем конкурентного доступа.
Два потока инкрементируют общий счётчик counter. На уровне машинного кода инкремент — три инструкции: загрузка значения из памяти, сложение, запись обратно. Если планировщик прерывает первый поток после загрузки, но до записи, а второй поток в это время читает, изменяет и записывает то же значение — результат одного инкремента теряется. Из двух инкрементов фиксируется один.
поток A поток B
-------- --------
load counter (= 5)
load counter (= 5)
add 1 (= 6)
add 1 (= 6)
store counter (= 6)
store counter (= 6)
результат: counter = 6, хотя было два инкремента (ожидание: 7)
Эта ситуация — гонка данных (data race) — прямое следствие двух фактов: потоки разделяют память (общее адресное пространство) и планировщик может прервать поток в произвольный момент. Защита от гонок — мьютексы, атомарные операции, барьеры памяти — тема синхронизации, вытекающая непосредственно из устройства планировщика.
См. также
- Ruby GVL timer thread — внутренний timer thread устанавливает флаг каждые ~100мс, заставляя текущий Ruby-поток освободить GVL и позволить другому получить CPU
Sources
- Robert Love, 2010, Linux Kernel Development — Chapter 4: Process Scheduling — https://www.oreilly.com/library/view/linux-kernel-development/9780768696974/
- Ingo Molnar, 2007, CFS Scheduler Design — https://docs.kernel.org/scheduler/sched-design-CFS.html
- Peter Zijlstra, 2023, EEVDF Scheduler — https://docs.kernel.org/scheduler/sched-eevdf.html
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapter 35: Process Priorities and Scheduling — https://man7.org/tlpi/
man 7 sched— описание политик планирования Linux — https://man7.org/linux/man-pages/man7/sched.7.html