1. Навіщо конкурентному коду таймаути і чому Sleep не рятує
Коли ви вперше пишете конкурентний код, він зазвичай виглядає бадьоро: «запустимо goroutine, дочекаємося результату з каналу». І все це правда — доки не станеться щось несподіване. А в реальних програмах «несподіване» — це не рідкісний виняток, а звична ситуація: обчислення затягнулося, відправник не стартував, отримувач пішов, черга переповнилася, а інколи просто так склалося.
У Go таймаут — це не магія і не окреме налаштування каналу. Таймаут — це ще одна подія, яка може статися паралельно з вашими send/receive. Тому ми не «ставимо таймаут на канал», а додаємо в select ще один case: «або дані надійшли, або час сплив». Такий патерн — один із класичних у конкурентності Go.
Чому не time.Sleep? Бо Sleep не вміє конкурувати з іншими подіями. Він просто блокує поточну goroutine на заданий час. Якщо дані надійдуть раніше, Sleep усе одно дочекається кінця паузи. А нам потрібно саме це: чекати, але не довше за N.
Ментальна модель: таймаут — це канал-сигнал
Перш ніж писати код, зафіксуймо просту модель. select уміє обирати між операціями з каналами. Отже, таймаут має виглядати як канал, у який «щось прийде» через N часу.
Це можна уявити так:
flowchart TD
A[Ми хочемо дочекатися результату з ch] --> B{select}
B -->|case v := <-ch| C[Отримали результат]
B -->|case <-timeoutCh| D[Час сплив: таймаут]
Важлива психологічна річ: таймаут — це не «прискорювач» і не «вбивця» інших goroutine. Таймаут лише каже: «ми припиняємо чекати тут». Якщо десь паралельно працює goroutine, вона продовжить працювати, доки ви не передбачите окремий протокол зупинки.
2. time.After(d): найпростіший таймаут на очікування
time.After(d) повертає <-chan time.Time. У цього каналу простий сенс: «через d часу в канал надійде значення часу». Нам зазвичай не важливий сам час — нам важлива подія.
Почнімо з мікроприкладу: отримати значення з каналу, але не чекати нескінченно.
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
select {
case v := <-ch:
fmt.Println("got:", v)
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout") // timeout
}
}
Цей код майже напевно надрукує timeout, бо ми ніколи нічого не надсилаємо в ch. І це якраз корисна демонстрація: без таймаута програма зависла б назавжди на <-ch, а так ми отримали другий вихід.
Маленький нюанс: створення таймаута
time.After(100 * time.Millisecond) створює таймер щоразу, коли ви викликаєте цю функцію. Це нормально для рідкісних операцій і навчальних прикладів. Але якщо ви викликаєте time.After у дуже гарячому циклі тисячу разів на секунду, це вже привід подумати про time.NewTimer. Поки просто тримаємо в голові: After — це «зручно», NewTimer — це «контроль».
4. Таймаут для receive: чекати результат не довше N
Тепер зробімо приклад ближчим до реальності: є goroutine, яка щось рахує і надсилає результат. Іноді вона встигає, іноді — ні.
Почнімо з невеликого навчального застосунку. Нехай це буде консольна «лабораторія затримок» — програма, яка запускає роботу в goroutine і чекає відповідь не довше заданого часу. Назвімо її скромно: timeout-lab.
Крок 1: функція, яка робить роботу і відправляє результат
package main
import (
"time"
)
func startWork(delay time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(delay)
out <- "work done"
}()
return out
}
Тут ми зробили out буфером 1. Навіщо? Щоб goroutine могла покласти результат і піти, навіть якщо отримувач уже перестав чекати. Це важливий захист від зависання відправника: буфер дає змогу goroutine надіслати значення й завершитися, навіть якщо ніхто його не прочитає.
Крок 2: чекати результат із таймаутом
package main
import (
"fmt"
"time"
)
func main() {
resultCh := startWork(150 * time.Millisecond)
select {
case msg := <-resultCh:
fmt.Println(msg) // work done
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout while waiting result") // timeout while waiting result
}
}
Якщо затримка роботи 150 ms, а таймаут 100 ms, ми підемо за таймаутом. Якщо змінити startWork(50 * time.Millisecond), то частіше побачимо work done.
Ключова думка: таймаут — це ще одна гілка select.
5. Таймаут для send: спробувати відправити, але не зависнути
Таймаут корисний не лише для читання. Відправлення в канал теж може блокуватися:
- на небуферизованому каналі відправлення чекає читача;
- на буферизованому каналі відправлення чекає на вільне місце, якщо буфер заповнений.
Якщо у вас є протокол «ми відправляємо результат у канал», то в якийсь момент може виявитися, що отримувач уже не читає, наприклад він пішов за таймаутом. Тоді відправник може зависнути на ch <- v.
Патерн «send або timeout» виглядає так само, просто змінюється операція в case.
package main
import (
"fmt"
"time"
)
func main() {
out := make(chan int) // unbuffered
select {
case out <- 1:
fmt.Println("sent") // (ймовірно, не побачимо)
case <-time.After(50 * time.Millisecond):
fmt.Println("timeout on send") // timeout on send
}
}
Оскільки читача немає, out <- 1 не готовий, і через 50 ms спрацює таймаут.
6. Перетворюємо це на акуратну функцію: recvWithTimeout
Коли ви бачите в коді один і той самий select { case <-ch: ... case <-time.After: ... } десять разів, мозок починає тихо сумувати. Тому типовий наступний крок — винести цей шаблон у функцію.
Зробімо функцію, яка чекає рядок із каналу не довше d і повертає (string, bool), де bool означає «встигли».
package main
import "time"
func recvWithTimeout(ch <-chan string, d time.Duration) (string, bool) {
select {
case v := <-ch:
return v, true
case <-time.After(d):
return "", false
}
}
І використання:
package main
import (
"fmt"
"time"
)
func main() {
ch := startWork(80 * time.Millisecond)
msg, ok := recvWithTimeout(ch, 100*time.Millisecond)
if ok {
fmt.Println("OK:", msg) // OK: work done
return
}
fmt.Println("timeout") // (якщо робота довша за таймаут)
}
Зверніть увагу на стиль: ok тут не «помилка», а саме ознака того, чи встигли ви. Ми свідомо не вводимо поки що складну обробку помилок — сьогодні наше завдання навчитися шаблону таймаута.
7. Коли потрібен time.NewTimer замість time.After
Коли time.After починає бути незручним
time.After чудовий, доки:
- ви використовуєте його рідко;
- ви не хочете керувати таймером.
Але бувають ситуації, коли вам хочеться сказати: «якщо ми отримали результат раніше, таймер нам уже не потрібен». У випадку time.After ви не можете зупинити таймер, бо не тримаєте його в руках: ви отримали лише канал.
У більшості навчальних задач це не критично. Але в конкурентних схемах із частими таймаутами або всередині циклів іноді корисно мати керований таймер.
Таймер як об’єкт: time.NewTimer(d)
time.NewTimer(d) повертає *time.Timer. У таймера є канал C, з якого можна прочитати сигнал спрацювання. Головна відмінність така: таймер можна зупинити через Stop().
Базовий шаблон:
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
fmt.Println("timeout") // timeout
}
}
Поки це здається тим самим, що й time.After. Але сила NewTimer проявляється тоді, коли в нас є конкуруюча подія, наприклад результат роботи.
Приклад: отримали результат — таймер більше не потрібен
package main
import (
"fmt"
"time"
)
func main() {
resultCh := startWork(50 * time.Millisecond)
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
select {
case msg := <-resultCh:
fmt.Println(msg) // work done
case <-timer.C:
fmt.Println("timeout")
}
}
Коли результат надходить за 50 ms, ми виходимо з select. Таймер усе ще існує, але defer timer.Stop() означає: «якщо таймер ще не спрацював, зупини його». Це дисциплінованіший стиль, коли ви хочете явно показати: таймер — це ресурс, і я його зупиняю.
Важливо: у Stop() є нюанси, наприклад що робити, якщо таймер уже встиг спрацювати. Тут ми не занурюємося в тонкощі «дренування» каналу таймера — нам важливий сам принцип: NewTimer дає контроль.
8. Мінісхема поведінки: After vs NewTimer
Зробімо маленьку таблицю, щоб у вас у голові нічого не змішалося.
| Інструмент | Що повертає | Зручність | Контроль | Типовий сценарій |
|---|---|---|---|---|
|
|
максимум | мінімум | «швидко й просто додати таймаут у select» |
|
|
трохи складніше | можна Stop() | «часті таймаути, потрібно керувати таймером» |
І ще раз людською мовою: After — це як одноразовий будильник «задзвонить через 5 хвилин», а NewTimer — будильник, у якого є кнопка «скасувати».
9. Вбудовуємо таймаут у timeout-lab: живіший приклад
Зараз зберемо невеликий цілісний приклад програми, яку можна запускати й отримувати різну поведінку, просто змінюючи затримки. Це добра вправа для мислення: ви починаєте відчувати, коли який case готовий.
package main
import (
"fmt"
"time"
)
func startWork(name string, delay time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(delay)
out <- name + ": done"
}()
return out
}
func main() {
ch := startWork("job#1", 120*time.Millisecond)
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
select {
case msg := <-ch:
fmt.Println("RESULT:", msg)
case <-timer.C:
fmt.Println("RESULT: timeout") // RESULT: timeout
}
}
Уявіть: якщо delay менший за 100 ms, надрукується RESULT: job#1: done. Якщо більший — спрацює таймаут.
Чому out буферизований? Щоб навіть якщо main уже пішов за таймаутом і завершився, сама goroutine не застрягла на out <- .... Це як покласти лист у поштову скриньку, а не чекати біля дверей, поки отримувач відчинить.
10. Таймаут — це не скасування роботи
Типова пастка новачка: «я поставив таймаут, отже робота зупинилася». Ні. Таймаут каже лише про те, що в цьому місці ви перестали чекати.
Якщо ваша goroutine робить щось довге, вона продовжить роботу навіть тоді, коли ви підете за таймаутом. Іноді це прийнятно, наприклад «нехай дорахує, результат уже не потрібен». Але іноді ні, наприклад «ми запускаємо тисячі операцій і хочемо зупинити зайві».
Щоб зупиняти зайві, зазвичай потрібен окремий сигнал завершення. У Go дуже часто роль такого сигналу виконує ctx.Done(), який теж читається як канал у select. Але сьогодні ми зосереджуємося саме на таймаутах After/NewTimer як на базовому прийомі й запам’ятовуємо: таймаут не замінює протокол зупинки.
11. Типові помилки
Помилка №1: використовувати time.Sleep замість таймаута в select.
Дуже хочеться написати: «ну, я почекаю 100 ms і потім перевірю канал», але Sleep не конкурує з іншими подіями. Він просто блокує goroutine. У результаті ви або втрачаєте час, бо дані надійшли раніше, а ви все одно спите, або отримуєте рвану логіку «прокинувся → перевірив → знову заснув», яка швидко перетворюється на погано керований цикл.
Помилка №2: робити time.After у щільному циклі й дивуватися навантаженню та алокаціям.
time.After зручний, але він створює таймер. Якщо ви викликаєте його дуже часто, ви створюєте дуже багато таймерів. У навчальних прикладах це не страшно, але в «гарячих» місцях коду краще перейти на time.NewTimer, де ви явно керуєте життєвим циклом таймера і можете його зупиняти.
Помилка №3: думати, що таймаут «зупиняє» іншу goroutine.
Таймаут завершує очікування лише там, де ви його обробили. Якщо у вас запущена goroutine, яка має вміти зупинитися, їй потрібен окремий шлях виходу: закриття вхідного каналу, окремий done-сигнал або скасування через контекст. Сигнал ctx.Done() теж читається в select. Без цього таймаут може призвести до ситуації: ми пішли, а goroutine лишилася.
Помилка №4: забути про ризик зависання відправника і зробити небуферизований канал для результату.
Якщо отримувач може піти за таймаутом, відправник може назавжди зависнути на ch <- result, якщо канал небуферизований і ніхто не читає. Один із простих способів захиститися — буфер 1 у каналі результату: «поклав і вийшов».
Помилка №5: використовувати time.NewTimer, але взагалі його не зупиняти.
Таймер — це ресурс рантайму. У простих випадках він сам доживе до спрацювання, і все буде гаразд. Але якщо ви хочете охайний стиль і особливо якщо таймерів багато, краще дотримуватися дисципліни: timer := time.NewTimer(d) і defer timer.Stop(). Так код читається як «створив ресурс — гарантовано завершив».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ