Deep Engineering
Средний·Опубликовано·55 МИН

io.Reader и io.Writer: контракт из двух методов

Собеседование по io идёт лестницей: что такое Reader — как написать цикл чтения — почему Read вернул меньше, чем просили, — как копирует io.Copy и сколько берёт памяти — зачем bufio и что будет без Flush — что не так с defer f.Close(). Прогон отвечает на каждый шаг: цикл, который смотрит на ошибку раньше байтов, теряет последний кусок; io.Copy гонит 100 000 байт одним Write, если у источника есть WriterTo; а функция с bufio и defer f.Close() возвращает nil и оставляет на диске пустой файл.

Полное техническое изложение

TL;DR

io.Reader и io.Writer — интерфейсы из одного метода каждый: Read(p) кладёт в p до len(p) байт, Write(p) пишет p и, если записал меньше len(p), обязан вернуть ошибку. Оба возвращают (n int, err error) — сколько байт прошло и что случилось. На этих двух сигнатурах держится всё: файл, сетевое соединение, gzip, bufio, bytes.Buffer, sha256 и io.Copy, который соединяет любой источник с любым приёмником.

Read может вернуть данные и ошибку в одном вызове — и обрабатывать их надо в этом порядке: сначала n байт, потом err. Документация разрешает два конца потока: последний кусок с nil и затем (0, EOF) — или последний кусок сразу вместе с EOF. Цикл if err != nil { break } перед обработкой n собирает на strings.Reader всю строку "hello, world", а на читателе второго вида — "hello, wor": два байта пропали без единой ошибки.

Дальше — то, что отличает понимание от пересказа. Read не обязан заполнять буфер: на буфер 8 читатель отдал 4 байта, и заполнить буфер целиком гарантирует io.ReadFull — а на коротком входе он отвечает io.ErrUnexpectedEOF. io.Copy берёт буфер 32 КиБ, но сначала ищет WriterTo у источника и ReaderFrom у приёмника: из *bytes.Reader 100 000 байт ушли одним Write, без единого выделения памяти. bufio.Writer превратил 1000 записей в 3, но третья ушла вниз только на Flush, и ошибка записи пришла тоже только на Flush. А функция с defer f.Close() и bufio без Flush вернула nil и оставила на диске файл в 0 байт. Цену буфера в одной конфигурации урок измерил: 10 000 записей по 16 байт в файл во временной папке, go1.24.7, linux/amd64 — 4248 мкс без bufio и 65 мкс с ним. Почему разница есть, объясняет механизм; сколько она составит у вас, надо мерить у себя.

Порог входа
Перед уроком достаточно понимать
  • как объявить тип и метод на нём — это урок о структурах;
  • что такое срез байт []byte, его длина и ёмкость; достаточно уровня «как пользоваться», подробно — в уроке о срезах;
  • что тип реализует интерфейс, если у него есть все методы интерфейса, — объявлять это не нужно; подробно — в уроке об интерфейсах, он стоит в линии позже;
  • что ошибка в Go — обычное возвращаемое значение error, которое сравнивают с nil, — подробно в уроке об ошибках;
  • что defer откладывает вызов до выхода из функции — подробно в уроке о defer, panic и recover.
Заранее знать не нужно
  • что именно обещает Read и почему ошибка приходит вместе с данными;
  • io.ReadFull, io.Copy, WriterTo и ReaderFrom, обёртки MultiWriter, TeeReader, LimitReader, Pipe;
  • bufio.Writer, Flush и ошибка, которую теряют в defer f.Close().

Что здесь на самом деле спрашивают

Лестница обычно такая:

  1. «Что такое io.Reader?» — разминка; сигнатуру помнят все.
  2. «Напишите цикл, который читает поток до конца» — здесь проверяют, в каком порядке кандидат смотрит на n и на err.
  3. «Почему Read вернул меньше, чем размер буфера?» — короткое чтение и io.ReadFull.
  4. «Как io.Copy копирует и сколько памяти ему нужно?» — буфер 32 КиБ и два интерфейса, которые позволяют без него обойтись.
  5. «Зачем bufio и что будет, если забыть Flush?»
  6. «Что не так с defer f.Close()?» — про ошибку, которую возвращает запись, сделанная при закрытии.

Стержень у лестницы один: Read и Write обещают только одно — сколько байт прошло за этот вызов и что случилось. Ни «весь буфер», ни «конец отдельно от данных», ни «записано на диск» в контракт не входят. Всё, на чём ловят на собеседовании, — попытка прочитать в контракте больше, чем в нём написано.

База: два метода и что они обещают

Фрагмент io/io.go — определения интерфейсов, комментарии опущены:

GO
type Reader interface {
    Read(p []byte) (n int, err error)
}
 
type Writer interface {
    Write(p []byte) (n int, err error)
}

Буфер приносит вызывающий: Read пишет в p, Write читает из p. Поэтому ни один из методов не обязан сам выделять память под данные, и поэтому же обоим документация запрещает удерживать p после возврата. Главная фраза контракта Read:

Read reads up to len(p) bytes into p. It returns the number of bytes read (0 <= n <= len(p)) and any error encountered.

перевод

Read читает до len(p) байт в p. Он возвращает число прочитанных байт (0 <= n <= len(p)) и любую встреченную ошибку.

— Пакет io — Reader
контракт языкаГарантии контракта Read, Write, ReadFull, Copy и bufio.Writer записаны в документации пакетов (go doc, go1.24.7); прогон bench/goio/internals.go их иллюстрирует, а не устанавливает. Размеры буферов, нарезка кусков трубой и ReadFrom, обход большой записи в bufio — поведение реализации конкретной версии; где это важно, оно помечено отдельно.

«До len(p)» — ключевые слова. У Write обещание другое: если он записал не весь срез, то обязан объяснить почему, — Write must return a non-nil error if it returns n < len(p) (Write обязан вернуть ошибку, не равную nil, если возвращает n < len(p)). Контракты несимметричны, и эту таблицу стоит запомнить сразу — на ней держатся все механизмы ниже:

Read(p)Write(p)
сколько байтот 0 до len(p), сколько естьlen(p); меньше — только с ошибкой
n > 0 вместе с err != nilбывает: сначала обработать nбывает, в том числе при n == len(p); если записано меньше len(p), ошибка обязательна
конец данныхio.EOF — не сбой, а сигнал—
буфер pудерживать нельзяудерживать и менять нельзя

Этого достаточно для ответа уровня Junior на первый вопрос: io.Reader — интерфейс с одним методом Read(p []byte) (n int, err error): он кладёт в p до len(p) байт и возвращает, сколько положил. io.Writer — то же для записи: если записал меньше len(p), обязан вернуть ошибку. Любой тип с таким методом — файл, соединение, буфер — подходит туда, где ждут интерфейс. Почему интерфейсу не нужно объявление «implements» и что лежит в интерфейсной переменной, разобрано в уроке об интерфейсах.

Правило Write работает в одну сторону: короткая запись без ошибки — нарушение контракта, а полная запись с ошибкой — нет. Писатель вправе принять все байты и всё же сообщить о проблеме, и вызывающий проверяет err даже при n == len(p). Прогон проверяет обе стороны.

Вывод bench/goio/internals.go, раздел 9:

9) WRITE: ПОЛНАЯ ЗАПИСЬ ВМЕСТЕ С ОШИБКОЙ
────────────────────────────────────────
  писатель возвращает len(p) и errQuota — контракт Write это допускает:
    io.Copy (общий цикл)        -> 12, err=quota exceeded
    io.Copy (*strings.Reader)   -> 12, err=quota exceeded
    bufio: Flush                -> quota exceeded
  писатель возвращает len(p)/2 и nil — контракт нарушен:
    io.Copy (общий цикл)        -> 6, err=short write

io.Copy и bufio ошибку полной записи передают наверх: все 12 байт засчитаны, и всё же копирование вернуло quota exceeded. Короткую запись без ошибки цикл io.Copy превратил в short write — подробнее в Механизме 3.

На чём проверено: прогоны — go1.24.7 и go1.25.1, linux/amd64; цитаты документации сверены по go doc 14.10.2026. На эту дату уже вышли Go 1.26 и 1.27; урок на них не запускался.

Механизм 1: данные приходят вместе с ошибкой

Первый вопрос, на котором ошибаются, — порядок. Документация Read разрешает читателю два способа закончить поток:

When Read encounters an error or end-of-file condition after successfully reading n > 0 bytes, it returns the number of bytes read. It may return the (non-nil) error from the same call or return the error (and n == 0) from a subsequent call.

перевод

Если Read встречает ошибку или конец файла после того, как успешно прочитал n > 0 байт, он возвращает число прочитанных байт. Он может вернуть (не nil) ошибку из того же вызова или вернуть ошибку (и n == 0) из следующего.

— Пакет io — Reader

Прогон читает поток "hello, world" буфером в 5 байт четырьмя читателями. strings.Reader, *os.File и io.Pipe отдают последний кусок с nil, а EOF — следующим вызовом с n=0. iotest.DataErrReader из стандартной библиотеки отдаёт EOF в том же вызове, что и последний кусок: он для того и сделан, чтобы проверять код на таком поведении.

Вывод bench/goio/internals.go, раздел 1, — по блоку на читателя:

  поток "hello, world", 12 байт, буфер 5 байт
  strings.NewReader
    Read #1 -> n=5 "hello"    err=nil
    Read #2 -> n=5 ", wor"    err=nil
    Read #3 -> n=2 "ld"       err=nil
    Read #4 -> n=0 ""         err=EOF
  *os.File
    Read #1 -> n=5 "hello"    err=nil
    Read #2 -> n=5 ", wor"    err=nil
    Read #3 -> n=2 "ld"       err=nil
    Read #4 -> n=0 ""         err=EOF
  io.Pipe, писатель: Write("hel"), Write("lo, wor"), Write("ld")
    Read #1 -> n=3 "hel"      err=nil
    Read #2 -> n=5 "lo, w"    err=nil
    Read #3 -> n=2 "or"       err=nil
    Read #4 -> n=2 "ld"       err=nil
    Read #5 -> n=0 ""         err=EOF
  iotest.DataErrReader(strings.NewReader)
    Read #1 -> n=5 "hello"    err=nil
    Read #2 -> n=5 ", wor"    err=nil
    Read #3 -> n=2 "ld"       err=EOF

Теперь цикл, который пишут чаще всего, — «прочитал, проверил ошибку, обработал».

Фрагмент — функция readBroken из bench/goio/internals.go, по смыслу:

GO
for {
    n, err := r.Read(buf)
    if err != nil {
        break
    }
    out = append(out, buf[:n]...)
}

Вывод, раздел 2:

2) ЦИКЛ, КОТОРЫЙ СНАЧАЛА СМОТРИТ НА ОШИБКУ
──────────────────────────────────────────
  if err != nil { break } до обработки n байт:
    strings.NewReader          -> "hello, world"
    *os.File                   -> "hello, world"
    io.Pipe ("hel", "lo, wor", "ld") -> "hello, world"
    DataErrReader              -> "hello, wor"  (потеряно 2 байта)

На первых трёх читателях цикл работает, на четвёртом теряет "ld" — последний кусок пришёл вместе с EOF, и break выбросил его необработанным. Ошибки при этом нет: функция вернула короткую строку как ни в чём не бывало.

Поэтому такой цикл легко проходит тесты: их обычно гоняют на strings.NewReader или на файле — на том виде читателя, где он верен. Ломается он на другом источнике, который вправе отдавать хвост вместе с EOF, и ломается тихо. Документация говорит, как надо:

Callers should always process the n > 0 bytes returned before considering the error err.

перевод

Вызывающий всегда должен обработать возвращённые n > 0 байт, прежде чем смотреть на ошибку err.

— Пакет io — Reader

Фрагмент — функция readCorrect из bench/goio/internals.go, по смыслу:

GO
for {
    n, err := r.Read(buf)
    out = append(out, buf[:n]...) // сначала байты
    if err == io.EOF {
        return out, nil
    }
    if err != nil {
        return out, err
    }
}

Вывод, раздел 2 (продолжение):

  сначала n байт, потом ошибка:
    strings.NewReader          -> "hello, world"
    io.Pipe ("hel", "lo, wor", "ld") -> "hello, world"
    DataErrReader              -> "hello, world"
  io.ReadAll(DataErrReader)    -> "hello, world" err=nil

Два уточнения, о которых спрашивают следом. io.EOF сравнивают через ==, и это не небрежность: документация требует, чтобы Read must return EOF itself, not an error wrapping EOF, because callers will test for EOF using == (Read возвращал сам EOF, а не ошибку, оборачивающую EOF, потому что вызывающие проверяют EOF через ==). Обёртки и errors.Is — это про ошибки, которые поднимаются по стеку; о них — урок об ошибках. И свой цикл писать обычно не нужно: io.ReadAll в прогоне собрал всю строку и с DataErrReader, а цикл io.Copy в исходнике сначала пишет nr > 0 байт и только потом смотрит на ошибку (io/io.go, строки 430 и 448 на go1.24.7). Успешный io.Copy возвращает nil, а не EOF.

Правильный цикл и запись через bufio с проверкой Flush помещаются в короткую программу. Полный файл bench/goio/cases/example/main.go — его можно скопировать в пустой каталог рядом с go.mod (module example, go 1.24) и запустить командой go run .:

GO
// Полный короткий пример урока «io.Reader и io.Writer»: цикл чтения по
// контракту Read и запись через bufio с проверкой Flush.
//
// Запуск в каталоге с этим файлом и go.mod: go run .
package main
 
import (
	"bufio"
	"fmt"
	"io"
	"os"
	"strings"
	"testing/iotest"
)
 
// readAll читает r до конца: сначала n байт, потом ошибка.
func readAll(r io.Reader) (string, error) {
	var out []byte
	buf := make([]byte, 5)
	for {
		n, err := r.Read(buf)
		out = append(out, buf[:n]...)
		if err == io.EOF {
			return string(out), nil
		}
		if err != nil {
			return string(out), err
		}
	}
}
 
func main() {
	// DataErrReader отдаёт последний кусок вместе с io.EOF.
	s, err := readAll(iotest.DataErrReader(strings.NewReader("hello, world")))
	fmt.Printf("readAll: %q, err=%v\n", s, err)
 
	w := bufio.NewWriter(os.Stdout)
	n, err := io.Copy(w, strings.NewReader("line through bufio\n"))
	if err == nil {
		err = w.Flush() // без Flush строка осталась бы в буфере
	}
	fmt.Printf("io.Copy: %d bytes, err=%v\n", n, err)
}

Вывод bench/goio/example.sh:

go1.24.7 linux/amd64 | полный пример: go run .

readAll: "hello, world", err=<nil>
line through bufio
io.Copy: 19 bytes, err=<nil>

Механизм 2: короткое чтение — Read не обязан заполнять буфер

Вторая половина того же «до len(p)»:

If some data is available but not len(p) bytes, Read conventionally returns what is available instead of waiting for more.

перевод

Если данные есть, но не len(p) байт, Read по соглашению возвращает то, что есть, а не ждёт остального.

— Пакет io — Reader

В прогоне буфер на 8 байт, а поток длиннее.

Вывод bench/goio/internals.go, раздел 3:

3) КОРОТКОЕ ЧТЕНИЕ: READ НЕ ОБЯЗАН ЗАПОЛНЯТЬ БУФЕР
──────────────────────────────────────────────────
  HalfReader.Read(buf[8])      -> n=4 "hell" err=nil
  OneByteReader.Read(buf[8])   -> n=1 "h" err=nil
  io.Pipe, писатель пишет "hel", "lo, wor", "ld"; читатель — буфером 8:
    Read -> n=3 "hel" err=nil
    Read -> n=7 "lo, wor" err=nil
    Read -> n=2 "ld" err=nil
    Read -> n=0 "" err=EOF

iotest.HalfReader и iotest.OneByteReader — тестовые обёртки, которые нарочно отдают меньше. Труба показывает то же без всякого умысла: каждый Read получил ровно то, что положил один Write на другой стороне, — 3, 7 и 2 байта, хотя буфер вмещал 8. В Механизме 1 тот же поток, читаемый буфером 5, пришёл кусками 3, 5, 2 и 2: один Write на 7 байт разошёлся на два Read. Границы кусков задаёт источник, а не буфер.

Отсюда классическая ошибка с заголовком фиксированной длины.

Фрагмент-иллюстрация, в прогоне его нет:

GO
hdr := make([]byte, 8)
n, err := r.Read(hdr) // n может оказаться 4

Код, который дальше разбирает все 8 байт, прочтёт половину мусора. Для «ровно столько байт» есть io.ReadFull:

ReadFull reads exactly len(buf) bytes from r into buf.

перевод

ReadFull читает из r в buf ровно len(buf) байт.

— Пакет io — ReadFull

Вывод, раздел 3 (продолжение):

  io.ReadFull(HalfReader, buf[8]) -> n=8 "hello, w" err=nil
  io.ReadFull(5 байт, buf[8])     -> n=5 "hello" err=unexpected EOF
  io.ReadFull(0 байт, buf[8])     -> n=0 err=EOF
  errors.Is(ErrUnexpectedEOF, EOF) -> false

HalfReader отдаёт по половине, а ReadFull зовёт его, пока не наберёт 8. Интереснее два нижних случая: EOF и ErrUnexpectedEOF — разные ответы. EOF значит «данных не было вовсе» — поток кончился ровно на границе записи, и это нормальный конец. ErrUnexpectedEOF значит «запись началась и оборвалась» — входные данные обрезаны. errors.Is их не путает, и цикл чтения записей фиксированной длины так и пишется: io.EOF — выйти, io.ErrUnexpectedEOF — сообщить о битом входе.

Механизм 3: io.Copy — буфер 32 КиБ или чужой метод

io.Copy(dst, src) — тот самый цикл из Механизма 1, написанный один раз. Но прежде чем гонять его, Copy спрашивает, не умеют ли стороны скопировать сами:

If src implements WriterTo, the copy is implemented by calling src.WriteTo(dst). Otherwise, if dst implements ReaderFrom, the copy is implemented by calling dst.ReadFrom(src).

перевод

Если src реализует WriterTo, копирование выполняется вызовом src.WriteTo(dst). Иначе, если dst реализует ReaderFrom, — вызовом dst.ReadFrom(src).

— Пакет io — Copy

Как это выглядит в коде — io/io.go, строки 407–419:

деталь реализации · Go 1.24Порядок проверок записан в документации io.Copy; размер буфера 32 * 1024 и уменьшение под LimitedReader — исходник io/io.go конкретной версии (строки 410, 414 и 418 на go1.24.7 и go1.25.1), а не гарантия.
GO
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
	// If the reader has a WriteTo method, use it to do the copy.
	// Avoids an allocation and a copy.
	if wt, ok := src.(WriterTo); ok {
		return wt.WriteTo(dst)
	}
	// Similarly, if the writer has a ReadFrom method, use it to do the copy.
	if rf, ok := dst.(ReaderFrom); ok {
		return rf.ReadFrom(src)
	}
	if buf == nil {
		size := 32 * 1024
		if l, ok := src.(*LimitedReader); ok && int64(size) > l.N {

Прогон проверяет каждую ветку счётчиками: обёртка вокруг источника считает вызовы Read и запоминает len(p), обёртка-приёмник — вызовы Write. Обёртки нарочно прячут все методы, кроме своего, — иначе Copy нашёл бы WriterTo у *bytes.Reader внутри.

Вывод bench/goio/internals.go, раздел 4:

4) IO.COPY: БУФЕР 32 КБ ИЛИ ЧУЖОЙ МЕТОД
───────────────────────────────────────
  копируется 100000 байт
  только Read -> только Write:   Read ×5, len(p)=32768; Write ×4 [32768 32768 32768 1696]; скопировано 100000
  *bytes.Reader (WriterTo):     Read ×0; Write ×1 [100000]; скопировано 100000
  -> *bytes.Buffer (ReaderFrom): Read ×10, первые len(p)=[512 512 1024]; скопировано 100000
  io.LimitReader(src, 100):     Read ×1, len(p)=100; Write ×1; скопировано 100
  всего выделено в куче (TotalAlloc) за один io.Copy, среднее по 1000:
    общий путь (буфер io.Copy)  выделений: 1, 32.0 КиБ
    через WriterTo              выделений: 0, < 1 КиБ

Четыре строки — четыре ветки исходника. Общий путь: буфер на 32 768 байт, одно выделение на каждый вызов Copy, четыре Write и пятый Read, вернувший EOF. WriterTo: *bytes.Reader отдал приёмнику весь срез одним Write, Read не вызывался, памяти не выделено. ReaderFrom: читает уже *bytes.Buffer — своими кусками, начиная с 512 байт. LimitReader: буфер ужат до 100 байт, потому что больше всё равно не прочитать.

Кто в стандартной библиотеке умеет так обходить буфер.

Вывод, раздел 5:

5) КТО В СТАНДАРТНОЙ БИБЛИОТЕКЕ РЕАЛИЗУЕТ WRITERTO И READERFROM
───────────────────────────────────────────────────────────────
  *strings.Reader    WriterTo: true  ReaderFrom: false
  *bytes.Reader      WriterTo: true  ReaderFrom: false
  *bytes.Buffer      WriterTo: true  ReaderFrom: true
  *bufio.Reader      WriterTo: true  ReaderFrom: false
  *bufio.Writer      WriterTo: false ReaderFrom: true
  *os.File           WriterTo: true  ReaderFrom: true
  *net.TCPConn       WriterTo: true  ReaderFrom: true
  *io.PipeReader     WriterTo: false ReaderFrom: false
  *io.LimitedReader  WriterTo: false ReaderFrom: false
  io.Discard         WriterTo: false ReaderFrom: true

Практический вывод — обёртка прячет методы того, что обёрнуто. Счётчик из прогона, io.LimitReader, любой ваш тип с одним Read выключают WriterTo источника: Copy видит только методы внешнего типа. ReaderFrom у приёмника при этом остаётся — в прогоне onlyReader → *bytes.Buffer всё равно пошёл через ReadFrom. Это следствие того, как устроено утверждение типа к интерфейсу, а не особенность io.

Обёртки из io данные не копят. MultiWriter раздаёт каждую запись всем писателям по очереди, TeeReader пишет прочитанное в писатель прямо внутри Read, LimitReader обрезает поток, Pipe соединяет код, который пишет, с кодом, который читает. Про Pipe документация говорит прямо: The data is copied directly from the Write to the corresponding Read (or Reads); there is no internal buffering (данные копируются напрямую из Write в соответствующий Read (или несколько Read); внутренней буферизации нет).

Вывод, раздел 6:

6) КОМПОЗИЦИЯ: ОБЁРТКИ НЕ КОПЯТ ДАННЫЕ
──────────────────────────────────────
  поток 1048576 байт
  MultiWriter(a, b):  a получил 1048576 байт за 32 Write, b — 1048576 за 32
  TeeReader(r, sha256): записано 1048576 байт, хеш совпал с sha256.Sum256: true
  LimitReader(r, 1000): записано 1000 байт
  Pipe, один Write на 1048576 байт: читатель получил 1048576 байт за 32 Write по 32768
  всего выделено в куче (TotalAlloc) за одно копирование 1 МиБ, среднее по 200:
    io.Copy(w, *bytes.Reader)      выделений: 0, < 1 КиБ
    io.Copy(MultiWriter(w1, w2))   выделений: 2, < 1 КиБ
    io.Copy(w, TeeReader)          выделений: 2, 32.0 КиБ
    io.Copy(w, io.Pipe)            выделений: 6, 32.5 КиБ
    io.ReadAll(*bytes.Reader)      выделений: 27, 5118.3 КиБ
  писатель вернул n < len(p) и nil: io.Copy -> 6, err=short write

Мегабайт через TeeReader или трубу стоит примерно один буфер io.Copy — 32,0 и 32,5 КиБ, а TeeReader при этом посчитал SHA-256 того же потока за один проход. Через MultiWriter из *bytes.Reader — меньше килобайта: WriterTo источника передал весь срез одной записью, а MultiWriter раздал её двум писателям. Для сравнения нижняя строка: io.ReadAll того же мегабайта выделил больше пяти мегабайт — он собирает всё в один растущий срез (подробнее в «Глубже»). Поток, который можно обработать по дороге, лучше обрабатывать по дороге.

Последняя строка — про контракт Write. Писатель вернул половину и nil, нарушив правило n < len(p) → ошибка, и общий цикл io.Copy сам превратил это в io.ErrShortWrite (io/io.go, строки 443–446 на go1.24.7). Источник в прогоне обёрнут, чтобы дело дошло до цикла: у голого *strings.Reader есть WriterTo, и ту же проверку сделал бы он. Без неё половина данных пропала бы молча.

Механизм 4: bufio.Writer — запись уходит вниз кусками, а хвост — только после Flush

bufio.Writer — писатель поверх писателя: он копит записи в буфере и отдаёт их вниз большими кусками. Счётчик под ним показывает, сколько Write доходит до нижнего писателя.

Вывод bench/goio/internals.go, раздел 7:

7) БУФЕРИЗАЦИЯ: СКОЛЬКО WRITE ДОХОДИТ ДО НИЖНЕГО ПИСАТЕЛЯ
─────────────────────────────────────────────────────────
  1000 записей по 10 байт = 10000 байт
  без bufio:                 Write внизу ×1000 дошло байт: 10000
  bufio.NewWriter, до Flush: Write внизу ×2    дошло байт:  8192, в буфере: 1808
  после Flush:               Write внизу ×3    дошло байт: 10000 [4096 4096 1808]
  размер буфера по умолчанию: 4096 байт
  один Write на 10000 байт в пустой буфер: Write внизу ×1 [10000]

Тысяча записей превратилась в три. Но третья ушла вниз только на Flush: до него нижний писатель получил 8192 байта, а 1808 лежали в буфере. Если программа закончится или функция вернётся без Flush, этих байт не будет нигде — bufio.Writer не знает, что его бросили. Документация говорит об этом прямо:

After all data has been written, the client should call the Writer.Flush method to guarantee all data has been forwarded to the underlying io.Writer.

перевод

Записав все данные, клиент должен вызвать метод Writer.Flush, чтобы гарантировать, что все данные переданы нижнему io.Writer.

— Пакет bufio — Writer

Последняя строка блока — деталь реализации: запись больше буфера в пустой буфер bufio не копирует, а отдаёт вниз напрямую (bufio/bufio.go, строки 680–683 на go1.24.7, комментарий Large write, empty buffer (большая запись, пустой буфер)).

Вторая сторона буфера — ошибка тоже откладывается. /dev/full в Linux — устройство, на котором любая запись кончается «нет места на устройстве».

Вывод, раздел 7 (продолжение):

  bufio.Writer поверх /dev/full (запись всегда ENOSPC):
    100 x Write(10 байт) без ошибки: 100 из 100
    Flush -> write /dev/full: no space left on device
    Write после ошибки -> write /dev/full: no space left on device
    без bufio, первый же f.Write -> write /dev/full: no space left on device

Сто записей «успешны»: они легли в буфер, до устройства не дошли. Ошибку вернул только Flush — и после неё bufio.Writer отвечает ею же на любой вызов:

If an error occurs writing to a Writer, no more data will be accepted and all subsequent writes, and Writer.Flush, will return the error.

перевод

Если при записи в Writer произошла ошибка, данные больше не принимаются, и все последующие записи, а также Writer.Flush, возвращают эту ошибку.

— Пакет bufio — Writer

Отсюда правило, которое и проверяют: с bufio проверять ошибку каждого Write недостаточно — проверять надо Flush. Липкость ошибки даже удобна: можно писать без проверок и проверить один раз — на Flush.

Механизм 5: Close тоже пишет — и его ошибку теряют в defer

Последний шаг той же мысли: у многих писателей последняя запись происходит не в Write, а при закрытии. Самый наглядный пример — gzip.Writer:

Close closes the Writer by flushing any unwritten data to the underlying io.Writer and writing the GZIP footer.

перевод

Close закрывает Writer: сбрасывает в нижний io.Writer всё незаписанное и пишет завершающий блок GZIP.

— Пакет compress/gzip — Writer.Close

Вывод bench/goio/internals.go, раздел 8:

8) CLOSE ТОЖЕ ПИШЕТ — И ЕГО ОШИБКУ ТЕРЯЮТ
─────────────────────────────────────────
  defer f.Close(), bufio без Flush, 100 строк: вернула nil, размер файла 0 байт
  gzip без Close: в нижнем писателе байт: 10
    чтение назад -> unexpected EOF
  gzip после Flush, без Close: в нижнем писателе байт: 62
    чтение назад -> unexpected EOF
  gzip после Close: в нижнем писателе байт: 75
    чтение назад -> 2600 байт, err=nil

Первая строка — функция, которую пишут постоянно.

Фрагмент — функция writeReportDeferOnly из bench/goio/internals.go, по смыслу:

GO
func writeReport(path string, lines int) error {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer f.Close()
    w := bufio.NewWriter(f)
    for i := 0; i < lines; i++ {
        fmt.Fprintf(w, "строка %d\n", i)
    }
    return nil
}

Сто строк вида «строка N» — 1590 байт, меньше буфера bufio по умолчанию (раздел 10 прогона: 100 строк отчёта из раздела 8: 1590 байт; буфер bufio по умолчанию: 4096 байт). Все они остались в буфере, файл закрыт пустым, а функция вернула nil. Ни одна проверка ошибок не сработала, потому что ошибки не было — была потерянная запись. С gzip то же на другом уровне: без Close в нижнем писателе только 10 байт заголовка, даже после Flush архив не читается — нет завершающего блока, и читатель отвечает unexpected EOF.

Теперь о самом defer f.Close(). Close возвращает error, а defer этот результат выбрасывает. Прогон показывает, что ошибку есть откуда взять.

Вывод, раздел 8 (продолжение):

  первый f.Close() -> nil
  второй f.Close() -> close <папка>/twice.txt: file already closed
  f.Write после Close -> write <папка>/twice.txt: file already closed
  запись в /dev/full:
    defer w.Flush(); defer f.Close()     -> функция вернула nil
    Flush проверен, Close в defer с err -> функция вернула write /dev/full: no space left on device

Функция, которая отложила и Flush, и Close, при записи в устройство без места вернула nil. Та, что проверила Flush и забирает ошибку Close в именованный результат, вернула настоящую причину. Ошибку здесь дал Flush: захват ошибки Close — страховка на случай, когда Close пишет сам, как у gzip.Writer; этот прогон её в деле не показывает.

Фрагмент — функция writeToChecked из bench/goio/internals.go под коротким именем:

GO
func writeTo(path string) (err error) {
    f, err := os.OpenFile(path, os.O_WRONLY, 0)
    if err != nil {
        return err
    }
    defer func() {
        if cerr := f.Close(); err == nil {
            err = cerr
        }
    }()
    w := bufio.NewWriter(f)
    if _, err = w.WriteString("отчёт\n"); err != nil {
        return err
    }
    return w.Flush()
}

Почему defer может менять возвращаемое значение и почему для этого нужен именованный результат, разобрано в уроке о defer, panic и recover. Здесь важно правило: у писателя последней записью считается Flush или Close, и их ошибки проверяют так же, как ошибку Write. Для файла, открытого только на чтение, defer f.Close() без проверки — обычная практика: дописывать там нечего. Возвращает ли Close у обычного файла ошибку записи, этот прогон не проверял: на локальном диске первый Close вернул nil. А гарантию, что данные дошли до диска, даёт не Close, а f.Sync() — по документации он commits the current contents of the file to stable storage (фиксирует текущее содержимое файла в постоянном хранилище), и его ошибку тоже проверяют.

Глубже: почему маленький Write дорог — и сколько стоит память

деталь реализации · Go 1.24Что f.Write у *os.File — это системный вызов write на каждый вызов, — исходник internal/poll/fd_unix.go конкретной версии, а не гарантия пакета os.
наблюдение замераbench/goio/practice.go, go1.24.7 linux/amd64, GOMAXPROCS=2, лучший из 15 кругов; файл во временной папке, записи по 16 байт, создание файла в замер не входит. Это наблюдение одной конфигурации, а не переносимое число: кратность зависит от файловой системы, ядра, размера записи и машины — на своей её нужно мерить. Счётчики вызовов и выделений — наблюдение реализации go1.24.7, а не контракт языка. Память — bench/goio/internals.go, раздел 6; под go1.25.1 вывод тот же, кроме десятой доли килобайта у io.ReadAll.

Счётчик из Механизма 4 говорит, сколько вызовов доходит вниз; замер говорит, что это значит для файла. 10 000 записей по 16 байт прямо в *os.File и через bufio.Writer.

Вывод bench/goio/practice.go, задача 2:

  10000 x f.Write(16 байт) в *os.File             4248 мкс, до файла дошло 10000 Write
  те же записи через bufio.Writer + Flush        65 мкс, до файла дошло 40 Write
  кратность                                      65
  на одну запись: 425 нс против 7 нс

Счётчик в том же прогоне показывает, что до файла дошли все 10 000 вызовов Write, а каждый f.Write у *os.File уходит в системный вызов write (internal/poll/fd_unix.go, FD.Write, строка 381 на go1.24.7: syscall.Write): 425 нс на запись. Как цена зависит от размера одной записи, замер не проверял — у него один размер, 16 байт. bufio превращает 10 000 вызовов в 40; в его 65 мкс входят и эти 40 вызовов, и копирование 10 000 записей в буфер — в среднем 7 нс на запись. Отсюда правило: много мелких записей в файл или в сокет — через буфер. Там, где Write и так пишет в память, как у bytes.Buffer, буфер лишний; а bufio.NewWriter поверх bufio.Writer с достаточно большим буфером, по документации, просто возвращает его же.

Вторая цена — память, и тут обратная ловушка. io.Copy держит в памяти один буфер на 32 КиБ, сколько бы данных ни шло (с обёртками в прогоне — не больше 32,5 КиБ). io.ReadAll держит всё.

Строка вывода bench/goio/internals.go, раздел 6:

    io.ReadAll(*bytes.Reader)      выделений: 27, 5118.3 КиБ

Это сумма всех выделений за вызов (TotalAlloc), а не память, занятая одновременно. ReadAll начинает со среза на 512 байт и растит его добавлением (io/io.go, строки 709–726 на go1.24.7); каждый рост — новый массив, а прежний становится мусором. В конце остаётся один срез:

Вывод bench/goio/internals.go, раздел 10:

  io.ReadAll(1048576 байт): len=1048576, cap=1114112 — итоговый срез

Итоговый буфер — около 1,06 МиБ; остальные ~4 МиБ из 5118,3 КиБ — промежуточные массивы, которые соберёт сборщик мусора. Сколько из них живы одновременно в пике, урок не мерил; но выделить их пришлось. А когда размер потока задаёт другая сторона — тело запроса, ответ чужого сервиса, — ReadAll без ограничения отдаёт ей решение, сколько памяти займёт ваш процесс. Если всё целиком действительно нужно в памяти — ограничьте размер (io.LimitReader); если нет — обработайте поток по дороге через io.Copy и обёртки.

Сводная таблица, которую удобно держать в голове:

задачаинструментчто проверить
прочитать поток до концаio.Copy, io.ReadAll, свой циклсначала n, потом err
прочитать ровно N байтio.ReadFullEOF против ErrUnexpectedEOF
много мелких записейbufio.Writerошибку Flush
запись со сжатием или буферомgzip.Writer, bufio.Writerошибку Close / Flush
поток в два местаio.MultiWriter, io.TeeReaderошибку любого из писателей
ограничить входio.LimitReaderчто обрезанный вход — не ошибка, а EOF

Как отвечать на собеседовании

Короткий ответ: io.Reader — интерфейс с одним методом Read(p []byte) (n int, err error): он кладёт в p до len(p) байт и возвращает, сколько положил, и ошибку. io.Writer — то же для записи, но записав меньше len(p), обязан вернуть ошибку, а ошибку проверяют и при полной записи. Читая, сначала обрабатывают n байт, потом смотрят на err, а io.EOF — это нормальный конец потока. Для готовых задач — io.Copy, io.ReadFull, bufio.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

На цикл чтения — порядок n и err. «Документация разрешает отдать последний кусок вместе с EOF. Цикл, который сначала проверяет err, на strings.Reader работает, а на iotest.DataErrReader вернул "hello, wor" вместо "hello, world" — без ошибки. Поэтому сначала buf[:n], потом err».

На «почему прочиталось меньше» — короткое чтение. «Read обещает до len(p), а не len(p): на буфер 8 читатель отдал 4. Для фиксированного размера — io.ReadFull; на обрезанном входе он вернёт ErrUnexpectedEOF, а на пустом — EOF, и это разные случаи».

На io.Copy — буфер и интерфейсы. «Сначала WriterTo у источника, потом ReaderFrom у приёмника, и только потом буфер 32 КиБ. Из *bytes.Reader 100 000 байт ушли одним Write без выделений; обёртка с одним Read этот путь выключает».

На bufio — Flush и отложенная ошибка. «Тысяча записей по 10 байт — три Write внизу, и последний только на Flush. Ошибка записи тоже придёт только на Flush: поверх /dev/full все сто Write вернули nil».

На defer f.Close() — запись при закрытии. «У gzip.Writer и bufio последняя запись — в Close и Flush. Функция с defer f.Close() и bufio без Flush вернула nil и оставила пустой файл. Поэтому Flush проверяю явно, а ошибку Close у писателя забираю в именованный результат».

Дальше спросят

Спросят дальше

Чем плох io.ReadAll для тела HTTP-запроса?

Короткий ответ

Тем, что он держит в памяти всё тело и выделяет больше, чем читает: в прогоне мегабайт обошёлся в 5118.3 КиБ выделений в сумме за 27 шагов роста (итоговый срез — 1114112 байт ёмкости, остальное — промежуточные массивы). А размер тела задаёт клиент. Поэтому тело либо ограничивают — io.LimitReader или http.MaxBytesReader в обработчике, — либо читают потоком: декодер JSON, io.Copy в файл, bufio.Scanner по строкам.

У io.LimitReader есть особенность, о которой стоит сказать: обрезанный вход он завершает обычным EOF, и код не узнает, что данные были длиннее. Если это важно, читают на один байт больше лимита и проверяют, дошли ли до него. Как это устроено в сервере — в уроке об HTTP-сервере.

Спросят дальше

Когда нужен io.Pipe?

Короткий ответ

Когда одна библиотека умеет только писать в io.Writer, а другая — только читать из io.Reader. Классический пример — сжать данные на лету и отдать их HTTP-клиенту как тело запроса: gzip.Writer пишет в PipeWriter в отдельной горутине, а запрос читает из PipeReader.

Буфера в трубе нет: каждый Write ждёт, пока данные заберут. В прогоне один Write на мегабайт читатель получил 32 кусками по 32768 байт — ровно по размеру буфера io.Copy, который читал из трубы. Две вещи обязательны: писатель должен закрыть трубу (Close или CloseWithError), иначе читатель будет ждать вечно, и писать и читать надо в разных горутинах — иначе первый же Write заблокирует программу.

Спросят дальше

Как протестировать, что код правильно читает поток?

Короткий ответ

Пакетом testing/iotest — он для этого и есть. DataErrReader отдаёт последний кусок вместе с EOF и ловит цикл из Механизма 1; HalfReader и OneByteReader отдают меньше, чем просили, и ловят код, который ждёт полный буфер; ErrReader на каждый Read возвращает заданную ошибку; TimeoutReader возвращает iotest.ErrTimeout на втором чтении. Обернуть ими входные данные теста стоит одной строки.

Есть и обратная проверка — iotest.TestReader(r, content): она проверяет ваш читатель — читает его кусками разного размера до EOF и сверяет прочитанное с ожидаемым содержимым.

Частые заблуждения

Утверждение

Если Read вернул ошибку, данных в этом вызове нет

На самом деле

Есть, и их надо обработать первыми: документация разрешает вернуть n > 0 вместе с ошибкой, в том числе с io.EOF. Цикл, проверявший err раньше n, на iotest.DataErrReader собрал "hello, wor" вместо "hello, world" — без ошибки.

Утверждение

Read заполняет буфер целиком, если данных хватает

На самом деле

Он обещает «до len(p)», а не len(p): на буфер 8 читатель отдал 4 байта, труба — 3, 7 и 2, ровно по одному Write. Ровно N байт читает io.ReadFull.

Утверждение

io.Copy всегда копирует через свой буфер 32 КиБ

На самом деле

Только если у источника нет WriterTo, а у приёмника — ReaderFrom. Из *bytes.Reader 100 000 байт ушли одним Write, Read не вызывался, выделений — ноль. И наоборот: обёртка с одним методом Read прячет WriterTo источника.

Утверждение

Если ни один Write не вернул ошибку, данные записаны

На самом деле

Не с bufio.Writer: поверх /dev/full все сто записей вернули nil, а no space left on device пришло только из Flush. Без Flush последние 1808 байт из 10 000 вниз не дошли вовсе.

Утверждение

defer f.Close() — всё, что нужно для файла

На самом деле

Для файла на запись — нет. Функция с defer f.Close() и bufio без Flush вернула nil и оставила файл в 0 байт; gzip без Close не читается — unexpected EOF. Ошибки Flush и Close у писателя проверяют, а defer их выбрасывает.

Утверждение

io.ReadAll — самый простой и дешёвый способ прочитать поток

На самом деле

Простой — да, дешёвый — нет: мегабайт обошёлся в 5118.3 КиБ выделений в сумме, потому что срез растёт добавлением; итоговый срез — около 1,06 МиБ, остальное — промежуточные массивы для сборщика мусора. io.Copy через обёртки в прогоне держал не больше одного буфера на 32 КиБ с небольшим — с трубой 32,5 КиБ, с MultiWriter меньше одного.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

DataErrReader отдаёт последний кусок вместе с io.EOF. Цикл проверяет ошибку раньше, чем печатает байты. Потом — io.ReadFull на входе короче буфера и bufio.Writer без Flush. Что напечатает этот код?
r := iotest.DataErrReader(strings.NewReader("hello, world"))
buf := make([]byte, 5)
for {
n, err := r.Read(buf)
if err != nil {
	break
}
fmt.Print(string(buf[:n]), "|")
}
fmt.Println()
n, err := io.ReadFull(strings.NewReader("hello"), make([]byte, 8))
fmt.Println(n, err)
var out bytes.Buffer
w := bufio.NewWriter(&out)
fmt.Fprint(w, "report")
fmt.Println(out.Len())

Практика · оцените

10 000 записей по 16 байт в файл: прямо в *os.File и через bufio.Writer с Flush в конце. Во сколько раз запись без буфера медленнее?
раз

Проверка знаний

Вопрос 1 из 5

Read вернул n = 2 и err = io.EOF. Что делать с этими двумя байтами?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Пакет io — ReaderОфициальная документация. Контракт Read. Сколько байт: «Read reads up to len(p) bytes into p. It returns the number of bytes read (0 <= n <= len(p)) and any error encountered» (Read читает до len(p) байт в p. Он возвращает число прочитанных байт (0 <= n <= len(p)) и любую встреченную ошибку). Короткое чтение: «If some data is available but not len(p) bytes, Read conventionally returns what is available instead of waiting for more» (Если данные есть, но не len(p) байт, Read по соглашению возвращает то, что есть, а не ждёт остального). Данные вместе с ошибкой: «It may return the (non-nil) error from the same call or return the error (and n == 0) from a subsequent call» (Он может вернуть (не nil) ошибку из того же вызова или вернуть ошибку (и n == 0) из следующего). И правило для вызывающего: «Callers should always process the n > 0 bytes returned before considering the error err» (Вызывающий всегда должен обработать возвращённые n > 0 байт, прежде чем смотреть на ошибку err). Сверено по go doc io.Reader, go1.24.7.https://pkg.go.dev/io#Reader
  2. Пакет io — Writer, EOF, ReadFullОфициальная документация. Контракт Write строже: «Write must return a non-nil error if it returns n < len(p)» (Write обязан вернуть ошибку, не равную nil, если возвращает n < len(p)). Как сравнивать с EOF: «Read must return EOF itself, not an error wrapping EOF, because callers will test for EOF using ==» (Read обязан возвращать сам EOF, а не ошибку, оборачивающую EOF, потому что вызывающие проверяют EOF через ==). ReadFull: «ReadFull reads exactly len(buf) bytes from r into buf» (ReadFull читает из r в buf ровно len(buf) байт) и «If an EOF happens after reading some but not all the bytes, ReadFull returns ErrUnexpectedEOF» (Если EOF случился после того, как прочитана часть байт, но не все, ReadFull возвращает ErrUnexpectedEOF). Сверено по go doc, go1.24.7.https://pkg.go.dev/io#ReadFull
  3. Пакет io — Copy, Pipe, TeeReaderОфициальная документация. Чем копирует Copy: «If src implements WriterTo, the copy is implemented by calling src.WriteTo(dst). Otherwise, if dst implements ReaderFrom, the copy is implemented by calling dst.ReadFrom(src)» (Если src реализует WriterTo, копирование выполняется вызовом src.WriteTo(dst). Иначе, если dst реализует ReaderFrom, — вызовом dst.ReadFrom(src)). Почему успешный Copy не возвращает EOF: «A successful Copy returns err == nil, not err == EOF» (Успешный Copy возвращает err == nil, а не err == EOF). У TeeReader: «There is no internal buffering - the write must complete before the read completes» (Внутренней буферизации нет — запись должна закончиться до того, как закончится чтение). У Pipe: «The data is copied directly from the Write to the corresponding Read (or Reads); there is no internal buffering» (Данные копируются напрямую из Write в соответствующий Read (или несколько Read); внутренней буферизации нет). Сверено по go doc, go1.24.7.https://pkg.go.dev/io#Copy
  4. io/io.go — copyBufferИсходный код Go. Строки 407–419 на go1.24.7 (на go1.25.1 — те же номера): сначала проверка src.(WriterTo) в строке 410, затем dst.(ReaderFrom) в строке 414, и только потом буфер — строка 418: size := 32 * 1024; если источник — *LimitedReader с меньшим остатком, буфер уменьшается до него (строки 419–425). Комментарий к первой проверке: «Avoids an allocation and a copy» (Обходится без выделения памяти и без копирования).https://github.com/golang/go/blob/go1.24.7/src/io/io.go#L407-L450
  5. Пакет bufio — WriterОфициальная документация. Зачем Flush: «After all data has been written, the client should call the Writer.Flush method to guarantee all data has been forwarded to the underlying io.Writer» (Записав все данные, клиент должен вызвать метод Writer.Flush, чтобы гарантировать, что все данные переданы нижнему io.Writer). Ошибка липкая: «If an error occurs writing to a Writer, no more data will be accepted and all subsequent writes, and Writer.Flush, will return the error» (Если при записи в Writer произошла ошибка, данные больше не принимаются, и все последующие записи, а также Writer.Flush, возвращают эту ошибку). Размер буфера по умолчанию — defaultBufSize = 4096, bufio/bufio.go, строка 19 на go1.24.7.https://pkg.go.dev/bufio#Writer
  6. Пакеты compress/gzip и os — CloseОфициальная документация. gzip.Writer.Close пишет: «Close closes the Writer by flushing any unwritten data to the underlying io.Writer and writing the GZIP footer» (Close закрывает Writer: сбрасывает в нижний io.Writer всё незаписанное и пишет завершающий блок GZIP). os.File.Close: «Close will return an error if it has already been called» (Close вернёт ошибку, если он уже вызывался). Сверено по go doc, go1.24.7.https://pkg.go.dev/compress/gzip#Writer.Close