Конкурентность: горутины, каналы и контекст

context.Context: отмена и таймаут по цепочке вызовов

Содержание курса

Поиск с ограничением по времени

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

Именно здесь в дело вступает контекст. Вызывающий код отводит на весь поиск строго ограниченное время и передаёт контекст первым аргументом при каждом обращении к источнику. Сервис обходит источники последовательно и на каждом шаге одновременно следит за двумя событиями: либо источник вернул очередной ответ, либо время вышло и контекст сообщил об отмене. Как только поступает сигнал об отмене, сервис прекращает дальнейшие запросы, не дожидаясь оставшихся источников.

Итогом работы становится пара: срез уже собранных ответов и ошибка, которую вернул контекст. Если все источники успели ответить в отведённое время, ошибка окажется пустой. Если же таймаут или явная отмена прервали поиск раньше срока, вызывающий код получает частичный результат вместе с понятным объяснением причины остановки — и может решить, достаточно ли собранных данных или стоит повторить запрос.

Что нужно сделать

Реализуйте Search(ctx context.Context, sources []Source). Передавайте ctx первым аргументом каждому Query. Для текущего источника ждите через select либо ответ, либо ctx.Done(). При отмене немедленно верните уже собранные ответы и ctx.Err(); следующие источники не вызывайте. Готовый вызывающий код показывает context.WithTimeout и обязательный defer cancel().

О данных в ответах

Используйте учебные данные. Не вставляйте пароли, токены, ключи доступа, паспортные и банковские данные, а также персональные данные других людей. Политика обработки данных.

Проверяется тестами (2)
  • Public go test ./...
  • health
    GET /health200
Go-проект · search.go

Сначала выполняется go test ./..., затем сервис собирается и проверяется HTTP-запросами. Внешняя сеть и скачивание модулей отключены.

caller.go
package main

import (
    "context"
    "time"
)

func SearchWithTimeout(sources []Source, timeout time.Duration) ([]string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
    return Search(ctx, sources)
}
search_test.go
package main

import (
    "context"
    "errors"
    "reflect"
    "testing"
)

type immediateSource string
func (s immediateSource) Query(context.Context) <-chan string { ch := make(chan string, 1); ch <- string(s); close(ch); return ch }
type recordingSource struct { value string; calls *int }
func (s recordingSource) Query(context.Context) <-chan string { *s.calls = *s.calls + 1; ch := make(chan string, 1); ch <- s.value; close(ch); return ch }
type cancelingSource struct{ cancel context.CancelFunc }
func (s cancelingSource) Query(context.Context) <-chan string { s.cancel(); return make(chan string) }

func TestSearchComplete(t *testing.T) {
    got, err := Search(context.Background(), []Source{immediateSource("alpha"), immediateSource("beta")}); if err != nil || !reflect.DeepEqual(got, []string{"alpha", "beta"}) { t.Fatalf("got=%v err=%v", got, err) }
}
func TestSearchDoesNotQueryPreCancelledContext(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background()); cancel(); calls := 0; got, err := Search(ctx, []Source{recordingSource{value: "never", calls: &calls}})
    if !errors.Is(err, context.Canceled) || len(got) != 0 || calls != 0 { t.Fatalf("got=%v err=%v calls=%d", got, err, calls) }
}
func TestSearchReturnsPartialResultWithoutLaterQueries(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background()); laterCalls := 0
    got, err := Search(ctx, []Source{immediateSource("kept"), cancelingSource{cancel: cancel}, recordingSource{value: "never", calls: &laterCalls}})
    if !errors.Is(err, context.Canceled) { t.Fatalf("err=%v", err) }; if !reflect.DeepEqual(got, []string{"kept"}) || laterCalls != 0 { t.Fatalf("partial=%v laterCalls=%d", got, laterCalls) }
}
main.go
package main

import (
    "io"
    "log"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/plain; charset=utf-8")
        w.WriteHeader(http.StatusOK)
        _, _ = io.WriteString(w, "ok\n")
    })
    if err := http.ListenAndServe(":18080", mux); err != nil {
        log.Fatal(err)
    }
}
РешениеGo · Go 1.27 (Modal)

Отправьте решение, чтобы увидеть результаты тестов.