Процессы, потоки и файловые дескрипторы

О лекции

Процесс — своё адресное пространство. Потоки одного процесса делят память и таблицу файловых дескрипторов. Горутина легче потока и мультиплексируется планировщиком M:N; забытый Close у HTTP Body часто течёт именно дескрипторами, а не «GMP».

Операционные системы cs собеседование

ОС изолирует процессы. У каждого своё виртуальное адресное пространство, свои права, свой набор дескрипторов.

Поток (OS thread) — единица исполнения внутри процесса. Потоки одного процесса разделяют кучу, глобальные переменные и таблицу FD. Сокет, который открыл один поток, виден другому. Поэтому data race в Go — про общую память процесса, а не про «два контейнера».

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

Файловый дескриптор — индекс в таблице ядра. Это не только файл. Это ещё сокет, pipe, epoll fd. Лимит ulimit -n (и cgroup) ограничивает, сколько их можно держать открытыми. Каждый listen, accept, открытый файл и HTTP-клиент без Close занимает слот. Кончились FD — странные ошибки accept/connect. Не «Go сломался».

Процесс Go — один OS process, несколько OS threads (M) и гораздо больше горутин (G). Это не «горутина = поток» и не отдельный процесс на запрос. fork() в многопоточном процессе копирует только текущий поток. В Go с cgo это источник тонких багов, поэтому рантайм fork ограничивает.

Потоки и горутины

Стек OS thread — мегабайты. Стек горутины стартует с килобайт и растёт. Переключение потока — вход в ядро. Парковка горутины — работа планировщика в user space, пока нет syscall. Поэтому «миллион горутин» мыслим, «миллион потоков» — обычно нет: память стеков и цена context switch.

Связь M:N: несколько потоков исполняют много горутин. Это не 1:1. Блокирующий syscall занимает M. P (логический процессор Go) может переехать на другой M. Горутина всё ещё не процесс: нет своей таблицы FD, нет изоляции памяти.

resp, err := http.Get(url)
if err != nil {
	return err
}
defer resp.Body.Close() // без Close слот в пуле и FD живут дальше
_, _ = io.Copy(io.Discard, resp.Body)

// процесс: одна таблица FD на все горутины
// ulimit -n → слишком много accept/файлов/клиентов без Close
// TIME_WAIT исходящих коротких connect — порты/FD тоже заняты

Незакрытое тело HTTP-ответа держит соединение и FD. Это утечка дескрипторов, не «утечка GMP».

Лимиты и утечки

  • Каждый сокет и файл — FD. Listen, accept, HTTP-клиент, файл лога без Close течёт слотами.
  • Потоки OS тяжелее горутин по стеку и по переключению.
  • Пул соединений БД — тоже FD (и процессы на стороне Postgres). Утечка Rows без Close бьёт и туда.

Короткие TCP без keep-alive плодят исходящие соединения. Сторона, закрывшая первой, держит 4-tuple в TIME_WAIT: исходящие порты заканчиваются. Симптом похож на «кончились FD». Причина — жизненный цикл сокета, не горутина.

Пример. Хендлер сходил в чужой HTTP API, тело не закрыл. Тысяча запросов в минуту — тысяча живых соединений и слотов в пуле. В lsof видно кучу сокетов. GC тут ни при чём: дескриптор жив, пока его не закрыли.

Типичная ошибка

Забыть resp.Body.Close() и объяснить падение как «утечку горутин» или поломку GC. Транспорт держит соединение, пока тело не прочитано и не закрыто: кончаются FD и слоты пула. Вторая путаница: горутина ≠ OS thread ≠ процесс.

На собеседовании

Утечка FD — незакрытый HTTP Body и пул БД. Потоки делят адресное пространство процесса. Горутина легче и мультиплексируется M:N. Проверка на практике: lsof и ulimit.