1. Навіщо взагалі два режими: без буфера і з буфером
Якщо дивитися на канали очима новачка, хочеться, щоб вони працювали як «труба»: кинув значення — і воно собі полетіло далі, не заважаючи іншим справам. Але Go влаштований прагматично: інколи вам потрібна жорстка синхронізація — «ми зустрілися й передали значення», а інколи — черга, щоб згладити різницю швидкостей між відправником і отримувачем. Саме тому в Go є небуферизовані і буферизовані канали — один і той самий інструмент, але з різною поведінкою.
Синтаксис відрізняється лише однією цифрою:
- make(chan int) — небуферизований канал (місткість 0)
- make(chan int, 3) — буферизований канал (місткість 3)
І ця «одна цифра» змінює те, коли горутини чекатимуть одна на одну.
Небуферизований канал: «зустрічна передача»
Небуферизований канал (make(chan T)) зручно уявляти як передачу предмета з рук у руки. Якщо ви простягаєте людині флешку, а поруч нікого немає, ви стоїте з простягнутою рукою. Якщо людина готова прийняти флешку, а ви ще не простягнули її, вона теж чекає. Обидві сторони мають «зустрітися» в часі.
У термінах Go це звучить так: відправлення в небуферизований канал блокується, доки хтось не почне отримувати, і навпаки, отримання блокується, доки хтось не відправить.
Подивімося на це як на міні-діалог (діаграма послідовності):
sequenceDiagram
participant S as Горутина-відправник
participant C as Канал (небуферизований)
participant R as Горутина-отримувач
S->>C: ch <- 42
Note over S,C: блокування, доки не почнеться отримання
R->>C: v := <-ch
Note over C,R: значення передано
Note over S: відправник розблоковано
Міні-приклад: «рукостискання» в коді
package main
import (
"fmt"
)
func main() {
ch := make(chan int) // небуферизований
go func() {
ch <- 42 // чекатиме, поки main не прочитає
}()
v := <-ch
fmt.Println(v) // 42
}
Тут main отримує значення й лише цим «звільняє» відправника. Важливе відчуття: небуферизований канал — це не черга, а синхронізація.
Чому це корисно, а не «незручно»?
Тому що інколи вам справді потрібно: «не продовжуй роботу, доки інша горутина не підтвердить, що значення прийнято». Наприклад, ви хочете гарантувати порядок кроків: спочатку підготували дані, потім віддали, а потім продовжуємо. Небуферизований канал — це вбудований «бар’єр» без Mutex і без спільної пам’яті.
2. Буферизований канал: черга і зсув моменту блокування
Буферизований канал (make(chan T, n)) можна уявити як поштову скриньку на n листів. Ви можете покласти лист — і піти, навіть якщо отримувач ще спить. Але якщо скринька заповнена вщерть, ви вже не зможете покласти туди ще один лист: доведеться чекати, поки хтось розбере пошту.
Це і є суть:
- send (ch <- v) блокується, коли буфер повний
- receive (<-ch) блокується, коли буфер порожній і ніхто не відправляє просто зараз
Тобто буфер не скасовує блокування, він лише зсуває його момент.
Міні-приклад: «черга на 2 елементи»
package main
import "fmt"
func main() {
ch := make(chan string, 2) // буферизований, cap=2
ch <- "A" // не блокується
ch <- "B" // не блокується
fmt.Println(<-ch) // A
fmt.Println(<-ch) // B
}
Тут відправлення взагалі не чекають на отримувача, бо буфер ще не заповнений.
Міні-приклад: як побачити блокування на повному буфері
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 1 // буфер заповнений
go func() {
fmt.Println(<-ch) // 1
}()
ch <- 2 // чекаємо, доки прочитають "1"
fmt.Println(<-ch) // 2
}
Щойно значення 1 прочитали, у буфері звільнилося місце, і відправлення 2 змогло завершитися.
Важлива практична деталь: «буфер 1» як спосіб не тримати горутину заручником
Іноді горутина має надіслати сигнал і завершитися, навіть якщо інша сторона ще не готова прийняти сигнал у цю ж мілісекунду. Тоді роблять канал із буфером 1: відправник кладе значення, виходить і не зависає в очікуванні отримувача.
Зараз ми не заглиблюємося в складні схеми очікування кількох подій; нам достатньо зрозуміти сам принцип: маленький буфер інколи потрібен не «для швидкості», а щоб дати горутині завершитися без ризику зависнути.
3. Практичні нюанси: блокування, len/cap і вибір
Коли саме блокується кожна сторона
Якщо намагатися запам’ятати це «на відчуттях», можна легко переплутати, хто кого чекає. Тому корисно мати в голові просту таблицю. Тут немає магії: лише два запитання — «чи є місце, щоб покласти?» і «чи є що взяти?».
Порівняймо:
| Тип каналу | cap(ch) | Коли блокується ch <- v | Коли блокується <-ch |
|---|---|---|---|
| Небуферизований | |
Завжди, доки немає отримувача | Завжди, доки немає відправника |
| Буферизований (n) | |
Коли буфер заповнений (len == cap) | Коли буфер порожній (len == 0) і ніхто не відправляє |
І ось ключова думка лекції: небуферизований канал — це синхронізація «зустрілися», а буферизований канал — черга «накопичили й розібрали».
Невелика схема-нагадування
flowchart TD
A["відправлення: ch <- v"] --> B{"cap(ch) == 0?"}
B -- так --> C["небуферизований: чекаємо отримання"]
B -- ні --> D{"len(ch) < cap(ch)?"}
D -- так --> E["кладемо в буфер, продовжуємо"]
D -- ні --> F["буфер повний: чекаємо отримання"]
G["отримання: <-ch"] --> H{"len(ch) > 0?"}
H -- так --> I["беремо з буфера / передаємо значення"]
H -- ні --> J{"cap(ch) == 0?"}
J -- так --> K["небуферизований: чекаємо відправлення"]
J -- ні --> L["буферизований: чекаємо відправлення (буфер порожній)"]
len(ch) і cap(ch): діагностика, але не «пульт керування»
Коли ви вперше бачите len(ch) і cap(ch), виникає спокуса: «О! Значить, можна написати if len(ch) > 0 { ... } і зробити неблокувальне читання». Це дуже людська думка — і дуже небезпечна, бо в конкурентному коді між if і дією може минути достатньо часу, щоб світ устиг змінитися.
Почнімо з правильного розуміння:
- cap(ch) — місткість буфера каналу (0 для небуферизованого)
- len(ch) — скільки значень прямо зараз лежить у буфері
Міні-приклад: діагностика буфера
package main
import "fmt"
func main() {
ch := make(chan int, 3)
fmt.Println(len(ch), cap(ch)) // 0 3
ch <- 10
ch <- 20
fmt.Println(len(ch), cap(ch)) // 2 3
}
Це зручно для налагодження і для пояснення того, що відбувається. Але це не «надійна синхронізація».
Чому? Тому що конкурентність — це коли кілька горутин можуть змінювати стан майже одночасно. Якщо ви перевірили len(ch), а потім вирішили щось зробити, інша горутина могла вже прочитати або записати значення, і ваш висновок став застарілим.
У реальному Go-коді len(ch) частіше грає роль «термометра»: подивитися температуру можна, а керувати погодою — ні.
Як обирати: небуферизований чи буферизований
На початку шляху дуже хочеться прийняти просте правило: «Буферизований канал — швидший, отже будемо використовувати буферизований». Але буфер — це не безплатна продуктивність. Буфер — це зміна протоколу.
Якщо ви ставите великий буфер «щоб точно не блокувалося», ви дозволяєте системі накопичувати роботу. А накопичувати можна нескінченно багато… доки не скінчиться пам’ять або доки програма не перетвориться на склад невиконаних задач.
Хороший спосіб думати про вибір такий:
Небуферизований канал корисний, коли ви хочете пряму синхронізацію і природний зворотний тиск: відправник не може втекти вперед, поки отримувач не встигне. Це часто робить поведінку системи передбачуванішою.
Буферизований канал корисний, коли у вас є зрозуміла причина розвести в часі відправника й отримувача. Наприклад, відправник працює ривками, а отримувач обробляє рівномірно, і невеликий буфер згладжує піки. Або ви хочете, щоб короткоживуча горутина могла відправити результат і завершитися, не залежачи від точного таймінгу отримувача (типовий буфер 1 як «поштова скринька для одного листа»).
Важливо, що питання «який розмір буфера» — це вже рішення на рівні дизайну: скільки елементів ми готові тримати в черзі? Іноді відповідь 1, іноді 10, іноді 0. Але майже ніколи відповідь не «мільйон, щоб не думати».
4. Приклад: збирання результатів обробки завдань
Щоб не залишатися на рівні абстрактних «поштових скриньок», давайте вбудуємо канали в невеликий фрагмент нашого навчального застосунку (умовно — менеджера завдань). Уявімо, що в нас є список завдань, і ми хочемо паралельно порахувати для кожного «вагу» — наприклад, довжину заголовка, — а потім зібрати результати в main.
Ми поки не використовуємо закривання каналу і for range ch — це окрема тема. Тут зробимо збирання результатів фіксованою кількістю читань: рівно стільки, скільки завдань.
Крок 1: модель даних і результат
package main
type Task struct {
ID int
Title string
}
type TaskStat struct {
ID int
TitleSize int
}
Крок 2: обробник одного завдання
package main
func calcStat(t Task) TaskStat {
return TaskStat{
ID: t.ID,
TitleSize: len(t.Title),
}
}
Крок 3: небуферизований канал — строга синхронізація
package main
import "fmt"
func main() {
tasks := []Task{{1, "Buy milk"}, {2, "Write Go"}, {3, "Sleep"}}
ch := make(chan TaskStat) // небуферизований
for _, t := range tasks {
go func(task Task) {
ch <- calcStat(task) // чекаємо, поки main не прочитає
}(t)
}
for i := 0; i < len(tasks); i++ {
stat := <-ch
fmt.Println(stat.ID, stat.TitleSize)
}
}
Що важливо відчути: навіть якщо якась горутина порахувала результат миттєво, вона не зможе «скинути» його в канал, доки main не почне читати. Тобто main тут задає ритм, і це інколи справді дуже зручно.
Крок 4: буферизований канал — невелика черга результатів
Тепер зробимо канал буферизованим. Наприклад, на 2 результати:
package main
import "fmt"
func main() {
tasks := []Task{{1, "Buy milk"}, {2, "Write Go"}, {3, "Sleep"}}
ch := make(chan TaskStat, 2) // буферизований, cap=2
for _, t := range tasks {
go func(task Task) {
ch <- calcStat(task) // може не чекати, доки буфер не повний
}(t)
}
for i := 0; i < len(tasks); i++ {
stat := <-ch
fmt.Println(stat.ID, stat.TitleSize)
}
}
Як змінилася поведінка? Тепер дві горутини можуть швидко відпрацювати: покласти результати в буфер і завершитися, навіть якщо main ще не прочитав. Але третє відправлення може заблокуватися, якщо буфер уже заповнений, бо cap=2, а завдань 3.
Важливе спостереження про порядок
Порядок приходу результатів у main за такого підходу не гарантується «за ID». Канал гарантує FIFO за фактом відправлень, але те, хто відправить раніше, залежить від планувальника й швидкості виконання горутин. Це нормально: конкурентність майже завжди означає, що порядок або не треба припускати, або треба забезпечувати окремо. Наприклад, сортуванням після збирання — але це вже логіка даних, а не логіка каналу.
5. Типові помилки під час вибору буферизованого чи небуферизованого каналу
Помилка № 1: «Буферизований канал ніколи не блокується».
Це популярна ілюзія: здається, що раз є буфер, то відправник завжди може «здати вантаж і піти». Насправді буфер скінченний. Коли len(ch) == cap(ch), відправлення блокується точно так само, як у небуферизованому каналі, просто в інший момент. Через цю помилку люди ставлять cap=1, відправляють два значення і дивуються, чому програма зависла.
Помилка № 2: «Давайте зробимо величезний буфер, щоб не думати про блокування».
Великий буфер часто перетворюється на склад проблем: він приховує неузгодженість швидкостей, накопичує значення, збільшує використання пам’яті й робить затримки менш передбачуваними. Іноді система виглядає «стабільною», доки не приходить реальне навантаження — і тоді пам’ять летить у космос. Буфер має бути усвідомленим рішенням: скільки саме ми готові зберігати в черзі очікування.
Помилка № 3: будувати логіку синхронізації на len(ch).
len(ch) показує стан «прямо зараз», але конкурентний світ змінюється миттєво. Код виду if len(ch) > 0 { v := <-ch } не гарантує неблокувальне читання: між перевіркою і читанням інша горутина могла вже вичитати значення. У підсумку ви все одно можете зависнути, тільки тепер ще й із відчуттям, що вас «обдурили».
Помилка № 4: обирати небуферизований канал там, де відправник має вміти швидко завершуватися.
Іноді у вас є коротка горутина, яка має надіслати один результат і закінчити. Якщо канал небуферизований, вона може зависнути на ch <- v, якщо отримувач затримався. У таких випадках буфер 1 часто робить поведінку стійкішою: горутина поклала значення й завершилася, не стаючи заручником таймінгів.
Помилка № 5: «Буфер — це прискорення», без розуміння, яку проблему ми розв’язуємо.
Буфер не робить ваш код автоматично швидшим. Він змінює місце, де горутини чекають одна на одну. Іноді це підвищує пропускну здатність, іноді — лише ускладнює налагодження. Якщо ви не можете пояснити словами, навіщо вам буфер, почніть із небуферизованого каналу: він зазвичай простіший, чесніший і дисциплінує протокол обміну.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ