Системные вызовы: граница 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-сервиса обычно нормально.

Типовые вопросы

  1. Чем системный вызов отличается от обычной функции?
    • Syscall пересекает privilege boundary, использует syscall ABI и выполняется ядром с проверкой недоверенных аргументов; обычная функция работает в адресном пространстве процесса по user-space ABI.
  2. Почему read может вернуть меньше запрошенного без ошибки?
    • Источник мог иметь меньше доступных байтов, а контракт разрешает partial read. Это нормально для потоков; собирайте нужный объём циклом или разбирайте поток инкрементально.
  3. Как правильно обработать EAGAIN?
    • Не крутить busy loop. Зарегистрировать readiness (epoll/poller), дождаться события и повторить операцию; всё равно обработать повторный EAGAIN из-за гонок и конкуренции.
  4. Почему нельзя всегда повторять syscall после EINTR?
    • Нужно знать контракт и факт частичного прогресса. Повтор может быть безопасен для некоторых операций, но не должен скрыть timeout, отмену или привести к повтору неидемпотентного действия на другом уровне.
  5. Как context.CancelFunc останавливает блокирующий Read?
    • Сам по себе никак. Поддерживающий API может связать context с закрытием FD/дедлайном; иначе нужен явный механизм отмены, предусмотренный конкретным API.
  6. Почему close нельзя бездумно ретраить после ошибки?
    • В Linux FD может уже быть освобождён и переиспользован другим потоком. Повторный close рискует закрыть чужой ресурс; сначала проектируйте единственного владельца и проверяйте контракт платформы.
  7. Когда 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.

Частые ошибки и ловушки

  • Называть 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 и системных вызовов.