Системные вызовы: граница user/kernel
Системный вызов (system call, syscall) — контролируемый вход из непривилегированного режима user space в код ядра для операции, требующей полномочий ядра: ввода-вывода, управления памятью, процессами, сетью или объектами файловой системы. В Linux приложение обычно вызывает функцию libc или обёртку runtime, та подготавливает номер вызова и аргументы по ABI, выполняет инструкцию перехода (syscall на x86-64), а ядро валидирует вход, работает с объектами и возвращает результат либо код ошибки.
Это не «вызов функции в другом модуле». При переходе меняются уровень привилегий и контекст исполнения; ядро обязано не доверять пользовательским указателям и может вытеснить текущую задачу. Поэтому системные вызовы — граница производительности, отказов и безопасности приложения.
Зачем это на интервью
Тема связывает медленный endpoint с реальной причиной: блокирующим read, исчерпанными FD, page fault, очередью accept, ошибкой EINTR или contention в ядре. На интервью важно объяснить путь запроса без мифа «всё происходит в kernel mode»: бизнес-логика и большая часть Go runtime остаются в user space, а в ядро уходят конкретные операции.
Интервьюер обычно ожидает, что кандидат отличает системный вызов от библиотечной функции, не предполагает успеха read/write целиком, понимает errno и знает, как отмена context.Context соотносится с реально блокирующим I/O.
Минимум для E4
- Назвать user mode и kernel mode, объяснить, почему приложению запрещены произвольный доступ к устройствам и страницам других процессов.
- Понимать цепочку «API библиотеки/runtime → ABI → вход в ядро → проверка аргументов → возврат значения или ошибки».
- Различать syscall (
openat,read,write,close,futex,epoll_wait) и библиотечную функцию, которая может не входить в ядро (memcpy, часть функций форматирования). - Всегда обрабатывать short read, short write,
EINTR,EAGAIN/EWOULDBLOCK, EOF и ошибки закрытия там, где это значимо. - Понимать, что блокирующий syscall может усыпить поток ОС; высоконагруженный код использует неблокирующие FD и readiness-мультиплексирование либо runtime, который делает это за него.
- Ограничивать ресурсы: закрывать FD, ставить дедлайны, валидировать размеры буферов и логировать ошибки с операцией и контекстом.
Углубление для E5/Senior
На x86-64 Linux инструкция syscall переключает процессор на entry path ядра; аргументы и результат следуют системному ABI, который отличается от ABI обычного C-вызова. Точные регистры и детали entry-кода архитектурозависимы, поэтому прикладной код не должен их хардкодить: используйте libc, Go runtime или поддерживаемые low-level пакеты. Возврат в user space сопровождается проверками безопасности и может включать планирование другой задачи; цена — не только несколько инструкций, но и cache/TLB effects, проверки доступа, копирование и ожидание устройства.
Ядро читает user memory через защищённые примитивы. Указатель, переданный в read или sendmsg, — недоверенный: страница может быть недоступна, размер — некорректен, а данные могут меняться конкурентно. Поэтому syscall может вернуть EFAULT, EINVAL, EMFILE, ENFILE, ENOSPC и другие ошибки, даже если «аргументы выглядят нормальными» на уровне приложения. Между проверкой и использованием данные могут измениться; такие TOCTOU-риски устраняют файловыми дескрипторами, openat относительно доверенного directory FD, флагами вроде O_NOFOLLOW и атомарными операциями ядра, а не проверкой пути строкой.
errno в C — thread-local описание последней ошибки, а не самостоятельный результат syscall. На уровне raw Linux ABI ошибка часто кодируется отрицательным значением; libc переводит её в -1 и устанавливает errno. Go обычно возвращает error (syscall.Errno) и скрывает регистровую деталь. Проверяйте ошибку через errors.Is, а не по тексту. EINTR означает прерывание сигналом до завершения операции; некоторые обёртки перезапускают её, но контракт конкретного API нужно читать. EAGAIN для nonblocking FD — нормальный сигнал «повторите после readiness», а не повод крутить CPU в цикле.
В Go goroutine не равна потоку ОС. Runtime ставит сетевые FD в nonblocking режим и использует netpoller; goroutine, ожидающая сеть, обычно не удерживает M. Но произвольный блокирующий syscall, cgo-вызов или операция, которую runtime не умеет парковать, может занять поток. context отменяет только то, что API умеет отменять: у net.Conn дедлайны будят I/O, а переданный в чужой syscall context.Context сам по себе ничего не прерывает. Для отмены дизайните FD/дедлайн/сигнал или отдельную goroutine согласно контракту API и избегайте утечки ожидающего worker.
Не оптимизируйте «количество syscall» вне профиля. Батчинг (readv, writev, sendmsg), буферизация и io.Copy могут уменьшить переходы, но увеличивают latency, память и сложность backpressure. io_uring может снизить overhead и дать асинхронную модель, однако добавляет ownership буферов, кольцевые очереди, обработку completion и эксплуатационный риск версии ядра. Выбирайте его только под измеренную нагрузку и с fallback/наблюдаемостью.
Ключевые понятия
| Понятие | Смысл | Практическое следствие |
|---|---|---|
| User space | Непривилегированный код процесса и его виртуальная память | Ошибка приложения обычно не даёт ему читать память другого процесса |
| Kernel space | Привилегированный код ядра и драйверов | Ошибка в ядре опаснее, поэтому вход строго валидируется |
| Syscall ABI | Соглашение о номере вызова, аргументах и возврате | Не смешивайте его с обычным function-call ABI и не пишите raw assembly без необходимости |
| File descriptor | Индекс таблицы дескрипторов процесса | FD — capability к объекту; его lifecycle и CLOEXEC критичны |
| Blocking I/O | Вызов ждёт данных, места, lock или устройства | Нужны deadline, cancellation strategy и capacity limits |
| Readiness | Ядро сообщает, что FD потенциально готов к операции | После readiness операция всё ещё может вернуть EAGAIN; повторно проверяйте результат |
| Page fault | Доступ к отсутствующей/неподгруженной странице | Может случиться в user-коде или при копировании в syscall и влиять на latency |
Контракты read и write
При n > 0 успешный read(fd, buf, n) возвращает от 1 до n байтов; 0 означает EOF для потокового источника. Отдельный случай — запрос с n == 0: он может успешно вернуть 0 и не сообщает об EOF. Успешный write тоже может принять меньше n байтов — особенно на nonblocking FD, pipe и socket. Цикл должен продвигать offset, а не повторять запись целого буфера и не дублировать данные.
import "io"
func writeAll(w interface{ Write([]byte) (int, error) }, p []byte) error {
for len(p) > 0 {
n, err := w.Write(p)
if n > 0 {
p = p[n:]
}
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
}
return nil
}В реальном Go-коде импортируйте io; для net.Conn добавьте write deadline, размер очереди и протокол обработки частично отправленного сообщения. Не ретрайте автоматически неидемпотентную операцию после неизвестного результата: при сетевой ошибке peer мог получить часть или всё сообщение.
Наблюдение за реальными вызовами
strace показывает boundary между процессом и ядром, а не смысл бизнес-операции. Это хороший способ проверить гипотезу: завис ли процесс в futex, ждёт ли epoll_wait, получает ли EAGAIN, не течёт ли openat. Считайте его инструментом диагностики: в production он добавляет overhead и может исказить timing.
strace -f -tt -T -e trace=%file,%network -p <pid>Для агрегированного профиля применяйте strace -c в воспроизводимом стенде, а для scheduler/latency — профиль Go, go tool trace, perf или eBPF по правилам эксплуатации команды. Сопоставляйте syscall-метрики с request rate, p99 и CPU: много epoll_wait у idle-сервиса обычно нормально.
Типовые вопросы
- Чем системный вызов отличается от обычной функции?
- Syscall пересекает privilege boundary, использует syscall ABI и выполняется ядром с проверкой недоверенных аргументов; обычная функция работает в адресном пространстве процесса по user-space ABI.
- Почему
readможет вернуть меньше запрошенного без ошибки?- Источник мог иметь меньше доступных байтов, а контракт разрешает partial read. Это нормально для потоков; собирайте нужный объём циклом или разбирайте поток инкрементально.
- Как правильно обработать
EAGAIN?- Не крутить busy loop. Зарегистрировать readiness (
epoll/poller), дождаться события и повторить операцию; всё равно обработать повторныйEAGAINиз-за гонок и конкуренции.
- Не крутить busy loop. Зарегистрировать readiness (
- Почему нельзя всегда повторять syscall после
EINTR?- Нужно знать контракт и факт частичного прогресса. Повтор может быть безопасен для некоторых операций, но не должен скрыть timeout, отмену или привести к повтору неидемпотентного действия на другом уровне.
- Как
context.CancelFuncостанавливает блокирующийRead?- Сам по себе никак. Поддерживающий API может связать context с закрытием FD/дедлайном; иначе нужен явный механизм отмены, предусмотренный конкретным API.
- Почему
closeнельзя бездумно ретраить после ошибки?- В Linux FD может уже быть освобождён и переиспользован другим потоком. Повторный
closeрискует закрыть чужой ресурс; сначала проектируйте единственного владельца и проверяйте контракт платформы.
- В Linux FD может уже быть освобождён и переиспользован другим потоком. Повторный
- Когда
openatбезопаснееopen?- Когда путь должен быть разрешён относительно уже доверенного directory FD. Это снижает path traversal и TOCTOU между проверкой каталога и открытием файла.
Практика
- Напишите программу, которая читает TCP stream маленькими кусками и собирает length-prefixed сообщения.
- Критерии приёмки: тест принудительно фрагментирует header и body; сообщения больше лимита отклоняются до выделения памяти; EOF и timeout различаются в логах и тестах.
- Найдите блокировку в учебном Go-сервисе с помощью
straceи профиля runtime.- Критерии приёмки: приложены команда и воспроизводимый сценарий; указаны syscall/состояние ожидания, p99 до и после изменения; исправление имеет тест или нагрузочную проверку.
- Реализуйте неблокирующий pipe на C или Go через
golang.org/x/sys/unixи readiness-цикл.- Критерии приёмки:
EAGAINне вызывает busy-wait; writer ограничен очередью; shutdown будит loop и закрывает все FD; тест проверяет backpressure при остановленном reader.
- Критерии приёмки:
- Проверьте обработку отмены у операции I/O в Go.
- Критерии приёмки: тест отменяет context во время ожидания; goroutine завершается в заданный срок;
go test -raceне находит гонок; в документации теста назван механизм, который реально будит I/O.
- Критерии приёмки: тест отменяет context во время ожидания; goroutine завершается в заданный срок;
Частые ошибки и ловушки
- Называть syscall любой вызов стандартной библиотеки.
fmt.Sprintfиbytes.Copyне требуют входа в ядро;net/httpможет выполнить много разных вызовов или ни одного на cache hit. - Считать, что один syscall равен одному сетевому пакету, запросу HTTP или дисковой операции. Это разные уровни абстракции.
- Игнорировать return count у
read/write; это приводит к обрезанным, склеенным или продублированным данным. - Делать
close(fd)одновременно из нескольких goroutine и затем использовать числовой FD. Владение дескриптором должно быть явным. - Лечить
EMFILEповышением лимита без поиска утечки и без резервного FD/controlled shedding. Лимит — защита хоста, а не настройка для бесконечного роста. - Использовать raw
syscallтам, где Go runtime предоставляет безопасный API. Это легко ломает scheduler, portability и lifecycle ресурсов. - Считать, что
epollгарантирует успехread. Между уведомлением и обработкой другой поток мог исчерпать данные; loop обязан реагировать на фактический результат.
Связанные темы
База разработки и Computer Science · IPC: pipes, sockets и shared memory · Go: runtime и конкурентность · Linux, сети и cloud · Наблюдаемость
Источники
- Linux man-pages:
syscalls(2),intro(2),read(2),write(2),close(2),openat(2),fcntl(2),epoll(7)иsignal(7)— первичные контракты Linux userspace ABI. - Linux kernel documentation:
core-api/entry.rst,locking/иio_uring/— устройство входа в ядро, синхронизация и asynchronous I/O. - POSIX.1-2024:
read(),write(),open(),close()и спецификация сигналов — переносимые семантики ошибок и частичных операций. - Документация Go:
net,os,io,context,runtimeиgolang.org/x/sys/unix; исходный код runtime netpoller — связь goroutine, сетевого I/O и системных вызовов.