9. Context Switch. Почему переключение между потоками дорого

Что происходит, когда CPU «забывает» задачу

«Истинная цена переключения контекста кроется не во времени переключения, а в ущербе, нанесенном иерархии памяти за время вашего отсутствия».
— Инженерная мудрость архитекторов высоконагруженных систем

Представьте, что вы с головой погружены в чтение захватывающего детективного романа. Вы находитесь на 142-й странице, удерживая в рабочей памяти сложнейшую паутину фактов: пятерых подозреваемых, порванную фотокарточку из гостиницы, фальшивое алиби в Лондоне и ночной рейс в Париж.

Внезапно кто-то вырывает книгу у вас из рук, клеит закладку, кладет перед вами толстенный учебник по органической химии и приказывает: «Срочно читай главу про ароматические углеводороды!».

Ваш мозг испытывает болезненный шок. Вам нужно полностью вытеснить из сознания образы подозреваемых, переключиться на бензольные кольца и сосредоточиться на новой задаче. Но не успели вы прочитать и двух страниц, как учебник химии отбирают и снова швыряют детектив: «Продолжай роман!». Где был лондонский поезд? Кто держал пистолет? Вам требуется несколько минут, чтобы перечитать последние абзацы и вернуть мысли в прежнее русло.

Именно этот сценарий непрерывно разыгрывается на кристалле кремния, когда вы запускаете go run main.go. В многозадачной операционной системе ваш процесс делит процессорное время с тысячами других системных потоков. Когда таймер планировщика прерывает поток, ядро ОС выполняет Context Switch (Переключение контекста).

Для разработчика на прикладном уровне это выглядит как незаметная глазу «магия». Но для микропроцессора это тяжелейшая хирургическая операция, способная обрушить пропускную способность бэкенда при росте нагрузки.


Стоимость переключения: от регистров до кэш-линий

Переключение контекста — это не просто перезапись счетчика команд на другой адрес в оперативной памяти. Это полный сброс внутреннего состояния вычислительного конвейера CPU.

Инженеры делят стоимость переключения контекста на две составляющие: прямую (Direct Overhead) и косвенную (Indirect Overhead).

DIAGRAM
                  АНАТОМИЯ ПЕРЕКЛЮЧЕНИЯ КОНТЕКСТА
 ┌─────────────────────────────────────────────────────────────┐
 │                  ПРЯМЫЕ ЗАТРАТЫ (Direct)                   │
 │                                                             │
 │  1. Сохранение регистров CPU (RAX..R15, RSP, RIP, RFLAGS)   │
 │  2. Сохранение векторных регистров (FPU, AVX-512: XSAVE)    │
 │  3. Смена стека ядра (RSP) и дескриптора task_struct        │
 │  4. Запись в регистр CR3 (смена таблиц страниц PML4)        │
 └──────────────────────────────┬──────────────────────────────┘
                                │
                                ▼
 ┌─────────────────────────────────────────────────────────────┐
 │           КОСВЕННЫЕ ЗАТРАТЫ (Indirect — НАСТОЯЩИЙ КИЛЛЕР!) │
 │                                                             │
 │  ★ Сброс / промахи TLB (Translation Lookaside Buffer)       │
 │  ★ Разрушение кэшей L1i / L1d / L2 (Cold Cache Misses)      │
 │  ★ Сброс конвейера инструкций (Branch Predictor Flush)      │
 └─────────────────────────────────────────────────────────────┘

Разберем каждый компонент этой цены:

  1. Сохранение регистрового состояния (Registers): Процессору необходимо сохранить ~60–100 регистров x86-64 (RAX, RBX, RSP, RIP, RFLAGS и др.) в структуру ядра. А если задача задействовала векторные инструкции AVX2 или AVX-512, инструкции сохранения состояния XSAVE / XRSTOR вынуждены перекачивать сотни байт векторных регистров (YMM0..YMM15, ZMM0..ZMM31), отнимая сотни тактов за раз.
  2. Виртуальная память и таблицы страниц (Page Tables): Каждый процесс изолирован в собственном адресном пространстве. Чтобы процессор увидел память нового процесса, ядро перезаписывает управляющий регистр CR3 физическим адресом его корневой таблицы страниц PML4.
  3. Крах кэша трансляции адресов (TLB): Кэш трансляций виртуальных адресов в физические (TLB — Translation Lookaside Buffer) жестко привязан к адресному пространству. При смене CR3 старые трансляции становятся недействительными. Происходит TLB Flush (очистка кэша), и каждый последующий доступ к памяти заставляет процессор мучительно выполнять четырех- или пятиуровневый Page Walk по иерархии таблиц в оперативной памяти.
  4. Охлаждение процессорных кэшей (CPU Caches L1/L2/L3): Кэш-память процессора хранит горячие данные и инструкции предыдущей задачи. При переключении эти данные становятся бесполезным мусором для нового процесса. Новый поток сталкивается с волной cache misses, заставляя шину памяти простаивать по 50–100 наносекунд на каждую загрузку строки кэша.
  5. Сброс конвейера и предсказателя переходов (Pipeline & Branch Prediction): Конвейер процессора (глубиной в 14–20 стадий) сбрасывается. Блок предсказания ветвлений (Branch Target Buffer) теряет накопленную историю переходов, из-за чего процессор начинает работу с серии остановок (Pipeline Stalls).

Под капотом Linux: switch_to и pt_regs

В ядре Linux аппаратное переключение между потоками сосредоточено в низкоуровневом ассемблерном макросе switch_to (для x86_64 в файле arch/x86/include/asm/switch_to.h и функции __switch_to):

C
// Упрощенная концептуальная логика переключения в ядре Linux
#define switch_to(prev, next, last) do {     __switch_to(prev, next);     /* Сохранение регистров RIP, RSP, RFLAGS в структуре pt_regs */     save_prev_state(prev);     /* Переключение контекста виртуальной памяти mm_struct */     switch_mm(prev->mm, next->mm, next);     /* Обновление кэша и TLB при смене адресного пространства */     flush_tlb_mm(next->mm);     /* Восстановление регистрового файла нового потока */     restore_next_state(next); } while (0)

Ключевая структура ядра, хранящая состояние задачи, — это task_struct (включающая системный дескриптор thread_info и массив сохраненных регистров pt_regs). Именно в нее ядро «эвакуирует» контекст, когда планировщик решает временно лишить поток процессорного времени.


Go Runtime: Green Threads вместо OS Threads

Именно здесь архитектура Go демонстрирует свой высочайший класс. Go отказывается от прямого маппинга 1:1 горутин на системные потоки ядра. Вместо этого рантайм использует M:N планировщик:

  • M (Machine): Реальный поток операционной системы (kernel/sys thread).
  • G (Goroutine): Легковесная единица исполнения рантайма Go.
  • P (Processor): Логический процессор рантайма, ресурс для исполнения кода, связывающий G и M.

Когда горутина блокируется на чтении из сетевого сокета или канале, рантайм Go не делает системный вызов ядра для переключения контекста!

Вместо этого:

  1. Контекст текущей G мгновенно сохраняется прямо на стеке текущего системного потока M.
  2. Логический процессор P мгновенно берет следующую готовую задачу G из своей локальной очереди выполнения.
  3. Если локальная очередь пуста, рантайм активирует системный монитор sysmon и выполняет Work Stealing у соседних P.
DIAGRAM
  СИСТЕМНЫЙ CONTEXT SWITCH (Linux OS)      CONTEXT SWITCH В РАНТАЙМЕ GO
 ┌───────────────────────────────────┐    ┌───────────────────────────────────┐
 │ Переход в Ring 0 (Syscall/Tick)   │    │ Никакого перехода в ядро! (Ring 3)│
 │ Сохранение 60-100 регистров CPU   │    │ Сохранение SP, PC, BP (gobuf)     │
 │ Смена CR3 (Таблицы страниц)       │    │ CR3 НЕ МЕНЯЕТСЯ (память общая)    │
 │ TLB Shootdown / Крах кэша         │    │ TLB остается горячим              │
 │ Векторы AVX-512 (XSAVE/XRSTOR)    │    │ Ассемблерная функция runtime.gogo │
 ├───────────────────────────────────┤    ├───────────────────────────────────┤
 │ Длительность: 3 000 - 15 000 нс   │    │ Длительность: 10 - 200 нс         │
 └───────────────────────────────────┘    └───────────────────────────────────┘

Переключение между G в Go — это классический Green Thread Context Switch, написанный на чистом ассемблере (runtime.gogo):

  • Сохраняется совсем немного состояния: в структуре gobuf это указатель стека SP, счетчик PC, указатель кадра BP (а также g и контекст ctxt).
  • Виртуальная память не затрагивается: регистр CR3 остается на месте, таблицы страниц и TLB не сбрасываются.
  • Процессор не покидает User Space (Ring 3).

Стоимость переключения между горутинами составляет всего ~100–200 наносекунд (около 1,000–2,000 тактов CPU), что в 10–50 раз быстрее системного переключения потоков ОС!


Mechanical Sympathy: Кэш-линии, TLB и конвейер процессора

Для ведущего инженера критически важно понимать физику того, как частые переключения задач истощают микроархитектуру современного процессора:

1. Cache Thrashing (Разрушение кэш-памяти)

Если горутина A обрабатывает входящий JSON-запрос и читает большую хэш-таблицу map[string]int, она непрерывно подгружает данные в быстрый кэш L1d процессора.

При переключении на Goroutine B (или сторонний процесс) новые данные безжалостно вытесняют данные задачи A. Когда планировщик возвращает задачу A, она получает тяжелый L1 miss. Каждый промах по кэшу L1 обходится в ~4 такта задержки, L2 miss — в ~12–14 тактов, а L3 miss заставляет процессор ждать ответа от RAM целых ~40+ тактов! В высоконагруженной системе со 100 000 RPS этот микроархитектурный «дребезг» превращается в миллисекунды хвостовой задержки (Tail Latency p99).

2. NUMA и локальность памяти

На многопроцессорных серверах с архитектурой NUMA (Non-Uniform Memory Access) модули памяти привязаны к конкретным сокетам CPU.

Если системный поток M перепрыгивает с одного NUMA-узла на другой из-за решения планировщика ОС, доступ к памяти становится асинхронным и мучительно долгим (задержка возрастает в 3–5 раз). Рантайм Go не закрепляет логические процессоры P за ядрами сам (за размещение потоков отвечает планировщик ОС), поэтому при необходимости жесткой изоляции инженеры прибегают к утилите numactl или системным вызовам в связке с runtime.lockOSThread.

3. Branch Prediction & Pipeline Flush

Конвейер современного CPU рассчитывает на предсказуемость кода: блоки предсказания ветвлений (Branch Target Buffer) угадывают результат if-else с точностью до 98%. При частых вытеснениях история переходов сбрасывается. Процессор теряет все предвычисленные инструкции и намертво застревает на конвейерных паузах (Pipeline Stalls). В утилите top нагрузка процессора может отображаться как умеренная (CPU load 50%), но реальная производительность падает в разы.

Bash
# Диагностика профиля задержек в Go через pprof
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30
# Обратите внимание на функции "runtime.schedule" и "runtime.gopark".
# Высокий процент времени в gopark свидетельствует об избыточном ожидании каналов, мьютексов или сетевого I/O!

Оптимизация: Как Go минимизирует OS Context Switch

Архитектура Go спроектирована как единый слаженный механизм для предотвращения системных переключений:

  1. Балансировка GOMAXPROCS:
    По умолчанию переменная равна количеству логических ядер CPU. Попытка установить значение, в разы превышающее число ядер, не ускорит CPU-bound расчеты, а лишь спровоцирует войну потоков за ядра и лавину сбросов кэшей.
  2. Асинхронный сетевой поллер netpoller:
    Рантайм интегрируется с механизмами ядра epoll (Linux), kqueue (macOS/BSD) и IOCP (Windows). Горутина, ожидающая ответа от сокета, не удерживает системный поток ОС! Она переводится в статус ожидания и регистрируется в очереди netpoll. Освободившийся поток M немедленно берет другую работу, а системный фоновый тред sysmon будит горутину, когда сокет готов к чтению.
  3. Алгоритм Work Stealing:
    Когда процессор P завершает свои локальные задачи, он не усыпляет системный поток вызовом ядра. Вместо этого он крадет задачи у соседних процессоров через атомарные операции CAS в пространстве пользователя.
  4. Осознанное использование runtime.LockOSThread():
    Вызов жестко привязывает горутину к системному треду ядра для совместимости с cgo или графическими тулкитами. Поскольку это отключает Work Stealing и увеличивает риск переключений ОС, его применяют исключительно точечно.

Итог

  1. Context Switch — дорогая системная операция ядра. Она включает сохранение регистров, сброс TLB, вымывание линий кэша L1/L2 и перезапуск конвейера процессора.
  2. Цена системного переключения: От 10 000 до 50 000 тактов CPU (~3–15 мкс). В высоконагруженных распределенных системах избыточные context switches могут сжигать до половины всей вычислительной мощности сервера.
  3. Преимущество Go: Благодаря M:N планировщику и Green Threads переключение горутин происходит в User Space без смены таблиц страниц и регистров CR3, укладываясь всего в ~100–200 наносекунд (~1 000–2 000 тактов).
  4. Главные угрозы производительности бэкенда — это не число горутин само по себе, а высокая конкуренция за блокировки мьютексов, разрушение процессорного кэша (Cache Thrashing) и блокирующие системные вызовы дискового ввода-вывода.
  5. Золотое правило конфигурации: держите GOMAXPROCS синхронизированным с доступными ядрами процессора и контейнера.

Мы разобрались с тем, почему переключение между потоками так дорого обходится процессору. Но как операционная система решает, кому уступить место в первую очередь, если ресурсов не хватает? В следующей статье мы разберем шкалу приоритетов: 10. Приоритеты процессов и потоков. nice, renice, realtime.