сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Интерфейсы и основы Go

Мапы и строки в Go

Мапы и строки: порядок, гонки, байты

Тест зелёный десять запусков подряд и красный на одиннадцатом - потому что он сравнивал строку, собранную обходом мапы. Сервис работал полгода и упал в пятницу с fatal error, которую не поймал ни один recover. Обрезали строку по len и получили половину буквы. Три классические истории, три вопроса на собеседовании.

Стержень: мапа не упорядочена и не потокобезопасна, а len строки считает байты, а не символы.

// Формулировки: «в каком порядке обходится map?», «что будет при записи из двух горутин?», «чему равен len("café")?»

Порядок обхода случаен, и это специально

Обход мапы стартует со случайной позиции, и порядок меняется от запуска к запуску. Проверено на мапе из пяти ключей: три обхода подряд в одном процессе дали [c d e a b], [d e a b c] и [a b c d e]. Не в разных запусках программы - в одном, подряд.

Сделано это намеренно и довольно жёстко: разработчики Go заранее ломают любую попытку завязаться на порядок, который не гарантирован и может измениться в следующей версии. Лучше уронить тест сразу, чем через год на проде.

Нужен стабильный вывод - в тесте, в отчёте, в подписи запроса - собирай ключи и сортируй. С Go 1.23 это одна строка: slices.Sorted(maps.Keys(m)). До 1.23 - цикл, сбор в слайс и slices.Sort.

// Мелочи рядом: чтение отсутствующего ключа безопасно и вернёт нулевое значение, форма v, ok := m[k] отличает «нет ключа» от «есть, но ноль», delete по несуществующему ключу ничего не делает. А вот адрес элемента взять нельзя: при росте мапа физически переносит элементы, поэтому &m[k] не компилируется, и m[k].Field = v тоже.

comma ok
v, ok := m[k] - различает отсутствие ключа и нулевое значение

Конкурентная запись убивает процесс

Встроенной синхронизации у мапы нет, и рантайм специально следит за одновременным доступом. Запускаем восемь горутин, которые пишут в одну мапу, и оборачиваем всё в defer с recover. Получаем: fatal error: concurrent map writes, трассировку горутины и завершение процесса. Строчка из recover не печатается вообще - его не позвали.

Это ключевое отличие от паники. Паника разворачивает стек и даёт коду шанс вмешаться. Fatal error не даёт ничего: рантайм считает, что тихо испорченная хеш-таблица опаснее честного падения, и убивает процесс на месте. Никакие middleware с recover в веб-сервере тут не помогут.

Лечится либо мьютексом рядом с мапой, либо sync.Map. Мьютекс - это замок: горутина берёт его перед работой с мапой и отпускает после, а остальные ждут своей очереди, поэтому одновременных записей не случается. Когда чтений заметно больше записей, берут RWMutex: он пускает читателей пачкой и запирает мапу только на время записи. Вторая выигрывает только на своих профилях: ключ записали один раз и много читают, либо горутины работают с непересекающимися наборами ключей.

// В смешанной нагрузке с активными записями sync.Map обычно проигрывает обычной мапе под RWMutex, да ещё и теряет типизацию: значения приходят как any, и на каждом чтении нужно приведение типа. Брать её по умолчанию - плохая идея, брать под её профиль - хорошая.

defer func() { fmt.Println("recover:", recover()) }()
m := map[int]int{}
for i := 0; i < 8; i++ {
    go func(i int) { for j := 0; j < 100000; j++ { m[i*100000+j] = j } }(i)
}
// fatal error: concurrent map writes
// строка с recover НЕ печатается - процесс уже мёртв
fatal error
аварийное завершение рантайма; в отличие от паники, recover его не перехватывает

Строка - это байты

Строка в Go неизменяема и хранит байты в кодировке UTF-8. Поэтому len возвращает число байтов, а не символов. Проверено: len("café") равен 5 при четырёх буквах, потому что é занимает два байта. На кириллице разрыв больше: len("привет") равен 12 при шести буквах, каждая по два байта.

Отсюда все беды с обрезанием строк по длине: обрежешь по границе байта - получишь половину символа и вопросительный знак в интерфейсе. Индексация s[i] тоже возвращает byte, а не символ. А вот range по строке идёт по кодовым точкам и выдаёт rune вместе со смещением в байтах - это правильный способ обойти строку по символам.

Режешь по символам - переводи в []rune, работаешь с сырьём и производительностью - держи []byte. Оба типа - просто псевдонимы: byte это uint8, rune это int32.

// Отдельная история - склейка в цикле. Строки неизменяемы, поэтому s += "x" создаёт новую строку и копирует старую целиком на каждой итерации. Замер на двадцати тысячах итераций: через += это 75 миллисекунд, через strings.Builder - 0,1 миллисекунды. Разница в семьсот сорок раз, и растёт она квадратично от числа итераций.

rune
псевдоним int32, одна кодовая точка Unicode
strings.Builder
растущий буфер для склейки строк без копирования на каждом шаге

Как отвечать: «В каком порядке обходится мапа и что будет при записи из двух горутин?»

Порядок обхода случайный, причём меняется даже между двумя обходами подряд в одном процессе - рантайм стартует со случайной позиции. Сделано намеренно, чтобы никто не построил логику на порядке, который не гарантирован и может поменяться между версиями Go. Если нужен предсказуемый вывод - в тесте, в отчёте, в подписи - я собираю ключи и сортирую, с Go 1.23 это одна строка через slices.Sorted и maps.Keys. Про конкурентность: внутренней блокировки у мапы нет, и рантайм специально ловит одновременный доступ. При обнаружении печатает fatal error про concurrent map writes и завершает процесс. Принципиально важно, что это не паника: recover её не перехватывает, я это проверял - строка из defer просто не выполняется. Сделано осознанно, потому что молча испорченная хеш-таблица хуже честного падения. Лечится мьютексом рядом с мапой, обычно RWMutex при перевесе чтений, либо sync.Map - но её я беру только под её профиль: записали один раз, читают многие, или ключи у горутин не пересекаются.

Объяснена причина рандомизации, дан рабочий рецепт стабильного порядка, названо точное поведение рантайма и подчёркнуто отличие fatal error от паники, а выбор между мьютексом и sync.Map сделан по профилю нагрузки.

На чём валят

  • Полагаются на порядок обхода мапы - тест начинает мигать через раз.
  • Пишут в мапу из горутин без синхронизации и получают fatal error в проде.
  • Думают, что concurrent map writes перехватывается через recover: нет, процесс умирает.
  • Берут sync.Map по умолчанию вместо мапы под RWMutex и проигрывают на смешанной нагрузке.
  • Считают len строки числом символов - для кириллицы это ровно вдвое больше.
  • Клеят строки через += в длинном цикле: на двадцати тысячах итераций это в сотни раз медленнее Builder.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 13, остальные разбираются в тренажёре.

  1. #go_maps_strings1 / 5
    Как отличить «ключ есть со значением 0» от «ключа нет» в map[string]int?
    A)Никак: значение 0 и отсутствие ключа в Go неразличимы
    B)Через форму v, ok := m[k]: ok=false, если ключа нет
    C)Сравнить len(m) до обращения и после него
    D)Сравнить m[k] с nil — отсутствующий ключ вернёт nil
    показать ответ и разбор
    +B)Через форму v, ok := m[k]: ok=false, если ключа нет

    // разбор: Обычное m[k] возвращает нулевое значение и при «нет ключа», и при «есть, но 0» — их не отличить. Форма с запятой-ok: v, ok := m[k] даёт ok=false, если ключа нет, и true, если есть (даже с нулевым значением). Это же используют в if _, ok := m[k]; ok для проверки наличия без чтения значения.

  2. #go_maps_strings2 / 5
    Строка s := "café". Чему равен len(s) и что выдаёт range по s?
    A)len=4; range идёт по 4 байтам-символам подряд
    B)len=5 (байты UTF-8); range идёт по рунам — 4 символа с офсетами
    C)len=5; range тоже идёт по 5 байтам по одному
    D)len зависит от локали ОС, а range проходит строку побайтово
    показать ответ и разбор
    +B)len=5 (байты UTF-8); range идёт по рунам — 4 символа с офсетами

    // разбор: Строка Go — неизменяемый срез байтов в UTF-8. len(s) считает БАЙТЫ: «café» = 5 (é занимает 2 байта), индексация s[i] тоже байтовая. А range по строке декодирует UTF-8 и выдаёт руны (кодовые точки) с байтовым офсетом — поэтому итераций 4. Для подсчёта символов есть utf8.RuneCountInString. Спот-чек: 5 байт, 4 руны.

  3. #go_maps_strings3 / 5
    Идиоматичный способ собрать большую строку из тысяч кусков в цикле?
    A)s += piece на каждой итерации — компилятор оптимизирует это в место
    B)На каждой итерации звать strings.Join по всему накопленному срезу
    C)strings.Builder — пишет в растущий буфер без копий на каждом шаге
    D)Конкатенация через fmt.Sprintf на каждой итерации цикла
    показать ответ и разбор
    +C)strings.Builder — пишет в растущий буфер без копий на каждом шаге

    // разбор: Строки неизменяемы, поэтому s += piece создаёт НОВУЮ строку и копирует всё накопленное на каждой итерации — квадратично по объёму. strings.Builder пишет в амортизированно растущий буфер (как append) и отдаёт строку один раз в конце — линейно. strings.Join хорош, если куски уже собраны в срез. fmt.Sprintf в цикле — самый медленный путь.

  4. #go_maps_strings4 / 5
    В каком порядке range обходит ключи map?
    A)В порядке вставки ключей — map сохраняет историю добавления
    B)В отсортированном порядке для сравнимых типов ключей
    C)В порядке хеширования, стабильном между запусками программы
    D)В случайном: порядок намеренно меняется от прохода к проходу
    показать ответ и разбор
    +D)В случайном: порядок намеренно меняется от прохода к проходу

    // разбор: Рантайм намеренно стартует обход со случайной позиции — чтобы никто не построил логику на порядке, который может измениться с версией. Практический вывод: нужен предсказуемый вывод (тесты, отчёты, JSON) — соберите ключи в слайс и отсортируйте через slices.Sort. С Go 1.23, где maps.Keys отдаёт итератор, это схлопывается в одну строку: slices.Sorted(maps.Keys(m)).

  5. #go_maps_strings5 / 5
    Несколько горутин пишут в одну map без синхронизации. Как это проявится?
    A)Часть записей потеряется, но программа продолжит работать дальше
    B)Рантайм обнаружит конкурентную запись и аварийно завершит процесс
    C)Записи выстроятся в очередь: map внутри защищена собственной блокировкой
    D)Программа зависнет на попытке одновременной записи в одну корзину
    показать ответ и разбор
    +B)Рантайм обнаружит конкурентную запись и аварийно завершит процесс

    // разбор: У map нет встроенной защиты, и рантайм специально следит за одновременным доступом: при обнаружении печатает fatal error: concurrent map writes и валит процесс. Это не паника — её нельзя перехватить через recover, что сделано намеренно: тихая порча хеш-таблицы хуже честного падения. Лечится мьютексом или sync.Map под её профиль нагрузки.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.