Мапы и строки в 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, остальные разбираются в тренажёре.
- Как отличить «ключ есть со значением 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 для проверки наличия без чтения значения.
- Строка 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 руны.
- Идиоматичный способ собрать большую строку из тысяч кусков в цикле?A)s += piece на каждой итерации — компилятор оптимизирует это в местоB)На каждой итерации звать strings.Join по всему накопленному срезуC)strings.Builder — пишет в растущий буфер без копий на каждом шагеD)Конкатенация через fmt.Sprintf на каждой итерации цикла
показать ответ и разбор
+C)strings.Builder — пишет в растущий буфер без копий на каждом шаге// разбор: Строки неизменяемы, поэтому s += piece создаёт НОВУЮ строку и копирует всё накопленное на каждой итерации — квадратично по объёму. strings.Builder пишет в амортизированно растущий буфер (как append) и отдаёт строку один раз в конце — линейно. strings.Join хорош, если куски уже собраны в срез. fmt.Sprintf в цикле — самый медленный путь.
- В каком порядке range обходит ключи map?A)В порядке вставки ключей — map сохраняет историю добавленияB)В отсортированном порядке для сравнимых типов ключейC)В порядке хеширования, стабильном между запусками программыD)В случайном: порядок намеренно меняется от прохода к проходу
показать ответ и разбор
+D)В случайном: порядок намеренно меняется от прохода к проходу// разбор: Рантайм намеренно стартует обход со случайной позиции — чтобы никто не построил логику на порядке, который может измениться с версией. Практический вывод: нужен предсказуемый вывод (тесты, отчёты, JSON) — соберите ключи в слайс и отсортируйте через slices.Sort. С Go 1.23, где maps.Keys отдаёт итератор, это схлопывается в одну строку: slices.Sorted(maps.Keys(m)).
- Несколько горутин пишут в одну map без синхронизации. Как это проявится?A)Часть записей потеряется, но программа продолжит работать дальшеB)Рантайм обнаружит конкурентную запись и аварийно завершит процессC)Записи выстроятся в очередь: map внутри защищена собственной блокировкойD)Программа зависнет на попытке одновременной записи в одну корзину
показать ответ и разбор
+B)Рантайм обнаружит конкурентную запись и аварийно завершит процесс// разбор: У map нет встроенной защиты, и рантайм специально следит за одновременным доступом: при обнаружении печатает fatal error: concurrent map writes и валит процесс. Это не паника — её нельзя перехватить через recover, что сделано намеренно: тихая порча хеш-таблицы хуже честного падения. Лечится мьютексом или sync.Map под её профиль нагрузки.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.