Клієнт-серверна архітектура

Інтерфейси та модель конкурентності мови Go

Повний курс з інтерфейсів та конкурентності Go: неявна реалізація та качина типізація, композиція інтерфейсів, набори методів (pointer vs value receiver), порожній інтерфейс any, приведення типів (Type Assertion, Type Switch), пастка nil-інтерфейсу, порівняння інтерфейсних значень, парадигма CSP, горутини та планувальник GMP, стан гонитви, вбудований детектор дедлоку, канали, select, семафор та конвеєр на каналах, пакет sync (Mutex, RWMutex, WaitGroup, Once, Map), sync/atomic та пакет context у HTTP-обробниках.

Інтерфейси та модель конкурентності мови Go

🎯 Навчальні цілі

  • Зрозуміти, яку інженерну проблему вирішують інтерфейси (interfaces) і чому Go свідомо відмовився від класичного наслідування на користь композиції та контрактів поведінки.
  • Опанувати механізм неявної реалізації інтерфейсів (implicit implementation) та принцип качиної типізації (duck typing), а також навчитися працювати з порожнім інтерфейсом any.
  • Оволодіти безпечними прийомами приведення типів: перевіркою через Type Assertion (форма «кома, ок») та багатогілковим Type Switch.
  • Розпізнавати класичну пастку типізованого nil-інтерфейса, яка є джерелом прихованих помилок навіть у досвідчених розробників.
  • Зрозуміти різницю між моделлю конкурентності CSP (Communicating Sequential Processes) у Go та моделлю спільної пам'яті з потоками й блокуваннями (Java, C#).
  • Навчитися створювати горутини (goroutines), розуміти внутрішню будову планувальника GMP та природу стану гонитви (race condition).
  • Опанувати канали (chan) як основний засіб безпечної передачі даних між горутинами, а також оператор select для мультиплексування декількох каналів.
  • Вивчити примітиви синхронізації пакета sync (Mutex, RWMutex, WaitGroup, Once), атомарні операції sync/atomic та пакет context для керування життєвим циклом конкурентних операцій.

🛠️ Технологічний стек

  • Мова: Go 1.22+
  • Інструменти діагностики: go run -race, go vet, GODEBUG=schedtrace=1000
  • Пакети стандартної бібліотеки: sync, sync/atomic, context, time

Від жорсткої ієрархії до гнучких контрактів поведінки

У попередній лекції ми розглянули, як мова Go організовує дані: структури (struct), композицію через вбудовування (embedding) та методи, прив'язані до одержувача (receiver). Наприкінці того матеріалу ми навели приклад із пінгвіном, який успадкував від базового класу метод fly(), хоча фізично літати не вміє. Цей приклад показує типову проблему об'єктно-орієнтованих мов: як описати спільну поведінку між різними типами без створення жорсткої ієрархії класів?

Проблема класичного наслідування

У мовах на кшталт Java або C# поліморфізм зазвичай будується навколо ієрархії класів: базовий клас Animal оголошує метод Fly(), а похідні класи Duck та Penguin успадковують цей метод, перевизначаючи за потреби його реалізацію. У такого підходу є відчутні недоліки під час проєктування серверних систем:

  1. Жорстке зв'язування з батьківським типом. Похідний клас завжди фізично залежить від коду базового класу. Якщо базовий клас з часом зміниться, це може непередбачувано вплинути на всі похідні типи — навіть ті, що концептуально не мали жодного стосунку до зміненої поведінки.
  2. Проблема невідповідної поведінки (Liskov Substitution Violation). Пінгвін технічно є твариною, але, успадкувавши метод Fly(), він змушений або кинути виключення під час виконання, або мовчки нічого не робити — обидва варіанти є пасткою для розробника, що викликає цей метод, розраховуючи на коректну поведінку.
  3. Заборона множинного наслідування. Практично жодна сучасна мова не дозволяє класу успадкувати реалізацію одразу від двох батьківських класів через невирішувану проблему «діамантового успадкування» (diamond problem): якщо обидва батьківські класи мають метод з однаковим ім'ям, компілятор не може однозначно визначити, яку саме реалізацію слід використати.

Мова Go підходить до цієї задачі інакше: розробники мови повністю відмовилися від наслідування реалізації та ключового слова extends. Натомість Go розділяє два питання: «що структура вміє робити» (контракт поведінки) та «як саме вона це робить» (конкретна реалізація). Для опису першого якраз і використовується інтерфейс.

Місток із попередньої лекції: пригадайте приклад Eater, Flyer, Swimmer для тварин Duck і Penguin. Там ми лише побіжно показали синтаксис type X interface { ... }, щоб проілюструвати композицію замість наслідування. У цій лекції ми розберемо інтерфейси системно: як вони влаштовані зсередини, чому реалізація завжди неявна, і як безпечно дізнатися конкретний тип значення, що ховається за інтерфейсом.

Інтерфейс як контракт поведінки: анатомія та синтаксис

Синтаксис:

type ІмʼяІнтерфейсу interface {
    Метод1(параметри) типРезультату
    Метод2(параметри) (тип1, тип2)
}

Простий приклад:

type Greeter interface {
    Greet() string
}

type English struct{}

func (English) Greet() string { return "Hello!" }

var g Greeter = English{}
fmt.Println(g.Greet()) // Hello!

Інтерфейс (interface) у мові Go — це тип, який не описує жодних даних, а лише перелічує набір сигнатур методів, якими повинен володіти будь-який конкретний тип, щоб вважатися таким, що «задовольняє» цей інтерфейс (satisfies the interface). Інакше кажучи, інтерфейс — це список вимог до поведінки, повністю відокремлений від того, як саме ця поведінка буде реалізована.

Синтаксично оголошення інтерфейсу дуже схоже на оголошення структури, але замість полів даних всередині фігурних дужок перелічуються сигнатури методів — без тіла функції, лише ім'я методу, список параметрів та тип результату:

type Notifier interface {
    Send(message string) error
}

Це оголошення варто прочитати так: «Будь-який тип, що надає метод Send, який приймає рядок і повертає error, автоматично вважається реалізацією інтерфейсу Notifier». Зверніть увагу: ми ніде не вказували implements Notifier. Це ключова риса мови Go, яку ми розглянемо в наступному розділі.

Інтерфейси можуть містити будь-яку кількість методів. Проте в Go прийнято робити інтерфейси дуже компактними, часто з одним-двома методами. Це відрізняється від практики Java чи C#, де інтерфейси на кшталт IRepository нерідко містять десятки методів. Погляньмо на io.Writer зі стандартної бібліотеки Go — один із найуживаніших інтерфейсів:

type Writer interface {
    Write(p []byte) (n int, err error)
}

Лише один метод! Проте саме завдяки такому мінімалізму цей інтерфейс однаково успішно реалізують файли на диску (*os.File), мережеві з'єднання (net.Conn), буфери в пам'яті (*bytes.Buffer), тіла HTTP-відповідей (http.ResponseWriter) і навіть стиснені потоки (gzip.Writer). Чим менший контракт — тим більше різнорідних типів здатні йому відповідати, і тим гнучкішим стає код, який приймає такий інтерфейс як параметр.

Практичне правило проєктування (Interface Segregation): формулюйте інтерфейси виходячи з потреб коду, який їх споживає, а не з можливостей типу, який їх реалізує. Якщо функції потрібно лише записати байти, приймайте параметр типу io.Writer, а не конкретний *os.File — тоді цю саму функцію можна буде використати і для запису у файл, і в мережевий сокет, і в буфер для модульного тестування.

Неявна реалізація та качина типізація (Duck Typing)

Найбільш незвична для новачків риса інтерфейсів Go полягає в тому, що жоден тип ніколи явно не оголошує намір реалізувати конкретний інтерфейс. У Java чи C# розробник зобов'язаний написати class FileLogger implements Notifier або class FileLogger : INotifier, і компілятор одразу перевірить, чи справді клас реалізував усі оголошені методи. У Go такого синтаксису не існує в принципі — компілятор Go визначає відповідність структури інтерфейсу повністю автоматично, виключно аналізуючи набір методів, які ця структура фактично має.

Такий підхід називають качиною типізацією (duck typing), за відомим правилом: «Якщо це виглядає як качка, плаває як качка і крякає як качка — то це, ймовірно, і є качка». Для компілятора не має значення назва типу чи пакет, де він оголошений — важливо лише те, чи збігаються сигнатури його методів із вимогами інтерфейсу.

type Notifier interface {
    Send(message string) error
}

// EmailSender ніде не згадує слово "Notifier"
type EmailSender struct {
    SMTPHost string
}

func (e EmailSender) Send(message string) error {
    fmt.Printf("Відправка email через %s: %s\n", e.SMTPHost, message)
    return nil
}

// SMSSender так само не оголошує жодного зв'язку з Notifier
type SMSSender struct {
    APIKey string
}

func (s SMSSender) Send(message string) error {
    fmt.Printf("Відправка SMS: %s\n", message)
    return nil
}

// Проте обидва типи можуть використовуватися там, де очікується Notifier
func broadcast(notifiers []Notifier, message string) {
    for _, n := range notifiers {
        n.Send(message)
    }
}

func main() {
    channels := []Notifier{
        EmailSender{SMTPHost: "smtp.kostyl.dev"},
        SMSSender{APIKey: "secret-key"},
    }
    broadcast(channels, "Сервер перезапущено")
}

Компілятор Go під час компіляції функції broadcast перевіряє єдине: чи має тип EmailSender метод Send(string) error, і чи має такий самий метод тип SMSSender. Оскільки обидва типи мають відповідний метод із точно збіжною сигнатурою, обидва автоматично «задовольняють» інтерфейс Notifier — без жодного явного оголошення, без імпорту пакета, де описаний інтерфейс, і навіть без знання про існування цього інтерфейсу в момент написання структур EmailSender і SMSSender.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam classAttributeIconSize 0

interface "Notifier\n(контракт)" as Notifier #DBEAFE {
  + Send(message string) error
}

class "EmailSender\n(struct)" as Email #DCFCE7 {
  + Send(message string) error
}

class "SMSSender\n(struct)" as SMS #FEF3C7 {
  + Send(message string) error
}

note top of Notifier #F8FAFC
  Жодна структура не пише
  "implements Notifier".
  Компілятор сам перевіряє
  збіг сигнатур методів.
end note

Email .up.|> Notifier : неявно
SMS .up.|> Notifier : неявно
@enduml

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

А що, якщо два різні пакети незалежно оголосять однаковий інтерфейс Send(string) error під різними іменами? Це абсолютно нормально й навіть заохочується у Go. Оскільки перевірка відповідності структурна, а не номінальна, той самий тип EmailSender зможе одночасно задовольняти й mail.Notifier, і alerting.MessageSender — за умови ідентичного набору сигнатур методів.

Композиція інтерфейсів: складання великих контрактів із малих

Синтаксис:

type Комбінований interface {
    Інтерфейс1
    Інтерфейс2
}

Простий приклад:

type Named interface{ Name() string }
type Aged interface{ Age() int }

// Person вимагає обидва методи одразу
type Person interface {
    Named
    Aged
}

Оскільки інтерфейси в Go зазвичай роблять невеликими, постає питання: як описати тип, що має підтримувати кілька операцій одночасно? Для цього використовують вбудовування інтерфейсів одне в одного (interface embedding). Класичний приклад зі стандартної бібліотеки — інтерфейс io.ReadWriter, який поєднує два раніше оголошених інтерфейси:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// ReadWriter — це композиція двох маленьких інтерфейсів
type ReadWriter interface {
    Reader
    Writer
}

Будь-який тип, що реалізує обидва методи, Read і Write, автоматично задовольняє й ReadWriter, хоча ніде явно не згадує це складене ім'я — той самий принцип неявної реалізації, який ми обговорили вище, просто застосований до складенішого контракту. Мережеве з'єднання net.Conn, наприклад, реалізує саме таку комбінацію методів — воно одночасно і читає, і записує байти в мережевий сокет.

Ще один типовий приклад компактного інтерфейсу зі стандартної бібліотеки — sort.Interface, який дозволяє функції sort.Sort впорядковувати будь-яку колекцію, незалежно від типу елементів:

type Interface interface {
    Len() int
    Less(i, j int) bool
    Swap(i, j int)
}

Щоб відсортувати власний зріз структур []Employee за зарплатою, достатньо реалізувати ці три методи для типу-обгортки над зрізом — і функція sort.Sort зможе відсортувати будь-які дані, що надають лише ці три елементарні операції, без жодного знання про внутрішню будову структури Employee.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam classAttributeIconSize 0

interface "Reader" as R #DBEAFE {
  + Read(p []byte) (int, error)
}
interface "Writer" as W #DCFCE7 {
  + Write(p []byte) (int, error)
}
interface "ReadWriter" as RW #FEF3C7 {
}

RW -up-|> R : вбудовано
RW -up-|> W : вбудовано
@enduml
Правило композиції: якщо ловите себе на думці «цьому інтерфейсу потрібно ще пару методів для нового сценарію», зазвичай краще оголосити новий, окремий вузький інтерфейс і скомпонувати його з наявним через вбудовування, ніж роздувати вже наявний інтерфейс — так існуючий код, що використовує старий вузький контракт, продовжить працювати без жодних змін.

Набори методів: чому pointer receiver іноді порушує реалізацію інтерфейсу

Синтаксис:

func (v Тип) МетодЗначення()   { /* value receiver */ }
func (p *Тип) МетодПокажчика() { /* pointer receiver */ }

Простий приклад:

type Counter struct{ n int }

func (c *Counter) Inc() { c.n++ } // pointer receiver

c := Counter{}
c.Inc()
fmt.Println(c.n) // 1

Кожен тип у Go має власний набір методів (method set) — перелік усіх методів, які можна викликати на значенні цього типу. Проте це правило приховує важливий нюанс, пов'язаний із різницею між методами на значенні (value receiver, func (e Employee) ...) та методами на покажчику (pointer receiver, func (e *Employee) ...), яку ми детально розглядали в лекції про структури й методи.

Правило формулюється так: набір методів типу T включає лише методи, оголошені з отримувачем T; натомість набір методів типу *T включає методи, оголошені і з отримувачем T, і з отримувачем *T. Це означає, що значення типу T (без покажчика) не задовольняє інтерфейс, якщо хоча б один із потрібних методів оголошений з отримувачем-покажчиком:

type Notifier interface {
    Send(message string) error
}

type SlackSender struct {
    channel string
}

// Метод оголошено з отримувачем-покажчиком *SlackSender
func (s *SlackSender) Send(message string) error {
    fmt.Println("Slack:", message)
    return nil
}

func broadcast(n Notifier) {
    n.Send("Деплой завершено")
}

func main() {
    sender := SlackSender{channel: "#devops"}

    broadcast(&sender) // ✅ компілюється: *SlackSender має метод Send
    // broadcast(sender)  // ❌ помилка компіляції!
    // "SlackSender does not implement Notifier (method Send has pointer receiver)"
}

Причина цього обмеження суто практична й пов'язана з безпекою пам'яті: якщо метод Send має отримувач-покажчик *SlackSender, це зазвичай означає, що метод потенційно змінює внутрішній стан структури. Якби компілятор дозволив передати broadcast(sender) (тобто копію значення), викликаний усередині функції метод Send міг би модифікувати лише тимчасову копію структури, приховану всередині інтерфейсного значення, — а виклик коду зовні ніколи б не побачив цих змін, що суперечило б інтуїтивному очікуванню розробника. Тому компілятор ще на етапі збірки забороняє таку небезпечну неоднозначність.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontSize 12

package "Набір методів типу SlackSender (значення)" #FEE2E2 {
    rectangle "лише методи з\nreceiver SlackSender" as SetT
}

package "Набір методів типу *SlackSender (покажчик)" #DCFCE7 {
    rectangle "методи з receiver SlackSender\n+ методи з receiver *SlackSender" as SetPT
}

note bottom of SetT #FEF3C7
  Send() має pointer receiver,
  тому SlackSender (значення)
  НЕ входить у набір методів
  і не задовольняє Notifier!
end note
@enduml
Практичне правило вибору отримувача: якщо структура має хоча б один метод з отримувачем-покажчиком (типова ситуація, коли метод змінює поле структури), послідовно оголошуйте всі методи цієї структури з отримувачем-покажчиком, а не змішуйте T і *T в одному типі. Це усуває плутанину з наборами методів і гарантує, що структура однаково поводиться незалежно від того, який метод викликається першим.

Порожній інтерфейс any: контейнер для будь-якого значення

Синтаксис:

var x any          // сучасний запис (з Go 1.18)
var y interface{}  // еквівалентний старий запис

Простий приклад:

var x any = 42
x = "тепер рядок"
x = true
fmt.Println(x) // true

Логічним продовженням ідеї структурної типізації є запитання: а що, якщо інтерфейс не вимагає жодного методу? Такий інтерфейс формально записується як interface{} — порожні фігурні дужки без жодної сигнатури всередині. Оскільки список вимог порожній, буквально будь-який тип у мові Go (числа, рядки, структури, зрізи, покажчики, навіть інші інтерфейси) автоматично задовольняє такий контракт, адже виконати «нуль вимог» може все, що завгодно.

Через надзвичайну поширеність цієї конструкції, починаючи з версії Go 1.18, у стандартній бібліотеці зафіксовано зручний псевдонім типу:

type any = interface{}

Це означає, що any та interface{} — це буквально один і той самий тип, і ви побачите обидва варіанти запису в реальному коді (сучасний стиль рекомендує саме коротке any). Класичний приклад використання порожнього інтерфейсу — сигнатура функції fmt.Println, яка повинна вміти прийняти для виведення значення абсолютно будь-якого типу:

func Println(a ...any) (n int, err error)

Порожній інтерфейс широко застосовується там, де функція за своєю природою мусить працювати з довільними даними без знання їхнього конкретного типу під час компіляції — наприклад, у логерах загального призначення, серіалізаторах JSON (json.Unmarshal часто приймає map[string]any для розбору структури невідомого наперед документа) чи узагальнених контейнерах на кшталт кешу.

type Cache struct {
    data map[string]any
}

func (c *Cache) Set(key string, value any) {
    c.data[key] = value
}

func (c *Cache) Get(key string) any {
    return c.data[key]
}

func main() {
    cache := Cache{data: make(map[string]any)}
    cache.Set("port", 8080)
    cache.Set("hostname", "api.kostyl.dev")
    cache.Set("is_debug", true)

    // Значення "port" зберігається як any, реальний тип — int
    fmt.Println(cache.Get("port"))
}

Проте використання any має практичні мінуси. По-перше, втрачається захист статичної типізації: компілятор не перевіряє, чи є значення cache.Get("port") числом, адже воно має тип any. Будь-які операції з ним потребують явного приведення типу. По-друге, збереження значення в інтерфейс часто призводить до виділення пам'яті в купі (escape to heap), що створює додаткове навантаження на збирач сміття (garbage collector).

Правило поміркованого використання any: використовуйте порожній інтерфейс лише тоді, коли тип даних дійсно невідомий і непередбачуваний наперед (розбір довільного JSON, узагальнене логування, рефлексія). Якщо ж набір можливих типів обмежений і відомий заздалегідь (наприклад, лише кілька варіантів повідомлень протоколу), надавайте перевагу конкретному невеликому інтерфейсу з іменованими методами — це залишає компілятору можливість перевіряти коректність коду ще до запуску програми.

Оскільки any приховує реальний тип даних, виникає потреба перевірити вміст і повернутися до роботи з конкретним типом. Для цього в Go передбачені механізми приведення типів, які ми розглянемо далі.


Приведення типів (Type Assertion)

Синтаксис:

value := x.(T)         // небезпечна форма — панікує, якщо динамічний тип x не T
value, ok := x.(T)     // безпечна форма — ok=false замість паніки

Простий приклад:

var x any = "привіт"
s, ok := x.(string)
fmt.Println(s, ok) // привіт true

Небезпечна форма: пряме твердження про тип

Приведення типу (Type Assertion) — це конструкція, яка дозволяє отримати з інтерфейсного типу конкретне значення, що зберігається всередині. Найпростіший запис виглядає так:

var data any = "hello, server"

str := data.(string) // "розпакувати" data як рядок
fmt.Println(str)      // hello, server

Ця конструкція виглядає безпечно рівно доти, доки припущення про тип справджується. Але що станеться, якщо всередині data насправді зберігається не рядок, а, наприклад, число?

var data any = 42

str := data.(string) // паніка під час виконання!

Програма завершиться панікою: panic: interface conversion: interface {} is int, not string. Для серверного застосунку це небезпечно, оскільки помилка в типі призводить до аварійної зупинки обробки запиту. Тому форму без перевірки використовують рідко — лише коли тип значення гарантовано відомий заздалегідь.

Безпечна форма: конструкція «кома, ок»

Для безпечної перевірки типу без ризику паніки Go пропонує двозначну форму твердження, яка повертає друге логічне значення — ознаку успішності перетворення:

var data any = 42

str, ok := data.(string)
if !ok {
    fmt.Println("data не є рядком, це:", data)
    return
}
fmt.Println("Рядок:", str)

Якщо реальний тип значення data не відповідає запитаному типу string, змінна ok набуде значення false, а str отримає порожній рядок. Жодної паніки не виникне. Саме форма з перевіркою ok є стандартним і безпечним підходом до роботи з типами у серверному коді.

Найпоширеніша помилка новачків: написати str := data.(string) без другого значення ok «про всяк випадок, для швидкості», а потім отримати неочікувану паніку на проді через кілька тижнів після запуску, коли клієнт надішле дані іншого формату. Завжди використовуйте форму value, ok := x.(T), якщо немає стовідсоткової гарантії реального типу значення.

Приведення до інтерфейсу, а не лише до конкретного типу

Твердження про тип можна виконувати не тільки для отримання конкретного типу (string, int, *User), а й для перевірки, чи реалізує значення інший, вужчий інтерфейс. Це особливо корисний прийом у мережевому програмуванні — наприклад, перевірка, чи підтримує з'єднання додаткову можливість встановлення тайм-аутів:

type Closer interface {
    Close() error
}

func tryClose(w io.Writer) {
    if closer, ok := w.(Closer); ok {
        closer.Close()
        fmt.Println("Ресурс успішно закрито")
        return
    }
    fmt.Println("Цей writer не підтримує закриття")
}

Багатогілкове приведення типу: конструкція Type Switch

Синтаксис:

switch v := x.(type) {
case T1:
    // v має статичний тип T1
case T2:
    // v має статичний тип T2
default:
    // жоден тип не збігся, v має тип x (any)
}

Простий приклад:

func printKind(x any) {
    switch x.(type) {
    case int:
        fmt.Println("число")
    case string:
        fmt.Println("рядок")
    }
}

Коли потрібно перевірити значення any не проти одного конкретного типу, а проти цілого набору можливих варіантів, повторювати конструкцію value, ok := x.(T) для кожного типу окремо було б громіздко. Для таких сценаріїв у Go існує спеціальна форма оператора switch, яка називається Type Switch:

func describe(v any) string {
    switch value := v.(type) {
    case int:
        return fmt.Sprintf("ціле число: %d", value)
    case string:
        return fmt.Sprintf("рядок довжиною %d символів", len(value))
    case bool:
        return fmt.Sprintf("логічне значення: %t", value)
    case nil:
        return "значення відсутнє (nil)"
    default:
        return fmt.Sprintf("невідомий тип: %T", value)
    }
}

Зверніть увагу на спеціальний синтаксис v.(type) — слово type тут використовується буквально, а не як заповнювач конкретного типу, і працює лише всередині заголовка конструкції switch. У кожній гілці case змінна value автоматично набуває саме того конкретного типу, який зазначено в цій гілці — тобто всередині case int: змінна value вже має статичний тип int, і компілятор дозволяє виконувати над нею типові арифметичні операції без додаткового приведення.

Практичне застосування Type Switch у клієнт-серверних системах найчастіше зустрічається під час розбору різнотипних повідомлень одного протоколу — наприклад, коли WebSocket-з'єднання чи чергу повідомлень потрібно обробляти залежно від конкретного варіанта команди:

type PingCommand struct{ Timestamp int64 }
type ChatMessage struct{ From, Text string }
type DisconnectCommand struct{ Reason string }

func handleCommand(cmd any) {
    switch c := cmd.(type) {
    case PingCommand:
        fmt.Println("Пінг отримано о:", c.Timestamp)
    case ChatMessage:
        fmt.Printf("[%s]: %s\n", c.From, c.Text)
    case DisconnectCommand:
        fmt.Println("Клієнт відключається:", c.Reason)
    default:
        fmt.Printf("Невідома команда типу %T проігнорована\n", c)
    }
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam activityFontSize 13

start
:Отримано значення "cmd" типу any;
if (Реальний тип == PingCommand?) then (так)
  :Обробити як Ping;
else (ні)
  if (Реальний тип == ChatMessage?) then (так)
    :Обробити як текстове повідомлення;
  else (ні)
    if (Реальний тип == DisconnectCommand?) then (так)
      :Закрити з'єднання;
    else (ні)
      :Гілка default —\nневідома команда;
    endif
  endif
endif
stop
@enduml
Одна гілка на кілька типів: якщо декільком типам відповідає однакова обробка, їх можна перерахувати через кому в одній гілці case int, int64, float64:. У такому разі змінна всередині гілки збереже статичний тип any (адже типи в списку різні), тож приведення до конкретного числового типу доведеться виконати додатково всередині тіла гілки.

Небезпечна пастка: типізований nil проти справжнього nil

Типова помилка під час роботи з інтерфейсами у Go пов'язана з їхньою внутрішньою будовою. Інтерфейсне значення в Go — це пара з двох елементів: покажчик на конкретний тип (type descriptor) та покажчик на самі дані (data pointer). Інтерфейс вважається рівним nil лише тоді, коли обидва ці компоненти порожні.

Розгляньмо класичний приклад, що ілюструє проблему на прикладі власного типу помилки:

type NotFoundError struct{ Resource string }

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s не знайдено", e.Resource)
}

func findUser(id int) *NotFoundError {
    if id == 0 {
        return &NotFoundError{Resource: "користувач"}
    }
    return nil // виглядає як "помилки немає"
}

func process(id int) error {
    var err *NotFoundError = findUser(id)
    return err // ПАСТКА: неявне перетворення *NotFoundError -> error
}

func main() {
    result := process(1)
    if result != nil {
        fmt.Println("Отримано помилку!") // це виконається, хоча findUser повернув справжній nil!
    }
}

Хоча функція findUser повертає буквально нульовий покажчик nil типу *NotFoundError, після присвоєння цього значення змінній типу error всередині функції process результат більше не дорівнює nil під час порівняння result != nil. Причина в тому, що інтерфейсне значення error тепер містить непорожній компонент типу (*NotFoundError), навіть попри те, що компонент даних дійсно порожній. Порівняння інтерфейсного значення з nil перевіряє обидва внутрішні компоненти одночасно, тож пара (тип: *NotFoundError, дані: nil) вважається відмінною від «справжнього» nil, який є парою (тип: nil, дані: nil).

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontSize 12

package "Справжній nil (var err error)" #DCFCE7 {
    rectangle "Тип: <відсутній>" as T1
    rectangle "Дані: <відсутні>" as D1
}

package "Типізований nil (err після process())" #FEE2E2 {
    rectangle "Тип: *NotFoundError" as T2
    rectangle "Дані: nil" as D2
}

note bottom of D2 #FEF3C7
  err != nil повертає TRUE,
  адже компонент типу заповнений!
end note
@enduml
Практичне правило захисту від цієї пастки: ніколи не оголошуйте функцію так, щоб вона повертала конкретний тип покажчика на помилку (*NotFoundError), який потім неявно присвоюється змінній інтерфейсного типу error. Замість цього завжди явно оголошуйте тип результату функції як error, а всередині функції за відсутності помилки явно повертайте нетипізований літерал nil: func findUser(id int) error { ...; return nil }. Це найпоширеніша причина прихованих if err != nil спрацьовувань у реальних Go-застосунках, і на неї варто зважати з першого дня написання коду.

Порівняння інтерфейсних значень оператором ==

Інтерфейсні значення в Go можна порівнювати оператором ==, і на перший погляд це працює настільки ж просто, як і для звичайних структур: два інтерфейсні значення вважаються рівними, якщо в них однаковий динамічний тип і рівні динамічні значення.

type Shape interface {
    Area() float64
}

type Rectangle struct{ Width, Height float64 }

func (r Rectangle) Area() float64 { return r.Width * r.Height }

func main() {
    var a Shape = Rectangle{Width: 2, Height: 3}
    var b Shape = Rectangle{Width: 2, Height: 3}

    fmt.Println(a == b) // true: однаковий динамічний тип і рівні поля
}

Проте тут ховається небезпечний нюанс: якщо динамічний тип, захований усередині інтерфейсу, сам по собі непорівнюваний (наприклад, містить зріз, мапу чи функцію — типи, для яких оператор == у Go взагалі не визначений), спроба порівняти два таких інтерфейсних значення не поверне false, а призведе до паніки під час виконання:

type Handler interface {
    Handle()
}

type FuncHandler struct {
    fn func() // непорівнюваний тип!
}

func (h FuncHandler) Handle() { h.fn() }

func main() {
    var a Handler = FuncHandler{fn: func() {}}
    var b Handler = FuncHandler{fn: func() {}}

    fmt.Println(a == b) // паніка під час виконання!
    // panic: runtime error: comparing uncomparable type main.FuncHandler
}
Компілятор тут не допоможе. На відміну від помилок компіляції, які ми бачили в попередніх пастках, цю проблему компілятор пропускає, оскільки статичний тип обох змінних — це просто інтерфейс Handler, а перевірка непорівнюваності виконується лише під час виконання, коли відомий реальний динамічний тип. Якщо структура, яку ви плануєте порівнювати через == (наприклад, як ключ мапи або в тестах через reflect.DeepEqual/==), містить поля-зрізи, мапи чи функції, порівнюйте її поля вручну або взагалі уникайте прямого порівняння == для такого типу.

Питання для самоперевірки: інтерфейси


Паралелізм проти конкурентності: два різні поняття

До цього моменту код у наших прикладах виконувався послідовно, в одному потоці. Проте для серверних застосунків такий підхід зазвичай неприйнятний. Наприклад, якщо під час обробки першого HTTP-запиту сервер очікує відповіді від бази даних 200 мс, другий клієнт увесь цей час чекатиме в черзі. Для ефективної обробки незалежних задач одночасно використовують конкурентність.

Перш ніж заглиблюватися в конкретні механізми мови Go, критично важливо розрізнити два поняття, які часто плутають навіть досвідчені розробники: конкурентність (concurrency) та паралелізм (parallelism). Роб Пайк, один із творців мови Go, сформулював це розрізнення напрочуд лаконічно:

«Конкурентність — це про те, як структурувати роботу з багатьма незалежними завданнями одночасно. Паралелізм — це про те, як фізично виконувати кілька обчислень одночасно».

Конкурентність — це властивість архітектури програми, яка дозволяє їй складатися з кількох незалежних, логічно одночасних потоків виконання, що можуть переплітатися в довільному порядку. Важливо: конкурентна програма не обов'язково виконується на кількох процесорних ядрах одночасно — вона просто структурована так, щоб декілька завдань могли просуватися вперед незалежно одне від одного, по черзі отримуючи невеликі проміжки процесорного часу.

Паралелізм, натомість, — це властивість фізичного виконання, коли кілька обчислень дійсно відбуваються в один і той самий момент часу на різних ядрах процесора. Паралелізм неможливий без багатоядерного (або багатопроцесорного) обладнання, тоді як конкурентність цілком можлива навіть на одноядерній машині — просто за рахунок швидкого перемикання між завданнями.

Аналогія з реального життя: уявіть одного кухаря на кухні, який готує три страви одночасно — він конкурентно перемикається між сковорідками, поки одна страва тушкується, він нарізає овочі для іншої. Це конкурентність: одна людина, кілька завдань, що просуваються по черзі. Тепер уявіть трьох кухарів, кожен з яких готує свою страву одночасно на окремій ділянці кухні — це вже справжній паралелізм: кілька виконавців, які фізично працюють одночасно.

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


Модель CSP: спілкування через канали замість спільної пам'яті

Історично більшість мов програмування, які підтримують конкурентність (Java, C#, C++), базуються на моделі спільної пам'яті з потоками (shared memory with threads): кілька потоків виконання отримують прямий доступ до одних і тих самих змінних у пам'яті, а щоб уникнути одночасної суперечливої зміни цих даних, розробник обгортає критичні ділянки коду блокуваннями (locks), м'ютексами (mutexes) чи семафорами. Ця модель працює, але вона надзвичайно крихка: розробник зобов'язаний вручну пам'ятати, які саме дані спільні, де саме потрібне блокування, і в якому порядку захоплювати кілька блокувань одночасно, щоб уникнути взаємного блокування (deadlock).

Мова Go спирається на модель CSP, Communicating Sequential Processes («послідовні процеси, що взаємодіють»). Головна практична відмінність CSP полягає в тому, що замість прямого доступу кількох потоків до спільної пам'яті кожен потік працює зі своїми локальними даними, а обмін інформацією відбувається шляхом передачі повідомлень через канали зв'язку. Цей принцип описує відоме правило Go:

«Не спілкуйтеся через спільну пам'ять; натомість діліться пам'яттю через спілкування» («Do not communicate by sharing memory; instead, share memory by communicating»).

На практиці це означає: замість того, щоб дві горутини одночасно читали й записували один і той самий лічильник у пам'яті (що вимагає ручного м'ютекса), одна горутина надсилає значення лічильника через канал іншій горутині, яка одноосібно володіє цим лічильником і єдина має право його змінювати. Оскільки в кожен момент часу лише один потік виконання фактично торкається конкретних даних, необхідність у явних блокуваннях суттєво зменшується — сам механізм передачі повідомлень через канал уже гарантує безпечну синхронізацію.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontSize 12

package "Модель спільної пам'яті (Java/C#: Thread + Lock)" #FEE2E2 {
    rectangle "Потік A" as ThreadA
    rectangle "Потік B" as ThreadB
    database "Спільна змінна\n(захищена м'ютексом)" as Shared #FEF3C7
    ThreadA -down-> Shared : lock() / запис
    ThreadB -down-> Shared : lock() / читання
}

package "Модель CSP (Go: Goroutine + Channel)" #DCFCE7 {
    rectangle "Горутина A\n(власні дані)" as GoA
    rectangle "Горутина B\n(власні дані)" as GoB
    queue "Канал (chan)" as Ch #DBEAFE
    GoA -right-> Ch : send
    Ch -right-> GoB : receive
}
@enduml

Важливо зазначити: Go не забороняє модель спільної пам'яті з м'ютексами — стандартний пакет sync надає Mutex та інші примітиви синхронізації. Проте канали є основним рекомендованим інструментом для взаємодії горутин, тоді як sync.Mutex використовують переважно для захисту простих спільних даних (наприклад, лічильника або мапи).

АспектМодель спільної пам'яті (потоки + блокування)Модель CSP (горутини + канали)
Одиниця виконанняПотік ОС (OS thread), важкий, кілька мегабайтів стекаГорутина, легковага, стек від 2 КБ
Механізм обміну данимиПрямий доступ до спільної змінної в пам'ятіЯвна передача повідомлення через канал
Основний захистМутекси, семафори, блокування вручнуКанали гарантують безпечну передачу без ручних блокувань
Типова помилкаЗабутий lock()/unlock(), взаємне блокуванняВитік горутини через незакритий канал
Хто керує потоками виконанняРозробник створює Thread/Task явноПланувальник Go Runtime (GMP) керує горутинами автоматично

Горутини: легковагі потоки виконання

Синтаксис:

go викликФункції(аргументи) // запускає функцію як окрему горутину, не чекаючи завершення

Простий приклад:

func hello() { fmt.Println("Привіт із горутини!") }

go hello() // виконається конкурентно, без очікування

Горутина (goroutine) — це функція, яка виконується конкурентно з іншими горутинами в межах одного й того самого адресного простору процесу. Синтаксично запустити нову горутину напрочуд просто: достатньо додати ключове слово go перед викликом будь-якої функції:

func sayHello(name string) {
    fmt.Println("Привіт,", name)
}

func main() {
    go sayHello("Олексій") // запускає нову горутину і НЕ чекає її завершення
    go sayHello("Марія")   // запускає ще одну горутину паралельно

    fmt.Println("Головна горутина продовжує роботу")
    time.Sleep(100 * time.Millisecond) // штучна затримка, щоб побачити результат
}

Зверніть увагу на ключовий нюанс: виклик go sayHello("Олексій") не блокує виконання головної функції main — програма одразу переходить до наступного рядка коду, а функція sayHello виконується десь «паралельно у фоні» (точніше — конкурентно, а справжній паралелізм залежить від кількості доступних ядер процесора). Сама функція main теж технічно виконується у власній горутині — так званій головній горутині (main goroutine), яка створюється автоматично під час запуску програми.

Критично важливий факт, який часто дивує новачків: якщо головна горутина main завершує роботу (функція main повертає керування), уся програма негайно завершується, навіть якщо інші запущені горутини ще не встигли доробити свою роботу. Саме тому в прикладі вище довелося додати штучну затримку time.Sleep — без неї програма, ймовірно, завершилася б раніше, ніж горутини встигли б хоч раз викликати fmt.Println. У реальному коді штучні затримки категорично не використовуються для синхронізації — для цього призначені канали та примітив sync.WaitGroup, які ми розглянемо трохи згодом.

Чому горутини набагато дешевші за потоки операційної системи

Головна перевага горутин над системними потоками операційної системи (OS threads) — мінімальні витрати ресурсів на їх створення та підтримку. Потік ОС потребує фіксованого виділення пам'яті під стек (зазвичай від 1 до 8 МБ) і керується ядром ОС, де перемикання контексту вимагає системних викликів.

Горутина, натомість, стартує з мінімального стека розміром лише 2 кілобайти (це деталь внутрішньої моделі пам'яті, яку ми розглядали в попередній лекції — стек горутини динамічно виділяється в купі процесу та здатний зростати чи стискатися залежно від глибини викликів функцій). Оскільки горутини керуються не операційною системою, а власним планувальником середовища виконання Go (без необхідності переходу в режим ядра для перемикання між ними), одна програма на Go цілком реально може одночасно підтримувати сотні тисяч активних горутин — тоді як аналогічна кількість потоків ОС миттєво вичерпала б усю доступну оперативну пам'ять сервера.

ХарактеристикаПотік ОС (Java Thread, C# Task на потоці)Горутина Go
Початковий розмір стека1–8 МБ (фіксовано під час створення)2 КБ (динамічно росте/стискається)
Хто планує виконанняПланувальник ядра операційної системиПланувальник Go Runtime (GMP, у користувацькому просторі)
Вартість створенняВідносно висока (системний виклик)Дуже низька (виділення невеликого блока в купі)
Практична межа кількостіТисячі (обмежено пам'яттю та планувальником ОС)Сотні тисяч і навіть мільйони
Перемикання контекстуДороге (перехід у режим ядра)Дешеве (виконується повністю в user space)
Практичний наслідок для серверної розробки: завдяки низьким витратам на горутини типовий Go-сервер запускає окрему горутину на кожне вхідне клієнтське з'єднання — цей підхід ми розглянемо в наступній лекції про TCP-сокети. У мовах на кшталт Java чи C# підхід «потік на з'єднання» вичерпав би пам'ять за великої кількості клієнтів, тому там використовують пули потоків або моделі async/await. У Go створювати пул горутин зазвичай немає потреби — планувальник самостійно справляється з великою кількістю завдань.

Планувальник GMP: як Go керує тисячами горутин на кількох ядрах

Виникає практичне питання: якщо горутини — це не системні потоки, як саме вони виконуються на ядрах процесора? За це відповідає внутрішній планувальник Go Runtime, що працює за моделлю GMP (за назвами трьох основних компонентів):

G (Goroutine) — сама горутина: структура, що містить виконувану функцію, покажчик на стек та статус виконання.

M (Machine) — системний потік операційної системи (OS thread). Потік M — це саме те, що ОС планує на фізичному процесорному ядрі.

P (Processor) — логічний контекст виконання, що пов'язує горутини G із системними потоками M. Кількість P задається змінною середовища GOMAXPROCS і за замовчуванням дорівнює кількості доступних процесорних ядер. Кожен P має власну чергу готових до виконання горутин, а потік M виконує Go-код тільки тоді, коли йому призначено вільний P.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontSize 12

package "Логічні процесори (P), кількість = GOMAXPROCS" #DBEAFE {
    queue "P1: локальна черга\n[G4, G7, G12, ...]" as P1
    queue "P2: локальна черга\n[G2, G9, ...]" as P2
}

package "Потоки ОС (M), прив'язані до ядер процесора" #DCFCE7 {
    rectangle "M1 (ядро CPU #1)" as M1
    rectangle "M2 (ядро CPU #2)" as M2
}

package "Глобальна черга (Global Run Queue)" #FEF3C7 {
    queue "Горутини без\nпризначеного P" as GQ
}

P1 -down-> M1 : виконує G, коли\nM1 вільний
P2 -down-> M2 : виконує G, коли\nM2 вільний
GQ .up.> P1 : періодично забирає порцію
GQ .up.> P2 : періодично забирає порцію
@enduml

Планувальник застосовує механізм балансування навантаження (work stealing): якщо локальна черга одного P спорожніла, вільний потік M забирає частину горутин із черги іншого P. Це забезпечує рівномірне завантаження всіх ядер CPU.

Ще одна важлива деталь стосується того, коли саме відбувається перемикання між горутинами. До версії Go 1.14 перемикання було переважно кооперативним (cooperative scheduling): горутина «добровільно» звільняла процесор лише в певних точках коду — під час виклику функцій, операцій з каналами, системних викликів чи виклику runtime.Gosched(). Це створювало теоретичну проблему: горутина з довгим обчислювальним циклом без жодного виклику функцій могла «монопольно» зайняти процесорне ядро й заблокувати інші горутини на невизначений час. Починаючи з Go 1.14, планувальник підтримує асинхронне витіснення (asynchronous preemption): якщо горутина виконується занадто довго (понад приблизно 10 мілісекунд), планувальник може примусово призупинити її та передати керування іншій горутині, навіть якщо перша ніколи не викликала жодної функції.

Чому це важливо на практиці: розуміння GMP пояснює, чому сервер на Go стабільно витримує десятки тисяч одночасних підключень. Горутина, яка очікує на дані з мережі (наприклад, у виклику conn.Read()), не блокує потік M — планувальник від'єднує її до надходження даних, звільняючи системний потік для інших задач.

Стан гонитви: небезпека одночасного доступу до пам'яті

Легкість запуску горутин вимагає обережності: якщо кілька горутин одночасно звертаються до спільної змінної і хоча б одна записує дані, виникає стан гонитви (race condition). Розглянемо приклад лічильника, який одночасно змінюють тисяча горутин:

func main() {
    counter := 0

    for i := 0; i < 1000; i++ {
        go func() {
            counter++ // НЕБЕЗПЕЧНО: конкурентний запис без синхронізації
        }()
    }

    time.Sleep(500 * time.Millisecond) // недопустимий "милиця"-синхронізатор
    fmt.Println("Підсумкове значення:", counter)
}

Логічно очікувати, що після запуску тисячі горутин, кожна з яких один раз збільшує лічильник на одиницю, фінальне значення counter дорівнюватиме рівно 1000. Проте на практиці результат виявляється непередбачуваним і змінюється від запуску до запуску, часто виводячи щось на кшталт 987 чи 994. Причина криється в тому, що операція counter++, попри свою візуальну простоту, насправді складається з трьох окремих машинних кроків: (1) зчитати поточне значення counter з пам'яті, (2) додати до нього одиницю, (3) записати новий результат назад у пам'ять. Якщо дві горутини одночасно виконують крок (1) і зчитують однакове старе значення, перш ніж будь-яка з них встигне виконати крок (3), одне з двох збільшень фізично «губиться» — обидві горутини записують результат, що враховує лише одне збільшення замість двох.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
autonumber

participant "Горутина A" as A #DBEAFE
participant "Пам'ять (counter)" as Mem #FEF3C7
participant "Горутина B" as B #DCFCE7

A -> Mem : Читає counter (= 41)
B -> Mem : Читає counter (= 41)
A -> A : Обчислює 41 + 1 = 42
B -> B : Обчислює 41 + 1 = 42
A -> Mem : Записує counter = 42
B -> Mem : Записує counter = 42
note over Mem #FEE2E2
  Втрачене оновлення!
  Мало бути 43, отримали 42.
end note
@enduml

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

Мова Go надає вбудований інструмент для автоматичного виявлення станів гонитви ще до того, як вони проявляться у продакшені — так званий детектор гонитви (race detector), який активується прапорцем -race:

go run -race main.go

З прапорцем -race середовище виконання Go відстежує кожне звернення до пам'яті та фіксує несинхронізований одночасний доступ горутин до спільних змінних. У разі конфлікту виводиться детальний звіт із номерами рядків обох звернень, а програма завершується з ненульовим кодом, що дозволяє виявляти такі помилки на етапі автоматичного тестування (CI).

Детектор гонитви не є заміною правильного проєктування коду. Він лише виявляє гонитви, які фактично трапилися під час конкретного запуску програми — якщо потенційно небезпечна ділянка коду жодного разу не була виконана конкурентно під час тестового прогону, детектор про неї нічого не повідомить. Крім того, увімкнений детектор суттєво уповільнює виконання програми та збільшує споживання пам'яті, тому в реальному продакшені його не використовують — лише під час тестування та локальної розробки.

Щоб уникнути стану гонитви, доступ до спільних даних синхронізують за допомогою примітивів пакета sync (зокрема sync.Mutex) або організовують обмін повідомленнями через канали, які ми розглянемо нижче.


Канали: типобезпечні труби для передачі даних між горутинами

Синтаксис:

ch := make(chan T)      // небуферизований канал
ch := make(chan T, N)   // буферизований канал ємністю N

ch <- значення           // надіслати значення в канал
значення := <-ch          // отримати значення з каналу
close(ch)                 // закрити канал (сигнал "більше даних не буде")

Простий приклад:

ch := make(chan int)
go func() { ch <- 5 }()
fmt.Println(<-ch) // 5

Канал (chan) — це тип даних у Go, який надає чергу для передачі типізованих значень між горутинами з автоматичною синхронізацією. Канал працює за принципом труби: одна горутина записує значення, інша його зчитує, причому операції надсилання та отримання безпечно узгоджуються самим середовищем виконання.

Канал створюється за допомогою вже знайомої нам функції make, з обов'язковим зазначенням типу переданих значень:

ch := make(chan int) // канал для передачі значень типу int

Надсилання значення в канал і отримання значення з каналу виконуються за допомогою спеціального оператора <- («стрілка»), напрямок якої вказує, у який бік рухаються дані:

ch <- 42       // надіслати значення 42 в канал ch
value := <-ch  // отримати значення з каналу ch у змінну value

Небуферизовані канали: синхронна передача «з рук у руки»

Якщо під час створення каналу через make(chan int) не вказано ємність, створюється небуферизований канал (unbuffered channel). У ньому операція надсилання ch <- value блокує відправника, доки інша горутина не почне читати значення, і навпаки: читання блокує отримувача, доки значення не буде надіслано. Відбувається пряма синхронна передача даних між горутинами (handoff).

func worker(results chan<- int) {
    time.Sleep(50 * time.Millisecond) // імітація повільної роботи
    results <- 42                      // заблокується, поки хтось не прочитає
}

func main() {
    results := make(chan int)
    go worker(results)

    fmt.Println("Очікуємо результат від горутини...")
    value := <-results // main блокується, поки worker не надішле значення
    fmt.Println("Отримано результат:", value)
}

Зверніть увагу на синтаксис параметра results chan<- int у сигнатурі функції worker — це спрямований канал (directional channel), який дозволяє передавати каналу лише в один бік (тут — тільки для запису, chan<-). Аналогічно існує форма <-chan int для каналу, придатного лише для читання. Хоча технічно канал завжди двонаправлений усередині, обмеження напрямку на рівні сигнатури функції — це важливий інструмент документування та захисту коду: компілятор не дозволить помилково прочитати з каналу, призначеного функцією виключно для запису.

Буферизовані канали: черга з обмеженою ємністю

Якщо під час створення каналу вказати другий числовий аргумент, буде створено буферизований канал (buffered channel) із фіксованою внутрішньою ємністю:

ch := make(chan int, 3) // буферизований канал ємністю 3 значення

Буферизований канал поводиться інакше: операція надсилання ch <- value блокується лише тоді, коли внутрішній буфер повністю заповнений; якщо в буфері ще є вільне місце, значення просто додається туди, і горутина-відправник продовжує роботу негайно, не чекаючи на отримувача. Симетрично, операція отримання блокується лише тоді, коли буфер порожній.

func main() {
    logQueue := make(chan string, 5)

    logQueue <- "запис 1" // не блокує, буфер має вільне місце
    logQueue <- "запис 2" // не блокує
    logQueue <- "запис 3" // не блокує

    fmt.Println("Черга наразі містить:", len(logQueue), "записів") // 3
}
Буферизований канал не замінює координацію, а лише згладжує навантаження. Він корисний, коли генератор подій тимчасово випереджає обробника, але якщо черга заповнюється швидше, ніж обробляється, відправники все одно заблокуються, як і в небуферизованому каналі.

Закриття каналу та безпечне читання через range

Коли відправник більше не має значень для передачі, канал варто явно закрити вбудованою функцією close(ch). Закриття каналу — це спосіб сигналізувати всім отримувачам: «більше даних не буде». Спроба записати значення у вже закритий канал спричиняє паніку, тоді як читання із закритого каналу залишається безпечним — воно негайно повертає нульове значення відповідного типу.

Щоб дізнатися, чи канал уже закритий, а чи просто тимчасово порожній, використовують двозначну форму отримання, аналогічну до Type Assertion:

value, ok := <-ch
if !ok {
    fmt.Println("Канал закрито, більше значень не буде")
}

Найзручнішим способом послідовно прочитати всі значення з каналу аж до його закриття є конструкція for-range, яка автоматично завершує цикл одразу після закриття каналу:

func producer(ch chan<- int) {
    for i := 1; i <= 5; i++ {
        ch <- i * i // надсилає квадрати чисел від 1 до 5
    }
    close(ch) // обов'язково закриваємо канал після завершення надсилання
}

func main() {
    ch := make(chan int)
    go producer(ch)

    for value := range ch { // цикл сам завершиться після close(ch)
        fmt.Println("Отримано:", value)
    }
    fmt.Println("Усі значення оброблено, канал закрито")
}
Пастка nil-каналу та витоку горутин. Читання або запис у канал зі значенням nil (тобто оголошений через var ch chan int, але ніколи не ініціалізований через make) блокує горутину назавжди — без жодної помилки чи паніки, просто вічне очікування. Так само небезпечно забути викликати close(ch), коли горутина-отримувач очікує на цикл for range ch: без закриття каналу цикл ніколи не завершиться, і горутина-отримувач залишиться «висіти» в пам'яті процесу до кінця роботи програми — це різновид витоку ресурсів, що має назву витік горутини (goroutine leak).

Вбудований детектор дедлоку: коли зупиняються всі горутини одразу

Окрім детектора гонитви -race, який ми розглянули раніше, Go Runtime автоматично й безкоштовно (без жодних додаткових прапорців) стежить за ще однією небезпечною ситуацією — тотальним дедлоком (deadlock), коли абсолютно всі горутини програми одночасно заблоковані й жодна з них ніколи більше не зможе прокинутися. Найпростіший спосіб продемонструвати таку ситуацію — спробувати прочитати значення з небуферизованого каналу, у який ніхто ніколи нічого не надішле:

func main() {
    ch := make(chan int) // небуферизований канал
    value := <-ch          // очікуємо значення, якого ніхто ніколи не надішле
    fmt.Println(value)
}

Запуск цієї програми не зависає мовчки назавжди — Go Runtime виявляє, що жодна горутина в системі більше не може просунутися вперед, і негайно аварійно завершує програму з промовистим повідомленням:

go run main.go
$ go run main.go
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
Чим цей детектор відрізняється від -race? Детектор гонитви -race шукає некоректний одночасний доступ до пам'яті між кількома активними горутинами і потребує явного прапорця під час запуску. Вбудований детектор дедлоку, натомість, працює завжди, без жодних прапорців, але виявляє лише повний глухий кут, коли в системі не залишилося жодної горутини, здатної виконати бодай один крок. Якщо заблокованими опиняться лише дві горутини з десяти, а решта вісім продовжують працювати, цей вбудований детектор нічого не помітить — програма просто повільно «підтікатиме» витоками горутин, і тут знадобляться вже інші інструменти діагностики (наприклад, аналіз дампу горутин через pprof).

Патерн «Виробник–Споживач» та пул воркерів через канали

Одним із найпоширеніших практичних застосувань каналів у серверному коді є патерн «Виробник–Споживач» (Producer–Consumer), а також його розширення — пул воркерів (Worker Pool), коли кілька однакових горутин-обробників конкурентно розбирають завдання з одного спільного каналу. Такий підхід ідеально підходить, наприклад, для паралельної обробки пакету зображень, парсингу списку файлів чи розсилки повідомлень великій кількості одержувачів, обмежуючи при цьому кількість одночасних обробників фіксованим числом.

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs { // читає завдання, поки канал jobs не закриють
        fmt.Printf("Воркер %d обробляє завдання %d\n", id, job)
        time.Sleep(50 * time.Millisecond) // імітація обчислень
        results <- job * job
    }
}

func main() {
    const numWorkers = 3
    jobs := make(chan int, 10)
    results := make(chan int, 10)
    var wg sync.WaitGroup

    // Запускаємо фіксовану кількість воркерів заздалегідь
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Надсилаємо 5 завдань у спільну чергу
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs) // сигналізуємо воркерам, що нових завдань не буде

    // Окрема горутина очікує завершення всіх воркерів і безпечно закриває results
    go func() {
        wg.Wait()
        close(results)
    }()

    // Збираємо результати через range: цикл автоматично завершиться після закриття results
    for res := range results {
        fmt.Println("Результат:", res)
    }
}

У цьому прикладі три горутини-воркери конкурентно змагаються за право прочитати наступне завдання з каналу jobs. Оскільки Go гарантує, що кожне конкретне значення з каналу отримає рівно один отримувач (навіть якщо кілька горутин одночасно очікують на читання з того самого каналу), жодне завдання не буде оброблене двічі й жодне не загубиться — сам канал бере на себе всю складність розподілу роботи, без потреби в явних блокуваннях чи чергах із ручним керуванням.

Зверніть увагу на два важливі моменти життєвого циклу каналів у цьому патерні:

  1. Закриття черги завдань close(jobs): після того як головна горутина відправила всі завдання, вона закриває jobs. Це дозволяє воркерам завершити цикл for job := range jobs, щойно черга спорожніє.
  2. Координація закриття результатів через sync.WaitGroup: читати результати через жорстко задану кількість ітерацій (for i := 0; i < 5) ненадійно, адже обсяг завдань у реальних програмах змінюється динамічно. Природний спосіб читання результатів — цикл for res := range results. Проте для його завершення канал results обов'язково має бути закритий. Якщо закрити його зарано — воркери впадуть у паніку (send on closed channel), а якщо не закрити взагалі — споживач назавжди заблокується у дедлоку після зчитування останнього елемента. Тому окрема горутина очікує завершення всіх воркерів через wg.Wait() і лише тоді викликає close(results).
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontSize 12

queue "jobs (buffered chan)" as Jobs #DBEAFE
rectangle "Worker 1" as W1 #DCFCE7
rectangle "Worker 2" as W2 #DCFCE7
rectangle "Worker 3" as W3 #DCFCE7
queue "results (buffered chan)" as Res #FEF3C7

Jobs -down-> W1
Jobs -down-> W2
Jobs -down-> W3
W1 -down-> Res
W2 -down-> Res
W3 -down-> Res
@enduml

Оператор select: мультиплексування декількох каналів

Синтаксис:

select {
case v := <-ch1:
    // отримано значення з ch1
case ch2 <- x:
    // значення x надіслано в ch2
default:
    // жодна гілка не готова (робить select неблокуючим)
}

Простий приклад:

ch := make(chan string, 1)
ch <- "готово"

select {
case msg := <-ch:
    fmt.Println(msg) // готово
default:
    fmt.Println("немає повідомлень")
}

Часто горутина повинна одночасно очікувати на події з кількох різних каналів, реагуючи на те, звідки саме першим надійшло значення. Для цього мова Go надає спеціальну керуючу конструкцію select, синтаксично схожу на switch, але призначену виключно для роботи з операціями на каналах:

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch1 <- "відповідь від сервісу А"
    }()
    go func() {
        time.Sleep(50 * time.Millisecond)
        ch2 <- "відповідь від сервісу Б"
    }()

    select {
    case msg1 := <-ch1:
        fmt.Println("Отримано:", msg1)
    case msg2 := <-ch2:
        fmt.Println("Отримано:", msg2)
    }
}

Конструкція select блокує виконання горутини доти, доки хоча б одна з перелічених гілок case не стане готовою до виконання (тобто відповідний канал не отримає значення або не звільниться місце для запису). Якщо готовими одночасно виявляються кілька гілок, Go псевдовипадково обирає одну з них — це свідоме архітектурне рішення, яке запобігає систематичному «голодуванню» (starvation) якоїсь конкретної гілки через постійну перевагу першої за порядком написання.

Тайм-аути через time.After та неблокуючий select з default

Одне з найпрактичніших застосувань select у мережевому програмуванні — реалізація обмеження часу очікування (timeout) на операцію, тривалість якої непередбачувана (наприклад, очікування відповіді від повільного зовнішнього сервісу). Пакет time пропонує зручну функцію time.After(d), яка повертає канал, що автоматично отримає значення рівно через вказану тривалість d:

func fetchWithTimeout(resultCh <-chan string) {
    select {
    case result := <-resultCh:
        fmt.Println("Успішно отримано:", result)
    case <-time.After(2 * time.Second):
        fmt.Println("Тайм-аут: сервіс не відповів за 2 секунди")
    }
}

Якщо канал resultCh не отримає значення протягом двох секунд, канал, повернутий time.After, стане готовим першим, і виконання перейде в гілку тайм-ауту — незалежно від того, чи «висить» ще десь фонова горутина, що намагається записати результат у resultCh.

Обережно з time.After усередині довгих циклів: Виклик time.After(d) створює новий внутрішній об'єкт таймера, пам'ять під який звільняється лише після закінчення інтервалу d — навіть якщо select успішно спрацював по іншій гілці набагато раніше. Якщо помістити select із time.After у цикл із частими ітераціями, це призведе до тимчасового накопичення таймерів і витоку пам'яті (memory leak). Для циклів рекомендується створювати один таймер через time.NewTimer, очищати або зупиняти його через timer.Stop() та перевикористовувати за допомогою timer.Reset().

Іншим корисним прийомом є додавання гілки default до конструкції select, яка робить всю конструкцію неблокуючою: якщо жодна з інших гілок не готова негайно, виконується саме default, і select не чекає жодної мілісекунди:

select {
case msg := <-notifications:
    fmt.Println("Нове сповіщення:", msg)
default:
    fmt.Println("Сповіщень наразі немає, продовжуємо роботу")
}
А що, якщо в select немає жодної гілки, готової до виконання, і немає default? Горутина заблокується на конструкції select до появи хоча б однієї готової гілки. Якщо жоден з очікуваних каналів взагалі ніколи не отримає значення (і немає гілки time.After для підстраховки), горутина зависне назавжди — це ще один різновид витоку горутини, який слід враховувати під час проєктування довготривалих фонових обробників.

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

Пул воркерів зручний, коли є черга окремих завдань. Але якщо горутини запускаються незалежно (наприклад, на кожен вхідний HTTP-запит) і потрібно лише обмежити кількість одночасних звернень до зовнішнього ресурсу, буферизований канал можна використати як семафор (semaphore) — лічильник доступних слотів.

var sem = make(chan struct{}, 5) // не більше 5 одночасних викликів

func callExternalAPI(url string) error {
    sem <- struct{}{}        // "захопити" дозвіл (блокує, якщо зайнято всі 5)
    defer func() { <-sem }() // "звільнити" дозвіл після завершення роботи

    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    return nil
}

Тип каналу навмисно обрано chan struct{} — порожня структура struct{} не займає жодного байта корисних даних (лише слугує «жетоном» присутності), тому такий канал використовується виключно для контролю кількості одночасних дозволів, а не для передачі змістовної інформації. Коли буфер каналу sem заповнений (усі 5 дозволів роздано), шоста горутина, що намагається виконати sem <- struct{}{}, заблокується, доки якась із перших п'яти не звільнить свій дозвіл викликом <-sem.

Різниця з Worker Pool: у пулі воркерів кількість горутин-обробників фіксована заздалегідь, і вони самі забирають завдання з черги. У патерні-семафорі кількість горутин, навпаки, може бути будь-якою (і зазвичай непередбачуваною наперед) — семафор лише обмежує, скільки з них одночасно можуть перебувати всередині захищеної ділянки коду.

Конвеєр обробки: з'єднання горутин у ланцюжок стадій

Конвеєр (pipeline) — ще один поширений патерн конкурентності, коли дані проходять через послідовність незалежних стадій обробки, кожна з яких реалізована як окрема горутина, з'єднана з попередньою та наступною стадією через канали. Кожна стадія читає значення з вхідного каналу, виконує над ним якесь перетворення та надсилає результат у вихідний канал наступної стадії:

func generate(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}

func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

func main() {
    // Три стадії з'єднані в конвеєр: generate -> square -> вивід
    for result := range square(generate(1, 2, 3, 4)) {
        fmt.Println(result) // 1, 4, 9, 16
    }
}

Кожна функція-стадія (generate, square) одразу повертає канал для читання, а сама передача значень відбувається асинхронно у фоновій горутині, яку функція запускає перед виходом. Такий підхід чудово масштабується: щоб додати нову стадію обробки (наприклад, фільтрацію лише парних квадратів), достатньо написати ще одну функцію з такою самою сигнатурою func(in <-chan int) <-chan int і вставити її в ланцюжок виклику, не змінюючи жодної з уже наявних стадій.

Небезпека витоку горутин у конвеєрах при передчасному виході: Якщо кінцевий споживач перерве читання раніше (наприклад, через повернення помилки або команду break у циклі), горутини попередніх стадій назавжди зависнуть, намагаючись відправити чергове значення out <- n у небуферизований канал, з якого більше ніхто не читає. Це спричиняє витік горутин (goroutine leak). У виробничих конвеєрах для запобігання витокам обов'язково прокидають канал скасування (наприклад, done <-chan struct{} або ctx context.Context) і на кожній стадії перевіряють його через select.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontSize 12

rectangle "generate()" as G #DBEAFE
queue "chan int" as C1 #F1F5F9
rectangle "square()" as S #DCFCE7
queue "chan int" as C2 #F1F5F9
rectangle "main(): for range" as M #FEF3C7

G -right-> C1
C1 -right-> S
S -right-> C2
C2 -right-> M
@enduml

Пакет sync: класичні примітиви синхронізації

Хоча канали є основним інструментом взаємодії горутин у Go, для захисту спільного стану в пам'яті часто простіше й ефективніше скористатися примітивами синхронізації з пакета sync. Розглянемо чотири найуживаніші з них.

sync.Mutex: взаємне виключення

Синтаксис:

var mu sync.Mutex
mu.Lock()
defer mu.Unlock()

Простий приклад:

var mu sync.Mutex
counter := 0

mu.Lock()
counter++
mu.Unlock()
fmt.Println(counter) // 1

М'ютекс (Mutex, від mutual exclusion — «взаємне виключення») — це примітив, який гарантує, що в певний момент часу до захищеної ділянки коду (critical section) може увійти лише одна горутина. Повернімося до нашого прикладу з непослідовним лічильником і виправимо його за допомогою sync.Mutex:

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()         // захопити виключне право на доступ
    defer c.mu.Unlock() // гарантовано звільнити його при виході з функції
    c.value++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    counter := SafeCounter{}
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }

    wg.Wait()
    fmt.Println("Підсумкове значення:", counter.Value()) // завжди рівно 1000
}

Виклик c.mu.Lock() блокує м'ютекс: якщо інша горутина вже утримує його, поточна чекає на звільнення через Unlock(). У Go рекомендується завжди звільняти м'ютекс через defer c.mu.Unlock() одразу після блокування — це гарантує виклик навіть при виникненні паніки або достроковому виході через return.

Найнебезпечніша помилка з м'ютексами — забутий Unlock(). Якщо горутина захопила м'ютекс і з якоїсь причини (помилка логіки, необроблена паніка) ніколи не викликала Unlock(), усі інші горутини, що намагаються захопити цей самий м'ютекс, заблокуються назавжди — це класичний дедлок (deadlock). Саме тому конструкція defer mu.Unlock() одразу після mu.Lock() вважається обов'язковим стандартом якісного коду.

sync.RWMutex: розділення читачів і письменників

Синтаксис:

var mu sync.RWMutex
mu.RLock()   // блокування для читання, дозволяє кількох читачів одночасно
defer mu.RUnlock()

mu.Lock()    // виключне блокування для запису
defer mu.Unlock()

Простий приклад:

var mu sync.RWMutex
data := map[string]int{"a": 1}

mu.RLock()
fmt.Println(data["a"]) // 1
mu.RUnlock()

Коли спільні дані значно частіше читають, ніж змінюють (наприклад, кеш конфігурації), звичайний sync.Mutex створює зайві затримки, блокуючи навіть паралельне читання. Для таких задач призначений sync.RWMutex: він дозволяє паралельно читати дані довільній кількості горутин, але блокує доступ повністю під час запису.

type ConfigStore struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *ConfigStore) Get(key string) string {
    c.mu.RLock()         // блокування для читання — дозволяє паралельних читачів
    defer c.mu.RUnlock()
    return c.data[key]
}

func (c *ConfigStore) Set(key, value string) {
    c.mu.Lock()         // виключне блокування для запису
    defer c.mu.Unlock()
    c.data[key] = value
}

sync.WaitGroup: очікування завершення групи горутин

Синтаксис:

var wg sync.WaitGroup
wg.Add(1)           // +1 до лічильника перед запуском горутини
go func() {
    defer wg.Done() // -1 до лічильника після завершення
}()
wg.Wait()            // блокується, доки лічильник не стане нулем

Простий приклад:

var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    fmt.Println("готово")
}()
wg.Wait()

Раніше в навчальних прикладах ми застосовували time.Sleep лише для простоти демонстрації. У робочому коді так робити не можна: надійним інструментом для очікування групи горутин є sync.WaitGroup. Вона працює як лічильник: Add(n) додає кількість горутин, Done() зменшує лічильник на одиницю при завершенні, а Wait() блокує виконання, поки лічильник не обнулиться.

func fetchURL(url string, wg *sync.WaitGroup) {
    defer wg.Done() // гарантовано зменшить лічильник, навіть при паніці
    fmt.Println("Завантаження:", url)
    time.Sleep(100 * time.Millisecond)
}

func main() {
    urls := []string{"a.dev", "b.dev", "c.dev"}
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)                // повідомляємо: з'явилась ще одна горутина для очікування
        go fetchURL(url, &wg)
    }

    wg.Wait() // блокується, поки всі три горутини не викличуть Done()
    fmt.Println("Усі URL оброблено")
}
Порядок Add() критично важливий. Виклик wg.Add(1) необхідно робити в тій самій горутині, що запускає нову горутину, до ключового слова go, а не всередині самої нової горутини. Якщо перенести Add(1) усередину функції fetchURL, головна горутина може встигнути викликати wg.Wait() ще до того, як лічильник взагалі збільшиться, — і Wait() поверне керування миттєво, не дочекавшись жодної фонової горутини.

sync.Once: гарантоване одноразове виконання

Синтаксис:

var once sync.Once
once.Do(func() {
    // виконається рівно один раз, незалежно від кількості викликів Do
})

Простий приклад:

var once sync.Once
for i := 0; i < 3; i++ {
    once.Do(func() { fmt.Println("ініціалізація") })
}
// "ініціалізація" надрукується лише один раз

Примітив sync.Once гарантує виконання функції рівно один раз, скільки б горутин не викликали її одночасно. Це зручно для лінивої ініціалізації спільних ресурсів (singleton) — наприклад, підключення до бази даних:

var (
    dbConn *sql.DB
    once   sync.Once
)

func getConnection() *sql.DB {
    once.Do(func() {
        fmt.Println("Встановлюємо з'єднання з базою даних...")
        dbConn, _ = sql.Open("postgres", "connection-string")
    })
    return dbConn
}

Навіть якщо сотні горутин одночасно викличуть getConnection(), тіло функції всередині once.Do(...) фактично виконається лише один-єдиний раз — усі інші виклики Do просто негайно повернуть керування, дочекавшись (якщо потрібно) завершення першого виклику.

sync.Map: конкурентна мапа для специфічних сценаріїв

Синтаксис:

var m sync.Map
m.Store(key, value)         // запис
value, ok := m.Load(key)    // читання
m.Delete(key)                // видалення
m.Range(func(key, value any) bool {
    return true // false — достроково перервати перебір
})

Простий приклад:

var m sync.Map
m.Store("x", 1)
v, _ := m.Load("x")
fmt.Println(v) // 1

Як ми вже згадували в попередній лекції, вбудована мапа Go (map[K]V) не є потокобезпечною: конкурентний запис і читання з різних горутин без синхронізації призводять до фатальної помилки fatal error: concurrent map read and map write. Типове рішення — обгорнути звичайну мапу в sync.RWMutex, як ми показали раніше в цій лекції. Проте стандартна бібліотека пропонує й готову альтернативу — sync.Map, спеціалізовану потокобезпечну мапу, яка не потребує зовнішнього м'ютекса:

var cache sync.Map

cache.Store("user:42", "Олексій") // запис значення за ключем

if value, ok := cache.Load("user:42"); ok { // безпечне конкурентне читання
    fmt.Println(value)
}

cache.Range(func(key, value any) bool {
    fmt.Println(key, "=", value)
    return true // повернути false, щоб достроково перервати перебір
})

cache.Delete("user:42")
sync.Map — це не універсальна заміна map + RWMutex. Офіційна документація прямо вказує, що sync.Map оптимізована лише для двох специфічних сценаріїв: (1) коли значення за конкретним ключем записується один раз, а читається багато разів (кеш, що лише зростає), або (2) коли різні горутини стабільно працюють із непересічними наборами ключів. У типовому сценарії зі змішаним інтенсивним читанням і записом одних і тих самих ключів звичайна мапа під захистом sync.RWMutex зазвичай працює швидше й передбачуваніше — тому sync.Map варто застосовувати свідомо, а не автоматично «про всяк випадок».

sync/atomic: атомарні операції без явних блокувань

Синтаксис:

var counter atomic.Int64
counter.Add(1)     // атомарне збільшення
counter.Load()      // атомарне читання
counter.Store(0)    // атомарний запис

Простий приклад:

var counter atomic.Int64
counter.Add(1)
fmt.Println(counter.Load()) // 1

Для найпростішого сценарію — конкурентного оновлення одного числового лічильника — навіть виклик Lock()/Unlock() можна вважати надлишковим накладним видатком. Пакет sync/atomic надає набір атомарних операцій (atomic operations), які виконують читання-модифікацію-запис як єдину неподільну операцію на рівні процесора, без потреби в окремому м'ютексі:

type SafeCounter struct {
    value atomic.Int64 // атомарний тип, з'явився у Go 1.19
}

func (c *SafeCounter) Increment() {
    c.value.Add(1) // атомарне збільшення, без Lock/Unlock
}

func (c *SafeCounter) Value() int64 {
    return c.value.Load() // атомарне читання
}

Типи atomic.Int64, atomic.Int32, atomic.Bool та atomic.Value надають зручні методи для атомарних операцій без виклику застарілих функцій з префіксами. Атомарні операції працюють швидше за м'ютекси, оскільки виконуються безпосередньо інструкціями процесора і не блокують горутину в планувальнику.

Коли обирати atomic, а коли Mutex: атомарні операції доцільні лише для одного ізольованого числового значення чи прапорця (лічильники метрик, флаги стану). Якщо потрібно узгоджено оновити кілька пов'язаних полів одночасно (наприклад, і баланс рахунку, і дату останньої транзакції), атомарні операції не підійдуть, оскільки кожна з них атомарна лише сама по собі, а не в сукупності — у такому разі необхідний повноцінний sync.Mutex, що захищає всю групу полів як єдине ціле.

Пакет context: керування життєвим циклом та скасуванням операцій

Синтаксис:

ctx := context.Background()
ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithTimeout(parent, тривалість)
ctx, cancel := context.WithDeadline(parent, момент)
ctx := context.WithValue(parent, key, value)
defer cancel() // обов'язково для WithCancel/WithTimeout/WithDeadline

Простий приклад:

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

<-ctx.Done()
fmt.Println(ctx.Err()) // context deadline exceeded

Останнім елементом моделі конкурентності Go, критично важливим саме для клієнт-серверних систем, є пакет context. Уявіть типову ситуацію: клієнт надіслав HTTP-запит, обробка якого запустила ланцюжок з кількох вкладених операцій — звернення до бази даних, виклик зовнішнього платіжного сервісу, запис у файл логів. Що має статися, якщо клієнт раптово розірве з'єднання (закриє вкладку браузера) ще до завершення обробки? Без спеціального механізму сервер продовжив би марно виконувати всі ці операції, витрачаючи ресурси на роботу, результат якої вже нікому не потрібен.

context.Context — це інтерфейс стандартної бібліотеки, який передає сигнали скасування, тайм-аути та службові дані крізь ланцюжок викликів. За правилами розробки на Go контекст завжди передають першим аргументом у функціях із мережевими або тривалими операціями: func FetchUser(ctx context.Context, id int) (*User, error).

Створення дерева контекстів

Контексти в Go організовані у вигляді дерева: кожен новий контекст створюється на основі батьківського контексту та автоматично успадковує його властивості скасування. Найпоширеніші функції створення контексту:

ctx := context.Background() // "кореневий" контекст, завжди без скасування чи дедлайну

// Контекст, який можна скасувати вручну через виклик cancel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // ЗАВЖДИ викликайте cancel(), щоб уникнути витоку ресурсів

// Контекст, що автоматично скасовується через задану тривалість
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

// Контекст, що скасовується у конкретний момент часу
deadline := time.Now().Add(5 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

Кожна з функцій WithCancel, WithTimeout, WithDeadline повертає дочірній контекст та функцію cancel, яка скасовує цей контекст і всі вкладені в нього. Функцію cancel() потрібно викликати завжди, навіть при успішному завершенні операції, щоб не витрачати ресурси на таймери чи системні структури. Тому одразу після створення контексту прийнято додавати defer cancel().

Канал Done() та поширення сигналу скасування

Кожен контекст надає метод Done(), який повертає канал, що закривається в момент скасування контексту (незалежно від причини — явний виклик cancel(), вичерпаний тайм-аут чи скасування батьківського контексту). Оскільки закритий канал завжди готовий до читання, конструкція select дозволяє горутині одночасно виконувати корисну роботу та реагувати на скасування:

func longRunningTask(ctx context.Context, resultCh chan<- int) {
    select {
    case <-time.After(5 * time.Second): // імітація тривалої роботи
        resultCh <- 42
    case <-ctx.Done():
        fmt.Println("Операцію скасовано:", ctx.Err())
        return
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    resultCh := make(chan int)
    go longRunningTask(ctx, resultCh)

    select {
    case res := <-resultCh:
        fmt.Println("Результат:", res)
    case <-ctx.Done():
        fmt.Println("Головна горутина: час вичерпано,", ctx.Err())
    }
}

У цьому прикладі контекст із тайм-аутом у дві секунди скасовується раніше, ніж завершується штучна п'ятисекундна операція всередині longRunningTask, тож і фонова горутина, і головна горутина синхронно дізнаються про скасування через свої власні виклики ctx.Done(), після чого метод ctx.Err() поверне конкретну причину — context.DeadlineExceeded для тайм-ауту або context.Canceled для явного виклику cancel().

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF
skinparam defaultFontSize 12

rectangle "context.Background()\n(кореневий контекст)" as Root #F1F5F9
rectangle "ctx1 = WithTimeout(Root, 3с)\n(HTTP-обробник запиту)" as Ctx1 #DBEAFE
rectangle "ctx2 = WithCancel(ctx1)\n(запит до бази даних)" as Ctx2 #DCFCE7
rectangle "ctx3 = WithCancel(ctx1)\n(виклик зовнішнього API)" as Ctx3 #FEF3C7

Root -down-> Ctx1
Ctx1 -down-> Ctx2
Ctx1 -down-> Ctx3

note bottom of Ctx1 #FEE2E2
  Якщо спрацював тайм-аут ctx1
  (або клієнт розірвав з'єднання),
  СКАСУВАННЯ АВТОМАТИЧНО
  поширюється на ctx2 та ctx3.
end note
@enduml

Передача службових значень через WithValue

Останній, найменш вживаний спосіб створення дочірнього контексту — context.WithValue(parent, key, value), який дозволяє прикріпити до контексту довільне службове значення (наприклад, ідентифікатор запиту для наскрізного трасування логів чи автентифікованого користувача, отриманого з токена):

type contextKey string

const requestIDKey contextKey = "requestID"

ctx := context.WithValue(context.Background(), requestIDKey, "req-8f21ac")

requestID, ok := ctx.Value(requestIDKey).(string) // приведення типу через Type Assertion
if ok {
    fmt.Println("Обробка запиту:", requestID)
}
context.WithValue — це не заміна параметрів функції. Офіційна документація Go прямо застерігає: використовуйте WithValue виключно для службових, наскрізних даних запиту (ідентифікатор трасування, дані автентифікації), а не для передачі звичайних бізнес-параметрів функції (на кшталт ID замовлення чи суми платежу) — такі значення варто передавати явними типізованими аргументами, щоб компілятор міг перевірити їхню коректність, на відміну від нетипізованого ctx.Value(key).(T), що вимагає ручного приведення типу з ризиком помилки.

context на практиці: скасування роботи в HTTP-обробнику

Синтаксис:

ctx := r.Context() // контекст, повʼязаний із життєвим циклом HTTP-запиту

Найпоширеніше практичне застосування пакета context у клієнт-серверних системах — автоматичне скасування обробки запиту в момент, коли клієнт розриває з'єднання, не дочекавшись відповіді. Пакет стандартної бібліотеки net/http, який ми детально розглянемо вже в наступній лекції, автоматично створює контекст для кожного вхідного запиту та скасовує його, щойно базове TCP-з'єднання з клієнтом обривається:

func slowHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context() // контекст, прив'язаний до життєвого циклу цього запиту

    select {
    case result := <-computeExpensiveReport(ctx):
        fmt.Fprintln(w, result)
    case <-ctx.Done():
        log.Println("Клієнт розірвав з'єднання, обчислення скасовано:", ctx.Err())
        return
    }
}

Завдяки цьому серверу не потрібно самостійно відстежувати, чи клієнт усе ще «на зв'язку» — достатньо передавати ctx углиб усього ланцюжка викликів (у запит до бази даних, у виклик зовнішнього сервісу) і скрізь перевіряти ctx.Done() через select, щоб вчасно припинити марну роботу та звільнити ресурси сервера для інших клієнтів.

Практичні інструменти діагностики конкурентного коду: функція runtime.NumGoroutine() повертає поточну кількість активних горутин у процесі — корисно викликати її до і після модульного тесту, щоб переконатися, що тест не залишив після себе «висячих» горутин (класична ознака витоку). Для глибшого аналізу — які саме горутини активні та на чому вони заблоковані — використовують дамп горутин через пакет net/http/pprof, детальний розгляд якого винесено в окрему тему самостійної роботи «Профілювання серверних застосунків Go за допомогою утиліти pprof».

Питання для самоперевірки: конкурентність


Підсумки лекції

У цій лекції ми розглянули інтерфейси та конкурентність у Go. Вони відображають ключові принципи мови: явність замість неявних дій та композиція замість жорсткої ієрархії. Інтерфейси дозволяють визначати спільні контракти поведінки без класичного наслідування, а горутини й канали дають змогу організувати паралельну та конкурентну обробку завдань без ручного керування системними потоками.

🧩 Інтерфейси

  • Інтерфейс — контракт поведінки без даних, реалізація завжди неявна (структурна типізація, качина типізація).
  • Порожній інтерфейс any приймає будь-який тип, але коштує втрати статичної типізації та ризику додаткового навантаження на збирач сміття.
  • Безпечне приведення типу — завжди через форму value, ok := x.(T), а не однопараметрову форму, що панікує.
  • Типізований nil усередині інтерфейсного значення — джерело прихованих помилок err != nil; завжди повертайте error, а не конкретний тип покажчика.

⚙️ Конкурентність

  • Go реалізує модель CSP: горутини спілкуються через канали, а не через спільну пам'ять із блокуваннями.
  • Горутини легковагі (стек від 2 КБ) та плануються моделлю GMP повністю в користувацькому просторі.
  • Стан гонитви — непередбачувана помилка одночасного доступу до пам'яті; виявляється прапорцем -race.
  • Канали, select, sync.Mutex/RWMutex/WaitGroup/Once, sync/atomic та context — набір інструментів для безпечної координації горутин.

Матеріал цієї лекції утворює безпосередній фундамент для наступної теми курсу — сокетного програмування та протоколу TCP, де кожне вхідне з'єднання клієнта буде обслуговуватися саме за допомогою окремої горутини, а канали та context стануть основним інструментом координації між горутиною читання, горутиною запису та механізмом плавного завершення роботи сервера (graceful shutdown).

Система типів та модель пам’яті мови Go

Повний курс системи типів та обробки помилок Go: змінні, тіньове перекриття, числові типи та wrap-around, константи iota, форматування fmt, операції, керуючі конструкції, функції, замикання, покажчики, make vs new, стек/купа та Escape Analysis, структури з вирівнюванням, композиція, методи, зрізи, мапи hmap/bmap, обробка помилок error (errors.Is, errors.As, errors.Join), defer LIFO, паніка panic та відновлення recover.

Сокетне програмування та протокол TCP у Go

Сокетне програмування мовою Go: сокети Берклі (Berkeley Sockets API) як файлові дескриптори, спостереження TCP-сегментів через tcpdump і netcat, життєвий цикл TCP-сесії (3-Way Handshake, 4-Way Close, стан TIME_WAIT), примітиви net.Dial і net.Listen, архітектура «горутина на з'єднання» з runtime netpoller, потокове введення/виведення з io та bufio, три методи фреймінгу прикладних протоколів (розділювачі, префікс довжини, TLV), транслятор повідомлень на базі Hub (CSP) та плавне завершення (Graceful Shutdown).

Copyright © 2026