Deep Engineering

MEASUREMENT

bench/goio/internals.go

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/golang/io
How to run it
go run bench/goio/internals.go
go run bench/goio/practice.go
bash bench/goio/example.sh

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «io.Reader и io.Writer»

Файл Что делает
internals.go восемь наблюдений: два допустимых конца потока (последний кусок с nil и затем (0, EOF) — или вместе с EOF), цикл, который проверяет ошибку раньше n и теряет хвост, короткое чтение и io.ReadFull, буфер io.Copy и обход через WriterTo/ReaderFrom (счётчики вызовов и len(p)), какие типы стандартной библиотеки реализуют эти интерфейсы, MultiWriter/TeeReader/LimitReader/Pipe и память на копирование через них, короткая запись, bufio.Writer и Flush (в том числе поверх /dev/full), gzip без Close, defer f.Close() и потерянная ошибка
example.sh собирает и запускает полный короткий пример cases/example/main.go (свой go.mod, go 1.24): цикл чтения по контракту Read и запись через bufio с проверкой Flush; добавлен после внешнего аудита 14.10.2026
cases/example/ тот самый пример: его можно скопировать в пустой каталог и запустить go run .
practice.go источник ответов двух практических задач урока: вывод цикла, io.ReadFull и bufio без Flush; кратность времени 10 000 записей по 16 байт в файл без bufio и с ним
go run bench/goio/internals.go
go run bench/goio/practice.go
bash bench/goio/example.sh

Разделы 9 и 10 internals.go добавлены после внешнего аудита 14.10.2026 в конец вывода, чтобы не сдвигать процитированные блоки: полная запись вместе с ошибкой и данные к задачам (размер отчёта из раздела 8, итоговый срез io.ReadAll). Подписи о памяти в разделах 4 и 6 уточнены: это суммарный объём выделений (TotalAlloc), а не одновременно занятая память.

Записи прогонов — в runs/, go1.24.7 linux/amd64, GOMAXPROCS=2. runs/internals-go1.25.txt — тот же internals.go под go1.25.1: поведение совпало, кроме десятой доли килобайта в среднем объёме io.ReadAll (код ReadAll в io/io.go у двух версий одинаковый). Временная папка в выводе заменена на <папка>. Раздел 7 и раздел 8 пишут в /dev/full — это устройство Linux, на котором любая запись кончается ENOSPC.

Что получилось

Последний кусок может прийти вместе с EOF. strings.Reader, *os.File и io.Pipe отдают последние байты с err=nil, а EOF — следующим вызовом с n=0. iotest.DataErrReader отдаёт "ld" и EOF одним вызовом. Документация io.Reader разрешает оба поведения.

Цикл if err != nil { break } до обработки n теряет хвост — но только на втором виде читателя. На strings.Reader, файле и трубе он собирает "hello, world", на DataErrReader — "hello, wor": два байта потеряны без ошибки. Цикл «сначала n байт, потом ошибка» и io.ReadAll правы на всех.

Read не обязан заполнять буфер. HalfReader на буфер 8 отдаёт 4 байта, OneByteReader — 1, труба — столько, сколько положил один Write (3, 7, 2). io.ReadFull дочитывает до 8; на входе из 5 байт отдаёт n=5 и unexpected EOF, на пустом — n=0 и EOF. errors.Is(io.ErrUnexpectedEOF, io.EOF) — false.

io.Copy берёт буфер 32 КиБ — если не нашёл чужого метода. Из читателя с одним Read в писатель с одним Write 100 000 байт идут четырьмя Write ([32768 32768 32768 1696]), буфер — одно выделение на 32.0 КиБ. Из *bytes.Reader (WriterTo) — одним Write на 100 000 байт, Read не вызывается, выделений ноль. В *bytes.Buffer (ReaderFrom) читает сам буфер: len(p) 512, 512, 1024… — обёртка над источником прячет его WriterTo, но ReaderFrom приёмника io.Copy всё равно находит. io.LimitReader(src, 100) уменьшает буфер io.Copy до 100 байт.

Обёртки не копят данные. MultiWriter и TeeReader передают каждый кусок дальше в том же вызове; копирование 1 МиБ через MultiWriter — 2 выделения меньше килобайта, через TeeReader — 32.0 КиБ (буфер io.Copy), через io.Pipe — 32.5 КиБ. Для сравнения: io.ReadAll того же мегабайта выделяет 5118.3 КиБ — срез растёт добавлением. Писатель, вернувший n < len(p) без ошибки, общий цикл io.Copy превращает в short write (io/io.go, строки 443–446 на go1.24.7); источник в прогоне обёрнут в onlyReader, иначе ту же проверку сделал бы WriteTo у *strings.Reader.

bufio.Writer собирает записи в куски по 4096 байт. 1000 записей по 10 байт: без bufio нижний писатель получает 1000 Write, с bufio — 3 ([4096 4096 1808]), и третий — только после Flush; без него 1808 байт остались в буфере. Один Write больше буфера в пустой буфер идёт вниз напрямую. Поверх /dev/full все 100 Write в bufio вернули nil, ошибка no space left on device пришла только из Flush — и дальше каждый Write возвращает её же.

Close тоже пишет. Функция с defer f.Close() и bufio без Flush вернула nil, а файл на диске — 0 байт. gzip.Writer без Close отдал вниз 10 байт (заголовок), после Flush — 62, и оба раза чтение назад кончается unexpected EOF; после Close — 75 байт и 2600 байт обратно без ошибки. Повторный f.Close() — file already closed. Запись в /dev/full с defer w.Flush() вернула nil; с проверенным Flush и Close в defer, пишущим в именованный результат, — no space left on device; ошибку здесь дал Flush.

Буфер в этой конфигурации дал кратность 65. 10 000 записей по 16 байт прямо в *os.File во временной папке — 4248 мкс (10 000 Write дошли до файла), через bufio.Writer — 65 мкс (40 Write), лучший из 15 кругов, go1.24.7 linux/amd64. Это наблюдение одной машины: кратность зависит от файловой системы, ядра и размера записи, на другой машине её надо мерить.

Write может вернуть len(p) вместе с ошибкой. Контракт запрещает только короткую запись без ошибки. Писатель, вернувший len(p), errQuota, — io.Copy -> 12, err=quota exceeded и в общем цикле, и через WriteTo у *strings.Reader; bufio.Flush возвращает ту же ошибку. Писатель, вернувший len(p)/2, nil, — short write.

Сумма выделений — не живая память. 100 строк отчёта из раздела 8 — 1590 байт, меньше буфера bufio на 4096. io.ReadAll мегабайта суммарно выделил 5118.3 КиБ, а итоговый срез — cap=1114112; остальное — промежуточные массивы роста.

Script

572 lines
//go:build ignore

// io.Reader и io.Writer: что на самом деле обещает контракт из двух методов.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. «Reader — это интерфейс с методом Read» знают все.
// Вопросы начинаются там, где код написан по образу strings.Reader, а
// работает с сетью, архивом или файлом: Read возвращает данные вместе с
// ошибкой, заполняет буфер не до конца, io.Copy делает работу чужими
// методами, а записанное через bufio и gzip доходит до файла только после
// Flush и Close. Каждый пункт здесь — запуск, а не пересказ документации.
//
// ВОСЕМЬ СЮЖЕТОВ:
//
//  1. Два допустимых конца потока: последний кусок с nil и затем (0, EOF),
//     или последний кусок сразу вместе с EOF (iotest.DataErrReader).
//  2. Цикл, который проверяет err раньше n, теряет последний кусок — но
//     только на втором виде читателя.
//  3. Короткое чтение: Read не обязан заполнять буфер; io.ReadFull обязан,
//     и при нехватке данных отвечает io.ErrUnexpectedEOF.
//  4. io.Copy: буфер 32 КБ, WriterTo и ReaderFrom вместо буфера, LimitReader
//     уменьшает буфер — видно по счётчикам вызовов и по len(p).
//  5. Какие типы стандартной библиотеки реализуют WriterTo и ReaderFrom.
//  6. Композиция: MultiWriter, TeeReader, LimitReader, Pipe — что делают и
//     сколько памяти берёт копирование через них; короткая запись.
//  7. Буферизация: сколько Write доходит до нижнего писателя без bufio и с
//     ним; без Flush хвост теряется; ошибка записи всплывает только на Flush.
//  8. Close тоже пишет: gzip без Close, bufio без Flush под defer f.Close(),
//     повторный Close и шаблон, который не теряет ошибку.
//
// И два раздела, добавленные после внешнего аудита 14.10.2026 (в конец,
// чтобы не сдвигать процитированные в уроке блоки):
//
//  9. Write может вернуть n == len(p) вместе с ошибкой — контракт этого не
//     запрещает; io.Copy и bufio такую ошибку передают наверх.
//  10. Данные к задачам: сколько байт в 100 строках отчёта из раздела 8 и
//     какой срез io.ReadAll возвращает в конце — чтобы не путать суммарный
//     объём выделений с итоговым буфером.
//
// Временные пути заменены плейсхолдером <папка>, чтобы вывод не менялся от
// запуска к запуску.
//
// ЗАПУСК:
//
//	go run bench/goio/internals.go
package main

import (
	"bufio"
	"bytes"
	"compress/gzip"
	"crypto/sha256"
	"errors"
	"fmt"
	"io"
	"net"
	"os"
	"path/filepath"
	"runtime"
	"strings"
	"testing/iotest"
)

func show(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

var tmpDir string

// hide заменяет временную папку плейсхолдером: путь меняется от запуска к
// запуску, а вывод должен быть одинаковым.
func hide(s string) string { return strings.ReplaceAll(s, tmpDir, "<папка>") }

func errStr(err error) string {
	if err == nil {
		return "nil"
	}
	return hide(err.Error())
}

// ------------------------------------------------------------ счётчики

// onlyReader прячет у читателя все методы, кроме Read: так io.Copy не найдёт
// WriterTo и пойдёт общим путём через свой буфер.
type onlyReader struct {
	r     io.Reader
	calls int
	sizes []int // len(p) каждого вызова
}

func (o *onlyReader) Read(p []byte) (int, error) {
	o.calls++
	o.sizes = append(o.sizes, len(p))
	return o.r.Read(p)
}

// countWriter считает вызовы Write и байты, ничего не храня.
type countWriter struct {
	calls int
	bytes int
	sizes []int
}

func (c *countWriter) Write(p []byte) (int, error) {
	c.calls++
	c.bytes += len(p)
	c.sizes = append(c.sizes, len(p))
	return len(p), nil
}

// memPerOp — сколько выделено в куче за один вызов f: число выделений и
// байты, в среднем по rounds вызовам. Как testing.AllocsPerRun, на время
// замера ставит GOMAXPROCS=1, чтобы чужие горутины не добавляли шума.
func memPerOp(rounds int, f func()) (allocs, bytes uint64) {
	defer runtime.GOMAXPROCS(runtime.GOMAXPROCS(1))
	f() // прогрев
	runtime.GC()
	var a, b runtime.MemStats
	runtime.ReadMemStats(&a)
	for i := 0; i < rounds; i++ {
		f()
	}
	runtime.ReadMemStats(&b)
	r := uint64(rounds)
	return (b.Mallocs - a.Mallocs + r/2) / r, (b.TotalAlloc - a.TotalAlloc + r/2) / r
}

// kib печатает байты в КиБ с одним знаком: последние байты среднего
// дрожат от запуска к запуску, а десятые доли килобайта — нет.
func kib(b uint64) string {
	if b < 1024 {
		return "< 1 КиБ"
	}
	return fmt.Sprintf("%.1f КиБ", float64(b)/1024)
}

// ------------------------------------------------------------ 1–2. EOF

func trace(name string, r io.Reader, size int) {
	buf := make([]byte, size)
	fmt.Printf("  %s\n", name)
	for i := 1; ; i++ {
		n, err := r.Read(buf)
		fmt.Printf("    Read #%d -> n=%d %-10q err=%s\n", i, n, buf[:n], errStr(err))
		if err != nil {
			return
		}
	}
}

// readBroken — цикл, который сначала смотрит на ошибку.
func readBroken(r io.Reader) string {
	var out []byte
	buf := make([]byte, 5)
	for {
		n, err := r.Read(buf)
		if err != nil {
			break
		}
		out = append(out, buf[:n]...)
	}
	return string(out)
}

// readCorrect — цикл по документации: сначала n байт, потом ошибка.
func readCorrect(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
		}
	}
}

// chunked — тот же поток "hello, world", но кусками: через io.Pipe, в
// который писатель кладёт "hel", "lo, wor" и "ld" тремя вызовами Write.
func chunked() io.Reader {
	pr, pw := io.Pipe()
	go func() {
		for _, part := range []string{"hel", "lo, wor", "ld"} {
			pw.Write([]byte(part))
		}
		pw.Close()
	}()
	return pr
}

// ------------------------------------------------------- 6. короткая запись

// shortWriter нарушает контракт Writer: пишет половину и не возвращает ошибку.
type shortWriter struct{}

func (shortWriter) Write(p []byte) (int, error) { return len(p) / 2, nil }

// ------------------------------------------------------------ 8. Close

// reportLine — строка отчёта из writeReportDeferOnly; вынесена, чтобы раздел
// 10 мог посчитать, сколько байт она занимает.
func reportLine(i int) string { return fmt.Sprintf("строка %d\n", i) }

// writeReportDeferOnly — так часто пишут: defer f.Close(), буфер не сброшен.
func writeReportDeferOnly(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++ {
		w.WriteString(reportLine(i))
	}
	return nil
}

// writeToDeferFlush — ошибка Flush отложена и потеряна.
func writeToDeferFlush(path string) error {
	f, err := os.OpenFile(path, os.O_WRONLY, 0)
	if err != nil {
		return err
	}
	defer f.Close()
	w := bufio.NewWriter(f)
	defer w.Flush()
	_, err = w.WriteString("отчёт\n")
	return err
}

// writeToChecked — Flush и Close проверены, первая ошибка возвращается.
func writeToChecked(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()
}

func main() {
	var err error
	tmpDir, err = os.MkdirTemp("", "goio")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(tmpDir)

	fmt.Printf("%s %s/%s | io.Reader и io.Writer\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)

	const text = "hello, world"
	path := filepath.Join(tmpDir, "hello.txt")
	if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
		panic(err)
	}

	// ------------------------------------------------------------------ 1
	show("1) ДВА ДОПУСТИМЫХ КОНЦА ПОТОКА")
	fmt.Printf("  поток %q, 12 байт, буфер 5 байт\n", text)
	trace("strings.NewReader", strings.NewReader(text), 5)
	f, _ := os.Open(path)
	trace("*os.File", f, 5)
	f.Close()
	trace("io.Pipe, писатель: Write(\"hel\"), Write(\"lo, wor\"), Write(\"ld\")", chunked(), 5)
	trace("iotest.DataErrReader(strings.NewReader)", iotest.DataErrReader(strings.NewReader(text)), 5)
	fmt.Println("  Оба поведения разрешены документацией io.Reader: последний кусок")
	fmt.Println("  может прийти с err=nil, а EOF — следующим вызовом, или вместе с EOF.")

	// ------------------------------------------------------------------ 2
	show("2) ЦИКЛ, КОТОРЫЙ СНАЧАЛА СМОТРИТ НА ОШИБКУ")
	fmt.Println("  if err != nil { break } до обработки n байт:")
	fmt.Printf("    strings.NewReader          -> %q\n", readBroken(strings.NewReader(text)))
	f, _ = os.Open(path)
	fmt.Printf("    *os.File                   -> %q\n", readBroken(f))
	f.Close()
	fmt.Printf("    io.Pipe (\"hel\", \"lo, wor\", \"ld\") -> %q\n", readBroken(chunked()))
	fmt.Printf("    DataErrReader              -> %q  (потеряно %d байта)\n",
		readBroken(iotest.DataErrReader(strings.NewReader(text))),
		len(text)-len(readBroken(iotest.DataErrReader(strings.NewReader(text)))))
	fmt.Println("  сначала n байт, потом ошибка:")
	s, _ := readCorrect(strings.NewReader(text))
	fmt.Printf("    strings.NewReader          -> %q\n", s)
	s, _ = readCorrect(chunked())
	fmt.Printf("    io.Pipe (\"hel\", \"lo, wor\", \"ld\") -> %q\n", s)
	s, _ = readCorrect(iotest.DataErrReader(strings.NewReader(text)))
	fmt.Printf("    DataErrReader              -> %q\n", s)
	b, err := io.ReadAll(iotest.DataErrReader(strings.NewReader(text)))
	fmt.Printf("  io.ReadAll(DataErrReader)    -> %q err=%s\n", b, errStr(err))

	// ------------------------------------------------------------------ 3
	show("3) КОРОТКОЕ ЧТЕНИЕ: READ НЕ ОБЯЗАН ЗАПОЛНЯТЬ БУФЕР")
	buf := make([]byte, 8)
	n, err := iotest.HalfReader(strings.NewReader(text)).Read(buf)
	fmt.Printf("  HalfReader.Read(buf[8])      -> n=%d %q err=%s\n", n, buf[:n], errStr(err))
	n, err = iotest.OneByteReader(strings.NewReader(text)).Read(buf)
	fmt.Printf("  OneByteReader.Read(buf[8])   -> n=%d %q err=%s\n", n, buf[:n], errStr(err))

	pr := chunked()
	fmt.Println("  io.Pipe, писатель пишет \"hel\", \"lo, wor\", \"ld\"; читатель — буфером 8:")
	for {
		n, err := pr.Read(buf)
		fmt.Printf("    Read -> n=%d %q err=%s\n", n, buf[:n], errStr(err))
		if err != nil {
			break
		}
	}

	n, err = io.ReadFull(iotest.HalfReader(strings.NewReader(text)), buf)
	fmt.Printf("  io.ReadFull(HalfReader, buf[8]) -> n=%d %q err=%s\n", n, buf[:n], errStr(err))
	n, err = io.ReadFull(strings.NewReader("hello"), buf)
	fmt.Printf("  io.ReadFull(5 байт, buf[8])     -> n=%d %q err=%s\n", n, buf[:n], errStr(err))
	n, err = io.ReadFull(strings.NewReader(""), buf)
	fmt.Printf("  io.ReadFull(0 байт, buf[8])     -> n=%d err=%s\n", n, errStr(err))
	fmt.Printf("  errors.Is(ErrUnexpectedEOF, EOF) -> %v\n", errors.Is(io.ErrUnexpectedEOF, io.EOF))

	// ------------------------------------------------------------------ 4
	show("4) IO.COPY: БУФЕР 32 КБ ИЛИ ЧУЖОЙ МЕТОД")
	data := bytes.Repeat([]byte("x"), 100_000)
	fmt.Println("  копируется 100000 байт")

	src := &onlyReader{r: bytes.NewReader(data)}
	dst := &countWriter{}
	nn, _ := io.Copy(dst, src)
	fmt.Printf("  только Read -> только Write:   Read ×%d, len(p)=%d; Write ×%d %v; скопировано %d\n",
		src.calls, src.sizes[0], dst.calls, dst.sizes, nn)

	dst = &countWriter{}
	nn, _ = io.Copy(dst, bytes.NewReader(data))
	fmt.Printf("  *bytes.Reader (WriterTo):     Read ×0; Write ×%d %v; скопировано %d\n",
		dst.calls, dst.sizes, nn)

	src = &onlyReader{r: bytes.NewReader(data)}
	var bb bytes.Buffer
	nn, _ = io.Copy(&bb, src)
	fmt.Printf("  -> *bytes.Buffer (ReaderFrom): Read ×%d, первые len(p)=%v; скопировано %d\n",
		src.calls, src.sizes[:3], nn)

	src = &onlyReader{r: bytes.NewReader(data)}
	dst = &countWriter{}
	nn, _ = io.Copy(dst, io.LimitReader(src, 100))
	fmt.Printf("  io.LimitReader(src, 100):     Read ×%d, len(p)=%d; Write ×%d; скопировано %d\n",
		src.calls, src.sizes[0], dst.calls, nn)

	fmt.Println("  всего выделено в куче (TotalAlloc) за один io.Copy, среднее по 1000:")
	cw := &countWriter{sizes: make([]int, 0, 8)}
	or := &onlyReader{sizes: make([]int, 0, 8)}
	br := bytes.NewReader(data)
	pa, pb := memPerOp(1000, func() {
		br.Reset(data)
		or.r, or.sizes = br, or.sizes[:0]
		cw.sizes = cw.sizes[:0]
		io.Copy(cw, or)
	})
	wa, wb := memPerOp(1000, func() {
		br.Reset(data)
		cw.sizes = cw.sizes[:0]
		io.Copy(cw, br)
	})
	fmt.Printf("    общий путь (буфер io.Copy)  выделений: %d, %s\n", pa, kib(pb))
	fmt.Printf("    через WriterTo              выделений: %d, %s\n", wa, kib(wb))

	// ------------------------------------------------------------------ 5
	show("5) КТО В СТАНДАРТНОЙ БИБЛИОТЕКЕ РЕАЛИЗУЕТ WRITERTO И READERFROM")
	impl := func(name string, v any) {
		_, wt := v.(io.WriterTo)
		_, rf := v.(io.ReaderFrom)
		fmt.Printf("  %-18s WriterTo: %-5v ReaderFrom: %v\n", name, wt, rf)
	}
	impl("*strings.Reader", strings.NewReader(""))
	impl("*bytes.Reader", bytes.NewReader(nil))
	impl("*bytes.Buffer", &bytes.Buffer{})
	impl("*bufio.Reader", bufio.NewReader(nil))
	impl("*bufio.Writer", bufio.NewWriter(io.Discard))
	f, _ = os.Open(path)
	impl("*os.File", f)
	f.Close()
	impl("*net.TCPConn", &net.TCPConn{})
	impl("*io.PipeReader", pr)
	impl("*io.LimitedReader", &io.LimitedReader{})
	impl("io.Discard", io.Discard)

	// ------------------------------------------------------------------ 6
	show("6) КОМПОЗИЦИЯ: ОБЁРТКИ НЕ КОПЯТ ДАННЫЕ")
	mb := bytes.Repeat([]byte("ab"), 1<<19) // 1 МиБ
	fmt.Println("  поток 1048576 байт")

	a1, a2 := &countWriter{}, &countWriter{}
	io.Copy(io.MultiWriter(a1, a2), &onlyReader{r: bytes.NewReader(mb)})
	fmt.Printf("  MultiWriter(a, b):  a получил %d байт за %d Write, b — %d за %d\n",
		a1.bytes, a1.calls, a2.bytes, a2.calls)

	h := sha256.New()
	d := &countWriter{}
	io.Copy(d, io.TeeReader(&onlyReader{r: bytes.NewReader(mb)}, h))
	want := sha256.Sum256(mb)
	fmt.Printf("  TeeReader(r, sha256): записано %d байт, хеш совпал с sha256.Sum256: %v\n",
		d.bytes, bytes.Equal(h.Sum(nil), want[:]))

	d = &countWriter{}
	io.Copy(d, io.LimitReader(bytes.NewReader(mb), 1000))
	fmt.Printf("  LimitReader(r, 1000): записано %d байт\n", d.bytes)

	pr2, pw2 := io.Pipe()
	go func() {
		pw2.Write(mb)
		pw2.Close()
	}()
	d = &countWriter{}
	io.Copy(d, pr2)
	fmt.Printf("  Pipe, один Write на 1048576 байт: читатель получил %d байт за %d Write по %d\n",
		d.bytes, d.calls, d.sizes[0])

	fmt.Println("  всего выделено в куче (TotalAlloc) за одно копирование 1 МиБ, среднее по 200:")
	mem := func(name string, f func()) {
		a, b := memPerOp(200, f)
		fmt.Printf("    %-30s выделений: %d, %s\n", name, a, kib(b))
	}
	sink1, sink2 := &countWriter{sizes: make([]int, 0, 64)}, &countWriter{sizes: make([]int, 0, 64)}
	reset := func() { sink1.sizes, sink2.sizes = sink1.sizes[:0], sink2.sizes[:0] }
	rd := bytes.NewReader(mb)
	mem("io.Copy(w, *bytes.Reader)", func() { reset(); rd.Reset(mb); io.Copy(sink1, rd) })
	mem("io.Copy(MultiWriter(w1, w2))", func() { reset(); rd.Reset(mb); io.Copy(io.MultiWriter(sink1, sink2), rd) })
	mem("io.Copy(w, TeeReader)", func() { reset(); rd.Reset(mb); io.Copy(sink1, io.TeeReader(rd, sink2)) })
	mem("io.Copy(w, io.Pipe)", func() {
		reset()
		r, w := io.Pipe()
		go func() { w.Write(mb); w.Close() }()
		io.Copy(sink1, r)
	})
	mem("io.ReadAll(*bytes.Reader)", func() { rd.Reset(mb); io.ReadAll(rd) })

	// Источник обёрнут: иначе io.Copy ушёл бы в WriteTo у *strings.Reader,
	// и short write выставил бы он, а не общий цикл io.Copy.
	nn, err = io.Copy(shortWriter{}, &onlyReader{r: strings.NewReader(text)})
	fmt.Printf("  писатель вернул n < len(p) и nil: io.Copy -> %d, err=%s\n", nn, errStr(err))

	// ------------------------------------------------------------------ 7
	show("7) БУФЕРИЗАЦИЯ: СКОЛЬКО WRITE ДОХОДИТ ДО НИЖНЕГО ПИСАТЕЛЯ")
	fmt.Println("  1000 записей по 10 байт = 10000 байт")
	rec := []byte("0123456789")
	low := &countWriter{}
	for i := 0; i < 1000; i++ {
		low.Write(rec)
	}
	fmt.Printf("  без bufio:                 Write внизу ×%-4d дошло байт: %5d\n", low.calls, low.bytes)

	low = &countWriter{}
	bw := bufio.NewWriter(low)
	for i := 0; i < 1000; i++ {
		bw.Write(rec)
	}
	fmt.Printf("  bufio.NewWriter, до Flush: Write внизу ×%-4d дошло байт: %5d, в буфере: %d\n",
		low.calls, low.bytes, bw.Buffered())
	bw.Flush()
	fmt.Printf("  после Flush:               Write внизу ×%-4d дошло байт: %5d %v\n", low.calls, low.bytes, low.sizes)
	fmt.Printf("  размер буфера по умолчанию: %d байт\n", bw.Size())

	low = &countWriter{}
	bw = bufio.NewWriter(low)
	bw.Write(make([]byte, 10_000))
	fmt.Printf("  один Write на 10000 байт в пустой буфер: Write внизу ×%d %v\n", low.calls, low.sizes)

	full, err := os.OpenFile("/dev/full", os.O_WRONLY, 0)
	if err == nil {
		bw = bufio.NewWriter(full)
		ok := 0
		for i := 0; i < 100; i++ {
			if _, err := bw.Write(rec); err == nil {
				ok++
			}
		}
		fmt.Println("  bufio.Writer поверх /dev/full (запись всегда ENOSPC):")
		fmt.Printf("    100 x Write(10 байт) без ошибки: %d из 100\n", ok)
		fmt.Printf("    Flush -> %s\n", errStr(bw.Flush()))
		_, err = bw.Write(rec)
		fmt.Printf("    Write после ошибки -> %s\n", errStr(err))
		_, err = full.Write(rec)
		fmt.Printf("    без bufio, первый же f.Write -> %s\n", errStr(err))
		full.Close()
	}

	// ------------------------------------------------------------------ 8
	show("8) CLOSE ТОЖЕ ПИШЕТ — И ЕГО ОШИБКУ ТЕРЯЮТ")
	rp := filepath.Join(tmpDir, "report.txt")
	err = writeReportDeferOnly(rp, 100)
	st, _ := os.Stat(rp)
	fmt.Printf("  defer f.Close(), bufio без Flush, 100 строк: вернула %s, размер файла %d байт\n",
		errStr(err), st.Size())

	var gz bytes.Buffer
	zw := gzip.NewWriter(&gz)
	zw.Write(bytes.Repeat([]byte("строка отчёта\n"), 100))
	fmt.Printf("  gzip без Close: в нижнем писателе байт: %d\n", gz.Len())
	_, err = readGzip(gz.Bytes())
	fmt.Printf("    чтение назад -> %s\n", errStr(err))
	zw.Flush()
	fmt.Printf("  gzip после Flush, без Close: в нижнем писателе байт: %d\n", gz.Len())
	_, err = readGzip(gz.Bytes())
	fmt.Printf("    чтение назад -> %s\n", errStr(err))
	zw.Close()
	fmt.Printf("  gzip после Close: в нижнем писателе байт: %d\n", gz.Len())
	out, err := readGzip(gz.Bytes())
	fmt.Printf("    чтение назад -> %d байт, err=%s\n", len(out), errStr(err))

	f, _ = os.Create(filepath.Join(tmpDir, "twice.txt"))
	fmt.Printf("  первый f.Close() -> %s\n", errStr(f.Close()))
	fmt.Printf("  второй f.Close() -> %s\n", errStr(f.Close()))
	_, err = f.Write(rec)
	fmt.Printf("  f.Write после Close -> %s\n", errStr(err))

	fmt.Println("  запись в /dev/full:")
	fmt.Printf("    defer w.Flush(); defer f.Close()     -> функция вернула %s\n", errStr(writeToDeferFlush("/dev/full")))
	fmt.Printf("    Flush проверен, Close в defer с err -> функция вернула %s\n", errStr(writeToChecked("/dev/full")))

	// ------------------------------------------------------------------ 9
	show("9) WRITE: ПОЛНАЯ ЗАПИСЬ ВМЕСТЕ С ОШИБКОЙ")
	fmt.Println("  писатель возвращает len(p) и errQuota — контракт Write это допускает:")
	nn, err = io.Copy(fullErrWriter{}, &onlyReader{r: strings.NewReader(text)})
	fmt.Printf("    io.Copy (общий цикл)        -> %d, err=%s\n", nn, errStr(err))
	nn, err = io.Copy(fullErrWriter{}, strings.NewReader(text))
	fmt.Printf("    io.Copy (*strings.Reader)   -> %d, err=%s\n", nn, errStr(err))
	bw = bufio.NewWriter(fullErrWriter{})
	bw.WriteString(text)
	fmt.Printf("    bufio: Flush                -> %s\n", errStr(bw.Flush()))
	fmt.Println("  писатель возвращает len(p)/2 и nil — контракт нарушен:")
	nn, err = io.Copy(shortWriter{}, &onlyReader{r: strings.NewReader(text)})
	fmt.Printf("    io.Copy (общий цикл)        -> %d, err=%s\n", nn, errStr(err))

	// ----------------------------------------------------------------- 10
	show("10) ДАННЫЕ К ЗАДАЧАМ: РАЗМЕР ОТЧЁТА И ИТОГ IO.READALL")
	total := 0
	for i := 0; i < 100; i++ {
		total += len(reportLine(i))
	}
	fmt.Printf("  100 строк отчёта из раздела 8: %d байт; буфер bufio по умолчанию: %d байт\n",
		total, bufio.NewWriter(io.Discard).Size())
	all, _ := io.ReadAll(bytes.NewReader(mb))
	fmt.Printf("  io.ReadAll(1048576 байт): len=%d, cap=%d — итоговый срез\n", len(all), cap(all))
	fmt.Println("  Всего выделено за вызов (раздел 6) больше: промежуточные массивы")
	fmt.Println("  роста становятся мусором, и их забирает сборщик.")
}

// errQuota — ошибка, которую писатель возвращает вместе с полной записью.
var errQuota = errors.New("quota exceeded")

// fullErrWriter принимает все байты и всё же сообщает об ошибке: так вправе
// сделать, например, писатель, который записал данные и обнаружил, что
// дальше места нет.
type fullErrWriter struct{}

func (fullErrWriter) Write(p []byte) (int, error) { return len(p), errQuota }

func readGzip(b []byte) ([]byte, error) {
	zr, err := gzip.NewReader(bytes.NewReader(b))
	if err != nil {
		return nil, err
	}
	return io.ReadAll(zr)
}