Задачи с собеседования по Go: Слайсы | Навыки
Сегодня мы разберём разбор двух типичных задач на собеседовании на Go-разработчика: первая посвящена пониманию работы слайсов и передаче их по ссылке в функции, а вторая — написанию конкурентного кода с ограничением числа горутин, корректной синхронизации через мьютексы и WaitGroup, а также правильному управлению жизненным циклом каналов. Никита из MTS Digital на примере реального собеседования показывает, где кандидаты чаще всего допускают ошибки, и объясняет, как системно подходить к решению подобных задач.
Вопрос 1. Что такое слайс в Go и из каких полей он состоит? Что произойдёт при передаче слайса в функцию и изменении его элемента внутри функции?
Таймкод: 00:00:36
Ответ собеседника: Правильный. Слайс — это примитив, состоящий из трёх полей: указатель на область памяти (массив), длина (количество заполненных элементов) и ёмкость (максимальное количество элементов). При передаче слайса в функцию передаётся указатель на ту же область памяти, поэтому изменения элементов слайса внутри функции отразятся на оригинальном слайсе.
Правильный ответ:
Слайс в Go — это динамическая структура данных, которая предоставляет удобную обёртку над массивом. Внутренне слайс представлен структурой из трёх полей:
- Указатель на массив (pointer): Указывает на первый элемент массива в памяти.
- Длина (length): Количество элементов, которые слайс содержит в данный момент.
- Ёмкость (capacity): Общее количество элементов, выделенных в базовом массиве, начиная с первого элемента слайса.
При передаче слайса в функцию передаётся копия этой структуры (указатель, длина, ёмкость), но указатель ссылается на ту же область памяти. Это означает, что изменения элементов слайса внутри функции будут видны в вызывающем коде, так как обе копии слайса ссылаются на один и тот же базовый массив.
package main
import "fmt"
func modifySlice(s []int) {
s[0] = 100 // Изменение элемента
s = append(s, 200) // Добавление элемента (может создать новый массив)
}
func main() {
slice := []int{1, 2, 3}
fmt.Println("До:", slice)
modifySlice(slice)
fmt.Println("После:", slice)
}
Вывод:
До: [1 2 3]
После: [100 2 3]
Изменение элемента s[0] внутри функции влияет на оригинальный слайс, но append не изменит оригинальный слайс, если он приведёт к выделению нового массива.
Вопрос 2. Что выведет программа, в которой слайс строк {"Hello", "World"} передаётся в функцию, где нулевой элемент меняется на "Goodbye", а затем к слайсу добавляется элемент "World" через append?
Таймкод: 00:02:18
Ответ собеседника: Правильный. Программа выведет [Goodbye World], так как нулевой элемент был изменён через указатель на исходный слайс, а append добавил второй элемент.
Правильный ответ:
Для точного ответа необходимо рассмотреть полный код программы. Предположим, программа выглядит следующим образом:
package main
import "fmt"
func modify(s []string) {
s[0] = "Goodbye" // Изменение нулевого элемента
s = append(s, "World") // Добавление элемента
}
func main() {
slice := []string{"Hello", "World"}
modify(slice)
fmt.Println(slice)
}
В этом случае вывод будет: [Goodbye World]. Изменение нулевого элемента внутри функции влияет на оригинальный слайс, так как слайс передаётся по значению, но содержит указатель на тот же базовый массив. Однако append добавляет элемент к копии слайса внутри функции, что не изменяет оригинальный слайс, если ёмкость позволяет.
Если программа выводит слайс после вызова функции, то результат зависит от того, был ли слайс изменён в месте вызова. В данном случае, поскольку append не возвращает новый слайс в вызывающий код, оригинальный слайс остаётся длиной 2, но с изменённым первым элементом.
Таким образом, вывод будет: [Goodbye World].
Вопрос 3. Как распараллелить проверку 10 000 ссылок на валидность с ограничением не более 10 горутин, используя каналы и примитивы синхронизации?
Таймкод: 00:03:43
Ответ собеседника: Правильный. Создать канал строк и наполнить его всеми ссылками, затем закрыть канал. Запустить WaitGroup для ожидания завершения всех горутин. Запустить 10 горутин, каждая из которых читает ссылки из канала через for-range, проверяет валидность и при успехе инкрементирует счётчик под мьютексом. После завершения всех горутин вывести значение счётчика.
Правильный ответ:
Для решения задачи можно использовать следующий подход:
-
Канал для ссылок: Создать буферизованный или небуферизованный канал, в который поместить все 10 000 ссылок. После заполнения канал закрыть, чтобы горутины завершились после обработки всех ссылок.
-
WaitGroup: Использовать
sync.WaitGroupдля ожидания завершения всех 10 горутин. -
Горутины-воркеры: Запустить 10 горутин, каждая из которых читает ссылки из канала и проверяет их валидность.
-
Синхронизация счётчика: Для подсчёта валидных ссылок использовать
sync.Mutexдля защиты разделяемого счётчика.
package main
import (
"fmt"
"net/http"
"sync"
)
func main() {
links := make([]string, 10000)
// Заполнение ссылок (пример)
for i := 0; i < 10000; i++ {
links[i] = fmt.Sprintf("https://example.com/page%d", i)
}
// Канал для ссылок
linkChan := make(chan string, len(links))
for _, link := range links {
linkChan <- link
}
close(linkChan)
// WaitGroup для ожидания горутин
var wg sync.WaitGroup
// Мьютекс для защиты счётчика
var mu sync.Mutex
validCount := 0
// Запуск 10 горутин
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for link := range linkChan {
// Проверка валидности ссылки
resp, err := http.Get(link)
if err == nil && resp.StatusCode == 200 {
mu.Lock()
validCount++
mu.Unlock()
}
}
}()
}
// Ожидание завершения всех горутин
wg.Wait()
fmt.Println("Valid links:", validCount)
}
Альтернативный подход: Вместо sync.Mutex можно использовать sync/atomic для атомарного инкремента счётчика, что может быть более эффективным:
import "sync/atomic"
// Заменить mu.Lock()/mu.Unlock() на:
atomic.AddInt64(&validCount, 1)
Этот подход обеспечивает контроль над количеством горутин и безопасный доступ к разделяемым данным.
Вопрос 4. Почему необходимо закрывать канал после заполнения и зачем использовать WaitGroup и мьютекс в задаче с параллельной проверкой ссылок?
Таймкод: 00:09:37
Ответ собеседника: Правильный. Канал необходимо закрыть, чтобы цикл for-range в горутинах завершился после прочтения всех элементов, иначе горутины зависнут в бесконечном ожидании. WaitGroup нужен для гарантии, что все горутины завершат работу до вывода результата. Мьютекс защищает общий счётчик от гонки данных при одновременном инкременте из нескольких горутин.
Правильный ответ:
Закрытие канала необходимо для корректного завершения горутин-воркеров. Когда канал закрыт, операция чтения из канала возвращает zero-value и false для второго значения, что позволяет циклу for range завершиться. Без закрытия канала горутины будут заблокированы в ожидании новых данных, что приведёт к утечке горутин и зависанию программы.
WaitGroup используется для синхронизации завершения горутин. Он позволяет главной горутине дождаться завершения всех воркеров перед тем, как вывести результат. Без WaitGroup главная горутина может завершиться раньше, чем воркеры обработают все ссылки, что приведёт к неполным или некорректным результатам.
Мьютекс необходим для защиты разделяемого ресурса — в данном случае, счётчика валидных ссылок. Без мьютекса несколько горутин могут одновременно пытаться изменить счётчик, что вызовет гонку данных (data race) и приведёт к непредсказуемым результатам. Мьютекс гарантирует, что только одна горутина в каждый момент времени может изменять счётчик.
// Пример использования WaitGroup и мьютекса
var wg sync.WaitGroup
var mu sync.Mutex
validCount := 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for link := range linkChan {
if isValid(link) {
mu.Lock()
validCount++
mu.Unlock()
}
}
}()
}
wg.Wait()
fmt.Println("Valid links:", validCount)
Альтернативы: Вместо мьютекса можно использовать атомарные операции (sync/atomic) или каналы для сбора результатов от каждой горутины, что может быть более идиоматичным в Go.
