16. Стандартная библиотека

«Стандартная библиотека языка программирования должна быть компактной, ортогональной и безупречно надежной».
— Кен Томпсон


Архитектурная карта доменов стандартной библиотеки Go

Стандартная библиотека Go (stdlib) спроектирована по принципу компонуемых интерфейсов (io.Reader, io.Writer, fmt.Stringer, error):

flowchart TD
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc

    Stdlib["Go Standard Library"]:::entry
    Stdlib --> IO["I/O & FS: io, bufio, os, io/fs, path/filepath"]:::process
    Stdlib --> Sync["Concurrency: sync, sync/atomic, context"]:::accent
    Stdlib --> Net["Network: net, net/http, net/url, crypto/tls"]:::process
    Stdlib --> Data["Data & Formats: encoding/json, encoding/binary, database/sql, uuid"]:::success
    Stdlib --> Diag["Diagnostics: runtime, runtime/pprof, runtime/trace, log/slog"]:::accent
  

Эволюция сериализации: encoding/json v1 vs encoding/json/v2

В Go 1.25 представлен пакет encoding/json/v2, устранивший исторические архитектурные дефекты v1 (медленная рефлексия, промежуточные аллокации, неоднозначности omitempty):

flowchart TD
    classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc

    subgraph V1["encoding/json v1 (Классический)"]
        Reflect1["Тяжелая рефлексия reflect.TypeOf/ValueOf"]:::warning
        Buffer1["Выделение временных []byte буферов на каждый шаг"]:::warning
        Omit1["omitempty: не скрывает false и нулевые структуры"]:::warning
        Dup1["Дубликаты ключей: тихо побеждает последнее значение"]:::warning
    end

    subgraph V2["encoding/json/v2 (Go 1.25+)"]
        SAX["jsontext: потоковый токенизатор без промежуточных аллокаций"]:::success
        FormatOpt["json.WithMarshalers / json.WithZeroFields опции"]:::accent
        Strict["Strict mode: дублирование ключей вызывает синтаксическую ошибку"]:::process
        ZeroOmit["omitzero: честный пропуск zero values любых типов"]:::success
    end
  

  1. Как устроен fmt?
  2. Как работает io.Reader?
  3. Что такое io.Writer?
  4. Как работает io.Copy?
  5. Что такое io.Pipe?
  6. Как работает bufio.Scanner?
  7. Как работает encoding/json?
  8. Как работает Marshal и Unmarshal?
  9. Как работают теги структуры (struct tags)?
  10. Как работает time.Time?
  11. Как работает time.Ticker?
  12. Как работает time.Timer?
  13. Как работает time.After и почему это может привести к утечке памяти?
  14. Как работает net/http клиент?
  15. Как работает net/http сервер?
  16. Что такое http.Handler и http.HandlerFunc?
  17. Как работает database/sql?
  18. Что такое sql.DB и sql.Tx?
  19. Как работает пул соединений в database/sql?
  20. Как работает os.File?
  21. Как работает os/signal?
  22. Как работает flag?
  23. Как работает context?
  24. Как работает sync пакет?
  25. Как работает sort?
  26. Как работает sort.Slice и sort.SliceStable?
  27. Как работает crypto/tls?
  28. Как работает net/http/httptest для тестирования?
  29. Как работает text/template и html/template?
  30. Как предотвратить XSS с помощью html/template?
  31. Как работает reflect?
  32. Когда стоит избегать reflect?
  33. Как работает unsafe?
  34. Как работает syscall?
  35. Как работает os/exec и как безопасно выполнять внешние команды?
  36. Как работает log и log/slog (Go 1.21+)?
  37. Что такое io/fs и зачем он нужен?
  38. Как работает embed для встраивания файлов?
  39. Как работает testing пакет для бенчмарков?
  40. Как работает runtime/debug и debug/elf?
  41. Что такое container/list и container/heap?
  42. Как работает ring из container/ring?
  43. Как работает math/big и когда его использовать?
  44. Что такое net/url и как правильно парсить URL?
  45. Как работает sync/atomic.Value?
  46. Как работает runtime/metrics?
  47. Что такое mime/multipart и как обрабатывать загрузку файлов?
  48. Как работает net/rpc и net/rpc/jsonrpc?
  49. Как работает archive/zip и archive/tar?
  50. Как работает compress/gzip?
  51. Что такое encoding/json/v2 в Go 1.25?
  52. Что такое пакет uuid в Go 1.27?

Как устроен fmt?

Краткий ответ

Пакет fmt реализует форматированный ввод-вывод на основе глаголов printf-стиля и интерфейсов Stringer, Formatter, GoStringer. Внутри принтера (pp) накапливается буфер, куда копируются строки, форматированные значения через рефлексию и интерфейс Formatter. Для эффективного вывода в io.Writer используется Fprint, а для строк – Sprintf с динамическим []byte-буфером.

Развёрнутый ответ

Основные функции: Print, Printf, Println, Sprintf, Fprintf. Все они делегируют работу внутреннему состоянию pp (private printer) из fmt/print.go. Упрощённо:

Go
func Fprintf(w io.Writer, format string, a ...any) (int, error) {
    p := newPrinter()
    p.doPrintf(format, a)
    n, err := w.Write(p.buf)
    p.free()
    return n, err
}

newPrinter() берёт pp из пула (sync.Pool), сбрасывает буфер и состояние. pp.doPrintf парсит строку формата: символ % запускает разбор флагов (-, +, #, , 0), ширины, точности и глагола. Для глаголов применяется рефлексия (пакет reflect) для извлечения значения, затем в зависимости от типа и интерфейсов вызываются:

  • Stringer (если %s или %v)
  • error (для %s/%v; если error реализует Error())
  • GoStringer (%#v)
  • Родной Formatter (приоритетный интерфейс, может взять управление полностью)

Форматирование чисел, строк, байтовых срезов обрабатывается напрямую для производительности, без рефлексии. Все результаты пишутся в p.buf — []byte с переиспользованием из пула.

Под капотом (internals)

  • pp хранит buf типа buffer (обёртка над []byte с методами WriteByte, WriteString).
  • Пул pp использует sync.Pool, чтобы минимизировать аллокации. После каждого вызова p.free() возвращает pp в пул, но предварительно обнуляет поля, чтобы избежать утечек ссылок.
  • Разбор формата реализован конечным автоматом в функции fmt/format.go:parseArgNumber и doPrintf. Сканирование идёт байт за байтом.
  • Для каждого аргумента вызывается p.printArg с рефлексивным значением. Если реализован fmt.Formatter, вызывается Format(State, verb), где State – внутренняя структура, передающая флаги, ширину и буфер.
  • Обработка %v и %+v для структур рефлексивно проходит поля: для %+v добавляются имена полей.
  • Escape-последовательности \n, \t интерпретируются компилятором, а % кодируется как %%.
  • Sprintf аллоцирует новый буфер, но для крупных строк может выполняться лишнее копирование; альтернатива — strings.Builder с fmt.Fprintf.

Типичные ошибки кандидатов

  • Забытый \n в Printf. fmt.Printf("result %d", val) не дописывает перевод строки, в отличие от log.Printf. Решение: добавить \n или использовать fmt.Println.
  • Смешивание log и fmt для одновременного вывода. fmt.Print пишет в os.Stdout, а log – в os.Stderr; это вызывает неупорядоченный вывод. Следует использовать один способ или слаженную синхронизацию log.SetOutput.
  • Использование fmt.Sprintf для конкатенации без форматирования. Например, s := fmt.Sprintf("foo %s", bar), когда достаточно s := "foo " + bar; это лишние аллокации и разбор формата. Используйте strings.Builder или конкатенацию.
  • Неучёт того, что Stringer может вызываться рекурсивно при печати самого себя. Если внутри String() вызвать fmt.Sprintf("%v", s), получится бесконечная рекурсия. Выход: передавать напрямую внутреннюю строку, либо вызывать Sprintf с другим глаголом (%s), если гарантируется нерекурсивность.

Возможные дополнительные вопросы интервьюера

Каким образом интерфейс fmt.Formatter позволяет кастомизировать вид печати?
Структура реализует метод Format(f fmt.State, verb rune), получая доступ к флагам (f.Flag('#')), ширине (f.Width()), точности и может писать напрямую в буфер через f.Write. Это даёт полный контроль над представлением.

Чем отличается %v от %+v от %#v?
%v — значение по умолчанию, %+v — в структурах добавляет имена полей, %#v — Go-синтаксис-представление значения (вызывает GoStringer, для строк добавляет кавычки, для чисел тип и т.д.).

Как снизить аллокации при логировании форматированных строк?
Использовать log/slog с аттрибутами-ключами без форматирования, либо fmt.Fprintf в strings.Builder, взятый из пула (sync.Pool), с последующим сбросом и возвратом буфера.


Как работает io.Reader?

Краткий ответ

io.Reader — фундаментальный интерфейс с методом Read(p []byte) (n int, err error), моделирующий потоковое чтение. Код читает до len(p) байт, возвращает количество прочитанных байт и ошибку. Сочетание n > 0 и err == io.EOF — норма, сигнализирующая об успешном окончании потока.

Развёрнутый ответ

Контракт Reader строг:

  • Может вернуть ненулевое n и io.EOF одновременно: данные дочитаны, следующий вызов вернёт 0, io.EOF.
  • После возвращения ошибки (включая io.EOF) поведение последующих вызовов не гарантировано, но стандартные реализации обычно стабильно возвращают 0, err.
  • Реализация не должна изменять переданный буфер p после возврата; если ошибка, буфер может быть частично заполнен.
  • Вызывающая сторона должна сначала обработать прочитанные байты (n), а потом проверять ошибку, даже если это EOF.

Классическая обёртка: io.LimitReader, io.TeeReader, io.MultiReader. Все они строятся поверх Reader, компонуя поведение без аллокации больших буферов.

Под капотом (internals)

  • Интерфейс – просто 1 метод, нет структур. Компилятор мономорфизирует вызовы, itab хранит указатель на метод.
  • Стандартные реализации: os.File.Read вызывает системный read, bytes.Buffer.Read копирует из внутреннего слайса, strings.Reader.Read отдаёт из строки.
  • io.Copy использует io.Reader и выделяет временный буфер (по умолчанию 32 КБ) для минимизации системных вызовов.
  • При проксировании соблюдается правило: если базовый Read вернул 0, io.EOF, обёртка также обязана вернуть 0, io.EOF (или ошибку).

Типичные ошибки кандидатов

  • Преждевременная проверка ошибки до учёта прочитанных байт. Код вида n, err := r.Read(buf); if err != nil { return err } теряет последние байты. Исправить: сначала обработать buf[:n], потом обрабатывать io.EOF.
  • Игнорирование короткого чтения. Полагают, что Read заполняет весь буфер; это не так. Всегда нужно использовать io.ReadFull или io.ReadAtLeast, если нужен точный размер.
  • Повторный вызов Read после io.EOF. Некоторые источники (например, os.File после закрытия) могут возвращать другую ошибку, но логика должна прекращать чтение после EOF.

Возможные дополнительные вопросы интервьюера

Как оптимизировать многократное чтение одного и того же ввода?
Использовать io.TeeReader для дублирования или io.MultiReader повторно, но если нужно перечитать — вероятно, следует прочитанные данные сохранить в bytes.Buffer и использовать bytes.Reader.

Какие интерфейсы расширяют io.Reader для эффективного копирования?
io.WriterTo у источника (ReadFrom у назначения) позволяют обойтись без промежуточного буфера, напрямую передавая данные. Например, os.File реализует ReadFrom с системным вызовом sendfile.

Почему io.Reader возвращает io.EOF, а не отдельный флаг?
Это позволяет использовать один канал ошибок и отличать «конец данных» от настоящих ошибок; обеспечивает идиоматическую обработку «прочитали и закончили» без дополнительных проверок.


Что такое io.Writer?

Краткий ответ

io.Writer — интерфейс с методом Write(p []byte) (n int, err error), который обязан записать все байты из p или вернуть ошибку, объясняющую, почему не удалось. Это минимальная абстракция для вывода.

Развёрнутый ответ

Контракт: если len(p) > 0, реализация должна записать всё; возврат n < len(p) всегда сопровождается не-nil ошибкой. Нельзя одновременно вернуть n == len(p) и err != nil – это нарушило бы инвариант. Стандартные приёмы: io.MultiWriter, io.WriteString (принимает строку без копирования в слайс, оптимизируя запись).

Обёртки io.LimitReader — для чтения ограниченного объёма; для Writer аналог io.LimitedWriter не требуется, так как контроль потока лежит на том, кто пишет.

Под капотом (internals)

  • Как и Reader, itab с одним методом.
  • os.File.Write делает системный write, bytes.Buffer.Write расширяет внутренний слайс, strings.Builder в Go 1.10+ также реализует io.Writer через WriteString без копирования.
  • io.Copy внутри использует буфер, читает из Reader, пишет в Writer. Если Writer реализует ReaderFrom, Copy передаёт управление ему, минуя буфер.
  • net.Conn реализует оба интерфейса; запись в соединение не буферизируется в net, но обёртки bufio.Writer добавляют буферизацию.

Типичные ошибки кандидатов

  • Писать, не проверяя ошибку и количество записанных байт. w.Write(data) может записать меньше, если ошибка не nil, но код продолжает считать, что запись успешна. Всегда обрабатывать: n, err := w.Write(data); if err != nil ....
  • Повторная запись того же слайса без сдвига. Если n < len(p), нужно дописывать p[n:] после устранения причины ошибки (но обычно ошибка неисправима и проще завершиться).
  • Предположение, что Write гарантированно пишет всё при len(p) > 0. Это гарантировано только при успешном выполнении, но ошибка может возникнуть после частичной записи; при ошибке n может быть > 0. Код должен сохранять прогресс.

Возможные дополнительные вопросы интервьюера

Как реализовать эффективный WriteString без аллокации?
Проверить, реализует ли Writer интерфейс io.StringWriter. Если да — вызвать WriteString, иначе преобразовать строку в []byte (аллокация). Стандартные пакеты проверяют этот интерфейс.

Для чего нужен io.Writer без возврата количества байт?
Контракт един — всегда нужно возвращать n. Но io.Discard просто проглатывает данные и всегда успешно возвращает len(p), nil.

В чём отличие между io.Writer и io.Closer при композиции?
io.Writer — только запись. Для управления временем жизни соединения или файла нужен io.Closer. io.WriteCloser объединяет оба интерфейса, например os.File, net.Conn, gzip.Writer.


Как работает io.Copy?

Краткий ответ

io.Copy(dst Writer, src Reader) перекачивает все данные из src в dst, возвращая общее количество скопированных байт и первую ошибку. Использует внутренний буфер 32 КБ, но если dst реализует ReaderFrom (либо src — WriterTo), происходит прямая передача без промежуточного буфера.

Развёрнутый ответ

Go
func Copy(dst Writer, src Reader) (written int64, err error) {
    return copyBuffer(dst, src, nil)
}

В copyBuffer проверяются интерфейсы:

  • Если dst реализует ReaderFrom, вызывается dst.ReadFrom(src) (например, os.File → sendfile).
  • Если src реализует WriterTo, вызывается src.WriteTo(dst) (например, bytes.Reader → dst.Write множественных кусков).
  • Иначе выделяется буфер (по умолчанию 32768 байт), циклично src.Read(buf) → dst.Write(buf[:nr]).

CopyN копирует ровно n байт, CopyBuffer позволяет задать внешний буфер.

Под капотом (internals)

  • Буфер изначально nil, и copyBuffer использует buf := make([]byte, 32*1024) при первом вызове, но если передан пользовательский буфер, применяется он.
  • При ReadFrom оптимизации, ядро Linux: sendfile копирует из файлового дескриптора в сокет без user-space буфера. Для TCPConn ReadFrom реализован через splice(2) (Go 1.17+).
  • Резерв буфера не меняет размер в цикле; при медленном приёмнике dst.Write может уйти в блокировку, но буфер остаётся того же размера.

Типичные ошибки кандидатов

  • Закрытие источников внутри Copy. io.Copy не закрывает ни src, ни dst. Необходимо закрыть источник с проверкой ошибки: if closeErr := src.Close(); closeErr != nil && err == nil { err = closeErr }.
  • Использование Copy без ограничения для удалённых источников, что может привести к неконтролируемому потреблению памяти. Рекомендуется io.CopyN или io.LimitReader.
  • Игнорирование io.EOF проверкой err != nil. Часто пишут if err != nil { return err }, теряя io.EOF, который означает успешное завершение.

Возможные дополнительные вопросы интервьюера

Как io.Copy определяет, использовать ли sendfile?
Проверяется, что dst — *os.File (обычно) и его дескриптор можно использовать, src — тоже *os.File или net.TCPConn. В Linux запускается syscall.Sendfile. В других ОС/условиях — буферизированное копирование.

Можно ли скопировать данные из io.Reader в несколько Writer одновременно?
Да, io.MultiWriter + io.TeeReader: io.Copy(io.MultiWriter(dst1, dst2), src). Копирование с размножением.

Почему io.Copy не обрабатывает сигналы прерывания?
Обработка сигналов — задача более высокого уровня. io.Copy при блокирующем вызове Read/Write может быть прервана только если используется контекст; для этого применяют os.File с неблокирующим режимом или net.Conn c таймаутами, но io.Copy сам не обрабатывает отмены.


Что такое io.Pipe?

Краткий ответ

io.Pipe создаёт синхронную in-memory пару *PipeReader и *PipeWriter, позволяющую горутинам обмениваться данными через io.Reader/Writer. Запись в PipeWriter блокируется, пока читатель не прочитает данные (синхронная буферизация), и наоборот. Удобен для соединения компонентов, ожидающих потоки.

Развёрнутый ответ

Go
r, w := io.Pipe()
go func() {
    defer w.Close()
    w.Write([]byte("hello"))
}()
data, _ := io.ReadAll(r)

PipeWriter.Write помещает данные во внутренний кольцевой буфер и ждёт, пока PipeReader.Read их не заберёт. Ёмкость буфера ограничена размером переданного слайса; блокировка гарантирует, что данные не потеряются. Закрытие w приводит к io.EOF в r. Закрытие r вызывает ошибку ErrClosedPipe при следующей записи в w.

Под капотом (internals)

  • Внутри pipe содержит однобайтовый буфер? Нет, структура pipe с полями: wrCh, rdCh (каналы для синхронизации), mutex, буфер-слайс data, rerr, werr.
  • Реализация на основе взаимной блокировки: Write захватывает mu, кладёт b в data, сигнализирует читателю, разблокирует mu и ждёт rdCh (готовности чтения). Read ждёт сигнала, копирует данные, уведомляет писателя.
  • Это не кольцевой буфер, а строгий handshake. Поэтому Pipe не подходит для высокопроизводительного стриминга, но даёт надёжную синхронизацию, аналогично каналу chan []byte.

Типичные ошибки кандидатов

  • Забыть закрыть PipeWriter в горутине. Читатель зависнет навсегда, ожидая EOF. Всегда используйте defer w.Close() после открытия пайпа.
  • Дедлок при записи и чтении в одной горутине. Write блокируется до Read, поэтому io.Copy(w, src) и io.ReadAll(r) должны быть в разных горутинах.
  • Использование io.Pipe для буферизованной передачи без параллелизма. Это усложнение; проще применить bytes.Buffer.

Возможные дополнительные вопросы интервьюера

Где io.Pipe используется в стандартной библиотеке?
net/http: httptest.NewRequest и http.NewRequest с телом, чтобы передать io.Reader парсеру; mime/multipart.NewWriter ожидает писателя, а os/exec.Cmd.StdinPipe использует io.Pipe для связи с stdin процесса.

Как прервать зависший Pipe?
Закрыть любую из сторон. Закрытие w разблокирует Read с io.EOF, закрытие r разблокирует Write с ErrClosedPipe. Можно комбинировать с контекстом: при отмене закрывать r.

Можно ли реализовать асинхронный пайп с буфером?
Да, через каналы chan []byte, но io.Pipe синхронный. Для асинхронного накопления используют bufio.Reader или кастомный буфер.


Как работает bufio.Scanner?

Краткий ответ

bufio.Scanner предоставляет удобный интерфейс для построчного (или по произвольному токену) чтения из io.Reader с настраиваемой функцией разделения (bufio.SplitFunc). Он последовательно читает токены с помощью Scan(), а текущий токен доступен через Text() или Bytes(). Встроенные сплиттеры: ScanLines, ScanWords, ScanRunes, ScanBytes.

Развёрнутый ответ

Go
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
    fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
    log.Fatal(err)
}

Scanner внутренне буферизирует чтение (bufio.Reader), вызывая SplitFunc для нахождения конца токена. SplitFunc получает последний кусок данных, возвращает advance, token, err. ScanLines возвращает токен без \n. Если токен не помещается во внутренний буфер, Scanner автоматически увеличивает буфер до MaxScanTokenSize (по умолчанию 64 КБ); при превышении выдаёт ErrTooLong.

Под капотом (internals)

  • Буфер buf с начальным размером startBufSize = 4096, при нехватке удваивается до MaxScanTokenSize.
  • Цикл Scan: вызывает r.Read для заполнения буфера, затем применяет сплиттер. Если сплиттер просит больше данных (err == nil, advance == 0), считывается ещё.
  • SplitFunc работает по сигнатуре func(data []byte, atEOF bool) (advance int, token []byte, err error). При atEOF == true и невозможности найти токен должен вернуть token с оставшимися данными.
  • Внутренний bufio.Reader используется не напрямую, а через собственную обёртку, чтобы поддерживать откат и поиск.

Типичные ошибки кандидатов

  • Считать, что Scanner работает с любым размером строки. По умолчанию лимит 64 КБ; на больших токенах (например, JSON без пробелов) Scan() вернёт false с ошибкой bufio.ErrTooLong. Решение: установить scanner.Buffer([]byte{}, bufio.MaxScanTokenSize*2).
  • Не проверять Err() после цикла. Многие думают, что Scan() == false всегда означает конец ввода; но это может быть ошибка. Всегда вызывать scanner.Err().
  • Многократный вызов Text() в цикле без сохранения. Каждый вызов Text() возвращает string(token) нового токена; токен перезаписывается следующим Scan(). Сохраняйте строку.
  • Использовать Scanner для высоконагруженной бинарной обработки. Из-за построчного парсинга и конвертации в строку это неоптимально; для бинарных данных лучше ручной io.ReadFull.

Возможные дополнительные вопросы интервьюера

Как написать собственный SplitFunc, выделяющий сообщения в формате, заканчивающиеся \n\n?
Реализовать функцию, которая ищет index \n\n, возвращает advance = index+2, token = data[:index], иначе 0, nil, nil (нужно больше данных), при atEOF вернуть остаток.

Как Scanner обрабатывает строки длиннее буфера?
Буфер увеличивается динамически, но останавливается на MaxScanTokenSize. Если сплиттер не вернёт токен до лимита, Scan возвращает ошибку ErrTooLong.

Почему Scanner не реализует io.Reader?
Потому что его задача — возвращать токены, а не непрерывный поток байт. Для потокового чтения используют bufio.Reader.


Как работает encoding/json?

Краткий ответ

Пакет encoding/json реализует кодирование/декодирование JSON через интерфейсы json.Marshaler/json.Unmarshaler и рефлексию. Marshal обходит структуру, преобразуя поля согласно тегам json:"name,options", и записывает результат в []byte. Unmarshal разбирает JSON-токены и распределяет значения в структуры/слайсы/мапы. Потоковые варианты — NewEncoder/NewDecoder.

Развёрнутый ответ

Основные функции:

Go
b, err := json.Marshal(v)   // v -> JSON
err := json.Unmarshal(b, &v) // JSON -> v

Для потоков:

Go
enc := json.NewEncoder(w)
err := enc.Encode(v)
dec := json.NewDecoder(r)
err := dec.Decode(&v)

Преобразование управляется тегами структуры. Поля без тега используют имя поля; тег - исключает поле. omitempty пропускает нулевые значения. string форсирует строковое представление числа. Интерфейсы: json.Marshaler (метод MarshalJSON), json.Unmarshaler (UnmarshalJSON), также encoding.TextMarshaler/TextUnmarshaler для строкового представления.

Под капотом (internals)

  • Marshal создаёт encodeState (пул состояний с внутренним bytes.Buffer), рекурсивно вызывает reflect-based marshal функцию. Для каждого типа существует таблица методов кодирования (encoder func), кэшируемая в map, чтобы избежать повторного обхода структуры. Рефлексия анализирует структуру, собирает поля, исключая неэкспортируемые и с тегом -. Встроенные поля анонимных структур продвигаются (promote).
  • Unmarshal использует decodeState с поточным декодером scanner (внутренний лексер). Лексер не буферизирует весь ввод, а читает токены постепенно. Структура также кэширует информацию о полях. При декодировании создаётся карта соответствия ключей JSON и полей структуры, поддерживается case-insensitive matching.
  • Для json.RawMessage откладывается парсинг; для json.Delayed (Go 1.16+) — ленивый разбор.
  • Encoder/Decoder держат ссылку на io.Writer/io.Reader и работают поточно, что позволяет обрабатывать огромные массивы без полной загрузки в память.

Типичные ошибки кандидатов

  • Передавать не-указатель в Unmarshal. Ошибка компиляции? Нет, Unmarshal принимает any, но если передать значение, а не указатель, данные будут десериализованы во временный объект и потеряны. Unmarshal проверяет, что второй аргумент — не nil-указатель; если нет, возвращает InvalidUnmarshalError.
  • Путать json:"-" и json:"-,". json:"-" полностью исключает поле, а json:"-," создаёт ключ - в JSON. Аналогичная путаница с omitempty.
  • Не удалять пустые поля, когда omitempty не учитывает вложенные структуры. omitempty работает только для нулевого значения; для вложенной структуры со всеми нулевыми полями она не считается пустой, если не является указателем. Приходится использовать указатель на структуру, чтобы omitempty срабатывал (nil).
  • Конкурировать за один Encoder/Decoder без синхронизации. Encode/Decode не потокобезопасны; параллельные вызовы требуют внешней синхронизации.

Возможные дополнительные вопросы интервьюера

Как обработать unknown-поля при десериализации, чтобы не падало с ошибкой?
Начиная с Go 1.10, завести поле с тегом json:"-" для захвата: Extra map[string]json.RawMessagejson:"-"и реализоватьUnmarshalJSON`, который сначала разбирает в map[string]json.RawMessage, затем в основную структуру, распределяя неизвестные ключи.

Как десериализовать полиморфные JSON-объекты?
Использовать json.RawMessage для временного хранения вложенного блока, затем по дискриминатору вызывать json.Unmarshal в конкретный тип. Или написать собственный UnmarshalJSON.

Какие уязвимости безопасности связаны с encoding/json?
Атаки через гигантские числа (resource exhaustion), рекурсивные структуры (stack overflow), десериализация в interface{}, приводящая к map-спам. Использовать Decoder.UseNumber для чисел, DisallowUnknownFields для строгой проверки, и лимиты размера через io.LimitReader.


Как работает Marshal и Unmarshal?

Краткий ответ

json.Marshal преобразует Go-значение в JSON-строку ([]byte), обходя структуры рефлексивно и сериализуя поля согласно тегам и реализованным интерфейсам Marshaler. json.Unmarshal разбирает JSON-данные и заполняет целевой объект, используя теги и Unmarshaler, поддерживая гибкое сопоставление ключей.

Развёрнутый ответ

Оба опираются на рефлексию и кэширование информации о типах. Примеры:

Go
type User struct {
    Name string `json:"name"`
    Age  int    `json:",omitempty"`
}
u := User{Name: "Alice"}
b, _ := json.Marshal(u) // {"name":"Alice"}
var u2 User
json.Unmarshal(b, &u2)

Marshal поддерживает MarshalJSON() для кастомной логики; если его нет, используется reflect для перебора полей. Указатели автоматически разыменовываются, нулевые указатели сериализуются как null. Карты кодируются как объекты JSON, ключи которых преобразуются в строки по тем же правилам.

Unmarshal ищет поле сначала по точному совпадению имени, затем case-insensitive. При нахождении JSON-ключа, отсутствующего в структуре, если DisallowUnknownFields установлен в декодере, возвращается ошибка. Иначе игнорируется. Числа могут быть декодированы в float64 (стандарт), но с UseNumber() остаются json.Number.

Под капотом (internals)

  • Генерация кодеков кэшируется в sync.Map по reflect.Type. Кэш хранит structFields и функции-энкодеры/декодеры, построенные на этапе первого обращения к типу.
  • Для структур строится fields — список экспортируемых полей с учётом встроенных типов. Сортировка по глубине встраивания, затем по алфавиту. При конфликте имён приоритет выше у более глубоко вложенного поля.
  • Marshal работает через внутренний буфер, дополняя его через e.WriteByte и аналоги.
  • Unmarshal использует поточный сканер JSON (внутренний scanner из encoding/json/scanner.go), который потребляет io.Reader, идентифицирует токены. Декодирование управляется конечным автоматом, который сопоставляет токены с ожидаемой структурой.
  • Анонимные поля структур промоутятся, если не конфликтуют с полями более высокого уровня.

Типичные ошибки кандидатов

  • Забыть про экспорт полей. Неэкспортированные поля игнорируются и при маршалинге, и при анмаршалинге, что часто приводит к пустым результатам. Решение: начинать имена полей с заглавной буквы или использовать кастомный MarshalJSON.
  • Попытка десериализовать число из строки без опции string. JSON-строка "123" не превратится в int автоматически, если не указан тег json:",string" для поля. Ошибка cannot unmarshal string into Go value of type int. Исправить: добавить опцию string.
  • Неверное использование omitempty для булевых полей. bool с false не опускается, потому что false — не нулевое значение? Нет, false — нулевое значение, оно опускается. Но int с 0 опускается. Путаница с тем, что считать zero-value: 0, "", false, nil. time.Time{} не является zero-value в смысле omitempty, если это не указатель.
  • Не копировать данные из json.RawMessage перед сохранением. RawMessage — это []byte, и при повторном использовании буфера декодера данные могут быть перезаписаны. Решение: делать copy или сериализовать сразу.

Возможные дополнительные вопросы интервьюера

Как Marshal справляется с циклическими ссылками?
Никак. При обнаружении цикла через рекурсию указателей возникает ошибка json: unsupported value: encountered a cycle via .... Нужно реализовать MarshalJSON с явной обработкой или использовать маршаллинг в промежуточный DAG.

Как сериализовать time.Time в unix timestamp?
Использовать кастомный тип с MarshalJSON/UnmarshalJSON, либо применить struct с полем time.Time и переопределить методы. Альтернативно, json.NewEncoder не имеет опции, поэтому только кастомная сериализация.

Чем отличается NewEncoder от Marshal с последующей записью?
NewEncoder пишет потоково, не аллоцирует полный слайс JSON, поэтому меньше памяти на больших объектах, и может записать несколько JSON-объектов последовательно (разделённых \n). Marshal возвращает полный []byte.


Как работают теги структуры (struct tags)?

Краткий ответ

Теги структуры — строковые литералы в обратных кавычках после типа поля, интерпретируемые пакетами через reflect.StructTag. Основной формат: key:"value", пары разделяются пробелами. encoding/json, xml, gorm и другие используют теги для управления поведением при маршалинге, валидации, ORM и т.д.

Развёрнутый ответ

Go
type User struct {
    Name string `json:"name" validate:"required"`
    Age  int    `json:"age,omitempty" db:"user_age"`
}

Получение тега через рефлексию:

Go
field, _ := reflect.TypeOf(User{}).FieldByName("Name")
tag := field.Tag
fmt.Println(tag.Get("json")) // "name"

Парсинг: StructTag.Get(key) ищет key:"value", возвращает значение до следующей пары или конца строки. Пробелы разделяют пары; значение может содержать опции через запятую (соглашение). Теги не влияют на компиляцию, только на рантайм-рефлексию.

Под капотом (internals)

  • В структуре reflect.StructTag лежит просто string. При компиляции теги сохраняются в бинарный образ — в отладочную секцию runtime.moduledata через structField.name и structField.tag.
  • StructTag.Get сканирует строку, ищет подстроку key + ":\"", экранирование кавычек не поддерживается; поэтому значение не может содержать двойные кавычки.
  • В Go 1.17+ введён reflect.StructTag.Lookup(key) возвращает значение и признак наличия, позволяя различить пустой тег и отсутствующий.
  • При старте runtime строит таблицы типов; для каждого поля сохраняется tag в структуре structField. Это позволяет избежать дополнительного разбора при каждом вызове Tag.Get.

Типичные ошибки кандидатов

  • Множественные пробелы и форматирование. Если есть лишние пробелы, Tag.Get("json") всё равно сработает, но ручное конструирование тегов без Get может быть хрупким. Лучше использовать Get.
  • Использование неверного формата key:value. json:"name,omitempty" — правильно; json="name" неверно (знак равенства вместо двоеточия) — тег не будет распознан Get.
  • Забыть про конфликты имён встроенных полей. Продвижение (promotion) полей из встроенных структур может привести к неожиданному перекрытию тегов. Например, два встроенных поля с одинаковым именем, но разными тегами — предпочтение у более глубокого.
  • Использование нескольких значений без учёта Lookup для определения наличия. tag.Get("json") вернёт первую пару; если нужен второй ключ с тем же именем — невозможно, уникальность по ключу.

Возможные дополнительные вопросы интервьюера

Как реализовать свой обработчик тегов для конфигурации?
Использовать reflect для перебора полей, получать field.Tag.Get("config"), парсить значение согласно своему синтаксису. Кэшировать разобранные данные в sync.Map для типа.

Может ли тег содержать двойную кавычку?
Стандартный парсер не поддерживает экранирование; кавычка внутри значения разорвёт формат. Обходной путь — использовать другое кодирование, например base64 или замену символов.

В чём разница между json:"-" и json:"-,"?
json:"-" — исключить поле, json:"-," — включить поле с именем - в JSON (имя ключа — "-"). Аналогично с другими пакетами.


Как работает time.Time?

Краткий ответ

time.Time — неизменяемая структура для представления момента времени с наносекундной точностью, календарным разбором (год, месяц, день) и часовым поясом. Внутренне хранит wall uint64 (настенные часы, монотонное смещение) и ext int64 (секунды с 0001-01-01), а также указатель на *Location. Реализует методы форматирования, арифметики и сравнения.

Развёрнутый ответ

Создание:

Go
t := time.Now()          // wall + монотонный сдвиг
t2 := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)

Хранение: wall содержит битовое поле: старшие биты — секунды с 1 января 1885 года (для быстрого извлечения года/дня), младшие — монотонный сдвиг и флаг hasMonotonic. Если монотонные часы не нужны, ext хранит секунды с 1-01-01. Такая двойная природа позволяет сравнивать и считать длительности без скачков системных часов. Location содержит имя и информацию о переходе на летнее время.

Форматирование: через магическую ссылочную дату Mon Jan 2 15:04:05 MST 2006 (= 01/02 03:04:05PM '06 -0700). time.Parse разбирает строку по этой же маске.

Под капотом (internals)

  • Структура занимает 24 байта (8 wall, 8 ext, 8 *Location) на 64-битной машине.
  • Now() делает системный вызов clock_gettime(CLOCK_REALTIME) и clock_gettime(CLOCK_MONOTONIC) (Linux). Монотонные часы сохраняются в младших 33 битах wall с флагом hasMonotonic = 1 << 63.
  • При t.Add(d) монотонный сдвиг вычитается и пересчитывается.
  • Before, After, Equal используют монотонные часы при наличии, иначе ext.
  • Форматирование избегает аллокаций: AppendFormat пишет в предоставленный []byte.

Типичные ошибки кандидатов

  • Использовать == для сравнения времени, игнорируя локаль и монотонные часы. Два времени могут быть равны по моменту, но различаться по локальному представлению; t1.Equal(t2) нормализует сравнение.
  • Передавать time.Time как ключ в map с разными локациями одного момента. Хеш time.Time включает локаль, поэтому time.Date(2026,5,1,0,0,0,0,UTC) и то же время в America/New_York могут дать разные хеши. Лучше нормализовать к UTC.
  • Игнорировать ошибки time.Parse и time.ParseInLocation. Некорректная строка формата возвращает time.Time{}; код продолжает работать с нулевым временем. Всегда проверять ошибку.
  • Некорректно использовать time.After в циклах с множеством горутин, что ведёт к утечке (см. далее).

Возможные дополнительные вопросы интервьюера

Как сериализовать time.Time в JSON с определённым форматом?
Реализовать кастомный тип с методами MarshalJSON/UnmarshalJSON с вызовом t.Format(time.RFC3339).

Чем опасен переход на летнее время для таймеров?
time.Timer не учитывает скачки часов; при переводе стрелок назад можно получить ложное срабатывание или задержку. Используйте монотонные часы при расчёте интервалов.

Как получить номер недели в году?
_, week := t.ISOWeek().


Как работает time.Ticker?

Краткий ответ

time.Ticker предоставляет канал C, в который с фиксированным интервалом отправляются значения time.Time. Он создаётся time.NewTicker(d), и должен быть остановлен Ticker.Stop() для освобождения ресурсов. Используется для периодических задач.

Развёрнутый ответ

Go
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
    select {
    case t := <-ticker.C:
        log.Println("tick at", t)
    case <-ctx.Done():
        return
    }
}

Особенности: первое событие приходит через интервал, а не сразу. Таймер можно перезапустить только созданием нового. Stop() останавливает тикер, но не закрывает канал; если горутина заблокирована на чтении, она не разблокируется — нужно использовать дополнительный сигнальный канал. Чтобы избежать утечек, всегда останавливайте тикер, когда он больше не нужен.

Под капотом (internals)

  • Внутренне Ticker — это обёртка над runtimeTimer (структура из runtime/time.go), связанным с горутиной-таймером (timerproc). Канал C — буферизованный канал размера 1.
  • Планировщик runtime хранит кучу таймеров (heap) на основе time-wheel (в Go 1.14+ перешли на time.heep), эффективно обрабатывая миллионы таймеров.
  • Когда таймер срабатывает, он помещает now в канал C и перезаряжает таймер на следующий интервал. Если канал заполнен (при медленном потребителе), tick пропускается — дропается. Это поведение «drop if channel full».
  • Остановка удаляет таймер из кучи, освобождает горутину.

Типичные ошибки кандидатов

  • Забыть вызвать Stop(). Тикер останавливается только сборщиком мусора, когда на него нет ссылок, но до тех пор таймер вызывает срабатывания и занимает ресурсы.
  • Пытаться читать из ticker.C после Stop() без дополнительного контроля. Канал не закрывается, горутина может зависнуть. Всегда добавляйте контекст или флаг выхода.
  • Использовать time.Tick (синглтон) в долгоживущих процессах, так как он не может быть остановлен и утекает. Всегда используйте NewTicker и Stop.
  • Полагать, что тики не пропускаются. При перегрузке получателя тики отбрасываются; если нужна точная периодичность без пропусков, лучше реализовать свой цикл с time.Sleep и фиксацией следующего момента.

Возможные дополнительные вопросы интервьюера

Как реализовать тикер с немедленным первым событием?
Создать канал, послать в него начальное значение, затем запустить NewTicker и в цикле читать из начального канала, а потом из тикера; либо использовать time.AfterFunc с немедленным вызовом и периодическим перезапуском.

Можно ли перезапустить остановленный тикер?
Нет, Reset для тикера не существует из-за сложности согласования с заполненным каналом. Нужно создать новый.

Как тикер взаимодействует с GC?
Сам тикер не защищён от GC; если на него нет ссылок, финализатор не гарантирует остановку таймера, но рантайм всё равно может освободить память. Но лучше явно останавливать.


Как работает time.Timer?

Краткий ответ

time.Timer откладывает однократное событие в будущем. Создаётся NewTimer(d), предоставляет канал C, который получит значение ровно один раз после истечения времени (или после вызова Reset). Обязан быть остановлен при ненадобности через Stop() для предотвращения утечек.

Развёрнутый ответ

Go
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
    // сработал
case <-ctx.Done():
    if !timer.Stop() {
        <-timer.C // drain
    }
}

Stop() возвращает true, если таймер был остановлен до срабатывания; false, если уже сработал. В последнем случае необходимо вычитать значение из канала (если не было прочитано ранее), чтобы избежать утечки памяти горутины-отправителя. Reset(d) перезапускает таймер, должен вызываться только на остановленном или уже сработавшем таймере, иначе возможно гонка — правильный паттерн: if !timer.Stop() { <-timer.C }; timer.Reset(d).

Под капотом (internals)

  • Аналогично Ticker, базируется на runtimeTimer, один общий таймерный heap на процесс.
  • При создании добавляется в кучу. Когда истекает, горутина-таймер отправляет текущее время в канал C.
  • Канал C имеет размер 1. Если канал не освобождён, повторная отправка невозможна, поэтому Reset на уже сработавшем без освобождения канала приведёт к блокировке рантайм-лупы и утечке.
  • В Go 1.23 появился time.Timer.Reset с улучшенной безопасностью, но drain всё ещё нужен для совместимости.

Типичные ошибки кандидатов

  • Игнорировать возвращаемое значение Stop() и не дренировать канал. Это гонка: между Stop() и возвратом таймер мог сработать и послать в канал; в канале останется значение, и следующий Reset может привести к «отравленному» каналу или утечке. Правильный паттерн: if !timer.Stop() { select { case <-timer.C: default: } } или <-timer.C после проверки.
  • Повторный вызов Reset на несработавшем таймере без предварительного Stop. С Go 1.23 это разрешено? В документации сказано, что Reset должен вызываться только на остановленном или сработавшем таймере. Вызов на активном таймере приводит к гонке между старым и новым сроком. Нужно сначала Stop, drain, потом Reset.
  • Использование time.After как долгоживущего таймера, не имея возможности остановить. Это приводит к утечкам (см. следующий вопрос).
  • Неправильная обработка канала в select с несколькими таймерами: если таймер не нужен, но он сработал в фоне, его канал может остаться невычитанным.

Возможные дополнительные вопросы интервьюера

Как реализовать надёжный таймаут с контекстом?
Использовать context.WithTimeout, который управляет таймером сам и гарантирует освобождение ресурсов после отмены.

Почему нельзя просто пересоздавать таймеры через time.After в цикле?
Каждый time.After создаёт таймер, который не удаляется до истечения, даже если вы выходите из select по другому событию. Множество вызовов в цикле создаёт горутины и память, постепенно утекающие.

Как Timer.Reset взаимодействует с непрочитанным каналом?
Если канал не был дренирован, при срабатывании после Reset сначала будет доставлено старое значение (с предыдущим временем), а новое событие не сможет быть отправлено, пока канал не опустошат. Это ломает логику.


Как работает time.After и почему это может привести к утечке памяти?

Краткий ответ

time.After(d) возвращает канал, который получит сигнал через d. Он эквивалентен NewTimer(d).C, за исключением того, что возвращённый таймер нельзя остановить. В циклических или долгоживущих select конструкциях это создаёт множество таймеров, которые не освобождаются до истечения, даже если они больше не нужны, что ведёт к утечкам памяти и горутин.

Развёрнутый ответ

Проблемный шаблон:

Go
for {
    select {
    case <-time.After(5 * time.Second):
        // делает что-то
    case v := <-ch:
        // обрабатывает v
    }
}

Каждую итерацию создаётся новый таймер, который останется в куче минимум на 5 секунд после истечения, даже если ch получает данные и цикл немедленно продолжается. Сборщик мусора не может удалить таймер, пока он не сработает и канал не будет закрыт (но канал не закрывается). Решение: использовать time.NewTimer с явным Stop и drain:

Go
timer := time.NewTimer(0)
defer timer.Stop()
for {
    timer.Reset(5 * time.Second)
    select {
    case <-timer.C:
        // таймаут
    case v := <-ch:
        // обработать
    }
}

Под капотом (internals)

  • Каждый time.After порождает runtimeTimer с каналом, который регистрируется в глобальной куче таймеров. Куча защищена мьютексом, но при большом количестве таймеров могут возрасти задержки.
  • Освобождение после истечения: горутина timerproc (или в Go 1.14+ специальная горутина) отправляет время в канал и удаляет таймер из кучи. Если на канал никто не читает, структура таймера остаётся в памяти до завершения отправки.
  • Если программа создаёт таймеры быстрее, чем они истекают, происходит линейный рост памяти и потребления CPU на обслуживание кучи.

Типичные ошибки кандидатов

  • Повсеместное использование time.After в select внутри цикла без осознания последствий. Это классическая ловушка.
  • Попытка обойти утечку через time.Tick для периодического опроса. time.Tick также нельзя остановить, он тоже даёт утечку, но медленнее (один таймер). Лучше NewTicker.
  • Использование time.After с очень маленькими интервалами как способ yield. Это создаёт огромное давление на GC и планировщик; правильно использовать runtime.Gosched или каналы.

Возможные дополнительные вопросы интервьюера

Когда всё же допустимо использовать time.After?
В краткосрочных операциях, например, единичный select с таймаутом, где утечка ограничена временем жизни горутины (короткий запрос). Но и там лучше явный таймер.

Как отладить утечку таймеров в продакшене?
Смотреть метрики: количество горутин, runtime.NumGoroutine(), профилирование кучи, pprof показывает time.After в графе вызовов. Также использовать GODEBUG=scheddetail=1.

Может ли GC собрать неиспользованный канал от time.After?
Нет, потому что канал всё ещё находится в куче таймеров и является достижимым. Только после срабатывания и удаления таймера канал может быть собран.


Как работает net/http клиент?

Краткий ответ

http.Client — высокоуровневый HTTP-клиент, выполняющий запросы (Do, Get, Post) с настройками таймаутов, кук, редиректов и транспорта. Внутри использует Transport (обычно http.DefaultTransport), который управляет пулом соединений, TLS-рукопожатиями, keep-alive и автоматическим следованием редиректам.

Развёрнутый ответ

Go
client := &http.Client{
    Timeout: 10 * time.Second,
}
resp, err := client.Get("https://example.com")

Основной метод — Do(req); Get/Post — удобные обёртки, создающие Request. Client применяет политику редиректов (по умолчанию до 10), проверку кук через Jar. Transport выполняет реальную сетевую коммуникацию. Можно подменить на кастомный для тестирования (httptest), логирования, прокси.

Client безопасен для одновременного использования несколькими горутинами. Response.Body обязан быть закрыт через defer resp.Body.Close(), иначе соединение не вернётся в пул.

Под капотом (internals)

  • DefaultTransport = &http.Transport{...}, инициализируется лениво.
  • Transport.roundTrip управляет кешем TCP-соединений (idleConn map, по ключу connectMethodKey). При новом запросе ищет свободное idle-соединение; если нет, создаёт новое через DialContext.
  • Поддержка HTTP/1.1, HTTP/2 (через golang.org/x/net/http2 или встроенный с Go 1.6+). Transport автоматически согласовывает протокол через TLS-ALPN.
  • DialContext для каждого соединения оборачивает net.Dialer с таймаутами Dial, KeepAlive. Настраивается DialTLSContext для кастомного TLS.
  • Пул idle-соединений имеет ограничение MaxIdleConns, MaxIdleConnsPerHost, IdleConnTimeout. Если пул переполнен, самые старые закрываются.

Типичные ошибки кандидатов

  • Не закрывать resp.Body. Даже если ответ не читается, тело должно быть закрыто, иначе соединение не вернётся в пул, и горутина утечёт.
  • Использовать дефолтный клиент без таймаутов. http.DefaultClient не имеет Timeout, что может привести к зависанию навечно. Всегда задавать Timeout или контекст.
  • Игнорировать буферизацию тела при чтении маленьких ответов. io.ReadAll читает всё; для потоковых ответов следует ограничить через io.LimitReader.
  • Делить один http.Transport с кастомными настройками между разными клиентами без учёта конфликтов. Если менять Transport после первого использования, это вызовет гонки. Лучше создавать новый транспорт для специфичных конфигураций.
  • Не проверять редиректные петли. Хотя Client ограничивает 10 редиректами, кастомный CheckRedirect должен предотвращать опасные редиректы (смену протокола или хоста).

Возможные дополнительные вопросы интервьюера

Как настроить прокси?
Через Transport.Proxy задать функцию, возвращающую *url.URL прокси, или ProxyFromEnvironment.

Как тестировать HTTP-клиент без сети?
Подменить Transport на httptest.NewServer или http.RoundTripper который возвращает фиктивные ответы.

Что такое http.RoundTripper и как он связан с Client?
Это интерфейс с методом RoundTrip(*Request) (*Response, error). Client вызывает Transport.RoundTrip. Позволяет встроить промежуточные слои: кеширование, аудит, заглушки.


Как работает net/http сервер?

Краткий ответ

http.Server слушает TCP-порт, принимает соединения и для каждого запускает горутину, обрабатывающую запросы через цепочку Handler. Сервер поддерживает keep-alive, таймауты чтения/записи, грациозную остановку, HTTP/2 через TLS. Стандартный DefaultServeMux — маршрутизатор по паттернам.

Развёрнутый ответ

Go
srv := &http.Server{
    Addr: ":8080",
    Handler: mux,
}
log.Fatal(srv.ListenAndServe())

ListenAndServe создаёт net.Listener. При поступлении соединения Serve в цикле принимает соединения, запуская на каждое горутину go c.serve(ctx). Внутри соединения читается запрос (http.ReadRequest), создаётся http.Response, и вызывается server.Handler.ServeHTTP(w, r). Ответ записывается обратно в сетевое соединение. Если Handler — nil, используется DefaultServeMux.

Под капотом (internals)

  • Соединение обслуживается в горутине, считывающей запросы с помощью bufio.Reader. Поддержка keep-alive активирована по умолчанию; после отправки ответа соединение возвращается в пул ожидания (conn.reader).
  • Конвейерная обработка (pipelining) поддерживается: несколько запросов могут читаться подряд без закрытия соединения.
  • Таймауты: ReadTimeout, WriteTimeout, IdleTimeout. Первый защищает от медленных клиентов, второй — от медленной записи, третий — закрывает простаивающие keep-alive соединения.
  • Shutdown(ctx) переводит сервер в режим закрытия: перестаёт принимать новые соединения, активные завершаются до истечения контекста.
  • HTTP/2: если сервер запущен с TLSConfig, активируется HTTP/2 (с помощью golang.org/x/net/http2/h2c для открытого текста или автосогласования TLS). Соединения мультиплексируются в одном TCP-соединении.
  • По умолчанию лимит на количество одновременных соединений отсутствует; защиту обеспечивает net.Listener с accept backlog и ОС-лимиты.

Типичные ошибки кандидатов

  • Не обрабатывать ошибку http.ErrServerClosed после Shutdown. Это ожидаемая ошибка; её следует игнорировать при graceful shutdown.
  • Задавать таймауты только на клиенте, не задавая на сервере. Slowloris атаки могут исчерпать ресурсы. Устанавливайте ReadHeaderTimeout и ReadTimeout.
  • Использовать глобальное состояние без защиты в обработчиках, что ведёт к гонкам.
  • Забыть вызвать Close на r.Body в обработчике. Хотя сервер автоматически закрывает тело после выхода обработчика, для гарантии и чистоты рекомендуется defer r.Body.Close().

Возможные дополнительные вопросы интервьюера

Как одновременно слушать HTTP и HTTPS?
Запустить две горутины: srv.ListenAndServe() для HTTP и srv.ListenAndServeTLS(certFile, keyFile) для HTTPS. Или используйте tls.Listen.

Как ограничить количество входящих запросов?
Семафор с буферизованным каналом в middleware, либо golang.org/x/net/netutil.LimitListener.

Что такое ResponseController (Go 1.20+)?
Позволяет управлять поведением ответа: SetReadDeadline, SetWriteDeadline, Hijack для обновления протокола (WebSocket).


Что такое http.Handler и http.HandlerFunc?

Краткий ответ

http.Handler — интерфейс с методом ServeHTTP(ResponseWriter, *Request), основная абстракция обработки HTTP-запроса. http.HandlerFunc — адаптер, позволяющий использовать функцию func(ResponseWriter, *Request) как Handler. Маршрутизаторы, middleware реализуют Handler.

Развёрнутый ответ

Go
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Это позволяет писать обработчики как обычные функции:

Go
var handler http.Handler = http.HandlerFunc(myFunc)

В DefaultServeMux регистрируются обработчики через Handle(pattern, handler) или HandleFunc(pattern, func).

Под капотом (internals)

  • ServeMux хранит внутреннее представление маршрутов (muxEntry), осуществляет поиск по longest match и обход редиректов (например, /tree → перенаправление на /tree/).
  • HandlerFunc — просто тип функции, реализующий интерфейс; при вызове ServeHTTP вызывает базовую функцию. Никакой магии времени выполнения нет.

Типичные ошибки кандидатов

  • Регистрировать функцию напрямую в mux.Handle без приведения типов. mux.Handle("/", myFunc) где myFunc просто функция — приведёт к ошибке компиляции, так как Handle ожидает Handler. Нужно http.HandlerFunc(myFunc).
  • Путать Handle и HandleFunc. HandleFunc автоматически оборачивает функцию в HandlerFunc, поэтому можно mux.HandleFunc("/", myFunc).
  • Реализовывать ServeHTTP на структуре без поддержки конкурентного доступа к её полям. Обработчики вызываются в разных горутинах, любые общие поля должны быть защищены.

Возможные дополнительные вопросы интервьюера

Как создать цепочку middleware?
Middleware — функция func(http.Handler) http.Handler. Оборачиваем: handler = middleware1(middleware2(handler)).

Как DefaultServeMux сопоставляет пути с шаблонами?
Использует сравнение по длине: для / всё подходит, для /images/ — любые пути с этим префиксом. Если зарегистрированы и /images/, и /images/thumbnails/, то более специфичный матчится первым.

В чём разница между http.Handler и http.HandlerFunc в плане производительности?
Никакой; вызов ServeHTTP у HandlerFunc — прямой вызов функции, у кастомной структуры — вызов метода через интерфейс, но devirtualization inlining может свести разницу к нулю.


Как работает database/sql?

Краткий ответ

database/sql предоставляет универсальный интерфейс для работы с реляционными БД через драйверы (database/sql/driver). Центральные типы: sql.DB — пул соединений; sql.Tx — транзакция; sql.Rows — курсор результатов. Пакет обеспечивает управление соединениями, повторные попытки, контекстную отмену и сканирование результатов в Go-типы.

Развёрнутый ответ

Основные сценарии:

Go
db, err := sql.Open("postgres", dsn)
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users")
defer rows.Close()
for rows.Next() {
    var id int; var name string
    rows.Scan(&id, &name)
}
err = rows.Err()

sql.DB открывает ленивые соединения, управляет их пулом. SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime контролируют размеры и время жизни. При запросах драйвер возвращает driver.Rows, которые адаптируются в sql.Rows.

Транзакции: db.BeginTx(ctx, nil), возвращает *Tx, который должен быть зафиксирован (Commit) или откачен (Rollback). Вложенные транзакции не поддерживаются; их эмулируют savepoints через ExecContext.

Под капотом (internals)

  • sql.DB не открывает соединения сразу. connector (реализация driver.Connector) связывает DB с драйвером.
  • Пул соединений реализован внутри sql.DB (freeConn — список свободных соединений, numOpen, мьютекс). При запросе conn(ctx) ищет свободное, если нет — создаёт новое, с учётом лимитов. Если лимит превышен, горутина встаёт в очередь ожидания с контекстом.
  • QueryContext/ExecContext вызывают db.conn для получения соединения, выполняют запрос, возвращают соединение в пул или закрывают, если ошибка фатальна.
  • При превышении maxIdle лишние соединения закрываются по таймеру. ConnMaxLifetime гарантирует закрытие старых для безопасности смены паролей, сброса DNS.
  • Scan использует рефлексию для преобразования драйвер-значений в переданные указатели. Поддерживаются sql.Scanner, driver.Valuer.

Типичные ошибки кандидатов

  • Не закрывать rows. Это удерживает соединение в пуле и может исчерпать пул. Всегда defer rows.Close(), даже если Scan может вернуть ошибку.
  • Игнорировать rows.Err() после цикла. Ошибка, возникшая во время итерации, не выбрасывается в Scan, а сохраняется. Необходимо проверить после цикла.
  • Не использовать контекстные версии (QueryContext, ExecContext), что лишает возможности таймаутов и отмены.
  • Смешивать sql.DB и sql.Tx без передачи контекста. Транзакции привязаны к соединению, нужно гарантировать корректную передачу tx в репозиторий.

Возможные дополнительные вопросы интервьюера

Что делать, если драйвер не поддерживает context.Context?
Контекст игнорируется, запрос нельзя отменить. Стоит выбрать драйвер с поддержкой или реализовать внешний таймаут с закрытием соединения через db.Conn.

Как реализовать паттерн «репозиторий» с транзакциями?
Передавать абстракцию Querier (интерфейс с методами QueryContext, ExecContext), реализованную как *sql.DB и *sql.Tx.

Чем sql.DB отличается от sql.Conn?
Conn — отдельное выделенное соединение из пула, полученное через db.Conn(ctx). Полезно для специфичных операций с временными настройками сессии.


Что такое sql.DB и sql.Tx?

Краткий ответ

sql.DB — пул соединений с БД, безопасный для конкурентного использования, предоставляющий методы Query, Exec без привязки к конкретному соединению. sql.Tx — транзакция, связанная с ровно одним соединением; предоставляет те же методы (плюс Commit/Rollback) и гарантирует атомарность.

Развёрнутый ответ

Go
db, _ := sql.Open("mysql", dsn)
// db — готова к использованию, сама открывает соединения

tx, _ := db.BeginTx(ctx, nil)
tx.ExecContext(ctx, "...")
tx.Commit() // или Rollback

После создания Tx, все операции внутри транзакции выполняются на одном и том же соединении. После Commit или Rollback соединение возвращается в пул, а Tx становится невалидным.

Под капотом (internals)

  • sql.DB содержит мутекс, счётчик соединений, слайс freeConn. conn внутренний включает driver.Conn, слот для сессии.
  • BeginTx берёт соединение из пула (conn), вызывает driver.Conn.BeginTx (если есть) или Begin. Возвращает *Tx, который хранит ссылку на это соединение.
  • При Commit/Rollback драйвер завершает транзакцию, после чего соединение можно использовать повторно. Если во время транзакции произошла ошибка (например, driver.ErrBadConn), транзакция помечается как «плохая» и соединение закрывается.

Типичные ошибки кандидатов

  • Забыть Rollback или Commit при ошибке. Если tx не освободить, соединение никогда не вернётся в пул, что исчерпает пул. Используйте defer tx.Rollback() сразу после BeginTx, tx.Commit() отменит откат.
  • Передавать Tx в горутины без синхронизации. Tx не горутино-безопасен, как и sql.DB не для одного соединения, но Tx тем более — все вызовы должны быть последовательными.
  • Использовать tx после Commit/Rollback. Паника или ошибка. Нужно обнулить.

Возможные дополнительные вопросы интервьюера

Как организовать вложенные транзакции?
Использовать savepoints через tx.Exec("SAVEPOINT sp"), но это сильно зависит от драйвера. Лучше пересмотреть архитектуру.

Чем sql.DB отличается от sql.Conn?
Conn — выделенное соединение, получаемое через db.Conn(ctx). Транзакция же также получает выделенное соединение, но Conn не начинает транзакцию.

Можно ли одновременно запускать несколько Query на одном *sql.Tx?
Только последовательно, потому что драйвер может быть не потокобезопасным. Параллельные запросы в одной транзакции требуют внешней синхронизации.


Как работает пул соединений в database/sql?

Краткий ответ

Пул соединений внутри sql.DB хранит простаивающие соединения в двусвязном списке (freeConn). При запросе (conn) сначала ищется простаивающее, если нет — создаётся новое при недостигнутом maxOpen. При лимите горутина блокируется в очереди ожидания с учётом контекста. Соединения с истёкшим временем жизни или превышающие maxIdle закрываются фоновой горутиной.

Развёрнутый ответ

Конфигурация:

Go
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)

Когда вызывается db.QueryContext, берётся соединение: метод db.conn(ctx). Он ищет в freeConn, проверяет, не истекло ли время жизни или idle. Если подходящего нет, создаёт новое, вызывая connector.Connect. Если число открытых соединений достигло maxOpen, горутина ожидает на условной переменной (db.connRequests map), пока не освободится соединение или не истечёт контекст.

После использования соединение возвращается в пул (putConn), где попадает в freeConn как idle. Фоновая горутина connectionOpener периодически закрывает простаивающие соединения, превышающие maxIdle или maxLifetime.

Под капотом (internals)

  • sql.DB.freeConn — это []*driverConn, не список, а слайс? На самом деле в Go < 1.17 был freeConn как двусвязный список, сейчас реализация на основе container/list? Исходный код (database/sql/sql.go): действительно freeConn — это []*driverConn, мутекс mu, и поиск линейный? Нет, смотря версию. В последней версии используется list из container/list, так как нужна быстрая вставка/удаление.
  • connector реализован драйвером (driver.Connector). При вызове Open без контекста Connect может быть простым.
  • Каждое driverConn содержит driver.Conn, мьютекс для сериализации доступа, временные метки createdAt, lastUsed.
  • db.maxLifetime и maxIdleTime проверяются при взятии из пула и периодически для удаления.

Типичные ошибки кандидатов

  • Не установить maxOpen, что может привести к исчерпанию ресурсов БД. По умолчанию maxOpen не ограничен (0 = unlimited). В продакшене обязательно задавать.
  • Задавать maxIdle больше maxOpen — бессмысленно, лишние idle никогда не создадутся; но ошибки нет, просто избыток.
  • Игнорировать ConnMaxLifetime при использовании прокси/балансировщиков. Старые соединения могут указывать на недоступные узлы. Настройка помогает ротировать.
  • Не проверять ошибки при SetConnMaxLifetime на отрицательное значение — оно воспринимается как 0 (бесконечно).

Возможные дополнительные вопросы интервьюера

Что происходит при превышении maxOpen и истечении контекста?
Горутина удаляется из очереди ожидания, возвращается ошибка ctx.Err().

Как пул обрабатывает ошибки «плохого соединения» (bad connection)?
Когда операция возвращает driver.ErrBadConn, соединение помечается как неисправное и удаляется из пула вместо возврата. Следующий запрос может создать новое.

Можно ли использовать пул для разных баз данных с одним sql.DB?
Нет, sql.DB привязан к одному драйверу и одному DSN.


Как работает os.File?

Краткий ответ

os.File — структура, представляющая открытый файловый дескриптор. Реализует интерфейсы io.Reader, io.Writer, io.Seeker, io.Closer. Операции чтения/записи перенаправляют системные вызовы (read, write, pread, pwrite) с учётом буферизации ОС. Возможно небуферизированное прямое чтение (syscall.O_DIRECT).

Развёрнутый ответ

Открытие: os.Open (только чтение), os.Create (чтение/запись, перезаписывает), os.OpenFile с флагами.

Go
f, err := os.Open("file.txt")
defer f.Close()
data := make([]byte, 1024)
n, err := f.Read(data)

Методы: ReadAt, WriteAt для случайного доступа без изменения позиции. Seek устанавливает смещение.

Под капотом (internals)

  • os.File содержит *file (внутренняя структура), в которой fd int, имя, флаги и *syscall.FD (pollable).
  • Чтение через fd.Read вызывает syscall.Read (или pread для ReadAt) в зависимости от ОС.
  • Поддержка poll.Desc для асинхронных операций с сетевыми дескрипторами (не относится к обычным файлам, кроме специальных).
  • На Linux для больших файлов может использоваться sendfile через io.Copy.
  • os.File блокирует операции; для неблокирующего ввода/вывода файлов нужно использовать syscall.O_NONBLOCK и select/poll через syscall.RawConn, но обычно обычные файлы всегда готовы к I/O.

Типичные ошибки кандидатов

  • Не закрывать файлы. Утечка файловых дескрипторов в долгоживущих приложениях. defer f.Close() не панацея: в циклах может накапливаться до выхода из функции.
  • Обрабатывать только ошибку открытия, игнорировать ошибки чтения/записи. Read может вернуть n < len(buf) и err (например, io.EOF); нужно проверять.
  • Использовать Seek без учёта Whence. Неправильные константы (0,1,2) соответствуют io.SeekStart, io.SeekCurrent, io.SeekEnd.
  • Думать, что запись в файл гарантирует немедленную фиксацию на диске. Запись идёт в кеш ОС; для гарантии нужно вызвать f.Sync().

Возможные дополнительные вопросы интервьюера

Как получить размер файла без чтения?
f.Stat() возвращает FileInfo, откуда берётся Size(). Или os.Stat(name).

Чем отличается os.Open от os.OpenFile?
Open вызывает OpenFile(name, O_RDONLY, 0). OpenFile позволяет задать флаги и права.

Что такое os.File.Fd() и зачем он нужен?
Возвращает целочисленный файловый дескриптор для прямых системных вызовов (например, syscall.Flock). Опасен, так как defer f.Close() закроет его, и ручные вызовы могут повредить.


Как работает os/signal?

Краткий ответ

Пакет os/signal доставляет уведомления о сигналах ОС в канал. signal.Notify(ch, sig...) регистрирует канал; при получении сигнала горутина рантайма отправляет его в канал. signal.Ignore отключает обработку по умолчанию. signal.Reset восстанавливает. Для graceful shutdown используют signal.NotifyContext.

Развёрнутый ответ

Go
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
log.Println("Shutting down...")

Канал должен быть буферизованным (или готовым к чтению), чтобы не потерять сигналы. Рекомендуемый размер: 1 или больше. NotifyContext создаёт контекст, который отменяется при первом сигнале.

Под капотом (internals)

  • Внутренний обработчик сигналов рантайма (os/signal.signal_recv) запускает горутину, которая вызывает signal_recv из пакета runtime для получения сигналов.
  • При signal.Notify создаётся структура handler, которая помещается в глобальный список. Горутина loop в signal_unix.go читает сигналы через signal_recv и отправляет в зарегистрированные каналы. Отправка неблокирующая: если канал заполнен, сигнал теряется, и в лог печатается предупреждение.
  • signal.Ignore отправляет специальную команду рантайму, чтобы он не использовал стандартный обработчик (например, для SIGPIPE).
  • signal.Reset сбрасывает к дефолтному поведению.
  • Рантайм перехватывает сигналы до того, как они попадут в стандартный обработчик, используя sigaction и SA_ONSTACK для гарантии обработки даже при переполнении стека.

Типичные ошибки кандидатов

  • Не буферизировать канал или не читать из него вовремя. Если канал signal.Notify(ch, syscall.SIGINT) небуферизирован и никто не читает в момент прихода сигнала, сигнал теряется, а обработчик по умолчанию может убить процесс.
  • Вызывать signal.Notify несколько раз для одного канала — это приводит к нескольким регистрациям и дублированию сигналов? Нет, если канал тот же, вторая регистрация игнорируется.
  • Не вызывать stop() после signal.NotifyContext. Это приводит к утечке горутины обработчика сигналов. Всегда defer stop().
  • Игнорировать syscall.SIGTERM, ожидая только SIGINT (Ctrl+C). В production-окружениях обычно посылают SIGTERM.

Возможные дополнительные вопросы интервьюера

Как протестировать обработку сигналов?
Послать сигнал процессу через syscall.Kill(syscall.Getpid(), syscall.SIGINT), но это повлияет на весь тестовый процесс. Лучше использовать signal.Notify с каналом и ручной отправкой сигнала в тестовой горутине.

Можно ли перехватить SIGKILL или SIGSTOP?
Нет, эти сигналы не перехватываются. SIGKILL сразу убивает процесс.

Как signal.NotifyContext интегрируется с HTTP-сервером?
ctx от signal.NotifyContext передаётся в srv.Shutdown(ctx), чтобы дать завершиться активным запросам при получении сигнала.


Как работает flag?

Краткий ответ

Пакет flag реализует разбор аргументов командной строки в стиле POSIX/GNU. Определяются переменные с помощью flag.String, flag.Int и т.д., затем вызывается flag.Parse. Поддерживаются флаги с дефисами, двойными дефисами, -flag value, -flag=value, булевы флаги -flag. Нестандартные аргументы размещаются в flag.Args().

Развёрнутый ответ

Go
var port int
flag.IntVar(&port, "port", 8080, "server port")
flag.Parse()
fmt.Println("Port:", port)

Перед Parse должны быть объявлены все флаги. После Parse флаги доступны по значениям. Порядок флагов не важен. Если указан неизвестный флаг, программа паникует (или можно установить flag.CommandLine.SetOutput и ловить ошибку). Также есть flag.FlagSet для создания подкоманд.

Под капотом (internals)

  • flag хранит карту formal map[string]*Flag.
  • Parse перебирает os.Args[1:]. Если аргумент начинается с - или --, он считается флагом. Ищется в formal, затем парсится значение.
  • Булевы флаги могут идти без значения.
  • Ошибки разбора записываются в flag.CommandLine.Output() и вызывают os.Exit(2) или панику в тестах.
  • В Go 1.18+ функция flag.Func позволяет задать произвольный тип со своей логикой парсинга.

Типичные ошибки кандидатов

  • Вызывать flag.Parse до объявления всех флагов. Флаги, объявленные позже, не будут распознаны; лучше объявить в init().
  • Путать flag.String (возвращает указатель) и flag.StringVar (принимает указатель). Часто новички пишут flag.String("name", "default", "usage") и забывают разыменовать.
  • Использовать flag.Parse() в тестах без сброса состояния. Глобальный flag.CommandLine может быть уже разобран; нужно либо сбрасывать, либо использовать локальный FlagSet.
  • Игнорировать flag.Args() для позиционных аргументов. Позиционные аргументы доступны после вызова Parse.

Возможные дополнительные вопросы интервьюера

Как реализовать подкоманды как в go build?
Создать flag.NewFlagSet с именем подкоманды, вызвать flagSet.Parse(os.Args[2:]) после проверки первого аргумента.

Как задать обязательный флаг?
Пакет flag не поддерживает обязательность напрямую. Проверять, был ли изменён флаг, сравнив со значением по умолчанию (или с помощью flag.Visit).

Можно ли использовать flag с конфигурационными файлами?
Можно комбинировать: сначала flag.Parse, затем применить файл конфигурации, переопределяя значения флагов.


Как работает context?

Краткий ответ

Контекст — иерархическая структура для передачи крайних сроков, отмены и scope-данных через API. context.Context предоставляет методы Deadline(), Done(), Err(), Value(). Создаются контексты через Background(), WithCancel, WithDeadline, WithTimeout, WithValue. Отмена родителя распространяется на всех потомков. Главный принцип: передавать контекст первым аргументом.

Развёрнутый ответ

Go
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
defer cancel()
select {
case <-ctx.Done():
    return ctx.Err()
case result := <-doWork(ctx):
}

context.WithValue хранит пары ключ-значение, рекомендуемый ключ — неэкспортируемый тип. Контекст неизменяем; WithValue возвращает новый контекст, оборачивающий родителя.

Под капотом (internals)

  • context.Background() и TODO() возвращают пустой контекст background (глобальная переменная background типа emptyCtx), который никогда не отменяется.
  • WithCancel возвращает cancelCtx, содержащий родителя, done канал (закрытый при отмене), мьютекс и список дочерних canceller. При вызове cancel() закрывается канал done, затем рекурсивно отменяются дочерние.
  • WithDeadline оборачивается в timerCtx, который использует time.Timer для автоматического вызова cancel по истечении срока. Таймер очищается при отмене родителя.
  • WithValue создаёт valueCtx с полями key, val.
  • Проверка ctx.Done() возвращает канал, который закрыт при отмене; горутины могут одновременно ждать.

Типичные ошибки кандидатов

  • Хранить контекст в структуре долгосрочно. Контексты должны передаваться аргументами, а не храниться как поле, если только это не http.Request с контекстом. Нарушает идиому «передавать контекст по цепочке вызовов».
  • Забывать вызвать cancel при создании WithCancel, WithTimeout. Утечка горутин и таймеров. Обязательно defer cancel().
  • Использовать строки в качестве ключа в WithValue. Риск коллизий между пакетами. Лучше создать свой тип type myKey struct{}.
  • Передавать контекст как nil (прямо или неявно). Принимающая сторона должна ожидать корректный контекст; nil приводит к панике. Если контекст не нужен, передавать context.TODO().

Возможные дополнительные вопросы интервьюера

Как реализовать собственный контекст с таймаутом, не используя стандартный?
Нужно аккуратно имплементировать Context интерфейс, управлять каналом Done. Но проще использовать готовый, оборачивая.

Может ли WithValue повлиять на производительность?
Цепочки контекстов могут стать глубокими; поиск Value идёт вверх по цепочке. В горячих путях лучше избегать излишних WithValue. Использовать локальные структуры.

Почему context не имеет метода Cause для ошибки?
С Go 1.20 Cause добавлен: context.Cause(ctx) возвращает причину отмены, переданную в WithCancelCause.


Как работает sync пакет?

Краткий ответ

sync предоставляет примитивы синхронизации: Mutex, RWMutex, WaitGroup, Cond, Once, Pool, Map. Они реализуют блокировки, ожидание событий, одноразовое выполнение и конкурентные структуры. Внутренне используют атомарные операции и early-спиннинг перед парковкой в планировщике.

Развёрнутый ответ

Основные примитивы:

  • Mutex: эксклюзивная блокировка, Lock/Unlock. Не реентерабельный.
  • RWMutex: разделяемая/эксклюзивная. RLock/RUnlock, Lock.
  • WaitGroup: ожидание группы горутин. Add, Done, Wait.
  • Once: гарантирует однократное выполнение функции, даже при конкурентных вызовах.
  • Cond: условная переменная на основе Locker, редкая необходимость.
  • Pool: временный пул объектов для снижения аллокаций. Не подходит для долгосрочного кэширования.
  • Map: конкурентная map, оптимизированная для стабильных ключей (в основном читаемых).

Под капотом (internals)

  • Mutex:
    • Внутри состояние (state) — 32 бита: заблокирован, разбужен, голодание, количество ожидающих. При Lock использован fast path (атомарный CAS). При конкуренции горутина крутится (spin) несколько тактов, затем вызывает runtime_SemacquireMutex, которая паркует горутину в очередь ожидания.
    • Unlock атомарно снимает флаг, при наличии ожидающих вызывает runtime_Semrelease.
    • «Голодающий» режим предотвращает бесконечное ожидание; после 1 мс переключается в режим голодания.
  • RWMutex: аналогично, с разделением читающих/пишущих через атомарный счётчик.
  • WaitGroup: внутри state1 — массив из трёх uint32: счётчик, количество ждущих, семафор. Add атомарно изменяет счётчик. Wait при ненулевом счётчике увеличивает ждущих и вызывает semacquire.
  • Once: флаг done (uint32) и мьютекс. Быстрый путь: атомарное чтение done. Если 0, захватывается мьютекс, двойная проверка, вызов f, установка done=1.
  • Pool: New функция. Внутри — per-P локальный пул и shared pool. При Get сначала проверяется локальный, при промахе крадёт из shared/других P, иначе вызывает New. Put кладёт в локальный. Объекты в пуле очищаются GC при сборке мусора (между циклами), поэтому не годятся для долгоживущих кешей.
  • Map: гибридная структура: read-only map (атомарный указатель) и dirty map с мьютексом. При большом количестве чтений обходится без блокировок. При записи промотируется dirty.

Типичные ошибки кандидатов

  • Копирование Mutex/WaitGroup после использования. Они не должны копироваться после первого использования; копия даст независимое состояние, гонки и дедлоки. Передавайте только по указателю.
  • Повторный Unlock или Unlock без Lock. Паника sync: unlock of unlocked mutex. Используйте defer mu.Unlock() сразу после Lock.
  • WaitGroup.Add после Wait. Возникает гонка; Add должен вызываться до того, как горутина, вызывающая Wait, продолжит, либо внутри горутин до Done. Правило: Add до запуска горутин.
  • Использование sync.Map всегда как замену map + Mutex. sync.Map эффективен только при преобладании чтений над записями и стабильных ключах. В иных случаях обычная map с мьютексом быстрее.
  • Утечки в Pool из-за сохранения ссылок на объекты, которые не возвращаются в пул с обнулением полей. Нужно обнулять перед Put.

Возможные дополнительные вопросы интервьюера

Как реализовать реентерабельный мьютекс?
Go осознанно не имеет реентерабельных мьютексов; их можно эмулировать через sync.Mutex + идентификатор горутины, но это не идиоматично. Обычно перепроектируют код.

Как RWMutex защищает от писательской голодовки?
При ожидании писателя новые читатели блокируются, давая приоритет писателю. Это предотвращает бесконечное накопление читателей.

Что происходит с объектами в sync.Pool при GC?
Все объекты, не возвращённые в пул, могут быть собраны; при следующей сборке мусора victim кеш очищается. Пул — временное хранилище, не кеш.


Как работает sort?

Краткий ответ

Пакет sort предоставляет сортировку срезов через sort.Slice и sort.SliceStable, а также интерфейсы для пользовательских коллекций (sort.Interface). Алгоритм — интроспективная сортировка (комбинация быстрой, пирамидальной и вставками). Стандартная сортировка не стабильна; для стабильности используется SliceStable или sort.Stable.

Развёрнутый ответ

Go
people := []Person{{"Bob", 30}, {"Alice", 25}}
sort.Slice(people, func(i, j int) bool { return people[i].Name < people[j].Name })
// стабильная
sort.SliceStable(people, func(i, j int) bool { return people[i].Age < people[j].Age })

Для сортировки коллекций, реализующих sort.Interface (Len, Less, Swap), можно использовать sort.Sort. Встроенные типы: sort.Ints, sort.Strings, sort.Float64s.

Под капотом (internals)

  • sort.Sort(data) вызывает quickSort(data, 0, data.Len(), maxDepth(data.Len())) — быстрая сортировка с выбором медианы по трём элементам. Если глубина рекурсии превышает maxDepth (≈ 2*log2(n)), переключается на пирамидальную сортировку (heapSort), гарантируя O(n log n) в худшем случае. Для коротких срезов (≤12) применяется сортировка вставками.
  • SliceStable использует адаптивную сортировку слиянием (stable), эффективную для уже частично упорядоченных данных, с линейной сложностью на отсортированных данных.
  • В Go 1.22 в sort добавили generic-версии slices.Sort в пакете slices, но sort остаётся.

Типичные ошибки кандидатов

  • Неправильная функция Less, нарушающая транзитивность или антисимметричность. Это приводит к панике, бесконечному циклу или неверному порядку. Less должен быть строгим частичным порядком.
  • Изменять слайс во время сортировки. Гонка, но в однопоточной программе тоже поведение неопределённое.
  • Полагаться на стабильность sort.Sort. Она не гарантирует стабильность; для стабильности использовать sort.Stable или SliceStable.
  • Забыть о том, что sort.Slice по-умолчанию не стабильна и для структур с одинаковыми ключами может менять порядок. Это важно для многоуровневой сортировки: сначала стабильная по второму ключу, потом нестабильная по первому (порядок второго сохранится). Но SliceStable всегда сохраняет.

Возможные дополнительные вопросы интервьюера

Как отсортировать map по ключам?
Извлечь ключи в слайс, отсортировать, итерироваться по слайсу и получать значения из map.

Какая сложность sort.Slice?
O(n log n) в среднем и худшем, память O(log n) для стека.

Чем отличается sort.Slice и sort.SliceStable внутренне?
Slice использует quicksort/интроспективную; SliceStable — стабильную сортировку слиянием.


Как работает sort.Slice и sort.SliceStable?

Краткий ответ

sort.Slice сортирует срез, используя функцию сравнения, но не гарантирует стабильность. sort.SliceStable сохраняет исходный порядок элементов с равными ключами. Обе функции опираются на рефлексию для обмена элементами, что порождает аллокацию interface{}, но при сравнении вызывается предоставленная функция less.

Развёрнутый ответ

Go
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
sort.SliceStable(s, func(i, j int) bool { return s[i].Field < s[j].Field })

Обе функции принимают срез как interface{} и замыкание. Создаётся вспомогательная структура sort.Interface через reflect.Swapper.

Под капотом (internals)

  • Slice получает срез через any, извлекает reflect.Value, строит swap через reflect.Swapper, возвращая функцию, которая меняет элементы местами.
  • Затем создаётся sort.Interface с Len, Less (замыкание), Swap. Вызывает quickSort или stable.
  • Из-за использования reflect.Swapper, каждая операция обмена вызывает рефлексивные вызовы (v.Index(i).Set(vj)), что несколько медленнее прямых операций над конкретными типами. В slices.Sort (Go 1.21) всё компилируется без рефлексии благодаря дженерикам.
  • SliceStable вызывает stable(data, data.Len()), где data — враппер над sort.Interface, используется стабильная сортировка слиянием.

Типичные ошибки кандидатов

  • Сравнение, изменяющее срез или имеющее побочные эффекты. Функция less вызывается многократно во время сортировки; побочные эффекты приведут к гонкам или повреждению данных.
  • Опечатки в less, путающее порядок аргументов. Например, return s[j] < s[i] вместо s[i] < s[j] инвертирует сортировку. Проверять модульными тестами.
  • Использование SliceStable без необходимости, где производительность критична. Стабильная сортировка медленнее; если стабильность не нужна, применять Slice.
  • Полагать, что sort.Slice работает с nil-срезом. Работает; Len для nil = 0, сортировка корректна.

Возможные дополнительные вопросы интервьюера

Как избежать накладных расходов reflect.Swapper?
Использовать slices.SortFunc из golang.org/x/exp/slices или стандартного пакета slices в Go 1.21+, где дженерики исключают рефлексию.

Можно ли сортировать срез с помощью sort.Slice и параллельной горутиной?
Нет, это небезопасно. Нужны блокировки или предварительное клонирование.

Почему sort.SliceStable гарантирует стабильность, а sort.Slice нет?
Потому что использует другой алгоритм: stable merge sort, который сохраняет относительный порядок равных элементов.


Как работает crypto/tls?

Краткий ответ

crypto/tls реализует протоколы TLS 1.2 и 1.3, предоставляя tls.Config для настройки сертификатов, проверки пиров, сессионных билетов. И сервер (tls.Listen), и клиент (tls.Dial) используют net.Conn с установкой безопасного канала. Пакет управляет рукопожатием, шифрованием, аутентификацией и сессиями.

Развёрнутый ответ

Go
// Сервер
cert, _ := tls.LoadX509KeyPair("cert.pem", "key.pem")
config := &tls.Config{Certificates: []tls.Certificate{cert}}
ln, _ := tls.Listen("tcp", ":443", config)
// Клиент
conn, _ := tls.Dial("tcp", "example.com:443", &tls.Config{InsecureSkipVerify: false})

Поддерживает взаимную TLS аутентификацию, ClientHello-фильтрацию, выбор кривых, ALPN (HTTP/2, gRPC). В Go 1.22 улучшена поддержка ECH (Encrypted Client Hello).

Под капотом (internals)

  • tls.Listen принимает TCP-соединения, для каждого запускает serverHandshake, обменивается сообщениями ClientHello/ServerHello, устанавливает сессионные ключи после обмена ключами (ECDHE, DHE).
  • Conn внутри хранит net.Conn, буферы записи/чтения, состояние записи и чтения с отдельными шифрами, MAC.
  • TLS 1.3 рукопожатие упрощено до 1-RTT, использует PSK и 0-RTT (ранние данные). Ключ согласовывается по ECDHE.
  • Проверка сертификатов: на клиенте по умолчанию сверяет цепочку с системным пулом корневых сертификатов, проверяет срок действия и соответствие имени хоста (SAN). Можно кастомизировать VerifyPeerCertificate.
  • Возобновление сессий: TLS 1.2 использует session tickets (шифруются серверным ключом), TLS 1.3 — PSK.
  • Криптографические операции используют crypto/aes, crypto/ecdsa, crypto/x509, crypto/elliptic. Ключи хранятся в памяти.

Типичные ошибки кандидатов

  • Установка InsecureSkipVerify: true без необходимости. Это отключает проверку сертификата, делая клиент уязвимым к MITM. Использовать только для тестов или с кастомной верификацией.
  • Забыть задать MinVersion и оставить TLS 1.0 включённым. По умолчанию MinVersion = TLS 1.2 с Go 1.18? В Go 1.18+ дефолт 1.2. Но лучше явно задать tls.VersionTLS12 или tls.VersionTLS13.
  • Переиспользование tls.Config без Clone(). Config содержит состояние (например, session ticket key), безопасно клонировать.
  • Не настроить ServerName в клиентском соединении, когда IP используется. При прямом IP-адресе проверка имени хоста не работает; нужно указать ServerName или отключать проверку с осторожностью.

Возможные дополнительные вопросы интервьюера

Что такое ALPN и зачем он нужен?
Application-Layer Protocol Negotiation — позволяет согласовать протокол (h2, http/1.1, gRPC) во время рукопожатия без дополнительных round-trips. Задаётся в tls.Config.NextProtos.

Как защищена память ключей?
Стандартно ключи хранятся в Go-памяти, могут попасть в swap. Для защиты можно использовать аппаратные модули (HSM) через crypto.Signer, но не встроено.

Как работает ротация сессионных ключей в TLS 1.2?
Сервер периодически меняет ключ SessionTicketKey через tls.Config.SetSessionTicketKeys. Клиент не замечает смены, старые билеты становятся невалидны.


Как работает net/http/httptest для тестирования?

Краткий ответ

Пакет httptest предоставляет утилиты для тестирования HTTP-обработчиков: httptest.NewServer запускает реальный HTTP-сервер на локальном порту, а httptest.NewRequest и httptest.ResponseRecorder позволяют тестировать обработчики без сети, захватывая ответ.

Развёрнутый ответ

Go
// Модульный тест обработчика
handler := http.HandlerFunc(myHandler)
req := httptest.NewRequest("GET", "/foo", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
// Проверяем status, body, headers

Для интеграционных тестов:

Go
ts := httptest.NewServer(mux)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/path")

NewTLSServer для HTTPS.

Под капотом (internals)

  • ResponseRecorder реализует http.ResponseWriter, сохраняя статус, заголовки, тело во внутренний bytes.Buffer. Result() возвращает сконструированный http.Response. Поддерживает http.Flusher, http.Pusher.
  • NewServer создаёт httptest.Server, который в фоне слушает на 127.0.0.1:0, получая случайный порт. URL возвращает http://127.0.0.1:<port>. При Close сервер останавливается.
  • Сервер использует net.Listen и реальную сеть, что может быть медленно по сравнению с рекордером, но позволяет тестировать клиентскую логику.

Типичные ошибки кандидатов

  • Забыть вызвать resp.Body.Close() после чтения из w.Result(). Хотя тело в рекордере — просто bytes.Buffer, http.Response требует закрытия для освобождения ресурсов.
  • Проверять тело до вызова w.Result(). Данные финализируются при Result(), после него w.Body становится недоступным для чтения? Нет, Body читается из рекордера до вызова Result()? Лучше получать тело из resp.Body.
  • Использовать httptest.NewServer в тестах, где достаточно ResponseRecorder, что замедляет тесты из-за реального сетевого стека.
  • Не устанавливать Content-Type в запросе при тестировании поведения разбора. NewRequest по умолчанию application/octet-stream. Для JSON нужно req.Header.Set("Content-Type", "application/json").

Возможные дополнительные вопросы интервьюера

Как протестировать WebSocket с httptest?
Нужно использовать net/http/httptest.NewServer и подключаться к нему WebSocket-клиентом, потому что ResponseRecorder не поддерживает hijack.

Можно ли создать сервер с TLS без реальных сертификатов?
httptest.NewTLSServer генерирует самоподписанный сертификат, клиент должен использовать http.Client с InsecureSkipVerify или кастомным транспортом, доверяющим сертификату сервера.

Как симулировать медленное соединение?
Обернуть net.Listener сервера с помощью netutil.LimitListener или использовать slowConn обёртку.


Как работает text/template и html/template?

Краткий ответ

Оба пакета — системы шаблонизации на основе данных. text/template генерирует произвольный текстовый вывод, выполняя простые подстановки и управляющие конструкции. html/template оборачивает text/template с автоматическим контекстным экранированием для безопасной генерации HTML, предотвращая XSS.

Развёрнутый ответ

Go
tmpl := template.Must(template.New("test").Parse("Hello, {{.Name}}"))
tmpl.Execute(os.Stdout, map[string]string{"Name": "World"})

Поддерживает действия: {{if}}, {{range}}, {{with}}, {{template}}, {{block}}, вызов функций. Контекст в html/template понимает, где выводится значение — в HTML-теле, атрибуте, JavaScript, CSS, URL — и применяет соответствующее экранирование (html.EscapeString, js.EscapeString, url.QueryEscape).

Под капотом (internals)

  • Парсинг шаблона строит дерево узлов (parse.Tree). Execute обходит дерево, рефлексивно извлекая данные из структуры.
  • Контекстное экранирование в html/template реализовано через анализ графа переходов состояний (HTML, JS, CSS, URL). При компиляции шаблона строится цепочка контекстов, вставляются необходимые функции экранирования.
  • Данные в шаблон передаются через reflect.Value, поддерживаются каналы, вызовы методов.
  • Must паникует при ошибке парсинга, удобно для глобальных переменных.

Типичные ошибки кандидатов

  • Забыть про экранирование в text/template при генерации HTML. Это приводит к XSS. Всё, что отдаётся в веб, должно использовать html/template.
  • Использовать html/template со сложной логикой, которая изменяет порядок вывода и ломает контекст. Несмотря на умность, пакет может ошибаться с определением контекста; лучше вынести логику из шаблона.
  • Вызывать функции, которые возвращают шаблонный размет (HTML) без маркировки типа template.HTML. В html/template строки экранируются, если явно не указан safe-type. Для безопасной вставки проверенного HTML используйте template.HTML.
  • Передавать nil в Execute как данные. Если шаблон ожидает поля, получится nil dereference; нужно передавать пустую структуру.

Возможные дополнительные вопросы интервьюера

Как html/template определяет, что значение является URL, и экранирует его?
По контексту: если значение выводится внутри атрибута href или src, оно подвергается URL-экранированию, гарантируя корректный синтаксис и отсутствие javascript:.

Чем {{.}} отличается от {{$.}}?
. — курсор; $ — корневой объект данных, переданный в Execute. Полезно при вложенных range.

Как реализовать пользовательские функции в шаблоне?
Через template.FuncMap и tmpl.Funcs(funcMap).


Как предотвратить XSS с помощью html/template?

Краткий ответ

html/template автоматически экранирует все подставляемые значения в соответствии с контекстом (HTML, атрибут, JS, CSS, URL). Достаточно всегда использовать html/template вместо text/template. Для явной вставки безопасного HTML использовать тип template.HTML, но только после тщательной санитизации контента.

Развёрнутый ответ

Правильный подход: все HTML-страницы рендерить через html/template. Пример:

Go
var tmpl = template.Must(template.New("page").Parse(`<a href="/user?name={{.Name}}">Profile</a>`))
// Name = "John" → href="/user?name=John" (авто URL-экранирование)

Если злоумышленник передаст Name = "javascript:alert(1)", html/template заметит контекст URL и заменит на безопасный #ZgotmplZ, или экранирует пробелы, предотвращая выполнение JavaScript. В контексте JavaScript-строки автоматически применяется \xHH-кодирование.

Важно: если вы используете template.HTML(value) для вставки сырого HTML, это эквивалентно доверительному пометке, и никакого экранирования не будет. Использовать только после применения библиотек санитизации (например, bluemonday).

Под капотом (internals)

  • Пакет строит «контекстную цепочку» при компиляции шаблона и вставляет экранирующие функции (htmlEscaper, urlEscaper, jsStrEscaper и др.).
  • В случае потенциально опасных переходов (example: URL с javascript:) возвращается template.HTML("") с кодом ошибки, но с Go 1.13+ это приводит к замене на #ZgotmplZ.
  • При использовании template.HTML экранирование пропускается. Так же работают template.JS, template.CSS, template.URL.

Типичные ошибки кандидатов

  • Использовать text/template для HTML. Это неминуемо приводит к XSS, если данные не экранируются вручную. Всегда html/template.
  • Помечать пользовательские данные как template.HTML без санитизации. Это открывает XSS. Нельзя доверять вводу.
  • Не учитывать, что html/template может изменить структуру документа при неверном контексте. Например, {{.}} в атрибуте без кавычек может привести к тому, что злоумышленник добавит атрибуты; защита: всегда заключать в кавычки.

Возможные дополнительные вопросы интервьюера

Может ли html/template полностью защитить от DOM XSS?
Нет, если JavaScript динамически вставляет данные в DOM без экранирования. Шаблонизатор защищает серверный рендеринг, но не клиентский код.

Как обрабатывать JSON, встроенный в <script>?
Использовать template.JS для внедрения, но предварительно сериализовать через json.Marshal и экранировать символы </ и ]]>, чтобы не сломать HTML/XML.

Что такое контекстная автоэкранировка и когда она может ошибаться?
Это способность пакета определять, в каком контексте выводится значение, и применять экранирование. Ошибки возможны при сложных запутанных шаблонах, где пакет неверно определяет экранирование, но это редко.


Как работает reflect?

Краткий ответ

Пакет reflect предоставляет возможность исследовать типы и значения во время выполнения: получать информацию о полях структуры, методах, тегах, а также динамически вызывать функции и изменять переменные. Ключевые типы — reflect.Type (описание типа) и reflect.Value (конкретное значение). Реализован через метаданные, сохраняемые компилятором для каждого типа.

Развёрнутый ответ

Go
v := reflect.ValueOf(x)
t := v.Type()
for i := 0; i < t.NumField(); i++ {
    field := t.Field(i)
    val := v.Field(i).Interface()
}

Можно создавать новые значения: reflect.New, reflect.MakeSlice. Устанавливать значения через v.Set(reflect.ValueOf(42)) при условии, что значение адресуемо (указатель). reflect.Indirect разыменовывает указатели.

Под капотом (internals)

  • Каждый Go-тип имеет дескриптор времени выполнения (runtime._type). reflect.Type — обёртка над *rtype. reflect.Value — тройка typ *rtype, ptr unsafe.Pointer, flag (флаги).
  • Компилятор генерирует метаданные для всех типов, используемых в пакете (и тех, на которые есть ссылки через рефлексию). В них хранится размер, alignment, имена полей, offsets, карта методов.
  • При ValueOf происходит escape analysis: если значение может быть изменено или передано через интерфейс, оно аллоцируется на куче. Для маленьких значений может быть скопировано.
  • Interface() превращает Value обратно в interface{}.
  • Рефлексия нарушает безопасность типов; некорректный вызов приводит к панике.

Типичные ошибки кандидатов

  • Пытаться изменить неадресуемое значение. v := reflect.ValueOf(x); v.SetInt(5) паникует. Нужно reflect.ValueOf(&x).Elem().SetInt(5).
  • Использовать Interface() на нулевом Value. Паника. Проверять v.IsValid().
  • Полагать, что рефлексия быстра. Она на порядок медленнее прямого доступа. Не использовать в горячих путях.
  • Не учитывать, что reflect.DeepEqual сравнивает неэкспортированные поля, что может привести к неожиданностям при сравнении структур с мьютексами. Использовать с осторожностью.

Возможные дополнительные вопросы интервьюера

Как получить все методы типа через рефлексию?
t.NumMethod() и t.Method(i) дают reflect.Method, содержащий имя и Func.

Как рефлексия обрабатывает интерфейсы?
ValueOf принимает interface{}. Если передать конкретное значение, будет его конкретный тип. Чтобы получить информацию о интерфейсном типе, нужно использовать reflect.TypeOf((*io.Reader)(nil)).Elem().

Чем reflect.TypeOf отличается от reflect.ValueOf?
TypeOf возвращает только метаданные типа, ValueOf — конкретное значение и тип.


Когда стоит избегать reflect?

Краткий ответ

Следует избегать reflect, когда критичны производительность, безопасность типов на этапе компиляции и читаемость. Он лишает статической проверки, затрудняет отладку и рефакторинг. Используйте только при обоснованной необходимости: универсальные библиотеки (JSON, ORM), где невозможно обойтись кодогенерацией.

Развёрнутый ответ

Красные флаги:

  • Циклы с высокой частотой вызовов, где reflect вызывает значительные накладные расходы.
  • Логика, которую можно выразить через интерфейсы или дженерики (Go 1.18+).
  • Потребность в строгой типизации для предотвращения ошибок.

Примеры альтернатив: дженерики для обобщённых алгоритмов, интерфейсы для полиморфизма, кодогенерация (например, go generate, stringer) для перечислений.

Под капотом (internals)

  • Каждый вызов reflect.Value требует escape анализа и потенциальной аллокации; доступ к полям через Index/Field не встраивается.
  • Сравнение производительности: прямой доступ к полю ~0.3 нс, через рефлексию ~50-100 нс.
  • Рефлексия лишает компилятор возможности инлайнинга и оптимизаций dead code elimination.

Типичные ошибки кандидатов

  • Использовать reflect для сортировки или простых операций, которые можно сделать через интерфейсы/дженерики. Результат: замедление в десятки раз.
  • Писать «фреймворк» на рефлексии с динамической диспетчеризацией, не имея на то веских причин. Это усложняет поддержку и дебаггинг.
  • Злоупотреблять reflect.DeepEqual в тестах без понимания его ограничений (неэкспортированные поля).
  • Пытаться обойти ограничения Go через reflect (создание невалидных типов), что может привести к неопределённому поведению.

Возможные дополнительные вопросы интервьюера

В каких местах стандартной библиотеки оправдано применение рефлексии?
encoding/json, fmt, sort.Slice, text/template, database/sql (сканирование). Везде, где данные произвольного типа должны быть обработаны обобщённо.

Как дженерики снижают потребность в рефлексии?
Они позволяют писать типобезопасный обобщённый код без interface{} и рефлексии, компилятор генерирует специализированный код.

Можно ли обойтись без рефлексии для сериализации?
Да, через кодогенерацию (например, ffjson, easyjson), что даёт большую производительность, чем рефлексия.


Как работает unsafe?

Краткий ответ

Пакет unsafe даёт обход системы типов Go: указатели произвольного преобразования (unsafe.Pointer), арифметику указателей (через uintptr), доступ к полям по смещению (unsafe.Offsetof), и создание макетов памяти. Компилятор не проверяет безопасность; некорректное использование приводит к порче памяти, нарушению сборщика мусора и неопределённому поведению.

Развёрнутый ответ

Ключевые элементы:

  • unsafe.Pointer — обобщённый указатель, может быть конвертирован в любой указатель и обратно.
  • uintptr — целое число для арифметики; не защищает от GC.
  • unsafe.Sizeof, unsafe.Offsetof, unsafe.Alignof — получение информации о памяти.
  • Допустимые паттерны (по документации): (1) конвертация *T1 в *T2 при одинаковом layout; (2) преобразование pointer в uintptr и обратно — только внутри одного выражения, иначе GC может переместить объект; (3) системные вызовы (через syscall.Syscall); (4) доступ к полям через unsafe.Add, unsafe.Slice.

Под капотом (internals)

  • unsafe.Pointer — это любой Go-указатель, игнорирующий типизацию. В некоторых реализациях компилятора это просто *void.
  • Арифметика указателей через unsafe.Add(ptr, len) (Go 1.17+), которая добавляет смещение в байтах.
  • GC рассматривает unsafe.Pointer как живой указатель (если он указывает в кучу). Когда он преобразован в uintptr, GC не отслеживает и объект может быть перемещён/собран.
  • Строгие правила: uintptr не должен храниться в переменной дольше выражения, которое включает преобразование в unsafe.Pointer.

Типичные ошибки кандидатов

  • Хранение uintptr в переменной и последующее преобразование в указатель. Между этими событиями GC может переместить объект, указатель станет невалидным. Следует выполнять преобразование в одном выражении.
  • Преобразование произвольных указателей в unsafe.Pointer и обратно без гарантий совместимости памяти. Это может нарушить alignment или strict aliasing.
  • Использование unsafe.Pointer для обхода экспорта полей структуры. Неэкспортированные поля могут менять смещение между версиями, ведёт к хрупкости.
  • Создание unsafe.Slice с неправильным размером/вместимостью, порождая повреждение памяти.

Возможные дополнительные вопросы интервьюера

Как безопасно преобразовать []byte в string без копирования?
Используя unsafe.SliceData и unsafe.StringData (Go 1.20+), например unsafe.String(&b[0], len(b)). Но модификация байтов после этого неопределена, так как строка immutable.

Почему не рекомендуется использовать unsafe в продакшене?
Потому что код становится зависимым от деталей реализации компилятора, может сломаться при обновлении версии Go, и обходит проверки безопасности памяти.

Какие новые функции появились в Go 1.17+ для уменьшения необходимости unsafe?
unsafe.Add, unsafe.Slice — стандартизированная арифметика, менее подверженная ошибкам, чем ручные uintptr манипуляции.


Как работает syscall?

Краткий ответ

Пакет syscall предоставляет низкоуровневый интерфейс к системным вызовам ОС. Экспортирует функции, такие как syscall.Open, syscall.Read, syscall.Write, syscall.Socket, syscall.Bind. Использует uintptr для указателей и номеров дескрипторов. Служит основой для os, net. В сборочных целях для Windows используется golang.org/x/sys/windows.

Развёрнутый ответ

Go
fd, err := syscall.Open("file.txt", syscall.O_RDONLY, 0)
buf := make([]byte, 1024)
n, err := syscall.Read(fd, buf)
syscall.Close(fd)

Пакет платформозависим; под капотом на Linux вызывает Syscall6, Syscall, реализованные на ассемблере (или через runtime.syscall). Сигналы, epoll, poll, termios, ptrace — всё доступно.

Под капотом (internals)

  • Каждая функция, например Open, вызывает Syscall с номером syscall (SYS_openat), передавая аргументы через регистры. На amd64: syscall.Syscall делает MOVQ аргументов и SYSCALL.
  • syscall также предоставляет RawSyscall для минимального вызова без проверки сигналов.
  • Внутренности: syscall.Syscall вызывает runtime.syscall_syscall, который обрабатывает прерывания и возвращает ошибку.
  • syscall.Mmap, syscall.Munmap используют прямые системные вызовы.

Типичные ошибки кандидатов

  • Неправильное использование uintptr для указателей в Syscall. Передача uintptr(buf) безопасна только если buf не перемещается во время вызова; syscall гарантирует, что GC не срабатывает во время блокирующего syscall? На самом деле syscall в Go может привести к зависанию GC, но нужно гарантировать, что срез buf достижим на протяжении вызова.
  • Забыть закрыть файловый дескриптор после syscall.Open. Утечка дескрипторов.
  • Использовать syscall напрямую вместо кроссплатформенных обёрток os, теряя переносимость.

Возможные дополнительные вопросы интервьюера

Почему syscall считается замороженным?
Из-за платформозависимости и сложности поддержки. Рекомендуется использовать golang.org/x/sys или стандартные os/net.

Как сделать неблокирующий accept?
Через syscall.Accept4(fd, syscall.SOCK_NONBLOCK). Но проще использовать net.

Как syscall взаимодействует с планировщиком?
При блокирующем системном вызове горутина отпускает P (процессор), и планировщик запускает другую горутину. Это позволяет переиспользовать потоки.


Как работает os/exec и как безопасно выполнять внешние команды?

Краткий ответ

Пакет os/exec запускает внешние команды, настраивая Cmd с аргументами, переменными окружения, stdin/stdout/stderr. Безопасность: избегать sh -c с пользовательским вводом, всегда передавать аргументы отдельно (не через строку команды), очищать окружение при необходимости, использовать контекст для таймаута и отмены.

Развёрнутый ответ

Go
cmd := exec.CommandContext(ctx, "ls", "-l", "/tmp")
cmd.Stdout = os.Stdout
err := cmd.Run()

Команда запускается через cmd.Start(), ожидается cmd.Wait(). CombinedOutput захватывает stdout+stderr. Для ограничения времени: cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }.

Безопасность:

  • Не передавать пользовательский ввод напрямую в оболочку: Command("sh", "-c", "echo " + userInput) — возможность инъекции. Всегда используйте Command(name, arg...) без оболочки.
  • Не запускать команды с sudo без крайней необходимости.
  • Устанавливать Path к бинарнику явно, если важно.
  • Очищать Env и добавлять только необходимое: cmd.Env = append(os.Environ(), "FOO=bar").

Под капотом (internals)

  • Cmd.Start создаёт процесс через os.StartProcess, который использует syscall.ForkExec (Unix) или CreateProcess (Windows). Файловые дескрипторы stdin/stdout/stderr дублируются через syscall.Dup2.
  • Контекстный вызов регистрирует горутину, ожидающую ctx.Done(), и при отмене вызывает cmd.Cancel (по умолчанию os.Process.Kill).
  • Wait собирает статус завершения, освобождает ресурсы ОС.

Типичные ошибки кандидатов

  • Строить команду через строку с sh -c и интерполяцией. Подвержено инъекциям. Исправление: Command("echo", userInput).
  • Забыть обработать cmd.StdoutPipe до Start, а потом повесить на чтение, приводя к дедлоку.
  • Не вызвать Wait после Start с пайпами, оставляя зомби-процесс.
  • Игнорировать ошибку cmd.Run(), когда она не nil. Ошибка может быть *exec.ExitError, содержащей код возврата.

Возможные дополнительные вопросы интервьюера

Как безопасно выполнить команду с ограничением доступа к файловой системе?
Использовать syscall.Chroot (привилегии root) или Linux namespaces, но exec сам не изолирует. Лучше контейнеризация (Docker).

Как перенаправить stdout программы в логгер с ограничением размера?
Подключить io.LimitReader к пайпу или использовать cmd.Stdout = limitedWriter.

Как обеспечить, чтобы дочерний процесс не пережил родительский?
Поддерживать связь через неблокирующий контроль; при cmd.Cancel убивать процесс. Но если родитель аварийно завершится, нужно syscall.Pdeathsig (Linux) или Setpgid.


Как работает log и log/slog (Go 1.21+)?

Краткий ответ

log — простой пакет для логирования в io.Writer с префиксом, флагами времени и файла. log/slog (с Go 1.21) предлагает структурированное логирование с уровнями (Debug, Info, Warn, Error), атрибутами (ключ-значение), поддержкой контекстов, пользовательскими обработчиками и эффективной записью без аллокаций через LogAttrs.

Развёрнутый ответ

Традиционный log:

Go
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("Hello")

slog:

Go
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
logger.Info("user login", "user", userID, "ip", remoteAddr)

slog поддерживает structured logging, группировку, уровни, With, LogAttrs для минимизации аллокаций. Handler — интерфейс для произвольного бэкенда (JSON, текст, syslog, и т.д.).

Под капотом (internals)

  • log использует sync.Mutex для сериализации вывода в Writer; форматирует время.
  • slog основан на Handler интерфейсе. Встроенные: JSONHandler, TextHandler. При вызове Info создаётся Record, содержащий время, уровень, сообщение, слайс Attr. Handler.Handle обрабатывает.
  • Оптимизация LogAttrs: передача атрибутов как slog.String, избегая аллокаций интерфейсов.
  • slog может выводить stack trace при вызове WithStack.
  • Извлечение из контекста: можно внедрить Handler с контекстом через slog.WithAttrs.

Типичные ошибки кандидатов

  • Смешивать log и slog в одном проекте, получая несогласованный формат. Лучше мигрировать полностью.
  • Использовать log.Panic и log.Fatal бездумно. Они вызывают os.Exit или панику, останавливая все горутины. Для серверов лучше возвращать ошибки.
  • При использовании slog передавать пары атрибутов нечётным количеством. Это паника: slog.Info("msg", "key") — не хватает значения.
  • Думать, что slog по умолчанию логирует в stderr. По умолчанию slog.SetDefault ставит текстовый обработчик в stderr, но при создании нового логгера нужно передавать Writer.

Возможные дополнительные вопросы интервьюера

Как добавить трассировку в каждый лог через slog?
Использовать middleware-обработчик, который извлекает traceID из контекста и добавляет в атрибуты, или создать Handler-обёртку.

Как slog избегает аллокаций при LogAttrs?
LogAttrs принимает variadic slog.Attr, а не any, благодаря чему не происходит упаковка в интерфейсы и не аллоцируется память.

В чём разница между slog.Info и slog.LogAttrs?
Info принимает any пары (чередующиеся ключи-значения), вызывает упаковку и рефлексию. LogAttrs принимает готовые slog.Attr, что быстрее.


Что такое io/fs и зачем он нужен?

Краткий ответ

Пакет io/fs определяет абстрактную файловую систему как интерфейс fs.FS с методом Open(name string) (fs.File, error). Позволяет работать с файлами независимо от хранилища: OS, embed, zip, память. Сопутствующие интерфейсы: fs.ReadDirFS, fs.StatFS, fs.GlobFS, fs.SubFS. Унифицирует код для тестирования и абстракции.

Развёрнутый ответ

Основной интерфейс:

Go
func readConfig(fsys fs.FS) ([]byte, error) {
    f, err := fsys.Open("config.json")
    if err != nil { return nil, err }
    defer f.Close()
    return io.ReadAll(f)
}

Можно передать os.DirFS("/etc") или embed.FS. fs.WalkDir рекурсивно обходит файлы. fs.ReadFile — удобная обёртка.

Под капотом (internals)

  • fs.FS — минимальный интерфейс. os.DirFS возвращает *os.File как fs.File.
  • embed.FS — встроенная файловая система только для чтения, реализует fs.ReadDirFS, fs.StatFS.
  • fs.WalkDir использует ReadDir и рекурсию.
  • Пакет также предоставляет функции проверки (fs.ValidPath), валидации пути без выхода за корень.

Типичные ошибки кандидатов

  • Пытаться открыть путь с ведущим слэшем (/config.json), что нарушает требования fs.FS (пути не должны начинаться с /). Нужно fs.ValidPath.
  • Использовать fs.FS там, где нужна запись. Интерфейс только для чтения. Для записи нужны другие абстракции.
  • Забыть про закрытие fs.File, особенно при fs.WalkDir, который возвращает файл в callback.
  • Полагать, что fs.ReadDir возвращает отсортированный список. Начиная с Go 1.17, ReadDir сортирует имена. Но собственные реализации fs.FS могут не соблюдать.

Возможные дополнительные вопросы интервьюера

Как протестировать код, использующий fs.FS?
Создать fstest.MapFS с виртуальными файлами: fstest.MapFS{"hello.txt": &fstest.MapFile{Data: []byte("world")}}.

Чем fs.File отличается от os.File?
fs.File — интерфейс (только чтение, Stat, Read, Close). os.File — конкретная реализация, дополнительно поддерживает запись, seek, fd и т.д.

Как embed использует io/fs?
embed.FS реализует fs.ReadDirFS, fs.StatFS, позволяя встроенным файлам быть доступными через стандартные абстракции.


Как работает embed для встраивания файлов?

Краткий ответ

Пакет embed (директива //go:embed) встраивает файлы и директории непосредственно в Go-бинарник во время компиляции, предоставляя доступ к ним через переменные типа embed.FS, string или []byte. Это read-only файловая система, удовлетворяющая io/fs.FS.

Развёрнутый ответ

Go
import _ "embed"

//go:embed templates/*.html
var tmplFS embed.FS

//go:embed config.json
var configData []byte

data, _ := tmplFS.ReadFile("templates/index.html")

Поддерживаются шаблоны glob: *.txt, directory/*.png. Вложенные директории рекурсивно включаются.

Под капотом (internals)

  • Компилятор на этапе сборки читает указанные файлы и упаковывает их в секцию данных бинарного файла (в go:embed добавляет их в runtime-таблицу встроенных файлов).
  • Переменная embed.FS инициализируется указателем на внутренний embedFile с таблицей файлов.
  • embed.FS реализует fs.ReadDirFS, fs.StatFS. Open возвращает io/fs.File, который читает из памяти.
  • Сжатие не применяется; при необходимости сжимают контент до //go:embed (например, compress/gzip).

Типичные ошибки кандидатов

  • Использовать абсолютные пути или .. в шаблонах. embed запрещает пути, выходящие за корень модуля.
  • Попытка использовать //go:embed с переменными, которые не являются string, []byte или embed.FS. Ошибка компиляции.
  • Забыть, что встроенные файлы занимают место в бинарнике. Большие файлы могут значительно увеличить размер.
  • Пытаться писать во встроенную ФС. Она read-only, вызовет панику.

Возможные дополнительные вопросы интервьюера

Можно ли встроить файлы по абсолютному пути вне модуля?
Нет, только относительно директории с исходным кодом.

Как embed работает с бинарным файлом, сжатым через upx?
upx может изменить структуру, embed не рассчитан на это и может сломаться. Лучше не сжимать.

Как проверить размер встроенных данных на этапе компиляции?
Использовать runtime-проверку: len(configData) > maxSize, но компиляционно нельзя. Можно написать тест.


Как работает testing пакет для бенчмарков?

Краткий ответ

Бенчмарки в Go — функции вида func BenchmarkXxx(b *testing.B), запускаемые go test -bench. Инфраструктура testing.B предоставляет таймер, управление циклом (b.N), остановку/запуск таймера, выделение памяти на итерацию (ReportAllocs). Бенчмарки многократно вызываются для получения устойчивых метрик, выводят время на операцию и аллокации.

Развёрнутый ответ

Go
func BenchmarkConcat(b *testing.B) {
    s := "hello"
    for i := 0; i < b.N; i++ {
        _ = s + " world"
    }
}

Фреймворк сам определяет b.N, увеличивая его до стабилизации времени. b.ResetTimer сбрасывает таймер после ресурсоёмкой подготовки. b.StopTimer/StartTimer управляют измерением. b.ReportAllocs включает отчёт о выделениях памяти.

Под капотом (internals)

  • testing.B.Run вызывает бенчмарк в цикле, экспоненциально увеличивая N от 1 до тех пор, пока измеренное время не станет стабильным (по умолчанию >= 1 секунда суммарного времени).
  • Внутренне используется benchTime пакета testing, функция runBenchmark измеряет время с помощью time.Now вокруг вызова функции бенчмарка.
  • Отчёт об аллокациях получается через runtime.ReadMemStats до и после, либо через testing.AllocsPerRun.
  • Параллельные бенчмарки (b.RunParallel) запускают несколько горутин.

Типичные ошибки кандидатов

  • Не учитывать, что b.N может быть 0, если бенчмарк не может завершиться. Избегать бесконечных циклов.
  • Забыть b.ResetTimer после дорогой настройки, что искажает результаты.
  • Использовать b.N для выделения ресурсов внутри цикла, создавая непреднамеренные аллокации. Подготовить данные заранее.
  • Сравнивать бенчмарки на разных машинах без указания -benchtime и -count. Нестабильность окружения; использовать -benchtime=10x для повторений.

Возможные дополнительные вопросы интервьюера

Как измерить количество аллокаций в бенчмарке?
b.ReportAllocs() включает вывод allocs/op. Или testing.AllocsPerRun(100, func(){...}).

Можно ли пропустить бенчмарк при определённых условиях?
b.Skip внутри функции бенчмарка.

Как работает b.RunParallel?
Создаёт pb *testing.PB с методом Next(), горутины запускают параллельно циклы; бенчмарк измеряет общую пропускную способность.


Как работает runtime/debug и debug/elf?

Краткий ответ

runtime/debug предоставляет доступ к информации о сборщике мусора (FreeOSMemory, SetGCPercent), стек-трейсам (PrintStack), управлению максимальными горутинами (SetMaxThreads), и сбору отладочной информации (build info через ReadBuildInfo). debug/elf реализует доступ к ELF-файлам (исполняемые файлы Linux) — чтение заголовков, секций, символов.

Развёрнутый ответ

Go
// runtime/debug
debug.SetGCPercent(100)
debug.FreeOSMemory()
buildInfo, _ := debug.ReadBuildInfo()
fmt.Println(buildInfo.Main.Version)

// debug/elf
f, _ := elf.Open("binary")
defer f.Close()
fmt.Println(f.Machine)

debug/plan9obj, debug/macho, debug/pe — аналоги для других форматов.

Под капотом (internals)

  • debug.ReadBuildInfo читает встроенный модуль runtime.buildVersion и данные модулей из секции .go.buildinfo или таблицы символов.
  • debug.Stack возвращает стек-трейс всех горутин, похоже на профилировщик.
  • debug/elf парсит бинарный ELF напрямую, используя структуры из debug/elf/elf.go. Поддерживает DWARF для отладки.

Типичные ошибки кандидатов

  • Использовать debug.SetMaxThreads без понимания последствий. Может вызвать панику при превышении лимита потоков.
  • Забыть закрыть elf.File — утечка дескриптора.
  • Парсить ELF-файлы в горячем пути; это ресурсоёмко.

Возможные дополнительные вопросы интервьюера

Как получить версию Go, которой скомпилирован бинарник?
Через debug.ReadBuildInfo().GoVersion. Или strings.Contains(runtime.Version(), "go1.x").

Можно ли уменьшить потребление памяти через FreeOSMemory?
Да, принудительно возвращает операционной системе неиспользуемую память, но это может увеличить работу GC.

Как прочитать символы из ELF?
elf.File.Symbols() или динамические символы ImportedSymbols.


Что такое container/list и container/heap?

Краткий ответ

container/list — двусвязный список, реализованный через кольцевую структуру Element. container/heap — интерфейс для кучи (приоритетной очереди), который работает с любым срезом, реализующим heap.Interface. Обе структуры являются примитивами, не потокобезопасны.

Развёрнутый ответ

Go
// list
l := list.New()
l.PushBack("a")
for e := l.Front(); e != nil; e = e.Next() {
    fmt.Println(e.Value)
}

// heap
h := &IntHeap{2, 1, 5}
heap.Init(h)
heap.Push(h, 3)
min := heap.Pop(h)

Под капотом (internals)

  • list хранит root элемент как sentinel. PushBack создаёт Element, связывает его, O(1).
  • heap использует функции up и down для восстановления инвариантов. Минимальный элемент всегда h[0]. Сложность O(log n). Сортировка через heap.Sort даёт пирамидальную сортировку.

Типичные ошибки кандидатов

  • Не инициализировать кучу перед использованием: h := &IntHeap{}; heap.Push(h, 1) без heap.Init. Правильно: сначала heap.Init.
  • Хранить в куче мутируемые элементы без перестроения. Изменение ключа приведёт к нарушению инварианта; нужно вызывать heap.Fix.
  • Использовать container/list вместо слайса, когда нужен частый доступ по индексу. У списка O(n) доступ по индексу.

Возможные дополнительные вопросы интервьюера

Как реализовать max-кучу с помощью container/heap?
Инвертировать функцию Less: return h[i] > h[j].

Когда оправдано использовать двусвязный список?
При частых вставках/удалениях в середине при больших объёмах данных и при отсутствии необходимости в индексации.

Как container/heap гарантирует O(log n)?
Через просеивание вверх/вниз, используя свойства полного бинарного дерева, представленного в срезе.


Как работает ring из container/ring?

Краткий ответ

container/ring — циклический двусвязный список фиксированного размера. Создаётся ring.New(n), элементы хранят Value (interface{}). Поддерживает перемещение (Next, Prev), связывание/развязывание (Link, Unlink), обход через Do. Полезен для кольцевых буферов, каруселей.

Развёрнутый ответ

Go
r := ring.New(5)
for i := 0; i < r.Len(); i++ {
    r.Value = i
    r = r.Next()
}
r.Do(func(x interface{}) {
    fmt.Println(x)
})

Под капотом (internals)

  • Ring — структура с next, prev указателями и Value. Циклический: next последнего указывает на первый, prev первого на последний.
  • Операции: Link соединяет два кольца за O(1), Unlink удаляет n элементов.
  • Do итерируется, не вызывая рекурсии.

Типичные ошибки кандидатов

  • Создать ring.New(0) — допустимо, но nil; вызов методов паникует. Проверять r != nil.
  • Забыть, что кольцо циклическое, и Do/ итерация по Next могут зациклиться, если условие выхода не r.Len().
  • Использовать ring как замену slice для случайного доступа — нет индексации.

Возможные дополнительные вопросы интервьюера

Можно ли использовать ring для кольцевого буфера?
Да, но фиксированный размер; для динамического нужен слайс с указателями.

Как клонировать кольцо?
ring.New(n); for ... r.Do(func(x){...}). Прямого метода нет.

Чем ring отличается от list?
У ring фиксированный размер (нет динамического добавления/удаления, хотя Link меняет размер). list динамический, без кольцевой связности по умолчанию.


Как работает math/big и когда его использовать?

Краткий ответ

math/big предоставляет целые числа произвольной точности (Int), рациональные (Rat) и числа с плавающей запятой произвольной точности (Float). Используется в криптографии, финансовых расчётах, где встроенные int64/float64 недостаточны по диапазону или точности. Реализован на чистых Go-алгоритмах (умножение Карацубы, деление с остатком, и т.д.).

Развёрнутый ответ

Go
a := new(big.Int)
a.SetString("123456789012345678901234567890", 10)
b := new(big.Int)
b.SetString("987654321098765432109876543210", 10)
sum := new(big.Int).Add(a, b)

Float поддерживает режимы округления, точность в битах. Rat для точных дробей.

Под капотом (internals)

  • Int хранит nat — срез слов ([]Word, где Word = uint). Операции реализованы ассемблерными вставками (addVW, subVW) для производительности.
  • Умножение использует Карацубу для больших чисел, возведение в степень — бинарный метод.
  • GC-friendly: Int содержит указатель на nat; при Set/Add возвращает новый объект, но может писать в существующий для избежания аллокаций.
  • Float — знак, мантиса (nat), экспонента, точность. Округление согласно IEEE 754.

Типичные ошибки кандидатов

  • Использовать big.Int для маленьких чисел, создавая излишние аллокации. Для 64-битного диапазона хватает int64.
  • Забыть про иммутабельность: a.Add(b, c) не изменяет b, c, но может записать в a, что ок. Однако a.SetString перезаписывает a.
  • Создавать big.Rat через new(big.Rat).SetFrac с нулевым знаменателем — паника.
  • Не проверять успешность SetString — если ошибка, значение не определено.

Возможные дополнительные вопросы интервьюера

Как быстро возвести в степень по модулю?
big.Int.Exp(x, y, m) использует бинарное возведение с модулем.

Как конвертировать big.Int в []byte?
i.Bytes() или i.FillBytes(buf).

Почему math/big не использует GMP?
Чтобы избежать cgo и лицензионных ограничений, обеспечить портабельность.


Что такое net/url и как правильно парсить URL?

Краткий ответ

Пакет net/url разбирает URL-адреса на компоненты (Scheme, Host, Path, Query и т.д.) через url.Parse. Предоставляет функции экранирования (QueryEscape, PathEscape). Ключевые типы: url.URL и url.Values (для query-параметров). Безопасное конструирование: избегать ручной сборки строки, использовать методы url.URL и url.Values.

Развёрнутый ответ

Go
u, err := url.Parse("https://example.com/path?q=hello%20world#frag")
fmt.Println(u.Scheme, u.Host, u.Path, u.Query().Get("q"), u.Fragment)
// Сборка
v := url.Values{}
v.Set("key", "value")
u := &url.URL{
    Scheme: "https",
    Host:   "example.com",
    Path:   "/path",
    RawQuery: v.Encode(),
}
fmt.Println(u.String())

Экранирование: url.PathEscape для сегментов пути, url.QueryEscape для ключей и значений.

Под капотом (internals)

  • Parse использует внутренний сканер, разбивая строку по символам :, //, ?, #. Декодирует percent-encoded последовательности.
  • String() собирает URL обратно, применяя соответствующее экранирование.
  • url.Values базируется на map[string][]string. Encode сортирует ключи для стабильности.

Типичные ошибки кандидатов

  • Строить URL конкатенацией строк без экранирования, что приводит к некорректным URL или уязвимостям.
  • Неправильно экранировать параметры: использовать PathEscape для query, что не экранирует &. Нужно применять QueryEscape.
  • Парсить URL без проверки ошибок: url.Parse может вернуть nil URL и ошибку, при непроверке паника.
  • Забыть вызвать u.Query() для доступа к параметрам, а парсить RawQuery вручную. Используйте u.Query().Get.

Возможные дополнительные вопросы интервьюера

Как безопасно добавить путь к базовому URL?
u.ResolveReference(ref) или через u.JoinPath. Вручную не рекомендуется.

Чем отличается url.Parse от url.ParseRequestURI?
Второй для HTTP-запросов, не принимает фрагменты и предполагает отсутствие схемы/хоста.

Как обработать повторяющиеся параметры?
v := u.Query(); v["key"] даёт слайс всех значений.


Как работает sync/atomic.Value?

Краткий ответ

atomic.Value — конкурентная переменная, позволяющая атомарно загружать и сохранять значения произвольного типа без блокировок. Гарантирует, что Load после Store увидит сохранённое значение. Может хранить только значения одного конкретного типа в течение жизни.

Развёрнутый ответ

Go
var config atomic.Value
config.Store(Config{Port: 8080})
// позже
if val, ok := config.Load().(Config); ok {
    fmt.Println(val.Port)
}

Тип фиксируется первым Store. Нарушение типа вызывает панику. Используется для кеша конфигурации без мьютексов.

Под капотом (internals)

  • Value содержит указатель на ifaceWords (два слова: тип и значение). При Store сравнивается тип, если отличается — паника.
  • Реализация использует атомарные операции над указателем (на некоторых платформах — atomic.LoadPointer и atomic.StorePointer) и барьеры памяти для гарантии видимости.
  • До Go 1.17 использовался unsafe.Pointer напрямую; сейчас используется type-based механизм.

Типичные ошибки кандидатов

  • Сохранять nil через Store(nil). value не поддерживает nil для первого store; нужно обернуть в указатель.
  • Первый Store делать из горутин с разными типами. Тип фиксируется первым успешным сохранением; последующие с другим типом паникуют.
  • Использовать atomic.Value для обновления «in-place» большого объекта. Обновление всего значения — копирование всей структуры; для частичного обновления лучше sync.Mutex или atomic.Value с указателем на структуру.
  • Думать, что atomic.Value обеспечивает синхронизацию сложных инвариантов нескольких полей. Он атомарно только обновляет ссылку; внутренние данные должны быть неизменяемы после публикации.

Возможные дополнительные вопросы интервьюера

Что быстрее: atomic.Value или мьютекс для частых чтений?
atomic.Value даёт быстрые lock-free чтения, поэтому лучше для read-heavy нагрузки.

Как хранить nil?
Использовать указатель на значение, сохранять (*Config)(nil), при Load проверять указатель.

Можно ли использовать atomic.Value с интерфейсами?
Да, тип фиксируется первым сохранённым типом.


Как работает runtime/metrics?

Краткий ответ

Пакет runtime/metrics (Go 1.16+) предоставляет стабильный API для чтения ключевых метрик Go-рантайма: использование памяти, горутины, GC, размер кеша и т.д. В отличие от runtime.ReadMemStats, он не вызывает STW и работает эффективно. Метрики описаны структурами Description, Value, предоставляются по ключам.

Развёрнутый ответ

Go
sample := []metrics.Sample{
    {Name: "/gc/heap/allocs-by-size:bytes"},
    {Name: "/sched/goroutines:goroutines"},
}
metrics.Read(sample)
fmt.Println(sample[0].Value)

Метрики доступны по URI-путям, например /memory/classes/heap/free:bytes. Всегда возвращают актуальные значения без существенного влияния на производительность.

Под капотом (internals)

  • metrics собирает данные из внутренней структуры runtime.memstats и других счётчиков без остановки мира, используя atomic чтения либо кеши.
  • metrics.Read вызывает runtime.readMetrics, которая заполняет буфер из соответствующих полей.
  • Метрики делятся на кумулятивные (счётчики) и мгновенные (gauge). Определены в runtime/metrics/description.go.

Типичные ошибки кандидатов

  • Парсить имена метрик жёстко закодированными строками, не проверяя их существование. Имена стабильны, но всё же лучше использовать константы metrics.Kind и metrics.Description для динамического подхода.
  • Вызывать metrics.Read слишком часто. Хотя вызов дешёвый, стоит избегать тысяч раз в секунду; подходит для периодического сбора.
  • Думать, что это замена профилировщику pprof. metrics даёт текущие значения, а не профиль времени/аллокаций.

Возможные дополнительные вопросы интервьюера

Как получить количество горутин?
metrics.Read с ключом /sched/goroutines:goroutines. Альтернативно runtime.NumGoroutine.

Чем runtime/metrics лучше runtime.ReadMemStats?
ReadMemStats вызывает stop-the-world для гарантии консистентности; metrics избегает этого.

Можно ли экспортировать эти метрики в Prometheus?
Да, парсить metrics.All() и передавать в свою экспозицию.


Что такое mime/multipart и как обрабатывать загрузку файлов?

Краткий ответ

Пакет mime/multipart реализует MIME multipart streams, используется для загрузки файлов через HTTP-формы (Content-Type: multipart/form-data). multipart.Reader парсит поток частей, каждая часть содержит заголовки и тело. На сервере r.ParseMultipartForm собирает их в память или на диск.

Развёрнутый ответ

Серверная сторона:

Go
r.ParseMultipartForm(10 << 20) // 10 MB max memory
file, header, err := r.FormFile("file")
defer file.Close()
// file — multipart.File, header содержит имя и размер

Низкоуровневый парсинг:

Go
mr := multipart.NewReader(r.Body, boundary)
for {
    part, err := mr.NextPart()
    if err == io.EOF { break }
    // чтение part
}

multipart.Writer для создания multipart-запросов.

Под капотом (internals)

  • ParseMultipartForm парсит Content-Type, извлекает boundary, затем использует multipart.Reader для чтения.
  • Части, превышающие лимит памяти, сохраняются во временные файлы через os.CreateTemp; при удалении формы нужно удалить временные файлы (автоматически при RemoveAll после запроса).
  • Буферизованное чтение с поиском boundary.

Типичные ошибки кандидатов

  • Не закрывать multipart.File и не удалять временные файлы. Утечка дескрипторов и места на диске.
  • Забыть вызвать ParseMultipartForm перед FormFile. Без этого FormFile вернёт ошибку.
  • Устанавливать слишком маленький maxMemory, тогда даже маленькие файлы пишутся на диск, замедляя обработку. Настраивать соответственно ожидаемому размеру.
  • Не проверять размер файла, что ведёт к exhaustion атакам. Ограничивать размер через http.MaxBytesReader перед парсингом.

Возможные дополнительные вопросы интервьюера

Как протестировать загрузку файла?
Создать multipart.Writer, записать части в bytes.Buffer, установить Content-Type с boundary, и отправить запрос через httptest.NewRequest.

Как отправить файл клиентом?
Использовать mime/multipart.Writer с http.Post, либо более удобные net/http.NewRequest с multipart.Writer и FormFile.

Что такое multipart.Reader.NextRawPart?
Возвращает часть без буферизации, позволяя более эффективно обрабатывать большие тела.


Как работает net/rpc и net/rpc/jsonrpc?

Краткий ответ

net/rpc реализует Remote Procedure Call (RPC) через Go-свой протокол (gob). Экспортированные методы объекта могут быть вызваны удалённо. net/rpc/jsonrpc использует JSON как кодирование, совместимое с другими языками. Оба устарели в пользу gRPC.

Развёрнутый ответ

Go
type Args struct { A, B int }
type Arith int
func (t *Arith) Multiply(args *Args, reply *int) error {
    *reply = args.A * args.B
    return nil
}
// Сервер
arith := new(Arith)
rpc.Register(arith)
rpc.HandleHTTP()
http.ListenAndServe(":1234", nil)
// Клиент
client, _ := rpc.DialHTTP("tcp", "localhost:1234")
var result int
client.Call("Arith.Multiply", Args{3, 4}, &result)

Под капотом (internals)

  • rpc.Register сохраняет тип и методы в карту. Клиентский вызов отправляет имя метода и аргументы через gob-кодер/декодер.
  • Транспорт может быть любым io.ReadWriteCloser.
  • jsonrpc реализует rpc.ClientCodec и rpc.ServerCodec через JSON.

Типичные ошибки кандидатов

  • Использовать неэкспортированные методы — не будут доступны.
  • Ошибка передачи не того типа (reply must be pointer type). Паника или ошибка.
  • Думать, что net/rpc обеспечивает безопасность. Нет аутентификации/шифрования; в продакшене нужен gRPC или оборачивать в TLS.

Возможные дополнительные вопросы интервьюера

Почему net/rpc считается устаревшим?
Ограничен только Go-клиентами, нет стриминга, слабая расширяемость. gRPC имеет межъязыковую поддержку и потоковые запросы.

Как реализовать асинхронный вызов?
client.Go(serviceMethod, args, reply, done).

Можно ли использовать JSON-RPC с HTTP?
Да, через jsonrpc.DialHTTP.


Как работает archive/zip и archive/tar?

Краткий ответ

Пакеты предоставляют чтение и запись ZIP- и TAR-архивов. archive/zip работает с zip.Reader/Writer, сжатием (deflate, store), шифрованием (AES, дефолтное ZipCrypto небезопасно). archive/tar — с tar.Reader/Writer, поддерживает различные форматы записей (USTAR, PAX, GNU). Оба работают поверх io.Reader/io.Writer.

Развёрнутый ответ

Чтение ZIP:

Go
r, _ := zip.OpenReader("archive.zip")
defer r.Close()
for _, f := range r.File {
    rc, _ := f.Open()
    // читать rc
    rc.Close()
}

Создание TAR:

Go
tw := tar.NewWriter(w)
hdr := &tar.Header{Name: "file.txt", Size: int64(len(data)), Mode: 0644}
tw.WriteHeader(hdr)
tw.Write(data)
tw.Close()

Под капотом (internals)

  • ZIP: FileHeader содержит метаданные, метод сжатия, смещение данных. Writer использует буферизацию и потоковое сжатие (flate).
  • TAR: записи по 512 байт, заголовки заполняются структурами. Пакет автоматически определяет формат (USTAR, PAX для больших файлов).

Типичные ошибки кандидатов

  • Не закрывать zip.ReadCloser/Writer, теряя данные.
  • Игнорировать ошибки при вызове tw.WriteHeader и tw.Write, что приводит к повреждению архива.
  • Использовать стандартное ZipCrypto шифрование для конфиденциальных данных (небезопасно). Лучше шифровать отдельно.
  • Не проверять path traversal уязвимости при распаковке: имена файлов в архиве могут содержать ../. Использовать filepath.SecureJoin или filepath.IsLocal.

Возможные дополнительные вопросы интервьюера

Как создать ZIP с сжатием Deflate?
zip.NewWriter(w) по умолчанию deflate. Можно указать метод через f.CreateHeader.

Как обработать распаковку TAR-архива с длинными именами?
tar.Reader сам декодирует PAX расширения.

Почему archive/tar не поддерживает сжатие?
Разделение ответственности: tar — формат архивации, сжатие накладывается отдельно (gzip, bzip2).


Как работает compress/gzip?

Краткий ответ

compress/gzip реализует чтение и запись gzip-формата (RFC 1952). gzip.Writer оборачивает io.Writer, сжимая данные; gzip.Reader оборачивает io.Reader, распаковывая. Поддерживает уровни сжатия gzip.BestCompression, gzip.BestSpeed. Внутри использует библиотеку compress/flate (DEFLATE).

Развёрнутый ответ

Go
// Сжатие
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
w.Write([]byte("data"))
w.Close() // обязательно закрыть, чтобы записать финальные блоки
// Распаковка
r, _ := gzip.NewReader(&buf)
defer r.Close()
data, _ := io.ReadAll(r)

gzip.Writer позволяет задать уровень и комментарий. При закрытии записывает контрольную сумму CRC32.

Под капотом (internals)

  • compress/flate реализует DEFLATE (LZ77 + Хаффман). Пакет gzip добавляет заголовок и трейлер.
  • Writer инициализирует flate.Writer с выбранным уровнем.
  • Reader оборачивает flate.Reader, также проверяет gzip-заголовок (магические байты, метод).
  • Потоковый: можно читать/писать гигабайтные данные без загрузки всего в память.

Типичные ошибки кандидатов

  • Не закрыть gzip.Writer, из-за чего данные теряются, т.к. финальные блоки не записаны. Использовать defer w.Close() с проверкой ошибки.
  • Забыть закрыть gzip.Reader, оставляя висящий внутренний буфер.
  • Не обрабатывать ошибку Close — она может вернуть ошибку записи в нижележащий writer.
  • Полагать, что gzip — единственный способ сжатия для HTTP. Для веба лучше использовать Content-Encoding: gzip с автоматической обработкой через middleware.

Возможные дополнительные вопросы интервьюера

Как gzip работает с многопоточным сжатием?
Стандартный пакет не поддерживает; есть сторонние обёртки (pgzip) с параллельным flate.

Как сжать данные в памяти?
var buf bytes.Buffer; w := gzip.NewWriter(&buf); ...; w.Close(); compressed := buf.Bytes().

Чем compress/gzip отличается от compress/zlib?
Формат контейнера: gzip имеет более богатые заголовки (имя файла, комментарий), zlib — облегчённый формат (RFC 1950). Оба используют DEFLATE.


Что такое encoding/json/v2 в Go 1.25?

Краткий ответ (30–60 секунд)

Пакет encoding/json/v2 (Go 1.25+) — это долгожданная фундаментальная переработка стандартного JSON-сериализатора, устранившая критические недостатки v1:

  1. Производительность и потоковая обработка: модуль jsontext реализует потоковый zero-allocation токенизатор (SAX-модель), в 2–4 раза быстрее v1.
  2. Строгая обработка дубликатов: наличие дублирующихся ключей в объекте теперь по умолчанию вызывает ошибку (в v1 последнее значение молча затирало предыдущие, что приводило к уязвимостям безопасности).
  3. Честный omitzero: новый тег omitzero пропускает любые нулевые значения (включая пустые структуры time.Time{} и кастомные типы с методом IsZero() bool), устраняя кривую семантику старого omitempty.

Развёрнутый ответ

Сравнительная таблица семантики v1 и v2:
| Возможность | encoding/json (v1) | encoding/json/v2 (Go 1.25+) |
|---|---|---|
| Дубликаты ключей | Тихо перезаписывает (риск атак) | Возвращает ошибку DuplicateNameError |
| Пропуск нулевых структур | omitempty не скрывает time.Time{} | omitzero честно вызывает IsZero() |
| Кастомные маршаллеры | MarshalJSON() ([]byte, error) | MarshalEncode(*jsontext.Encoder) error |
| Форматирование типов | Фиксированное глобальное поведение | Тонкие опции через json.WithMarshalers() |

Пример кода на v2:

Go
import "encoding/json/v2"

type Order struct {
    ID        string    `json:"id"`
    CreatedAt time.Time `json:"created_at,omitzero"` // Скрывается, если IsZero() == true!
    Comment   string    `json:"comment,omitzero"`
}

func encodeOrder(o Order) ([]byte, error) {
    // В 2-3 раза быстрее и без лишних аллокаций
    return json.Marshal(o, json.Deterministic(true))
}

Под капотом (internals)

В v2 компилятор и рантайм кэшируют таблицы дескрипторов полей без многократного обращения к медленной рефлексии reflect.TypeOf. Маршаллинг происходит напрямую в буфер потока через метод MarshalEncode, минуя создание промежуточных срезов байт.

Типичные ошибки кандидатов

  • Ожидание 100% совместимости с v1: в v2 намеренно изменены дефолты (строгость к дубликатам, валидация UTF-8).
  • Использование omitempty вместо omitzero: в коде на v2 рекомендуется использовать именно omitzero.

Что такое пакет uuid в Go 1.27?

Краткий ответ (30–60 секунд)

Начиная с Go 1.27, в стандартную библиотеку добавлен официальный пакет uuid, положив конец многолетней фрагментации сторонних зависимостей (google/uuid, gofrs/uuid). Пакет нативно поддерживает спецификацию RFC 9562 (включая UUIDv4 — криптографически стойкий случайный идентификатор, и UUIDv7 — монотонно возрастающий во времени идентификатор, оптимизированный для первичных ключей в базах данных и B-tree индексов).

Развёрнутый ответ

Go
import (
    "fmt"
    "uuid"
)

func ExampleUUID() {
    // 1. UUIDv7 (Timestamp-ordered: миллисекунды UNIX + случайные биты)
    // Идеально подходит для primary key в PostgreSQL / MySQL / CockroachDB!
    idV7, err := uuid.NewV7()
    if err != nil {
        panic(err)
    }
    fmt.Println("UUIDv7:", idV7.String())

    // 2. UUIDv4 (Классический случайный)
    idV4, _ := uuid.NewV4()

    // 3. Парсинг и сравнение за O(1) через 16-байтовый массив [16]byte
    parsed, _ := uuid.Parse("018d3b2f-8a0a-7000-8000-000000000000")
    if idV7 == parsed {
        fmt.Println("IDs match!")
    }
}

Под капотом (internals)

  1. Тип uuid.UUID определен как компактный фиксированный массив [16]byte. Он не аллоцирует память в куче при создании и передаче.
  2. Поддерживает оператор == напрямую в регистрах процессора (через SSE/AVX инструкции на amd64 и NEON на arm64).
  3. uuid.NewV7() упаковывает 48-битный timestamp Unix epoch в старшие байты, что гарантирует монотонный рост значений. При вставке записей в B-tree индекс базы данных новые узлы всегда добавляются в конец дерева, предотвращая дорогостоящие page splits и фрагментацию страниц диска.

Типичные ошибки кандидатов

  • Продолжение генерации UUIDv4 для первичных ключей в БД: UUIDv4 полностью случаен, что разрушает кластеризованные индексы в БД при больших объемах (Random I/O). Кандидат должен уверенно предлагать UUIDv7.
  • Ненужные аллокации строк: хранение UUID в структурах в виде string вместо uuid.UUID ([16]byte) увеличивает расход памяти в 3 раза.