Перейти к основному содержимому

Собеседование на Go-разработчика с тимлидом из Avito | Эйч Навыки

· 14 мин. чтения

Сегодня мы разберём живое собеседование по Go, в ходе которого кандидат Женя последовательно решает три задачи: анализ конкурентного кода с горутинами и мапой, разбор поведения интерфейсов и nil-указателей, а также проектирование кэширующего HTTP-сервиса с защитой от гонок и корректным завершением фоновых рутин. Интервьюер Саша даёт подсказки, задаёт уточняющие вопросы и по итогу развёрнуто оценивает уровень кандидата, отмечая как сильные стороны, так и зоны роста.

Вопрос 1. Расскажи о какой-либо интересной задаче, которую ты решал за последние полгода-год в работе.

Таймкод: 00:05:47

Ответ собеседника: Правильный. Кандидат рассказал о двух задачах: обновление кластеров Kubernetes в облаке с написанием брокера сообщений, и оптимизация сервиса с написанием кэша для клиентов с проблемной библиотекой. Также отметил интерес к платформенные задачи и пониманию экосистемы технологий (Kubernetes и т.д.).

Правильный ответ:

Отличный ответ! Действительно, задачи, связанные с обновлением кластеров Kubernetes и оптимизацией сервисов, являются одними из наиболее интересных и сложных в современной разработке. Они требуют глубокого понимания инфраструктурных аспектов и умения интегрировать различные технологии для достижения высокой производительности и надежности.

Вопрос 2. Посмотри на этот код и попробуй выжать из него максимум — что можно улучшить, какие проблемы видишь? Код итерирует по ключам мапы, в горутине вызывает time.Sleep и инкрементирует значение в мапе.

Таймкод: 00:08:45

Ответ собеседника: Правильный. Кандидат верно определил проблему с замыканием — переменная цикла key захватывается по ссылке и к моменту выполнения горутины будет равна последнему значению. Также отметил, что горутины могут не успеть выполниться из-за выхода из main. Для исправления предложил использовать sync.WaitGroup и sync.RWMutex для безопасной работы с мапой из горутин.

Правильный ответ:

Кандидат верно определил ключевые проблемы. Давайте разберем их подробнее и рассмотрим оптимальное решение.

1. Проблема с замыканием (Closure Capture)

В Go переменные цикла for захватываются замыканиями по ссылке. Это означает, что к моменту выполнения горутины, переменная key может уже указывать на последнее значение из итерации.

Проблемный код:

m := map[string]int{"a": 1, "b": 2, "c": 3}

for key := range m {
go func() {
time.Sleep(100 * time.Millisecond)
m[key]++ // key будет одним и тем же для всех горутин
}()
}

Исправление через локальную переменную:

for key := range m {
key := key // создаем локальную копию
go func() {
time.Sleep(100 * time.Millisecond)
m[key]++
}()
}

Или через параметр функции:

for key := range m {
go func(k string) {
time.Sleep(100 * time.Millisecond)
m[k]++
}(key)
}

2. Состояние гонки (Race Condition) при доступе к мапе

Мапы в Go не являются потокобезопасными. Одновременная запись из нескольких горутин приводит к панике concurrent map writes.

Решение с sync.RWMutex:

var mu sync.RWMutex

for key := range m {
go func(k string) {
time.Sleep(100 * time.Millisecond)
mu.Lock()
m[k]++
mu.Unlock()
}(key)
}

Альтернатива — sync.Map (для случаев с частыми чтениями и редкими записями):

var m sync.Map

for key := range keys {
go func(k string) {
time.Sleep(100 * time.Millisecond)
if val, ok := m.Load(k); ok {
m.Store(k, val.(int)+1)
}
}(key)
}

3. Синхронизация завершения горутин

Без sync.WaitGroup функция main завершится до выполнения горутин.

Полное исправление:

package main

import (
"fmt"
"sync"
"time"
)

func main() {
m := map[string]int{"a": 1, "b": 2, "c": 3}
var mu sync.Mutex
var wg sync.WaitGroup

for key := range m {
wg.Add(1)
go func(k string) {
defer wg.Done()
time.Sleep(100 * time.Millisecond)
mu.Lock()
m[k]++
mu.Unlock()
}(key)
}

wg.Wait()
fmt.Println(m)
}

4. Дополнительные улучшения

  • Context для отмены: Добавьте context.Context для возможности отмены горутин по таймауту или сигналу.
  • Worker Pool: Для большого количества ключей используйте пул воркеров вместо создания горутины на каждый ключ.
  • Error Group: Если горутины могут возвращать ошибки, используйте errgroup.Group.

Вопрос 3. Расскажи про хеш-таблицу (map) в Go: что это за структура данных, какие особенности и проблемы есть, какие связанные типы из пакета sync знаешь?

Таймкод: 00:15:22

Ответ собеседника: Правильный. Кандидат рассказал, что хеш-таблица хранит пары ключ-значение, ключи должны быть хешируемыми, доступ по ключу за O(1). Упомянул проблему перестроения мапы при load factor ~50%. Знал про sync.Map, отметил что она безопасна для конкурентного доступа, в отличие от обычной map. Также обсудили, что обычная map не безопасна для одновременной записи из разных горутин.

Правильный ответ:

1. Внутреннее устройство map в Go

Map в Go реализована как хеш-таблица с методом цепочек (chaining) для разрешения коллизий. Внутри она представлена структурой hmap, которая содержит:

  • Buckets (корзины): Массив бакетов, каждый из которых хранит до 8 пар ключ-значение.
  • Hash function: Функция хеширования, зависящая от типа ключа.
  • Count: Текущее количество элементов.
// Упрощенная структура hmap (из runtime)
type hmap struct {
count int
flags uint8
B uint8 // log2 количества бакетов
noverflow uint16
hash0 uint32
buckets unsafe.Pointer
oldbuckets unsafe.Pointer // для постепенного перемещения
nevacuate uintptr
}

2. Хешируемость ключей

Ключи должны поддерживать операторы == и !=. Нехешируемые типы:

  • slice
  • map
  • func

Хешируемые типы: int, string, struct с хешируемыми полями, array, pointer, interface.

3. Сложность операций

ОперацияСредний случайХудший случай
ПоискO(1)O(n)
ВставкаO(1)O(n)
УдалениеO(1)O(n)

Худший случай возникает при большом количестве коллизий.

4. Рост мапы (Evacuation)

При достижении load factor ~6.5 (не 50% — это неточно) происходит рост мапы:

// Упрощенная логика проверки необходимости роста
func (h *hmap) growing() bool {
return h.count > bucketCnt*(1<<h.B)/2
}

Постепенное перемещение (Incremental Evacuation): Go использует подход, при котором элементы перемещаются из старых бакетов в новые постепенно, а не все сразу. Это предотвращает длительные паузы.

5. Проблемы обычной map

  • Не потокобезопасна: Одновременная запись вызывает панику concurrent map writes.
  • Недетерминированный порядок итерации: Специально рандомизирован для предотвращения зависимости от порядка.
  • Нельзя брать адрес элемента: &m["key"] — ошибка компиляции.

6. Потокобезопасные альтернативы

sync.Map — оптимизирована для двух сценариев:

  • Ключи с записью один раз, но множеством чтений
  • Множество горутин читают/пишут в непересекающиеся наборы ключей
var m sync.Map

// Запись
m.Store("key", 42)

// Чтение
if val, ok := m.Load("key"); ok {
fmt.Println(val.(int))
}

// Удаление
m.Delete("key")

// Атомарная запись если не существует
actual, loaded := m.LoadOrStore("key", 42)

// Атомарная замена
m.Store("key", 100)

sync.RWMutex + map — более универсальное решение:

type SafeMap struct {
mu sync.RWMutex
m map[string]int
}

func (s *SafeMap) Get(key string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
val, ok := s.m[key]
return val, ok
}

func (s *SafeMap) Set(key string, val int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = val
}

7. Когда что использовать

СценарийРекомендация
Один потокОбычная map
Много чтений, мало записейsync.RWMutex + map
Ключи пишутся один раз, читаются многоsync.Map
Высоконагруженные записиsync.RWMutex + map или sharding

8. Sharding для высокой нагрузки

type ShardedMap struct {
shards [32]struct {
mu sync.RWMutex
m map[string]int
}
}

func (s *ShardedMap) getShard(key string) *struct {
mu sync.RWMutex
m map[string]int
} {
h := fnv.New32a()
h.Write([]byte(key))
return &s.shards[h.Sum32()%32]
}

Вопрос 4. Посмотри на код с интерфейсом Order, функцией CreateOrder и вызовом в main. Объясни, что произойдёт при выполнении этого кода в зависимости от переданных параметров (true/false).

Таймкод: 00:23:19

Ответ собеседния: Правильный. Кандидат разобрал код и сначала предположил, что оба случая вернут созданный заказ. Затем с подсказкой вспомнил внутреннее устройство интерфейса в Go (пара указателей: на тип и на данные). Понял, что при возврате интерфейса, содержащего nil-указатель на структуру, сам интерфейс не будет nil (тип будет заполнен, данные — nil). Это приведёт к тому, что проверка order2 != nil будет true, но обращение к методам вызовет panic. Зафиксировал этот вариант как окончательный ответ.

Правильный ответ:

1. Внутреннее устройство интерфейса в Go

Интерфейс в Go — это структура из двух указателей (iface):

type iface struct {
tab *itab // информация о типе и методах
data unsafe.Pointer // указатель на данные
}

Интерфейс равен nil только когда оба поля равны nil.

2. Проблема nil-указателя в интерфейсе

Рассмотрим типичный проблемный код:

type Order interface {
GetID() string
}

type order struct {
id string
}

func (o *order) GetID() string {
return o.id
}

func CreateOrder(fail bool) Order {
if fail {
var o *order = nil
return o // возвращаем nil-указатель, обернутый в интерфейс
}
return &order{id: "123"}
}

func main() {
order1 := CreateOrder(false)
order2 := CreateOrder(true)

fmt.Println(order1 == nil) // false
fmt.Println(order2 == nil) // false !!! Ожидали true
}

3. Что происходит при fail = true

┌─────────────────────────────────────────────────────────────┐
│ var o *order = nil │
│ ┌─────────┐ │
│ │ o: nil │ │
│ └─────────┘ │
│ │
│ return o // приведение к Order │
│ ┌─────────────────────────────────────────┐ │
│ │ iface { │ │
│ │ tab: *itab{type: *order} ← НЕ NIL! │ │
│ │ data: nil ← NIL │ │
│ │ } │ │
│ └─────────────────────────────────────────┘ │
│ │
│ order2 == nil → false (tab != nil) │
└─────────────────────────────────────────────────────────────┘

4. Последствия

order2 := CreateOrder(true)

// Проверка пройдет успешно
if order2 != nil {
fmt.Println("order2 is not nil") // выполнится!
}

// Но вызов метода вызовет panic
id := order2.GetID() // panic: runtime error: invalid memory address

5. Способы решения

Вариант 1: Возвращать явный nil

func CreateOrder(fail bool) Order {
if fail {
return nil // возвращаем nil интерфейс, а не nil-указатель
}
return &order{id: "123"}
}

Вариант 2: Проверка внутри функции

func CreateOrder(fail bool) (*order, error) {
if fail {
return nil, errors.New("failed to create order")
}
return &order{id: "123"}, nil
}

Вариант 3: Использование reflect (не рекомендуется)

func isNilInterface(i interface{}) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
return v.Kind() == reflect.Ptr && v.IsNil()
}

6. Правило

> Если функция возвращает интерфейс, никогда не возвращайте nil-указатель на конкретный тип. Возвращайте либо явный nil, либо используйте указатель на конкретный тип как возвращаемое значение.

7. Демонстрация проблемы

package main

import "fmt"

type Stringer interface {
String() string
}

type myString struct {
value string
}

func (s *myString) String() string {
return s.value
}

func getStringer(flag bool) Stringer {
if flag {
var s *myString = nil
return s // ОШИБКА: возвращаем nil-указатель в интерфейсе
}
return &myString{value: "hello"}
}

func main() {
s := getStringer(true)

// Это напечатает "not nil"!
if s != nil {
fmt.Println("not nil")
}

// Это вызовет panic
fmt.Println(s.String())
}

Вопрос 5. Посмотри на этот HTTP-сервер: он обращается к внешнему API для получения курса биткоина. Представь, что на сервис идёт нагрузка 500k rpm, внешний API может выдержать только 10k rpm, а ответ валиден минимум секунду. Как бы ты решил проблему, чтобы не положить внешний сервис? Напишите код кэша.

Таймкод: 00:36:09

Ответ собеседника: Правильный. Кандидат предложил реализовать кэш, который обновляется в отдельной горутине с помощью time.Ticker (каждые 10 секунд). Предложил использовать context.Context для корректного завершения горутины вместо каналов, так как это более идиоматично в Go. Написал код с полем cache, функцией обновления в горутине с select и <-ctx.Done() для остановки. Также добавил mutex для защиты от гонки данных при параллельном чтении и записи кэша.

Правильный ответ:

1. Архитектура решения

┌─────────────────────────────────────────────────────────────────┐
│ 500k rpm │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ HTTP Server │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ In-Memory Cache │ │ │
│ │ │ ┌─────────────┐ ┌─────────────┐ │ │ │
│ │ │ │ Cached Value │ │ Expiry Time │ │ │ │
│ │ │ └─────────────┘ └─────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ │ ↓ │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ Background Refresher (1 goroutine) │ │ │
│ │ │ Updates every 10s │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ~10 requests/sec │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ External API (10k rpm) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

2. Полная реализация кэша

package main

import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)

// CacheEntry хранит значение и время истечения
type CacheEntry struct {
Value float64
ExpiresAt time.Time
}

// BTCExchangeRateCache кэш для курса BTC
type BTCExchangeRateCache struct {
mu sync.RWMutex
entry CacheEntry
fetchFunc func() (float64, error)
refreshInterval time.Duration
ttl time.Duration
}

// NewBTCExchangeRateCache создает новый кэш
func NewBTCExchangeRateCache(
fetchFunc func() (float64, error),
refreshInterval time.Duration,
ttl time.Duration,
) *BTCExchangeRateCache {
return &BTCExchangeRateCache{
fetchFunc: fetchFunc,
refreshInterval: refreshInterval,
ttl: ttl,
}
}

// Start запускает фоновое обновление кэша
func (c *BTCExchangeRateCache) Start(ctx context.Context) {
// Первоначальное заполнение
c.refresh()

ticker := time.NewTicker(c.refreshInterval)
go func() {
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.refresh()
case <-ctx.Done():
log.Println("Cache refresher stopped")
return
}
}
}()
}

// Get возвращает кэшированное значение
func (c *BTCExchangeRateCache) Get() (float64, bool) {
c.mu.RLock()
defer c.mu.RUnlock()

if time.Now().Before(c.entry.ExpiresAt) {
return c.entry.Value, true
}
return 0, false
}

// refresh обновляет значение кэша
func (c *BTCExchangeRateCache) refresh() {
value, err := c.fetchFunc()
if err != nil {
log.Printf("Failed to refresh cache: %v", err)
return
}

c.mu.Lock()
defer c.mu.Unlock()

c.entry = CacheEntry{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
log.Printf("Cache refreshed: %f", value)
}

// ForceRefresh принудительное обновление (для health checks)
func (c *BTCExchangeRateCache) ForceRefresh() error {
value, err := c.fetchFunc()
if err != nil {
return err
}

c.mu.Lock()
defer c.mu.Unlock()

c.entry = CacheEntry{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
return nil
}

// fetchBTCFromAPI запрос к внешнему API
func fetchBTCFromAPI() (float64, error) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.coindesk.com/v1/bpi/currentprice.json")
if err != nil {
return 0, fmt.Errorf("API request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}

var result struct {
BPI struct {
USD struct {
RateFloat float64 `json:"rate_float"`
} `json:"USD"`
} `json:"bpi"`
}

if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("decode failed: %w", err)
}

return result.BPI.USD.RateFloat, nil
}

func main() {
cache := NewBTCExchangeRateCache(
fetchBTCFromAPI,
10*time.Second, // обновление каждые 10 секунд
15*time.Second, // TTL 15 секунд
)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

cache.Start(ctx)

http.HandleFunc("/btc", func(w http.ResponseWriter, r *http.Request) {
rate, ok := cache.Get()
if !ok {
// Кэш пуст или истек - пробуем принудительно
if err := cache.ForceRefresh(); err != nil {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
rate, ok = cache.Get()
if !ok {
http.Error(w, "Service unavailable", http.StatusServiceUnavailable)
return
}
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]float64{
"btc_usd": rate,
})
})

log.Fatal(http.ListenAndServe(":8080", nil))
}

3. Расширенная версия с singleflight

Для случаев, когда при истечении кэша множество запросов могут одновременно пойти в API:

import "golang.org/x/sync/singleflight"

type BTCExchangeRateCacheWithSingleFlight struct {
mu sync.RWMutex
entry CacheEntry
fetchFunc func() (float64, error)
sf singleflight.Group
refreshInterval time.Duration
ttl time.Duration
}

func (c *BTCExchangeRateCacheWithSingleFlight) refresh() {
v, err, _ := c.sf.Do("fetch", func() (interface{}, error) {
return c.fetchFunc()
})

if err != nil {
log.Printf("Failed to refresh: %v", err)
return
}

c.mu.Lock()
defer c.mu.Unlock()

c.entry = CacheEntry{
Value: v.(float64),
ExpiresAt: time.Now().Add(c.ttl),
}
}

4. Альтернативные подходы

ПодходПлюсыМинусы
In-memory cacheПростой, быстрыйНе масштабируется
RedisРаспределенный, персистентныйЗависимость от Redis
Redis + local cacheЛучшая производительностьСложнее реализация
HTTP cache headersСтандартный подходНе всегда применимо

5. Redis-версия

import "github.com/redis/go-redis/v9"

type RedisBTCCache struct {
client *redis.Client
ttl time.Duration
}

func (c *RedisBTCCache) Get(ctx context.Context) (float64, bool) {
val, err := c.client.Get(ctx, "btc:rate").Float64()
if err != nil {
return 0, false
}
return val, true
}

func (c *RedisBTCCache) Set(ctx context.Context, rate float64) error {
return c.client.Set(ctx, "btc:rate", rate, c.ttl).Err()
}

Вопрос 6. Почему не рекомендуется хранить context.Context как поле структуры в Go?

Таймкод: 01:05:44

Ответ собеседника: Правильный. Кандидат признал, что у него был неудачный опыт с хранением контекста в структуре. Интервьюер пояснил основные проблемы: Контекст нельзя контролировать — структура создаётся один раз и контекст в ней может быть уже устаревшим/отменённым. Если объект передаётся между разными уровнями иерархии, можно случайно передать не тот контекст и отменить что-то не то. Лучше передавать контекст явно как аргумент функции. Также обсудили, что хранение метаинформации в контексте (context.Value) — спорная практика, и лучше передавать параметры явно через конструктор или аргументы функций.

Правильный ответ:

1. Проблемы хранения context в структуре

А. Потеря контроля над временем жизни

// ПЛОХО
type Service struct {
ctx context.Context
db *sql.DB
}

func NewService(ctx context.Context, db *sql.DB) *Service {
return &Service{ctx: ctx, db: db}
}

func (s *Service) DoWork() error {
// Контекст может быть уже отменен!
return s.db.QueryRowContext(s.ctx, "SELECT ...")
}

Проблема: если Service создается при инициализации приложения, а контекст привязан к HTTP-запросу, то к моменту вызова DoWork() контекст может быть уже отменен.

Б. Неявная связность (Hidden Coupling)

// ПЛОХО - контекст скрыто влияет на поведение
type Repository struct {
ctx context.Context
}

func (r *Repository) Get(id int) (*User, error) {
// Вызывающий код не знает, что есть таймаут
return r.db.QueryContext(r.ctx, "SELECT ...", id)
}

// ХОРОШО - контекст передается явно
func (r *Repository) Get(ctx context.Context, id int) (*User, error) {
return r.db.QueryContext(ctx, "SELECT ...", id)
}

В. Распространение отмены

// ПЛОХО - отмена одного контекста может затронуть всё
type Server struct {
ctx context.Context
db *Database
cache *Cache
}

func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
// Контекст сервера общий для всего
user, err := s.db.GetUser(s.ctx, id) // может быть отменен
data, err := s.cache.Get(s.ctx, key) // тоже
}

2. Правильный подход

// ХОРОШО - контекст передается как первый аргумент
func (r *Repository) Get(ctx context.Context, id int) (*User, error) {
// ...
}

func (s *Service) Process(ctx context.Context, req Request) error {
user, err := s.repo.Get(ctx, req.UserID)
if err != nil {
return err
}
// Контекст автоматически пробрасывается вниз
return s.cache.Set(ctx, user.Key(), user)
}

3. Исключения из правила

Иногда хранение контекста в структуре оправдано:

// Допустимо: долгоживущий объект с собственным контексом
type Worker struct {
ctx context.Context
cancel context.CancelFunc
}

func NewWorker(parent context.Context) *Worker {
ctx, cancel := context.WithCancel(parent)
return &Worker{ctx: ctx, cancel: cancel}
}

func (w *Worker) Stop() {
w.cancel()
}

4. Проблемы context.Value

// ПЛОХО - неявные зависимости через контекст
func Handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

// Кто положил userID? Откуда он взялся?
userID := ctx.Value("userID").(int)
}

// ХОРОШО - явные параметры
func Handler(w http.ResponseWriter, r *http.Request) {
userID := authenticate(r)
processUser(w, userID)
}

5. Рекомендации

ПрактикаРекомендация
HTTP handlersБрать из r.Context()
Бизнес-логикаПередавать как аргумент
РепозиторииПередавать как аргумент
Долгоживущие воркерыСоздавать свой контекст
МетаинформацияПередавать явно, не через context.WithValue

6. Цитата из официальной документации

> "Contexts should not be stored in structs. Instead, pass them explicitly to each function that needs it. The context should be the first parameter, typically named ctx."