Почему O(1) проигрывает O(n): структуры данных в Go на реальном железе

от автора

Объясню структуры данных через очередь в поликлинике, а потом покажу, где эта аналогия ломается: почему связный список с «вставкой за 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), бабуля уже всё знает.

Где Big O перестаёт помогать

В учебнике написано: вставка в связный список — 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 := 0for _, v := range s {    sum += v}// списокsum := 0for cur := head; cur != nil; cur = cur.next {    sum += cur.val}
Одна кеш-линия — шестнадцать int64

Одна кеш-линия — шестнадцать int64

Медианы пяти прогонов, -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 := headfor 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%. Зато память ровно вдвое на обоих размерах, и вдвое меньше аллокаций. На этом стенде подсказка оказалась в первую очередь про память и аллокатор; с другими типами ключей и значений картина может отличаться.

Ещё несколько свойств map

Второе, за что бабуля берёт плату: на маленьких наборах её работа заметна.

Посчитать хеш, выбрать группу, сравнить control word, проверить ключ целиком — это работа. Перебрать несколько элементов подряд в массиве — тоже работа, но она вся в кеше и без хеширования.

Ключей

map

Перебор слайса

Кто быстрее

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.7GOOS/GOARCH  : darwin/arm64CPU          : Apple M3 Pro, GOMAXPROCS=12cache line   : 128 байт   (sysctl hw.cachelinesize)L1d / L2     : 64 КБ / 4 МБпрогонов     : 5 (-count=5), в таблицах медианы

Числа с ARM-машины, и на x86-64 с 64-байтной линией абсолютные значения будут другими. Механизм — нет: он про то, что происходит, когда рабочий набор перестаёт помещаться в кеш.

Обход я гонял дважды. В первом прогоне разброс был такой, что на тысяче элементов разброшенный список местами выходил быстрее плотного — похоже, машина была занята чем-то ещё. В статье числа из второго прогона.

Код замеров открыт, гоняется одной командой, и в нём закрыты три ловушки, из-за которых замеры такого рода обычно врут: мёртвый код (компилятор выбрасывает обход, результат которого не используется), выгодное для одной из сторон начальное состояние (отсюда три варианта списка, один из них — контрольный, отделяющий порядок обхода от способа аллокации) и проверка, что сравниваемые структуры вообще содержат одинаковые данные.

Что выбрать

Ни одна из этих структур не «лучше» другой. Они отвечают на разные вопросы:

Что нужно

С чего начинать

перебирать всё подряд, считать, суммировать

слайс

доступ по номеру

слайс

поиск по ключу

map

совсем маленький набор полей

слайс пар — на моём стенде до четырёх ключей та же скорость, и это проще

часто вставлять и удалять, указатель на место уже есть

список, обычно вместе с map

стабильные адреса элементов

список

важен порядок обхода

слайс, или ключи из map с сортировкой

Универсальных порогов в таблице намеренно нет. Число, на котором map начинает выигрывать у перебора, зависит от типа ключа, размера значения, доли промахов и процессора — у меня оно одно, у вас будет другое. Надёжный способ его узнать — замерить свой случай.

Код и замеры из статьи лежат на backendstart.ru — там же есть другие разборы backend-задач в таком формате. Всё можно прогнать у себя.

Что забрать с собой

  1. Список берут за то, что указатель на узел уже есть или нужны стабильные адреса. Не за «O(1) на вставке».

  2. Замеряете список — гоняйте оба состояния, узлы подряд и узлы вразброс. Первое польстит.

  3. Знаете размер карты — скажите его в make. На моём тесте это вдвое меньше памяти и аллокаций.

  4. Худшая из миллиона вставок в карту заняла у меня 2 мс. Критична tail latency — смотрите свой request path.

  5. Карта между горутинами без синхронизации убивает процесс целиком, и recover не спасёт.

В таблице сложности у массива и списка по-прежнему написано O(n). На моём M3 Pro между ними получилось ×322.

Big O не соврал. Просто про разницу в ×322 он ничего не обещал.

ссылка на оригинал статьи https://habr.com/ru/articles/1072720/