Почему использование семафора замедляет мою программу Go

Я написал программу, которая очищает все страницы веб-сайта с помощью горутин:

func main() {
    start := time.Now()

    knownUrls := getKnownURLs(os.Getenv("SITEMAP_URL"))

    var wg sync.WaitGroup
    for index, url := range knownUrls {
        wg.Add(1)

        fmt.Printf("%d/%d\n", index+1, len(knownUrls))

        go func() {
            if err := indexArticleFromURL(url, client); err != nil {
                log.Fatalf("Error indexing doc: %s", err)
            }
            wg.Done()
        }()
    }

    wg.Wait()

    elapsed := time.Since(start)
    fmt.Printf("Took %s", elapsed)
}

Это работает потрясающе быстро, если быть точным, 5,9 секунды на тысячу страниц. Но меня беспокоит то, что если на веб-сайте тысячи страниц, он будет создавать тысячи горутин.

Поэтому я провел его рефакторинг с помощью пакета под названием семафор. Насколько я понимаю, количество горутин должно быть ограничено тем, что процессор может обработать. Не должно снижаться производительность, поскольку приведенная выше программа уже физически не могла использовать больше потоков, чем может обеспечить процессор.

func main() {
    start := time.Now()
    ctx := context.Background()

    knownUrls := getKnownURLs(os.Getenv("SITEMAP_URL"))

    var (
        maxWorkers = runtime.GOMAXPROCS(0)
        sem        = semaphore.NewWeighted(int64(maxWorkers))
    )

    for index, url := range knownUrls {
        if err := sem.Acquire(ctx, 1); err != nil {
            log.Printf("Failed to acquire semaphore: %v", err)
            break
        }

        fmt.Printf("%d/%d\n", index+1, len(knownUrls))

        go func() {
            if err := indexDocFromURL(url, client); err != nil {
                log.Fatalf("Error indexing doc: %s", err)
            }
            sem.Release(1)
        }()
    }

    if err := sem.Acquire(ctx, int64(maxWorkers)); err != nil {
        log.Printf("Failed to acquire semaphore: %v", err)
    }

    elapsed := time.Since(start)
    fmt.Printf("Took %s", elapsed)
}

Но теперь, когда я запускаю программу, это занимает значительно больше времени: 11+ секунд.

Похоже, этого не должно быть, поскольку runtime.GOMAXPROCS(0) возвращает максимальное количество процессоров, которые могут выполняться одновременно.

Почему версия с семафором медленнее? И как мне сделать так, чтобы его производительность соответствовала небезопасной программе, и при этом быть уверенным, что количество горутин не приведет к ее сбою?

У вас может быть гораздо больше горутин, одновременно ожидающих ввода-вывода, чем у вас есть ядер ЦП, поскольку ваша рабочая нагрузка не привязана к ЦП. Это все, что говорит о том, что это неподходящий вариант использования семафора.

Charles Duffy 16.07.2024 00:25

(... и, чтобы подчеркнуть это, «небезопасная» программа никогда не была небезопасной с самого начала. Если вы определите, что существует предел, за которым поведение каким-то образом ухудшается - скажем, сервер, к которому вы подключаетесь, по времени out - тогда конечно, используйте семафор, но поместите количество слотов на тот предел, за которым все начинает идти не так, а не на количество ядер ЦП).

Charles Duffy 16.07.2024 00:32
Создание API ввода вопросов на разных языках программирования (Python, PHP, Go и Node.js)
Создание API ввода вопросов на разных языках программирования (Python, PHP, Go и Node.js)
API ввода вопросов - это полезный инструмент для интеграции моделей машинного обучения, таких как ChatGPT, в приложения, требующие обработки...
3
2
78
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

В исходном коде у вас есть один поток на ядро ​​ЦП, но горутин больше, чем потоков. Это нормально: среда выполнения Go внутренне переключает задачи между горутинами без участия планировщика ядра, паркуя одну из них всякий раз, когда она ожидает ввода-вывода, и переключаясь на другую. Если задача на 99,999% ожидает сетевого ресурса и 0,0001% ЦП, то одно ядро ​​ЦП может с комфортом обрабатывать 1 000 000 горутин одновременно — вам нужно достаточно памяти для распределения кучи, а сетевой протокол должен быть устойчив к задержкам. достаточно, чтобы удаленный сервер не истекал по тайм-ауту, если для планирования горутины требуется некоторое время (и если ваши соединения происходят с одним и тем же сервером, он должен быть готов справиться с этой нагрузкой), но пока у вас есть эта память, и удаленная служба (и промежуточный сетевой стек) так же надежна, как и ваш клиентский код, все в порядке. (HTTP/2 поддерживает мультиплексирование для выполнения неограниченного количества запросов через одно TCP-соединение — надеюсь, вы используете его здесь).


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

Горутины не являются потоками или процессами. Это близко к сопрограмме. Поэтому никакой нагрузки нет, даже если вы используете тысячи горутин, которых намного больше, чем количество ядер ЦП.

Однако, если вы в конечном итоге используете слишком много горутин, возможно, придется их предложить. Например, ограничение одновременного доступа к ресурсам и т.д...

В этом случае рекомендуется использовать пакет golang.org/x/sync/errgroup. Внутренне максимальное число контролируется с помощью мьютекса.

Другие вопросы по теме