Работа с файлами и вводом-выводом в Go: рабочие приёмы
Стандартная библиотека Go покрывает большинство задач ввода-вывода, но выбор между os.ReadFile, bufio.Scanner и io.Copy определяет, упадёт ли сервис на файле в 4 ГБ или обработает его за секунды. Разбираем подходы, сравниваем и показываем, где каждый ломается.

Почему «просто прочитать файл» — это три разных задачи
В Go есть минимум три стандартных способа прочитать файл: os.ReadFile, построчное чтение через bufio.Scanner и потоковое копирование через io.Copy. Все три компилируются, все три возвращают данные — но на файле в несколько гигабайт первый способ съест всю память процесса, второй молча обрежет длинные строки, а третий отработает ровно. Проблема не в API, а в том, что разработчик выбирает приём по привычке, а не по задаче.
Ниже — четыре приёма из практики, каждый с ценой и границами применимости. Версия — Go 1.22, но всё работает начиная с 1.16, если не оговорено иначе.
Приём 1. Чтение целиком: os.ReadFile
Самый короткий путь — одна строка, весь файл в []byte.
package main
import (
"fmt"
"os"
)
func main() {
// Читаем конфиг целиком — файл маленький, это безопасно
data, err := os.ReadFile("config.yaml")
if err != nil {
fmt.Fprintf(os.Stderr, "не удалось прочитать конфиг: %v\n", err)
os.Exit(1)
}
fmt.Printf("прочитано %d байт\n", len(data))
}
Когда подходит: конфиги, шаблоны, JSON-ответы — всё, что гарантированно помещается в память. На практике — файлы до десятков мегабайт.
Цена: аллокация равна размеру файла плюс небольшой оверхед на рост слайса. Если файл весит 2 ГБ, процесс займёт минимум 2 ГБ RSS. В контейнере с лимитом памяти это OOMKill без предупреждения.
Приём 2. Построчное чтение: bufio.Scanner
Классика для логов, CSV, любых текстовых потоков, где нужна обработка по строкам.
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Open("access.log")
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка открытия: %v\n", err)
os.Exit(1)
}
defer file.Close()
scanner := bufio.NewScanner(file)
// Увеличиваем буфер: по умолчанию 64 КБ, длинные строки будут обрезаны
const maxLineSize = 1024 * 1024 // 1 МБ
scanner.Buffer(make([]byte, maxLineSize), maxLineSize)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
_ = line // обработка строки
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "ошибка чтения на строке %d: %v\n", lineNum, err)
os.Exit(1)
}
fmt.Printf("обработано %d строк\n", lineNum)
}
Ловушка с размером буфера
По умолчанию bufio.Scanner использует буфер в 64 КБ. Строка длиннее — и scanner.Err() вернёт bufio.ErrTooLong, а scanner.Scan() просто перестанет возвращать true. Никакой паники, никакого явного сообщения в stdout. Вы просто теряете хвост файла. Вызов scanner.Buffer() до начала итерации решает проблему, но про него регулярно забывают.
Когда подходит: текстовые файлы любого размера, где единица обработки — строка.
Цена: каждая строка — аллокация нового string. Если нужна только проверка (grep-подобная задача), используйте scanner.Bytes() и работайте с []byte напрямую — это избавит от копирования при конвертации в строку.
Приём 3. Потоковое копирование: io.Copy и io.LimitReader
Когда файл нужно передать куда-то — в HTTP-ответ, в другой файл, в сжатый архив — читать его в память не нужно вовсе.
package main
import (
"fmt"
"io"
"os"
)
func main() {
src, err := os.Open("dump.sql")
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка: %v\n", err)
os.Exit(1)
}
defer src.Close()
dst, err := os.Create("dump_copy.sql")
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка создания: %v\n", err)
os.Exit(1)
}
defer dst.Close()
// Копируем потоково: в памяти одновременно только 32 КБ (размер буфера по умолчанию)
written, err := io.Copy(dst, src)
if err != nil {
fmt.Fprintf(os.Stderr, "ошибка копирования: %v\n", err)
os.Exit(1)
}
fmt.Printf("скопировано %d байт\n", written)
}
io.Copy использует внутренний буфер в 32 КБ. Файл в 10 ГБ скопируется без заметного роста RSS. Если нужен буфер другого размера — io.CopyBuffer.
Защита от бомб: io.LimitReader
Принимаете файл от пользователя через HTTP? Оберните Body в io.LimitReader, иначе клиент может отправить бесконечный поток.
// Ограничиваем тело запроса 10 МБ
limited := io.LimitReader(r.Body, 10*1024*1024)
written, err := io.Copy(dst, limited)
Когда лимит исчерпан, LimitReader возвращает io.EOF — копирование завершится без ошибки, но written покажет, что записано ровно 10 МБ. Проверяйте это значение и, если нужно, возвращайте 413 Request Entity Too Large.
Приём 4. Запись с гарантией: Sync, Rename, ошибки Close
Запись файла, которая не теряет данные при сбое — это не просто os.Create + Write + Close. Классический паттерн — запись во временный файл с последующим атомарным переименованием.
package main
import (
"fmt"
"os"
)
func writeConfig(path string, data []byte) error {
// Создаём временный файл в той же директории — это важно для Rename
tmp, err := os.CreateTemp(".", "config-*.tmp")
if err != nil {
return fmt.Errorf("создание tmp: %w", err)
}
tmpName := tmp.Name()
// При любой ошибке — удаляем недописанный файл
defer func() {
if err != nil {
os.Remove(tmpName)
}
}()
if _, err = tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("запись: %w", err)
}
// Sync сбрасывает буферы ОС на диск
if err = tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("sync: %w", err)
}
// Close тоже может вернуть ошибку — не игнорируйте
if err = tmp.Close(); err != nil {
return fmt.Errorf("close: %w", err)
}
// Атомарная замена: файл либо старый, либо новый, но не пустой
if err = os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
Почему ошибку Close нельзя игнорировать
Типичный defer file.Close() отбрасывает возвращаемое значение. Для чтения это допустимо. Для записи — нет: на некоторых файловых системах и при использовании NFS реальная ошибка записи приходит именно в Close. Паттерн с именованным err в defer или явный вызов Close до Rename решает проблему.
В Go ввод-вывод дёшев по синтаксису, но дорог по последствиям. Разница между «работает на ноутбуке» и «работает в проде» — это Sync, LimitReader и проверка ошибки Close.
Сравнение подходов
| Приём | Потребление памяти | Подходит для | Где ломается |
|---|---|---|---|
os.ReadFile | Равно размеру файла | Конфиги, шаблоны, мелкие JSON | Файлы > сотен МБ, контейнеры с лимитом |
bufio.Scanner | Размер буфера (64 КБ по умолчанию) | Логи, CSV, текстовые потоки | Строки длиннее буфера без Buffer() |
io.Copy | 32 КБ (буфер) | Перекладывание между Writer и Reader | Без LimitReader — бомба от клиента |
| Tmp + Sync + Rename | Зависит от способа записи | Конфиги, БД, всё, что нельзя потерять | Кросс-девайсный Rename (разные ФС) |
Когда что выбирать
- Файл помещается в память и нужен целиком (парсинг JSON, YAML, TOML) —
os.ReadFile. - Файл большой, обработка построчная (фильтрация логов, ETL текстовых выгрузок) —
bufio.Scannerс явным буфером. - Файл большой, нужно передать дальше без изменений (проксирование, бекап, отдача в HTTP) —
io.Copy. - Запись, которая не должна оставить битый файл — Tmp + Sync + Rename.
Если ни один критерий не подходит — начинайте с io.Copy: он наименее рискован по памяти и не навязывает построчную модель.
Что делать дальше
Настройте в CI линтер errcheck, чтобы непроверенные ошибки Close при записи не проходили ревью. Посмотрите пакет io/fs (Go 1.16+), если работаете с embed-ресурсами или виртуальными файловыми системами — он использует те же интерфейсы io.Reader/io.Writer, и все четыре приёма применимы без изменений. Для мониторинга в проде отслеживайте метрику RSS процесса и количество открытых файловых дескрипторов: именно они первыми сигналят о неправильно выбранном способе чтения.
Сноска: RSS (Resident Set Size) — объём физической оперативной памяти, которую процесс реально занимает. В отличие от виртуальной памяти (VSZ), RSS показывает, сколько RAM используется прямо сейчас, и именно по нему OOM-killer в Linux принимает решение о завершении процесса.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Go standard library — package os (официальная документация), Go standard library — package io (официальная документация)
Частые вопросы
Чем os.ReadFile отличается от os.Open + io.ReadAll?
Почему bufio.Scanner молча теряет данные при длинных строках?
Безопасен ли os.Rename для атомарной замены файла?
Что будет, если не вызвать Sync перед Rename?
Как правильно обрабатывать ошибку Close при записи в defer?
У меня Go 1.15 — что из статьи не сработает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.