DESAI.DEV
← ВЕРНУТЬСЯ

Системное программирование на Go под Linux

Книга о том, что происходит между вашим кодом и железом > `syscall`, `os`, `net`, `runtime`, `unsafe` — и Linux под ними. От того, что такое системный вызов, до контейнера, написанного с нуля.

Книга о том, что происходит между вашим кодом и железом

> syscall, os, net, runtime, unsafe — и Linux под ними. От того, что такое системный вызов, до контейнера, написанного с нуля.

---

Как читать эту книгу

Это не справочник функций — их документация и так лежит на pkg.go.dev. Это книга про модель: как Go-программа на самом деле разговаривает с операционной системой, почему стандартная библиотека устроена именно так, и что происходит в те наносекунды, когда вы пишете f.Read(buf) или conn.Write(data).

Главы выстроены по нарастанию, каждая опирается на предыдущую:

  • Часть I. Граница — user space и kernel space, что такое системный вызов физически.
  • Часть II. syscall — как Go делает системные вызовы, почему пакет syscall заморожен и что такое x/sys/unix.
  • Часть III. os — дружелюбная обёртка и что она прячет.
  • Часть IV. Файлы и дескрипторы — «в Linux всё есть файл» на практике.
  • Часть V. Процессы — fork, exec, wait и почему в Go нельзя просто взять и сделать fork().
  • Часть VI. Сигналы — асинхронные уведомления и как их укрощает рантайм Go.
  • Часть VII. IPC — pipe, FIFO, socketpair, разделяемая память, mmap.
  • Часть VIII. Мультиплексирование — select, poll, epoll.
  • Часть IX. runtime — планировщик G-M-P, память, сборщик мусора, и как всё это стыкуется с системными вызовами.
  • Часть X. net — сокеты и netpoller: почему вы пишете блокирующий код, а получаете асинхронный ввод-вывод.
  • Часть XI. unsafe — работа с памятью напрямую, правила unsafe.Pointer, разметка структур.
  • Часть XII. Контейнеры с нуля — namespaces и cgroups, из которых сделан Docker.
  • Часть XIII. /proc и наблюдаемость — как система рассказывает о себе.

Предполагается, что вы уже пишете на Go и понимаете горутины и каналы. Всё остальное — по дороге. Примеры рассчитаны на Linux/amd64; где важна архитектура или ядро — отмечено особо.

---

Часть I. Граница между вашим кодом и ядром

1.1 Два мира: user space и kernel space

Когда вы запускаете Go-программу, она исполняется процессором не в вакууме, а внутри жёстко разделённого мира. Этот мир поделён на два кольца привилегий — их так и называют «кольцами» (rings) со времён x86.

Kernel space (кольцо 0) — привилегированный режим. Код, работающий здесь (ядро Linux и его модули), может всё: обращаться к любому участку физической памяти, напрямую командовать устройствами через порты ввода-вывода, включать и выключать прерывания, переключать таблицы страниц. Это режим полновластия.

User space (кольцо 3) — режим ограниченных полномочий, в котором работают все обычные программы: ваш Go-бинарник, bash, nginx, PostgreSQL. Здесь процессор физически запрещает опасные операции. Попытка прочитать чужую память приводит к аппаратному исключению; попытка выполнить привилегированную инструкцию — тоже. Программа заперта в своей песочнице.

Зачем такое разделение? Ради двух вещей — изоляции и арбитража. Изоляция: сбойный (или вредоносный) процесс не может повредить ни ядро, ни соседние процессы — он огорожен аппаратно. Арбитраж: ядро — единственный владелец всех ресурсов (памяти, дисков, сети, процессорного времени) и распределяет их между процессами по правилам, которые процесс обойти не может.

Ключевое следствие для нас: программа в user space не умеет делать почти ничего интересного сама. Прочитать файл? Файл — на диске, диском командует ядро. Отправить пакет в сеть? Сетевой картой командует ядро. Создать поток? Потоки планирует ядро. Даже выделить память под кучу — и то надо просить у ядра расширить адресное пространство. Всё, что программа умеет сама, — это считать в регистрах и своей памяти. Как только нужен внешний мир — приходится стучаться в кольцо 0.

Этот стук и называется системным вызовом (system call, syscall). Это единственная официальная дверь из user space в kernel space.

1.2 Что такое системный вызов физически

Раз user space не может просто взять и «вызвать функцию ядра» (адресные пространства и привилегии разные), нужен специальный аппаратный механизм переключения. На x86-64 это инструкция syscall (на старых системах был программный прерыватель int 0x80; на ARM64 — инструкция svc).

Вот что происходит по шагам, когда программа делает системный вызов на x86-64 Linux:

1. Программа кладёт номер системного вызова в регистр RAX. У каждого syscall есть номер: read — 0, write — 1, open — 2, close — 3, mmap — 9, и так далее (полная таблица — в заголовках ядра, например /usr/include/asm/unistd_64.h). 2. Аргументы раскладываются по регистрам в строгом порядке: RDI, RSI, RDX, R10, R8, R9 — до шести штук. (Обратите внимание: четвёртый аргумент в R10, а не в RCX, как в обычном C-соглашении о вызовах, — потому что RCX затирается самой инструкцией syscall.) 3. Выполняется инструкция syscall. Процессор атомарно переключается в кольцо 0, сохраняет адрес возврата, переключает стек на стек ядра и прыгает по фиксированному адресу — в обработчик системных вызовов ядра (его адрес заранее записан в специальный регистр MSR_LSTAR). 4. Ядро смотрит на номер в RAX, находит нужную функцию в своей таблице системных вызовов (sys_call_table), проверяет аргументы (валиден ли указатель? есть ли права?) и выполняет работу — уже во всевластном режиме. 5. Результат кладётся обратно в RAX. Успех — это неотрицательное число (например, read вернёт число прочитанных байт). Ошибка — это отрицательное число в диапазоне от -1 до -4095, кодирующее номер ошибки: -1 это EPERM, -2 это ENOENT, -13 это EACCES и так далее. 6. Ядро выполняет инструкцию sysret, возвращая процессор в кольцо 3 и в ту точку программы, что шла за syscall.

Всё. Это и есть «дверь». Каждый раз, когда ваша Go-программа читает файл, дверь открывается и закрывается по этой схеме.

Стоимость перехода. Переключение в ядро и обратно — не бесплатно. Это сотни-тысячи тактов процессора: надо сохранить регистры, переключить стек, сбросить некоторые кеши предсказателя ветвлений (особенно после Meltdown/Spectre-митигаций вроде KPTI, которые ещё и таблицы страниц переключают). Порядок величины — десятки-сотни наносекунд на пустой syscall. Отсюда важный инженерный вывод, который будет всплывать снова и снова: системные вызовы стоит группировать. Прочитать файл одним read на 64 КБ дешевле, чем 64 тысячи read по байту. Именно поэтому существует буферизация (bufio), векторный ввод-вывод (readv/writev), а сетевой стек Go так старательно избегает лишних обращений к ядру.

1.3 Обёртки: от инструкции syscall до os.Open

Никто не пишет syscall-инструкцию руками (кроме авторов рантаймов и libc). Над голым механизмом выстроена лестница абстракций, и Go-программист обычно живёт на её верхних этажах, не подозревая о нижних. Пройдём по ступеням снизу вверх — это, по сути, оглавление всей книги:

┌─────────────────────────────────────────────┐
│  Ваш код:  os.Open("f.txt")                  │  ← удобно, кроссплатформенно
├─────────────────────────────────────────────┤
│  Пакет os:  проверки, обёртка *os.File,      │  ← Часть III
│             интеграция с рантаймом            │
├─────────────────────────────────────────────┤
│  x/sys/unix.Open / syscall.Open:             │  ← Часть II
│  раскладка аргументов, вызов Syscall()        │
├─────────────────────────────────────────────┤
│  runtime·Syscall (ассемблер):                │  ← Часть IX
│  entersyscall → инструкция SYSCALL → exit     │
├─────────────────────────────────────────────┤
│  Ядро Linux: sys_open, работа с ФС, драйверы  │  ← кольцо 0
└─────────────────────────────────────────────┘

В отличие от программ на C, Go не использует libc для системных вызовов (в стандартной сборке под Linux). C-программа зовёт open() из glibc, а та уже делает syscall. Go же несёт собственную реализацию до самого низа: его рантайм сам раскладывает регистры и сам выполняет инструкцию syscall. Это осознанное архитектурное решение — оно даёт Go статические бинарники без зависимости от версии libc и, что важнее, позволяет рантайму знать о каждом системном вызове и правильно реагировать на блокировки (об этом — большая глава про планировщик). Ценой отказа от libc стало то, что Go пришлось самому поддерживать таблицы номеров syscall для каждой ОС и архитектуры — отсюда гигантские сгенерированные файлы в x/sys/unix.

> Нюанс про не-Linux. На macOS, Windows и BSD запрещено или не принято звать ядро напрямую в обход системной библиотеки (ABI стабилен только на уровне libSystem/kernel32/libc). Там Go вынужден ходить через них. Но под Linux ABI системных вызовов — это стабильный контракт («мы никогда не ломаем user space», знаменитое правило Линуса), поэтому Go общается с ядром напрямую. Эта книга — про Linux, где всё честно и прозрачно.

1.4 errno: как ядро сообщает об ошибках

Мы видели, что ядро возвращает ошибку отрицательным числом в RAX. В мире C это превращается в глобальную переменную errno и возврат -1 из функции-обёртки. В Go этот механизм обёрнут в тип syscall.Errno:

// syscall.Errno — это просто число (номер ошибки), реализующее интерфейс error.
type Errno uintptr

func (e Errno) Error() string { return errorString(e) } // "no such file or directory"

Ключевые ошибки, которые вы будете встречать всю жизнь:

| Константа | errno | Значение | | --- | --- | --- | | EPERM | 1 | нет прав на операцию | | ENOENT | 2 | нет такого файла или директории | | EINTR | 4 | вызов прерван сигналом (важнейший случай — см. ниже) | | EBADF | 9 | неправильный файловый дескриптор | | EAGAIN/EWOULDBLOCK | 11 | «сейчас нельзя, попробуй позже» (неблокирующий ввод-вывод) | | ENOMEM | 12 | нет памяти | | EACCES | 13 | доступ запрещён | | EEXIST | 17 | файл уже существует | | EPIPE | 32 | запись в закрытый на том конце канал/сокет | | EADDRINUSE | 98 | адрес (порт) уже занят |

Правильный способ проверить конкретную ошибку в Go — через errors.Is и константы из x/sys/unix (или syscall):

_, err := os.Open("/nope")
if errors.Is(err, os.ErrNotExist) { // высокоуровневая проверка (переносимая)
    // ...
}

// или до самого низа, если нужна конкретика:
var errno syscall.Errno
if errors.As(err, &errno) && errno == syscall.ENOENT {
    // ...
}

EINTR — та ошибка, о которой все забывают. Если системный вызов заблокировался (например, read ждёт данные из сокета), и в этот момент процессу пришёл сигнал, ядро может прервать вызов и вернуть EINTR. Это не настоящая ошибка — это «нас перебили, повтори». В программах на C приходится оборачивать каждый блокирующий вызов в цикл while (ret == -1 && errno == EINTR). Прекрасная новость: в Go об этом почти всегда заботится рантайм. Начиная с Go 1.14, рантайм автоматически перезапускает системные вызовы, прерванные его собственными сигналами (а он использует сигналы для асинхронной вытеснительной планировки — см. часть про runtime). Поэтому в обычном коде на os/net вы EINTR не увидите. Но если вы делаете «сырые» вызовы через syscall/unix — помнить про EINTR придётся самому.

1.5 strace: смотрим на дверь своими глазами

Всё сказанное — не абстракция; это можно увидеть. Утилита strace перехватывает все системные вызовы процесса и печатает их. Это ваш рентген для системного программирования — привыкайте запускать её при любом непонятном поведении.

Соберём тривиальную программу:

package main

import "os"

func main() {
    f, _ := os.Open("/etc/hostname")
    buf := make([]byte, 64)
    n, _ := f.Read(buf)
    os.Stdout.Write(buf[:n])
    f.Close()
}

И посмотрим на её системные вызовы (-f — следить за всеми потоками, потому что Go многопоточен):

go build -o reader main.go
strace -f ./reader 2>&1 | grep -E 'openat|read|write|close'

В выводе среди рантаймовой суеты вы найдёте примерно это:

openat(AT_FDCWD, "/etc/hostname", O_RDONLY|O_CLOEXEC) = 3
read(3, "myhost\n", 64)                 = 7
write(1, "myhost\n", 7)                 = 7
close(3)                                = 3

Прочитаем построчно — здесь видна вся глава разом:

  • openat(...) = 3 — открытие файла вернуло файловый дескриптор 3 (0, 1, 2 заняты под stdin/stdout/stderr — о них в части IV). Флаг O_CLOEXEC Go ставит сам — это важная деталь безопасности, к которой мы вернёмся. os.Open под капотом зовёт не open, а openat — более современный вариант с явным указанием базовой директории.
  • read(3, ..., 64) = 7 — прочитали 7 байт из дескриптора 3 в буфер размером 64. Обратите внимание: мы просили 64, получили 7 — read возвращает столько, сколько есть сейчас, а не сколько попросили. Это фундаментальное свойство, из-за которого нельзя писать f.Read(buf) и считать, что буфер заполнен; отсюда io.ReadFull и циклы дочитывания.
  • write(1, ..., 7) = 7 — записали 7 байт в дескриптор 1 (stdout). os.Stdout — это просто обёртка над дескриптором 1.
  • close(3) = 3 — вернули дескриптор ядру.

strace будет вашим спутником всю книгу. Когда программа «зависла» — strace покажет, на каком системном вызове она стоит. Когда «тормозит» — strace -c подсчитает, сколько времени ушло на какие вызовы. Когда «падает с непонятной ошибкой» — вы увидите точный syscall и точный errno.

---

Часть II. Пакет syscall и x/sys/unix

2.1 Три поколения одного пакета

Здесь важно сразу расставить исторические акценты, иначе вы запутаетесь в трёх похожих именах.

Пакет syscall появился в самом начале Go как способ звать ядро. Проблема: он часть стандартной библиотеки, а значит подпадает под гарантию обратной совместимости Go 1 — его нельзя менять. Но операционные системы развиваются: добавляются новые системные вызовы, новые флаги, новые структуры. «Замороженный» пакет не мог за этим угнаться. Поэтому начиная примерно с Go 1.4 команда объявила syscall закрытым для развития: он остаётся для того, что уже есть, но новое туда не добавляют.

Пакет golang.org/x/sys/unix — официальная замена. Он лежит вне стандартной библиотеки (в модуле golang.org/x/sys), поэтому не связан гарантией Go 1 и активно развивается. Тут есть свежие системные вызовы (epoll_pwait2, pidfd_open, io_uring-примитивы, memfd_create), полные наборы флагов и констант для каждого дистрибутива и ядра. Для нового системного кода используйте именно его.

go get golang.org/x/sys/unix

Пакет runtime и внутренний runtime/internal/syscall — самый нижний уровень, ассемблерные точки входа, которые физически выполняют инструкцию syscall. Вы туда не ходите напрямую; и syscall, и x/sys/unix в конечном счёте зовут именно эти функции.

Практическое правило: os/net/io — для обычного кода; x/sys/unix — когда нужен конкретный системный вызов, которого нет в обёртках; syscall — только если вынуждает старый код или чужой API.

2.2 Как устроен вызов: Syscall, Syscall6, RawSyscall

В основе всего лежит горстка функций с почти одинаковыми сигнатурами. Вот центральная:

func Syscall(trap, a1, a2, a3 uintptr) (r1, r2 uintptr, err Errno)

Прочитаем каждый аргумент — это буквально та регистровая раскладка из части I, только в виде функции Go:

  • trap — номер системного вызова (то, что уедет в RAX). SYS_READ, SYS_WRITE, SYS_OPENAT... Константы с номерами лежат в пакете.
  • a1, a2, a3 — первые три аргумента (уедут в RDI, RSI, RDX). Тип uintptr — целое, способное вместить и число, и адрес указателя.
  • r1, r2 — возвращаемые значения (обычно интересен r1 — это RAX после вызова; r2 нужен для редких вызовов вроде pipe2, возвращающих два значения).
  • err — Errno. Обёртка уже разобрала знак результата: если ядро вернуло отрицательное число-ошибку, оно превратилось в ненулевой err; если успех — err == 0.

Есть варианты:

  • Syscall6 — то же самое, но на шесть аргументов (RDI..R9). Нужен для вызовов вроде mmap (6 аргументов) или pread.
  • RawSyscall / RawSyscall6 — самое интересное отличие. Обычный Syscall сообщает планировщику Go, что горутина уходит в потенциально долгий системный вызов (через внутренние runtime.entersyscall/exitsyscall) — чтобы рантайм мог передать процессор (P) другому потоку и не простаивать. RawSyscall этого не делает: он выполняет вызов «в лоб», не уведомляя рантайм. Его используют только для гарантированно мгновенных и неблокирующих вызовов (например, getpid, close), где накладные расходы на уведомление планировщика были бы дороже самого вызова. Если по ошибке сделать RawSyscall на блокирующем вызове (скажем, read из пустого сокета), вы заблокируете поток целиком вместе с его P, и планировщик об этом не узнает — верный путь к загадочным зависаниям под нагрузкой. Мораль: RawSyscall — инструмент авторов рантайма, не ваш.

Вот как выглядит ручной системный вызов через нижний уровень (так делать в проде не надо — это для понимания, что внутри unix.Write):

package main

import (
    "fmt"
    "syscall"
    "unsafe"
)

func main() {
    msg := []byte("привет из сырого write\n")

    // write(1, &msg[0], len(msg)) — пишем в stdout (дескриптор 1)
    r1, _, errno := syscall.Syscall(
        syscall.SYS_WRITE,                  // номер: 1
        uintptr(1),                         // fd = stdout
        uintptr(unsafe.Pointer(&msg[0])),   // указатель на данные
        uintptr(len(msg)),                  // сколько байт
    )
    if errno != 0 {
        fmt.Println("ошибка:", errno) // errno реализует error
        return
    }
    fmt.Printf("записано байт: %d\n", r1)
}

Здесь мы своими руками собрали то, что делает библиотека: положили номер SYS_WRITE, указатель на буфер и длину — и получили результат. Обратите внимание на unsafe.Pointer(&msg[0]): ядру нужен адрес первого байта, а uintptr — это способ передать адрес как целое (про тонкости unsafe и почему так делать опасно — целая часть XI).

2.3 Обёртки x/sys/unix: как это выглядит по-человечески

К счастью, вручную раскладывать регистры не нужно почти никогда. x/sys/unix даёт типизированные обёртки над каждым системным вызовом. Тот же write:

import "golang.org/x/sys/unix"

n, err := unix.Write(1, []byte("привет\n")) // fd, срез — и всё
if err != nil {
    // err — это уже настоящий error (Errno)
}

Обёртка сама достаёт указатель на данные из среза, сама передаёт длину, сама превращает Errno в error. Внутри неё — тот самый Syscall(SYS_WRITE, ...), но вся грязная работа спрятана.

Разберём практический пример посерьёзнее — прочитаем файл «вручную», минуя пакет os, через голые системные вызовы. Это покажет, из чего os.ReadFile собран:

package main

import (
    "fmt"
    "os"

    "golang.org/x/sys/unix"
)

func readFileRaw(path string) ([]byte, error) {
    // 1. open: получаем файловый дескриптор.
    //    O_RDONLY — только чтение; 0 — режим прав (не важен без O_CREAT).
    fd, err := unix.Open(path, unix.O_RDONLY|unix.O_CLOEXEC, 0)
    if err != nil {
        return nil, fmt.Errorf("open %s: %w", path, err)
    }
    defer unix.Close(fd) // ВСЕГДА закрываем — дескрипторы это ограниченный ресурс

    // 2. fstat: узнаём размер файла, чтобы выделить буфер правильного размера.
    var st unix.Stat_t
    if err := unix.Fstat(fd, &st); err != nil {
        return nil, fmt.Errorf("fstat: %w", err)
    }

    // 3. read в цикле: помним из части I, что один read может вернуть НЕ ВСЁ.
    buf := make([]byte, st.Size)
    var total int
    for total < len(buf) {
        n, err := unix.Read(fd, buf[total:])
        if err != nil {
            if err == unix.EINTR {
                continue // прервал сигнал — повторяем (см. 1.4)
            }
            return nil, fmt.Errorf("read: %w", err)
        }
        if n == 0 {
            break // EOF: данные кончились раньше, чем ожидали по размеру
        }
        total += n
    }
    return buf[:total], nil
}

func main() {
    data, err := readFileRaw("/etc/hostname")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    os.Stdout.Write(data)
}

В этих сорока строках — квинтэссенция системного программирования: открыть ресурс и обязательно закрыть его через defer; спросить у ядра метаданные (fstat); читать в цикле, потому что read не гарантирует полноту; уважать EINTR; отличать ошибку от EOF (n == 0). Пакет os делает всё это за вас — но теперь вы знаете, что именно.

2.4 Структуры ядра: Stat_t и товарищи

Системные вызовы часто принимают и возвращают структуры — их разметка байт в байт совпадает со структурами ядра. unix.Stat_t из примера выше — это Go-зеркало C-структуры struct stat:

type Stat_t struct {
    Dev     uint64 // устройство
    Ino     uint64 // номер inode
    Nlink   uint64 // число жёстких ссылок
    Mode    uint32 // тип файла + права (см. ниже)
    Uid     uint32 // владелец
    Gid     uint32 // группа
    Size    int64  // размер в байтах
    Blksize int64  // предпочтительный размер блока для ввода-вывода
    Blocks  int64  // сколько блоков по 512 байт реально занято
    Atim    Timespec // время последнего доступа
    Mtim    Timespec // время последней модификации
    Ctim    Timespec // время изменения метаданных
    // ...
}

Поле Mode — упакованное битовое поле: старшие биты кодируют тип (обычный файл, директория, символическая ссылка, устройство, сокет, FIFO), младшие — права (rwx для владельца/группы/остальных плюс спецбиты setuid/setgid/sticky). Разбирается масками:

switch st.Mode & unix.S_IFMT { // S_IFMT — маска типа
case unix.S_IFREG:
    fmt.Println("обычный файл")
case unix.S_IFDIR:
    fmt.Println("директория")
case unix.S_IFLNK:
    fmt.Println("символическая ссылка")
case unix.S_IFSOCK:
    fmt.Println("сокет")
}

perms := st.Mode & 0o777 // младшие 9 бит — права rwxrwxrwx
fmt.Printf("права: %o\n", perms) // например, 644

Эта разметка — прямое отражение того, как данные лежат в памяти ядра. Именно поэтому в системном программировании так важна тема разметки структур и выравнивания, к которой мы вернёмся в части про unsafe: одна ошибка в порядке или размере полей — и вы читаете мусор.

2.5 Когда спускаться на этот уровень

Подавляющую часть времени вы будете жить в os и net. Спускаться в x/sys/unix стоит, когда нужного нет в обёртках. Примеры реальных поводов:

  • Тонкая настройка сокета через setsockopt: SO_REUSEPORT (несколько процессов слушают один порт), TCP_NODELAY, SO_BINDTODEVICE, IP_TRANSPARENT.
  • Файловые операции без гонок: openat, renameat2 (атомарное переименование с флагами), statx.
  • Продвинутая память: mmap, madvise, mlock, memfd_create (анонимный файл в памяти).
  • Уведомления ядра: inotify (слежение за файлами), signalfd, eventfd, timerfd — превращают события в читаемые дескрипторы.
  • Изоляция: unshare, setns, clone с флагами namespaces (часть XII).
  • Управление процессами по-новому: pidfd_open, pidfd_send_signal (сигнал процессу без гонки с PID reuse).

Во всех этих случаях x/sys/unix даёт готовую типизированную обёртку, а если и её нет — остаётся unix.Syscall, но это уже последний рубеж.

---

Часть III. Пакет os: дружелюбная обёртка

3.1 Что os добавляет поверх syscall

Если x/sys/unix — это ядро, говорящее с вами почти на своём языке, то os — переводчик, дипломат и телохранитель. Он добавляет четыре вещи, которых на нижнем уровне нет.

Переносимость. os.Open, os.Create, os.Getenv работают одинаково на Linux, macOS и Windows. Под капотом — совершенно разные системные вызовы (openat на Linux, CreateFileW на Windows), но наружу торчит единый интерфейс. Ваш бизнес-код не знает, на чём он запущен.

Интеграция с рантаймом. Это самое важное и незаметное. Когда вы делаете f.Read() на файле или (особенно) на сокете, os не просто дёргает read — он взаимодействует с планировщиком Go так, чтобы блокировка одной горутины не останавливала весь поток. Для сетевых дескрипторов это делается через netpoller (часть X). Голый unix.Read этого не умеет — он заблокирует поток.

Безопасность ресурсов. os.File — это не просто число-дескриптор, а структура с финализатором: если вы забыли Close, сборщик мусора рано или поздно закроет файл за вас (не полагайтесь на это, но как страховка — работает). Плюс os сам ставит флаг O_CLOEXEC на все дескрипторы (почему это критично — в части про exec).

Человеческие ошибки. os оборачивает Errno в os.PathError / os.SyscallError с контекстом: не просто «ENOENT», а «open /etc/nope: no such file or directory». И даёт переносимые предикаты: os.IsNotExist(err), errors.Is(err, os.ErrPermission).

3.2 Файлы: os.File и его натура

os.File — центральный тип. Получить его можно многими путями:

f, err := os.Open("data.txt")           // только чтение (O_RDONLY)
f, err := os.Create("out.txt")          // создать/обрезать для записи (O_WRONLY|O_CREATE|O_TRUNC)
f, err := os.OpenFile("log.txt",        // полный контроль над флагами и правами
    os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)

Флаги os.O_ — те же самые константы ядра, что мы видели в unix, просто с префиксом пакета. Третий аргумент OpenFile — права (mode) для создаваемого файла, в восьмеричной записи 0o644 (владелец — чтение+запись, остальные — только чтение). Реальные права будут ещё и урезаны маской umask процесса.

os.File реализует ключевые интерфейсы io:

// io.Reader, io.Writer, io.Closer, io.Seeker, io.ReaderAt, io.WriterAt...
var _ io.ReadWriteCloser = (*os.File)(nil)

Именно поэтому файл можно скормить в io.Copy, bufio.NewReader, json.NewDecoder — весь мир io работает с ним из коробки. Это, кстати, отличная иллюстрация философии Go: маленький интерфейс io.Reader (один метод Read) объединяет файлы, сокеты, буферы в памяти, сжатые потоки и HTTP-тела в единую абстракцию «источник байтов».

Достать сырой дескриптор можно методом Fd() — но с оговорками:

fd := f.Fd() // uintptr — тот самый номер дескриптора

// Осторожно: Fd() на сетевом файле переводит его в БЛОКИРУЮЩИЙ режим,
// вырывая из-под управления netpoller. После этого горутина, читающая
// с этого сокета, заблокирует поток. Для сокетов используйте SyscallConn().

Правильный способ сделать «сырой» системный вызов на дескрипторе os.File, не ломая интеграцию с рантаймом, — через SyscallConn:

raw, err := f.SyscallConn()
if err != nil { /* ... */ }

err = raw.Control(func(fd uintptr) {
    // здесь можно безопасно вызвать, например, setsockopt на fd
    unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
})

Control гарантирует, что дескриптор не будет закрыт из-под вас на время вызова, и не ломает неблокирующий режим. Это мостик между уютным os и сырым unix, когда нужно и то, и другое.

3.3 Процесс изнутри: аргументы, окружение, рабочая директория

os — это ещё и окно в состояние самого процесса.

// Аргументы командной строки. os.Args[0] — имя программы, дальше — аргументы.
fmt.Println(os.Args) // [./myprog -v file.txt]

// Переменные окружения.
home := os.Getenv("HOME")             // "" если нет
val, ok := os.LookupEnv("MY_VAR")     // ok различает "пусто" и "не задано"
os.Setenv("KEY", "value")             // меняет окружение ТЕКУЩЕГО процесса
for _, kv := range os.Environ() {     // все переменные как "KEY=value"
    fmt.Println(kv)
}

// Идентификаторы.
fmt.Println(os.Getpid())  // PID процесса
fmt.Println(os.Getppid()) // PID родителя
fmt.Println(os.Getuid(), os.Getgid()) // реальные UID/GID пользователя

// Рабочая директория.
dir, _ := os.Getwd()
os.Chdir("/tmp") // сменить (влияет на все относительные пути процесса)

// Выход. os.Exit НЕ выполняет defer'ы и не ждёт горутины — это резкий выход!
os.Exit(1)

Тонкость про окружение: os.Setenv меняет окружение только текущего процесса (и наследуется потомками, которых вы запустите после). Изменить окружение родителя или уже запущенного соседа нельзя — это фундаментальное свойство Unix: окружение копируется при fork, а не разделяется.

И про os.Exit: он немедленно завершает процесс системным вызовом exit_group, не разворачивая стек. Это значит — никакие defer не сработают, буферы не сбросятся, горутины не доработают. Поэтому os.Exit уместен только на самом верху main (или в обработчике фатальной ошибки), и никогда — в середине логики, где что-то нужно доубрать.

3.4 Временные файлы, директории, права

// Временный файл: os сам придумает уникальное имя, вы получите открытый *os.File.
tmp, err := os.CreateTemp("", "upload-*.tmp") // "" = системная /tmp
if err != nil { /* ... */ }
defer os.Remove(tmp.Name()) // за собой убираем
defer tmp.Close()

// Временная директория.
dir, err := os.MkdirTemp("", "workspace-*")
defer os.RemoveAll(dir) // рекурсивно

// Директории.
os.Mkdir("one", 0o755)          // одна, родитель должен существовать
os.MkdirAll("a/b/c", 0o755)     // с промежуточными, как mkdir -p

// Метаданные.
info, err := os.Stat("file")    // следует по символическим ссылкам
info, err = os.Lstat("link")    // НЕ следует — расскажет про саму ссылку
if info.IsDir() { /* ... */ }
fmt.Println(info.Size(), info.Mode(), info.ModTime())

// Права и владелец.
os.Chmod("file", 0o600)
os.Chown("file", uid, gid)

3.5 Чтение директорий и обход дерева

Директория в Unix — это тоже файл, содержимое которого — список имён с номерами inode. Читать её напрямую (getdents) неудобно, поэтому os даёт обёртки:

// Быстрый способ: сразу срез записей.
entries, err := os.ReadDir("/etc")
for _, e := range entries {
    fmt.Println(e.Name(), e.IsDir())
    // e.Info() дёрнет stat только при необходимости — экономия системных вызовов
}

os.ReadDir намеренно возвращает []os.DirEntry, а не []os.FileInfo: DirEntry содержит только имя и тип (которые приходят даром из getdents), а полную информацию (Info()) подгружает ленивым stat лишь если вы её спросите. На директории с десятками тысяч файлов это разница между одним системным вызовом и десятками тысяч.

Для рекурсивного обхода — filepath.WalkDir (тоже ленивый, в отличие от старого filepath.Walk):

import "path/filepath"

filepath.WalkDir("/some/tree", func(path string, d os.DirEntry, err error) error {
    if err != nil {
        return err // ошибка доступа к конкретному узлу
    }
    if d.IsDir() && d.Name() == ".git" {
        return filepath.SkipDir // не заходить внутрь этой директории
    }
    fmt.Println(path)
    return nil
})

3.6 io/fs: абстракция файловой системы

С Go 1.16 появился пакет io/fs — интерфейс абстрактной файловой системы. os.DirFS("/some/root") даёт «вид» на реальную ФС, а embed.FS — на файлы, вшитые в бинарник на этапе компиляции. Код, написанный против fs.FS, не знает, откуда берутся файлы:

import (
    "io/fs"
    "embed"
)

//go:embed templates/*.html
var templates embed.FS // файлы вкомпилированы в бинарник!

func render(fsys fs.FS, name string) ([]byte, error) {
    return fs.ReadFile(fsys, name) // работает и с embed.FS, и с os.DirFS, и с zip
}

Это верхний этаж лестницы абстракций из части I: настолько высоко, что «файл» уже может вообще не существовать на диске. Но под os.DirFS всё те же openat/read, что мы разбирали внизу.

---

Часть IV. Файлы и дескрипторы: «всё есть файл»

4.1 Великая унификация Unix

Есть одна идея, которая делает Unix (и Linux) концептуально стройным, и её надо прочувствовать: почти всё в системе представлено как файл. Не только файлы на диске, но и:

  • терминал, за которым вы сидите (/dev/tty);
  • сетевое соединение (сокет);
  • канал между процессами (pipe);
  • устройства (/dev/null, /dev/random, диск /dev/sda);
  • даже некоторые события ядра (signalfd, eventfd, timerfd, inotify).

«Быть файлом» здесь означает конкретную вещь: с объектом можно работать через файловый дескриптор одним и тем же набором системных вызовов — read, write, close, poll. Читаете вы байты из файла на диске или из TCP-соединения — вызов один и тот же: read(fd, buf, n). Эта унификация — причина, по которой один интерфейс io.Reader в Go накрывает такое разнообразие источников.

4.2 Что такое файловый дескриптор физически

Файловый дескриптор — это маленькое неотрицательное целое число. Но за этим числом стоит трёхуровневая структура в ядре, которую полезно держать в голове, потому что она объясняет массу тонкостей (наследование при fork, разделяемую позицию чтения, dup).

Процесс                     Ядро (глобально)              
┌───────────────────┐       ┌──────────────────────┐      ┌─────────────┐
│ Таблица дескрипт. │       │ Таблица открытых     │      │ Таблица     │
│ (у каждого своя)  │       │ файлов (open file    │      │ inode       │
│                   │       │ table, разделяемая)  │      │             │
│  0 ──────────────────────▶│ [запись: offset,     │─────▶│ метаданные, │
│  1 ──┐            │       │  режим, флаги] ──────┼──────│ где данные  │
│  2   │            │       │                      │      │ на диске    │
│  3 ──┼──────────▶ │       │ [другая запись]      │      │             │
└──────┼────────────┘       └──────────────────────┘      └─────────────┘
       └─ два дескриптора могут указывать на ОДНУ запись (после dup)

Три уровня:

1. Таблица дескрипторов процесса. У каждого процесса своя. Дескриптор 3 в вашем процессе и 3 в соседнем — совершенно разные вещи. Именно эту таблицу индексирует то самое маленькое число. 2. Таблица открытых файлов (ядро, общая). Каждая запись хранит позицию чтения/записи (offset), режим доступа и флаги. Ключевой момент: несколько дескрипторов (в одном процессе или в разных) могут указывать на одну запись — тогда они делят offset. Так происходит при dup и при fork. 3. Таблица inode. Собственно объект: файл, его метаданные и указатели на данные. Один inode может быть открыт много раз разными записями.

Из этой картинки вытекают вещи, которые иначе кажутся магией:

  • После fork родитель и потомок делят offset. Дескрипторы копируются, но указывают на ту же запись в open file table. Поэтому если оба пишут в один дескриптор, их вывод не затирается, а идёт по очереди в общий offset.
  • dup(fd) создаёт второй дескриптор на ту же запись. close одного не закрывает объект, пока жив второй. На этом построено перенаправление вывода (2>&1 в шелле — это dup2).
  • Дескрипторы — исчерпаемый ресурс. У процесса есть лимит (ulimit -n, обычно 1024 или больше). Утечка дескрипторов (забытый Close) рано или поздно приводит к EMFILE — «too many open files», классической проблеме высоконагруженных сервисов. Отсюда железное defer f.Close().

4.3 Три дескриптора, которые есть всегда

При старте любого процесса Unix ему дают три уже открытых дескриптора:

  • 0 — stdin, стандартный ввод (os.Stdin);
  • 1 — stdout, стандартный вывод (os.Stdout);
  • 2 — stderr, стандартный вывод ошибок (os.Stderr).

Это соглашение, а не закон физики — но на нём держится вся композиция Unix-утилит. Именно потому, что программы читают из 0 и пишут в 1, работает конвейер cat file | grep foo | sort: шелл соединяет stdout одной программы с stdin другой через pipe. А разделение 1 и 2 позволяет ./prog > out.txt 2> err.txt — данные и ошибки в разные файлы.

В Go это просто три os.File:

fmt.Fprintln(os.Stdout, "нормальный вывод")   // → дескриптор 1
fmt.Fprintln(os.Stderr, "диагностика/ошибки") // → дескриптор 2
input, _ := io.ReadAll(os.Stdin)              // ← дескриптор 0

Практическое правило, которое отличает хорошую утилиту: результат — в stdout, всё остальное (логи, прогресс, ошибки) — в stderr. Тогда вашу программу можно поставить в конвейер, и диагностика не испортит данные.

4.4 Offset, Seek и позиционный ввод-вывод

Каждая обычная запись в open file table хранит позицию. Последовательные read/write двигают её вперёд. Явно управлять ею — Seek:

f, _ := os.Open("data.bin")

f.Seek(100, io.SeekStart)   // на 100 байт от начала
f.Seek(0, io.SeekEnd)       // в конец (например, чтобы узнать размер)
pos, _ := f.Seek(0, io.SeekCurrent) // где мы сейчас

// Позиционный ввод-вывод: читать/писать по смещению, НЕ трогая offset.
// Это pread/pwrite — критично для конкурентного доступа: несколько горутин
// могут читать РАЗНЫЕ части одного файла без гонки за общий offset.
buf := make([]byte, 512)
n, _ := f.ReadAt(buf, 4096)  // прочитать 512 байт начиная со смещения 4096
f.WriteAt(data, 8192)        // записать по смещению 8192

ReadAt/WriteAt (системные вызовы pread/pwrite) — недооценённый инструмент. Они не зависят от текущей позиции и не меняют её, поэтому безопасны для параллельного использования из многих горутин на одном os.File. На этом строятся встраиваемые БД и форматы файлов с произвольным доступом.

4.5 Атомарность записи и долговечность данных

Тема, на которой горит бесчисленное количество приложений: запись в файл не означает, что данные на диске.

Когда вы делаете write, данные попадают в страничный кеш ядра (page cache) — в оперативную память. Ядро запишет их на настоящий диск позже, когда ему удобно. Если в этот момент выключат питание — данные пропадут, хотя write вернул успех. Чтобы гарантировать попадание на носитель, нужен fsync:

f, _ := os.Create("important.dat")
f.Write(data)
f.Sync()  // fsync: блокируется, пока ядро не сбросит данные на физический диск
f.Close()

Но и fsync одного файла мало, если вы хотите атомарную замену (частая задача: обновить конфиг/состояние так, чтобы читатель никогда не увидел半-записанный файл). Каноничный рецепт «write-rename»:

func atomicWrite(path string, data []byte) error {
    dir := filepath.Dir(path)
    // 1. Пишем во временный файл в ТОЙ ЖЕ директории (rename атомарен только в пределах ФС).
    tmp, err := os.CreateTemp(dir, ".tmp-*")
    if err != nil {
        return err
    }
    tmpName := tmp.Name()
    defer os.Remove(tmpName) // на случай ошибки — подчистить

    if _, err := tmp.Write(data); err != nil {
        tmp.Close()
        return err
    }
    // 2. fsync временного файла — данные точно на диске.
    if err := tmp.Sync(); err != nil {
        tmp.Close()
        return err
    }
    if err := tmp.Close(); err != nil {
        return err
    }
    // 3. rename — атомарная операция: путь либо указывает на старый файл, либо на новый.
    //    Промежуточного состояния читатель не увидит НИКОГДА.
    if err := os.Rename(tmpName, path); err != nil {
        return err
    }
    // 4. (Педантично) fsync директории, чтобы сама запись о переименовании пережила сбой.
    d, err := os.Open(dir)
    if err != nil {
        return err
    }
    defer d.Close()
    return d.Sync()
}

rename в пределах одной файловой системы атомарен на уровне ядра: наблюдатель, открывающий path, получит либо целиком старый файл, либо целиком новый. Этот приём — фундамент надёжного хранения состояния (его используют etcd, БД, менеджеры пакетов). Три тонкости, которые все забывают: временный файл — в той же директории (иначе rename через границу ФС не атомарен и превратится в копирование); fsync перед rename; fsync директории после.

4.6 Блокировки файлов

Когда несколько процессов лезут в один файл, нужна координация. Linux даёт два механизма блокировок, и путать их — источник боли.

flock (unix.Flock) — блокировка на весь открытый файл, простая, привязана к записи в open file table:

f, _ := os.OpenFile("resource.lock", os.O_CREATE|os.O_RDWR, 0o644)
defer f.Close()

// Эксклюзивная блокировка; LOCK_NB — не блокироваться, а сразу вернуть ошибку, если занято.
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
    log.Fatal("ресурс уже заблокирован другим процессом")
}
defer unix.Flock(int(f.Fd()), unix.LOCK_UN)

// ... эксклюзивная работа с ресурсом ...

fcntl-локи (POSIX record locks) — умеют блокировать диапазоны байт внутри файла, но имеют печально известную семантику: блокировка снимается при закрытии любого дескриптора на этот файл в процессе. flock проще и обычно достаточно для «один процесс — один ресурс» (например, чтобы не запустить две копии демона: PID-файл под flock).

---

Часть V. Процессы: fork, exec и особый путь Go

5.1 Как в Unix вообще появляются процессы

В Unix есть ровно один способ создать новый процесс, и он поначалу кажется странным. Нет вызова «запусти программу X». Вместо этого — два раздельных примитива, которые почти всегда используются в паре.

fork() создаёт точную копию текущего процесса. После fork существуют два почти одинаковых процесса: родитель и потомок. У них идентичные: код, данные, куча, стек, открытые дескрипторы, рабочая директория. Различаются они ровно в одном — в возвращаемом значении fork: родителю возвращается PID потомка, а потомку — ноль. По этому значению код и понимает, «кто он теперь».

// Как это выглядит в C — чтобы понять модель:
pid_t pid = fork();
if (pid == 0) {
    // мы в ПОТОМКЕ
} else if (pid > 0) {
    // мы в РОДИТЕЛЕ, pid — это PID потомка
} else {
    // ошибка fork
}

exec() (семейство execve и родственники) заменяет образ текущего процесса на другую программу. Тот же процесс (тот же PID, те же открытые дескрипторы) — но теперь в нём исполняется совсем другой код: код и данные старой программы стёрты и заменены новыми. exec при успехе не возвращается — возвращаться уже некуда, старой программы нет.

Схема «запустить внешнюю программу» = fork + exec:

родитель ──fork()──▶ потомок (копия родителя)
                        │
                        └──execve("/bin/ls")──▶ потомок стал программой ls
родитель ──wait()──────────────────────────────▶ дождался завершения потомка

Родитель форкается, потомок немедленно вызывает exec нужной программы, а родитель через wait дожидается её завершения и забирает код возврата. Между fork и exec есть крошечное окно, в котором потомок ещё «свой» — и в этом окне удобно донастроить его окружение: перенаправить дескрипторы (чтобы вывод потомка шёл в pipe), сменить рабочую директорию, сбросить привилегии. Именно здесь работает магия шелла: ls > out.txt — это fork, затем в потомке dup2 (перенаправить дескриптор 1 в файл), затем exec ls.

Зачем такое разделение вместо одного «spawn»? Оно на удивление мощное: всё окно между fork и exec — это полностью настраиваемый шаблон нового процесса, и настройка делается обычным кодом, а не гигантским набором флагов. Плюс fork без exec — это способ распараллелить сам процесс (так работают классические серверы вроде Apache prefork).

5.2 Почему в Go нельзя просто взять и вызвать fork()

А вот теперь — важнейшая для Go-системщика вещь, которую надо усвоить намертво: вы не можете безопасно вызвать голый fork() в Go-программе. В x/sys/unix даже нет удобной обёртки Fork(), и это не упущение, а защита.

Причина в природе fork и природе рантайма Go. fork копирует только вызывающий поток, но не остальные потоки процесса. А Go-программа всегда многопоточна: рантайм держит несколько потоков ОС (M) для планировщика, отдельный поток для сборщика мусора, поток sysmon для мониторинга, потоки для системных вызовов. В момент fork все они, кроме форкающего, в потомке просто исчезают — но структуры данных, которыми они владели, остаются в скопированной памяти в произвольном состоянии.

Представьте: поток GC в момент fork держал внутренний мьютекс аллокатора. В потомке этот поток не существует, а мьютекс остался «захваченным навсегда». Первая же попытка потомка выделить память — вечная блокировка (или паника). Планировщик в потомке видит очереди горутин, которые некому исполнять. Состояние рантайма в потомке — фарш.

Единственное, что безопасно делать между fork и exec в такой ситуации, — это вызывать так называемые async-signal-safe функции: узкий список примитивных системных вызовов, не трогающих сложные структуры (никаких аллокаций, никаких мьютексов, никакого планировщика). И exec в их числе. Поэтому Go разрешает fork только в связке с немедленным exec, и делает это через специальную, написанную с ювелирной осторожностью функцию syscall.forkExec, где между fork и exec не выполняется ни одной небезопасной операции.

Вывод для практики: запускайте внешние процессы через os/exec. Он инкапсулирует всю эту хрупкую механику. Ручной fork — привилегия авторов рантайма.

5.3 os/exec: правильный способ запускать процессы

os/exec — это высокоуровневый, безопасный и удобный интерфейс к fork+exec.

package main

import (
    "bytes"
    "context"
    "fmt"
    "os/exec"
    "time"
)

func main() {
    // Контекст с таймаутом: если процесс не уложится — его убьют (SIGKILL).
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    cmd := exec.CommandContext(ctx, "grep", "-c", "root", "/etc/passwd")

    // Куда пойдёт вывод. Можно подключить к буферам, файлам, pipe.
    var stdout, stderr bytes.Buffer
    cmd.Stdout = &stdout
    cmd.Stderr = &stderr

    // Окружение потомка (если не задать — наследуется от нас).
    cmd.Env = append(cmd.Environ(), "LC_ALL=C")

    // Run = Start + Wait: запустить и дождаться.
    err := cmd.Run()
    if err != nil {
        // *exec.ExitError означает, что процесс запустился, но вернул ненулевой код.
        var ee *exec.ExitError
        if errors.As(err, &ee) {
            fmt.Printf("код выхода: %d, stderr: %s\n", ee.ExitCode(), stderr.String())
        } else {
            fmt.Printf("не удалось запустить: %v\n", err) // например, программы нет в PATH
        }
        return
    }
    fmt.Printf("вывод: %s", stdout.String())
}

Важно различать два вида ошибок, которые новички путают: не удалось запустить (программа не найдена, нет прав — err не ExitError) и запустился, но завершился с ошибкой (ненулевой код возврата — exec.ExitError). Это разные ситуации: в первом случае программа вообще не работала, во втором — работала и сообщила о неудаче.

Потоковая обработка вывода (когда вывод большой или нужен в реальном времени) — через pipe:

cmd := exec.Command("journalctl", "-f") // следить за логами в реальном времени
stdout, _ := cmd.StdoutPipe()
cmd.Start()

scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
    line := scanner.Text()
    fmt.Println("лог:", line) // обрабатываем строку за строкой по мере поступления
}
cmd.Wait() // обязательно после дочитывания pipe — иначе зомби (см. 5.5)

5.4 Тонкая настройка потомка: SysProcAttr

Через поле cmd.SysProcAttr (тип syscall.SysProcAttr) открывается доступ к тому самому «окну между fork и exec» — можно донастроить потомка на уровне ядра. Это Linux-специфичная мощь:

cmd.SysProcAttr = &syscall.SysProcAttr{
    // Запустить потомка под другим пользователем (сброс привилегий).
    Credential: &syscall.Credential{Uid: 1000, Gid: 1000},

    // Новая группа процессов — чтобы можно было убить всё дерево разом.
    Setpgid: true,

    // Отправить SIGKILL потомку, если РОДИТЕЛЬ умрёт (защита от сирот).
    Pdeathsig: syscall.SIGKILL,

    // САМОЕ ИНТЕРЕСНОЕ: новые namespaces. Это основа контейнеров (часть XII).
    Cloneflags: syscall.CLONE_NEWNS | // новое пространство монтирования
        syscall.CLONE_NEWPID | // новое пространство PID — потомок станет PID 1
        syscall.CLONE_NEWNET | // новый сетевой стек
        syscall.CLONE_NEWUTS,  // свой hostname
}

Одно поле Cloneflags превращает os/exec в примитивный container runtime — мы этим воспользуемся в части XII, чтобы написать docker run в тридцать строк.

5.5 Зомби, сироты и waitpid

Когда процесс завершается, он не исчезает мгновенно. Ядро сохраняет его «огрызок» — код возврата и статистику — до тех пор, пока родитель не заберёт эту информацию через wait/waitpid. Процесс в этом состоянии называется зомби (zombie, Z в выводе ps). Зомби не потребляет ресурсов, кроме одной записи в таблице процессов, — но если родитель никогда не делает wait, зомби копятся и в итоге исчерпывают таблицу процессов (нельзя создать новый процесс — EAGAIN).

Мораль: на каждый запущенный процесс должен быть свой Wait. В os/exec это cmd.Wait() (или cmd.Run(), который включает Wait). Забыли Wait после Start — сделали зомби.

Обратная ситуация — сирота (orphan): родитель умер раньше потомка. Такого потомка «усыновляет» init (PID 1), который регулярно делает wait и подчищает зомби. В контейнерах это важная деталь: если ваш процесс — PID 1 в контейнере, вы становитесь этим «усыновителем» и обязаны подчищать чужих зомби (иначе они копятся). Поэтому в контейнеры часто ставят крошечный init вроде tini.

// Идиома «запустить и не ждать» с корректной уборкой:
cmd := exec.Command("worker")
cmd.Start()
go func() {
    cmd.Wait() // в отдельной горутине — заберём статус, не создадим зомби
}()

5.6 Демонизация и почему в Go она не такая, как в C

В классическом C демон (фоновый сервис) создаётся ритуалом: fork (родитель выходит), setsid (новая сессия, отвязка от терминала), второй fork, chdir("/"), закрытие 0/1/2, перенаправление в /dev/null. Всё это — чтобы процесс пережил закрытие терминала и не имел управляющего tty.

В Go этот ритуал не работает — из-за проблемы fork из 5.2 (многопоточность рантайма). Поэтому в Go-мире демонизацию не делают внутри программы. Вместо этого:

  • Полагаются на систему инициализации (systemd, runit, s6). Ваша программа остаётся обычным foreground-процессом, пишет логи в stdout/stderr, а systemd сам делает её сервисом, отвязывает от терминала, перезапускает при падении, собирает логи в journald. Это современный и правильный путь.
  • Если демонизация всё же нужна без systemd, программа перезапускает сама себя через os/exec с нужными атрибутами (Setsid: true), а первый экземпляр выходит. Существуют библиотеки-хелперы, но по сути они делают именно это.

Философски это отражает сдвиг: раньше программа сама заботилась о своей «фоновости», теперь это ответственность оркестратора (systemd на хосте, Kubernetes в кластере). Ваш Go-сервис должен быть простым foreground-процессом с graceful shutdown по сигналу — а сигналы это следующая часть.

---

Часть VI. Сигналы: асинхронные уведомления

6.1 Что такое сигнал и откуда он берётся

Сигнал — это программное прерывание, способ ядра (или другого процесса) сказать вашему процессу «случилось событие» асинхронно, вне зависимости от того, что процесс сейчас делает. Это древнейший и самый примитивный механизм IPC в Unix: сигнал несёт ровно один бит информации — свой номер. Никаких данных, только «произошло событие типа N».

Откуда прилетают сигналы:

  • От пользователя через терминал. Ctrl+C шлёт SIGINT, Ctrl+\ — SIGQUIT (с дампом), Ctrl+Z — SIGTSTP (приостановка).
  • От других процессов через kill(pid, sig). Утилита kill в шелле — это просто обёртка над этим системным вызовом.
  • От ядра при аппаратных событиях. Обращение по неверному адресу → SIGSEGV. Деление на ноль → SIGFPE. Запись в закрытый pipe → SIGPIPE.
  • От системы инициализации при остановке. systemd/Kubernetes при выключении сервиса шлют SIGTERM, а через grace-period — SIGKILL.
  • От таймеров и дочерних процессов. SIGALRM (будильник), SIGCHLD (потомок завершился).

6.2 Важнейшие сигналы

| Сигнал | № | Дефолтное действие | Смысл | | --- | --- | --- | --- | | SIGINT | 2 | завершить | прерывание с клавиатуры (Ctrl+C) | | SIGQUIT | 3 | завершить + core | Ctrl+\, для отладки | | SIGKILL | 9 | завершить | неперехватываемый, мгновенная смерть | | SIGSEGV | 11 | завершить + core | обращение к неверной памяти | | SIGPIPE | 13 | завершить | запись в канал без читателя | | SIGTERM | 15 | завершить | вежливая просьба завершиться (по умолчанию у kill) | | SIGSTOP | 19 | приостановить | неперехватываемая пауза | | SIGCONT | 18 | продолжить | возобновить после STOP | | SIGCHLD | 17 | игнорировать | потомок изменил состояние | | SIGHUP | 1 | завершить | «повесили трубку»; по традиции — «перечитай конфиг» | | SIGUSR1/2 | 10/12 | завершить | свободны для нужд приложения |

Два сигнала стоят особняком: SIGKILL (9) и SIGSTOP (19) нельзя ни перехватить, ни проигнорировать, ни переопределить. Это гарантия ядра, что систему всегда можно вернуть под контроль. Когда вы делаете kill -9, процесс не получает шанса ничего сделать — ядро просто вычёркивает его. Поэтому «graceful» реакция возможна только на другие сигналы (SIGTERM, SIGINT): их вы можете перехватить и корректно завершиться. SIGKILL — это когда терпение кончилось.

6.3 Почему сигналы и Go — сложные соседи

В программах на C сигнал обрабатывается обработчиком сигнала (signal handler) — функцией, которую ядро вызывает, прерывая нормальное исполнение в произвольной точке. И это ад: обработчик может прерваться в середине любой операции, поэтому внутри него можно вызывать только тот самый узкий список async-signal-safe функций. Нельзя брать мьютексы (вдруг прервали в момент, когда мьютекс уже взят — deadlock), нельзя выделять память (аллокатор мог быть в середине работы), нельзя почти ничего.

Go решает эту проблему элегантно: рантайм ставит свои низкоуровневые обработчики сигналов, но не даёт вашему коду исполняться в опасном контексте. Вместо этого пойманный сигнал превращается в обычное значение, которое отправляется в канал. Ваш код читает этот канал из нормальной горутины — в безопасном контексте, где можно всё. Асинхронный, опасный сигнал становится синхронным, ручным сообщением в канале. Это прекрасный пример философии Go «превратить сложное асинхронное в простое синхронное через каналы».

6.4 os/signal: ловим сигналы правильно

package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // Канал для сигналов. Буфер >= 1 ОБЯЗАТЕЛЕН: доставка неблокирующая,
    // и если канал полон в момент сигнала, сигнал ПОТЕРЯЕТСЯ.
    sigCh := make(chan os.Signal, 1)

    // Подписываемся на конкретные сигналы. Без указания — ловятся все ловимые.
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)

    fmt.Println("работаю, PID:", os.Getpid())
    fmt.Println("пошлите сигнал: kill -HUP", os.Getpid())

    for sig := range sigCh {
        switch sig {
        case syscall.SIGHUP:
            fmt.Println("SIGHUP: перечитываю конфигурацию...")
            // reloadConfig() — и продолжаем работать
        case syscall.SIGINT, syscall.SIGTERM:
            fmt.Println("завершаюсь по", sig)
            // gracefulShutdown()
            return
        }
    }
}

Три правила signal.Notify, нарушение которых порождает трудноуловимые баги:

1. Буфер канала минимум 1. Доставка сигнала в канал неблокирующая: рантайм пытается положить сигнал и, если канал полон, молча выбрасывает. Небуферизованный канал, в который никто не читает в точный момент сигнала, — гарантированная потеря. 2. Подписывайтесь на конкретные сигналы. signal.Notify(ch) без аргументов ловит всё, включая SIGCHLD и прочий шум, который забьёт канал. 3. signal.Stop(ch) для отписки, когда канал больше не нужен, чтобы рантайм перестал в него слать.

6.5 signal.NotifyContext: современная идиома

С Go 1.16 появился способ ещё изящнее — сразу превратить сигнал в отмену контекста. Это стыкует сигналы со всей экосистемой context (и с graceful shutdown из гайда по конкурентности):

func main() {
    // ctx отменится при первом SIGINT/SIGTERM.
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{Addr: ":8080"}
    go srv.ListenAndServe()

    <-ctx.Done() // ждём сигнал
    stop()       // ВАЖНО: восстанавливаем дефолтное поведение сигналов —
                 // теперь ВТОРОЙ Ctrl+C убьёт процесс мгновенно (правильный UX:
                 // если graceful shutdown завис, пользователь может «донажать»)

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    srv.Shutdown(shutdownCtx)
}

Пара «первый сигнал ловим — второй пропускаем к дефолту» — важная деталь хорошего сервиса. Первый SIGTERM запускает вежливое завершение; если оно зависло, второй должен убить процесс, а не быть снова проглоченным. NotifyContext + stop() дают это бесплатно.

6.6 Сигналы, которые Go обрабатывает по-своему

Рантайм Go перехватывает некоторые сигналы для собственных нужд, и об этом надо знать:

  • SIGPIPE. Запись в закрытый на том конце сокет/pipe по умолчанию убивает процесс. Go подавляет SIGPIPE для своих дескрипторов и вместо этого возвращает ошибку EPIPE из Write — гораздо цивилизованнее. (Исключение — если вы пишете напрямую в дескрипторы 1/2, унаследованные от шелла.)
  • SIGSEGV, SIGBUS, SIGFPE. Рантайм ловит их, чтобы превратить в панику Go с осмысленным стектрейсом (например, разыменование nil-указателя — это SIGSEGV, который вы видите как panic: runtime error: invalid memory address).
  • SIGURG. С Go 1.14 рантайм использует его для асинхронной вытеснительной планировки: чтобы прервать горутину, застрявшую в длинном цикле без вызовов функций, рантайм шлёт потоку SIGURG. Именно поэтому в strace вы видите поток SIGURG-ов — это не баг, это планировщик работает. И именно поэтому системные вызовы могут прерываться (EINTR), а рантайм их автоматически перезапускает.
  • SIGQUIT (Ctrl+\) заставляет Go-программу вывалить стектрейсы всех горутин и упасть — бесценно для отладки зависаний. Управляется переменной GOTRACEBACK.

6.7 Отправка сигналов из Go

// Найти процесс и послать сигнал.
proc, err := os.FindProcess(pid) // на Unix всегда «успешно» — не проверяет существование
if err == nil {
    proc.Signal(syscall.SIGTERM) // вот здесь уже узнаем, жив ли он (ESRCH если нет)
}

// Убить дерево процессов: если потомок создан с Setpgid,
// отрицательный PID шлёт сигнал всей группе.
syscall.Kill(-pgid, syscall.SIGTERM)

// Сигнал самому себе — иногда нужно (например, «доиграть» дефолтное поведение).
syscall.Kill(os.Getpid(), syscall.SIGSTOP)

Тонкость Unix: os.FindProcess на Unix-системах не проверяет, существует ли процесс, — он всегда возвращает объект. Реальная проверка происходит при отправке сигнала: если процесса нет, Signal вернёт ошибку. А ещё есть классическая гонка: между тем, как вы узнали PID, и тем, как послали сигнал, процесс мог умереть, а его PID — достаться другому процессу (PID reuse). Современное решение — pidfd (unix.PidfdOpen): дескриптор, привязанный к конкретному процессу, а не к переиспользуемому номеру.

---

Часть VII. IPC: как процессы общаются

7.1 Карта средств межпроцессного взаимодействия

Процессы изолированы друг от друга — в этом весь смысл процессов. Но иногда им надо общаться, и Linux предлагает целый арсенал механизмов межпроцессного взаимодействия (IPC), каждый со своей нишей:

  • Сигналы (часть VI) — один бит «событие произошло». Минимум информации, максимум асинхронности.
  • Pipe (канал) — однонаправленный поток байт между родственными процессами. Основа шелловских конвейеров.
  • FIFO (именованный канал) — то же, но с именем в файловой системе, для неродственных процессов.
  • Сокеты Unix-домена — двунаправленный канал, самый гибкий; умеет передавать даже дескрипторы.
  • Разделяемая память — самый быстрый способ: два процесса видят один участок памяти. Общаются без копирования и без ядра на горячем пути.
  • Очереди сообщений и семафоры (System V / POSIX IPC) — структурированные сообщения и синхронизация. В современном Go-коде встречаются редко, поэтому упомянем кратко.

Пройдём по главным.

7.2 Pipe: конвейер

Pipe — это однонаправленная труба в ядре с двумя концами: пишущим и читающим. Данные, записанные в пишущий конец, читаются из читающего в том же порядке (FIFO). Классически используется между родственными процессами: родитель создаёт pipe до fork, дескрипторы копируются потомку, и теперь один процесс пишет, другой читает.

package main

import (
    "fmt"
    "os"
)

func main() {
    // os.Pipe возвращает пару связанных *os.File: читающий и пишущий концы.
    r, w, err := os.Pipe()
    if err != nil {
        panic(err)
    }

    go func() {
        defer w.Close() // ЗАКРЫТЬ пишущий конец — сигнал EOF читателю!
        fmt.Fprintln(w, "сообщение через pipe")
        fmt.Fprintln(w, "второе сообщение")
    }()

    buf := make([]byte, 256)
    for {
        n, err := r.Read(buf)
        if n > 0 {
            os.Stdout.Write(buf[:n])
        }
        if err != nil {
            break // io.EOF, когда пишущий конец закрыт И буфер вычитан
        }
    }
}

Три свойства pipe, которые определяют его поведение и порождают все связанные грабли:

1. EOF наступает при закрытии всех пишущих концов. Читатель получит io.EOF только когда закрыты все дескрипторы на запись. Если забыть закрыть пишущий конец (например, он остался открытым в родителе после fork), читатель зависнет навсегда, ожидая данные, которые не придут. Это причина №1 зависаний с pipe. 2. Запись в pipe без читателей → SIGPIPE/EPIPE. Если читающий конец закрыт, а вы пишете — ядро шлёт SIGPIPE (в Go это EPIPE, см. 6.6). Так шелл узнаёт, что head в producer | head закрылся, и останавливает producer. 3. Буфер ограничен (обычно 64 КБ). Когда буфер полон, write блокируется, пока читатель не разгребёт. Это естественный backpressure: быстрый писатель притормаживает под медленного читателя.

Внутри Go pipe между горутинами — это io.Pipe (чисто в памяти, без ядра), а os.Pipe — настоящий системный pipe с дескрипторами (нужен, когда один конец отдаётся внешнему процессу через exec).

7.3 FIFO: pipe с именем

Обычный pipe существует только пока живы держащие его процессы и доступен только родственникам. FIFO (named pipe) решает это: он имеет имя в файловой системе, и любой процесс с правами может его открыть.

import "golang.org/x/sys/unix"

// Создать FIFO в файловой системе (как mkfifo).
unix.Mkfifo("/tmp/mypipe", 0o644)

// Процесс-писатель (в одном терминале):
w, _ := os.OpenFile("/tmp/mypipe", os.O_WRONLY, 0)
// ВНИМАНИЕ: OpenFile на FIFO для записи БЛОКИРУЕТСЯ, пока кто-то не откроет на чтение!
fmt.Fprintln(w, "привет через FIFO")

// Процесс-читатель (в другом терминале):
r, _ := os.OpenFile("/tmp/mypipe", os.O_RDONLY, 0)
io.Copy(os.Stdout, r)

Ключевая особенность и грабля: открытие FIFO блокируется до появления процесса на другом конце. Открыли на запись — ждёте читателя; открыли на чтение — ждёте писателя. Это фича (синхронизация «встречи»), но она удивляет. Обойти можно флагом O_NONBLOCK.

FIFO — простой способ связать две несвязанные программы через файловую систему без сети. Но для серьёзного IPC чаще берут Unix-сокеты.

7.4 Unix-domain сокеты: гибкий и быстрый IPC

Unix-domain сокеты (UDS) — это сокеты, живущие в пределах одной машины. По API они почти идентичны сетевым (тот же net.Dial/net.Listen), но данные не уходят в сетевой стек — они копируются в ядре напрямую между процессами. Отсюда — они быстрее TCP по localhost (нет накладных расходов TCP/IP), и это стандартный способ общения локальных сервисов: так работают Docker (/var/run/docker.sock), PostgreSQL, systemd, X11.

// Сервер:
ln, err := net.Listen("unix", "/tmp/echo.sock")
if err != nil { panic(err) }
defer ln.Close() // удаляет файл сокета

for {
    conn, err := ln.Accept()
    if err != nil { break }
    go func(c net.Conn) {
        defer c.Close()
        io.Copy(c, c) // эхо
    }(conn)
}

// Клиент:
conn, _ := net.Dial("unix", "/tmp/echo.sock")
conn.Write([]byte("привет"))

Обратите внимание: интерфейс — тот же net.Conn, что и для TCP. Ваш код обработки соединения не отличает Unix-сокет от сетевого. Красота единого интерфейса.

Суперспособность UDS — передача файловых дескрипторов между процессами. Через специальное «вспомогательное сообщение» (SCM_RIGHTS) один процесс может отправить другому открытый дескриптор. Дескриптор — это индекс в таблице конкретного процесса (см. 4.2), но ядро при передаче создаёт эквивалентную запись в таблице получателя. Так работает, например, graceful restart сетевых серверов с нулевым простоем: старый процесс передаёт слушающий сокет новому, и ни одно соединение не теряется.

// Отправка дескриптора (упрощённо):
rights := unix.UnixRights(int(file.Fd()))
unix.Sendmsg(sockFd, nil, rights, nil, 0)
// На приёме — unix.Recvmsg + unix.ParseSocketControlMessage + unix.ParseUnixRights

7.5 Разделяемая память и mmap

Самый быстрый IPC — вообще без копирования: два процесса отображают один и тот же участок физической памяти в свои адресные пространства и работают с ним как с обычным срезом байт. Ядро участвует только при настройке; дальше обмен идёт на скорости памяти.

Механизм — mmap. Он же используется для отображения файлов в память (memory-mapped files), что полезно и вне IPC.

package main

import (
    "fmt"
    "os"

    "golang.org/x/sys/unix"
)

func main() {
    f, _ := os.OpenFile("/tmp/shared.dat", os.O_CREATE|os.O_RDWR, 0o644)
    defer f.Close()

    const size = 4096
    f.Truncate(size) // задать размер файла

    // mmap: отобразить файл в память.
    //   PROT_READ|PROT_WRITE — читать и писать через память;
    //   MAP_SHARED — изменения видны другим процессам и попадают в файл
    //                (MAP_PRIVATE — приватная копия, изменения не расшариваются).
    data, err := unix.Mmap(int(f.Fd()), 0, size,
        unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
    if err != nil {
        panic(err)
    }
    defer unix.Munmap(data)

    // Теперь data — это []byte, напрямую отображённый на файл/память.
    // Запись в срез = запись в файл, БЕЗ вызовов write.
    copy(data, []byte("данные в разделяемой памяти"))

    // Другой процесс, сделавший mmap того же файла с MAP_SHARED,
    // увидит эти байты немедленно. Для гарантии сброса на диск — unix.Msync.
    fmt.Println("записано через отображённую память")
}

Чем mmap-файл лучше обычного read/write для больших файлов: во-первых, нет копирования из страничного кеша ядра в буфер программы — вы работаете с кешем напрямую; во-вторых, ядро подгружает страницы лениво, по мере обращения (page fault), и умно вытесняет неиспользуемые. Поэтому mmap любят БД и поисковые движки (так работает, например, значительная часть Bolt/bbolt).

Грабли mmap, о которых надо знать: обращение за пределы отображения или к «дырке» в разреженном файле даёт SIGBUS (не панику Go, а честный сигнал, который свалит процесс); отображённая память не участвует в подсчёте кучи Go, и сборщик мусора о ней не знает — вы управляете ей вручную через Munmap; для анонимной разделяемой памяти (без файла) используют MAP_ANONYMOUS|MAP_SHARED или, современнее, memfd_create (unix.MemfdCreate) — анонимный файл, живущий только в памяти.

7.6 eventfd, timerfd, signalfd: события как дескрипторы

Отдельно упомяну красивое семейство Linux-специфичных механизмов, воплощающих принцип «всё есть файл» до предела: они превращают события в читаемые дескрипторы, чтобы их можно было ждать через epoll вместе с сокетами и файлами.

  • eventfd — счётчик-семафор в виде дескриптора. Один процесс/поток пишет в него число, другой читает — простейшая межпоточная нотификация. Внутри рантайма Go похожая идея используется для пробуждения netpoller.
  • timerfd — таймер как дескриптор: когда таймер срабатывает, дескриптор становится «читаемым». Позволяет ждать таймеры и сокеты одним epoll.
  • signalfd — сигналы как дескриптор: вместо асинхронного обработчика вы читаете сигналы из дескриптора синхронно. (В Go это не нужно — рантайм уже даёт сигналы через каналы, см. 6.3 — но концептуально это тот же приём.)

Эти примитивы — фундамент событийных циклов на Linux. Go прячет их за netpoller'ом, но если вы пишете что-то экзотическое (свой event loop поверх epoll), они ваши друзья.

---

Часть VIII. Мультиплексирование ввода-вывода

8.1 Проблема: один поток, много соединений

Вот фундаментальная задача сетевого сервера. У вас тысяча открытых соединений. В любой момент данные могут прийти по любому из них — а могут и не прийти. Как их обслуживать?

Наивный подход «поток на соединение с блокирующим read» упирается в стоимость потоков: тысяча потоков ОС — это гигабайты стеков и убийственные накладные расходы на переключение. Наивный подход «крутить цикл, опрашивая каждое соединение неблокирующим read» сжигает процессор впустую (busy-wait), проверяя тысячу молчащих сокетов миллион раз в секунду.

Нужен способ сказать ядру: «вот тысяча дескрипторов; усыпи меня и разбуди, когда хоть на одном появятся данные». Это и есть мультиплексирование ввода-вывода (I/O multiplexing). Linux предлагает три поколения таких механизмов.

8.2 Три поколения: select, poll, epoll

select (1983) — древнейший. Вы передаёте ядру три битовые маски дескрипторов (ждём чтения / записи / ошибок), ядро блокируется и возвращает, когда что-то готово. Проблемы: жёсткий лимит FD_SETSIZE (обычно 1024 дескриптора — потолок); маски надо пересоздавать на каждый вызов; ядро линейно сканирует все дескрипторы — O(n) на каждый вызов. Для маленьких наборов ещё жив, для тысяч соединений непригоден.

poll (1997) — убрал лимит на 1024 (передаётся массив, а не битовая маска), но осталась главная беда: O(n) сканирование всего набора на каждый вызов. При 100 000 соединений, из которых активны 10, ядро всё равно проверяет все 100 000. Не масштабируется.

epoll (2002, Linux-специфичный) — решение «C10k problem» (как обслужить 10 000 соединений). Ключевая идея — разделить регистрацию и ожидание. Вы один раз регистрируете дескрипторы в объекте epoll (epoll_ctl), а потом много раз ждёте события (epoll_wait). Ядро хранит зарегистрированный набор у себя и возвращает только готовые дескрипторы — не весь набор. Сложность epoll_wait — O(число готовых событий), а не O(общего числа дескрипторов). Это и делает возможными миллионы соединений на одной машине.

select/poll: «проверь ВСЕ эти дескрипторы»  → ядро сканирует все → O(n) каждый раз
epoll:       «я заранее дал тебе набор»     → ядро следит сам
             «дай ГОТОВЫЕ»                    → возвращает только их → O(готовых)

8.3 Как работает epoll: три вызова

// Это иллюстрация механики. В реальном Go-коде так писать НЕ НАДО —
// всё это спрятано внутри runtime netpoller (см. часть X). Но понимать полезно.
import "golang.org/x/sys/unix"

// 1. epoll_create1: создать epoll-инстанс, получить его дескриптор.
epfd, _ := unix.EpollCreate1(0)
defer unix.Close(epfd)

// 2. epoll_ctl: зарегистрировать дескриптор сокета для слежения.
//    EPOLLIN — интересуют события «доступно чтение».
//    EPOLLET — edge-triggered режим (см. ниже).
event := unix.EpollEvent{Events: unix.EPOLLIN | unix.EPOLLET, Fd: int32(connFd)}
unix.EpollCtl(epfd, unix.EPOLL_CTL_ADD, connFd, &event)

// 3. epoll_wait: заснуть, пока не появятся готовые дескрипторы.
events := make([]unix.EpollEvent, 128)
for {
    n, err := unix.EpollWait(epfd, events, -1) // -1 = ждать бесконечно
    if err == unix.EINTR {
        continue
    }
    for i := 0; i < n; i++ {
        fd := events[i].Fd
        // на fd готовы данные — читаем их неблокирующим read до EAGAIN
        handleReadable(int(fd))
    }
}

8.4 Level-triggered vs edge-triggered

У epoll есть два режима уведомления, и путаница между ними — источник тонких багов.

Level-triggered (LT, по умолчанию) — «пока есть данные, я буду напоминать». epoll_wait возвращает дескриптор как готовый всякий раз, пока в нём остаются непрочитанные данные. Можно прочитать часть и вернуться в цикл — на следующей итерации дескриптор снова придёт как готовый. Проще и прощает ошибки.

Edge-triggered (ET, флаг EPOLLET) — «я скажу только в момент появления новых данных, дальше сам». Дескриптор возвращается как готовый только когда состояние меняется (пришли новые данные). Если вы не вычитали всё за один раз, о остатке вам больше не напомнят — он «зависнет». Поэтому с ET обязательно читать в цикле до EAGAIN (пока read не скажет «данных больше нет»). ET эффективнее (меньше вызовов epoll_wait), но требует дисциплины.

Go в своём netpoller использует edge-triggered epoll — и всю дисциплину «читать до EAGAIN» рантайм берёт на себя, а вам отдаёт простой блокирующий conn.Read. В этом и суть следующих частей: epoll — сложный, но вы его не видите, потому что между ним и вами стоит планировщик Go. Чтобы понять, как именно, надо сначала разобрать сам планировщик.

---

Часть IX. runtime: планировщик, память, сборщик мусора

Это концептуальное сердце книги. Здесь сходится всё: почему системные вызовы не останавливают программу, почему миллион горутин — это нормально, откуда берётся и куда девается память. Рантайм Go — это маленькая операционная система внутри вашего процесса, надстроенная над настоящей ОС.

9.1 Зачем вообще нужен планировщик пользовательского уровня

Вернёмся к конфликту из части VIII: потоки ОС — дорогие. Стек потока — мегабайты; создание — системный вызов; переключение между потоками — переход в ядро и обратно (микросекунды). Если бы каждая горутина была потоком ОС, миллион горутин потребовал бы терабайты памяти и утопил бы систему в переключениях контекста.

Идея Go: сделать два уровня планирования. Есть небольшое число потоков ОС (их планирует ядро), и поверх них рантайм Go мультиплексирует тысячи горутин (их планирует сам рантайм, в user space, без обращения к ядру). Переключение горутины — это просто смена нескольких регистров и указателя стека внутри процесса, наносекунды, без входа в ядро. Стек горутины стартует с ~2 КБ и растёт по необходимости. Отсюда — дешевизна, ради которой всё и затевалось.

Такую модель называют M:N-планированием: M горутин на N потоках ОС. Реализована она через три сущности — G, M, P.

9.2 Модель G-M-P

        P (логический процессор, GOMAXPROCS штук)
        │  локальная очередь готовых G
        │  ┌──┬──┬──┬──┐
        │  │G3│G7│G2│  │  ← runnable-горутины ждут своей очереди
        │  └──┴──┴──┴──┘
        ▼
   ┌─────────┐        ┌─────────┐
   │    M    │◀──────▶│    G    │  ← M исполняет G, «стоя» на P
   │ (поток  │  runs  │(горутина)│
   │  ОС)    │        └─────────┘
   └─────────┘
        ▲
        │ M должен «держать» P, чтобы исполнять Go-код

   Глобальная очередь G  (общая, для переполнения и балансировки)
   ┌──┬──┬──┐
   │G9│G5│..│
   └──┴──┴──┘
  • G (goroutine) — сама горутина: её стек, указатель инструкций, состояние. Дешёвая структура, их могут быть миллионы. Бывает в состояниях: исполняется, готова к исполнению (runnable, ждёт в очереди), заблокирована (ждёт канал, мьютекс, сеть, системный вызов).
  • M (machine) — поток ОС. Именно M физически исполняет код на процессоре. Их немного. M может исполнять Go-код, только когда «держит» P.
  • P (processor) — логический процессор, разрешение на исполнение Go-кода и контекст для него. Число P равно GOMAXPROCS (по умолчанию — числу ядер CPU). У каждого P есть локальная очередь готовых горутин и локальный кеш памяти (mcache — о нём в разделе про аллокатор). P — это то, что ограничивает параллелизм: сколько бы ни было потоков, одновременно исполнять Go-код могут только GOMAXPROCS из них, потому что P всего GOMAXPROCS.

Почему три сущности, а не две (горутина + поток)? P — гениальное добавление (появилось в Go 1.1). Оно разрывает жёсткую связь «поток → работа». Когда M застревает в системном вызове (в ядре), он отдаёт свой P другому M — и тот продолжает крутить горутины. Ядро держит один поток заблокированным, но параллелизм Go-программы не страдает: освободившийся P тут же подхватывает свободный или новый M. Без P пришлось бы либо блокировать весь параллелизм на время syscall, либо плодить потоки бесконтрольно. Разберём это подробно — это и есть ответ на вопрос «почему системные вызовы не тормозят Go».

9.3 Жизненный цикл: как горутина исполняется

Когда вы пишете go f(), рантайм создаёт структуру G, кладёт её в локальную очередь текущего P (если та полна — половина очереди уезжает в глобальную, для балансировки). Дальше:

1. M, стоящий на P, закончив текущую горутину, берёт следующую G из локальной очереди своего P. 2. Если локальная очередь пуста — M пытается стащить работу: сначала заглядывает в глобальную очередь, потом делает work stealing — крадёт половину горутин из локальной очереди случайного другого P. Это балансирует нагрузку без центрального диспетчера: простаивающие P сами находят себе работу. 3. Исполняет G, пока та не завершится, не заблокируется или не будет вытеснена.

Кооперативная и вытесняющая планировка. Долгое время Go был чисто кооперативным: горутина уступала процессор только в «точках переключения» — при вызове функций (в прологе функции есть проверка), операциях с каналами, аллокациях. Это создавало проблему: горутина с плотным циклом без вызовов функций (for { x++ }) могла захватить P навечно, не давая другим работать, и даже подвесить сборку мусора. С Go 1.14 добавили асинхронную вытесняющую планировку: поток sysmon замечает горутину, работающую дольше ~10 мс, и посылает её M сигнал SIGURG (вот зачем он из 6.6!). Обработчик сигнала безопасно приостанавливает горутину и возвращает управление планировщику. Теперь ни один цикл не может монополизировать процессор.

9.4 Системные вызовы и планировщик — ключевой механизм

Вот кульминация, ради которой писались части I–VIII. Что происходит, когда горутина делает блокирующий системный вызов (скажем, read с диска, который займёт миллисекунды)?

Наивно поток M просто заблокировался бы в ядре, и его P простаивал бы всё это время — минус один процессор из GOMAXPROCS. Если так заблокируются все M, программа встанет, хотя горутин полно. Go этого не допускает. Механика:

1. Перед системным вызовом рантайм вызывает runtime.entersyscall. Он помечает: «M сейчас уйдёт в ядро». P отвязывается от M и переходит в состояние, где его может забрать другой M. 2. M выполняет инструкцию syscall и блокируется в ядре. Но его P свободен. 3. Специальный поток sysmon (или сам механизм handoff) замечает освободившийся P и передаёт его другому M (из пула простаивающих или создаёт новый). Этот M подхватывает P и продолжает исполнять другие горутины. Параллелизм сохранён: одно ядро занято заблокированным syscall'ом, но логический процессор P работает на другом потоке. 4. Когда системный вызов завершился, M выходит из ядра и вызывает runtime.exitsyscall. Он пытается снова заполучить P: если есть свободный — берёт и продолжает исполнять горутину; если все P заняты — горутина ставится в очередь готовых, а M паркуется в пул простаивающих потоков (или уничтожается, если их накопилось много).

Именно поэтому в 2.2 было так важно различать Syscall и RawSyscall: Syscall делает entersyscall/exitsyscall (сообщает планировщику, позволяет handoff), а RawSyscall — нет (для мгновенных вызовов, где handoff дороже самого вызова). Ошибочный RawSyscall на блокирующем вызове заблокирует M вместе с P — потерянный процессор.

Есть тонкость про два вида syscall'ов с точки зрения рантайма. Быстрые (возвращаются мгновенно) обрабатываются оптимистично: entersyscall не отвязывает P немедленно, а лишь помечает состояние; если вызов вернулся быстро, P никуда не уходил — экономия. Медленные (заблокировались надолго) ловит sysmon: увидев, что M торчит в syscall дольше порога, он принудительно отбирает у него P и отдаёт другому M (это называется handoff). Так система адаптируется: короткие вызовы не платят за handoff, длинные не держат P.

И отдельная, важнейшая ветка: сетевые операции вообще не блокируют M. Для них есть netpoller (часть X) — сетевой read не уходит в блокирующий syscall, а паркует только горутину, регистрируя дескриптор в epoll. M остаётся свободным и крутит другие горутины. Блокирующие syscall'ы (с отдачей P) — это в основном файловый ввод-вывод и вызовы к внешним библиотекам; сеть идёт мимо через epoll.

9.5 sysmon: демон-надзиратель

sysmon («system monitor») — служебный поток, запускаемый рантаймом, который работает без P (ему не нужно разрешение исполнять Go-код) и периодически просыпается (от 20 мкс до 10 мс). Его обязанности — фоновая гигиена планировщика:

  • отбирать P у потоков, застрявших в долгих системных вызовах (handoff из 9.4);
  • вытеснять горутины, работающие дольше 10 мс (посылать SIGURG);
  • запускать сборку мусора, если она давно не запускалась;
  • опрашивать netpoller, если это давно не делалось, и будить горутины, чьи сетевые операции готовы;
  • возвращать неиспользуемую память ОС.

Это тот самый фоновый поток, из-за которого даже пустая Go-программа в strace показывает активность: sysmon живёт своей жизнью.

9.6 Память: стек и куча в мире Go

Память горутины делится на стек и кучу — как и везде, но с особенностями.

Стек. У каждой горутины свой стек, стартующий с крохотных ~2 КБ (в отличие от мегабайтных стеков потоков ОС). Стек растёт динамически: когда его не хватает, рантайм выделяет вдвое больший, копирует туда содержимое старого и переписывает указатели (contiguous stacks, с Go 1.4; раньше были сегментированные стеки с известной проблемой «hot split»). Может и сжиматься при сборке мусора. Именно управляемый рост стека делает возможным миллион дешёвых горутин.

Куча. Всё, что «убегает» за пределы стека, живёт в куче и управляется сборщиком мусора. Что именно попадает в кучу, решает escape analysis — анализ побега на этапе компиляции. Если компилятор докажет, что значение не переживёт функцию (не «убежит»), оно ляжет на стек (дёшево, освобождается автоматически при возврате). Если значение возвращается по указателю, кладётся в структуру, живущую дольше, или его адрес уходит в другую горутину — оно «убегает» в кучу.

# Посмотреть решения escape analysis:
go build -gcflags='-m' main.go
# ./main.go:10:2: moved to heap: x       ← убежало в кучу
# ./main.go:15:13: ... does not escape   ← осталось на стеке

Практический смысл: аллокации в куче нагружают сборщик мусора, стековые — бесплатны. Понимание escape analysis — основа оптимизации Go-программ по памяти. Классические причины побега: возврат указателя на локальную переменную, передача в interface{} (боксинг), захват переменной замыканием, которое живёт дольше функции, слишком большой объект.

9.7 Аллокатор: mcache, mcentral, mheap

Go не зовёт malloc на каждую аллокацию — это было бы медленно и порождало бы конкуренцию за глобальный лок. Вместо этого — многоуровневый аллокатор, вдохновлённый TCMalloc, устроенный так, чтобы горячий путь был без блокировок:

  • mcache — у каждого P свой. Кеш свободных блоков популярных размеров (size classes). Аллокация маленького объекта — это взять готовый блок из mcache своего P. Поскольку mcache привязан к P, а на P в каждый момент только один M, — никакой синхронизации не нужно, аллокация почти бесплатна.
  • mcentral — центральный список блоков каждого size class, общий для всех P. Когда mcache опустел, он подтягивает блоки отсюда (уже с блокировкой, но это редко).
  • mheap — вся куча целиком, запрашивает страницы у ОС через mmap. Большие объекты (>32 КБ) выделяются прямо здесь.

Объекты сгруппированы по ~70 классам размеров, чтобы бороться с фрагментацией: запрос на 100 байт округляется до ближайшего класса (112), и все блоки этого класса взаимозаменяемы. Память у ОС берётся большими кусками (арены по 64 МБ) и нарезается внутри — чтобы не дёргать mmap на каждый чих.

9.8 Сборщик мусора: конкурентный, трёхцветный, с барьером записи

GC в Go спроектирован под одну главную цель — малые паузы. Не максимальную пропускную способность, не минимальную память, а именно короткие остановки (обычно меньше миллисекунды), чтобы не дёргать латентно-чувствительные сервисы. Ради этого он конкурентный: работает одновременно с вашими горутинами, почти не останавливая их.

Алгоритм — трёхцветная пометка-очистка (tri-color mark-and-sweep), неперемещающая (объекты не двигаются в памяти — поэтому указатели стабильны, что важно для взаимодействия с C и unsafe) и не поколенческая (в отличие от JVM; для типичных Go-программ с их упором на стековые аллокации это оказалось разумным компромиссом).

Трёхцветная абстракция:

  • Белые объекты — кандидаты на удаление (пока не доказано, что живые).
  • Серые — живые, но их ссылки ещё не просмотрены.
  • Чёрные — живые, и все их ссылки просмотрены.

GC начинает с корней (стеки горутин, глобальные переменные), красит их в серый, затем в цикле берёт серый объект, красит в чёрный, а всё, на что он ссылается, — в серый. Когда серых не осталось — всё живое чёрное, всё белое можно удалять (фаза sweep, тоже конкурентная и ленивая).

Проблема конкурентности и барьер записи. Пока GC красит объекты, ваши горутины продолжают менять указатели. Опасность: чёрный объект (уже просмотренный) получает ссылку на белый (ещё не помеченный), и одновременно исчезает последняя серая ссылка на этот белый — GC решит, что белый мёртв, и удалит живой объект. Катастрофа. Решение — барьер записи (write barrier): на время сборки компилятор вставляет перед каждой записью указателя маленькую проверку, которая «подкрашивает» задетые объекты, сохраняя инвариант «чёрный не ссылается на белый напрямую». Барьер работает только во время активного GC-цикла, в остальное время его нет.

Паузы Stop-The-World. Полностью без остановок не обойтись: есть два коротких STW — в начале цикла (включить барьер записи, просканировать корни) и в конце (финальная синхронизация пометки). Но они измеряются десятками микросекунд, а не миллисекундами, потому что основная работа идёт конкурентно.

9.9 Управление сборщиком: GOGC и GOMEMLIMIT

Когда запускается GC? По достижению порога роста кучи, задаваемого GOGC (по умолчанию 100):

GOGC=100 → GC стартует, когда куча выросла на 100% с последней сборки
           (то есть удвоилась). Меньше GOGC → чаще GC, меньше памяти, больше CPU.
           Больше GOGC → реже GC, больше памяти, меньше CPU.
GOGC=50 ./myapp    # агрессивнее собирать (экономим память ценой CPU)
GOGC=off ./myapp   # выключить GC вовсе (для короткоживущих батч-задач)

С Go 1.19 появился GOMEMLIMIT — мягкий лимит памяти. Он говорит рантайму «старайся не превышать N байт», и GC начинает работать активнее по мере приближения к лимиту, даже если порог GOGC ещё не достигнут. Это спасение для контейнеров: раньше Go-приложение, не знающее о лимите памяти cgroup, могло раздуть кучу и получить OOM-kill; теперь GOMEMLIMIT=500MiB держит его в рамках. Рекомендуемая практика в Kubernetes — ставить GOMEMLIMIT в ~90% от лимита памяти пода и GOGC оставить дефолтным (или выключить, положившись на memlimit).

Наблюдать за GC:

GODEBUG=gctrace=1 ./myapp
# gc 1 @0.012s 2%: 0.018+1.2+0.005 ms clock, ... → детали каждого цикла GC:
#   номер, время с старта, доля CPU на GC, фазы (STW+конкурентная+STW), размеры кучи

9.10 Наблюдаемость рантайма из кода

import "runtime"

runtime.NumGoroutine()   // сколько горутин живо (рост = возможная утечка)
runtime.NumCPU()         // ядер в системе
runtime.GOMAXPROCS(0)    // текущее число P (0 = только прочитать)

var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("куча: %d МБ, циклов GC: %d, пауза GC: %v\n",
    m.HeapAlloc/1024/1024, m.NumGC, time.Duration(m.PauseNs[(m.NumGC+255)%256]))

runtime.GC()             // принудительная сборка (для тестов/бенчмарков)

Для продакшена — runtime/pprof и net/http/pprof: профили CPU, кучи, горутин, блокировок. go tool pprof и go tool trace показывают жизнь рантайма в деталях — какие горутины чего ждут, где паузы GC, где конкуренция за локи. Это главный инструмент, когда «что-то тормозит, а что — непонятно».

---

Часть X. net: сокеты и netpoller

Теперь, когда планировщик разобран, можно раскрыть главную магию Go для серверов: почему вы пишете простой блокирующий сетевой код, а получаете производительность асинхронного event loop, как в nginx или Node.js, — но без единого колбэка.

10.1 Сокет как файловый дескриптор

Сетевое соединение в Unix — это сокет, а сокет — это файловый дескриптор (часть IV во всей красе). Классическая последовательность системных вызовов для TCP-сервера:

socket()  → создать сокет, получить дескриптор
bind()    → привязать к адресу и порту
listen()  → перевести в режим прослушивания
accept()  → принять входящее соединение → НОВЫЙ дескриптор на это соединение
read()/write() на дескрипторе соединения → обмен данными
close()   → закрыть

Клиент проще: socket() → connect() → read/write → close.

В Go всё это спрятано за net.Listen и net.Dial, но под ними — ровно эти вызовы. Можно увидеть в strace любого Go-сервера: socket(AF_INET, SOCK_STREAM|SOCK_NONBLOCK|SOCK_CLOEXEC, ...), bind, listen, accept4. Обратите внимание на флаг SOCK_NONBLOCK — вот первый ключ к магии: Go создаёт все сокеты неблокирующими.

10.2 Дилемма блокирующего и неблокирующего ввода-вывода

Сокет может работать в двух режимах.

Блокирующий (по умолчанию в C): read на сокете без данных усыпляет весь поток до прихода данных. Просто программировать («прочитай мне байты»), но не масштабируется — поток на соединение, см. 8.1.

Неблокирующий: read на сокете без данных немедленно возвращает EAGAIN («сейчас нечего, зайди позже»). Не блокирует поток, но программировать мучительно: приходится строить event loop вокруг epoll, вручную отслеживать состояние каждого соединения, дробить логику на колбэки (та самая «callback hell» из мира Node.js).

Go совершает фокус: берёт неблокирующие сокеты (для эффективности) и подаёт их программисту как блокирующие (для простоты). Разрыв между этими двумя мирами закрывает netpoller, работающий в связке с планировщиком.

10.3 netpoller: как это работает

Вот что происходит, когда ваша горутина вызывает conn.Read(buf) на TCP-соединении:

1. Рантайм пробует неблокирующий read на дескрипторе. Если данные уже есть — читает и сразу возвращает. Горутина даже не заметила, что сокет неблокирующий. 2. Если данных нет (read вернул EAGAIN), рантайм не блокирует поток. Вместо этого он:

  • регистрирует дескриптор в epoll (epoll_ctl), если тот ещё не зарегистрирован, с интересом «доступно чтение»;
  • паркует горутину (переводит G в состояние ожидания, связывая её с этим дескриптором) через gopark;
  • M освобождается и берёт из очереди другую готовую горутину. Поток продолжает работать!
  • 3. Когда данные приходят, ядро помечает дескриптор готовым в epoll. Кто-то вызывает netpoll (это делает планировщик при поиске работы, или sysmon, или специальный поток) — epoll_wait возвращает список готовых дескрипторов. 4. Рантайм находит горутины, припаркованные на этих дескрипторах, и переводит их обратно в готовые (goready). Они возвращаются в очереди планировщика и вскоре продолжат исполнение с того же conn.Read, который теперь успешно прочитает данные.

горутина: conn.Read(buf)
   │
   ├─ есть данные? → читаем, возврат ✓
   │
   └─ EAGAIN → регистрируем fd в epoll, ПАРКУЕМ горутину (gopark)
                        M свободен → крутит другие горутины
                              ⋮  (проходит время)
              ядро: данные пришли, fd готов в epoll
                        │
              netpoll: epoll_wait вернул fd → goready(горутина)
                        │
              горутина снова в очереди → M подхватывает → read успешен ✓

Итог: одна горутина на соединение — это дёшево. Заблокированная на сети горутина не держит поток ОС — она держит лишь свою маленькую структуру G и запись в epoll. Миллион соединений = миллион дешёвых горутин + один epoll + горстка потоков. Вы пишете прямолинейный синхронный код:

func handleConn(conn net.Conn) {
    defer conn.Close()
    buf := make([]byte, 4096)
    for {
        n, err := conn.Read(buf)  // «блокирующий» для вас, неблокирующий+epoll внутри
        if err != nil {
            return                // io.EOF или ошибка
        }
        process(buf[:n])
    }
}

// и запускаете горутину на соединение, не думая о масштабе:
for {
    conn, err := ln.Accept()
    if err != nil { break }
    go handleConn(conn)  // хоть миллион — рантайм разрулит
}

Никаких epoll, колбэков, машин состояний. Синхронный код + дешёвые горутины + невидимый netpoller = производительность event-loop-сервера с простотой блокирующего кода. Это, возможно, главная причина, почему Go так хорош для сетевых сервисов.

10.4 Таймауты и дедлайны

Раз чтение «блокирующее», как его прервать по таймауту? Через дедлайны — они реализованы всё тем же netpoller (дескриптор ждёт не только данные, но и срабатывание таймера):

conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
    var netErr net.Error
    if errors.As(err, &netErr) && netErr.Timeout() {
        // дедлайн истёк — данные не пришли за 5 секунд
    }
}

conn.SetWriteDeadline(...) // отдельно на запись
conn.SetDeadline(...)      // на обе операции сразу

Дедлайн — не таймаут на операцию, а абсолютный момент времени, после которого операция провалится. Его нужно сдвигать перед каждой операцией (или периодически), иначе он «сгорит» один раз и все последующие чтения будут мгновенно падать. Это частый баг: поставили дедлайн один раз перед циклом чтения — второе чтение сразу упало с таймаутом.

В современном коде вместо ручных дедлайнов чаще используют context: http.Server и хорошие библиотеки БД/RPC сами проецируют отмену context на дедлайны сокета. Отсюда стык с частью про сигналы и graceful shutdown: SIGTERM → отмена корневого контекста → дедлайны на всех соединениях → чистое завершение.

10.5 Настройка сокетов: за пределами net

Иногда нужен контроль, которого net не даёт напрямую, — опции сокета через setsockopt. Правильный путь — через net.ListenConfig/net.Dialer с колбэком Control, который даёт доступ к дескриптору до привязки, не ломая netpoller:

lc := net.ListenConfig{
    Control: func(network, address string, c syscall.RawConn) error {
        return c.Control(func(fd uintptr) {
            // SO_REUSEPORT: несколько процессов слушают ОДИН порт,
            // ядро балансирует соединения между ними (zero-downtime деплой,
            // масштабирование на ядра). Классический приём для高-нагрузки.
            unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
        })
    },
}
ln, err := lc.Listen(context.Background(), "tcp", ":8080")

Другие частые опции: TCP_NODELAY (отключить алгоритм Нейгла — важно для интерактивных протоколов с мелкими пакетами; Go, кстати, включает его по умолчанию), SO_KEEPALIVE (детект мёртвых соединений), SO_RCVBUF/SO_SNDBUF (размеры буферов).

10.6 Когда netpoller не помогает: файлы и DNS

Важная асимметрия: netpoller работает для сокетов и pipe, но не для обычных файлов на диске. Причина в Linux: epoll не поддерживает регулярные файлы (они «всегда готовы» с точки зрения epoll, но read с диска всё равно блокируется на время физического ввода-вывода). Поэтому дисковый read, промахнувшийся мимо страничного кеша, — это настоящий блокирующий syscall, который отдаёт P другому M (механизм 9.4). Обычно это не проблема (файловые операции быстры и редки по сравнению с сетевыми), но при интенсивном дисковом вводе-выводе можно увидеть рост числа потоков ОС — рантайм создаёт их, чтобы компенсировать заблокированные на диске M. (На горизонте — интеграция с io_uring, которая обещает асинхронный дисковый ввод-вывод и для Go; следите за развитием рантайма.)

DNS — отдельная история: у Go два резолвера. Чистый Go-резолвер (по умолчанию во многих конфигурациях) сам ходит по сети через netpoller — не блокирует потоки. Резолвер через cgo (libc getaddrinfo) уходит в блокирующий C-вызов и держит поток. Выбор управляется GODEBUG=netdns=go|cgo; для контейнеров и высокой конкуренции обычно предпочтителен чистый Go-резолвер.

---

Часть XI. unsafe: работа с памятью напрямую

11.1 Зачем нужен unsafe и чем он опасен

Go — язык с безопасной памятью: вы не можете случайно прочитать чужую память, выйти за границу среза без паники, перепутать типы. За это отвечает система типов и рантайм. Пакет unsafe — это официальная лазейка, отключающая эти гарантии. Его имя — честное предупреждение: используя unsafe, вы берёте на себя обязанности, которые обычно выполняет компилятор, и цена ошибки — не паника, а порча памяти, загадочные падения и уязвимости.

Зачем он вообще существует? Для системного программирования он незаменим в трёх сценариях:

  • Разметка структур ядра. Системные вызовы принимают C-структуры с точной байтовой раскладкой; иногда нужно интерпретировать сырые байты как структуру или наоборот.
  • Взаимодействие с C (cgo): C и Go по-разному видят память, и unsafe — мост между ними.
  • Оптимизации без копирования. Превратить []byte в string без копирования, интерпретировать буфер как массив структур — там, где копирование недопустимо дорого.

Правило номер ноль: если можно без unsafe — делайте без unsafe. Он для тех редких мест, где действительно нельзя иначе.

11.2 Четыре инструмента пакета

Пакет unsafe крошечный — по сути четыре вещи:

unsafe.Pointer   // «универсальный указатель»: указатель на что угодно, без типа
unsafe.Sizeof(x) // размер значения в байтах (во время компиляции)
unsafe.Alignof(x)// требование выравнивания типа
unsafe.Offsetof(s.field) // смещение поля внутри структуры в байтах

Плюс с Go 1.17/1.20 добавили безопасные помощники, которыми и следует пользоваться вместо ручной арифметики:

unsafe.Add(ptr, offset)        // арифметика указателей корректно (вместо uintptr-хаков)
unsafe.Slice(ptr, len)         // сделать срез из указателя и длины
unsafe.SliceData(slice)        // указатель на нижележащий массив среза
unsafe.String(ptr, len)        // сделать string из указателя и длины
unsafe.StringData(str)         // указатель на байты строки

11.3 unsafe.Pointer и его отличие от uintptr

Это центральное и самое опасное место. Есть два «сырых» способа держать адрес, и путать их смертельно.

unsafe.Pointer — это указатель, который сборщик мусора видит и отслеживает. Если объект жив, пока на него есть unsafe.Pointer, GC его не удалит и (в неперемещающем GC Go) не сдвинет.

uintptr — это просто число, равное адресу. GC его не видит и не отслеживает. Для GC uintptr — обычное целое, а не ссылка.

Отсюда — смертельная ловушка: как только вы превратили unsafe.Pointer в uintptr, объект по этому адресу становится «невидим» для GC. Между строкой, где вы получили uintptr, и строкой, где снова его используете, сборщик может решить, что объект мёртв, и удалить его — а вы держите число-адрес, указывающее теперь в никуда (или на новый объект). Поэтому:

> Преобразование unsafe.Pointer → uintptr → unsafe.Pointer должно происходить в одном выражении, без промежуточного хранения uintptr в переменной. Разорвёте — GC может убрать объект прямо посреди.

// ПРАВИЛЬНО: всё в одном выражении, GC не успевает вклиниться.
p := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.field))

// ОПАСНО: uintptr «живёт» в переменной — GC может убрать объект между строк.
addr := uintptr(unsafe.Pointer(&s)) // s теперь невидим для GC!
// ... тут может случиться сборка ...
p := unsafe.Pointer(addr + off)     // addr, возможно, указывает в никуда

// СОВРЕМЕННО и БЕЗОПАСНО: unsafe.Add вместо ручной арифметики.
p := unsafe.Add(unsafe.Pointer(&s), unsafe.Offsetof(s.field))

11.4 Разрешённые паттерны

Документация пакета перечисляет закрытый список легальных способов использовать unsafe.Pointer. Всё, что вне списка, — потенциально неопределённое поведение, которое к тому же ловит go vet. Главные разрешённые паттерны:

1. Конвертация T1 → T2, если T1 и T2 совместимы по разметке (например, зеркальные структуры). p := (T2)(unsafe.Pointer(&t1)). 2. Арифметика указателей через unsafe.Add (или uintptr в одном выражении) — доступ к полю или элементу массива по смещению. 3. unsafe.Slice/unsafe.String — построение срезов/строк из указателя и длины. 4. Вызовы syscall.Syscall, где аргумент-uintptr получен из unsafe.Pointer прямо в списке аргументов вызова (рантайм это специально учитывает). 5. Работа с reflect через задокументированные соответствия.

11.5 Классический пример: []byte ↔ string без копирования

Строка в Go неизменяема, а []byte — изменяем; конвертация между ними по умолчанию копирует данные (чтобы сохранить неизменяемость строки). В горячем коде (парсеры, сериализация) это копирование иногда недопустимо. unsafe позволяет разделить память — но с обязательством не менять байты, пока живёт строка:

// []byte → string без копирования (с Go 1.20).
func bytesToString(b []byte) string {
    if len(b) == 0 {
        return ""
    }
    return unsafe.String(unsafe.SliceData(b), len(b))
    // ОБЯЗАТЕЛЬСТВО: после этого нельзя менять b, пока используется строка!
    // Иначе нарушите неизменяемость строк — фундаментальный инвариант Go.
}

// string → []byte без копирования.
func stringToBytes(s string) []byte {
    if s == "" {
        return nil
    }
    return unsafe.Slice(unsafe.StringData(s), len(s))
    // ОБЯЗАТЕЛЬСТВО: полученный срез НЕЛЬЗЯ модифицировать — строки неизменяемы,
    // запись в этот срез — порча памяти или падение (строка может лежать в
    // read-only сегменте бинарника).
}

Это законно и работает, но каждый такой вызов — контракт, который вы обязаны соблюдать, потому что компилятор больше не следит. Именно поэтому это делают точечно, в изолированных хелперах, с большими комментариями.

11.6 Разметка структур: выравнивание и размер

Системному программисту постоянно нужно знать точную раскладку структур — потому что они должны совпадать байт в байт со структурами ядра или сетевыми протоколами. Здесь правит выравнивание (alignment): процессор эффективнее (а иногда только и может) читать данные с адресов, кратных их размеру. Компилятор вставляет между полями «дырки» (padding), чтобы каждое поле было выровнено.

import "unsafe"

type Bad struct {
    a bool  // 1 байт
    b int64 // 8 байт — должен лежать по адресу, кратному 8
    c bool  // 1 байт
}
// Реальная раскладка: a(1) + padding(7) + b(8) + c(1) + padding(7) = 24 байта!
// unsafe.Sizeof(Bad{}) == 24

type Good struct {
    b int64 // 8
    a bool  // 1
    c bool  // 1
    // padding(6) в конце для выравнивания массива таких структур
}
// unsafe.Sizeof(Good{}) == 16 — то же содержимое, но на треть меньше

Мораль: порядок полей влияет на размер структуры. Складывая поля от больших к маленьким, вы минимизируете padding. В обычном коде это микрооптимизация, но в структурах, которых миллионы, или в тех, что мапятся на память ядра, — существенно. Инструмент fieldalignment (из golang.org/x/tools) находит неоптимально уложенные структуры автоматически.

Проверить раскладку:

var s Bad
fmt.Println(unsafe.Sizeof(s))        // 24 — полный размер
fmt.Println(unsafe.Offsetof(s.b))    // 8 — где начинается b
fmt.Println(unsafe.Alignof(s.b))     // 8 — требование выравнивания int64

11.7 cgo: граница двух миров

Когда нужно вызвать C-библиотеку (которой нет аналога в Go), используется cgo. Это отдельная большая тема, но отметим её место в системном программировании и связанные с ней тонкости памяти:

/*
#include <stdlib.h>
#include <string.h>
*/
import "C"
import "unsafe"

func useC() {
    // Go-строка → C-строка: C.CString ВЫДЕЛЯЕТ C-памятью (malloc),
    // GO НЕ УБЕРЁТ ЕЁ — освобождать вручную!
    cstr := C.CString("привет")
    defer C.free(unsafe.Pointer(cstr)) // иначе утечка C-памяти

    C.strlen(cstr)
}

Ключевые правила границы cgo, нарушение которых портит память:

  • Go-указатели нельзя долго хранить в C. GC Go может (в будущем) двигать объекты или удалять их; C об этом не знает. Правило cgo: C-код не должен сохранять Go-указатель после возврата из вызова.
  • C-память не управляется GC — что выделили через C.malloc/C.CString, то освобождаете вручную (C.free).
  • Каждый вызов C — это выход из-под планировщика Go (подобно блокирующему syscall: рантайм делает аналог entersyscall, отдаёт P). Частые мелкие cgo-вызовы дороги из-за этих переходов — ещё одна причина предпочитать чистый Go.

cgo — мощный, но тяжёлый мост: он ломает статическую линковку, замедляет сборку, усложняет отладку и портит переносимость. Девиз сообщества — «cgo is not Go»: используйте его только когда действительно нет чистого Go-решения.

---

Часть XII. Контейнеры с нуля: namespaces и cgroups

Мы прошли весь путь снизу вверх, и теперь можем сделать то, что кажется магией, а на деле — прямое применение всего изученного: написать примитивный аналог docker run на чистом Go. Контейнер — это не виртуальная машина и не какая-то особая сущность ядра. Контейнер — это обычный процесс Linux, которому ядро наврало про окружающий мир (через namespaces) и ограничило ресурсы (через cgroups). Всё. Docker, Podman, containerd — это удобные обёртки над двумя механизмами ядра, которые мы сейчас потрогаем руками.

12.1 Namespaces: индивидуальный взгляд на систему

Namespace (пространство имён) — механизм ядра, дающий процессу изолированный вид на некоторый глобальный ресурс. Процесс в своём namespace видит только «свою» версию этого ресурса и не подозревает об остальном. Видов namespace несколько, и каждый изолирует свою грань системы:

  • PID namespace — своя нумерация процессов. Процесс в новом PID-namespace становится PID 1 внутри него и не видит процессы снаружи. ps внутри контейнера показывает только его процессы.
  • Mount (mnt) namespace — своё дерево точек монтирования. Позволяет дать контейнеру свою корневую файловую систему, невидимую снаружи.
  • Network (net) namespace — свой сетевой стек: свои интерфейсы, IP-адреса, таблицы маршрутизации, порты. Контейнер может слушать порт 80, не конфликтуя с хостом.
  • UTS namespace — свой hostname и domainname. Отсюда контейнер может иметь имя, отличное от хоста.
  • IPC namespace — своя System V IPC и POSIX-очереди сообщений.
  • User namespace — своё отображение UID/GID. Позволяет быть root (UID 0) внутри контейнера, оставаясь непривилегированным пользователем снаружи. Основа rootless-контейнеров и важнейший рубеж безопасности.
  • cgroup namespace — изолирует вид на иерархию cgroups.

Namespace создаётся либо системным вызовом clone (при создании процесса — с соответствующими флагами CLONE_NEW), либо unshare (отделить текущий процесс в новый namespace), либо setns (присоединиться к существующему — так docker exec входит в контейнер).

В Go, как мы видели в 5.4, флаги namespace задаются через SysProcAttr.Cloneflags при запуске процесса. Рантайм Go делает clone с этими флагами вместо обычного fork.

12.2 Собираем контейнер: код

Каноничный трюк реализации container runtime на Go — программа перезапускает сама себя в новых namespaces. Первый запуск («родитель») стартует свою же копию с флагами namespace и специальным первым аргументом; второй запуск («потомок») видит этот аргумент, понимает, что он уже внутри namespaces, и донастраивает окружение (hostname, mount, root) перед запуском целевой команды.

package main

import (
    "fmt"
    "os"
    "os/exec"
    "syscall"
)

func main() {
    switch os.Args[1] {
    case "run":
        parent() // мы снаружи: создать namespaces и перезапустить себя
    case "child":
        child()  // мы уже внутри namespaces: донастроить и exec цель
    default:
        fmt.Println("usage: run|child <cmd> [args...]")
        os.Exit(1)
    }
}

// parent: запускает /proc/self/exe (саму эту программу) заново,
// но уже в новых namespaces.
func parent() {
    // /proc/self/exe — символическая ссылка на исполняемый файл текущего процесса.
    // Запуская его с аргументом "child", мы перезапускаем СЕБЯ внутри namespaces.
    cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
    cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr

    cmd.SysProcAttr = &syscall.SysProcAttr{
        // Вот они — те самые namespaces:
        Cloneflags: syscall.CLONE_NEWUTS | // свой hostname
            syscall.CLONE_NEWPID | // потомок станет PID 1
            syscall.CLONE_NEWNS | // своё дерево монтирования
            syscall.CLONE_NEWNET, // свой сетевой стек (изолированный, без сети!)
        // Для rootless можно добавить CLONE_NEWUSER с UidMappings/GidMappings.
    }

    if err := cmd.Run(); err != nil {
        fmt.Println("ошибка:", err)
        os.Exit(1)
    }
}

// child: исполняется УЖЕ внутри namespaces. Настраиваем «мир» контейнера.
func child() {
    fmt.Printf("внутри контейнера, PID: %d\n", os.Getpid()) // напечатает 1!

    // Свой hostname (виден только внутри UTS namespace).
    syscall.Sethostname([]byte("container"))

    // Здесь в настоящем runtime были бы:
    //  - chroot/pivot_root в образ файловой системы контейнера;
    //  - монтирование своего /proc (mount -t proc proc /proc),
    //    чтобы ps показывал только процессы контейнера;
    //  - настройка сети (veth-пара, мост);
    //  - применение cgroups (см. 12.3);
    //  - сброс привилегий (capabilities), seccomp-фильтры.
    // syscall.Chroot("/path/to/rootfs"); os.Chdir("/")

    // Заменяем себя целевой командой (exec — тот самый из части V).
    cmd := exec.Command(os.Args[2], os.Args[3:]...)
    cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
    if err := cmd.Run(); err != nil {
        fmt.Println("ошибка команды:", err)
        os.Exit(1)
    }
}

Запуск (нужны права root или user namespace):

go build -o mycontainer main.go
sudo ./mycontainer run /bin/sh
# внутри контейнера, PID: 1        ← процесс думает, что он PID 1
# / # hostname                     ← "container", а не хост
# / # ps                          ← (после mount /proc) видит только себя

В этих шестидесяти строках — концептуальное ядро Docker. Всё остальное в настоящих runtime (образы, слои файловой системы, оверлеи, сети, тома, оркестрация) — это удобства и надёжность поверх этого ядра. Изоляция же обеспечивается ровно двумя вещами: namespaces (что процесс видит) и cgroups (сколько процесс может съесть).

12.3 cgroups: ограничение ресурсов

Namespaces изолируют видимость, но не потребление. Процесс в своём PID-namespace всё ещё может сожрать всю память или все ядра хоста. Ограничивают потребление cgroups (control groups) — механизм ядра для учёта и лимитирования ресурсов группы процессов.

В cgroups v2 (современная унифицированная иерархия) это выглядит как работа с файловой системой, смонтированной в /sys/fs/cgroup. Создать группу — создать директорию; задать лимит — записать число в файл; поместить процесс — записать его PID в файл. Снова «всё есть файл»:

func applyCgroup(pid int) error {
    base := "/sys/fs/cgroup/mycontainer"

    // 1. Создать cgroup (просто mkdir — ядро само создаёт управляющие файлы).
    if err := os.MkdirAll(base, 0o755); err != nil {
        return err
    }

    // 2. Лимит памяти: 100 МБ. Превысит — процесс получит OOM-kill.
    if err := os.WriteFile(base+"/memory.max", []byte("104857600"), 0o644); err != nil {
        return err
    }

    // 3. Лимит CPU: "50000 100000" = 50 мс на каждые 100 мс = полъядра (0.5 CPU).
    if err := os.WriteFile(base+"/cpu.max", []byte("50000 100000"), 0o644); err != nil {
        return err
    }

    // 4. Поместить процесс в cgroup — записать его PID.
    //    Все потомки автоматически наследуют группу.
    return os.WriteFile(base+"/cgroup.procs",
        []byte(strconv.Itoa(pid)), 0o644)
}

Вот и весь секрет docker run --memory=100m --cpus=0.5: под капотом Docker пишет ровно эти числа в ровно эти файлы. cgroups контролируют память (memory.max), процессорное время (cpu.max), ввод-вывод (io.max), число процессов (pids.max) и другое. Именно cgroups, а не namespaces, дают тот GOMEMLIMIT-контекст из 9.9: лимит памяти пода в Kubernetes — это memory.max в cgroup, и Go-рантайм с версии 1.19 умеет его учитывать (частично; полная автоматическая интеграция с cgroup-лимитами — предмет продолжающейся работы, поэтому GOMEMLIMIT пока часто задают явно).

12.4 Capabilities и seccomp: не всё или ничего

Ещё два кирпича безопасности, которые стоит знать, даже если пользуетесь ими редко.

Capabilities дробят всемогущество root на ~40 отдельных полномочий. Вместо «root может всё / обычный юзер почти ничего» можно выдать процессу точечно: CAP_NET_BIND_SERVICE (слушать порты <1024, не будучи root), CAP_SYS_TIME (менять системное время), CAP_NET_ADMIN (настраивать сеть). Контейнеры запускают с урезанным набором capabilities — так, даже став «root» внутри, процесс не может навредить хосту. Управление — через libcap или прямые вызовы capset.

seccomp (secure computing) — фильтр системных вызовов. Вы задаёте BPF-программу, которая для каждого syscall решает: разрешить, запретить (вернуть ошибку) или убить процесс. Docker по умолчанию блокирует несколько десятков опасных системных вызовов. Это последний рубеж: даже если процесс скомпрометирован, seccomp не даёт ему сделать mount, reboot или другую разрушительную операцию. В Go seccomp-профиль можно установить через libseccomp или собрать BPF вручную и применить prctl(PR_SET_SECCOMP, ...).

Вместе namespaces + cgroups + capabilities + seccomp + user namespaces дают многослойную изоляцию, из которой и собран современный контейнер. Теперь, когда вы видите docker run, вы знаете, что происходит на каждом слое.

---

Часть XIII. /proc и наблюдаемость

13.1 /proc: ядро рассказывает о себе через файлы

Принцип «всё есть файл» доведён до логического предела в псевдо-файловой системе /proc. Это не файлы на диске — это окно в структуры данных ядра, представленное как файлы. Читая их, вы получаете актуальное состояние системы и процессов; записывая в некоторые — меняете параметры ядра. Ни одного специального API — только read/write, которые вы уже знаете.

// Информация о своём процессе:
data, _ := os.ReadFile("/proc/self/status")   // память, потоки, UID, capabilities...
cmdline, _ := os.ReadFile("/proc/self/cmdline") // аргументы (разделены \0)
data, _ = os.ReadFile("/proc/self/maps")       // карта памяти: что куда отображено

// Про любой процесс по PID:
data, _ = os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid))

// Файловые дескрипторы процесса — директория символических ссылок:
fds, _ := os.ReadDir("/proc/self/fd") // сколько открыто, куда указывают — диагностика утечек fd!

// Общесистемное:
loadavg, _ := os.ReadFile("/proc/loadavg")     // средняя нагрузка
meminfo, _ := os.ReadFile("/proc/meminfo")     // память системы
cpuinfo, _ := os.ReadFile("/proc/cpuinfo")     // процессоры

/proc/self — удобный алиас на «текущий процесс» (ядро подставляет ваш PID). Именно отсюда /proc/self/exe из части XII брал путь к собственному бинарнику. Через /proc реализованы ps, top, htop, lsof, мониторинг памяти и почти вся наблюдаемость Linux — все они просто читают эти файлы и форматируют.

Практический пример — детектор утечки дескрипторов, который пригодится в диагностике «too many open files»:

func countOpenFDs() int {
    entries, err := os.ReadDir("/proc/self/fd")
    if err != nil {
        return -1
    }
    return len(entries) - 1 // минус сам дескриптор, открытый для чтения этой директории
}
// Растёт со временем при неизменной нагрузке → где-то забыт Close.

13.2 /sys и procfs-родственники

Рядом живёт /sys (sysfs) — окно в устройства и подсистемы ядра, и уже знакомый /sys/fs/cgroup (cgroups из части XII). Философия та же: конфигурация и состояние ядра как читаемые/записываемые файлы. Хотите узнать состояние сетевого интерфейса — читаете /sys/class/net/eth0/.... Хотите изменить планировщик ввода-вывода диска — пишете в /sys/block/sda/queue/scheduler.

Эта унификация — одна из причин, почему системное программирование под Linux из Go так приятно: львиную долю задач наблюдаемости и настройки решают обычные os.ReadFile/os.WriteFile, без единого экзотического системного вызова. Вы уже умеете всё нужное — из части III.

---

Заключение: карта того, что мы прошли

Соберём всю книгу в одну картину. Мы поднялись по лестнице абстракций снизу вверх и увидели, как каждый уровень опирается на предыдущий:

Ваш код (os.Open, net.Dial, go f())
   │
   ├─ os / net / io ─────── удобные, переносимые обёртки (III, X)
   │      │
   │      ├─ runtime ────── планировщик G-M-P, netpoller, GC, память (IX)
   │      │       делает системные вызовы неблокирующими для горутин
   │      │
   │      └─ x/sys/unix ─── типизированные обёртки над каждым syscall (II)
   │             syscall (заморожен) — легаси-уровень
   │                  │
   │                  └─ runtime·Syscall (ассемблер) ── инструкция SYSCALL (I, II)
   │                          │  entersyscall/exitsyscall — стык с планировщиком
   │                          ▼
   └──────────────────── ЯДРО LINUX (кольцо 0) ──────────────────────
          файлы (IV), процессы (V), сигналы (VI), IPC (VII),
          epoll (VIII), namespaces + cgroups (XII), /proc (XIII)
                                   │
                          unsafe (XI) — лазейка вбок,
                          когда нужно тронуть память напрямую

Главные идеи, которые стоит унести с собой:

  • Системный вызов — единственная дверь в ядро, и она не бесплатна; отсюда буферизация, батчинг и вся инженерия вокруг «меньше ходить в ядро».
  • Go не использует libc под Linux — рантайм сам говорит с ядром, и именно поэтому он знает о каждом блокирующем вызове и умеет не простаивать (entersyscall/handoff).
  • «Всё есть файл» превращает файлы, сокеты, каналы, события и даже состояние системы в единый интерфейс read/write — а в Go в единый io.Reader/io.Writer.
  • Планировщик G-M-P + netpoller — причина, по которой вы пишете простой блокирующий код и получаете производительность асинхронного сервера. Это, возможно, лучшее инженерное решение в Go.
  • fork в Go нельзя без немедленного exec — из-за многопоточности рантайма; запускайте процессы через os/exec.
  • Сигналы Go делает синхронными через каналы, убирая ад обработчиков сигналов.
  • Контейнер — это процесс с namespaces и cgroups, и вы теперь умеете собрать его руками.
  • unsafe — контракт, который вы берёте на себя у компилятора; цена ошибки — порча памяти, поэтому только когда действительно нельзя иначе.

Практический чеклист системного Go-кода

  • [ ] Каждый открытый ресурс (файл, сокет, дескриптор) закрывается через defer — дескрипторы исчерпаемы.
  • [ ] read читается в цикле (он возвращает не всё); write проверяется на неполную запись.
  • [ ] EINTR учтён при сырых вызовах через syscall/unix (в os/net рантайм делает это сам).
  • [ ] Внешние процессы — через os/exec, каждый со своим Wait (иначе зомби). Никакого ручного fork.
  • [ ] Сигналы ловятся в буферизованный канал (или NotifyContext); первый — graceful, второй — к дефолту.
  • [ ] RawSyscall не используется для потенциально блокирующих вызовов.
  • [ ] Для опций сокета — ListenConfig.Control/SyscallConn, а не File.Fd() (последний ломает netpoller).
  • [ ] Дедлайны на сетевых операциях обновляются перед каждой (или через context).
  • [ ] В контейнере GOMEMLIMIT согласован с memory.max cgroup, чтобы не ловить OOM-kill.
  • [ ] unsafe изолирован в маленьких документированных хелперах; go vet проходит; uintptr не хранится в переменных между преобразованиями.
  • [ ] Утечки дескрипторов и горутин проверены (/proc/self/fd, runtime.NumGoroutine, pprof).
  • [ ] Код прогнан под strace хотя бы раз — вы видели его настоящие системные вызовы.

Что читать и чем щупать дальше

Инструменты, которые должны стать рефлексом:

  • strace -f -c ./prog — какие системные вызовы и сколько времени.
  • ltrace — вызовы библиотек (полезнее для cgo-кода).
  • go tool pprof и go tool trace — профили и трассировка рантайма.
  • GODEBUG=gctrace=1,scheddetail=1,schedtrace=1000 — рантайм вслух рассказывает о GC и планировщике.
  • perf top, bpftrace — когда нужно спуститься ещё ниже, к самому ядру.

Тексты:

  • The Linux Programming Interface (Michael Kerrisk) — библия системного программирования под Linux; читается годами, но это лучший источник по всему, что здесь затронуто.
  • Документация пакетов runtime, syscall, golang.org/x/sys/unix на pkg.go.dev — она подробнее, чем кажется.
  • Исходники рантайма Go (src/runtime/): proc.go (планировщик), netpoll.go и netpoll_epoll.go (netpoller), mgc.go (сборщик мусора) — читаемы и обильно комментированы.
  • man 2 — секция 2 системного руководства описывает каждый системный вызов; man 7 <тема> (man 7 signal, man 7 namespaces, man 7 epoll) — обзорные статьи по концепциям.

И главное: запускайте strace на своих программах. Вся эта книга — про то, что происходит за дверью в ядро; strace открывает эту дверь и показывает всё своими глазами. Как только абстракции os, net и runtime станут для вас не магией, а знакомой последовательностью системных вызовов, вы перестанете бояться любого «непонятного» поведения — вы просто посмотрите, что программа говорит ядру, и всё станет ясно.

Удачи в глубинах. 🐧

← ВСЕ ПОСТЫ