Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 11 из 25 ~18 мин +50 XP

Параллельный скрапинг: Async и WaitGroup

colly async выручает, когда каталог на пятьдесят страниц собирается по одной странице за раз, а сеть всё это время простаивает: запросы уходят параллельно, а c.Wait() и мьютекс вокруг общего слайса склеивают результат в одну выдачу. В уроке — механика Async, честный разбор гонки данных и две ловушки, на которых сыпется первый параллельный скрапер.

Содержание урока · 10 разделов

Скрапер из прошлых уроков ходит по сайту как добросовестный посетитель: страница пришла, распарсилась, ушла следующая. На десятке страниц разницы с браузером не заметить, но возьмите каталог из пятисот — и арифметика расстраивает. Обычный запрос к удалённому сайту живёт сотню-другую миллисекунд, и почти всё это время процессор скрапера бездельничает: соединение установлено, пакеты едут по сети, сервер думает. Пока страница в пути, программа просто стоит. Последовательный режим превращает это стояние в сумму: пятьсот страниц по двести миллисекунд ожидания — полторы минуты чистого sleep, за который источник вам ничего не обещал. Узкое место здесь сеть, а не процессор и не парсер; отсюда и лечение — не дожидаться ответа, отправляя следующий запрос. Именно для этого в Go существуют горутины, и colly их использует, если попросить.

colly async: как включается параллельный режим#

Включается режим одной опцией: colly.NewCollector(colly.Async()) — или присвоением c.Async = true, если коллектор уже собран. Поведение при этом одинаковое: опция — сахар над тем же полем. Дальше меняется сам характер Visit. В синхронном режиме он возвращается только после того, как ответ пришёл и колбэки отработали; в параллельном Visit завершается сразу после постановки запроса в очередь, а вся работа — соединение, ожидание, разбор HTML, ваши OnHTML-колбэки — уезжает в горутины. Отсюда два следствия, которые придётся держать в голове. Первое: Visit можно звать подряд сколько угодно раз — очередь разбирается параллельно, темп задают правила LimitRule. Второе: main после Visit ничего не ждёт — программа добегает до конца и закрывается вместе с недоигравшими горутинами. Для этого у коллектора есть парный метод c.Wait(): он блокирует выполнение, пока все запущенные запросы не завершатся. В синхронном режиме он не нужен — там к моменту возврата Visit всё уже случилось.

Минимальный параллельный скрапер

GO
package main

import (
        "fmt"

        "github.com/gocolly/colly/v2"
)

func main() {
        c := colly.NewCollector(colly.Async())

        c.OnHTML("span.text", func(e *colly.HTMLElement) {
                fmt.Println(e.Text)
        })

        for i := 1; i <= 5; i++ {
                pageURL := fmt.Sprintf("https://quotes.toscrape.com/page/%d/", i)
                c.Visit(pageURL)
        }

        c.Wait()
}

Пять Visit уходят почти одновременно: каждый возвращает управление сразу, и main доезжает до c.Wait(), пока запросы ещё в полёте. Wait разбудит программу, когда отработает последний колбэк. Уберите последнюю строку — main завершится мгновенно, чаще всего до первого ответа, и в терминале не напечатается ничего. Проверка занимает десять секунд: закомментируйте Wait и запустите.

Что выигрываем: сеть — узкое место, а не процессор#

Экономика параллельного скрапинга строится на бесплатном ожидании. Разбор HTML даже крупной страницы стоит микросекунды — узкое место всегда сеть: рукопожатие, TLS, время сервера на ответ. Параллельный режим перемножает эти ожидания: пока пятьдесят запросов висят в сети, процессор почти спит, зато страницы приходят волнами, а не по одной. Правда, выигрыш обычно меньше, чем обещает деление на количество горутин: под напором сайт начинает отвечать медленнее, часть соединений упирается в лимиты операционной системы. И вежливость: всплеск из пары сотен одновременных запросов — это уже не скрапинг, а репетиция DDoS, и админ источника имеет полное право вас забанить. Поэтому параллелизм ограничивают правилами LimitRule из урока про задержки и параллелизм: Delay задаёт паузу между запросами, Parallelism — сколько запросов к одному домену живут одновременно. Вежливый параллельный скрапер — это Async с ограниченным Parallelism, а не Async в чистом виде.

Забытый c.Wait(): классика пустой выдачи#

Живое место, на котором спотыкается почти каждый первый параллельный скрапер: программа выглядит работающей — ни ошибок, ни падений, — а вывод пустой. Причина примитивна: main завершился раньше, чем пришли ответы. В параллельном режиме Visit только ставит запрос в очередь; если после него нет c.Wait(), программа доезжает до конца main и закрывается вместе со всеми горутинами, включая ваши колбэки. Colly никуда не жалуется: с её точки зрения всё в порядке, просто вселенная кончилась. Диагноз ставится за минуту: тот же код с синхронным коллектором печатает данные, а с Async — тишина; значит, ищите пропавший Wait. Я держу это правило жёстким: в файле появился colly.Async() — в этом же файле обязан появиться c.Wait(). Это пара «открыл — закрыл», а не опция на выбор, и нарушение пары дороже, чем кажется: данные в таких прогонах не потеряны — они никогда не родились.

Общий слайс: гонка данных под Async#

Печать в консоль — вершина айсберга; настоящий скрапер копит результаты в общий слайс структур. И здесь параллельный режим ломается по-взрослому: OnHTML-колбэки выполняются в горутинах, горутины бегут одновременно, а append к одному слайсу из двух горутин — учебная гонка данных, подробно разобранная в уроке про горутины. Проявляется она зловеще-мягко: чаще всего ничего не происходит, но счётчик иногда врёт — то 98 цитат, то 100 при одном и том же наборе страниц. Пара элементов теряется, файл пишется урезанным, два месяца всё чисто, пока однажды отчёт не уходит к заказчику неполным. Компилятор это не ловит: типы сходятся, логика корректна. Ловит детектор гонок — запустите скрапер с go run -race, и на первом же конкурентном append вы получите развёрнутый отчёт: две горутины, адрес памяти, стеки. Лечение стандартное: всё, что мутирует общее, — под мьютекс.

Мьютекс вокруг append

GO
package main

import (
        "fmt"
        "sync"

        "github.com/gocolly/colly/v2"
)

type quote struct {
        Text   string
        Author string
}

func main() {
        mu := sync.Mutex{}
        quotes := []quote{}

        c := colly.NewCollector(colly.Async())

        c.OnHTML("div.quote", func(e *colly.HTMLElement) {
                q := quote{
                        Text:   e.ChildText("span.text"),
                        Author: e.ChildText("small.author"),
                }
                mu.Lock()
                quotes = append(quotes, q)
                mu.Unlock()
        })

        for i := 1; i <= 10; i++ {
                pageURL := fmt.Sprintf("https://quotes.toscrape.com/page/%d/", i)
                c.Visit(pageURL)
        }

        c.Wait()

        fmt.Println("собрано цитат:", len(quotes))
}

Структура q собирается из локальных значений e.ChildText — конфликтовать здесь не с чем: у каждой горутины свой e и свой q. Опасная зона одна — append к общему слайсу. Вход под mu.Lock(), выход под mu.Unlock(), между ними единственная строка. Обратите внимание на порядок: мьютекс захватывается после сборки структуры — всё медленное остаётся вне критической секции, и колбэки почти не ждут друг друга. c.Wait() гарантирует, что len(quotes) считается после отработки всех колбэков, а не посреди.

Критическая секция из одной строки — правильная форма: чем она короче, тем меньше горутины простаивают в очереди. Распространённая ошибка — захватить мьютекс на весь колбэк вместе с вложенными Visit: формально безопасно, фактически параллельность схлопывается в последовательную, и вы платите сложностью за ноль выгоды. Вторая тонкость — defer mu.Unlock(): внутри короткого колбэка явная пара Lock/Unlock читается проще, а defer в горячем цикле — расходы по мелочи, которые на миллионе страниц складываются во что-то заметное. Проверить себя просто: удалите мьютекс и прогоните скрапер несколько раз с go run -race — детектор покажет обе горутины и адрес, за который они дерутся. Если вы проходили каналы из смежного урока, соблазн передавать результаты через chan будет велик: схема рабочая, но в colly-колбэках удобнее мьютекс — колбэк ничего не обязан знать о том, кто и когда прочтёт данные.

WaitGroup: по коллектору на горутину

GO
urls := []string{
        "https://quotes.toscrape.com/page/1/",
        "https://quotes.toscrape.com/page/2/",
        "https://quotes.toscrape.com/page/3/",
}

var wg sync.WaitGroup

for _, url := range urls {
        wg.Add(1)
        go func(u string) {
                defer wg.Done()
                cc := colly.NewCollector()
                cc.Visit(u)
        }(url)
}

wg.Wait()

Паттерн для случая, когда задачи независимы и каждой нужен свой набор колбэков или свои настройки: отдельный синхронный коллектор на каждый адрес, своя горутина, sync.WaitGroup как счётчик — Add до запуска, Done через defer, финальный Wait. Внутри такой горутины Async не нужен: Visit и так живёт в вашей горутине. Один общий коллектор с Async() и c.Wait() проще — берите его, если колбэки одинаковы для всех адресов.

Async без LimitRule: всплеск нагрузки#

Темп в параллельном режиме не задаётся сам собой. В синхронном запросы идут друг за другом, и пауза из LimitRule лежит между ними естественно; в параллельном без правил все Visit уходят практически одновременно — пятьсот страниц каталога влетают в источник одной волной. LimitRule навешивает политику на домен: Delay — пауза между запросами, RandomDelay — разброс поверх неё, Parallelism — сколько запросов живут одновременно. В паре с Async это и есть вежливый параллельный скрапер: пул из четырёх одновременных запросов с паузой в секунду собирает в разы быстрее последовательного обхода и не топит чужой сервер. Если прогон начал ловить 429 или падать по таймаутам — проблема обычно не в самой параллельности, а в том, что ошибки никто не ловит: как colly показывает ошибки сети и как строить повторы вручную — отдельный урок.

Проверка: пара Async и Wait

Коллектор создан с colly.Async(). Что произойдёт, если после c.Visit(...) сразу завершить main?

Проверка: гонка на слайсе

Почему append к общему слайсу из OnHTML-колбэка в Async-режиме опасен?

Что напечатает программа?

Прочитайте код и выберите, что он выведет:

c := colly.NewCollector(colly.Async())
c.OnHTML("span.text", func(e *colly.HTMLElement) {
        fmt.Println(e.Text)
})
c.Visit("https://quotes.toscrape.com/")
// c.Wait() — забыли
fmt.Println("done")

Восстановите каркас параллельного скрапера

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

c := colly.NewCollector(colly.()) c.OnHTML("div.quote", func(e *colly.HTMLElement) { mu.() quotes = append(quotes, q) mu.() }) c.Visit("https://quotes.toscrape.com/") c.()

Соберите параллельный скрапер по строкам

От создания коллектора до завершения ожидания.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Вторая ловушка: дубли, которых не видно#

Вторая ловушка тоньше первой. В параллельном режиме вывод перемешивается: строки от разных страниц чередуются, прогресс скачет, и «странность» легко списать на сам режим. Под перемешиванием же может лежать настоящий баг: селектор оказался шире, чем вы думали, и срабатывает на вложенные элементы; страница попала в очередь дважды; OnHTML навешан и на контейнер, и на каждого ребёнка. В синхронном режиме дубль виден глазами — вывод идёт по порядку, и повтор бросается в таблицу результатов. Мой отладочный приём: снять Async на время разбора, вернуть детерминированный порядок и посмотреть, сколько раз реально срабатывает OnHTML и что именно кладётся в слайс. Async сам по себе дубли не создаёт — но и маскирует их отлично: след повторного Visit тонет в перемешанном логе. Лог URL в OnRequest решает вопрос: два одинаковых адреса в списке скажут больше, чем час гаданий.

Карточки: параллельный скрапинг

Термин

colly.Async()

нажмите, чтобы перевернуть
Определение

Опция NewCollector (или поле c.Async = true): запросы и колбэки уходят в горутины, Visit возвращается сразу после постановки в очередь.

1 / 6

Что дальше по курсу#

Темп задан, параллельность под контролем — дальше скраперу нужно направление: как ходить не по пяти пронумерованным страницам, а по целому каталогу с пагинацией, разбирает урок про пагинацию. Ошибки сети и повторные попытки — в уроке про OnError и таймауты. А если хочется понять, что именно происходит под c.Wait(), вернитесь к уроку про горутины и перечитайте с новым опытом: теперь он читается совсем иначе.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • colly.Async() переводит Visit в постановку в очередь: запросы и колбэки живут в горутинах.
  • c.Wait() обязателен в параллельном режиме: без него main завершается раньше ответов, вывод пуст.
  • Сеть — узкое место скрапера; параллельный режим перемножает ожидания, а не скорость процессора.
  • Общий слайс под колбэками — гонка данных: мьютекс вокруг append и проверка go run -race.
  • Async без LimitRule — всплеск одновременных запросов; Parallelism и Delay держат темп вежливым.
  • Дубли и перемешанный вывод в Async отлаживаются снятием Async и логом URL в OnRequest.
  • Несколько независимых задач — по коллектору на горутину и sync.WaitGroup вместо c.Wait().

Домашнее задание

Возьмите скрапер с мьютексом из урока, увеличьте выборку до всех десяти страниц quotes.toscrape.com и запустите трижды: сравните длину слайса и порядок вывода. Потом удалите мьютекс и прогоните тот же код с go run -race — сохраните отчёт детектора и верните мьютекс на место. Финальный шаг: закомментируйте c.Wait() и словами опишите, что именно изменилось в выводе, — это ощущение не заменяется чтением.

Личные заметкиПусто
Хранятся только в вашем браузере (Colly · Параллельный скрапинг: Async и WaitGroup)

Вопросы и ответы по уроку

Чем c.Wait() отличается от sync.WaitGroup?

c.Wait() знает о внутренней очереди colly и дожидается всех запущенных запросов одного коллектора. WaitGroup считает ваши собственные горутины и нужен, когда вы сами раскидываете задачи — например, по коллектору на каждый URL. Схемы не исключают друг друга.

Сколько запросов colly шлёт одновременно в Async-режиме?

Без LimitRule — столько, сколько вызовете Visit: очередь не ограничивает параллелизм сама. Чтобы не устраивать всплеск, задайте LimitRule с Parallelism и Delay — это штатный способ держать темп.

Нужен ли WaitGroup, если у меня один коллектор?

Нет: одного c.Wait() достаточно. WaitGroup появляется, когда горутин много и каждая живёт своей жизнью — например, отдельный синхронный коллектор на группу адресов со своими колбэками.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Testify25 уроков~9 чпрактика25 уроков уже доступны

Testify: тестирование Go с assert и require

Интерактивный курс Testify для Go: assert и require, табличные тесты, suite, mock-объекты, Eventually, HTTP-тесты. 25 уроков с примерами и практикой в браузере.

  • Писать читаемые проверки через assert и require, а не лестницы if
  • Проверять ошибки, паники и краевые случаи без ручного кода
  • Сравнивать структуры, слайсы, карты и числа с плавающей точкой
  • Строить табличные тесты и сьюты с хуками подготовки