Съдържание на курса
Урок 6 — Конкурентност
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Продължителност: 2 сесии по 60-90 минути. Това е причината Go да съществува и единственият урок в курса,
който се преподава на няколко минавания: goroutines, после канали и WaitGroup, после context и ограничението на паралелизма,
приложено към revisor. С едно минаване не се получава — потвърждава го същият ред, който използва курсът по Go с
най-много учещи в света.
Като приключиш, ще можеш:
- Да пуснеш goroutine и да обясниш, с истинска проба, защо програмата може да приключи, преди тя да е приключила.
- Да използваш
sync.WaitGroup, за да изчакаш група goroutines, и да разпознаеш точното съобщение, което Go дава, когатоAdd/Doneне съвпадат. - Да обясниш защо
revisorразпределя резултатите по канал, вместо да пише в споделен map, и сам да предизвикаш грешката, която би се случила, ако не го правеше. - Да четеш, дословно, изхода на
go test -race, когато открие истинско състезание за данни (data race). - Да използваш
context.WithTimeout, за да сложиш времево ограничение на една операция, и да обясниш защоdefer cancelar()не е по избор. - Да ограничиш колко goroutines се изпълняват едновременно със семафор от канал и да измериш, че ограничението наистина се спазва.
Защо е важно
До урок 5 revisor проверява услугите една по една: ако имаш десет услуги и всяка
отговаря за една секунда, целият отчет отнема десет секунди, какъвто и да е редът. Със
сто услуги — почти две минути. А времето за чакане не го решаваш ти: решава го най-бавната услуга
в списъка, умножено по броя им.
Не е задължително да е така, защото проверката на услуга е чакане, а не работа: почти през цялото
време на това чакане процесорът не прави нищо, само чака мрежов отговор. Go е проектиран
от хора, които в Google прекарваха дните си в чакане точно на това — мрежови отговори между хиляди
услуги — и затова конкурентността не е библиотека, добавена по-късно: тя е част от езика
още от първия ред (go, запазена дума, а не функция от библиотека).
Резултатът, който ще изградиш в този урок: същите десет услуги по една секунда всяка, проверени всички едновременно, за малко повече от една секунда общо вместо десет. Това число — разликата между "последователно" и "паралелно" — е наградата на урока и ще го измериш сам, а не ще го повярваш от чуто.
🔑 И предупреждението, което прави този урок различен от предишните: в уроци 2 до 5 една програма, която се компилира и чиито тестове минават, почти винаги е наред. В конкурентността — не. Програма със състезание за данни може да се компилира, да се изпълнява, да минава тестовете си деветдесет и девет пъти и да се провали на стотния — или никога да не се провали на твоята машина и да се проваля всеки ден в продукция, с повече ядра и повече натоварване. Грешките в този урок са онези, които не се виждат с просто око, и затова точка 4 от този урок — истинските грешки, предизвикани и уловени — е най-важната в целия курс.
Понятията
6.1 Goroutines: пускането е тривиално, чакането — не
Пускането на goroutine е една дума:
go revisar(s)
И толкова. Това стига, за да може revisar(s) да се изпълнява конкурентно с останалата част от програмата, без да се чака
да приключи, за да се продължи със следващия ред. Всяка струва приблизително 2 KB памет
в началото (растат, ако е нужно), а не мегабайтите на нишка на операционната система — затова програма
на Go може да има стотици хиляди живи goroutines, без да планира нищо специално, нещо, което би било
немислимо с нишки на системата.
Но погледни тази програма, с най-честия бъг от първия ден на конкурентността в Go (пълна в
programas/revisor/ejemplos/06-goroutines-sin-esperar/main.go):
func main() {
servicios := []string{"catalogo", "pagos", "inventario"}
for _, s := range servicios {
go fmt.Println("revisando", s)
}
// el programa termina AQUÍ, sin haber esperado a ninguna goroutine
}
Пуснах я 260 пъти на тази машина, за да измеря колко често СЕ ВИЖДА проблемът, а не да го гадая:
40 с go run, още 20, налагайки един-единствен логически процесор (GOMAXPROCS=1, за да отнема на програмата
всякакъв шанс някоя goroutine да успее да се изпълни на друго ядро, докато main приключва), и 200 с
вече компилирания двоичен файл (go build + ./gor61, за да махна от пътя времето, което отнема компилирането).
Резултатът:
$ go run main.go
$ go run main.go
$ go run main.go
Празно. И трите, и практически всичките 260 (само едно от 200-те пускания на компилирания двоичен файл отпечата нещо — останалите нищо).
🔴 И това число трябва да се чете много внимателно, защото е лесно да се стигне до грешното заключение.
Дефектът не присъства „1 от всеки 260 пъти“: той присъства всичките 260 от 260, без изключение. В
нито едно от 260-те пускания програмата не изчака своите goroutines — дори в единственото, което
отпечата нещо. Онова, което се променя между пусканията, не е дали програмата чака (никога не го прави): а дали, по чист
късмет в начина, по който операционната система разпределя времето между процесите, някоя goroutine успява да изпълни
fmt.Println в процепа от време между момента, в който е пусната, и момента, в който main убива цялата програма. Този
процеп почти никога не стига — затова почти никога не се вижда нищо — но програмата е също толкова счупена в
259-те тихи пускания, колкото и в онова, което отпечата.
Ето какво трябва да запомниш от това: винаги счупено, почти никога видимо. Не е "има малка вероятност да се провали" — да го прочетеш така е точно грешката, която трябва да се избегне: програмата никога не прави правилното и в повечето случаи симптомът не успява да се покаже навреме, за да го забележиш. Така този вид бъг се промъква: минава прегледа на кода (компилира се, изпълнява се, не гърми), минава ръчните тестове (кой пуска програма 260 пъти, за да ѝ се довери?), минава CI (който пуска веднъж, може би два пъти) — и предупреждава едва когато вече е в продукция, с повече натоварване, повече ядра и процеп от време, който един ден все пак успява да се отвори, в най-лошия възможен момент да го откриеш.
⚠️ Не го приемай и като "това никога не отпечатва нищо, на никоя машина". С повече натоварване, друга операционна система или просто лош късмет процепът може да се отваря по-често — всъщност се отвори веднъж в същите тези 260 пускания. Онова, което не се променя между машините, е, че програмата никога не чака: това е структурно, не зависи от късмета. Пусни сам експеримента на своя компютър, с достатъчно повторения, за да означава числото нещо, и сравни.
Това не е периодичен бъг на програмата: работата е там, че main приключва веднага щом стигне края на тялото си,
без да я е грижа дали още има работещи goroutines — а когато main приключи, цялата програма
приключва с нея, включително живите goroutines, без предупреждение и без грешка. Пускането на goroutine е лесно;
истинската работа е да се увериш, че програмата ги изчаква да приключат, преди да продължи.
6.2 sync.WaitGroup: правилният начин да се чака
var wg sync.WaitGroup
for _, s := range servicios {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("revisando", s)
}()
}
wg.Wait() // aquí sí espera a que las tres hayan llamado Done
Шаблонът е винаги един и същ и си струва да се запомни в този ред:
wg.Add(1)преди да пуснеш goroutine, а не вътре в нея. Ако го сложиш вътре,Wait()може да се изпълни, преди goroutine да е успяла да направи свояAdd, и тогава не брои това чакане.defer wg.Done()като първи ред на goroutine.deferгарантира, че ще се изпълни, каквото и да стане вътре — дори ако goroutine предизвика panic — а поставянето му първо предпазва от това раненreturnпо средата на кода да забрави да го отчете.wg.Wait()там, където наистина ти трябва резултатът, обикновено точно преди да използваш онова, което goroutines са създали.
Какво става, ако пропуснеш стъпка 1 — липсващ Add —, предизвикано нарочно (пълна в
programas/revisor/ejemplos/06-waitgroup-sin-add/main.go):
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func(n int) { // <- sin wg.Add(1) antes de esta línea
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
fmt.Println("listo")
Пуснах я шест пъти поред, с -race, за да видиш какво наистина се случва, а не само крайната
грешка:
$ go run -race sinesperar.go
listo
panic: sync: negative WaitGroup counter
...
exit status 2
$ go run -race sinesperar.go
listo
panic: sync: negative WaitGroup counter
...
exit status 2
(cuatro corridas más, idénticas: "listo" primero, el panic después — 6 de 6, CON -race)
🔑 Прочети това внимателно, защото е най-подвеждащата грешка в урока: програмата отпечатва listo
ПРЕДИ да гръмне, и шестте пъти. Това не е случайност на това пускане: wg.Wait() не чака нищо,
защото броячът никога не е бил увеличен (никога не е имало Add(1)), така че Wait() се връща
незабавно, main отпечатва listo, сякаш всичко е минало добре, и едва тогава една от
изоставащите goroutines — която наистина се е изпълнявала, само че Go никога не я е изчакал — приключва своя Sleep и
извиква Done(), като изважда единица от брояч, който вече е бил на нула. Panic не е първото, което виждаш:
той е последното, след като програмата вече ти е казала, че всичко е наред.
⚠️ И тук идва частта, която измерих погрешно първия път, и си струва поправката да остане записана: без
-race същата тази програма никога не гърми. Същите шест пускания, без -race:
$ go run sinesperar.go
listo
$ go run sinesperar.go
listo
(cuatro corridas más, idénticas: "listo" y nada más — 6 de 6, SIN -race, saliendo con código 0)
time.Sleep(10 * time.Millisecond) не е онова, което КАРА panic да се появи — то е онова, което го ПРЕДОТВРАТЯВА, без
-race. main стига до wg.Wait(), който се връща незабавно, отпечатва listo и цялата програма
приключва много преди да изтекат 10-те милисекунди — така че нито една от петте изоставащи goroutines
не успява дори да се събуди от своя Sleep и да извика Done(). Програмата изглежда, буквално,
"приключила успешно": код на изход 0, без никаква следа, че нещо е било сглобено погрешно. Онова, което наистина кара
panic да се появява последователно, е инструментирането на -race: наблюдението на всеки достъп до паметта,
за да се открият състезания, кара програмата да работи забележимо по-бавно, и тази допълнителна бавност е
точно онова, което дава време на някоя изоставаща goroutine да завърши своя Sleep и да извика Done(),
преди процесът да приключи.
Това е точно видът грешка, която не се проявява винаги, и затова е най-ценната в този
урок — и сега с правилните данни: току-що измери, че дори не е нужна "по-кратка работа",
за да остане незабелязана. Пусната както си е, БЕЗ -race, тя вече остава незабелязана 6 от 6 пъти. Учещ,
който пусне този пример без -race — очевидното, ако никой не го предупреди — ще види listo и нищо
повече и ще заключи, с пълно право, че програмата работи. Не работи: Add(1) продължава да липсва
по абсолютно същия начин. Единственото, което се е променило, е дали нещо е успяло да се покаже навреме, за да го издаде — същият
шаблон "винаги счупено, почти никога видимо" от раздел 6.1, тук с различен участник: не е натоварването на
машината, а дали си пуснал с включен детектор на състезания (race detector), или не.
Done() изважда единица от вътрешния брояч на WaitGroup. Ако никога не си направил Add(1), броячът
започва от нула и изваждането на единица го прави отрицателен — и Go, вместо да го пропусне тихо, предизвиква
panic със съобщение, което казва точно какво не е наред: negative WaitGroup counter. Това дословно съобщение
е твоята следа: ако го видиш, почти винаги липсва Add(1) някъде или има повече Done(), отколкото
би трябвало. Но истинската поука не е съобщението на panic — а това, че програмата вече е казала
listo, преди то да се появи.
6.3 Защо revisor не споделя map: канали
Девизът на езика, и си струва да го запомниш дословно: „не общувай, като споделяш памет; споделяй памет, като общуваш“. Вместо всяка goroutine да пише резултата си в споделен map или slice — което би изисквало всеки достъп да се защити със заключване (lock) —, всяка изпраща резултата си по канал, и една-единствена goroutine (или основният код) ги събира от другата страна.
ch := make(chan servicio.Estado) // sin buffer: cada envío espera a que alguien reciba
ch := make(chan servicio.Estado, 10) // con buffer: hasta 10 caben sin esperar a que nadie reciba
ch <- estado // enviar
e := <-ch // recibir
close(ch) // cerrar: después de cerrado, recibir sigue funcionando hasta vaciarlo
for e := range ch { ... } // recibe hasta que el canal se cierre Y se vacíe
Проверих какво става, ако вместо канал използвам споделен slice без защита, точно грешката,
която каналите предотвратяват. Тази програма пуска хиляда goroutines, които увеличават една и съща променлива (пълна в
programas/revisor/ejemplos/06-carrera-de-datos/main.go):
contador := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
contador++ // dos goroutines pueden leer el mismo valor antes de que la otra escriba
}()
}
wg.Wait()
fmt.Println("contador:", contador)
Без детектора на състезания програмата не гърми — само дава грешен резултат, и различен всеки път:
$ go run carrera.go
contador: 956
956, а не 1000. Никаква грешка, никакъв panic, никакъв знак, че нещо се е объркало — само число, по-малко
от онова, което би трябвало да бъде, защото някои от хилядата събирания са се загубили, когато две goroutines са прочели
contador едновременно, и двете са добавили единица към една и съща стара стойност, и едно от двете записвания е презаписало
другото, без никой да разбере. Това е най-опасният бъг на конкурентността: не се проваля, дава
правдоподобен и грешен резултат. Такава програма може да мине преглед, да мине повърхностни тестове и да
се проваля в продукция спорадично в продължение на месеци, преди някой да забележи.
6.4 go test -race: как детекторът намира бъга
Същата програма, пусната с -race, наистина го издава — с точни доказателства къде:
$ go run -race carrera.go
==================
WARNING: DATA RACE
Read at 0x00c00013c018 by goroutine 11:
main.main.func1()
carrera.go:15 +0x68
Previous write at 0x00c00013c018 by goroutine 8:
main.main.func1()
carrera.go:15 +0x78
Goroutine 11 (running) created at:
main.main()
carrera.go:13 +0x6c
Goroutine 8 (finished) created at:
main.main()
carrera.go:13 +0x6c
==================
contador: 847
Found 2 data race(s)
exit status 66
Прочети го отгоре надолу, защото всеки блок отговаря на различен въпрос:
Read at ... by goroutine 11иPrevious write at ... by goroutine 8: две различни goroutines са докоснали един и същ адрес в паметта (0x00c00013c018, променливатаcontador), едната чете, а другата пише, без какъвто и да е механизъм, който да гарантира, че едната чака другата.carrera.go:15и в двете: точният ред от кода, където се е случило (contador++), един и същ ред за двете, защото това е единственият ред, който докосва тази променлива.Goroutine 11 (running) created at ... carrera.go:13: откъде е дошла тази goroutine — редът наgo func() {...}()вътре въвfor—, за да можеш да проследиш кое пускане е било.Found 2 data race(s)иexit status 66: детекторът не спира на първото състезание, което намери; продължава да работи и ги докладва всичките, а програмата завършва с код на изход, различен от 0 и от 1 (66 е кодът, който Go запазва за това), за да може pipeline за непрекъсната интеграция да го различи от обикновен провал.
А крайното число, contador: 847, се промени спрямо пускането без -race (956). Не по
случайност: -race кара програмата да работи по-бавно и с повече инструментиране, което променя
точния ред, в който goroutines се преплитат — още една причина този бъг да е толкова коварен:
грешното число дори не е едно и също грешно число всеки път.
🔴 Правилото, което следва от това, без изключение: конкурентна програма, която минава тестовете си без
-race, не е тествана. Пускай -race още от първия тест на конкурентност, който напишеш, а не когато
"нещо изглежда странно" — защото, както току-що видя, нищо не изглежда странно, докато не стане твърде късно.
6.5 Другите два начина да се провалиш: panic и взаимно блокиране (deadlock)
Panic, ако затвориш канал неправилно:
- Изпращането към вече затворен канал предизвиква panic:
panic: send on closed channel. - Затварянето на канал два пъти предизвиква panic:
panic: close of closed channel.
Правилото, което предотвратява и двете: канала го затваря онзи, който изпраща, никога онзи, който получава, и го затваря само веднъж,
обикновено от отделна goroutine, която знае кога вече няма да има повече изпращания (ще го видиш в
раздел 6.7, с wg.Wait(), последвано от close).
Взаимно блокиране (deadlock), ако никой от другата страна не слуша. Предизвиках го с възможно най-кратката програма
(пълна в programas/revisor/ejemplos/06-deadlock/main.go):
func main() {
canal := make(chan int)
canal <- 1 // nadie del otro lado está leyendo: se bloquea
fmt.Println(<-canal)
}
$ go run candado.go
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
candado.go:7 +0x38
exit status 2
Това поправя нещо, което много обяснения приемат за даденост: deadlock в Go не винаги "увисва
завинаги". Самият runtime на Go има детектор на deadlocks: ако в някой момент всички
goroutines на програмата спят в очакване на нещо, което никога няма да се случи, Go го забелязва и убива програмата
с fatal error: all goroutines are asleep - deadlock!, с точния ред, където е заседнала
([chan send] в този случай, защото е изпращала). Ако програмата ти изглежда "увиснала завинаги", вместо
да приключи с тази грешка, почти сигурно не е истински deadlock: по-вероятно е да имаш
поне една жива goroutine, която прави нещо друго (например таймер или HTTP сървър, който
слуша), и тя пречи на runtime да обяви, че "всички" спят.
6.6 context: как се отменя и как се слага времево ограничение
ctx, cancelar := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelar() // SIEMPRE, incluso si terminas antes de que se cumplan los 5 segundos
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req) // se aborta solo si pasan los 5 s
context е стандартният подход в Go за две неща: да се сложи времево ограничение на операция,
която може да отнеме твърде дълго, и да се разпространи отмяна надолу (ако горният бъде отменен,
всичко, което зависи от него, също се отменя, без всяка функция да трябва да преоткрива собствен
механизъм).
⚠️ defer cancelar() не е по избор, дори когато операцията вече е приключила сама.
context.WithTimeout пуска вътрешен таймер; ако никога не извикаш функцията за отмяна, този
таймер остава жив, докато изтече първоначалният срок, като задържа памет през цялото това време
— а ако програмата ти създава такива контексти постоянно (по един за всяка проверена услуга, като
revisor), без defer cancelar() натрупваш течове на памет, пропорционални на броя на направените проверки.
Това е най-честото изтичане в програмите на Go, които използват context, и затова си струва да напишеш defer на
същия ред, на който създаваш контекста, преди да напишеш каквото и да е друго.
6.7 Todos: функцията, която събира трите части
Ето как наистина изглежда централната функция на revisor (programas/revisor/internal/revisar/todos.go), след като
са събрани goroutines, канали, семафор и context:
func Todos(ctx context.Context, r Revisor, servicios []servicio.Servicio, paralelo int) []servicio.Estado {
if len(servicios) == 0 {
return nil
}
if paralelo <= 0 {
paralelo = ParaleloPorOmision
}
resultados := make(chan servicio.Estado, len(servicios))
semaforo := make(chan struct{}, paralelo)
var wg sync.WaitGroup
for _, s := range servicios {
wg.Add(1)
go func() {
defer wg.Done()
semaforo <- struct{}{} // pide turno; se bloquea si ya hay «paralelo» corriendo
defer func() { <-semaforo }() // devuelve el turno pase lo que pase
// Cada servicio tiene su propio tiempo límite, hijo del general. Si
// el de arriba se cancela, este muere con él.
propio, cancelar := context.WithTimeout(ctx, s.TimeoutEfectivo())
defer cancelar() // sin esto, el temporizador no se libera: es la fuga más común de Go
resultados <- r.Revisar(propio, s)
}()
}
// Quien envía cierra, nunca quien recibe. Y se cierra desde otra goroutine
// porque Wait tiene que poder esperar mientras el bucle de abajo ya está
// recibiendo: si cerráramos aquí mismo, con el canal lleno nos trabaríamos.
go func() {
wg.Wait()
close(resultados)
}()
estados := make([]servicio.Estado, 0, len(servicios))
for e := range resultados {
estados = append(estados, e)
}
return estados
}
Прочети я с още пресни предишните раздели, защото всяка част отговаря на проблем, който вече видя:
- Една goroutine на услуга (6.1), отчитана с
wg.Add(1)/defer wg.Done()(6.2), за да знае програмата кога са приключили всички. - Канал
resultadosс буфер (6.3) събира състоянията: никой не пише в споделен slice или map, така че не е нужно никакво заключване за тази част. semaforo := make(chan struct{}, paralelo)е канал, използван като квота: има място заparaleloстойности, така чеparalelo + 1-вата goroutine, която опита да пише в него (semaforo <- struct{}{}), се блокира, докато друга не освободи мястото си (<-semaforo, вdefer). Това е същият канал с буфер от раздел 6.3, използван не за пренасяне на данни, а за водене на сметка колко "реда" остават.- Собствен
context.WithTimeoutза всяка услуга (6.6), дете на общияctx: ако горният бъде отменен (например ако изтече общото време на отчета), всички деца се отменят заедно с него. close(resultados)от ДРУГА goroutine, следwg.Wait()— и това заслужава обяснение, защото е частта, която се вижда най-трудно с просто око: ако затворим канала в същата goroutine, която правиfor e := range resultadosпо-долу, щяхме да заседнем, защотоWait()има нужда всички goroutines да четат от канала, за да се освободи място и да могат да върнат реда си, но цикълът за четене никога нямаше да тръгне, защото първо щяхме да чакамеWait(). Като се пусне отделно, затварянето и четенето се случват едновременно, а не едно след друго.
6.8 Тестване без мрежа: Falso и измереното ограничение на паралелизма
Тестването на Todos срещу истински услуги би било бавно и недетерминирано. revisor използва фалшив
Revisor (programas/revisor/internal/revisar/falso.go), който симулира отговори, забавяния и дори услуги, които никога
не отговарят, всичко в паметта:
type Falso struct {
Respuestas map[string]RespuestaFalsa
mu sync.Mutex
Contador int
}
func (f *Falso) Revisar(ctx context.Context, s servicio.Servicio) servicio.Estado {
f.mu.Lock()
f.Contador++
f.mu.Unlock()
// ...
}
🔒 Тук наистина е нужен mutex и това е изключението от правилото "предпочитай канали" от раздел 6.3.
Contador е споделено цяло число, което много goroutines увеличават едновременно по време на
тестовете (Falso.Revisar се извиква конкурентно, по веднъж за всяка услуга, която Todos
проверява). Канал би свършил работа, за да се изпращат резултати, но за прост споделен брояч
sync.Mutex около единствения ред, който го докосва, е по-просто и по-ясно. Правилото не е "никога не използвай
mutex": то е "преди да използваш такъв, запитай се дали канал изразява по-добре онова, което правиш" — и за
изпращане на резултати почти винаги да; за споделен брояч почти винаги mutex е правилният
инструмент.
С Falso този тест измерва нещо, което иначе би било почти невъзможно да се провери уверено: че
семафорът от раздел 6.7 наистина ограничава колко проверки се изпълняват едновременно, а не само че "работи
като цяло" (пълна версия, без съкращения, в programas/revisor/internal/revisar/todos_test.go):
func TestTodos_elParaleloLimitaCuantasCorrenALaVez(t *testing.T) {
const totalServicios = 20
const limite = 3
var enVuelo, maximoObservado int32
// medidorDeConcurrencia cuenta, con un contador atómico, cuántas llamadas
// a Revisar están abiertas AL MISMO TIEMPO, y se queda con el máximo.
medidor := &medidorDeConcurrencia{enVuelo: &enVuelo, maximoObservado: &maximoObservado, espera: 15 * time.Millisecond}
servicios := make([]servicio.Servicio, totalServicios)
// ... llena servicios ...
Todos(context.Background(), medidor, servicios, limite)
if max := atomic.LoadInt32(&maximoObservado); max > int32(limite) {
t.Errorf("se observaron %d consultas simultáneas; el límite era %d", max, limite)
}
}
Истинско пускане, с активен детектор на състезания (за да се потвърди, че дори самото измерване не внася състезание):
$ go test ./internal/revisar/... -race -v -run TestTodos_elParaleloLimitaCuantasCorrenALaVez
=== RUN TestTodos_elParaleloLimitaCuantasCorrenALaVez
--- PASS: TestTodos_elParaleloLimitaCuantasCorrenALaVez (0.11s)
PASS
ok github.com/habil/revisor/internal/revisar 1.435s
20 услуги, ограничение 3, и тестът потвърждава, че никога не е имало повече от 3 извиквания на Revisar, изпълнявани
едновременно — не защото го приемаме от кода, а защото атомарен брояч го е измерил, докато е работел.
6.9 Времето, измерено: последователно срещу паралелно
С Falso, настроен да симулира истински забавяния, тестът TestTodos_respetaElTimeoutPorServicio
потвърждава другата страна на монетата: услуга, която никога не отговаря (Colgado: true), с
Timeout: 30 * time.Millisecond не може да накара Todos да отнеме повече от това:
$ go test ./internal/revisar/... -run TestTodos_respetaElTimeoutPorServicio -v
=== RUN TestTodos_respetaElTimeoutPorServicio
--- PASS: TestTodos_respetaElTimeoutPorServicio (0.03s)
0.03 секунди, не повече. context.WithTimeout от раздел 6.7, дете на общия ctx, прекъсна тази
проверка точно когато трябваше, без останалата част от програмата да трябва да разбира, че се е случило.
Грешката, която ще видиш
Всички тези са дословни, предизвикани нарочно за този урок:
| Симптомът | Дословно съобщение / доказателство | Какво става и какво да направиш |
|---|---|---|
main приключва, преди goroutines да се изпълнят |
(никакъв изход или частичен изход, непоследователен между пусканията) | Липсва sync.WaitGroup (или канал), който да накара main да изчака. Раздел 6.1 |
Add/Done не съвпадат |
Програмата отпечатва, че всичко е минало добре първо, а panic: sync: negative WaitGroup counter идва СЛЕД това |
Липсва wg.Add(1) преди пускането на някоя goroutine или има излишен Done(). Wait() се е върнал, без да чака нищо. Раздел 6.2 |
| Споделени данни без защита | WARNING: DATA RACE + Found 2 data race(s) + exit status 66 (с -race); грешно число без обяснение, без -race |
Две goroutines докосват една и съща памет без синхронизация. Използвай канал, за да изпратиш резултата, или mutex, ако наистина ти трябва споделен брояч. Раздели 6.3 и 6.4 |
| Изпращане към затворен канал | panic: send on closed channel |
Някой друг вече е затворил канала или го е затворил онзи, който не е трябвало. Затваряй винаги от страната на изпращащия, само веднъж |
| Затваряне два пъти на един и същ канал | panic: close of closed channel |
Две goroutines (или два пътя в кода) опитват да затворят един и същ канал. Централизирай затварянето на едно място |
| Никой от другата страна на канал без буфер | fatal error: all goroutines are asleep - deadlock! |
Runtime на Go открива, че цялата програма е заспала в очакване на нещо, което никога няма да се случи, и я убива с точно този ред. Раздел 6.5 |
Забравил си defer cancelar() |
(без незабавна грешка; изтичане на памет, натрупано с времето) | Всеки неотменен context.WithTimeout държи таймера си жив, докато изтече сам. Раздел 6.6 |
И инструкцията, която обобщава целия този урок: ако ще пишеш конкурентност, пускай -race още от
първия си тест, а не когато нещо замирише — защото, както видя в раздел 6.3, нищо не мирише, докато
не стане твърде късно.
Какво се прави погрешно
- Пускане на goroutine за всеки елемент, без никакво ограничение, срещу външен ресурс. С 5 услуги не се забелязва. С 5,000 програмата отваря 5,000 връзки наведнъж и тясното място престава да бъде отдалечената услуга и става собствената ти машина (или мрежата, или самият сървър, върху който се изсипват 5,000 едновременни заявки). Семафорът от раздел 6.7 съществува точно за това — никога не позволявай "колко goroutines пускам" да зависи само от "колко елемента имам".
- Пренебрегване на
context, който ти дават, или неразпространяването му. Ако функция получаваctxи извиква друга операция, която може да се забави, без да ѝ го подаде, тази операция няма да се отмени, когато оригиналниятctxбъде отменен — имаш два часовника, които не си говорят. Всичко, което може да се забави, получаваctxот онзи, който го е извикал. - Незатваряне на отвореното.
context.WithTimeoutбез свояcancelar(), HTTP връзка безresp.Body.Close()(урок 7), незатворен файл: всяко е различно изтичане, но начинът да се избегнат е един и същ —deferведнага след отварянето, преди да напишеш какъвто и да е друг ред. - Споделяне на памет, вместо да се комуникира, "защото е по-бързо за писане". Споделен map с
mutex около целия блок може да изглежда по-кратко от сглобяването на канал, но е по-лесно да
забравиш
Lock()в една-единствена точка на достъп, отколкото да забравиш да изпратиш по канал — а първото забравяне не се забелязва, докато детекторът на състезания (или, по-лошо, продукцията) не го открие. - Да разчиташ, че "никога не се е проваляло" означава "наред е". Състезание за данни може да мине деветдесет и
девет пускания и да се провали на стотното или да се проваля само с повече ядра от тези на лаптопа ти. Единственото
доказателство, че една конкурентна програма няма състезания, е да я пуснеш с
-race, а не да я видиш да минава няколко пъти без него.
Упражнения
- Напиши програмата от раздел 6.1 (goroutines без чакане) и я пусни пет пъти поред. Запиши колко пъти е отпечатала нещо и колко пъти не.
- Добави ѝ правилен
sync.WaitGroup(раздел 6.2) и потвърди, че сега трите реда се отпечатват винаги, при петте пускания. - Възпроизведи бъга с липсващ
Addот раздел 6.2 и постави целияpanicв дневника си. - Възпроизведи състезанието за данни от раздел 6.3 (споделен брояч без защита) и пусни
същата програма с и без
-race. Сравни двете крайни числа и постави целия изход от-raceв дневника си. - Възпроизведи deadlock от раздел 6.5 с канал без буфер и без получател. Потвърди, че виждаш
fatal error: all goroutines are asleep - deadlock!, а не тихо увисване. - Вземи своя собствен
revisor(или този от курса) и пусниTestTodos_elParaleloLimitaCuantasCorrenALaVez, като сменишlimiteна 1, а после на 10. Обясни със свои думи защо резултатът от теста не се променя (винаги минава), но времето, което отнема, се променя. - (Малко по-трудно) Махни семафора от
Todos(остави всички goroutines да се пускат без ограничение) и пусни отновоTestTodos_elParaleloLimitaCuantasCorrenALaVez. Потвърди, че сега се проваля, и постави точното съобщение за грешка, което даваt.Errorf, с максимума, който е бил наблюдаван.
Решения
1-2. Няма едно-единствено число: зависи от машината ти. Важното е сравнението — без WaitGroup
непоследователно; с него винаги трите реда.
Програмата отпечатва
listoпърво —Wait()се е върнал незабавно, защото броячът никога не е бил увеличен — аpanic: sync: negative WaitGroup counterидва след това, с трасировка, която включваsync.(*WaitGroup).Add(...). При няколко пускания поред редът е винаги един и същ:listo, после panic.Без
-race— число, по-малко от очакваното (например 956 от 1000), без никаква грешка. С-race— блокътWARNING: DATA RACEс точния ред от кода, плюсFound N data race(s)иexit status 66.fatal error: all goroutines are asleep - deadlock!, сgoroutine 1 [chan send]:(или[chan receive], според това от коя страна е заседнала) и точния ред на канала.Тестът минава и в двата случая, защото измерва истинския максимум и го сравнява с ограничението, което самият той е настроил (1 или 10) — никога с фиксирано очаквано число. Общото време обаче се променя: с
limite=120-те проверки са строго последователни (едната чака другата), сlimite=10се изпълняват на две партиди по 10 вместо на двадесет по една.Без семафор всичките 20 goroutines се изпълняват едновременно, така че
maximoObservadoще бъде близо до 20 (не точно ограничението на теста, който продължава да иска 3).t.Errorfказва нещо катоse observaron 20 consultas simultáneas; el límite era 3— тестът НАИСТИНА открива регресията, което е точно онова, за което съществува.
Как разбирам, че съм успял
- Видях със собствените си очи програма да приключва, без да е изчакала своите goroutines (раздел 6.1).
- Предизвиках
panic: sync: negative WaitGroup counterи видях, че програмата отпечатаlistoПРЕДИ panic — и мога да обясня защо. - Предизвиках истинско състезание за данни и видях разликата между пускането му с и без
-race. - Мога да прочета блок
WARNING: DATA RACEи да кажа кой ред, кои goroutines и коя променлива са в конфликт. - Предизвиках
fatal error: all goroutines are asleep - deadlock!и знам защо Go го открива, вместо да остане увиснал завинаги. - Мога да обясня, с истинския код на
Todos, за какво служи всяка от четирите му части: goroutines, канал за резултати, семафор иcontextза всяка услуга. - Измерих, с истински тест (не по памет), че ограничението на паралелизма на
revisorнаистина се спазва. - Знам как да обясня защо
Falso.Contadorизползва mutex вместо канал и защо това не противоречи на общото правило от раздел 6.3.
За допълнително четене
- A Tour of Go: Concurrency — официалната обиколка на goroutines,
канали и
sync.WaitGroup, интерактивна. - Go Blog: Share Memory By Communicating — оригиналната статия, в която се обяснява девизът, цитиран в раздел 6.3.
- Package context — официалната документация, с четирите канонични
случая на употреба (
WithCancel,WithTimeout,WithDeadline,WithValue). - Data Race Detector — официалната документация на
-race: какво открива, какво НЕ открива (например не намира deadlocks, само състезания за данни) и цената му по време на изпълнение.
Термини от този урок
| Термин | Какво означава |
|---|---|
| goroutine | функция, която се изпълнява конкурентно с останалата част от програмата, много по-евтина от нишка на операционната система |
sync.WaitGroup |
механизъм за изчакване група goroutines да приключи, чрез броене на Add/Done |
| състезание за данни (data race) | две goroutines достъпват една и съща памет едновременно, поне едната пише, без синхронизация |
канал (chan) |
механизмът на Go, чрез който една goroutine изпраща данни на друга без споделена памет |
| семафор от канал | канал с буфер, използван като квота от свободни редове, а не за пренасяне на данни |
context |
стандартен механизъм за разпространяване на времеви ограничения и отмяна между функции |
| deadlock | състояние, в което всички goroutines на програмата спят в очакване на нещо, което никога няма да се случи; runtime на Go го открива и прекратява програмата |
Предпочитате имейл? Пишете ни на hola@habil.mx