MEASUREMENT
bench/goio/practice.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
153 lines//go:build ignore
// Две задачи урока про io.Reader и io.Writer: обе отвечают прогоном.
//
// ЗАДАЧА 1 — ПРЕДСКАЗАТЬ ВЫВОД. Три места, где код, написанный «по
// strings.Reader», ведёт себя не так, как ждут: цикл, проверяющий ошибку
// раньше n, теряет последний кусок у читателя, который отдаёт его вместе с
// EOF; io.ReadFull на коротком входе возвращает прочитанное и
// io.ErrUnexpectedEOF; bufio.Writer без Flush не передал вниз ни байта.
//
// ЗАДАЧА 2 — ОЦЕНИТЬ КРАТНОСТЬ. Во сколько раз 10 000 записей по 16 байт
// прямо в *os.File медленнее, чем те же записи через bufio.Writer. Ответ
// показывает, что каждый Write файла — отдельный системный вызов, а bufio
// собирает их в куски по 4096 байт.
//
// ЗАПУСК:
//
// go run bench/goio/practice.go
package main
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing/iotest"
"time"
)
const (
writes = 10_000
rounds = 15
)
var record = []byte("0123456789abcdef") // 16 байт
// best возвращает лучшее время из rounds прогонов f.
func best(f func()) time.Duration {
lo := time.Duration(1 << 62)
for i := 0; i < rounds; i++ {
start := time.Now()
f()
if d := time.Since(start); d < lo {
lo = d
}
}
return lo
}
// counter считает вызовы Write, дошедшие до нижнего писателя.
type counter struct {
w io.Writer
calls int
}
func (c *counter) Write(p []byte) (int, error) {
c.calls++
return c.w.Write(p)
}
func main() {
fmt.Printf("%s %s/%s | io: две задачи урока\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
fmt.Println()
fmt.Println("ЗАДАЧА 1 — предсказать вывод")
fmt.Println()
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())
fmt.Println()
fmt.Println("ЗАДАЧА 2 — во сколько раз запись без bufio медленнее")
fmt.Println()
dir, err := os.MkdirTemp("", "goio-practice")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "out.bin")
f, err := os.Create(path)
if err != nil {
panic(err)
}
defer f.Close()
// Файл создаётся один раз: в замер входят только записи. Каждый круг
// пишет с начала файла поверх прошлого.
direct := best(func() {
f.Seek(0, io.SeekStart)
for i := 0; i < writes; i++ {
f.Write(record)
}
})
bw := bufio.NewWriter(f)
buffered := best(func() {
f.Seek(0, io.SeekStart)
bw.Reset(f)
for i := 0; i < writes; i++ {
bw.Write(record)
}
bw.Flush()
})
// Сколько Write дошло до файла — счётчиком вне замера времени.
cnt := &counter{w: f}
f.Seek(0, io.SeekStart)
for i := 0; i < writes; i++ {
cnt.Write(record)
}
directCalls := cnt.calls
cnt.calls = 0
f.Seek(0, io.SeekStart)
bw.Reset(cnt)
for i := 0; i < writes; i++ {
bw.Write(record)
}
bw.Flush()
bufferedCalls := cnt.calls
us := func(d time.Duration) float64 { return float64(d.Nanoseconds()) / 1000 }
fmt.Printf(" %d x f.Write(16 байт) в *os.File %8.0f мкс, до файла дошло %d Write\n", writes, us(direct), directCalls)
fmt.Printf(" те же записи через bufio.Writer + Flush %8.0f мкс, до файла дошло %d Write\n", us(buffered), bufferedCalls)
fmt.Printf(" кратность %8.0f\n", float64(direct)/float64(buffered))
fmt.Printf(" на одну запись: %.0f нс против %.0f нс\n",
float64(direct.Nanoseconds())/writes, float64(buffered.Nanoseconds())/writes)
fmt.Println()
fmt.Printf(" Лучший из %d кругов. %s %s/%s, GOMAXPROCS=%d, файл во временной папке.\n",
rounds, runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
fmt.Println()
fmt.Println(" Без bufio каждый f.Write — отдельный вызов в ядро. С bufio записи")
fmt.Println(" копируются в буфер на 4096 байт и уходят вниз кусками. Кратность")
fmt.Println(" зависит от файловой системы и ядра; в замер не входит создание файла.")
}