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().
Что здесь на самом деле спрашивают
Лестница обычно такая:
- «Что такое
io.Reader?» — разминка; сигнатуру помнят все. - «Напишите цикл, который читает поток до конца» — здесь проверяют, в
каком порядке кандидат смотрит на
nи наerr. - «Почему
Readвернул меньше, чем размер буфера?» — короткое чтение иio.ReadFull. - «Как
io.Copyкопирует и сколько памяти ему нужно?» — буфер 32 КиБ и два интерфейса, которые позволяют без него обойтись. - «Зачем
bufioи что будет, если забытьFlush?» - «Что не так с
defer f.Close()?» — про ошибку, которую возвращает запись, сделанная при закрытии.
Стержень у лестницы один: Read и Write обещают только одно — сколько
байт прошло за этот вызов и что случилось. Ни «весь буфер», ни «конец
отдельно от данных», ни «записано на диск» в контракт не входят. Всё, на чём
ловят на собеседовании, — попытка прочитать в контракте больше, чем в нём
написано.
База: два метода и что они обещают
Фрагмент io/io.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)) и любую встреченную ошибку.
«До len(p)» — ключевые слова. У Write обещание другое: если он записал
не весь срез, то обязан объяснить почему, —
Write
must return a non-nil error if it returns n
(Write обязан вернуть ошибку, не равную nil, если возвращает n < len(p)). Контракты
несимметричны, и эту таблицу стоит запомнить сразу — на ней держатся все
механизмы ниже:< 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) из следующего.
Прогон читает поток "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, по смыслу:
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.
Фрагмент — функция readCorrect из bench/goio/internals.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 .:
// Полный короткий пример урока «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 по соглашению возвращает то, что есть, а не ждёт остального.
В прогоне буфер на 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. Границы кусков задаёт источник, а не буфер.
Отсюда классическая ошибка с заголовком фиксированной длины.
Фрагмент-иллюстрация, в прогоне его нет:
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) байт.
Вывод, раздел 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/io.go, строки 407–419:
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 не копирует, а отдаёт вниз напрямую (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 проверять ошибку каждого
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.
Вывод 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, по смыслу:
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 под коротким именем:
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 дорог — и сколько стоит память
Счётчик из Механизма 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.ReadFull | EOF против 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 меньше одного.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
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())Практика · оцените
Проверка знаний
Read вернул n = 2 и err = io.EOF. Что делать с этими двумя байтами?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
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.Reader100 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 мкс с ним. Почему разница есть, объясняет механизм; сколько она составит у вас, надо мерить у себя.
На самом деле
- Есть, и их надо обработать первыми: документация разрешает вернуть
n > 0вместе с ошибкой, в том числе сio.EOF. Цикл, проверявшийerrраньшеn, наiotest.DataErrReaderсобрал"hello, wor"вместо"hello, world"— без ошибки. - Он обещает «до
len(p)», а неlen(p): на буфер 8 читатель отдал 4 байта, труба — 3, 7 и 2, ровно по одномуWrite. Ровно N байт читаетio.ReadFull. - Только если у источника нет
WriterTo, а у приёмника —ReaderFrom. Из*bytes.Reader100 000 байт ушли однимWrite,Readне вызывался, выделений — ноль. И наоборот: обёртка с одним методомReadпрячетWriterToисточника. - Не с
bufio.Writer: поверх/dev/fullвсе сто записей вернулиnil, аno space left on deviceпришло только изFlush. БезFlushпоследние 1808 байт из 10 000 вниз не дошли вовсе. - Для файла на запись — нет. Функция с
defer f.Close()иbufioбезFlushвернулаnilи оставила файл в 0 байт;gzipбезCloseне читается —unexpected EOF. ОшибкиFlushиCloseу писателя проверяют, аdeferих выбрасывает. - Простой — да, дешёвый — нет: мегабайт обошёлся в 5118.3 КиБ выделений в сумме, потому что срез растёт добавлением; итоговый срез — около 1,06 МиБ, остальное — промежуточные массивы для сборщика мусора.
io.Copyчерез обёртки в прогоне держал не больше одного буфера на 32 КиБ с небольшим — с трубой 32,5 КиБ, сMultiWriterменьше одного.
Что разобрано
- Что здесь на самом деле спрашивают
- База: два метода и что они обещают
- Механизм 1: данные приходят вместе с ошибкой
- Механизм 2: короткое чтение — Read не обязан заполнять буфер
- Механизм 3: io.Copy — буфер 32 КиБ или чужой метод
- Механизм 4: bufio.Writer — запись уходит вниз кусками, а хвост — только после Flush
- Механизм 5: Close тоже пишет — и его ошибку теряют в defer
- Глубже: почему маленький Write дорог — и сколько стоит память
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
6 ИСТОЧНИКОВ
- Пакет 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
- Пакет 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
- Пакет 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
- 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 - Пакет 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 - Пакеты 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