Задачка на конкурентный поиск с отменой в Go

конкурентный поиск

Перед нами интересная задача: нужно реализовать функцию MultiSearch, которая запускает несколько поисков одновременно и возвращает результат, как только один из них успешно завершится.

Давайте разберем шаг за шагом.

Условие задачи

Нужно написать следующую функцию:

type Result struct{}

type SearchFunc func(ctx context.Context, query string) (Result, error)

func MultiSearch(ctx context.Context, query string, sfs []SearchFunc) (Result, error)
{
    // нужно реализовать
}

Функция должна:

  1. Запустить все SearchFunc конкурентно.
  2. Вернуть первый успешный результат.
  3. Отменить выполнение оставшихся поисков.
  4. Если все поиски завершились с ошибкой — вернуть ошибку.

Почему это сложно?

Кажется просто: запустить горутины и ждать результаты. Но есть подводные камни:

  • Как отменить оставшиеся поиски при первом успехе?
  • Как не утечь горутинам?
  • Как обработать случай, когда все поиски провалились?

Решение: Контекст + Каналы

Ключ к решению — контекст с отменой. Он позволяет сигнализировать всем горутинам о том, что результат уже найден.

 

Шаг 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)
    }

    // дальше будем добавлять код...
}

Что здесь происходит:

  1. Каждая функция запускается в своей горутине.
  2. Если функция вернула ошибку — отправляем ее в errChan.
  3. Если функция вернула успешный результат:
    1. Пытаемся отправить его в resultChan.
    2. Если отправка успешна (мы первые) — отменяем контекст.
    3. Если канал уже занят — игнорируем (кто-то уже был быстрее).

Шаг 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()
        }
    }
}

Логика работы:

  1. Ждем в цикле событий из трех каналов.
  2. Если пришел результат — сразу возвращаем его.
  3. Если пришла ошибка — увеличиваем счетчик.
  4. Если все функции вернули ошибку — возвращаем последнюю.
  5. Если контекст отменен — возвращаем ошибку контекста.

Полное решение

Соберем все вместе и добавим обработку краевых случаев:

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)

Что произойдет:

  1. Все три поиска запустятся одновременно.
  2. Допустим, DuckDuckGo ответил первым результатом.
  3. Результат отправляется в resultChan.
  4. Контекст отменяется.
  5. Google и Bing получают сигналы отмены и прерывают поиск.
  6. Функция возвращает результат DuckDuckGo.

Основные используемые концепции

  1. Контексты — для координации отмены.
  2. Каналы — для безопасной коммуникации между горутинами.
  3. Select — для ожидания нескольких операций.
  4. Замыкания — для захвата переменных в горутинах.
  5. Буферизация каналов — чтобы избежать блокировок.

Частые ошибки и как их избежать

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-провайдерам
  • Проверке доступности серверов
  • Чтении из нескольких реплик

Попробуйте сами: Измените код так, чтобы он возвращал не последнюю, а самую частую ошибку, если все поиски завершились неудачно. Это отличное упражнение для понимания работы с каналами.

Есть вопросы по реализации? Нашли способ улучшить код? Делитесь в комментариях — обсудим вместе!

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: