1. Зачем нужна буферизация
Когда вы только начинаете писать программы, легко думать так: «Ну я же просто записываю текст в файл — что тут может быть дорого?». Но I/O — штука с характером. Любое реальное чтение/запись часто упирается в системные вызовы, драйверы, файловую систему, кеши ОС и всё такое, что точно не выполняется за “одну наносекунду”.
Буферизация — это идея «не дёргать нижний уровень слишком часто». Вместо того чтобы делать 10 000 маленьких записей по 1–10 байт, мы складываем данные в память и отдаём вниз более крупными порциями. То же самое с чтением: мы читаем из файла/сокета «оптом» в память, а потом уже выдаём вашему коду кусочки, которые он просит.
Представьте курьера: если курьер будет ездить за каждой одной скрепкой, вы разоритесь. Если он заберёт коробку скрепок за один раз — вы красавчик-логист. bufio в Go — это как раз «коробка скрепок» между вашим кодом и реальным источником/приёмником байт.
2. bufio.Reader: чтение поверх io.Reader
bufio.Reader — это обёртка над любым io.Reader. Снаружи он выглядит как обычный ридер, но внутри держит буфер, куда заранее подчитывает данные из нижнего источника. Это даёт два практических плюса: во‑первых, вы меньше «беспокоите» нижний I/O, а во‑вторых, получаете удобные методы вроде чтения «до разделителя».
Как создать bufio.Reader и что он “оборачивает”
Начнём с самого простого: bufio.NewReader(r) принимает любой io.Reader. Это может быть файл (*os.File), строковый ридер (strings.NewReader), сетевое соединение — что угодно. И это важно: bufio — не «про файлы», а «про поток байт».
package main
import (
"bufio"
"fmt"
"strings"
)
func main() {
r := bufio.NewReader(strings.NewReader("hello\nworld\n"))
fmt.Println(r.Size()) // например: 4096 (размер буфера по умолчанию)
}
Здесь мы не делаем ничего полезного, но ловим идею: у bufio.Reader есть внутренний буфер. Он не обязан совпадать с тем, что вы читаёте.
Чтение до "\n": ReadString и последняя строка
В учебных задачах (и в реальных CLI‑утилитах) очень часто нужно читать текст «построчно». Да, можно читать байты и вручную искать "\n", но это тот случай, когда вы либо пишете свой bufio.Reader, либо просто используете bufio.Reader. Мы не настолько богаты временем.
ReadString('\n') читает данные до разделителя и возвращает строку. Важная деталь: если "\n" найден, он обычно включается в результат. Это удобно, но иногда неожиданно.
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func main() {
r := bufio.NewReader(strings.NewReader("a\nb\nlast"))
for {
line, err := r.ReadString('\n')
if len(line) > 0 {
fmt.Printf("line=%q\n", line) // line="a\n", потом "b\n", потом "last"
}
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read error:", err)
return
}
}
}
Ключевой момент здесь не в ReadString, а в обработке конца потока: последняя строка может прийти без "\n". И тогда ReadString('\n') вернёт кусок данных (например, "last") и ошибку io.EOF одновременно. Если вы сначала проверите err, а потом решите, что “EOF — всё, выходим”, вы потеряете последнюю строку и будете грустить.
Эта «двойная» ситуация (len(line) > 0 и err == io.EOF) — классика потокового протокола и прямое продолжение того, что вы делали с (n, err) на «сыром» Read.
ReadBytes: то же, но в []byte
Иногда вам удобнее работать с байтами, а не со строками. Например, вы хотите потом сделать bytes.TrimSpace, или вы обрабатываете данные как «сырые» bytes.
ReadBytes('\n') похож на ReadString('\n'), но возвращает []byte.
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func main() {
r := bufio.NewReader(strings.NewReader("x\ny\n"))
for {
b, err := r.ReadBytes('\n')
if len(b) > 0 {
fmt.Printf("chunk=%q\n", b) // chunk="x\n", chunk="y\n"
}
if err == io.EOF {
break
}
if err != nil {
fmt.Println("read error:", err)
return
}
}
}
Смысл тот же: сначала обрабатываем то, что получили, и только потом решаем, что делать с ошибкой.
3. bufio.Writer: запись и Flush()
Если bufio.Reader делает чтение удобнее, то bufio.Writer делает запись одновременно и быстрее, и… опаснее для новичка. Потому что появляется новый шаг: данные могут «застрять» в буфере и не попасть в файл/вывод, пока вы не сделаете Flush().
И да: это ровно тот момент, когда программист впервые в жизни пишет программу, а она «иногда не записывает последние строки». Звучит как мистика, а на деле — просто забыли Flush().
Как работает bufio.Writer и где лежат данные до Flush()
bufio.NewWriter(w) принимает любой io.Writer (файл, os.Stdout, bytes.Buffer) и начинает копить данные во внутреннем буфере. В какой-то момент буфер переполняется — и bufio.Writer сам проталкивает часть данных вниз. Но если вы записали мало данных, то они могут так и остаться внутри, пока вы не скажете: «Всё, завершаем, выталкивай остаток».
Эта команда и называется Flush().
Чтобы почувствовать это руками, удобно взять bytes.Buffer как «нижний» writer.
package main
import (
"bufio"
"bytes"
"fmt"
)
func main() {
var out bytes.Buffer
bw := bufio.NewWriter(&out)
_, _ = bw.WriteString("hello")
fmt.Println("now:", out.String()) // now: "" (пока пусто!)
_ = bw.Flush()
fmt.Println("after flush:", out.String()) // after flush: "hello"
}
Да, мы тут проигнорировали ошибки у WriteString, потому что пример маленький. В реальном коде — не игнорируем.
Flush() — часть протокола записи
Очень важно эмоционально принять: Flush() — это полноценная I/O‑операция. Она может вернуть ошибку. Иногда именно на Flush() ошибка и проявляется, потому что до этого данные сидели в памяти и никуда реально не писались.
Интересная деталь: bufio.Writer внутри ведёт себя как «копилка ошибок»: при записи он может запомнить первую ошибку и дальше делать операции “no-op”, а итоговую проблему вы увидите на Flush().
Отсюда практическое правило: если вы использовали bufio.Writer, то Flush() должен стать у вас таким же обязательным шагом, как «закрыть файл».
Close() файла и Flush() буфера — разные сущности
Очень частая мысль: «Ну я же сделал defer f.Close(), значит всё закроется и запишется». Так вот: Close() закрывает файл, но не обязано автоматически “вытолкнуть” то, что вы накопили в bufio.Writer, потому что буфер — это другой объект.
Правильная последовательность обычно такая: сначала Flush(), потом Close() (или defer Close(), но Flush() всё равно делаем явно).
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
f, err := os.Create("tasks.txt")
if err != nil {
fmt.Println("create:", err)
return
}
defer f.Close()
bw := bufio.NewWriter(f)
if _, err := bw.WriteString("first line\n"); err != nil {
fmt.Println("write:", err)
return
}
if err := bw.Flush(); err != nil {
fmt.Println("flush:", err)
return
}
}
Да, можно сделать defer bw.Flush(), но это тонкий момент: во‑первых, вы тогда должны корректно обработать ошибку Flush() (а defer-ошибки часто “теряются”, если не думать). Во‑вторых, иногда вы хотите явно понимать: «вот здесь запись точно завершена».
Карта в голове: bufio.Reader vs bufio.Writer
Чтобы не путаться в ощущениях, полезно держать перед глазами простую схему: где именно лежат данные и кто кого «дёргает».
flowchart TD
subgraph R["ЧТЕНИЕ (Reader)"]
A["ваш код"] --> B["bufio.Reader\n(буфер в памяти)"] --> C["нижний io.Reader\n(файл / сеть / строка)"]
end
flowchart TD
subgraph W["ЗАПИСЬ (Writer)"]
D["ваш код"] --> E["bufio.Writer\n(буфер в памяти)"] --> F["нижний io.Writer\n(файл / stdout / буфер)"]
E -.-> G["Flush()"]
G -.-> F
end
Смысл в том, что при чтении bufio.Reader “подкачивает” данные заранее, а при записи bufio.Writer “дожимает” данные вниз только когда вы попросите или когда буфер переполнится. Поэтому Flush() — центральный ритуал, который нельзя пропускать.
И да, это тот самый случай, когда в программировании полезно иметь ритуалы. Как чистить зубы. Только Flush() иногда возвращает ошибку.
4. Мини‑приложение: “таск‑лист в файле”
Теперь давайте соберём маленький кусок кода, который выглядит как часть реального приложения: у нас есть список задач, и мы хотим сохранить его в файл и потом прочитать. Мы не делаем “идеальную архитектуру” (это отдельная тема), но пишем достаточно аккуратно, чтобы код не был одноразовым.
Представим формат хранения максимально простой: одна строка = одна задача. Никаких JSON, CSV и прочей радости — просто текст.
Запись задач построчно через bufio.Writer
Начнём с функции, которая принимает файл и записывает туда задачи. Для простоты считаем, что каждая задача — строка.
package main
import (
"bufio"
"fmt"
"io"
)
func WriteTasks(w io.Writer, tasks []string) error {
bw := bufio.NewWriter(w)
for _, t := range tasks {
if _, err := bw.WriteString(t + "\n"); err != nil {
return fmt.Errorf("write task: %w", err)
}
}
if err := bw.Flush(); err != nil {
return fmt.Errorf("flush tasks: %w", err)
}
return nil
}
Здесь важно сразу два момента. Мы не печатаем ошибки внутри функции, а возвращаем их наверх (по‑Go‑шному). И мы считаем Flush() частью контракта: если не “дожали” буфер — значит запись не завершена.
Чтение задач построчно через bufio.Reader
Теперь прочитаем файл обратно. Мы используем ReadString('\n'). При этом аккуратно обрабатываем конец файла и последнюю строку без "\n".
package main
import (
"bufio"
"fmt"
"io"
"strings"
)
func ReadTasks(r io.Reader) ([]string, error) {
br := bufio.NewReader(r)
var tasks []string
for {
line, err := br.ReadString('\n')
if len(line) > 0 {
tasks = append(tasks, strings.TrimRight(line, "\n"))
}
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("read task line: %w", err)
}
}
return tasks, nil
}
Да, strings.TrimRight(line, "\n") выглядит чуть странно (почему не TrimSpace?), но это осознанно: мы убираем только перевод строки, а пробелы в задаче пусть остаются (вдруг пользователь реально хочет задачу "купить молоко " — люди разные).
Склеим вместе: сохранить и прочитать
Теперь покажем маленький main, который записывает задачи в файл и тут же читает обратно. Это не «продуктовый сценарий», а просто проверка.
package main
import (
"fmt"
"os"
)
func main() {
tasks := []string{"buy milk", "learn Go", "sleep"}
f, err := os.Create("tasks.txt")
if err != nil {
fmt.Println("create:", err)
return
}
if err := WriteTasks(f, tasks); err != nil {
fmt.Println("write tasks:", err)
_ = f.Close()
return
}
_ = f.Close()
in, err := os.Open("tasks.txt")
if err != nil {
fmt.Println("open:", err)
return
}
defer in.Close()
got, err := ReadTasks(in)
if err != nil {
fmt.Println("read tasks:", err)
return
}
fmt.Println("loaded:", got) // loaded: [buy milk learn Go sleep]
}
Обратите внимание на тонкий момент: мы закрыли файл после записи, а потом открыли заново на чтение. Это не строго обязательно, но в таких мини‑проверках помогает мыслить ясно: запись завершили, файл закрыли, потом читаем заново.
5. Типичные ошибки при работе с bufio.Reader/Writer
Ошибка №1: “Я записал, но в файле пусто / нет последней строки”.
Это почти всегда забытый Flush(). bufio.Writer может хранить данные в памяти и не отдавать их нижнему writer’у сразу. Если программа завершилась, а буфер не был сброшен, вы теряете хвост данных. Исправление простое: если используете bufio.Writer, то в конце записи обязателен Flush() и обязательная проверка его ошибки.
Ошибка №2: путать Close() и Flush() и надеяться, что “оно само”.
Close() закрывает ресурс (например, файл), но bufio.Writer — отдельная обёртка со своим внутренним состоянием. Нужно сначала завершить работу буфера (обычно Flush()), а потом закрыть файл. Если пытаться закрыть файл, не сбросив буфер, вы можете получить частично записанный результат или странные эффекты.
Ошибка №3: терять последнюю строку при чтении через ReadString('\n').
Если файл не заканчивается переводом строки (это абсолютно нормально), то последняя порция данных вернётся вместе с io.EOF. Если ваш код устроен как “если ошибка — выходим”, вы теряете данные. Надёжный паттерн: сначала обрабатываем line (если она не пустая), потом проверяем err, и io.EOF воспринимаем как штатное завершение.
Ошибка №4: создавать bufio.Writer/bufio.Reader на каждую операцию.
Иногда новички делают обёртку внутри цикла: на каждой итерации создают новый bufio.NewWriter(f) и пишут одну строку. Это почти полностью убивает смысл буферизации, потому что вы постоянно создаёте новый буфер и не получаете накопления. Буферизатор обычно создают на поток целиком: один bufio.Writer на весь процесс записи, один bufio.Reader на весь процесс чтения.
Ошибка №5: игнорировать ошибки Flush() и считать, что раз WriteString не ругнулся, значит всё хорошо.
Во‑первых, Flush() — это I/O и он может упасть. Во‑вторых, сама модель работы bufio.Writer предполагает накопление и отложенную фиксацию ошибок: он может вести себя как “копилка первой ошибки”, а наружу вы узнаете проблему именно на Flush().
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ