JavaRush /Курси /Go SELF /Конкурентність і паралелізм: що робить рантайм Go

Конкурентність і паралелізм: що робить рантайм Go

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

1. Навіщо нам конкурентність

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

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

Конкурентність і паралелізм: у чому різниця

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

Стисло зафіксуймо визначення.

Термін Просте визначення Що ви зазвичай отримуєте
Конкурентність (concurrency) Кілька логічних задач просуваються почергово в одному проміжку часу Зручну структуру: очікування I/O, фонові дії, обробку кількох запитів
Паралелізм (parallelism) Реальне одночасне виконання на кількох ядрах CPU Потенційне прискорення обчислень, але не гарантоване

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

2. Рантайм Go і планувальник горутин

Процес, потоки ОС і «магія» горутин

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

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

Рантайм Go — це частина платформи, яка працює разом із вашим кодом і відповідає за кілька великих речей. Він планує горутини, займається збиранням сміття (GC), допомагає з мережевими очікуваннями й загалом робить так, щоб ваш код виглядав простим, а працював швидко. Історично поліпшення продуктивності Go торкалися й планувальника горутин; наприклад, у нотатках до релізу Go 1.1 окремо згадували оптимізації планувальника, а також появу race detector як важливого інструмента для конкурентного коду.

Спрощена модель G / M / P

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

У Go часто використовують спрощену модель із трьох літер:

Літера Як розшифрувати Інтуїтивний сенс
G goroutine «задача», яка хоче виконуватися
M machine (потік) потік ОС, який реально виконує інструкції
P processor (контекст планувальника) «дозвіл» виконувати Go-код; посередник між G і M

Якщо зовсім по-людськи, то горутини (G) — це черга задач. Потоки ОС (M) — реальні робочі руки. А P — це обмежувач і диспетчер: скільки «робочих місць» для виконання Go-коду зараз активно.

Схематично це можна уявити так:

flowchart LR
    G1[Горутина G] --> P1[P: планування]
    G2[Горутина G] --> P1
    P1 --> M1[M: потік ОС]
    P2[P: планування] --> M2[M: потік ОС]

Про GOMAXPROCS

Є параметр GOMAXPROCS, який визначає, скільки «P» рантайм використовує для виконання Go-коду. У сучасних версіях Go значення за замовчуванням зазвичай відповідає кількості доступних CPU (ядер/логічних процесорів), і це часто означає: паралелізм можливий, але все одно не гарантований «за вашим бажанням».

Ви можете просто подивитися поточне значення так:

package main

import (
	"fmt"
	"runtime"
)

func main() {
	fmt.Println(runtime.GOMAXPROCS(0)) // наприклад: 8
}

Тут runtime.GOMAXPROCS(0) повертає поточне значення, не змінюючи його. Зараз для нас важливе не налаштування, а сама думка: рантайм вирішує, скільки Go-коду може виконуватися одночасно.

4. Недетермінованість і життєвий цикл програми

Чому порядок виконання — не контракт

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

Спочатку — контрольний приклад: звичайний послідовний код. Тут порядок абсолютно передбачуваний:

package main

import "fmt"

func main() {
	fmt.Println("A") // A
	fmt.Println("B") // B
}

Тепер мінімально увімкнемо конкурентність — так, просто одним словом go. І раптом ваш внутрішній «провісник майбутнього» починає помилятися:

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("A")
	go fmt.Println("B")

	time.Sleep(10 * time.Millisecond) // милиця для демонстрації
}

Що тут важливо зрозуміти — і це справді жирний маркер: порядок «A/B» не гарантований. Іноді буде A, потім B, іноді B, потім A, іноді вам узагалі здаватиметься, що комп’ютер знущається. Насправді він просто чесно працює.

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

main закінчився — процес закінчився

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

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

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		time.Sleep(50 * time.Millisecond)
		fmt.Println("горутина завершилася") // може не надрукуватися
	}()

	fmt.Println("main завершено") // main завершено
}

Тут main друкує "main завершено" і виходить. Після цього процес цілком може завершитися раніше, ніж горутина прокинеться після Sleep. Іноді, залежно від планувальника й швидкості виконання, вам пощастить і ви побачите обидва рядки, але це буде саме «пощастить», а не контракт.

Це правило особливо важливе для реальних застосунків (CLI, серверів, воркерів): якщо ви запускаєте фонову роботу, ви маєте явно продумати, хто і як дочекається її завершення.

5. Продуктивність і очікування

Чому конкурентність не зобов’язана прискорювати

Дуже людська думка така: «Якщо я зроблю паралельно, буде швидше». Іноді так. Іноді ні. Іноді стане навіть повільніше — і це теж нормально.

Конкурентність дає виграш особливо часто в I/O-сценаріях: мережа, диски, очікування таймерів, запити. Там ви не впираєтеся в CPU постійно, і можливість «перемкнутися на іншу справу» реально підвищує загальну пропускну здатність.

Але для CPU-bound задач (коли ви просто рахуєте) прискорення залежить від кількості ядер, від накладних витрат на планування, від роботи GC, від того, чи не почали горутини завзято боротися за один і той самий ресурс. Тобто конкурентність — це насамперед про організацію, а не про «турборежим».

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

Чому Sleep — не синхронізація

Зараз буде момент, який дуже люблять новачки, бо він «працює». А потім його починають ненавидіти, бо на іншому комп’ютері не працює. Йдеться про спробу синхронізуватися через time.Sleep.

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

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		fmt.Println("роботу завершено") // роботу завершено
	}()

	time.Sleep(1 * time.Millisecond) // "сподіваємося, що вистачить"
}

Проблема в тому, що це не протокол очікування, а ставка в казино. На вашій машині цього може вистачити, на CI — ні, на ноутбуці в режимі енергоощадження — інколи, а в момент, коли поруч відкрився браузер із 40 вкладками, — узагалі сумно.

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

6. Де конкурентність з’являється в реальних застосунках

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

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

Сьогоднішня лекція потрібна вам саме як ментальна база: коли ви пізніше побачите код, який запускає роботу окремо й чекає групу задач, ви не сприйматимете це як магічний ритуал. Ви розумітимете: рантайм планує виконання, порядок не гарантований, а коректність вимагає явних домовленостей.

7. Типові помилки й чому вони трапляються

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

Помилка № 2: вважати порядок виконання (або друку) фіксованим.
Щойно з’являється конкурентність, порядок подій стає недетермінованим. Якщо ви пишете код, який логічно залежить від того, що спочатку надрукується "A", потім "B", то ви будуєте дім на піску. Іноді він стоїть, іноді ні — і найнеприємніше, що ламається «рідко», тобто в найбільш незручний момент.

Помилка № 3: використовувати time.Sleep як механізм очікування завершення.
Sleep — це пауза, а не домовленість. Вона не каже рантайму «дочекайся горутини», вона каже вашому потоку «полеж». На швидких машинах ви «випадково» потрапляєте в потрібний інтервал, на повільних — ні. Надійні програми так не будують: вони використовують явні протоколи очікування завершення роботи.

Помилка № 4: думати, що горутини утримають програму живою.
Це часта психологічна пастка: раз горутина щось робить, значить програма має почекати. Але Go влаштований інакше: завершення main завершує процес цілком, і незавершені горутини припиняються. Це не жорстокість мови — це просте й корисне правило життєвого циклу.

Помилка № 5: намагатися вгадувати поведінку планувальника.
Іноді хочеться думати: «ну він же спочатку запустить ось цю горутину, бо я створив її першою». Планувальник нікому нічого не обіцяв. Ба більше, реальна поведінка залежить від версії рантайму, навантаження, числа ядер, таймінгів, GC і безлічі дрібниць. Єдина стабільна стратегія — не будувати коректність на припущеннях про порядок і час виконання без явного узгодження.

1
Задача
Go SELF, 65 рівень, 0 лекція
Недоступна
Два терміни
Два терміни
1
Задача
Go SELF, 65 рівень, 0 лекція
Недоступна
Ліміт процесорів
Ліміт процесорів
1
Задача
Go SELF, 65 рівень, 0 лекція
Недоступна
Режим планувальника
Режим планувальника
1
Задача
Go SELF, 65 рівень, 0 лекція
Недоступна
Модель прискорення
Модель прискорення
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ