JavaRush /Курси /Go SELF /Обмежена конкурентність: семафор через буферизований кана...

Обмежена конкурентність: семафор через буферизований канал у Go 1.25

Go SELF
Рівень 69 , Лекція 1
Відкрита

1. Семафор на буферизованому каналі: теорія та базовий шаблон

Для новачка ця ідея звучить дуже переконливо: «У мене 10 000 задач? Чудово, запущу 10 000 goroutine!» На невеликих прикладах це справді працює, тож мозок швидко робить висновок: «Отже, так і треба». Але в реальному коді такий підхід перетворюється на конкурентний варіант жарту: «замовимо всім піцу, а рахунок оплатимо потім».

Проблема не в Go: він уміє працювати з багатьма goroutine. Проблема в тому, що ви перестаєте керувати системою: скільки задач одночасно виконується, скільки памʼяті витрачено, хто кого блокує, скільки активних операцій «у повітрі».

Саме тут зʼявляється поняття bounded concurrency — обмежена, тобто контрольована, конкурентність: ми явно задаємо межу, наприклад «не більше 8 задач одночасно».

Два види обмежень: in-flight і черга

Дуже корисно розділяти в голові дві цифри, які новачки часто плутають.

Перша цифра — кількість задач у роботі (in-flight): скільки задач зараз реально виконуються.

Друга цифра — розмір черги: скільки задач ми готові накопичити, перш ніж система почне гальмувати виробника.

Тут ми зосереджуємося на першому: обмежуємо саме одночасну роботу. Це і є bounded concurrency у вузькому значенні: «не більше N задач одночасно» — навіть якщо задач мільйон.

Схематично, якщо спростити, це можна уявити так:

flowchart LR
    P[Виробник: створює задачі] -->|запуск| G[Горутини задач]
    G --> W[Робота]
    W --> D[Готово]
    subgraph Limit[Ліміт N]
      W
    end

Нам потрібен механізм, який пропустить задачу в зону W лише тоді, коли в межах ліміту N є вільний слот.

Семафор на буферизованому каналі: ідея «жетонів» і чому це по-Go

Слово «семафор» може звучати як щось із підручника з операційних систем, але в Go це зазвичай дуже простий і практичний прийом. Ми створюємо буферизований канал ємністю N і використовуємо його як лічильник «дозволів». Є дозвіл — задача може почати роботу. Немає — вона чекає.

Класична форма:

sem := make(chan struct{}, N)

Чому struct{}? Тому що нам не потрібно передавати дані — нам потрібен лише факт «зайнято/вільно». Порожня структура не займає памʼяті під поля, і це добре читається: «я надсилаю жетон, а не значення».

Дві дії семафора:

  • acquire (зайняти слот) — sem <- struct{}{}
  • release (звільнити слот) — <-sem

І так, у цей момент ви використовуєте канал як «структуру керування», а не як «трубу для даних». Це нормально: канали в Go — не лише про передавання значень, а й про синхронізацію, і стандартні патерни це активно використовують.

Мінімальний скелет: ліміт + WaitGroup

Важливо не переплутати ролі інструментів. Семафор обмежує кількість задач у роботі, але він не відповідає на питання «а коли всі завершилися?». Для того щоб дочекатися завершення всіх задач, нам і далі потрібен WaitGroup.

Скелет виглядає так: ми запускаємо задачі, кожна з них бере жетон, виконує роботу, повертає жетон і повідомляє wg.Done().

package main

import (
	"fmt"
	"sync"
)

func main() {
	const limit = 3
	sem := make(chan struct{}, limit)

	var wg sync.WaitGroup
	for i := 1; i <= 5; i++ {
		wg.Add(1)
		sem <- struct{}{} // acquire: якщо ліміт досягнуто, main зачекає

		go func(id int) {
			defer wg.Done()
			defer func() { <-sem }() // release

			fmt.Println("завдання", id, "виконано")
		}(i)
	}

	wg.Wait()
	fmt.Println("усі завдання завершено")
}

Зверніть увагу на дві речі.

По-перше, sem <- struct{}{} стоїть до go ...: це важливий вибір у проєктуванні, і ми розберемо його окремо.

По-друге, звільнення семафора оформлено через defer, щоб навіть у разі раннього return або помилки, якби вона була, слот не «витікав».

Де робити acquire: до запуску goroutine чи всередині

Семафор можна використовувати двома способами, і обидва працюють, але поводяться по-різному щодо памʼяті та керування.

Варіант A: acquire до запуску goroutine.

Коли ви робите acquire до запуску goroutine (зазвичай це main або функція, яка запускає обробку), ви не створюєте зайвих goroutine. Якщо ліміт досягнуто, поточна goroutine просто чекає, доки хтось звільнить слот.

sem <- struct{}{} // acquire
wg.Add(1)
go func() {
	defer wg.Done()
	defer func() { <-sem }() // release
	// work
}()

Це означає: «ми обмежуємо не лише паралельність, а й кількість створених goroutine». Для великих обсягів задач це зазвичай зручніше.

Варіант B: acquire всередині goroutine.

У другому варіанті ви створюєте goroutine для кожної задачі, а вже всередині вона чекає на жетон. Паралельність буде обмежена, але goroutine можуть накопичуватися тисячами й просто стояти в черзі на семафорі.

wg.Add(1)
go func() {
	defer wg.Done()

	sem <- struct{}{}         // acquire (тепер чекає goroutine)
	defer func() { <-sem }()  // release
	// work
}()

Чому це може бути погано? Тому що goroutine хоч і легкі, але не безплатні. А головне — ви знову втрачаєте частину контролю: ви обмежили «у роботі», але не обмежили «скільки всього запущено й очікує».

Практичне правило без фанатизму: якщо задач потенційно багато й ви хочете буквально «тримати систему на повідку», робіть acquire до go.

Приклад: обробка пакета задач із результатами

Щоб приклад не зводився лише до «друкуємо рядок», продовжимо лінію з worker pool: у нас є Task, і ми хочемо отримати Result. Але тут ми не тримаємо постійних воркерів, а запускаємо горутину для кожної задачі, водночас обмежуючи паралельність семафором.

Зробимо просту модель: задача множить число на 2, а результат складаємо в канал результатів. Збір (collector) — у main, однією горутиною, щоб не виникало гонок під час запису результатів.

package main

import (
	"fmt"
	"sync"
)

type Task struct {
	ID int
	N  int
}

type Result struct {
	ID  int
	Out int
}

func main() {
	tasks := []Task{{1, 10}, {2, 20}, {3, 30}, {4, 40}, {5, 50}}

	const limit = 2
	sem := make(chan struct{}, limit)
	results := make(chan Result)

	var wg sync.WaitGroup
	for _, t := range tasks {
		sem <- struct{}{} // acquire: обмежуємо in-flight
		wg.Add(1)

		go func(task Task) {
			defer wg.Done()
			defer func() { <-sem }() // release

			results <- Result{ID: task.ID, Out: task.N * 2}
		}(t)
	}

	go func() {
		wg.Wait()
		close(results)
	}()

	for r := range results {
		fmt.Println(r.ID, r.Out)
	}
}

Тут є важлива дрібниця: ми передаємо task Task параметром в анонімну функцію. Це захищає нас від класичної пастки зі змінною циклу.

У Go 1.22+ типова проблема range стала менш підступною, але звичка передавати потрібне значення параметром усе ще рятує в реальних варіантах — наприклад, якщо змінну оголошено поза циклом або ви використовуєте інший цикл. Краще бути нудним і правильним, ніж веселим і шукати помилку вночі.

Семафор і worker pool: це не одне й те саме

Зовні може здатися: «і там, і там є обмеження паралельності — то яка різниця?». Різниця в тому, яка саме структура є основною.

Worker pool — це архітектура «черга задач + фіксована кількість воркерів, які живуть довго». Семафорна схема — це «горутина на задачу, але не більше N одночасно».

Зручно порівняти це як інженерний вибір:

Критерій Worker pool Семафор через buffered channel
Життєвий цикл goroutine Довгоживучі воркери Горутина зазвичай відповідає одній задачі
Контроль паралельності Через кількість воркерів Через розмір буфера sem
Черга задач Зазвичай канал jobs Може взагалі не бути черги (наприклад, цикл по слайсу)
Коли це легше читати Коли є потік задач і ролі producer/worker/collector Коли задачі природно оформити як функції, але ліміт усе одно потрібен
Головний ризик новачка Неправильно закрити results і зависнути Накопичити goroutine, якщо acquire всередині go

Тобто семафор — чудовий варіант, коли worker pool здається надто громіздкою конструкцією: ролей забагато, а обмежити паралельність усе одно потрібно.

Як зрозуміти, що семафор працює: уявний експеримент

Щоб упевнено писати конкурентний код, корисно вміти програвати його в голові. Уявіть, що limit = 2, а у вас 5 задач.

Перші дві ітерації циклу покладуть два жетони в sem і запустять дві goroutine. На третій ітерації sem <- struct{}{} спробує покласти третій жетон, але буфер уже заповнений, тому надсилання блокується. І це добре: код, що запускає задачі, зупиняється й чекає, поки одна із задач не звільнить слот (<-sem). Щойно одна задача завершується й звільняє жетон, запуск наступної продовжується.

Таким чином, кількість задач у роботі в будь-який момент часу не перевищує cap(sem).

Можна навіть тимчасово, лише для розуміння, вивести len(sem) — але важливо памʼятати: це діагностика, а не логіка керування. У конкурентному коді не можна будувати логіку на кшталт «якщо len(sem) < cap(sem), тоді …»; між перевіркою і дією все вже могло змінитися.

3. Типові помилки під час використання семафора на буферизованому каналі

Помилка №1: зробити sem unbuffered і дивуватися, чому все зависло.
Якщо написати make(chan struct{}), то це канал без буфера, і sem <- struct{}{} чекатиме, поки хтось не зробить <-sem. Це вже не семафор, а дивна передача жетона з рук у руки. Для семафора майже завжди потрібен саме буферизований канал: make(chan struct{}, N).

Помилка №2: забути release і закоркнути систему.
Найчастіша помилка: слот зайняли, а звільнити забули. У результаті через деякий час код, що запускає задачі, назавжди впирається в заповнений sem, і програма фактично зависає. Допомагає дисципліна: звільнення краще оформлювати через defer func(){ <-sem }() поруч із acquire, щоб ці дії завжди були парою.

Помилка №3: зробити release двічі й отримати зависання в несподіваний момент.
Семафор — це сувора бухгалтерія: один acquire = один release. Якщо ви випадково зробите два <-sem, другий чекатиме жетона, якого ніхто не надішле, і зависне вже ваша задача. Це той випадок, коли помилка не шумить: panic немає, а програма тихо завмирає.

Помилка №4: робити acquire всередині goroutine за великої кількості задач і не розуміти, чому зростає споживання памʼяті.
Паралельність справді буде обмежена, але goroutine створюватимуться тисячами й стоятимуть у черзі на sem <- struct{}{}. На малих прикладах усе красиво, а на великих раптом виникає питання: «чому стільки goroutine?». Якщо задач може бути багато, зазвичай безпечніше робити acquire до go, щоб код, який запускає задачі, сам сповільнював створення goroutine.

Помилка №5: випадково захопити змінну циклу й обробити не ті дані.
Якщо ви запускаєте goroutine всередині циклу, акуратно фіксуйте значення. Надійний спосіб — передавати значення параметром в анонімну функцію: go func(t Task){ ... }(t). Так код читається й працює однаково незалежно від того, range це чи інший цикл.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ