Почему O(1) проигрывает O(n): структуры данных в Go на реальном железе
- суббота, 22 августа 2026 г. в 00:00:17
Объясню структуры данных через очередь в поликлинике, а потом покажу, где эта аналогия ломается: почему связный список с «вставкой за O(1)» в прикладном Go обычно проигрывает обычному массиву.
Спойлер: асимптотика здесь не ошибается. Ошибается вывод, который мы из неё делаем.
Статья для тех, кто асимптотику знает, но не проверял её замером.
Сидишь в очереди к врачу. Номерка нет, ты знаешь одно: за кем занимать.
Это связный список. У элемента ссылка на следующего, и больше ничего:
type patient struct { name string next *patient // «а я за вами» }
Найти в такой очереди конкретного человека можно только пройдя её от соседа к соседу: двадцать человек — двадцать вопросов «вы последний?». Это O(n). Запомнил ещё и того, кто занял после тебя, — получился двусвязный список: ходить можно в обе стороны, и человека можно выдернуть, не обходя очередь заново, — но только если ты уже стоишь рядом с ним. Найти его всё равно придётся обходом.
В коридоре стоят стулья, и они пронумерованы. «Третий стул» — идёшь и садишься, никого не спрашивая.
Это массив — в аналогии. В Go на практике здесь обычно будет слайс, элементы которого лежат в непрерывном backing array, и всё сказанное дальше про локальность относится именно к нему. Адрес элемента считается арифметикой: начало плюс номер, умноженный на размер. Один переход, что для третьего стула, что для три тысячи двести седьмого.
seats := make([]string, 40) seats[3] = "Иванов" who := seats[3] // сразу, без обхода
Цена — в том, что стулья прикручены к полу: посадить кого-то в середину ряда можно только сдвинув всех, кто правее.
А потом заходит бабуля.
Она помнит всех. Кто в синей куртке, кто с папкой, кто отошёл покурить, кто «я только спросить». Спрашиваешь «а Петрова кто?» — отвечает сразу, не пересчитывая очередь.
Бабуля — это hash map. Ключ (примета) превращается в число, число указывает, где искать, дальше остаётся проверить пару кандидатов.
byName := make(map[string]*patient, len(all)) for _, p := range all { byName[p.name] = p } p := byName["Петров"] // сразу, без прохода по очереди
В поликлинике | В коде |
|---|---|
пронумерованные стулья | массив, доступ по индексу |
«я за вами» | связный список |
помнишь и переднего, и заднего | двусвязный список |
приметы человека | ключ |
полка, куда бабуля кладёт по примете | группа слотов |
двое с одинаковыми приметами | коллизия |
людей стало больше, чем полок | рост карты |

Пока ты бежишь по очереди от соседа к соседу за O(n), бабуля уже всё знает.
В учебнике написано: вставка в связный список — O(1), в массив — O(n). Вывод как будто очевиден: вставляем часто — берём список.
Я такой вывод делал. Для прикладного Go он часто оказывается неверным.
Асимптотика отвечает на вопрос, как растёт время с размером данных. Она не говорит, сколько стоит одна операция. А разница в цене между «сдвинуть непрерывный кусок памяти» и «перейти по указателю в непредсказуемое место» — та часть, которой в формуле нет вообще.
Процессор не читает память по одному байту. Он тянет её блоками — кеш-линиями. Размер линии зависит от микроархитектуры, а не от системы команд. На машине, где сделаны замеры (Apple M3 Pro), sysctl hw.cachelinesize отвечает 128 байт; на большинстве x86-64 будет 64. Стенд целиком — в конце статьи.
Для массива это подарок. Элементы лежат вплотную, поэтому одна загруженная линия содержит сразу шестнадцать int64 — и последовательный обход ими воспользуется, не запрашивая память заново на каждом шаге.
Для связного списка — наоборот. Узел такой формы на 64-битной платформе занимает 16 байт:
type node struct { val int64 // 8 next *node // 8 } // unsafe.Sizeof(node{}) == 16
В кеш-линию их влезает восемь. Но лежат ли рядом с нужным узлом те семь, которые понадобятся дальше по обходу, — зависит от того, как узлы распределились по куче. Если список собирали по одному узлу вперемешку с другими аллокациями, соседи по памяти и соседи по цепочке — разные узлы.
И главное: cur = cur.next — цепочка зависимых обращений. Адрес следующего узла становится известен только после того, как приехал предыдущий, и это резко ограничивает memory-level parallelism. Последовательный обход массива процессор хорошо предсказывает и подгружает линии вперёд; с разбросанным списком подгружать заранее ему значительно сложнее.
Промах кеша с походом в оперативную память стоит десятки наносекунд, то есть сотни тактов; точное число зависит от уровня кеша, памяти и процессора. Формула O(n) этой цены не содержит.
Проверяем. Одинаковые данные, одинаковая работа — сложить все значения:
// массив sum := 0 for _, v := range s { sum += v } // список sum := 0 for cur := head; cur != nil; cur = cur.next { sum += cur.val }

Медианы пяти прогонов, -count=5. Все варианты содержат одни и те же значения, тест сверяет их сумму:
Элементов | Массив | Список: узлы подряд | Список вразброс: один блок | Список вразброс: отдельные аллокации |
|---|---|---|---|---|
1 000 | 480 нс | 1.61 мкс (×3.3) | 1.67 мкс (×3.5) | 1.70 мкс (×3.5) |
10 000 | 4.46 мкс | 16.8 мкс (×3.8) | 46.1 мкс (×10) | 46.0 мкс (×10) |
100 000 | 49.4 мкс | 178.5 мкс (×3.6) | 1.17 мс (×24) | 1.31 мс (×26) |
1 000 000 | 491 мкс | 1.77 мс (×3.6) | 158.2 мс (×322) | 146.7 мс (×299) |
Цена одного элемента на миллионе:
массив: 491 мкс / 1e6 ≈ 0.49 нс на элемент список подряд: 1.77 мс / 1e6 ≈ 1.8 нс на узел список вразброс: 158.2 мс / 1e6 ≈ 158 нс на узел
158 нс на один зависимый переход по указателю — ровно тот порядок, которого стоит обращение к памяти мимо кеша. Асимптотика у обоих обходов O(n).
Этой цифре я сначала не поверил. Разброшенный список отличался от плотного двумя вещами сразу: узлы выделены по одному через &node{}, и связи перемешаны. Значит ×322 могли объясняться вовсе не локальностью, а поведением аллокатора. Так появился четвёртый вариант: узлы в том же одном блоке make([]node, n), что и у плотного, но связаны в случайном порядке. Отличие от плотного ровно одно — порядок связей.
Он дал 158.2 мс против 146.7 мс у отдельных аллокаций. Разница 7% при отставании от массива в три сотни раз: случайные связи внутри одного блока уже дают те же ~150 мс. Похоже, почти вся разница здесь именно в порядке обхода. Пять прогонов не позволяют сказать, что способ аллокации не влияет вообще, но рядом с ×300 его вклад небольшой.
У этой машины L1d — 64 КБ, L2 — 4 МБ. Теперь посмотрим на размер рабочего набора:
Элементов | Массив | Список | Где помещается |
|---|---|---|---|
1 000 | 8 КБ | 16 КБ | в L1 |
10 000 | 80 КБ | 160 КБ | в L2 |
100 000 | 800 КБ | 1.6 МБ | в L2 |
1 000 000 | 8 МБ | 16 МБ | больше L2 |
Даже плотный список стоит примерно ×3.6, и эта надбавка почти одинакова на всех размерах: ×3.3, ×3.8, ×3.6, ×3.6. Из чего она складывается, один этот бенчмарк не разделяет — у узла 16 байт против 8 у элемента массива, то есть вдвое больший рабочий набор, плюс чтение next и зависимость шага от предыдущего. Важно, что от размера данных она не зависит.
А цена перестановки связей от размера зависит резко. Если считать не от массива, а от плотного списка: ×1.0, ×2.8, ×6.6, ×89. На тысяче элементов разницы почти нет — всё помещается в L1. Когда рабочий набор перестаёт помещаться в L2, та же перестановка стоит в девяносто раз.
list_dense — это список сразу после того, как его аккуратно собрали в цикле. Замерь только его, и вывод получится «медленнее, но терпимо». Бывают нагрузки, где список таким и остаётся, но после череды вставок и удалений рассчитывать на это уже нельзя.
Хорошо, обход у списка медленнее. Но вставка-то O(1)?
O(1) — это только момент перецепления указателей. До нужного места ещё надо дойти:
// список: сначала дойти, потом вставить prev := head for k := 0; k < i-1; k++ { // вот где появляется O(n), если известен индекс, а не узел prev = prev.next } prev.next = &node{val: 42, next: prev.next}
// массив: сдвинуть хвост s = append(s, 0) copy(s[i+1:], s[i:]) s[i] = 42
Обе операции линейные. Но copy работает с непрерывным диапазоном памяти: компилятор сводит его к специализированному копированию блока, которое читает и пишет подряд и хорошо использует пропускную способность памяти. А проход по указателям — та самая цепочка зависимых обращений, каждое из которых ждёт предыдущего.
Элементов | Массив (сдвиг) | Список (дойти и вставить) | Список (узел уже в руках) |
|---|---|---|---|
1 000 | 145 нс | 397 нс (×2.7 хуже) | 3.4 нс |
100 000 | 18.9 мкс | 51.5 мкс (×2.7 хуже) | 3.6 нс |
Список с обходом проигрывает массиву в 2.7 раза на обоих размерах — при том что «по учебнику» у него вставка O(1), а у массива O(n).
Вот в третьей колонке у списка действительно O(1): 3.4 нс на тысяче элементов и 3.6 нс на ста тысячах. Именно такую картину и ожидаешь от O(1) — пропал обход, и стоимость почти не изменилась. Нужный узел для этого должен уже лежать в руках.
Список имеет смысл там, где указатель на нужный узел уже есть и добывать его обходом не надо. Классический пример — LRU-кеш: map даёт указатель на узел за один шаг, список переставляет его в начало за одну операцию. Ни одного обхода.
type lru struct { order *list.List // порядок использования index map[string]*list.Element // ключ → узел в списке } func (c *lru) Get(key string) (any, bool) { el, ok := c.index[key] if !ok { return nil, false } c.order.MoveToFront(el) // указатель уже есть — O(1) без обхода return el.Value, true }
Ещё один настоящий случай — когда нужны стабильные адреса. append может выделить новый backing array и скопировать туда данные. Взятый раньше указатель остаётся валидной памятью, но ссылается на старый массив: через слайс вы уже видите новый, и запись по старому указателю в него не попадёт. Узлы списка с места не двигаются.
У бабули тоже есть цена, и первая — рост.
У классической хеш-таблицы это выглядит так. Записей стало больше, чем позволяет заполнение, — выделяется массив вдвое больше, и все записи перекладываются туда. Для таблицы на гигабайт это значит, что одной из вставок придётся выполнить работу по росту всей таблицы. Остальные — наносекунды, а эта надолго, и создаёт выброс в хвосте распределения задержек: заметит его тот запрос, которому не повезло.
А теперь что делает Go. Начиная с версии 1.24 встроенный map реализован по схеме Swiss Tables, и команда Go в блоге называет причину прямо: Go часто используют для серверов, чувствительных к задержкам, поэтому операции над встроенными типами не должны произвольно влиять на tail latency.
Как это сделано, по описанию из того же блога:
хранилище разбито на группы по 8 слотов, к каждой группе прицеплено 64-битное control word — по байту на слот;
байт говорит, пуст слот, удалён или занят, и если занят — содержит младшие 7 бит хеша ключа (h2);
поиск сравнивает искомый h2 со всеми восемью байтами control word одной операцией вместо восьми последовательных сравнений ключей (по описанию в том же блоге, на amd64 для этого используются SIMD-инструкции). Сравниваются не ключи, а метаданные, поэтому совпадение — это ещё не ответ, а кандидат: 7 бит совпадают у разных ключей примерно в одном случае из 128, и полный ключ всё равно проверяется;
большая карта разбита на независимые таблицы, каждая до 1024 записей; старшие биты хеша выбирают таблицу. Переполнилась одна — делится она, остальных это не касается.
Проверяем, замеряя каждую из миллиона вставок по отдельности:
d := make([]time.Duration, n) // место под отсчёты — заранее m := make(map[int]int) for i := 0; i < n; i++ { start := time.Now() m[i] = i d[i] = time.Since(start) }
Это демонстрационный эксперимент, а не бенчмарк цены mapassign. Одна вставка быстрее вызова time.Now, поэтому прибор — заметная часть измеряемого: пустой замер (только time.Now и time.Since) даёт p50 = 41 нс. Читать медиану как чистую стоимость вставки нельзя. Что этой методикой видно хорошо — выбросы: вставка, которая стоит многократно дороже соседних, из-под накладных расходов таймера торчит.

Миллион вставок, GC отключён на время замера:
пустой замер (только таймер): p50 = 41 нс p50 = 167 нс p99 = 875 нс p99.9 = 32.7 мкс max = 2.01 мс ← ≈12 000× от измеренной медианы дороже 100 × p99 (87.5 мкс): 98 вставок из 1 000 000
Здесь рассказ пришлось править по факту. Худшая вставка заняла 2 мс. Выбросы кучкуются примерно между #838 000 и #926 000 и на степени двойки не похожи. GC я на время теста отключил, так что это не он.
Почему именно они возникают, из этого теста я не знаю. Можно подозревать выделение новых таблиц или первые обращения к свежим страницам памяти, но таймер вокруг m[k] = v этого не доказывает. Тут уже нужен профиль.
Локализация роста избавляет от копирования всей карты, но выбросы в хвосте остаются. Если у вас в SLA стоят миллисекунды на хвосте, «в Go теперь хорошая карта» — не аргумент.
Работу при росте можно частично или полностью не делать, если размер известен заранее:
m := make(map[string]int) // размер неизвестен — карта растёт по ходу дела m := make(map[string]int, len(rows)) // размер известен
Второй аргумент make — это подсказка, а не фиксированная ёмкость: рост он не отменяет. Но позволяет выделить место сразу под ожидаемое количество записей вместо того, чтобы приходить к нему через несколько промежуточных ростов.
Записей | Без подсказки | С подсказкой | Разница |
|---|---|---|---|
10 000 | 424 мкс · 591 КБ · 79 allocs | 134 мкс · 296 КБ · 33 allocs | ×3.2 по времени, ×2 по памяти |
1 000 000 | 101 мс · 75.6 МБ · 8208 allocs | 88 мс · 37.8 МБ · 4097 allocs | ×1.15 по времени, ×2 по памяти |
На десяти тысячах записей подсказка даёт втрое по времени, а на миллионе — всего 15%. Зато память ровно вдвое на обоих размерах, и вдвое меньше аллокаций. На этом стенде подсказка оказалась в первую очередь про память и аллокатор; с другими типами ключей и значений картина может отличаться.
Второе, за что бабуля берёт плату: на маленьких наборах её работа заметна.
Посчитать хеш, выбрать группу, сравнить control word, проверить ключ целиком — это работа. Перебрать несколько элементов подряд в массиве — тоже работа, но она вся в кеше и без хеширования.
Ключей |
| Перебор слайса | Кто быстрее |
|---|---|---|---|
4 | 10.2 нс | 10.2 нс | поровну |
8 | 11.8 нс | 19.9 нс | map ×1.7 |
16 | 18.5 нс | 44.0 нс | map ×2.4 |
32 | 18.9 нс | 81.5 нс | map ×4.3 |
64 | 18.3 нс | 146.9 нс | map ×8.0 |
128 | 18.7 нс | 312.5 нс | map ×16.7 |
С восьми ключей map уже впереди, и дальше отрыв растёт линейно, потому что у него время почти не меняется — 18-19 нс от шестнадцати ключей и до ста двадцати восьми.
Про методику: ищется последний ключ из присутствующих, то есть худший случай для перебора при попадании. Ключи одинаковой длины — иначе сравнение строк отбрасывало бы кандидатов по длине, не сравнивая байты, и перебор выглядел бы лучше, чем есть. Промах (ключа нет вовсе) для перебора ещё дороже: он обходит всё до конца, тогда как у map промах не превращается в полный линейный обход всех элементов. Цифры в таблице — оценка сверху для перебора на попаданиях, и переносить их на нагрузку с частыми промахами нельзя.
На этом стенде точка равенства оказалась между четырьмя и восьмью ключами — намного раньше, чем я ожидал. То есть «на маленьких наборах перебор быстрее» — правда, но «маленький» здесь означает не десяток, а буквально несколько.
Ещё три свойства map, на которые натыкаются в проде.
Конкурентная запись убивает процесс. Не паникой, которую можно поймать:
fatal error: concurrent map writes
recover не поможет — это fatal error, а не panic: процесс умирает целиком, вместе со всеми остальными запросами. Лечится обычным sync.RWMutex рядом с картой; sync.Map — не универсальная замена: её документация прямо называет два сценария, под которые она оптимизирована, — ключ записывается один раз и читается много (write-once, read-many) и наборы ключей у горутин не пересекаются. В остальных случаях map под мьютексом обычно проще и понятнее. Но первый вопрос — зачем карта вообще разделяется между горутинами.
Порядок обхода не определён. Спецификация языка говорит прямо: порядок итерации по карте не задан и не гарантируется одинаковым от одной итерации к другой.
for k, v := range m { // порядок не определён; полагаться на него нельзя fmt.Println(k, v) }
Ловится обычно тестом, который зелёный локально и красный в CI — или наоборот. Нужен порядок — собирайте ключи в слайс и сортируйте.
Память после удаления. Что происходит с занятой картой памятью, когда из неё удалили все ключи, — вопрос к реализации, а не к спецификации, и по одной версии Go отвечать за другую нельзя. Поэтому замер:
heap до карты: 0.2 МБ миллион записей: 36.3 МБ (+36.1 МБ) после delete всех: 36.3 МБ (len = 0) после clear: 36.3 МБ удержано: 100%
В этом эксперименте на Go 1.24.7 результат однозначный: ни delete всех ключей, ни clear не вернули ни одного мегабайта. Карта на миллион записей заняла 36 МБ и держит их, пока сама достижима.
Если после пика важно избавиться от удержанной ёмкости, карту придётся заменить новой: clear очищает содержимое, но не ёмкость.
go version : go1.24.7 GOOS/GOARCH : darwin/arm64 CPU : Apple M3 Pro, GOMAXPROCS=12 cache line : 128 байт (sysctl hw.cachelinesize) L1d / L2 : 64 КБ / 4 МБ прогонов : 5 (-count=5), в таблицах медианы
Числа с ARM-машины, и на x86-64 с 64-байтной линией абсолютные значения будут другими. Механизм — нет: он про то, что происходит, когда рабочий набор перестаёт помещаться в кеш.
Обход я гонял дважды. В первом прогоне разброс был такой, что на тысяче элементов разброшенный список местами выходил быстрее плотного — похоже, машина была занята чем-то ещё. В статье числа из второго прогона.
Код замеров открыт, гоняется одной командой, и в нём закрыты три ловушки, из-за которых замеры такого рода обычно врут: мёртвый код (компилятор выбрасывает обход, результат которого не используется), выгодное для одной из сторон начальное состояние (отсюда три варианта списка, один из них — контрольный, отделяющий порядок обхода от способа аллокации) и проверка, что сравниваемые структуры вообще содержат одинаковые данные.
Ни одна из этих структур не «лучше» другой. Они отвечают на разные вопросы:
Что нужно | С чего начинать |
|---|---|
перебирать всё подряд, считать, суммировать | слайс |
доступ по номеру | слайс |
поиск по ключу |
|
совсем маленький набор полей | слайс пар — на моём стенде до четырёх ключей та же скорость, и это проще |
часто вставлять и удалять, указатель на место уже есть | список, обычно вместе с |
стабильные адреса элементов | список |
важен порядок обхода | слайс, или ключи из |
Универсальных порогов в таблице намеренно нет. Число, на котором map начинает выигрывать у перебора, зависит от типа ключа, размера значения, доли промахов и процессора — у меня оно одно, у вас будет другое. Надёжный способ его узнать — замерить свой случай.
Код и замеры из статьи лежат на backendstart.ru — там же есть другие разборы backend-задач в таком формате. Всё можно прогнать у себя.
Список берут за то, что указатель на узел уже есть или нужны стабильные адреса. Не за «O(1) на вставке».
Замеряете список — гоняйте оба состояния, узлы подряд и узлы вразброс. Первое польстит.
Знаете размер карты — скажите его в make. На моём тесте это вдвое меньше памяти и аллокаций.
Худшая из миллиона вставок в карту заняла у меня 2 мс. Критична tail latency — смотрите свой request path.
Карта между горутинами без синхронизации убивает процесс целиком, и recover не спасёт.
В таблице сложности у массива и списка по-прежнему написано O(n). На моём M3 Pro между ними получилось ×322.
Big O не соврал. Просто про разницу в ×322 он ничего не обещал.