Принцип на работа на Go горутините (Goroutine): 2KB променлив стек и M:N планировчик GMP

Goroutine
Когато молим Gopher-ите да разкажат за предимствата на golang, често се появява статия, посветена на конкурентността (Concurrency). Нейната основа е goroutine, която може да се обработва леко и просто. Тук е представено кратко описание на това понятие.
Конкурентност (Concurrency) срещу паралелизъм (Parallelism)
Преди да разгледаме goroutine, искаме да обясним две понятия, които често се объркват.
- Конкурентност: Конкурентността се отнася до обработката на много задачи едновременно. Това не означава непременно, че те действително се изпълняват едновременно, а структурна и логическа концепция, при която множество задачи се разделят на малки части и се изпълняват на редуване, така че за потребителя да изглежда, че няколко задачи се обработват едновременно. Конкурентността е възможна и върху едно ядро.
- Паралелизъм: Паралелизмът е „обработка на множество задачи едновременно на множество ядра“. Буквално, работата се извършва паралелно, като други задачи се изпълняват едновременно.
Goroutine улеснява реализацията на конкурентност чрез планировчика на Go runtime, а чрез настройката GOMAXPROCS използва и паралелизъм на естествен начин.
Многонишковостта (Multi thread) в Java, която се използва често, е типичен пример за понятието паралелизъм.
Защо goroutine са добри?
Леки са (lightweight)
Разходите за създаване са значително по-ниски в сравнение с други езици. Възниква въпросът защо golang използва толкова малко ресурси? Причината е, че местата за създаване се управляват вътре в Go runtime. Това е така, защото goroutine е лека логическа нишка, по-малка от единица OS нишка, която започва със стек от около 2KB и според реализацията може динамично да се разширява чрез добавяне на стек.
Тъй като се управляват на ниво стек, създаването и премахването им е много бързо и евтино, поради което е възможно работата с милиони goroutine да не натоварва системата. В резултат на това Goroutine, благодарение на планировчика на runtime, може да минимизира намесата на OS kernel.
Добра производителност (performance)
Първо, Goroutine, както беше описано по-горе, има малка намеса от страна на OS kernel, поради което превключването на контекста на потребителско ниво (User-Level) е по-евтино в сравнение с единица OS нишка и задачите могат да се сменят бързо.
Освен това се използва модел M:N, при който goroutine се разпределят върху OS нишки и се управляват. Създава се пул от OS нишки, така че без да са необходими много нишки, обработката е възможна с малко нишки. Например при състояние на изчакване, като системно извикване, Go runtime изпълнява друга goroutine върху същата OS нишка, така че OS нишката не спира и ефективно използва CPU, което позволява бърза обработка.
Поради това Golang може да постигне висока производителност в сравнение с други езици, особено при I/O операции.
Компактност (concise)
Също така голямо предимство е, че когато е необходима конкурентност, една ключова дума go позволява лесно да се изпълни функция.
Трябва да се използват сложни механизми за заключване, като Mutex и Semaphore, а при използване на заключвания неизбежно трябва да се вземе предвид състоянието на взаимно блокиране (DeadLock), което прави етапа на проектиране преди разработката по-сложен.
Goroutine следва философията „не споделяй памет за комуникация, а комуникирай, за да споделяш памет“ и препоръчва предаване на данни чрез канал (Channel). SELECT се комбинира с канали и поддържа функционалност, която позволява обработка от канала, в който данните са готови. Освен това, с помощта на sync.WaitGroup може лесно да се изчака завършването на всички goroutine, което улеснява управлението на работния процес. Благодарение на тези инструменти се предотвратяват състоянията на състезание за данни между нишки и конкурентната обработка е по-безопасна.
Също така, чрез context се управляват на потребителско ниво (User-Level) жизненият цикъл, отмяната, таймаутът, крайният срок и обхватът на заявката, което позволява до известна степен да се гарантира стабилност.
Паралелна работа на Goroutine (GOMAXPROCS)
Може да възникне въпросът: след като говорихме за добрата конкурентност на goroutine, не поддържа ли тя и паралелизъм? Броят на ядрата на съвременните процесори вече е двуцифрен, а дори домашните компютри често имат значителен брой ядра.
Goroutine обаче извършва и паралелна работа, а това е GOMAXPROCS.
Ако GOMAXPROCS не е зададен, стойността се определя различно в зависимост от версията.
До 1.5: стойност по подразбиране 1; за повече от 1 е необходимо задаване по начин като
runtime.GOMAXPOCS(runtime.NumCPU())От 1.5 до 1.24: стойността е променена на броя на всички налични логически ядра. От този момент, освен при специфични ограничения, разработчикът обикновено не е необходимо да я задава
1.25: като известен език за контейнерна среда, той проверява cGroup в linux, за да установи настроеното в контейнера
CPU ограничение.Тогава, ако броят на логическите ядра е 10, а CPU ограничението е 5,
GOMAXPROCSсе задава на по-малкото число 5.
Промяната в 1.25 има много голямо значение. Причината е, че се повишава използваемостта на езика в контейнерна среда. Това позволява да се намалят ненужното създаване на нишки и превключването на контекста, като по този начин се предотвратява CPU троттлинг (throttling).
1package main
2
3import (
4 "fmt"
5 "math/rand"
6 "runtime"
7 "time"
8)
9
10func exe(name int, wg *sync.WaitGroup) {
11 defer wg.Done()
12
13 fmt.Printf("Goroutine %d: 시작\n", name)
14 time.Sleep(10 * time.Millisecond) // 작업 시뮬레이션을 위한 지연
15 fmt.Printf("Goroutine %d: 시작\n", name)
16}
17
18func main() {
19 runtime.GOMAXPROCS(2) // CPU 코어 2개만 사용
20 wg := sync.WaitGroup();
21 goroutineCount := 10
22 wg.Add(goroutineCount)
23
24 for i := 0; i < goroutineCount; i++ {
25 go exe(i, &wg)
26 }
27
28 fmt.Println("모든 goroutine이 끝날 때까지 대기합니다...")
29 wg.Wait()
30 fmt.Println("모든 작업이 완료되었습니다.")
31
32}
33
Планировчик на Goroutine (модел M:N)
Във връзка с по-горе споменатото разпределение върху OS нишки чрез модела M:N, по-подробно съществува GMP моделът на goroutine.
- G (Goroutine): Най-малката единица работа, която се изпълнява в Go
- M (Machine): OS нишка (реалното място на изпълнение)
- P (Processor): Логически процес, управляван от Go runtime
Това са съставните части. P допълнително притежава локална опашка за изпълнение (Local Run Queue) и изпълнява ролята на планировчик, който назначава присвоените G на M. Накратко, goroutine работи по следния начин:
Работният процес на GMP е както следва:
- Когато се създаде G (Goroutine), то се поставя в локалната опашка за изпълнение на P (Processor).
- P (Processor) назначава G (Goroutine) от локалната опашка за изпълнение към M (Machine).
- M (Machine) връща състоянието на G (Goroutine): block, complete или preempted.
- Work-Stealing (кражба на работа): Ако локалната опашка за изпълнение на P се изпразни, другият P първо проверява глобалната опашка. Ако и там няма G (Goroutine), той краде работа от локалната опашка на друг P (Processor), така че всички M да работят без прекъсване.
- Обработка на системни извиквания (Blocking): Когато по време на изпълнение на G (Goroutine) възникне блокиране, M (Machine) влиза в състояние на изчакване. В този момент P (Processor) се отделя от блокирания M (Machine), свързва се с друг M (Machine) и изпълнява следващия G (Goroutine). По този начин при изчакване по време на I/O операции не се губи CPU време.
- Ако една G (Goroutine) заема процесора прекомерно дълго (preempted), на други G (Goroutine) се дава възможност за изпълнение.
Golang изпълнява и GC (Garbage Collector) върху Goroutine, като почиства паметта паралелно, докато прекъсването на изпълнението на приложението (STW) се свежда до минимум, което позволява ефективно използване на системните ресурси.
И накрая, Golang е един от силните аспекти на езика и има още много други, затова се надявам много разработчици да се насладят на Go.
Благодаря ви.