На моём менторстве на одном из этапов мы с учениками разбираем их собеседования. Они присылают мне записи своих собеседований, а я разбираю их по вопросам: что спросили, как человек ответил и что стоит подтянуть к следующему разу. Когда таких записей собирается много, становится видно, что на первом собеседовании по Go спрашивают почти одно и то же. Основываясь на своём опыте и разобранных собеседованиях учеников, в этой статье я расскажу, какие вопросы звучат почти всегда, а какие требования из вакансии можно сразу не учить (спойлер: это примерно половина списка).
Статья будет полезна тем, кто готовится к первому собеседованию на позицию Go-разработчика: переходит в Golang из другого языка или из тестирования, закончил обучение и начинает откликаться на вакансии. Здесь собраны вопросы, которые чаще всего задают на собеседовании Go-разработчика, и темы, которые можно отложить. Если вы уже работаете на Go и регулярно проходите собеседования, большую часть этого вы и так знаете.
Расскажу на примере одного моего ученика. Алексей (имя изменено) работает ручным тестировщиком, опыта в разработке у него нет. После работы у него остаётся 2-3 часа свободного времени, и эти часы он тратит на изучение Go. Когда Алексей дошёл до подготовки к собеседованиям, мы провели ему пробное собеседование внутри команды. Такое собеседование называют моком: его проводят свои люди, чтобы слабые места всплыли до настоящего собеса.
Мок длился час. Алексей отвечал на вопросы, а в конце услышал фразу, которую не хочет услышать ни один разработчик.
Что показал мок?
В конце бадди (мой помощник, который проводит моки с учениками) сказал Алексею: «Подготовься получше, дальше пока не идём». Для ученика эта фраза может прозвучать жёстко, но в этом и есть основная цель.
Мок нужен ровно для того, чтобы всплыло всё, чего человек пока не знает. У Алексея всплыли 3 темы:
- Индексы в PostgreSQL. Алексей назвал только 2 вида индексов, B-Tree и Hash, хотя в PostgreSQL их больше. Индекс нужен, чтобы база данных находила нужные строки быстро и не просматривала всю таблицу целиком.
- Алгоритмическая сложность. На этот вопрос Алексей не ответил. Алгоритмическая сложность показывает, насколько сильно растёт время работы программы, когда данных становится больше.
- SQL-запросы руками. Алексею дали практическое задание: посчитать, сколько пользователей живёт в каждом городе, и вернуть это число в отдельной колонке. На такой запрос у него ушло много времени.
На следующий день Алексей сел разбирать эти 3 темы и уже через неделю пересдал мок.
Если провалить мок, а затем подтянуть знания, то на это уйдёт всего лишь неделя работы. Но если провалить настоящее собеседование в компанию, куда ты целился месяцами, можно потерять предложение о работе. Поэтому я лучше сам разверну своего ученика на моке, чем это сделает интервьюер в компании его мечты.
Темы, на которых споткнулся Алексей, входят в список вопросов, которые звучат почти на каждом первом собеседовании по Go. Дальше разберу этот список целиком.
5 вопросов, которые задают почти всегда
Ниже вопросы, которые задают почти на каждом первом собеседовании по Go. По каждому расскажу, как он обычно звучит и какой ответ хочет услышать интервьюер.
1. Конкурентность и параллельность
Обычно вопрос звучит так: «Чем конкурентность отличается от параллельности?»
Оба слова описывают ситуацию, когда в программе несколько задач. Разница в том, о чём говорит каждое из них.
Конкурентность описывает, как устроена программа: она разбита на независимые задачи, которые могут выполняться вперемешку. На одном ядре процессора такие задачи идут по очереди, и программа очень быстро переключается между ними. Со стороны кажется, что всё происходит одновременно. Если ядер несколько, Go распределяет задачи по ядрам, и они действительно работают в один момент.
Параллельность описывает само выполнение: задачи работают в один и тот же момент, каждая на своём ядре. Поэтому для параллельности нужно несколько ядер процессора, а конкурентная программа работает и на одном.
Роб Пайк, один из создателей Go, формулирует разницу так: конкурентность про то, как иметь дело со многими задачами сразу, параллельность про то, как выполнять много задач одновременно.
После этого почти всегда задают уточняющий вопрос: «Чем горутина отличается от потока операционной системы?» Горутиной называют лёгкую задачу внутри Go-программы. При запуске она занимает около 2 КБ памяти, а поток операционной системы обычно занимает от 1 МБ. Поэтому горутин в одной программе можно запускать сотнями тысяч: 100 000 горутин на старте займут около 200 МБ, а столько же потоков операционной системы заняли бы от 100 ГБ. А распределением горутин по потокам занимается планировщик, который встроен в сам Go.
2. Data race
Вопрос звучит так: «Что такое data race, когда он возникает и как его поймать?»
Data race возникает, когда 2 или больше горутин одновременно работают с одними и теми же данными, и хотя бы одна из них эти данные меняет. В таком случае результат работы программы становится непредсказуемым.
Вот пример:
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println(counter)
}Здесь 1000 горутин одновременно прибавляют к переменной counter единицу. WaitGroup нужен, чтобы программа дождалась, пока все горутины закончат работу (подробнее о нём в следующем вопросе про способы синхронизации). Кажется, что в конце должно получиться 1000. Но на практике число часто выходит меньше и при каждом запуске разное.
Так происходит, потому что 2 горутины могут одновременно прочитать одно и то же значение, например 500. Обе прибавят к нему единицу и обе запишут 501. В итоге одно прибавление теряется.
Поймать такую ошибку помогает встроенный в Go инструмент. Если запустить программу или тесты с флагом -race (например, go run -race main.go или go test -race), Go сам покажет, в каком месте 2 горутины обращаются к одним данным без защиты. Как эти данные защитить, расскажу в следующем вопросе (способы синхронизации).
3. Способы синхронизации
Вопрос звучит так: «Какие способы синхронизации горутин ты знаешь?» Синхронизация нужна, чтобы горутины не мешали друг другу, когда работают с общими данными.
Вот основные способы, которые стоит назвать:
- Мьютекс (sync.Mutex). Пока одна горутина работает с данными, остальные ждут, когда она закончит. Есть ещё sync.RWMutex: он разрешает многим горутинам одновременно читать данные, а менять их может только одна.
- WaitGroup (sync.WaitGroup). Позволяет дождаться, пока все горутины закончат работу.
- errgroup (golang.org/x/sync/errgroup). Работает как WaitGroup и вдобавок возвращает ошибку: метод Wait дожидается всех горутин и возвращает первую ошибку, которая случилась. Если создать группу через errgroup.WithContext, при первой ошибке контекст отменится, и горутины, которые его проверяют, смогут остановиться раньше.
- Каналы. Через канал горутины передают данные друг другу. Подробнее о каналах расскажу в следующем вопросе (каналы).
- Атомарные операции (пакет sync/atomic). Это простые действия, например «прибавить единицу», которые выполняются целиком. Другая горутина не может вмешаться посередине такой операции.
Вот как мьютекс исправляет пример из прошлого вопроса (data race):
var mu sync.Mutex
go func() {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
counter++
}()Перед тем как изменить counter, горутина вызывает mu.Lock(), и остальные горутины ждут. После изменения вызывается mu.Unlock(), и следующая горутина получает доступ к данным. Теперь в конце всегда получается 1000.
Кроме названий способов, стоит объяснить, когда какой из них использовать:
- мьютекс, когда несколько горутин меняют общие данные;
- атомарные операции, когда нужно защитить простое значение, например счётчик;
- WaitGroup или errgroup, когда нужно дождаться завершения горутин;
- каналы, когда горутины передают данные друг другу.
4. Каналы
Вопрос звучит так: «Чем буферизованный канал отличается от небуферизованного?» После него обычно спрашивают про select, дедлоки и закрытие канала.
Каналом в Go называют способ передать данные из одной горутины в другую. Каналы бывают 2 видов:
- Небуферизованный канал (make(chan int)) не хранит данные. Горутина, которая отправляет значение, ждёт, пока другая горутина его примет. Отправка и получение происходят в один момент.
- Буферизованный канал (make(chan int, 3)) может хранить внутри несколько значений, в этом примере 3. Горутина отправляет значение и продолжает работу, не дожидаясь получателя. Ждать ей придётся, только когда место в канале закончится.
Select. Оператор select позволяет горутине ждать данные сразу из нескольких каналов и работать с тем каналом, в котором данные появились первыми. Если данные уже лежат сразу в нескольких каналах, select выбирает один из них случайно.
Дедлок. Так называют ситуацию, когда горутины ждут друг друга и ни одна не может продолжить работу. Самый простой пример: отправить значение в небуферизованный канал, из которого никто не читает.
func main() {
ch := make(chan int)
ch <- 1 // программа ждёт получателя, но его нет
fmt.Println(<-ch)
}Такая программа остановится с ошибкой fatal error: all goroutines are asleep - deadlock!
Закрытие канала. Канал закрывают функцией close(ch), когда данных в нём больше не будет. Из закрытого канала можно дочитать значения, которые там остались. А вот запись в закрытый канал вызывает панику, то есть аварийное завершение программы. Повторное закрытие канала тоже вызывает панику. Поэтому закрывает канал тот, кто в него пишет.
5. PostgreSQL
Go-разработчик почти всегда работает с базой данных, поэтому без вопросов про неё собеседование не обходится. Обычно спрашивают про индексы, транзакции и команду EXPLAIN. А ещё часто просят написать SQL-запрос руками.
Индексы. Индекс нужен, чтобы база данных быстро находила нужные строки и не просматривала всю таблицу. Частый вопрос: «Какие типы индексов есть в PostgreSQL и когда какой использовать?» Именно на нём споткнулся Алексей. Основные типы такие:
- B-Tree. Создаётся по умолчанию и подходит для большинства задач: поиск по точному значению, по диапазону («больше», «меньше») и сортировка.
- Hash. Подходит только для поиска по точному значению.
- GIN. Нужен для поиска внутри сложных значений: массивов, JSON и текста.
- GiST. Используется для геоданных и других сложных типов данных.
- BRIN. Подходит для очень больших таблиц, где данные лежат по порядку, например по дате записи.
Транзакции и ACID. Транзакцией называют набор действий с базой, которые выполняются либо все вместе, либо ни одно. Например, перевод денег: списать сумму с одного счёта и зачислить на другой. Если зачисление не прошло, списание тоже отменяется. ACID описывает 4 свойства, которые база обещает для транзакций:
- Atomicity (атомарность): транзакция выполняется целиком или не выполняется совсем;
- Consistency (согласованность): после транзакции данные соответствуют всем правилам базы;
- Isolation (изолированность): одновременные транзакции не видят незафиксированных изменений друг друга. Насколько строго они разделены, задаёт уровень изоляции. В PostgreSQL по умолчанию стоит Read Committed: транзакция видит чужие изменения, как только их зафиксировали, даже посреди своей работы. Строже изолируют Repeatable Read и Serializable;
- Durability (долговечность): завершённая транзакция сохраняется, даже если сервер сразу после неё выключится.
EXPLAIN. Эта команда показывает, как база данных собирается выполнять запрос: будет ли она просматривать всю таблицу или воспользуется индексом. EXPLAIN ANALYZE дополнительно выполняет запрос и показывает реальное время работы. По результату видно, почему запрос работает медленно.
SQL руками. Кроме теории, часто дают практическое задание, как Алексею на моке: посчитать, сколько пользователей живёт в каждом городе. Для этого нужно знать GROUP BY и COUNT:
SELECT city, COUNT(*) AS user_count
FROM users
GROUP BY city;В крупных банках и корпорациях про базу спрашивают глубже: про MVCC, долгие транзакции и блокировки. MVCC позволяет многим пользователям одновременно читать и менять данные: каждая транзакция видит свою версию данных, поэтому чтение не мешает записи. А долгие транзакции опасны тем, что удерживают блокировки и старые версии строк. Из-за этого другие запросы ждут, а база разрастается.
Плюс вопросы про слайсы и map
Кроме этих 5 вопросов, почти на каждом первом собеседовании спрашивают про слайсы и map.
Слайсы. Слайсом в Go называют список элементов, который может расти. У слайса есть длина (len), то есть сколько элементов в нём сейчас, и вместимость (cap), то есть сколько элементов поместится в него без выделения новой памяти.
Частый вопрос: «Что происходит со слайсом, когда ты добавляешь элемент через append?» Если свободное место в слайсе есть, элемент просто добавляется в конец. Если места нет, Go выделяет новый участок памяти побольше и копирует туда все элементы. Насколько больше будет новый участок, зависит от размера слайса:
- если вместимость меньше 256, новая вместимость будет в 2 раза больше старой;
- если вместимость 256 и больше, коэффициент плавно снижается от 2 к 1,25: слайс на 512 элементов вырастет примерно в 1,6 раза, на 1024 примерно в 1,5, а к 1,25 рост приближается только на очень больших слайсах.
Итоговое число Go ещё округляет вверх под размер блока памяти, поэтому вместимость бывает больше расчётной.
Второй частый вопрос: «Что будет, если создать один слайс из другого и изменить в нём элемент?» Оба слайса смотрят на одну и ту же область памяти. Поэтому изменение элемента в одном слайсе будет видно и в другом. Так продолжается, пока append не выделит одному из слайсов новую память.
Map. Map хранит пары «ключ и значение», например «имя пользователя и его возраст». Чаще всего про map спрашивают так: «Что будет, если несколько горутин одновременно будут менять map без защиты?» Правильный ответ: программа может аварийно завершиться с ошибкой fatal error: concurrent map writes. Чтобы этого избежать, map защищают мьютексом или используют sync.Map.
Задачки на defer. Ещё бывают короткие задачки на внимательность. Вот одна из тех, что я даю своим ученикам:
func test() (x int) {
defer func() { x++ }()
return 42
}Что вернёт функция test()? Правильный ответ: 43. Команда defer откладывает вызов функции до момента, когда test() завершается. Сначала return 42 записывает число 42 в результат x. Потом срабатывает отложенная функция и прибавляет к x единицу. Это работает, потому что у результата функции есть имя x и отложенная функция может его изменить.
И вторая задачка из той же серии:
func main() {
defer fmt.Println("Первый вызов")
defer fmt.Println("Второй вызов")
fmt.Println("Основной код")
}Программа выведет строки в таком порядке: «Основной код», «Второй вызов», «Первый вызов». Если в функции несколько defer, они срабатывают в обратном порядке: последний отложенный вызов выполняется первым.
Что спрашивают на любом собеседовании
Есть 2 блока вопросов, которые звучат на каждом собеседовании: в любой компании и на любом этапе.
Рассказ по своему резюме
Интервьюер открывает твоё резюме и просит подробнее рассказать о том, что там написано. Особенно о цифрах. Обычно звучат такие вопросы:
- «Откуда взялась эта цифра?»
- «Как ты её измерял?»
- «Что конкретно сделал ты сам?»
- «Какие были другие варианты решения?»
Анализируя все собеседования, которые я разбирал, могу сказать: это проблема номер один, на которой ошибаются люди. В резюме написана впечатляющая фраза, например «SLA вырос с 99,12 до 99,94» (в SLA обычно фиксируют доступность сервиса: какую долю времени он работает без сбоев). А внятного объяснения со стороны кандидата нет. Если человек не может объяснить, откуда взялась цифра, опытный интервьюер поймёт это сразу.
Поэтому под каждую цифру в резюме ответ нужно подготовить заранее.
Практическая задача на конкурентность
Второй блок: практическая задача, в которой нужно запустить несколько горутин и организовать их совместную работу. Формулировки у задач разные, но суть одна. Чаще всего встречаются такие:
- Worker pool. Несколько горутин по очереди берут задачи из общей очереди и выполняют их. Количество горутин ограничено заранее, например 5.
- Слияние каналов. Нужно собрать данные из нескольких каналов в один общий канал.
- Rate limiter. Нужно пропускать не больше заданного количества запросов в секунду. Остальные запросы ждут своей очереди или отклоняются.
- Балансировщик. Нужно распределять задачи между несколькими исполнителями так, чтобы нагрузка на них была равномерной.
Если хотя бы одну из этих задач ты ни разу не писал руками, напиши её до собеседования.
Что из вакансии почти не спрашивают
Теперь хорошая новость. Если открыть любую вакансию Go-разработчика, в ней будет длинный список технологий. Но на первом собеседовании примерно половину этого списка почти не трогают. Вот что обычно спрашивают про такие технологии:
- Kubernetes. Это система, которая запускает программы на множестве серверов и следит за их работой. Глубже вопроса «Как ваш сервис попадает на сервер?» на первом собеседовании обычно не идут.
- Kafka. Это система, через которую сервисы передают друг другу сообщения. Как Kafka устроена внутри, на первом собеседовании спрашивают редко. Чаще звучит простой вопрос: «Где ты её использовал?»
- Elasticsearch, ClickHouse, Grafana, Prometheus. Это системы для поиска, аналитики и графиков, по которым следят за работой сервисов. Обычно спрашивают максимум: «Смотрел ли ты метрики своего сервиса?» Метриками называют числа, по которым видно, как работает сервис: сколько запросов он обрабатывает, как быстро отвечает и сколько ошибок выдаёт.
- CI/CD. Это автоматическая сборка, проверка и выкладка кода. Вопрос обычно звучит так: «Расскажи, какой путь проходит твой код от коммита до работающего сервиса». Коммитом называют сохранённое изменение в коде.
Получается, что длинный список технологий в вакансии нужен, чтобы отсеять резюме. Собеседование по этому списку обычно не строят. Гораздо важнее уверенно отвечать на 5 вопросов, которые я разобрал выше.
Чем отличаются собеседования в разных компаниях
Набор вопросов почти везде похожий, но формат собеседования сильно зависит от того, в какую компанию ты идёшь. Я делю компании на 4 типа.
Крупные IT-компании
Здесь почти всегда проводят лайвкодинг. Это часть собеседования, где ты пишешь код в реальном времени, а интервьюер наблюдает. Обычно на это отводят около часа и дают одну задачу: сначала базовую часть, потом её усложнённую версию.
Пользоваться нейросетями на такой секции нельзя. Часто нельзя даже запустить код и проверить, работает ли он. А если начать гуглить синтаксис прямо во время задачи, это почти приговор. Интервьюер делает вывод, что руками ты код не писал.
Крупные банки и корпорации
Здесь техническое собеседование обычно проводит руководитель команды. Вопросов по теории и внутреннему устройству технологий больше, чем в других компаниях. Например, спрашивают:
- про MVCC и долгие транзакции в PostgreSQL (о них я писал выше в вопросе про PostgreSQL);
- про SKIP LOCKED: это способ разбирать задачи из таблицы так, чтобы несколько обработчиков не взяли одну и ту же строку;
- про разницу между proto2 и proto3: это 2 версии формата, в котором сервисы передают друг другу данные.
В таких компаниях чаще всего проваливаются, когда кандидат спорит с интервьюером, хотя сам не уверен в своей правоте. Лучше прямо скажи, как ты это понимаешь, и уточни, так ли это.
Средние продуктовые компании
Основа собеседования здесь: разговор о твоём опыте по резюме. К нему добавляют несколько практических вопросов и вопросы про эксплуатацию. Например: «Как ты развернёшь своё Go-приложение на сервере?» Так проверяют, умеешь ли ты запустить написанный код в работу.
Стартапы
Здесь собеседование обычно проходит как разговор с основателем компании. Технических вопросов минимум. Основатель проверяет твою мотивацию и адекватность.
Как подготовиться за неделю
Вернусь к Алексею. После мока у него было 3 конкретные темы на неделю. Он разбирал их одну за другой, а всё остальное отложил. Через неделю он пересдал мок.
Повторить такую подготовку можно по этому списку:
- Ответь вслух на 5 вопросов из этой статьи. Если на каком-то вопросе ты начинаешь путаться, этот вопрос стоит повторить.
- Напиши руками задачу на конкурентность. Хотя бы worker pool и слияние каналов, без подсказок и нейросетей.
- Потренируйся писать SQL-запросы. Пиши как можно больше запросов руками, без подсказок: с GROUP BY, COUNT и объединением таблиц через JOIN.
- Подготовь историю под каждую цифру в резюме. Для каждой цифры ответь на 4 вопроса из раздела про резюме: откуда она взялась, как ты её измерял, что сделал сам и какие были другие варианты.
- Пройди пробное собеседование. Лучше с человеком, перед которым тебе немного неловко. Так ты почувствуешь волнение, похожее на волнение на настоящем собеседовании.
Что в итоге
На первом собеседовании по Go проверяют основы языка и то, как ты рассказываешь о своём опыте. Знать все технологии из вакансии для этого не нужно.
Алексей после пересдачи мока продолжает готовиться, предложение о работе у него пока впереди.