
Привет, Хабр! Я Никита Иванов, техлид команды «Видео» в KION. В ИТ я уже девять лет, а последние пять работаю с Go. Сегодня расскажу, какую задачу считаю идеальной для собеседования на позицию Go-разработчика. Этот текст — переработка моего доклада с митапа МТС True Tech Go, видеоверсию можно посмотреть тут.
Программисты знают, что технические интервью редко бывают простыми. Составить задачу, которая честно проверяет реальные скилы, а не сводит все к банальному FizzBuzz, — отдельное искусство. Ниже поделюсь своим подходом к формулировке таких задач, расскажу о реальном кейсе, нюансах подбора и критериях оценки, которые действительно работают на практике. Думаю, этот текст будет полезен техлидам и менеджерам, а еще разработчикам, которые собираются на собеседование.
Как все начиналось

Год назад мне пришлось собирать команду для нового проекта. Нужно было найти примерно пять Go-разработчиков — и был у меня на это всего один месяц. Как вы понимаете, на практике все заняло больше времени, чем хотелось бы.
Одной из сложностей стало то, что для собеседования нужно было подготовить и теоретическую, и практическую часть. Я поставил себе цель продумать их максимально тщательно. Изучал много информации, общался с коллегами из МТС и со своими бывшими сотрудниками, чтобы узнать их мнение.
В результате появилось такое задание: реализовать алгоритм ограничения запросов по принципу корзины токенов, то есть rate limiter. Суть в том, что токены пополняются с фиксированной скоростью, их максимальный запас тоже ограничен фиксированным значением. За каждый вызов нужно вычитать N токенов. Если их недостаточно, возвращается false. На собеседовании нужно было реализовать соответствующий интерфейс.

Все не то, переделываем!
Я решил, что эта задача — отличная идея. Но уже на первых двух собеседованиях стало ясно: практически все кандидаты понимают, что такое токены, корзина токенов и rate limiter, но никто ни разу не реализовывал такой алгоритм самостоятельно. В этом и заключалась основная проблема.
После нескольких собеседований я подвел итоги. Слабым местом стала задача с rate limiter: она занимала примерно треть времени собеседования. Большая часть уходила на объяснение самой задачи, а потом кандидата буквально приходилось вести за руку через все этапы. В результате у меня не было никакого представления о том, как он пишет код.

К тому же часто мы не успевали дойти до вопросов по многопоточности: их можно было бы добавить, но времени на это не хватало. Я надеялся, что быстро найму одного сеньора, смогу делегировать ему процесс интервью и буду дальше расширять команду. Но получившаяся задача для такой цели не подходила, потому что вариантов решений было довольно много. Так что я решил пересмотреть, чего вообще хочу получить от практической части собеседования.
Какой нужен результат
Мне было важно проверять именно навыки написания кода на Go, способность читать чужой код и быстро находить проблемные места. К тому же у практической задачи не должно было быть слишком много вариантов ответа, иначе быстро оценить результат не получится. Практическая часть тоже не должна затягиваться. Ее задача — это, скорее, раскрыть кандидата. Подтвердить то, что он уже показал на теории. И тут мне стало интересно, как к этому вопросу подходят другие компании.
Я пообщался со знакомыми специалистами, сам сходил на несколько собеседований и сделал небольшую выборку — получилось около 16 компаний. После этого создал диаграмму: размер круга на ней показывает количество организаций в каждой группе. Собраны они по числу заданий на собеседовании. Получилось, что у большинства — примерно три задачи. Есть и те, у кого две или четыре. В целом около 80% компаний укладываются в такой формат на интервью. Видимо, это помогает не перегружать кандидатов лишними вопросами.

Что касается группировки вопросов, почти все компании используют схожую схему. Обычно есть задачи на написание кода и рефакторинг. А еще те из них, которые проверяют общее понимание синтаксиса.
Генерим задачи для собеседования
Вскоре после собственного исследования я нанял старшего разработчика. И вот мы вместе начали думать, какой пул задач собрать для собеседований. Что получилось, показываю дальше.
Что выведет код
Первая группа задач была на «Что выведет код?». Если вы проходили собеседования за последние пару лет, то, скорее всего, сталкивались с такими заданиями. Тут можно сэкономить время на теоретических вопросах, чтобы на практике узнать, как кандидат работает с базовыми структурами данных языка.
Пример на слайсы № 1
a := []int{1, 2, 3}
b := append(a[:1], 10)
fmt.Println(b, a) // [1, 10] [1,10,3]
Есть еще один плюс у таких задач: из-за простоты кандидаты быстро их решают и получают заряд мотивации. Обычно после первых двух заданий уходит волнение.
Пример на слайсы № 2
func f1(s []int) {
s[1] = 20
s = append(s, 80)
}
func f2(s *[]int) {
(*s)[1] = 10
*s = append(*s, 40)
}
s := []int{1, 2, 3}
f2(&s)
f1(s) // [1,20,3,40]
fmt.Println(s)
Эта задача уже посложнее, хотя занимает от двух до пяти минут. Кандидат должен уметь объяснить, как работает выделение памяти для базового массива и почему результат получается именно таким. В задаче есть две функции: в одной передается указатель на слайс, в другой — он копируется и, соответственно, получаем разное поведение. Это хорошее задание на понимание нюансов использования слайсов и их внутренней реализации.
Главный плюс таких заданий — их можно придумать много. В телеграм-каналах часто попадаются подобные задачи: с defer, мапами, слайсами и так далее. Их легко подготовить, решаются они быстро, и кандидаты чувствуют себя комфортно.
Ну а теперь проверим уровень владения кодом!
Слияние каналов
Задача
// Написать функцию для объединения каналов
func mergeChs(chs ...<-chan int) <-chan int {
result := make(chan int)
wg := sync.WaitGroup{}
wg.Add(len(chs))
for _, ch := range chs {
ch := ch // < go1.23
go func() {
defer wg.Add(-1)
// Вычитываем канал и перекладываем значение в результирующий
for v := range ch {
result <- v
}
}()
}
go func() {
// Ожидаем завершения всех горутин
wg.Wait()
close(result)
}()
return result
}
Думаю, многим знакомо задание «Написать функцию объединения каналов». Я и сам встречал его на собеседованиях в разных компаниях. В чем тут плюс? Для меня как для нанимающего менеджера важно не просто проверить, что кандидат умеет писать код. Но и увидеть, насколько человек ориентируется в основных элементах Go, которые постоянно применяются в работе.
В такой задаче кандидат взаимодействует сразу с несколькими важными элементами Go. Нужно задействовать разные типы каналов, применять wait-группы для ожидания завершения горутин, а еще продумать решение с точки зрения мультиплексирования.
Медленная функция
Задача
func ctxFunc(ctx context.Context) (int64, error) {
ch := make(chan int64, 1)
go func() {
ch <- slowFunc()
}()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
А вот и еще один хороший вариант задачи. Суть в том, что есть некая медленная функция (slow func), и нужно написать для нее обертку, не меняя ее сигнатуру. Я специально экономил место, поэтому сигнатуры здесь не всегда полные.
Чем хороша эта задача? Во-первых, она простая. Во-вторых, позволяет сразу проверить несколько навыков кандидата — по сути, те же, что и в предыдущей задаче, но в другом контексте. Здесь уже появляется вопрос блокировки: если не использовать буферизированный канал или не придумать другое решение, можно легко столкнуться с блокировкой на восьмой строке. Это хороший кейс для проверки внимательности.
Еще один плюс — небольшая вариативность решений, хотя есть и разные подходы. Можно решить не только через контекст с deadline или timeout, но, например, написать декоратор с использованием time.Duration и тикеров. Оба способа считаются валидными.
Параллельная закачка URL
Задача
// Написать функцию, которая запрашивает URL из списка и в случае положительного кода 200 выводит
// в stdout в отдельной строке url: , code:
// В случае ошибки выводит в отдельной строке url: , code:
// Функция должна завершаться при отмене контекста.
// Доп. задание: реализовать ограничение количества одновременно запущенных горутин.
func fetchParallel(ctx context.Context, urls []string) {
const concurrentLimit = 10
httpClient := &http.Client{}
sem := make(chan struct{}, concurrentLimit)
wg := sync.WaitGroup{}
mch := make(chan string)
defer close(mch)
go func() {
for msg := range mch {
fmt.Println(msg)
}
}()
wg.Add(len(urls))
for _, u := range urls {
select {
case <-ctx.Done():
return
case sem <- struct{}{}:
}
u := u
go func() {
defer wg.Add(-1)
defer func() { <-sem }()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
fmt.Printf("http.NewRequestWithContext: %v\n", err)
return
}
resp, err := httpClient.Do(req)
if err != nil {
fmt.Printf("http client Do: %v\n", err)
return
}
defer resp.Body.Close()
select {
case mch <- fmt.Sprintf("url: %s, code: %d", u, resp.StatusCode):
case <-ctx.Done():
return
}
}()
}
wg.Wait()
}
Теперь о еще одной довольно популярной задаче «Параллельная проверка URL». Здесь нужно написать функцию, которая делает запросы к URL из списка. В зависимости от возвращаемого статус-кода она выводит одну и ту же строку, только статус может отличаться. Другими словами, требуется обработать результат HTTP-запроса и правильно отреагировать на разные коды ответа.
Есть дополнительные условия: нужно реализовать ограничение на количество одновременно запущенных горутин, а также предусмотреть завершение работы функции при отмене контекста.
Что здесь интересно? В этой задаче можно выделить отдельную горутину для чтения всех результатов, а это дополнительная возможность для оценки подхода кандидата. Еще один момент — проблема передачи переменных в замыкание (актуально до Go 1.23). Если не скопировать значение явно, на определенной строке могла возникнуть ошибка. В новых версиях Go это уже не так критично, но спросить об этом пока еще можно.
Если кандидат реализует дополнительные условия, ему придется придумать способ ограничить количество одновременно работающих горутин — например, использовать семафор или worker pool. Решения бывают разные, и здесь возникает пространство для обсуждения. Еще важен вывод результата. Например, когда несколько горутин одновременно записывают в stdout, могут возникнуть проблемы с конкуретной записью. Это хороший повод обсудить синхронизацию.
Проверить решение такой задачи просто. Она хорошо покрывает различные аспекты работы с Go и в то же время не сильно перегружена дополнительными условиями.
Ниже перейду к следующей категории — это задачи на чтение кода. Их недооценивают на собеседованиях, хотя они очень полезны. Я представил такую задачу в формате уровней: базовый, средний и максимальная сложность.
Базовый уровень
Задача
func FindMaxProblem() {
var maxNum int
for i := 1000; i > 0; i-- {
go func() {
if i%2 == 0 && i > maxNum {
maxNum = i
}
}()
}
fmt.Printf("Maximum is %d", maxNum)
}
Как думаете, сколько тут проблем? Я выделил около четырех — например, отсутствие sync.WaitGroup. Есть вопросы с замыканием внутри go func, что было актуально до Go 1.23. А еще цикл почему-то начинается с 1 000 — это не ошибка, но может поломать условие задачи, так как мы сразу получаем максимальное число. В целом это хороший пример для старта и обсуждения типичных ошибок.
Сложнее
Задача
func FindMaxProblem1() {
var wg sync.WaitGroup
ch := make(chan string, 5)
mu := sync.Mutex{}
for i := 0; i < 5; i++ {
wg.Add(1)
go func(ch chan<- string, i int, grp *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
msg := fmt.Sprintf("Goroutine %s", strconv.Itoa(i))
ch <- msg
}(ch, i, &wg)
}
for {
select {
case q := <-ch:
fmt.Println(q)
}
}
wg.Wait()
}
Главная проблема — в самом нижнем кейсе: нигде не закрывается канал, из-за этого происходит блокировка в финальном цикле. Нужно разобраться с этой ошибкой: вынести wait-группу в отдельную горутину, закрыть канал, и только тогда можно нормально завершить работу, не зависнув.
С этого кейса обычно и начинают разбор. Остальные ошибки — скорее, минорные. Например, здесь не совсем понятно, зачем используется мьютекс, не применяется форматированный вывод (через %d). Канал передается в замыкание с wait-группой, хотя можно было обойтись без этого.
Лично мне этот вариант очень понравился, как только я впервые его увидел. Он буквально стреляет по всем типичным ошибкам сразу и отлично подходит для разбора на собеседовании.
Максимальная сложность
Задача
type Effector func(context.Context) (string, error)
func Throttle(e Effector, max uint, refill uint, d time.Duration) Effector {
var tokens = max
var once sync.Once
return func(ctx context.Context) (string, error) {
if ctx.Err() != nil {
return "", ctx.Err()
}
once.Do(
func() {
ticker := time.NewTicker(d)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tokens = min(tokens+refill, max)
}
}
}()
},
)
if tokens <= 0 {
return "", fmt.Errorf("too many calls")
}
tokens--
return e(ctx)
}
}
Напоследок — более сложный вариант. Мы с коллегой случайно наткнулись на него: он прислал мне этот пример и спросил, вижу ли я здесь проблемы. Это задача из одной популярной книги по Go-разработке: снова пример с rate limiter, только упрощенный. Он показывает, как простые шаблоны и заготовки можно использовать для практических задач на собеседовании. Такие шаблоны — таймауты, rate limiter и другие похожие решения — часто встречаются в книгах и других материалах по Go. Они действительно хороши, но у этого примера есть две заметные проблемы.
Первая, с токенами, крайне очевидна: общий ресурс для всех вызовов может привести к неопределенному поведению и сбоям в логике rate limiter. Вторая проблема связана с контекстом, и ее замечают не все. Тут важно быть внимательным, потому что такие ошибки встречаются и в реальном коде.
В этом примере есть фоновая задача, которая начисляет токены с определенным интервалом. Если мы отменяем дочерний контекст, то и эта background-задача завершается. После этого все последующие вызовы будут получать ошибку too many calls, потому что токены больше не пополняются.
Поэтому контекст лучше выносить в родительскую функцию или искать другие варианты реализации. Это хороший пример, который заставляет кандидата задуматься о деталях и показать внимательность к подобным нюансам.
Топовая задача
Что касается задач на темы вроде generics, sync.Condition, unsafe, atomic или выравнивания в структурах, то я пробовал задавать их и в теории, и на практике. На самом деле, если эти темы не используются в ваших проектах или нет объективной необходимости их применять, то давать такие задачи на собеседовании нет смысла. Исключение, возможно, дженерики. То же самое касается и заданий на вложенный mutex — я бы не стал включать их в пул.
Вернемся к вопросу: какая из всех этих задач идеальная? Думаю, у каждого здесь будет свой ответ. Лично мне больше нравится эта:

Код
// Написать функцию, которая запрашивает URL из списка и в случае положительного кода 200 выводит
// в stdout в отдельной строке url: , code:
// В случае ошибки выводит в отдельной строке url: , code:
// Функция должна завершаться при отмене контекста.
// Доп. задание: реализовать ограничение количества одновременно запущенных горутин.
// Написать функцию, которая запрашивает URL из списка и в случае положительного кода 200 выводит
// в stdout в отдельной строке url: <url>, code: <statusCode>
// В случае ошибки выводит в отдельной строке url: <url>, code: <statusCode>
// Функция должна завершаться при отмене контекста.
// Доп. задание: реализовать ограничение количества одновременно запущенных горутин.
func fetchParallel(ctx context.Context, urls []string) {
const concurrentLimit = 10
httpClient := &http.Client{}
sem := make(chan struct{}, concurrentLimit)
wg := sync.WaitGroup{}
mch := make(chan string)
defer close(mch)
go func() {
for msg := range mch {
fmt.Println(msg)
}
}()
wg.Add(len(urls))
for _, u := range urls {
select {
case <-ctx.Done():
return
case sem <- struct{}{}:
}
u := u
go func() {
defer wg.Add(-1)
defer func() { <-sem }()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
fmt.Printf("http.NewRequestWithContext: %v\n", err)
return
}
resp, err := httpClient.Do(req)
if err != nil {
fmt.Printf("http client Do: %v\n", err)
return
}
defer resp.Body.Close()
select {
case mch <- fmt.Sprintf("url: %s, code: %d", u, resp.StatusCode):
case <-ctx.Done():
return
}
}()
}
wg.Wait()
}
Эта задача позволяет балансировать между вариативностью решений и легкостью проверки. Да, опытные разработчики с ней быстро справятся, причем различными способами. Но все же с ее помощью можно проверить моменты, которые действительно важны на практике:
работу с конкурентностью,
примитивы синхронизации,
использование каналов,
работу с контекстом.
А что там с LLM?
Я выбрал самые интересные, на мой взгляд, задачи и решил прогнать их через DeepSeek с моделью R1. Он щелках их, как белка орешки — без особых затруднений. Причем в процессе еще и подшучивал над тем, что можно было бы просто использовать стандартный rate limiter, а ты, мол, пытаешься что-то изобретать.

Сложность с LLM сейчас в том, что предсказать их влияние на практические задачи на собеседовании очень трудно. Ими легко воспользоваться, и они закрывают около 80% кейсов в рамках задачи. Иногда, правда, DeepThink генерировал не самый логичный код: например, там, где любой разработчик написал бы return, он вставлял break. Это сразу бросается в глаза и вообще не похоже на работу человека. Но если дать задачу вроде самого первого rate limiter, DeepThink решает ее идеально.
Получается интересная ситуация: бороться с этим можно или усложняя задачи, или увеличивая контекст. То есть перегружая кандидатов, что тоже не очень. Есть еще вариант — принять это как новую реальность. Возможно, уже сейчас стоит считать навык работы с нейросетями таким же важным, как и умение программировать, если кандидат способен понимать и применять сгенерированный код.
Что в итоге
А выводы у меня такие. Если практика на собеседовании длится больше 45 минут, участники сильно устают. Лучше ориентироваться на 30–45 минут. Все остальное время лучше распределить на теоретические вопросы и обсуждение предыдущего опыта кандидата. Кстати, как раз это LLM пока что обрабатывать тяжело, поэтому важно спрашивать потенциального сотрудника о его опыте на прошлых работах и задавать глубокие вопросы.
Специфические задачи не дают ожидаемого результата — это мой субъективный опыт. Иногда кандидат не понимает какую-то узкую тему, которая кажется очевидной. Из-за этого можно упустить хорошего специалиста. Добавляйте легкие задачи: они действительно мотивируют кандидата, помогают избавиться от волнения и двигаться дальше. Это важно, чтобы раскрыть человека с лучшей стороны, а не создавать лишний стресс.
И еще один момент — компании часто забывают включать задания на рефакторинг и чтение кода. На мой взгляд, это отдельный и важный навык. Такие задачи очень полезны для оценки кандидата, но, возможно, это мое субъективное мнение.
Если есть вопросы, задавайте в комментариях. Буду рад обсудить!