---
title: "Параллельный скрапинг: Async и WaitGroup"
description: "colly async: параллельный парсинг go-скрапером — Async(), c.Wait(), мьютекс вокруг общего слайса, гонки данных и ловушка пустой выдачи. С примерами."
course: "Colly: парсинг и веб-скрапинг на Go"
url: "https://golangmaster.ru/courses/colly/lessons/11-async-skreping/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Colly: парсинг и веб-скрапинг на Go», урок 11 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/colly/lessons/11-async-skreping/*

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

Скрапер из прошлых уроков ходит по сайту как добросовестный посетитель: страница пришла, распарсилась, ушла следующая. На десятке страниц разницы с браузером не заметить, но возьмите каталог из пятисот — и арифметика расстраивает. Обычный запрос к удалённому сайту живёт сотню-другую миллисекунд, и почти всё это время процессор скрапера бездельничает: соединение установлено, пакеты едут по сети, сервер думает. Пока страница в пути, программа просто стоит. Последовательный режим превращает это стояние в сумму: пятьсот страниц по двести миллисекунд ожидания — полторы минуты чистого 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 из [урока про задержки и параллелизм](/courses/colly/lessons/10-limitrule/): Delay задаёт паузу между запросами, Parallelism — сколько запросов к одному домену живут одновременно. Вежливый параллельный скрапер — это Async с ограниченным Parallelism, а не Async в чистом виде.

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

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

> **Песочница этот код не запустит.** Скрапер ходит по сети, а браузерный интерпретатор сетевых вызовов не исполняет — runner-ов в этом курсе не будет. Понадобится локальный Go: go mod init demo, затем go get github.com/gocolly/colly/v2 — и все примеры урока запускаются в терминале против живых песочниц quotes.toscrape.com.

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

Печать в консоль — вершина айсберга; настоящий скрапер копит результаты в общий слайс структур. И здесь параллельный режим ломается по-взрослому: OnHTML-колбэки выполняются в горутинах, горутины бегут одновременно, а append к одному слайсу из двух горутин — учебная гонка данных, подробно разобранная в [уроке про горутины](/courses/go-advanced/lessons/04-gorutiny/). Проявляется она зловеще-мягко: чаще всего ничего не происходит, но счётчик иногда врёт — то 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 — детектор покажет обе горутины и адрес, за который они дерутся. Если вы проходили каналы из [смежного урока](/courses/go-advanced/lessons/05-kanaly/), соблазн передавать результаты через 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 показывает ошибки сети и как строить повторы вручную — [отдельный урок](/courses/colly/lessons/16-oshibki-retry/).

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

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

- Visit вернёт ошибку, потому что запрос не успел уйти
- colly сам подождёт завершения всех запросов перед выходом
- ✅ main завершится сразу — колбэки, скорее всего, не отработают вовсе
- Запросы отменятся, но colly напечатает предупреждение

Visit в Async-режиме только ставит запрос в очередь и возвращается немедленно; ждать кого-то colly не умеет — процесс завершается вместе со своими горутинами. Отсюда пара «Async() плюс Wait()»: Wait блокирует main до отработки всех запущенных запросов.

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

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

- append не работает со слайсами больших размеров
- ✅ Колбэки выполняются в горутинах и могут одновременно мутировать один слайс — это гонка данных
- colly копирует слайс перед каждым колбэком
- append теряет элементы, если HTML пришёл частично

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

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

```go
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")
```

**Что выведет программа?**

- Все цитаты страницы, потом done
- ✅ done, а цитаты — почти никогда
- done и потом все цитаты
- Программа зависнет на Visit

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

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

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

```go
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.___()
```

Подсказка: Опция называется как режим; мьютекс открывает Lock и закрывает Unlock; main дожидается всех запросов методом Wait.

Правильные ответы в пропусках: `Async`, `Lock`, `Unlock`, `Wait`

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

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

Правильный порядок строк:

1. `c := colly.NewCollector(colly.Async())`
2. `c.OnHTML("span.text", func(e *colly.HTMLElement) {`
3. `  mu.Lock()`
4. `  quotes = append(quotes, e.Text)`
5. `  mu.Unlock()`
6. `})`
7. `c.Visit("https://quotes.toscrape.com/")`
8. `c.Wait()`

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

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

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

- **colly.Async()** — Опция NewCollector (или поле c.Async = true): запросы и колбэки уходят в горутины, Visit возвращается сразу после постановки в очередь.
- **c.Wait()** — Блокирует main, пока не завершатся все запущенные запросы. Нужен только в Async-режиме; без него процесс завершится раньше данных.
- **Гонка данных** — Две горутины мутируют одну память без синхронизации: потерянные элементы, гуляющий счётчик. Ловится go run -race, лечится мьютексом.
- **sync.Mutex** — Замок вокруг критической секции: Lock перед мутацией общего слайса, Unlock после. Критическая секция — короткая.
- **sync.WaitGroup** — Счётчик горутин: Add до запуска, Done через defer, Wait в конце. Альтернатива c.Wait(), когда коллекторов много — по одному на горутину.
- **Parallelism (LimitRule)** — Ограничение одновременных запросов к домену в LimitRule — вежливый способ удержать всплеск Async-режима.

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

Темп задан, параллельность под контролем — дальше скраперу нужно направление: как ходить не по пяти пронумерованным страницам, а по целому каталогу с пагинацией, разбирает [урок про пагинацию](/courses/colly/lessons/12-paginaciya/). Ошибки сети и повторные попытки — в [уроке про OnError и таймауты](/courses/colly/lessons/16-oshibki-retry/). А если хочется понять, что именно происходит под c.Wait(), вернитесь к [уроку про горутины](/courses/go-advanced/lessons/04-gorutiny/) и перечитайте с новым опытом: теперь он читается совсем иначе.

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

- 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() и словами опишите, что именно изменилось в выводе, — это ощущение не заменяется чтением.

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

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

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

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

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

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

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