Перед нами интересная задача: нужно реализовать функцию MultiSearch, которая запускает несколько поисков одновременно и возвращает результат, как только один из них успешно завершится.
Давайте разберем шаг за шагом.
- Условие задачи
- Почему это сложно?
- Решение: Контекст + Каналы
- Шаг 1: Создаем контекст с отменой
- Шаг 2: Создаем каналы для общения
- Шаг 3: Запускаем поиски в горутинах
- Шаг 4: Ждем результат или ошибку
- Полное решение
- Как это работает на практике
- Основные используемые концепции
- Частые ошибки и как их избежать
- Упрощенная версия для понимания
- Вместо заключения
Условие задачи
Нужно написать следующую функцию:
type Result struct{}
type SearchFunc func(ctx context.Context, query string) (Result, error)
func MultiSearch(ctx context.Context, query string, sfs []SearchFunc) (Result, error)
{
// нужно реализовать
}
Функция должна:
- Запустить все
SearchFuncконкурентно. - Вернуть первый успешный результат.
- Отменить выполнение оставшихся поисков.
- Если все поиски завершились с ошибкой — вернуть ошибку.
Почему это сложно?
Кажется просто: запустить горутины и ждать результаты. Но есть подводные камни:
- Как отменить оставшиеся поиски при первом успехе?
- Как не утечь горутинам?
- Как обработать случай, когда все поиски провалились?
Решение: Контекст + Каналы
Ключ к решению — контекст с отменой. Он позволяет сигнализировать всем горутинам о том, что результат уже найден.
Шаг 1: Создаем контекст с отменой
func MultiSearch(ctx context.Context, query string, sfs []SearchFunc) (Result, error) {
// создаем новый контекст, который можно отменить
ctx, cancel := context.WithCancel(ctx)
defer cancel() // важно: освобождаем ресурсы при выходе
// дальше будем добавлять код...
}
Что здесь важно:
context.WithCanelсоздает контекст, который можно отменить вызовомcancel().defer cancel()гарантирует, что ресурсы контекста будут освобождены.- Если оригинальный контекст отменен (таймаут или ручная отмена), наш контекст тоже отменится.
Шаг 2: Создаем каналы для общения
func MultiSearch(ctx context.Context, query string, sfs []SearchFunc) (Result, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// канал для успешного результата (буфер 1, чтобы не блокировать)
resultChan := make(chan Result, 1)
// канал для ошибок
errChan := make(chan error, len(sfs))
// дальше будем добавлять код...
}
Почему буферизованные каналы?
resultChanс буфером 1: первый успех запишется и не заблокирует горутину.errChanс буфером на все функции: все ошибки смогут быть записаны без блокировки.
Шаг 3: Запускаем поиски в горутинах
func MultiSearch(ctx context.Context, query string, sfs []SearchFunc) (Result, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
resultChan := make(chan Result, 1)
errChan := make(chan error, len(sfs))
// запускаем все поисковые функции
for _, searchFunc := range sfs {
go func(sf SearchFunc) {
// выполняем поиск
result, err := sf(ctx, query)
// если контекст отменен, просто выходим
if ctx.Err() != nil {
return
}
if err != nil {
// отправляем ошибку
errChan <- err
return
}
// успешный результат
select {
case resultChan <- result:
cancel() // отменяем контекст для всех горутин
default:
// кто-то уже отправил результат
}
}(searchFunc)
}
// дальше будем добавлять код...
}
Что здесь происходит:
- Каждая функция запускается в своей горутине.
- Если функция вернула ошибку — отправляем ее в
errChan. - Если функция вернула успешный результат:
- Пытаемся отправить его в
resultChan. - Если отправка успешна (мы первые) — отменяем контекст.
- Если канал уже занят — игнорируем (кто-то уже был быстрее).
- Пытаемся отправить его в
Шаг 4: Ждем результат или ошибку
Теперь самая важная часть — как дождаться результата:
func MultiSearch(ctx context.Context, query string, sfs []SearchFunc) (Result, error) {
// ... предыдущий код ...
// счетчик завершенных поисков
completed := 0
for {
select {
case result := <-resultChan:
// нашли результат
return result, nil
case err := <-errChan:
// одна из функций вернула ошибку
completed++
// если все функции завершились с ошибкой
if completed == len(sfs) {
return Result{}, err
}
case <-ctx.Done():
// контекст отменен (таймаут или отмена извне)
return Result{}, ctx.Err()
}
}
}
Логика работы:
- Ждем в цикле событий из трех каналов.
- Если пришел результат — сразу возвращаем его.
- Если пришла ошибка — увеличиваем счетчик.
- Если все функции вернули ошибку — возвращаем последнюю.
- Если контекст отменен — возвращаем ошибку контекста.
Полное решение
Соберем все вместе и добавим обработку краевых случаев:
func MultiSearch(ctx context.Context, query string, sfs []SearchFunc) (Result, error) {
if len(sfs) == 0 {
return Result{}, errors.New("no search functions provided")
}
// контекст для отмены
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// каналы для общения
resultChan := make(chan Result, 1)
errChan := make(chan error, len(sfs))
// запускаем все поиски
for _, sf := range sfs {
go func(searchFunc SearchFunc) {
result, err := searchFunc(ctx, query)
// проверяем, не отменен ли поиск
if ctx.Err() != nil {
return
}
if err != nil {
errChan <- err
return
}
// пытаемся отправить результат
select {
case resultChan <- result:
cancel() // успех! отменяем остальные
default:
// уже кто-то успел
}
}(sf)
}
// ждем результаты
completed := 0
var lastErr error
for {
select {
case result := <-resultChan:
return result, nil
case err := <-errChan:
completed++
lastErr = err
if completed == len(sfs) {
return Result{}, lastErr
}
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
}
Как это работает на практике
Представьте, что у вас есть 3 поисковые системы:
searchFuncs := []SearchFunc{
googleSearch,
bingSearch,
duckduckgoSearch
}
// запускаем поиск одновременно во всех
result, err := MultiSearch(ctx, "golang concurrency", searchFuncs)
Что произойдет:
- Все три поиска запустятся одновременно.
- Допустим, DuckDuckGo ответил первым результатом.
- Результат отправляется в
resultChan. - Контекст отменяется.
- Google и Bing получают сигналы отмены и прерывают поиск.
- Функция возвращает результат DuckDuckGo.
Основные используемые концепции
- Контексты — для координации отмены.
- Каналы — для безопасной коммуникации между горутинами.
- Select — для ожидания нескольких операций.
- Замыкания — для захвата переменных в горутинах.
- Буферизация каналов — чтобы избежать блокировок.
Частые ошибки и как их избежать
1. Забыть defer cancel()
- Последствия: Утечка ресурсов контекста.
- Решение: Всегда использовать
deferпри работе с контекстами.
2. Не проверять ctx.Err()
- Последствия: Горутины продолжают работу после отмены.
- Решение: Всегда проверять контекст перед длительными операциями.
3. Небуферизованные каналы
- Последствия: Горутины могут заблокироваться.
- Решение: Использовать буферизацию для каналов, куда пишут несколько горутин.
Упрощенная версия для понимания
Если код кажется сложным, вот минимальная рабочая версия:
func MultiSearchSimple(ctx context.Context, query string, sfs []SearchFunc) (Result, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
resultChan := make(chan Result, 1)
// просто запускаем все и ждем первый результат
for _, sf := range sfs {
go func(f SearchFunc) {
result, err := f(ctx, query)
if err == nil {
select {
case resultChan <- result:
cancel()
default:
}
}
}(sf)
}
select {
case result := <-resultChan:
return result, nil
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
Вместо заключения
Такой паттерн — «запустить несколько операций и взять первый успешный результат» — очень полезен в реальных приложениях. Он используется при:
- Поиске в нескольких базах данных
- Запросе к разным API-провайдерам
- Проверке доступности серверов
- Чтении из нескольких реплик
Попробуйте сами: Измените код так, чтобы он возвращал не последнюю, а самую частую ошибку, если все поиски завершились неудачно. Это отличное упражнение для понимания работы с каналами.
Есть вопросы по реализации? Нашли способ улучшить код? Делитесь в комментариях — обсудим вместе!