Есть интересный эксперимент: читаем файл на 500 МБ через
os.ReadFile(), ставим программу на паузу и смотрим RSS процесса.
b, err := os.ReadFile("bigfile.txt")
if err != nil {
panic(err)
}
fmt.Println(len(b))
fmt.Scanln()
Ожидаем около 500 МБ RSS.
А видим условные 3 МБ.
На первый взгляд кажется, что
os.ReadFile() ничего не загрузил. Но причина совсем другая.[]byte - это только descriptor:
slice
├─ ptr ───────────┐
├─ len │
└─ cap │
▼
+-------------------+
| 500 MB byte array |
+-------------------+
Сам slice header на 64-битной системе занимает всего несколько машинных слов. Основные 500 МБ находятся в backing array на heap.
Ключевой момент - liveness analysis.
После:
fmt.Println(len(b))
fmt.Scanln()
переменная
b больше не используется.Для Go это означает, что объект может перестать считаться live ещё до выхода
main(). GC получает право освободить backing array, пока программа всё ещё ждёт Enter.Добавим обращение после паузы:
fmt.Scanln()
fmt.Println(b[0])
И RSS внезапно становится примерно равен размеру файла.
Это хороший пример того, почему:
scope переменной != время жизни объекта для GC.
Чтобы явно сохранить объект живым до определённой точки, в низкоуровневом коде существует:
runtime.KeepAlive(b)
Но использовать
KeepAlive просто ради красивого RSS обычно не нужно - он важен прежде всего при работе с finalizer, unsafe, syscall и foreign memory.Ещё одна ловушка -
go run.
go run main.go bigfile.txt
сначала компилирует временный binary, а затем запускает его отдельным процессом. Поэтому смотреть память нужно у процесса вида:
/tmp/go-build.../exe/main
а не у самого
go run.Практический вывод для production:
data, _ := os.ReadFile(path)
подходит для небольших файлов, но большой файл означает большую аллокацию, давление на heap и GC.
Для потоковой обработки лучше:
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
r := bufio.NewReaderSize(f, 256<<10)
или вообще:
_, err = io.Copy(dst, f)
Тогда объём рабочей памяти зависит от размера буфера, а не от размера файла.
Главный урок: RSS показывает состояние процесса в конкретный момент, а не полную историю его аллокаций.
И если профилируете память Go-приложения, одного
ps мало - смотрите ещё heap profile, runtime.MemStats и GODEBUG=gctrace=1.