Оглавление
- Фундаментальные концепции
- Взаимодействие с ОС и I/O
- Go Runtime и модель GMP
- Примитивы синхронизации
- Каналы (Channels)
- Оператор Select и Context
- Обработка сигналов и завершение
- Проблемы многопоточности
1. Фундаментальные концепции
Конкурентность vs Параллелизм
По определению Роба Пайка:
- Конкурентность (Concurrency): Композиция независимо выполняющихся процессов. Это про структуру программы. Способность решать несколько задач одновременно, переключаясь между ними.
- Параллелизм (Parallelism): Одновременное выполнение вычислений. Это про исполнение. Невозможен без многоядерной архитектуры.
Модели многозадачности
- 1:1 (Один к одному): Один поток ОС на одну задачу.
- Плюсы: Простота, исполнение на уровне ядра.
- Минусы: Дорогое создание потоков, накладные расходы.
- 1:N (Один ко многим): Один поток ОС на множество задач.
- Плюсы: Эффективно для IO-задач.
- Минусы: Блокировка одной задачи блокирует весь поток ОС. Нет параллелизма.
- M:N (Многие ко многим): Множество задач на множество потоков ОС. Используется в Go.
- Плюсы: Гибридная модель, эффективное использование ресурсов.
Виды многозадачности
- Кооперативная: Задачи добровольно передают управление (риск блокировки всей системы зависшей задачей).
- Вытесняющая: Система сама прерывает задачи по кванту времени.
- В Go: Гибридная модель (кооперативная с элементами вытеснения через
sysmon).
2. Взаимодействие с ОС и I/O
User Space vs Kernel Space
- User space: Изолированная область для приложений. Ограничен доступ к ресурсам.
- Kernel space: Область ядра ОС. Полный доступ к оборудованию.
- Системные вызовы (Syscalls): Способ обращения приложения из user space к функциям ядра.
- Context Switch: Дорогостоящий процесс сохранения/восстановления состояния потока при переключении между ними или при переходе в режим ядра.
Мультиплексированный ввод-вывод
Позволяет ждать событий на множестве дескрипторов сразу:
- select(): Устаревший механизм (лимит 1024 дескриптора, медленный перебор).
- epoll (Linux) / kqueue (BSD): Современные механизмы для решения проблемы C10K. Позволяют эффективно обслуживать десятки тысяч соединений. Используются в Go Netpoller.
3. Go Runtime и модель GMP
Компоненты Runtime
Go-приложения имеют большой рантайм (МБ), который включает:
- Scheduler: Управление горутинами.
- GC: Сборщик мусора.
- Netpoller: Асинхронный сетевой ввод-вывод.
- Sysmon: Системный мониторинг (фоновая проверка).
Модель GMP (M:N)
- G (Goroutine): Логический поток. Начальный стек 2KB (динамический).
- M (Machine): Поток ОС (thread). Выполняет код.
- P (Processor): Контекст планирования. Количество P = количеству ядер (
GOMAXPROCS). Связывает G и M.
Очереди:
- LRQ (Local Run Queue): Локальная очередь у каждого P.
- GRQ (Global Run Queue): Глобальная очередь для “лишних” горутин.
Механизмы планировщика (Work-stealing, Handoff)
Раунд планирования:
- 1/61 времени — проверка GRQ.
- Проверка собственной LRQ.
- Work-stealing: Кража половины задач из LRQ другого процессора P (если своя пуста).
- Проверка Netpoller.
«Тик» в Go
- Тик — это внутреннее событие планировщика Go, когда
M(поток ОС) проверяет и запускает горутину. -
Связка терминов:
G— goroutineP— виртуальный процессор (scheduler context)M— машина/поток ОС, выполняющийGчерезP
«Every 61 ticks» в планировщике
- Каждые 61 тик
Mпроверяет глобальную очередь готовых к запуску горутин (global run queue). -
Цель:
- Обеспечить справедливое распределение работы между
P - Избежать голодания горутин
- Обеспечить справедливое распределение работы между
- Локальная очередь
runqу каждогоPиспользуется чаще, глобальная — реже.
Пример кода из runtime Go:
if schedtick%61 == 0 && sched.runqsize > 0 {
gp = globrunqget(_p_)
if gp != nil {
return gp, false
}
}
Почему выбрано число 61?
- Эмпирически оптимизировано для баланса производительность ↔ fairness.
- Проверка глобальной очереди каждый тик — дорого, слишком редкая проверка — плохо для справедливости.
-
61 — простое число:
- Снижает вероятность синхронизации циклов между потоками/виртуальными процессорами
- Уменьшает lock contention на глобальную очередь
Простые числа и распределение нагрузки
- Если бы использовалось составное число (например, 60), циклы нескольких
Pмогли бы совпадать, вызывая пиковую нагрузку на lock. - Простое число «размывает» циклы, делая проверку очередей более равномерной.
- Используется как псевдослучайный распределитель, повышающий масштабируемость.
Lock contention
-
Lock contention — конкуренция потоков/горутин за один и тот же lock.
- Проблемы:
- Потоки ждут, пока lock освободится → падение производительности
- Ухудшение масштабируемости при росте числа потоков
- Методы снижения lock contention:
- Сокращать критическую секцию (короткий
Lock/Unlock) - Использовать локальные очереди (
P.runq) вместо глобальной - Атомарные операции (
sync/atomic) - Шардирование ресурсов
- Сокращать критическую секцию (короткий
- В Go runtime:
- Локальные очереди минимизируют lock contention
- Проверка глобальной очереди раз в 61 тик уменьшает «толпу» потоков вокруг одного lock
Итог
- 1/61 тик = каждые 61 планировочный цикл
Mпроверяет глобальную очередь, чтобы:- Обеспечить fairness
- Снизить lock contention
- Поддерживать производительность при высокой конкуренции
- Простое число помогает равномерно распределять проверки между потоками, предотвращая пики нагрузки.
Handoff (Передача):
- Если горутина делает блокирующий системный вызов (
syscall), она блокирует поток M. - Процессор P отсоединяется от M и находит/создает новый поток M для продолжения работы над оставшимися горутинами из своей LRQ.
Sysmon:
- Опрашивает Netpoller каждые 10 мс.
- Принудительно помечает горутины, работающие > 10 мс, для вытеснения.
Потоки ОС vs Горутины
| Характеристика | Потоки ОС | Горутины |
|---|---|---|
| Управление | Ядро ОС | Go runtime |
| Переключение | ~1-2 мкс | ~200 нс |
| Стек | Фиксированный (1-8 MB) | Динамический (от 2 KB) |
4. Примитивы синхронизации
Пакет sync
- WaitGroup: Ожидание группы горутин.
- Mutex: Взаимное исключение (один Lock в момент времени).
- RWMutex: Много читателей (RLock) ИЛИ один писатель (Lock).
- Once: Однократное выполнение кода.
- Atomic: Атомарные операции без прерываний (счетчики).
Семафоры и Мьютексы
- Семафор: Счетчик доступных ресурсов (например, ограничение пула соединений).
- Мьютекс: Взаимное исключение для одного ресурса (критическая секция).
- Пример: Мьютекс защищает структуру данных от гонок, Семафор лимитирует количество потоков в БД.
5. Каналы (Channels)
Философия и свойства
“Don’t communicate by sharing memory, share memory by communicating.”
- IPC: Реализуют межпроцессное взаимодействие внутри приложения.
- FIFO: Строгая очередность.
- Потокобезопасность: Встроенная блокировка и синхронизация.
- Буферизация:
- Небуферизированные: Рандеву (синхронизация) читателя и писателя.
- Буферизированные: Блокировка только при пустом/полном буфере.
Операции и состояния (Таблица поведения)
| Операция | nil канал |
Закрытый канал | Открытый канал |
|---|---|---|---|
close |
panic |
panic |
success |
send (<-) |
block forever |
panic |
block или success |
recv (<-) |
block forever |
never block (zero value) |
block или success |
Внутреннее устройство (hchan)
Канал — это структура в куче:
buf: Кольцевой буфер данных.lock: Мьютекс для защиты канала.recvq/sendq: Списки ожидания (очереди горутин).- Direct Write: Оптимизация — копирование данных из стека писателя в стек читателя напрямую, минуя буфер, если читатель уже ждет.
6. Оператор Select и Context
Работа Select
- Позволяет ждать готовности множества каналов.
- Если готовы несколько — выбор псевдослучайный.
- default: Делает операцию неблокирующей. Без него — блокировка до готовности любого
case. - Ошибка: Чтение из закрытого канала в
selectвсегда готово (вернет zero value), что может породить бесконечный цикл.
Контекст (context)
Механизм управления деревом вызовов.
- Отмена каскадная: Отмена родителя отменяет всех детей.
- Основные виды:
Background()/TODO(): Базовые контексты.WithCancel(): Ручная отмена.WithTimeout()/WithDeadline(): Отмена по времени.WithValue(): Передача метаданных (использовать с осторожностью).
- Done(): Канал для отслеживания завершения.
7. Обработка сигналов и завершение
Graceful Shutdown (Корректное завершение):
- Создание буферизированного канала для
os.Signal. - Подписка через
signal.Notify(наSIGINT,SIGTERM). - Ожидание сигнала и вызов
cancel()контекста. - Важно:
SIGKILLперехватить нельзя.
8. Проблемы многопоточности
При разработке следует учитывать риск возникновения:
- Deadlock: Взаимная блокировка (например, чтение из
nilканала или циклическая зависимость). - Livelock: Бесполезная активность без прогресса.
- Lock contention: Высокая конкуренция за одну блокировку, снижающая производительность.
- Утечки горутин: Горутины, заблокированные навсегда (например, в
selectбез таймаута или на чтении из канала, который никто не закроет).