JavaRush /Курсы /Go SELF /bufio.Reader/Writer: буферизация и Flush()

bufio.Reader/Writer: буферизация и Flush()

Go SELF
40 уровень , 1 лекция
Открыта

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().

1
Задача
Go SELF, 40 уровень, 1 лекция
Недоступна
Буфер и Flush
Буфер и Flush
1
Задача
Go SELF, 40 уровень, 1 лекция
Недоступна
Чтение до EOF
Чтение до EOF
1
Задача
Go SELF, 40 уровень, 1 лекция
Недоступна
Файл с заметками
Файл с заметками
1
Задача
Go SELF, 40 уровень, 1 лекция
Недоступна
Нумерация потока
Нумерация потока
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ