Итак, финал части 1! По крайней мере основного разбора без дополнений. Собственно, сегодня мы разберем то, чем в основном с точки зрения ядра ОС и CPU являются все вот эти ваши бэкенды – системные вызовы и всё, что вокруг них.
На самом деле механизм немного замудренный и “в лоб” сразу все эти системные вызовы мы разбирать не будем, потому что вокруг них существует ещё несколько концепций.
Итак, начнем!
User mode, Kernel mode
Процессор исполняет код в разных уровнях привилегий.
Это механизм защиты, который не позволяет пользовательским программам напрямую управлять системой. Зачем? Для безопасности. Чтобы чей-нибудь опасный ассемблерный вайбкод случайно всё не поломал!
Да и вообще, если бы любая программа могла выполнять любые инструкции CPU, она могла бы:
• Читать память других процессов
• Управлять устройствами
• Изменять таблицы страниц
• Выключать систему
Чтобы этого не происходило, CPU разделяет код по уровням доступа.
Хотя в архитектуре x86 существует 4 уровня привилегий:
Ring 0 – kernel mode
Ring 1 – драйверы
Ring 2 – системные службы и файлы
Ring 3 – user mode
По факту, Ring 1 и Ring 2 – это легаси, которое Американские деды проектировали с научной красотой, но без реальной производственной необходимости. В реальной жизни используются только 2 уровня – Ring 0 и Ring 3 для бОльшей совместимости софта с другими ОС, упрощения поддержки, да и вообще переключение уровней доступа дорогое(100-150 тактов CPU), соответственно, чем больше колец, тем хуже производительность.
Получается, что
В user mode выполняется обычный код программ.
Например:
• Браузер
• База данных
• Go runtime
• CLI утилиты
• Ваш вайбкод
В этом режиме запрещены инструкции, которые могут повлиять на систему.
Например:
• Управление устройствами
• Изменение page tables
• Управление прерываниями
• Прямой доступ к физической памяти
Если программа попытается выполнить такую инструкцию, CPU сгенерирует exception(если что, это аппаратное прерывание).
В свою очередь, в kernel mode выполняется код операционной системы.
Здесь доступны все инструкции процессора.
Ядро может:
• Управлять памятью
• Планировать потоки
• Работать с драйверами устройств
• Управлять файловой системой
• Обрабатывать сетевые пакеты
Kernel mode обладает полным контролем над системой.
А теперь внимание!!!
Переход из user mode в kernel mode выполняется через системный вызов.
На x86-64 используется инструкция:
syscall
Давайте зафиксируем:
Системный вызов (system call) – обращение прикладной программы к ядру операционной системы для выполнения какой-либо операции.
Системные вызовы
Теперь рассмотрим более детально, что вообще происходит в этот момент.
Инструкция означает для процессора (CPU core), следующую последовательность действий
1) Переключить CPU в kernel mode
2) Передать управление ядру
3) Выполнение операции
После завершения происходит возврат:
sysret
Но, я уже понимаю, что возникают 2 вопроса: “Что значит “Передать управление ядру”?” и “Что ядро вообще должно исполнять?”
Давайте по порядку. Передача управления ядру происходит следующим образом:
1) CPU переключается из Ring 3 в Ring 0
2) Переключается на kernel stack
3) Загружается точка входа в kernel
4) Управление передается обработчику системного вызова
В Linux x86-64 используется соглашение:
RAX – номер syscall
RDI – arg1
RSI – arg2
RDX – arg3
R10 – arg4
R8 – arg5
R9 – arg6
Это по сути аргументы системного вызова, которые именно вы и передаете
Что происходит после перехода в kernel mode?
1) Сохранение контекста процесса
2) По номеру системного вызова из RAX ядро находит обработчик в таблице sys_call_table
3) Выполнение операции
Инструкция syscall сохраняет адрес возврата в специальный регистр RCX(То есть RIP → RCX) и RFLAGS в R11, когда ядро закончит работу, инструкция sysret возьмет этот адрес и вернет процессор обратно в Ring 3 к вашей следующей строчке кода!
А что вообще за обработчики?
На самом деле, как и обработчик прерываний, который мы обсуждали здесь в первом разделе. Операционная система при запуске похожим образом загружает точки входа в такие обработчики в особые модельно-зависимые регистры(MSR) процессора.
Например, для x86:
IA32_LSTAR – точка входа для системного вызова
IA32_STAR – сегменты перехода
IA32_FMASK – маска флагов
А номера системных вызовов?
Это просто соответствия, которые также загружаются нашей ОС. Например для linux можете посмотреть тут. 100% большинство из них узнаете!
А теперь давайте посмотрим путь самурая(зачеркнуто) системного вызова
Есть код в Go:
package mainimport ("syscall")func main() {msg := []byte("Yo bro!!!!\n")syscall.SyscallN(1,uintptr(1),uintptr(unsafe.Pointer(&msg[0])),uintptr(len(msg)),)}// func SyscallN(trap uintptr, args ...uintptr) (r1, r2 uintptr, err Errno)/*trap → номер системного вызоваargs → аргументы, которые полетят в системный вызовtrap = 1 = write(linux)args[0] = 1args[1] = указатель на буферargs[2] = длина*/
Внутри Go runtime вызывается asm-функция.
Для Linux x86-64 она выглядит примерно так:
MOVQ trap+0(FP), AXMOVQ args+8(FP), DIMOVQ args+16(FP), SIMOVQ args+24(FP), DXSYSCALL
Перед syscall:
RAX = номер системного вызова
RDI = arg1
RSI = arg2
RDX = arg3
То есть в нашем случае
RAX = 1
RDI = 1
RSI = &msg[0]
RDX = 11
Теперь что делает CPU:
Когда выполняется SYSCALL
Процессор
1) Переключается из Ring 3 → Ring 0
2) Сохраняет RIP в RCX
3) Сохраняет RFLAGS в R11
4) Загружает точку входа в kernel
Эта точка входа хранится в MSR:
IA32_LSTAR
Linux получает управление в entry_SYSCALL_64
Файл:
arch/x86/entry/entry_64.S
Дальше вызывается do_syscall_64
А теперь само ядро:
Ядро читает:
RAX = номер системного вызова и находит точку входа в обработчик в таблице.Для write это sys_write.
После чего обращаясь к драйверу терминала(если stdout) или файловой системы производим запись и далее sysret!
Разобрали только для Linux. Для Mac или Windows делать мы этого не будем, потому что получится долго и по большей части бессмысленно из-за похожести механизма по своему принципу!
Дороговизна системных вызовов и удешевление
Краткий ответ – да и еще раз да.
Но почему это вдруг? Хотя интуитивно можно понять после вышеуказанного, что это явно несет хорошие такие накладные расходы, давайте подробнее.
Само по себе переключение режима тоже чего-то да стоит. 100-150 тактов, как я ранее указал. Но помимо этого происходит также следующее:
-
Системный вызов действует как полный барьер памяти
-
Сохранение контекста(уже в самом коде ядра)
-
RIP → RCX, RFLAGS → R11
-
Остановка и перестройка пайплайна обработки команд(для обработки уже системного вызова)
-
Cache miss для команд и памяти(как правило)
И это только на вызове!!!
А после sysret
-
Переключается уровень доступа
-
Опять переключается контекст
-
Опять перестраивается пайплайн
Соответственно, стоимость системного вызова БЕЗ взаимодействия с устройством(например, принтером) – это где-то 150-400 циклов, а если взаимодействовать и с ними, то это может обойтись в несколько тысяч!
Если часто использовать системные вызовы, то CPU будет очень грустно из-за огромных накладных расходов, поэтому придумали разного рода оптимизации. Давайте рассмотрим их:
Буферизация
В Go например это будет выглядеть так:
w := bufio.NewWriter(os.Stdout)w.WriteString("hello")w.WriteString("guys")w.Flush()
То есть реальный системный вызов только при w.Flush() и вместо двух write операций, мы получили одну!
Batch-операции
Некоторые системные вызовы позволяют передать сразу несколько операций.
К примеру:
-
writev
-
readv
-
sendmsg
-
recvmmsg
Вместо:
write
write
write
можно сделать writev и передать сразу несколько буферов.
И уменьшить количество переходов user → kernel
epoll
Если сервер работает с тысячами соединений, naïve подход выглядел бы так:
read(socket1)
read(socket2)
read(socket3)
Но большинство сокетов в данный момент ничего не имеют для чтения.
Каждый read – системный вызов. Представили боль CPU?
epoll решает проблему:
-
Программа регистрирует сокеты в ядре ОС в специальной структуре данных, которая является красно-черным деревом(для файловых дескрипторов) с помощью epoll_ctl. Дерево помогает быстро искать по файловым дескрипторам.
-
Ядро отслеживает события. То есть когда на сетевую карту что-то приходит, она создает аппаратное прерывание, а после чего драйвер сетевой карты передает в буфер конкретного сокета данные, как только данные приходят, срабатывает обработчик, с помощью которого сокет помещается в двусвязный список готовых событий(ReadyList).
-
Приложение спрашивает, у кого есть данные. С помощью системного вызова epoll_wait программа получает доступ к готовым нужным ей событиям из Ready List, то есть ядро ОС копирует информацию о готовых событиях из этого самого списка!
Да, к слову, наш любимый netpoller из Go Scheduler точно так же использует данный системный вызов!
mmap
Иногда повторные системные вызовы можно вообще убрать.
Например, обычное чтение файла:
read(fd, buffer)
Каждое чтение требует системного вызова.
А mmap делает иначе:
файл отображается в память процесса
После этого чтение файла выглядит как обычный доступ к памяти
Zero-copy
Некоторые системные вызовы позволяют не копировать данные между user space и kernel space.
Например:
sendfile
splice
Обычный путь:
диск → kernel → user → kernel → сокет
sendfile делает:
диск → kernel → сокет
Без копирования в user space.
Думаю, необходимые механизмы и нюансы работы CPU(и даже частично операционной системы) мы разобрали, теперь пойду выйду из подвала, прогуляюсь и начнем уже разбирать непосредственно Go! Настоятельно рекомендую пробежаться по всей части целиком. Вот ссыль на первую статью.
Основная часть первой части закончилась, поэтому если что-то будет еще, то уже со звездочкой, неупорядоченно и в том случае, если я захочу с вами этим поделиться.
ссылка на оригинал статьи https://habr.com/ru/articles/1027988/