Деконструкция GO: CPU, RAM и что там происходит. Системные вызовы. Часть 1.5

от автора

Итак, финал части 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 решает проблему:

  1. Программа регистрирует сокеты в ядре ОС в специальной структуре данных, которая является красно-черным деревом(для файловых дескрипторов) с помощью epoll_ctl. Дерево помогает быстро искать по файловым дескрипторам.

  2. Ядро отслеживает события. То есть когда на сетевую карту что-то приходит, она создает аппаратное прерывание, а после чего драйвер сетевой карты передает в буфер конкретного сокета данные, как только данные приходят, срабатывает обработчик, с помощью которого сокет помещается в двусвязный список готовых событий(ReadyList).

  3. Приложение спрашивает, у кого есть данные. С помощью системного вызова 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/