100 0060k Go u KaK HX H30eXKaTb
Teiba Xapwahu
100 Go Mistakes AND HOW TO AVOID THEM
TEIVA HARSANYI
100 ошибок Go
ИКАК ИХ ИЗБЕЖАТЬ
ТЕЙВА ХАРШАНИ
Xapuahn Teuba
X22 100 olin6ok Go u kak ux us6ekatb. - C16.: nIurep, 2024. - 480 c.: un. - (Cepur 4Jn6 npopeccunonanob). ISBN 978-5-4461-2058-1
Jyunnii cncoc6 yyunnits kod - nonars u ncnpabnits onin6ku, cdetahnbie npu ero hanucanin. B 3toii yhnukanbouh khure npoahanunupobanbi 100 tnnunhbx onin6ok u he3pdektunbhi x npuem6 B Go- npnJoxenux.
Bb hayuntec s nncats nJnomatunhbi u bupasuntelhbiu6 k0 ha Go, pas3epete decrtku nhtepechbix npumepob u cnehapheb u noimete, kak o6hapyxh6 oin6ku u notehnuaanbhiie onin6ku b c6oux npnJunshenux. Yto6bi bam 6b1o yJ66hee pa6otan, c knurol, abtop pas3e- Jn1 metoJbi npelotnpanuehni onin6ok ha heckonbko katetopnii, hauinhar ot tnnob Jahnbix u pa6otbi co ctpokamii u sakahvubar konkypenhthbim nporpammpnobanhieM u tctupobanhieM.
JlJn onnunux Go- pa3pa6otunukon, xopouo 3hakomix c cunitakscncom zH1ka.
16+ (B COOTBETCTBUN C CDEJepaJbHbIM 3aKOHOM OT 29 Jeka6pr 2010 r. Ne 436- 03. )
E5K 32.973.2- 018.1 YDK 004.43
IpaBa Ha us3aHue nonyHehI no cornaueHnIO c Manning Publications. Bce npBa sauuyueHbI. Hukakar 4acb JaHn6H KHnI6 He MoxKet 6bIb Bocnpou3eDeHa B kakO6 6bI To Hn 6bI6 Dopme 6e3 nucbMehHoro pa3peueHnI BnJaJenbJeb aBtopckix npaB.
HdpopMaJus, coJepxMaJarca 8 JaaH6H KHnre, nonyHea us ucTOHnHukOB, paccMaJtpuaeMbIX u3JaTeN6T6OM Kac HaJexHbIe. Tem He MeHee, uMoe 6 bJyJy BosMoxHbIe 4en6beHecxke HnI nExHn6xckIe ouJn6Ku, u3JaTeN6T6BO He MoxKet rapaHnUpOaBb 66COnJOTHyIO ToH6HCTb 6 JnJnHOTy npu6OJbMbIX CBeJehnI6 M He HeCet OTBETCTBENHOCTU 3a BosMoxHbIe ouJn6Ku, cB3a3hnHe c ucJnOJb3b3aHnem KHnI6. B KHnre BosMoxHbI yJnOMHahHnI opraHn3aJnI, Jea- TeN6HCTb KOTOpbIX saJpeueHa Ha teppuropnI PoccnIcK6O JBeJepaJnI, takux kak Meta PlaTorms Inc., Facebook, Instagran 6 6J. V3JaTeN6T6BO He HeCet OTBETCTBENHOCTU 3a JOCyTN6HCTb MaTePbJIOB, CcsJHn6 H6 KOTOpbIe BbI MoxKete HaJn6 B 3to6 KHnre. Ha MOMeH7 noJ6roTOBk6 KHnI6 K H3JaHnIO BcE CcsJHn6 H6 HnTePHE- pecypcbI 6bI6n JeeCTByIOJnMnI.
ISBN 978- 1617299599 aHnI. ISBN 978- 5- 4461- 2058- 1
© 2022 Manning Publications © Перевод на русский язык ООО «Прогресс книга», 2023 © Издание на русском языке, оформление ООО «Прогресс книга», 2023
18
20
22
25
26
27
28
36
98
148
171
187
Глава 7. Обработка ошибок........................................................................208Глава 8. Конкурентность: основы................................................231Глава 9. Конкурентность: практика................................................270Глава 10. Стандартная библиотека........................................321Глава 11. Тестирование................................................355Глава 12. Оптимизация................................................402
18 20 22 22 23 24 25 26 27 28 29 30
1.3. 100 OHHH60 31
1.3.1. EaH 32 1.3.2. H3JHHHHH HJIOXHOCTb 33 1.3.3. HJIOXaH HHTaEMOCTb 33 1.3.4. HEOHHHMMaHbHaa HHH HHHJIOOMaHTHHHcKaH OPHaHHMaHH 34 1.3.5. OTCyTCTbHHe yJIO6CTBa B API 34 1.3.6. HEOHHHMMH3HPOBaHHHbHbH KOI 34 1.3.7. HeJIOCTaTOHHaH aHH POHO3BOJHTeJIbHOCTb 35
HTOH 35
36
2.1. OHHH6Ka #1: HHHHcHHaHHeHHHO 3aTeHHaHb HHeHeHHbHHe 36 2.2. OHHH6Ka #2: JHHHHHbH bJIOXeHHHbHbH KOI 39 2.3. OHHH6Ka #3: HHHHpaHHbHbH HCHOJIb3OBaTb HJyHHKHHHO HHHHHHaJIb3aHH 42 2.3.1. KOHHHeHHH 42 2.3.2. KOHa HCHOJIb3OBaTb HJyHHKHHH HHHH 46 2.4. OHHH6Ka #4: 3JIOyHOHTpe6JIaTb HHeHeHHaH HHeHeHHH 49 2.5. OHHH6Ka #5: aHTPH3HHaTb HHHeHeHeHeHeHe 50 2.5.1. KOHHHeHHH 50 2.5.2. KOHa HCHOJIb3OBaTb HHHeHeHeHeHe 53 2.5.3. 3aHp3HHHeHe HHHeHeHeHeHe 57 2.6. OHHH6Ka #6: HHHeHeHeHeHe HHa HCTOPOHe HPOHO3BOJHTeJIH 58 2.7. OHHH6Ka #7: BO3BaHa HHHeHeHeHeHe 61 2.8. OHHH6Ka #8: aHHy He HOBOpHT HH O HeH 64 2.9. OHHH6Ka #9: HyTaHHHHa B HCHOJIb3OBaHHH HJXHeHeHHKOB 66 2.9.1. KOHHHeHHH 67 2.9.2. OHHHHeHeHeHeHe HHHH HCHOJIb3OBaHHH H HJIOYHOHTpe6JIeHHH 71
2.10. Oпийбка #10: не знать о возможных проблемах со встраиванием типов. 73 2.11. Oпийбка #11: не использовать паттерн функциональных опций. 77 2.11.1. Структура Config. 79 2.11.2. Паттерн Строитель. 80 2.11.3. Паттерн функциональных опций. 82 2.12. Oпийбка #12: неорганизованность проекта. 85 2.12.1. Структура проекта. 85 2.12.2. Организация пакета. 86 2.13. Oпийбка #13: создавать пакеты утилит. 88 2.14. Oпийбка #14: игнорировать коллизии имен пакетов. 90 2.15. Oпийбка #15: не иисать документацию по коду. 91 2.16. Oпийбка #16: не использовать линтеры. 94 Итоги. 95
Плава 3. Типы данных. 98
3.1. Oпийбка #17: пуганица с восьмеричными литералами. 98 3.2. Oпийбка #18: игнорировать целочисленные переполнения. 100 3.2.1. Концепции. 100 3.2.2. Обнаружение целочисленного переполнения при инкрементировании. 102 3.2.3. Обнаружение целочисленного переполнения при сложении. 103 3.2.4. Обнаружение целочисленного переполнения при умножении. 103 3.3. Oпийбка #19: не понимать проблем, связанных с главающей точкой. 104 3.4. Oпийбка #20: не понимать особенностей, связанных с длиной среза и его емкостью. 109
3.5. Ouu6ka #21: heoqhektubhas uhuquaлизация cpe3a. 114 3.6. Ouu6ka #22: nytать nyctbie u hyleebbie cpe3b 118 3.7. Ouu6ka #23: henpabubihno npobepratb nycctoty cpe3a 122 3.8. Ouu6ka #24: henpabubihno cos3ababatb koninu cpe3oB. 124 3.9. Ouu6ka #25: neosxhdanhbie no6oHmbie sqhpektni npi ucnonb3obanhni append b onepaunax co cpe3ami. 125 3.10. Ouu6ka #26: cpe3b u yteyku namrtu. 129 3.10.1. YTeyku emkocsti. 129 3.10.2. Cpe3 u ykas3areJn. 131 3.11. Ouu6ka #27: heoqhektubho uhuquaлизupobatb kapTb. 135 3.11.1. KoHHeHnIIn 135 3.11.2. HHHnHnHnHnHn 137 3.12. Ouu6ka #28: kapTb u yteyku namrtu 139 3.13. Ouu6ka #29: neKoppckTnoc cpaBnemie sha-ennii 142 Htoru. 146
148
4.1. Ouu6ka #30: irnHopupobatb To, yTO 3JemenTbI B HHKJIe range konHpyIOTcA 148 4.1.1. KoHHeHnIIn 149 4.1.2. KonHn3 HJyHnHn 150 4.2. Ouu6ka #31: irnHopupobatb To, kak B HHKJIax range BHHHnJHOTcA apryMeHTbI 152 4.2.1. KaHaJIb 154 4.2.2. MaccHb. 155 4.3. Ouu6ka #32: irnHopupobatb BJIHHHne, kotopoe oka3bIBaET ucnonb3bOBaHHe 3JemenTbO yKas3areJn B HHKJIax range. 157 4.4. Ouu6ka #33: JelJaTb HeBeHbHe JOnyHHeHnI bO bpe3M HtepaHnH KapTbI 161
4.4.1. YInoprdOHNbAHHe 161 4.4.2. BcTaBKa KaPTbI BO BpeMn HTEpaHnI 163 4.5. OHH6Ka #34: HHHOpHPOBaTb OCO6ENHOCTH Pa6OTbI OHepaTOpa bReaK.... 165 4.6. OHH6Ka #35: HcHONb3bOBaTb Defer BHyTpH HHKJIb 167 HTOH 169
171
5.1. OHH6Ka #36: He HHHMMaTb KOHHHeHHHnI pyH 172 5.2. OHH6Ka #37: HHeTOHHaH HTEpaHHa HTPoK 174 5.3. OHH6Ka #38: HHeHpaBHbHbO HcHONb3bOBaTb OyHHHHHn IOp6e3K 177 5.4. OHH6Ka #39: HeIOcCTaTOHHaH cTeHeHe bOHTHMH3aHHH HPH KOHHKaTeHaHHH HTPoK 179 5.5. OHH6Ka #40: 6EcHONe3HbHe HpeO6pa3bOBaHHa HTPoK 182 5.6. OHH6Ka #41: HIOcTpHOK H yTeHKH HaMHTH 183 HTOH 186
187
6.1. OHH6Ka #42: He 3HaTb, KaKOH HHH HONJyHaeTJI HcHONb3bOBaTb 188 6.2. OHH6Ka #43: He HcHONb3bOBaTb HMHeOBaHHbHe HapaeHTbH pe3yJIbTaTa....191 6.3. OHH6Ka #44: HO6OHHHe 3pHpeKTH H OT HMHeOBaHHbH K HapaeHTpOB pe3yJIbTaTa 194 6.4. OHH6Ka #45: HOBHpaH HOJyHaeTJI HnI 196 6.5. OHH6Ka #46: HcHONb3bOBaTb HMa HaHJIa B KaHeCTHe BHOJIbIX aHHHbH HyHHHHH 200 6.6. OHH6Ka #47: HHHOpHPOBaTb TO, KaK BbIHcJIbHOcTa HpRYMeHTbH H HOJyHaeTJI OHepaHTOpa Defer 202 6.6.1. BbIHcJIeHe He HpRYMeHTb 203 6.6.2. HOJyHaeTJI 3HaHeHHH H HJIy KKa3aTeJI 205 HTOH 207
7.06pa6otka own6ok. 208
7.1. OHH6ka #48: nAnHKa. 209 7.2. OHH6ka #49: HHHOHHOPOBaTb O6OPaHHBaHHe OHH6Ku. 211 7.3. OHH6ka #50: HHeTOHHaH HPOBeHKa THHHa OHH6Ku. 215 7.4. OHH6ka #51: HHeTOHHaH HPOBeHKa 3HaHeHHe HHHOHHa OHH6Ku. 219 7.5. OHH6ka #52: ABOHHaH O6pa6otka OHH6Ku. 221 7.6. OHH6ka #53: HHe BHHOHHHHTb O6pa6otky OHH6Ku. 224 7.7. OHH6ka #54: HHe BHHOHHHHTb O6pa6otky OHH6Ku OHepaHOrpa Hefer. 226 HTOH. 229
8.06kHypeHTHOCTb: OCHOBbI 231
8.1. OHH6ka #55: HYPHTb KOHKypeHTHOCTb H napaJIeJIeJIeMe. 232 8.2. OHH6ka #56: HJIJIaTaTb, YTO KOHKypeHTHOCTb 6bICTpee. 236 8.2.1. HJIaHHpOBaHHe b Go. 236 8.2.2. HapaJIeJIeJIbHaH cOrTHPOBKa cJIJIaHHHeM. 239
8.3. OHH6ka #57: HYPTaTbCb B TOM, KOJIa HCHOJIb3OBaTb KHaHaJIbI, a KOJIa MbIOTeKcbI. 244
8.4. OHH6ka #58: HHe HHHHMATb HPO6JIeM HOHKH. 246
8.4.1. HONKa JaHHbIX H cOCTOaHHHe HOHKH. 246 8.4.2. MOJIeJIb HaMaHTH Go. 252
8.5. OHH6ka #59: HHe HHHHMATb BJIJIaHHHe THHHa pa6OHeH HAPpy3KH Ha KOHKypeHTHOCTb. 255
8.6. OHH6ka #60: HHeBeHKHO HONHMATb KOHTeKCTbI Go. 261
8.6.1. KpaHHHHe cPOK. 262 8.6.2. CHHHaJIb OTMeHbI. 263 8.6.3. 3HaHeHHe HOHTEKCTOB. 264 8.6.4. HHepeXBaT OTMeHbI KOHTeKCTa. 266
HTOH. 268
270
9.1. OHH61: nepedabatb henoJxoJauuui kontekct 270 9.2. OHH62: sanJckatb ropytnuy u he shatb, koJia ce octahobutb.273 9.3. OHH63: neoctoropxho o6paiaatbca c ropytniamu u nepemehbIMM uJNKJA 276 9.4. OHH64: oKuJatb JetePMHHupobanhoe nObeJehue npu ucnJoJbSOBaHbIH seIect u kahanJb. 278 9.5. OHH65: ne ucnJoJbSOBaTb kahanJb yBeJOMJIehuI 283 9.6. OHH66: ne ucnJoJbSOBaTb hyJIeBbIe kahanJIb. 285 9.7. OHH67: raJatb hacJet pa3mepa kahanJIa. 291 9.8. OHH68: sa6bIBaTb o BosMoxHbIX nO6OyHbIX o6pEKTax npu qopMaTHupOBaHbIH cTpok. 294 9.8.1. ToHka JahanbIX b etcd 295 9.8.2. B3aIMo6JIOKupOBKa. 296 9.9. OHH69: oO3JaBaTb cUTyaiJIO rOHKu JahanbIX H3-3a onepaTOpa append. 299 9.10. OHH6a #70: hebepeHO ucnJoJbSOBaTb MbIOTekcbi co cpe3aMn u kapTamu 301 9.11. OHH6a #71: henpaBUIbHO ucnJoJbSOBaTb sync.WaitGroup. 304 9.12. OHH6a #72: sa6bIBaTb o sync.Cond. 307 9.13. OHH6a #73: he ucnJoJbSOBaTb errgroup 313 9.14. OHH6a #74: konupOBaTb THI sync 317 HTOr. 319
321
10.1. OHH6ka #75: henpaBUIbHO saJaBaTb npOMeXyTOH bpeMeH1. 322 10.2. OHH6ka #76: time.After u yTeYKu nAmAT1. 323 10.3. OHH6ka #77: THIHNyHbIe OHHH6Ku npu o6pa6OTke JSON. 326 10.3.1. HeoXKuJahHoe nObeJehue H3-3a BCTpaBaBaHbIH THIIOB. 326
10.3.2. JSON и монотонные часы 32910.3.3. Карта типа any 332
10.4. OHH6ka #78: THHHHbie OHH6Ku, CBA3aHHbIe c SQL 333
10.4.1. He 3HATb, UTO sQL.Open He BcERJa yCTaHABJIbIaET COeJHHeHHe c 6a3oUJ aHHbIX 333 10.4.2. 3a6bIBaTb o nyJe coeJHHeHHuI 334 10.4.3. He uCHOJIb3OBaTb nOJOTOBJIeHHbIe ONepaTOpbl 336 10.4.4. HENpaBbJIbHaa o6pa6oTKa HyJIeBbIX 3HaYeHHuI 337 10.4.5. He o6pa6aTbIbIaTb OHH6Ku UTEpaIIN cTpOK 339
10.5. OHH6ka #79: He 3aKpHbIaTb BpeMeHHbIe pecYpCb 340
10.5.1. TeJIo HTTP 340 10.5.2. sql.ROws. 343 10.5.3. os.File. 344
10.6. OHH6ka #80: 3a6bIBaTb o6 ONepaTOpe retum nOcJIe OTbeta Ha HTTP- 3aIPOc 346
10.7. OHH6ka #81: uCHOJIb3OBaTb CTaHJIaPTbIbIe HTTP- KJIeHT u cEpBep 348
10.7.1. HTTP- KJIeHT 348 10.7.2. HTTP- cEpBep 351
HTOrI 353
11. TCTHPOBAHHe 355
11.1. OHH6ka #82: He pacnpeJIeJIbTb TcCTbI no KaterOpHIM 356
11.1.1. TeIu c6opKu 356 11.1.2. HepemHHbIe cpeJIb 358 11.1.3. KoportKuI peXKIM 359
11.2. OHH6ka #83: He BKIIOyATb QJIar -race 360
11.3. Ommoka #84: he ucnonysobatb peKumbi bbnonJheHnia teCTOB 363
11.3.1. PJIar parallel 363 11.3.2. PJIar - shufle 365
11.4. Ommoka #85: he ucnonysobatb Ta6JnHbIe teCTB 366
11.5. Ommoka #86: saJepxxHbI b IOHHr- teCTax 371
11.6. Ommoka #87: he3dDkeKTHbHa pa6oTa c API bpeMeHn 374
11.7. Ommoka #88: he ucnonysobatb naketbI yTnJHr TJIa teCTupOBaHnIa 379
11.7.1. PIaket httptest 379 11.7.2. PIaket iotest 381
11.8. Ommoka #89: nucatb heTOyHbIe 6eHyMapKH 384
11.8.1. He c6paCbIbATb IJIu He CTaBHTb Ha nAy3y TaHMEp 385 11.8.2. JelJIaTb heBepeHbIe npeJnOJIOxHeHnI a o MUKPO6eHyMapKaX 386 11.8.3. He6peKaHoe OTHOHHeHnIe K OTnIMHbIaIHMH KOMnIJHrTOpa 389 11.8.4. 3dDkeK Ta6JnOJaTeJIa 391
11.9. Ommoka #90: he u3yvATb bce BO3MOxH0CTI teCTupOBaHnIa B Go 395
11.9.1. IOKpBHTHe teCTaMH 395 11.9.2. TeCTupOBaHnIe H3 Jpyroro naketa 396 11.9.3. BcIOMOcrTaTeJIbHbIe dyHkHnI 397 11.9.4. HactpOUKa H JEMOHTaX 398
HrorH 399
12. ONTIMHbIa 402
12.1. Ommoka #91: he nOHMHaTb yCTPOuCTBO KJIa CPU 403
12.1.1. ApxHtTeKTypa CPU 403
12.1.2. K3II- JIMHnI 405
12.1.3. Cpe3 cTpyKTyp H cTpyKTypa cpe3OB 408
12.1.4. Предсказуемость 41012.1.5. Стратегия размещения кэша 41212.2. Ошибка #92: писать конкурентный код, который приводит к ложному совместному использованию 41812.3. Ошибка #93: не учитывать параллелизм на уровне инструкций 42312.4. Ошибка #94: не знать о выравнивании данных 43012.5. Ошибка #95: не понимать различий между стеком и кучей 43512.5.1. Стек и куча 43512.5.2. Эскейп-анализ 44012.6. Ошибка #96: не знать, как сократить число выделений памяти 44312.6.1. Изменения API 44312.6.2. Приемы оптимизации компилятора 44412.6.3. sync.Pool 44512.7. Ошибка #97: не полагаться на встраивание 44812.8. Ошибка #98: не использовать диагностический инструментарий Go 45112.8.1. Профилирование 45112.8.2. Трассировщик выполнения 46012.9. Ошибка #99: не понимать, как работает сборщик mycopa 46512.9.1. Концепции 46512.9.2. Примеры 46712.10. Ошибка #100: не понимать особенностей запуска Go внутри Docker и Kubernetes 471Итоги 474Заключение 475
Дэйец Харшани: продолжай оставаться тем, кто ты есть, братик. Твой потолок — звезды.
Милой Мелиссе.
Предисловие
B 2019 году я во второй раз начал профессионально заниматься работой на Go в качестве основного языка программирования. Тогда я заметил некоторые закономерности, связанные с ошибками написания кода на Go. Я подумал, что обобщение информации о таких частых ошибках было бы полезно для раз- работчиков.
B своем блоге я сделал пост «10 самых распространенных ошибок, с которыми я сталкивался в проектах на Go» («The Top 10 Most Common Mistakes I've Seen in Go Projects»). Пост стал популярным: его прочитали более 100 000 человек, он был выбран новостным бюллетенем Golang Weekly как один из лучших за 2019 год. Мне льстили положительные отзывы, которые я получил от сообще- ства Go.
Я понял, что обсуждение типичных ошибок — это мощный инструмент разра- ботки. Сопровождаемый конкретными примерами, он поможет им эффективно осваивать новые навыки, облегчать запоминание как контекста, в котором эти ошибки встречаются, так и способов, позволяющих их избегать.
Около года я собирал примеры типичных ошибок: из профессиональных про- ектов других разработчиков, из репозиториев оленсорсных программ, из книг, блогов, исследований и обсуждений в сообществе Go. Могу сказать, что я и сам был «достойным источником информации» в плане подробных ошибок.
К концу 2020 года размер моей коллекции ошибок достиг 100 штук, и это по- казалось мне подходящим, чтобы предложить идею публикации какому-либо издательству. В результате я связался с Manning, которое считал высококлассным
издательством, публиковавшим качественные книги, — для меня оно стало иде- альным партнером. Потребовалось почти два года и бесчисленное количество итераций, чтобы четко сформулировать суть каждой из 100 ошибок вместе с релевантными примерами и несколькими решениями, где контекст — это ключевой фактор.
Очень надеюсь, что моя книга поможет вам избежать этих распространенных ошибок и улучшить владение языком Go.
Blazodapnoctu
Xoyy bispasutb cboo pnushatelbnocb mhorum jiodam. Moim podnteram - 3a to, yto noldeprkain mehra b tot momenr, kora bo bpema yue6b r oiyrul ce6r tak, kak 6yDTO Haxoxycb b cnyaynnu noinoro npobala. Moemy rdee Kah- Noio Eemohy (Jean- Paul Demont) 3a to, yto nomor yвидetb cbet b kohne tyinheJr. Iberpy Iotbe (Pierre Gautier) 3a to, yto 6bl 3amevatelbnbim BJoxhObureJem u nomor mne noBeputb B ce6r. JAMuehny IIAM6ohy (Damien Chambon) 3a to, yto 3actarJb meHs noctonhno nodnHmatb nJahkny u nodtaJnkbaJ mehra K JyuyneMy. Jlopahy Bephapy (Laurent Bernard) 3a to, yto 6bl 6opasuom Jra nOJpaKahnur u npubeJ meHa K ocoshanuo toro, yto habbiku counabhoro o6nHnus oceHb BaxHbI. Bajentuny JelenJIacy (Valentin Deeleplace) 3a nocJedobatelbnocbts u JoruyHocbts ero uckJIO- yunteJbHO nOJesHbIX OTbSIBOB. Jyry PaJJepy (Doug Rudder) 3a to, yto o6yYuJI meHa ToHKOMy uckycctbny nepJaYu uJeiB nucbMeHnou Opme. Tupdphu TeJJorp (Tiffany Taylor) u K3ru Tenhant (Katie Tennant) 3a BbIcokOkauectebHoe peJaktupOBaHue u koppektypy tekcta, a takxe Tumy bAH Jep3eHy (Tim van Deurzen) 3a rJy6Hny u kaeectbO npodeccnoHahJbHoro peJenHsupOBaHnI.
Xoyy takxe no6JiarOJaprrb KJIapy IIAM6on (Clara Chambon) - moIO JIO6IMyIO MaJenbKyIO KpecTHnIy, BupxHnIu IIAM6on (Virginie Chambon) - mJueJIIIEro yeJIOBeKa Ha cBeTe, BcIO cEbMIO XapIIaHnI, AqpOJnIu KatHKy (Afroditi Katika), CepxHO Tapce3a (Sergio Garcez) u KacInepa BeHTcena (Kasper Bentsen) - 3aMeYatelbnbIX HHxHeHepOB- pa3pa6oTryHkOB, a takxe bce coo6nIeCTbO Go.
Hakoneu, xoteJI 6bi no6JiarOJaprrb cBOux peJenHseHroB: AJaMa BahaJaMaJikeHa (Adam Wanadamaken), AJeccaHJpo Kamneuca (Alessandro Campeis), AJIJeha Iyua (Allen Gooch), AHHpeca Cakko (Andres Sacco), AHyIaMa CehrynIty (Anupam Sengupta), Sopko JxypKobuHa (Borko Djurkovic), EpaJa Xoppokca (Brad Horrocks), KamaJIa Kakapa (Camal Cakar), HaJb3a M. IIeJroHa (Charles
M. Shelton), Kрисa Aiana (Chris Allan), KnuΦpopda Tep6era (Clifford Thurber), Kosmo Jamiaho Iprete (Cosimo Damiano Prete), JbBnJa Kponkautra (David Cronkite), JbBnJa Jkeikooca (David Jacobs), JbBnJa Mopaeka (David Moravec), ΦpHncica Ceraua (Francis Setash), JxahJyuyJxKu CnahbOo (Gianluigi Spagnuolo), Jxysenne Makcua (Giuseppe Maxia), XupoIoku Myuy (Hiroyuki Musha), JxKemca BnIiona (James Bishop), Jxepoma Maiepa (Jerome Meyer), JxOxJia XOMca (Joel Holmes), Jxouarana P. Hovra (Jonathan R. Choate), Iopra Pojen6ypra (Jort Rodenburg), Kitta Kuma (Keith Kim), KebuHa JIro (Kevin Liao), Jeba Baitie (Lev Veyde), Maptnua Jenerpta (Martin Dehnert), Mstra BeJke (Matt Welke), HupaJxka IIaxa (Neeraj Shah), Ockapa Yt6yJrra (Oscar Urbult), Ieitiu Jn (Peiti Li), ΦnJnnna JxahepTka (Philipp Janertq), Po6epra Bemhepa (Robert Wenner), Paiaha Барроуска (Ryan Burrowsq), Paiaha Xy6era (Ryan Huber), CAnketa Haika (Sanket Naik), CaraJpy Poia (Satadru Roy), IIOna J. Bnka (Shon D. Vick), Tada Maiepa (Thad Meyer) u BaaJma Typkoba. Bce baIin npeJxJoxeHnIa n amevanHnI nomorJiu cJelJaTb STy KnIry JyYJIIe.
Книга «100 ошибок Go и как их избежать» содержит описание 100 распространенных ошибок, которые допускают Go- разработчики. Она в значительной степени сосредоточена на самом языке и его стандартной библиотеке, а не на внешних библиотеках или фреймворках. Обсуждения большинства ошибок сопровождаются конкретными примерами, иллюстрирующими те обстоятельства, когда такие ошибки могут совершаться. Эта книга — не какая-то догма. Каждое предлагаемое решение детализировано в той мере, чтобы передать контекст.
Для кого эта книга
Эта книга предназначена для разработчиков, уже знакомых с языком Go. В ней не рассматриваются его основные понятия — синтаксис или ключевые слова. Предполагается, что вы уже занимались реальным проектом на Go. Но прежде чем углубляться в большинство конкретных тем, удостоверимся, что некоторые базовые вещи понимаются ясно и четко.
Структура книги
Книга состоит из 12 глав:
Глава 1 «Go: просто научиться, но сложно освоить» объясняет, почему, несмотря на то что Go считается простым языком, его нелегко освоить досконально. В ней также приведены типы ошибок, которые мы рассмотрим в книге.
Глава 2 «Организация кода и проекта» содержит описание распространенных ошибок, которые могут помешать организовать программный код чистым, идио- матичным, удобным для дальнейшей обработки и поддержки образом.
В главе 3 «Типы данных» обсуждаются ошибки, связанные с основными типами, срезами и картами.
В главе 4 «Управляющие структуры» исследуются распространенные ошибки, связанные с циклами и другими управляющими структурами.
В главе 5 «Строки» рассматривается принцип представления строк и связанные с ним распространенные ошибки, приводящие к неточности или неэффективности кода.
В главе 6 «Функции и методы» обсуждаются распространенные проблемы, связанные с функциями и методами, такие как выбор типа получателя и предотвращение распространенных ошибок отложенного выполнения (defer).
В главе 7 «Обработка ошибок» рассматривается идиоматическая и точная обработка ошибок в Go.
В главе 8 «Конкурентность: основы» представлены основные концепции конкурентности. Мы разберем, почему конкурентность не всегда быстрее, в чем различия между конкурентностью и параллелизмом, а также обсудим типы рабочей нагрузки.
В главе 9 «Конкурентность: практика» рассмотрены примеры ошибок, связанных с конкурентностью при использовании каналов, горутин и других примитивов Go.
Глава 10 «Стандартная библиотека» содержит описание распространенных ошибок, допускаемых при использовании стандартной библиотеки с HTTP, JSON или (например) time API.
В главе 11 «Тестирование» обсуждаются ошибки, которые делают тестирование и бенчмаркинг менее универсальными, эффективными и точными.
Глава 12 «Оптимизация» завершает книгу. В ней исследуются способы того, как оптимизировать приложение для повышения его производительности, — от понимания основ функционирования центрального процессора до конкретных тем, связанных с Go.
O коде в книге
Книга содержит множество примеров исходного кода как в нумерованных листингах, так и в тексте. В обоих случаях исходный код форматируется монouрин- ным широтом, в отличие от обычного текста. Иногда для кода также применяется
xuphiu upuT, uTo6bi bblJelJutb dpammenbI, usmenbHbHueca no cpaHheHnIc npe- bblJyHnMn nharan, - hanpmer, npu do6abJenHnHn HOBa 4yHKHnHONaJIbHocTn b cyHHeCTbYIOIIyIyO cTpOky koJa.
Bo MHorux cJyvaax opnHnHnJIbHaar bepcnI ucxOJHOro koJa nepedopmatHpyeTcra; do6abJIHOTcra pasbHbH cTpOK u usmenenHbHe OTCTyNbI, uTO6bi koJ nOmeHnJIcHa cTpHahHHe. HHorJa Jaxke SToro OKa3bIBaEcra HeJIOCTaTOHHO u B JnctnHnIu BkJIIOvaIOCTa Mapkepbi npOJIOJIxHeHnIa cTpOK $(\Rightarrow)$ . Takxke us ucxOJIHOro koJa vaCTO yJaJIHOTcra KOMMeHTapHn, ecJIu koJ OIIHCbIBaEcra B TeKCTe.
IcHIOJIHReMebIe dpammenbI koJa MOxHO 3arpy3HTb us bepcnI liveBok (3JIeKTPOHnH) nO aJpecy hHtps://livebook.manning.com/book/100- go- mistakes- how- to- avoid- them. IOJIHbHbI koJ npuMEpOb KHnHn IocTyHnE JJIa 3arpy3Ku Ha caHre Manning nO aJpecy hHtps://
www.manning.com/books/100- go- mistakes- how- to- avoid- them u GitHub hHtps://github.com/teivah/100- go- mistakes.
Oopym liveBook
Ipuo6petaa KHnry <100 OIIH6OK Go u KaK HX H36eKaTb>, BbI nOJIyvaEcTe 6ecIIaTHbHbI doCTyIN K 3aKpbITOMy Be6- opyMy H3JIaTeJIbCTBa Manning (Ha aHrJIuIbCKOM 33bIKe), Ha KOTOpOM MOxHO OCTaBJIbTb KOMMeHTapHnO KHnRE, 3aJaBaTH TeXHnYeCKHe BOIIpOcbI u nOJIyVaTb nOMOIIb OT aBTOpa u JpyrIX nOJIb3OBaTeJIeI. UTO6bi nOJIyVHTb doCTyIN K opyMy, OTKpOuTe CTpaHnIy hHtps://livebook.manning.com/book/100- go- mistakes- how- to- avoid- them/discussion. IHHopMaHnHO o oporyMaX Manning u npaBaIJax nOBeJehHnI Ha HINX CM. Ha hHtps://livebook.manning.com/#!/discussion.
B pAMKaX cBOUX o63a3TeJIbCTb nepeJ YHTaTeJIaMn H3JIaTeJIbCTBO Manning npeJOCTaBaJIeT pecypc JJIa cOJIepKaTeJIbHOro o6IIeHnIa YHTaTeJIeI u aBTOPOB. OTu o63a3TeJIbCTBa He nOJIpa3yMeBaIOt KOHKpETHyIO CTeneHb yIaCTnIa aBTOpa, KotOpoe OCTaEcTa aO6pOBOJIbHbIM (u HeOJIJIaYbHaeMebIM). 3aJaBaIHTe aBTOpy XOpOIIHe BOIIpOcbI, uTO6bI OH He TeprJI uHHTepeca K nPOHcXOJIaIIeMv! OOpyM u aPXUBbI o6cyKJIeHHI aOCTyINbHb Ha Be6- caHre H3JIaTeJIbCTBa, nOKa KHnIa npOJOJIxKeaT H3JIaBaTaBcra.
TEÍВА ХАРШАНИ — старший инженер-программист в Docker. Работал в области страхования, транспорта и в отраслях, где критически важна безопасность, например в управлении воздушным движением. Увлечен языком Go и тем, как разрабатывать и реализовывать на нем надежные приложения.
Иллюстрация на обложке
На обложке книги — рисунок под названием «Femme de Bucciari en Croatia» («Женщина из Бакара, Хорватия»).
Иллюстрация взята из выпедшего в 1797 году каталога национальных костю- мов, составленного Жаком Грассе де Сен-Савьером. Каждая иллюстрация этого каталога тщательно прорисована и раскрашена от руки. В прежние времена по одежде человека можно было легко определить, где он живет и какова его профессия или положение. Manning отдает дань изобретательности и иници- ативности компьютерных технологий, используя для своих изданий обложки, демонстрирующие богатое вековое разнообразие региональных культур, ожи- вающее на изображениях из собраний, подобных этому.
Om издательства
Ваши замечания, предложения, вопросы отправляйте по адресу
comp@piter.com (издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства
www.piter.com вы найдете подробную информацию о наших книгах.
Go: просто научиться, но сложно освоить
Bэтой главе:
- Что делает Go эффективным, масштабируемым и производительным языком- Почему языку Go просто научиться, но овладеть им по-настоящему сложно- Общее описание распространенных типов ошибок, допускаемых разработчиками
Ошибаться свойственно всем. Как сказал Альберт Эйнштейн:
Тот, кто никогда не совершал ошибок, тот никогда не пробовал что-то новое.
В конце концов, важно не количество совершенных ошибок, а наша способность учиться на них. Это утверждение относится и к программированию. Мастерство, которое мы приобретаем, — это не волшебство. Мы делаем множество ошибок и учимся на них. Это основная мысль книги. Мы рассмотрим и изучим 100 распространенных ошибок, которые допускаются во многих сферах использования языка Go, и это поможет вам стать более опытным программистом.
В главе 1 мы кратко расскажем, почему Go с годами стал одним из основных и стандартных инструментов работы. Мы обсудим, почему, несмотря на то что Go считается простым в изучении, овладение его нюансами может быть весьма сложным. Наконец, познакомимся с основными понятиями из этой книги.
1.1. GO: OCHOBНЫЕ МОМЕНТЫ
Если вы читаете нашу книгу, то, скорее всего, уже «подсели» на Go. Поэтому в этом разделе будет только краткий обзор, призванный напомнить, что делает Go таким мощным языком.
Отрасль разработки программного обеспечения (ПО) за последние десятилетия значительно изменилась. Большинство современных систем больше не создается одним человеком. Все они — результат работы команд, состоящих из многих программистов, а иногда даже из сотен, если не тысяч. Написанный программный код должен быть читабельным, выразительным, удобным в сопровождении, чтобы обеспечивать надежную работу системы на протяжении многих лет. С другой стороны, в нашем быстро меняющемся мире максимальное повышение гибкости и сокращение времени выхода на рынок очень важны для большинства компаний. Программирование тоже должно следовать этой тенденции, поэтому компании стремятся к тому, чтобы программы работали максимально продуктивно при чтении, написании и сопровождении кода.
В ответ на эти вызовы и требования в 2007 году компания Google создала язык Go. С тех пор многие организации приняли его для использования в различных областях программирования: в API, автоматизации, базах данных, интерфейсах командной строки и т. д. Сегодня многие считают Go одним из основных языков для разработки облачных систем.
Что касается функциональности, то в Go нет наследования типов, исключений, макросов, частичных функций, поддержки ленивых вычислений или неизме- няемости, перегрузки операторов, сопоставления шаблонов и т. д. Почему? Вот что об этом говорит официальный FAQ по Go (
https://go.dev/doc/faq):
Почему в Go нет какой-то функции X? Ваша любимая функция может от- сутствовать, поскольку не вписывается в логику или структуру языка, влияет на скорость компиляции или ясность дизайна кода либо просто потому, что сделала бы фундаментальную модель системы слишком сложной.
Оценка качества языка программирования на основании количества функций в нем, вероятно, некорректна. По крайней мере для Go эта метрика не главная.
При оценке адекватности использования языка в масштабе какой-то организации используют несколько важных характеристик. К ним относятся:
- Стабильность. Несмотря на то что в Go вносятся частые изменения (направленные на улучшение самого языка и устранение уязвимостей с точки зрения безопасности), он остается достаточно стабильным языком. Некоторые считают это качество одной из лучших особенностей языка.
- Выразительность. Мы можем определить выразительность языка по тому, насколько написание и чтение кода отвечает представлениям о естественности и интуитивной понятности. Уменьшенное количество ключевых слов и ограниченные способы решения общих проблем делают Go выразительным языком для больших кодовых баз.
- Компляция. Что может быть более раздражающим для разработчиков, чем долгое ожидание сборки для тестирования приложения? Стремление к быстрой компиляции всегда было сознательной целью разработчиков языка. А это основа высокой производительности.
- Безопасность. Go – надежный язык со статической типизацией. Следователь- но, у него есть стротие правила времени компиляции, которые в большинстве случаев обеспечивают безопасность типов.
Go был создан с нуля с очень полезными функциями: с примитивами конку- рентности, горугинами и каналами. Ему особо не нужно полагаться на внешние библиотеки для создания эффективных конкурентных приложений. Наблюдение за тем, насколько важна конкурентность в наши дни, также показывает, почему Go сейчас самый подходящий язык и будет оставаться им в обозримом будущем.
Некоторые считают Go простым языком, и отчасти это правда. Например, новичок может разобраться с его основными возможностями менее чем за один день. Возникает вопрос: зачем же изучать книгу, посвященную систематизации ошибок в Go, если он так прост?
1.2. ПРОСТО НЕ ОЗНАЧАЕТ ЛЕГКО
Между понятиями «просто» и «легко» есть тонкая разница. «Простой» применительно к технологии означает несложный для изучения или понимания. «Легкость» означает возможность добиваться чего угодно без особых усилий. Go прост в изучении, но не всегда легок в освоении.
Возьмем, к примеру, конкурентность. В 2019 году было опубликовано исследо- вание, посвященное ошибкам конкурентности: «Понимание реальных ошибок
конкурентности в Go».1. Это исследование было первым систематическим анализом ошибок конкурентности. Оно опиралось на данные нескольких популярных репозиториев Go - Docker, gRPC и Kubernetes. Один из самых важных выводов заключается в том, что большинство блокирующих ошибок вызвано неточным использованием парадигмы передачи сообщений (message passing) по каналам, несмотря на убеждение, что передача сообщений легче обрабатывается и менее подвержена ошибкам, чем разделяемая память.
Какой должна быть реакция на такой вывод? Должны ли мы считать, что разработчики языка ошибались насчет передачи сообщений? Должны ли мы пересмотреть использование конкурентности в нашем проекте? Конечно нет.
Это не вопрос противопоставления передачи сообщений разделяемой памяти и выявления из них «победителя». Но разработчики Go должны хорошо понимать, как использовать конкурентность, каково ее влияние на современные процессоры, когда следует предпочтеть один подход другому и как избежать при этом попадания в типичные ловушки. Этот пример подчеркивает, что хотя каналы и горутины могут быть простыми для изучения, на практике это совсем не просто.
Понятие «просто не значит легко» можно обобщить на многие аспекты Go, а не только на конкурентность. И чтобы стать опытными Go- разработчиками, нужно хорошо разбираться во всех его аспектах. А это требует времени, усилий и ошибок.
Цель книги - помочь ускорить наш путь к мастерству, рассмотрев 100 ошибок в Go.
1.3. 100 OllmBok B Go
Почему следует прочитать эту книгу? Почему бы вместо этого не углубить знания с помощью «обычной» книги, которая достаточно подробно рассматривает разные темы?
В статье, опубликованной в 2011 году, нейробногоги доказали, что столкновение с ошибками - это лучшие моменты для развития способностей нашего мозга2.
Все мы проходили через процесс обучения на какой- то ошибке, вспоминая этот случай через месяцы или даже годы, когда с ним был связан какой- то контекст. В статье Джанет Меткалф (Janet Metcalfe) говорится, что это происходит потому, что ошибки оказывают стимулирующее воздействие! Суть в том, что мы можем помнить не только саму ошибку, но и ее контекст. И поэтому обучение на ошибках так эффективно.
Чтобы усилить этот эффект, в книге каждая рассматриваемая типичная ошибка подкреплена примерами из реальной практики. Эта книга не только о теории, она поможет избежать ошибок и принимать взвешенные, осознанные решения.
Скажи мне, и я забуду. Науи меня, и я запомню. Вовлеки меня, и я научусь.
Неизвестный автор
Здесь представлены семь основных категорий ошибок, которые можно классифицировать как:
- баги;- излишнюю сложность;- плохую читаемость;- неоптимальную или нециноматическую организацию;- отсутствие удобства в API;- неоптимизированный код;- недостаточную производительность.
Далее я дам краткое описание каждой категории ошибок.
1.3.1. Баги
Первый и, возможно, самый очевидный тип — это ошибки в исходном коде. В 2020 году исследование, проведенное Synopsys, оценило стоимость багов в ПО только в США более чем в 2 триллиона долларов².
Баги могут приводить и к трагическим последствиям. Вспомним случай с аппаратом для лучевой терапии Theraç- 25 производства компании Atomic Energy of Canada Limited (AECL). Из- за состояния гонки машина дала своим пациен- там дозы облучения, которые в сотни раз превышали ожидаемые, что привело к смерти трех пациентов. Этот пример показывает, что баги могут повлечь за собой не только денежные потери. И мы, как разработчики, должны помнить, насколько важна наша работа.
Я рассмотрю множество случаев, которые могут привести к различным багам, включая гонки данных, утечки, логические ошибки и др. Хотя точные тесты и должны обнаруживать такие ошибки как можно раньше, иногда мы можем пропускать их из- за различных факторов, например из- за нехватки времени или их сложности. И разработчику важно убедиться, что для устранения таких багов сделано все возможное.
1.3.2. Излишняя сложность
Следующая категория ошибок связана с излишней сложностью. Значительная часть сложности ПО вызвана тем, что разработчики стремятся думать о своем воображаемом будущем. Вместо того чтобы решать конкретные задачи прямо сейчас, может возникнуть соблазн создать «эволюционирующее» ПО, которое будет пригодным для любого будущего варианта использования. В большинстве случаев это приводит к тому, что объем недостатков превышает число преимуществ, что делает код сложным для понимания и анализа.
Возвращаясь к Go, можно вспомнить множество примеров того, как у разработчиков возникает соблазн разработать абстрактные функции для будущего, например интерфейсы или дженерики. В этой книге обсуждаются примеры, когда следует проявлять особую осторожность, чтобы не переусложнить код.
1.3.3. Плохая читаемость
Как написал Роберт Мартин (Robert Martin) в книге «Clean Code: A Handbook of Agile Software Craftsmanship»1, соотношение времени, затрачиваемого на чтение и написание когда, значительно превышает 10 : 1. Большинство из нас начинали программировать в собственных проектах, где удобочитаемость не так важна. Но сегодняшняя разработка ПО — это программирование во временном
измерении: нужно убедиться, что с приложением все еще можно работать и под-держивать его спустя месяцы, годы или, возможно, даже десятилетия после релиза.
При программировании на Go можно наделать много ошибок, которые затруднят читаемость кода. Среди таких ошибок может быть и вложенный код, и представ- ления типов данных, а иногда и использование неименованных результатирующих параметров. На протяжении этой книги мы будем учиться писать читаемый код и заботиться о его будущих читателях (в частности, о себе).
1.3.4. Неоптимальная или неидиоматическая организация
Другой тип ошибки — это неоптимальная или неидиоматическая организация кода и проекта. Такие проблемы могут затруднить анализ и дальнейшую под-держку проекта. В этой книге рассмотрены некоторые из распространенных ошибок такого рода. Например, мы увидим, как структурировать проект и об-ращаться с пакетами утилит или функциями инициализации. Рассмотрение этих ошибок поможет организовать код и проекты более эффективно и идиоматично.
1.3.5. Отсутствие удобства в API
Распространенные ошибки, снижающие удобство API для наших потребите- лей, — это еще один тип. Если API неудобен для пользователя, он будет менее выразительным и, следовательно, более трудным для понимания и более под- верженным дальнейшим ошибкам.
Такие ошибки встречаются во многих ситуациях и могут заключаться в чрез- мерном использовании типа апу, в использовании неправильных порождающих паттернов при работе с опциями или в слепом применении стандартных методов объектно-ориентированного программирования, что влияет на удобство исполь- зования API. Мы рассмотрим распространенные ошибки, мешающие передавать в распоряжение наших пользователей удобные для них API.
1.3.6. Неоптимизированный код
Код, оптимизированный в недостаточной степени, — еще один тип ошибок раз- работчиков. Их можно сделать по разным причинам, например из-за непонима- ния особенностей языка или даже из-за отсутствия фундаментальных знаний.
Hедостаточная производительность — одно из наиболее очевидных последствий этой ошибки, но не единственное.
Оптимизация кода полезна и для точности. Например, в этой книге представ- лены некоторые распространенные методы, обеспечивающие высокую точность операций с плавающей точкой. Мы также рассмотрим множество случаев, которые могут негативно сказаться на производительности кода, например, из-за недостаточного распараллеливания задач, незнания того, как уменьшать использование ресурсов памяти, или влияния выравнивания данных. Поговорим о вопросах оптимизации под разными углами.
1.3.7. Недостаточная производительность
В большинстве случаев мы задаемся вопросом: какой язык лучше всего выбрать для конкретного нового проекта? Ответ: тот, с которым мы работаем наиболее продуктивно. Для достижения мастерства очень важно знать, как работает язык, и использовать его по максимуму.
Мы рассмотрим конкретные примеры, которые помогут стать продуктивными при работе на Go. Например, написание эффективных тестов для обеспечения работоспособности кода, использование стандартной библиотеки для повышения эффективности, а также извлечение максимальной пользы из инструментов профилирования и литеров. Пришло время разобраться в этих 100 распространенных ошибках Go!
ИТОГИ
- Go — это современный язык программирования, который позволяет повысить производительность разработчиков, что сегодня крайне важно для большинства компаний.- Go прост в изучении, но нелегок в освоении. Поэтому важно углубить свои знания, чтобы использовать его наиболее эффективно.- Обучение на разборе ошибок и на конкретных примерах — это мощный способ овладеть языком. Книга на примерах разбора 100 распространенных ошибок ускорит путь к профессиональному мастерству.
Opeanusauus koda u npoekma
Bэтой главе:
- Удцоматическая организация кода- Эффективная работа с абстракциями: интерфейсы и дженерики- Как структурировать проект: лучшие практики
Cделать текст кода в Go чистым, идиоматичным и удобным для сопровождения — непростая задача. Чтобы понять суть лучших практик, связанных с написанием кода и организацией проекта, потребуется накопить определенный опыт и набить пишки. Каких ловушек следует избегать (например, затенения переменных и злоупотребления вложенным кодом)? Как структурировать пакеты? Когда и где использовать интерфейсы или дженерики, функции инициализации и пакеты утилит? Рассмотрим распространенные ошибки в организации кода.
2.1. ОШИБКА #1: НЕПРЕДНАМЕРЕННО ЗАТЕНЯТЬ ПЕРЕМЕННЫЕ
Область видимости переменной — это те места кода, в которых можно ссылать- ся на эту переменную, другими словами, та часть приложения, где действует привязка имени. В Go имя переменной, уже объявленное во внешней области
Bицимости, mжет быть повторно объявлено во внутренней области видимости. Такая ситуация называется затенением переменной и может приводить к рас- пространенным ошибкам.
B пример ниже показан непреднамеренный побочный эффект из- за наличия затененной переменной. В этом фрагменте кода HTTP- клиент создается двумя разными способами, в зависимости от булева значения tracing:
var client *http.Client 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06 if tracing { client, err := createClientWithTracing() if err != nil { return err } log.Println(client) } else { client, err := createDefaultClient() if err != nil { return err } log.Println(client) } // Использование переменной client
B этом примере в самом начале объявляется переменная client. Затем мы используем краткий оператор присваивания переменной $(=)$ в обоих внутренних блоках, чтобы присвоить результат вызова функции внутренним переменным client, а не внешней переменной client. В результате оказывается, что внешняя переменная всегда равна нулю.
Примечание Этот код компицируется, поскольку внутренние переменные client используются в вызовах логирования. В противном случае появлялись бы ошибки компиляции: client declared and not used.
Как обеспечить присвоение значения именно исходной переменной client? Есть два варианта.
var client *http.Client if tracing { c,err $\equiv$ createClientWithTracing() 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06- 06. if err != nil { return err } client $= \mathbb{C}$ 1 else{ // Ta xe noruka }
3десь мы присваиваем результат временной переменной с, область видимости которой находится только в пределах блока if. Затем присваиваем его обратно переменной client. То же делаем для блока else.
Во втором варианте используется оператор присваивания (=) во внутренних блоках для непосредственного присвоения результатов функции переменной client. Но для этого нужно создать переменную error, поскольку оператор присваивания работает только в том случае, если имя переменной уже было объявлено. Например:
var client *http.Client var err error 06bABrAetca nepemehan err if tracing{ client, err $=$ createClientWithTracing() Icnonb3yetca оперator prucbaBanhur, if err $! =$ nil{ 4to6bi hanprmyio prucBoutb nepemehoH return err client 3havemHe, BosBpaUaemoe *http.Client } else{ // Ta xe noruka }
Чтобы не присваивать значение временной переменной, мы можем напрямую присвоить результат переменной client.
Оба способа вполне допустимы. Основное различие между ними заключается в том, что во втором варианте мы выполняем только одно присваивание, что можно считать более легким для чтения. Кроме того, со вторым вариантом можно объединить и реализовать обработку ошибок вне блоков операторов if/ else, как показано в следующем примере:
if tracing { client, err = createClientWithTracing() } else { client, err = createDefaultClient() } if err != nil { // Типичная обработка ошибок }
Затенение переменной происходит, когда ее имя повторно объявляется во внутренней области видимости, но мы видели, что эта практика чревата ошибками. Установка правила, запрещающего затененные переменные, зависит от личного вкуса. Иногда бывает удобно повторно использовать существующее имя, например err, для обозначения тех переменных, которые так или иначе связаны с ошибками. Но следует быть начеку, потому что теперь мы знаем, что можем столкнуться со сценарием, когда код компилируется, но переменная на самом
dene noiyyaeet shayehne, otinuaioleece ot oaxujaemoro. Iosxe b stoi rJabe Mb paccmotpum, kak ohaayyknubats satenehnbie nepemenhbie.
B cJedyioem pasdje nokasaho, noyemy baxko he 3JoynortpeoJrTb bJoxehnbiM kOJOM.
2.2. OlluBKA #2: JIuHHiN BJoxeHbNbI KOA
Mehtalbhar moJelb, otnocraarca k konkpetnomy nportpammному npoJykty, npectabraeret coou biytpenee mbcJehhoe npectabrenhe o tom, kak beJet ce6a cucTema. Ipu nporpammuopbании hyxho npueprxubatbca takux mehtalbHbix moJelue (hanpимер, o6uux b3aHmOdeicTbHbI b koJe u paJusauHax hyHkHii). Koj cHHTaetcr yJooO4HTaEMbIM no MHoxecrty kpHrepeB ucnoJb3oBaHue uMeH/ HaBaHnii, corJacOBaHHOcTb, cootBecTbJyIOUee dopMaTupOBaHue u T. J. Huta6eJb- Hbii kOJ tpe6yet meHbHie korHHTUBHbIX ycuJnii JnIa noHUMaHnIe ero cootBecTbHbI MeHTaJbHouM oJelJn, noJxOyM yro Jerey uHTaTb u cOJpOBOKJaTb.
Baxkheiiiiuicnck yJooO4HTaEMOCTH - 3TO pAkcTOp KJyHCTBa BJoxeHbIX ypoBHei. IPeJIOJoxkM, yTO Mbl pa6otaeM HaJ HOBbIM npoekTOM u hyxHO nOHrTb, YTO dJTaet cJedyioHax hyHkHnJn join:
func join(s1, s2 string, max int) (string, error) { if s1 == "" { return "", errors.New("s1 is empty") } else { if s2 == "" { return "", errors.New("s2 is empty") } else { concat, err := concatenate(s1, s2) << oJH6Ku if err != nil { return "", err } else { if len(concat) > max { return concat[
], nil } else { return concat, nil } } } } func concatenate(s1 string, s2 string) (string, error) { //... }
func concatenate(s1 string, s2 string) (string, error) { //... }
Эта функция join объединяет две строки и возвращает подстроку, если длина больше максимальной. Кроме того, она обрабатывает проверки s1 и s2 и проверяет, возвращает ли вызов concatenate ошибку.
C точки зрения реализации функциональности все сделано правильно. Но выстраивание ментальной модели, охватывающей все различные случаи, скорее всего, будет непростой задачей. Почему? Из- за количества вложен- ных уровней.
Посмотрим на код, выполняющий ту же функцию, по реализованный по- другому:
func join(s1, s2 string, max int) (string, error) { if s1 == "" { return "", errors.New("s1 is empty") } if s2 == "" { return "", errors.New("s2 is empty") } concat, err := concatenate(s1, s2) if err != nil { return "", err } if len(concat) > max { return concat[
], nil } return concat, nil } func concatenate(s1 string, s2 string) (string, error) { // ... }
Вы, наверное, заметили, что выстраивание ментальной модели в этой новой версии кода требует меньше когнитивного напряжения, хотя код выполняет то же самое, что и раньше. Здесь есть только два вложенных уровня. Как упомянул Мэт Райер (Mat Ryer), эксперт, участвующий в дискуссии подкаста Go Time (
https://medium.com/@matryer/line- of- sight- in- code- 186dd7cdea88):
Выровняйте «счастливый путь» (happy path) по левому краю — так вы сможете быстро просмотреть, что происходит ниже на каком- то одном уровне и увидеть, что на нем ожидаемо выполняется.
В первой версии выполнения этого упражнения было сложно определить, что из ожидаемого выполняется, из- за вложенных операторов if/else. И наоборот, вторая версия требует просмотра вниз первого уровня, чтобы увидеть поток
BbIIOJIHEMbIX DEICTBNI, I BTOPOYOPOBH, YTO6bI YBUJETb, KAK O6pa6aTbIBaIOcA nOrpahIyHbIe cJIyuaI, KAK nOKa3aHO Ha puc. 2.1.
Puc. 2.1. YTO6bI nOHA7b, YTO BxOJHT B OXHJaEMbIy NOTOK BbIIOJIHEMbIX DEICTBNI, HYXHO nPOCMOHTeTb CTOn6Eu
Kak npaBUNIO, yEM OOJIbIe BJIoxeHHbIX yPOBHHeI Tpe6yET qYHKIIJ, TEM cJIoxkHee ee yHTaTb II nOHHMaTb. PacCMOTpHM HEcKOJIbKO pa3JIyHHbIX nPIHHeHeHnI STOro npaBUNIA, YTO6bI OHTUMH3IPOBaTb KOJI JJIa yJI06CTBa YTeHnI:
KorJa nPOUCxoJUT Bo3BpaT H3 OJIOKa If, cJIeJIyET BO Bcex cJIyuaRx ONyCKaTb OJIOK else. HaIpHMEp, Mbl He JOIJIKHbI nIcATAb:
if foo(){ //... return true }else{ //... }
BMeCTO STOro cJIeJIyET ONyCTHb OJIOK eIse, KAK nOKa3aHO JJIcEB:
if foo(){ //... return true } //...
Bo BTOPOU BEpcHn I OTOro bparMeHTa KOJI, HaxOJIbHIIIIICa B OJIOKe eIse, nepeMeIIaTeTcH Ha BEpXHHIy pOBOeHb, YTO yIPOIIaET eTO YTeHMe.
- Moжно следовать этой логике в случае с путем, не являющимся «счастливым»:
if s != "" { // ... } else { return errors.New("empty string") }
Здесь пустая переменная s определяет путь, не являющимся «счастливым». Поэтому нужно изменить это условие так:
if s == "" { — Изменение условия B if return errors.New("empty string") } // ...
Эту версию кода читать легче, потому что она показывает «счастливый» путь на левом краю и уменьшает количество блоков.
Написание читаемого кода — важная задача для каждого разработчика. Стремление уменьшить количество вложенных блоков, выравнивание счастливого пути по левому краю и возврат как можно раньше — это конкретные средства для улучшения читабельности кода.
Далее обсудим типичные ошибки в проектах Go, связанные с неправильным использованием функции инициализации.
2.3. ОШИБКА #3: НЕПРАВИЛЬНО ИСПОЛЬЗОВАТЬ ФУНКЦИЮ ИНИЦИАЛИЗАЦИИ
Иногда в приложениях Go неправильно используются функции инициализации. Потенциальные последствия — трудности в отслеживании и обработке ошибок или сложный в понимании код. Освежим наше представление о том, что такое функция инициализации, а затем рассмотрим, когда ее использование уместно.
2.3.1. Концепция
Функция инициализации (init) — это функция, используемая для инициализации состояния приложения. Она не имеет аргументов и не возвращает результата
(ΦyHKIuA func()).KorJa naket uHHIIuAJIu3upyEcA, oIeHnIaIOTCA Bce oObRbJIeHnIa KOHCTaHT u nEpemEHbIX B nAkete. 3atem BbIIOJIHIOCTa ΦyHKIuu uHHIIuAJIu3aIIu. Bot nPимер uHHIIuAJIu3aIIu nAketa main:
package main import "fmt" var a $=$ func() int{ fmt.Println("var") IcnonHreTCa B nepByO Oepepb return 0 }() func init(){ fmt.Println("init") IcnonHreTCa Bo BtopyO Oepepb } func main(){ fmt.Println("main") IcnonHreTCa B nocnEHHO Oepepb }
IcnonHHeHHe Koa STOrO nPимерa BbIBeJET CJIeJIYIOIIee:
var init main
ΦyHKIuA init BbIIOJIHreTCa npu uHHIIuAJIu3aIIu nAketa. B CJIeJIYIOIIeM nPимерe MbI OnpeJeJIeM JBa nAketa - main u redis, rJe main sависит ot redis. Chavала main.go u3 OCHOBHOrO nAketa:
package main import("fmt" "redis") func init(){ //... } func main(){ err : $=$ redis.Store("foo", "bar") Yka3aHHe Ha saBисHMOCTb OT naketa redis //... }
A затем redis.go из пакета redis:
package redis
// imports
func init() { //...}
func Store(key, value string) error { //...}
Поскольку main зависит от redis, сначала выполняется функция инициализации в пакете redis, затем — в основном пакете, а затем сама функция main. На рис. 2.2 показана эта последовательность.
Мы можем определить несколько функций инициализации init для каждого пакета. В таком случае последовательность выполнения функции инициализации внутри пакета задается алфавитным порядком исходных файлов. Например, если пакет содержит файл a.go и файл b.go и в обоих содержится функция инициализации, то первой выполняется та из них, что находится в a.go.
[ImageCaption: Пример с функциями инициализации]
Рис. 2.2. Сначала выполняется функция инициализации init из пакета redis, затем функция инициализации init из пакета main и, наконец, сама функция main
Не следует слишком сильно полагаться на такой порядок выполнения функций инициализации внутри пакета — это может быть опасно, ведь исходные файлы могут быть переименованы и это может повлиять на порядок выполнения функций init.
Мы также можем определить несколько функций init в одном исходном файле. Например, такой код вполне допустим:
package main
import "fmt"
func init() { $\leftarrow$ Первая функци init fmt.Println("init 1") } func init() { $\leftarrow$ Первая функци init fmt.Println("init 2") }
func main() { }
Первая выполненная функция init является первой в исходном порядке. Вот вывод этого кода:
init 1 init 2
Мы также можем использовать функции инициализации init для реализации побочных эффектов. В следующем примере мы определяем пакет main, который не имеет сильной зависимости от foo (например, нет прямого использования публичной функции — public function). Но в примере требуется, чтобы пакет foo был инициализирован. Мы можем сделать это, используя оператор _:
package main
import ( "fmt" _"foo" Mmoprom foo octnraetcr no6owhbi 3pekt ) func main(){ // }
Bэтом случае пакет foo инициализируется перед main. Следовательно, функции инициализации (init) в foo выполняются.
Другой аспект функции init в том, что ее нельзя вызвать напрямую, как в следующем примере:
package main
func init() {}
func main() { init().
$\exists \mathrm{tot}$ KOL BHIJACT OHHIFSKY KOMIIHJIHIIH:
()$ go build . ./main.go:6:2: undefined: init
Tenepb, koraMaI oCbeKuJIN B nAmATn npeJCTaBLeHHe o pa6oTe pyHKIuN init, noCMOrpHM, kora a cJeJyET HX uCIOJIb3OBaTb.
2.3.2. Korpa ucnOJIb3OBaTb pyHKIu init
PaccmOrpHM nIyMep yMocTHoro ucnOJIb3OBaHnI: yJepxaHHe nYJa coeJHHHeHHI c 6a3oH JAHHbIX. B pyHKIin init oTKpBbAeTcA 6a3a JAHHbIX c nOMOIIbIO sQl.Open. Mbl saJaem Aty 6a3y JAHHbIX KaK rJIO6aJIbHyIO nepemENHyIO, KoTOpyIO nO3Xe MoJyT ucnOJIb3OBaTb JpyTnue pyHKIuIN:
var db *sql.DB
func init() { dataSourceName := os.Getenv("MYSQL_DATA_SOURCE_NAME") → Переменная среды d, err := sql.Open("mysql", dataSourceName) if err != nil { log.Panic(err) } err = d.Ping() if err != nil { log.Panic(err) } db = d → OnpepeNerT cB3b DB cro6aJIbHOH nepemENHOH db
Mbl oTKpBbAeM 6a3y JAHHbIX, npOBepreM, moxeM JIN ee npOHHroBaTb, a satem cB3bBaem ee c rJIO6aJIbHOH nepemENHOH. YTO moxHO cKa3aTb o takoH peaJINsauIN? OnuIIem три ee ochObHbIX HeJIOCTaTKa.
IpexEJe Bcero, o6pa6oTKa OHH6ok B pyHKIin uHHIIaJIb3aIINH HocIT oRpHn- vennbHbI Xapakrep. Ae6icbHteJIbHO, nOccOJIbKy pyHKIIN uHHIIaJIb3aIINH He bblJaet coo6IIeHnIa o6 OHH6Kax, eJHHCTBeHHbIH cIIOCO6 coo6IIuTb O BO3MOXHOH OHH6Ke - bbl3BaTb npepbBaHHe nO panic, YTO npHBeJET K OCTaHOBke bblIOJIHeHnIa
приложения. В нашем примере остановить приложение можно в любом случае, если не удается открыть базу данных. Но решение о такой остановке не обяза- тельно должно приниматься самим пакетом. Возможно, вызывающая сторона предпочла бы реализовать повторную попытку или использовать резервный механизм. В этом случае открытие базы данных в функции инициализации не позволяет клиентским пакетам реализовать свою логику обработки ошибок.
Другой важный недостаток связан с тестированием. Если мы добавим в этот файл тесты, функция инициализации будет выполняться перед запуском тесто- вых случаев, что не обязательно будет тем, что нужно (например, если добавить юнит- тесты в служебную функцию, которая не требует создания такой связи). Поэтому функция init в этом примере усложняет написание юнит- тестов.
Последний недостаток заключается в том, что в примере требуется присвоить пул соединений базы данных глобальной переменной. Глобальные переменные имеют ряд серьезных недостатков, например:
- Внутри пакета глобальные переменные могут изменяться любыми функциями.
- Юнит-тесты могут быть более сложными, поскольку функция, зависящая от глобальной переменной, больше не будет изолирована.
В большинстве случаев следует инкапсулировать переменную, а не сохранять ее глобальной.
По этим причинам предыдущую инициализацию, скорее всего, лучше будет обрабатывать как часть простой старой функции, например:
func createClient(dsn string) (*sql.DB, error) 1 PpHnMnAaetcrMnActHnHnKaJahHbix db, err : $=$ sql.Open("mysql",dsn) 11 BoSBpauaetcr*sql.DB u ouuoka if err $! =$ nil{ return nil, err BoSBpauaetcrAouuoka } if err $=$ db.Ping(); err $! =$ nil{ return nil, err } return db, nil }
Используя эту функцию, мы устранили основные недостатки, о которых говорили ранее, следующим образом:
-
Ответственность за обработку ошибок возлагается на вызывающую функцию.
-
Появляется возможность создать интеграционный тест для проверки, работает ли эта функция.- Пул соединений/связей инкапсулирован внутри этой функции.
Нужно ли любой ценой избегать функций инициализации? Не совсем. Есть случаи, когда эти функции могут быть полезны. Например, официальный блог Go (
http://mng.bz/PWOW) использует функцию инициализации для настройки статической конфигурации HTTP:
func init() { redirect := func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) } http.HandleFunc("/blog", redirect) http.HandleFunc("/blog/", redirect) static := http.FileServer(http.Dir("static")) http.HandleFunc("/favicon.ico", static) http.HandleFunc("/fonts.css", static) http.HandleFunc("/fonts/", static) http.HandleFunc("/lib/godoc/", httpStripPrefix("/lib/godoc/", http.HandlerFunc(staticHandler))) }
В этом примере функция инициализации не может стать причиной сбоя (http. HandleFunc может вызвать рапс, но только если обработчик равен nil, чего в данном случае нет). При этом нет необходимости создавать какие-либо глобальные переменные, и функция не повлияет на возможные юнит-тесты. Таким образом, этот фрагмент кода представляет собой хороший пример того, где функции инициализации могут оказаться полезны. Подводя итог, мы увидели, что функции инициализации могут привести к некоторым проблемам:
- Они могут ограничивать возможности по обработке ошибок.- Они могут усложнять реализацию тестов (например, попадобится устанавливать внешнюю зависимость, которая в рамках юнит-тестов может и не потребоваться).- Если инициализация требует, чтобы мы определили какое-то состояние, то это нужно будет сделать через использование глобальных переменных.
Использовать функции инициализации нужно очень внимательно. Но они могут быть полезны в некоторых ситуациях, например при определении статической конфигурации, как мы увидели в этом разделе. В противном случае, как и просто в большинстве случаев, инициализацию следует обрабатывать с помощью специальных функций.
2.4. Olln6ka #4: 3JIOyIOTPEbJrTb FETTEPAMM U CETTEPAMM
Hkancyляция данных b nporpammiopbании oshavает cokpantne shayennii uin coctoanua o6bekta. Tettepbi u cettepbi - oto cpejctba dria bKJIOyehna uHkancyляции nytem npeJocrtablenius xcknoptupobанныx metoJob nobepx he3xcnoptupobанныx noJeeu o6bektob.
B Go het abtomatuyecxoll nOJдержku retrepoB u cettepob, kacB Jpyrux r3bikax. He cHHTaecra o6b3aTeJIbHbM uJiu uJHOMATyHbM uCIOJIb3ObaHHe retrepoB u cettepOB dJia JocTyNA K nOJIaM cTpyKtypbi (struct). HanpHMEp, cTHaJapTHaA 6u6JIuoteka peaJH3yET cTpyKtypbi, rJe heKOTopbIe nOJIa JocTyHbH bIaIprMyIO, kac cTpyKtypa time.Timer:
timer := time.NewTimer(time.Second) <- timer.C. C- 3TO none <- chan Time
MbI MoJIn 6bI Jaxke MOJIMpHIIpOBaTb C hanpHMyIO, xOta 3TO MHe peKOMeHJyETcA (6oJIbIHe He 6yJEM nOJIyIaTb co6bIтия). Ho 3TOI pHMEp nOKa3bIBaET, 4TO cTHaJIapT- haa 6u6JIuoteka Go he tpe6yET ucnOJIb3ObaHHA rETtrepoB u/ uJiu cettepOB, Jaxke koJJa He HaJIO HbMeHHTb nOJIe.
C JpyroU cTOpoHbI, ucnOJIb3ObaHHe retrepoB u cettepOB JaET heKOTOpbIe npeIMy- uIeCTBa:
Ohu uHkancyJIupyIOT nObeJehue, cBraaHHOe c nOJIyHeHMEJaHbIX KaKoro- To nOJIa uJiu npucBOeHMEME MY 3HavHHA, 4TO nO3bOJIaET JO6aBJaTb HOBbIe yHk- uJiu nO3JHee (hanpHMEp, npOBePKy nOJIa, BO3BpaT bHyHcJIeHHoro 3HavHHA uJiu o6ePTbIBaHHe JocTyNA K nOJIo bOKpyr MbIOTeKca). Ohu cKpblBaIOT bHytpeHHee npeJCTaBJIeHHe, Jabaa 6oJIbIHe rU6KoCTu B OnpeJe- JHeHnI ToIO, 4TO MbI paCpKbIBaEM. Ohu JaiOT Toyky nepeXbata npu OTJaJaKe, KoJJa cBOuCTbTO 33MeHraTcA BO bpeMn ucnOJIHeHnIa, 4TO yIpOIIaET OTJaJaKy.
EcJiu MbI cTaJIKbBaeMccA c TaKmu cJIyVaaMm uJiu npeJIbIJIM BO3MOXKbIbIBaPIaHT ucnOJIb3ObaHHA, rapaHTupyIa npaMypIO CObMeCTHMOCbTb, ucnOJIb3ObaHHe retrepoB u cettepOB MOXeT npHHeCTu heKOTOpyIO nOJIb3y. HanpHMEp, cCJiu MbI ucnOJIb3yEM uX c nOJIeM BalaNCe, MbI JOJIKbIb CJeJIOBaTb BOT 3TUM cOrJIaIIeHHaM O HaIMeHOBaHHaIX:
- MetoJ rettropa JOLXeH Ha3bIBaTbcA BalaNCe (a He GetBaIance).- MetoJ cETtropa JOLXeH Ha3bIBaTbcA SetBaIance.
Ipmmer:
currentBalance := customer.Balance(). $\leftarrow$ Fetter if currentBalance < 0 { customer.SetBalance(0). $\leftarrow$ Cetter }
He следует перегружать код fettepами и cettepами в структурах, если они не приносят никакой пользы. Будьте прагматиками и ищитебаланс между эффек- тивностью и соблюдением идиом, которые в других парадигмах программиро- вания иногда считаются непререкаемыми.
Помните, что Go — уникальный язык, созданный исходя из целей достижения многих характеристик, включая простоту. Но если возникнет потребность в гет- терах и сеттерах или эта потребность предвидится в будущем, гарантируя при этом «совместимость вперед», в их использовании нет ничего плохого.
Далее обсудим проблему злоупотребления интерфейсами.
2.5. OlluBKA #5: 3APR3HrTb HHTePDECbI
2.5. OlluBKA #5: ЗАГРЯЗНЯТЬ ИНТЕРФЕЙСЫИнтерфейсы — это один из краеугольных камней языка Go при разработке и структурировании кода. Но как и со многими другими инструментами или концепциями, излишнее их использование становится недостатком. Загрязнение интерфейса (interface pollution) — это перегруз кода ненужными абстракциями, затрудняющими понимание. Это распространенная ошибка разработчиков, переходящих на Go с других языков и имеющих другие привычки. Прежде чем углубиться в тему, освежим знания об интерфейсах в Go. Затем посмотрим, когда использование интерфейсов уместно, а когда это становится загрязнением кода.
2.5.1. Концепции
Интерфейс предоставляет способ задать поведение объекта. Мы используем интерфейсы для создания общих абстракций, которые могут быть реализованы несколькими объектами. Интерфейсы в Go реализуются неявно. В языке нет явного ключевого слова (например, implements), которое бы показывало, что объект X реализует интерфейс Y.
Чтобы понять, что делает интерфейсы такими мощными инструментами, рас- смотрим два популярных интерфейса из стандартной библиотеки: io.Reader и io.Writer. Пакет io предоставляет абстракции для примитивов ввода/вывода.
Среди этих абстракций io.Reader относится к чтению данных из источника данных, а io.Writer — к записи данных в нужное место, как показано на рис. 2.3.
Puc. 2.3. io.Reader vntaet usистovника данных u sanonhnet6aитoBbiC pres, a io.Writer3anucbIaet m36aитoBoro cpe3aB HYXHOe MeCTO
io.Reader CopepRkH B cEc ToJbko MeTO Read:
type Reader interface { Read(p []byte) (n int, err error) }
IoIb3oBaTeJIbckue peaJn3aI11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 1
Co cBoeu cTOpoHb1, io.WriTer onpeJeJIeT eJ111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 type Writer interface { Write(p []byte) (n int, err error) }
type Writer interface { Write(p []byte) (n int, err error) }
IoIb3oBaTeJIbckue peaJn3aI111111111111111111111111111111111111111111111111111111111111111111111111111 1
io.Reader cHHTbIaEt J111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111io.WriTer 3aII111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
В чем смысл наличия этих двух интерфейсов в языке? Какой смысл в создании этих абстракций?
Предположим, нужно реализовать функцию, которая должна копировать содержимое одного файла в другой. Можно создать специальную функцию, которая бы в качестве входных данных принимала два файла *os. Files. Или создать более общую функцию, используя абстракции io. Reader и io. Writer:
func copySourceToDest(source io.Reader, dest io. Writer) error { //...}
Эта функция будет работать с параметрами *os. File (поскольку *os. File реализует как io.Reader, так и io. Writer) и любым другим типом, реализующим эти интерфейсы. Например, мы могли бы создать свой собственный io. Writer, который пишет в базу данных, и код остался бы прежним. Это увеличивает универсальность функции, и следовательно, возможность ее повторного использования.
Кроме того, написание южит-теста для этой функции проще, потому что вместо обработки файлов можно использовать пакеты строк и файлов, которые предоставляют полезные реализации:
func TestCopySourceToDest(t *testing.T) { const input = "foo" source := strings.NewReader(input) → Cоздает io.Reader dest := bytes.NewBuffer(make([]byte, 0)) → Cоздает io. Writer err := copySourceToDest(source, dest) → Вызывает copySourceToDest if err != nil { t.FailNow() } got := dest.String() if got != input { t.Errorf("expected: %s, got: %s", input, got) }}
В этом примере источником (source) является *strings.Reader, а назначением (dest) — *bytes. Buffer. Мы тестируем поведение функции copySourceToDest без создания каких-либо файлов.
При проектировании интерфейсов помните о степени детализации (то есть сколько методов содержится в интерфейсе). Известная среди Go- разработчиков присказка (
https://www.youtube.com/watch?v=PAAKCSZUG1c&t=318s) говорит, насколько большим должен быть интерфейс:
Чем больше интерфейс, тем слабее абстракция.
Роб Пайк (Rob Pike)
Добавление методов к интерфейсу может снизить возможности по его повтор- ному использованию.
Io.Reader и Io.writer - мощные абстракции, поскольку их невозможно сде лать еще проше. Кроме того, можно комбинировать детализированные интерфейсы для создания абстракций более высокого уровня. Так обстоит дело c io.ReadWriteiter, который сочетает в себе функции чтения и записи:
type Readwriter interface { Reader Writer }
PruMeyAHNE Kak cksa3aJ 3HHHTeIH, <Bce hyxho detaTb kak moxho npoIe, ho he npoIe aroro>. PruMehHTeJbHo K HHTeppeicax ro oshavat, yTO nouck uJeaJbHOHd JeraJHsauHn HHTeppeicHa he o6r3aTeJbHO JOLxen 6bTb npoCTbIM npoIEccOM.
PaccmOrpum pacnpoCTpaHehHbie cJyvau, kOrJa ucnoJbSbOaHHe HHTeppeicOB yMeCTHO.
2.5.2. KorJa ucnoJb3OBaTb HHTeppeicB!
KorJa cJeIeyet cO3JaBaTb HHTeppeicB! B Go? PaccmOrpum три kOHHpetHbIX cJena- pия, korJa cHHTaEcra, vTO HHTeppeicB! moryT 6bTb noJesHb. O6paruTe BHmHahue, vTO IeJb cOCToUT He B ToM, vTO6bI JaTb ucyeprnBiaAoIuIe peKOMeHJaIuIu: YEM 6OJIbIe npuMepoB a 6bI J6O6aBbI, TeM B 6OJIbIeIbI cTeIeHn OHn sABHceJIu 6bI OT KOHTEKCTa. Ho эти три cJyvaa JaIOT o6Ieee npeJCTaBaJIeHue o BOnPOce:
- O6Ieee nOBeJIeHue.- CHHxHeHe cB3a3aHHOCTH.- OrpHauIyHeHe nOBeJIeHnIa.
O6Ieee nOBeJIeHue
IePbblbI bapuaHT, kOtorpbblb Mbl o6cyJUM, - 3TO ucnoJIb3OBaHHe HHTeppeicB, kOrJa HeCKOJIbKO THIIOB peaJIu3yIOT o6Ieee nOBeJIeHue. ToTa Ja MoXHO 3aKJIoYHTb
это поведение внутрь какого-то интерфейса. В стандартной библиотеке много таких примеров. Например, сортировка какой-либо коллекции может быть раз- ложена на три действия:
- Получение данных о количестве элементов в коллекции.- Сообщение о том, должен ли один элемент быть размещен перед другим.- Перестановка двух элементов.
B пакет sort добавляется следующий интерфейс:
type Interface interface { Len() int $\leftarrow$ Число элементов Less(i, j int) bool Сравнение двух элементов Swap(i, j int) Перестановка двух элементов }
Этот интерфейс имеет большой потенциал для переносользования, поскольку включает в себя общее поведение для сортировки любой приндексированной коллекции.
Можно найти десятки реализаций пакета sort. Если в какой- то момент мы имеем дело, например, с набором целых чисел и хотим его отсортировать, будет ли нас интересовать то, как это может быть реализовано? Важен ли алгоритм сортировки: сортировка сдлиннем или быстрая сортировка? Во многих случаях это неважно. От способа сортировки можно абстрагироваться, и здесь мы зависим только от sort.Interface.
Нахождение правильной абстракции для факторизации поведения также может принести много пользы. Так, в пакете sort предоставляются служебные функции, которые используют sort.Interface: например, проверка того, была ли коллекция уже отсортирована. Например, func IsSorted(data Interface) bool { n := data.Len() for i := n - 1; i > 0; i- - { if data.less(i, i- 1) { return false } } return true }
sort.Interface — правильный уровень абстракции, и это дслает его очень ценным.
Рассмотрим следующий случай, когда полезно использование интерфейсов.
CHHXeHHe CBr3aHHOCTH (deCOupling)
EIE OJH BAKHbI CICHAPH - OTJeJIeHHe KOJa OT EIO PEaJH3aHIN. ECIH MBI NOJIaRAeMCH Ha AOCTPAKHIO BMECTO KOHKpETHOH PEaJH3aHIN, cAma PEaJH3aHIN MOKeT 6bIb 3aMeHeHa Ha ADyTYKO 6e3 HeO6XOJIMOCTH MeHrTHb KOI. OTO H ECTb NPHHHIN NOJCTaHOBKH KJICKOB (6yKBa L B NPHHHINAX SOLID Po6epta MArTHHA).
OJHO H3 IPEHMYHCTCH CHHXeHHHIN CBA3aHHOCTH MOKeT OTHOCHTBA K KHHIT- TCTAM. IPeJIOJIOXKUM, Mb XOTHM PEaJH3OBaTB MeTOJ CreateNeWCuStomer, KOToPHHICO3JaeT HOBORO NOTpe6UTEJIa I COXPAHReT EIO. Mb PEHHHIN IOJIaTaTBCa HeIOOcpeJCTBHeHHO Ha KOHKpETHYO PEaJH3aHINIO (CKaXeM, Ha CTpyKTYpy mYsQL.StOre):
type CustomerService struct { store mysql.Store 3aBHCUT OT KOHKpETHOro cTOCO6a PEaJIaBaHIN } func (cs CustomerService) CreateNeWCustomer(id string) error { customer : $=$ Customer{id: id} return cs.store.StoreCustomer(customer) }
A YTO 6yJET, eCIH Mb 3axOTHM NPOTECTUPOBaTB OTOT MeTOJ? IOCKOJIbKy customerService HCHOJIb3VET PEaJIbHyIO PEaJH3aHINIO JJa xPAHeHHA Customer, HYXHO NPOTECTUPOBaTB EIO C NOMOIIbIO HHTErpHIIOHbIX TECTOB, YTO Tpe6yET 3aJIyCKa 3K3eMIIJIraPa MySQL (eCIH TOJIbKO Mb He HCHOJIb3yEM aJIbTePHaTHUBHbIH MeTOJ go- sqImock, HO Ora TEMA BbIXOJIT 3a pAMKH JaHHOYO Pa3JeJIa). XOtA HHTErpHIIOHbIe TECTbI IOJIe3HbI, OTO He BcERJa To, YTO Mb XOTHM JEJIaTB. JJa 6OJIbIeIH RU6KOCTH HYXHO OTB3aTb CustomerService OT pKaTHUeCKOIH PEaJH3aHIN. CJIeJIaTB OTO MOXHO Hepe3 HHTEpOpeIc:
type customerStorer interface { C03Jaetca a6CTpaKHIN XpaHHINHUA StoreCustomer(Customer) error } type CustomerService struct { storer customerStorer 0TBa3bIbAeT CustomerService OT qaTHyIeCKOIH peaJIa3aHIN } func (cs CustomerService) CreateNeWCustomer(id string) error { customer : $=$ Customer{id: id} return cs.storer.StoreCustomer(customer) }
CoxpaHeHHe CO3JIaHHOHO NPOTpe6UTEJIa B 6a3e TeNePb OcyIIeCTbJIaTeTCH Hepe3 HHTEp- peIic, YTO Jaet 66JIbIyIO rI6KoCTb B TeCTHPOBaHHH MeTOJIa. HaIpHMEp, Mb MOKeM:
HCIOJIb3OBaTB KOHKpETHYO PEaJH3aHINIO B HHTErpHIIOHbIX TECTaX;
- применять в юнит-тестах имитации (моки) или любые другие тестовые дублеры;- делать и то и другое.
Обсудим третий сценарий: ограничение поведения.
Ограничение поведения
Третий сценарий на первый взгляд может показаться контринтутивным. Речь идет об ограничении типа определенным поведением. Представим, что мы реализуем пользовательский конфигурационный пакет для работы с динамической конфигурацией. Мы создаем специальный контейнер для конфигураций int с помощью структуры IntConfig, в которой определены два метода: Get и Set. Вот как будет выглядеть такой код:
type IntConfig struct { // ... } func (c *IntConfig) Get() int { // Получить конфигурацию } func (c *IntConfig) Set(value int) { // Обновить конфигурацию }
Теперь предположим, что мы получили IntConfig, который содержит в себе определенную конфигурацию, например какое-то пороговое значение. Но в нашем коде нас интересует только получение значения этой конфигурации, и мы хотим предотвратить его обновление. Как мы можем обеспечить, чтобы семантически эта конфигурация была доступна только для чтения, если мы не хотим изменять пакет конфигурации? Ответ: создав абстракцию, которая ограничивает поведение только получением значения конфигурации:
type intConfigGetter interface { Get() int }
Тогда в коде можно указать только intConfigGetter вместо конкретной реализации:
type Foo struct { threshold intConfigGetter }
func NewFoo(threshold IntConfiggetter) Foo { BBoДится rettep kOнцуграции return Foo{threshold: threshold} } func (f Foo) Bar(){ threshold := f.threshold.Get() 4TeHne kOнцуграции //... }
B этом примере rettep kOнцуграции внедряется в фабричный метод NewFoo. Он не влияет на потребителя этой функции, поскольку он по-прежнему может передавать структуру IntConfig по мере реализации IntConfigGetter. Затем в методе Bar можно только прочитать kOнцуграцию, но не изменить ее. Поэтому мы также можем использовать интерфейсы, чтобы ограничить тип определенным поведением, например, если нужно соблюсти семантику.
Мы рассмотрели три возможных сценария использования интерфейсов, в которых они считаются полезными: выделение общего поведения, некоторое снижение связанности и ограничение типа определенным поведением. Это не исчерпывающий список, но он дает общее представление о том, когда интерфейсы в Go полезны.
Закончим этот раздел обсуждением проблем загрязнения интерфейса.
2.5.3. Загрязнение интерфейса
Злоупотребление интерфейсами в проектах на Go — частое явление. Возможно, у разработчика, который этим грешит, был опыт работы с C# или c Java и он счел естественным создавать интерфейсы, а не конкретные типы. Но в Go все должно работать не так.
Интерфейсы полезны для создания абстракций. И главное предостережение при знакомстве программиста с абстракциями — это помнить, что абстракции нужно открывать, а не создавать. Это означает, что мы не должны начинать создавать абстракции в коде, если для этого нет веской причины. Нужно не конструировать интерфейсы, а ждать возникновения конкретной потребности в них. Иными словами, создавайте интерфейс только тогда, когда он действительно нужен, а не тогда, когда возникает лишь ощущение, что он может понадобиться.
В чем основная проблема, связанная с чрезмерным использованием интерфейсов? Они делают поток кода менее ясным и более сложным. Добавление бесполезного косвенного уровня не приносит никакой пользы, а лишь создает бесполезную абстракцию, затрудняющую чтение, понимание и осмысление
кода. Если нет веской причины для добавления интерфейса и неясно, как этот интерфейс делает код лучше, нужно поставить под сомнение цель создания такого интерфейса. Почему бы не вызвать реализацию какого-либо действия напрямую?
ПРИМЕЧАНИЕ При вызове метода через интерфейс мы можем стол- кнуться с оверхглом производительности. Требуется поиск в структуре данных хеш-таблицы, чтобы найти конкретный тип, на который указывает интерфейс. Но это не проблема во многих контекстах, поскольку оверхед минимален.
Следует быть очень осторожными при создании абстракций в коде: их следует обнаруживать, а не создавать. Для разработчиков характерно чрезмерно усложнять код в попытках угадать идеальный уровень абстракции. Этого следует избегать, поскольку в большинстве случаев в результате код «загрязняется» ненужными абстракциями и становится сложным для чтения.
He программируйте интерфейсы, открывайте их.
Po6 Naik
He будем пытаться решить проблемы абстрактно, будем решать только то, что нужно сейчас. И последнее, но не менее важное: если вы не понимаете, как какой-то интерфейс улучшает код, то следует подумать о его удалении для упрощения кода.
В следующем разделе продолжим эту тему и рассмотрим связанную с интер- рейсами распространенную ошибку: создание интерфейсов на стороне произ- водителя (producer).
2.6. ОШИБКА #6: ИНТЕРФЕЙСЫ НА СТОРОНЕ ПРОИЗВОДИТЕЛЯ
В предыдущем разделе мы поговорили о том, когда использование интерфейсов оправданно. Но Go-разработчики часто неправильно понимают другой вопрос: где должен жить интерфейс?
Прежде чем углубиться в эту тему, удостоверимся, что термины этого раздела вам понятны:
-
Сторона производителя (Producer)
-
интерфейс, определенный в том же пакете, что и конкретная реализация (рис. 2.4).
-
Сторона потребителя (Consumer)
-
интерфейс, определенный во внешнем пакете, где он используется (рис. 2.5).
Интерфейс живет на стороне производителя.
Pис. 2.4. Интерфейс определяется вместе с конкретной реализацией пакета
Pис. 2.5. Интерфейс определяется там, где он и используется
Часто можно увидеть, как разработчики создают интерфейсы на стороне производителя наряду с конкретной реализацией. Этот программный дизайн привычен для разработчиков, имеющих опыт работы с C # или c Java. Но в Go в большинстве случаев так делать не следует.
Обсудим пример: создадим специальный пакет для хранения и извлечения данных о потребителях. Мы решаем, что все вызовы в том же пакете должны проходить через следующий интерфейс:
package store
type CustomerStorage interface { StoreCustomer(customer Customer) error GetCustomer(id string) (Customer, error) UpdateCustomer(customer Customer) error
GetAllCustomers() ([]Customer, error) GetCustomersWithoutContract() ([]Customer, error) GetCustomersWithNegativeBalance() ([]Customer, error) }
Можно подумать, что есть веские причины для создания этого интерфейса и предоставления доступа к нему на стороне производителя. Возможно, это хороший способ отвязать код потребителя от фактической реализации. Им, возможно, мы стараемся предвидеть, что это поможет потребителям в создании тестовых дублеров. Какой бы ни была причина, в Go это не лучшая практика.
Интерфейсы в Go реализованы неявно, что обычно меняет правила игры по сравнению с языками с явной реализацией. В большинстве случаев подход, которому стоит следовать, аналогичен тому, что мы описали в предыдущем разделе: абстракции следует открывать, а не создавать. Это означает, что производитель не должен навязывать определенную абстракцию всем потребителям. Вместо этого потребитель должен решить, нужна ли ему какая-либо форма абстракции, а затем определить наилучший уровень абстракции для своих нужд.
В предыдущем примере один из потребителей не будет заинтересован в отвязывании своего кода. Возможно, другой потребитель заходит отвязать свой код, но его интересует только метод GetAllCustomers. Тогда он может создать интерфейс только одним методом, ссылаясь на структуру Customer из внешнего пакета:
package client
type customersGetter interface { GetAllCustomers() ([]store.Customer, error) }
Исходя из организации пакетов, результат этого показан на рис. 2.6. Несколько замечаний:
- Поскольку интерфейс customersGetter используется только в пакете client, он может остаться неэкспортированным. - На рисунке это выглядит как циклические зависимости. Но зависимости от store к client нет, поскольку интерфейс реализован неявно. Поэтому такой подход не всегда возможен в языках с явной реализацией.
Суть состоит в том, что пакет client теперь может определить для своих нужд наиболее точную абстракцию (в этом примере есть только один метод). Это связано с концепцией принципа разделения интерфейса (I - ISP - в SOLID),
которая гласит, что ни один потребитель не должен зависеть от методов, которые он не использует. И в этом случае лучший подход — разместить конкретную реализацию на стороне производителя, дать к ней доступ и позволить потребителю решить, как ее использовать и нужна ли вообще здесь абстракция.
Puc.2.6. Naket client определяет необходимую ему абстракцию, создавая собственный интерфейс
Для полноты изложения отметим, что подход интерфейсов на стороне производителя иногда используется в стандартной библиотеке. Например, пакет encoding определяет интерфейсы, реализованные другими субпакетами, такими как encoding/json или encoding/binary. Является ли пакет encoding неверным с этой точки зрения? Точно нет. В этом случае абстракции, определенные в пакете encoding, используются во всей стандартной библиотеке, и разработчики языка знали, что предварительное создание этих абстракций полезно. Мы вернулись к обсуждению предыдущего раздела: не создавайте абстракцию, если вы просто думаете, что она может быть полезна в будущем, или не можете доказать, что она будет действительно нужна.
В большинстве случаев интерфейс должен жить на стороне потребителя. Но в определенных контекстах (например, когда мы твердо знаем, а не просто предвидим, что абстракция будет полезна для потребителей) можно сделать его на стороне производителя. В этом случае мы должны стремиться к тому, чтобы она была минимальной, что увеличивало бы потенциал ее переспользования и делало ее легко компонуемой.
Продолжим обсуждение интерфейсов в контексте сигнатур функций.
2.7. ОШИБКА #7: ВОЗВРАТ ИНТЕРФЕЙСОВ
При разработке сигнатуры функции может потребоваться вернуть либо интерфейс, либо конкретную реализацию. Разберемся, почему возврат интерфейса во многих случаях считается в Go плохой практикой.
Мы только что объяснили, почему интерфейсы вообще могут жить на стороне потребителя. На рис. 2.7 показано, что произойдет с точки зрения зависи- мостей, если функция вернет интерфейс вместо структуры. Это приводит к проблемам.
Рассмотрим два пакета:
- client, который содержит интерфейс Store.- store, который содержит реализацию Store.
[ImageCaption: Рис. 2.7. Есть зависимость пакета store or пакета client]
В пакете store мы определяем структуру InMemoryStore, реализующую интерфейс Store. Мы также создаем функцию NewInMemoryStore для возврата интерфейса Store. При таком дизайне есть зависимость пакета реализации от пакета потребителя, и это может показаться странным.
Например, пакет client больше не может вызывать функцию NewInMemoryStore, в противном случае возникла бы циклическая зависимость. Возможным решением может быть вызов этой функции из другого пакета и внедрение реализации Store в client. Однако обязанность сделать это означает, что такой дизайн должен быть оспорен.
Что произойдет, если структуру InMemoryStore будет использовать другой потребитель? Тогда, возможно, захотелось бы переместить интерфейс Store в другой пакет или обратно в пакет реализации, но мы уже обсуждали, почему во многих случаях это плохая идея. Похоже, что этот код с душком.
Возврат интерфейса, как правило, ограничивает гибкость, поскольку мы заставляем всех потребителей использовать один конкретный тип абстракции.
Будьте консервативная в том, что вы делаете, и либеразивы в том, что принимаете от других.
Transmission Control Protocol (TCP, протокол управления передачей)
Если применить эту кдиому к Go, то это будет означать:
- возврат структур вместо интерфейсов;- допущение использования интерфейсов, если это возможно.
Конечно, есть и исключения. Разработчики знают, что правила никогда не выполняются на 100 %. Самое важное из них касается типа error — интерфейса, возвращаемого многими функциями. Можно изучить и другое исключение в стандартной библиотеке с пакетом io:
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }
B этом примере функция возвращает экспортированную структуру io.LimitedReader. Но сигнатура функции — это интерфейс, io.Reader. В чем причина нарушения правила, которое мы обсуждали до сих пор? io.Reader — это предварительная абстракция. Это не тот уровень, который определяется клиентами, а навязываемый разработчиками языка, которые заранее знали, что этот уровень абстракции будет полезен (например, с точки зрения возможности переносользования и компоновки).
B большинстве случаев возвращать лучше не интерфейсы, а конкретные реализации. В противном случае дизайн будет усложненным из-за зависимостей пакетов, а гибкость — ограниченной, поскольку всем клиентам придется использовать одну и ту же абстракцию. Этот вывод аналогичен предыдущим разделам: если мы четко знаем (а не просто предполагаем), что абстракция будет полезна для потребителей, то можем подумать о возврате интерфейса. В противном случае мы не должны навязывать использование абстракций; необходимость их использования должна быть «обнаружена» клиентами. Если клиенту по какой-либо причине нужно абстрагировать реализацию, он все равно сможет сделать это на клиентской стороне.
B следует примен разделе обсудим распространенную ошибку, связанную с использованием any.
2.8. Oшивка #8: ANY HE ГОВОРИТ НИ О ЧЕМ
B Go тип интерфейса, который определяет пульевые методы, известен как пустой интерфейс, interface{}. B Go 1.18 предварительно объявленный тип any стал чем- то вроде псевдокима для пустого интерфейса, поэтому во всех случаях interface{} может быть заменен на any. Во многих случаях any можно считать чрезмерным обобщением, и как считает Po6 Пайк, any не передает никаких смыслов (
https://www.youtube.com/watch?v=PAAkCSZUG1c&t=7m36s). Напомним о некоторых основных понятиях, а затем обсудим потенциальные проблемы с any.
Тип any может содержать любой тип значения:
func main() { var i any
i = 42 Tuni int i = "foo" Tn string i = struct { Cpykrypa s string }{ s:"bar", $\textbf{j} = \textbf{f}$ 0yHKpM $\mathrm{\underline{\Pi}} = \mathrm{\underline{I}}$ PpHcBoeHne shaeHnnyctomy uJehntuHkTaory, Uo6bI pMep mor cKOMnHnupOBaTbC}
func f() {}
При присвоении значению типа any мы теряем всю информацию о типе, что требует подтверждения типа (type assertion), чтобы получить что-либо полезное из переменной i, как в предыдущем примере. Посмотрим другой пример, где использование any не совсем точно. В нем мы реализуем структуру Store и скелет двух методов, Get и Set. Мы используем эти методы для хранения различных типов структур, Customer и Contract:
package store
type Customer struct{ // kakoi- to koa } type Contract struct{
// kakou- to koa } type Store struct{} func (s *Store) Get(id string) (any, error) { Bosbpauser any // } func (s *Store) Set(id string, v any) error { 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 func (s *Store) Set(id string, v any) error { 1111111111111111111111111111111111111111111111111111111111111111111111111111111111 1
Xotra B kOde Store het hivvero omm6ovhoro c touku зрения komnunriin, cJedyet octahobutbcsa u noJyMats b curnhatypax metoJob. IocKoJbky Mbi npunhmam u Bosbpaum apymeutbi any, metoJam he xbaatet bbaразuteJbHocTn. EcJiu Jpy- rum paspa6otyukam nortpe6yется ucnoJb3obatb ctpyktypy Store, um npudetcsa nokonatbcsa B Jokymehtaации uJiu B kOde, UTO6bI noHrTb, kak ucnoJb3obatb 3J1 meToJbI. CJedobatelbho, npunHrtue uJiu Bosbpat Tuna any he nepedaeT shaunmoU uHформaunu. IocKoJbky bo bpeMra komnunriinu het saHutbi, Huvto he meunaet bbi- 3bIBaOIIeI hyHkI1111 bIbMaTb 311 meTOJbI c JIO6bIM TUNOM aJHbIX, hanpHmer int:
s := store.Store{} s.Set("foo", 42)
IcnoJb3yA any, Mbl teprAem heKotorpbie npeumyIIecTba Go kaK J3bika co ctatnueckOu Tnun3a1ueu. CJedyet K36eratb Tuna any u JelJatb curnhatypb MaksumJbH0 RbHbIM. YTo kacaetcsa hauiero npumepa, 3TO MOKET O3haaTb Jy6JHupOBaHue MetoJOB Get u Set JIA KAKJoro TUNa:
func (s *Store) GetContract(id string) (Contract, error) { // ... } func (s *Store) SetContract(id string, contract Contract) error { // ... } func (s *Store) GetCustomer(id string) (Customer, error) { // ... } func (s *Store) SetCustomer(id string, customer Customer) error { // ... }
3Jecb meTOJbI JocTaTOyHO bbaразuteJbHbI, YTO cHUKaet pJCK henoHmMAnHs. HaJ111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 3111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
интерфейса. Например, если клиента интересуют только методы Contract, он может написать что-то вроде этого:
type ContractStorer interface { GetContract(id string) (store.Contract, error) SetContract(id string, contract store.Contract) error}
В каких случаи апу полезен? Посмотрим на стандартную библиотеку и два примера, где функции или методы принимают аргументы апу. Первый пример находится в пакете encoding/json. Поскольку мы можем маршалировать любой тип, функция Marshal принимает аргумент апу:
func Marshal(v any) ([]byte, error) { // ...}
Другой пример можно найти в пакете database/sql. Если запрос параметризован (например, SELECT * FROM FOO WHERE id =?), параметры могут быть любыми. Следовательно, он также использует аргументы апу:
func (c *Conn) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) { // ...}
Таким образом, апу может быть полезен, если есть реальная необходимость принять или вернуть любой возможный тип (например, когда дело доходит до маршалинга или форматирования). В общем, мы должны любой ценой избегать чрезмерного обобщения своего кода. Возможно, иногда небольшое дублирование кода будет приветствоваться, если это улучшает другие аспекты, например выразительность.
Обсудим другой тип абстракций: дженерики.
2.9. Ошивка #9: ПУТАНИЦА В ИСПОЛЬЗОВАНИИ ДЖЕНЕРИКОВ
В Go 1.18 в язык добавлены дженерики. Это позволяет писать код с типами, которые можно указать позже и создавать при необходимости. При этом может возникнуть путаница, когда использовать дженерики. Мы опишем общую концепцию дженериков в Go, а затем покажем, когда их полезно использовать, а когда нет.
2.9.1. KoHqenHm
CJeylouHa yHkHnHn H3BJEkaeT Bce KJI0YH H3 THnIa map[tring]int:
func getKeys(m map[string]int) []string { var keys []string for k := range m { keys $=$ append(keys,k) } return keys }
HTo, eCJIu Mbl 3axotHM uCIOJIb3OBaTb aHaJIOrIyHyIO yHkHnI0 DJIa dyroro THnIa kapt, hanpимер DJIa map[int]string? Io no3bJIeHnIa JxHeHpHKOB y pa3pa6OrTHu- KOB 6blIO HeCKOJIbKO BaPHaHTOB: uCIOJIb3OBaHHe reHepaIInu koJa, otpaXeHHe uJIu AyoJIupOBaHHe koJa. Hanpимер, Mbl MoJIu 6bl HaIINcaTb Jbe yHkHnIIn, no OJIHOU AJIa KaXJIOro THnIa kapt, uJIu JaxKe nOIIbIaTbCa paCIIInIpHTb BO3MOXHOCTH IeTKeys, YTO6bl OH nIpuHUMaJI pa3HbIe THnIb kapt:
func getKeys(m any)([]any, error) 1 PpuHmMaTe u Bo3BaHpAeI switch t := m.(type) { apryMeHTb THnIa any default: return nil, fmt.Errorf("unknown type: %T", t) 06pa6aTbBaTe OIIu6Ku case map[string]int: BpeMeHn BbIIOJIHeHnHnHn, var keys []any eCnI THnI eUHe He paAnI3OBaH for k := range t { keys $=$ append(keys, k) } return keys, nil case map[int]string: // CkOJIHPOBaTb, JOrIky 33BJe4eHnIa } }
B 3TOM nIpHmPe HeCKOJIbKO pPO6JIeM. IPpeXeJe Bcero, yBeJIHvIHBaTeCTa IIa6JIOHHbIi KOI. ECIJI HYXHO JIO6aHHTb eIIe KaKOJI- TO CJyvIaI, nIpIeTeCTa Jy6JIupOBaTb IUKJI range. Ipu 3TOM yHkHnI HTeIePb nIpuHUMaTe THnI any, STO O3Ha4aTe, YTO Mbl TepeMe HeKOTOpbe npeIMyIIeCTBa Go KaK THnIu3IPOBaHHOro 33bIa. IPOBepeKa Toro, NoJI- AePKHbBaTeCTa THnIOTr THnI, nPOBOJIHTCra BO BpeMn BbIIOJIHeHnHn nPOIpaMMe, a He BO BpeMn ee KOMIIIIJIaIIII. IO3TOMy HYXHO BePHyTb OIIu6Ky, eCIJI nPEeCTaBaJIeHHbIi THnI HeIe3BeCTeHn. HAKOHeI, HOCKOJIbKy THnI KJI0Ya MOKeTe 6blTb JII6O int, JII6O sTTrIng, Mbl 663aHbI BePHyTb Cpe3 THnIa any, YTO6bl BbIeJIHTb THnIb KJI0YeI. TAKOI IIOXOJI yBeJIHvIHBaTe HaIry3Ky Ha BbI3BaBaOIIyIO yHkHnIIO, HOCKOJIbKy KJIHeHTy MOKeTe TAKXe nOITpe6OBaTbCra BbIIIOJIHTb nPOBepeKy THnIa KJI0YeI IJIu IOIOJIHHTeJIbHOe npeo6pa3OBaHHe. EJIaIOJIapa JxHeHepHKaM MOKHO CJeJIaTb peJaKaTTOpHHr STOro KOJa, uCIOJIb3yra nIpaMeTePb THnIa.
Параметры типа — это общие типы, которые можно использовать с функциями и типами. Например, аргументом следующей функции является параметр типа:
func foo[T any](t T) $\left{ \begin{array}{ll} \end{array} \right.$ — T— это параметр типа //...}
При вызове foo мы несредем угла аргумент типа any. Передача аргумента типа называется инстанцированием (instantiation), и эта работа выполняется во время компиляции. Это позволяет сохранить безопасность типов как часть основных возможностей языка и избежать оверхеда во время выполнения.
Вернемся к функции getKeys и воспользуемся параметрами типа для написания универсальной версии, которая будет принимать карты любого типа:
func getKeys[K comparable, V any](m map[K]V) []K { Kлючи — типа comparable, var keys []K Создается срез keys aV — типа any for k := range m { keys = append(keys, k) } return keys }
Для обработки карты мы определяем два вида параметров типа. Прежде всего значения могут быть типа any: V any. Однако в Go ключи карты не могут быть типа any. Например, мы не можем использовать срезы:
var m map[[byte]int
Этот код приводит к ошибке компиляции: invalid map key type []byte. Чтобы не принимать любой тип ключа, мы обязаны ограничить аргументы типа, чтобы тип ключа соответствовал определенным требованиям. Здесь требуется, чтобы тип ключа был сравним (можно использовать $= =$ или $! =$ ). Следовательно, мы определили K как comparable, а не как any.
Ограничение аргументов типа для соответствия определенным требованиям называется constraint (слово и означает ограничение) — это тип интерфейса, который может содержать:
- набор поведения (методов);- произвольные типы.
Последнее рассмотрим на конкретном примере. Допустим, мы не хотим принимать какой-либо тип comparable для типа ключа map. Например, мы
xOTUM ORpaHnIHTb ETO TUNIAMU INT UJI string. MbI MOxEM ONpeJENTb ORpaHnIHTb TAK:
Jля начала MbI опpeJeJnEM uHrepeic customConstraint, vTO6bI opraHnIHTb JOnyctHmble TUNIb 3haVehnIMI int uJIu string, ucnoJIb3yA onepaTOp o6beJинeHnIa| (MbI o6cyJIM ucnoJIb3OBaHnIe ~ hemHoro no3xke). K tenepb 8bJIaTeTcA customConstraint BMecTO compaRable, kak 6bIJIo paHbIne.
Curnatypa getKeys rapaHTupyet, vTO MbI MOxEM bI3bIBaTb ero c kapTOI JIO6oro TUNIa JAHnbIX, HO TUNI KJI0yA JOLxkEN 6bIb int uJIu string. HaHpимер, Ha bI3bIBaIouIeIcTropoHe:
m = map[string]int{ "one": 1, "two": 2, "three": 3,}keys := getKeys(m)
B Go noJpa3yMeBaTeTc, vTO getKeys bI3bIBaTeTcA c apryMeHTOM TUNIa string. IppeJbIyIInIuI bI3OB 3KbIHaJIeHTeH bOT OTOMy:
Ao cux nop MbI o6cyKbIaJIu npHmerbI ucnoJIb3OBaHnIa JxHeHpHKOB Jля qyHKIInI. Ho MbI takxke MOxEM ucnoJIb3OBaTb JxHeHpHKU co cTpyKTypaHnI JAHnbIX. HaHpMEp, MOxHO CO3JaTb CB3aHHbIi cIuCok, coJepKaHnIb3haVehnIa JIO6oro TUNIa. Jля OTOrO MbI HaIInIIeM MeTOJ Add, KoTOpBII JIO6aBJIeT y3eJI:
type Node[T any] struct { $\leftarrow$ IcnoJIb3yETc aJpaMeTp TUNIa Val T next *Node[T] } HnctaHnIupyTeTc qyHKIInI, func (n *Node[T]) Add(next *Node[T]) { npHnHmKaOIIaI aJprYMeHT TUNIa n.next $=$ next }
~int u int
B yem pasnulqa mekay opranhuenhmu aprymentob tina (constraint), unonbby- loymmu ~int u int? Icnonbsoванue int opranhunbaet tun tonbko stmum tunom, torpa kak ~int orpanhunbaet bce tunbi, Gasobbim tunom kotopbix ahrnretca int. Jn nnnnctpaunu jabaute npejctabum constraint, B kotopom mbi koteniu 6bi opranhunb tun nno6bim tunom int, peanusyioyum metod String() string:
type customConstraint interface { ~int String() string }
Icnonbsoванue storo constraint'a orpanhunbaet aprymentb tuna nonbsobaterbckmmu tunami. Hanpимер,
type customInt int
func (i customInt) String() string { return strconv.int(int(i)) }
Iockonbky customInt npejctabraet co6ou tun int u peanbyet metod String() string, tun customInt ydobnetboreret saahanomy opranhunho (constraint). Ho ecnu mbi n3mehm uero, vto6bi oh coepexan int Bmecto ~int, unonbsoванue customInt npubejet k ouu6ke komnunrauun, nockonbky tun int he bbnonharet String() string.
B atom npumere mbi ucno1b3yem napametры tunia dria onpejcelenius T u ucno1b3yem o6a no1a B Node. Yto kacaeetca metoda, uhctaninupyetca npnunmaiouia dyhknqur. JeicstbuteJbHo, nockonbky Node rBJIeetca yHnBepcaJbHbM, OH takxke J0Jxeh cJedobatb saJahHomy napametpy tunia.
I nocJedhee, vTO cJedJyet otmetutb B oTHouHeHnI napametpOB tuna: OHn He Moryt ucno1b3oBatbcsa c aprymentamu metoda, a ToJbko c aprymentamu dyhknqun uJiu noJy1a1r1cJ1bM11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
type Foo struct {} func Foo) bar
T any {} ./main.go:29:15: methods cannot have type parameters
Если мы хотим использовать дженерики с методами, получатель должен быть параметром типа.
Рассмотрим конкретные случаи, когда использовать дженерики.
2.9.2. Общие случаи использования и злоупотребления
Когда полезно использовать дженерики? Обсудим несколько распространенных случаев, когда это рекомендуется:
-
Структуры данных. Мы можем использовать дженерики, чтобы выделить тип элемента, например, если реализуем двоичное дерево, связанный список или кучу.
-
Функции, работающие со срезами, картами и каналами любого типа. Например, функция объединения двух каналов будет работать с любым типом канала. Следовательно, можно использовать параметры типа, чтобы определить тип канала:
func merge[T any](ch1, ch2 <-chan T) <-chan T { // ... }
- Факторизация поведения вместо типов. Пакет sort, например, содержит интерфейс sort. Interface, включающий в себя три метода:
type Interface interface { Len() int Less(i, j int) bool Swap(i, j int }
Этот интерфейс используется различными функциями: sort. Ints или sort. Float64s. Используя параметры типа, можно выделить действие по сортиров-ке (например, определив структуру, содержащую срез, и функцию сравнения):
type SliceFn[T any] struct { —— Используется параметри типа S []T Compare func(T, T) bool Cравниваются два элемента T }
func (s SliceFn[T]) Len() int { return len(s.S) } func (s SliceFn[T]) Less(i, j int) bool { return s.Compare(s.S[i], s.S[j]) } func (s SliceFn[T]) Swap(i, j int) { s.S[i], s.S[j] = s.S[j], s.S[i] }
HockoJbKy cykytypa SlicCeFn peaJn3yET sort. InterfaCe, MoXHO oTCOpTUpo- bать npeJocTabJIeHbHbHbI cpe3 c nOMOIIbIb OyHKIIIIH sort.Sort(sort.Interface):
s := SliceFn[int]{ S: []int{3,2,1}, Compare: func(a,b int) bool{ return a $< b$ } , } sort.Sort(s) fmt.Println(s.s) [123]
3Jecb факtorpusaaHn JeeicTbHn no3bOJIeT n36eXaTb co3JaHn HJIa KaXdoro TIIa cBOeH bYHKIIIIH.
A korJa ucnoJIb3OBaTb JJxHeHepIku He peKOMeHJIyETcra?
- Ipu bJb3OBe MeTODa c apzymeHmOM munA. PaccMOrpHM bYHKIIIIH, kotOrpa nOJIy- yaeT Ha BxOJe io.Writer H bIB3bIBaET MeTOJ Write:
func foo[T io.Writer](w T) { b := getBytes() _, _ = W.write(b) }
B JTOm cJIyVae ucnoJIb3OBaHnue JxHeHepIKOB He npHHeCCT KOJIy HHKaKOH IOnJIb3bI. HyXHO HaIIpJMyIO cJelIaTb 3HaVeHnHe apryMeHTa W paHbHM io.Writer.
- Kozda smo deJaeT mOd 6OJee cJIOXcHbM. JxHeHepIku HHKOrJa He bIBaIOT O63a-teJIbHbHM, u pa3paOTyHkIu Go npeKpaCnO XJIIu 6e3 HHX OJee JecraHn JET. ECJIH bI ucnoJIb3yEM JxHeHepIku - yHHBepeCaJIbHbIe bYHKIIIIH HIIH cTpYkTybH - u O6- HapyXHbBaEM, YTO YTO He deJIaeT KOI OJee nOHHTHbIM, TO cJIeJIyET nepEcMOTpETb cBOe peIHeHnHe JJIa KOHkpeTHOro cJIyVae.
XOTa JxHeHepIku H mOyT bIHb HJIE3HbI, OyJIbTe OCTOpOXHbIH HIIH HxcHbHbHn. IpuHHIIH 3Jecb TakoH JKe, KaK H Hpu ucnoJIb3OBaHnH HHTepHbEicOB. JxHeHepIku BBOJIaT HHeKOTOpYO OboMy a6CTpaKIIH, a HaM HYXHO nOMHHTb, YTO HHeYXHbNe a6CTpaKIIH TOJIbKO cYIOXHbIOT pa6OTy.
He OyJEM saTpJ3HbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbH
JJIaJee O6cyJIHM bO3MoxHbIe nPO6JIeMb HIIH ucnoJIb3OBaHnH bCTpaHbHbHn HIIHbH.
2.10. OWWKA #10: HE 3HATb O BOSMOXHbIX NPOBJEMAX CO BCTPAUBAHHEM TUNIOB
Ipu coszahinu ctpyktypia Go nosboyreet bctpaubatb tunb. Ho inorada sto moxet npubectni k heoxkudahhomy nobeJehnno, eciu mbi he noHmam cex nocJedctbui takoro bctpaubahn. Irorobopim, kak bctpaubatb tunbI, vto sto daeT u kakue moryt obitb npooJEMbI.
B Go noJe ctpyktypia hasbIbIaecra bctpoehnbiM, eciu ohO o6bIbJIehO 6e3 Imehn. Hanpимер,
type Foo struct { Bar BCTPOEHHOe nOne } type Bar struct { Baz int }
B ctpyktype Foo tunn Bar o6bIbJIeh 6e3 cBraahHoro Imehn, cJedobateJIbHO, oTO bCTPOEHHOe nOJIe.
Mbi ucnoJIbJIyem bctpaubahnue dJia npooJbHKehnIa (promote) noJIeI u MetoJIOB bCTPOEHHOe tunia. HoOkoJIbky Bar coJepKHT nOJIe Baz, sto nOJIe npooJIbIraeTcra b Foo (puc. 2.8). Takum o6paOBm, Baz ctahOBHTcra doCTyINHbIM u3 Foo:
foo := Foo{} foo.Baz = 42
Доступк Baz bO3MOXeH nO JByM pa3HbIM nIyTЯM: JI6O nO npooJIbIraeMOMy Yepes Foo.Baz, JI6O nO HOMHHaJIbHOMy Yepes Bar, Foo.BaR.Baz. O6a otHOcArTcra K OJIHOMy u ToMy Xe nOJIIO.
Foo struct { Bar struct { Baz int] --PpOJIbIXHHe -- Baz int }
Puc. 2.8. baz npooJIbIHyI, a no3TOMy doCTyIeH hanpЯмую u3 S
Tenepb, kOrJa Mbi bCIOMHnJIu, vTO takoe bCTPOEHbIe tunbI, JabaIite paccMOтрUM npuMEp henpaBUBJIbHoro ux ucnoJIb3OBahnIa. HuXke Mbi peaJI3JIyEM struct, kotopbIi xpaHnT heKOTOrpbIe JaaHbIe B nIaMraTn, u xOTIUM saIIIIHTb IeO OT KOHKypeHTHOro doCTyIIa c nOMOIIbIbIOMbIOTeKcA:
type InMem struct { sync.Mutex BCTPOeHHoe noNe m map[string]int } func New() *InMem { return &InMem{m: make(map[string]int)} }
HHTeppeiCbI u BCTpaBAnHue
BCTpaBAnHue takxe HCTONb3yETcA BHyTpu HHTeppeiCbOB dIa oCbEduHHeHHA HHTeppeiCa c ApyruMM. B cneJyOuEM npuMere io. Readwriter COCToT M 1o. Reader u io.Writer:
type Readwriter interface { Reader Writer }
To, HTO OnIcAHO B STOM Pa3JeNe, OTHOcUTcA ToIbKO KO BCTPOeHHaM NoJIaM B CTpyK typax.
Mbi peHnJn cJelatb Kapry He3KcHoptHpyeMO, ToObl KJIHeHTbI He MoJn B3aUMO- JelICTbOBaTb c Hei HaIHpMyIO, a ToJIbKO Hepe3 AKcHoptHpOBaHHbIe MeTOJbI. MeXJy TEM NOJIe MbIOTeKc BCTPOeHO. HO3TOMy MbI MoXeM PeaJIu3OBaTb MeTOJ Get TAK:
func (i *InMem) Get(key string) (int, bool) { i.Lock() PnAmO MCTyI K MeTOJy Lock v, contains := i.m[key] i.Unlock() To xe caMoe OTHOcHTeNbHO MeTOJa Unlock return v, contains }
HocKOJIbKy STOT MbIOTeKc BCTPOeH, MbI MoXeM HaIHpMyIO oCbAPHaTbC a K MeTOJaM Lock u Unlock H3 HOJIyHATeJIa i.
Mbi yxe roBOpuJIH, YTO To HmuMEp HETpaBUBIbHOro HcHONb3OBaHHa BCTpaBAnHHA THHOB. HOeMy TAK? HocKOJIbKy sync.Mutex - 3TO BCTPOeHHbI THH, MeTOJIb Lock u Unlock OyJyT npOJIbHraTbCra. HO3TOMy o6a MeTOJIa CTaHyt BHIJIMbIMU JJIa BHeIHHIX nOTpe6HTeJIeI, HcHONbJIyIOHIX InMem:
m := inmem.New() m.Lock() // ??
Takoe npoJbuzkeHue, nepoRTHO, - hekeJateJIbHbii 3pDekr. B 6oJIbHnHCTBe cJIy- vaeb Mblotekc - oTO TO, UTO Mbl XOITUM HHkaNCyJIPOBaTB B CTpyKTypy u cJelATb heBnJUMbIM JJIa bHeIIHIX KJIeHTOB. HOsTOMy 3Jecb He cJeJyET JelATb Ero BCTPOeHHbIM NOJEM:
type InMem struct { mu sync.Mutex $\leftarrow$ Yka3bIBaET, UTO nOne sync.Mutex He ABnJaETCA BCTPOeHHbIM m map[string]int }
IOcKOJIbKy Mblotekc He BCTPOeH u He 3KcIOpTUPyETcA, JOCTyI K HeMY BHeIIHIX NOTpe6HTeJIeJI 3aKpBIT.
PaccMOrpHM JpyroU HpMep, rJe BCTpaBaBHue MoxHO cYHTaTb nPaBnJIbHbIM NOJ- XOJOM.
HyxHO HaHucATb COcCTBeHHbII JORrep, coJepxKaIIii iO.WriteCloSer u JelJIaIOIIii JOCTyIHbIMu JBa MeTOJa: Write u ClOse. ECJIu 6bI iO.WriteCloSer He 6bI BCTPOeHHbIM, KOI 6bIJI 6bI TAKHM:
type Logger struct { writeCloser io.writeCloser } func (1 Logger) Write(p []byte) (int, error) { return 1. writeCloser.Write(p) $\leftarrow$ NepeHaPpaBJIeT BbIBOB Ha writeCloser } func (1 Logger) Close() error { return 1. writeCloser.Close() NepeHaPpaBJIeT BbIBOB Ha writeCloser } func main() { 1 := Logger{writeCloser: os.Stdout} $\begin{array}{rl}{\mathrm{\quad _},} & = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _} = \mathrm{\quad _~} \end{array}$ 1. Write([]byte("foo")) $\mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_} = \mathrm{_}$ Close() }
Logger JOLxKeH 6blI npeJocTaBHb IocTyI KaK K MetOJIy WritE, TaK u K MetOJIy ClOse, KOTOpbIE 6bI MoJIbKO nepeHaIIpaBJIaJI BbIBOB Ha iO.WriteCloser. HO eCIJI TeNepB cJelATb nOJIe BCTPOeHHbIM, TO MoxHO yJaJIHTb OTI MeTOJIb nepeHaIIpaBJIeHHA:
type Logger struct { io.writeCloser io.Writer JelJaEcTc BCTPOeHHbIM
}
func main() { 1 := Logger{WriteCloser: osStdout} - - = 1. Write([]byte("foo")) - = 1. Close() }
Логгер остается для клиентов тем же самым, с двумя экспюртированными методами Write и Close. Но при этом становится возможным не расписывать эти дополнительные методы для простой переадресации вызова. Кроме того, продвижение Write и Close означает, что Logger удовлетворяет интерфейсу io.Write- Closer.
Встраивание и создание подклассов в ООП
Отличие встраивания от создания подклассов в ООП иногда может сбивать с толку. Основное отличие между ними связано с идентификацией получателя метода. Посмотрим на следующую иллюстрацию. В левой части представлен тип X, встроенный в Y, тогда как в правой части Y расширяет X.
Foo() становится методом в Y, Foo() становится методом в Y, X остается получателем Foo(). Y становится получателем Foo().
При встраивании X остается получателем Foo. Но при создании подклассов получатель Foo становится подклассом — Y. Встраивание связано со структу- рированием, а не с наследованием.
Какой следует сделать вывод о встраивании типов? Прежде всего оно редко бывает по-настоящему нужно, а это значит, что независимо от сути конкретной
задачи мы, скорее всего, сможем решить ее и без применения встраивания типов. В основном оно используется для удобства: для продвижения поведения.
Если же мы все-таки решаем использовать встраивание типов, то нужно помнить о двух основных ограничениях:
- He следует его использовать исключительно как синтаксический сахар
- для упрощения доступа к полю (например, Foo.Baz() вместо Foo.Bar.Baz()). Если это единственная причина, то вместо встраивания внутреннего типа лучше использовать поле. Oно не должно продвигать данные (поля) или поведение (методы), которые мы хотим скрыть от посторонних глаз: например, если оно позволяет клиен-там получить доступ к поведению блокировки, которое должно оставаться приватным для структуры.
Примечание Кто-то может возразить, что использование встраивания типов приводит к дополнительным усилиям в сопровождении в контексте экспортируемых структур. И правда, встраивание типа внутрь экспортиру-емой структуры означает некоторую осторожность по мере использования типа. Например, если мы добавляем во внутренний тип новый метод, то важно убедиться, что он не нарушает ограничения этого типа. Чтобы из-бежать дополнительных усилий, имеет смысл запретить встраивание типов в публичные структуры.
Осознанное использование встраивания типов с учетом этих ограничений поможет избежать шаблонного кода с дополнительными методами перенаправления. Но важно понимать, что мы не делаем это исключительно в «косметических» целях и не продвигаем элементы, которые должны оставаться скрытыми.
В следующем разделе обсудим общие паттерны для работы с опциональными конфигурациями.
2.11. Ошвыка #11: НЕ ИСПОЛЬЗОВАТЬ ПАТТЕРН ФУНКЦИОНАЛЬНЫХ ОПЦИЙ
При разработке API может возникнуть вопрос: как быть с опциональными конфигурациями? Эффективное решение этой проблемы может улучшить удобство нашего API. В этом разделе рассматриваются конкретный пример и способы обработки опциональных конфигураций.
IpeanioJoxKIM, HужHO pa3pa6oTaTb 6u6JnOteKy, B KOTOpOu 6yJet peaJn3OBaHa hyHKuia dJia CO3JahHn HTTP- cepBepa. ApyMeHTaMn 3toH hyHKuin 6yJyT pa3Hbie BxOJHbIe JAHHbIe: aJpec u nopt. BoT TaK BbIrJraJHT cKeJIeT 6yHKuIN:
func NewServer(addr string, port int) (*http.Server, error) { // ...}
KJIeHTbI HaIIeH 6u6JnOteKu Ha4aJn nOJb3OBaTbCra 3toH 6yHKuIeH, u do nOpbI do BpeMeHn OHn JOBOJbIHbI. Ho B KaKOuI- To MOmeHT OHn HaIVHAIOAT JKaJOBaTbCra, 4TO hyHKuIa HeCKOJbIKO OTpaHHuVHeHa, B YaCTHOCT, He IMeET JpyrUX BxOJIbIX napaMeTpOB (HaIIpUMep, TaHM- aYt SaIInCn I KOHTEKCT nOJKJIOvEHTa). MbI OTBevAeM, 4TO Jo6aBJIeHHe HObBIX napaMeTpOB hyHKuIu nPIbOJUT K nPO6JIeMaM CObMeCTUMOCTu, bIHyJyKJaJ KJIeHTbO b3MeHeHTb CIIOCO6 bIb3OBa NewServer. MeJxJy TeM MbI XOteJIu 6bI paCIIIIpHTb JorHKy yIPIaBJIeHHa nOptaMn TaK (puc. 2.9):
- ECTu nOpt He yKa3aH, TO uCnOJIb3yETcra 3aJIaHHbIH no yMOLJaHHIO nOpt.
- ECTu napaMeTp nOpta OTpIIaTeJIeH, TO BO3BpaIIaTeCTa OIIaJIbKa.
- ECTu napaMeTp nOpta paBeH 0, TO uCnOJIb3yETcra CJyVHaIHbIH nOpt.
- B OCTaJIaIHbIX CJyVHaIX 6yHKuIN HCHOJIb3yET nOpt, a3aJIaIHbIH KJIHeHTOM.
Puc. 2.9. JOrTUKa, CB3aHHaJ C ONIyHMH 3HaVHeHn IapaMeTpI aOrTa
Как мы можем реализовать эту функцию удобным для API способом? Посмотрим на варианты.
2.11.1. Ctpyktypa Config
IockoJbKy Go he nodepxuBaeT heo63aTeJbHbIe napametpbi B cunHatypaX yHkH- uii, nepbHii BosMoxHbii nOJxoJ - ucnOJb3OBaTb ctpykTypy konqHurypaHii JJH nepeJauH toro, vTO o63aTeJbHo, a vTO - onHinoHaJbHo. HanpHmer, o63aTeJbHbIe napametpbi Moryt saJaBaTaCbIc Kak napametpbi yHkHuu, torTa kak onHinoHaJbHbIe Moryt o6pa6aTbBaTaCbIc b ctpykType Config:
type Config struct { Port int }
func NewServer(addr string, cfg Config) { }
Takoe peHHeHe yeYtpaHHeT npo6Jemy cobMeCTHmOCTH. EcJH MbI J66aBUM HOBbIe onHuu, OHH He 6yJyT JOMaTaCbIc Ha cTOpOHe KJIHeHTa. Ho STOT nOJxoJ He peHHeT 3aJaayy nO yHpaBaJIeHHO nOptaMH B cOOTbTeCTbHH C Tpe6OBaHaJMH. CJeJyET UMTeb B BJJy, vTO ecJH nOJIe ctpykTypb He saJaHO, ero HyxHO HHHnHaJIH3HPOBaTb HyJIe- bHM 3HaJeHeHeM:
0 JJr IeJIOyHcJIeHHOro napametpa; JJr THHa c nJIaBaaOHHeI TOyKOH; 1 1 JJr cTpOKH; Nil - JJr cpe3OB, KaPT, KaHaJIOB, yKa3aTeJIeI, HHTEpDeIcOB H yHkHuu.
IOsTOMy B cJIeJIyIOHeM nHmHepe o6e ctpykTypb 3KBuBaJIeHTHbI Jpyr Jpyry:
c1 := httplib.Config{ Port: 0, ←- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } c2 := httplib.Config{ ←- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - }
B HaHeM cJIy4ae HyxHO HaHTH cIOOc6, KaK OTJIHHTb nOPT, 3HaJeHeHe KOTOporo HaHeHeHHO yCTaHOBJIeHO KaK 0, OT OTcyTCTbYIOHeTO nOPTa. OJHHM H3BaHuaHTOB cTaHeT o6pa6oTKa Bcex napametpOB ctpykTypb konqHurypaHuu KaK yKa3aTeJIeI:
type Config struct { Port *int }
C nomoupью ieJouyncJehmero ykasatera mi cemaHTyeccku cmaocem bblJelUTb pa3Huy my meKdy shauHnem 0 H otcyyctcyyouyHm shauHnem (nyJebbIM ykasateraJeM - nil).
To pa6ouHnH BapHant, Ho y heTo cctb heckoJbko heJocctaTkoH. IpeKJe bCero KJH- eHTaM MoKTeT 6bIb HeyJIOHO aJababTb HeJouyicJehHbHbI ykasateraJIb. IM npuyetcr aO3JaTb nepeMeHHyIO, a saTeM nepeJabatb ykasateraJIb:
port := 0 config := httplib.Config{ Port: &port, $\leftarrow$ 3aJaeTc4 ueJouyncJehHbHbI ykasaterb }
Huyero ctpauHnHO, ho b ueJOM API cTaHOBHTcH meHee yJIOHbIM b ucnoJIb3OBaHnH. KpOMe torO, yEM 6oJIbHHe onHnHb Mbl JIOaBJIaEM, TeM cJIOXHee cTaHOBHTcH KOI.
BtorbIM HeJIOCTaTKOM HbIbHeTcH To, YTO KJIHeHT, ucnoJIb3yIOHbIH HaHnIy 6u6JIuOTeKY c KoH@HurypaHHeH Hn yMOJIyHAnHO, JIOJXeH 6yJIeT nepeJabatb nJcyTIO cTpyKTypy:
httplib.NewServer("localhost", httplib.Config{)
KoJ bHirJraHHT TaK cEcEe. Te, KTO 6yJyT ero HHTaTb, JIOJXHbI 6yJyT nOHHTb cMbICJI 6TOH maruyecKOH cTpyKTyPbI.
JpyroH bapHant - ucnoJIb3OBaTb KJIaccyHecKuH nattHep HcTpouTeJIb, KaK npeJ- cTaBJIeHO b cJIeJIyIOHeH pa3JIeJIe.
2.11.2. Паттерн Строитель
Первоначально входивший в состав паттернов проектирования «Банды четырех», Строитель обеспечивает гибкое решение различных проблем создания объектов. Конструирование Config отделено от самой структуры. Для этого требуется дополнительная структура ConfigBuilder, которая получает методы для конфигурирования и создания файла Config.
Рассмотрим конкретный пример и то, как он поможет в разработке удобного API, отвечающего всем требованиям, включая управление портами:
type Config struct { $\leftarrow$ Ctryktypa Config Port int }
type ConfigBuilder struct { Crtрукta Config builder, coplepxaaar onyioonaHbHbi nort port *int } func (b *ConfigBuilder) Port( port int) *ConfigBuilder { 0tkpbTbH (public) metoA dAa haetponiku nopta b.port $=$ &port return b } MetoBuild dna co3aHn func (b *ConfigBuilder) Build() (Config, error) { 1 Crrkyryb config cfg : $=$ Config{ if b.port $= =$ nil { 0cHOBHaa noruka, cBraHHa c ynpaBneHem noptami cfg.Port $=$ defaultHTTPPort } else{ if *b.port $= = 9$ { cfg.Port $=$ randomPort() } else if *b.port $< 0$ { return Config{, errors.New("port should be positive") } else{ cfg.Port $=$ *b.port } } return cfg, nil } func NewServer(addr string, config Config) (*http.Server, error) { //... }
Ctрукtypa ConfigBuilder coplexait kohpyrypaunio kluhenta. Ona npeDocrabraet metoPort dria haetponiku nopta. O6bHHO takoM metoK oHoourypupobanua BosBpaIIaet cam CtpouTeJIb, tO6bI Mbl moJiu ucnoJIbSOBaTb IeIOyky MetoJIOB (Haunpmer, builder.Foo("foo").Bar("bar)). On takxe npeDocrabraet metoJ Build, kotopbHl coplexait JoruKy HHHnIaJIHsaiI1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
PruMEyAHIE PeaJnBauHs CtpouTeJIa BosMOxHa pa3JIHbIMu cIOO6aMn - kakoro- To eJyHctBHeHn BePHoro het. Haunpmer, heKotorpbie npeDnoYHTaIOT nOJXO, npu kotopcoM JoruKa dJia onpeDEJIeHnIa OKOHyATeJIbHOro 3havHnIa nopta HaxOJHTcA BHyTpu MetOJa Port, a He Build. IeJIb SToro pa3JIeTa - npeDctaBHTb o63op CtpouTeJIa, a He paccMOrтрEb bce BapuaHTb1 peaJIb3aII11.
3atem kluheHt 6yJet hcnOJIb3OBaTb cO3JaHHbIH Ha OCHOBc CtpouTeJIa API (MbI npeDIOJIaRAeM, vTO nOmeCTHJIu KOI B nAkET httplIB):
builder := httplib.ConfigBuilder{} $\leftarrow$ Создается Ctpoutenb config builder.Port(8080) $\leftarrow$ Задается порт cfg, err := builder.Build() $\leftarrow$ Создается Ctpyktypa config if err != nil { return err } server, err := httplib.NewServer("localhost", cfg) $\leftarrow$ Передается Ctpyktypa config if err != nil { return err }
Сначала клиент создает ConfigBuilder и использует его для настройки опцион- ного поля, например порта. Затем вызывает метод Build и проверяет наличие ошибок. Если все в порядке, то конфигурация передается в NewServer.
Такой подход делает управление портами удобнее. Не требуется передавать целочисленный указатель, так как метод Port принимает целое число. Но все еще нужно передать Ctpyktypy Config, которая может быть пустой, если клиент хочет использовать конфигурацию по умолчанию:
server, err := httplib.NewServer("localhost", nil)
Другой недостаток в некоторых ситуациях связан с обработкой ошибок. В языках программирования, где генерируются исключения, методы Ctpouтеля, например Port, могут генерировать исключения, если входные данные неверны. Если мы хотим сохранить возможность связывать вызовы в цепочки, функция не может возвращать ошибку. Поэтому приходится откладывать проверку в методе Build. Если клиент может передавать несколько параметров, но нужно обработать именно тот случай, когда порт недействителен, это усложняет обработку ошибок.
Рассмотрим другой подход, называемый паттерном функциональных опций, который опирается на вариативные аргументы.
2.11.3. Паттерн функциональных опций
Последний подход, который мы обсудим, — это паттерн функциональных опций (рис. 2.10). Есть разные реализации, отличающиеся друг от друга небольшими вариациями, но основная идея в следующем:
Нежспортированная Ctpyktypa содержит конфигурацию: options.
Каждая из ее опций представляет собой функцию, которая возвращает ошибку одного и того же типа: type Option func(options *options). Например, withPort
имеет аргумент типа int, значение которого соответствует порту, и возвращает тип Option, представляющий способ обновления структуры options.
[ImageCaption: Pис. 2.10. Опция WithPort обновляет окончательную структуру options]
Bot реализация структуры options, типа Option и опции with- Port на Go:
type options struct { Konduryация структуры port *int} Type Option func(options *options) error func WithPort(port int) Option { Konduryация конфигурации, которая обновляет порт return func(options *options) error { if port < 0 { return errors.New("port should be positive") } options.port = &port return nil}
3десь WithPort возвращает замыкание. Замыкание (closure) — это анонимная функция, которая ссылается на переменные вне своего тела, в данном случае на переменную port. Замыкание учитывает тип Option и реализует логику проверки порта. Каждое поле конфигурации требует создания публичной функции (которую принято начинать с предикса with), содержащей аналогичную логику: проверка входных данных при необходимости и обновление структуры конфигурации.
Посмотрим на последнюю часть на стороне провайдера, а именно — как реализован NewServer. Мы будем передавать параметры как вариативные аргументы.
Cледовательно, нужно перебрать все эти параметры, чтобы видоизменить струк- туру конфигурации options:
func NewServer(addr string, opts ...Option) ( Nnnnnnnean annnnnnnnnnnn *http.Server, error) $\leftarrow$ aprymentb Option var options options Co3dаетca nyctan ctpyktypa options for opt $\equiv$ range opts{ Ppno3b0aunc nep6op bce x b0bix onnii err $\equiv$ opt(&options) Ppnoycnunt bhzoe kawpon onnii, vto npnepant if err $! =$ nil{ K H3mhenHnno o6uei ctpyktypbi options return nil, err } } // Ha stom stane cTpoutrca ctpyktypa options struct, kotopar coapexnT config // Takum o6pa3om, bbi moxem peanusobatb Hauy noruky, cbesaHnyo // c konhpurypaueu nopta var port int var port int if options.port $= =$ nil{ port $=$ defaultHTPPort }else{ if *options.port $= = 0$ { port $=$ randomPort() }else{ port $=$ *options.port } } //... }
Havнем c создания nустoui ctpyktypbi options. 3atem mi nepenupaeM kaxbii apryment Option u bbnolnrem ux dria bnoymenenhna ctpyktypbi options (umeite b buly, vto tun Option - - oto pyhKlria). Kak toIbko ctpyktypa options cosJaha, moxho peanusobatb oonHvatelhnyio Joruky ynpablenHnH noptramu.
IockoJbky NewServer принимat bapnatusbHbie aprymentH Option, KJueHt moxet bH3bIBaTb sTOT API, nepedabaa heckoJbko napametpob nocne o63aTeJbHoro aprymenta aJpeca. Hanpимер,
server, err := httplib.NewServer("localhost", httplib.WithPort(8080), httplib.WithTimeout(time.Second))
Ho ecJiu emy hyxHa konhpurypaun no ymoJyahnio, he hyxho 3aJabatb apryment (hanpимер, nyctyio ctpyktypy, kak b npeJbIyHnux cJyvaax). BbI3OB co cTOPOHbI KJueHTa tenepb moxet bblJraJETb Tak:
server, err := httplib.NewServer("localhost")
Это паттерн функциональных опций. Он предоставляет удобный и дружественный к API способ обработки опций. Хотя Строитель может быть допустимым вариантом, у него есть некоторые незначительные недостатки, которые делают паттерн функциональных опций идиоматическим способом решения этой проблемы в Go. Этот паттерн используется в разных библиотеках Go, например gRPC.
В следующем разделе поговорим о неорганизованности проекта.
2.12. ОШИБКА #12: НЕОРГАНИЗОВАННОСТЬ ПРОЕКТА
Организация проекта на Go — непростая задача. Поскольку Go предоставляет большую свободу при создании пакетов и модулей, не так просто выделить действительно лучшие практики. В этом разделе обсудим распространенный способ структурирования проекта, а затем несколько практических способов улучшения его организации.
2.12.1. Структура проекта
В языке Go нет строгого соглашения о структурировании проекта. Но с годами появился один макет проекта (
https://github.com/golang- standards/project- layout).
Если проект достаточно мал (всего несколько файлов) или если компания уже создала свой стандарт, возможно, этот макет проекта не стоит использовать или переходить на него. В других случаях советуем рассмотреть такой вариант. Основные каталоги в проекте:
-
/cmd — основные исходные файлы. Файл main.go приложения foo должен находиться в /cmd/formain.go.- /internal — закрытый (private) код: мы не хотим, чтобы другие импортировали его для своих приложений или библиотек.- /pkg — общедоступный (public) код, который мы хотим предоставлять другим в пользование.- /test — дополнительные внешние тесты и тестовые данные. Юнит-тесты в Go находятся в том же пакете, что и исходные файлы. Но, например, общедоступные тесты API или интеграционные тесты должны находиться в /test.
-
/configs
-
файлы конфигурации.- /docs
-
проектные и пользовательские документы.- /examples
-
примеры для нашего приложения и/или общедоступной библиотеки.- /api
-
файлы контрактов API (Swagger, Protocol Buffers и т. д.).- /web
-
ресурсы, относящиеся к веб-приложению (статические файлы и т. д.).- /build
-
файлы упаковки и непрерывной интеграции (CI).- /scripts
-
скрипты для анализа, установки и т. д.- /vendor
-
зависимости приложений (например, зависимости модулей Go).
B этом списке нет каталог /src, как в некоторых других языках. Причина в том, что /src слишком общий, поэтому в этом makете отдается предпочтение /cmd, /internal или /pkg.
Примечание B 2021 году Расс Кокс (Russ Cox), один из основных мейнтейнеров Go, раскритиковал этот maket. В основном проект существует в рамках организации GitHub golang- standards, хоть и не является офици- альным стандартом. Имейте в виду, что в отношении структуры проекта нет обязательных соглашений. Этот maket может быть полезен вам или нет, но не- решительность — единственное неправильное решение. Поэтому согласуйте maket для поддержания единообразия в своей компании, чтобы разработчики не тратили время на переход от одного репозитория к другому.
Теперь поговорим, как организовать в Go основную логику репозитория.
2.12.2. Организация пакета
B Go нет концепции «позднакетов», но можно организовывать пакеты в под- каталогах. Если посмотреть на стандартную библиотеку, то сетевой каталог организован так:
yamc/net /http client.go ... /smtp auth.go ... addselect.go ...
net действует и как пакет, и как каталог, содержащий другие пакеты. Но net/http he наследуется из net и не имеет особых прав доступа к пакету net. Элементы внутри net/http могут видеть только экспортированные элементы net. Основное преимущество подкаталось состоит в том, что пакеты хранятся там, где у них максимальная связность (cohesion) с другими элементами.
Что касается общей организации, то на этот счет есть разные мнения. Например, нужно ли организовать приложение, отталкиваясь от контекста или от уровней? Это зависит от ваших предпочтений. Можно использовать группировку кода по контексту (например, контекст потребителя, контекст контракта и т. д.) или следовать принципам гексагональной архитектуры и группировке по техниче- скому уровню. Если решение соответствует варианту использования, оно не может быть неправильным, пока мы действуем последовательно.
В отношении пакетов есть несколько лучших практик, которым будет полезно следовать. Так, рекомендуется избегать преждевременной упаковки, поскольку это может привести к презмерному усложнению проекта. Иногда лучше исполь- зовать простую организацию и развивать проект, сохраняя четкое понимание того, что в нем содержится, чем заставлять себя создавать идеальную структуру с самого начала.
Детализация (гранулярность) — еще одна важная вещь, которую следует учи- тывать. Избегайте десятков «нанопакетов», содержащих только один или два файла. Если же подобное происходит, то, вероятно, потому, что пропущены не- которые логические связи между этими пакетами. Это затрудняет понимание проекта теми, кто будет читать код. И наоборот, избегайте огромных пакетов, за содержимым которых теряется смысл того, почему этот пакет назван именно так.
К выбору названий пакетов подходите с осторожностью. Придумывать имена сложно. Чтобы помочь клиентам понять проект Go, называйте пакеты, оттал- киваясь от их возможностей, а не от их содержимого. Название должно быть осмысленным. Имя пакета должно быть коротким, лаконичным, выразительным, а еще состоять из одного слова в нижнем регистре.
Что касается вопроса о том, что следует экспортировать, то тут правило простое. Сводите к минимуму то, что должно быть экспортировано, чтобы уменьшить связанность (coupling) между пакетами и скрывать ненужные экспортируемые элементы. Если нет уверенности, надо ли экспортировать какой-то элемент или нет, по умолчанию его не нужно экспортировать. Позже, если обнаружится, что экспортировать его все же нужно, можно изменить код. Помните и о некоторых исключениях, например о создании экспортируемых полей, чтобы структуру можно было демаршалировать с помощью encoding/json.
Организовывать проект непросто, но соблюдение приведенных правил упростит его поддержку. Помните, что согласованность также жизненно важна для облегчения сопровождения. Так что важно удостовериться, что в кодовой базе все консистентно.
Рассмотрим пакеты утилит.
2.13. ОШИБКА #13: СОЗДАВАТЬ ПАКЕТЫ УТИЛИТ
Поговорим о весьма распространенном неудачном приеме: создании общих пакетов, таких как utils, common и base. Рассмотрим проблемы, связанные с таким подходом, и узнаем, как улучшить организацию кода.
Рассмотрим пример, вдохновленный официальным блогом Go. Речь идет о реализации заданной структуры данных (карты, где значение игнорируется). Идиоматический способ сделать это в Go - обработка через тип map[K]struct{} с параметром K, который может быть любым типом, допустимым в map, в качестве ключа, в то время как значение является типом struct{}. Карта с типом значения struct{} передает, что нас не интересует само значение. Представим два метода в пакете utils:
package util
func NewStringSet(...string) map[string]string{} { // ...}func SortStringSet(map[string]string{}) []string { // ...}
Клиент будет использовать этот пакет bot так:
set := util.NewStringSet("c", "a", "b")fmt.Println(util.SortStringSet(set))
Проблема в том, что название пакета util бессмысленно. Мы могли бы назвать его common, shared или base, но эти названия тоже бессмысленны и не дают никакого представления о том, что делает этот пакет.
Вместо «пакет утилит» (utility package) лучше придумать более выразительное имя, например stringset («набор строк»):
package stringset
func New(...string) map[string]struct{} { ... } func Sort(map[string]struct{}) []string { ... }
B отом примере мы удалили суффиксы для NewStringSet и SortStringSet, которые соответственно стали New и Sort. На стороне кишента это теперь выглядит так:
set := stringset.New("c", "a", "b") fmt.Println(stringset.Sort(set))
Примечание В предыдущем разделе я затронул идею «нанопакетов». Создание в приложении десятков нанопакетов может усложнить отслеживание того, что стоит за выполнением кода. Но сама идея использования нанопакетов не всегда плохая. Если небольшая группа кода имеет высокую внутреннюю связность и не относится к чему-либо еще, приемлемо выделить ее в отдельный пакет. Строгого правила для этого нет, и задача часто состоит в том, чтобы найти баланс.
Можно найти еще дальше. Вместо создания служебных функций можно создать специфический тип и предоставить Sort как метод так:
package stringset
type Set map[string]struct{} func New(...string) Set { ... } func (s Set) Sort() []string { ... }
Это изменение делает клиент еще более простым. На пакет stringset будет только одна ссылка:
set := stringset.New("c", "a", "b") fmt.Println(set.Sort())
Небольшой редакторник убирает бессмысленное имя пакета и дает выразительный API. Как упомянул Дейв Чейни (член команды проекта Go), мы достаточно часто находим служебные пакеты, которые управляют стандартными возможностями. Например, если мы решим иметь клиентский и серверный пакеты, куда нужно поместить общие типы? В этом случае одним из возможных решений будет объединение клиента, сервера и общего кода в единый пакет.
Именование пакетов — важная часть общей конструкции приложения, к этому следует подходить с вниманием и осторожностью. Как правило, создание общих пакетов без осмысленных имен — плохая практика, к ней можно от- нести служебные пакеты с именами вроде utils, common или base. Именуйте пакеты, отталкиваясь от того, какие действия он производит, а не от того, что в нем содержится. Это будет эффективным способом повысить его выразительность.
В следующем разделе обсудим пакеты и коллизии имен.
2.14. ОШИБКА #14: ИГНОРИРОВАТЬ КОЛЛИЗИИ ИМЕН ПАКЕТОВ
Коллизии имен пакетов возникают, когда переменная имеет такое же имя, как и у существующего пакета, что мешает его переносользованию. Рассмотрим пример с библиотекой, открывающей клиент Redis:
phppackage redistype Client struct { ... }func NewClient() *Client { ... }func (c *Client) Get(key string) (string, error) { ... }
Перейдем на сторону клиента. Несмотря на существование имени пакета redis, в Go вполне допустимо создать и переменную с именем redis:
3десь происходит коллизия имени переменной redis с именем пакета redis. Хотя такое использование наименований и разрешено, его следует избегать. Во всей области действия переменной redis пакет redis будет недоступен.
Предположим, что какой-то квалификатор ссылается как на переменную, так и на имя пакета внутри всей функции. В этом случае тот, кто читает код, может и не понять, на что он ссылается. Как избежать такой коллизии? Первый вариант — использовать другое имя переменной. Например:
```phpredisClient := redis.NewClient()v, err := redisClient.Get("foo")```
Это, пожалуй, самый простой способ. Но если по какой- то причине нужно оставить redis в качестве имени нашей переменной, можно поиграть с импортом пакетов. Применяя импорт пакетов, можно использовать псевдоним, чтобы изменить квалификатор для ссылки на пакет redis. Например:
import redisapi "mylib/redis" Для пакета redis создается псевдоним // ...
redis := redisapi.NewClient() Указывается на доступ к пакету redis через псевдоним redisapi v, err := redis.Get("foo")
Для импорта использовался псевдоним redisapi, ссылакющийся на пакет redis, чтобы сохранить имя переменной redis.
Примечание Один из вариантов — использование точечного импорта для доступа ко всем общедоступным элементам пакета без ссылки на квалификатор пакета. Но такой подход приводит к общему возрастанию путаницы, и в большинстве случаев его следует избегать.
Следует избегать коллизий имен переменных и встроенных функций. Например, мы могли бы сделать что-то вроде такого:
Но тогда встроенная функция сору будет недоступна, пока есть переменная сору. Поэтому нужно стремиться предотвращать коллизии имен переменных, чтобы избегать двусмысленности. Если все же по какой- то причине коллизия происходит, найдите другое имя, которое будет нести нужный смысл, либо используйте псевдоним импорта.
В следующем разделе рассмотрим распространенную ошибку, связанную с документацией.
# 2.15. Ошивка #15: НЕ ПИСАТЬ ДОКУМЕНТАЦИЮ ПО КОДУ
Создание соответствующей документации — важная часть написания кода. Она упрощает клиентам использование API, а также помогает в поддержке
и сопровождении проекта. Некоторые правила Go помогут сделать код идио- матичным.
Прежде всего, каждый экспортируемый элемент должен быть задокументирован. Будь то структура, интерфейс, функция или что-то еще, если элемент экспортируется, он должен быть задокументирован. Принято добавлять комментарии, начиная с имени экспортируемого элемента. Например:
// Customer - это представление потребителя. type Customer struct{ // ID возвращает идентификатор потребителя. func (c Customer) ID() string { return "" }
По правилам, каждый комментарий должен быть полным предложением, заканчивающимся точкой. Также имейте в виду, что когда мы описываем функцию (или метод), мы должны указывать то, что функция должна делать, а не то, как она это делает. Это относится и к ядру функции, и к комментариям, но не к документации. В идеале документация должна содержать достаточно информацию, чтобы клиенту не нужно было каждый раз изучать код, чтобы понять, как использовать экспортируемый элемент.
# Устаревшие элементы
Экспортированный элемент можно объявить устаревшим с помощью комментария // Deprecated:
// ComputePath возвращает быстрейший путь между двумя точками. // Deprecated: Эта функция использует устаревший способ расчета быстрейшего // пути. Вместо нее используйте ComputeFastestPath. func ComputePath() {}
Тогда, если разработчик использует функцию ComputePath, он должен получить соответствующее предупреждение. (Большинство IDE обрабатывают комментарии // Deprecated:.)
Когда дело доходит до документирования переменной или константы, нас может заинтересовать передача связанных с ними двух аспектов: назначения и содержания. Первый должен быть отражен в документации, что будет полезно для внешних клиентов. Последний не обязательно делать публичным. Например:
// DefaultPermission разрешение по умолчанию, используемое движком магазина. const DefaultPermission = 00644 // Необходим доступ на чтение и запись.
Эта константа представляет собой разрешение по умолчанию. Документация передает ее назначение, тогда как комментарий рядом с константой описывает ее фактическое содержание (доступы для чтения и записи).
Чтобы помочь клиентам и мейнтейнерам понять объем пакета, нужно докумен- тировать каждый пакет. По соглашению комментарий начинается с // Package, за которым следует имя пакета:
// Пакет math представляет основные константы и математические функции. // // Этот пакет не гарантирует битовую идентичность результатов // в разных архитектурах. package math
Первая строка комментария к пакету должна быть краткой, поскольку она появится в пакете (см. пример на рис. 2.11). В последующих строках можно изложить остальную информацию.
Рис. 2.11. Пример сгенерированной документации стандартной библиотеки Go
Документирование пакета можно выполнить в любом из файлов Go, здесь нет никаких правил. Обычно документацию по пакету помещают в соответствующий файл с тем же имением, что и сам пакет, или в какой-то определенный файл, например doc.go.
И последнее, что следует сказать о документации пакетов: комментарии, не примыкающие к объявлению, опускаются. Например, следующий комментарий об авторских правах не будет виден в создаваемой документации:
// Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD- style // license that can be found in the LICENSE file. // Пакет math представляет основные константы и математические функции. // Пустая строка. Предыдущие комментарии не будут включены в документацию // Этот пакет не гарантирует битовую идентичность результатов // в разных архитектурах. package math
Kazdый экспортируемый элемент должен быть задокументирован. Документи- рование кода не должно быть чем- то ограничено. Пользуйтесь всеми возможностями, чтобы убедиться, что оно поможет клиентам и мейнтейнерам понять назначение кода.
В последнем разделе этой главы рассмотрим распространенную ошибку, каса- ющуюся инструментов: неиспользование линтеров.
# 2.16. OllnBKA #16: HE UNOJIb3OBATb JINHTePbI
JInhtep - oTO abTOMATyIyecKuH InCTryMEHT JIA aHaJIN3a KOJa I OTJOBa OINIOOK b hem. B sadaYu storo pa3Jena he BxOJunt npedocTabJehue ucvepIIbIaIOIero cInucka cyIIecTbYIOIux JInhtepob, nocKoJbKy takoU cInicok cJehb 6bIcTO yCTapeet. Ho hyxHO nOHIMaTb H nOMHHTb, noyey JInhtepbI baxHb JIA 6OJIbIINHCTBa npoekTOB Ha Go.
PaccмотpHM npuMEp. B pa3JeTe, nocBraIIeHOM OINIOke #1, uia o6cyxKdJIn 3aTe- hение nepemehhbx. HcnoJIb3yJy JInhtep vet, bCTpOeHHbIb b Ha6Op InCTryMEHTOB Go, a tAKxke shadow, moxHO o6HaPyxHbTs 3aTeHeHbIe nepemehhbie:
package main
import "fmt"
func main(){ i := 0 if true{ i := 1 3aTeHeHbHa nepemehHbA fmt.Println(i) } fmt.Println(i) }
IOcKoJIbKy vet BKJIoVCH B 6InHaPHbIi naket Go, yCTaHOBHM cHaTaJIa shadow, cBxKEM ero c vet, a saTeM saIIyCTUM:
$\Updownarrow$ go install \ golang.org/x/tools/gp/analysis/pases/shadow/cmd/shadow VctahOBka shadow $\Updownarrow$ go vet - vettool $= \Phi$ (which shadow) YctahOBmehue cBz3u c Go vet vepes ./main.go:8:3: nconb3yBahue apryMeHTa vettool declaration of "i" shadows declaration at line 6 Go vet bBraBraT 3aTeHeHbYO nOpeMeHbYO
Как мы видим, vet сообщает, что переменная i в этом примере затенена. Ис-пользование соответствующих литеров поможет сделать код более надежным и обнаружить потенциальные ошибки.
ПРИМЕЧАНИЕ Линтеры не выявляют все ошибки, описанные в этой книге. Поэтому рекомендуем продолжить чтение ;).
Еще раз подчеркиваем, что цель этого раздела — не перечисление всех доступных литеров. Но вот список, с которым точно можно ежедневно сверяться:
https://golang.org/cmd/vet/ — стандартный анализатор Go. https://github.com/kisielk/errcheck — средство проверки ошибок. https://github.com/fzipp/gocyclo — анализатор цикломатической сложности. https://github.com/jgautheron/goconst — анализатор повторяющихся строковых констант.
Помимо литеров, используйте форматировщики кода для исправления стиля написанного кода. Вот некоторые инструменты, которые стоит попробовать:
https://golang.org/cmd/gofmt/ — стандартный форматировщик кода Go. https://godoc.org/golang.org/x/tools/cmd/goimports — стандартный модуль форматирования импорта Go.
Взгляните и на golangci- lint (https://github.com/golangci/golangci- lint). Это инструмент для анализа кода, который обеспечивает видимость поверх многих полезных литеров и форматировщиков. Он позволяет запускать литеры параллельно для повышения скорости анализа, что весьма удобно.
Линтеры и форматировщики — это мощные способы улучшить качество и согласованность кода. Уделите время тому, чтобы понять, какие из них следует использовать, и убедитесь, что автоматизировали их выполнение (например, с помощью CI или с Git pre- commit hook).
# ИТОГИ
- Избегайте затенения переменных во избежание ссылок на неправильную переменную или запутывания читателей кода.
- Избегайте использования вложенных уровней и выразнивайте «счастливый путь» по левому краю — это упрощает построение ментальной модели кода.
- При инициализации переменных помните, что функции инициализации содержат в себе ограниченные возможности по обработке ошибок, что усложняет обработку состояний и тестирование. В большинстве случаев инициализации следует обрабатывать как специальные функции.
- Принудительное использование геттеров и сеттеров не является в Go идио-матическим. Правильный подход заключается в том, чтобы быть прагматичным и находить должный баланс между эффективностью и следованием определенным идиомам.
- Абстракции следует «открывать», а не создавать. Для предотвращения из-лишней сложности создавайте интерфейс только тогда, когда он действительно нужен, а не тогда, когда вы лишь предполагаете, что он может понадобиться в будущем, либо если можете доказать, что абстракция допустима.
- Размещение интерфейсов на стороне потребителя позволяет избежать из-лишних абстракций.
- Чтобы избавиться от ограничений с точки зрения гибкости, в большинстве случаев функции должны возвращать не интерфейсы, а конкретные реализации. И наоборот, функции должны принимать интерфейсы всегда, когда это возможно.
- Используйте апу только в том случае, если нужно принять или вернуть любой возможный тип, например json.Marshal. В противном случае апу не несет значимой информации и может привести к проблемам при компиляции, позволяя вызывающей функции обращаться к методам слюбым типом данных.
- Полагаясь на дженерики и параметры типа, можно избежать написания шаблонного кода для разделения элементов или поведения. Используйте параметры типа лишь тогда, когда видите конкретную необходимость в них. В противном случае они вводят ненужные абстракции и усложняют код.
- Использование встраивания типов также поможет избежать шаблонного кода. Но убедитесь, что это не приведет к проблемам с видимостью в тех случаях, когда некоторые поля должны оставаться скрытыми.
- Для подходящей обработки параметров в удобной для API манере использ- зуйте паттерн функциональных опций.
- Следование макету проекта может стать хорошим способом структурировать проект, особенно если в новом проекте вы стремитесь к соблюдению имею- цихся соглашений для стандартизации.
- Именование — важнейшая часть проектирования приложений. Создание пакетов с именами common, util или shared не имеет ценности для читателя кода. Преобразуйте имена таких пакетов во что-то более осмысленное и конкретное.- Чтобы избежать коллизий имен переменных и пакетов, приводящих к пучанице или ошибкам, используйте уникальные имена для каждого из них. Если это невозможно, применяйте псевдоним импорта, изменяя квалификатор так, чтобы отличать имя пакета от имени переменной, или придумайте лучшие имена.- Чтобы клиенты и мейнтейнеры проекта лучше понимали назначение кода, документируйте экспортированные элементы.- Чтобы улучшить качество и внутреннюю согласованность кода, используйте линтеры и средства форматирования.
# Bэтой главе:
- Типичные ошибки, связанные с основными типами- Фундаментальные концепции срезов и карт, которые надо знать для предотвращения возможных ошибок, утечек или неточностей- Сравнение значений
Работа с типами данных — часть стандартной работы инженеров- программистов. В этой главе рассмотрим распространенные ошибки, связанные с базовыми типами, срезами и картами. В этой главе мы не говорим о строках, им посвящена следующая глава.
# 3.1. ОШИБКА #17: ПУТАНИЦА С ВОСЬМЕРИЧНЫМИ ЛИТЕРАЛАМИ
Рассмотрим частую путаницу с представлением восьмеричного литерала, которая может привести к ошибкам. Как вы думаете, что выведет этот код?
sum := 100 + 010 fmt.Println(sum)
Ha nepbbii B3rJraJ MoXHO oXnJaTb, vTO B pe3yJIbTaTe BbInOJIHeHnIa SToro koJa 6yJet bIbJeJeho 100 + 10 = 118. Ho bMecTO SToro nOJIyAaTcA 108. HOyEMy?
B Go IeJIOYHCJIeHHbIb JInTEpaJI, HaYHHaIOIIuIicA c 0, cYHTaEcTcA bOcMbEpIyHHbIM IeJIbIM YHCJIOM (to eCTb YHCJIOM no OCHOBaHHIO 8), NoSTOMy 10 no OCHOBaHHIO 8 paBHraTcA 8 no OCHOBaHHIO 10. TAKIM o6pa3OM, cYMMa B npeJbIJIyIeM npuMepe paBHa 100 + 8 = 108. DOMHHTb o TAKOM cBOuCTBe IeJIoYHCJIeHHbIX JInTEpaJIbO oYeHb BaxHO, vTO6bI u36eTaTb nYTaHHIIbI npu vTEHnI KOJa.
BocbMepIyHbIe IeJIbIe YHCJIa NoJIe3HbI b pa3HbIX cIeHaIpaIX. JOnIyCTHM, MbI XO- THM OTKpbITb bJaII c NoOMIIbIb Os.OpenFile. Ota yyHKHnIa Tpe6yET nepeJaYH pa3peIIeHnIa KaK uInT32. ECJIu MbI XOtHM cOoTbETCTbOBaTb pa3peIIeHnIO Linux, TO DJIa JyYIIIIeI YHTaEMOCTu MoxEM nepeJaTb bocbMepIyHbIe YHCJIb bMecTO YHCJIa no OCHOBaHHIO 10:
file, err := os.OpenFile("foo", os.O_RDONLY, 0644)
B OTOM npuMepe 0644 npeJCTaBJIeT ONpeJeJIeHHOe pa3peIIeHnIe Linux (yTEHnIe DJIa bCeX nOJIb3OBaTcIeJIb, a sainIcBs ToJIbKO DJIa TeKyIIeTO). TAKaKe MoXHO JOOaBHb CUMBOJI O (6yKBa O B HHXHeM peIuCTpe) nOcJIe HyJIa:
file, err := os.OpenFile("foo", os.O_RDONLY, 0644)
IpeKHKCbI 0o u 0 hecyT OJHO u TO Xe 3haYHeHnIe. Ho ucIOnb3bOBaHnIe 0o NoOMXeT cJeJIaTb KOJ 6OJIee nOHHTbHM.
IPUMeYAHIE MoXHO ucIOnb3bOBaTb CUMBOJI O B bEpXHeM peIuCTpe bMecTO CUMBOJIa O B HHXHeM peIuCTpe. Ho nepeJaVa 00644 MoxKet yCJIInTb nYTaHHIIy, nIoTOMy vTO, B 3aBHcHMeCTu OT nIpuIbTa, 0 MoxKet bBJIJIaJIeTb OyHeb NoXOke Ha O.
O6paTnIe bHHMaHnIe Ha npeJCTaBJIeHHa JpyrIX IeJIoYHCJIeHHbIX JInTEpaJIbO:
- d6ouHbIX
- ucIOnb3bYeTcA npeKHKCb 0b uJIu 0B (HaIpuMep, 0b100 paBHIO dEcra-тиYHOMy 4);- uecTHaDIyamepIyHHbIX
- ucIOnb3bYeTcA npeKHKCb 0x uJIu 0x (HaIpuMep, 0xF paBHIO dEcraтиYHOMy 15);- MHUMbIX
- ucIOnb3bYeTcA cybKHKCb i (HaIpuMep, 3i).
Hakoneц, moxho uciolysobatb cimboл noyepkrbания $()$ b kaectbe pasdein- teля dля yao6ctba vtehna. Hanpmer, sanucatb 1 mullnapa tak: 1_000_000_000. Moxho uciolysobatb cimboл noyepkrbания c dyrymu npectab.enehramu (hanpmer,000_00_01).
B Go есть возможность обработки двоичных, шестнадцатеричных, мнимых и восьмеричных чисел. Восьмеричные числа начинаются с 0. Чтобы улучшить читаемость и избежать потенциальных ошибок, сделайте восьмеричные числа явными, используя предикс 00.
B следующем разделе углубимся в различные аспекты, связанные с использованием целых чисел, и обсудим, как в Go обрабатываются переполнения.
# 3.2. OlluBKA #18: UTHOPuPOBATb LEIOYHCJENHbIE NEPEIOJHEHnR
Hепонимание того, как целочисленные переполнения обрабатываются в Go, может привести к критическим ошибкам. Далее в разделе углубимся в эту тему. Ho chavала bcnomним несколько концепций, связанных с целыми числами.
# 3.2.1. Konцепция
B Go есть в общей сложности 10 типов целых чисел. Как показано в таблице, есть четыре целочисленных типа со знаком и четыре целочисленных типа без знака.

Table (html):
<table><tr><td>Целье числа со знаком</td><td>Целье числа без знака</td></tr><tr><td>int8 (8 bits)</td><td>uint8 (8 bits)</td></tr><tr><td>int16 (16 bits)</td><td>uint16 (16 bits)</td></tr><tr><td>int32 (32 bits)</td><td>uint16 (16 bits)</td></tr><tr><td>int64 (64 bits)</td><td>uint64 (64 bits)</td></tr></table>
Hae bcero ucnolysyotcra dba dyrux ueJoucncyehhbx tuna: int u uint. Ohu meiot pasepe, kotopbll sabcucut ot cuctembl: 32 6uta b 32- 6uthbx cuctemax uJiu 64 6uta b 64- 6uthbx cuctemax.
O6patumcr k bonpcam nepenoJhenur. PpeJnOJoxkM, Hyxho uHnIuJausupobatb int32 do ero maksuMaIbHoro shavehnra, a satem ybeJnHnTb. Kak dyJet bectu ce6a bOT OTOT KOD?
var counter int32 = math.MaxInt32 counter++ fmt.Printf("counter=%d\n", counter)
он компилируется и не называет паники во время выполнения. Однако оператор counter++ генерирует целочисленное переполнение:
counter=- 2147483648
Оно возникает, когда результатом арифметической операции является значение вне диапазона, который может быть представлен заданным числом байтов. В int32 используются 32 бита. Вот двоичное представление максимального значения int32 (math.MaxInt32):
0111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111100111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111221111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111123111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
Поскольку int32 — это целое число со знаком, бит слева представляет знак цело- го числа: 0 — положительное, 1 — отрицательное. Если мы увеличим это целое число, не останется места для представления нового значения. Следовательно, это приводит к целочисленному переполнению. С точки зрения двоичного кода вот новое значение:
10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 - 3значения 31 бита установлены в 0- -
Как мы видим, знаковый бит теперь равен 1, что означает отрицательное значение. Это значение — наименьшее возможное для целого числа со знаком, представленного 32 битами.
ПРИМЕЧАНИЕ Наименьшее возможное отрицательное значение не равно 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111. Большинство систем полагаются на операцию дополнения до двух для представления двоичных чисел (инвер-тировать каждый бит и добавить 1). Основная цель этой операции — сделать $x + (- x)$ равным 0 независимо от $x$ .
В Go целочисленное переполнение, которое можно обнаружить во время компиляции, приводит к ошибке компиляции. Например:
var counter int32 = math.MaxInt32 + 1 constant 2147483648 overflows int32
Но во время выполнения целочисленное переполнение или антипереполнение (потеря значимости) не происходит, и это не приводит к панике приложения.
Baskho nomnhtb o takom nobeJehnn, nockoIbky oHo moxet npubectn k cKpbIbM oIinokam (hanpmer, ybeJinvenue 3havения nJoro uicia uJiu cyMmupobahue nJIOxKunTeJIbHbIX nJbIX uiceJ, kotopbie npubOJrT K pesyJIbTaTy c optnIaTeJIbHbIM 3hakOM).
IpeKde vem yrJy6JrTbcr b to, kak o6hapyKubatb nJIOyuncJenHoe nepenoJIHeHue c nomouHbO o6bIyHbIX onepaHnii, noJyMaem, kOrJa boo6Iue o6 3TOM HVXHO 6ecIO- kouTbcr. B 6oJIbIInHcTbe cJyvaeb, hanpmer npu o6pa6oTke cYetyuka sanpoCob uJiu ochOBHbIX onepaHnix cJioxeHnIa/yMHOxeHnIa, boJIHOBaTbcr He cTOunt, eJJI bM1 ucIOJIb3yEM npaHnIbHbIi nJIOyucJIeHbHbIi TnI. Ho, hanpmer, b npOeKtx c opraHnIeHbHbIM o6bEbOM nIamrtu, ucIOJIb3yIOIuIX MeHbIIne nJIOyucJIeHbHbIe TnIbI, pa6otaIOHnIX c 6oJIbIInMnIu uicJIaMnI uJIN bInIOJIHIOHnIX onepaHnIu KoHBepcnI/ npe- o6pa3OBaHnIa, moxet nIortpe6OBaTbcr npOeBcTn npOBeprk Ha npeJMEt BO3MOXHbIX nepenoJIHeHnIi.
Примечание Неудачный запуск ракеты Ariane 5 в 1996 году (https://www.bugsnag.com/blog/hug- day- ariane- 5- disaster) произошел из-за переполнения, возникшего в результате преобразования 64- битного числа с плавающей точкой в 16- битное целое число со знаком.
# 3.2.2. Обнаружение целочисленного переполнения при инкрементировании
Чтобы обнаружить целочисленное переполнение при выполнении операции инкрементального увеличения значения переменной типа, основанного на определенном размере (int8, int16, int32, int64, uint8, uint16, uint32 или uint64), можно сравнивать это значение с математическими константами. Например, в случае с int32:
func Inc32(counter int32) int32 { if counter $= =$ math.MaxInt32 { CpaBHeHHe c math.MaxInt32 panic("int32 overflow") } return counter + 1 }
Эта функция проверяет, достигла ли переменная значения math.MaxInt32. Если да, то ее увеличение приведет к переполнению.
A что насчет типов int и uint? До версии Go 1.17 приходилось создавать эти константы вручную. Теперь же math.MaxInt, math.MinInt и math.MaxUint
CTaJIu vactbI0 IIaKeta math. EcJIu HyxHO IpOBepuTb Ha nepeIOJIHeHHe nepeMeHHyIO TIIIIa int, MOxHO cJelIaTb STO C IOMOIIbIb math.MaxInt:
func IncInt(counter int) int{ if counter $= =$ math.MaxInt{ panic("int overflow") } return counter + 1 }
JIoruka ta xe camaa aIa uint. MoxHO ucnoJIb3OBaTb math.MaxUint:
func IncUint(counter uint) uint{ if counter $= =$ math.MaxUint{ panic("uint overflow") } return counter + 1 }
B otom pasdJeJe Mbl y3nahu, kak npOBepaTb IeJIouHCJIeHHHe nepeIOJIHeHHe HnHkeMeHTupOBaHHH. A vTO HacYeT cJIoxeHHH?
# 3.2.3. O6HaPYxHeHHe IeJIouHCJIeHHHOro nepeIOJIHeHHeH HnH cJIoxeHHH
Kak o6HaPYxHtB IeJIouHCJIeHHoe nepeIOJIHeHHe HnH cJIoxeHHH? OTbET sAKJIouaetcrs B IOBTOpHOM ucIOJIb3OBaHHH math.MaxInt:
func AddInt(a, b int) int{ if a > math.MaxInt- b { $\leftarrow$ IpoBepaTeTa, He npou3oIdet Hn IeJIouHCJIeHHoe nepeIOJIHeHHe panic("int overflow") } return a + b }
B otom npHMepe a u b - - aba onepaHJa. EcJIu a fOJIbIHe, YeM math.MaxInt - b, OIEpaHua npHbJeT K IeJIouHCJIeHHOMy nepeIOJIHeHHeHb. TeHepB paCcMOrHbM yMHoxeHHe.
# 3.2.4. O6HaPYxHeHHe IeJIouHCJIeHHHOro nepeIOJIHeHHeH HnH yMHoxeHHH
C yMHoxeHHeM OyIeT vYtB cJIoxHee. CJIeJIyET BbIIOJIHHTb npOBepKu MHHmAlbHOro IeJIOrO uIcJIa, math.MaxInt:
IpoBepka ieJouHCHoro nepenOHHeHnI npu yMHoxeHnI npoBouHCTa B heCKOJIbKHX HIIAROB. CHaVaJIa HYxHO npoBepuTb, paBHe JII OJIIH H3 OIEpaHIOB HYJIIO, eJHHHIIe HJIH math.MinInt. 3aTeM pa3eJIHTb pe3yJIbTaT yMHoxeHnI Ha b. EcJIH pe3yJIbTaT He 6yJIeT paBHeH IcXOJIHOMy 3HaVeHHO (a), 3HaHeT, npo3OIIJI0 IeJIo- yIcJIeHHOe nepenOHHeHnIe.
TakIM o6pa3OM, ieJIouHCHHe nepenOHHeHnI (u aHTHHepeJIHHeHnI) - <TUXue> onepaHIIu B Go. YTo6bI o6HaPyxHbATb nepenOHHeHnI aJIa u36eHaHnI cKpbITbIX OIIH6oK, ucIOJIb3yUite onIHeaHHbIe b 3TOM pa3eJIe cJIyxe6HbIe dyHHKIIH. TakxKe nOMHHTe, YTO B Go eCTb nIaKeT aJIa pa6OTbI c 6OJIbIIHmIu yIcJIaMI: math/bIg. IcIOJIb3yUITE ero, eCIH int HeJIoCTaTOHO.
B cJIeJIyIOIIeM pa3eJIeI noIbOBOpHM O nepeMeHHbIX c IIJIaBaIOIIeI TOxKOI.
# 3.3. OlluBKA #19: HE NOHMATb nPoBJeM, CBR3AHbIX C IIJABAIOUJIeI TOxKOI
B Go eCTb IBa THIIa c IIJIaBaIOIIeI TOxKOI (He cYHTaT aKOMIIeKCHbIX uIcEJI): float32 u float64. KoHIeIIIIHnI uIcEJIc IIJIaBaIOIIeI TOxKOI 6bIIa nPOJIJIOXeHa JIIa peIIeHHnI OCHOBHOI IPO6JIeMbI ieJIouHCHbIX THIIOB: hEcIOOc6HOCTb npeJIcTbAJIaTb IPO6JIbIe 3HaVeHHnI. YTo6bI u36eKaTb HeIIpIHTHbIX cIOIIpIu3OB, HYxHO 3HaTb, YTO aIpIbMeTUKa uIcEJI c IIJIaBaIOIIeI TOxKOI aBJIaTeCTa aIIIIpOcKUMaIIeI peaJIbHOI aIpIbMeTUKu. IIOroBOpHM o6 oco6eHHOCTx pa6OTbI c TAKHMn aIIIIpOcKUMaIIHMHn I cIIOCO6aX nObblIIeHHnI TOyHOCTu eeIpe3yJIbTaTOB. PaccMOrTHHM nIIHMeP c yMHoxeHnIeM:
var n float32 = 1.0001 fmt.Println(n * n)
Moxho oKuJatb, vTO b pe3yJbTaTe 6yJet bIbIeJeho: 1.0001 \* 1.0001 = 1.00020001, he tak Jn? Ho ha 6oJbIInHctBe npoIeccopoB x86 6yJet bblJaHO 3ha4eHue 1.0002. Io- yemy? Chavaa HaJxHO nOHAbTb ToHKOcTb apuqmEtuKu uHceJI c nJabaoIuei ToyKOu.
BosbMeM b KauectBe npHmepa tun float64. O6patute bHMaHne, vTO MeXy math. SmallestNonzeroFloat64 (mHHmMaJbHoe 3ha4eHue JJr Tuna float64) u math. MaxFloat64 (makcHmMaJbHoe 3ha4eHue JJr tuna float64) cTb 6eckOHeYHoe KoJn- vectBtO 3ha4eHnui JeeicTbHTeJIbHbIX uHceJI. C JpyroU cTOpoHbI, tun float64 uMeet KoHeYHoe uHcJIO 6uTOB - 64. IocKOJIbKy yMeCTHTb 6eckOHeYHO MHoro uHceJI b KoHeYHoe MHoxeCTBO HeBO3MOxHO, npuxoJHTcra pa6oTaTb c npu6JnIXeHnHmH. Kak pe3yJIbTaT - nOteprA ToyHOcTb. Ta xe JorUKa npHMeHUMa H K tuny float32.
IpeJCTaBLeHne uHceJI c nJabaoIuei ToyKOu B Go coOTbETCTbYet CTaHJaPTy IEEE754, npu STOM HeKOTOpbI 6uTbI OTHOcTaCra K MaHTucce, a Jpyrue - K nOKa3aTeJIo cTeHeHn (nOprJIKy uHcJIa, uJIu 3KcNoHeHTe). MaHmucca - JTO 6a3OBOe 3ha4eHne, TorJa KaK 3KcNoHeHTa - JTO cTeHeHb, B KOTOpyIO HyXHO BO3BeCTH uHcJI0 2, To6bI npu nepeMHoxeHnIu Ha MaHTuccy nOJIyHTb uckOMoe uHcJI0.
B tunax c nJabaoIuei ToyKOu OJHHapHOu ToyHOcTb (float32) 8 6uT OTBOJITaCra Ha npeJCTaBLeHne 3KcNoHeHTb (nOprJIKa uHcJIa), a 23 6uta - MaHTucCb. B tunax c nJabaoIuei ToyKOu JBOuHOu ToyHOcTb (float64) 3Tu 3ha4eHnH 6oCTaBJIaJIOT 11 u 52 6uta JJr nOprJIKa u MaHTucCb coOTbETCTbEHHO. OcTaIOIIuIcra 6uT npeJIHa3ha4eHn JJIr 3Haka. YTo6bI npeO6pa3OBaTb uHcJI0 c nJabaoIuei ToyKOu B deCraTnHOe npeJCTaBLeHne, uCIOJIb3yETcra cJeJIyOIIuIi pacYeT:
sign * 2^exponent * mantissa
Ha puc. 3.1 nokasaho npeJCTaBLeHne uHcJIa 1.0001 B bIJIe uHcJIa tuna float32. B nokasateJe cTeHeHn uCIOJIb3yETcra 8- 6uTHaH hotaIIaIc c u36bItkOM u cMeIeHHeM: 3ha4eHne nokasateJIc cTeHeHn 01111111 O3ha4eHt 20, TorJa KaK MaHTucCa paBHa 1.000100016593933. (O6patute bHMaHne, vTO 3TO1 pa3JIe1 He npeJIHa3ha4eHn JJIr 66bIcHeHnI1TO, KaK pa6OTaTCTTO npeO6pa3OBaHne.) CJIeJIbOHeJIbHO, deCraTnHOe 3ha4eHne paBHO $1\times 2^{0}\times 1.000100016593933$ . TakIM 66pa3OM, To, vTO Mb XIaHHM B npeJCTaBLeHnIu c nJabaoIuei ToyKOu OJHHapHOu ToyHOcTb, paBHO He 1.0001,
Puc. 3.1. npeJCTaBLeHne uHcJIa 1.0001 B bIJIe tuna float32
a 1.000100016593933. Недостаточная прецизионность влияет на точность хранимого значения.
Теперь мы понимаем, что и float32, и float64 — это аппроксимации. Чем это чревато для разработчиков? Первый вывод относится к сравнениям. Использо-вание оператора $= =$ для сравнения двух чисел с плавающей точкой может привести к неточностям. Поэтому нужно сравнивать разницу между ними, чтобы определить, меньше ли она некоторого малого значения допустимой ошибки. Например, в библиотеке тестирования testify (https://github.com/stretchr/testify) есть функция InDelta, подтверждающая, что два значения находятся друг от друга в пределах заданной дельты.
Имейте в виду, что результат вычислений с числами с плавающей точкой зависит от конкретного процессора. У большинства из них для выполнения таких вычислений есть модуль операций для работы с плавающей точкой (FPU). Но нет никакой гарантии, что результат, полученный на одном компьютере, будет таким же на другой машине с другим FPU. Сравнение двух значений с использованием дельты может быть решением для выполнения валидных тестов на разных машинах.
# Виды чисел с плавающей точкой
B Go есть три специальных вида чисел с плавающей точкой:
- положительная бесконечность;- отрицательная бесконечность;- NaN (Not-a-Number — «не число») — для представленная результата неопределенной или непредставимой операции.
Согласно IEEE-754, NaN — единственное число с плавающей точкой, удовлетворяющее условию f != f. Вот пример, в котором эти специальные типы чисел создаются и выводятся:
var a float64 positiveInf := 1 / a negativeInf := - 1 / a nan := a / a fmt.Println(positiveInf, negativeInf, nan)
+Inf - Inf NaN
Мы можем проверять, является ли число с плавающей точкой бесконечностью, используя math.IsInf, и является ли оно NaN, используя math.IsNaN.
Мы увидели, что преобразования десятичных чисел в числа с плавающей точкой могут привести к потере точности. Это ошибка из-за преобразования типов. Также ошибка может накапливаться в последовательности операций с числами с плавающей точкой.
Рассмотрим пример с двумя функциями, выполняющими последовательность одних и тех же операций в разном порядке. В нашем примере f1 начинает с ини- циализации float64 значением 10 000, а затем многократно (n раз) прибавляет к этому результату 1.0001. И наоборот, f2 выполняет те же операции, но в об- ратном порядке (прибавляя 10 000 в конце):
func f1(n int) float64 { result := 10_000. for i := 0; i < n; i++ { result += 1.0001 } return result } func f2(n int) float64 { result := 0. for i := 0; i < n; i++ { result += 1.0001 } return result + 10_000. }
Запустим эти функции на компьютере с процессором x86. При этом мы будем задавать числу n разные значения.

Table (html):
<table><tr><td>n</td><td>Точное значение</td><td>f1</td><td>f2</td></tr><tr><td>10</td><td>11000.1</td><td>10010.000999999993</td><td>10010.001</td></tr><tr><td>1K</td><td>11000.1</td><td>11000.0999999999293</td><td>11000.099999999982</td></tr><tr><td>1M</td><td>1.0101e+06</td><td>1.0100999999761417e+06</td><td>1.0100999999766762e+06</td></tr></table>
Офратите внимание: чем больше n, тем больше накапливающаяся неточность. Но мы видим и то, что точность функции f2 лучше, чем y f1. Поэтому имейте в виду, что порядок вычислений с плавающей запятой может повлиять на точность результата.
При выполнении цепочки сложений и вычитаний следует сгруппировать операции сложения или вычитания для чисел с одинаковым порядком величины, прежде чем прибавлять или вычитать те числа, значения которых сильно
отличаются. Поскольку f2 прибавляет 10 000 в самом конце, в итоге получается более точный результат, чем y f1.
A что насчет умножения и деления? Представим, что хотим вычислить следу- ющее:
a × (b + c)
Как мы знаем, результат должен быть одинаковым с
a × b + a × c
Запустим эти две формулы с a, имеющим порядок величины, отличный от b и c:
a := 100000.001 b := 1.0001 c := 1.0002
fmt.Println(a * (b + c)) fmt.Println(a*b + a*c)
200030.00200030004 200030.0020003
Точным результатом будет число 200030.002. Поэтому расчет по первой формуле имеет наихудшую точность. При выполнении вычислений с плавающей точкой, включающих сложение, вычитание, умножение или деление, для повышения точности нужно сначала выполнить операции умножения и деления. Иногда это может повлиять на время выполнения (в предыдущем примере требуется три операции вместо двух). В этом случае придется делать выбор между точностью и временем выполнения.
float32 и float64 в Go — это типы с приблизительными значениями чисел. Поэтому важно помнить о нескольких правилах:
- При сравнении двух чисел в представлении с плавающей точкой убедитесь, что разница между ними находится в допустимом диапазоне. - При выполнении операций сложения или вычитания для достижения большей точности результата группируйте операции с числами одинакового порядка. - Если последовательность операций требует сложения, вычитания, умножения или деления, то для повышения точности результата сначала выполните операции умножения и деления.
В следующем разделе подробно рассмотрим срезы и затронем два важных понятия: длина среза и его емкость.
# 3.4. OllnBka #20: HE NOHMATb OCOBENHOCTE, CBR3AHHbIX C AЛИHOY CPE3A И ETO EMKOCbYIO
Go- paspaofotviku dobolbno vacto nytaiot nonartna dlnbni cрезa u ero emkости. Ycboehne stux konietiiuykho dria dphektubnoo dpaofotku ochobhix onepaeni - uhninuaлизainu cрезa nlin doaba.enuaeninu alemenitob c npucoeudnenuem, konupobaniem nlin hapeskou. To honohumahue moxet npubectu k heonitumalb- homy ucnonbsobанию cрезob nlin daxke k ytevkam namiut (o vem noiiet peu b cJedyionux pasdejax).
B Go sa cрезom ctoit mascub. To oshavaet, vto dанныe cрезa xpanrtnca b ctpyktype dанныx mascuba. Cpes takxe o6pa6atbbaet Joruky do6abления alemента, eciu pesepbhiu mascub asnolhen, nlin ymebnuenur pesepnioro mascuba, eciu on noytnu nyct.
Bnyтри cefo cpes cOepxkut ykasatelb ha pesepbhiu mascub, a takxe dlinny u emkость. Jlinha - oTO KONIyecstbo alemenitob, cOepxbaHuxcra b cpe3e, tora kak emkость - oTO KONIyecstbo alemenitob b pesepbHOM mascube. Paccmotpum heckoJbko npimepob, vTO6bi bblno nonhthee. Chavaia uhninuaJusunpyem cpe3 saJahnoui Jlinbni u emkости:
s := make([]int, 3, 6) ← Cpe3 JlinHOY 3 u emKoCTbIO 6
Lepbbli aprymeht, sabaonnii nlinny, o6rasatejen. A bot btopol aprymeht, otno- cnauucr k emKoctu, heo6rasatejen. Ha puc. 3.2 nokasan pe3yIbIat bbnonJhenua oTOro koJa b namiu.
Puc. 3.2. Cpe3 JlinHOY 3 u emKoCTbIO 6
B Jahnom cJyuae make cO3Jaet mascub n3 nIectu alemenitob (emKocb). Ho nocKoJb- ky Jlinha cрезa bblna sadaha pabHou 3, to Go uhninuaJusunpyet ToJbko nepbblie tpu
длемента. Кроме того, поскольку срез является типом []int, первые три элемента инициализируются нулевым значением int: 0. Для элементов, обозначенных серым цветом, память зарезервирована, но они пока не используются.
Если вывести этот срез, то мы получим элементы в диапазоне его длины [0 0 0]. Если установить s[1] в 1, то значение второго элемента среза обновится, не влияя на длину или емкость последнего, что показано на рис. 3.3.
Puc.3.3. O6HOBnEHeBTOPOrO Элемента cpe3a: s[1] = 1
Ipu atom doctyn k xlementy sa npeJelami diana3oHa dnnb1 sanpeueh, daxe eciu mecto b namяти nia3 hero yxe bblJeleno. Hanpumep, s[4] = 0 npueJelet K nahuke:
panic: runtime error: index out of range [4] with length 3
Kak ucnol3bOaTb octaBIneeC4 npOcTpAnCTBO cpe3a? C nOMOIBIO BCTPOeHHONI pyHKI1111 append:
s = append(s, 2)
Этот код добавляет к существующему cpe3y s HOBbIb Элемент. On ucnol3b3yet nepbblI otmeVehnbIi cpebIM IBETOM xlement (mecto noJ kotopblI 6blJIO bblJeleno, ho eIIe He ucnol3b3oBaHO) dJia xpaHeHnIa 3haVehnIa 2, kak nokasaHO Ha puc. 3.4.
Puc.3.4. PpucOeJHHeHHe eJemента K cpe3y s
Длина cpe3a изменена c 3 на 4, поскольку теперь cpe3 cOJepxKHT vEBbIpe Элемента. Что произойдет, eciu добавить eIIe три элементa (при этом peseBbHblI MaccuB станет недостаточно GOLbI1111M)?
s = append(s, 3, 4, 5) fmt.Println(s)
Eсли мы запустим этот код, то увидим, что срез смог справиться с нашим запросом:
[0 1 0 2 3 4 5]
Поскольку массив — это структура фиксированного размера, он может хранить новые элементы до элемента 4. Когда мы хотим вставить элемент 5, массив уже заполнен: Go внутри себя создает другой массив, удваивая его емкость и копируя в него все элементы, а затем вставляет элемент 5. Это показано на рис. 3.5.

[ImageCaption: Рис. 3.5. Поскольку исходный резервный массив заполнен, Go создает другой массив и копирует в него все элементы]
Примечание В Go размер среза будет удваиваться до тех пор, пока он не станет содержать 1024 элемента, после чего будет увеличиваться на 25%.
Теперь срез ссылается на новый резервный массив. А что произойдет с предыдущим массивом? Если на него больше нет ссылок, он в итоге освобождается сборщиком мусора (GC — garbage collector), если был выделен в куче. Мы под-робнее обсудим память кучи при разборе ошибки #95 («не понимать различий между стеком и кучей») и рассмотрим, как работает сборщик мусора, при разборе ошибки #99 («не понимать, как работает сборщик мусора»).
Что происходит при нарезке? Нарезка — это операция, выполняемая над массивом или срезом, задающая полуоткрытый диапазон. Первый индекс включается, а второй исключается. В следующем примере показано то, что происходит при этом, а на рис. 3.6 показан результат в памяти.
s1 := make([]int, 3, 6) Срез длиной 3 и емкостью 6 s2 := s1[1:3] Нарезка по индексам от 1 до 3
Pис. 3.6. Срезы s1 и s2 ссылаются на один и тот же резервный массив, но с разной длиной и емкостью
Первым делом создается s1 как срез длиной 3 и емкостью 6. Когда нарезкой s1 создается s2, оба среза ссылаются на один и тот же резервный массив. Однако s2 начинается с другого индекса — с 1. Поэтому его длина и емкость (длина 2 и емкость 5) отличаются от s1. Если мы обновляем элемент s1 [1] или элемент s2 [0], то эти изменения вносятся в один и тот же массив и, следовательно, видны в обоих срезах, как показано на рис. 3.7.
Pис. 3.7. Поскольку за s1 и s2 стоит один и тот же массив, обновление общего элемента делает изменение видимым в обоих срезах
A что произойдет, если присоединить новый элемент к (или добавить его в) s2? Изменяет ли следующий код также и s1?
s2 = append(s2, 2)
Общий резервный массив изменяется, но при этом длина изменяется только y s2. На рис. 3.8 показан результат добавления элемента в s2.
s1 остается срезом длиной 3 и емкостью 6. Таким образом, если мы выведем s1 и s2, добавленный элемент будет виден только для s2:
s1=[0 1 0], s2=[1 0 2]

[ImageCaption: Puc. 3.8. DobaBneHne eJemEHTa B s2]
Takoe nObeJehue baxHo nonHmать, vTO6bI he dEJIaTb heBepHbIX npEJIOJIOXeHHHnI npu ucnOJIb3OBaHHHn append.
PNUMeYAHHE B3Tux npHmerax pe3epBHbIH MaccuB bHyTpHHHHnI u heJIOCTy- neH HeIOcpeJCTbHeHHO Go- paspa6OTyHKy. EJHHCTbHeHHOe nCJIIOyHeHue — cJIyvaIH, kOra a cpe3 CO3JaTeCT HyTeM Hape3kU cyIIeCTbYIOIIeTO MaccuBa.
H nocJeJHee, Ha HTO CTouT OFpaTHTb HHmHHee: HTO, eCJI Mbl pOJOJIXHM npHOc- eJHHaTb eJEMeHTbI K s2 BJIJIOTb JO TOTO MOMeHTa, kOra a pe3epBHbIH MaccuB 3aIOJI- HHTcra? KakHM 6yJET cOCTOaHHue c TOyKu SPehHnI namHTH? JabaIHTe JO6aBuM eIIe три eJEMeHTa, vTO6bI pe3epBHOMy MaccuBv He XBaTIJI0 eMKOCTH:
s2 = append(s2, 3) s2 = append(s2, 4) s2 = append(s2, 5) Haэтом этапе резервный массив уже заполнен
BbIIOJIHeHeHe eTOro koJa npUBOJHT K CO3JaHHIO eIIe OJIHOro pe3epBHoro MaccuBa. Ha puc. 3.9 nOKa3aHO, kAc 3TO OTpa3aHTcA Ha nAMaTH.
s1 u s2 Tenepb cCbJIaHTcA Ha JBa pa3HbIX MaccuBa. ПосKOJIbKy s1 no- npexHeMy npeJCTaBJIeT cO6OIH cpe3 JJIHHOIH 3 u eMKOCTbIO 6, y HeTO bCe eIIe eCTb HeKOTOpbIH JIOCTyIHbIH bYbEp, HO3TOMY OH npOJOJIKaeT cCbJIaTbCa Ha uCIOJIbHIbIH MaccuB. KpOMe TOro, HOBbIH p3e3epBHbIH MaccuB b6JI CO3JaH KOnIpOBaHHeM uCIOJIHOro, HaYHHaH c nEpBOro HHJeKcA s2. Bot nOvEMy HOBbIH MaccuB HaYHHaHeCTcA c 1, a He c 0.
IOJBOOJIa HTOr, MOxHO cKa3aTb, HTO dJIHa cpe3a — 3TO KOJIbIeCTbO IOCTyIHbIX eJIe- MeHTOB b HeM, TOrJIa kAc eMKOCTb cpe3a — 3TO KOJIbIeCTbO eJIeMeHTOB b pe3epBHOM MaccuBe. JO6aBuHeHee eJIeMeHTa B nOIHHbIH cpe3 (JIJIHa = = eMKOCTb) npUBOJIHT K CO3- JaHHIO HOBoro pe3epBHoro MaccuBa c HOBOIH eMKOCTbIO, KOnIpOBaHHIO bCeX eJIeMeHTOB H3 npEJIbJIyIIeTO MaccuBa u yCTaHOBJIeHHIO yKa3aTeJIa cpe3a Ha HOBbIH MaccuB.

[ImageCaption: Puc. 3.9. Dofablenhe neemehtoB s2 po tex nop, noka pesepbhiM MaccuB he okaketcr saononhenbim]
B cJedyIouem pa3Jele ncInoJb3yem nonHrtur JJInHbI u EMKocTH npu HHnIuJIn3aI111 cPesa.
# 3.5. OlluBKA #21: HE3ΦΦEKTUBHAЯ ИНИЦИАЛИЗАЦИЯ СРЕЗА
Kak 6bIIO nokasaho, npu HHnIuJIn3aI111 cPesa c nOOMIIbIO onepatropa make HyxHO ykasaTb JJInhy u JOnOJHHuTeJbHO EMKocTb. PacInpOCTpaHentHaa OIIu6ka - 3a6bITb npuJaTb COOTbETCTbYIOJIEe 3haVehue o6ouM 3T1M napaMeTpAM b Tex CJyVaaX, kOrJa 3TO UMeeT CMBICJI.
IpeJIOJIOxUM, HyxHO peaJIN3OBaTb qyHKII1IO cOvVeT, KoTOpaA oTO6paXaet cpe3 Foo b cpe3 Bar, u onu o6a 6yJyT uMEbI OJHHaKOBoe KOJIVeCTBO 3JEMeHTOB. Bot OJHa H3 TaKUX peaJIN3aI111, KoTOpaA nePbBIM JELOM npuXOJ1T Ha yM:
func convert(foos []Foo) []Bar { bars := make([]Bar, 0) → Создается результатующий cPesa for _, foo := range foos { bars = append(bars, fooTbAr(foo) → Foo преобразуется в Bar, который } return bars }
Chavала мы инициализируем пустой cPesa элементов Bar с nOOMIIbIO make([] Bar, 0). Затем используем append для добавления элементов Bar. Chavала bars nуст, поэтому добавление первого элемента создает резервный массив размером 1.
Kaждый раз, когда резервный массив заполняется до конца, Go создает новый массив, удваивая его емкость (o чем шла речь в предыдущем разделе).
Такая логика создания нового массива, когда текущий оказывается заполненным, повторяется несколько раз, когда мы добавляем третий элемент, пятый, девятый и т. д. Предполагая, что входной срез содержит 1000 элементов, этот алгоритм требует выделения/создания десяти резервных массивов и копирования в об- шей сложности более 1000 элементов из одного массива в другой. Это приводит к необходимости дополнительных действий со стороны сборщика мусора по очистке памяти от всех этих временных резервных массивов.
Для улучшения производительности стоит помочь среде выполнения Go. Это можно сделать двумя способами. Первый вариант — переносользовать тот же код, но создать в памяти срез с заданной емкостью:
func convert(foos []Foo) []Bar { n := len(foos) Инициализация с нулевой bars := make([]Bar, 0, n) Для поддержки и заданной емкостью for _, foo := range foos { bars = append(bars, fooToBar(foo)) bars обновляется, чтобы return bars }
Единственное изменение заключается в создании bars с емкостью, равной n — длине foos.
Go внутри себя предварительно резервирует место под массив из n элементов. Поэтому добавление элементов вплоть до n раз означает, что при каждом таком добавлении используется один и тот же резервный массив, поэтому резко сокращается количество действий по выделению места под массивы. Второй вариант — создать bars заданной длины:
func convert(foos []Foo) []Bar { n := len(foos) bars := make([]Bar, n) for i, foo := range foos { bars[i] = fooToBar(foo) } return bars }
Поскольку мы инициализируем срез с некоторой длиной, место для n элементов Bar уже выделено, и они все инициализированы нулевым значением.
Cледовательно, чтобы установить значения элементов, используем не append, a bar[i].
Какой из этих вариантов лучше? Выполним бенчмарк с тремя решениями и входным срезом из 1 миллиона элементов:
Как мы видим, в случае первого решения на производительность оказывается значительное влияние. Если мы продолжаем выделять массивы и копировать элементы, первый бенчмарк дает результат, который почти на $400\%$ медленнее, чем два других. Сравнивая второе и третье решения, можно заметить, что третье примерно на $4\%$ быстрее, потому что мы избегаем повторных вызовов встро- енной функции append, которая выполняется несколько дольше по сравнению с прямым присваиванием.
Если задание емкости и использование append менее эффективно, чем задание длины и присвоение прямого индекса, почему этот подход используется в про- ектах Go? Рассмотрим пример в Pebble — в хранилище ключей и значений систем с открытым исходным кодом, разработанном Cockroach Labs (https:// github.com/cockreachdb/pebble).
Функция collectAllUserKeys должна выполнить итерации по срезу структур, чтобы отформатировать определенный срез байтов. Результатующий срез будет в два раза длиннее входного:
func collectAllUserKeys(emp Compare, tombstones []tombstonewithLevel) [][]byte { keys := make([]byte, 0, len(tombstones)\*2) for _ t := range tombstones { keys = append(keys, t.Start.UserKey) keys = append(keys, t.End) } // ... }
Здесь сознательный выбор заключается в использовании заданной емкости и функции append. Почему? Если бы мы использовали заданную длину вместо емкости, код был бы таким:
func collectAlluserKeys(cmp Compare, tombstones[]tombstonewithLevel)[][]byte{ keys : $=$ make([][]byte,len(tombstones)\*2) for i,t $\equiv$ range tombstones{ keys[i\*2] $=$ t.Start_UserKey keys[i\*2+1] $=$ t.End } //... 1
Посмотрите, каким сложным выглядит код для обработки индекса среза. Учи- тывая, что рассматриваемая функция не слишком чувствительна к производи- тельности, было решено выбрать самый простой для чтения вариант.
# Cpe3bi u ycnobur
A vTO cnyuится, eCnM 6yayuaa dnHa cpe3a touHo heusbectha? Hanpumep, vTO, eCnM dnHa BbixoHocTO cpe3a saBucHOT oKakoro- to ycnobur?
func convert(foos []Foo) []Bar { //инцинализацияbars добавление элементаFoo только for_,foo := range foos { Btom cnyaee,ecm bbnonhraetca if something(foo) { onpeJdenenHoe ycnobue //Dobablenue элементa k bar } } return bars }
B stom npumere элемент Foo npeo6pa3yetca B Bar u do6abnretca B cpe3 ToNbko npu onpeJdeneHHOM ycnobu (if something(foo)).DonxHb nu Mbl HHuHuaHn3u- pobatb bars kak nycrou cpe3 nu6o kak cpe3 c saJahHou dnyHou unu emKocTbI0?
3decb het ctpororo npaBuna. 3to usBewHaar npo6nema dnr nporpaMmuctoB: 4TO nyuue- - sarpy3uTb B6onbuei ctenehn npouJeccop unu nAmaTb? BosMoxHo, eCnu something(foo) berho a 99% cnyaee, ctoHt HHuHuaHn3upobueb bars c3aJahHou dnyHou unu emKocTbI0. PeuHHeue saBucHOT oKonKpetHouCnTyaJuu.
Преобразование одного типа среза в другой — частая операция в Go- разработке. Как мы видели, если длина будущего среза уже известна, для создания в памяти сначала пустого среза нет веской причины. Наши варианты — создать срез либо с заданной емкостью, либо с заданной длиной. Мы видели, что из этих двух решений второе работает немного быстрее. Но использование среза заданной емкости и функции арpend в некоторых контекстах может быть проще для реализации и чтения.
Дальше поговорим о разнице между нулевыми и пустыми срезами и обсудим, почему это важно для Go- разработчиков.
# 3.6. OlluBKA #22: NYTATb NYCTbIE И HYJEBbIE CPE3bl
Paspafotviku Go jboxbihov vacto nytaiot hyjebbie u nycbte cpe3b. B sabucimoctni ot konkpetной cutyaiinu moxet nonaio6utbcra nconobosatb to uin apyroe. Mexdy tem hekotorpbie 6u6inoteku detaot mexdy himi pasличne. Jria npodyktubhoni pa6otbi co cpe3ami baxko pas6opatbcra b 3trix nonartrix. Ipexdie vem paccmatpurbas kakue- nu6o npимерbi, npubeay onpeJelenHur:
- Cpe3 cuntaetcra nycbim, eCnu ero Jnuna paBha 0.
- Cpe3 cuntaetcra hyjebbim, eCnu ero 3havenne paBHO ni1.
Paccmotpum cncoc6b1 nnnnuaJn3a1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
func main(){ var s []string BapuaHT 1(3havenne 0) log(1,s) s = []string(nil) BapuaHT2 log(2,s) s = []string{} BapuaHT3 log(3,s) s = make([]string,0) BapuaHT4 log(4,s) } func log(i int, s []string){ fmt.Printf( %d: empty=%t\tilde{n1=%t\n", 1, len(s) == 0, s == nil) }
BOT YTO BIBEJET STOT KOI:
1: empty=true nil=true 2: empty=true nil=true 3: empty=true nil=false 4: empty=true nil=false
Bce cpe3bi nycTbl, to ectb ux dnuha paBha 0. Nootomy u hyJeboui cpe3 toxke nyctoi. Ho toJbko nepBbie dba cpe3a hyJebbie. EcJiu ectb heckoJbko cnoco6ob uhnHnauJnu3upobatb cpe3, to kakou bapnant ctout npednoVectb? Otmeuy Jbe BeIu:
OJHO u3 ochOoBbIX pa3JirYuui MeXJy hyJebBbIM u nIyctbIM cpe3OM kacaetcA bblJeJehnus nIamяти. HHnIuauJn3aIu3aIu3yJeboro cpe3a he tpe6JyET bblJeeJehnus nIamrtu, yero heJb33r cKa3aTb o nIyctOM cpe3e. BbI3oB bCTpOeHHOuO OyHKIyIu append 6yJET pa6oTaTb he3aBucuMo OT ToIO, JBJJIeTcA JII cpe3 hyJebBbIM. HaIpnимер: var s1 []string fmt.PrintIn(append(s1, "foo")) // [foo]
CJedOBaTeJbHO, eCJII hyHKIyIa BosBpaIIaet cpe3, MbI he JODXbHb JelIaTb TaK, KaK B JpyrUX J3bIKaX, u BosBpaIIaTb HeHyJebyIO KOJIJeKIIIO H3 cOo6paXeHnIH Oe3oIac- HOcTn. IOcKoJbKy hyJeboui cpe3 he tpe6JyET kakoro- Jn6o pe3epBnIpoBани nIamrtu, cJedJyET BosBpaIIaTb hyJeboui, a He nIyctoU cpe3. IOcMOTpHIM Ha OyHKIyIIO, KoTOpaJ BO3BpaIIaET cpe3 cTpOe:
func f() []string { var s []string if foo() { s = append(s, "foo") } if bar() { s = append(s, "bar") } return s}
EcJiu u foo, u bari paBbI false, MbI nOJIyVaeM nIyctoU cpe3. YTo6bI npedotBpaIHTb cO3JaHue nIyctoro cpe3a Oe3 cOco6oro Ha To OchOBaHnIa, cJedJyET bbl6paTb bapnIaHT 1 (var s []string). MoXHO bbl6paTb u bapnIaHT 4 (make([]string, 0)) co cTpOoKu hyJeboui JJIHbI, HO nIo cpaJHeHnIO c bapnIaTOM 1 oTO He npIaHnIeT HHKaKOu nOJIb3bI, a KpOMe ToIO, eIIe u nOtpe6yET pe3epBnIpoBани MecTa b nIaMrtu.
Ho B cJIyJae, koJTa hyXKO HcO3JaTb cpe3 H3BecTHOU JJIHbIM, cJedJyET HcOJIb3bOaTb bapnIaHT 4 (s := make([]string, length)), KaK nOKa3aHO B cJedJIoIIeM npIbMepe:
func intsToStrings(ints []int) []string { s := make([]string, len(ints)) for i, v := range ints { s[i] = strconvitoa(v) } return s}
Kak yxe oocyckdalocn npu pas6ope onn6ku #21 (he3p@cktnbhar uhninua/naa- uia cpe3a), npu takom cuenapnun hyxko yctanab/ubatb ero d/nnny (nnu emkocb), yto6bi us6exatb dono.nunitelbhbix bblde/enuin namztnu n oninupobanun. Nootomy b c/nyae hauiero npnmera, rde ykasahbi pasnbie cnooc6bi uhninua/naa/nnu cpe3a, octaetcrs dba bapnantz:
- Bapnant 2: s := []string(nil)- Bapnant 3: s := []string{}
Bapnant 2 hevacto ucno/lsyembl, ho moxet 6b1b no/eseh kak cunntakc/neckun caxap, nockolbky mb moxem sa/antb hy/eboui cpe3 o/hnou ctpokou, hanpmerp, uc- no/ls3yra append:
s := append([]int(nil), 42)
Ec/nu 6bi mbi ucno/ls3o/aa/nu bapnant 1 (var s []string), to notpe6oba/ncb 6bi /be ctpoku ko/aa. Xotra sto u he camaa baxkhaa ontnmu/naa/nnu vnta6e/lsbhoctnu, ho o takom bapnantte toxke ctount znats.
PpMMEyAHNE B pas3/ene c onn6kou #24 (henpabn/lnho cos3/abatb konnn cpe3oB) mbi paccmoTpum oJnn c/nyaui, kOr/aa dO6ab/eneu eJ/emeHTa k hy/ebOMy cpe3y umeet cmbic.
Tenepb paccmoTpum bapnant 3 (s := []string{)}. On pekome/nyeTcra J/na cos/ahnna cpe3a c hau/abhbmu 3/emeHTaMn:
s := []string{"foo", "bar", "baz"}
Ec/nu cos/abatb cpe3 c hau/abhbmu 3/emeHTaMn He hyxko, He ucno/ls3yUte 3toT bapnant. On a/et te xke npe/m/uy/ecctba, yTO u bapnant 1 (var s []string), 3a uck/no/emeHn TOro, yTO cpe3 okas/ibaetcrs heny/ebbIM, c/ce/meb/ate/lnho, tpe6/yeT bblde/enu namztnu. Nootomy us6era/nte bapnantta 3, ec/ni ykasahne hau/abhbix 3/emeHTOB heo6/3a/te/lnho.
PpMMEyAHNE Hekotopbie J/nnтерb moryt pacno/3a/abatb ucno/ls3o/abHne bapnantra 3 6e3 hau/abhbix 3/ae/enuin u pekome/ny/ot u3/emeHTb ero ha bapnant 1. Ho nomHnte, yTO yTO takxke me/neret cemaHTnky c heny/JeBoro ha hy/eboui cpe3.
Y/om/HEm, yTO B heKOTOpbIX 6u6/nuotekaX y/untbIbAIOcTn pa3/nn/nnu Me/xdy hy/JeBbIMn u nyc/ebIMn cpe3aMn. Tak o6cTOUT de/IO, hanpmerp, c naketom encoding/
json. В следующих примерах маршализруются две структуры, одна из которых содержит нулевой, а вторая — ненулевой, но пустой срез:
var s1 []float32 $\leftarrow$ HylneBou cpe3 customer1 : $=$ customer{ ID:"foo", Operations: s1, } b,- = json.Marshal(customer1) fmt.Println(string(b)) s2 := make([]float32, 9) $\leftarrow$ HeryneBou nycuO cpe3 customer2 : $=$ customer{ ID:"bar", Operations: s2, } b,- $=$ json.Marshal(customer2) fmt.Println(string(b))
3anyctuB kOu H3 ToTO nmupea, Mb yBnJUM, YTO pe3yJnTaTbI MapnJnHra JnA 3TUX AByx ctpyktyp pasJnHbI:
{"ID":"foo","Operations":null} {"ID":"bar","Operations":[]}
3Jecb hyJebou cpe3 mapnJnpyetcr kak JJIemenT nul1, torJa kak henyJebou nycuOc cpe3 mapnJnpyetcr kak nycuO maccub. EcJn Mb pa6otaeM b koHtkecTe ctporux JSON- KJuehtoB, kotopbie pasJnuaot nul1 u [ ], o6 otom pasJnHnI uOeHb BажHO nOMHHTb.
Iaket encoding/json - he eJHHCTBeHHbI nakET H3 CTaJIaPTHOH O6JInOTeKu, B Kotopom npOBOJHTcH takoe pasJnYue. Hanpимер, Reflect.DeepEqual BosBpaIIaET false, ecJn Mb cpaBHbIaEM hyJebou u henyJebou nycuO cpe3, YTO cJeJyET nOMHHTb, hanpимер, B KoHtkecTe IOHHT- TecTOB. B JIO6OM CJyVae npu pa6ote co CTaJIaPTHOH O6JInOTeKoU uJIu kakHMu- TO bHeIINHMu O6JInOTeKamu HYxHO CJeJdtb sa tem, YTO6bI npu HcOJb3OBaHnI ToU uJIu uHOU bepcHn HmH KJH HeOxKIJaHHbIM pe3yJIbTaTAM.
IJabOJbI uTOr, MoXHO cKa3aTb, YTO B Go cCTb pa3JnYue MeXJy HYJebbIMu H nYCTbMn cpe3amu. HyJebou cpe3 paBен nI1, torJa kak nycuO cpe3 IMeET HyJebyIO JJIHhy. HyJebou cpe3 nyct, Ho nycuO cpe3 He o63satelbHO paBен nI1. KpOMe toro, HyJebou cpe3 He tpe6yET HnIkaKoro pe3epbupOBaHnI nAMrTn. B JTOM pa33eTe Mb BUJeJIu, kak HHnIuAJIu3upOBaTb cpe3 B sAbIcHMOcTn OT KoHtkecTa, IcIbOb3yA:
var s []string, eCJIH HET OnpeJeJIeHHOCTu B OTHOIIeHHn KOHeYHOU JJIHbI u cpe3 MOXeT ObIb nYCTbM.
[ ]string(nil) как синтаксический сахар для создания нулевого и пустого cpe3a.
make([]string, length), если будущая длина известна.
Последнего возможного варианта - []string{} - следует избегать, если ини- циализируется срез без элементов. Наконец, следует проверять, предусмотрены ли в использовных библиотеках различия между нулевыми и пустыми срезами, чтобы предотвратить неожиданное поведение.
В следующем разделе посмотрим на лучший способ проверки пустого среза после вызова функции.
# 3.7. OlluBKA #23: HENPABUJIbHO IPOBEPRTb NYCTOTY CPE3A
B предыдущем разделе мы узнали, что между нулевыми и пустыми срезами есть различие. Есть ли идиоматический способ проверить, содержит ли какой- - to cрез элементы? Отсутствие четкого ответа на этот вопрос может привести к малозаметным ошибкам.
Bэтом примере вызываем функцию getOperations, которая возвращает срез типа float32. Далее мы хотим вызывать функцию handle только в том случае, если этот срез содержит элементы. Вот первая (ошибочная) версия кода этих действий:
func handleOperations(id string) { operations : \(=\) getOperations(id) if operations \(! =\) nil{ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \) handle(operations) } } func getOperations(id string) []float32 { operations : \(=\) make([]float32, 0) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if id \(= =\) ""{ return operations - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } // добавление элементов k operations return operations }
Мы определяем, есть ли в срезе элементы, проверяя, не является ли срез operations нулевым. Но у этого кода есть проблема: функция getOperations никогда не возвращает нулевой срез, она возвращает пустой срез. Поэтому значение проверки operations != nil всегда будет true.
Что делать? Один из подходов — изменить getOperations так, чтобы он возращал нулевой срез, если id пуст:
func getOperations(id string) []float32{ operations := make([]float32,0) if id $= =$ ""{ return nil Bo3bpar nil bmecto operations } // Add elements to operations return operations }
Bmecto toro tto6bi bo3bpaqtas operations, eciu id nycr, mi bo3bpaam nil. Takum o6pasom, pea1n3yemar hamu npobepka ha nylевой cpe3 cootbetctbyet deicbtunelbности. Ho takoui nolxod pa6otaeet he bo bcex cnytayuiax - kontekct, B kotopom mi haxo2umc, he bcer7a nosb01ret usmenutb bbi3biaembl 06ekt. Hanpumep, eciu ucnolb3yetcr bheunhna 6n6nioteka, mi he 6ydem cos3abatb nyl- pekbect to1bko d1r toro, tto6bi samenutb nycstbie cpe3i ha nylевые.
Kak tor7a npobeputb, asbretcr cpe3 nycbim u11 nylebbami? Hyxho npobeputb ero 7111ny:
func handleOperations(id string) { operations := getOperations(id) if len(operations) != 0 { // Проверка длины среза handle(operations) }
B предыдущем разделе мы упоминали, что пустой cpe3 по определению имеет нулевую длину. При этом нулевые срезы всегда пусты. Поэтому, проверя длину среза, мы учитываем все возможные сценарии:
- Если cpe3 равен nil, to len(operations) != 0 принимает значение false.- Если cpe3 не равен nil, а является пустым, то значение len(operations) != 0 также будет false.
Следовательно, проверка длины — лучший способ, поскольку мы не всегда можем контролировать то, что будет происходить в результате выполнения вызываемых функций. Как говорится в Вики по Go, при проектировании интерфейсов следует избегать различий между нулевыми и пустыми срезами, которые могут приводить к малозаметным ошибкам программирования. При возврате срезов не должно быть ни семантической, ни технической разницы, возвращаем мы п11 или пустой срез. Оба варианта должны означать одно и то же для вызывающей
стороны. Тот же принцип применим и к картам. Чтобы проверить, пуста ли карта, проверяйте ее длину, а не то, равна ли она nil.
В следующем разделе узнаем, как правильно создавать конии срезов.
# 3.8. ОШИБКА #24: НЕПРАВИЛЬНО СОЗДАВАТЬ КОПИИ СРЕЗОВ
Встроенная функция сору позволяет копировать элементы из исходного среза в другой. Хотя эта встроенная функция удобна, Go- разработчики не всегда правильно ее понимают. Рассмотрим одну распространенную ошибку, которая приводит к копированию неправильного количества элементов.
В следующем примере мы создаем один срез и копируем его элементы в другой. Что выведет этот код?
src := []int{0, 1, 2} var dst []int copy(dst, src) fmt.Println(dst)
В результате мы получим [], а не [0 1 2]. Что же мы упустили?
Чтобы эффективно использовать функцию сору, важно понимать, что число элементов, скопированных в другой срез, определяется минимумом между:
- Длиной исходного среза;
- Длиной второго среза.
В предыдущем примере src — это срез длиной 3, а dst — срез с нулевой длиной, поскольку он инициализируется со своим нулевым значением. Поэтому функция сору копирует количество элементов, равное минимуму в наборе 3 и 0: здесь этот минимум будет равен 0. Поэтому полученный срез будет пустым.
Если мы хотим выполнить полное копирование, второй срез должен иметь длину больше или равную длине исходного. Здесь мы устанавливаем длину, отталкиваясь от параметров исходного среза:
src := []int{0, 1, 2} dst := make([]int, len(src)) copy(dst, src) fmt.Println(dst)
Поскольку dst теперь срез, инициализированный с длиной, равной 3, то копи-руются три элемента. На этот раз, если мы запустим код, его результатом будет [0 1 2].
ПРИМЕЧАНИЕ Другая распространенная ошибка — инвертировать порядок аргументов при вызове функции copy. Помните, что срез, в который происходит копирование, — первый аргумент, а срез-источник — второй.
Использование встроенной функции copy — не единственный способ копирования элементов среза. Есть альтернативы, самая известная из которых следующая (в ней используется append):
src := []int{0, 1, 2} dst := append([]int(ni1), src...)
Мы присоединяем элементы из исходного среза в другой, нулевой. Следователь- но, этот код создает копию среза длиной 3 и емкостью 3. Эта альтернатива имеет то преимущество, что все действия выполняются в одной строке. Но использо-вание функции copy более идноматично и, следовательно, легче для понимания, даже несмотря на то, что требует дополнительной строкы.
Копирование элементов из одного среза в другой — довольно частая операция. При использовании функции copy мы должны помнить, что количество скопированных элементов определяется минимумом между длинами двух срезов.
Имейте в виду, что есть и альтернативные способы копирования среза, поэтому не стоит удивляться, если мы будем находить их в текстах кодов.
Продолжим изучать срезы и связанную с ними распространенную ошибку при использовании append.
# 3.9. ОШИБКА #25: НЕОЖИДАННЫЕ ПОБОЧНЫЕ ЭФФЕКТЫ ПРИ ИСПОЛЬЗОВАНИИ APPEND В ОПЕРАЦИЯХ СО СРЕЗАМИ
В этом разделе обсуждается распространенная ошибка при использовании append, которая в некоторых ситуациях может приводить к неожиданным побочным эффектам. В следующем примере мы инициализируем срез s1, создаем s2 путем нарезки s1 и создаем s3, добавляя элемент к s2:
s1 := []int{1, 2, 3} s2 := s1[1:2] s3 := append(s2, 10)
Мы инициализируем срок s1, содержащий три элемента, а s2 создается из сре- за s1. Затем к s3 мы вызываем функцию append. Каким будет состояние этих трех срезов после выполнения кода? Догадаетесь?
На рис. 3.10 показано, как будут выглядеть в памяти оба среза после вы- полнения второй строки, то есть после создания s2. s1 — это срез длиной 3 и емкостью 3, а s2 — срез длиной 1 и емкостью 2, за ними обоими стоит один и тот же резервный массив, о котором мы уже упоминали. При добавлении элемента с помощью append проверяется, заполнен ли срез (длина $= =$ емкость, то есть length $= =$ capacity). Если он не заполнен, функция append добавляет в него элемент, обнольля резервный массив и создавая срез, длина которого увеличивается на 1.
Рис. 3.10. Оба среза имеют один и тот же резервный массив, но разные длины и емкости
В этом примере s2 не заполнен, в него можно записать еще один элемент. На рис. 3.11 показано копечное состояние этих трех срезов.
В резервном массиве мы обновили последний элемент, придав ему значение 10. Поэтому если мы выведем то, что находится во всех срезах, получим:
s1=[1 2 10], s2=[2], s3=[2 10]
Содержимое среза s1 было изменено, хотя мы не обновляли s1 [2] или s2 [1] напрямую. Об этом нужно помнить, чтобы избежать проявления каких-то последствий, которые мы не собирались получить.
Puc.3.11.3a bceMn cpe3aMn cTOUT OJUN H M TOT Xe pe3epBHHM MaccuB
Iocmotpum, kak otot npiinun bJnret ha nepedauy pe3yJbTaTa onepaHn Ha pesku b pyHkHn. B cJedyIouem bparMeHTe koJa uHnHnHnH3upyem cpe3 c TpeMn eJemehTami u bJ3bIBaem pyHkHn, kotopar uMeet Jelo ToJbko c ero nepBbIMn JByMn eJemehTami:
func main(){ s := []int{1,2,3} f(s[:2]) // Иелонльзование s } func f(s []int){ // 06новление s }
B takoii peaJn3aHnHn otHx JeeicTbHn, ecJn f o6HOBJIeT nepbbie Jba eJemehTa, u3meHeHnH BnJHbI cpe3y B main. Ho ecJn f bJ3bIBaet append, OH o6HOBJIeT TpetnH JJemehT cpe3a, xOTa MbI nepedaem pyHkHnHn ToJbko Jba eJemehTa. Ha- npимер,
func main(){ s := []int{1,2,3} f(s[:2]) fmt.PrintIn(s) // [1 2 10] } func f(s []int){ _ = append(s, 10) }
EcJn MbI xOTum 3aIIHTnTb TpetnH JJemehT H3 cOo6paXeHnH I6e3onacHocTn, To cTb rapaHTupObaTb, YTO f he o6HOBnT ero, cyIIeCTbYet Jba bapuaHTa.
Первый вариант — передать копию среза, а затем создать результирующий срез:
func main(){ s := []int{1,2,3} sCopy := make([]int,2) Konupobahue nepbix copy(sCopy,s) byx элементos s b Copy f(sCopy) result := append(sCopy,s[2]) Ppncoeanhret s[2] k sCopy an d opmupobahn // Icnonb3obahue pesybnata pesybntrpyouero cesa } func f(s []int){ // 06hOBJeHHe s }
Iockolsky Mbl nepedam koinuo B f, to jaxke ecJiu sta yhKlIur BbIbIbIaet append, oto he npubeJet k noHbJehIIO kakoro- Jino no6ovHoro 3dpekTa 3a npeJelami dianasaona nepbых dbyx 3JemehToB. Hedoctatok otoro bариanta B ToM, YTO B oTOM cJyvae kOI cTahOBuTcra GoJee cJoxHbIM JJIa YtHeHnI u noBbJIeTcra donolHHTeJIbHaA KOnIur, YTO MoxKet cTaTb np6JemOu, ecJiu cpe3 GoJIbIIOuI no pa3Mepy.
Bropou bариant moXHO ucnoIb3OBaTb JJIa opraHnIeHnI JIaIa3OHa bJlIHnIr BO3- MoxHbIX caII- 3dpekTbO ToJIbKO nepbBbIMJ dbyMn 3JemehTam. JTa onlIIn BkJI6OaET TaK Ha3bIaEMOe noJIHOe bopaxcneue cpe3 (full slice expression): s[low:high:max]. JToT onepaTOp co3JaeT cpe3, aHaJIOnIyHbIbI co3JaHHOMy c HOMOIIbIO s[low:high], 3a uckJI6OeHHeM ToI0, YTO EMKOCTb noJIyvaIOHeTOcra cpe3 paBHa max- 1ow. BOr npuMep bIb3Oba f:
func main(){ s := []int{1,2,3} f(s[:2:2]) 1epedav aqocpe3a c ucnoIb3OBaHHeM noJIHOO bIbIaKeHnI cpe3 // Icnonb3OBaHHe s } func f(s []int){ // 06hOBJeHHe s }
3Jecs cpe3, nepcIaHHnIb B f, - - atO He s[:2], a s[:2:2]. CneJIOBaTeJIbHO, EMKOCTb cpe3a paBHa $2 - 0 = 2$ , kak noKa3aHO Ha puc. 3.12.
Ipu nepedave s[:2:2] Mbl MoxKem opraHnIHTb JIaIa3OH bIHnIHA 3dpekTb nepbBbIMJ dbyMn 3JemehTam. JTo TaKxe u36aBJIeT HaC OT he66xoJIMOCTu bblIOJIHrTb konupOBaHue cpe3a.
Ipu ucnoIb3OBaHnI Hape3Ku Mbl JOLXHbI nOMHHTb, YTO MoxKem cTOJIKHyTbcsa c cuntyaIueu, npubOJIHIIeI K henpeJIHaMepeHnHbIM no6ovHbIM 3dpekTam. EcJIu

[ImageCaption: Puc.3.12.s[0:2] cosdaet cpe3 dnnHou2 u emKocTb1o 3,TorJa kak s[0:2:2] cosdaet cpe3 dnnHou2 u emKocTb1o2]
pe3yJIbHpyIouHnI cpe3 uMeet Jlnny MeHbHHe, Yem ero emKocTb, append MoXet H3- MeHrHb ucXOJHbHnI cpe3. EcJIn Mbl xotum opranHunHb JuaanaOH BJunHnHb BO3MoxHbIX caHn- 3dDkeKTOB, moXHO ucnoJIb3OBaTb Jn6o konHIO cpe3a, Jn6o noJIHOe bHpaXeHHe cpe3a, VTO He no3bOJIHT JelJIaTb konHIO.
B cJeJyIouIeM pa3JJeJe npOJIOJIKIM o6cyXdHHe cpe3OB, HO b KOnTEKCTe nOteHnI- aJIbHbIX yTeYek nAMrTH.
# 3.10. OWWBA #26: CPE3bI M YTEYKIN IAMRTI
B 3TOM pa3JJeJe Mbl noKaxeM, VTO Hape3Ka cyIIeCTbYIOIIeTO cpe3a IJIu MaccuBa MoXeT B HeKOTOrbIX cJIyVaxr npHbOJIbTb K yTeYKam nAMrTH. O6cyJIM JBa cJIyVaxr: OJHn, npu KOTOpOM npOucxoJHnI yTeYKa emKocTn, u JpyroH - cBaBaHHbHnI c yKa3aTeJIrMnI.
# 3.10.1. YTeYKIN EMKOCTNI
B cJIyVae yTeYKIN emKocTn npJeCTaBIM peaJIu3aIIIO KaKoro- To co6CTBeHHOro JBOuHHOro npOTOKOJIa. CO6OHeHe MeXeT COJepXaTb 1 MUNJIHON FaHTOB, a B nePbBX nIHTn FaHTax xpAnHTcTn TnI STOro CO6OHeHnI. Ipu bHIIOJIHeHnI KoJIa Mbl BOcIIpH- HUMaem JTI CO6OHeHnI a JJIa HeJeHnI aJIHTa xOTHM COXpaHHTb B nAMrTH IOcJeJIHHe 1000 TIIIOB CO6OHeHnI. BOT cKeJIeT qYHKIIHnI:
func getMessageType(msg []byte) []byte { BbivicneHne tna co66ueHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnnnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn
DyHKnHn geMessageType bHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH
OnepaHn nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn Hnnn5. Ho ero emKocTb ocTaetcr takoK ke, kak y ucxonHoro cpe3a. OctaJIbHbIe eJIemenHnI no- npeXHeMnY coXpaHnHOTcr b nAMnHn, Jaxke ecJIb b nTOre otcyTCTbYIOT CcblJIkn Ha nsg. PaccMOrnHn nHnHep c 6oJIbHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH
Cледовательно, мы сохраняем в памяти только 5 байт для каждого типа сообщения.
# Nolhbie bbiражenna cpe3a u ytevka emkocntu
A uto hacuet ncnoibobanua nonhoro bbiражenua cpe3a dna peuehna stou npo6jembl? Iocmotpum пример:
func getMessageType(msg []byte) []byte {return msg[:5:5]}
3 decs getMessageType bosbpaiaet ymehbluenhnyo bepcnu ncxodhoro cpe3a— cpe3 dninoh 5 u emkoctbio 5. Ho cmoxet nu c6opuuk mycopa bocctahobutb недостynhoe npoctpaHctbo u3 6aita 5? Cneuupukaua Go ophiuaanbHo he onpредenret ero nobegeHue. Ho c nomoubio runtime.Memstats Mbi moxem sa- nucbIaTb cTaTctuKy pacnpedelnTea naMraTn, hanpHmer KOnHuectbo 6autob, bbldeJenHbIX B Kyue:
func printAlloc(){var m runtime.MemStatsruntime.ReadMemStats(&m)fmt.Printf("kd kB\n", m.Alloc/1024)}
Ecnu Mbi bH3bIbaeM ty yHkHnIIO nOcne bH3Oba getMessageType u runtime.GC(), vTO6bi npHuydtelnbio sanycutb c6opky mycopa, недoctyHoe npoctpaHctbo he bocctahabJbmaeTca. PesePbHbN MaccnB ueJukOM bce euue HaxoJutcr B naMraTn. CneDobatelbHo, ncnoHb3obaHue nonhoro bbiражenua cpe3a heJonyctumO (ecnu sto he 6ydet kak- to ncnpaBneHo b 6ydyuem o6hOBneHn Go).
B kavectbe smnnpHuckorO npaBnIa sanomHnte, vTO hape3ka 6oJbInIOrO cpe3a uJn MaccuBa moxet nortnHnuaJbHo npuBeCTu K bIcokOMy nortpe6JehnIO naMraTn. OctaIOueecr B naMraTn npoctpaHctbo he 6ydet bocctahobJheo c6opuukom mycopa, u Mbi moxem coxpHraTb B naMraTn oyeHb 6oJbInIouB pe3epHbNtN MaccuB, hecmotpr Ha ucnoJb3obaHue ToJbko heckoJbKuX 3JemHtoB. HcnoJb3obaHue KonHn cpe3a - 3ro cInoc6 npedotBpaIHeHnA takoN cHTyaaHn.
# 3.10.2. Cpe3 u yKasatenu
Mbi bUdeJn, vTO hape3ka moxet bbl3aTb yTeyky u3- 3a ToHKOcTeN, cBraaHbIX c emKoctbio cpe3a. A vTO hacvet 3JemHtoB, kotopbIe bce euie aBJHOTcA vaCTbIO
pезервного массива, но находятся за пределами диапазона, определяемого длиной? Собирает ли их сборщик мусора?
Возьмем структуру Foo, содержащую байтовый срез:
type Foo struct { v[]byte }
После каждого шага мы проверяем, как производится распределение пространства в памяти:
- Выделяем срез из 1000 элементов Foo.- Выполняем итерацию по каждому элементу Foo и каждый раз выделяем 1 Мбайт для среза v.- Вызываем keepFirstTwoElementsOnly, который возвращает только первые два элемента с использованием нарезки, а затем вызываем сборщик мусора.
Мы хотим увидеть, как поведет себя память после вызова keepFirstTwoElementsOnly и сборки мусора. Вот сценарий в Go (повторно используем упомянутую ранее функцию printAlloc):
func main(){ Производится резервирование памяти под срез foos := make([]Foo, 1_000) из 1000 элементов printAlloc() for i := 0; i < len(foos); i++ { → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → - → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → 1 Mбайт foos[i] = Foo{ v: make([]byte, 1024*1024), } } printAlloc() two := keepFirstTwoElementsOnly(foos) → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → →→ runtime.GC() → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → > printAlloc() runtime.KeepAlive(two) → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → - - → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → - 1 Mбайт GC, чтобы принудительно вызвать очистку кучи runtime.KeepAlive(two) → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → - → → → → → → - → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → - → → → → → → 1 Mбайт GC, [ ]Foo { return foos[:2] }
В этом примере мы создаем срез foos, затем для каждого элемента — срезы размером в 1 Мбайт, а потом вызываем keepFirstTwoElementsOnly и сборщик
mycopa. B конце концов мы используем runtime. KeepAlive, чтобы после сборки mycopa сохранить ссылку на переменную two и не удалять ее.
Мы ожидаем, что сборщик мусора удалит 998 оставшихся элементов Foo и данные, выделенные для среза, поскольку доступа к этим элементам больше нет. Но это не так. Например, приведенный код может вывести следующее:
83 KB 1024072 KB 1024072 KB Посте операции нарезки
Сначала выделяется около 83 Кбайт данных. Действительно, мы зарезервировали память под 1000 нулевых значений в Foo. Второй результат выделяет 1 Кбайт на срез, что увеличивает память. Но обратите внимание на то, что сборщик мусора не очистил оставшиеся 998 элементов после последнего шага. В чем причина?
Важно помнить об этом правиле при работе со срезами: если элемент является указателем или структурой с полями-указателями, сборщик мусора не восстано-вит память, зарезервированную под эти элементы. В нашем примере, поскольку Foo содержит срез (а срез — это указатель поверх резервного массива), память, остающаяся занятой под 998 элементов Foo и их срезы, не восстанавливается. Таким образом, хотя эти 998 элементов становятся недоступными, они остаются в памяти до тех пор, пока сохраняется ссылка на переменную, возвращаемую функцией keepFirstTwoElementsOnly.
Какие есть варианты, чтобы не допустить утечки оставшихся элементов Foo? Первый вариант, опять же, — создать копию среза:
func keepFirstTwoElementsOnly(foos []Foo) []Foo { res := make([]Foo, 2) copy(res, foos) return res}
Поскольку мы копируем только первые два элемента среза, сборщик мусора будет знать, что на оставшиеся 998 элементов больше не будет никаких ссылок и память под них теперь можно очистить.
Если мы хотим сохранить базовую емкость в 1000 элементов, то есть пометить срезы для оставшихся элементов как nil в явном виде, есть такой вариант:
func keepFirstTwoElementsOnly(foos []Foo) []Foo { for i := 2; i < len(foos); i++ {
foos[i].v = nil } return foos[:2] }
3десь мы возвращаем срез длиной 2 и емкостью 1000, но устанавливаем срезы остальных элементов равными пі1. Следовательно, сборщик мусора теперь сможет описать 998 резервных массивов.
Какой вариант лучше? Если мы не хотим сохранять емкость на уровне 1000 элементов, то, вероятно, первый. Но ответ на этот вопрос может зависеть и от соотношения элементов. На рис. 3.14 представлены варианты, из которых мы можем выбрать, предполагая, что срез содержит n элементов, а нам нужно сохранить только i элементов.
Pис. 3.14. Вариант 1 используется вплоть до 1-то элемента, а далее используется вариант 2
При использовании первого варианта создается копия i элементов. Следовательно, он должен применяться от элемента 0 до элемента i. При втором варианте оставшиеся срезы делаются равными пі1, поэтому он должен применяться от элемента i до элемента n. Если особо важна скорость выполнения операций, а i ближе к n, чем к нулю, то возможно рассмотреть использование только второго варианта. Он потребует перебора меньшего количества элементов (по крайней мере два варианта действий стоит сравнить).
В этом разделе мы рассмотрели две потенциальные проблемы с утечкой памяти. Первая заключалась в нарезке существующего среза или массива для сохранения их емкости. Если мы обрабатываем большие срезы и оставляем только их части, делая их повторную нарезку, останется зарезервированным большой объем памяти, который никак не используется. Вторая проблема заключается в том, что когда мы используем операцию нарезки с указателями или структурами с полями указателей, нужно помнить, что сборщик мусора не очистит память, используемую этими элементами. В таком случае есть два варианта: либо выполнить копирование, либо явно пометить оставшиеся элементы или их поля равными пі1.
Теперь обсудим карты в контексте их инициализации.
# 3.11. OWWKA #27: HEЭФФЕКТИВНО ИНИЦИАЛИЗИРОВАТЬ КАРТЫ
В этом разделе поговорим о проблеме инициализации карт. Чтобы понять, почему важна настройка их инициализации, сначала вспомним основы реализации карт в Go.
# 3.11.1. Концепции
Карта представляет собой неупорядоченный набор пар «ключ — значение», в котором все ключи различны. В Go карта основана на структуре данных хеп- таблицы. Хеп-таблица представляет собой массив сегментов, каждый из которых является указателем на массив пар «ключ — значение», как показано на рис. 3.15.
На рис. 3.15 за хеп- таблицей стоит массив из четырех элементов. Если при- смотреться к индексу массива, мы заметим, что один сегмент состоит из одной- единственный пары «ключ — значение» (элемент): "two"/2. Каждый сегмент имеет фиксированный размер из восьми элементов.
# Представление хеш-таблицы: map[string]int
# Maccub
Pис. 3.15. Пример хеш-таблицы с особым вниманием к сегменту 0
Каждая операция (чтение, обновление, вставка, удаление) выполняется путем ассоциирования ключа с индексом массива. Этот шаг зависит от хеп-функции.
Эта функция стабильна, потому что мы хотим, чтобы она всякий раз возвращала один и тот же сегмент, если задан один и тот же ключ. В предыдущем примере hash("two") возвращает 0. Следовательно, элемент хранится в сегменте, на который ссылается индекс массива 0.
Если мы вставляем другой элемент и хеширование ключа возвращает тот же индекс, Go добавляет еще один элемент в тот же сегмент. На рис. 3.16 показан этот результат.
# Представление хеш-таблицы: map[string]int
Maccus

Table (html):
<table><tr><td>0</td><td></td><td>Ключ</td><td>Значение</td></tr><tr><td>1</td><td></td><td>"two"</td><td>2</td></tr><tr><td>2</td><td></td><td>"six"</td><td>6</td></tr><tr><td>3</td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td></tr><tr><td></td><td></td><td></td><td></td></tr></table>
Puc. 3.16. hash("six") возвращает значение 0, поэтому элемент записывается в тот же сегмент
B случае вставки в уже заполненный сегмент (переполнение сегмента) Go создает еще один сегмент из восьми элементов и связывает с ним предыдущий сегмент. На рис. 3.17 показан результат этого.
Что касается операций чтения, обновления и удаления, то Go должен вычислить соответствующий индекс массива. Затем Go последовательно перебирает все ключи, пока не найдет заданный. Таким образом, в наихудшем случае временный сложность в результате этих трех операций равна $O(p)$ , где $p$ — общее количество элементов в сегментах (по умолчанию один сегмент, несколько сегментов в случае переполнения).
A теперь обсудим, почему эффективная инициализация карты столь важна.
Maccus
Pредставление хеш-таблицы: map[string]int
Puc. 3.17. B случае переполнения сегмента Go создает новый сегмент и устанавливает его связь с предыдущим
# 3.11.2. Инициализация
Чтобы понять проблемы, связанные с возможной неэффективной инициализацией карты, создадим тип map[string]int, содержащий три элемента:
m := map[string]int{ "1": 1, "2": 2, "3": 3,}
За этой картой стоит резервный массив, состоящий из одной записи, а следова- тельно, из одного сегмента. Что произойдет, если добавить 1 миллион элементов? В этом случае этой одной записи будет недостаточно, потому что поиск ключа в худшем случае будет означать перебор тысяч сегментов. Поэтому карта долж- на иметь возможность автоматически увеличиваться, чтобы соответствовать количеству элементов.
Когда карта увеличивается в размере, она удаивает количество своих сегментов. Каковы условия для такого увеличения карты?
- Cреднее количество элементов в сегментах (называемое коэффициентом загрузки) превышает определенную константу, которая равна 6.5 (но ее значение может измениться в будущих версиях, поскольку это внутренний параметр Go).
- Слишком много сегментов оказываются переполненными (содержат более восьми элементов).
Когда размер карты растет, все ключи снова пересылаются во все сегменты. Вот почему при худшем сценарии вставка ключа может быть операцией $O(n)$ , где $n$ — это общее количество элементов в карте.
Мы видели при рассмотрении срезов, что если бы заранее знать количество элементов, которые нужно добавить в какой-то срез, то можно было бы иници- ализировать его с заданным размером или емкостью. Это позволяет избежать повторения дорогостоящей операции роста среза. Аналогичная ситуация и с кар- тами. Можно использовать встроенную функцию make, чтобы при создании кар- ты указать ее начальный размер. Например, если мы хотим инициализировать карту, которая должна содержать 1 миллион элементов, то сделать это можно так:
m := make(map[string]int, 1_000_000)
В случае с картами можно задавать встроенной функции make в качестве аргу- мента только начальный размер, а не емкость, как это было в случае со срезами. Здесь мы оперируем только с одним аргументом.
Указав размер, мы делаем некий намек на количество элементов, которые, как ожидается, будут содержаться в карте. Внутренними средствами карта созда- ется с количеством сегментов, соответствующим хранению одного миллиона элементов. Это экономит процессорное время, поскольку карте не нужно на лету создавать какие-либо сегменты и перебалансировать их.
Кроме того, указание размера $n$ не означает создания карты с максимальным количеством элементов, равным $n$ . При необходимости мы можем добавить больше чем $n$ элементов. Это указание означает запрос к среде выполнения Go на выделение места в памяти для карты с как минимум $n$ элементами, что будет полезно, если мы заранее знаем размер.
Чтобы понять, почему важно указывать размер, запустим два бенчмарка. Первый вставляет 1 миллион элементов в карту без установки ее начального размера; во втором случае мы инициализируем карту с заданным размером:
BenchmarkMapWithoutSize- 4 6 227413490 ns/op BenchmarkMapWithSize- 4 13 91174193 ns/op
Btropou bapiaant, b kotopom sajaetcra havaibnbi pasmer, binoinhrerca npimepho ha $60\%$ 6bictpee. 3aJabaa pasmer, mi npeJotbpaiaem onepaun no ybeJiuveniuo pasmera kaprbi, heo6xdJumbie Jia koppektHoui bctabku xJememHob.
Nostromy, kak u B cJyvaee co cpe3amu, eCiu mi sapahee 3haem koJiuHecstbo xJememHob, kotopbie 6ydet coJedpxaTb kapra, cJedyet ee cosJabatb, yka3bIbIa ee havaJbHbiJ pasmer. To no3bOJaeT u36eKaTb noTeHnIaJbHoro ybeJiuvenHnI pasmera kaprbi, 4TO noTpe6yet 3haYHTeJIbHbIX bIyHcJInTeJIbHbIX pecypcOb, nocKoJIbKy bJJeYet noBtorphoe bblJeJIehue doctaTOuHoro npocTpaHctBa b naMraT u nere6aJIaHcHpObKy bCex ee xJememHob.
IpoJoJxum HaII pasJHbOpo o kaprax u pacscmOrpum pacnIpcOtpaHennHyo oIIn6ky, npubOJHnIyI o K yTeHKaM naMraTn.
# 3.12. OllnBKA #28: KAPTbI U YTEYKU IAMRTN
Ipu pa6ote c kapTamu b Go hyxho noHmmtb heKotopbie baxHbIe xapaKтерuctnIku toro, kak kaprbi ybeJiuHbIaIOTcra u yMeHbIIaIOTcra. YrJy6JIeHHe b 3ry Temy nOMOxet npeJOTbpaHtTb npo6JeMbi, bI33bIaIOIIue yTeHKu naMraTn.
Jля havaIa, uTo6bi pacscmOrpets kOHHpetHbIi npuMER stoui npo6JIeMbi, cosJaJIHM cIeHaPi, rJee 6yJem pa6oTaTb co cJeJyIouIeI kaproU:
m := make(map[int][128]byte)
KakJbIi xJememHt m npeJCTaBJIeT co6oU MaccuB H3 128 6aIToB. CJeJIaEM cJeJIyIouIee:
- Co3JaJIHM b naMraTn nJcyTyo kapry.
- JIo6aBMn b Hee 1 MIJIJIOH xJememHOTb.
- COrpem bce 3ru xJememTbI u bI3OBEM bblIOnHHe Hn GC.
IocJIe kaXJIOro HIIaI aJbIeJEM pa3Mepr KyHn (b 3roT pa3 HcIOnb3yA mera6aIbTb). JTo noKaXeT, kak B 3TOM nIyMepe BeJET c66J naMraTb:
n := 1_000_000 m := make(map[int][128]byte) printAlloc() for i := 0; i < n; i++ { 1- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - m[i] = randBytes() } printAlloc()
for i := 0; i < n; i++ { ← удаление 1 миллиона элементов delete(m, i) } runtime.GC() ← Python запуск сборки мусора (GC) printAlloc() runtime.KeepAlive(m) ← сохранение ссылки на m, чтобы GC не «чистил» карту
Создаем пустую карту, добавляем в нее 1 миллион элементов, затем удаляем 1 миллион элементов, а затем запускаем сборник мусора. Обязательно сохраняем ссылку на карту с помощью runtime. KeepAlive, чтобы сборник мусора не очищал карту. Запустим код:
0 MB ← После резервирования в памяти места под m 461 MB ← После добавления 1 миллиона элементов 293 MB ← После удаления 1 миллиона элементов
Что мы видим? Вначале размер кучи минимален. Затем, после добавления в карту 1 миллиона элементов, он значительно увеличивается. Мы ожидали, что после удаления всех элементов размер кучи сильно уменьшится, однако карты в Go работают иначе. Несмотря на то что сборник мусора удалил все элементы, размер кучи по-прежнему значителен и составляет 293 Мбайт. Память освободилась, но не в той мере, как мы могли бы ожидать. Почему?
В предыдущем разделе мы говорили, что карта состоит из восьми элементных сегментов. На самом деле карта в Go — это указатель на структуру runtime. hmap. Эта структура содержит несколько полей, в том числе поле B, задающее количество сегментов в карте:
type hmap struct { B uint8 // log_2 of # of buckets // (can hold up to loadFactor * 2^B items) // ... }
После добавления 1 миллиона элементов значение B равно 18, что означает $2^{18} = 262144$ сегмента. Каково будет значение B, когда мы удалим 1 миллион элементов? Все так же 18. Следовательно, карта по-прежнему содержит такое же количество сегментов.
Причина в том, что количество сегментов в карте не может сокращаться. Поэтому удаление элементов из карты не влияет на количество существующих сегментов, оно просто обнуляет слоты в сегментах. Карта может только расти и иметь больше сегментов, но она никогда не уменьшается.
B предыдущем примере мы перешли от 461 Mбайт к 293 Mбайт, потому что элементы были удалены, но запуск сборщика мусора не повлиял на саму карту. Даже количество дополнительных сегментов (сегментов, созданных вследствие переполнений) остается прежним.
Cделаем шаг назад и обсудим ситуацию, когда то, что карта не может уменьшаться, рискует стать проблемой. Представьте себе создание кэша с помощью map[int][128]byte. Эта карта содержит для каждого ID клиента (int) последовательность из 128 байтов. Теперь предположим, что мы хотим сохранить последние 1000 клиентов. Размер карты останется постоянным, поэтому не стоит беспокоиться, что карта не может сжаться.
Допустим, что мы хотим сохранять данные за один час. Но компания решила к Черной пятнище провести большую промоакцию: тогда за один час к нашей системе могут подключиться миллионы клиентов. Через несколько дней после этой Черной пятницы в карте будет столько же сегментов, сколько их было в пиковое время. Это объясняет, почему мы можем столкнуться с высокой загрузкой памяти, которая при таком сценарии существенно не уменьшается.
Какие могут быть решения этой проблемы, если мы не хотим вручную перезапускать сервис для очистки того объема памяти, который потребляется картой? Одним из решений может быть регулярное повторное создание копии текущей карты. Например, каждый час мы можем создавать новую карту, копировать все элементы и освобождать предыдущую. Главный недостаток такого варианта в том, что после копирования и до следующей сборки мусора за небольшой промежуток времени может требоваться в два раза больший объем текущей памяти.
Другим решением было бы изменить тип карты для хранения указателя массива: map[int]*[128]byte. Это не отменяет того факта, что у нас будет значительное количество сегментов. Однако каждый сегмент будет резервировать память только в соответствии с размером указателя, а не 128 байтов (то есть 8 байтов в 64-разрядных и 4 байта в 32-разрядных системах).
Возвращаясь к исходному сценарию, сравним потребление памяти для каждого типа карты после каждого шага. Такое сравнение показано в таблице:

Table (html):
<table><tr><td>War</td><td>map[int][128]byte</td><td>map[int]*[128]byte</td></tr><tr><td>Cоздание пустой карты</td><td>0 Mбайт</td><td>0 Mбайт</td></tr><tr><td>Добавление 1 миллиона элементов</td><td>461 Mбайт</td><td>182 Mбайт</td></tr><tr><td>Удаление всех элементов и исполнение GC</td><td>293 Mбайт</td><td>38 Mбайт</td></tr></table>
Как мы видим, после удаления всех элементов, с типом map[int]*[128]byte требуется значительно меньший объем памяти. Кроме того, объем требуемой памяти менее значителен в пиковое время из-за оптимизации, производимых для уменьшения потребления памяти.
ПРИМЕЧАНИЕ Если ключ или значение превышает 128 байт, Go не будет хранить их непосредственно в сегменте карты. Вместо этого хранится указа-тель — для ссылки на ключ или на значение.
Как мы видели, добавление $n$ элементов в карту и последующее удаление из нее всех элементов означает то, что в памяти сохраняется такое же количество сегментов. Нужно помнить, что поскольку карта Go может только увеличиваться в размере, то и потребление памяти только увеличивается. Каких-либо автоматизированных процедур для его уменьшения нет. Если в результате мы сталкиваемся со слишком высоким потребителем ресурсов памяти, то можно пробовать разные варианты его уменьшения: например, сделать так, чтобы Go пересоздал карту, или использовать указатели, чтобы проверить возможность оптимизации.
В последнем разделе этой главы обсудим сравнение значений в Go.
# 3.13. ОШИБКА #29: НЕКОРРЕКТНОЕ СРАВНЕНИЕ ЗНАЧЕНИЙ
Сравнение значений — обычная операция в программировании. Мы часто применяем сравнения: пишем функцию для сравнения двух объектов, сравниваем значения с какой-то ожидаемой величиной и т. д. Интуитивное желание — использовать оператор $=$ везде. Но как будет показано дальше, это не всегда верно. Когда уместно использовать $= =$ и каковы альтернативы?
Начнем с примера. Мы создаем базовую структуру клиента и используем $= =$ для сравнения двух ее экземпляров. Что выявляет этот код?
type customer struct { id string}func main() { cust1 := customer{id: "x"} cust2 := customer{id: "x"} fmt.Println(cust1 == cust2)}
CpaBHeHHe 3Tux AByx cTpyKtyp customer B Go 01nyctumas onepaHn, u 0HO BbIeJET 3HaHeHHe true. A 4TO, eCnI Mbl HeMHoro 33HeHm cTpyKtypy customer u 006aBMn nOIE cPE3a?
type customer struct { id string operations []float64 } func main(){ cust1 := customer{id: "x", operations: []float64{1. }} cust2 := customer{id: "x", operations: []float64{1. }} fmt.Println(cust1 == cust2) }
MoxHO oKuJaTb, UTO OTOT KOI BbIJaCT true. Ho OH JaXke He KOMIIJIUPyETcR: invalid operation:
cust1 == cust2 (struct containing []float64 cannot be compared)
Ipo6Jema cBra3aHa c TEM, xak pa6oTaIOT onepaTOpbl == u !=. Co cpe3aMn uJIN kapTAMn OHn He pa6oTaIOT Boo6Ie. I NocKOJIbKy cTpyKtypa customer coJepxHt cPE3, OHa He KOMIIJIUPyETcX.
BaXHO nOHmmATb, kAK HcnoJIb3OBaTb == u ! = dJr toro, 1TO6M npOBOJHTb cpaBHeHHe KOppeKTHo. Mbl MoXeM HcnoJIb3OBaTb 3TH onepaTOpbl dJr cpaBHeHnIa cOIOCTaBIMbIX onepaHJIOB:
6yJIeBbIX 3HaHeHnI: paBHebl JIN JBA aJoruyeCKHX 3HaHeHnI; uceZ (int, float u mHmbl KoMnJIeKCHbIX uceZ): paBHebl JIN JBA aJcJIOBbIX 3HaHeHnI; cmpok: paBHebl JIN JBe cTpOKu; kanaJIOB: 6blJIN JIN JBA kahaJIa cO3JaHbI OJHmM u TEM Xe bI3OBOM dyHKIIN make JIN6o paBHebl JIN 6aKaHaJIa nIl; ummerpdeucOB: uMeKOT JIN JBA uHmerpdeuca OJHHaKOBbIe aJHHaMnuyeCKHe TUNbI u OJHHaKOBbIe aJHHaMnuyeCKHe 3HaHeHnI JIN6o paBHebl JIN 6a uHmerpdeuca nIl; yka3aMeTeIe: yka3bIaBaOT JIN JBA yka3aTeJIa Ha OJHO u TO Xe 3HaHeHnIe B nIaMHTu JIN6o paBHebl JIN 6a yka3aTeJIa nIl; cmpyKmyp u MaCCueOB: cOCTOaT JIN OHn u3 NoXOxKIX TUNIOB.
IPIUMe4AHHe MoXHO HcnoJIb3OBaTb onepaTOpbl ?, >=, < u > kAK c YUCJIoBbIMn TUNaMn JIN3 cpaBHeHnI 3HaHeHnI, TAK u CO cTpOa3aMn JIN3 cpaBHeHnI HX JekcuyeCKoro nOpraJKa.
B последнем примере код не скомпилировался, так как структура была составлена на основе типа, не подлежащего сравнению (среза).
Нужно знать и о возможных проблемах использования $= = \text{и} ! = \text{с типами апу. Например, разрешено сравнение двух целых чисел, присвоенных типам апу:}$
var a any = 3 var b any = 3 fmt.Println(a == b)
B результате этот код выведет true.
Ho что, если мы инициализируем два типа customer (в последней версии, содержащей поле среза) и присваиваем значения типам апу? Вот пример:
var cust1 any = customer{id: "x", operations: []float64{1. .} var cust2 any = customer{id: "x", operations: []float64{1. .} fmt.Println(cust1 == cust2)
Этот код компилируется. Но поскольку оба типа нельзя сравнивать, так как структура customer содержит поле среза, выполнение кода приводит к ошибке:
panic: runtime error: comparing uncomparable type main.customer
Имея в виду такое поведение, как можно сравнить два среза, две карты или две структуры, содержащие не подлежащие сравнению типы? Если мы придерживаемся стандартной библиотеки, один из вариантов — использовать отражение во время выполнения с пакетом reflect.
Отражение — это форма метапрограммирования, которая относится к способности приложения анализировать и изменять свою структуру и поведение. Например, в Go можно использовать Reflect.DeepEqual. Эта функция сообщает, являются ли два элемента глубоко равными (deeply equal), рекурсивно обходя два значения. Элементы, которые могут быть ее аргументами, являются базовыми типами, а также массивами, структурами, срезами, картами, указателями, интерфейсами и функциями.
Примечание Reflect.DeepEqual ведет себя определенным образом в зависимости от типа, который мы задаем. Прежде чем использовать эту функцию, внимательно прочитайте документацию.
3anyctim koul us nepboro nprepa eue pas, o6aBun Reflect.DeepEqual:
cust1 := customer{id: "x", operations: []float64{1. }} cust2 := customer{id: "x", operations: []float64{1. }} fmt.Println(reflect.DeepEqual(cust1, cust2))
Hecmotpr aH to uTO crpykTypa customer coJepxKun He nOJLeKaHnue cpaBHeHnIu THHb (cpe3), ona paOCTaCT, kak H OXHJaTocb, BbJaBaa shaHeHne true.
Ipu ucnolbSobahin Reflect.DeepEqual baxkho nomHHTb o abyx beuax. Bo- nepBbix, ota hyHKHnue detaet pa3HnHue MeXkJy nycTOuI u HylneouI kOJLeKnuei, kak o6cyKJaJIOc b paccmOTpeHnI uHnIOKu #22 (nyTaTb nycTbie u Hylnebme cpe3b). BbJnEcra Jn oTO npo6JemOa? He o6J3aTeJbHO - bce 3aBucnT OT kOHHpeTHOro cJyvaH. HanpHmep, eJIn HужHO cpaBHnHb pe3yJbTaTbI abyx onepaHnIuHnra (hanpHmep, H3 JSON B cTpykTypy Go), to moxet notpe6OBaTbca nOJepKHyTb oTO pa3JnHnue. CToHnI nomHHTb o tAKOM nOBeJHeHnI, uTO6bI 3dphEKTbHHO uCnOJIbSOBaTb Reflect.DeepEqual.
IpyraasarBosJka J6OBaJbHO cTaHaJapTHa JJIa 6OJIbHnHCTBa a3bIKOB. IocKoJIbKy oTa hyHKHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH
EcJn npou3bOJutTeJIbHOCTb - peIIaIOHnIu daktOp, JpyrHM bapuaHnOMOKET 6bITb peaJI3aHnHnI c66CTbHeHnIOO mETOJa cpaBHHeHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH nHeHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn H
func (a customer) equal(b customer) bool { if a.id != b.id { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - return false } if len(a.operations) != len(b.operations) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - return false } for i := 0; i < len(a.operations); i++ { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if a.operations[i] != b.operations[i] { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - return false } } return true }
Здесь мы создаем собственный метод сравнения со своими способами проверки различных полей структуры customer. Запуск локального бенчмарка на срезе, состоящем из 100 элементов, показывает, что этот метод equal примерно в 96 раз быстрее, чем Reflect.DeepEqual.
Нужно помнить, что применение оператора == довольно ограниченно. Например, он не работает со срезами и картами. В большинстве случаев задача сравнения решается использованием Reflect.DeepEqual, но основным недостатком становится снижение производительности. В контексте юнит-тестов возможны другие варианты: использование внешних библиотек c go- cmp (https://github.com/google/go- cmp) или testify (https://github.com/stretchr/testify).
Но если при выполнении кода важна производительность, то использование собственного метода может оказаться лучшим решением.
Важно помнить, что в стандартной библиотеке уже есть некоторые методы сравнения. Например, можно использовать оптимизированную функцию bytes. Compare для сравнения двух срезов байтов. Перед использованием собственного метода убедитесь, что не занимаетесь велосипедостроением.
# ИТОГИ
- При чтении существующего кода имейте в виду, что целочисленные литералы, начинающиеся с 0, являются восьмеричными числами. Для удобочитаемости делайте восьмеричные целые числа явными, добавляя к ним предикс 80.
- Поскольку целочисленные переполнения и антипереполнения в Go обрабатываются автоматически, можно реализовать собственные функции для их обнаружения.
- Выполнение сравнений чисел с плавающей точкой по принципу попадания их разницы в пределы заданной дельты может обеспечивать переносимость кода.
- При выполнении операций сложения или вычитания для повышения точности группируйте операции с числами, переменными, значениями одинаковых порядков величины. Сначала делайте умножение и деление, а потом сложение и вычитание.
- Понимание разницы между длиной и емкостью среза очень важно в разработке на Go. Длина среза — это количество доступных в нем элементов, а емкость — количество элементов в резервном массиве.
- При создании среза инициализируйте его с заданной длиной или емкостью, если его длина заранее известна. Это уменьшает количество операций по ре-
зервированию места в памяти и повышает производительность. Та же логика применима и к картам: при инициализации задайте их размер.
- Использование копирования или полного выражения среза
- это способ предотвратить возникновение конфликтов при использовании функции append, если две разные функции используют срезы с одним и тем же ре-зервным массивом. Но только создание копии среза предотвращает утечку памяти, если вы хотите уменьшить срез большого размера.
- Если вы хотите скопировать один срез в другой с помощью встроенной функции copy, помните, что количество копируемых элементов соответствует минимуму из длин двух этих срезов.
- Работая со срезом указателей или со структурами с полями указателей, можно избежать утечек памяти, сделав исключенные операцией нарезки элементы равными nil.
- Чтобы избежать часто возникающей путаницы при использовании пакетов encoding/json или reflect, нужно понимать разницу между нулевыми и пустыми срезами. Они оба являются срезами нулевых данных и емкости, но только нулевой срез не требует для себя выделения места в памяти.
- Чтобы убедиться, что срез вообще не содержит элементов, проверьте его длину. Эта проверка работает независимо от того, нулевой срез или пустой. То же самое касается и карт.
- Для разработки однозначных API не следует проводить различные между нулевыми и пустыми срезами.
- Карта в памяти всегда может увеличиваться в размере, но никогда не уменьшается. И если это приводит к проблемам с памятью, попробуйте разные варианты действий: например, принудительно пересоздавать карты с помощью внутренних средств Go или использовать указатели.
- Для сравнения типов и Go используйте операторы == и !+, если два типа можно сравнивать в принципе: логические значения, числа, строки, указатели, каналы и структуры, полностью состоящие из сопоставимых друг с другом типов. В противном случае можно использовать Reflect.DeepEqual и запла-тить цену за отражение либо использовать пользовательские реализации и библиотеки.
# Bэтой главе:
Kak uikl c klnchebim clobom range npucbauaet shaavehur элементam u oleyhuaet задahhoe bbaражhue Pa6ota c uiknamu u ykasateraamu c range Ppeqotbpauehue tuinuyhbx ouu6ok utepaupui kapt u hapyuehna bbnonhenuh uikna Icnconb3oBahue yhkupin defer bnytpu uikna
YnpaBraioque ctpyktypbi B Go oJhOBpeMeHHO u NoXoxu Ha ahanoruyHbie B C uJn Java, u otJnuaioTcA ot Hux. HanpHmer, B Go het uikJIOb do HJn while, a eCTb ToJbko o6o6ueHbHbI uikJ for. PaccmOrpHM tuinuyHbie ouu6ku, cB3aHbHbie c ynpaBraioHnmu ctpyktypamu, u oco6oe bHnHahue yJeJnM KJIOyeBOMy CJIOBy range, KoTOpoe uacto nonHmaoT hebeepHo.
# 4.1. OlluBKA #30: UTHOPUPOBATb TO, 4TO 3JEMeHTbI B LIKJIe RANGE KONIPYIOTcA
range - oTO yJIO6HbIi cInOco6 utepaupuI no pa3JnuyHbIM ctpyktypam JAHbIX. He HужHO uMEbTb JEOIO c uHJ2ekcamu u npObeprATb cOCTOaHHue sAbepHHeHHCTu uKJIa.
Ho Go- разработчики могут забыть или не знать, как range присваивает значения, что приводит к распространенным ошибкам. Об этом и поговорим далее.
# 4.1.1. Концепция
Цикл range позволяет проводить итерации по различным структурам данных:
- строкам;- массивам;- указателям на массивы;-срезам;-картам;- принимающим каналам.
По сравнению с классическим циклом for цикл с range — это удобный способ перебора всех элементов одной из этих структур данных благодаря лаконич-ному синтаксису. Он также в меньшей степени подвержен ошибкам, поскольку не требует обрабатывать условия и переменную цикла вручную, что позволяет избежать ошибки на единицу (off- by- one error). Вот пример с итерацией по срезу строк:
s := []string("a", "b", "c")for i, v := range s { fmt.Printf("index=%d, value=%s\n", i, v)}
Этот код перебирает каждый элемент среза. На каждой итерации, когда мы перебираем срез, range создает пару значений: индекс и значение элемента, при- сваиваемые в i и v соответственно. Как правило, range создает два значения для каждой структуры данных, кроме принимающего канала, для которого создает только один элемент (значение).
В некоторых случаях нужно только значение элемента, а не его индекс. Так как неиспользование локальной переменной приводит к ошибке компиляции, можно использовать пустой идентификатор для замены индексной переменной:
s := []string("a", "b", "c")for _, v := range s { fmt.Printf("value=%s\n", v)}
Благодаря пустому идентификатору мы перебираем каждый элемент, игнорируя его индекс и присваивая в v только значение элемента.
Если же значение нас не интересует, второй элемент можно опустить:
for i := range s {}
Теперь, когда мы освежили в памяти особенности использования range, посмотрим, какие значения позволшаются во время итераций.
# 4.1.2. Копия значения
Чтобы эффективно использовать range, важно понимать, как во время каждой итерации обрабатывается значение. Рассмотрим пример.
Создадим структуру account, содержащую единственное поле balance:
type account struct { balance float32 }
Затем создадим срез структуры account и переберем каждый элемент, используя цикл range. Во время каждой итерации мы увеличиваем balance для каждой структуры account:
accounts := []account{ {balance: 100. }, {balance: 200. }, {balance: 300. }, } for _, a := range accounts { a.balance += 1000 }
Как вы думаете, какой из этих двух вариантов показывает содержимое среза в соответствии с приведенным кодом?
- [{100} {200} {300}]- [{1100} {1200} {1300}]
Правильный ответ — [{100} {200} {300}]. В этом примере range не влияет на содержимое среза.
B Go bce, что мы присваиваем, является копией:
- Если мы присваиваем результат выполнения функции, возвращающей структуру, Go создает копию этой структуры.- Если мы присваиваем результат выполнения функции, возвращающей указатель, Go создает копию адреса памяти (в 64-битной архитектуре адрес имеет длину 64 бита).
Об этом важно помнить, чтобы избежать типичных ошибок, в том числе связанных с циклами range. Когда range совершает итерацию по структуре данных, выполняется копирование каждого элемента в переменную- значение (второй элемент).
Вернемся к нашему примеру: перебор каждого элемента account приводит к тому, что копия структуры присваивается переменной значения а. Следовательно, увеличение balance с помощью a.balance += 1000 изменяет только переменную значения (a), а не элемент в срезе.
Что будет, если нужно обновить элементы среза? Есть два варианта получить доступ к элементу с помощью индекса среза. Этого можно добиться либо с помощью классического цикла for, либо с помощью цикла range, используя индекс вместо переменной значения:
for i := range accounts { accounts[i].balance += 1000} for i := 0; i < len(account); i++ { accounts[i].balance += 1000}
Оба этих варианта приводят к одинаковому эффекту: к обновлению элементов в срезе accounts.
Какой из них предпочтительнее? Зависит от контекста. Если нужно просмотреть каждый элемент, первый цикл будет короче для записи и чтения. Если же нужно проконтролировать, какой конкретно элемент мы хотим обновить (например, один из двух), то следует использовать второй цикл.
Помните, что элемент значения в цикле range является копией. Поэтому если значение представляет собой структуру, которую нужно изменить, мы будем обновлять только копию, а не сам элемент (при условии, что модифицируемое
значение или поле не являются указателем). Предпочтительным вариантом является доступ к элементу через индекс с использованием цикла range или классического цикла for.
# O6новление элементов cpe3a: tpetniu bapuaht
Другой вариант продолжить использовать цикл range и получить доступ к значению,но изменить тип cpe3a на cpe3 указателей account:
accounts := []*account{ 06новление типа cpe3a до []*account {balance: 100. }, {balance: 200. }, {balance: 300. }, } for _, a := range accounts { a.balance += 1000 1 Pramoe o6hObnHHe nJemHHTOB cpe3a }
KaK Mbl yxe roBopunim, nepemehная a rBnJreTcA konuey yka3aTeTn account, xpahar uJeroCra b cpe3e. Ho nocKoJbky o6a yka3aTeJn ccbinaotcr a HaJy n ty xe ctpyktypy, onepaTOp a.balance += 1000 o6HOBnJret JnemHHT cpe3a.
Y 3roro bapuaht a ccts dba HeJocTaTka. Bo- nepBbix, Tpe6yetcr o6hOBnTb Tn1 cpe3a, 4TO He bcerJa BosMoxHo. Bo- BtorpBix, ecJn baxHa npou3bOJHTeJbHOcTb, To utepaJyra no cpe3y yka3aTeJeN Moxket 6bIb meHee 366ektUBHOu JnA uENTpaJbHoro npoJeccopa n3- 3a otcyctbTbIa npedckasyemocTn (o6cydum 3TOT MOMeHT B ouu6ke #91 (He noHmATb yctpouCTBO kJIIa CPU)).
B cJeJyIouIem pa3JeJeJm bI pOJOLJXUM pa6OTy c uIkJIaMn range u IocMOTpUM, KaK OJehnBaeTcra saJaBaemoe BbIpaxeHne.
# 4.2. Ollu6ka #31: UTHOPUPOBATb TO, KAK B LIKJAX RANGE BbIyHCJI8HOTC4 APfYMeHTbl
CunHtakcuc uikJia range tpe6yet haJnHnIa BbIpaxeHnIa. HaIpnMep, B uIkJIe for i, v := range exp, exp - 3TO BbIpaxeHne. Kak Mbl BnJeJn, 3TO Moxket 6bIb cTpoka, MaccuB, yka3aTeJb Ha MaccuB, cpe3, kapTa uJn kanaJ. Tenepb nIorOBOpUM o ToM, KaK BbIyHCJIeTcra 3TO BbIpaxeHne. 3TO baxHbIb MOMeHT, nO3bOJIrOIIuIuI u36eXaTb MHOrUX TnIInyHbIX OIIII6OK.
Paccmotpum npmep, rje k cpe3y do6abnrcs alemehnt, no kotopomy Mbl bllnoJnHEM ntepaHnIo. Kak bbl cHnTaete, saBepHnItcra Jn otot nIkJI?
s := []int{0, 1, 2} for range s { s = append(s, 10) }
YTo6bi nonHrbs cyTb, cJedJyet nonHnHrbs, YTO npu ncnoJIb3oBahnHn IJkJIa range yKa3bIaEMOe bblpaXeHnIe bblHncJIrcrcr ToJIbKO OJHn pa3 — nepEJ havajOM IJkJIa. B JTOm KOHTEKCTe CJJBOO \*BblHncJIrcrcr> O3HaYaeT, YTO npeJIocTbAJIeHHOe bblpaXeHnIe KOnHpyeTcra BO bpeMeHHyIO nepeMeHHyIO, a saTem IJkJI rangE bblnoJIHreT ntepaHnIu HaJ JTOHn nepeMeHHOu. B JTOm npHmepe npu bblHncJIeHHnI bblpaXeHnIa s pe3yJIbTaTOM 6yJET KOnHn cpe3a, KaK nOKa3aHO Ha puc. 4.1.
Puc. 4.1. s KOnHpyeTcra BO bpeMeHHyIO nepeMeHHyIO, cncnoJIb3yEMyIO B IJkJIe rangE
IJkJI range ucnoJIb3yET JTy bpeMeHHyIO nepeMeHHyIO. HcxOJIHbIH cpe3 s TAKXe o6HOBJIrcrcr BO bpeMn KaXKJOH ntepaHnI. CJJedOBaTeJIbHO, nocJIe TpeX ntepaHnIH cOCTOaHnIE 6yJET TAKHn, KaK Ha puc. 4.2.
Puc. 4.2. BpeMeHHaR nepeMeHHaR OCTaTeTCr cpe3OM JJIHHOJI 3, NoSTOMy ntepaHnI npeKpaHbIaOTcra
Kаждый шаг приводит к добавлению нового элемента. Но за три шага мы прошлись по всем его элементам. Длина временного среза, используемого в range, остается равна 3, поэтому цикл завершается после трех итераций.
Такое поведение отличается от классического цикла for:
s := []int{0, 1, 2} for i := 0; i < len(s); i++ { s = append(s, 10) }
B этом примере цикл никогда не закончится. Значение выражения len(s) вычисляется во время каждой итерации, и раз мы продолжаем добавлять элементы, то никогда не достигнем состояния завершения цикла. Чтобы правильно использовать циклы в Go, важно помнить об этой разнице.
При использовании range помните, что вышеописанное поведение (выражение вычисляется только один раз) также применимо ко всем типам данных. В качестве примера посмотрим на последствия такого поведения для двух других типов: каналов и массивов.
# 4.2.1. Каналы
Рассмотрим пример, где цикл range осуществляет итерации по каналу. Мы создаем две горугины, каждая из которых отправляет элементы в два канала. Затем в родительской горугине реализуем потребителя на одном канале, используя цикл range, который пытается переключиться на другой канал во время выполнения цикла:
ch1 := make(chan int, 3) go func() { ch1 <- 0 ch1 <- 1 ch1 <- 2 close(ch1) }() ch2 := make(chan int, 3) go func() { ch2 <- 10 ch2 <- 11 ch2 <- 12 close(ch2) }() ch := ch1 ch присваивается значение первого канала
for v := range ch { —— Создается клиентканала путемитерацийно ch fmt.Println(v) ch = ch2 ch присваиваетсязначениевторогоканала }
Ta же логика применяется в отношении того, как вычисляется выражение range. Выражение, задаваемое для range, представляет собой канал ch, указывающий на ch1. Следовательно, range вычисляет ch, выполняет его компрование во временную переменную и итерирует по элементам из этого канала. Несмотря на то что ch = ch2, цикл range продолжается по ch1, а не ch2:
0 1 2
Ho оператор ch = ch2 все- таки оказывает некоторое влияние. Поскольку мы присвоили второй переменной значение ch, то если после этого кода вызовем close(ch), закроется второй канал, а не первый.
Tеперь посмотрим, как оператор range вычисляет каждое выражение только один раз и влияет на результат при использовании его с массивом.
# 4.2.2. Массив
Как влияет использование range с массивом? Поскольку выражение, по которому проводятся итерации в цикле range, вычисляется до начала цикла, то, что присваивается временной переменной цикла, является копией массива. Посмотрим на этот принцип на примере, где некоторый индекс массива обновляется во время выполнения цикла:
a := [3]int{0, 1, 2} —— Создается массив из трех элементов for i, v := range a { —— Проводятся итерации по массиву a[2] = 10 —— Обновляется последний индекс if i == 2 { —— На печать выводится содержимое последнего индекса fmt.Println(v) } }
Этот код меняет значение последнего индекса на 10. Но в результате выполнения кода будет выведено не 10, а 2, как показано на рис. 4.3.
Как мы уже говорили, оператор range создает копию массива. По мере выполнения цикла обновляется не копия, а исходный массив a. Следовательно, значение v на последней итерации равно 2, а не 10.
Puc. 4.3. range npobodnt utepaulun no konin maccuba (ceea), b to bpema kak uikl monupunpyet a (cpaa)
Ecln hyxko bIbectiu qaktniyeckoe shavehne nocJednero xieMeHTa, To cJelatb oTO moxHO AByM4 cInocOaMn:
- Получая доступ к элементу по его индексу:
a := [3]int{0, 1, 2} for i := range a { a[2] = 10 if i == 2 { fmt.Println(a[2]) } }
Поскольку мы обращаемся к исходному массиву, этот код выводит 2 вместо 10.
- Используя указатель массива:
a := [3]int{0, 1, 2} for i, v := range &a { цикл range осуществляется по &a, a не по a a[2] = 10 if i == 2 { fmt.Println(v) } }
Мы присваиваем копию указателя массива временной переменной, используемой оператором range. Но поскольку оба указателя ссылаются на один и тот же массив, обращение к v также возвращает 10.
Оба варианта допустимы и приводят к правильному результату. Но во втором варианте не производится копирование всего массива, о чем следует помнить, если массив имеет значительный размер.
Таким образом, в цикле range выражение, по которому проводятся итерации, вычисляется только один раз — перед началом цикла, при этом выполняется копирование этого выражения (независимо от типа). Помните о таком поведении, чтобы избежать ошибок, которые могут привести к тому, что вы обратитесь к неправильному элементу.
В следующем разделе посмотрим, как избежать ошибок при использовании range с указателями.
# 4.3. ОШИБКА #32: ИГНОРИРОВАТЬ ВЛИЯНИЕ, КОТОРОЕ ОКАЗЫВАЕТ ИСПОЛЬЗОВАНИЕ ЭЛЕМЕНТОВ УКАЗАТЕЛЯ В ЦИКЛАХ RANGE
Рассмотрим специфическую ошибку при использовании цикла range с элементами указателя: ссылки не на те элементы, которые на самом деле нужны.
Прежде всего проясним причину использования среза или карты элементов- указателей. Есть три основных сценария:
- Xранение данных с применением семантики указателей подразумевает совместное использование элемента. Например, следующий метод содержит логику для вставки элемента в кэш:```pythontype Store struct { m map[string]*Foo}func (s Store) Put(id string, foo *Foo) { s.m[id] = foo // ...}```
Здесь применение семантики указателя подразумевает, что элемент Foo общий как для вызывающего объекта Put, так и для структуры Store.
- Бывают случаи, когда мы уже производим какие-to действия с указателями. Поэтому вместо значений может быть удобно сохранять непосредственно указатели.
- Если мы держим в памяти структуры больших размеров и они часто из-меняются, то можно использовать указатели, чтобы избежать операции копирования и вставки при каждом таком изменении:
func updateMapValue(mapValue map[string]LargeStruct, id string) { value := mapValue[id] ➔ Копирование value.foo = "bar" mapValue[id] = value ➔ Вставка } func updateMapPointer(mapPointer map[string]*LargeStruct, id string) { mapPointer[id].foo = "bar" ➔ Прямое изменение элемента карты }
Поскольку аргумент функции updateMapPointer — это карта указателей, изменение поля foo можно выполнить за один шаг.
Обсудим распространенную ошибку с элементами- указателями, возникающую при работе с range. Рассмотрим две структуры:
- Customer, представляющую собой потребитель.
- Store, которая содержит карту указателей Customer. type Customer struct { ID string Balance float64 } type Store struct { m map[string]*Customer }
Следующий код итерирует по срезу элементов Customer и сохраняет их в карте m:
func (s *Store) storeCustomers(customers []Customer) { for _, customer := range customers { s.m[customer.ID] = &customer ➔ Сохранение указателя customer в карте } }
Мы итерируем по входному срезу с помощью оператора range и сохраняем указатели Customer в карте. Но приводит ли такой метод к тому, что ожи- дается?
BbI3OBem ero c nIOMOIBIO DparMeHTa H3 Tpex pa3HbIX CTpyKTyp Customer:
s.storeCustomers([]Customer{ {ID:"1",Balance:10}, {ID:"2",Balance:- 10}, {ID:"3",Balance:0} })
Bot pesyJIbIaT SToro KoDA, eCJIu bIbIecT1u coJepXkIMOe KapTbI:
key=1,value=&main.Customer{ID:"3",Balance:0} key=2,value=&main.Customer{ID:"3",Balance:0} key=3,value=&main.Customer{ID:"3",Balance:0}
BMeCTo coXpaHeHnIa Tpex pa3HbIX CTpyKTyp Customer Bce 3JIeMeHTbI, xpaHnIuIecra b kapTe, cCbJIaIOTcra Ha OJHy u Ty Xe CTpyKTypy Customer - TpetbI0. YTO nOIIIO he tak?
HтерaI1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111H11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
func (s \*Store) storeCustomers(customers []Customer) { for _, customer := range customers { fmt.Printf("%pn", &customer) BbIBOJapeca customer s.m[customer.ID] = &customer } }
0xc000096020 0xc000096020 0xc000096020
Iovemy 3TO BaxHO? PaccMOTpHM KaXAYIO UтерaIIIO:
- При первой UтерaIIII customer cCbJIaIeTcra Ha nepBbII 3JIeMeHT
- Customer 1. MbI coXpaHnIeM yKaBaTeJIb B CTpyKType custOmer. При bTropOI UтерaIIII customer tenepb cCbJIaIeTcra Ha ApyroU 3JIeMeHT
- Customer 2. TAKxKe coXpaHnIeM yKaBaTeJIb B CTpyKType custOmer. Наконец, при последнeй UтерaIIII custOmer cCbJIaIeTcra Ha последний элe-мeHT
- Customer 3. И опять тот же yKaBaTeJIb coXpaHnIeTcя B kapTe.
Bконце kaXдOI UтерaIIII MbI трижды coXpaHnJIu OJIH и тот же yKaBaTeJIb B kapTe (рис. 4.4). Последняя запись 3того yKaBaTeJIя в память является cCbJIKOй на
последний элемент среза — Customer 3. Поэтому все элементы карты ссылаются на один и тот же потребитель.
Puc. 4.4. Переменная customer имеет постоянный адрес, поэтому мы храним в карте один и тот же указатель.
Как решить эту проблему? Есть два способа. Первый похож на тот, что мы виде- ли при разборе ошибки #1 (непреднамеренно затенять переменные). При этом потребуется создание локальной переменной:
func (s *Store) storeCustomers(customers []Customer) { for _, customer := range customers { current := customer s.m[current.ID] = ¤t 3 answers указателя в карту }}
B этом примере мы не храним указатель, ссылающийся на customer, а сохраняем указатель, ссылающийся на current — переменную, ссылающуюся на уникальный Customer во время каждой итерации. По мере выполнения цикла мы сохраняли в карте разные указатели, ссылающиеся на разные структуры Customer. Другое решение состоит в том, чтобы сохранять указатель, ссылающийся на каждый элемент, используя индекс среза:
func (s *Store) storeCustomers(customers []Customer) { for i := range customers { customer := &customers[i] ➔ — Присвоение customer значения указателя на элемент i s.m[customer.ID] = customer ➔ — Сохранение указателя customer }}
B этом решении customer теперь указатель. Поскольку он инициализируется во время каждой итерации, то имеет уникальный адрес. Поэтому мы храним в картах разные указатели.
При итерациях по структурам данных с использованием range помните, что все значения присваиваются уникальной переменной с одним уникальным
adpecom. Iecin bo bpeMk kaxdou utepaHnI xpahnIb yka3atelb, cbliaonHic a ha sty nepemehnyo, to Mbl okakemc a b cunyainu, kora dyet coxpaharbc aJun u tot xke yka3atelb, ccliaonHic a ha oJun u tot xke xlemenr - camlu nocJendnui. Jty npoJemy moxko penHnIb, npHnydntelbHo cO3Jab Jocakshyio nepemehnyo b oJlactu deicctbHn IHKla uJn yka3atelb, ccliaonHic a ha xlemenr cpe3a pepe3 ero uHJekc. O6a peHHeHnI xopoHnI. Xotr Mbl B3JIn B kavectbe BxOJHbIX JaHnHbIX ctpykTyrpy cpe3a, c kapTaMn Moxket Bo3HnKnybTa aHaJoruyHnag npoJJem3a.
B cJedyIouem pa3JeeJe paccмотpIM OIIu6Ku, cB3aHnHbie c utepaHnMn Io kapTaM.
# 4.4. Ollu6kA #33: JEJATb HEBEPHblE AONyUeHnIa BO BPEM# UTEPAUUN KAPTbI
UTepaHnI kapT Bb3bBaIOT MHoro henoHmMnHnIa I OIIu6ok, B OCHOBHOM H3- 3a ToRo, VTO pa3pa6oTuyIku Jel3aIOT HEBepHbie JOnyIHeHnI. B JTOM pa3JeeJe o6cyJIM JBa pa3HbIX CJyV4a:
yIopJdOyHbHnIe; o6HOBJeHnIe kapTbI BO BpeMn utepaHnH.
Ipu utepaHnIX kapTbI Bo3MOXHbI JBe OIIu6Ku, OCHOBaHnHbie Ha HEBepHbIX JOnyIHeHnIX.
# 4.4.1. YnopJdOyHbHnIe
BakHO IOHHTb HeckOJIbKO OyHJaMeHTaJIbHbIX CBOJCTB CTpyKTyPbI JaHnHbIX Map:
OHa He xpahnI TaHnHbie, oTcoprupOBaHnHbie IIO KJIoy (kapTa He OCHOBaHa Ha JBOuYHOM JepeBe). OHa He coxpHnIeT npOJdOk, B KOTOpOM 6blIn JODaBJeHbI JaHnHbie. HaIpHmep, eCJIu Mbl bCTaBaJIJeM napy A nepel napoH B, To He JOnJKHnI JelJaTb HHKaKUX npel- JOnJoxHeHnI, OCHOBaHnHbIX Ha TAKOM npOJdJKe bCTaBKnI.
BoJee ToRo, Ipu utepaHnIX kapTbI Mbl Boo6Ie He JOnJKHbI JelJaTb HHKaKUX JOnyIHeHnI OTHOcHTeJIbHO yIOpJdOyHbHnIa. IOcMOHpHM, VTO BbITeKaet H3 STOro yTbepXdeHnI.
PaccмотpHM kapTy Ha puc. 4.5, coCTOaIIyIyI O3 VETbIpeX cERMeHTOB (3JEMeHTbI npEJCTaBaJIroT KJIoy). KaXblbI UnJJekc pe3epBHOro MaccHba cCbIIaTeCTa Ha KaKOI- TO cERMeHT.

[ImageCaption: Puc.4.5.Kapta c vetbipbma cermenTami]
C nIOMOIIbIO IIKJIa range nepe6erem 3JemenHbI 3TOI kapTb I u bIbIeJem bce KJI0Y:
for k := range m { fmt.Print(k) }
Mы уже говорили, что сортировка данных по ключу не делается. Следовательно, мы не можем ожидать, что в результате выполнения этого кода будет выведено acdeyz. Кроме того, мы также уже сказали, что в картах не сохраняется порядок вставки. Следовательно, мы также не можем ожидать, что будет выведено ayzcde.
Moxho Jiu oXcidatb, vro kod bIbeJet KJI0Yu B ToM nOprJke, B kakom OHu cEIYac xpaHrTcB kapTe, to cEtb aczdey? Het. B Go nOprJok utepaHbI no kapTe he onpeDeJIremcra he yka3bIaemcra. Het Hukakou rapaHTHn, vro nOprJok OyJet OJHHaKOBbIM ot oJHOuI utepaHn K Jpyrou. CJeJyet nomHHTb o tAKOM nObeJehnHn kapTbI, vTO6bI He OCHOBbIBaTb KOI Ha HeBePHbIX JOnyIHeHnIX.
CInpaBeJIInBOcTb bCexэтIX yTbeprXdeHnI nOJIbTeprXJaetcra paHbIMu sanyckaMn koJa:
zdyaec czyade
BbIBOJ OTJyvaetcra ot oJHOuI utepaHnK Jpyrou.
PruMME4aHNE Xota het Hukakou rapaHTHn OTHOcHTeJIbHO nOprJKa utepaHnI, pacnpeJeJIeHne utepaHnI hepaBHOMePHo. UMeHHO nOOTOMy B oOHNHn aJIbHOuI cneIHNbIkaHnHn Go roBOpHTcra, vTO utepaHnI heOnpeJeJIeHHaR, a He cJIyvaIHnA.
Почему в Go такой причудливый способ итераций карты? Это сознательный выбор разработчиков языка. Они хотели добавить элемент случайности, чтобы программы никогда не основывались на каких-либо предположениях об упорядочивании при работе с картами (см. http://mng.bz/M2NW).
Go- разработчикам никогда не следует делать какие-либо предположения относительно порядка при итерациях карт. Отметим, что использование пакетов из стандартной или внешней библиотеки может приводить к разному поведению. Например, когда пакет encoding/json маршалирует карту в JSON, он выстраивает данные в алфавитном порядке по ключам, независимо от порядка вставки. Но это не свойство самой карты в Go. Если нужно какое-либо упорядочивание, рекомендую использовать другие структуры данных, например двоичную кучу (библиотека GoDS на https://github.com/emirpasic/gods содержит полезные реализации структурирования данных).
Рассмотрим вторую ошибку, связанную с обновлением карты в процессе итераций по ней.
# 4.4.2. Вставка карты во время итераций
В Go разрешено обновление карты (вставка или удаление элемента) во время итераций — это не приводит к ошибкам компиляции или выполнения. Но есть нюанс, который следует учитывать при добавлении элемента в карту во время итерации, чтобы избежать недетерминированных результатов.
В примере ниже проводятся итерации по map[int]bool. Если значение пары равно true, мы добавляем еще один элемент. Как вы думаете, что выведет этот код?
m := map[int]bool{ 0: true, 1: false, 2: true, } for k, v := range m { if v { m[10+k] = true } } fmt.Println(m)
Результат непредсказуем. Посмотрите на несколько примеров вывода:
map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true] map[0:true 1:false 2:true 10:true 12:true 20:true 22:true 30:true 32:true] map[0:true 1:false 2:true 10:true 12:true 20:true]
Bot что говорится в спецификации Go по поводу создания нового элемента карты во время итераций:
Eсли запись карты создается во время итерации, она может быть произведена во время итерации или пропущена. Выбор может варьироваться для каждой созданной записи и от одной итерации к другой.
Когда элемент добавляется к карте во время итерации, он может быть либо создан, либо нет при последующей итерации. В Go нет возможности как-то «на- вязать» поведение кода. Оно может варьироваться от одной итерации к другой, и поэтому мы трижды получали разные результаты.
Важно помнить о таком поведении, чтобы код не выдавал непредсказуемых результатов. Если нужно обновить карту во время итерации по ней и убедиться, что добавленные записи не часть этой итерации, то одним из решений будет работа с копией карты:
m := map[int]bool{ 0: true, 1: false, 2: true, } m2 := copyMap(m) $\leftarrow$ Создается копия первоначальной карты for k, v := range m { m2[k] = v if v { m2[10+k] = true $\leftarrow$ 0бновляется m2 вместо m } } fmt.Println(m2)
В этом примере мы отделяем читаемую карту от обновляемой. Мы продолжаем итерировать по т, но все обновления делаются на т2. Эта новая версия кода ведет к предсказуемому и повторяемому результату:
map[0:true 1:false 2:true 10:true 12:true]
При работе с картой не полагайтесь на следующее:
- на то, что данные упорядочиваются по ключам;- на то, что порядок вставки сохранится;- на детерминированность порядка итераций;
- ha то, что элемент будет создан во время той же итерации, во время которой он был добавлен.
Помня об этих особенностях поведения, мы сможем избежать распространенных ошибок, основанных на неверных предположениях. В следующем разделе рас- смотрим ошибку, которая довольно часто допускается при прерывании циклов.
# 4.5. ОШИБКА #34: ИГНОРИРОВАТЬ ОСОБЕННОСТИ РАБОТЫ ОПЕРАТОРА BREAK
Оператор break обычно используется для прекращения цикла. Когда циклы используются в сочетании со switch или select, разработчики часто совершают ошибку, прерывая не тот оператор.
Рассмотрим пример. Мы используем switch внутри цикла for. Когда индекс цикла получает значение 2, требуется прервать цикл:
for i := 0; i < 5; i++ { fmt.Printf("%d ", i) switch i { default: case 2: Ecm: i打架 2, to bIsbIbIaEcra onepaTop break break}
Ha первый взгляд код может казаться правильным, но он не приводит к выполнению ожидаемых действий. Оператор break не завершает цикл for, он завершает действие оператора switch. Следовательно, вместо итерации от 0 до 2 этот код выполняет итерацию от 0 до 4: 0 1 2 3 4.
Важное правило, о котором следует помнить, заключается в том, что оператор break завершает выполнение самого последнего оператора for, switch или select. И здесь он прерывает действие switch.
Как написать код, который будет прерывать цикл, а не действие оператора switch? Самый идиматический способ — использовать метку:
loop: 0пределяется метка loop for i := 0; i < 5; i++ { fmt.Printf("%d ", i) switch i {
default: case 2: break loop $\leftarrow$ Npepbiaetca ukn, nprba3aHbii k metke loop, a he k switch } }
3Jecb Mbi cBra3bIaEM loop c uikJOM for. IocKoJbKy Mbi yka3bIaEM sty metky B onepatope break, to on npepbIbIaET uikJ, a he JedictbIe onepatopa switch. HOBbIi KOI BbIBeJET 0 1 2, kak u tpe6obaJIocb.
# break c metkou - oTO TO Xe cAmoe, YTO u goto?
Hekotopbie pa3pa6oT4uKu MoryT noctabutb noJ cOMHeHHe To, UTO break c MetKou uJIOMaTHueH, u pacOMaTpIbATb ero kak npuyyDJIbIbI onepatop GOTO. Ho 3TO He TAK, u TakoN kOJ uCnOJIb3yETcA B CTaHapArTHOI 6u6IuOTeke. HanpMHep, B nakete net/http npu uTeHmu cTpok u3 6yHepa:
readlines:
for{ line, err := rw.Body.ReadString('\n') switch{ case err == io.EOF: break readlines case err != nil: t.Fatal("unexpected error reading from CGI: %v", err) } //... }
B 3TOM npuMHepe uCnOJIb3yETcA rOBOpRHaar cAma 3a Ce6A MetKa readlines, uTO6bI noJyepkHytb uEnb uIuKJa. NoSTOMy npepbIbAHue JedictbIa onepatopa c nOMOuIbIo MetOK - - 3TO uJIOMaTHuecKuN 1oJxOa B Go.
IpepbIbAHue He Toro onepatopa takXe moxet npou3oIth u B cJyVae c select, haxoJaHIIuMcr bHyTpu uIuKJa. B npuMHepe Huxe Mbi xOTIM uCnOJIb3OBaTb select c JByMx cJyVaaMm u bMbitu u3 uIuKJa, eCJIu KoHTeKCT OTMeHHeTcA:
for{ select{ case <- ch: // Kakue- To JEDCTBHA case <- ctx.Done(); break $\leftarrow$ uKJI npepbIbIaETcA, eCJIu KoHTeKCT OTMeHHeTcA } }
3десь самым внутренним оператором из списка for, switch, select является select, a не for. Поэтому цикл продолжается. Чтобы прервать сам цикл, используем метку:
loop: 0пределяется метка loop for { select{ case<- ch: //Kakue- - to deicctbия case<- ctx.Done(: break loop 1 1
Kak u o xkudal ocs, onepatrop break npuboujut k bixxoly us uikla, a he k nepebibahnio bbiinolhenua select.
PruMeyAHHe MoxHO ucnoJIb3OBaTb continue c metkou, vTO6b1 nepetitu K cJedyIOIeU ntepaHnM otMeyehHoro uikJia.
Ipu ucnoJIb3OBaHnH switch uJiu select bHytpu uikJia HyxHO 6b1b oYehb bHumaTcHbHbHm. Ipu ucnoJIb3OBaHnH bnek tHnTctb1ho npOepaHtTe, ha kakou onepatrop on nobJnIeret. IcnoJIb3OBaHnHe Metok - - sto uJiuOMaTnYecke peHHeHe dJia npHny- JnteJIbHoro npekpaHHeHnH aOnpeJeJIeHHOHO onepatropa.
B nocJeJIeHm pa3JeJIe o6cyJUM oco6eHHOCTH uikJIOB B coYeTaHnH c KJI0YeBbM cJIOBOM defer.
# 4.6. OlluBKA #35: IcNIOJIb3OBaTb DEFER BHyTPU LIKJIOB
Onepatop defer oTKJaaJIbIbAeT bbiInOHHeHe bH3Oba JO bO3bpaHHeHnH coCeeJIeI dyHK1111. B OcHOBHOM OH ucnoJIb3yETcA dJia cOKpaHHeHn Hia6JIOHHOro koJa. HaIpnHep, ccsH pecypC B HTOre oJIANeH b6Ib3aKpHnT, MoXHO ucnoJIb3OBaTb deFeT, vTO6b1 u36eXaTb nOBTropeHnH bH3OBOB dJia 3aKpHbTnH nepeJ KaXJbIM bO3bpaTOM. PacnpoCTpaHeHnHa aUnI6Ka - - He3HaHe nOcJIeJICTbHnI ucnoJIb3OBaHnH deFeT bHytpu uikJia.
HanpHmep, HyXHO peaJIu3OBaTb dyHK11110, KoTOpaH 6yJIeT oTKpHbBaTb KaKyIO- TO rpy111y bHaJI1OB, np11YeM nyTn K H1M nOJIyYeHb1 H3 KaHaJIa. Mb IeJIaem ntepaH1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
func readFiles(ch <- chan string) error { for path : $=$ range ch { Nterpauu no kanany file, err : $=$ os.Open(path) Otkpbaaetca gain if err $! =$ nil{ return err } defer file.Close() Otknabbaaetca bbi3ob file.Close( //Kakue- to deactbura c gainom } return nil }
PruMe4AHue O6pa6otka oinu6ok, cBraahhых c defer, paccmotpeha b pas- . dele, nocbrauehhom oinu6ke #54 (he bInnoJnHrTs o6pa6otky oInu6ku oneparto- pa defer).
B takoii peaJn3a1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
Kak pennits Jny np6oemy? Moxho oblo Obi n3aBantcsr ot defer n o6pa6oTa113aKpbirue baiJia BpyHnyo. Ho b takom cJyvae npulJIOcs bbi oKzasTcsr ot yJ06Hou1 1yHk1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111113111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111.
Hanpимер, moxho peaJn3oBa1b 1yHk1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111011110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101111
func readFiles(ch <- chan string) error { for path : $=$ range ch { if err : $=$ readFile(path); err $! =$ nil{ Bb30B readFile, kotopar return err 1 coapxHnT OCHOBHyIO JorHky } } return nil } func readFile(path string) error { file, err : $=$ os.Open(path) if err $! =$ nil{ return err } defer file.Close() CoxpaHreT bbi3oB defer
// Какие- то действия с файломreturn nil}
3десь defer вызывается после возврата readFile, то есть в конце каждой итера- ции. Поэтому мы не держим дескрипторы файлов открытыми до возвращения родительской функции readFiles.
Другой подход в том, чтобы сделать функцию readFile закрывающей:
func readFile(ch <-chan string) error { for path := range ch { err := func() error { // ... defer file.close() // ... }() Bbimonhne yxe onpedehenno onepaun закbHtna if err != nil { return err } } return nil }
По сути, это остается тем же самым решением: добавление еще одной окружающей функции для выполнения defer во время каждой итерации. Преимущество простой старой функции в том, что она немного понятнее, и мы также можем написать для нее специальный юнит- тест.
При использовании defer помните, что она планирует вызов функции, когда возвращается окружающая функция. Вызов defer внутри цикла будет складывать все вызовы в стек: они не будут выполняться во время каждой итерации, что может привести к утечке памяти, например, если цикл так и не завершится. Наиболее удобный подход к решению этой проблемы — введение еще одной функции, которая будет вызываться на каждой итерации. Но если важна производительность, то недостатком этого метода будет оверхед на требуемое время на вызовы функции. Если это ваш случай, то следует избавиться от defer и об- работывать вызов defer вручную перед выполнением цикла.
# ИТОГИ
- Значение элемента, по которому проводится цикл range, является копией. Поэтому чтобы изменить структуру, обращайтесь к ней, например, через ее
индекс или используйте классический цикл for (если только элемент или поле, которые вы хотите изменить, не являются указателем).
- Понимание того, что выражение, переданное оператору range, вычисляется только один раз перед началом цикла, поможет избежать неэффективного присваивания в итерации по каналу или срезу.
- Используя локальную переменную или обращаясь к элементу по индексу, можно предотвратить ошибки при копировании указателей внутри цикла.
- Чтобы обеспечить предсказуемость результатов при использовании карт, помните, что эта структура данных:
- не упорядочивает данные по ключам;- не сохраняет порядок их вставки;- не имеет детерминированного порядка итерации;- не гарантирует, что элемент, добавленный во время итерации, будет создан во время этой итерации.
- Использование break или continue с меткой приводит к прерыванию какого-то конкретного оператора. Это может быть полезно при работе с операторами switch или select внутри циклов.
- Извлечение логики цикла внутри функции приводит к выполнению оператора defer в конце каждой итерации.
# Bэтой главе:
- Понимание фундаментальной концепции рун в Go- Предотвращение распространенных ошибок при итерации и обрезке строк- Избавление от неэффективного кода, возникающего из-за конкатенации строк или бесполезных преобразований- Предотвращение утечек памяти при работе с подстроками
B Go строка — это неизменяемая структура данных, содержащая:
- указатель на неизменяемую последовательность байтов;- общее количество байтов в этой последовательности.
B языке Go есть уникальный способ работы со строками. Go вводит концепцию рун: она очень важна для понимания и может сбивать с толку новичков. Когда мы разберемся, как в этом языке обрабатываются строки, то сможем избежать распространенных ошибок при итерациях строк. Рассмотрим и типичные ошибки при использовании или создании строк. Кроме того, увидим, что иногда можно
paботать напрямую с []byte, избегая дополнительных выделений памяти. Наконец, обсудим, как избежать распространенной ошибки, которая может привести к утечкам из подстрок. Основная цель этой главы — помочь вам понять, как работают строки в Go, разобрав несколько ошибок.
# 5.1. OIIuBKA #36: HE NOHMATb KOHHEIIyM PYH
Для начала обсудим концепцию рун в Go. Эта концепция — ключ к пониманию того, как обрабатываются строки, что позволяет избежать распространенных ошибок. Для начала освежим основные понятия.
Baxhno noHmать pasHHny MeXyKoJupoBkOu cHmBOLOB (charset) u koJupoBaHnem (encoding):
- KojJupoBka cHmBOLOB, charset,
- это просто набор cHmBOLOB. Hanpимер, koJupoBka Unicode содержит $2^{21}$ cHmBOL.
- KojJupoBaHnue
- это перевод cHucka cHmBOLOB в JBOHTHbий код. Hanpимер, UTF-8
- это стандарт кодupoBaHnur, определяющий cInoco6 того, как возможно закодJupoBaHb все cHmBOLы Unicode в переменном количестве байтов (от 1 до 4 байт).
Mы упомянули слово «символы», чтобы упростить определение кодировки. Но в Unicode мы используем концепцию кодовой точки для ссылки на элемент, представленный одним значением. Например, символ汉 определяется кодовой точкой U+6C49. Используя UTF-8,汉 кодируется тремя байтами: 0xE6, 0xB1 и 0x89. Почему это важно? Потому что в Go pyha — это кодовая точка Unicode.
Mы сказали, что UTF-8 кодирует символы в количестве байтов от 1 до 4 байт, следовательно, до 32 бит. Вот почему в Go pyha — это псевдоним типа int32:
type rune = int32
Eще одна вещь, важная для UTF- 8: некоторые считают, что строки Go всегда имеют кодировку UTF- 8, но это не так. Рассмотрим пример:
s := "hello"
Mы присваиваем строковый литерал (строковую константу) переменной s. B Go исходный код представлен в UTF- 8, то есть все строковые литералы кодируются в последовательность байтов с использованием UTF- 8. Но строка представляет
co60ii noc.ledobatelnohocb npousboubhbix 6aitob, u oha he o6rasatelbno ochobaha ha UTF- 8. Korda bmi pa6otaeM c nepemenhou, kotopar he 6mla unininua/niupobaha us ctpokoboro Jntepaia (hanpимер, npu vtenhin us qau/lobouicnctembl), bMl he mokem cHnIaTb no yMOLyHnIIO, vTO OHA cncOJIb3yET KoJHpOBNy UTF- 8.
PruMmEyAHnE golang.org/x - penosnropu, npeJocrabJraionnii pacnupenHnI cTahJapHnOH d6oJIuOteru, - coJepxHnI nIaerbH 6Ia pa6oTbI c UTF- 16 u UTF- 32.
BepHemcK pIpmerpy c pIpBETCTBHeM. ECTb cTpoka, cocTOHnIaH nI nIaTn cHmBOLOB: h,e,l,l u o.
Otnu npocmbe cHmBOLb KojHpyIOTcA c cncOJIb3OBaHnIe OJIHoro 6aitra KaKxJbI. Bot nOyEMy BbI3OB qyHKIIHn 3anpoca 7JHbH s Bo3BpaIIaet 5:
s := "hello" fmt.Println(len(s)) // 5
Ho cHmBOLH He BcERa KojHpyETcA OJIHm 6aJITOM. Bo3BpaIIaBcB K cHmBOLy 7, Mbl yIOMaHnyJn, vTO B UTF- 8 OH KojHpyETcA TpeMx 6aitaMn. OTO nOJIbTeprKJaetcA npImuepOM:
s := "汉" fmt.Println(len(s)) // 3
BMeCTo 1 B 3TOM pIpHmpe BbIBOJHTcA 3. IpImuHeMaH K cTpOKe BCTpOeHHa qyHK- uIa Ien Bo3BpaIIaet He KOIHvEcTBO cHmBOLOB, a uIcJIO 6aitOB.
U HaO6opOT, Mbl MOKeM CO3JaTb cTpOKy, OTTAJIKUBaBcB OT cHnIcKa 6aitOB. Mbl yXe yIOMHHaJn, vTO cHmBOL 7K KojHpyETcA TpeMx 6aitaMn: OxE6, OxE11 0xE89:
s := string([]byte{0xE6, 0xE1, 0x89}) fmt.Printf("%s\n", s)
3Jecs Mbl CO3JIeM cTpOKy H3 ATIX TPEX 6aitOB. KoTJa Mbl BHeOJIH M eE, TO nOJIyH3e He Tpu cHmBOLa, a OJIH: 7.
BbIBOJIb:
- KojHpOBKa cHmBOLOB
- 3TO Ha6Op cHmBOLOB. KojHpOBaHHe Xe OIIcCbIaEt, KaK KOIHpOBKa npeO6pa3OBbIaEcTcA B JBOIyHbIbI KOI.
- B Go cTpOKa cCblJaEcTcA Ha Heu3MeHeMebIbI cpe3 npOu3eOJIbHbIX 6aitOB.
- Исходный код Go использует UTF-8. Все строковые литералы — строки UTF-8. Но поскольку строка может содержать какие угодно произвольные байты, если получена откуда-то еще (а не из исходного кода), то нет гарантии, что она будет основана на кодировке UTF-8.
- Руна соответствует понятию кодовой точки Unicode, означающей элемент, представленный одним значением.
- При использовании UTF-8 кодовая точка Unicode может быть закодирована с помощью одного, двух, трех или четырех байтов.
- Применение функции len к строке возвращает количество байтов, а не количество рун.
3нать эти понятия необходимо, потому что руны в Go встречаются повсюд. Посмотрим на конкретное применение этих знаний в связи с распространенной ошибкой, совершаемой при итерации строк.
# 5.2. Olln6kA #37: HETO4HAAR UTEPAUMA CTPOK
HrepaHn ctpok - pacipocpaenHoe deicTbue. BosMoxHo, Mx xotnM bHnOInHnTb kakyo- to onepaHnO dIa kaXdOu pyHb I ctpoke nIu peaHnOaBaTb noIb3OBaTeJIckyIO pyHkHnO dIa noicka onpeJeJIeHnOu noJctpoku. B o6onx cJyvaax Mb dOJIKHbI ocyIIeCTbJIaTb nepe6op pa3HbIX pyH ctpoku. Ho b ToM, kak pa6oTaet HrepaHn, Jierko sanyTaTbca.
PaccMOrpHm npuMep, nIe xotnM bIbEctu pa3HbIe pyHb I ctpoke I ux cootBecTcTbyIouIue no3nI11u:
s := "hēllo" — Литерал строки содержит специальную руну — ê. for i := range s { fmt.Printf("position %d: %c\n", i, s[i]) } fmt.Printf("len=%d\n", len(s))
Mы используем оператор range для итерации по s, а затем выводим каждую руну, используя ее индекс в строке. Вот результат:
position 0: h position 1: A position 3: 1 position 4: 1 position 5: 0 len=6
Этот код делает не то, что мы хотим. Выделим три момента:
- Вторая руна в выводе на печать
- А, а не $\hat{\mathbf{e}}$ .- Мы перепрыгнули с позиции 1 сразу на позицию 3... Но что находится на позиции 2?- len возвращает число 6, тогда как s содержит только 5 рун.
Начнем с последнего момента. Мы уже упоминали, что len возвращает количество байтов в строке, а не количество рун. Поскольку мы присвоили s значение строкового литерала, то s будет строкой UTF- 8. При этом специальный символ $\hat{\mathbf{e}}$ не кодируется одним байтом
- для этого требуется два байта. Следовательно, вызов len(s) возвращает 6.
# Подсчет количества рун в строке
A что, если мы хотим получить количество рун в строке, а не количество байтов? To, как мы сможем это сделать, будет зависеть от кодировки.
B предыдущем примере мы присвоили s значение строкового литерала, поэтому можно использовать пакет unicode/utf8:
fmt.Println(utf8. RuneCountInString(s)) // 5
Вернемся к рассматриваемому циклу, чтобы понять оставшиеся сюрпризы:
for i := range s { fmt.Printf("position %d: %c\n", i, s[i])}
Мы должны признать, что в этом примере итерируем не каждую руну, а каждый начальный индекс руны, как показано на рис. 5.1.
При выводе на печать s[i] выводится не i- я руна, а байт с индексом i в пред- ставлении UTF- 8. Следовательно, мы вывели hAllo вместо hêllo. Как исправить кол, чтобы он выводил все разнообразные руны? Есть два варианта.
Pис. 5.1. Печать s[i] выводит представление UTF- 8 каждого байта с индексом i
Мы должны использовать значение элемента оператора range:
s := "hêllo" for i, r := range s { fmt.Printf("position %d: %c\n", i, r) }
Чтобы не выводить руну с помощью s[i], мы используем переменную r. Ис-пользование цикла range для строки возвращает две переменные: начальный индекс руны и саму руну:
position 0: h position 1: ê position 3: l position 4: l position 5: o
Другой подход заключается в преобразовании строки в срез рун и итерации по нему:
s := "hêllo" runes := []rune(s) for i, r := range runes { fmt.Printf("position %d: %c\n", i, r) } position 0: h position 1: ê position 2: l position 3: l position 4: o
Здесь мы преобразуем s в срез рун, используя []rune(s). Затем мы проводим итерацию по этому срезу и используем значение элемента оператора range для вывода всех рун. Единственная разница связана с позицией: вместо вывода начального индекса последовательности байтов руны код выводит непосред- ственно индекс руны.
Это решение приводит к оверхеду на время выполнения по сравнению с предыдущим. Действительно, преобразование строки в срез рун требует выделения места в памяти для дополнительного среза и преобразования байтов в руны: временная сложность $O(n)$ , где $n$ — количество байтов в строке. Поэтому если нужно выполнить итерацию по всем рунам, то используйте первое решение.
Если мы хотим получить доступ к i- й руне строки с помощью первого варианта, важно понимать, что доступа к индексу рун не будет, скорее мы будем знать
TOIbko haavalbHbiiHnJekc kakol- to pyHbI b nocJedobatelNHOcTn 6aHTOB. B 6oJIb- uHHCTbTe TAKHX cJIyVaeB npeJIOVOHTueJIbHee BTOPOuB aBpuAHr:
s := "hêllo" r := []rune(s)[4] fmt.Printf("%c\n", r) // o
OTOT KOI bIMbOJHT YCTBepryHO pyHb, CHaIaIa npeOpaOyA cTpOKy B cpe3 pyHb.
# Bo3Moxkhaa onTmMn3aHn3 aocTyna K onpeJedenHnOu pyHe
EcIu cTpOka cOCTOHT M3 OJHO6aIaTOBbIX pyH, TO Bo3MoxkH OJMH MetOa ONTmMn3aHnH: HapnmuMp, KoJa cTpOka coJepxHnT 6yKbHb OT A Jo Z u OT a Jo z. MbI MoxkEM nOJIy- yHTb AOCTyN K i- u pyHe 6e3 npeO6pa3OBaHnH BcEi cTpOKH B cpe3 pyH, O6paTbIHbHb K 6aHTy HapnpaMYO c NoMOuHbO s[ i]:
s := "hello" fmt.Printf("%c\n", rune(s[4])) // o
EcIu tpe6yetca bHIOJMHbIb nTEpaHnOIO pyHaM cTpOKH, MoxkHO cICIOJIb3OBaTb IJKJI range HapnpaMYO no STOI cTpOke. HO cJeJyET NoMHHTb, YTO HHJeKc COOTBETCTbYET HE HHJeKcy pyHbI, a HaVaJIbHOMy HHJeKcy NOcJeJOBaTeJIbHOcTn 6aHTOB pyHbI. EcJIu MbI xOTUM nOJIyHbTb AOCTyN K cAMOuI pyHe, HYxHO cICIOJIb3OBaTb 3HaYHeHe 3JIemHeTa ONepaTOpa range, a He HHJeKc B cTpOke, nOTOMy YTO pyHa Moxket COCTOHTb H3 HeCKOJIbKHx 6aHTOB. A ecJIu HYxHO nOJIyHbTb i- IO pyHy cTpOKH, TO B 6OJIbIHHCTbE cJIyVaeB cJIeJyET npeO6pa3OBbIBaTb cTpOKy B cpe3 pyH.
JaJee paccMOTpHM YaCTO bCTpeYaIOHnIbca cICTOHHK nYTaHHbIH bI Hn IcIOJIb3OBaHHn bYHKHnIb O6pe3KH b IaJKeTe sTrrngs.
# 5.3. OllbKa #38: HENTaBUNbHO cICIOJIb3OBaTb OyHKHnIb O6PE3KH
OJHa H3 pacIpoCTpaHHeHbIX OIIu6oK Hpn IcIOJIb3OBaHHn IaJKeTa sTrrngs - HeKOTOrpa Hepa36epHxA, cBra3aHHaHc IcIOJIb3OBaHHeM TrImRight H TrImSuffix. O6e bYHKHnIb cJIyXaT OJIHOu IEJIH, H UX AOBOJIbHO JIEKO cIyTaTb.
B cJIeJIyIOHeM npuMepe MbI cICIOJIb3yEM TrImRight. YTO bIMbJeT OTOT KOJ?
fmt.Println(strings.TrimRight("123oxo", "xo"))
Ответ: 123. Но этого ли вы ожидали? Если нет, то, вероятно, вы ожидали результаты функции TrimSuffix. Рассмотрим их обе.
TrimRight удаляет все завершающие руны, содержащиеся в заданном множестве. В нашем примере мы передали множество хо, которое содержит две руны: х и о. На рис. 5.2 показана логика этого действия.
Puc.5.2. TrimRight bbnonhnet ntepaunio b odpatnom hanpaanlehnn, noka he haujretca pyha, he bxodraaar B 3TO MHoxecstBo
TrimRight nepenupaet kaxnyio pyhy b opatnom noprdke. Eciu pyha nbnreterca vactbio nepoctableninoro mhoxecstba, to yhknuia ydarier ee, eciu het, to octa- habливает ntepaunin u bosbpaunat octabunyioca ctpoky. Bot noyemy hain npumer bosbpaunat 123.
C apyroll cropohbi, TrimSuf+ix bosbpaunat ctpoky oes yzasanhoro sabepnlaonmero cydpukca:
fmt.Println(strings.TrimSuffix("123oxo", "xo"))
Iockolbky 123oxo sakahunbaetcr ha xo, otoT kOJ bIBOJunt 123o. Kpome toro, yda- Jehne sabepnlaonmero cydpukca he nbnreterca nobtoparioneica onepaunuei, nostomy TrimSuffix("123oxo", "xo") bosbpaunat 123xo.
IpuHHHn 6yJET TEM Xe JIA JEBOU Vactu cTpOKu c TrimLeft u TrimPrefix:
fmt.Println(strings.TrimLeft("oxo123", "ox")) // 123 fmt.Println(strings.TrimPrefix("oxo123", "ox")) // 0123
strings.TrimLeft ydaJrert bce havaJbHbie pyhbi, coepxaanuecr b mhoxecstbe, u, cJedobatereJbHo, bIMoJunt 123. TrimPrefix ydaJrert saJahHbHii havaJbHbHii npe- ukc, bIBOJd 0123.
IocJedHee sameyahine no teme: Trim npumehnet K ctpOke kak TrimLeft, tak u TrimRight. NoSTOMy OH ydaJrert bce BeJyHHe u nocJedyIOHHe pyHbI, coJepKaHHecr B MHoxecstBe:
fmt.Println(strings.Trim("oxo123oxo", "ox")) // 123
Takим образом, мы должны убедиться, что понимаем разницу между TrimRight/ TrimLeft и TrimSuffix/TrimPrefix:
- TrimRight/TrimLeft: удаляет замыкающие/ведущие руны в наборе.
- TrimSuffix/TrimPrefix удаляет указанный сурффикс/предфикс.
B следующем разделе углубимся в рассмотрение конкатенации строк.
# 5.4. OllnBKA #39: HEJ0CTATONHAAR CTENeHb ONTUMN3ALyM NPU KONKATEHAUMI CTPOK
Для конкатенации строк в Go предусмотрены два основных подхода, но один из них в некоторых условиях может быть очень неэффективным. Разберемся, какой вариант следует предпочтить и когда.
Напишем код с функцией concat, которая объединяет все строковые элементы среза с помощью оператора $+ =$ ..
func concat(values []string) string { s := "" for _, value := range values { s += value } return s }
Bo время каждой итерации оператор $+ =$ объединяет s со строкой value. На первый взгляд эта функция может показаться правильной. Но в этой реализации мы забываем одну из основных характеристик строки: ее неизменность. Следовательно, с каждой итерацией s не обновляется, вместо этого в памяти создается новая строка, что сильно влияет на время выполнения этой функции.
K счастью, у этой проблемы есть решение - - пакет strings и структура Builder:
func concat(values []string) string { sb := strings.Builder{} Co3jaetcr strings.Builder for _, value := range values { _, _ = sb.WriteString(value) D06aBnretcr строкa } return sb.String() Bo3Bpauaetcr pesybnupyouan srpoka }
3десь мы сначала создали структуру strings. Builder, задав ей нулевое значение. Bo время каждой итерации мы создавали результату щую строку, вызывая
Metod WriteString, kotorpbii do6abJrert coJepxKumoe value bo bnytpennii 6yep, cBoJd k mHHHMymy koinupobanue namrtn.
O6patutre bHmHahue, hto writeString B kavectBe BtorpoT oHboJa BosBpaIIaet oIInI6ky, Ho Mbl HaMEpeHmO ee uHropupyem. DeicTbHTeJIbHO, oIOT MetOa HukorJa He BePHet HeHyJIeByIO oIInI6ky. Tak Jra vero xe OH BosBpaIIaet oIInI6ky kak vactb cBoeH cHrHatypbI? strings.Builder peaJH3yET HHTepDbeC io.Stringwriter, kotorpbii coJepxKit eJHnctBennbHbH MetOa: Writel- String(s string) (n int, err error). CJeIObateJIbHO, UTO6bI coOTbETCTbOBaTb STOMy HHTepDbeiCy, WritelString JOLIXeH BosBpaIIaTb oIInI6ky.
IPUMeYAHue HJHOMaTHueckoe uHropupOBaHue oIInI6ok Mbl o6cyJIM B oIInI6ke # 53 (He bHInOJIHrTb o6pa6OTky oIInI6Ku).
IcInOJIb3yA strings.Builder, Mbl takxe moxeM J66aBHTb:
- cpe3 6aHra c nomoHb1o Write;- oJHHOvHbHbI 6aHrT c nomoHb1o WriteByte;- oJHHOvHy1o pyHy c nomoHb1o WriteRune.
strings.Builder coJepxKut bHytpu ce6a 6aHTOBbHbI cpe3. KaXbHbH bH3OB WritelString npHBOJHT K bH3OBy append, npHMeHreMOMy K STOMy cpe3y. 3TO npHBOJHT K IBYM nocJIeJIcTbHbM. Bo- nepbHbIX, sty ctpykTpypy He cJIeJIyET uCnOJIb3OBaTb B peXHMe KoH- kypeHTHOro bHInOJIHeHbIH, TAK KAK bH3OBbH append npHbEZYT K COCTOHHIO rOHKu. Bo- bTOpbIX, 6yJIET UMeTb MeCTO TO, UTO Mbl yXe bHJIeJIu npH pa36ope oIInI6Ku #21 (He36DkeKTHbHbH aHHHINaJIH3aIIaH cpe3a): ecJIu 6yJIyIIa3 aJIHbHa cpe3a yXe H3BecTHa, HYXHO 3aPaHee bHJIeJIHbTb nOI hero MeCTO b namrtn. JJIa STOIJIeJIb B strings.Builder cECTb MetOa Grow(n int), OH nomOraET rapaHTHupOBaTb HaJIHbHe MeCTa JJIa eIIe n 6aHrT.
B3rJIaHeM Ha Jpyry1o nepcHIO MetOa aConcat, bH3BaB Grow c o6HbHM KOJIHecTbOM 6aHrTOB:
func concat(values []string) string { total := 0 for i := 0; i < len(values); i++ { total += len(values[i]) sb := strings.Builder{} sb.Grow(total) for _, value := range values { _, _ = sb.WriteString(value) } return sb.String()}
Перед началом итераций мы вычисляем общее количество байтов, которое будет содержать окончательная строка, и присваиваем это значение переменной total. Обратите внимание, что нас интересует не количество рун, а количество байтов, поэтому мы используем функцию len. Затем мы выдываем Grow, чтобы гарантировать наличие места для байтов total, прежде чем проводить итерации по строкам.
Запустим бенчмарк для сравнения трех версий (v1 с использованием $+ =$ , v2 с использованием strings.Builder{} без предварительного резервирования места в памяти и v3 с использованием strings.Builder{} с предварительным резервированием). Входной срез содержит 1000 строк, и каждая строка содержит 1000 байт:
BenchmarkConcatV1- 4 16 72291485 ns/op BenchmarkConcatV2- 4 1188 878962 ns/op BenchmarkConcatV3- 4 5922 199340 ns/op
Как мы видим, последний способ самый эффективный: на 99 % быстрее, чем v1, и на 78 % быстрее, чем v2. Мы можем спросить себя, как двукратное итерирование по входному срезу может ускорить код? Ответ кроется в ошибке # 21 (неэффективная инициализация среза): если для среза с заданной длиной или емкостью не выделено место заранее, то этот срез будет продолжать расти каждый раз, когда окажется заполненным, что приведет к дополнительным выделениям памяти и копиям. Следовательно, двукратное итерирование в этом случае — наиболее эффективный вариант.
strings.Builder — рекомендуемое решение для конкатенации списка строк. Обычно это решение следует использовать в циклах. Если просто нужно объединить несколько строк (например, имя и фамилию), использование strings.Builder не рекомендуется, так как это сделает код менее читаемым, чем использование оператора $+ =$ или fmt. Sprintf.
С точки зрения производительности решение с использованием strings.Builder будет быстрее с того момента, когда нужно будет объединять более пяти строк. Несмотря на то что точное число зависит от многих факторов (например, от размера объединенных строк и от конкретного процессора), это может быть эмпирическим правилом, которое поможет понять, когда предпочтество одно решение другому. Также не стоит забывать, что если количество байтов будущей строки заранее известно, то следует использовать метод Grow для предварительного выделения места под внутренний байтовый срез.
Ниже обсудим пакет bytes и то, как с его помощью предотвращать бесполезные преобразования строк.
# 5.5. OшИБКА #40: БЕСПОЛЕЗНЫЕ ПРЕОБРАЗОВАНИЯ СТРОК
BbI6upar meKdy pa6otroi co ctpokamu uIic []byte, oOblbHnHcBo npOrpaMmuctoB bbl6epyt ctpoku us co6paXkeHnii yao6ctba. Ho oOblbHaia vacb oIepaHnii bOoa/ bbl6Oa Ha caMOM dEIE bblnoJnHreTcA c nOMOIIbIO []byte. HaIpHmep, io. ReadeR, io.Writcr u io.ReadA11 pa6otaiot c []byte, a he co ctpokamu. CJeIObAteJIbHO, pa6oTa co ctpokamu tpe6yet oOIOJnHuteJIbHbIX npeo6pa3OBaHnii, xotra nakcr bYtes coJepxKHT MHORHue H3 Tex xe OIEpaHnii, vTO u nakcr stTingS.
PaccmOrpum npHmer toro, vTO he cJedeYem dEJIaTb. Mb peaJIu3yem pyHkHnIO getBytes, kotopar npHmHmct JahnHbIe io.Reader b kavectBe bXOJHbIX, HnTaet H3 HUX u bbl3bIbAcr pyHkHnIO sanitize. OyHHeHne 6yIeT bblIOJnHeno nyTcM o6pe3Ku bCex HaaJIbHbIX u KoHeYHbIX np66eJIOB. Bot cKeJIeT pyHkHnHn getBytes:
func getBytes(reader io.Reader)([]byte, error) {b, err := io.ReadAll(reader) b3ro []byteif err != nil {return nil, err}// Bb3bO OyHCTKIN
Mb bbl3bIbAem ReadA11 u npucBaIbAem b 3Ha4eHne 6aUtTOBOrO cpe3a. Kak peaJIu3oBaTb pyHkHnIO sanitize? OJHmM H3 bapuaHnTOB mOxet 6nTb co3JaHne pyHkHnIu sanitize(string) string c uCIOJIb3bOBaHnIem naketa stTings:
func sanitize(s string) string {return strings.TrimSpace(s)
Tenepr bepHemcA K getBytes: kOrJa Mb npOBOJIM kAKHe- To dEicTbHn c []byte, chavaJIa HyxHO npeo6pa3OBaTb ero B cTpOKy, npeXde Yem bbl3bIbATb sanitize. 3aTeM HyxHO npeo6pa3OBaTb pe3yJIbTaTbI o6paTnHO B []byte, hotOMy vTO getBytes BO3BpaHnIeT 6aUtOBBHnI cpe3:
return []byte(sanitize(string(b))), nil
B Yem np6oJIeMa stOII peaJIu3aHnI? BouHna HJIaTa: 3a npeo6pa3OBaHnIe []byte b cTpOKy, a 3aTeM 3a np6o6pa3OBaHnIe cTpOKu B []byte. C ToYKH spHnHnI uCIOJIb3bOBaHnI HnIa nAmHnIu kaxJoe H3 yTnX npeo6pa3OBaHnII tpe6yet oOIOJnHuteJIbHoro bblJeJIeHnI McCTa B Hei. Jaxke cCJIu 3a cTpOKoII cTOunt kAKoII- To []byte, JJIa npeo6pa3OBaHnIa []byte B cTpOKy tpe6yTeCTa co3JaHnIe KoIInIu 6aUtTOBOrO cpe3a. 3To O3Ha4aTeT H6BOe bblJeJIeHnIe McCTa B nAmHnI u KoIInpOBaHnIe bCex 6aUtTOB.
# HeusmeHreMocTb cTpok
HcnoNb3yUTe cJedyOuMUn KOD, UTO6bI npOBepUTb TOT QaKT, UTO CO3JaHue cTpOKu H3 []byte npUBOaUT K KONHPOBANHIO:
b := []byte{'a', 'b', 'c'} s := string(b) b[1] = 'x' fmt.Println(s)
BbInonHHeHue 3toro KODa BbIBOaUT abc, a He axc. BeJb b Go cTpoka HeusMeHreMa.
Kak peaJH3OBaTb hyHkIuIO Sanitize? BMeCTo toro UTO6bI pIpnHmATb I BO3BpaIaTb cTpOKy, HYxHO nPOH3BecTn DEicTbHn HaJ 6aITOBbIM cpe3OM:
func Sanitize(b []byte) []byte { return bytes.TrimSpace(b) }
Bnakete bytes takxke cctb hyHkIuH TrimSpace JIa o6pe3Ku bCex HaaJIbHbIX u KoHHeHbIX npo6eJIOb. TorJa bH3OB hyHkIuH Sanitize He nOTpe6yET JOnOJIHHTeJIbHbIX npeo6pa3OBaHnH:
return Sanitize(b), nil
Kak Mbl yxe yIOMHHaHn, 6oJIbHn aActb onepaHnI bBOJa/6bIBOJa bHInOHHEtCra c nOMOIIbIO []byte, a He cTpok. KoJJa Mbl 3aJaEcMcr bOpocOM, c Yem pa6oTaTb — co cTpokamu HJn c []byte, BcIOMHmM, UTO pa6oTa c []byte He o6raTeJIbHO meHee yJIO6Ha. Bce xKcIopHtHpyeMbie hyHkIuH naketa strings takxke uMeIOT aJIbTEpHa- tUBbI b nakete bytes: Split, Count, Contains, Index u T. A. He3aBucHmO OT toro, bHInOHHEM JIu Mbl bHOJa/6bIBOJa HJn Het, chavaaJIa HYxHO npOBepUTb, moxHO JIu peaJH3OBaTb bEcB npOpeC, HcIOnb3yJa 6aITbI bMeCTO cTpok, u I36eKaTb 3aTpAT Ha JOnOJIHHTeJIbHbIe npOe6pa3OBaHnH.
B nocJaJIeHem pa3JJeJe o6cyJIM, kak onepaHnI c nOJIcTpOKuM OKeT npUBOJHTb K yTeYKe nAMaTn.
# 5.6. OllN6KA #41: NOJCTPOKu I YTEYKu NAMRTN
Ipu o6cyxJHeHnI OIIIn6Ku #26 (cpe3b I yTeYKu nAMaTn) Mbl yBuJeJIu, kak Ha- pe3Ka cpe3a HJIu MaCCaBa moxKet npUBeCTn K yTeYKe nAMaTn. To npIMeHmHO
и к операциям со строками и подстроками. Посмотрим, как в Go обрабатываются подстроки для предотвращения утечек памяти.
Чтобы извлечь подмножество строки, можно использовать такой синтаксис:
s1 := "Hello, World!" s2 := s1[:5] // Hello
s2 создается как подстрока s1. В этом примере создается строка из первых пяти байтов, а не первых пяти рун. Мы не должны использовать этот синтаксис в слу- чае рун, закодированных несколькими байтами. Вместо этого сначала нужно преобразовать входную строку в тип []rune:
s1 := "Hello, World!" s2 := string([]rune(s1)[:5]) // Hello
Теперь рассмотрим конкретную проблему, показывающую возможные утечки памяти.
Мы будем получать сообщения журнала в виде строк. Каждый журнал сначала будет отформатирован с использованием универсального уникального идентификатора (UUID, 36 символов), за которым последует само сообщение. Требуется хранить эти UUID в памяти: например, чтобы хранить в кэше последние n UUID. Отметим, что такие записи из журнала могут быть довольно тяжелыми (до тысяч байтов). Вот так будет выглядеть реализация:
func (s store) backlog(log string) error { if len(log) < 36 { return errors.New("log is not correctly formatted") } uuid := log[:36] s.store(uuid) // Какие-то действия }
Чтобы извлечь UUID, мы используем операцию подстроки с log[:36], поскольку знаем, что UUID закодирован в 36 байтах. Затем мы передаем эту переменную uuid в метод store, который сохранит ее в памяти. Несет ли это решение в себе какие-либо проблемы? Да, несет.
Для выполнения операции с подстрокой в спецификации Go не указывается, должны ли результатуующая строка и строка, участвующая в этой операции, использовать одни и те же данные. Однако стандартный компилятор Go позволяет им совместно использовать один и тот же резервный массив, что с точки зрения распределения памяти и достижения лучшей производительности — наиболее
yдачное решение, поскольку предотвращает новое резервирование места в памяти и копирование.
Сообщения в журнале могут быть довольно тяжелыми. log[:36] создаст новую строку, ссылающуюся на тот же резервный массив. Поэтому каждая строка uuid, которую мы храним в памяти, будет содержать не просто 36 байт, а количество байтов в исходной строке log: потенциально — тысячи байтов.
Как исправить ситуацию? Сделать глубокую копию подстроки, чтобы внутрен- ний байтовый срез uuid ссылался на новый резервный массив, состоящий всего из 36 байт:
func (s store) handleLog(log string) error { if len(log) < 36 { return errors.New("log is not correctly formatted") } uuid := string([]byte(log[:36])) BbnonHreTcA[byte,a saTeM- s.store(uuid) / / Kakue- to deicTbua }
Копирование выполняется путем преобразования подстроки сначала в []byte, а затем снова в строку. Так мы предотвращаем утечку памяти. За строкой uuid стоит массив, состоящий всего из 36 байт.
Некоторые IDE или линтеры могут предупреждать, что преобразование string([]byte(s)) не требуется. Например, GoLand — среда разработки Go от JetBrains — предупреждает об избыточном преобразовании типов. Это верно в том смысле, что мы преобразуем строку в строку, но фактически эта операция имеет реальное воздействие на поведение программы. Как уже говорилось, это предотвращает ситуацию, когда за новой строкой стоит тот же резервный массив, что и за uuid. Помните, что предупреждения от IDE или линтеров иногда могут быть неточными.
Примечание Поскольку строка, как правило, является указателем, вызов функции для передачи строки не приводит к глубокому копированию байтов. Скопированная строка по-прежнему будет ссылаться на тот же резервный массив.
Начиная с Go 1.18, стандартная библиотека также включает решение с strings. Clone, которое возвращает новую копию строки:
uid := strings.Clone(l0g[:36])
BbI3OB strings.Clone cosJaet Koniuo log[:36] b HOBOM MecTe nAmяти, npeJotBpaIaA yTeYky nAmяти.
Ipu cobepIeHnIu onepaIuI c nodctpokoi B Go nomHutIe o Abyx BeIaax. Bo- nepBbIX, sadaBaemBii unItepbaJI ocHOBaH Ha yIcJIe 6aUTOB, a He pyH. Bo- BtOpbIX, onepaIaIc nodctpokoi Moxet npIbIecTH K yTeYke nAmяти, nockoIbky pesyJIbIupyIouIaI nodctpoka 6yIeT ucnoIbIbOaTb ToT xe pe3epBbIbI MaccuB, HTO u ucxoJIaIa cTpoka. YTo6bI STOrO He npou3OIIJI0, MoxHO BbIIOJIHHTb KonIipOBaHHe cTpOKu BpyHyIO uJIu ucnoIbIbOaTb strings.Clone H3 Go 1.18.
# UTOFU
- Для правильной работы со строками в Go важно понимать, что руна соответствует концепции кодовой точки Unicode и может состоять из нескольких байтов.- Итерация строки с помощью range выполняет итерацию по рунам с индексом, соответствующим начальному индексу последовательности байтов руны. Чтобы получить доступ к определенному индексу руи (например, к третьей руне), преобразуйте строку в []rune.- strings.TrimRight/strings.TrimLeft удаляет все последующие/ведущие руны, содержащиеся в заданном множестве, тогда как strings.TrimSuffix/strings.TrimPrefix возвращает строку без указанного суффикса/префикса.- Конкатенация списка строк должна выполняться с помощью strings. Builder, чтобы предотвратить резервирование места в памяти для новой строки во время каждой итерации.- Помните, что пакет bytes позволяет совершать те же операции, что и пакет strings,
— это поможет избежать лишних преобразований байт/строка.- Использование копий вместо подстрок может предотвратить утечку памяти, поскольку строка, возвращаемая операцией над подстрокой, будет поддерживаться тем же самым массивом байтов.
# Bэтой главе:
- Korда использовать получатели значений или указателей- Korда использовать именованные параметры результата и какие у них побочные эффекты- Как избежать распространенной ошибки при возврате нулевого получателя- Почему использование функций, которые принимают имя файла, не является лучшей практикой- Обращение с аргументами defer
Функция оборачивает последовательность инструкций в модуль, который может быть вызван в другом месте. Она может принимать некоторые входные данные и производить некоторые выходные данные. А вот метод — это функция, при- вязанная к какому-то конкретному типу. Этот тип называется получателем и может быть указателем или значением.
Начнем с обсуждения того, как выбрать тип получателя. Затем обсудим имено- ванные параметры, их использование и возникающие при этом ошибки. Также
обсудим типичные ошибки при создании функций или возврате ими определенных значений, таких как нулевой получатель.
# 6.1. OlluBA #42: HE 3HATb, KAKOY TUNI NOJYHATEJAR MCIOJIb3OBATb
BbI6op tuna noJyHateJra dJia metOJa he bcerJa npocT. KoJa ucnoJIb3OBaTa noJyHateJra, aBbJIIOIIuecra shavehnraMn? KoJa ucnoJIb3OBaTa noJyHateJra, aBbJIIOIIuecra yka3aTeJIrma? B stOM pa3aJeIe paCCmOrpHM yCJIoBma dJia IpuHraTma IpaBaIIbHOro peIIeHna.
B rJabe 12 Mbl noJpo6HIO noroBopum o shavehnaX u yka3aTeJraX, a takxke o6 ochobHbIX pa3JyHuaIX MeXkJy HUMH. JAHnbIH pa3JeJ JIIIIb noBepxHOCTHO kOCHeTcA STOUI TeMbl c ToYKu SPehna IpoN3BOJHTeJIbHOCTHn. KpOMe ToTO, BO MHOrUX CJIyVaax ucnoJIb3OBaHne 3Havehna HJII yka3aTeJIa B Ka4eCTBe noJyHateJIa doJIxHO JIIKTOBaTbca He coo6paXeHnaMn IpoN3BOJHTeJIbHOCTHn, a Yem- To JpyrHm, TTO Mbl u o6cyJUM. Ho chauaJIa BcIOMHmM, KaK pa6OraIOT noJyHateJIu.
B Go MoxHO IpuBraTa K MetOJy noJyHateJIb JID6O shavehna, JID6O yka3aTeJIa. Ipu ucnoJIb3OBaHnHn noJyHateJIa 3Havehna Go co3Jaet KoIIIO O3Havehna u nepeJaet ee MetOJIy. JIO6bIe H3MeHeHnIa o6bEkTa OCTaIOCTa JOKaJIbHbIMH JJIa MetOJa. IcXOJIbHbIH o6bEkT OCTaTeCTa Heu3MeHeHnHbIM.
B STOM npHMEpe H3MeHeTcA noJyHateJIb 3Havehna:
type customer struct { balance float64 } func (c customer) add(v float64) { ← → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - func main() { c := customer{balance: 100. } c.add(50. ) customer balance octaTeCTa fat.Print("balance: %.2F\\n", c.balance) ← → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → - → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → → →
IOcKOJIbKy Mbl ucnoJIb3yEM noJyHateJIb 3Havehna, yBebJIuCeHeHe balance B MetOJa add He H3MeHeT HJIe balance ucXOJIHOuI cTpyKTypbl custoMe:
100.00
B случае с получателем указателя Go передает методу адрес объекта. По своей сути то, что передается методу, также является копией, но копируется только указатель, а не сам объект (передачи по ссылке в Go нет). Любые модификации получателя выполняются на исходном объекте. Вот тот же пример, но теперь получатель является указателем:
type customer struct { balance float64 } func (c \*customer) add(operation float64) { Nonyvarenb ykasatera c.balance $+ =$ operation } func main(){ c := customer{balance:100.0} c.add(50.0) fmt.Printf("balance: %.2f\n", c.balance) customer balance o6hobnretca }
Поскольку мы используем получатель указателя, увеличение balance изменяет поле balance исходной структуры customer:
150.00
Выбор между получателями значений и указателей не всегда прост. Обсудим некоторые условия, которые помогут сделать выбор.
Получатель должен быть указателем:
- Если метод должен изменить получатель. Это правило также действует, если получатель является срезом, а метод должен добавлять элементы: - type slice [jint - func (s *slice) add(element int) { *s = append(*s, element) }- Если в получателе метода есть поле, которое нельзя скопировать: например, тип, входящий в пакет синхронизации (подробнее об этом в разделе об ошибке #74 (копировать тип sync).
Получатель следует сделать указателем:
- Если получатель
- крупный объект. Использование указателя может сделать вызов более эффективным, так как предотвращает создание большой по размеру копии этого объекта. Если вы сомневаетесь в том, крупный ли это
o6bekt, xopoinei nockasakoui ctahet 6enymapknhr. Ipaaktnuecku hebosmoxko ykasats kohnkpetnhsu pasmer, nockolsky on sabucint ot mhonux pkaktopob.
Nolnyvatelis doyxeH o6rits shayehiem:
- Eclu hyxko o6ecneutis heusmenhoctb no/nyvatelra.
- Eclu no/nyvatelem aasnecra kapra, dyhknun nnn kanal. Hnane boshnknct onnoka npu komnunriunn.
Nolnyvatelis cneoyem cneanats shayehiem:
- Eclu no/nyvatelis npejctabJrert co6ou cpe3, kotopbui he hyxko usmenrts.
- Eclu no/nyvatelis npejctabJrert co6ou he6oJblloui macsuc nnn ctpyktypy, kotorpaa rbnrertca tnnom shayehnus 6es usmenreMbx noJeni, hanpumep time.Time.
- Eclu no/nyvatelis rbnrertca 6asobbim tnnom - int, float64 nnn string.
Oдин c/nyuaH hyxko noxcnnrs. Onnyctum, mbi paspa6atbnaeM apyryo ctpyktypy customer. Ee usmenreMbie noJia he rbnrhotca yactb1o ctpyktypy hanpmy1o, a ha xoJartrca bnytpu apyrot ctpyktypy:
type customer struct { data \*data balance he rbnrertca actb1o ctpyktypy customer hanpmyio, type data struct { ho coJepxnctca b ctpyktype, ha kotopyo cshnaetcr none ykasatena balance float64 func (c customer) add(operation float64) { 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 func main(){ c := customer{data:&data{ balance: 100, }} c.add(50. ) fmt.Printf("balance: %.2f\n", c.data.balance) }
Hecmotpr ha to vto no/nyvatelis rbnrertca shayehiem, bblsob add b kohne kohnob usmenrct факtnuecknhi balance:
150.00
B 3tom c/nyvae he o6r3atelbho, vto6bi no/nyvatelb 6bl1 ykasatelem dJr usmenehnus balance. Ho dJr rchocntu moxho npeJnoVectb no/nyvatelb ykasatelra, vto6bi noJ- yepknytb, vto customer kak o6bekt - usmenreMbi.
# Cmeuibanhue tunoB noJyvatenei
Moxho nu cmeuibnats tunbi noJyvatenei, hanpumep, B ctpyktype, coJepxkauei heckonbko metoJob, dge hehotopbie coJepxkat noJyvatenu ykasatene, a Jpyrue- noJyvatenu 3havehur? O6uJee mhhehne takobo, uto sto cJedyet sanpetutb. Ho B cTahJaprtHou 6u6Jnoteke cctb koHtpnpuMeprb, hanpumep time. TIme.
Pa3pa6ou4uku xotenr o6ecne4u1b HeH3MeHHeMoc1b ctpyktypb time. TIme. CneJ6obatelbho, 6onbmuHctbO metoJob, takux kak After, IsZero u UTC, uMeIot noJyvatenu 3havehur. Ho dJra coBMeCTUMOCTu c cyuJecTbyoummu uHтерdeicamu - encoding. TextUnnar- shaler - ctpyktypa time. TIme Jonxna peanusobatb meToJ Unmarsha1Binary([]byte) error, kotopbui u3MeHreT noJyvatelb, 3aJabaeMbi 6a6T0BbIM cpe3OM. 3JOT MeTOJ uMeet noJyvatenu ykasatena.
B uJenom cJedyet u36eratb cmeuibanhur tunoB noJyvatenei, ho sto he sanpeuJeho B 100 % cJyvaeb.
HeBosMoxHO dats ucHepHbIbAIOIue peKOMeHJaIIIIu no nooxy ucnoJIb3OBaHur noJyvatenei, tak kak bCerTa 6yJyT KpaHHe cJyvau. HeJb 3TOro pa3JeTa B TOM, vTO6bi npedoc3abu1b pyKoB0OCTbO, OXBaTbIbAIOIee 6onbHIIHCTbO cJehapueb. Ho yMOL7aHHO MbI MoxKem bI66pArb noJyvatE1b snaHeH1H, cCJIH net bCek1X npuYHH He Jel7aTb 3TOro. EcJIu Bo3Hukaot coMHHeH1H, ucnoJIb3yUTe noJyvatelb ykasatelra.
JaJee o6cyJUM uMeHOBaHHbIe napaMeTpbI pe3yJIbTaTa: uTO 3TO u KOrJa uX ucnoJIb3OBaTb.
# 6.2. Ollu6kA #43: HE IcNIOJIb3OBATb UMEHOBAHHbIe IAPAMETPbI PE3yJIbTATA
IMeHOBaHHbIe napaMeTpbI pe3yJIbTaTa - 3TO peJko ucnoJIb3yEMaB Go onIIu1. B 3TOM pa3JeJe paccMOrp1M cJyvau, KOrJa uJecEcO6pa3HO ucnoJIb3OBaTb uMeHOBaHHbIe napaMeTpbI pe3yJIbTaTa, vTO6bI cJeJIaTb API 6OJee yJOOHbIM. Ho chavaJIa BCIOMH1M, kak c HUM1 pa6oTaTb.
KOrJa MbI BO3BpaIIaEM napaMeTpbI B qyHK111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
6e3 задания ero apryментob). B atom c/yvae b kaeectbe bosbpaiaembix 3havennii ucnolb3yiotcra tekyuine 3havennia napametpob pesy.istrata.
Bot пример, b котором используется именованный параметр результата b:
func f(a int) (b int) { Mmehyet napametr int pesy.istrata b $\texttt{b} = \texttt{a}$ return Bospaiaetc tekyue shavenn b }
B atom примерe мы присваиваем имя b napametrpy pesy.istrata. Kor.aa мы bbi.baem return 6e3 apryментon, on Bosbpaiaet tekyuiee 3havennie b.
Kor.aa pekomehyetcricnolb3obartb umehoahnbie napametrpi pesy.istrata? Chavana paccmotpum c.ledyioinii unterpeic, kotopbii co.ержait meto. d.ia no.uyehnia koopdunat c заданногo a.1peca:
type locator interface { getCoordinates(address string) (float32, float32, error) }
Hockolsky otot unterpeic he3kcnoptrupyembi, to no.1p6.5ha dokymentaiai heo6rasatenbha. Ho cMokcete iu bbi doradatbc, npovutab otot koa, vto npectab.iaiot co6oii aba pesy.istrata float32? Bosmokho, oto nupota u dolrota, ho vto us nux vto? 3abucit ot konipetnhix cor.laiienhi: nupota he bcrzla nepbbli d.1emeht. U vto6bi nohartb cyt, npu.etcra binnolnHTb koa.
Hcnolb3yem umehoahnbie napametrpi pesy.istrata d.ia y.oo4outaemocTt koa:
type locator interface { getCoordinates(address string) (lat, lng float32, err error) }
3Jecb yke noHnHb1 shawnnia cunHatyb1 meto.aa chavала impoTa (latitude), noTOM d.1o1rota (longitude).
Tenepb noroBopim o tom, kor.aa ncno.ys3obatb umehoahnbie napametrpi pesy.1b. tat a pea.1nusanu meto.aa. Hyxho .1u ncno.ys3obatb ux kak .a c.aa cao.1 pea.nusanu?
func (1 loc) getCoordinates(address string) ( lat, lng float32, err error) { // ... }
B этом конкретном случае наличие выразительной синчатуры метода поможет читателям кода. Поэтому лучше использовать именованные параметры результата.
Примечание Если нужно вернуть несколько результатов одного и того же типа, можно подумать о создании специальной структуры с осмысленными именами полей. Но это не всегда возможно: например, в случае существую- шего интерфейса, который мы не можем обновить.
Рассмотрим еще одну синчатуру функции, позволяющую хранить тип Customer в базе данных:
func StoreCustomer(customer Customer) (err error) { // ... }
Здесь присваивание параметру ошибки имени err никак дополнительно не про- ясняет ситуацию и не помогает читателям. В этом случае нужно отказаться от использования именованных параметров результата.
Решение о том, использовать или нет именованные параметры результата, завис- сит от контекста. Если нет уверенности в том, что их использование делает код более читабельным, именованные параметры результата использовать не нужно.
Также обратите внимание, что наличие уже инициализированных параметров результата может быть весьма удобным в некоторых контекстах, даже если они не обязательно улучшают читабельность. Следующий пример, предложенный в Effective Go (https://go.dev/doc/effective_go), вдохновлен функцией io.ReadFull:
func ReadFull(r io.Reader, buf []byte) (n int, err error) { for len(buf) > 0 && err == nil { var nr int nr, err = r.Read(buf) n $+ =$ nr buf $=$ buf[nr:] } return }
B этом примере именованные параметры результата не улучшают читабельность. Но поскольку и n, и err инициализируются нулевыми значениями, то такая реализация функции короче. С другой стороны, с первого взгляда эта функция может немного сбивать с толку. И это вопрос поиска баланса.
Замечание о пустых return (return без аргументов): они считаются допустимыми в коротких функциях. В противном случае они могут навредить удобочитае- мости, потому что читатель должен помнить выходные данные на протяжении всей функции. Будьте последовательными в рамках функции: используйте либо только пустые операторы return, либо только их, но с аргументами.
В большинстве случаев использование именованных параметров результата в контексте определения интерфейса может повысить удобочитаемость без каких-либо побочных эффектов. Но в контексте реализации какого-либо метода строгого правила нет. В некоторых случаях именованные параметры результата могут повысить читаемость, например, если два параметра имеют одинаковый тип. В других случаях их также можно использовать для удобства. Именованные параметры результата следует использовать только тогда, когда от этого есть очевидная выгода.
ПРИМЕЧАНИЕ При обсуждении ошибки #54 (не выполнять обработку ошибки оператора defer) обсудим еще один вариант использования имено- ванных параметров результата в контексте вызовов defer.
При недостаточном внимании применение именованных параметров результата может привести к некоторым побочным эффектам и непредвиденным послед- ствиям, что мы увидим в следующем разделе.
# 6.3. ОШИБКА #44: ПОБОЧНЫЕ ЭФФЕКТЫ ОТ ИМЕНОВАННЫХ ПАРАМЕТРОВ РЕЗУЛЬТАТА
Именованные параметры результата могут оказаться полезны в некоторых ситуациях, но поскольку инициализация этих результирующих параметров происходит с присваиванием им нулевого значения, то их применение иногда может привести к малозаметным багам. В данном разделе поговорим об этом подробнее.
Усовершенствуем наш предыдущий пример метода, который возвращает широту и долготу по заданному адресу. Поскольку мы возвращаем два числа float32, мы решили использовать именованные параметры результата, чтобы сделать широту и долготу явными. Эта функция сначала проверит заданный адрес, а затем определит координаты. В промежутке она выполнит проверку входного контекста, чтобы убедиться, что он не был отменен и что срок его выполнения не истек.
PruMEyAHue Mbl noDpo6Hee norOBopum o tom, vto B Go noDpa3yMeBaetcrn oJd KoHTekcTOM, npu pa36ope omm6ku #60 (heBepHO nOHHMaTb KoHTekCTbI Go). EcJIu bbl he 3hakOMbI c KoHTekcTами, BkpattIe: KoHTekCT MoXet hEcTb B cEcE curnaJIb OTMeHbI uCH KpaIHHero cpoka. Mbl MoXeM npOBepHTb COcTOHHue 3THX curnaJIbO, bbl3BaB MeTOJI Err u y6eJIbIHnIcb, vTO BO3bpaIHJeMaA omm6ka He paBHa nIl.
Bot HOBaR peaJn3aHnIa METOJa getCooRdinates. KaK BbI AymaTe, vTO He TaK c 3THM KOJOM?
func (1 loc) getCoordinates(ctx context.Context, address string) ( lat, lng float32, err error) { isValid := 1. validateAddress(address) Bannadaua aapeca if !isValid { return 0, 0, errors.New("invalid address") } if ctx.Err() != nil { BpOBePKa, 6bl nI OTMeHeH KoHTekCT return 0, 0, err uHe HCTeK nI KpaIHnI cPOk } // NoJyHeHHe M BO3BaPai KooPdHHaT }
Ha nepBbIb B3IJIaJ omm6ka MoXeT 6blTb HeOqebuJIHnI. Omm6ka, BO3bpaIHJeMaA B o6JIaCTu BnJUMOCTu If ctx.Err() != nil, - 3TO err. HoMa He npucBOuJIu nepe- MeHHoU err HnIkaKoTO 3havHeHnI. Ei no- npexHeMv npucpOeHO HylJeOe 3havHeHne TIIa omm6ku: nil. CJeJOBaTeJIbHO, 3TOT KOI BcERJa 6yIeT BO3bpaIHaTb omm6ky nI.
OTOT KOI KOMIIJIUPyTcTc, nOTOMy vTO err 6blJa HHIIIIaJIu3upOBaHa HylJeBbIM 3ha- vHeHHeM 6JIaroJaPbI HMeHOBaHHbIM napaMeTpAM pe3yJIbTaTa. Bez npucBOeHnI HMeHnI Mbl nOJIyVHJIu 6bl omm6ky KOMIIJIaIIIIII:
Unresolved reference 'err'
Oдин из возможных выходов — сделать переменную err pанной ctx.Err():
if err := ctx.Err(); err != nil { return 0, 0, err }
Mbl npoJOLJKaem BO3bpaIHaTb err, ho chavaJa npucBaBaem eii pe3yJIbTaT ctx.Err(). O6paTutTe bHnMaHHe, vTO err B 3TOM npUmepe 3atenHreT npememennyo pe3yJIbTaTa.
3aBepHnI M 3TO o6cyXKeHeHe, eIIe pa3 nOJIepKhyb, vTO UMHeHOBaHHHe napaMeTpbl pe3yJIbTaTa MoIyT b HeKOTOpbIX cJIyVaax yJIyHIIHTb yHTaemOCTb koJa (HaIIpHMeP, BO3BaTaT OJHHOro u ToO Oke TIIa HecKOJIbKO pa3) u 6blTb BeCbMa yJIO6HbIMu B JpyrIX.
Ho nomnute, vto kaxubii takou napametr uhninuaлизnpyetcr cboum hyjebbim shavehuem. Kak mi bi biediu b stom pasdene, sto moket npibectu k heoyebuJbHbM oinuckam, kotopbie he bcerda jertko o6hapyKutb. EyJbte octopoxhbi npu ucnoJb- sobanin umehobahhblx napametpob pesyJbtrata, vto6bi us6ecaztb Bosmoxhblx no6ovhblx 3dpektoB.
# Icnonb3oBaHue nyctoro onepatopa retun
Jpyrou bapuaHr- ncnonb3oBaTb nyctou onepatop retun:
if err $=$ ctx.Err);err $! =$ nil{ return }
Ho npu stom hapyuaeTcr npaBunO, yTbeprxdaouuee, vTO He HyxHO cMeuHbATb B oJHOM pparMeHTe koJa nycTbie onepatopbl retun c takmm xe onepatopamu, HO c apryMeHTaMn. B stom npuMepe, BeporATHO, cneJeyt npuJepxHbBaTbca nepboro bapuaHTa. NomHutte, vTO npuMeMeHue umehobahhblx napametpob pesyJbTaTa He bcerda paBHO tpe6obahnIO npuMeHHTb nycTbie onepatopbl retun. MHorJa moXHO npocTO ucnoJb- sobaTb umehobahhblie napametpbl pesyJbTaTa, vTO6bi cJenatb cHTHaTpy6oJee uIcTOi.
B cJeJyIouIem pa3JeJe o6cyJUM pacnpocTpaHeHHyIO oHnucKy, BosHHkaaOIIyIO, KoJJa hyHKHnH bO3BpaIIaET uHtrepdeic.
# 6.4. OlluBKA #45: Bo3BPA T ONJyVATEJr NIL
O6cyJum cJyvau, KoJJa BosBpaIIaETcr uHtrepdeic, u niorOBpUM, nOyeMy B hekoTOpbIX o6ctoArTeJIbCTBaX oTO MOXET npuBoJUTb K oHnuckam. OTa oHnucKa, BeporATHO, OJHa H3 cAblx pacnpocTpaHeHbHbIX b Go, nOTOMy vTO ee MOXHO cHHTaTb KoHtpHH- tyHTUBHOu, nO KpaUHeu sepe JIO ToIO, KaK ee cOBepHIIJII.
PaccмотpHM npuMepr. Mbl 6yJem pa6oTaTb HaJ cTpykTypoB Icstomer u peaJn3yEM MeTOaVaIidatE JJIa npObeprk e e pa6oTocno66oCTb (sanity check). BMeCTo ToIO yTO6bl BosBpaIIaTb nepbyIO oHnucKy, Mbl xOTum BosBpaIIaTb cHncOK oHnucOK. JJIa 3TOIO cO3JaJIM c66CTeHbHbI TnI JJIa nepeJaYu 3a pa3 HeCKOJIbKHX oHnucOK:
type MultiError struct { errs []string }
func (m *MultiError) Add(err error) { —— добавление ошибки
m.errors = append(m.errors, err.Error()) } func (m *MultiError) Error() string { return strings.Join(m.errors, ";") }
MultiError удOBJETBopraet uHrepeicy ouinook, nockolsky peaJusyet ctpoky Error(). Oh pacKpBIBaet MetoA Add JIAJ JO6aBJIeHnI oINIOKu. C nOMOIIbIO STOJI CTpyKTypi Mbl MoxeM peaJINsOBaTb MetoA Customer. VaIidate, vTO6bI nPOBepruTb Bo3pact u HMa KJIueHTa. EcJI nPOBePKa Ha pa6OTocInOCoHOCTb nPOuJIeha ycneHHO, Mbl xOTHM BePHyTb OINIOKy nIl:
func (c Customer) Validate() error { var m *MultiError - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if c.Age < 0 { m = &MultiError{ m.Add(errors.New("age is negative")) } if c.Name == "" { if m == nil { m = &MultiError{ } m.Add(errors.New("name is nil")) } return m }
B этой реализации m инициализируется нулевым значением *MultiError, то есть nil. Когда же проверка на работоспособность не проходит, мы создаем новую MultiError (если необходимо), а затем добавляем ошибку. В конце концов мы возвращаем m, которая может быть либо нулевым указателем, либо указателем на структуру MultiError, в зависимости от результатов проверки.
Проверим эту реализацию, запустив код с валидной структурой Customer:
customer := Customer{Age: 33, Name: "John"} if err := customer. Validate(); err != nil { log.Fatal("customer is invalid: %v", err) }
В результате получим:
2021/05/08 13:47:28 customer is invalid: <nil>
Результат может удивить. Структура Customer валидна, но и условие err != nil истинно, и запись на ошибки привела к выводу <nil>. В чем проблема?
Мы должны знать, что в Go получатель указателя может равняться nil. Поскспериментируем, создав фиктивный тип и вызвав метод с нулевым получателем указателя:
type Foo struct{} func (foo \*Foo) Bar() string { return "bar" } func main(){ var foo \*Foo fmt.Println(foo.Bar()) foo paba nil }
foo инициализируется нулевым значением указателя nil. Но этот код компи- лируется и выводит bar, если мы его запустим. Нулевой указатель является допустимым получателем.
Почему так? В Go метод — это синтаксический сахар для функции, первым параметром которой является получатель. Следовательно, метод Bar, который мы рассматривали, похож на эту функцию:
func Bar(foo \*Foo) string{ return "bar" }
Мы знаем, что передача нулевого указателя на функцию допустима. Поэтому и использование нулевого указателя в качестве получателя допустимо.
Вернемся к исходному примеру:
func (c Customer) Validate() error { var m \*MultiError if c.Age < 0{ //... } if c.Name $= =$ { //... } return m }
m инициализируется нулевым значением указателя nil. Затем, если все проверки проходят успешно, аргумент оператора return является не самим nil, а нулевым указателем. Поскольку нулевой указатель является допустимым получателем, преобразование результата в интерфейс не даст нулевого значения. То есть вы- зывающий объект Validate всегда будет получать ненулевую ошибку.
Чтобы прояснить этот момент, вспомним, что в Go интерфейс — это обертка диспетчеризации (dispatch wrapper). Здесь то, что содержится в этой обертке, равно нулю (указатель MultiError), а сама обертка — нет (интерфейс error) (рис. 6.1).

[ImageCaption: Рис. 6.1. Обертка error ненулевая]
Независимо от того, чем является Customer, вызывающая эту функцию сторона всегда будет получать ненулевую ошибку. Понимание такого поведения крайне важно, потому что эта ошибка широко распространена.
Итак, что нужно, чтобы сделать код этого примера корректным? Самое простое решение — возвращать т, только если она не равна нулю:
func (c Customer) Validate() error { var m \*MultiError if c.Age < 0{ //... } if c.Name $= =$ { //... } if m $! =$ nil{ return m m возвращается, только если была хотя бы одна ошибка } return nil B противном случае возвращается nil }
В самом конце метода мы проверяем, не равна ли т нулю. Если да, то возвращаем т. В противном случае мы возвращаем nil в явном виде. Следовательно, если структура Customer наличная, мы возвращаем нулевой интерфейс, а не нулевой получатель, преобразованный в ненулевой интерфейс.
Мы увидели, что в Go допускается использование нулевого получателя, а интерфейс, преобразованный из нулевого указателя, не является нулевым интерфейсом. По этой причине, когда нужно вернуть интерфейс, нужно возвращать не нулевой указатель, а непосредственно нулевое значение. Как правило, наличие нулевого указателя нежелательно и означает вероятное наличие ошибки.
Рассмотренный здесь случай — наиболее распространенный и приводящий к ошиб- . ke. Но такая проблема связана не только с ошибками: это может произойти с любым интерфейсом, реализованным с использованием получателями указателей.
В следующем разделе обсудим типичную ошибку при использовании имени файла в качестве входных данных функции.
# 6.5. ОШИБКА #46: ИСПОЛЬЗОВАТЬ ИМИ ФАЙЛАВ КАЧЕСТВЕ ВХОДНЫХ ДАННЫХ ФУНКЦИИ
При создании новой функции, которая должна прочитать файл, передача ей имени файла не считается хорошей практикой и может приводить к негативным последствиям, например к сложностям в написании юнит-тестов. Углубимся в эту проблему и пойдем, как ее устранить.
Предположим, нужно реализовать функцию для подсчета количества пустых строк в файле. Одним из способов реализации этой функции было бы принятие имени файла и использование bufio.NewScanner для сканирования и проверки каждой его строки:
func countEmptyLinesInFile(filename string) (int, error) { file, err := os.Open(filename) 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 if err != nil { return 0, err } // 06работка закрытия файла Cosdahne yhkuun scanner us nepemehonos.File, scanner := bufio.NewScanner(file) kotopar pas6nbaet pokonosn paan ha ctpokn for scanner.Scan() { Hrepauun no kakpoi ctpoke // ... } }
Мы открываем файл filename. Затем используем bufio.NewScanner для сканирования каждой строки (по умолчанию вводимый файл разбивается на строки).
У этой функции ожидаемое поведение. Пока задаваемое имя файла валидно, мы будем читать данные из него и возвращать количество пустых строк. Но в чем проблема?
Допустим, нужно реализовать юнит-тесты, чтобы покрыть следующие случаи:
- номинальный случай;- пустой файл;- файл, содержащий только пустые строки.
Kaждый юнит-тест потребует создания в нашем Go-проекте какого-то файла. Чем сложнее функция, тем больше случаев нужно протестировать и тем больше файлов мы создадим. В некоторых случаях может потребоваться создать десятки файлов, и процесс станет неуправляемым.
Кроме того, эту функцию нельзя переспользовать. Например, если бы пришлось реализовать ту же логику, но подсчитать количество пустых строк при HTTP- запросе, пришлось бы продуслировать основную логику:
func countEmptyLineslnHTTPRequest(request http.Request) (int, error) { scanner := bufio.NewScanner(request.Body) // Konnyertea ta xe camaa noruka }
Oдин из способов преодолеть эти ограничения — сделать так, чтобы функция принимала *bufio.Scanner (вывод, возвращаемый bufio.NewScanner). Обе функции имеют одинаковую логику с момента создания переменной scanner, поэтому такой подход будет работать. Но в Go идиоматический способ — начать с абстракции считывания данных.
Напишем новую версию функции countEmptyLines, которая вместо этого получает абстракцию io.Reader:
| Получение io.Reader | func countEmptyLines(reader io.Reader) (int, error) { | scanner := bufio.NewScanner(reader) | for scanner.Scan() { // ... | } | } |
Поскольку bufio.NewScanner принимает io.Reader, мы можем напрямую передать переменную reader.
В чем преимущества такого подхода? Прежде всего, эта функция абстрагируется от источника данных. Он является файлом? HTTP- запросом? Входом сокета? Для функции это не важно. Поскольку *os.File и поле Body в http.Request реализуют io.Reader, мы можем переспользовать одну и ту же функцию не- зависимо от типа ввода.
Еще одно преимущество связано с тестированием. Мы упоминали, что создание файла для каждого теста может стать громоздкой процедурой. Теперь, когда countEmptyLines принимает io.Reader, мы можем реализовать юнит-тесты, создав io.Reader из строки:
func TestCountEmptyLines(t *testing.T) { emptyLines, err := countEmptyLines(strings.NewReader( ) \foo bar baz \)) // Логика теста}
Bэтом тесте мы создаем io.Reader,используя strings.NewReader напрямую из строкового литерала. Nostomy he hyxko coszabatb dna kaxdoro tecta cbul qaul. Kaxblii tect- kenc moket 6bits abtohomhым, vto ylywiaet vuta6elhocbts u ylo6ctbo conpoboxdenua, nockolsky he hyxko otkpibats apyrol qaul, vto6bi ybuJetb coJepxkumoe.
Icnolb3obahue umehu qaula b kavectbe bxonbix dhanbix yhkuinu dna vtenur us qaula b oJbIunhctbe cJyvaeb cJedyet pacmatpubatb kak kod c ylnkom (3a uckJioVenuem onpeJeeJenuhbx yhKuu, hanpumep os.Open). Mbl ybuJenu, vto oTO yCJoxkner tOHunt- tecta, nockolsky moket notpe6obatca coszatb heckOJb- ko qaulob. To takxke cHukaeBosMoxHocbts MHoroKpatHoro ncnolb3obahur yhKuu (xota he bce yhKuu npeJhashavehb dria takoro ncnolb3obahur). IcnoJb3obahue HntrpeJblica io.Reader a6cTpanpyer hctovnnik dnnhax. Hc3a bucHmo ot toro, aBJIHocra JI bXODHbie dahnbie qaulom, ctpokol, HTTP- sanpcOm uJiu sanpcOm gRPC, yhKuuo c takol peaJn3aHuei moxho nepencnolb3obatb u JERko npotecupobartb.
B nocJedhem pa3JeeJtouT rJabbi o6cyJum tunnuyHyo oHn6fKy, cB33aHHyio c onepa- torpom defer: kak bHyHcJHIOCTa apryMeHTb1 yhKuu/mToJa u nOJyVaeJiu MetOJa.
# 6.6. OlluBKA #47: UTHOPUPOBATb TO, KAK BbIHHCJROTCa APryMeHTbI u NOJyVATEJIN ONEPATOPA DEFER
B npeJbIyJIIem pa3Jee Mbl roBopuJiu, vTO onepatop defer saJepxKubaeT bbl- noJHeHHe bbl3Oba J0 Tex nop, noka okpyxaIOUa4 yhKuu he bephet pe3yJIbTaT. Go- pa3pa6oTyHku He bcrJJa noHmMaIOT, kak bHyHcJrIHTc4 apryMeHTbI. YrJy6HmC4 b sTy np6oJemy b JbYx noJpa3JeJax: nepBbIi oTHocHTc4 k apryMeHTaM yhKuuI u MeTOJbO, a BtorpoI cH33aH c noJyVaeJrMm MeTOJbO.
# 6.6.1. Bbivicnenme aprymenToB
HTo6bi nonrts, kak bivicnrotrc aprymenb1 defcr, pacnnotpim npmer. B hem hekar yhknin doxkha bmbibatb abe yhknin - foo bbar. Kpome toro, oha takxe doxkha otcJexknbats ctaryc bHnOJhenuH:
StatusSuccess, ecln u foo u bar he bosbpaiaot hukakix oHn6ok. StatusErrorFoo, ecln bosbpaiaet oHn6ky foo. StatusErrorBar, ecln bosbpaiaet oHn6ky bar.
ByJem ucnoJb3oBaTb sTOT ctaryc dJr heckoJbKHX dEicTbM, hanpmer dJr ybeJOMJehnus dpyroU rOpyTunHbI u dJr HHkpeMeHTa cTeTyHkoB. HTo6bi us6eKaTb noBtorpeHnH sTIX bH3OBOB nepel kaKaJbM onepatropom retun, 6yJem ucnoJb3oBaTb defcr. To nepBaar peaHn3aIaHn:
const ( StatusSuccess $=$ "success" StatusErrorFoo $=$ "error_foo" StatusErrorBar $=$ "error_bar" ) func f() error { var status string defer notify(status) 0TknabbaBt bH3OB notify defer incrementCounter(status) 0TknabbaBt bH3OB incrementCounter if err $\equiv$ foo(); err $! =$ nil { status $=$ StatusErrorFoo YctahabnbaBt 3haVHeMe status B error foo return err } if err $\equiv$ bar(); err $! =$ nil { status $=$ StatusErrorBar YctahabnbaBt 3haVHeMe status B error bar return err } status $=$ StatusSuccess YctahabnbaBt 3haVHeMe status B success return nil }
Chavала Mbl o6bBbJreM nepemennyio status. 3atem oTkJIaJbIBaEM bbl3Obbl notify n incrementCounter c noMoub1o defcr. Hpn bHnOJhneHn bceN sroU qyHnKnHn H b saBucimOCTu OT nyTu bHnOJhenuHn Mbl cootBecTbYIOHnM o6pa3OM o6HOBJIeM status.
Ho ecln nonpo6obatn sanycTUTb bHnOJhneHue sroU qyHnKnHn, Mbl yBuJnM, YTO he3aBucimO OT nyTu bHnOJhenuHn notify u incrementCounter bceTJa bH3bIBaIOTCa c oJHnM u TEM xe ctarycom: nyctar cTpoka. Iovemy?
Baxkho nonять oJhy beHb o bHyHcJehHnI apyMeHTOB bYHKHnIu defe: OHH bHyHcJJIHOTcA cpa3y, a He nocIE BoSBpaTa OKpyKaIOIeI bYHKHnI. B HaIHeM nIpMepe MbI bHbIHbAeM notify(status) u incrementCounter(status) KaK OTJIOXeHbIe bYHKHnI. CJIeIOBaTeJIbHO, Go OTJIOXeHt bHIOJIHeHHe 3TIX bH3OBOB IO TOIO MOMeHTa, KaK f bepHHeTcA c TeKyHnI M HaeHeHeM stAtus Ha 3Tane, Ha KOTOpOM MbI cHIOJIb3OBaJIu defeR, nepeJaB TaKHM b6pa3OM nIcyTyIO cTpOKy. KaK peHnIb 3Ty nPO6JIeMy, eCHn MbI xOTHM nPOIOJIbXaTAT hCIOJIb3OBaTb defeR? ECTb JBa cIOXO63.
IepBoe peIHeHHe - nepeJaTb cTpOKOBbIb yKaBaTeJIb bYHKHnI M defe:
func f() error { var status string YkaBaTeJIb Ha cTpOKy nepeJaeTcA defeR notify(&status) bYHKHnI nOITy KaK apryMeHT defeR incrementCounter(&status) YkaBaTeJIb Ha cTpOKy nepeJaeTcA // JaJee koa bYHKHnI OCTaTeTcA Heu3MeHHebIM bYHKHnI incrementCounter if err $\equiv$ foo(); err $! =$ nil{ kaKaIyMeHT status $=$ StatusErrorFoo return err } if err $\equiv$ bar(); err $! =$ nil{ status $=$ StatusErrorBaR return err } status $=$ StatusSuccess return nil }
IpoJIOJIKaeM o6HObJIaTb stAtus B sBaHcHmOCTu OT cJIyIaR, HO TeIePb nOITy u incrementCounter nOJIyvaIOT B KaHeCTBe apryMeHTa yKaBaTeJIb Ha cTpOKy. IOyMeY oTOT nOJIxoJ pa6OraTeT?
Ipu cHIOJIb3OBaHnI defeR apryMeHTb bHyHcJIJIHOTcA cpa3y - - B JaHHOM nIpMepe OHn aBaJIaOTcA aJpeCeOM stAtus. Ja, cama nepeMeHHea status u3MeHeTcA Ha nPOTOXeHeHnI bHIOJIHeHHe HbYHKHnI, HO eee aJpec OCTaTeTcA Heu3MeHHebIM bHe sBaHcHmOCTu OT TOIO, YTO nPOIOXOJIHr. CJIeIOBaTeJIbHO, eCHn nOITy uJIu incrementCounter cHIOJIb3yET 3HaHeHeHe, Ha KOTOpOe cCbJIaTeTcA yKaBaTeJIb cTpOKu, OHu bIyJIyT pa6OraTb TaK, KaK u OXHJIaJIoCb. Ho oTO peIHeHe tpe6yET u3MeHeHeHnI cHInaTypBb IByx bYHKHnI, YTO He bCeTJa BO3MOXHeO.
ECTb u Jpyroe peIHeHe: bH3OB sAmbKaHnI KaK OHepaTOpa defeR. HaIOmHHeaM, YTO sAmbKaHeHe (closune) - - oTO aHOHHmHaa bYHKHnI, KOTOpa cCbJIaTeTcA Ha nepeMeHHebIe bHe cBOeTO TeJIa. ApyMeHTbI, nepeJIaHHbIe B bYHKHnI0 defeR, bHyHcJIJIHOTcA cpa3y. HO MbI OJIJIHbI 3HaTb, YTO nepeMeHHebIe, Ha KOTOpHe cCbJIaTeTcA sAmbKaHeHe defeR, bHyHcJIJIHOTcA bO bpeMea bHIOJIHeHeHnI sAmbKaHeHe (cJIeIOBaTeJIbHO, KOJIa BO3- bpaIIaTeTcA OKpyKaIOIaHa bYHKHnI).
Bot пример, показывающий работу замыкания defer. Оно ссылается на две переменные, одна из которых аргумент функции, а вторая — переменная вне ее тела:
3десь замыкание использует переменные i и j. i передается как аргумент функции, поэтому вычисляется немедленно. И наоборот, j ссылается на переменную вне тела замыкания, поэтому вычисляется при выполнении замыкания. Если запустить этот пример, будет выведено 0 1.
Следовательно, можно использовать замыкание для реализации новой версии нашей функции:
3десь мы включаем вызовы notify и incrementCounter в замыкание. Это замыкание ссылается на переменную status вне своего тела. Таким образом, status вычисляется после выполнения замыкания, а не при вызове defer. Это решение также работает и не требует изменения сигнатуры notify и incrementCounter.
A что происходит при применении defer к методу с указателем или получателем значения? Давайте посмотрим.
# 6.6.2. Получатели значений или указателей
При разборе ошибки #42 (не знать, какой тип получателя использовать) мы говорили, что получатель может быть либо значением, либо указателем. Та же логика, связанная с оценкой аргумента, применяется, когда мы используем defer в методе: получатель вычисляется сразу. Разберем последствия применения обоих типов получателей.
Bo- nepbых, bot пример, b kotopom bbi3biaetcr metoB npimeheninu k no/ya- te/no 3ha/ehna c ncno.b3obanheM defe, ho bno.c/edcbnun no/ya/ate/5 me/retcr:
func main(){ s := Struct{id:"foo"} defer s.print() s bbi4cnaetcr heme/ae/ehho s.id $=$ "bar" o6hobenne s.id (hebndmoe) type Struct struct{ id string } func s Struct) print{ fmt.Println(s.id) foo }
Mbi otk/aa/baem bbiio/ihne bbi3oba meto/a print. Kak u B ci/yaee c aprymenrtamu, bbi3ob defe/ heme/ae/ehho bbi4cnc/ret no/ya/ate/ra. C/edobat/eb/ho, defe/ 3a/epx/ka/ eat bbiio/ihne heme/aa co ctpyktypou, kotopar co/epx/ant no/e id, pabhoe foo. No3tomy stot kool bbi/ob/ut foo.
H aao6opot, eciu ykaaate/ia rba/raetcr no/ya/ate/em, notehnualb/bie usmenehnia no/ya/ate/ra noc/ee bbi3oba defe/ 6y/yt b/udhbi:
func main(){ s- yka3at/eb, no3tomy oh bbi4cnaetcr heme/ae/ehho, ho mo/ke/ s := &Struct{id:"foo"} cbi/na/ta/ca ha ap/ry/io nepemeh/yno p/h bbiio/ihne/nu meto/aa defe/ defe/ s.print() s.id $=$ "bar" 06hobenne s.id (b/udmoe) } type Struct struct{ id string } func s \*Struct) print{ fmt.Println(s.id) bar }
No/ya/ate/la s tak/ke bbi4cnc/retcr heme/ae/ehho. O/ha/ko b/3ob meto/a npu- bo/ut k konupobahnino no/ya/ate/ra yka3at/er. C/edobat/eb/ho, usmenehnia, bhece/ehh/ie b ctpyktypy, ha kotopyo ccb/la/etcr yka3at/eb, b/udm/bie. 3tot ko/ b/ba/oz/ut bar.
Kor/aa bbi bbi3biaeM defe/ B npimeheninu k b/ynk/uu u/ni meto/ay, ux aprymenhbi bbi4cnc/raotcr heme/ae/ehho. Eciu bbi no/toM 3axotim usmenin/ta 3a/abaeMbie nepe/ defe/ aprymenhbi, bbi mo/keM ncno/53obat/ b yka3at/er/ u/ni 3a/b/ka/na. 3/la meto/aa cpa/3y bbi4cnc/retcrs u no/ya/ate/la. C/edobat/eb/ho, no/be/ge/ne 3a/b/cut ot toro, rba/raetcr no/ya/ate/la sha/ae/neM u/ni yka3at/etem.
# UTOFU
- Peiение об использовании получателя значения или указателя должно приниматься на основе его типа, необходимости изменения, наличия поля, которое не может быть скопировано, и размера объекта. Если сомневаетесь, используйте получатель указателя.
- Применение именованных параметров результата может быть эффективным способом улучшить читаемость функции/метода, особенно если несколько параметров результата имеют один и тот же тип. В некоторых случаях этот подход также может быть удобен, так как именованные параметры резуль-тата инициализируются нулевым значением. Но будет очень внимательны в связи с возможными побочными эффектами.
- В случае возврата интерфейса будете очень внимательны, если потребуется возвращать его нулевое значение: надо вернуть не нулевой указатель, а явное значение nil. В противном случае могут возникнуть непредвиденные по-следствия, поскольку вызывающая сторона получит значение не nil.
- Определение функций, которые получают типы 1c.Reader вместо имен файлов, повышает шансы на возможность переспользования этих функций и упрощает тестирование.
- Передача указателя функции defer и перенос вызова внутрь замыкания — два возможных решения, позволяющих обойти ситуацию, при которой аргументы и получатели вычисляются сразу.
# Bэтой главе:
- Korда нужен режим паники- Korда следует оборачивать ошибку- Эффективное сравнение типов и значений ошибок, начиная с версии Go 1.13- Идиоматическая обработка ошибок- Как следует игнорировать ошибку- Обработка ошибок в вызовах defer
Обработка ошибок - это фундаментальный аспект создания надежных и наблюдаемых приложений, и этот аспект должен быть столь же важным, как и любая другая часть кода. В Go, в отличие от большинства языков программирования, обработка ошибок не основывается на традиционном механизме try/catch. В Go ошибки обрабатываются с помощью возвращения значения ошибки вместе с другими значениями из функции.
В этой главе рассмотрим распространенные проблемы, связанные с обработкой ошибок.
# 7.1. OWWKA #48: NAHUKA
Havunaioine Go- paspaotyniku vacto nytaiotcs o opaaotke oonuok. B Go oonuoku obivho opaaatbiaaotcs dyhkuimu uin metoamu, kotopae b kaecetbe cbeoro noclednero napametpa bosbpaiaot tun error. Ho hekotorpbi m paspaotynikam takoui nodoxl moxet nokasatcsa heoxnulahnbiM, u y Hux boshukhet co6lash o6- pa6otats ouu6kic nomoaia panic n recove - tak xe, kak ato zonatcsa B Jav3 uJiu Python. Ocbexkum npectabления o konцепiiu nahnku u o6cydum, korza nаниkobatcs cunitaetcs ymecthbiM.
B Go panic - - oTO bCTpOehna dyhKlir, kotopar octahabJbuaet o6bivhbiN notok:
func main() { fmt.Println("a") panic("foo") fmt.Println("b")}
TOT KOL BIBOJUT a, a sATeM OCTaHABJbAaetcs nepeJ BIBOJOM b:
a panic: foo
goroutine 1 [running]: main.main() main.go:7 +0xb3
IOcne sanycka naiHKu oHa npoJoJxAaetcsa BBePX NO cTEKY Bb3OBOB AO Tex nop, noka Jn6o he npou3oJlJet BosBpar H3 tekYlIeI rOpyTHHb, Jn6o panic He 6yJet nepexbaven c nOMOIIbIb recovery:
func main() { defer func() { Bb3OBbI bocCTaHABJbAaIOTcA bHytpu otJoxehnOro 3aMbIKaHnif r := recover(); r != nil { fmt.Println("recover", r) }}(f) Bb3OBf.kOTopar sanyckaet naiHKy. JaHukafunc f() { fmt.Println("a") panic("foo") fmt.Println("b")}
Korда Bb3bIBaetcsa panic, tekyuIee BbInOJHeHHe BhyHKlIu f octahabJbAaetcsa u dyhKlIu nOJHHMAeTcsa BBePX NO cTEKY Bb3OBOB: B man. IOcKOJbKy naniHKa
перехватывается с помощью recover, в main она не останавливает выполнение горутины:
a recover foo
Bb3oB yHKHn recovery() Jia nepxbata naiHKu rOpyTHHb IIOJeeH ToJbKO bnyTHn yHKHn defeT, b npOrTHbOM CyYaae yHKHn npOCTO bephe nIl n OJeeH Hn Ha YTO He OyJET BJIHTb. To cBraHo c TEM, YTO yHKHn defeT TaKke BbIIOJI- HJIOTcA, KoJIA OKpyKaIOIIaA yHKHn BbIbIBaEt naiHKy.
Teneps noJyMaem, a KoJia yMeCTHO BbI3bIBaTb naiHKy? B Go panic ucnoJIb3yetcA JIA o6O3HaHeHnI IO- HAcTOJIHeMy uckJIOnIHTeJIbHbIX cITyayIu, TaKux KaK OIIu6Ka npOrpaMmucTa. Hanpимер, eJIn Mbl o6paTMMcA K nIakety net/http, To sAmetum, YTO B MeTOJee WriteHeader cETS Bb3oB yHKHn n checkWriteHeaderCode JIA npOBePKH npaBUIbHOCTu KoJIA COCTOaHHnI:
func checkWriteHeaderCode(code int) { if code < 100 || code < 999 { panic(fmt.Sprintf("invalid WriteHeader code %v", code)) }}
Эта функция вызывает панику, если код состояния недействителен, что в чистом виде является ошибкой программиста.
Другой пример, в основе которого лежит ошибка программиста, можно найти в пакете database/sql при регистрации драйвера базы данных:
func Register(name string, driver driver.Driver) { driversMu.Lock() defer driversMu.Unlock() if driver == nil { panic("sql: Register driver is nil") // Паника, если драйвер нулевой } if _, dup := drivers[name]; dup { panic("sql: Register called twice for driver " + name) // Паника, если драйвер уже зарегистрирован }
Эта функция вызывает состояние паники, если драйвер равен nil (driver. Driver — это интерфейс) или уже зарегистрирован. Оба случая снова будут считаться ошибками программиста. Кроме того, в большинстве случаев (например, с go- sql- driver/mysql (https://github.com/go- sql- driver/mysql), самым популярным драйвером MySQL для Go) Register вызывается через функцию init,
что ограничивает возможности по обработке ошибок. По всем этим причинам разработчики сделали так, что в случае ошибки функции вызывает панику.
Другой случай паники — когда наше приложение требует зависимость, но ее не удается инициализировать. Допустим, мы предоставляем сервис для создания новых учетных записей клиентов. На каком-то этапе сервису нужно проверить действительность введенного адреса электронной почты. Чтобы реализовать это, мы решаем использовать регулярное выражение.
В Go пакет regexp предоставляет две функции для создания регулярного выражения из строки: Compile и MustCompile. Первая возвращает *regexp. Regexp и ошибку, а вторая возвращает только *regexp. Regexp, но в случае ошибки вызывает панику. В этом случае регулярное выражение — обязательная зависимость. Действительно, если не удастся его скомпилировать, мы никогда не сможем проверить правильность введенного адреса электронной почты. Поэтому следует использовать MustCompile и в случае обнаружения ошибки вызвать панику.
Панику в Go следует использовать с осторожностью. Мы рассмотрели два важных случая: в одном из них должен возникнуть сигнал об ошибке программиста, а в другом приложение не может создать обязательную зависимость, то есть когда налицо появление неких исключительных ситуаций, которые должны заставить нас остановить приложение. В большинстве других случаев управление ошибками должно выполняться с помощью функции, которая возвращает правильный тип error в качестве последнего возвращаемого аргумента.
Приступим к обсуждению ошибок. В следующем разделе посмотрим, когда следует оборачивать ошибку.
# 7.2. ОШИБКА #49: ИГНОРИРОВАТЬ ОБОРАЧИВАНИЕ ОШИБКИ
Начиная с Go 1.13, директива %и позволяет удобно оборачивать ошибки. Но не всегда понятно, когда это нужно делать, а когда нет. Вспомним, что такое оборачивание ошибок и когда его использовать.
Оборачивание — это упаковка ошибки внутри контейнера-обертки, который делает доступной и исходную ошибку (рис. 7.1). Есть два основных сценария использования оборачивания ошибок:
- добавление дополнительного контекста к ошибке;- маркировка ошибки как специфической.
Puc.7.1. O6opavuBAnHe OMMOOK
PaccmOrpum npmep. Mbl noJyvаем saipoc ot konkpeTHTO oJy3OBaTeJa Ha JocTyn K pecypcy 6a3M JaaHbix, HO BO BpeMra saipoca noJyvаем ommcky «Otkas B JocTynE» (<pepmissio n denied>). EcJm uHpopmaHn 66 stou omm6ke saHocuTcA B kakou- to xypnJ, To JJ3a nJenuotJJaJku hyxho J66aHnTb JOnOJHHnTeJbHbHbH bKOHTEKCT. B JTOm CJyvae Mbl MOxEM O6epHytb omm6ky, To66bI yKasATb, KTO 6bl I nOJb3OBaTeJEM u K kaKOMy pecypcy 6bl J saipoc (puc. 7.2).
Puc.7.2. J66aBNeHHe OJOnJHHnTbHbHOrO KOHTEKCTa K OMM6ke «Otkas B JocTynE»
Teneps npednJIOJoxkM, VTO BMeCTO JO6aBJeHnI KOHTEKCTa Mbl XOTUM nOMeTHTb Omm6ky. HaIpnMEp, peJausOBaTb O6pa6OTyHk HTTP, kotopbHb npOBeprEeT, Bce JII Omm6ku, nOJyVehHbIe npu Bb3OBE qyHKHnHbI, OTHOcCTcA K THIy FOrbidden, YTO6bI MoxHO 6blJIO BepHytb KOI COCTO3HnHn 403. B JTOm CJyvae Mbl MOxEM O6epHytb JTy Omm6ky BHyTpu Forbidden (puc. 7.3).
Puc.7.3. MapKupOBKa OMM6Ku Forbidden
B o6OuX CJyvaxx uJcXOHaax Omm6ka oCTaTeCTa JOCTyHnOH. CJJeIOBaTeJbHO, Bb3bI- baIOIIaA cTOpoHa TaKxE MOxEk O6pa6OTaTb Omm6ky, pa3BepeHyB ee u nepenPOBepeHb uCTOyHnK Omm6ku. O6paTUTE BHHMaHnE, VTO HHOrJa eCTb CMbCI uCnOJb3OBaTb O6a nOJXOJa: u JO6aBJIaTb KOHTEKCT, u MapKupOBaTb Omm6ky.
Teneps, koTa Mbl BbIaCHnJJI OCHOBHbIe CJTyaIIHn, B KotOpbIX MOxHO uCnOJIb- 3OBaTb O6opavuBaHnHe Omm6ku, nOcMOrTpUM Ha cJIOCO6bI BO3BpaTa nOJyVehHnOH HAMu Omm6ku. PaccmOrpUM qpaRMHeT KOJa u H3yVHM OmmHn BHyTpu 6JIOKa if err $! =$ nil:
func Foo() error { err := bar() if err != nil { //? ← Как возвратить ошибку? } //...}
Первый вариант — вернуть эту ошибку напрямую. Если мы не хотим ее пометить и понимаем, что нет какого- либо полезного контекста, который имеет смысл добавить, то такой подход вполне подойдет:
if err != nil { return err}
На рис. 7.4 показано, что мы возвращаем ту же ошибку, что и bar.
Pис. 7.4. Мы можем вернуть ошибку напрямую
До версии Go 1.13 для оборачивания ошибки единственным вариантом без применения внешней библиотеки было создание пользовательского типа ошибки:
type BarError struct { Err error}func (b BarError) Error() string { return "bar failed:" + b.Err.Error()}
Затем вместо прямого возврата err мы обернули эту ошибку в BarError (cm. рис. 7.5):
if err != nil { return BarError{Err: err}
Преимущество такого варианта в гибкости. Поскольку BarError — это пользо- вательская структура, то при необходимости мы можем добавить в нее любой
дополнительный контекст. Но если потребуется повторить эту операцию, то необходимость создавать специфический тип ошибки может загрязнить код.
Для решения этой проблемы в Go 1.13 появилась директива %w:
if err != nil { return fmt.Errorf("bar failed: %w", err)
Этот код оборачивает исходную ошибку, чтобы можно было добавлять дополнительный контекст без необходимости создания другого типа ошибки (рис. 7.6).
Pис. 7.6. Оборачивание ошибки в стандартную ошибку
Поскольку исходная ошибка остается доступной, клиент может развернуть родительскую ошибку, а затем проверить, относится ли исходная ошибка к какому-либо специфическому типу или значению (эти вопросы обсуждаются в следующих разделах).
Последний вариант, который мы обсудим, — использование директивы %v:
if err != nil { return fmt.Errorf("bar failed: %v", err)
Отличие заключается в том, что сама ошибка не обернутая. Мы преобразуем ее в другую ошибка, чтобы добавить контекст, и исходная ошибка становится недоступной (рис. 7.7).
Информация об источнике проблемы остается доступной. Но вызывающая сторона не может развернуть эту ошибку и проверить, была ли источником всех неприятностей bar error. Так что в некотором смысле эта оция носит более ограничительный характер, чем %w. Нужно ли предотвращать такие ситуации, поскольку стала доступной директива %w? Не обязательно.
Оборачивание ошибки делает исходную ошибку доступной для вызывающей стороны. Следовательно, это означает введение их потенциальной связки.
IpeJctabBte, UTO Ml ucnoJb3yEM o6eprtky, u Bb3bIBaIOIaF Foo corOpa IpoBeprEt, aBJIaTeCA JII bAr eeroT uCXOJHOuOIIu6KOI. A UTO, eCJIu Ml b3MeHm KOI u Boc- IONb3yEMcA JpyroU OyHkIuE, KotOrpa 6yIET BO3BpaIIaTb JpyroU TII OIIu6Ku? To 6yIET HapyIIaTb IpOHeJyIy TpoBeprKu OIIu6K, cJelJaHHyIO b33aIBaIOIuEi cTOPOHOu.
HTo6bI y6eJHTbca, UTO HaIIu KJIHeHTb He IONJIaIOTcA Ha TO, UTO Ml cYHTaEM JEdTaJIaMn peaJI3aIIII, BO3BpaIIaEMaA OIIu6Ka JOLJKHa 6bIb IpeO6pa3OBaHa, a He o6eprhyTa. B tAKOM CJyY4e BMEcTO %W MOXHO ICIIOJIb3OBaTb %V.
IOJbEeJEM IpOMeXyTOHbIHbI UTOr STIX bapuaHTOB.

Table (html):
<table><tr><td>Bариант/случай</td><td>Дополнительный контекст</td><td>Пометка ошибки</td><td>Исходная ошибка доступна?</td></tr><tr><td>Bosbрат ошибки напрямую</td><td>Нет</td><td>Нет</td><td>Да</td></tr><tr><td>Пользовательский тип ошибки</td><td>Возможен (например, если тип ошибки содержит стро-ковое поле)</td><td>Да</td><td>Возможно (если исходная ошибка экспортируется или доступна через метод)</td></tr><tr><td>fmt.Errorf c %w</td><td>Да</td><td>Нет</td><td>Да</td></tr><tr><td>fmt.Errorf c %v</td><td>Да</td><td>Нет</td><td>Нет</td></tr></table>
Ipu o6pa6otke OIIu6Ku Mb MoXeM o6eprHyTb ee. O6eprTka - 3TO JODaBJeHHe K OIIu6Ke JIOIOJIHTeJIbHOro KoHTeKcTa u/IJIu ee MapKupOBKa KaK cIeIIuIbIyHeCKOu: eCJIu HYXHO IOMeTHTb OIIu6Ky, Mb JOLJKHbI cO3JIaTb JJIa Hee co6CTeHbIHbI TII. Ho eCJIu HYXHO JIIIIb JODaBJTb JIOIOJIHTeJIbHbIbI KONTeKCT, TO cJeJyET ICIIOJIb- 3OBaTb fmt. Errorf c JIpeKtUBOu %w, tAK KaK Ipu 3TOM He HYXHO cO3JIaBaTb HOBbIb TII OIIu6Ku. TeM He MeHee Ipu o6opaYbBaHIIu OIIu6K cO3JIaTeCA IOTeHIIaJIbHaa cB33b, nOCKOJIbKy IcXOJIHaa OIIu6Ka CTaHOBHTcA JOCTyIHOU JJIa b3bIBaIOIIuEi cTOPOHbI. EcJIu HaJIo IpeJOTbPaIHTb 3TO, TO cJeJyET ICIIOJIb3OBaTb He o6eprky, a IpeO6pa3OBaHHe OIIu6Ku, HaIIpMEp fmt. Errorf c JIpeKtUBOu %v.
B 3TOM pa3JeTe a nOKa3aJI, KaK o6eprHyTb OIIu6Ky c IOMOIIbIb JIpeKtUBbI %w. Ho KaK ee ICIIOJIb3OBaHHe IOBbIHHeT Ha IpOBeprKy TIIa OIIu6Ku?
# 7.3. OIIU6KA #50: HETO4HAAR IPOBEPKA TUNA OIIU6KU
B IpeJbIyIIIeM pa3JeTe Mb paCcMOrTeJIb BO3MOXHbIb CIIOCO O6OpaYbBaHHe OIIu6K c IOMOIIbIO JIpeKtUBbI %w. Ho Ipu ICIIOJIb3OBaHHe 3TOro IOJXOJIa BaxXHO 33MeHHTb
и способ проверки типа ошибки на его специфичность, иначе обработка ошибок может оказаться неточной.
Рассмотрим пример. Напишем обработчик HTTP для возврата суммы тран- закции по идентификатору (ID). Обработчик будет анализировать запрос, чтобы получить данные об этом ID и информацию о сумме из базы данных (БД). Реализация такой функции может приводить к ошибкам в двух случаях:
- Если ID недействителен (длина строки не равна пяти символам).- Если запрос к $\mathsf{B}\mathsf{I}$ не удался.
B первом случае мы хотим вернуть StatusBadRequest (400), a во втором - ServiceUnavailable (503). Для этого создадим тип transientError, чтобы подчеркнуть, что ошибка временная. Родительский обработчик проверит тип ошибки. Если ошибка будет иметь тип transientError, обработчик вернет код состояния 503, в противном случае — код 400.
Сосредоточимся на определении типа ошибки и функции, которую будет вызывать обработчик:
type transientError struct { err error 1 Cоздaetca nonabOaTeIbckN func (t transientError) Error() string { Tm transientError return fmt.Sprintf("transient error: %v", t.err) 1 func getTransactionAmount(transactionID string) (float32, error) { if len(transactionID) $! = 5$ { return 0, fmt.Errorf("id is invalid: %s", transactionID) Bo3Bpat npoctnouwnuk, cnn ID tpa33aKun heqedctbunlen 1 amount, err : $=$ getTransactionAmountFromDB(transactionID) if err $! =$ nil{ return 0, transientError{err: err} Bo3Bpat ouwnk u tma transientError 1 return amount, nil 1
getTransactionAmount возвращает ошибку, используя fat. Errorf, если ID не- действителен. Но если получить данные о сумме транзакции из $\mathsf{B}\mathsf{I}$ не удается, getTransactionAmount оборачивает ошибку в тип transientError.
Теперь напишем код HTTP- обработчика, который проверяет тип ошибки и воз- вращает соответствующий HTTP- код состояния:
PpmeHr oeparop switc K tuny ouinoku, Mb Bospaiaem cootbetrctbyioii Koo coctorHnH HTTP 400 B cJyVae heyaau opaHnHn K D n 503 B cJyVae nepexoarHnHn ouinoku
TOT KoB BnOJHe paOCTOcTOCOOeH. Ho npeDnOJOKHM, YTO HYXHO BbInOJIHHTb He6OJIbInO" peDpaktopnHr getTransactiOnAmount. Tenepb TransientError 6yLet Bo3- BpaHnATbC4 DyHKInHnI getTransactiOnAmountFronDB BMEcTO getTransactiOnAmount. При этом getTransactiOnAmount o6opavaHbAet yTy ouin6Ky c nOMOIIbIO dIpeK- TUBbI %w:
EcJiu Mbi Sanyctum otot Koa, OH BcerJa Bephet 400, He3aBicMmo ot TnIa ouin6Ku, NoSTOMy cJyvaH, KoJaa dOJIKHA 6bIb BbIaHa TransientError, HukorJa He NoSBHTcR. Kak o6bяснить takoe новедение?
Перед редакторингом функция getTransactionAmount возвращала transientError (рис. 7.8). После же него transientError возвращается функцией getTransactionAmountFromDB (рис. 7.9).
B данном случае transientError: true
Pис. 7.8. Поскольку transientError возвращался функцией getTransactionAmount, в случае сбоя в обращении к базе данных его значение было true
B данном случае transientError: false
Pис. 7.9. Теперь getTransactionAmount возвращает обернутую ошибку, поэтому transientError принимает значение false
To, что возвращается в результате выполнения getTransactionAmount, не является непосредственно transientError: это обертка ошибки transientError. Поэтому в данном случае transientError принимает значение false.
Именно для этой цели в Go 1.13 появилась директива для оборачивания ошибок и способ проверки того, относится ли обернутая ошибка к некоторому определенному типу, с помощью errors. As. Эта функция рекурсивно разворачивает ошибка и возвращает true, если какая-то ошибка в цепочке соответствует ожидаемому типу.
Перепишем нашу реализацию вызова с помощью errors. As:
func handler(w http.ResponseWriter, r *http.Request) { // Получение ID транзакции amount, err := getTransactionAmount(transactionID) // Визов етегs.As с указателем if err != nil { // Визов етегs.As error() { // Визов етегs.As в качестве аргумента
Bновой версии кода мы избавились от случаев с типом switch и теперь используем errors.As. Эта функция требует, чтобы второй аргумент (целевая ошибка) был указателем. В противном случае функция будет компилироваться, но вызывать ланику во время выполнения. Независимо от того, является ли ошибка, проявляющаяся во время выполнения, непосредственно типа transientError или ошибкой, обертывающей transientError, функция errors.As возвращает значение true. Следовательно, наш обработчик вернет код состояния 503.
Если мы используем оборачивание ошибок в среде Go 1.13, следует использовать errors.As, чтобы проверить, относится ли ошибка к какому-то определенному типу. Независимо от того, возвращается ли ошибка непосредственно функцией, которую мы вызываем, или обернутой внутрь какой- то еще ошибки, errors.As сможет рекурсивно развернуть основную ошибку и посмотреть, относится ли одна из ошибок к какому-либо определенному типу.
Мы только что увидели, как сравнивать типы ошибок. Теперь поговорим о сравнении значений ошибок.
# 7.4. ОШИБКА #51: НЕТОЧНАЯ ПРОВЕРКА ЗНАЧЕНИЯ ОШИБКИ
Этот раздел аналогичен предыдущему, но здесь речь пойдет о сигнальных ошиб- ках (значениях ошибок). Сначала мы определим понятие сигнальных ошибок. Затем увидим, как сравнить ошибку с каким-то значением.
Сигнальная ошибка (sentinel error) — это ошибка, определенная как глобальная переменная:
import "errors" var ErrFoo = errors.New("foo")
B общем случае принято начинать с Err, за которым следует тип ошибки: здесь ErrFoo. Сигнальная ошибка сообщает об ожидаемой ошибка. Но что мы пользу- меваем под этим? Обсудим это в контексте библиотеки SQL.
Мы собираемся создать метод Query, который позволит выполнять запрос к базе данных. Этот метод возвращает срез строк. Как поступить в случае, когда строки не найдены? Есть два варианта:
- Вернуть контрольное значение, например нулевой срез (вспомните о strings. Index, который возвращает контрольное значение
- 1, если подстрока от-сутствует).- Вернуть конкретную ошибку, которую клиент может проверить.
Рассмотрим второй подход: метод может возвращать конкретную ошибку, если не найдено никаких строк. Мы можем классифицировать это как ожидаемую ошибку, потому что допускается передача запроса, который не вернет какие- либо строки. И наоборот, такие ситуации, как проблемы с сетью и ошибки при подключении и опросе соединения, являются непредвиденными ошибками. Это означает не то, что мы не хотим обрабатывать непредвиденные ошибки, а то, что семантически эти ошибки несут в себе разные смыслы.
Если посмотреть на стандартную библиотеку, можно найти много примеров сигнальных ошибок:
- sql.ErrnNoRows
- возвращается, когда запрос не возвращает ни одной строки (что как раз соответствует нашему случаю).- io.EOF
- io.Reader возвращает эту ошибку, когда больше нет доступных входных данных.
Это общий принцип, стоящий за сигнальными ошибками. Они сообщают об ошибках, которые можно ожидать заранее и наличие которых, как предполага- ется, клиенты будут проверять. Поэтому в качестве общих указаний:
- Ожидаемые ошибки должны быть сделаны в виде значений (сигнальных ошибок): var ErrFoo = errors.New("foo").- Непредвиденные ошибки должны быть оформлены как типы ошибок: type BarError struct { ... }, где BarError реализует интерфейс error.
Вернемся к обсуждению типичной ошибки. Как мы можем сравнивать ошибку с каким-то конкретным значением? Используя оператор $= =$ :
err := query() if err != nil { if err == sql.ErrNoRows { // ...} else { // ...}
3десь мы вызываем функцию query и получаем ошибку. Проверка того, является ли она ошибкой sql.ErrNoRows, выполняется с помощью оператора $= =$ . Но как говорилось в предыдущем разделе, сигнальную ошибку также можно обернуть. Если sql.ErrNoRows обернут с использованием fmt.Errorf и директивы %w, err $= =$ sql.ErrNoRows всегда будет принимать значение false.
Go 1.13 дает на это ответ. Мы видели, как errors. As используется для проверки типа ошибки. В случаях со значениями ошибок мы можем использовать его аналог: errors. Is. Перепишем предыдущий пример:
err := query() if err != nil { if errors.Is(err, sql.ErrNoRows) { // ... } else { // ... }
Использование errors. Is вместо оператора $= =$ позволяет выполнять сравнение, даже если ошибка обернута с помощью %w.
Таким образом, если в приложении мы используем обертку ошибок с помощью директивы %w и fmt. Errorf, проверка ошибки на ее равенство определенному значению должна выполняться с использованием errors. Is, a не $= =$ . И даже если сигнальная ошибка обернута, errors. Is может рекурсивно ее развернуть и сравнивать каждую ошибку в цепочке с заданным значением.
Теперь обсудим один из наиболее важных аспектов обработки ошибок: не об- работывать ошибку дважды.
# 7.5. ОШИБКА #52: ДВОЙНАЯ ОБРАБОТКА ОШИБКИ
Многократная обработка программных сбоев — это оплошность, которую часто допускают разработчики, и это не какая-то особенность Go. Разберемся, почему это может стать проблемой и как эффективно обрабатывать ошибки.
Hаниiem kod yhkuin GetRoute dria pacveta mariyta, ottaikuaas ot nap koopdunat havaibhouin kohewhoui toyek. Ppednoloxkum, vito sta yhkuin dyet bIsbIbats hekcncopriupobahnyo yhkuino getRoute, coJepKaIIyIy oJshec- JorIky dria pacveta ontnmababno maripyta. Ieped bIs3OBOM getRoute Mbl JOLXHbI npO- berutb koopdunatbI hcxOJHOHbI kohewHOHbI TOyek, ucnoJIb3yIa validateCoordinates. Mbl takxke xotum, vTObbl BosMOxHbIe onuu6ku pericrtpupoBaJIcB b xyphaJIe. Pea- Jus3HunM oXKET bHrJIaJIeTb TAK:
func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) {err := validateCoordinates(srcLat, srcLng)if err != nil { log.Println("failed to validate source coordinates") // Peructpaua u BosBpat return Route(), err } err = validateCoordinates(dstLat, dstLng) if err != nil { log.Println("failed to validate target coordinates") // Peructpaua u BosBpat return Route(), err } return getRoute(srcLat, srcLng, dstLat, dstLng)func validateCoordinates(lat, lng float32) error { if lat > 90.0 || lat < - 90.0 { log.Printf("invalid latitude: %f", lat) } return fmt.Errorf("invalid latitude: %f", lat) if lng > 180.0 || lng < - 180.0 { log.Printf("invalid longitude: %f", lng) // Peructpaua u BosBpat return fmt.Errorf("invalid longitude: %f", lng) } return nil}
Что не так с этим кодом? Прежде всего, validateCoordinates будет весьма громоздким и неуклюжим из-за повторений сообщений об ошибках invalid latitude или invalid longitude одновременно в журнале и в виде возвращаемой ошибки. Кроме того, если мы запустим код, например, с недопустимым значением широты, то в журнал регистрации ошибок будут внесены следующие строки:
2021/06/01 20:35:12 invalid latitude: 200.000000 2021/06/01 20:35:12 failed to validate source coordinates
Почему наличие двух строк, относящихся к одной ошибке, — проблема? Это усложняет отладку. Например, если рассматриваемая функция вызывается несколько раз в режиме конкурентного выполнения, эти два сообщения могут не следовать в журналах одно за другим, что усложнит процесс отладки.
OHHHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnnnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn
Hepenuiem koll yhnykun nak, vto6bi oha oopaotbna aonuonu tonbko oun pas: func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) { err : $=$ validateCoordinates(srcLat, srcLng) if err $! =$ nil{ return Route{), err ToHbko BosBpat oHnHnK } err $=$ validateCoordinates(dstLat, dstLng) if err $! =$ nil{ return Route{), err ToHbko BosBpat oHnHnK } return getRoute(srcLat, srcLng, dstLat, dstLng) } func validateCoordinates(lat, lng float32) error { if lat $>$ 90.0||lat<- 90.0{ return fmt.Errorf("invalid latitude: %f",lat) ToHbko BosBpat oHnHnK } if lng $>$ 180.0||lng<- 180.0{ return fmt.Errorf("invalid longitude: %f", lng) ToHbko BosBpat oHnHnK } return nil }
B этой версии каждая ошибка обрабатывается только один раз путем ее прямого возврата. Далее, предполагая, что вызывающая сторона GetRoute обрабатывает возможные ошибка, регистрируя их в журнале, код выведет сообщение в случае недопустимого значения широты:
2021/06/01 20:35:12 invalid latitude: 200.000000
Идеальна ли эта новая версия кода? Вовсе нет. Действительно, в случае задания недопустимой широты первая реализация приводила к двум записям в журнале. Но при этом мы знали, какой вызов validateCoordinates был ошибочным: по координатам начальной или конечной точки. Во втором случае эта информация теряется, поэтому нужно добавить к ошибке дополнительный контекст.
Модифицируем последнюю версию кода, используя оборачивание ошибок Go 1.13 (опускаем строки, определяющие функцию validateCoordinates, так как она остается неизменной):
func GetRoute(srcLat, srcLng, dstLat, dstLng float32) (Route, error) { err := validateCoordinates(srcLat, srcLng)
if err $! =$ nil{ return Route{), fmt.Errorf("failed to validate source coordinates: %w", err) Bo3BparO6epHytOnOuM6Kn } err $=$ validateCoordinates(dstLat, dstLng) if err $! =$ nil{ return Route{), fmt.Errorf("failed to validate target coordinates: %w", err) Bo3BparO6epHytOnOuM6Kn } return getRoute(srcLat, srcLng, dstLat, dstLng) }
Kakdara oninoka, Bosbpaiaemar validateCoordinates, teepb o6ephyt a dria noiy- vения donolnhteibnoto kontekcta, a umehho cBraaha c koorduinatamu havaibnou nlin konevhnou toyku. I ecJiu mi saicytum BbinoJnhehne hOoN bepcun, bot vTO 6ydet sapeructpupobaho Ha bH3bbaioIeeI cTOPOHe B cJyhae heBepHOH IIupotbi havaibnou toyku:
2021/06/01 20:35:12 failed to validate source coordinates: invalid latitude: 200.000000
B этой Bepcnu Mbi yJnH Bce cJyvan H cBcHn Hx K OJnOH SanHCH B Kyphnale oninook des noterpu kakou- Jn6o IehnouH HHopmaHnH. Kpome toro, kaxdaa oninoka o6pa6a- tIbIaEcra ToJbIbko oJnH pa3, VTO yIpoIIaEt KOI, no3bOJra H3bEraTb nOBtorpaIOIIuxcA coo6IIeHnU O6 OJHux H Tex Xe OIIu6KaX.
O6pa6otka onin6ku oJ7KHa 6bIb bHInOJHeHa ToJbIbO oJHn pa3. PeructpaHnIa onin6ku - 3TO o6pa6otka onin6ku. CJeIObIaTeJIbHO, HyXHO Jn6o sapeructpupo- BaTb, Jn6o BepHytb onin6ky. DeJaa 3TO, Mbi yIpoIIaEm KOI u oOCTIraEM JyVIIeIO nOHHmAnHn cHtyaHnI c OIIu6KaMn. IcnoJIb3OBaHnHe o6eprtxn onin6ok - hau6oJee yJIO6HbIb nOJXOJ, nOcXOJIbKy nO3bOJIeT yKa3bIBaTb Ha nOcXOJIhyIO onin6ky H JIO6aBJIaTb K OIIu6Ke KOnTeKCT.
B cJeJyI0IIeM pa3JeJe paCcMOTpHM nOJXOJIaHnI cInOco6 HHOrpIPOBaHnI OIIu6ok B Go.
# 7.6. Olln6kA #53: HE BbInOJIHATb O6PA6OTKY Olln6kN
B heKOrOpbIX cJyVaaX tr66yEcTa npoIHHOrpIPOBaTb OIIu6Ky, BosBpaIIaEmyIO yJHK- IIeI. B Go JeJIaTb 3TO HyXHO ToJIbKO OJHNM cInOco6OM.
Paccmotpum npимер, ue bbi3biaem yhkiuio notify, Bosbaapaonnyio eJinctben- hbi a prymeht error. lonyctum, yto ata ommoka hac he nhtrepecyet, nootomy mbi hamepehho onyckaem kakyio- jino o6pa6otky omm6ok:
func f() { // ... notify() 06pa6otka ouu6ku nponyuhea}func notify() error { // ...}
Iocko1bky Mbi xotum npounrhopupobatb omm6ky, To b 3tom npимерe npocto bbi3biaem yhkiuio notify, he npucbaibaa pesy.1brat ee bimno1hehna kJaccucyeckou nepemenhnoi err. C yhkiuiohano1bnoi toyku 3penur b 3tom koDe het hivero nJoxoro: on komnunipyetcra u pa6otает, kak u oKunJalocb.
Ho c toyku 3penur conpoboxJaemocut takoui koJ MoXket bbaBaTa np66Jembi. Nocta- bim ce6a ha mecto yeJловeca, kotopbiu bHjut ero bIepbIe. On sametut, yto notify Bosbpaiaet omm6ky, ho ota omm6ka he o6pa6atbIbaeTcra podutTeJbckou0 yhkiuuei. Kak JoraJatbcsa, yto nponyck o6pa6otku omm6ku 6b11 npeJhamepehHbIM? Kak nonHrth; npedblJyJiiuP a3apa6otyuk npocto 3a6b11 cJelJatb. takyio o6pa6otky uJ11 cJelJal 3to cosHateJbH0?
No3tomy kOrJaa Mbi xotum nIrnopupobatb omm6ky B Go, eCTb ToJbko oJ111 cnooc6 oto6pasutb 3to hamepehine b koDe:
= notify()
BMeCTo toro yTO6b1 bOo6uTe he npucbaibatb 3haYHeHe omm6ku kakoU- Jn6o nepemeho1, Mbi npucbaibaeM ee nyctomy uJentHduIkaTary. C Toyku 3penur komnunJ1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
Takou Koj takxe MoXket conpoboxJaatbcsa kommentrapiem, ho he takum, kak B 3tom npимерe:
// Nrhopupobatb omm6ky = notify()
// Доставка не более одного раза. // Поэтому в случае ошибок некоторые из них допустимо просто пропускать. _ = notify()
Ситуации игнорирования ошибок в Go должны быть исключительными. Во многих случаях лучше предпочтить их регистрацию, пусть даже на низком уровне журнала. Но если вы уверены, что ошибку можно и нужно игнорировать, то делайте это явно, присвоив ее пустому идентификатору. Так читатель когда поймет, что ошибку проигнорировали намеренно.
В последнем разделе этой главы обсудим, как обрабатывать ошибки, возвращаемые функцией defer.
# 7.7. ОШИБКА #54: НЕ ВЫПОЛНЯТЬ ОБРАБОТКУ ОШИБКИ ОПЕРАТОРА DEFER
Отказ от обработки ошибок в операторах defer — это оплошность, которую часто допускают разработчики. Поговорим, в чем тут проблема, и обсудим пути решения.
В примере ниже реализуем функцию для запроса к базе данных, чтобы получить баланс по идентификатору клиента (ID). Будем использовать database/sql и метод Query.
Примечание Не будем вдаваться в работу этого пакета. Поговорим об этом в разделе, посвященном разбору ошибки #78 (типичные ошибки, связанные с SQL).
Вот возможная реализация (фокусируется на самом запросе, а не на разборе результатов):
const query = "...." func getBalance(db *sql.DB, clientID string) ( float32, error) { rows, err := db.Query(query, clientID) if err != nil { return 0, err } defer rows.Close() // Используется rows }
rows — это тип *sql.Rows. On реализует интерфейс Closer:
type Closer interface { Close() error }
Этот интерфейс содержит единственный метод Close, который возвращает ошибку (рассмотрим эту тему при разборе ошибки #79 (не закрывать временные ресурсы)). В предыдущем разделе я говорил, что ошибки всегда должны обрабатываться. Но в этом случае ошибка, возвращаемая вызовом defer, игнорируется:
defer rows.Close()
Если мы не хотим обрабатывать ошибку, нужно проигнорировать ее в явном виде, используя пустой идентификатор:
defer func() $\{\_ =$ rows.Close() }()
Эта версия более многостовна, но с точки зрения удобства сопровождения она лучше, поскольку мы явно указываем, что игнорируем ошибка.
Однако в данном случае, вместо того чтобы сдело игнорировать все ошибка от отложенных вызовов, следует задуматься, нет ли другого, более правильного подхода. Здесь вызов Close() возвращает ошибку, если не удается освободить соединение с БД из пугла. Следовательно, игнорирование этой ошибка — не лучший вариант. Более грамотным подходом будет регистрация сообщения:
defer func() { err := rows.Close() if err != nil { log.Printf("failed to close rows: %v", err) } }()
Теперь, если закрытие rows не проходит, код зарегистрирует сообщение об ошибка, чтобы мы знали о ней.
А если вместо обработки ошибка мы захотим передать ее клиенту, который вызывает функцию getBalance, чтобы он решил, как ее обработать?
defer func() { err := rows.Close() if err != nil { return err } }()
Этот код не скомпицируется. Действительно, оператор return связан с анонимной функцией func(), а не с getBalance.
Если мы хотим связать ошибку, возвращаемую getBalance, с ошибкой, обнаруженной в вызове defer, надо применить именованные параметры результата. Вот первая версия реализации требуемых действий:
func getBalance(db \*sql.DB, clientID string) ( balance float32, err error) { rows, err : $=$ db.Query(query, clientID) if err $! =$ nil{ return 0, err } defer func(){ err $=$ rows.Close() }() if rows.Next(){ err : $=$ rows.Scan(&balance) if err $! =$ nil{ return 0, err } return balance, nil } //... }
Как только переменная rows будет создана, мы откладываем вызов rows.Close() в анонимной функции. Эта функция присваивает переменной err, которая инициализируется с использованием именованных параметров результата, значение ошибки.
Код может выглядеть нормально, но в нем есть проблема. Если rows.Scan воз- вращает ошибку, rows.Close выполняется в любом случае; но поскольку этот вызов переопределяет ошибку, возвращаемую getBalance, вместо ошибки мы можем вернуть ошибку піл при успешном возврате rows.Close. Другими словами, если вызов db.Query завершится успешно (первая строка функции), ошибка, возвращаемая getBalance, всегда будет соответствовать ошибка, возвращаемой rows.Close, а это не то, что нужно.
Логика, которую нужно реализовать, не так уж и проста.
- Если rows.Close выполнена успешно, то:
- если rows.Close также успешно выполняется, то ошибка не возвращается;- если rows.Close завершается с ошибкой, то возвращается эта ошибка.
Ho eciu rows. Scan he ipoxoJut, to Joruka будет heckoJbko cJoxkhee, notomy yTO, cKopee bCero, npuJetcH o6pa6atbIBaTb aBe oHn6Ku.
EcJiu rows.Scan he ipoxoJut, to:
ecJiu rows.Close BbInOJHnEcTcA yCneHnHO, TO BO3BpaIIaEcTcA OHH6Ka H3 rows.Scan; ecJiu rows.Close aBepHnEcTcA c OHH6KoH... torJa yTO?
HTo Jelatb, eCJiu H rows. Scan, H rows.Close he ipoxoJAT? ECTb heckoJbko bapuaH- TOB. HanpHmep, Mb MoKem bepHytb kakyio- TO noJb3oBaTeJbckyio OHH6Ky, kotopar aYJET coJepxKaTb b cEcE aBep HHH6Ku. ApyroH bapuaH, kotopbH Mb H peaJH3y- eM, - 3TO BO3BpaIIaTb OHH6Ky rows.Scan, HO perIcTpHpOBaTb B xKypHaJIe OHH6Ky rows.Close. BOr OKOHaTeJIbHaA peaJH3aIIaJ aTOH aHOHHmHOH OyHKIIH:
defer func() {closeErr := rows.Close() ← Присвоение оцибки из rows.Close другой переменнойif err != nil { ← Если оцибка уже не nil, определяем приоритетif closeErr != nil { log.Printf("failed to close rows: %v", err)}return}err = closeErr ← В противном случае возвращаем closeErr
OHH6Ka rows.Close npucBaIBaEcTcA ApyroH nepemehH0H: closeErr. IpexKJe yEM npucBouTb ee err, Mb npOBeprAem, OTJHuaEcTcA JH err OT n11. EcJiu Ja, 3haHHT, getBaIance yxe bepHyJa OHH6Ky, noSTOMy Mb 3aHOcHm err b xKypHaJI u BO3BpaIIaEcM cyIIeCTbYIOIIyIO OHH6Ky.
OHH6Ku bCerJa JOLJKHb o6pa6atbIBaTbC. B cJyvae OHH6K, BO3BpaIIaEcMbIX defer, kak mHHHMym HYxHO a3HO UX npouHropupOBaTb. EcJiu SToro HeJocCTaTOyHO, cJeJyET o6pa6oTaTb OHH6Ky HAnpHMyIO, aBepIcTpHpOBaB eE B xKypHaJIe HJIH nepeJaB bbi3bIBaIOIIeHCTropoHe, kak noKa3aHO B STOM pa3JeJIe.
# UTOFU
- Использование паники в Go
- это вариант борьбы с оцибками. Но такой вариант следует применять с осторожностью только в самых крайних слу-чаях: например, чтобы сигнализировать об оцибке программиста или когда невозможно загрузить обязательную зависимость.
- Oборачивание ошибки позволяет пометить ошибку и/или дополнить ее каким-то контекстом. Однако это создает потенциальную связанность, поскольку делает исходную ошибку доступной для вызывающей функции. Если вы хотите предотвратить это, не используйте оборачивание ошибок.- Если вы используете оборачивание ошибок с помощью директивы %w и fmt. Errorf, сравнение ошибки с типом или значением должно выполняться с помощью errors. As или errors. Is соответственно. В противном случае, если возвращаемая ошибка, которую вы хотите проверить, обернута, она не пройдет проверку.- Чтобы передать информацию об ожидаемой ошибка, используйте сигналь-ные ошибки (значения ошибок). Непредвиденная ошибка должна быть определенного типа.- В большинстве ситуаций ошибку следует обрабатывать только один раз. Регистрация ошибка — это тоже обработка. Поэтому выбирайте между ведением журнала или возвратом ошибка. Во многих случаях оборачивание ошибок — это хорошее решение, поскольку позволяет снабдить ошибка до-полнительным контекстом, а также вернуть исходную ошибка.- Иннорирование ошибки, будь то во время вызова какой-либо функции или в функции defer, должно выполняться в явном виде с использованием пустого идентификатора. Иначе читатели вашего кода запутаются и не поймут, игнорируете вы ошибку намеренно или случайно.- Не игнорируйте ошибки, возвращаемые функцией defer. Лучше обработать их напрямую либо передать их вызывающей функции — в зависимости от контекста. Если вы все же хотите проигнорировать ошибка, используйте пустой идентификатор.
# Bэтой главе:
- Понимание конкурентности и параллелизма- Почему конкурентность не всегда быстрее- Влияние рабочих нагрузок, увязанных с процессором (CPU-bound) и системой ввода/вывода (I/O-bound)- Каналы и мыотексы — особенности использования- Понимание различий между гонкой данных и состоянием гонки- Работа с контекстами в Go
B последние десятилетия производители процессоров перестали концен- трироваться только на вопросах повышения их тактовой частоты. Современные процессоры имеют несколько ядер и отличаются гиперпоточностью (hyperthreading — наличие нескольких логических ядер в одном физическом ядре). Для эффективного использования возможностей этих архитектур разра- ботчики ПО должны хорошо понимать особенности конкурентного выполнения программ.
Несмотря на то что в Go есть простые примитивы, их наличие не обязательно означает, что написание конкурентного кода — это легко. В данной главе обсудим основные понятия, связанные с конкурентностью, а затем сосредоточимся на ее практических аспектах.
# 8.1. ОШИБКА #55: ПУТАТЬ КОНКУРЕНТНОСТЬ И ПАРАЛЛЕЛИЗМ
Даже после многих лет опыта конкурентного программирования разработчики могут недостаточно четко понимать разницу между конкурентностью и парал- лелизмом. Прежде чем углубляться в эти темы в контексте Go, попробуем про- яснить эти концепции в целом. Проиллюстрируем их на примере из реальной жизни: работе кофейни.
В этой кофейне один бариста, он отвечает за прием заказов и их приготовление с помощью одной кофемашины. Клиенты делают заказы, а затем ждут кофе (рис. 8.1).
# Pис. 8.1. Простая кофейня
Если бариста трудно обслуживать всех клиентов быстро и качественно, а заведение хочет ускорить общий процесс, то можно нанять второго бариста и купить вторую кофемашину. Клиент в очереди будет ожидать, когда кто-то из двух сотрудников сможет его обслужить (рис. 8.2).
В новом процессе каждая часть системы независима. Кофейня должна обслуживать потребителей в два раза быстрее. Это — параллельная реализация системы обслуживания.
Если владелец хочет масштабировать свой кофейный бизнес, он может привлекать новых сотрудников и устанавливать дополнительные кофемашины. Но это не единственный возможный вариант. Другой подход состоит в том, чтобы разделить обязанности сотрудников: например, один отвечает за прием
заказов, а другой — за помол кофейных зерен, из которых он сварит кофе в единственной кофемашине. Кроме того, чтобы не заставлять клиентов ждать в об- шей очереди до тех пор, пока не обслужат текущего клиента, можно ввести еще одну очередь — для клиентов, ожидающих выполнения принятых заказов (как в Starbucks) (рис. 8.3).
Puc.8.2. Koqehra:ay6nupobahue
Puc.8.3. Pasdenheue o6nasnocntei cotpydnukob koqehin
B takoii hooii cистeme o6cyxunbания kJinentob Mbl he coверnneM deicntbria napalnlehho. BMecto otoro nepecmatpbaeetca ooniar ctpyktypa: Mbl pasoubaem oJhy poJb Ha Jbe u Bbouim eue oJhy oqepel. B otJnuue or napalnleJn3Ma, CmbcJI kotoporo B tom, vto oJhin u te xe deicntbria coBepnaiotca oJhOBpeMeHHo, koncy- peHmHocmb cBrsaha co ctpyktypou.
IpeJnOJaraa, vTO oJhin nOTok coOTBecTbYeT 6apucta, pJHHmaOIIeMy sKaasbl, a Jpyrou - koqemaHHHe, Mbl BBeJn TpetuM nOTok - nOMoJa koqehbix sepeH. KaX- Jbii nOTok hesaBucim, ho bce deicntbria bHytpu hero JOLJbHbI kOopJHHpObaTbC
c другими. Поток принимающего заказы сотрудника должен сообщать, какой объем кофейных зерен нужно смолоть. А поток кофемолки должен обмениваться сообщениями с потоком кофеварки.
A если нужно увеличить пропускную способность, обслуживая все больше кли- ентов в единицу времени? Поскольку помол зерен занимает больше времени, чем прием заказов, возможное изменение может состоять в том, чтобы нанять еще одного сотрудника на помол кофе (рис. 8.4).

[ImageCaption: Puc. 8.4. HahumaeM euq oJHoro cotpydHuka dna nomona koqehbix ereh]
Ctpyktypa koqehihu octaetcr npexhei. To no- npexhemy tpexstanhar cxeMa: npuhrrs 3akas, cmoJorbs koqe u csapurts koqe. CJedobatrebno, c touku spenua konkypehntoctu het nukakux usmeneni. Ho mi bernyJncs k do6abJenuo Jne- mehta napaJ. Jnycb JJr oJhoro konkpethoro nIara - Jtana nodrotoBku K okonyateJbHomy bHIOJmeHnO 3akasa.
IpeJnOJIOXUM, YTO y3Koe meCTO, 3ameJJrIOUee becs npoJecC, - JTO koqebapka. IcnoJb3OBaHnue ToJbIKO oJHOuJ koqemaunHbI npuBoJHT K konkypeHnIIN MeXJy pa3HbIMn IOTOKaMn IOMOJIa koqe, nocKoJIbKy OHn OJa oXKJDAIaOT, KoJJa IOTOK koqemaunHbI cTaHeT JJIa Hux JOCTyHbIM. Kak moXHO peIInITb JTy 3aJauy? JJo6aBHTb JOnOJIHnTeJIbHbIe IOTOKu koqemaunHbI (puc. 8.5).
BMeCTO oJHOuJ koqemaunHbI Mbl yBeJnIyJII COOTBeTCTbYIOHnIuY yPOBeHb napaJIJe- Jn3Ma, yctahOBuB OJJIHHe MaIIHn. IJ chOba ctpyktypa He 33MeHnJIacb: OHa OCTaEcra tpeXCTyIeHvIaTOuI. Ho nponyckHaJ cInOco6HOCTb BeqI cIcTeMbI JOJIKa yBeJIyHHTbC, nOTOMy YTO yPOBeHb konkypeHnIIN MeXJy IOTOKaMn IOMOJIa koqe yMeHbIInITcR.

[ImageCaption: Puc.8.5.Yctahobka6onbulero ucnca koqemauin]
B takom dusaihne mi moxem sametutb hevto oveh baxkhoe: konkypehmnoctb oce- cnevuaeem bosmoxhocrb naapa/nelusma. Konkypehnthocrts nostata/kuhbaet k cos/ahnuo ctpyktypbi d/ra peienuh bceii npo/lembi vepes ee pas/uehne ha vactu, de/ctbuis bhytpu kotopbix moxko pacnapa/le/untb.
Konkypehmnoctb - smo o pa/ome c o/obnium kon/uecmtbom beueii odhOBpeMeHnO. Iapa/nel/usm - smo o obino/nehunu mhoxcectba de/ odhOBpeMeHnO.
Po6 Iaik (Rob Pike)
Htak, konkypehnthocrts u napa/nel/usm - oTO pashbie be/nu. Konkypehnthocrts cBraaha co ctpyktypol: mi moxem npebpatutb noc/le/obnate/hyno pea/nsa/nuo b napa/nel/hyno, BbO/ia pas/nuhbie niaru, kotopbie moryt cobepnaitbcra b pam- kax ot/le/bHbix napa/nel/hno bbiino/hreMbx notokob. A napa/nel/usm kacaetcra bbiino/neHnur: moxko ucno/льObать ero ha ypObhe niaroB, do/ab/raa do/ble napa/nel/ebHbix notokob. Honumahue 3Tux ayx konIeIInIuI o/ehb baxkho d/ra kavecTbeHHnOI paspa/6oTku ha Go.
Teneps norobopum o pacnpoctpaHeHHOI OInu/ke: y/ex/LeHnI, uto konkypeHTHOCTb - oTO bcer/da npaBul/hbHbIy nYtb.
# 8.2. OllnBKA #56: NOJAFATb, YTO KOHKYPEHTHOCTb BbICTPEE
Mногие разработчики ошибочно полагают, что конкурентное решение всегда быстрее, чем последовательное. И это в корне неверно. Общая производительность выбранного решения зависит от многих факторов, таких как эффективность структуры (конкурентность), от того, какие части могут выполняться параллельно, и от уровня конкуренции между вычислительными блоками. Этот раздел освежит некоторые фундаментальные знания о конкурентности в Go. Мы увидим пример, где конкурентное решение не обязательно будет более быстрым.
# 8.2.1.Планирование в Go
Поток — это наименьшая единица обработки, которую может выполнять операционная система (OC). Если процесс хочет выполнять несколько действий одновременно, он запускает несколько потоков. Потоки могут быть:
- конкурентными: два или более потока могут запускаться, выполняться и завершаться в перекрывающиеся периоды времени — как поток бариста и поток кофемашины в предыдущем разделе;- параллельными: одна и та же задача может выполняться несколько раз одновременно, как потоки нескольких бариста.
OC отвечает за оптимальное планирование процессов потоков, чтобы:
- все потоки могли использовать процессорное время, не слишком долго ожи-лая своей очереди;- рабочая нагрузка распределялась между различными ядрами процессора максимально равномерно.
Примечание Термин поток (thread) на уровне CPU может иметь другое значение. Каждое физическое ядро может состоять из нескольких логических ядер (концепция пиперпоточности — hyperthreading), которые также называются потоками (thread). В этом разделе слово «поток» используется тогда, когда имеется в вицу единица обработки, а не логическое ядро.
Ядро процессора выполняет различные потоки. Когда оно переключается с одного потока на другой, выполняется операция под названием переключение контекста. Активный поток, потребляющий циклы процессора и находящийся
B COCTORHHH HBNONHEHHA (executing state), nepExOJHT B COCTORHHH E ZOMOBHOCHMH HBNONHEHHA (runnable state), sto O3HAvAeT, YTO OH ROTOB K BAnyCky H OXKIDeT AOCTYINHOe AIdpo. HepEKJIovHHe KOHTEKCTA CCHTaeTся JOpOrOCTOHHHeI ONepaHHeI, NOCKOJIbKy OC HYXHO COspAHHTb TEKYIIee COCTOHHHe BbIHONHEHHA NOTOKa nepeJI nepEKJIovHeHHeM (HaIIpHMEp, TEKYIIHe 3HAvEHHHA pERICTPOH).
B Go HельBs co3JaBaTH notOKH HaIIpHMyIO, HO eCTb BO3MOXHOCTb co3JaBaTH rOpyTHHb, KOTOpbie MOXHO pAcCMaTpHBaTB KaK notOKH Ha yPOBHe HPIJI0XeHHA. HPI JTOM eCIH notOK OC yIIpaBaJIeTcra cAMOI OC, TO rOpyTHHa yIIpaBaJIeTcra cpeOI BbIHONHEHHA Go. KpOMe TOro, HO cpaBaHeHHIO c notOKOM OC rOpyTHHa 3aHHMaeT MeHbHe MeCTa B nAMHTH: 2 K6aIIT H3 Go 1.4. Pa3MeP notOKa OC 3aBucHIT OT OC, HO, HaIIpHMEp, B Linux/X86- 32 pa3MeP no yMOJIaHHHO COCTaBJIeT 2 M6aIIT (cM. hTtP:// mng.bz/DgMw). MeHbIHulI pa3MeP AeJIaT nepEKJIovHeHe KOHTEKCTa 6OJIee 6bICTpBIM.
IPUMeVAHHe HepEKJIovHeHe KOHTEKCTa rOpyTHHb no cpaBaHeHHIO c TakoBbIM AJIa notOKa pOBOCKOJIHT pHMepHO Ha 8O- 9O % 6bICTpee B 3aBисимOCTH OT aPXHTeKTyPbI.
TenepB o6cydHM, KaK pa6OTaTeT nJIaHHpOBHIIHk Go, H nOIbMeM, KaK o6pa6aTaBBaIaOTcra rOpyTHHb. 3JeeCb HcIcOJIb3yETcra cJeJIyIOHaa TePMHHOJIOrHA (cM. hTtP://mng.bz/N611):
G- rOpyTHHa M- notOK OC (M O3HAvAeT machine,MaUHHA) P- AIdpo CPU (P O3HAvAeT processor, nPOueccOp)
KaXbIbI notOK OC (M) Ha3HAvAeTcra AIdpy CPU (P) nJIaHHpOBHIIHKOc OC. 3aTeM KaXbJaH rOpyTHHa (G) sanyckaTeTcra Ha M. HepeMeHHa AGOMAXPROCS onpeJeJIeT npeJeJI KOIJIHeCTBa notOKOB M, OTBeYaIOHIx 3a OJIHObpeMeHHoe HcIcOJIHeHeHe KOIa nOIb3OBaTeTbIcKoro YpOBHra. Ho eCIH notOK 3a6JIOKHPOBaH B cIcTemeHOM BbI3OBe (HaIIpHMEp, BBOJa/BBbOeA), nJIaHHpOBHIIHk MOXeT sanyCTHA fOJIbHe HeTOKOB M. HaYHHaH c Go 1.5, GOMAXPROCS no yMOJIaHHHO paBeH KOJIHeCTbY JOCTyINHbIX AIdp CPU.
IOPyTHHa IMeTeT 6OJIee nPOCTOI KU3HeHHbIbI HIIKH, YeM notOK OC. OHa MOXeT COBePIHaTB OJIHO H3 cJIeJIyIOHIx AeIcTbHIH:
ucnOJIHeHe (eXeCuTHeNG) - Ha M Ha3HAvAeHO HcIcOJIHeHe HeTpyTHHb, H BxOJIaIIHe B Hee HHCTpyKHHeH HbIHONJHHIOCTa; ZOMOBHOcMb K OBNONHEHHeHO (runnable) - rOpyTHHa OXKIDeT nepExOJa B COCTOHHHe BbIHONHEHHA;
- **ожидание (waiting)** — горутина остановлена и ожидает завершения чего-либо, например системного вызова или операции синхронизации (например, получения мыотекса).
Oстался последний шаг к пониманию того, как в Go реализуется планирование: что происходит, когда горутина создана, но еще не может быть выполнена, например, все остальные М уже выполняют G. Что тогда будет делать среда выполнения Go? Ответ: поставит в очередь. Среда выполнения Go обрабатывает два типа очередей: по одной локальной очереди для каждого P и глобальная очередь, которая ориентирована на выполнение на всех P.
На рис. 8.6 показана заданная ситуация планирования на четырехъядерной машине с GOMAXPROCS, равным 4. Частями являются логические ядра (P), горучины (G), потоки OC (M), локальные очереди и глобальная очередь.
Заметьте, мы видим пять M, тогда как для GOMAXPROCS установлено значение 4. Но как я уже сказал, при необходимости среда выполнения Go может создать больше потоков OC, чем значение GOMAXPROCS.
Рис. 8.6. Пример текущего состояния приложения Go, выполняемого на четырехъядерной машине. Горутины, которые не находятся в состоянии выполнения, либо готовы к выполнению (ожидают в очереди на выполнение), либо ожидают (блокирующую операцию)
В настоящее время P0, P1 и P3 заняты выполнением потоков среды Go. Но P2 при этом простаивает, так как M3 отключен от P2, и никакой горутины для выполнения нет. Это не очень хорошая ситуация, потому что шесть готовых
к запуску горутин ожидают выполнения, некоторые в глобальной очереди, а некоторые в других локальных очередях. Как среда выполнения Go справится с этой ситуацией? Вот реализация планирования в псевдокоде (см. http:// mng.bz/lxY8):
runtime.schedule() { // Только 1/61 от всего времени, проверка глобальной очереди выполнения // на найдение G. // Если ничего не найдено, проверка локальной очереди. // Если ничего не найдено, // попытка украсить у других P. // Если опять ничего не найдено, проверка глобальной очереди готовых к выполнению. // Если ничего не найдено, опрос сети. }
При каждом шестьдесят первом выполнении планировщик Go будет проверять, доступны ли горутины из глобальной очереди. Если нет, он проверит свою локальную очередь. Если же и глобальная и локальная очереди пусты, планировщик может переживать горутины из других локальных очередей. Этот принцип в планировании называется кражей задач (work stealing), и он позволяет недостаточно загруженному процессору активно искать горутины, ожидающие своего выполнения на другом процессоре, и украсить некоторые из них.
Обратите внимание: до версии Go 1.14 планировщик был кооперативным, что означало, что горутина могла быть контекстно отключена от потока только в определенных случаях блокировки (например, отправка или получение канала, операции ввода/вывода, ожидание получения мыотекса). Начиная с Go 1.14, планировщик стал витесняющим (preemptible): когда горутина выполняется в течение некоторого заданного отрезка времени (10 мс), она будет помечена как вытесняемая и может быть контекстно отключена и заменена другой горутиной. Это позволяет использовать процессор в тот период, когда выполняется какое-то длительное задание, а также для выполнения и других задач.
Теперь, когда мы понимаем основы планирования в Go, рассмотрим пример: параллельная реализация сортировки слиянием.
# 8.2.2. Параллельная сортировка слиянием
Рассмотрим, как работает алгоритм сортировки слиянием. Реализуем парал- лельную версию. Цель состоит не в том, чтобы написать код, который будет наиболее производительным, а в том, чтобы показать, почему конкурентность не всегда быстрее.
Cytb a/roputma coruppouku c/nihiem coctout b mhorokpatnom pas6uehni kakoro- to cniucka ha aba noocnucka do tex nop, noka kaxdbiu us hux he 6ydet coctoarts us oJhoro eJemента. 3atem эти noocnucku o6beдиняются takum o6pasom, vto b pesybnrate no/yaetca otcoprtupobанныi cuncok (pic. 8.7). Kaxdaz oneraunr pas3deJehnur pas6ubaeT cuncok ha aba noocnucka, torza kak oneraunr cJnHnur o6beдинr et aba noocnucka b oJun otcoprtupobанныi cuncok.

[ImageCaption: Puc. 8.7. Pnuменue a/roputma coruppouku c/nihiem mhorokpatno pas6ueat kaxdbiu cncok ha aba noocnucka. 3atem npumehretrca onepaunh cniHnur, vto6bi nonyueHnbiu cncok okabancr otcoprtupobанныM]
Bot noc/edobate/bnar pea/ni3a/na rtoro a/roputma. Koa cokpa/len, tak kak 3/ecb 3TO HE RJIABHOE:
func sequentialMergesort(s []int) { if len(s) $\epsilon = 1$ { return } middle := len(s) / 2 sequentialMergesort(s[:middle])
sequentialMergesort(s[middle:]) Btora nanobnua merge(s, middle) 06beдинение abyx nonobun } func merge(s []int, middle int){ //... }
TOT aJIroputM uMeet ctpyktypy, kotopar detaet ero otkpbitM JIA KOnkypeHTHOCTH. IocKoJIbKy KaKaJaa onepaIIaIa sequenceMergesort pa6otaeT c he3aBicHbIM Ha6opOM JaaHbIX, kotoppe He HyXHO nOJIHOCTbIO KOnIPOBaTb (3Jecb He3aBicHMOe npeJCTaBLeHHe OCHOBmOro MaCCuBa c IcIOJIb3OBaHHeM Hape3Ku), Mb MoXeM pacnpeJeJIb3 JTy pa6OyYO HaPry3ky MeXJy aJpamu CPU, aIanyCTb KaXJyIO onepaIIIO sequenceMergesort B JpyroU rOpyTHHe. Bot koJ nepBOU naPaJIJIeJIbHOU peaJIu3aIIII:
func parallelMergesortV1(s []int) { if len(s) $\epsilon = 1$ { return } middle := len(s) / 2 var wg sync.WaitGroup wg.Add(2) go func() { 3anyckaetcr nepBaar nonobuHa pa6otbI b ropyTHHe defer wg.Done() parallelMergesortV1(s[:middle]) }() go func() { 3anyckaetcr Btopar nonobuHa pa6otbI b ropyTHHe defer wg.Done() parallelMergesortV1(s[middle:]) }() wg.Wait() merge(s, middle) 06beдинe HHe 3TIX nOJIbHn
B JTOU bepcHn koJa KaKaJaa nonOBuHa pa6OeU HaPry3Ku o6pa6aTbBaetcr aTJeJIbHnOH IropyTHHOH. PoJIHTeJIbckar ropyTHHa OXKJIaet a3aBepIHeHHA BbIIOJIHeHHA O6eIX VaCTeU c nOMOIIbIO onepaIIpTa sync.WaitGroup. TakMn o6pa3OM, MbI BbI3bIaEM MeTOJ Wait nepeJ onepaIIHeuEc JJIHHHHA.
PIMMEYAHHE EcJH BbI eHJe He 3HaXOMH C onepaTOpOM sync.WaitGroup,TO UMHeiTe B BIIJy, VTO Mb paCCmOTpHM ero nOJPO6Hee B pa3JeJIe, nOcBraIIeHHOM pa36Opy OIIII6Ku #71 (HeIpaBaUBHbHO IcIOJIb3OBaTb sync.WaitGroup). BkpaTIe: OH nO3BOJIaTeT JIOXKaTaTbca a3aBepIHeHHA n onepaIIuI (66bIHHO rOpyTHHbI, KaK B IIpeJIbJIyIIeM IIpIHepe).
Tenep b y hac eCTb naPaJIJIeJIbHaa bepcHn aJIropUTMa cOrTuppObKH cJIHHHeM. IeJJI Mb3aIIyCTHM 6eHHMaHK JJIa cpaBaHeHHA JTOU bepcHn c nOc3eJOBaTeJIbHOU, nepBaa
версия должна быть быстрее. Так ведь? Запустим его на четырехъядерной машине с 10 000 элементов:
Benchmark_sequentialMergesort- 4 2278993555 ns/op Benchmark_parallelMergesortv1- 4 17525998709 ns/op
Удивительно, но параллельная версия оказалась на порядок медленнее. Почему? Как получилось, что параллельная версия, в которой рабочая нагрузка распределяется между четырьмя ядрами, работает медленнее, чем последовательная версия, работающая на одной машине? Давайте проанализируем.
Если у нас есть срез из 1024 элементов, родительская горутина запускает две другие горутины, каждая из которых отвечает за обработку соответствующей половины, состоящей из 512 элементов. Каждая из этих горутин запустит две новые горутины, отвечающие за обработку 256 элементов, затем 128, и так далее, пока мы не запустим горутину, вычисляющую один элемент.
Если рабочая нагрузка, которую мы хотим распараллелить, слишком мала, то есть мы собираемся провести соответствующие ей вычисления слишком быстро, преимущество распределения задания по ядрам теряется: время, не-обходимое для создания горутины и ее выполнения планировщиком, слишком велико по сравнению с прямым сдиянием небольшого количества элементов в текущей горутине. Хотя горутины леговесны и запускаются быстрее, чем потоки, мы все же можем столкнуться со случаями, когда рабочая нагрузка слишком мала.
Примечание О том, как выявить неудачное распараллеливание, поговорим при разборе ошибки #98 (не использовать диагностический инструментарий Go).
Какой можно сделать вывод? Алгоритм сортировки сдиянием нельзя распарал- лелить? Погодите, не будем спешить.
Используем другой подход. Поскольку применение новой горутины для слияния небольшого количества элементов неэффективно, определим порог. Он будет указывать на то, какое минимальное число элементов должна содержать половина списка, чтобы обрабатывать ее параллельно имело смысл. Если количество элементов в половине меньше этого значения, будем обрабатывать ее последовательно. Вот новая версия кода:
const max = 2048 → Задание величины порога func parallelMergesortV2(s []int) { if len(s) <= 1 {
return } if len(s) $\epsilon =$ max{ sequentialMergesort(s) Bb3oB nocneqobatelbHouB BepcH 1 else{ Ecm nper npebHueh, to Bbmonhretca napanlnebHaeBepm middle := len(s)/2 var wg sync.WaitGroup wg.Add(2) go func{ defer wg.Done) parallelMergesortV2(s[:middle]) }() go func{ defer wg.Done) parallelMergesortV2(s[middle:]) }() wg.Wait() merge(s, middle) } }
Ecmu kouinectbo elemenertB cpe3e s mehbie max, Mb Bb3binaem nocneqobatelbHyio bepcHIO. B npotubHOM cJyiae npoJouxaem Bb3bIBaTb napaJJIeJIbHyIO peaJIu3aI110. BJirIeT JII tAkOI nOJXOJ Ha pe3yJIbTaT? Ja, BJIirIeT:
Benchmark_sequentialMergesort- 4 2278993555 ns/op Benchmark_parallelMergesortV1- 4 17525998709 ns/op Benchmark_parallelMergesortV2- 4 1313010260 ns/op
Btopar bepcHs napaJIeJIbHouB peaJIu3aI11u 6oJee yem Ha 40 % 6bIcTpee nocneqoBateJIbHouB, u bce 7to 6JIaroJIapa uJee nopora, onpeJeeJIbHIOero 8bIcKebTUBHOCTb napaJIeJIbHouB o6pa6oTCKI.
PruMEvAHNE Iovemy B kauectbe naporoboro 3havehnra B3a1 2048? ToTomy uto 7to 6bIIO onTumMaJIbHbIM 3havehnem aIa 7to1 10HkpeTHO1 pa6OueH harpy3ku Ha moe1 MaHnHe. B o6IIem, takue marHueckue 1ncIa cJJeJyET TIIaTeJIbHO onpeJeeJIbTb c NoMOnIbI6 OHyMapKOB (pa6OtaIOIbIX B cpeJe BbIIOJIHeHnIa, aHaJIorIuYHOuI Tou, kotopar 6yJet ucIOJIb3ObaHa npu peaJIbHOM npHMHeHnIu npOrpaMmAHa Ha npaIaTHKe). HHTrepeCHo OTMeTnIb, 7TO Sanyck oJIHOro H ToTO Xe aJIropuTMA Ha 33bIke 63 kOHIIeIIIIII IropyTnH BJIirIeT Ha 7TO 3havehnue. HaIIpuMEp, BbIIOJIHeHnIe ToTO Xe npHMepa Ha Java c ucIOJIb3ObaHHeM nOTOKOB O3havaeT, 7TO ONTUMaJIbHOe 3havehnue 6yJet 6JIuXe K 8192. 7TO uJIJIIOCTpIpyET ToT bAkt, HAcKOJIbKO rOpyTnHbI 6oJee 8bIcKebTUBHbI, yem nOTOKu.
B 7tou rJIabe Mb paCCMOrpeJIu OyHJIaMeHTaJIbHbIe KOHHIIeIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII
выполнение горутин. Используя пример с параллельной сортировкой слия- нием, мы показали, что конкурентность не всегда приводит к более быстрому выполнению кода. Как мы увидели, развертывание механизма горутин для об- работки незначительных рабочих нагрузок (на примере слияния относительно небольшого набора элементов) сводит на нет преимущества, которые мы могли бы получить от параллелизма.
Каков вывод? Помните, что конкурентность не всегда быстрее и ее не следует по умолчанию рассматривать как способ решения всех проблем. Во-первых, она все усложняет. Во-вторых, современные CPU стали невероятно эффек- тивны при выполнении последовательного и предсказуемого кода. Например, суперскалярный процессор может с высокой эффективностью распараллелить выполнение инструкций на одном ядре.
Означает ли это, что не следует использовать конкурентность? Конечно нет. Но важно помнить о вышеприведенных рассуждениях. Если мы не уверены, что параллельная версия будет быстрее, то правильным подходом будет начать с простой последовательной версии и отталкиваться от нее, используя, например, профилирование (см. разбор ошибки #98 (не использовать диагностический инструментарий Go) и бенчмарки (см. разбор ошибки #89 (писать неточные бенчмарки)). Это может быть единственным способом убедиться, что конкурентность того стоит.
В следующем разделе узнаем, когда следует использовать каналы, а когда мыо- тексты.
# 8.3. ОШИБКА #57: ПУТАТЬСЯ В ТОМ, КОГДА ИСПОЛЬЗОВАТЬ КАНАЛЫ, А КОГДА МЬЮТЕКСЫ
При возникновении проблемы конкурентности не всегда ясно, как лучше реализовать решение: с помощью каналов либо мыотексов. Поскольку Go поощряет совместное использование памяти при обмене данными, одной из ошибок может быть постоянное принудительное использование каналов, независимо от ситу- ации. Но следует рассматривать оба варианта как взаимодополняющие. В этом разделе поговорим, когда мы должны отдавать предпочтение одному варианту перед другим. Цель главы в том, чтобы дать общие рекомендации, которые помогут принимать верные решения.
Для начала напомню о каналах в Go: каналы — это механизм коммуникации. Внутри себя канал — это некий трубопровод, который мы можем использовать
для отправки и получения значений и который позволяет соединять конкурент- ные горугины. Канал может быть:
- небуферизованным: отправляющая горугина блокируется до тех пор, пока получающая горугина не будет готова;- буферизованным: отправляющая горугина блокируется только тогда, когда буфер оказывается полностью заполненным.
Вернемся к изначальному вопросу. Когда нужно использовать каналы, а когда мыотексы? Возьмем пример на рис. 8.8. В нем есть три разные горугины, которые соотносятся друг с другом следующим образом:
- G1 и G2
- параллельные горугины. Это могут быть две горугины, выполняющие одну и ту же функцию, которая продолжает получать сообщения из канала, или две горугины, одновременно выполняющие один и тот же обработчик HTTP.- C другой стороны, G1 и G3 являются конкурентными горугинами — так же, как и G2 и G3. Все горугины — это часть общей конкурентной структуры, но G1 и G2 выполняют первый шаг, а G3 — следующий шаг.
Рис. 8.8. Горугины G1 и G2 параллельны, тогда как G2 и G3 конкурентны
Как правило, параллельные горугины должны синхронизироваться, например, когда им нужно получить доступ или изменить общий ресурс, такой как срез. Синхронизация производится с помощью мыотексов, но не с любыми типами каналов (не с буферизованными каналами). Следовательно, в общем случае синхронизация между параллельными горугинами должна достигаться с помощью мыотексов.
И наоборот, в общем случае конкурентные горугины должны координировать и оркестровать свои действия. Например, если G3 должна объединить результаты как из G1, так и из G2, то G1 и G2 должны сообщить G3, что новый промежуточный результат доступен. Эта координация относится к коммуникации, следовательно, должна осуществляться с помощью каналов.
Что касается конкурентных горугин, то можно представить себе случай, когда мы захотим передать право собственности на ресурс с одного шага (G1 и G2) на другой (G3), например, если G1 и G2 расширяют возможности какого-то общего ресурса и в какой-то момент мы посчитаем их работу завершенной. В этом случае мы должны использовать каналы, чтобы сигнализировать о том, что конкретный ресурс готов, и обработать передачу права собственности.
Мыотексы и каналы имеют разную семантику. Всякий раз, когда нужно разделить состояние или получить доступ к общему ресурсу, мыотексы обеспечивают эксклюзивный доступ к этому ресурсу. И наоборот, канал — это механизм для передачи сигналов с данными или без них (chan struct{) или нет). Координация или передача права собственности должна осуществляться по каналам. Важно знать, являются ли горугины параллельными или конкурентными, потому что обычно для параллельных горугин нужны мыотексы, а для конкурентных каналы.
Давайте обсудим широко распространенную проблему, связанную с конкурент- ностью: проблему гонки.
# 8.4. ОШИБКА #58: НЕ ПОНИМАТЬ ПРОБЛЕМ ГОНКИ
Проблемы гонки — это одни из самых сложных и коварных ошибок, с которыми сталкиваются программы. Мы должны понимать некоторые важные вопросы: гонка данных и состояние гонки, их возможные последствия, а также способы их избежать. Сначала обсудим гонку данных и состояние гонки в сравнении друг с другом, затем изучим модель памяти Go и поговорим, почему все это столь важно.
# 8.4.1. Гонка данных и состояние гонки
Сосредоточимся на гонке данных (data race), которая происходит, когда две или более горугины одновременно обращаются к одной и той же ячейке памяти и по крайней мере одна из них выполняет запись в эту ячейку. Вот пример, когда две горугины увеличивают значение общей переменной:
i := 0go func() {i++ Yвеличение значенияi}()go func() {i++}()
Если запустить этот код с использованием детектора гонок Go (опция - race), он предупредит о том, что произошла гонка данных:
WARNING: DATA RACEWrite at 0x00c00008e000 by goroutine 7:main.main.func2()Previous write at 0x00c00008e000 by goroutine 6:main.main.func1()
Окончательное значение i также непредсказуемо. Иногда это может быть 1, а иногда и 2.
Какая проблема в этом коде? Оператор i++ производит три действия:
- чтение I;- увеличение значения i на 1;- перезапись нового значения в i.
Если первая горутина выполняется и ее выполнение запершается раньше, чем у второй, то происходит вот что.

Table (html):
<table><tr><td>Горутина 1</td><td>Горутина 2</td><td>Операция</td><td>i</td></tr><tr><td>Чтение</td><td></td><td></td><td>0</td></tr><tr><td>Увеличение на 1</td><td></td><td></td><td>0</td></tr><tr><td>Перезапись</td><td></td><td></td><td>1</td></tr><tr><td></td><td>Чтение</td><td></td><td>1</td></tr><tr><td></td><td>Увеличение на 1</td><td></td><td>1</td></tr><tr><td></td><td>Перезапись</td><td></td><td>2</td></tr></table>
Первая горутина читает, увеличивает и записывает значение 1 обратно в i. Затем вторая горутина выполняет тот же набор действий, но начинает уже со значения i, равного 1. Следовательно, окончательный результат, записанный в i, равен 2.
Однако нет никакой гарантии, что первая горутина запустится или завершится раньше второй. Мы также можем столкнуться со случаем чередующегося вы- полнения, когда обе горутины выполняются одновременно и конкурируют за доступ к i. Вот еще один возможный сценарий.

Table (html):
<table><tr><td>Горутина 1</td><td>Горутина 2</td><td>Операция</td><td>i</td></tr><tr><td rowspan="2">Чтение</td><td></td><td></td><td>0</td></tr><tr><td>Чтение</td><td>&lt;-</td><td>0</td></tr><tr><td rowspan="2">Увеличение на 1</td><td></td><td></td><td>0</td></tr><tr><td>Увеличение на 1</td><td></td><td>0</td></tr><tr><td rowspan="2">Перезапись</td><td></td><td>-&gt;</td><td>1</td></tr><tr><td>Перезапись</td><td>-&gt;</td><td>1</td></tr></table>
В таком случае обе горутины сначала читают i и получают значение этой переменной, равное 0. Затем они обе увеличивают это значение и записывают свой локальный результат, то есть 1, обратно, а это не является ожидаемым результатом.
Это возможное последствие гонки данных. Если две горутины одновременно обращаются к одной i той же ячейке памяти и хотя бы одна записывает в нее данные, результат может быть опасным. Хуже того, в некоторых ситуациях в ячейке памяти может оказаться значение, содержащее бессмысленную ком- бинацию битов.
ПРИМЕЧАНИЕ При обсуждении ошибки #83 (не включать флаг -race) мы увидим, как среда Go помогает обнаружить гонку данных.
Как предотвращать гонки данных? Рассмотрим несколько разных методик. Я не буду перечислять все возможные варианты (например, опустим atomic.Value), а покажу основные из них.
Первый вариант — сделать операцию инкремента атомарной, то есть выполняе- мой целиком за один шаг. Это предотвращает запутанное выполнение операций.

Table (html):
<table><tr><td>Горутина 1</td><td>Горутина 2</td><td>Операция</td><td>i</td></tr><tr><td rowspan="3">Чтение и увеличение на 1</td><td rowspan="3">Чтение и увеличение на 1</td><td>&lt;&gt;</td><td>0</td></tr><tr><td>&lt;&gt;</td><td>1</td></tr><tr><td>&lt;&gt;</td><td>2</td></tr></table>
Jaxe csiu otpaa rorytnna anyctncta npea nepnoll, to peayilatam octanecna 2.
AtomapHbie onepaHnH moxHo BbHnOHnHb B Go c nOmOHbHn HAKeTa sync/atomic. Bot npHmer, kak atomapHo ybeJHunBaTb int64:
var i int64 go func(){ atomic.AddInt64(&i,1) AtomapHoe ybeJHvHHe 3havHnH () go func(){ atomic.AddInt64(&i,1) To xe caMoe }()
O6e rOpyTHHb o6HOBJIHOT i atomapHo. AtomapHna onepaHnH he moxket 6bTb npe- pbaha, uTO npeJOTbpaHnHct cHtyaHnIO c OJHObpeMeHHbIM npeJocrTbHHeHMe JocrTyHa H3 JbVX MeCT. He3aBHCbM0 OT nOprJKa BbHnOHHeHnH rOpyTHH b B HTOre OyJET paHHO 2.
PPrMEyAHHe Ilaket sync/atomic npeJocrTbHHeHt npHmHtHbHl JIA int32, int64, uint32 u uint64, Ho He JIA int. Bot noyemy b OTOM npHmepe i uMeet THH int64.
JpyroH bapuaHr - cHnxponH3HpOBaTb Jbe rOpyTHHb c nOmOHbIO cneHuaJIbHOHCTryKtypHb JAHHbIX, MbIOTeKca. CJIOBO MbIOMeKc (mutex) O6pa3OBaHO OT «mutual exclusion», uTO O3HaaAT eB3aMHHOe IcKJIHOeHHe». MbIOTeKc O6ecneHbHbAT e6paHHeHe K TaK Ha3bIbAeMOH KpHTHHeCKOll cekHnH He 6OJee OJIHbH HOpYTHHb. B nakete sync onpeJeJIeHecra THH Mutex:
i := 0 mutex := sync.Mutex{} go func() { mutex.Lock() —— Hачало критического раздела i++ —— Увеличение значения на единицу mutex.Unlock() —— Конец критического раздела }() go func() { mutex.Lock() i++ mutex.Unlock() }()
Bэтом примере инкрементирование i является критической секцией. Независи- мо от порядка горугин, выполнение этого кода также выдает детерминированное значение i, равное 2.
Какой подход лучше? Все довольно просто. Как я говорил, пакет sync/atomic работает только с определенными типами данных. Если нужно обрабатывать данные каких-то других видов (например, срезы, карты или структуры), то мы не можем полагаться на sync/atomic.
Другой возможный вариант — запретить совместное использование одного и того же места в памяти и отдать предпочтение взаимодействию между горугинами. Например, создать канал, который каждая горутина использует для получения значения инкремента:
i := 0 ch := make(chan int) go func() { ch <- 1 Yведомление горутины об увеличении на 1 }() go func() { ch <- 1 }() i += <- ch Yведомление i от того ее значения, которое было получено из канала i += <- ch
Какдая горутина отправляет по каналу уведомление о том, что нужно увеличить i на 1. Родительская горутина собирает эти уведомления и увеличивает i. Поскольку она единственная горутина, непосредственно пишущая в ячейку i, в таком решении не возникнет гонки данных.
Подведем итоги. Гонка данных происходит, когда несколько горутин одновременно обращаются к одной и той же ячейке памяти (например, к одной и той же переменной) и по крайней мере одна из горутин выполняет запись. Мы рассмотрели варианты предотвращения этой проблемы с помощью трех синхронизирующих подходов:
- использование атомарных операций; - защита критических секций с помощью мыотексов; - использование связи и каналов для обеспечения того, чтобы переменная обновлялась только одной горутиной.
Во всех этих трех случаях значение i в итоге будет равно 2, независимо от порядка выполнения двух горутин. Но в зависимости от того, какую операцию мы хотим
выполнить, обязательно ли приложение, свободное от гонки данных, означает детерминированный результат? Рассмотрим на другом примере.
Теперь горутины не увеличивают общую переменную, а каждая выполняет операцию присваивания значения. Используем мыотекс для предотвращения гонки данных:
i := 0 mutex := sync.Mutex{} go func() { mutex.Lock() defer mutex.Unlock() i = 1 —— Первая горутина присваивает переменной i значение, равное 1 }() go func() { mutex.Lock() defer mutex.Unlock() i = 2 —— Вторая горутина присваивает переменной i значение, равное 2 }()
Первая горутина присваивает переменной i значение 1, а вторая - 2.
Есть ли здесь гонка данных? Нет. Обе горутины обращаются к одной и той же переменной, но не одновременно, так как ее защищает мьютекс. Но является ли этот пример детерминированным? Тоже нет.
В зависимости от порядка выполнения i в итоге будет равно либо 1, либо 2. Этот пример не приводит к гонке данных. Но в нем есть состояние гонки (race condition). Оно возникает, когда поведение зависит от последовательности или времени выполнения событий, которые невозможно контролировать. В данном случае время событий - это порядок выполнения горутин.
Обеспечение определенной последовательности выполнения горутин - вопрос координации и оркестровки. Если мы хотим сначала перейти из состояния 0 в состояние 1, а затем из состояния 1 в состояние 2, нужно найти способ гарантировать, что горутины выполнятся по порядку. Эту проблему можно решить с помощью каналов. Координация и оркестровка также могут гарантировать, что к определенному разделу будет обращаться только одна горутина, что также может означать исключение мьютекса в предыдущем примере.
При работе с конкурентными приложениями важно понимать, что ситуация гонки данных отличается от ситуации состояния гонки. Гонка данных возникает, когда несколько горутин одновременно обращаются к одной и той же ячейке памяти и по крайней мере одна из них выполняет запись в эту ячейку. Гонка данных означает возможность неожиданного поведения. Тем не менее
приложение, в котором обеспечено отсутствие ситуаций с гонками данных, не обязательно будет выдавать детерминированные результаты. Приложение может быть свободным от гонок данных, но по-прежнему зависеть от неконтролиру- емых событий (выполнение горутины, скорость распространения сообщения по каналу или длительность обращения к базе данных). В таких случаях будет наблюдаться состояние гонки. Понимание этих концепций очень важно при профессиональной разработке конкурентных приложений.
Теперь рассмотрим модель памяти Go и разберемся, почему это важно.
# 8.4.2. Модель памяти Go
В предыдущем разделе мы обсудили три основных метода синхронизации горутин: атомарные операции, мыотексы и каналы. Но есть еще несколько основных принципов, о которых важно знать. Например, буферизованные и небуферизованные каналы предоставляют разные гарантии. Чтобы избежать неожиданных гонок, вызванных неющимианием основных спецификаций языка, посмотрим на модель памяти Go.
Модель памяти Go (https://golang.org/ref/mem) — это спецификация, определяющая условия, при которых чтение из переменной в одной горутине может гарантированно произойти только после записи в ту же переменную другой горутиной. Другими словами, она предоставляет определенные гарантии, о которых разработчики должны помнить, чтобы избежать гонки данных и обеспечить детерминированный результат.
В рамках одной горутины нет возможности несинхронизированного доступа. То, что одно действие происходит раньше другого, гарантируется порядком, заданным программой.
При работе с несколькими горутинами помните о некоторых из этих гарантий. Мы будем использовать выражение типа «A < B» для обозначения того, что событие A происходит до события B. Рассмотрим эти гарантии (некоторые из них скопированы из модели памяти Go):
- Создание горутины происходит до начала выполнения этой горутины. Следовательно, чтение переменной, а затем запуск новой горутины, которая производит записи в эту переменную, не приводит к гонке данных:
i := 0 go func() { i++ }()
- И наоборот, выход из горутины не обязательно произойдет до наступления какого-либо события. Следующий пример содержит гонку данных:
i := 0 go func() { i++ }() fmt.Println(i)
Если мы хотим предотвратить гонку данных, следует синхронизировать эти горутины.
- Отправка по каналу происходит до завершения соответствующего приема из этого канала. В следующем примере родительская горутина увеличивает значение переменной перед отправкой, а другая горутина считывает ее после чтения канала:
i := 0 ch := make(chan struct{}) go func() { <-ch fmt.Println(i) }() i++ ch <- struct{}{
Порядок такой:
variable increment < channel send < channel receive < variable read<
C помощью транзитивности мы можем обеспечить то, что доступ к i будет синхронизирован и, следовательно, не будет гонки данных.
- Закрытие канала происходит до получения замыкания. Следующий пример аналогичен предыдущему, за исключением того, что вместо отправки сообщения мы закрываем канал:
i := 0 ch := make(chan struct{}) go func() { <-ch fmt.Println(i) }() i++ close(ch)
Nostrmy otot npuimep takxe cbo6oJen ot roHKu Jанныx.
Nostrmy otot npuimep takxke cbo6oJen ot roHKu Jанныx.- NoC. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIOc. JIO. JIOc. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO.JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. J IO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIOJIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JI. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO.
JIOJIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIOJIOJIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. J IO. J IO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIOJIO.
i := 0ch := make(chan struct{}, 1)go func() {i = 1 <- ch}()ch <- struct{}{fmt.Println(i)
JIOJIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO.JIO. JIO. JIO.JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO. JIO.JIO. JIO. JIO
PUC. 8.9. EcrJn kanan foyepnusobah, To oTO npuBODUT K rONke JAnHbIX
J3meHnM kanJATAK, rTO6bI OH CTJJI He6yepnusobahHbIM, - JJIa JJIJIocCTpaIIN rapaHTnI CO cTOPOHbI MOJcJIIN IaMRTI:
i := 0ch := make(chan struct{}) Kahan JeraJCTa He6yepnusobahHbIMgo func() {i = 1 <- ch}()ch <- struct{}{fmt.Println(i)
B JTOJOM npuMEpe J3MeHHeHnE TIIIIa KahanJIa yCTpaHnE TONKy JAnHbIX (puc. 8.10). 3JecbMbI BJJIM IJIaBHoe: 3aJIINcB rapaHTnIPOBaHHO nPOuCXOJIIT JIO YTEHnIa. O6paTHTe
внимание, что стрелки не представляют причинно-следственную связь (хотя, конечно, получение невозможно без предварительной отправки); они представ- ляют собой гарантии порядка, предоставляемые моделью памяти Go. Поскольку прием из небуферизованного канала происходит до отправки, запись в i всегда будет происходить до чтения.
Puc.8.10. Ecrn kanan he 6yepersoban, To OH he npurodunt k rOHke dahhbx
B stom pasdene mi paccmotpe.nu ochobhne rapantnii moedni namrtu Go. Ix no- humahne dojxho jexahts b ochobe hanucahna konkypenthtoro koja u moxet y6e- pev6 ot heberhbx npeduzoxexnii u onyuehni, npurodxhnx k ronke dahhbx u coctozhrnio ronku.
B cJeyyioiem pasdene noroBopm, noyemy tak Baxko nonhmatb tunl pa6oye Ha- rpy3ku.
# 8.5. OlluBKA #59: HE NOHMATb BJINARHIE TUNA PA6OYEH HAFPY3KHA KOHKYPEHTHOCTb
B stom pasdene paccmatpubacra bJinHnue tuna pa6oyeH harpy3ku Ha konkypent- hbie peaJnusanin. B saBucimocntu ot toro, bJnIeret Jn kaka- to pa6oyar harpy3ka b 6oJbIeuei ctenenHn Ha npoueccop (CPU) nJn Ha cHctemy BbOJa/BBbOJa (I/O), cootBecTbryyioHue npo6JoxbI 6yJem peHnats no- pa3Homy. Jn naxaJia onpeJelHmcr c nonHtrHmH.
BpemBbinoHHeHnB pa6oyeH harpy3ku opraHnHeo oJHmHn3 cJeyyioHnX bAkcTOpOB:
TakTOBouH vacTOuO/ckopocTbIO pa6oTbI eHHTpaJbHoro npoueccopa: hanpHmep, sto rJaBhblH bAkcTO npu bblnoHHeHnI aJIropHTMa cOrTupObKu cJirHnHeM. Takar pa6oyar harpy3ka bAsbIaEcra CPU- bound. CkopocTbIO pa6oTbI cHcteMbl BbOJa/BBbOJa: hanpHmep, sto rJaBhblH bAkcTO ppu bblnoHHeHnI bIb3Oba REST nJn3anpoca K 6a3e JaHhbx. Pa6oyar harpy3ka B stom cJyvae bAsbIaEcra I/O- bound.
- Объемом доступной памяти: такая рабочая нагрузка называется memory-bound.
Примечание Последний фактор самый редкий, учитывая, что память в последние десятилетия сильно подешевела. Поэтому в этом разделе основное внимание уделяется двум первым типам рабочей нагрузки: CPU-bound и I/O-bound.
Почему так важно классифицировать рабочую нагрузку в контексте конкурент-ных приложений? Разберемся с этим, рассмотрев один из паттернов конкурент-ности: пул рабочих процессов (worker pooling).
В следующем примере реализована функция read, которая принимает io.Reader и многократно считывает из него 1024 байта. Мы передаем эти 1024 байта функции task, которая выполняет какие-то задачи (позднее увидим какие). Функция task возвращает целое число, а мы должны вернуть сумму всех результатов. Вот последовательная реализация:
func read(r io.Reader) (int, error) { count := 0 for { b := make([]byte, 1024) _, err := r.Read(b) 4 4 тение 1024 байт if err != nil { if err == io.EOF { 4 4 Остановка цикла, когда достигнут его конец break } return 0, err } count += task(b) Yвеличение count на величину, } return count, nil получающуюся на основе результата функции task }
Эта функция создает переменную count, считывает входные данные io.Reader, вызывает задачу и увеличивает count. А что будет, если мы захотим запускать все функции task параллельно?
Одним из вариантов является использование так называемого паттерна Worker Pool. Для этого необходимо создать воркеры (workers — рабочие процессы- горутины) фиксированного размера, которые опрашивают задачи из общего канала (рис. 8.11).
Сначала мы запускаем фиксированный пул горутин (позднее обсудим, сколько их будет). Затем создаем общий канал, в котором публикуем задачи после

[ImageCaption: Puc. 8.11. Kaxdara rorytna n3 qnckupobahhoro nyna nonyuaet dahnble us o6uero kahana]
Kaxdoro vtenur B io.Reader. Kaxdara rorytna n3 nyla nonyuaet dahnble n3 storo kahana, bbnolnhrer cbeo paoty, a satem atomarho o6hobraer o6nun cvetyuk.
Bor cnooc6 hanicarb cootnetctnyonnnn koa na Go c nylom b 10 rorytnh. Kaxdara rorytnha atomarho o6hobraer o6nun cvetyuk:
func read(r io.Reader) (int, error) { var count int64 wg := sync.WaitGroup{} Co3ahanue kahana var n = 10 emokctb1o, pabhou nny ch := make(chan []byte, n) wg.Add(n) 06aBneHne n k WaitGroup for i := 0; i < n; i++{ Co3ahanue nyna n3 n rorytnh go func(){ defer wg.Done() Bb13oB metoqa Done, kora rorytnha nonyuna dahnble n3 kahana for b := range ch { Kaxdara rorytnha nonyuaet pahhble n3 o6uero kahana v := task(b) atomic.AddInt64(&count, int64(v)) } }() } for{ b := make([]byte, 1024) // Ytenue n3 r b ch <- b 0y6nukauaH hOBOH3aDavB kahane nocne kaxdoro vtenue } close(ch) wg.Wait() 0xKudahue sabeprueHnra rpynnb Wait neped BosBpatom return int(count),nil }
В этом примере мы используем n для определения размера пула. Мы создаем канал с той же емкостью, что и этот пул, и группу ожидания (Wait) с дельной, равной n. Таким образом, мы уменьшаем потенциальную конкуренцию в роди-тельской горутине в процессе публикации сообщений. Мы повторяем эти дей-ствия n раз, чтобы создать новую горутину, которая получает данные из общего канала. Каждое полученное сообщение обрабатывается путем выполнения task и атомарного увеличения значения общего счетчика. После чтения из канала каждая горутина уменьшает группу ожидания.
В родительской горутине мы продолжаем читать из 1o, Reader и публикуем каждую задачу в канале. И последнее, но не менее важное: мы закрываем канал и ждем завершения работы группы ожидания (это будет означать, что все до- черние горутины завершили свою работу) перед возвратом.
Наличие фиксированного количества горутин ограничивает влияние недостат-ков, которые мы уже обсуждали. Это сужает влияние ресурсов и предотвращает переполнение внешней системы. А теперь встает основной вопрос: каким должно быть значение размера пула? Ответ зависит от типа рабочей нагрузки.
Если рабочая нагрузка — типа I/O- bound, то ответ зависит от внешней системы. С каким количеством конкурентных обращений сможет справиться система, если мы хотим максимизировать ее пропускную способность?
Если рабочая нагрузка — типа CPU- bound, то рекомендуется полагаться на GOMAXPROCS — переменную, которая устанавливает количество потоков OC, выде-ленных для выполнения горутин. По умолчанию это значение равно количеству логических процессоров.
# Использование runtime.GOMAXPROCS
Мы можем использовать функцию runtime.GOMAXPROCS (int) для обновления значения GOMAXPROCS. Вызов с 0 в качестве аргумента не менее, а просто воз- вращает текущее значение:
n := runtime.GOMAXPROCS(0)
Итак, зачем нужно сопоставление размера пула с GOMAXPROCS? Рассмотрим пример и предположим, что будем запускать наше приложение на четырехъядерной машине. Таким образом, Go создаст четыре потока OC, в которых будут выполняться горутины. Поначалу все может быть неидеально: мы можем столкнуться со сценарием, когда есть четыре ядра CPU и четыре горутины, но при этом вы- полняться будет только одна горутина, как показано на рис. 8.12.

[ImageCaption: Puc. 8.12. BbinoJHAreTcA He GoJee oJHOuI rOpyTHHbI]
Ha M0 BbinoJHAreTcA rOpyTHHa H3 nyyJia pa6Oyux npOIEccOcB. CJJeJOBaTeJbHO, BcE 3TH rOpyTHHbI HauHbAaRrO nOyJy4aTb COO6JHeHbI H3 KaHaJIa H BbinoJHraTb CBOH 3aJaH Hua. Ho JJIa tpex JpyTHx rOpyTHH H3 nyyJia eIIe He Ha3HaaHebI COO6TBeCTbYIOIIe M. CJJeJOBaTeJbHO, OHH HaxOJIaTcA B COcTOaHHH H rOTOBHOcTbI K BbinoJHeHHHO. M1, M2 H M3 He UMHeOT rOpyTHH, KOTOpBbE MOJIJI 6bI Ha HIX BbinoJHraTbcA, HOSTOMy OCTaIOTcA He y Jel. TAKHM O6pa3OM, pa6OtaeT TOJIbKO OJIHa rOpyTHHa.
B KoHHe KOHIOB, yHHTbHaB aKOHHeIIHIO KpaXH 3aJIa, KOTOpyO MbI yXe OINcBbBaJI, P1 MOXeT yKpaCTb rOpyTHHbI H3 JOKaJIbHOuI OJepeDJI PO. Ha puc. 8.13 P1 yKpaJI TPH rOpyTHHbI y PO. B 3TOuI cHryaIIHn IJIaHnPObIIHK Go TaXxe MOXeT Ha3HaaHTb B uTOre BcE rOpyTHHbI JpyroMy nOTOky OC, HO HeT HHKaKOuI OIppeJcJIeHHOCTH OTHOcHTeJIb- HO ToIO, KOJIa STO JOJIXHe pOPO3OuITH. HO nOcKOJIbKy OJIHOI H3 OCHOBHbIX IIeJIeI nJIaHnPObIIHKa Go aBJJIeTcA OHTHM3aIIaIH pecypcOB (B aHaHOM cJIyvae Ha3HaaHeHHe BbinoJHeHHe H rOpyTHH pa3JIuHbHbIMH M), MbI aOJIJIXHbI nPIuITH K cIIeHaPIIO pUC. 8.13 c yJeTOM XapaKTEpa pa6Oyux HaRPy3Ok:
OTOT cIIeHaPIuI HO- IppeXHeMy He OHTHMaJIeH, HOTOMY YTO pa6OtaeT He 6OJIee JbYX rOpyTHH. JOnIyCTHM, HaMaIIHe HaanyIIeHO TOJIbKO HaIIe nPIuJIOXeHeHe (KPOMe nPO- IeEcCOB OC), HO3TOMY P2 H P3 OCTaIOCTcA CBO6OJIbHMH. B KoHHe KOHIOB OC JOJIXHa 3aJeIcIcTbOBaTb M2 H M3 TaK, KaK nOKa3aHO Ha pUC. 8.14.
3JeeCb nJIaHnPObIIHK OC peIIIIH Ha3HaaHTb M2 Ha P2 H M3 Ha P3. ONIaTb Xe, HeT HHKaKOuI yJepeHHOCTH, KOJIa STO nPOu3OuIeT. HO yHHTbHaBa, HO MaIIuHa BbinoJHraT
To лько наше четырехпоточное приложение, это должно быть окончательной картиной.

[ImageCaption: Puc.8.13. Bbnonhrercta he Bonee byx ropytnn]

[ImageCaption: Puc.8.14. Bbnonhrercta he Bonee vebipex ropytnn]
Cuntyaia usmenilacb - ona ctala ontnumalbnoi. Vebipe ropytnb binnonhrotrc B otJelbhbix notokax, a notoku ha otJelbhbix rJpax. Taonl nJaxoJ ymenbinaet konJuectbO nepekJJoueHnui kOHTEKCTa kak Ha yPOBHe ropytnH, tak H Ha yPOBHe notokOB.
Ota rJIO6aJbHaar kaptnHa He MOxet 6bIb pa3pa6otaha H sainponnena HaMH (npo- rpamMnctamH Go). Ho xak Mbl BnJeJn, Mbl MOxem J66Hbcsa takon 6JaronpnurHnO 6ntyaHnH b CJyvae pa66Hux harpy3ok tuna CPU- bound, to cectb npH haJnHnH nJyJa pa66Hux npoueccOB H Ha OCHOBe GOMAXPROCS.
PРИМЕЧАНИЕ Если при каких-то особых условиях мы захотим, чтобы количество горутин было привязано к количеству ядер CPU, почему бы не положиться на функцию runtime. NumCPU(), которая возвращает количество логических ядер CPU? Как я говорил, параметр GOMAXPROCS может быть из- менен и может быть меньше, чем количество ядер CPU. В случае рабочей нагрузки типа CPU- bound, если количество ядер равно четырем, но есть только три потока, нужно запустить три горутины, а не четыре. В противном случае поток будет делить время выполнения между двумя горутинами, увеличивая количество переключений контекста.
При реализации паттерна Worker Pool мы увидели, что оптимальное количество горутин в пуле зависит от типа рабочей нагрузки. Если рабочая нагрузка, вы- полняемая рабочими процессами, является I/O- bound, то это значение зависит от внешней системы. И наоборот, если рабочая нагрузка будет типа CPU- bound, то оптимальное количество горутин близко к количеству доступных потоков. Знание типа рабочей нагрузки (I/O- или CPU- bound) очень важно при разработке конкурентных приложений.
И последнее, но не менее важное: помните, что в большинстве случаев нужно проверять предположения с помощью бенчмарков. Конкурентность — не простой принцип, и отталкиваясь только от нее, можно сделать поспешные предположения, которые окажутся неверными.
В последнем разделе этой главы обсудим важнейшую тему в Go — контексты.
# 8.6. ОШИБКА #60: НЕВЕРНО ПОНИМАТЬ КОНТЕКСТЫ GO
Разработчики часто неправильно понимают тип context. Context, несмотря на то что он является одним из ключевых понятий языка и осполой конкурентности в Go. Изучим эту концепцию и убедимся, что мы понимаем, как эффективно ее использовать. Согласно официальной документации (https://pkg.go.dev/context):
Контекст переносит крайний срок, сигнал отмены и другие значения через границы API.
Разберемся в этом определении и во всех концепциях, связанных в Go с кон- текстом.
# 8.6.1. KpaHHHUKOOK
KpaHHHUKOOK (deadline) yka3bIaET Ha HeKHH MOMeHT BpeMeHn, OnpeDEJIeMeHbIH OJHMn H3 CJIeJIYIOIIIX CIOOcO6OB:
time.Duration c haCTOHIIIOIO MOMeHTa (HaIIpHMEp, YepES 250 Mc); time.Time (HaIIpHMEp, 2023- 02- 07 00:00:00 UTC).
CemaHTHKa KpaHHero cPpKa O3Ha4aET, VTO TeKyIIaA JeaTeCJIbHOCTb OJI3KHa 6bIb OCTaHOBJIeHa, eCJIu STOT KpaHHHUK PpKa HaCTyIIII. <DeTaTeJIbHOCTb> 3TO, HaIIpHMEp, SaIIpOC TIIIIa BBIOJI/BBIBOJI UIIU rOpyTIHa B COCTOaHHHIN OXIIaHHIN IOJIyHeHHIN COO6IIeHHIN H3 KaHaJIa.
PaccMOrpHM pIIyIOXeHHHe, KOTOpoe KaXKbIe 4 cekyHbIH IOJIyIaET OT paJIapa aHHbIe O IO3IIIIIII cAmOJIeTa. IOJIyIaHb IO3IIIIIIIO, MbI XOITHM IOJIeJIHTbCra EIO c APyIHM H pIIyIOXeHHHHMn, JJIa KOTOpbIX uHHepeC npeJIcTaBJIeT ToJIbIKo NOcJIeJIHbIH IO3IIIIIII.
B HaIIHeM paCIIOpXeHHHIN cCTb uHHeTpHeIc pubIIshHeR, COJIePraaIIIIb I b Ce6e OJIHH- eJIHHCTBeHHbIHbI MeTOJI:
type publisher interface { PubliSh(ctx context.Context, position flight.Position) error }
OTOT MeTOJI pIIyHHMaeT KOHHeKCT H IO3IIIIIIIO. IPpeJIIOJIaJIaTeCTa, VTO KOHKpHTHaa peaJI3aIIIIa BbI3bIbAeT OyHkIIIIIO JJIa NY6JIHKaIIIII COO6IIeHHIN OPOKepy (HaIIpHMEp, HcIIOJIb3OBaHHIe Sarama JJIa NY6JIHKaIIIII COO6IIeHHIN KafKa). OTa OyHkIIIIa KOHHeKCTmHO 3aBucUMaIa (context aware), STO O3Ha4aET, VTO OHa MOKeT OTMeHHITb SaIIpOC IOcJIe OTMeHHI KOHHeKCTa.
IPpeJIIOJIaIaR, VTO MbI He IOJIy4aEM cyIIeCTBbIIOIIIII KOHHeKCT, VTO HYXHO npeJIOCTa- bIbTb MeTOJIy PubliSh BKa4eCTBe aPryMeHTa KOHHeKCTa? R rOBOpII, VTO JJIa DPyIHX pIIyIOXeHHIN HYXHa uHHepHMaIIIIa ToJIbIKO O cAmOI bIOcJIeJIHeH InO3IIIIII. IOJITOMIy CO3JIaBaeMebIH KOHHeKCT OJIJIXeH COO6IIaTb O HeIb YepES 4 cekyHbIH, a eCJIu MbI He cMOrJIu IOJIy6JIHKOBaTb IO3IIIIIIIO, TO cJIeJIJIYET OCTaHOBHIb BHa3OB PubIIsh:
type publishHandler struct { pub publisher C03a3HHe KOHHeKCTa, KOOpbIH OyIeT OXIIaJIaTb ToJIbKO 4 cekyHbIH func (h publishHandler) publishPosition(position flight.Position) error { ctx, cancel := context.WithTimeout(context.Background(), 4\*time.Second) defer cancel() OTKJIaJIbBaHHHe OTMeHbI return h.pub.Publish(ctx, position) IepeJIa4a CO3JIaBaHeO KOHeKCTa }
Этот код создает контекст с помощью функции context.WithTimeout, которая принимает тайм- аут и контекст. Поскольку publishPosition не получает существующий контекст, мы создаем его из пустого контекста с помощью context. Background. Между тем context.WithTimeout возвращает две переменные: созданный контекст и функцию отмены func(), которая отменит контекст после вызова. Передача созданного контекста в метод Publish должна произойти не позднее чем через 4 секунды.
В чем смысл вызова функции cancel как функции defer? Внутри себя context. WithTimeout создает горутину, которая будет храниться в памяти в течение 4 секунд или до тех пор, пока не будет вызвана cancel. Следовательно, вызов cancel в качестве функции defer означает, что при выходе из родительской функции контекст будет отменен, а созданная горутина остановлена. Это мера предосторожности, чтобы при возвращении мы не оставили в памяти сохраненные объекты.
Теперь перейдем к отвороту аспекту контекстов Go — сигнал отмены (cancellation signals).
# 8.6.2. Сигналы отмены
Другой вариант использования контекстов в Go — передача сигнала отмены. Допустим, нужно создать приложение, которое вызывает CreateFileWatcher(ctx context.Context, filename string) внутри другой горутины. Эта функция создает специальный файловый монитор (file watcher), который постоянно читает файл и улавливает его обновления. Когда предоставленный контекст становится неактуальным или отменяется, эта функция обрабатывает его, чтобы закрыть дескриптор файла.
Наконец, когда происходит возврат из main, мы хотим, чтобы все обрабатывалось корректно путем закрытия этого файлового дескриптора. Поэтому нам нужно передать сигнал. Возможный подход — использовать context.WithCancel, возвращающий контекст (возвращается первая переменная), который отменяется после вызова функции cancel (возвращается вторая переменная):
Kогда происходит возврат из main, происходит и вызов функции cancel для от- мены контекста, переданного в CreateFileWatcher, — чтобы дескриптор файла корректно закрылся.
Обсудим еще один аспект контекстов Go: значения.
# 8.6.3. Значения контекстов
Последний вариант использования контекстов Go — перенос списка «ключ — значение». Для начала посмотрим, как это можно применить.
Контекст, передающий значения, может быть создан так:
ctx := context WithValue(parentCtx, "key", "value")
Как и context.WithTimeout, context.WithDeadline и context.WithCancel, context. WithValue создается из родительского контекста (в данном примере parentCtx). В этом случае мы создаем новый контекст ctx, содержащий те же характеристики, что и parentCtx, но также передающий ключ и значение.
Можно получить доступ к значению, используя метод Value:
ctx := context WithValue(context.Background(), "key", "value") fmt.Println(ctx.Value("key")) value
Задаваемые ключ и значения имеют тип any. Действительно, для значений мы хотим передавать тип any. Но почему ключ должен быть еще и пустым интер- рейсом, а не, например, строкой? Потому, что это может привести к коллизиям: две функции из разных пакетов могут в качестве ключа использовать одно и то же строковое значение. Следовательно, последнее из них переопределит предыдущее. Лучшей практикой при обработке контекстных ключей будет создание неэкспортируемого пользовательского типа:
```cpppackage providertype key stringconst myCustomKey key = "key"func f(ctx context.Context) { ctx = context.WithValue(ctx, myCustomKey, "foo") // ...}```
Контанта myCustomKey не экспортируется. Поэтому нет риска, что другой пакет, использующий тот же контекст, может переопределить уже заданное значение.
Даже если другой пакет создает тот же myCustomKey на основе типа key, это будет другой ключ.
Так какой смысл иметь контекст, содержащий список «ключ — значение»? Поскольку контексты Go повсеместны, есть множество сценариев использования.
Например, при трассировке может потребоваться, чтобы разные подфункции использовали один и тот же идентификатор корреляции. Некоторые разработчики могут посчитать его слишком агрессивным (invasive), чтобы быть частью сигнатуры функции. В связи с этим мы могли бы также решить включить его как часть задаваемого контекста.
Другой пример: если мы хотим реализовать промежуточное ПО HTTP. Если вы не знакомы с таким понятием, то поясню: промежуточное ПО (middleware) — это промежуточная функция, выполняемая перед выполнением запроса. Например, на рис. 8.15 мы настроили два промежуточных модуля, которые должны быть выполнены перед выполнением самого обработчика. Если требуется, чтобы промежуточные программы взаимодействовали, они должны пройти через контекст, обработанный в *http.Request.
Pис. 8.15. Прежде чем достичь обработчика, запрос проходит через настроенное сконфигурированное промежуточное ПО
Создадим пример промежуточного ПО, которое отмечает, является ли исходный хост валидным:
type key string const isValidHostKey key $=$ "isValidHost" Cоздание ключа контекста func checkValid(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter,r "http.Request) { validHost : $=$ r.Host $= =$ "acme" IpoBepka banuHocctx xocta ctx : $=$ context WithValue(r.Context(), isValidHostKey, validHost) next.ServeHTTP(w,r.WithContext(ctx)) $\left.\frac{1}{\sqrt{3}}\right)$ Cоздание нового контекста co } Bb130B cneayouiero wara shavehnem, vTo6bi coouuhtb, c HOBbIM KoHTeKCTOM ADuCTBUTeNeH nI uCXOaHbI XOCT
Сначала мы определяем специфический контекстный ключ isValidHostKey. Затем промежуточное ПО checkValid проверяет, валиден ли исходный хост. Эта информация передается в новом контексте, который далее передается на следующий шаг HTTP с помощью next.ServeHTTP (следующим шагом может быть другое промежуточное ПО HTTP или конечный обработчик HTTP).
Этот пример показал, как контекст со значениями можно использовать в конкретных приложениях. В предыдущих разделах мы видели, как создавать контекст для передачи крайнего срока, сигнала отмени и/или значений. Мы можем использовать этот контекст и передавать его контекстно-зависимым библиотекам, то есть библиотекам, содержащим в себе функции, которые принимают контекст.
Теперь представим, что нужно создать библиотеку и требуется, чтобы какие-то внешние клиенты предоставляли контекст, который можно было бы отменить.
# 8.6.4. Перехват отмены контекста
Тип context.Context экспортирует метод Done, который возвращает односторонний канал уведомления (то есть он может только получать): <- chan struct{}. Этот канал закрывается, когда работа, связанная с контекстом, должна быть отменена. Например:
- Канал Done, связанный с контекстом, созданным с помощью context. WithCancel, закрывается при вызове функции cancel.- Канал Done, связанный с контекстом, созданным с помощью context. WithDeadline, закрывается по истечении крайнего срока.
Внутренний канал следует закрывать, когда контекст отменяется или наступает крайний срок, а не тогда, когда он получит некоторое определенное значение, потому что закрытие капяла — это единственное действие с ним, сигнал о котором получат все горутины-потребители. Таким образом, все потребители будут уведомлены об отмене контекста или достижении крайнего срока.
Более того, context.Context экспортирует метод Err, возвращающий nil, если канал Done еще не закрыт. В противном случае возвращается ненулевая ошибка, объясняющая, почему канал Done был закрыт. Например:
- ошибка context.Canceled, если канал отменен;- ошибка context.DeadlineExceeded, если крайний срок действия контекста прошел.
# Peanhsaция yhkuin, nonyvaiouei kontekct
B yhkuin, kotopar nonyvaet kontekct, coo6uaioyin o bosmoxhou otmene unu taum- ayte, deicstbna no nonyvenuo unu otnpaBke coo6ueHnB kaHaHHe JonxHbI BbinoJnHrTbca 6JoknyoHyHm o6pa3om. HanpHmer, B cJedykoUeA yHkHyHn Mbl OTnpaBnJreM coo6ueHnEe B kaHaH n nonyvaem ero n3 Jpyroro kaHaJa:
func f(ctx context.Context) error { // ch1 <- struct{}} // ...}
Pp6nema 3decb B ToM, uto eCnI KOnHKecr OTmehreTcA unu Hctekaet BpeM, B Te- vHHe kotoporo on aKtyaJeh, To, BosmoxHo, npudetcr oxupaTb, noka coo6ueHne He 6ydet OTnpaBHeHOn unu nonyvHeno. BMeCTo SToro Mbl JONxHbI cncOJIb3OBaTb select nu6o dJr oxupaHnra sabepeHnHr deicstbHn kaHaJa, nu6o dJr oxupaHnA OTMeHbI KOnHKecrTa:
func f(ctx context.Context) error { // ...select { ← OtnpaBka coo6ueHnB ch1 unu oxupaHne OTmehb KOnHKecrTacase <- ctx.Done():return ctx.Er()case ch1 <- struct{}{}:}select { ← OnyvHne coo6ueHnA n3 ch2 unu oxupaHne OTmehb KOnHKecrTacase <- ctx.Done():return ctx.Er()case v := <- ch2://...}
B 3TOH HOBON BEpcHm, eCnI cTx OTmehreTcA unu sabepeHaEcTcA, Mbl HeMeJnHHO BosBpaueMcr, He 6Jonnypya kaHaJ OTnpaBku unu nonyvHnHn.
PaccMortpM npHmer, B kotopom tpe6yetcr n aJaJbIe noyvHtB coo6ueHnHn H3 kaHaJa. B To xe BpeMn HaHa peaJn3aHnA JONxHa yHunbIBaTb KOnHKecr H BOSBpaHnTb 3HaVHeHe, eCnI npeJooTabJIeHHbIi KOnHKecr BBInOJIHeH:
func handler(ctx context.Context, ch chan Message) error {for {select {case msg := <- ch: ← PpOJONxHeHe nonyvHnHn coo6ueHnHr ot ch// Kakne- To deicstbna c msg
case <- ctx.Done(): $\leftarrow$ Eсли контекст выполнен, возврат связанной с ним ошибки return ctx.Er(- ) } } }
Мы создаем цикл for и используем select в двух случаях: получение сообщений от ch или получение сигнала о том, что контекст выполнен и работу надо остановить. Это пример того, как сделать функцию контекстно зависимой.
Опытные Go- разработчики должны понимать, что такое контекст и как его использовать. context. Context доступен в стандартной библиотеке и во всех внешних библиотеках. Как я говорил, контекст позволяет передавать крайний срок, сигнал отмены и/или список «ключ — значение». В общем случае функция, на которую пользователи ожидают ответа, должна принимать контекст, поскольку это позволяет вызывающим сторонам решать, когда прервать вызов этой функции.
Если вы сомневаетесь, какой контекст использовать, выбирайте context. TODO() вместо передачи пустого контекста с помощью context. Background. context. TODO() возвращает пустой контекст, но семантически сообщает, что используемый контекст либо неясен, либо еще недоступен (например, еще не передан родителем).
Все доступные контексты в стандартной библиотеке безопасны для конкурентного использования несколькими горутинами.
# ИТОГИ
- Понимание фундаментальных различий между конкурентностью и параллелизмом — краеугольный камень в знаниях Go-разработчика. Конкурентность — это о структуре, тогда как параллелизм — о выполнении.- Конкурентность не всегда ведет к более быстрым решениям. Варианты, предусматривающие распараллеливание минимальных рабочих нагрузок, не обязательно будут быстрее, чем последовательная реализация. Сравнение бенчмарков решений с последовательной и конкурентной реализацией должно быть способом проверки допущений.- Знание того, как горутины взаимодействуют друг с другом, полезно, когда вы выбираете между каналами и мыотексами. Обычно параллельные горучины требуют синхронизации и, следовательно, использования мыотексов. Конкурентные горучины обычно требуют координации и оркестровки и, следовательно, использования каналов.
- Гонка данных и состояние гонки
- это разные понятия. Гонка данных происходит, когда несколько горутин одновременно обращаются к одной и той же ячейке памяти и по крайней мере одна из горутин выполняет запись в эту ячейку. Отсутствие гонки данных не обязательно означает детерминированность в результате выполнения неких действий. Когда поведение зависит от последовательности или времени наступления событий, которые невозможно проконтролировать, то получается состояние гонки.
- Понимание модели памяти в Go и лежащих в ее основе гарантий с точки зрения упорядочения и синхронизации важно для предотвращения возможной гонки данных или состояния гонки.
- При создании нескольких горутин учитывайте тип рабочей нагрузки. Если создаются CPU-bound горутины, то это число должно ограничиваться значением переменной GOMAXPROCS (которая по умолчанию отражает количество ядер CPU на хосте). При создании I/O-bound горутин следует учитывать характеристики внешней системы.
- Контексты в Go также очень важны для понимания конкурентности. Контекст позволяет передавать информацию о крайних сроках, сигналы отмены и/или списки «ключ
- значение».
# 9
# Bэтой главе:
- Предотвращение типичных ошибок при работе с горутинами и каналами- Последствия применения стандартных структур данных вместе с конкурентным кодом- Использование стандартной библиотеки и некоторых расширений- Предотвращение гонки данных и взаимоблокировок
B предыдущей главе мы рассмотрели базовые понятия конкурентности. Теперь поговорим об ошибках, возникающих при работе с примитивами конкурент-ности.
# 9.1. Ошивка #61: ПЕРЕДАВАТЬ НЕподходящий КОНТЕКСТ
Контексты — это неотъемлемая часть работы с конкурентностью в Go, их рекомендуется передавать во многих ситуациях. Но иногда передача контекста может
приводить к малозаметным ошибкам, мешающим правильному выполнению подфункций.
Paccmotpum nprep, rre peanusyem HTTP- o6pa6otyuk, binoJnHnouinii he- kotorbie saJauu u bospbpaiaouinii otbet. Ho npexde vem bepnyts otbet, ero hyxho onipabuTb B teMy Kafka. Ml he xotum hakasbIaTb HTTP- notpe6nteJra kakoii- nu6o saJepxkoi, noStomy deicstbue ny6JukaHnI oJxho o6pa6atbIaTbca acинxpoHHo - B HOBou iropyTunHe. Ml npeJIOJIaraeM, vTO y Hac cctb bynKlIa publish, npuHHmaIOHaar konTecKt, vTO6bI deicstbue ny6JukaHnI COO6IeHnIa moxho 6bIIO npepbaTb, ecJiu, hanpumep, konTecKt otmenen. Bot BosmoxHaar peaJIN3aIuIa:
func handler(w http.Responsewriter, r \*http.Request) { BbnonHreTca heKotopar saJaa response, err : $=$ doSometask(r.Context(), r) no bHicneHnIO otBeta HTTP if err $! =$ nil{ http.Errorw, err.Error(), http.StatusInternalServerError) return } go func() { Co3anHue rOpyTHbI aJn ny6JukaHnI otBeta B Kafka err : $=$ publish(r.Context(), response) // Kakue- to deicTbHc c err }() writeResponse(response) 3anucb HTTP- otBeta }
Chavала bIbIbIaem bynKlIuI0 doSometask, vTO6bI noJIyvHtI, nepemennyI response. Oha ucnoJIb3yetcrs B rOpyTHe, bIbIbIaIOIeI publish, u JIA dOpmATupOBaHnIa HTTP- otBeta. При bIbIbObe publish Mbl nepedaeM konTecKt, npukpenJIeHbIa K HTTP- saIpocy. Kak bbl dyMaete, vTO He TaK c 3TUM koJOM?
Mbl J0JxHbI 3HaTb, vTO konTecKt, npukpenJIeHbIa K HTTP- saIpocy, moxet 6bIb OTmenen npu heKotopRix yCJIbObIaX:
KorJa coeJинeHue kJIneHTa sakpIbIaEcra. B cJIyvae saIpoca HTTP/2, korJa OH oka3bIbIaEcra otmeneHnHbI. KorJa otBET 6blJ HaIpaBJIeH KJIueHTy o6paTHO.
B nepBbIX Abyx cJIyvAaX Mbl, BeporIhO, nocTynaeM npaBJIbHbO. Hanpumep, ecJIu noJIyvaeM otBET OT doSometask, HO npu 3TOM KJIueHT sAKpIaI coeJIHHeHe, TO HopMaJIbHO 6yJet bIbIbATbI publish c yXe OTmeneHnHbIM konTecKtOM, TaK kak COO6IeHHe He 6yJet ony6JHKOBaHIO. Ho kak 6bIb c nocJIeJHIM cJIyvaeM?
B To BpeMx kak otBET 6yJet OTIpBaJIrTbcr KJIueHTy, konTecKt, cBraaHbIbI c saIpOcOM, 6yJet OTmenHrTbcr. TakIM o6pa3OM, Mbl cTaJIKIBaEcra c COcTOaHHeM rOHKu:
- Если ответ отправлен после публикации в Kafka, то мы и возвращаем ответ, и успешно публикуем сообщение.- Но если ответ написан до или во время публикации в Kafka, то сообщение не должно быть опубликовано.
В последнем случае вызов publish приведет к возврату ошибки, потому что мы быстро вернули ответ HTTP.
Как решить эту проблему? Одна идея состоит в том, чтобы не передавать родительский контекст. Вместо этого мы вызовем publish с пустым контекстом:
Вот это сработает. Независимо от того, сколько времени потребуется для обратной записи HTTP- ответа, мы можем вызывать publish.
Но что, если контекст содержит полезные значения? Например, если бы контекст содержал идентификатор корреляции для распределенной трассировки, мы могли бы соотнести HTTP- запрос и публикацию в Kafka. В идеале нам бы хотелось иметь новый контекст, который не зависит от возможной отмены родительского контекста, но все же передает значения.
Стандартный пакет не предоставляет немедленного решения этой проблемы. Поэтому возможным решением будет реализация собственного контекста Go, аналогичного предоставленному контексту, за исключением того, что он не несет сигнала отмены.
Context.Context — это интерфейс, содержащий четыре метода:
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <- chan struct{ Err() error Value(key any) any }
Крайние сроки, содержащиеся в контексте, обрабатывает метод Deadline, а сигналами отмены управляют методы Done и Err. Когда какой- то крайний срок прошел или контекст был отменен, Done должен вернуть закрытый канал, a Err — ошибку. Наконец, все значения передаются с помощью метода Value.
Создадим собственный контекст, который отвязывает сигнал отмены от родительского контекста:
3a ucklnoyehnem metoda Value, kotopbii bbi3biaet podnterbcknii kontekct dia usbnyehnus shayehnus, apyrue metoai bosbpaiaot shayenue no ymo/yaanuo, no- otomy kontekct hikorua he cuntaeetsa npcpovehnbiM uin otmenenhbiM.
Icnolbysa cbono nolbouanterbcknii kontekct, Mb moxem bia3aTb ny6/ukaa11o u otb3aTb cunha/ oTMenbi:
Teneps nepedanhbiu d n6/ukaa11u kontekct hikorua he okazketc yctapebunm u/in otmenenhbiM on 6ydet hectu b cefe bce shayehnus n3 podnterbckoro kontekcta.
Takum o6pa3om, nepedabatb kontekct cJedyet octropoxho. 1nokasal sto ha npumere o6pa6otku acинxpoHHoro deicbtra ha ochobe kontekcta, cBraahHoro c HTTP- 3anpcocm. Iocko/aky kontekct otmeneretsa b to bpeM, kora Mb bosbpaiaem otBtet, acинxpoHHoe deicbTbe moxet 6b1b heoxnudanho octanobJeho. IomHute o noc/edctbHax nepedayu, aahHoro kontekcta u o tom, vto, d3a konkpethoro deic- ctbura bcer/aa moxho co3aTa6 co6ctbennbhiK kontekct.
B cJedy1ouem pa3aJe o6cyJIM sa1yck ropytnHb 6e nIa1a no ee octanohke.
# 9.2. OWWBA #62: 3A1YCKATb FOPYTHHY M HE 3HATb, KOFDA EE OCTAHOBUTb
IopytHb1 sa1yckatb JERko u deHHeo, npuyem hactoJbko, vTO bbi moxete He noHmmtb, kora cJedyet octanabJInBaTb HOByIO ropytHny, a 3TO moxet npubOJHTb
к утечкам. Незнание того, когда остановить горутину, является проблемой про- ектирования и распространенной ошибкой конкурентности в Go. Разберемся, как ее предотвратить.
Для начала давайте определим, что такое утечка горутины. С точки зрения потребления памяти горутина требует минимального размера стека в 2 Кбайт, который может меняться по мере необходимости (максимальный размер стека составляет 1 Гбайт для 64-разрядных и 250 Мбайт для 32-разрядных систем). Горутина может содержать ссылки на переменные, место под которые выде- лено и зарезервировано в куче. Между тем горутина может содержать такие ресурсы, как HTTP-соединения или соединения с базой данных, открытые файлы и сетевые соцеты, которые в итоге должны быть корректно закрыты. Если происходит утечка горутины, то будут и утечки таких ресурсов.
Рассмотрим пример, в котором момент остановки горутины неясен. Здесь родительская горутина вызывает функцию, которая возвращает канал, а затем создает новую горутину, которая продолжает получать сообщения от этого канала:
ch := foo() go func() { for v := range ch { // ... } }()
Созданная горутина завершится, когда ch будет закрыт. Но знаем ли мы точно, когда такое закрытие произойдет? Это может быть неочевидно, потому что ch создается функцией foo. Если канал никогда не будет закрыт, то возникнет утечка. Нужно быть осторожными с точками выхода из горутины и убедиться, что они достигнуты.
Обсудим пример. Разработаем приложение, которое должно отслеживать некоторую внешнюю конфигурацию (например, с помощью подключения к базе данных). Вот его первая реализация:
func main() { newWatcher() // Запуск выполнения приложения } type watcher struct { /* Некоторые ресурсы */ } func newWatcher() { w := watcher{} go w.watch() —— Создание горутины, отслеживающей некоторую внешнюю конфигурацию }
Мы вызываем newWatcher, который создает структуру watcher и запускает горути- ну, отвечающую за наблюдение за конфигурацией. Проблема с этим кодом в том, что при выходе из основной горутины (возможно, из-за сигнала OC или из-за того, что она имеет конечную рабочую нагрузку) приложение останавливается. Ресурсы, созданные в watcher, не закрываются корректно. Как предотвратить это?
Oдин из вариантов — передача в newWatcher контекста, который будет отменен при возвращении main:
func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() newWatcher(ctx) —— Передача в newWatcher контекста, который в дальнейшем будет отменен // Запуск выполнения приложения}func newWatcher(ctx context.Context) { w := watcher{} go w.watch(ctx) —— Распространение этого контекста}
Мы передаем созданный контекст на метод watch. Когда контекст отменяется, структура watcher должна закрыть свои ресурсы. Но есть ли гарантия, что y watch будет время, чтобы это сделать? Конечно нет. И это недостаток дизайна нашего кода.
Проблема в том, что мы использовали подачу сигнала, чтобы сообщить, что го- рутина должна быть остановлена. Мы не заблокировали родительскую горутину до тех пор, пока ресурсы не окажутся закрыты. Сделаем это:
func main() { w := newWatcher() defer w.close() —— Откладывание вызова метода close // Запуск выполнения приложения}func newWatcher() watcher { w := watcher{} go w.watch() return w}func (w watcher) close() { // Закрытие ресурсов}
Теперь в watcher есть новый метод — close. Чтобы не посылать в watcher сигнал о том, что пришло время закрыть его ресурсы, теперь вызывается метод close с помощью defer, что обеспечивает закрытие этих ресурсов до выхода из приложения.
Горутина — это такой же ресурс, как и любой другой, который должен быть закрыт для освобождения памяти или других ресурсов. Запуск горутины без понимания того, когда ее остановить, — это проблема дизайна программы. Всякий раз, когда горутина запускается, вы должны четко понимать, когда она остановится. И последнее, но не менее важное: если горутина создает ресурсы и время ее жизни принязано к времени жизни приложения, то перед выходом из приложения, вероятно, будет безопаснее дождаться завершения этой горутины. Это гарантирует, что ресурсы будут освобождены.
Теперь обсудим одну из самых типичных ошибок при работе в Go — неправиль- ное обращение с горутинами и переменными цикла.
# 9.3. ОШИБКА #63: НЕОСТОРОЖНО ОБРАЩАТЬСЯ С ГОРУТИНАМИ И ПЕРЕМЕННЫМИ ЦИКЛА
Неправильное обращение с горутинами и переменными цикла — это, пожалуй, одна из самых распространенных ошибок в Go при написании конкурентных приложений. Рассмотрим пример и определим условия, при которых эта ошибка возникает, а также узнаем, как ее предотвращать.
В следующем примере инициализируется срез. Затем в замыкании, которое вы- полняется как новая горутина, мы получаем доступ к этому элементу:
s := []int{1, 2, 3} for _, i := range s { ➡— Итерации по каждому элементу go func() { fmt.Print(i) ➡— Обращение к переменной цикла }() }
Мы могли бы ожидать, что этот код выведет 123 в произвольном порядке (поскольку нет гарантии, что горутина, созданная первой, первой и завершится). Но вывод этого кода — не детерминированный. Иногда он выводит 233, а иногда 333. Почему?
В этом примере мы создаем новые горутины внутри замыкания. Напоминаем, что замыкание — это функция, которая ссылается на переменные вне своего тела: в данном случае это переменная i. Помните, что когда горутина выполняется из замыкания, она не фиксирует значения, которые были при ее создании. Вместо этого все горутины ссылаются на одну и ту же переменную. Когда горутина запускается, она выводит значение переменной i на тот момент,
когда выполняется fmt.Print. Следовательно, с момента запуска горутины i могла измениться.
На рис. 9.1 показан возможный вариант выполнения кода, когда он выводит 233. Со временем значение i меняется: 1, 2, а затем 3. На каждой итерации мы запускаем новую горутину. Поскольку нет гарантии, когда каждая горутина запустится и завершится, то и результат может быть равным. В этом примере первая горутина выводит значение i, когда оно равно 2. Затем другие горутины выводят i, когда оно уже равно 3. Этот пример выводит 233. Поведение этого кода не детерминировано.
Pис. 9.1. Горутины обращаются к переменной i, которая не является фиксированной, а меняется со временем
A что делать, если нужно, чтобы каждое замыкание обращалось к тому значению, которое имела переменная i при создании горутины? Если мы хотим продолжать использовать замыкание, то первый вариант предлагает создание новой переменной:
for _, i := range s { val := i —— Создание переменной, пожальной для каждой итерации go func() { fmt.Print(val) }()
Почему этот код будет работать правильно? На каждой итерации мы создаем новую локальную переменную val. Эта переменная фиксирует текущее значение i перед созданием горутины. Следовательно, каждая горутина из замыкания
выполняет оператор Print с тем значением i, которое для нас ожидаемо. Этот код выводит 123 (опять же, в производьном порядке).
Второй вариант больше не зависит от замыкания и использует фактическую функцию:
for _, i := range s { BbInonHHeHue yHkHun, kotopar npHnHMeHt go func(val int) { qenoe 4hcnb a nKctbe aprymenHn fmt.Print(val) }(i) BbI30B yHkHun c tekyHun 3havHnem nepemehHnii
По-прежнему в новой горутине выполняется анонимная функция (например, не запускается go f(i)), но на этот раз это не замыкание. Функция не ссылается на val как на переменную вне своего тела; val теперь является частью входных данных функции. Так мы фиксируем i на каждой итерации, и приложение работает так, как ожидалось.
Следует осторожно обращаться с горутинами и переменными цикла. Если горутина представляет собой замыкание, которое обращается к переменной итерации, объявленной вне ее тела, то это может вызывать проблемы. Их можно решить, либо создав локальную переменную (например, используя val := i перед выполнением горутины), либо сделав функцию, не являющуюся замыканием. Оба варианта работают одинаково хорошо, и нет никаких оснований для того, чтобы предпочтеть один другому. Некоторым разработчикам подход с замыканием может показаться более удобным, в то время как другие найдут подход с фактической функцией более выразительным.
Что происходит с оператором select на нескольких каналах? Давайте выясним.
# 9.4. ОШИБКА #64: ОЖИДАТЬ ДЕТЕРМИНИРОВАННОЕ ПОВЕДЕНИЕ ПРИ ИСПОЛЬЗОВАНИИ SELECT И КАНАЛОВ
При работе с каналами Go-разработчики могут делать неверные предположения о том, как select ведет себя с несколькими каналами. Это может привести к скрытым ошибкам, которые трудно выявить и отловить.
Допустим, мы хотим реализовать горутину, которая должна получать данные из двух каналов:
- messageCh
— для новых сообщений, которые нужно будет обработать;- disconnectCh
— для получения уведомлений об отключениях. В этом случае мы хотим вернуться из родительской функции.
При получении данных из этих двух каналов требуется отдать приоритет messageCh. Например, если происходит какое-то отключение, нужно убедиться, что перед возвратом получены все сообщения. Приоритизацию можно установить следующим образом:
for {select { Ncnohsobahne onepatopa select dna nonyvения dahnux n3 heckonbKHX kananobcase v := <- messageCh: Nonyvение hObix coo6uehni fmt.Println(v)case <- disconnectCh: Nonyvение coo6uehni of otkniovенияfmt.Println("disconnection, return")return}}
3десь мы используем оператор select для получения сообщений из нескольких каналов. Поскольку мы хотим отдать приоритет тому, что приходит из messageCh, то можно предположить, что в коде сначала следует обработать messageCh, а уже потом disconnectCh. Но будет ли этот код работать? Попробуем это проверить, написав фиктивную порутину-производитель, которая будет отправлять 10 сообщений, а затем уведомление об отключении:
for i := 0; i < 10; i++ { messageCh <- i}disconnectCh <- struct{}{
Eсли мы запустим этот пример, то вывод может выглядеть так (если messageCh будеризируется):
01234disconnection, return
Мы получили не 10 сообщений, а только пять. В чем причина? Она кроется в спецификации оператора select с несколькими каналами (https://go.dev/ref/spec):
Eсли одна или несколько коммуникаций могут быть выполнены, то будет выбрана только одна из них с помощью равномерного псевдослушайного выбора.
B отличие от оператора switch, где выполняется первый совпадающий случай, оператор select случайным образом выбирает один из возможных вариантов, если их несколько.
Поначалу такое поведение может показаться странным, но для этого есть веская причина: стремление предотвратить возможное голодание (starvation). Предположим, что первая возможная коммуникация выбрана на основе исходного порядка. Тогда мы можем попасть в ситуацию, когда получим данные только по одному каналу, которому соответствует быстрый отправитель. Чтобы предотвратить такие ситуации, разработчики языка решили использовать случайный выбор.
Возвращаясь к нашему примеру, можно сказать, что, даже несмотря на то, что case v := <-messageCh стоит первым в исходном порядке, если и в канале messageCh, и в канале disabledCh есть какие-то сообщения, то нет никакой уверенности, какое из них будет выбрано. По этой причине поведение кода из примера — не детерминированное. Можно получить и 0 сообщений, и 5, и 10.
Каким может быть решение? Существуют разные способы получения всех сообщений перед возвратом по отключению.
Если есть только одна горутина, которая генерирует сообщения и уведомления об отключении, то возможны два варианта:
- Сделать канал messageCh неубуферизованным вместо буферизованного. Поскольку горутина-отправитель блокируется до тех пор, пока го-рутина-получатель не будет готова, такой подход гарантирует, что все сообщения от messageCh будут получены до отключения по уведомлению из disconnectCh.- Использовать один канал вместо двух. Например, можно определить структуру, которая будет передавать либо новое сообщение, либо сигнал об отключении. Каналы гарантируют, что порядок отправлены сообщений будет таким же, как и порядок их получения. Так мы сможем гарантировать, что уведомление об отключения будет получено последним.
Если в нашем случае несколько горутин-производителей, не всегда можно гарантировать, сообщение какой конкретно из них будет записано первым. И независимо от того, имеем мы неубуферизованный канал messageCh или единственный канал, такая конфигурация приводит к состоянию гонки среди горутин-производителей. Тогда возможно следующее решение:
1. Получение данных либо из messageCh, либо из disconnectCh.
2. EcJIn noJyVeho yBcJOMJIeHue o6 oTKJIoVehHn, To:
- npouHnTaTb bce HmeIOIIuecra b messageCh coo6IIeHnI, eCJIu OHn TaM eCTb;
- BePHyTbCra.
Bot peaJn3aIIHn 3toro peIIeHnI:
for{ select{ case v :=<- messageCh: fmt.Println(v) case<- disconnectCh: for{ BnOHeHHbie for/select select{ case v :=<- messageCh: 4TeHHe octaIOIIuxcra coo6IIeHnI fmt.Println(v) default: 3atem Bo3Bpat fmt.Println("disconnection, return") return } } }
B 3TOM peIIeHnI uCnOJIb3yETcra BnyTpHeHHnI OJIok for/select, B KoTOpOM o6pa6aTbIBaIOCTa JBa cJIyVaA (cAses): OJIun - messageCh, ApyTOi - default. HepExOJI K default B onepaTOpe select npOuCXOJHT ToJIbKO B TOM cJIyVaE, eCJIu Hn OJIun H3 ApyrIX cJIyVaEB He npOuCXOJHT. ApyrIMu cJIOBaMnI, 3TO O3HaVaET, YTO Mbl nepeIDEM K Bo3BpaTy ToJIbKO nOcJe ToRo, Kak nOJIyVHM bce octaBIIIuecra b messageCh coo6IIeHnI.
IOcMoTpHM, Kak pa6OTaET 3TOT KOI Ha KoHHKpeTHOM npUmepe. Mbl paccMOTpHM cH tyaIIIO, KoIIa B messageCh eCTb JBa coo6IIeHnI u OJIHO oTKJIIOVehnIe B disconnectCh (puc.9.2).
Puc. 9.2. HaaJIbHOe coCTOrHnIe
В этой ситуации select случайным образом выбирает вариант. Предположим, что select выбирает второй (рис. 9.3).

[ImageCaption: Puc.9.3.Пonyvение cunhana o6 otkniovении]
Мы получаем сигнал об отключении и входим в блок, который находится внутри select (рис. 9.4). В этом сценарии, пока сообщения остаются в messageCh, select всегда будет отдавать приоритет первому случаю перед default (рис. 9.5).

[ImageCaption: Puc.9.4.BoxkeHHbii select]

[ImageCaption: Puc.9.5.Пonyvение oставшихся сообщений]
Как только мы получили все сообщения из messageCh, select не блокирует и выбирает вариант default (рис. 9.6). Происходит возврат и остановка го- рутины.

[ImageCaption: Puc.9.6. NepexoK cnyaao default]
Ouncанныi cInocOIO3OJIeT y6eJUTbCra, YTO Mb IONJyUM BcE OCTaBIIHecA COO6IIeHnIa H3 KaHaJIa c NOJIyATeJIeM H3 HEcKOJIbKUX KaHaJIbO. KOHeYHO, ECJIu COO6IIeHnIe OTIIpaBJeIHO B messageCh nocJIe BO3BpaTa H3 rOpyTIHbI (HaIIpMEp, ECJIu y HaC cEti HEcKOJIbKO rOpyTIH- IPOHBOOJIHTeIcI), TO Mb OTO COO6IIeHnIe IPOIIyCTHM.
Ipu ucInOJIb3OBaHnI select c HEcKOJIbKUMu KaHaJIaMn IyXKO nOMHnIb, YTO ECJIu npu STOM BO3MOXHb HEcKOJIbKO bapHaHTOB, TO nePbBbI H3 HIX B HCXOJIHO M IPOJdKe He b6I6paEcra aBTOMaTIyEcKu. Go AeIcTbYeT cJIyVaIHbM O6pa3OM, NoSTOMy HeT HHKaKObI yBepeHHOCTI, KaKObI H3 bapHaHTOB OyJET b6I6paH. YTO6bI npeOJIeTb 3Ty TpyJIHOCTb, B cJIyVae OJIHObI rOpyTIHbI- IPO3BOOJIHTeJIa Mb MOxKem JJI6O ucInOJIb3OBaTb He6y6epu3OBaHnIbE KaHaJIb, JJI6O cBeCTu BcE IPO3BOOJIHTeIb B OJIun KaHaJI. B cJIy- Yae c HEcKOJIbKUMu rOpyTIHaMn- IPO3BOOJIHTeJIaMn JJIa yCTaHOBKu nIPOpHTeTOB MOXHO ucInOJIb3OBaTb ONepaTOpbl select H ONepaTOp defaUIt.
Huxke o6cyJUM pacIpcOCTpaHeHbHbI TIIn KaHaJIa — KaHaJIb yBcJOMJIeHnIb.
# 9.5. OlluBKa #65: HE IcNIOJIb3OBaTb KAHAJbI yBcJOMJIeHnIb
KaHaJIb — это MeXaHnIbM B3aIMOJeIcTbHbI MeXkJy rOpyTIHaMn I c NoMOIIbIO cIrHa- JIOb. CIrHaJI MOxKeT 6bIb bKaK c JaaHHbIMn, TaK H 6e3 HIX. IJ JJIa nporpaMmIcTb Go nOcJIeJIHnIb cJIyVaIb He cBeJJa nPOCTObI.
Paccmotpum npимер. Cоздajum kahal, kotopbii будет ybedomJrtb hac bcrkii pas, kora npoucxodut kakoe- to otklnoehne. Ojha us ndei - oopaatbcsa c hum kak c chan bool:
disconnect := make(chan bool)
Tenepb npednoloxmm, vto mi b3aumodeicbtyem c API, npedoctabJraonium takoii kahal. Посkolsky sto kahal Jorhneckux 3havennii, mi mokem nolyvats cooonehna Buda kak true, tak n false. Habephoe, nonartho, kanoi cmbicn hecet b cefe true. Ho vto oshavaeat false? Oshavaeat jiu sto, vto mi he otklnoyuuinc? I b takom clyvae kak vacto mi dyem noluyvats takol curnhal? Oshavaeat jiu sto, vto mi bocctahobuu iceudnchne?
Dолжhni jiu mi boooine okuJatb noluyvenu false? Bosmoxho, cJedyet oKudatb toJbko noluyvenu cooouenui co shavenuem true. EcJiu sto tak, to sto oshavaeat, vto JJia nepedayu hekotoropu uHcopmaunu he Hужho задabatb uJiu onpeJеляb konkpet- hoe shavehue - Hужen kahal, no kotopomy he nepedaoctca Jahhbie. HJiuomatnueckui cInococ cnipabutbcra c Jyum - cosJatb kahal nycbix ctpyktyp: chan struct{}.
B Go nyctara ctpyktypa - sto ctpyktypa 6es kakux- Jino noJei. He3aBucuMo ot apxutektypb oha shanmaet b nIamrtu 0 6auit, b vem mi moxem y6eJutbcsa, ucnoJb- 3yra unsafe.Sizeof:
var s struct{} fmt.Println(unsafe.Sizeof(s))
0
PpMEvAHHE Iovemy he cJedyet ucnoJb3oBatb nycstou uHrepdeic (var i interface{})? Iotomy vto nycstou uHrepdeic umeet heHyJebouo o6bem. Oh 3aHUMaet 8 6auit B 32- 6unHou apxutektypee u 16 6auit B 64- 6unHou apxutektypee.
Iycstaa ctpyktypa - cTahapart Jee- факto JJia o6o3havenua otcyctbua cMbicJa. Ha- npимер, eJiu Hужha ctpyktypa b Bиде xeII- MHoxecctba (KoJleKlJia yHukalbHbIX JJIeMeHTOB), To cJedyet ucnoJb3oBatb b kavectbe 3havenua nyctyio ctpyktypy map[K]struct{}.
PpHmenutelbho K kahalam: eJiu mi xotum cosJatb kahal JJia otnipabku ybedomJenuii 6e3 Jahnbix, nOaxodrauui cInococ cJedatb sto b Go - chan struct{}. OJHO H3 cAbix H3BecThbix npHmehenui kahalaa nycbix ctpyktyp cB3aH0 c oHntekctamu Go, kotopbie mi boccyJUM B JTOU JJABe.
Kahal moxet 6bIb c JahnbIMu uJiu 6e3 Hux. EcJiu Hужho pa3pa6otatb uJiuomatu- vecкий API B cootbetectbnu co cTahJaprtamu Go, nomHute, vTO kahal 6e3 JahnbIX
должен быть выражен типом chan struct{}. Таким образом, получатели сигнала понимают, что не должны ожидать никакого смысла от содержания сообщения — имеет значение только сам факт получения ими сообщения. В Go такие каналы называются каналами уведомлений (notification channels).
В следующем разделе обсудим, как Go ведет себя с нулевыми каналами, и узнаем, зачем их использовать.
# 9.6. ОШИБКА #66: НЕ ИСПОЛЬЗОВАТЬ НУЛЕВЫЕ КАНАЛЫ
При работе с каналами в Go разработчики часто забывают о том, что нулевые каналы иногда могут быть полезны. Так что же это такое и почему об этом нужно помнить?
Рассмотрим горутину, которая создает нулевой канал и ожидает получения сообщения. Что должен делать ее код?
var ch chan int - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Тип переменной ch - - chan int. Нулевое значение канала - - это nil, то есть ch равно nil. Горутина не будет вызывать панику, но заблокируется навсегда.
Принцип тот же и в случае, если мы отправляем сообщение в нулевой канал. Эта горутина блокируется навсегда:
var ch chan int ch <- 0
Тогда почему Go позволяет получать сообщения из нулевого канала или от- правлять их в него? Рассмотрим пример.
Реализуем функцию func merge(ch1, ch2 <- chan int) <- chan int для объединения двух каналов в один. Под их слиянием (см. рис. 9.7) мы подразумеваем, что каждое сообщение, полученное в ch1 или в ch2, будет отправлено в возвра-щаемый канал.
Как мы можем сделать это в Go? Напишем наивную реализацию этого действия, в которой запускаем горутину и получаем данные из обоих каналов (результат- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

[ImageCaption: Puc. 9.7. CnirHne Abyx kananob B Oдин]
B apyroii ropyTHHe Ml noyVaeM JahnHble H3 o6oux kananob, u kaXdoe co66HHe B uTOre nY6JHKyETcA B ch.
Ipo6Jema nepBouB BepcHn B ToM, UTO Ml noyVaeM JahnHble CHaAaJaa H3 ch1, a 3atem H3 ch2. To O3Ha4aet, UTO Ml He 6yJem Huyero noyVaatb H3 ch2, noka ch1 He 6yJet 3akpbit. To He nOJxOajmt JJA HaHHeo CJyVaa, Tak KaK mOxet OKa3aTbc, UTO ch1 CTahET OTKpBITbIM HaBceTJa. NoSTOMy HaJIO cJelTaTb Tak, UTO6M noyVaatb JahnHble H3 o6oux kananob OJHHOBpeMeHHO.
YJyHnM KOI c KONHKyEHTHbIM NOJyVaeJIaMn, IcNIOJIb3yA select:
func merge(ch1, ch2 <- chan int) <- chan int { ch := make(chan int, 1) go func() { for { select { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - case v := <- ch1: ch <- v case v := <- ch2: ch <- v } } close(ch) }() return ch }
Onerатор select niosbоляet ropyтинe oJhOBpeMeHHO oXKJaTb BbInOJIHeHnIa HeCKOJIbKUX onepaIuH. IIocKOJIbKy MbI o6epHyJIu ero B IJIKJI fOr, To oJOLXbHb IOLyVATb COO6IIeHnIa H3 ToRo uJIJI uHOro KaHaJIa MHoroKpaTHo. ByJIeT JII pa6oTaTb oTOT KOI?
OJHa H3 ero npo6JIeM b ToM, YTO onepaTOp cIloSe(ch) HeJIOCTyJIeH. 3aIIKJIbHaHHe KaHaJIa c IcIIOJIb3OBaHHeM onepaTOpa range npepbIaEcTc, KoJIa KaHaJI oKa3bIbEcTc 3aKpbIbIM. Ho B cIIOOc6e, KoTOpBIM MbI peaJIu3OBaJIu fOr/SeIeCT, He yJIaBJIbBaIOCTc Te MOMeHTbI, KoJIa 3aKOBbIaEcTc ch1 uJIu ch2. II YTO eIIe xY3Ke, eCJIu B KaKOII- TO MOeHT ch1 uJIu ch2 3aKpoEcTc, BOI YTO nOJIyIHT nOJIyVaTeJIb o6bEJIHeHnHOro KaHaJIa npu peructpaIuH 3haVehnIa:
received: 0 received: 0 received: 0 received: 0 ...
IOJIyVaeTJIb 6yJIeT nOcTOHnHO nOJIyVaTb IIeJIoe YIcJIo, paHbOe NYJIIO. IOyEy? IIpIeM H3 3aKpbITOro KaHaJIa - He6JIoKIIpyIIOIaI a nepaIuH:
ch1 := make(chan int) close(ch1) fmt.Print(--ch1, <-ch1)
Iloka MbI oXKJIaem, YTO OTOT KOIJIu6o BbI3OBeT COcTO3HHe BiaHHKu, JIu6o npIBeJIeT K 6JIOKIPOBKe, OH 3aIIyCKaEcTc a I bIBOJIuT 0 0. 3JIeCb MbI npeXbBaTbIaEcM CO6bItue 3aKpbItIu, a He To, YTO bKaKtIuEcCKu COJIePKuIcTc B COO6IIeHnI. BOI KaK npOBepHTb, IOJIyVaeM MbI COO6IIeHHe IIJI cIInHaJI 3aKpbItIu:
ch1 := make(chan int) close(ch1) IPIcBaBaHHe 3haVehnI a nepeMeHHO6I open v, open := <-ch1 he3aBucHMO OT ToTO, OTKpbIT KaHaJI uJIH HeT fmt.Print(v, open)
IcIIOJIb3Y3a nepeMeHHyIO open 6yJIeBa TIIna, MbI yBuJIUM, OTKpbIT JII eIIe ch1:
0 false
MeXJIy TEM 0 npucBaBaHBeTc a nepeMeHHO6I V, nIoTOMy YTO 3TO HYJIeBoe 3haVehHe IIeJIOrO YIcJIa.
BePhemcra ko BTOpOMy peIIeHHIO. OHo pa6oTaT He OHeb XOpOIO, eCJIu ch1 3aKpbIT. IocKOJIbKy npu o6paIIeHnIu K select 6yJIeT case v := <- ch1, npoJIOJIXkM o6paIIaTb- cI K 3TOMY CJyVaIO u NY6JIuKOBaTb HYJIeBoe IIeJIoe YIcJIo B o6bEJIHeHnHOM KaHaJIe.
Cделаем uhar hasad u nocmotpum, kak Jyuuie pennitsy npo6Jemy (puc. 9.8). Tpe6yется noJyvats dahnbie us o6oux kananob. Torda Jn6o:
ch1 закpbiBaetcr nepBbIM, noSTOMy Mb JOLXKbI nOLyvATb dahnbie us ch2, noka OH HE OyJET sAKpBIM; ch2 закpbiBaetcr nepBbIM, noSTOMy Mb JOLXKbI nOLyvATb dahnbie us ch1, noka OH HE OyJET sAKpBIM.
Puc. 9.8. O6pa6otka pasnHbix cJyvaaeB s3aBicUMOCTu OT ToTO, kakoi kanan 6yJET sAKpBIT nepBbIM
Kak peaJn3oBaTa bTO cpeJCTBaMa Go? HanHnIem BepcuO KODa, noXOxKyIO Ha To, YTO Mb MoJIn 6bI cJcJATb, ucnoJb3yA nOLXOJ KOHeYHHOrO ABTOMaTa I 6yJEBbI 3HaVeHnI:
func merge(ch1, ch2 <- chan int) <- chan int { ch := make(chan int, 1) ch1Closed := false ch2Closed := false go func() { for { select { case v, open := <- ch1: if !open { ← 06pa6otka B cny4ae, kOrJa ch1 3akpbit ch1Closed = true break } ch <- v case v, open := <- ch2: if !open { ← 06pa6otka B cny4ae, kOrJa ch2 3akpbit ch2Closed = true break } ch <- v }
if ch1Closed && ch2Closed { 3akpbitue u bosbpat, kora3akpbitl o6a kahana close(ch) return } } }() return ch }
Bbodrtae abe lorivecke nepemenhbe: ch1closed u ch2closed. Kak to/ko m b1 no/uyaeM coo6nne h3 kakoro- nu6o kahana, to npobepeM, rBraetca Jn oho cun- haiom 3akpbitua. Ecnu Jaa, to mbi o6pa6atbbaem ero, nomevar kahanl kak 3akpbitbi (hanpимер, ch1closed $\equiv$ true). Iocne 3akpbitua o6oux kahanob 3akpbiaem u o6b- eJnHeHbHbI kahanl u octanabJnbaem ropytnuy.
B vem npo6/ema storo koa, kpome toro, vto on yc/oxhnerca? Ectb oJun baxhbiM oMehT: kora oJun H3 JbYx kahanJOB 3akpbit, IJNKJ for 6ydet deicTbObaTb kac IJNKJ oXKJdHnHn curnaJia sanHroctu, to ectb on 6ydet npoJoxaTbca, Jaxe eJnu b Jpyrom kahane he 6ydet noJyAeho hoboe coo6nne. HyXho nomHnTb o nobeJehnHn onepatropa select b stom npumere. Jonyctum, kahan ch1 3akpbit (noJOTOMy m b1 he 6y/EM noJyvaTb H3 heTO HOBbE coo6nneHnA). Ipu o6paueHnHn K onepatopy select OH 6ydet XJaTb BbInOJHeHnHn oJHoro H3 TpeX yc/6obuH:
- ch1 3akpbit;- B ch2 eCTb HOBoe coo6nneHne;- ch2 3akpbit.
IepBoe yc/6OBue - ch1 3akpbit - bcerJa 6ydet ucTunHbM. No3TOMy noka Mb he noJyvHM coo6nneHne B ch2 u stot kahan he okaxetca 3akpbit, IJNKJ npoJoxHnTca B pAMkax nepBoro cJyhaa. To npBueJet K 6eccMbIcJehHnHt Tpate npoJeccorpHbIX IJNKJOB, vero cJeJyET H366TaTb. No3TOMy takoe penHeHne HepaJyMHO.
MoxHO 6bJIO 6bI yJyvHnHb HaCTb KoHevHoro abTOMaTa H peaJn3OBaTb BJJoxeHHbIe IJNKJIb for/select B kaxJOM cJyvae. Ho stO cJeJaJIO 6bI KOI JHe 6OJee cJoxHbIM u TpyHbHM JJIa noHmHahHn.
No3TOMy npuHJIO BpeMnBepHytbca K HyJEBbIM kahanJaM. Kak r robOpuJ, noJyHeHne c HyJeboro kahana 6ydet 6Jokupobatb bInIOJHeHne koJa nAbceraa. A kak hacvet ucnoJb3OBaHnHn stOuI uJeu B HaInem penHeHnH? YTo6bI nocie 3akpbitua kahana He 3aJaBaTb 3haVHeHue JorHvHeckOuI nepemehnOuI, npucBoHM 3TOMy kahaJIy 3haVHeHue nI1. HanHnHem okOHvaTeJbHyIO BepсиIO koJa:
func merge(ch1,ch2<- chan int)<- chan int{ ch $\coloneqq$ make(chan int,1) go func({ lpoonxutb bbnonnne,cnn for ch1 $! =$ nil || ch2 $! =$ nil{xora 6bi omin kahan nenyebn select{ case v,open $\coloneqq$ <- ch1: if !open{ ch1 $\equiv$ nil npcboeHue kahany ch1 3havHn nI break nocne toro, kak OH 3akpHbAaetcr ch<- v case v,open $\coloneqq$ <- ch2: if !open{ ch2 $\equiv$ nil lpcboeHue kahany ch2 3havHn nI break nocne toro, kak OH 3akpHbAaetcr ch<- v } } close(ch) }() return ch }
Для начала здесь мы остаемся внутри цикла до тех пор, пока хотя бы один канал остается открытым. Затем, если ch1 оказывается закрыт, мы присваиваем ему значение nil. Следовательно, во время последующей итерации цикла оператор select будет ждать только двух условий:
- в ch2 есть новое сообщение;- ch2 закрыт.
ch1 больше не часть решаемого уравнения, поскольку это нулевой канал. Между тем мы сохраняем ту же логику для ch2 и присваиваем ему значение nil после его закрытия. Наконец, когда оба канала закрыты, происходит закрытие объединенного канала и возврат. На рис. 9.9 показана схема этой реализации.
Это нужная реализация. В ней охватываются все возможные случаи и не требуется использование цикла ожидания сигнала занятости, который будет впустую тратить процессорное время.
Мы увидели, что ожидание из нулевого канала или отправка в него — это блокирующее действие, и такое его поведение небесполезно. Как мы заметили на примере слияния двух каналов в один, возможно использовать нулевые каналы для реализации элегантного конечного автомата, который будет удалять из оператора select один из возможных случаев. Помните об этой идее: нулевые
кандлы полезны в некоторых ситуациях и должны быть частью инструментария Go- разработка при работе с конкурентным кодом.

[ImageCaption: Pus. 9.9. Ppleм из o6ox kananob. Ecnu oJин из Hux закpbit, mi npucbaaam emy shavehne nil, ut6bi nonyvats dahnbie toJbko us oJhoro kanana]
B cJedyIouem pasJele c6cyJum, kakoi pasmer yctanabJиваTb npu cosJahnnu kanana.
# 9.7. OlluBKA #67: FADATb HAcYET PA3MEPA KAHAJA
Kahan, cosJahnbiu c nomoubio bctpoehnou dyhKunu make, moket 6b1b J16o 6y- depu3obanhbiM, J16o me6ydepu3obanhbiM. U 3Jecb vacro bo3onkaot Jbe oI116ku: nJyTaTb, kakoi Tn11 kanала b16pать, a b cJyvae 6ydepu3obanhoro kanала - raJatb, kakoi ero pasmer sadaTb. Pas6epemcr b 3Jux momентаX.
BcnomHm ochOBHbie nonHтия. He6ydepu3obanHbii kanan - JTO kanan 6e3 emKocTn. Ero mokho cosJatb, onyctun ykasahne pasmera HJ11 ykasan pasmer 0:
ch1 := make(chan int) ch2 := make(chan int, 9)
Используя небуферизованный (или синхронный) канал, отправитель будет блокироваться до тех пор, пока получатель не получит данные из канала.
И наоборот, будеризованный канал — это канал с емкостью, и он должен быть создан с размером больше или равным 1:
ch3 := make(chan int, 1)
В случае с будеризованным каналом отправитель может отправлять сообщения, пока канал не оказывается заполненным. Как только канал заполнится, он будет заблокирован до тех пор, пока горутина получателя не прочтет сообщение. Например:
ch3 := make(chan int, 1) ch3 <- 1 ch3 <- 2
Первая отправка не блокируется, а вторая блокируется, так как на данном этапе канал переполнен.
Сделаем шаг назад и обсудим принципиальные различия между двумя типами каналов. Каналы — это абстракция конкурентности, обеспечивающая связь между горутинами. Но как обстоит дело с синхронизацией? В рамках принципов конкурентности синхронизация означает, что мы можем гарантировать, что несколько горутин в какой-то момент будут находиться в известном состоянии. Например, мыотекс обеспечивает синхронизацию, потому что гарантирует, что только одна горутина может одновременно находиться в критической секции. Что касается каналов:
- Небудеризованный канал обеспечивает синхронизацию. Есть гарантия, что две горутины будут в известном состоянии: одна получает, а другая отправляет сообщение.
- Будеризованный канал не обеспечивает сильной синхронизации. Горутина-производитель может отправить сообщение, и если канал не заполнен, продолжить выполнение. Единственная гарантия заключается в том, что горутина не получит сообщение до того, как оно будет отправлено. Но эта гарантия процстекает только из-за наличия причино-следственной связи (вы не выпьете коде, пока не приготовите его).
Важно помнить об этом фундаментальном различии. Оба типа каналов обеспечивают связь, но только один из них обеспечивает синхронизацию. Если нужна синхронизация, используйте небудеризованные каналы. Небудеризованные каналы легче понимать: будеризованные каналы могут приводить к неочевид-ным взаимоблокировкам, которые в случаях с небудеризованными каналами были очевидны сразу.
Есть и другие случаи, когда предпочтительнее небуферизованные каналы: например, в случае канала уведомлений, где уведомление обрабатывается с помощью закрытия канала (close(ch)). Здесь использование буферизованного канала не принесет никакой пользы.
Но что, если нужен буферизованный канал? Какой размер ему задать? Значение по умолчанию, которое используют для буферизованных каналов, равно его минимуму, то есть единице. Можно подойти к решению данной задачи со следующей точки зрения: есть ли веская причина не использовать значение 1? Вот список возможных случаев, когда нужно использовать другой размер:
- При использовании паттерна вроде пула рабочих процессов (worker pooling), что означает создание фиксированного количества горутин, которые должны отправлять данные в общий канал. Тогда можно связать размер канала с количеством созданных горутин.- При использовании каналов для решения проблем ограничения скорости выполнения. Например, если нужно ограничить использование ресурсов, устанавливая ограничение на количество запросов, следует настроить размер канала в соответствии с этим ограничением.
Если ваш случай не связан с этими ситуациями, то будете осторожны с выбором других размеров канала. Довольно часто можно увидеть код, использующий для установки размера канала магические числа:
ch := make(chan int, 49)
Почему тут вдруг число 40? В чем причина? Почему не 50 или даже не 1000? Значение нужно задавать исходя из веских оснований. Возможно, оно вытекало из какого-то бенчмарка или из тестов производительности. Во многих случаях хорошей идеей будет прокомментировать такое решение и привести обоснование.
Имейте в виду, что решение о точном размере очереди — непростая задача. Председатель вопрос баланса между загрузкой процессора и памяти. Чем меньше это значение, тем с большей конкуренцией за ресурс центрального процессора мы можем столкнуться. Но чем это значение больше, тем больше памяти нужно выделить и зарезервировать.
Еще один важный момент упоминается в документе 2011 года о LMAX Disruptor (Martin Thompson et al.; https://lmax- exchange.github.io/disruptor/files/Disruptor- 1.0. pdf):
Очереди, как правило, всегда близки к заполнению или почти пусты из-за разницы в темпах работы потребителей и производителей. Они очень редко работают в сбалансированном среднем положении, когда темпы производства и потребления совпадают.
Поэтому редко можно найти размер канала, который будет стабильно точным, то есть точное значение которого не приведет к слишком большому количеству конфликтов или напрасному выделению памяти.
Вот почему, за исключением вышеописанных случаев, лучше всего начинать с размера канала, равного 1. Если вы сомневаетесь, его всегда можно будет из- мерить, например, с помощью бенчмарков.
Как и везде в программировании, здесь тоже есть исключения. Поэтому цель данного раздела не в том, чтобы охватить все, а в том, чтобы дать азы понимания того, какой размер использовать при создании каналов. Синхронизация гарантирована при использовании небуферизованных каналов, с буферизо- ванными такой гарантии нет. Кроме того, если нужен буферизованный канал, не забывайте использовать по умолчанию 1 в качестве значения размера канала. Осмотрительно принимайте решение о выборе какого-либо другого значения, а обоснование выбора должно быть закомментировано в коде. И по- следнее, но не менее важное: помните, что использование буферизованных каналов также может приводить к непредсказуемым взаимоблокировкам, которые легче обнаруживать и выявлять в случаях с небуферизованными каналами.
В следующем разделе обсудим возможные побочные эффекты при формати- ровании строк.
# 9.8. ОШИБКА #66: ЗАБЫВАТЬ О ВОЗМОЖНЫХ ПОБОЧНЫХ ЭФФЕКТАХ ПРИ ФОРМАТИРОВАНИИ СТРОК
Форматирование строк - обычная операция для разработчиков, независимо от того, возвращают они ошибку или регистрируют сообщение в логе. Но доволь- но легко забыть о возможных побочных эффектах форматирования строк при работе в конкурентном приложении. В этом разделе рассмотрим два примера: один (взятый из репозитория etcd) приводит к возникновению гонки данных, а другой - к взаимоблокировке.
# 9.8.1. Fонка данных в etcd
etcd - это распределенное хранилище ключей и значений, реализованное на Go. Oho ucnolbysyecx bo mhorux npoektx, Bknoyaa Kuberpetes, dxa xpanenur bexdahbix klaetepa. Ono npeoctabraet API dria bsaumoelctbria c klaetepom. Hanpmer, hnteppeic Watcher ucnolbysyecx dria ybeoMlehna o6 u3menehnni Jahanbix:
type Watcher interface { // Watch otcnexnbaer kJio4 nnn npck. PocmotpeHbie cofbitra 6yayt BosBpaueHb // vepes BosBpaueamn kahan. // ... Watch(ctx context.Context, key string, opts ...OpOption) WatchChan Close() error }
API ucnolbysyet notonobyio nepedavy gRPC. Bpatue: ato texhnoIorua henpepbIboro o6mena aahbimu mekdy kIueHTom u cepbepom. Cepbep dOxkeH bectu cHucok bex KIueHTOB, ucnolbysyOIIux sty dyHKIIO.CJedobateJIHO, hnteppeic Watcher peaJH3yetcr cykytypoW watcher, coJepxanuei bce aKtunbHbie notoku:
type watcher struct { // // streams coJepxat bce aKtunbHbie notoku gRPC, otmeVehHbie shaVehHem cTx. streams map[string]="watchGrpcStream }
KIOH kapTbI OCHOBaH Ha KoHTEkCTe, npeOCTaBJIeHHOM HPI BbI3OBE MeTOJa Watch:
func (w \*watcher) Watch(ctx context.Context, key string, opts ...OpOption) WatchChan { // ... ctxKey := fmt.Sprintf("%v", ctx) // ... Wgs := w.stream[ctxKey] // ...
ctxKey - это kIno4 kapTbI, oTpopMaTupobanHbIH nCXOJa H3 KoHTEkCTa, npeOCTaBJIeHHoTO KJIueHTOM. При qopMaTupobanHn cTpoku H3 KoHTEkCTa, CO3JaHHoTO CO 3Ha- vениями (context.witbValue), Go будет cHunbIBaTb bce shaVehHn, coJepxanuecr b stom KoHTEkCTe. B stom cJyvae pa3pa6ortuHkH etcd o6HapyxKuJIu, vTO KoHTEkCT, npeOCTaBJIeMblu Watch, bbl KoHTEkCTOM, coJepxanuIM H3MeHreMblE b heKOTOpbIX yCловuRx shaVehHn (hanpимер, yka3aTeJIb Ha cTpKyTpyy). Onu o6HapyxKuJIu cJyvau, kOrJa OJHa rOpytHna o6HOBJIrJa OJHO H3 shaVehHnI KoHTEkCTa, a dpyraa BbInOJIHrJa Watch u cHunbIBaJIa bce shaVehHn b stom KoHTEkCTe. To npHbOJIJIo K rOHke JahanbIX.
Cуть исправления (https://github.com/etcd- io/etcd/pull/7816) — не полагаться на fmt. Sprintf для форматирования ключа карты, что предотвращает обход и чтение цепочки обернутых значений в контексте. Вместо этого была реализована специальная функция streamKeyFromCtx для извлечения ключа из определенного значения контекста, которое не было изменяемым.
ПРИМЕЧАНИЕ Потенциально изменяемое значение в контексте может создать дополнительную сложность в деле предотвращения гонки данных. Вероятно, это решение, касающееся дизайна кода, и к нему нужно подходить очень тщательно.
Этот пример показывает, что нужно быть осторожными с побочными эффектами от форматирования строк в конкурентных приложениях; в этом примере таким эффектом стала гонка данных. В следующем примере увидим побочный эффект, приводящий к взаимоблокировке.
# 9.8.2. Взаимоблокировка
Предположим, что мы работаем со структурой Customer, доступ к которой возможен конкурентно. Используем sync.RWMutex для защиты доступа, будь то чтение или записи. Реализуем метод UpdateAge для обновления возраста клиента и проверки, что он имеет положительное значение. Также реализуем интерфейс Stringer.
Заметите ли вы проблему со структурой Customer в этом коде, предоставляющем метод UpdateAge и реализующем интерфейс fmt.Stringer?
type Customer struct { mutex sync.RWMutex —— Использование sync.RWMutex для защиты конкурентных доступов id string age int } func (c *Customer) UpdateAge(age int) error { c.mutex.Lock() —— Блокировка и откладывание разблокировки по мере обновления Customer defer c.mutex.unlock() if age < 0 { —— Возврат ошибки, если значение age отрицательно return fmt.Errorf("переменная age для customer %v должна быть положительным числом", c) } c.age = age return nil }
func (c *Customer) String() string { c解锁. RLock() // Волокровка и откладывание разблокировки по мере чтения Customer defer c解锁. RUnlock() return fmt.Sprintf("id %s, age %d", c.id, c.age)}
Проблема здесь неочевидна. Если указанный возраст отрицательный, то мы возвращаем ошибку. Поскольку ошибка форматируется с использованием директивы %s на получателе, будет вызван метод String для форматирования объекта Customer. Но поскольку метод UpdateAge уже захватывает блокировку мыотекса, метод String не сможет ее захватить (рис. 9.10).
Рис. 9.10. Выполнение UpdateAge, если переменная age отрицательна
Это приводит к взаимоблокировке. Если все горугины также находятся в спящем состоянии, то это приводит к панике:
fatal error: all goroutines are asleep - deadlock! goroutine 1 [semacquire]: sync.runtime_SemacquireMutex(0xc0009818c, 0x10b7d00, 0x0)
Как быть в этой ситуации? Прежде всего она иллюстрирует важность юнит- тестирования. В таком случае можно возразить, что создавать тест с заданием отрицательного возраста не стоит, так как логика в этом случае достаточно проста. Но без надлежащего покрытия тестами эту проблему можно не заметить и пропустить.
Что здесь можно улучшить, так это ограничить область действия блокировки мыотекса. В UpdateAge мы сначала получаем блокировку и проверяем, корректен ли ввод. Сделаем наоборот: сначала проверим ввод, и если ввод корректен, полу- чим блокировку. Преимущество этого заключается в уменьшении потенциальных побочных эффектов, но также может повлиять на производительность — блокировка устанавливается только тогда, когда действительно требуется, а не до того, как это выяснится:
func (c *Customer) UpdateAge(age int) error { if age < 0 { return fmt.Errorf("переменная age для customer %v должна быть положительным числом", c) } c.mutex.Lock() // блокировка мыотекса только после того, как ввод был проведен defer c.mutex.Unlock() c.age = age return nil}
В нашем случае блокировка мыотекса только после проверки корректности ввода возраста позволяет избежать взаимоблокировок. Если возраст отрицательный, String вызывается без предварительной блокировки мыотекса.
В некоторых случаях непросто или вообще невозможно ограничить область блокировки мыотекса. В таких условиях нужно быть крайне осторожными с формированием строк. Возможно, мы захотим вызвать другую функцию, которая не будет пытаться получить мыотекс, или захотим изменить только способ форматирования ошибки, чтобы она не вызывала метод String. Например, следующий код не приводит к взаимоблокировке, потому что идентификатор клиента (customer ID) регистрируется только при прямом доступе к полю id:
func (c *Customer) UpdateAge(age int) error { c.mutex.Lock() defer c.mutex.Unlock() if age < 0 { return fmt.Errorf("age should be positive for customer id %s", c.id) } c.age = age return nil}
Мы видели два конкретных примера: в одном форматируется ключ из кон- текста, а в другом — возвращается ошибка, которая форматирует структуру. В обоих случаях форматирование строки приводит к проблеме: к гонке данных и взаимоблокировке соответственно. Поэтому в конкурентных приложениях нужно быть очень осторожными и помнить о побочных эффектах формати- рования строк.
В следующем разделе обсудим поведение append при конкурентных вызовах.
# 9.9. ОШИБКА #69: СОЗДАВАТЬ СИТУАЦИЮ ГОНКИ ДАННЫХ ИЗ-ЗА ОПЕРАТОРА APPEND
Ранее я говорил, что такое гонка данных и какие могут быть последствия. Теперь посмотрим на срезы и на то, будет ли добавление элемента в срез с помощью оператора append вызывать гонку данных.
В следующем примере инициализируем срез и создадим две горутины, кото- рые будут использовать append для создания нового среза с дополнительным элементом:
s := make([]int, 1) go func() { Добавление к нового элемента в новой горутине s1 := append(s, 1) fmt.Println(s1) }() go func() { Добавление к нового элемента в новой горутине s2 := append(s, 1) fmt.Println(s2) }()
Есть ли здесь гонка данных? Нет.
Вспомним основные свойства срезов из главы 3. За любым срезом стоит ре- зервный массив, а сам срез имеет два свойства: длину и емкость. Длина — это количество доступных элементов в срезе, а емкость — это общее количество элементов в резервном массиве. Когда мы используем append, поведение зависит от того, заполнен ли срез (длина == емкости). Если да, то для добавления нового элемента среда выполнения Go создает новый резервный массив. В противном случае она добавляет элемент в имеющийся резервный массив.
В этом примере мы с помощью make([]int, 1) создаем срез длиной 1 и емкостью 1. Поскольку срез заполнен, использование append в каждой горутине возвращает
cpe3, 3a kotopbIM cTOINT HOBbIl MaccuB. ONepaTOp append ne H3MeHnET cyIIeCTbY- IOIINuMaccuB, u 3TO He npuBoJUT K rONke JAHHbIX.
Tenep b sanyctIM ToT ke npHmer c he6oJIbIIM u3MeHeHnEM HHHIIaJIu3aII111 s. BMeCTo CO3JAHnIA cpe3a JJIHHOuI 1 CO3JaJUM ero JJIHHOuI O 1 H cMKoCTbI0 1:
s := make([]int, 0, 1) $\leftarrow$ Изменение способа инициализации среза // To же самое
Что можно сказать об этом новом примере? Содержит ли он гонку данных? Да:
WARNING: DATA RACE Write at 0x00c00009e080 by goroutine 10: Previous write at 0x00c00009e080 by goroutine 9:
Мы создаем срез с помощью make([]int, 0, 1). Следовательно, массив не заполнен. Обе горугины пытаются обновить один и тот же индекс резервного массива (индекс 1), что и вызывает гонку данных.
Как предотвратить гонку данных, если мы хотим, чтобы обе горугины работали над срезом, содержалим начальные элементы из s и дополнительный элемент? Одно из решений — создание копии s:
s := make([]int, 0, 1) go func() { sCopy := make([]int, len(s), cap(s)) copy(sCopy, s) $\leftarrow$ Создание копии для использования append на копии среза s1 := append(sCopy, 1) fmt.Println(s1) }() go func() { sCopy := make([]int, len(s), cap(s)) copy(sCopy, s) $\leftarrow$ To же самое s2 := append(sCopy, 1) fmt.Println(s2) }()
Обе горугины делают копию среза. Затем применяют append к этой копии, а не к исходному срезу. Это предотвращает гонку данных, поскольку обе горугины работают с изолированными данными.
При работе со срезами в конкурентных контекстах помните, что применение append к срезам не всегда исключает гонку. В зависимости от конкретного
среза и от того, заполнен ли он, поведение будет меняться. Если срез заполнен, append выполняется без гонок. В противном случае несколько горутин могут конкурировать за обновление одного и того же индекса массива, что приведет к гонке данных.
# [онки данных в срезах и картах
Как гонки данных влияют на срезы и карты? Когда есть несколько горутин, верно следующее:
Доступ к одному и тому же индексу среза со стороны по крайней мере одной горутины, обновляющей соответствующее значение, — это ситуации с гонкой данных. Горутины обращаются к одному и тому же месту в памяти.
Доступ к различным индексам срезов независимо от операции не вызывает гонку данных. Разные индексы означают разные ячейки памяти.
Доступ к одной и той же карте (независимо от того, тот же это или другой ключ) со стороны по крайней мере одной горутины, обновляющей эту карту, ведет к гонке данных. Чем это отличается от структуры данных среза? Как я говорил в главе 3, карта — это массив сегментов, и каждый сегмент — это указатель на массив, состоящий из пар «ключ — значение». Алгоритм хеширования исполь- зуется для определения индекса массива сегмента. Поскольку этот алгоритм содержит в себе некоторый элемент случайности во время инициализации карты, одно выполнение может привести к тому же индексу массива, а другое — нет. Детектор гонок обрабатывает этот случай, выдавая предупреждение независимо от того, происходит ли фактическая гонка данных.
Не должно быть разных реализаций в зависимости от того, заполнен ли срез. Учитывайте, что применение append к общему срезу в конкурентных приложе- ниях может привести к гонке данных. Следовательно, этого следует избегать.
Теперь обсудим типичную ошибку с неточными блокировками мыотексов по- верх срезов и карт.
# 9.10. ОШИБКА #70: НЕВЕРНО ИСПОЛЬЗОВАТЬ МЫОТЕКСЫ СО СРЕЗАМИ И КАРТАМИ
При работе в конкурентных контекстах, где данные одновременно и изменя- мы, и используются совместно, часто приходится реализовывать защищенный доступ к структурам данных с помощью мыотексов. Распространенной ошибкой
является неверное использование мыотексов при работе со срезами и картами. Рассмотрим пример и разберемся с потенциальными проблемами.
Мы реализуем структуру Cache для кэширования балансов клиентов. Эта структура будет содержать карту балансов для каждого идентификатора клиента (customer ID) и мыотекс для защиты конкурентных доступов:
type Cache struct { mu sync.RWMutex balances map[string]float64}
ПРИМЕЧАНИЕ В этом решении используется sync.RWMutex, чтобы разрешить несколько операций чтения при отсутствии операций записи.
Затем добавляем метод AddBalance, который изменяет карту balances. Изменение выполняется в критической секции (внутри блокировки и разблокировки мыотекса):
func (c *Cache) AddBalance(id string, balance float64) { c.mu.Lock() c-balances[id] = balance c.mu.Unlock()}
Нужно реализовать метод расчета среднего баланса для всех клиентов. Одна из идей состоит в том, чтобы обрабатывать минимальную критическую секцию так:
func (c *Cache) AverageBalance() float64 { c.mu.RLock() balances := c-balances c.mu.RUnlock() sum := 0. for _, balance := range balances { sum += balance } return sum / float64(len(balances))}
Сначала мы создаем копию карты в локальной переменной balances. В критической секции выполняется только копирование для того, чтобы выполнять цикл, обращаясь к каждому балансу, с целью расчета среднего, за пределами критической секции. Работает ли это решение?
Если запустить тест с использованием флага - race с двумя конкурентными горутинами, одна из которых вызывает AddBalance (тем самым изменяя
balances), a другая вызывает AverageBalance, происходит гонка данных. В чем проблема?
Внутреннее устройство карты представляет собой структуру runtime. hmap, содержащую в основном металданные (например, счетчик) и указатель, ссылающийся на сегменты данных. Итак, balances := c. balances не копирует фактические данные. Тот же принцип работает и со срезом:
s1 := []int{1, 2, 3} s2 := s1 s2[0] = 42 fmt.Println(s1)
Вывод s1 возвращает [42 2 3], несмотря на то что мы изменили s2. Причина в том, что действие s2 := s1 создает новый срез: s2 имеет ту же длину и такую же емкость и поддерживается тем же массивом, что и s1.
Возвращаясь к нашему примеру, отметим, что назначаем balances новую карту, ссылающуюся на те же сегменты данных, что и c. balances. Тем временем две горутины выполняют операции с одним и тем же набором данных, и одна из них изменяет его. Следовательно, это гонка данных. Как это исправить? Есть два варианта.
Если операция внутри цикла не тяжелая (в данном случае это так, поскольку выполняется только приращение значения), нужно защитить всю функцию:
func (c *Cache) AverageBalance() float64 { c.mu.RLock() defer c.mu.RUnlock() Pазблокировка при возврате из функции sum := 0. for _, balance := range c-balances { sum += balance } return sum / float64(len(c-balances)) }
Критическая секция теперь охватывает всю функцию, включая итерации внутри цикла. Это предотвращает гонку данных.
A вот если операция итерации не легковесная (то есть тяжелая), то нужно работать с актуальной копией данных и защищать только эту копию:
func (c *Cache) AverageBalance() float64 { c.mu.RLock() m := make(map[string]float64, len(c-balances)) for k, v := range c-balances {
$\begin{array}{rl} & {\mathfrak{m}[\mathfrak{k}] = \mathfrak{v}}\\ & \} \\ & {\mathfrak{c}.\mathfrak{m}\mathfrak{u}.\mathfrak{R}\mathfrak{u}\mathfrak{n}\mathfrak{l}\mathfrak{o}\mathfrak{c}\mathfrak{k}()}\\ & {\mathfrak{s}\mathfrak{u}\mathfrak{m}\mathfrak{\beta} = \mathfrak{\theta}.}\\ & {\mathfrak{f}\mathfrak{o}\mathfrak{r}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrack}\\ & {\mathfrak{s}\mathfrak{u}\mathfrak{m}\mathfrak{\beta} + \mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{\beta}\mathfrak{}}\\ & \} \\ & {\mathfrak{r}\mathfrak{e}\mathfrak{t}\mathfrak{u}\mathfrak{n}\mathfrak{s}\mathfrak{u}\mathfrak{m}\mathfrak{\beta} / \mathfrak{f}\mathfrak{l}\mathfrak{o}\mathfrak{a}\mathfrak{t}\mathfrak{6}\mathfrak{4}(\mathfrak{l}\mathfrak{e}\mathfrak{n}(\mathfrak{m}))}\\ & \} \end{array}$
Kak tolsko mi cделash rly6okyio konino, mi ocbo6okzаем mbiotekc. Htepaunu bbiinoJhriotcrs ha konin sa npeделamu kputineckouc cekin.
Iopa3mbiunJrem HaJ otum peienuem. Hyxho nepepapaTs b uikJre bce shave- hия картbi abaxdBi: oJun pas dJra konupobahnus u oJun pas dJra bbiinoJhenua onepaunu (B Janhom cJyvae dJra uHkpemента). Ho B kputineckouc cekin ha- xoJutcrs tolsko koninr картbi. CJedobateJbHoo, sto peienue moxet nOJouTn torJaa u tolsko torJaa, korJaa onepaunu he rJbJreTcra 6bIcmpo. Hanpимер, eJiu onepaunu tpe6yet o6pauehniK bHeiHnei 6ase Janhbix, sto peienue, berOJTHO, 6yJet 6oJee 3dDektHbHbM. HeBos3moxho onpeJelJить nopor npu bbi6ope toro uJiu uhorO peienua, tak kak bbi6op 3abucit ot koJiuectBa 3JemehToB u cpeJHero pa3мерa ctpyKtYpbl.
Tak yTO 6yJbTe octorpoKbHb c rpahnijamu 6Jokupobku mbiotekca. B 3tom pa3JeTe r Ioka3aJ, Iovemy npJscboehnus kakoui- to kapte 3haueHnus cyIuectByIounei картbi (uJiu cyIuectByIouneo cpe3a) heJocctatovho dJia saIutbI oT rOHKu Janhbix. Hobaar nepemehna, 6yJb to kapta uJiu cpe3, nOJdeprKbuaetcrs teM xe ha6opom Janhbix. Ectb Jba ochobHbix peienua dJia npeJotBpaueHnus storo: saIututb bCIO 6yHKI110 uJiu pa6otatb c konuei aKtyaJbHbIX Janhbix. Bo bcecx cJyVaaX 6yJbTe bHumaeJb- bHb npu co3Jahnun koJa kputineckux cekinuI u y6eJutecb, yTO uX rpahnIbI ToYHO onpeJeJehbI.
Давайте поговорим об ошибке при использовании sync.waitGroup.
# 9.11. OшиБКА #71: НЕПРАВИЛЬНО ИСПОЛЬЗОВАТЬ SYNC.WAITGROUP
sync.waitGroup — это механизм ожидания завершения п операций. Как правило, его используют, чтобы дождаться завершения п горутин. Вспомним сначала публичный API, затем рассмотрим довольно частую ошибку, приводящую к не- детерминированному поведению.
Группа ожидания может быть создана с нулевым значением sync.waitGroup:
wg := sync.WaitGroup{}
sync.WaitGroup содержит внутри себя счетчик, по умолчанию инициализиро- ванный нулевым значением. Мы можем увеличивать значение этого счетчика с помощью метода Add(int) и уменьшать его с помощью Done() или Add с от- рицательным значением аргумента. Если мы хотим дождаться, когда счетчик станет равным 0, то нужно использовать блокирующий метод Wait().
ПРИМЕЧАНИЕ Счетчик не может быть отрицательным, иначе горутина запаникует.
В следующем примере мы инициализируем группу ожидания, запустим три горутины, которые будут обновлять счетчик атомарно, а затем дождемся их завершения. Подождем, когда эти три горутины выведут значение счетчика (которое должно быть равно 3). Как вы думаете, в чем проблема этого кода?
wg := sync.WaitGroup{} var v uint64 for 1 := 0; 1 < 3; i++ { go func() { Cоздается горутина wg.Add(1) Ybeniuybaaetca shaeue cyeuika rypnbi okyadnna atomic.AddUint64(&v, 1) Atomapno ybeniybaaetca shaeue nepemehnoV wg.Done()} Oxuyahue ao tex nop, noka bce ropytnhi he ybenuyat wg.Wait() nepemehnyo v neped bbiooom ee ha neyatb fmt.Println(v)
Eсли мы запустим этот пример, то получим какое- то неде терминированное значение: код может вывести любое значение от 0 до 3. Кроме того, если мы включим флаг - race, Go даже поймает гонку данных. Почему так, если мы используем для обновления v пакет sync/atomic? Что не так с этим кодом?
Проблема в том, что wg.Add(1) вызывается во вновь созданной, а не в родитель- ской горутине. Следовательно, нет никакой гарантии, что мы указали группе ожи- дания, что хотим подождать завершения трех горутин перед вызовом wg.Wait().
Рисунок 9.11 показывает возможный сценарий ситуации, когда код выводит 2. В этом сценарии основная горутина запускает три другие. Но последняя гору- тина выполняется после того, как две первые уже вызвали wg.Done(), поэтому родительская горутина уже разблокирована. Следовательно, в этом сценарии,
когда главная горутина читает переменную v, она оказывается равной 2. Детектор гонки также может обнаруживать небезопасный доступ к v.
Puc. 9.11. Последняягорутинавызывает wg.Add(1)после того,какосновная горутинаужepasблокирована
Ipu pa6ote c ropytnhamu baxkho nomnhtb, vTO Hx bInolnhehne he aBlaecra detepmHHupOBaHHbIM bcs cHHxpoHusaaHn. HanpHmer, cJedyIOHnHnKoBbEdet Jn6o ab, Jn6o ba:
go func() { fmt.Print("a")}()go func() { fmt.Print("b")}()
O6e ropytnHb moryt 6bTb hashavehb pa3HbIM notokam, u het hukakou oppeJeneHnOCTH HacHCTTO, KaBOnI HOTOK 6yJET bINOJIHHTbCra nepBIM.
CPU JOLxken ucIOJIb3OBaTb 6apbep naMxmu (memory fence, IJIN memory barrier), YTO6bI o6ecneHHTb nOpJIOK. Go npeJocrTaBJreT pa3JIHbME metOJIb cHHxpoHusaaHn JJIa peaJIH3aHnH bapbepOB HaMxHt: hanpHmer, sync.WaitGroup bKJIo4aET OTHOIIeHNe MeXJy wg.Add H wg.Wait THIa <npoHcxOJHT JO>.
BosBpaIIaRcB K HaHHeMy nPHmepy, cCTb JBa bAPuaHTa peHHeHnA nPO6JIeMbI. Bo- nepBbIX, nepeJ IHKJIOM c 3 MbI MOxEM bIB3aTb wg.Add:
wg := sync.WaitGroup{}var v uint64wg.Add(3)
for i := 0; i < 3; i++ { go func() { // ... }()} // ...
Bo- BtOpBx, Mbl moxeM Bb3bIBaTb wg.Add nepel3anyckom dOyepHux rOpyTHH BO BpeMx KaXdOuI HtepaHMH uIKJIA:
wg := sync.WaitGroup()var v uint64for i := 0; i < 3; i++ { wg.Add(1) go func() { // ... }()} // ...
O6a peHHeHnB nOJHe KOpHnH. EcJIn 3haVHeHe, KOTOpOe Mbl XOJIH M B UTOre yCTaHO- BHTb JJIa cHeTTHaKa rpyHnB OXKIDaHnH, H3BeCTHO 3apaHee, nepBOe peHHeHe H36aBJIeT Hac OT HeO6XOJIMOCTH Bb3bIBaTb Wg.Add HeCKOJIbKO pa3. HO HYXHO y6EJHTbC4, YTO Be3Je HeOJIb3yCTeTc OJIH H I ToT Xe OCTeTHH, YTO6bI K66KaTb H3eBHbIH OHH6OK.
ByJbTe OCTOpOxHb H He OIOyCKaHTe STy OHH6Ky. Hpn cICIOJIb3OBaHHH sync. WaitGroup OHepaHnH AaD OJJIKaHb 6bITb BbIIOJIHeHa LO 3aIIyCKa rOpyTHHb B pOJI- TeJIbCKOuI rOpyTHHe, B To BpeMx KaK OHepaHnH DOne OJJIKaHb BbIIOJIHrTbC4 BHyTnH rOpyTHHb.
B cJeJIyIOHeM pa3JJeJIe O6cyJIM ApyroU HpHMHTHb HaKeTa sync: sync.Cond.
# 9.12. OWWBA #72: 3A6bIBATb O SYNC.COND
CpeJn HpHMHTHBOB cHHxPOH3aHnH, BxOJIaHHx B HaKeT sync, sync.Cond, BePO- rTHo, HaHMHeHe cICIOJIb3yEMbIH H I OHrHTHbIH. HO OH JaET TY OYHKIIIOHOaJIbHOCTb, KOTOpOu HeJIb3a JIOCTHb c IOOMOIbIO KaHaJIOB. B STOM pa3JJeJIe paCCMOrTHHM HpHMep, IOKa3bIBaIOHnH, KOJIa sync.Cond MOxKeT 6bITb IOJIe3eH, a TaKxKe ero cICIOJIb3OBaHHHe.
HpHMep B STOM pa3JJeJIe peaJIH3yET MeKaHH3M IOCTHxHeHnH HeJIeIH no COOpy IO- xKePTBOBaHHuH: HpHIJIOxKeHHe, KOTOpOe reHepHpYeT OIOBeHeHeHnH, KOJIa IOCTHraIOOTcH OHepeJIeHeHbHe HeJIH. EyJIeT OJIHa rOpyTHHa, OTBeYaIOHnH 3a yBeJIHHeHe HeJIaH- ca («ropyTHHa O6HOb3eHnHx»). Apyrue rOpyTHHb 6yJIyT HOJIyHaTb O6HObJIeHnH
и выводить сообщение всякий раз, когда будет достигнута конкретная цель (<горутины- c.лушатели>). Например, одна горутина ожидает достижения цели в 10 долларов, а другая - в 15 долларов.
Первое решение использует мыотексы. Горутина обновления каждую секунду отслеживает состояние баланса и увеличивает его. А горутины- c.лушатели находятся в состоянии постоянного выполнения соответствующих циклов до тех пор, пока заданная в них цель не будет достигнута:
type Donation struct { Cоздание u nichtahupobahue ctpytynu Donation, mu sync.RwMutexx Copepxaueni tekyuuni Ganahc u Mbotexc balance int } donation := &Donation{ // ropyruh- cnyuatenu f := func(goal int){ Cоздание sambikания donation.mu.RLock() for donation.balance < goal{ Ppobepka octnixehna pen donation.mu.RUnlock() donation.mu.Rlock() } fmt.Printf("d goal reached\n", donation.balance) donation.mu.RUnlock() } go f(10) go f(15) // ropyruha o6hObleHua go func(){ for{ PpodxeHne ybenyHHea Ganahca time.Sleep(time.Second) donation.mu.Lock() donation.balance++ donation.mu.Unlock() } }()
Мы защищаем доступ к общей переменной donation.balance с помощью мыотекса. Если запустить этот пример, он будет работать так, как и ожидалось:
$10 goal reached$ 15 goal reached
Основная проблема, которая делает эту реализацию ужасной, - цикл активного ожидания (busy loop). Каждая горутина- c.лушатель продолжает выполнять цикл до тех пор, пока не будет достигнута цель сбора пожертвований, что приводит к трате огромного количества циклов процессора впустую и сильно нагружает его. Пошцем лучшее решение.
Cделаем niar nasad. Hyxko naitiu cnooc6, no3boляiouei ropytnhe o6hobleniai curnha/лизировать kaждbi pas, kora6a/ashc o6hob/raetca. Bcer/aa, kora a Go pev6 saxoaut o nepe/ase curna/ob, c/edyet paccmatpubatb bosmoxhocts bncnolb- sObания kanalob. IIonpo/oyem dpyryio bepciuo pea/naa/uu - c ncno/5bObаниem npimutuba kanalaa:
type Donation struct { balance int ch chan int 06hObHHeuDonation takMm o6pa3om, 4TO OH cOpeOxUT KaHaJI} donation : $=$ &Donation{ch: make(chan int)} // ropyTHHb- c/yuAte/nu f : $=$ func(goal int){ for balance : $=$ range donation.ch { 0nyeHHe o6hOeHHeHn kaHa/ob if balance $> =$ goal{ fmt.Printf("\$ld goal reached\n", balance) return } } } go f(10) go f(15) // ropyTHHA O6HOB/HeHHA for{ time.Sleep(time.Second) donation.balance++ OtnpaBa koo6ueHHA KaXbHb pa3, donation.ch <- donation.balance kOrpa npoucxOant o6hObeHHe Ga/ahca }
Kazdaa ropyTHHa- cyHnate/6 no/uyaeT Jahnbie us o6niero kanala. MeKdy tem ropyTHHa o6hOB/HeHHA OTnpaBa/raet coo6nHeHHA BcKHHH pa3, kora6a/ahac o6hOB/raetca. To penuHeHHe bbl/aeT takouO B3sOxHbHbI pe3yJIbraT («goal reached> - «Ie/6 docturnHyt/a»):
$11 goal reached$ 15 goal reached
HepBaar ropyTHHa J0JxHA bbl/aa 6brrb yBeJOM/HeHa, kora6a/aa/anc oOcturnHret 10, a He 11 J0JIJIapOB. YTO Xe npou3OIIIO?
Coo6nHeHe, OTnpaBa/HeHHe B kaHa/I, pInHnHMaetca ToJIbKO oJHOH I ropyTHHOU. B haIHeM npHmepe, ecJIu nepBaar ropyTHHa no/uyaeT Jahnbie us kanala paHbHe Btorpoi, To moxet npou3Ou/rtu To, uto noka3a/ho Ha puc. 9.12.
PeKHM pacnpeJe/HeHHA no yMOLyHAnHIO ppu MHoxKecTBe ropyTHH, pInHnHMaIOHux us o6niero kanala, aBJIeTcra IJKJIyHeckHM. 3TO moxet H3MeHHTbCra, ecJIu oJHa ropyTHHa He rOTOBa nOJyVaTb coo6nHeHHA (He haXOJHTcra B coCTOaHHH uOxH/ahHHA Ha kana/Je). Tor/aa Go OTnpaBa/raet coo6nHeHe cJedyIOHHei JooCTyHHOH I ropyTHHe.
Puc. 9.12. Первая ropytna nonyvaet coo6uehne o6 1 onnape, satem Btopar ropytna nonyvaet coo6uehne o 2 onnapaex, satem nepbaa ropytna nonyvaet coo6uehne o 3 onnapaex u t. d.
Kakdoe coo6uehne prinhmaetcr tolko oJhoui ropytnhou. B otom npumere nep- baar ropytnha he nonyinula coo6uehne \(\) 10$ , a Btopar ero nonyvunla. HeckoJbKum ropytnham oJhOBpeMeHHO Moxet 6bTb nepEJaho toJbKO co6bTbue 3akpbTnI kaHaJa. Ho MbI he xotum 3akpbTnTb kaHaJ, notomy yTO torJa ropytnha o6HOBJehnI He cMoxet oTnpaBJaTb coo6uehne.
Ectb eHce oJma npo6Jema c hncJbJbOaHnIeM kaHaJIOB: BosBpaT Hs ropytnHcJyHiaTeTeu npoucxcOJHT bCckuI pa3, korJa cooTbeTcTbYIOHHe ueJn c6opa noXeptBbOaHnIi JocTnHnyTbI. CJeJoBaTeJIbHO, ropytnHa o6HOBJehnIa JOLJKaHa 3HaTb, korJa bce cJyHiaTeJIu nepEcTaHyt nonyvATb coo6uehne Hs kaHaJa. B npoTUBHOM cJyVae, co bpeMeHeM, kaHaJI nepeIOJnHHTcI u sa6JIOKupyET oTnpaBaTeJIa. Bo3MoxHbIM peHHeHHeM Moxet cTaTb npImHeHeHe sync .WaItGropu, HO 3TO bce yCJIOKHHT.
B uJeaTe hyXHO HaTnI cnooc6 MHoroKpaTHo nepEJaBaTb yBcJOMJIeHnIe HeckoJbKUM ropytnHaM bCckuI pa3, korJa GaJIaHc o6HOBJIeTcra. K cчаCTbIO, B Go cCTb peHHeHe: sync .Cond. ChavaTa HeMHoro TeopHn, a satem nocMOTpHM, kaR MoxHO c nOMOJIbIO 3TOro npHMHTIBa peHHTb JTy 3aJaYy.
CorJIaCHO oJpHnIaJIbHOuI JOKyMeHTaIuH (https://pkg.go.dev/sync):
Cond peaJnJ3yem nepeMeHnJyO yCJIOBa, moXky bCmpeHnI OJra zopymHn, oXcuJaIOuJX uJIu o66BaJIaIOuJX o o63HukHOBeHnIu co6bIMHnI.
YcJIOBaHa nepeMeHnHaI — 3TO KoHTeIHep nOtOKOB (B JaHHOM cJyVae ropytnH), oXKuJdaIOHnIX bblIOJIHeHnIa ONpeJeJIeHHoro yCJIOBaIr. B HaIIeM npUMepe yCJIOBaI — 3TO o6HOBJIeHee GaJIaHaCa. FopytnHa o6HOBJIeHnIa paccbJIaTeT yBcJOMJIeHee bCckuI pa3, korJa o6HOBJIeTcra GaJIaHaC, a ropytnHa- cJyHiaTeJIb oXKIJaTeT o6HOBJIeHnI. KpOMe
TOIO, sync.Cond uCIOIoIb3yET sync.Locker (\*sync.Mutex uI1I \*sync.RWMutex) JIJI npeJOTBpaIIeHnIa rONKu JAnHbIX. BosMOxKHaI peaJIu3aIIIaI:
type Donation struct { cond \*sync.Cond DobaBneHne \*sync.Cond balance int } donation : $=$ &Donation{ cond: sync.NewCond(&Sync.Mutex{}), sync.Cond uCIOIb3yET MblOeKc } // rOpyTHHb- cJyUaTeIu f : $=$ func(goal int){ donation.cond.L.Lock() for donation.balance $<$ goal{ OxudaeT BbInonHeHnIa yCnOBnIa (O6HOBHeHnIa donation.cond.Wait() GanaHca)B pamKaX 6JONHPOBKH/pa36JONHPOBKH } fmt.Printf("%d\$ goal reached\n", donation.balance) donation.cond.L.UnIlock() } go f(10) go f(15) // rOpyTHHA O6HOBHeHnIa for{ time.Sleep(time.Second) donation.cond.L.Lock() donation.balance++ YbENuHeHe BanaHca B pamKaX OJONHPOBKH/pa36JONHPOBKH donation.cond.L.UnIlock() donation.cond.Broadcast() TpaHcnJnIa факTa BbInonHeHnIa yCnOBnIa (GanaHc O6HOBHeH) }
ChavaJIa Mbl CO3JaEM \*sync.Cond c nOMOIIbIO sync.NewCond u BbOJIM \*sync.Mutex. A vTO HacYeT rOpyTHH- cJyUaTeJIeIu u rOpyTHHbI O6HOBHeHnIa?
IopryTHHbI- cJyUaTeJIu oCTaIOTcA B saIyKJIeHHOM cOCTOHHHIO JO Tex nOp, nOka He 6y- JET IOCTIrrHyt GaJIaHc HIOXePTBOBaHHuI. BHyTpH uIKKTa Mbl HcIOnJIb3yEM MeTOJ Wait, KOTOpBII 6JONKHpyeT rOpyTHHy IO BbInOJIHeHnIa yCIOBnIa.
PIMMEyAHHe YIOCTOBepHMc, vTO TePMHn yCIOBue 3Jecb nOHTaHn. B 3JOM KOHTeKcTe Mbl TOBOJUM O6 O6HOBHeHnIu GaJIaHca, a He O6 yCIOBnIu IOCTuXeHnIa IeJIeBOrO yPOBHn c6opa nOXePTBOBaHHuI. TAKHM O6pa3OM, 3TO OJIHa yCIOBHnA nepeMeHnHa, CObMeCTHO uCIOJIb3yEMaA JByMn rOpyTHHbMn- cJyUaTeJIaMn.
BbI3OB Wait JOLJIeH nPOIcXOJIuTb BHyTpH KpHTHHeCKOII cEKIIH, vTO MOXeT nO- Ka3aTbCra CTpaHHbIM. He nOMeIIaET JII 6JONKHPOBKa JpyTHM rOpyTHHaM JIOXaTbCra BbInOJIHeHnIa ToIO Xe KyCIOBnIa?
Ha самом деле реализация Wait такова:
1. Pa36локировка mbiotексa.
2. Приостановка выполнения горутины и ожидание уведомления.
3. Блокировка mbiotексa, когда приходит уведомление.
Ioprytnbhi- cJyHiatelJn ImeIOT JBa KpHTHueckux pa3JelJa:
- при доступе к donation.balance в for donation.balance < goal;- при доступе к donation.balance в fmt.Printf.
Takим образом, все обращения к общей переменной donation.balance оказываются защищены.
A что насчет горутины обновления? Обновление баланса выполняется в критической секции, чтобы предотвратить гонки данных. Затем мы вызываем метод Broadcast, который пробуждает все горутины, ожидающие выполнения условия каждый раз при обновлении баланса.
Поэтому когда мы запустим этот пример, он выведет то, что мы ожидаем:
10$ goal reached 15$ goal reached
B нашей реализации условная переменная основана на обновляемом балансе. Поэтому каждый раз, когда делается новое пожертвование, переменные горутин- cJyHiatelJn aktHbHpyHOTcra, vTO6bI npObeHHTb, doctHHTyTa JIN COOTBeTCTbYIOHJaH IeJb c6opa nOxeHTbObAHnI. JTo peHHeHe H36aBJaT Hac ot HcncOJIbObAHnI HHKJIbO bAHrTOCTu, kotopbIe tpArTAT npOueccOpHoe BpeMn Ipu nOBtOpHbIX npOBePKaX.
OTmeVy oJHn BosMoxHbIi HeJIOCTATOK Ipu ucIOJIbObAHnI sync.Cond.KOrJa MbI OTIpaBJIeM kakOe- To ybeJOMJIeHue, HaIpnMEp, B cTpyKrypy chan, Jaxke eJIN AKtUBHoro ero nOJIyVATeJrB JAHnHbIb MOMeHT HET, COO6HHeHe 6yOePH3yETcra. JTO rapaHTHpyeT, vTO ybeJOMJIeHue 6yJET B KoHHee KoHIOB nOJIyVHeo. IcIOJIbObAHnI sync.Cond c MeTOJOM Broadcast npO6yKJaET bce ropyHbHb, oXHJaIOHHe B JAHnHbI MOMeHT bHIOJIHeHnI yCJIbObHnI. EcJIu ux HET, ybeJOMJIeHue 6yJET nPOnyHHeo. JTO baxKbHbI IpnHnIun, o Kotopom HyXHO nOMHHTb.
IepeJaVa curnaJIbO b Go MoxeT ocyHHeCTbJIaTbcs c nOMOIIbIo kahJIbO. EJHHCTbHHeOe cO6bITe, Kotopoe cMOrYT OJIHObpeMeHHO nOIbMaTb HeCKOJIbKO rOpyTHH, - JTO 3akpHbIue kahaJIa, HO OHO MoxeT nPOH3OHTH TOJIbKO oJHn pa3. NoSTO- My eCJIu MbI HeOJIHOkpaTHO OTIpBaJIeM yBeJOMJIeHnI HeCKOJIbKHM ropyTHHAM,
sync.Cond- xopoieee peuiehne takou3aayu. TOT npumutub ochobah ha nepemeHbix yclobu, kotopbie yctahabJubaot konteHepbi notokob, oKuaioHux bHIOJIHeHnI aHpeJeJIeHHoro yclobu. IcnoJIb3y8 sync.Cond, Mbl MOxEM TpaHCJIHPOBaTb cHrHaJIbI, kotopbie npoJyKJiaOT Bce rOpyTHHbI, oKuaJIaOHHe bHIOJIHeHnIa KaKOro- To yclobu.
# Signal() u Broadcast()
Mbl MOxEM npoJyJHb OJHy rOpyTHHy, uCnoJIb3y8 Signal() BMeCTo Broudcast(). C ToYKu 3peHnIa cemaHTHKu 3TO 6ydet TEM xe caMbIM, 4TO u OTnpaBKa COo6HHeHnIa B cTpyKTypy chan He6JIoKuPyIOHm o6pa3OM:
ch := make(chan struct{}) select { case ch <- struct{}}: default: }
PacHupHm 3HahHnI o npumutubaX kOHHypeHTHOCTH, uCnOJIb3y8 golang.org/x u nIakET errgroup.
# 9.13. OWWBA #73: HE UCIIOJIb3OBATb ERRGROUP
BeJIocuIIeJIocItpOeHnIE b JIO6OM J3bIKe npOrpaMmHpOBaHnI - nJIoxaI nJeeI. YaCTTO B KOIJOBbIX 6a3ax nepeonpeJeJIaIOTCIc cIIOcO6bI 3aIIyCKa HeCKOJIbKUX rOpyTHH u arperHpOBaHnI aHH6JOK. Ho B 3KocIcTeMe Go eCTb nIakET, npeJIHa3HaHeHHbI JJIa nOIJIePKXKu 3TOro 4aCTTOro bapuaHnTa uCnOJIb3OBaHnI. PacMCTOpHm ero u y3HaeM, nOvEMy OH JOIJIkeH 6bIHb 4aCTbIb HHCTpyMeHTapnI Go- pa3pa6OTYHKa.
golang.org/x - 3TO peHO3HTOpHnI, coJIePKaHnIi paCIIHpHeHnI cTHaJIapTHOH 6u6JIIOOTeknI. PenO3HTOpHnI yJNC, JBJJIIOIIHnIcIc ero 4aCTbIb, coJIePKaHnI yJIO6HbI nIakET: errgroup.
JIOIyCTHM, HyXHO o6pa6OTaTb HeKyIO yJYHKIHNIO, u Mbl B KaVecTBe apryMeHTa nOIJIy- vaeM KaKHe- To JAHHbIe, kotopbie xOTHM uCnOJIb3OBaTb JJIa bIb3OBa bHeIIHeTO cEpBucIa. I3- 3a OrpaHnIHeHnI Mbl He MOxEM cJIeJIaTb ToJIbKO OJIHn Ib3OB. KaXKbIbI pa3 Mbl eJIaJeM HeCKOJIbKO bI3JOBOB c KaKHM- TO JpyrHM nOIJIHIOXeCTBOM. KpOMe ToIO, BcE 3TI bIb3OBbI bHIIIOJIHKeOTCIc napaJIJIeJIbHO (puc. 9.13).
B cJIy4ae nOaBJHeHnI bO bpeMx bIb3OBa KaKOu- To OJIHOuI OHII6Ku Mbl xOTHM cJIeJIaTb ee Bo3BpaT. B cJIy4ae xe nOaBJHeHnI HeCKOJIbKUX OIIII6OK Mbl xOTHM BePHyTb ToJIbKO
одну из них. Напишем основу реализации этого сценария, используя только стандартные примитивы конкурентности:
func handler(ctx context,Context,circles []Circle)([Result,error){ results := make([]Result,len(circles)) wg := sync.WaitGroup{) Cоздanue rypnbi WaitGroup dAr oKudahna wg.Add(len(results)) 3anycka Bcex hauxx ropytnk for i, circle := range circles{ i :=i Cоздanue hOBOH nepemehHOH I,исnoBsyEVOH B ropytnHe (CM.ouNbKy #63) circle := circle To xe caMoe c circle go func(){ 3anyck ropytnbH dAr kaxdoro Circle defer wg.Done) Yka3anue ha To, kOra ropytnHa 3aBepueHa result, err := foo(ctx, circle) if err != nil{ //? } results[i] $=$ result 06bednHne pesybnatob }() } wg.Wait() //... }
Pис. 9.13. Каждый круг приводит к параллельному вызову
Мы решили использовать sync.WaitGroup, чтобы дождаться завершения всех горутин и объединить результаты в срезе. Это один из способов. Другим может быть отправка каждого частичного результата в канал и объединение их в другой горутине. Основная проблема тут заключалась бы в изменении порядка входящих сообщений, если бы это потребовалось. Поэтому мы решили использовать самый простой подход и совместно используемый срез.
Примечание Поскольку каждая горутина записывает данные в определенный индекс, в этой реализации нет гонки данных.
Ho есть один важный случай, который мы еще не рассматривали. Что, если foo (вызов, сделанный в новой горутине) вернет ошибку? Как обработать эту ситуацию? Возможно варианты, в том числе такие:
- Как и в случае со срезом результатов, у нас может быть срез ошибок, который совместно используется в горутинах. Каждая горутина в случае ошибки будет что-то записывать в этот срез. Пришлось бы выполнить итерации по этому срезу в родительской горутине, чтобы определить, произошла ли ошибка (временная сложность O(n)).- Горутины могут получить доступ к единственной переменной ошибки через общий мыотекс.- Можно было бы подумать о совместном использовании канала ошибок, тогда родительская горутина получала бы и обрабатывала эти ошибки.
Независимо от выбранного варианта решение усложняется. По этой причине был разработан пакет errgroup.
Он экспюртирует одну функцию withContext, которая возвращает структуру *Group с заданным контекстом. Эта структура обеспечивает синхронизацию, передану ошибок и отмену контекста для группы горутины и экспюртирует только два метода:
- Go, чтобы запустить вызов в новой горутине;- Wait, чтобы заблокировать выполнение, пока все горутины не будут завершены. Он возвращает первую ненулевую ошибку, если она есть.
Перепишем код, используя errgroup. Сначала импортируем пакет errgroup:
$ go get golang.org/x/sync/errgroup
Собственно реализация:
results[i] = resultreturn nil})if err := g.Wait(); err != nil { Bb130B Wait dna oXnqaaHn BbnonHHeHn Bcex rOpyTHnreturn nil, err}return results, nil}
Chavала мы создаем \*errgroup.Group,предоставляя для этого родительский контекст. Ha kaxdoui ntepaunu ucnolb3yem g.Go dria b3soba hOboui rOpyTHbI. Jtot metoI pIunHmaet func() error B kavectBe BxOJbHbIX aHbHbIX c 3aMbKaHnem, o6opauaiaoum b33o3 foo u o6pa6aTbiaaoum u pesyJbTaT, u onu6ky. OchobHoe otJnuue ot nepbouB bepcuu B tom, uTO ecJu Mbl nOJyvaem onu6ky, to BosBpaiaem ee us storo 3aMbKaHnH. 3atem g.Wait nosbOJreT JoxJaTbca saBepueHnH BbInOJ- hHnH Bcex rOpyTHn.
JTo peHnHe npoIe, vem nepBoe (Kotorpoe moxHo nocuHtATb vaCTHyHbIM, nocKoJbKy Mbl he o6pa6aTbiaaJn OIIIOky). 3Jecb he HyxHo nOJIaTaTbca Ha JOnOJIHHTeJIbHbIe nIpmHHTbHbI kOHkypeHTHOCTH, a errgroup.Group oKa3bMaetca JocTaTOvHn dJra peHHeHn3aJaYH.
EIE OJHO npeHmYIIeCTbO, kotopoe Mbl noka He paccmOTpeJIH. - JTO o6IyHn KонтekCT. IPeJCTaBHM, UTO HyXHO uHIIIIHPOBaTb Tpu napaJIJeJIbHbIX b33OBA:
- nepBbHbI bO3BpaiaeT OIIu6ky vepes 1 mUJIInCEkYHJy;
- BtorpoH n tpetnH b33OBA bO3BpaiaIaOT pesyJIbTaT uJI OIIu6ky vepes 5 cekyHJ.
Mbl xOTHM BepHytb OIIu6ky, ecJH OHa Boo6IHe nOraBHTcH, a XJaTb saBepueHnH BTOporO n tpetBero b33OBOB HeT cMbICa. Icnonb3bOaHHe errgroup.WithContext co3JaeT o6IIHn KонтekCT, HcInOJIb3yEMbIH bO Bcex napaJIJeJIbHbIX b33OBAx. IocKoJbKy nepBbHbIH b33OBA bO3BpaiaeT OIIu6ky vepes 1 mUJIInCEkYHJy, OH OTMeHHT KонтekCT n, cJIeIOBaTeJIbHO, bInOJIHeHHe HpyTux rOpyTHn. TakHM o6pa3OM, He npu- JeTcH XJaTb 5 cekyHJ, vTO6bl BepHytb OIIu6ky. JTO eIHe OJIHO npeHmYIIeCTbO npu HcInOJIb3OBAHnH rpyTHnI OIIu6OK.
IPUMeYAHNE IPOJeCC, b33bIBaEMbIH g.Go, JOLJKeH b3aTb KонтekCTHO saBucu- MbIM. B nIpotUBHOM cJIyae OTMeHa KонтekCTa He 6yJET HMeTb HHKaKORO oDpEkTa.
KorJa HyXHO 3aIIyCTHTb HeckOJIbKO rOpyTHn n o6pa6aTbiaBaTb OIIu6Ku, a TakXe nepeJaBaTb KонтekCT, cTOHT nOJIyMaTb, MoxKeT JII errgroup b3bTb peHHeHnEM. Kak Mbl
Bилечи, otot naket o6ecneuuaet cunxponusauino dria rpynni ropytni u npeoctanb.lreet unctpymeht dria o6pa6otku onn6ok u onnux kontekctob.
B nocledhem pasdete otol rJabbi o6cydum onn6ky, kotopyo, onnyckaot npu konupobahinu tunia sync.
# 9.14. OWWBA #74: KONIPOBATb TUN SYNC
Iaket sync npeoctanb.lreet 6asobbie npnmutnbi cunxponusauin: Mblotekcb, yclobhbie nepemenhbie u rpynni oKudahn. Jlra bcex stnx tunob ects xectkoe npabu/o: konupobatb ux heLb3a hukorJa. Pa36epemca b npuunhax u BosMoxhbx np6/emax.
Mbi cos3adum notoko6esonachyio ctpyktypy dанныx d.la xpanehnir cyeTukob. Oh 6ydet co/epxatb map[strinqlint, npectab.larionnii co6ou tkeynnee 3havehne Jlra kaxdoro cyeTukka. Mbi takxe 6ydem ucnonb3obatb sync.Mutex, notomy yto Jocnyu J0Jxken 6b1b3aunhien. Kpome toro, J06abum metOu Iecrement Jlra ybeJnu- vehnur 3aJahhoro umeHn cyeTukka:
type Counter- struct { mu sync.Mutex counters map[string]int } func NewCounter() Counter{ 0a6puHaa yyHKuia return Counter{counters: map[string]int{} } func (c Counter) Increment(name string){ c.mu.Lock() YbeJnuvemue 3havemur cyeTukka B kputnueckou cekuun defer c.mu.Unlock() c.counters[name]++ }
Joruka ybeJnuvemur bmaJouHreTca B kputnueckou cekuun cMxJy c.mu.Lock() u c.mu.Unlock(). Ionnpo6yem npuHemnits cos3aHnbiu metOu, ucnonb3yra napametr - race Jlra bmaJouHemur cJedyIouIeTO npuHepa. Jrot npuHep- sanyckaet Jbe ropytnnbi u ybeJnuvuaet ux cyeTuku:
counter := NewCounter() go func() { counter.Increment("foo") }() go func() { counter.Increment("bar") }()
Ipu sanycke koja bosniukhet rohka dannhых:
WARNING: DATA RACE
Ipo6lema stoui pealnbaaun b konupobanun mbotekca. Iockolsky noiyvatel. Increment anbrcerea anbrcnem, bonkhi pas, kora mba bmbnbaem Increment, bni noinhrcerca konupobanue ctpyktypbi Counter, kotorpa rakke konupyet mbotekc. Nostromy b o6lneikphtnucckou cekin nppauehne he bnnonhrcers.
Tunbi sync he oJoxkhi konupobatbcra. Jto nравuio pacnppocrpanrcers ha cJedyIoune tunbi:
sync.Cond sync.Map sync.Mutex sync.RwMutex sync.Once sync.Pool sync.WaitGroup
Konupobanue mbotekca he oJoxkho npousbouitbcra boo6lue. Kakue ects a.nbtrepaHUBbI? Hepbaa - n3menutb tun noiyvatelra dria metoda Increment:
func (c *Counter) Increment(name string) { // Takoï же код}
Изменение типа получателя позволяет избежать копирования Counter при вы- зове Increment. Поэтому его внутренний мbotекс не копируется.
Если нужно сохранить тип получателя (значение), то второй вариант состоит в том, чтобы изменить тип поля mu в Counter на указатель:
type Counter struct { mu *sync.Mutex counters map[string]int}func NewCounter() Counter { return Counter{
mu: &sync.Mutex{}, — Изменение способа инициализации mu counters: map[string]int{}, } }
Eсли у Increment есть получатель типа «значение», он все равно копирует структуру Counter. Но поскольку mu теперь указатель, он будет выполнять только копирование указателя, а не фактическую копию sync.Mutex. Поэтому такое решение тоже предотвращает гонку данных.
Примечание Мы также изменили способ инициализации mu. Поскольку mu — указатель, если мы опустим его при создании счетчика, он будет инициализирован нулевым значением указателя: nil. Это вызовет панику в горутине при вызове c.mu.Lock().
С проблемой непреднамеренного копирования поля из пакета sync можно столкнуться в следующих случаях:
- Вызов метода с получателем типа «значение» (как мы видели).- Вызов функции с аргументом sync.- Вызов функции с аргументом, содержащим какое-то поле из пакета sync.
В каждом таком случае нужно быть настороже. Отмечу, что некоторые линтеры могут отлавливать эту проблему, например, с помощью go vet:
$ go vet . ./main.go:19:9: Increment passes lock by value: Counter contains sync.Mutex
Эмпирическое правило: всякий раз, когда несколько горутин должны получить доступ к общему элементу sync, нужно убедиться, что все они обращаются к одному и тому же эпземпляру. Это правило применяется ко всем типам, определенным в пакете sync. Использование указателей — способ решить эту проблему: может быть либо указатель на какой-то элемент sync, либо указатель на структуру, содержащую элемент sync.
# ИТОГИ
- Важно понимать те условия, при которых контекст может быть отменен: например, обработчик HTTP отменяет контекст после отправки ответа.
- Чтобы избежать утечек, помните, что всякий раз, когда горутина запускает-ся, у вас должно быть четкое понимание, как и когда ее следует остановить.
- Чтобы избежать ошибок с горутинами и переменными цикла, создавайте локальные переменные или вызывайте функции вместо замыканий.
- Понимание того, что оператор select при использовании с несколькими каналами выбирает указанные варианты случайным образом, позволяет не делать неверных допущений, которые приводят к неочевидным ошибкам конкурентности.
- Отправляйте уведомления, используя тип chan struct{}.
- Использование нулевых каналов должно быть частью вашего конкурентного инструментария, поскольку позволяет, например, исключать какие-то варианты из операторов select.
- Тщательно все обдумайте, прежде чем решить, какой тип канала использовать в зависимости от конкретной проблемы. Только небуддеризованные каналы дают надежные гарантии синхронизации.
- Должна быть веская причина для указания отличного от единицы размера канала в случае, если этот канал буддеризованный.
- Форматирование строк может привести к вызову существующих функций, а это значит, что нужно следить за возможным появлением взаимоблокировок и других гонок данных.
- Вызов append не всегда исключает гонку данных, поэтому его не следует использовать одновременно в общем срезе.
- Понимание того, что срезы и карты являются указателями, позволяет предотвращать гонки данных.
- Чтобы правильно использовать sync.WaitGroup, перед запуском горутин вызывайте метод Add.
- Повторяющиеся уведомления нескольким горутинам можно отправлять с помощью sync.Cond.
- Синхронизировать группу горутин и обрабатывать ошибки и контексты можно с помощью пакета errgroup.
- Типы sync не подлежат копированию.
# Bэтой главе:
- Как правильно задать значение промежутка времени- Понимание потенциально возможных утечек памяти при использовании time. After- Как избежать распространенных ошибок при обработке JSON и SQL- Закрытие временных ресурсов- Важность оператора return в HTTP-обработчиках- Почему в production-grade приложениях не должны использоваться HTTP-клиенты и серверы по умолчанию
Стандартная библиотека Go представляет собой набор основных пакетов, расширяющих возможность языка. Например, на Go можно разрабатывать HTTP-клиенты или серверы, обрабатывать данные JSON или писать программы взаимодействия с базами данных SQL. Все эти возможности предоставляются стандартной библиотекой. Но при ее использовании легко можно допускать
OIIH. KpOme torO, pa3pa6OTyHKu moryT He AO KOHIa nOHmATb ee nObeJehue, YTO BeJET K OIIH6KAM H anHicAHnIO nPIJIOxEHnI, KOTOpBHe HEJI3r cHHTaTb proDuctio1 grade. PaccMOrPHM HeKOropbE THHnHbIe OIIH6Ku HPI HcIOJIb3OBaHnI CTaHJIaPHHOI 6H6JIHOTeKH.
# 10.1. OWWBA #75: HENTPABIBIHO 3AJABATB NPOMEKYTOK BPEMEHI
CTaHJIaPHTHa 6H6JIHOTeKa npeJocTaBJIeT o6HHe bYHKHeH H MeTOJIb, KOTOpBHe nPIHmMaIOT time.Duration (npoJIOJIxHHeJIbHOCTb BpeMeHn). HO nOcKOJIbKy time.Duration - 3TO nceBJOHm JJIa THnIa int64, HObHKu B 33bIke Go moryT 3aIIyTaTbCra H yKa3bIBaTb HeIPIaBHnIyHO npoJIOJIxHHeJIbHOCTb. HaIPImuEp, npo- rpAMMucTbI c OIIbIOTOM pa6OTb Ha Java uJIu JavaScript nPIbBbIKJIu nepeJaBaTaTb YHCJIOBbIe THnIbI.
COSJaJIUM HOBbIbI time.Ticker, KOTOpBbI 6yJIeT KaXJIyIO cEKyJIy nepeJaBaTaTb THKaHbE YaCOB:
ticker := time.NewTicker(1000) for { select { case <- ticker.C: // Kakhe- to действия }
EcJIu MbI 3aIIyCTHM 3TOT KOI, TO 3aMeTHM, YTO THKu 6yJIyT bBJaBaTaTbCra He KaXJIyIO cEKyHIJIy, a KaXJIyIO MHKPOCEKYHIJIy.
IOcKOJIbKy time.Duration OCHOBaH Ha THnIe int64, npeJIbJIyIyIuI KOI 3BJIJIeTcra JO- nIyCTHMBIM, TAK KaK JJIa int64 HJCJIo 1000 3BJIJIeTcra TAKXe JOIIyCTHMBIM 3HaHeHHeM. HO bYHKHeH Htme.Duration BO3BpaIIaTeB peMeH, npoIIeJIHee MeXJIy KaHKMH- TO JBy- Mx MOMeHTaMn, B HaHOCEKYHOaX. IOoTOMy B bBHHeIPIbHeJIeHHOHm nPImuEp eNeWbIKHeR 6bJIJIa 3aJIaHa paBHOIb JJIHHeJIbHOCTIb B 1000 HaHOCEKYHIJI = 1 MHKPOCEKYHIJIe.
Ta OIIH6Ka bCTpeYeTcra oYeHb YaCTO. CTaHJIaPHbIe 6H6JIHOTeKH JJIa Java uJIu JavaScript 3aIIaCTyIO Tpe6yIOT yKa3bIBaTb COOHTeCTbYIOIIyHO npoJIOJIxHHeJIbHOCTb B MHJIJIHCEKYHIJIaX.
BoJIee torO, ecJIu MbI XOTHM cIIeIIaJIbHO CO3JIaTb time.Ticker c HHTePBaJIOM B 1 MHKPOCEKYHIJIy, TO He JOJIxHbI nepeJaBaTaTb int64 HaIIpXMyIO. YTO6bI 336eXaTb BO3MOXHOIb IyTAHnIIbIb, BcERJa HCIIOJIb3yIbTe time.Duration API:
ticker = time.NewTicker(time.Microsecond) // Или ticker = time.NewTicker(1000 * time.Nanosecond)
Это не самая сложная ошибка в этой книге, но разработчики, знакомые с другими языками, могут легко попасть в ловушку, полагая, что для функций и методов в пакете time требуется миллисекунды. Мы должны помнить об использовании API time.Duration и указывать типом единицы времени Int64.
Теперь обсудим ошибку при использовании пакета time с time.After.
# 10.2. ОШИБКА #76: TIME.AFTER И УТЕЧКИ ПАМЯТИ
time.After(time.Duration) - это удобная функция, которая возвращает канал и ждет, пока истечет указанное время, прежде чем отправить в этот канал некоторое сообщение. Обычно она используется в конкурентном коде. В противном случае, если мы хотим приостановить выполнение программы на протяжении заданного промежутка времени, можно использовать time. Sleep(time.Duration). Преимущество time.After заключается в том, что ее можно использовать для реализации таких сценариев, как: «Если я не получу никакого сообщения из этого канала в течение 5 секунд, то я...». Но в кодовых базах часто встречаются вызовы time.After в циклах, что может быть основной причиной утечек памяти.
Рассмотрим пример, где реализуем функцию, которая многократно получает сообщения из канала. Мы также хотим занести в журнал какую-то предупреждающую запись, если в течение более 1 часа не получено никаких сообщений. Вот возможная реализация:
func consumer(ch <-chan Event) { for { select { case event := <- ch: 06pa6otka co6bitna handle(event) case <- time.After(time.Houn): Yronyoukou zhanouan chtyuka pnoouk pntcor log.Println("warning: no messages received") } } }
3десь select используется в двух случаях: при получении сообщения из ch и через 1 час, если за этот промежуток не пришло никаких сообщений (time.After оценивается во время каждой итерации, поэтому показатель тайм-аута каждый
pas c6pac6b8aemca). Ha nepb8bi b3r7r81 k01 b8ir7r81111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
Kak mi yxe roborulni, time.After bosbpaiaet kanal. Kto- to moxet oxdatb, uto stot kanal 6ydet sakpbaatbcra bo bpema kaxdou utepaunu uikla, ho het. Pecypcbi, cosdahnbie time.After (bK1104a kaan), oco6o6kdaotcs no uccteehnn bpemenu oxdahnus (taum- ayta) u sanumaot mecto b nma4tn do tex nop, noka 3to oco6o6xdehne he nporo3o3det. Kakob o6bem 3toro mecta? B Go 1.15 npu kaxd0m b3130be time.After hncno1b3yetca oko1o 200 6aunt nma4tn. Eciu mi no1y4aem 3haunite1b8b8i o6bem co60111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110011111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 011111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
# Bnyrpu time.After
Cneayet otmetutb, vro time.After takxe sabucut ot time.Timer.ODhako oha BosBpauaet to/nko mo/ce C, no/onomy 3/ceb het doctyna k met/oy Reset:
package time func After(d Duration)<- chan Time { return NewTimer(d).C Coshahue hoooro time.Timer u basbpar no/ ka/ana }
Iocmotpum ha pea/naaHIO HOBOH BEpcH, B KOToPOH IcIIO/3bYeTcH time.NewTimer:
func consumer(ch<- chan Event){ timerDuration $\begin{array}{rl}{=}&{1}\end{array}$ time.Hour timer $\begin{array}{rl}{\mathbf{\Psi}:=}&{\mathbf{\Psi}}\end{array}$ time.NewTimer(timerDuration) Coshahue hoooro ta/merpa for{ OCHOBHOH uHKn timer.Reset(timerDuration) C6poc otc/eta ta/merpa select{ case event: $\begin{array}{rl}{=}&{\mathbf{\Psi}}\end{array}$ <- ch: handle(event) case<- timer.c: Ta/mer otc/utan bce sa/ahhoe BpeMn log.Println("warning: no messages received") } } }
B stou pea/naaHn coXpaHnecra cHtyaHn c nObTOpeHnem OJHOro dEicTbHn BO BpeM kaxdou utepaHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH nHeHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH
IPMMEvAHNE B paccmOTpeHnOM nHMepe dIa nPOCTOTM nPEbblYHnA rOpy- tHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHneHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHHeHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHeHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn HHeHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn
Icno/3bObaHnue time.After B uKne - he eJHHCTBeHHbHn cJyvaH, KOTOpblM oXket nIpuectu K nIKOBOMy nOTpe/3/enuo nAmATn. IpO6/ema cBn3aHa c MHOROKpATHO bH3bIBaEMbIM KOJOM. LIKJI - 3TO OJHH H3 cJyvaEB, HO cIcIO/3bObaHnue time.After
в функции HTTP-обработчика может привести к тем же последствиям, поскольку эта функция будет вызываться несколько раз.
В общем, использовать time.After следует очень осторожно. Помните, что созданные при этом ресурсы будут освобождены только по истечении заданного в таймере времени. Когда вызов time.After повторяется (например, в цикле, в функции-потребление Kafka или в обработчике HTTP), это может привести к пику в потреблении памяти. В таком случае используйте time.NewTimer.
В следующем разделе обсуждаются наиболее распространенные ошибки, совершаемые при обработке JSON.
# 10.3. OllnBKA #77: TUNIVHbIE OllnBkM nPI OBPABOTKE JSON
B Go реализована отличная поддержка JSON с помощью панета encoding/json. B этом разделе рассмотрим три распространенные ошибки, связанные с кодированием (маршалингом) и декодированием (демаршалингом) данных JSON.
# 10.3.1. HeoxnJahHoe nOBegeHne H3-3a BCTpaBANHra TUNOB
B раздел, посвященном разбору ошибки #10 (не знать о возможных проблемах со встраиванием типов) мы подробно рассмотрели эти проблемы. В контексте обработки формата JSON обсудим еще одно потенциальное влияние встраивания типов, которое может привести к неожиданным результатам маршалинга/демаршалинга.
B следующем примере создадим структуру Event, содержащую идентификатор (ID) и встроенную метку времени:
type Event struct {ID inttime.Time BCTPOEHoe none}
Tak kak time.Time BCTPOeH, Mbl MOxEM NOJyHrTs JoCTyN K cTO mETOJAM HEnIOcpeJCTBeHHO Ha yPOBHe CO6bitu, HaIPmuMp, event.Second().
KakOBO BO3MoxHoe BJIHnue BCTPOeHbIX NOJIeI npu MapIIaJHHre JSON? Y3HaEM oTO Ha cJeJyIouIem nImuMepe. CO3JaJUM OK3eMIIJIrp Event u MapIIaJIupyem ero B JSON. YTO BbIJaCT oYOT KOI?
event := Event{ID: 1234, Time: time.Now(), }b, err := json.Marshal(event)if err != nil { return err}fmt.Println(string(b))
Мы можем ожидать, что код выведет что-то подобное этой строке:
{"ID":1234,"Time":"2021- 05- 18T21:15:08.381652+02:00"}
Ho на самом деле мы получим:
"2021- 05- 18T21:15:08.381652+02:00"
Как это объяснить? Что случилось с полем ID и значением 1234? Поскольку это поле экспютрируется, оно должно было быть маршалировано. Здесь нужно обратить внимание на два момента.
Во-первых, как обсуждалось при разборе ошибки #10, если встроенный тип поля реализует интерфейс, структура, содержащая встроенное поле, также будет реализовывать этот интерфейс. Во-вторых, мы можем изменить поведение маршалинга по умолчанию, заставив тип реализовывать интерфейс json.Marshaler. Этот интерфейс содержит единственную функцию MarshalJSON:
type Marshaler interface {MarshalJSON() ([]byte, error)}
Bot пример пользовательского маршалинга:
type foo struct{} Onpepeenehe ctrykypb func foo MarshalJSON) []byte, error{ Peanusaupe mearalJsoN return []byte("foo"),nil BosBpat ctatwckoro otBera } func main{ b, err := json.Marshal(foo{) json.Marshal onpepeeneer onb3obatebckou if err != nil { peanusaupeMarshalJSON panic(err) } fmt.Println(string(b)) }
Поскольку мы изменили поведение маршалинга JSON по умолчанию, реализовав интерфейс Marshaler, этот код выводит foo.
Iproяснив эти два момента, вернемся к исходной проблеме со структурой Event:
type Event struct {ID inttime.Time}
time.Time pea.nis3vet nhtepdeic json.Marshaler. Посkolnky time.Time aBJIaetcra bctpoehnbiM noJem Event, komnIJIrTOp npoJbnraet ero metoJbi. NoJtomy Event takxe pea.nis3vet json.Marshaler.
CJedobatelbho, npu nepedave Event b json.Marshal ncnoJb3yetcra nobeJehue mapnauinhra, npeJocTabJreMoe time.Time, BMeCTo nobeJehur no yMoJvanHnIO. NoJSTOMy mapnauinHr Event npubOJHT K uHropupOBaHnIO noJIH ID.
IPUMeVahNE Mbi cTOJHKyJIncb 6bi c nOJOHOH nPOJnEMOuH u B npOTUBONOJIOKHOM cJIyVae, to eCTb eCJIu 6bi JemapnauJIupOBaJIu Event c IOMOIIbIO json. Unmarshal.
Ectb Jba bapnahtra peIehnur atOu npOJnEMbI. HepbHbI - JooJabuHb HM, vTOObI nOJIe time.Time GoJIbIHe He GbJIo bCTPOeHbIM:
type Event struct {ID intTime time.Time time.Time goJee He aBJIaetcA bCTPOeHbIM TUNOM}
EcJIu Mbi tak mapnauJIupyem BepcHIO atOu cTpyKTybI Event, TO OHa bJIbeJET vTO- TO BPOJe JTOro:
{"ID":1234,"Time":"2021- 05- 18T21:15:08.381652+02:00"}
EcJIu xe HyxKHO COXpaHnITH nOJIe time.Time bCTPOeHbIM, TO JpyroH bapnHHT - 3aCTaBHTb Event pea.nis3OBaTb uHTEpdeic json.Marshaler:
func (e Event) MarshalJsoN()([]byte, error) {return json.Marshalstruct { Co3JaHue aHOHHMHOH cTpyKTybID intTime time.Time}ID: e.ID,Time: e.Time,}
B этом решении мы реализуем собственный метод MarshalJSON при определении анонимной структуры, отражающей структуру Event. Но это решение более громоздкое и требует, чтобы метод MarshalJSON всегда соответствовал структуре Event.
Обращаться со встроенными полями следует с большой осторожностью. Хотя продвижение полей и методов встроенного типа поля иногда может быть удоб- ным, это также может привести к малозаметным ошибкам, когда родительская структура будет реализовывать интерфейсы без явного сигнала. Кроме того, при использовании встроенных полей важно знать о побочных эффектах.
В следующем разделе рассмотрим еще одну распространенную ошибку, связанную с использованием time.Time.
# 10.3.2.JSONи монотонные часы
Ipu mapliaunhre uin amapliaunhre ctрукtyры, coepxakaneli tun time.Time, uhorza moxho ctolnkyhться c henpebdunhbimu oinukamu cpaBhenua. NoTomy будет nolesho noJpo6hee usyunitb time.Time, tO6bi yMets b ux npeJotBpaunatb.
OnepaHunHna cHctema noJdeprKHBaet dba pasHbix tuna vacob: haCtenHbie (wall clock) u mOHOTOHbIe (monotonic clock). Chavaa paCCmOTpHM o6a 7TIX tuna, a saTem BosMoxHbie nocJecHcTBHn Hpu pa6Ote c JSON u time.Time.
HactenHbie vacbI cncOJb3yHOTcA Jля onpeJelenua tekyHHeTO BpeMeHn cytok. 7tu vacbI Moryt 6bTb H3MeHeHbI. HanpHmer, ecJiu vacbI cHnxponH3upOBaHbI c cncOJb- 3OBaHHeM npotokOla ceteboro BpeMeHn (NTP), oHu Moryt 6bTb nepeBeJehbI no BpeMeHn BnepeJ uJiu Ha3aJ. He cJedyet H3meprbH npOJOLJxKTeJIbHocTb kakoro- To npOMeXytka BpeMeHn c nomOIIbIo haCTeHbIX vacOB, uHaVe moxHO cTOJkHytbCra c BeCbMa cTpHnHbIM noBeJeHeMeH cUCTeMbI, hanpHmer c otHpHJateJIbHbI npOJOLJXKTeJIbHocTbIb. Bot noJeMey b OC cctb BtopoIb tun - mOHOTOHbIe vacbI. MoHOTOHbIe vacbI rapaHrHpyIOT, 7TO BpeMx BcERJa dBHXeTcA BnepeJ u He noJbepXeHO BJIHnHIO cKaHkOB BO BpeMeHn. Ha Hux Moryt BJIHrTb KoppEKTupOBHn vacToTbI (hanpHmer, ecJiu cepepeO6HapYkHbIaCT, 7TO JOKaJIbHHe KBaHpeHbIe vacbI uJyT B JpyTOM TeMHe, YEM NTP- cepBep), HO HUKorJa - cKaYKH BpeMeHn.
B cJedyOIIem npHmepe paccmOTpHM ctрукtypy Event, coJepKaHnYIO OJHO nOJIe time.Time (HeBCTPOeHIOe):
type Event struct { Time time.Time }
Cоздаем экземпляр структуры Event, маршалируем его в JSON и демаршалируем в другую структуру. Затем сравниваем обе структуры. Выясним, всегда ли процесс маршалинга/демаршалинга симметричен:
t := time.Now() → Получение текущего значения покального времени event1 := Event{ → Создание экземпляра структуры Event Time: t, } b, err := json.Marshal(event1) → Маршалирование в JSON if err != nil { return err } var event2 Event err = json.Unmarshal(b, &event2) → Демаршалирование JSON if err != nil { return err } fmt.Println(event1 == event2)
Что выдаст этот код? false, a не true. Почему?
Для начала выведем содержимое event1 и event2:
fmt.Println(event1. Time) fmt.Println(event2. Time)
2021- 01- 10 17:13:08.852061 +0100 CET m=+0.000338660 2021- 01- 10 17:13:08.852061 +0100 CET
Код выводит разное содержимое для event1 и event2. Вывод почти одинаков, за исключением частк m=+0,000338660. Что это значит?
B Go вместо разделения двух часов на два разных API time. Time может содержать как настенные, так и монотонные часы. Когда мы получаем местное время с помощью time.Now(), то этот оператор возвращает time. Time с показаниями времени обоих часов:
2021- 01- 10 17:13:08.852061 +0100 CET m=+0.000338660 Wall time Monotonic time
И наоборот, когда мы демаршалируем JSON, поле time. Time не содержит времени монотонных часов — только настенных. Следовательно, когда мы сравниваем структуры, результат оказывается равен false из-за разницы в монотонном времени. По этой же причине мы видим разницу, когда выводим обе структуры. Как решить эту проблему? Есть два варианта.
# time.Time m Mecrtononoxchne
Kakpoe none time.Time cBraHo c time.Location, npedctabJnouyM vacOBOI noRc.Hanpимер:
t := time.Now() // 2021- 01- 10 17:13:08.852061 +0100 CET
3ДесввкаKecTbeMecrononoxchna yctahonben noAc cpgeHecBponeckoro BpeMe- Hn,notomyyto ucnonbSobahO time.Now(),kotorpoe BosBpaUaET keyyue MecThoe BpeMn.Pesybnat MapbuaJnHra JSON saBucnT OT Mecrononoxchna.EcnI storo cne- dyet u36exaTb, moXHO saHukcupobatb onpedeJenHoe Mecrononoxchne:
location, err := time.LoadLocation("America/New_York") 3адаем «Америка/ if err != nil { Hbю- Йорк» в качестве return err teкуцего места
t := time.Now().In(location) // 2021- 05- 18 22:47:04.155755 - 0500 EST
Bкачестве aльтернативы moXHO nonyuHb teкуцее BpeMn в Формате UTC:
t := time.Now().UTC() // 2021- 05- 18 22:47:04.155755 +0000 UTC
KorДa мы uспoльузем оператор $= =$ Jля сравнения o6oих полей time.Time,он сравнивает bce noля структуры, вклочая и часть,соответствуюццую монотонным часам.Чтобы из6ежать этого,мы можем uспoльзовать мстод Equal:
fmt.Println(event1. Time.Equal(event2. Time))
true
Meto Equal he yunitbbaet BpeMn monotonhbx vacob, noTomy kOa BbIBOJHT true. Ho b atom cJyuae mbl сравниваем toJbko noля time.Time, a he podutelbckue структуры Event.
BtoroB bариант- coxpahnits $= =$ Jля сравнения дbyx струкtyp,но y6paTb BpeMn mонотонных часов c nomoцbю MetoДa Truncate. Jtoт MetoД возврацает окург- ленный в мeнbцццю строрун до кратногo заданной Jлитeльности pe3yJbтат 3начения time.Time. MoXHO uспoльзовать ero, ykasaB нyлевую npooJoJkuteJb- нoctb,Haipимер:
t := time.Now() event1 := Event{ Time: t.Truncate(0), 04истка BpeMни от «монотонной части» } b, err := json.Marshal(event1)
if err != nil { return err}var event2 Eventerr = json.Unmarshal(b, &event2)if err != nil { return err}fmt.Println(event1 == event2) ➔— Сравнение с помощью оператора ==
В этой версии кода два поля time.Time оказываются равны. Поэтому данный код выводит true.
Процесс маршалинга/демаршиалинга не всегда симметричен, и в данном случае мы столкнулись со структурой, содержащей time.Time. Помните об этом, чтобы не писать ошибочные тесты.
# 10.3.3. Карта типа any
При демаршиалинге данных мы можем иметь дело с картой вместо структуры. Когда ключи и значения не определены, работа с картой, а не со статической структурой дает некоторую гибкость. Но есть правило, о котором следует помнить, чтобы избежать неверных предположений и возможной паники горутины.
Создадим код, который демаршилирует сообщение в карту:
b := getMessage()var m map[string]anyerr := json.Unmarshal(b, &m) ➔— Задание указателя на картуif err != nil { return err}
Добавим к предыдущему коду следующий JSON:
{"id": 32, "name": "foo"}
Поскольку мы используем общую карту map[string]any, она автоматически парсит все различные поля:
map[id:32 name:foo]
Ipu ucnolobonanin kaptni tunia any baxkno nomnunb bot o vem: niooe ucnjoboe 3havene, hesabucimo ot toro, colepakut jiu oho decratunhoe ucnio uiu het, npeo6pasyetcsa b tuni float64. BbIbeJem tun m["id"] u y6eJumcsa b stom:
fmt.Printf("%T\n", m["id"])
float64
Y6eJutecb, vto he Jelaeete ounn6ovhbx npednoloxenunu he oKudaeete, vto uc- J0bble shavehna 6es Jecratunhbx shakob 6yJyT no ymoJvahnno npeo6pasobahb b IeJbie ucJia. Hebeprhbe npednoloxenua otnocutelbho npeo6pasobahua tunob Moryt npubectnu k nainike ropytnH.
B cJedyioIem pa3Jee JoccyJum pacnpoctpanenhbie ounn6ku npu hanucanin npuJoxenun, B3aumOJedicTbyJouJux c 6asamu Janhbix SQL.
# 10.4. OllnBKA #78: TUNIVHbIE OllnBKM, CBR3AHHbIE C SQL
Haker database/sql npeJlatact ounnun nhtrepche JIA SQL- 6as Janhbix (un SQL- nOJ66hbx). Ipu STOM J0BOLbHO 1aCTO MOXHO BCTpeTHTbcsa c HeKOTOpbMn nia6JonHbMn nOJXOLaMn u ounn6Kamu npu ucnoJb3obahnun 3toro naketa. PaccmOrpHm nrtb tuninHbix ounn6ok.
# 10.4.1. He 3HATb, VTO sql.Open He BcerJa yCTaHABJIBaET coeJHHeHue c 6a3OJ JAHHbIX
Ipu ucnoJb3obahnun sql. Open oJHO u3 3a6JyXdHnun cOCTOHT B TOM, vTO 3Ta yHnKnIa JOLJKH a yCTaHABJIBaTb coeJHHeHnra c 6a3OJ JAHHbIX:
db, err := sql.Open("mysql",dsn) if err != nil { return err }
Ho 3TO He BcerJa TAK. CoJIaCnO JOKyMeHTaIHN (https://pkg.go.dev/database/sql),
Open MoXem npocmo npOBeprAmb npaBunbHOcMb u DeicmBmTeJIbHOcMb cBoux apzymeHmOb, he CO3JaBaA coeJdHneHue c 6a3OJ JAHHbIX.
Ha самом деле поведение зависит от используемого драйвера SQL. Для некоторых драйверов sql. Open не устанавливает соединение: оператор является только подготовкой к этому (например, с db. Query). Поэтому первое подключение к базе данных может быть установлено методом ленивого подключения.
Что дает такое знание? Например, в каких-то случаях мы захотим сделать некоторый сервис готовым только после того, как будем знать, что все зависимости правильно настроены и доступны. Если мы этого не знаем, сервис может принимать трафик, несмотря на конфигурацию, содержащую ошибки. Если нужно убедиться, что функция, использующая sql. Open, обеспечивает доступность основной базы данных, применяйте метод Ping:
db, err := sql.Open("mysql",dsn) if err != nil { return err } if err != db.Ping(); err != nil { Bызов метода Ping всегда sql.Open return err }
Ping заставляет код установить соединение, которое гарантирует, что имя источника данных действительно и база данных доступна. Обратите внимание, что альтернативой Ping является PingContext, который запрашивает дополнительный контекст, сообщающий, когда пишт должен быть отменен или истекает время его ожидания.
Несмотря на возможную контринтутивность, помните, что sql. Open не обязательно устанавливает соединение, и первое соединение может быть открыто лениво. Если нужно протестировать конфигурацию и убедиться, что база данных доступна, то после sql. Open нужно вызвать метод Ping или PingContext.
# 10.4.2. Забывать о пупе соединений
Подобно тому, как важно понимать, что стандартное поведение клиента и сервера в пакете HTTP может быть неэффективно в продакшене (см. ошибку #81), необходимо хорошо представлять себе, как обрабатываются подключения к базе данных в Go. Функции sql. Open возвращает структуру *sql. DB. Она представляет собой не какое-то одно соединение с базой данных, а пул таких соединений. Это стоит отметить, чтобы не было соблазна реализовать это вручную. Соединение в пуле может иметь два состояния:
- уже используется (например, другой горутиной, запускающей запрос); - простаивает (уже создано, но пока не используется).
Важно помнить, что создание пула приводит к четырем доступным параметрам конфигурации, которые мы можем переопределить. Каждый из этих параметров является экспортированным методом *sql.DB:
- SetMaxOpenConns
- максимальное количество открытых подключений к базе данных (значение по умолчанию unlimited, неограниченно).- SetMaxIdleConns
- максимальное количество неактивных подключений (значение по умолчанию 2).- SetConnMaxIdleTime
- максимальное количество времени, в течение которого соединение может быть бездействующим, прежде чем будет закрыто (значение по умолчанию unlimited, неограниченно).- SetConnMaxLifetime
- максимальное количество времени, в течение которого соединение может оставаться открытым, прежде чем будет закрыто (значение по умолчанию unlimited, неограниченно).
На рис. 10.1 показан пример с максимум пятью соединениями. Он имеет четыре текущих соединения: три неактивных и одно используемое. Таким образом, один с.лот остается доступным для дополнительного подключения. Если приходит новый запрос, он выберет одно из свободных соединений (если оно все еще будет доступно). Если свободных соединений больше нет, пул создаст новое соединение, если доступен дополнительный с.лот. В противном случае он будет ждать, пока какое-то из соединений не станет доступным.
Рис. 10.1. Гул соединений с пятью соединениями
Итак, почему мы должны настраивать эти параметры конфигурации?
- Задание SetMaxOpenConns важно для production-grade-приложений. Посколь-ку значение по умолчанию unlimited, мы должны его задать, чтобы убедиться, что оно соответствует возможностям основной базы данных.
- Значение SetMaxIdleConn (по умолчанию: 2) следует увеличить, если приложение генерирует значительное количество одновременных запросов. В противном случае это приложение может сталкиваться с частыми повтор-ными подключениями.
- Задание SetConnMaxIdleTime важно, если приложение может столкнуться со всплеском запросов. Когда приложение возвращается в более спокойное состояние, нужно убедиться, что созданные соединения в итоге освоождаются.
- Установка SetConnMaxLifetime может быть полезной, например, если мы подключаемся к базе данных, работающей в режиме блансировки нагрузки. Тогда мы убеждаемся, что приложение никогда не будет использовать соединение слишком долго.
Для production- grade- приложений нужно учитывать эти четыре параметра. Можно использовать несколько пулов соединений, если приложение имеет различные сценарии использования.
# 10.4.3. Не использовать подготовленные операторы
Подготовленный оператор — это функция, реализованныя во многих базах данных SQL для выполнения повторяющегося оператора SQL. По сути, оператор SQL отличается тем, что предварительно компилируется и отделяется от данных, которые обрабатывает. У него есть два основных преимущества:
- Эффективность — оператор не нужно перекомпилировать (компилиция означает синтаксический анализ + оптимизация + трансляция).
- Безопасность — этот подход снижает риск атак путем внедрения кода SQL.
Если какой- то оператор применяется многократно, то следует использовать возможности, предоставляемые подготовленными операторами. Мы также должны их использовать в ненадежных контекстах (например, при открытии доступа к эндиоинту в интернете, где запрос отображается на оператор SQL).
Чтобы использовать подготовленные операторы, вызываем метод Prepare, а не Query из *sql.DB:
stmt, err := db.Prepare("SELECT * FROM ORDER WHERE ID = ?") if err != nil { return err } rows, err := stmt.Query(id) —— Выполнение подготовленного запроса // ...
Мы подготовливаем оператор, а затем выполняем его, передавая ему аргументы. Первым результатом выполнения метода Prepare является файл *sql.Stmt, который можно будет еще как-то использовать и одновременно запускать. Когда этот оператор становится ненужным, его надо закрыть при помощи метода Close().
Примечание Методы Prepare и Query имеют альтернативы, если требуется предоставление какого-то дополнительного контекста: PrepareContext и QueryContext.
Из соображений эффективности и безопасности помните о возможности использования подготовленных операторов, когда в этом есть смысл.
# 10.4.4. Неправильная обработка нулевых значений
Следующая ошибка заключается в неправильной обработке нулевых значений в запросах. Создадим программу, с помощью которой требуется получить данные о возрасте сотрудника и его отделе:
rows, err := db.Query("SELECT DEP, AGE FROM EMP WHERE ID = ?", id) → Выполнение if err != nil { return err } // Отложить закрытие строк var ( department string age int ) for rows.Next() { err := rows.Scan(&department, &age) → Сканируем каждую строку if err != nil { return err } // ... }
Для выполнения запроса используется Query. Затем мы проводим итерацию по строкам и используем Scan, чтобы скопировать столбец в переменные в соответствии с указателями department и age. Если запустить этот код, то при вызове Scan получим ошибку:
2021/10/29 17:58:05 sql: Scan error on column index 0, name "DEPARTMENT": converting NULL to string is unsupported
Драйвер SQL выдает ошибку, поскольку значение отдела равно NULL. Если столбец может принимать значение NULL, то есть два варианта предотвращения возврата ошибки оператором Scan.
Первый подход — сделать department указателем на строку:
var ( department *string → Изменение типа co string на *string age int ) for rows.Next() { err := rows.Scan(&department, &age) // ... }
Мы передаем в scan адрес указателя, а не напрямую адрес строкового типа. При этом если это значение равно NULL, то department будет равен нулю.
Другой подход заключается в использовании одного из типов sql.NullXXX, например sql.Null- String:
var ( department sql.NullString → Изменение типа на sql.NullString age int ) for rows.Next() { err := rows.Scan(&department, &age) // ... }
sql.NullString — это обертка поверх строки. Она содержит два экспортируемых поля: String содержит строковое значение, a Valid сообщает, не является ли значение строки NULL. Доступны следующие обертки:
- sql.NullString- sql.NullBool- sql.NullInt32- sql.NullInt64- sql.NullFloat64- sql.NullTime
Оба подхода вполне работоспособны, причем sql.NullXXX более явно выражает намерение, как упомянул Pacc Кокс, основной мейнтейнер Go (http://mng.bz/rJNX):
Какой-либо разницы нет. Мы думали, что люди могут чаще хотеть использо- вать NullString, потому что он очень распространен и, возможно, более явно выражает намерение, чем *string. Но работать будут оба.
Лучшей практикой для работы со столбцом, значение которого может быть равно NULL, является либо обработка его как указателя, либо использование типа sql. Nullxxx.
# 10.4.5. He o6pa6atbIaTb ouin6ku ntepaIyH cTpok
Ene oJHH TnHnHbHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH
func get(ctx context.Context, db \*sql.DB, id string) (string, int, error) { rows, err : $=$ db.QueryContext(ctx, "SELECT DEP,AGE,FRM EMP WHERE $\texttt{ID} = \texttt{?}"$ id) if err $! =$ nil{ 06pa6otka ouin6ok bo BpeMn return "",0, err BbInonHHeHn3anpoca } defer func(){ err : $=$ rows.Close() 06pa6otka ouin6ok bo BpeMn 3ankpHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH n HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH nH nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH n H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH Hn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn Hn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH n Hn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH Hn H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn Hn H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH n Hn H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH Hn Hn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn Hn Hn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH n Hn Hn HnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH Hn Hn H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHn Hn Hn H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH n Hn Hn H nHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnHnH Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn H n Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn H Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn Hn H
B этой функци мы обрабатываем три ошибки: при выполнении запроса, закрытии строк и скантировании строки. Но этого недостаточно. Нужно знать и помнить, что цикл for rows.Next() {} может прерваться, когда больше нет никаких строк или когда возникает ошибка при подготовке следующей строки. После выполнения каждой итерации по строке мы должны вызывать rows. Err, чтобы различать эти два случая:
func get(ctx context.Context, db *sql.DB, id string) (string, int, error) { // ... for rows.Next() { // ... } if err := rows.Err(); err != nil { // Проверка rows.Err для определения return "", 0, err. // того, что цикл на предыдущем шаге return department, age, nil. // octahовичская из-за какой- то ошибки }
Это лучшая практика, о которой нужно помнить. Поскольку rows.Next может останавливаться либо когда в цикле проводятся итерации по всем строкам, либо когда возникает ошибка при подготовке следующей строки, нужно проводить проверку rows.Err после каждой итерации.
Давайте обсудим следующую частую ошибку: когда разработчики забывают закрывать временные ресурсы.
# 10.5. Ошивка #79: НЕ ЗАКРЫВАТЬ ВРЕМЕННЫЕ РЕСУРСЫ
Довольно часто программы работают с переходными (или временными) ресурсами, которые нужно в какой- то момент закрывать, например, чтобы избежать утечек на диске или в памяти. Структуры обычно могут реализовывать интерфейс io. Closer, чтобы сообщить, что временный ресурс должен быть закрыт. Рассмотрим три типичных примера того, что происходит, когда ресурсы остаются незакрытыми.
# 10.5.1. Тело HTTP
Для начала обсудим эту проблему в контексте HTTP. В качестве примера рассмотрим собственный метод getBody, который отправляет запрос HTTP GET и возвращает гело отсена HTTP. Вот его первая реализация:
type handler struct { client http.Client url string func (h handler) getBody() (string, error) { resp, err := h.client.Get(h.url) 3anpoc HTTP GET if err != nil { return "", err
} body,err := io.ReadAll(resp.Body) 4444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444444 if err != nil { return "", err } return string(body), nil }
Мы используем http.Get и анализируем ответ с помощью io.ReadAll. Реализация этого метода выглядит вполне нормально: он корректно возвращает тело ответа HTTP. Но при этом есть утечка ресурсов. Разберемся где,
resp - это тип \*http.Response. On содержит поле Body io.ReadCloser (io. ReadCloser реализует как io.Reader, tak u io. Closer). To tejo do/xo h6bts sakpbito, eciu http.Get he bosbpaiaet oinu6ky, uhae boshukhet yteva pecypcoB B atom ci/yaee npi/oxkение coxpaHunt cuyaHuo, kora vacts pahee bbl/eneHou namrtu, kotopar o/ble he hyxha, ho he moxet 6bts ocoo/ox/ena c6opnukom mycopa, octanhется sapcesepbupobahnou, u B xy/uuix c/yaaxx nomeiaet k/uehntam nObtorpo hисno/lsobartb TCP- coe/uhneHue.
Cambii npocotii u ydObHbii cncoco sakpbit te/ia - ucnol/oboatb onepatop defer:
defer func() { err := resp.Body.Close() if err != nil { log.Printf("failed to close response: %v\n", err) }}()
B этой реализации закрытие ресурса тела обрабатывается корректно как функция defer, которая будет выполняться после получения возврата getBody.
Примечание На стороне сервера при реализации обработчика HTTP не требуется закрывать тело запроса, поскольку сервер делает это автоматически.
Тело ответа должно быть закрыто независимо от того, читаем ли мы его. Например, если нас интересует только код состояния HTTP, а не его тело, то, чтобы избежать утечки, последнее нужно закрыть, несмотря ни на что:
func (h handler) getStatusCode(body io.Reader) (int, error) { resp, err := h.client.Post(h.url, "application/json", body) if err != nil { return 0, err } defer func() { 3akpbitue tena otBeta, даже eCnM bI ero he vntaem err := resp.Body.Close()
if err != nil { log.Printf("failed to close response: %v\n", err);}return resp.StatusCode, nil}
Эта функция закрывает тело ответа, даже если оно не прочитано.
Еще одна важная вещь: когда мы закрываем тело, поведение различается в зависимости от того, прочитано что-то из него или нет:
- Если мы закроем тело без чтения, HTTP-транспорт, используемый по умолчанию, может закрыть соединение.- Если мы закроем тело после чтения, используемый по умолчанию HTTP-транспорт не закроет соединение; следовательно, его можно будет испольствовать и далее.
Если getStatusCode вызывается повторно и мы хотим использовать остающиеся неразорванные соединения, то нужно прочитать тело, даже если его содержание нас не интересует:
func (h handler) getStatusCode(body io.Reader) (int, error) { resp, err := h.client.Post(h.url, "application/json", body) if err != nil { return 0, err } // Закрытие тела ответа _, _ = io.Copy(io.Discard, resp.Body) // 4тение тела ответа return resp.StatusCode, nil}
В этом примере мы читаем тело исключительно для того, чтобы поддерживать соединение в открытом состоянии. Обратите внимание, что вместо использования io.ReadAll мы применяли io.Copy к io.Discard реализации io.Writer. Этот код читает тело, но никак не копирует прочитанное, фактически игнорируя его, что делает такую реализацию более эффективной, чем io.ReadAll.
Закрытие ресурса во избежание утечек связано не только с управлением HTTP- телом. Все структуры, реализующие интерфейс io.Closer, в какой-то момент должны быть закрыты. Этот интерфейс содержит единственный метод Close:
type Closer interface { Close() error}
# Korapa закрвивать тело ответа
Довольно часто в реализациях тело закрывается не когда ошибка равна nil, a когда получен непустой ответ:
resp, err := http.Get(url) if resp != nil { ➡— Если ответ не равен nil... defer resp.Body.Close() ➡— ... закрыть тело ответа как функцию defer } if err != nil { return "", err }
Эта реализация необязательна. Она основана на том, что при некоторых условиях (например, при сбое перенаправления) ни resp, ни err не будут равны nil. Но согласно документации Go (https://pkg.go.dev/net/http):
В случае ошибка любой ответ может быть проигнорирован. Ненулевой ответ с ненулевой ошибка возникает только в случае сбоя CheckRedirect, но даже в этом случае возвращаемый Response.Body уже будет закрыт.
Поэтому проверка if resp != nil {} не требуется. Нужно придерживаться первоначального решения, которое закрывает тело в функции defer, только если нет никаких ошибка.
Рассмотрим, на что влияет sql.Rows.
# 10.5.2. sql.Rows
sql.Rows — это структура, используемая в качестве результата SQL- запроса. Поскольку эта структура реализует io. Closer, ее нужно закрывать. В следующем примере закрытие строк не выполняется:
db, err := sql.Open("postgres", dataSourceName) if err != nil { return err } rows, err := db.Query("SELECT * FROM CUSTOMERS") ➡— Отуществление SQL- запроса if err != nil { return err } // Использование строк. return nil
Если вы забудете закрыть строки, произойдет утечка соединения, которая не позволит вернуть это соединение с базой данных обратно в пул соединений.
Мы можем обрабатывать закрытие как функцию defer, следующую за блоком if err != nil:
// Открытие соединения rows, err := db.Query("SELECT * FROM CUSTOMERS") if err != nil { return err } defer func() { if err := rows.Close(); err != nil { log.Printf("failed to close rows: %v\n", err) } }() // Использование строк.
После вызова Query мы должны в конечном итоге закрыть rows, чтобы предотвратить утечку соединения, если при этом не возвращается ошибка.
Примечание Как обсуждалось в предыдущем разделе, переменная db (тип *sql.DB) соответствует пуль соединений. Она также реализует интерфейс io.Closer. Но как следует из документации, структуру sql.DB закрывают редко: считается, что ее предназначение подразумевает открытое состояние в течение долгого времени и совместное ее использование многими горути- нами.
Обсудим закрытие ресурсов при работе с файлами.
# 10.5.3. os.File
os.File представляет собой дескриптор открытого файла. Как и sql.Rows, в конце концов он должен быть закрыт:
f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, os.ModeAppend) if err != nil { return err } defer func() { if err := f.Close(); err != nil { log.Printf("failed to close file: %v\n", err) } }()
В этом примере мы используем defer, чтобы отложить вызов метода Close. Если в итоге os.File не закроется, это не приведет к утечке: файл будет закрыт автоматически, когда os.File будет обработан сборщиком мусора. Но лучше
Bb3bIBaTb Close aHHO, notOMy vTO Mb He 3haEM, kOrJa 6ydet 3anyuHn c6opHnK mycopa b cJeJyIOnnii pas (ecJn ToJbKO Mb He 3anyctMm ero BpyHnyIO).
Y aHHOro bH3Oba Close eCTb eIe OJHO npeIMyIIeCTBO: aKTHHoe oTcJeXbIHAnHe bO3BpaIIaEMOu OIIu6Ku. HaIIpHMEp, oTOT bH3Ob OJJIkeH bIHIOJIHrTbC a cJIy4e c baiJIaMn, oTKpBbTbMn aJia 3aIIcHn.
3aIIcHb B aEckpHIIrop baiJIa He aHbIeTcH cHnxpoHHonO IIepaIIeI. H3 cOo6paXeHHnI npoUsBOJHTeJIbHOCTu aHnHbIe 6ydepu3yIOTcH. Ha cTpHnIIe pyKoBOJCTBa BSD, rIe roBOpHTcH o cIose(2), yIOMHHaTeCH, vTO aKpBHtue MoXeT nIpBecTn K OIIu6Ke B paHHee He3aDpHKcIPOBaHHONI 3aIIcHn (Bce eIIe HaxOJHHHeIcB 6ydepe), BO3HHKHeIe BO BpeMx OIIu6Ku BBOJa/3bIBOJa. IO3TOMy ecJIu MbI XOITM 3aIIcIaTb aHnHbIe B baiJI, MbI JOJIKHbI nepeJaBaTb JIO6bIe OIIu6Ku, KoTOpBb BO3HHKaOIT nIPI aKpBHtIN baiJIa:
func writeToFile(filename string, content []byte) (err error) { // OTKpBHtue baiJIa defer func() { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - closeErr := f.Close() if err == nil { err = closeErr } }() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - return
B 3TOM nIpMEpe Mb IcHIOJIb3yEM uMeHOBaHHbIe apyMeHTbI u yCTaHaBJIbBaEM OIIu6Ky b OTBete f. Closc, ecJIu 3aIIcHb npoIIJIa yCneIIHnO. IcJIu vTO- TO c 3TOU dyHkHeIe II0IaEt He TaK, To KJIueHTbI y3HaIoT o6 3TOM u cMOrYt OTpeaIrpOBaTb.
BoJIee toro, yCneIIHoe bAkpBHtue JoCTyINHOro aJia 3aIIcHn baiJIa os. File He raPaH- tUPyET, vTO OH 6ydet 3aIIcHah Ha Juck. 3aIIcHb MoXeT nO- npeXHeMv HaxOJIHTbC a b 6ydepe baiJIIOBOuI cIcTcEMbI u He c6paCbIBaTbC a Ha Juck. EcJIu coXpaHeHne baiJIa aBJIaTeTcH KpHTHHeCKHb M bAkTOpOM, MbI MoXeM cHIOJIb3OBaTb MeTOa JyNC() aJia bHKcaIIHn I3MeHeHnH. B 3TOM cJIy4e OIIu6Ku, 6epyIIHe cHoe Ha4aJI0 b Closc, MoXHO cMeJI0 uTHOpHpOBaTb:
func writeToFile(filename string, content []byte) error { // OTKpBHtue baiJIa defer func() { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 3333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333 33333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333 133333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333323333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333336666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666666333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333
B этом примере содержится функция синхронной записи. Она обеспечивает запись содержимого (контента) на диск перед возвратом. Но тут недостатком является негативное влияние на производительность.
Мы увидели, насколько важным является требование закрывать временные ресурсы и таким образом избегать утечек. Эфемерные ресурсы необходимо закрывать в нужное время и в конкретных ситуациях. Но не всегда сразу становится понятным, что именно нужно будет закрыть. Мы можем получить эту информацию, только внимательно прочитав документацию по API или руководствуясь опытом. Помните, что если структура реализует интерфейс io. Closer, в итоге нужно вызвать метод Close. И последнее, но не менее важное — надо понимать, что делать, если закрытие не удалось: достаточно ли будет зарегистрировать сообщение и занести его в журнал или нужно передать его дальше? Все зависит от реализации, как было показано на трех примерах выше.
Давайте поговорим о типичных ошибках, связанных с обработкой HTTP, когда разработчики забывают об операторе return.
# 10.6. ОШИБКА #80: ЗАБЫВАТЬ ОБ ОПЕРАТОРЕ RETURN ПОСЛЕ ОТВЕТА НА HTTP-ЗАПРОС
При написании обработчика HTTP легко забыть оператор return после ответа на HTTP- запрос. Это может привести к странной ситуации, когда мы должны были бы остановить обработчик после ошибки, но не сделали этого.
Рассмотрим пример:
func handler(w http.ResponseWriter, req *http.Request) { err := foo(req) if err != nil { http.Error(w, "foo", http.StatusInternalServerError) —— Обработка ошибки } // ...}
Если foo возвращает ошибку, то она будет обработана с помощью http.Error, который отвечает на запрос сообщением об ошибке foo и внутренней ошибкой сервера 500. Проблема в том, что если мы войдем в ветку if err != nil, выполнение приложения продолжится, потому что http.Error не останавливает обработчик.
Каковы реальные последствия такой ошибки? Для начала обсудим это на уровне HTTP. Предположим, что мы завершили работу предыдущего
HTTP- обработка, добавив шаг для вывода тела успешного HTTP- ответа и кода состояния:
func handler(w http.ResponseWriter, req \*http.Request) { err : $=$ foo(req) if err $! =$ nil{ http.Error(w,"foo",http.StatusInternalServerError) } $- , =$ w.Write([]byte("all good")) w.WriteHeader(http.StatusCreated) }
B случае, если err != nil, HTTP- ответ будет выглядеть так:
foo all good
Это тот ответ содержит как сообщение об ошибке, так и сообщение об успешном выполнении.
Мы возвращаем только первый код состояния HTTP: в предыдущем примере это 500. Однако Go также запишет в журнал предупреждение:
2021/10/29 16:45:33 http: super+luous response.writeHeader call from main.handler (main.go:20)
Оно означает, что мы пытались записать код состояния несколько раз, что избыточно.
C точки зрения выполнения основное воздействие будет заключаться в продолжении выполнения функции, которая должна была быть остановлена. Например, если foo в дополнение к ошибке возвращает еще и указатель, продолжение выполнения будет означать использование этого указателя, что может привести к разыменованию нулевого указателя (и, следовательно, к панике горутины).
Чтобы исправить эту ошибку, нужно подумать о добавлении оператора return после http.Error:
func handler(w http.ResponseWriter, req \*http.Request) { err : $=$ foo(req) if err $! =$ nil{ http.Error(w,"foo",http.StatusInternalServerError) return → Добавление операторa return } //... }
Благодаря оператору return функция остановит свое выполнение, если мы завершим ее в ветви if err != nil.
Эта ошибка, вероятно, не самая сложная из разбираемых в этой книге. Но о ней легко забыть, поэтому она встречается довольно часто. Всегда нужно помнить, что http.Error не останавливает выполнение обработчика и должен быть добавлен вручную. Такая проблема может и должна быть выловлена во время тестирования, если у нас достаточное покрытие тестами.
В последнем разделе этой главы обсудим вопросы, связанные с HTTP. Разберем, почему в production- grade- приложениях не нужно полагаться на стандартные реализации HTTP- клиента и сервера.
# 10.7. ОШИБКА #81: ИСПОЛЬЗОВАТЬ СТАНДАРТНЫЕ HTTP-KЛИЕНТ И СЕРВЕР
Пакет http предоставляет реализации HTTP- клиента и сервера. Но разработчики часто совершают такую ошибку — полагаются на их стандартные (по умолчанию) реализации в контексте приложений, которые потом разверты- ваются в рабочей среде. Рассмотрим связанные с этим проблемы и способы их решения.
# 10.7.1. HTTP-KЛИЕНТ
Определим, что означает клиент по умолчанию (default client, или стандартный клиент). В качестве примера рассмотрим запрос GET. Используем нулевое значение структуры http.Client так:
client := &http.Client() resp, err := client.Get("https://golang.org/")
Или используем функцию http.Get:
resp, err := http.Get("https://golang.org/")
Оба подхода одинаковы. Функция http.Get использует http.DefaultClient, который также основан на нулевом значении http.Client:
// DefaultClient - это стандартный Client, который используется в Get, // Head и Post. var DefaultClient = &Client{
B чем проблема с использованием стандартного HTTP- клиента?
Прежде всего в стандартном клиенте не определяются никакие тайм- ауты. Их от- сутствие — это не то, что нужно для production- grade- систем: такая ситуация может привести ко многим проблемам, например бесконечно повторяющимся запросам, которые истощают системные ресурсы.
Перед тем как углубиться в доступные при выполнении запроса тайм- ауты, рассмотрим пять шагов, связанных с HTTP- запросом:
1. Запрос на установление TCP-соединения.
2. TLS-рукопожатие (TLS handshake — если оно включено).
3. Отправка запроса.
4. Чтение заголовков ответа.
5. Чтение тела ответа.
На рис. 10.2 показано, как эти шаги соотносятся с тайм- аутами основного клиента.
Рис. 10.2. Пять шагов HTTP- запроса и соответствующие тайм- ауты
Есть четыре тайм- аута:
- net. Dialer. Timeout — указывает максимальный отрезок времени, в течение которого запрос на соединение будет ожидать установления соединения.
- http. Transport. TLSHandshakeTimeout — указывает максимальное время ожидания подтверждения (рукопожатия) TLS.
- http. Transport. ResponseHeader Timeout — указывает время, в течение которого происходит ожидание получения заголовков ответа сервера.
- http. Client. Timeout — указывает ограничение по времени для всего запроса. Он включает в себя все шаги, от шага 1 (Запрос на установление TCP-соединения) до шага 5 ( Чтение тела ответа).
# TaM-ayr HTTP-KHHeHTa
BosMoxHO, BbI CTaNKHbANHb CO cNeJyOuHbI OIMHKOH npu yKa3aHnHn Http.Client. Timeout:
net/http: request canceled (Client.Timeout exceeded while awaiting headers)
3Ta OHHHKa OHHHAnAT, VTO SHJIOHHT HHO OTBTHH B OTBHOHHOH O HPOHn. MH HONy- YaeM 3Ty OHHH6ky 6O XKHJaHnH H aRoJONBKOH, NOTOMy 4TO HX HTEHHe — nEpBHbI uar npu OXHJaHnH H OTBTEa.
Bot пример HTTP- клиента, который переопределяет эти TaHM- aYbI:
client := &http.Client{ Timeout: 5 * time.Second, TaHM- aYt pna rno6anbHoro sanpoca Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: time.Second, TaHM- aYt pna sanpoca u oXHJahnH coeJHHeHnH {).DialContext, TLSHandshakeTimeout: time.Second, TaHM- aYt pna TLS- pyKonoxHATnH ResponseHeaderTimeout: time.Second, TaHM- aYt pna oXHJahnHn }, nony- HennH HOnJONBKOH }
Mы создаем клиент с 1- секундным TaHM- аутом для запроса на соединение, TLS- рукопожатия и чтения заголовка ответа. Между тем каждый запрос имеет глобальный 5- секундный TaHM- аут.
Bторой аспект, который следует учитывать при работе со стандартным HTTP- клиентом, — это то, как обрабатываются соединения. По умолчанию HTTP- клиент создает пул соединений. Стандартный клиент повторно использует подключения (это можно запретить, установив для http.Transport.Disable- KeepAlives значение true). Есть дополнительный TaHM- аут, чтобы указать, как долго бездействующее соединение будет сохраняться в пуле: http.Transport.IdleConnTimeout. Его значение по умолчанию — 90 секунд, это означает, что соединение можно повторно использовать для других запросов в течение этого времени. После этого, если соединение не использовалось повторно, оно будет закрыто.
Чтобы настроить количество соединений в пуле, нужно переопределить http.Transport.MaxIdleConnns. По умолчанию это значение равно 100. Но обратите внимание на нечто важное — ограничение http.Transport.MaxIdleConnsperHost для каждого хоста, значение которого по умолчанию устанавливается равным 2.
Например, если мы инициируем 100 запросов к одному и тому же хосту, то после этого в пуле останутся только 2 соединения. Поэтому если мы еще раз инициируем 100 запросов, придется повторно открывать как минимум 98 соединений. Эта конфигурация может повлиять и на среднюю задержку, если приходится иметь дело со значительным количеством параллельных запросов к одному и тому же хосту.
Для production- grade- систем мы, вероятно, захотим перепределить тайм- ауты, которые используются по умолчанию. И настройка параметров, связанных с пу- лом соединений, также может существенно повлиять на задержку.
# 10.7.2. HTTP-сервер
К реализации HTTP- сервера следует подходить с осторожностью. Стандартный сервер можно создать, используя нулевое значение http. Server:
server := &http.Server() server.Serve(listener)
Можно использовать такие функции, как http.Serve, http.ListenAndServe или http.ListenAndServeT.S, которые также используются в стандартном http.Server.
Как только запрос на соединение принят, HTTP- ответ можно разделить на пять шагов:
1. Ожидания запроса клиента.
2. TLS-pykonожатие (TLS handshake — если оно включено).
3. Чтение заголовков запроса.
4. Чтение тела запроса.
5. Отправка ответа.
ПРИМЕЧАНИЕ TLS-рукопожатие не нужно повторять при уже установ- ленном соединении.
На рис. 10.3 показано, как эти шаги соотносятся с тайм- аутами главного сервера. Три основных тайм- аута:
- http.Server.ReadHeaderTimeout — поле, указывающее максимальное время, отводимое на чтение заголовков запроса;
- http.Server.ReadTimeout
- поле, в котором указывается максимальное время, отводимое на чтение всего запроса;
- http.TimeoutHandler
- обертка функции, указывающей максимальное время, отводимое обработчику на завершение.
Coeдинение yctahonbneho
Puc. 10.3. Пять шагов HTTP-ответа и соответствующие тайм-ауты
Последний параметр является не параметром сервера, а оберткой поверх об- работчика, ограничивающей время его работы. Если обработчик не ответит вовремя, то сервер отправит: «503 Service Unavailable» («503 сервис недоступен») с каким-то специфичным сообщением, а контекст, переданный обработчику, будет отменен.
Примечание Мы намеренно опустили http.Server.writeTimeout, в котором нет необходимости, поскольку был выпущен (в Go 1.8) http.TimeoutHandler. У http.Server.write- Timeout есть несколько проблем. Прежде всего, его поведение зависит от того, включен ли TLS или нет, что усложняет понимание и использование. Он также закрывает TCP- соединение без возврата правильного HTTP- кода, если время ожидания истекло. И он не передает отмену в контекст обработчика, поэтому тот может продолжить выполнение, не учитывая, что TCP- соединение уже закрыто.
При предоставлении конечной точки ненадежным клиентам рекомендуется хотя бы задать поле http.Server.ReadHeaderTimeout и использовать функцию- обертку http.TimeoutHandler. В противном случае клиенты могут использовать эту уязвимость и, например, бесконечно создавать соединения, что приведет к истощению системных ресурсов.
Вот так можно настроить сервер с этими тайм-аутами:
s := &http.Server{Addr: :8080", ReadHeaderTimeout: 500 * time.Millisecond, 06eprtka HTTP- 06pa6ot4nka ReadTimeout: 500 * time.Millisecond, Handler: http.TimeoutHandler(handler, time.Second, "foo"), }
http.TimeoutHandler- 06pa6ot4nka. Ec.ru этот 06pa6ot4nka не отвечает в течение 1 секунды, сервер вернет код состояния 503 с foo в качестве HTTP- ответа.
Как говорилось для HTTP- клиентов, мы можем настроить и на стороне сервера максимальное время для обработки следующего запроса, когда разрешены keep- alive- соединения. Это делается с помощью http.Server.IdleTimeout:
s := &http.Server{ // ... IdleTimeout: time.Second, }
O6ратите внимание, что ec.ru http.Server.IdleTimeout не задан, для тайм- аута простоя используется значение http.Server. ReadTimeout. Ec.ru ни один из этих параметров не задан, то тайм- аутов не будет вообще и соединения будут оста- ваться открытыми до тех пор, пока не окажутся закрыты клиентами.
Bazkho, vto6bi B production- grade- npulioxeHияx he ucnoInSoBaJIucb stahJapHbie HTTP- клиeHbI u ceperebI. B npotubHOM cJyvae H3- 3a otcyTctbHra TaM- ayTOB uJII Jaxke H3- 3a BpeJHOHOCHbIX JelCTbHnI KJIneHTOB sAnPOCbI MoYIT 3aBuchyTb Habcera.
# UTOFU
- FyJbTe octopoxHbI c dyHKlJHmI, npHnHmAnOHNmI time.Duration. HecMOTprHa to vTO nepeJava JezMro uCnJa paspeHena, vTO6bI npeJOTepaHTb BO3MOxHyIOnyTahuIy, старaHteCb ucnoJIb3OBaTb time API.- OTKa3 OT BbJ3OBOB time.After B nOBTOpJIOHIXc dyHKlJHx (IuKJIb uJII HTTP- 06pa6ot4nku) nO3eOJIreT H36eXaTb nIuKOBOrO nOTpe6JIeHnI nAmATI. PecycbI, CO3JahHbIe time.After, ocBO6OxJaaOTcA ToJIbKO no ucTeHeHnI yCTaHOBJIeHHOrO 3HaVeHnI TaUMepa.- NoJxoJUte c octopoxHOCTbIO K ucnoJIb3OBaHHIO BCTpOeHbIX nOIeIb B CTpyKtypaX Go.OHO MoxeT npHbEcTbI K HeOyeBUIHbIM OIIu6KaM, HaHpимер nepeonpeJeJIeHnIO nObeJehnIa MapIIaJIHnIa nO yMOJIaHHnIO BCTpOeHbIM nOIeM time.Time, peaJIu3yIOHnIM uHHTepBeIc json.Marshaler.
- При сравнении двух структур time. Time помните, что эта структура содержит как настенные, так и монотонные часы и сравнение с использованием оператора $= =$ выполняется для них обоих.- Чтобы не делать неверные допущения при предоставлении карты во время демаршалинга данных JSON, помните, что числовые значения по умолчанию преобразуются в тип float64.- Вызывайте методы Ping или PingContext, если нужно протестировать конфигурацию и убедиться, что база данных доступна.- Настройте параметры подключения к базе данных для production-grade-приложений.- Использование подготовленных операторов SQL делает запросы более эффективными и безопасными.- Работайте в таблицах со столбцами, значение которых может быть равно NULL, используя указатели или типы sql. NullXXX.- После итерации по строкам вызывайте метод Err для "sql. Rows для того, чтобы убедиться, что вы не пропустили ошибку при подготовке следующей строки.- Закрывайте все структуры, реализующие io. Closer, чтобы избежать возможных утечек.- Чтобы избежать неожиданного поведения кода при реализации HTTP-обработчика, убедитесь, что не забыли использовать оператор return, если хотите, чтобы обработчик останавливался после http. Error.- Для production-grade-приложений не пользуйтесь стандартными реали-зациями HTTP-клиента и сервера. По умолчанию в этих реализациях нет тайм-аутов, которые обязательны в продакшене.
# Bэтой главе:
- Kатегоризация тестов и повышение их надежности- Как сделать тесты Go детерминированными- Работа с пакетами httptest и iotest- Как избежать совершения типичных ошибок в бенчмарках- Совершенствование процесса тестирования
Tестирование — важнейший аспект жизненного цикла проекта. Оно дает бес- численные преимущества: укрепляет доверие к приложению, документирует код, облегчает рефакторинг. По сравнению с другими языками Go предоставляет продвинутые и полезные примитивы для написания тестов. В этой главе рассмотрим типичные ошибки, делающие тестирование хрупким, менее эффективным и менее точным.
# 11.1. OWWKA #82: HE PACIPPEDEJRTb TECTbI NO KATEFORIM
IIupamuya tectupobanua - oTO MoDEb, kotopar rpynnupyet tectbI no pasbIM kateropurM (puc. 11.1). IOHut- tectbI (uuu moJyJbHbIe tectbI) haxoJATcR b ochobaHnun nupamuya. BoJbIMHHCTBO tectOB JOLJKbI bIbTb KaK MOJyJbHbIe: OHu nIIIIIyTcA OTHOcUTeJbHO IpOCTO, bIcTPO BbIIOJIHIOHTcR u BbICOKOJCTePmHHUPOBaHHbI. HO mepe npoJbUNkeHnIa bHePX nO nupamuya tectbI cTahOBaTcR bOJee cJIOXHbIMu JJIa HaIIucahnIa u bOJee MeJJIeHHbIMu npu BbIIOJIHeHnI, u IUX JeTepmHHUPOBaHHOCTb TpyJIHee rapaHTUPOBaTb.
O6bIHbIbI nOJXOJ 3aKaIOJaEcTcR b ToM, YTO6bI rBHO yKaaTb, KaKue H3 tectOB cJIeJ dyet npOBOJHTb. HaIIpHMEp, B 3aBucIMOCTu OT JTaIa aK3BeHHOro uIkJIa nPOeKTa Tpe6yEcTcR 3aIIyCTHTb BcE BO3MOXHbIe tectbI JNUO ToJIbKO OHHIT- tectbI. OTCyTCTbIe KJIaCCuJbKaIaIIu tectOB O3HaaEt nOteHHIaJIbHyIO nOtePO BpeMeHn I u cyJIuI, a TaKxe nOtePO ToYHOCTu B OnpeJeJIeHHn b6bEma tecta. B JTOM pa3JeJIe b6cyJIM Tpu OCHOBHbIX cIOOc6a Ka3CCuJbKaIaIIu tectOB B Go.
PUC. 11.1. IpuMEp nIpaMaJbI TeCTUPOBaHnI
# 11.1.1. Teru c6opKu
Ha6OJee pacnpoCTpaHHObIHbI cIOOc6 KaJaccuJbKaIaIIu tectOB - uCIOJIb3OBaHHe TeTOB c6OpKu. Ter c6OpKu - oTO cneIIaJIbHbIbI KOMMeHTapnIb B HaYaJIe bIaJIa Go, 3a KotOpbIM cJIeJIyET nIyCTaI cTpOKa.
IOcMOrpuIte Ha TaKOuI bIaJI bAr.go:
//go:build foo
package bar
OTOT bIaJI cOJIepXHIT TeT fOo. O6paTnTe bHMaHHe, YTO OJIH nIaKET MOXeT cOJIepXaTb HeCKOJIbKO bIaJIbO c pa3HbIMu TeTaMH c6OpKu.
PРИМЕЧАНИЕ Начиная с версии Go 1.17, синтаксис // +build foo был заменен на //go:build foo. Сейчас (в Go 1.18) gofmt синхронизирует эти две формы, чтобы упростить миграцию.
Теги сборки используются в двух случаях. Во-первых, в качестве условной оп- ции для сборки приложения. Например, если нужно, чтобы исходный файл был включен, только если разрешен cgo (cgo позволяет пакетам Go вызывать код C), добавьте ter //go:build cgo build. Во-вторых, если нужно классифицировать тест как интеграционный, тогда мы добавляем специальный флаг сборки, например integration.
Bot пример файла db_test.go:
//go:build integration package db import("testing") func TestInsert(t \*testing.T){ //... }
3десь есть integration в качестве тега сборки. Он указывает, что файл содержит интеграционные тесты. Преимущество использования тегов сборки заключается в том, что можно выбирать, какие виды тестов выполнять. Предположим, что пакет содержит два тестовых файла:
- только что созданный файл: db_test.go;- другой файл, не содержащий тег сборки: contract_test.go.
Если запустить внутри этого пакета go test без каких-либо параметров, он запустит только тестовые файлы без тегов сборки (contract_test.go):
$\)$ go test - v . \(= = =$ RUN TestContract - - - PASS: TestContract (0.01s) PASS
Ho если задать ter integration, выполнение go test также будет включать db_test.go:
$\)$ go test - - tags=integration - v . \(= = =$ RUN TestInsert - - - PASS: TestInsert (0.01s) $= = =$ RUN TestContract - - - PASS: TestContract (2.89s) PASS
Takим образом, запуск тестов с каким- то terom включает в себя исполнение как файлов без тегов, так и файлов, соответствующих тегу. Но что, если мы хотим запускать только интеграционные тесты? Возможный способ — добавить тег отрицания в файлы кониг- тестов. Например, использование !integration оз- начает, что мы хотим включить тестовый файл, только если флаг integration не включен (contract_test.go):
//go:build !integration package db import ( "testing" func TestContract(t \*testing.T) { //... }
Icnolb3yra takoii nolko, mi do6bnaemcra toro, vto:
- запуск go test с флагом integration запускает только интеграционные тесты;- запуск go test без флага integration запускает только юнит- тесты.
O6cyaHm bapnait, pa6otaroHii na ypoBne oJHOro TcTcTa, a he qaiJia.
# 11.1.2. Переменные среды
Kak ckasaJI IlIter Eypron (Peter Bourgon), uJen co66necstba Go, teru c6opku umeIOT OJIN rJaBbHiiI HeJocTaTOK: otcyTctbue curnaJIOB o TOM, vTO TECT IpOuI- HOpHpOBaH (CM. http://mng.bz/qYIr). B nepBOM npUmepe, KoJTa Mbi BbIIOJHNJII go test без флагов c6opku, on noka3aJI ToJbKO te tectbI, kotopbIe 6bJIu BbIIOJHENbI:
$\Updownarrow$ go test - v. $= = =$ RUN TestUnit - - - PASS: TestUnit (0.01s) PASS ok db 0.319s
EcJIu Mbi He 6yJEM bHUMaTeJIbHbI K ToMy, kak o6pa6aTbIbAHOcTc TcTn, Mbi MoXeM 3a6bIbTb o cyIIecTbYIOHbIX tectax. ITo StOuI npIyHHe HeKOTorpbIe IpOeKbIb IpEJIO- vHTaIOT IpOBepraTb KaTeropIIO tectOB c nOMOIIbIO nepeMeHbIX cpeJbI.
HaHpImep, moXHO peaJIN3OBaTb интеграционный tect TestInsert, IpOBepIB HeKOTOpYIO OnpeJeJIeHHyIO nepeMeHnyIO cpeJbI u, BO3MOXHO, IpOIIyCTbIb KaKOu- To nOJIO6HbIiTecT:
func TestInsert(t \*testing.T){ if os.Getenv("INTEGRATION") $! =$ "true" t.Skip("skipping integration test") } //... }
Eсли значение переменной среды INTEGRATION не установлен true, to тест будет пропускаться с выдачей сообщения:
$\Updownarrow$ go test - v. $= =$ RUN TestInsert db Integration_test.go:12: skipping integration test - - - - SKIP: TestInsert (0.00s) - - - - RUN TestUnit - - - - PASS: TestUnit (0.00s) PASS ok db 0.319s
Oдно из преимуществ использования этого подхода — явное указание на то, какие тесты пропускаются и почему. Этот метод используется не так часто, как теги сборки, но о нем стоит знать, поскольку он предоставляет некоторые преимущества.
Рассмотрим еще один способ категорииации тестов: короткий режим.
# 11.1.3. Короткий режим
Другой подход к классификации тестов связан с их скоростью. Возможно, придется отделить тесты с коротким временем выполнения от тестов с длительным временем выполнения.
Допустим, есть набор юнит- тестов, и известно, что один из них медленный. Нужно отнести его к отдельной группе, чтобы не приходилось его запускать каждый раз (особенно если он запускается, например, после сохранения файла). Короткий режим позволяет сделать это:
func TestLongRunning(t \*testing.T){ if testing.Short() { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - t.Skip("skipping long- running test") } //... }
Используя testing.Short, можно узнать, был ли во время выполнения теста включен короткий режим. Затем мы используем Skip, чтобы пропустить тест.
HTo6bi 3anyctutb tectbi B kopoTkoM peKUME, HYXHO IOcJATb - short:
% go test - short - v. $= = =$ RUN TestLongRunning foo_test.go:9: skipping long- running test - - - SKIP: TestLongRunning (0.00s) PASS ok foo 0.174s
TestLongRunning явHO iponyckaetcr npu bInioJheninu tectob. O6parutre bInImaHne, VTO, B OTJInHue OT TeTOB c6opKu, sta onHnIa pa6oTaet JJIa KaXdoro tecta, a He JJIa KaXdoro qaiJia.
Takum o6pa3OM, kateropuisaHnIa tectoB - 3TO JyVIIaIa npaKtuka B ycneIInOH tpa- terinu tectupobahnI. B JStOM pa3JeJe Mbi paccmOTpeJIn Tpu cInocOa KJaccuJpKaIaHn tectoB:
c ucnIoJb3OBaHnIeM teTOB c6opKu Ha ypoBHe tectOBOro qaiJia; C c ucIoJb3OBaHnIeM nepeMeHHbIX cpeJb JJIa nOMeTKu OnpeJcJeIHHoro tecta; Ha OcHOBe JJIutJeJIHHOCTu Ix BbInIOJHeHnIa c ucIoJb3OBaHnIeM kopoTKOro peXUMa.
MoxHO kOM6HHupOBaTH JTI nOJXOJbI: HaIpnMEp, ucIOnJIbObATb TeTn c6opKu uJI nepe- MeHHbIe cpeJb JJIa KJaccuJpKaIaHnIu tecta (HaIpnMEp, OHnIT- uJIu uHterpaIIOOHHOHO) u kopoTKuI qeXUM, eJIn IpoeKT cOJepKHT JOJIro BbInOJIHIOIInuecra OHnIT- tectbI.
B cJeJIyIouIeM pa3JeJe o6cyJIM, nOyeMg yBaXHO BKIIOyATb qJIaI - race.
# 11.2. OJIM6KA #83: HE BKIIOyATb QJIaT -RACE
B pa3JeJe, nocBaIIeHmOM pa36Opy OIIIn6Ku #58 (He nOHmMCTb np66JeM rOHKu), MbI OnpeJeJIJIn IOHKy JaaHHbIX KaK cUTyIaIIO, KoJJa JBe rOpyTHHbI OJIHObpeMeHHO o6paIIaIOTcra K OJIHOI I u ToI Jke nepeMeHHO, Ipn I3OM XOJIa 6bI OJIHa I3 rOpyTHH npOJ3BOJIT 3aInIcB b OTy nepeMeHHyIO. B Go eCTb CTaHaJIapTHbIbI uHCTpyMeHT, nO- moraIOIIuI I6HaPYXKIBaTb rOHKy JaaHHbIX. OJIHa I3 paCIIpoCTpaHeHHbIX OIIIn6OK pa3pa6OTyIKOB cOCTOIT B TOM, YTO OHn 3a6bIBaIOT O BaXHOCTu STOro uHCTpyMeHTa u He aKTHbIbpyIOT eTO. B J3OM pa3JeJe y3HaeM, KaKue HeXeJIaTeJIbHbIe cUTyIaIInu OTJIaBaJIbIaET JeteKTOp rOHKu, KaK eTO ucIOnJIb3OBaTb u KaKHe y HeTO ORpaHHvHnIa.
B Go JeteKTOp rOHKu - 3TO He uHCTpyMeHT CTaTnYeCKoro aHaJIIn3a, ucIOnJIb3yIOIIuI- cI bO bpeMx KOMIIJIJIaIIn. OH npeJIHa3aHaeH JJIa nOuCKa rOHOK JaaHHbIX, KOTOpHe
происходят во время выполнения. Чтобы его активировать, установите флаг - race во время компиляции или запуска теста. Например:
$ go test - race ./...
Как только детектор гонки оказывается активирован, компилятор инструментирует код для обнаружения гонок данных. Понятие инструментация относится к действиям компилятора, добавляющим дополнительные инструкции: отслеживание всех обращений к памяти и запись, когда и как они происходят. Во время выполнения детектор следит за тем, не возникают ли гонки данных. Но помните о том, что это достигается ценой дополнительного расхода ресурсов из-за включенного состояния детектора гонки:
- уровень использования памяти может увеличиться в 5-10 раз;- время выполнения может увеличиться от 2 до 20 раз.
Из-за такого оверхеда обычно рекомендуется включать детектор гонки только во время локального тестирования или непрерывной интеграции. В рабочем продукте его следует избегать (или использовать, например, только в случае канареечных релизов).
Если обнаружена гонка, то Go выдает предупреждение. Например, следующий код содержит гонку данных, потому что к i можно обращаться одновременно и для чтения, и для записи:
```javapackage mainimport ( "fmt")func main() { i := 0 go func() { i++ }() fmt.Println(i)}```
Запуск этого приложения с флагом - race регистрирует следующее предупреждение о гонке данных:
Y6eJUMcA, vTO tAKHe COO6IIeHnIy yIO6HO uITaTb. Go BcERJa peIrcTpIPIYeT cJIeJIyI0IIee:
- Причастные кгонке конкурирующие горугины: здесь
- основная горугина и горугина 7.
- Где в коде происходят обращения: в данном случае это строки 9 и 10.
- Когда были созданы эти горугины: горугина 7 была создана в main().
Примечание Внутри себя детектор гонки использует некоторые часы — структуру данных, которую применяют для определения частичного упорядочивания событий (а также в распределенных системах, таких как базы данных). Каждое создание горугины приводит к созданию некоторых часов. Инструментация обновляет некоторые часы при каждом действии по доступу к памяти и акте синхронизации. Затем она сравнивает некоторые часы, чтобы обнаружить потенциальную гонку данных.
Детектор гонки не может отлавливать ложноположительные срабатывания (то есть ситуации, выглядящие как гонки данных, но не являющиеся ими). Если мы получим от него какое-то предупреждение, то это означает, что код действи- тельно содержит гонку данных. И наоборот, иногда он дает ложноотрицательные результаты (пропуск фактически имеющих место гонок данных).
Отмечу две вещи, касающиеся тестирования. Во-первых, детектор гонки может быть хорош лишь настолько, насколько хороши наши тесты. Важно убедиться, что конкурентный код тщательно тестируется на предмет гонок данных. Во- вторых, учитывая возможные ложноотрицательные результаты, логику теста для проверки гонки данных можно поместить в цикл. Это увеличивает шансы на отлавливание возможных гонок данных:
func TestDataRace(t *testing.) \{ for i := 0; i < 100; i++ { // Существующая погика. } \}
Кроме того, если какой-то конкретный файл содержит тесты, которые приводят к гонке данных, мы можем исключить его из обнаружения гонок с помощью тега сборки !race:
//go:build !race package main import( "testing" func TestFoo(t \*testing.T){ // func TestBar(t \*testing.T){ // }
Этот файл подлежит сборке только в том случае, если отключен детектор гонки. В противном случае весь файл не будет собран и тесты выполняться не будут.
Настоятельно рекомендуется запуск тестов с флагом - гасе для приложений, в которых используется конкурентность. Иногда это даже обязательно. Это позволяет активировать детектор гонки, который инструментирует код для обнаружения потенциальных гонок данных. Его активация сильно влияет на производительность и значительно нагружает память, поэтому этот подход необходимо использовать в специфических обстоятельствах, например при локальном тестировании или при CI.
В следующем разделе обсудим два флага, относящиеся к режиму выполнения: parallel и shuffle.
# 11.3. Ошивка #84: HE ИСПОЛЬЗОВАТЬ РЕЖИМЫ ВЫПОЛНЕНИЯ ТЕСТОВ
Во время выполнения тестов команда go может воспринимать набор флагов, влияющих на выполнение тестов. Типичная недоработка - не знать эти флаги и упускать при этом возможности для более быстрого выполнения кода или более эффективного обнаружения ошибок. Рассмотрим флаги parallel и shuffle.
# 11.3.1. Флаг parallel
Режим параллельного выполнения позволяет запускать определенные тесты параллельно, что может быть очень полезно, например, для ускорения проведения длительных тестов. Можно поставить метку о том, что тест должен выполняться параллельно, вызвав t.Parallel:
func TestFoo(t *testing.T) { t.Parallel() // ...}
Когда мы помечаем тест с помощью t.Parallel, он выполняется одновременно со всеми другими параллельными тестами. Но с точки зрения организации исполнения кода Go сначала запускает один за другим все последовательные тесты. После их завершения выполняются параллельные тесты.
Следующий код содержит три теста, но только два из них отмечены для парал- лельного выполнения:
func TestA(t *testing.T) { t.Parallel() // ...}func TestB(t *testing.T) { t.Parallel() // ...}func TestC(t *testing.T) { // ...}
Запуск тестов для этого файла выводит следующие записи:
= RUN TestA === PAUSE TestA ← TestA приостанавливается === RUN TestB === PAUSE TestB ← TestB приостанавливается === RUN TestC ← 3апускается TestC --- PASS: TestC (0.00s) === CONT TestA ← TestA и TestB возобновляются --- PASS: TestA (0.00s) === CONT TestB --- PASS: TestB (0.00s) PASS
TestC выполняется первым. TestA и TestB зарегистрированы первыми, но их выполнение приостанавливается до завершения TestC. Затем исполнение возобновляется, и они выполняются параллельно.
По умолчанию максимальное количество одновременно выполняемых тестов равно значению GOMAXPROCS. Для сериализации тестов или, например, увеличения этого числа в контексте длительных тестов, выполняющих много операций ввода/вывода, можно изменить это значение с помощью флага - parallel:
$ go test - parallel 16.
3десь максимальное количество параллельно выполняемых тестов равно 16. Теперь рассмотрим другой режим при выполнении тестов Go: shuffle.
# 11.3.2. Флаг -shuffle
Hачиная с версии Go 1.17, можно задать случайный порядок выполнения тестов и бенчмарков. Когда это нужно? Лучшая практика при написании тестов — делать их изолированными. Например, они не должны зависеть от порядка их выполнения или от каких-либо общих для них переменных. Наличие скрытых зависимостей может приводить к ошибкам теста или, что еще хуже, к ошибкам, которые не будут обнаружены во время тестирования. Используем флаг - shuffle для рандомизации тестов, то есть их выполнения в случайном порядке. Можно задать состояние этого флага on или off, чтобы включить или отключить режим перетасовки тестов (по умолчанию он отключен):
$ go test - shuffle=on - v.
Иногда нужно повторно запустить тесты в том же порядке. Например, если тесты не проходят во время CI, потребуется воспроизвести ошибку локально. В этом случае вместо установки флага - shuffle в состояние on требуется передать значение стартового числа (seed) для рандомизации тестов. Мы можем получить это значение как один из результатов выполнения тестов в случайном порядке, включив подробный режим (- v):
$ go test - shuffle=on - v. - test.shuffle 1636399552801504000 3начение seed === RUN TestBar - - - PASS: TestBar (0.09s) === RUN TestFoo - - - PASS: TestFoo (0.09s) PASS ok teivah 0.129s
3десь мы выполнили тесты случайным образом, но при этом go test вывел значение стартового числа: 1636399552801504000. Чтобы тесты выполнялись в том же порядке, мы передаем в shuffle это значение:
$ go test - shuffle=1636399552801504000 - v. - test.shuffle 1636399552801504000 === RUN TestBar - - - PASS: TestBar (0.09s) === RUN TestFoo - - - PASS: TestFoo (0.09s) PASS ok teivah 0.129s
Tectbi 6bIu BbIIOJIHeHbI b ToM xe nOprJke: chavaaTa TestBar, a 3atem TestFoo.
C cyuIecTByIouI1M1 tectObIM1 $\Phi$ Jaramu cJedyet pa6oTaTb octorOxHO. Takxe HyxHO 3HATb, kakue HOBHe yHK1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 1elbHoe bInIOJIHeHHe tectOB MoxKer 6bITb OTJI111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 bpeMx I0JIHOIO tectIupOBaH111. A peX11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111121111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
# 11.4. OWWBA #85: HE IICNOJIb3OBATb TAEJNUHbIE TECTbI
Ta6J111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111I111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111T1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111113111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111511111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111161111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111117111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111811111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111191111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111114111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111.
PaccMOrpHM cJcJyIOJIyIO yJyHK1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111100111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110
func removeNewLineSuffixes(s string) string { if s $= =$ { return s } if strings.HasSuffix(s,"rn") { return removeNewLineSuffixes(s[:len(s)- 2]) } if strings.HasSuffix(s,"n") { return removeNewLineSuffixes(s[:len(s)- 1]) } return s }
Ora yyHK111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101011111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110
- Bход заканчивается несколькими \n;- Bход заканчивается без новых строк.
B следующем коде для каждого случая создается юнит-тест:
func TestRemoveNewLineSuffix_Empty(t \*testing.T) { got : $=$ removeNewLineSuffixes("") expected : $=$ "" if got $! =$ expected { t.Errorf("got: %s", got) } } func TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine(t \*testing.T) { got : $=$ removeNewLineSuffixes("a\r\n") expected : $=$ "a" if got $! =$ expected { t.Errorf("got: %s", got) } } func TestRemoveNewLineSuffix_EndingWithNewLine(t \*testing.T) { got : $=$ removeNewLineSuffixes("a\n") expected : $=$ "a" if got $! =$ expected { t.Errorf("got: %s", got) } } func TestRemoveNewLineSuffix_EndingWithMultipleNewLines(t \*testing.T) { got : $=$ removeNewLineSuffixes("a\r\n\n") expected : $=$ "a" if got $! =$ expected { t.Errorf("got: %s", got) } } func TestRemoveNewLineSuffix_EndingWithoutNewLine(t \*testing.T) { got : $=$ removeNewLineSuffixes("a\n") expected : $=$ "a" if got $! =$ expected { t.Errorf("got: %s", got) } }
Kakdая dyhkiua cootbetctbyet konkpethomy clyaio, kotopbiu hao nokpbit. Ho b 3tom koe dba недoctatka. Bo- nepbix, bce meha dyhkiuui 3dec b oehb cJox- hbie (hanpmer, TestRemoveNewLine- Suffix_EndingWithCarriageReturnNewLine Jlinной b 55 cHmBolob). 3ro moxet chusnits noHmHahue toro, uto dyhkiu dJoxha tectupobatb. BtopoH недoctatok - 6oJbHlhar cteneHb Jy6Jupobahua kOa 3Tux dyhkiuui, kotopbie umeiot oJHHakobyro ctpyktrypy:
1. Bb130B removeNewLineSufixes.
2. OnpeJedenue oXkJjaemoro 3haueHnI.
3. CpaBHeHnue 3haueHnI.
4. 3anucb coo6nHnHn 66 oHn6ke.
EcHn notpe6yeTcHnHeHnTb KaKou- To H3 3Hn XHaO, HanpHMEp BcHnOHnTb oXH- Jaemoe 3haueHnue B coo6nHnue o6 oHn6ke, To npnJTeTcH no3T6opuTb 3TO 33HeHeHnue BO Bcex TcTax. U yem oJbHHe TcTcOb, TeM cJIOXHee noJdepeXka koJa.
B takux cJyvaax moXHO ucnoJIb3OBaTb Ta6JnHbHe TcTbI: HX JorUKa nHnHecTcTb ToJIb- ko oJHn pa3. Ta6JnHHe TcTbI oCHOBaHb Ha noJIteTcTax, a oJHa TcCTOBaB qyHKnIa HOMKTe BcJIHOvATb B c66a HeCKOJIbKO takux noJIteCTbO. HanpHMEp, cJIeJIyIOHnHnTb TcTcOdeprXnT dba noJIteCTa:
func TestFoo(t \*testing.T){ t.Run("subtest 1", func(t \*testing.T){ BbnonHeHe nepboro noJIteTcT (subtest 1) if false{ t.Error() } }) t.Run("subtest 2", func(t \*testing.T){ BbnonHeHe Btoporo noJIteTcT (subtest 2) if 2 != 2{ t.Error() } }) }
DyHKnIa TestFoo BcJIHOvaet B c66a dba noJIteCTa. EcJIH Mb 3aIIyCTHM 3TOT TcT, OH nOKaXeT pe3yJIbTaTbI KaK JJa subtest 1, TaK H JJa subtest 2:
- -- PASS: TestFoo (0.00s)
-- PASS: TestFoo/subtest_1 (0.00s)
-- PASS: TestFoo/subtest_2 (0.00s) PASS
MbI TaHKe MoXeM 3aIIyCTHnTb ToJIbKO oJHnH TcTcT, HcHOJIbJIYJ aJIb 3TOTO qyHr - run u o6beJHHb HMn poJIHTeJIbCKoro TcTcTc C noJIteCTOM. HanpHMEp, 3aIIyCTHnTb ToJIbKO subtest 1:
$\Updownarrow$ go test - run=TestFoo/subtest_1 - v 1nCnOb3OBaHHe qHaTa- run JnJ3anycka $= = =$ RUN TestFoo ToJIbKO subtest 1 $= = =$ RUN TestFoo/subtest_1 - - - PASS: TestFoo (0.00s) - - - PASS: TestFoo/subtest_1 (0.00s)
BepheMcs k hainemy nprmery u nocmortpum, kak ucnoJbSobatb nodtectbi, to6bi npeJotBpRatutb dy6JunpoBahue koJa, kotopbui coJepxHtB e6e Joruky tectupoBahus. OcHOBHaa uJea cocTOunt B ToM, YTO6bi co3Jabatb JJIa KaKaJoro cJIyvaar c6uI nodtect. Ectb pasHbie BapuaHbI, HO Mbl o6cyJum ctpyktypy JauHbIX B BnJe kapbI, rJee KJIo4 cootBecTcbyet Imehu tecta, a 3ha4ehue npeJctaBbJret co6ouT tectObBie JauHbIe (bxoJHbIe, oXkJJaemBle).
B ta6JunHbIX tectax Mbl H36eraem ucnoJbSobahnua Ha6JOHHOro koJa npu nomouH ctpyktypbi JauHbIX, coJepxanueu tectObBie JauHbIe BMecTc e nodtectamu. Bot BosMoXkhaa peaJIN3aIIaJia c HcnoJbSobahnueM kapbI:
func TestRemoveNewLineSuffix(t \*testing.T) { tests : $=$ map[string]struct { OnpeJeneHue tectObBIX aHbIX input string expected string } empty:{ KaXpa3anucb B kapte npeJctabJret co6ou nodtect input: "" expected: "", }, ending with \n\n':{ input: "a\n", expected: "a", }, ending with \n':{ input: "a\n", expected: "a", }, ending with multiple \n':{ input: "a\n\n", expected: "a", }, ending without newline':{ input: "a", expected: "a", }, } for name, tt : $=$ range tests { HrepaHn no kapte t.Run(name, func)t \*testing.T) { got : $=$ removeNewLineSuffixes(tt.input) BbInonHHeHHe HOBoro nodtecta if got ! $=$ tt.expected { nJg KaXpnu3anucb KaJTe t.Errorf("got: %s, expected: %s", got, tt.expected) } }) }
Hepemehna test - 3ro kapra. Ee KJIo4 - 3ro HmT tecta, a 3ha4ehue - JauHbIe JJIa tecta, B hainem cJIyvae BxoJHbIe JauHbIe u oXkJJaemBla cTpoka. KaKaJaa 3aIIuCb
в карте — это новый случай, который мы хотим покрыть. Поэтому для каждой записи карты запускается новый подтест.
Такой тест устраняет два недостатка:
- Имя каждого теста теперь представляет собой строку, а не имя функции в стиле PascalCase, что упрощает чтение.- Логика теста записывается только один раз и используется для всех раз-нообразных случаев. Изменение структуры тестирования или добавление нового теста требуют минимальных усилий.
В таблицных тестах может быть еще один источник ошибок: как уже ранее говорилось, мы можем пометить какой-то тест как выполняющийся параллельно, вызвав t.Parallel. Мы также можем сделать это в подтестах внутри замыкания, предоставляемого t.Run:
for name, tt := range tests { t.Run(name, func(t *testing.)) { t.Parallel() // Использование tt }}
Ho это замыкание использует переменную цикла. Чтобы предотвратить проблему, подобную описанной при разборе ошибки # 63 (неосторожно обращаться с горутинами и переменными цикла), которая может привести к тому, что замыкания будут использовать неправильное значение переменной tt, мы должны создать другую переменную или теневую копию (shadow copy) tt:
for name, tt := range tests { tt := tt // Создание теневой копии tt, что делает переменную локальной для цикла t.Run(name, func(t *testing.)) { t.Parallel() // Использование tt }}
Таким образом, каждое замыкание будет обращаться к своей собственной переменной tt.
Если у нескольких юнит-тестов схожая структура, мы можем объединить их, используя возможности таблицных тестов. Поскольку этот метод предотвращает дублирование, он упрощает изменение логики тестирования и добавление новых опций.
Обсудим теперь, как предотвратить появление нестабильных тестов.
# 11.5. OWWKA #86: 3AДЕРЖКИ В ЮНИТ-ТЕСТАХ
Hecma6u/6b/6 (flaky) tect moxet kak npoutn, tak u he npoutn 6es kaxux- nu6o usmenenii b koqe. Ha/nu/ue hecma6u/6b/6x tectob - sto c/ua/6 n3 cawbix 6o/6b/6ux npo6/6em b tectupobanin, nockobky onu doporue b ot/ia/ke u noidpba/ant ybereh- hoc6t6 b to/6hoc6t6 tectupobanin. Bb/36b time. Sleep b tecte moxet c/rrha/1/3/3/6pba/6 b o36moxh6n hecta6u/6b/6ctn. Ha/np/mer, konkypen/tnh6n k/4 vacto tectup/ye/6t6 c n1omoub/6o 3a/3epxek (sleeps). B 31om pas/3e/6 npe/cta6b/6e/6 b konk/pe/tnh6e me/6o/6b/6 y/aa/6e/6n 3a/3epxek n6 tectob u, takum o6pa3om, npe/ot/pa/nu/6n 6c/3a/3a/6n he- cta6u/6b/6b/6x tectob.
Ioka/xy 31o ha np/merpe aha/1/3a 6y/hk/1/6n, kotopar bo3epa/1/6t6 3ha/6e/6n u 3a- nyckae/6 r0pyt/6n, bbl/6o/6n/6n/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n n/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n 6b/6n
type Handler struct { n int publisher publisher } type publisher interface { Publish([]Foo) } func (h Handler) getBestFoo(someInputs int) Foo { foos := getFoos(someInputs) 1- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - best := foos[0] 1- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Coxpahene nepboro 3nemehta (npobe/cka 6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/ 6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n 6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n /6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/7 1 h.publisher.Publisher(foos) 1- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n
Ctpykrypa Handler co/epxknt dba n0/6n. n0/6e n 3a/bic/moc6ts publisher, ncn0/6b- 3yemyo 6n/6ny6/6nka/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n 3yemyo 6n/6ny6/6nka/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6
Kak npotecrupobat6 3ty yyhk/1/6n? Ha/ncat6 kaky/6- to vac6 k/6n, 6106/6n 6o/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n/6n
MoxHo CbIMIrupOBaTH HHTepDBeC publlshcr, UTO6bI 3aInucATb apryMeHTbI, nepeJaBaEMbIe npu bIsObe MeTOJa Publlsh. 3atem npePbATbCra Ha HecKOJIbKO MIJIInCEkYHJI nepEaI npOBepKOu3aInIcaHHbIX apryMeHTOB:
type publisherMock struct { mu sync.RwMutexx got []Foo } func (p \*publisherMock) Publish(got []Foo) { p.mu.Lock() defer p.mu.Unlock() p.got $=$ got } func (p \*publisherMock) Get() []Foo { p.mu.RLock() defer p.mu.RUnlock() return p.got } func TestGetBestFoo(t \*testing.T) { mock : $=$ publisherMock{} h : $=$ Handler{ publisher:&mock, n: 2, } foo : $=$ h.getBestFoo(42) // npoBepka foo time.Sleep(10 \* time.Millisecond) 10- mUNInCEkYHAaBra nay3a neped npOBepKOu published : $=$ mock.Get() apryMeHTbO, nepeJaAHBbIX B Publlsh // npoBepka published }
Mbi nIIIeM mOK (UMIIraIIIO) publlshcr, KoTOpBbI uCIOJIb3yET MbIOTeKc JJI3aIInUTbI doCTyNA K nOJIc publlshed. B OTOM IOHUT- TECTe MbI bIsbIBaEM time.SLeep, YTO6bI OCTaBUTb HeKOtorpoe BpeMn nepeI npOBepKOu apryMeHTOB, nepeJaHHbIX B Publlsh.
OTOT tECT hEcTa6uJIeH no cBOeU cyти. CtporoU rapaHTHn, vTO 3aJepxKkU uMeHHO B 10 MUNJInCEkYHd 6yJCT doCTaTOyHO, HET (B aHHOM npUmepe STO BEcMbA BePOrTHO, HO HetOyHO).
Kak yJIyIInUTb TaKOu OHHT- TECT? IpeKde Bcero nepHOJuyeCKu npOBepaTb 3aJIaH- HOe yCJIOBue, uCIOJIb3yA nOBTOpHbIe nOIIbITKu. HAnpHMep, HaIIucATb OyHKIIIO, KOTOpaH npHINHMaeT yTpepKdeHnIa B KaueCTBe apryMeHTa, a TaKKe MAKcIMaJIbHOe KOJIuVEcTBO nOIIbITOK H BpeMn OXKIJaHHn, U bIsbIBaETcA nepHOJuyeCKu, YTO6bI U3- 6eKaTb 3aHrTOrO IUKJIa:
func assert(t \*testing.T, assertion func() bool, maxRetry int, waitTime time.Duration) {
for i := 0; i < maxRetry; i++ { if assertion() { Проверка assertion return } time.Sleep(waitTime) Пауза перед повторной попыткой}t.Fail() Витоге проваливается после нескольких попыток}
Эта функция проверяет предоставленное утверждение (assertion) и выдает собой после совершения определенного количества попыток. Мы также используем time.Sleep, но в этом коде могли бы использовать и более короткую задержку.
Например, вернемся к TestGetBestFoo:
assert(t, func() bool { return len(mock.Get()) == 2}, 30, time.Millisecond)
Вместо задержки в 10 миллионе кунд мы делаем задержку каждую миллисекунду и задаем максимальное количество повторных попыток. Такой подход в случае успешного прохождения теста сокращает общее время его выполнения, потому что сокращается интервал ожидания. Таким образом, «стратегия повторных попыток» — это предпочтительный подход по сравнению с использованием пассивных задержек.
Примечание Некоторые библиотеки тестирования, например testify, предлагают функции, использующие «повторные попытки». В testify есть функция Eventually, реализующая утверждения, которые в конечном итоге должны оказаться правильными, а также другие функции, например настройку сообщения об ошибке.
Другая стратегия заключается в использовании каналов для синхронизации горугины, публикующей структуры Foo, и горугины тестирования. Например, в реализации моков вместо копирования полученного среза в поле можно от- править это значение в канал:
type publisherMock struct { ch chan []Foo}func (p *publisherMock) Publish(got []Foo) { p.ch <- got → 0тправка полученного аргумента}func TestGetBestFoo(t *testing.T) {
mock := publisherMock{ ch: make(chan []foo), } defer close(mock.ch) h := Handler{ publisher: &mock, n: 2, } foo := h.getBestFoo(42) // Проверка foo if v := len(<- mock.ch); v != 2 { Cравнение аргументов t.Fatal("expected 2, got %d", v) } }
Происходит отправка полученного аргумента в канал. Тем временем горутина тестирования настраивает мок и создает утверждение на основе полученного значения. Мы также можем реализовать стратегию тайм-аута, чтобы убедиться, что не придется ждать мокс. ch вечно, если что-то пойдет не так. Например, можно использовать select со случаем time.After.
Что выбрать: повторы или синхронизацию? Синхронизация сокращает время ожидания до минимума и делает тест полностью детерминированным, если он хорошо спроектирован.
Но если использовать синхронизацию по какой- то причине нельзя, то следует пересмотреть дизайн кода, поскольку в нем могут таться проблемы. Если синхронизация действительно невозможна, мы должны — для устранения недетерминированности результатов тестов — использовать опцию «повторных попыток», которая является лучшим выбором, чем использование пассивных задержек.
Теперь обсудим, как предотвратить нестабильность тестов при использовании API времени.
# 11.6. ОШИБКА #87: НЕЭФФЕКТИВНАЯ РАБОТА С API ВРЕМЕНИ
Некоторые функции должны полагаться на API времени, например, для получения текущего времени. В таком случае легко получить хрупкие (brittle) юнит- тесты, которые в какой- то момент могут провалиться. В этом разделе рассмотрим пример и обсудим связанные с ним варианты. Цель этого раздела — не охватить
все возможные сценарии и методы, а дать рекомендации по написанию более надежных тестов функций с использованием API времени.
Допустим, в приложении происходят какие- то события, которые нужно сохранить в конце памяти. Создаем структуру Сасне для хранения самых последних событий. Эта структура будет предоставлять для использования три метода, которые делают следующее:
- добавляют событий;- получают все события;- образают события до заданной продолжительности (сосредоточимся на этом методе).
Каждый из методов должен иметь доступ к данным о текущем времени. Рассмотрим первую реализацию третьего метода с использованием time.Now() (будем считать, что все события отсортированы по времени):
type Cache struct { mu sync.RWMutex events []Event type Event struct { Timestamp time.Time Data string } func c \*Cache) TrimOlderThan(since time.Duration) { c.mu.RLock() Defer c.mu.RUnlock() Bbivutahne shaveenr saahhoni t := time.Now().Add(- since) npoJonxuntenbHocru n3 tekyuero BpeMeH for i := 0; i < lenc.events); i++ { if c.events[i].Timestamp.After(t) { c.events $\equiv$ c.events[i:] 06pe3ka co6bitni return } } }
BbivucJreM npeMeHnyo t, kOtopar aJOLKha GbIb paBha tekyuIemy BpeMeHn sa bivetom заданной продолжительности. Поскольку события сортируются no BpeMeHn, Mbi o6hObJreM bHytpeHHnii cpe3 events, kak toJhko JocTnraem co6bitn, BpeMn kotoporo haCTyTnIeT nOcJe t.
Как протестировать этот метод? Мы могли бы отталкиваться от значения текущего BpeMeHn, используя time.Now для создания событий:
func TestCache_TrimOlderThan(t \*testing.T){ events := []Event{ Co3ahne co6bitn c nomoubio time.Now() {Timestamp: time.Now().Add(- 20 \* time.Millisecond)}. {Timestamp: time.Now().Add(- 10 \* time.Millisecond)}. {Timestamp: time.Now().Add(10 \* time.Millisecond)}, } cache := &Cache{} 06peska co6bitn cache.Add(events) D6aBAnHeHue tHux co6bitN B K3uI upoJoxHHTeNbHCTbI cache.TrimOlderThan(15 \* time.Millisecond) Goae 15 muHnuckeyHn got := cache.GetAll() NonyHHeHue Bcex co6bitn expected := 2 if len(got) $! =$ expected{ t.Fatalf("expected %d, got %d", expected, len(got)) } }
D6oabraem cpe3 co6bitnB K3uI c nomoubio time.Now() u D6oabraem uJiu bHHTaem he6oJIbHHe dHHTeJIbHHeCTH. 3aTeM o6pe3aEM 3T1 co6bitnHa yPobHe 15 MHJIHcEKyHd H bHIIoJIHHeM yTbEpKdHeHHe (assertion).
Y 3TOro nOJxoJa eCTb OaIH rJIaHbHbIH HeJIOCTaTOK: eCJIu MaIHmHa, Ha KOTOPOIH bbi- nOJIHHeTcT cTct, bHe3aHHO OKa3bIbAeTcT aHHTOT, MOxKeT o6pE3aTbC3a MeHbIH eCO6bI- tH, yEM OxKIIaJIocb. MoxHO yBeJIuHHTb nPOJOJIKHTeJIbHCTb, UTO6bI yMeHbIHHTb BePOHTHOCTb nPOBaJIeHHOIO TCTa, HO 3TO He BcETJa PeaJIbHO. HAnpHMEp, UTO, eCJIu 6bI nOJIe MeTKu BpeMeHn 6bIIO He3KcIIOpTupOBaHHbIM nOJIeM, creHepupOBaHHbIM npu J6o6aBHeHnI co6bitn? B tAKOM cJIyuae HeJIb3a 6bIIO 6bI nepeJaTb KoHkpeTHyIO MeTKy BpeMeHn, TO MOxKeT nPIbHecTn K J6o6aBHeHnIO B IOHHT- TECT 3aJepxek.
IPO6JIeMa cB33aHa c TcM, KaK bHytpeHHe yCTpOeH TrimOlderThan: nOckOJIbKy OH bIb3bIbAeT time.Now(),TO HaJExKbHe IOHHT- TECTbI CTaHOBHTcT cIOxHee PeaJIH3OBbIBaTb. O6cyJIM JBa nOJxoJa, HапpBaJIeHHbIe Ha To, UTO6bI cJelATb TECT 6OJIee HaJExKbHM.
IEpBbIH nOJxoJ 3aKJI6pAeTcT B TOM, UTO6bI cJelATb cIOCO6 nOJIyHeHnI TeKyIIeTO BpeMeHn 3aBHCIMOCTbTO OT CTpyKTybI CaChE. B nPOJIaKHeHe Me bHHeJIpIJIb 6bI HaCTmOJIuJyO peaJIH3aIIIO, a B OHHT- TECTax, HапpHMEp, nepeJaTn ObI 3aIJIyIIKky (CTa6).
ECTb pa3JIuHbIe MeTOJIb CO3JaHnI TaKOIH 3aBHCIMOCTu, HапpHMEp uHHTepqEIC uJIH THT qyHKIIHn. HOckOJIbKy 3JcCb HHOJIb3bJyCTeIa ToJIbKO OJIHn MeTOJI (CTIme.Now(), MOxKO OIPeJeJIHTb THT qyHKIIHn:
type now func() time.Time type Cache struct { mu sync.RWMutex events []Event now now }
Tun now - - oTO pyHKIIuR, Kotopar BosBpaIIaET time.Time.B bap6puHnOi pyHKIIuM oXHO nepeJaTb aKTyaJIbHyIO pyHKIIuIO time.Now TaK:
func NewCache() *Cache { return &Cache{ events: make([]Event, 0), now: time.Now, }}
IocKoJIbKy sABuCIMoCTb now octaetcr hE3KcIopTupOBaHnOi, To OHa HEJocTyIHa JIA BHeIIHIX KJIueHTOB.KpOMe ToRo, B HaIIeM IOHnT- TECTe MoxHO CO3aTb CTpyKTypy CaCe, BHeJpIB NODDEJIbHyIO (fake) peaJIb3aIIIO func() time.Time Ha OCHOBe 3aранee onpeJeJIeHHIOO BpeMeHnI:
func TestCache_TrimOlderThan(t \*testing.T){ events: $\equiv$ []Event{C33aHHe co6bHnHa OCHOBe onpeJeJIeHbIX BpeMeHbIX MetOK {Timestamp: parseTime(t,"2020- 01- 01T12:00:00.04Z")), {Timestamp: parseTime(t,"2020- 01- 01T12:00:00.05Z")), {Timestamp: parseTime(t,"2020- 01- 01T12:00:00.06Z")), } cache: $=$ &Cache{now: func() time.Time{ BHeJpeHne CTaTHeCKOJI return parseTime(t,"2020- 01- 01T12:00:00.06Z") IyHKIIu JIA BHKaIIHn $\}$ BpeMeHnI cache.Add(events) cache.TrimOlderThan(15 \* time.Millisecond) //... } func parseTime(t \*testing.T, timestamp string) time.Time { //... }
# Icnonb3oBaHue rno6aJbHouN nepeMeHHOU
BMeCTO cIcnonb3oBaHnI dOJIa MoxHO nOJIyHb DHHbIe O BpeMeHnI yepe3 rno6aJIb- hyIO nepeMeHnIyIO:
var now = time.Now Oпределение новой глобальной переменной
B целом мы должны стараться избегать такого состояния, в котором могут изменяться какие- то общие параметры и структуры. В нашем случае это привело бы как минимум к одной конкретной проблеме: тесты больше не были бы изо-лированы, поскольку зависели бы от одной общей переменной. В частности, поэтому тесты нельзя было запускать параллельно. При возможности следует обрабатывать такие случаи как часть структурных зависимостей, способствуя изоляции тестов.
Ipu coszahinu hooi ctpyktypi Cache Mbi Bheprem sabnucmocTb now ha ochobe заданногo bpeMeHn. Blaroapa takomy nOxOy tect haJexmbii. Jaxe b hauxyHnux ycJobHnax pesyJbttat storo tecta 6yJet detepMHHupOBaHbM.
Это решение также расширяемое. Например, функция вызывает time.After. Тогда можно либо добавить другую зависимость after, либо создать один интерфейс, объединяющий два метода: Now и After. Но у этого подхода есть важный недостаток: зависимость now недоступна, если мы, например, создаем юнит-тест из внешнего пакета. Мы рассмотрим это в разделе, посвященном ошибке \#90 (не изучать все возможности тестирования в Go).
В этом случае используем другую методику. Вместо обработки времени как не- экспортируемой зависимости запросим текущее время у клиентов:
func (c *Cache) TrimOlderThan(now time.Time, since time.Duration) { // ...}
Чтобы пойти еще дальше, можно объединить два аргумента функции в одном time.Time, что будет представлять собой определенный момент времени, до которого мы хотим обрезать события:
func (c *Cache) TrimOlderThan(t time.Time) { // ...}
Вычислить этот момент следует на вызывающей стороне:
cache.TrimOlderThan(time.Now().Add(time.Second))
И в тесте мы также должны передать соответствующее время:
func TestCache_TrimOlderThan(t *testing.T) { // ... cache.TrimOlderThan(parseTime(t, "2020- 01- 01T12:00:00.66Z"). Add(- 15 * time.Millisecond)) // ...}
Это самый простой подход, поскольку он не требует создания другого типа и заглушки.
К тестированию кода, использующего API времени, следует подходить с большой осторожностью, так как оно может быть причиной нестабильных тестов. Мы рассмотрели два способа справиться с этим. Первый — сохранить взаимодействия time
как часть зависимости, которую можно свимитировать в конт- тестах, используя наши собственные реализации или полагаясь на внешние библиотеки. Второй — переработать API и запрашивать у клиентов необходимую информацию, например текущее время (этот метод проще, но подразумевает большие ограничения).
Теперь обсудим два полезных пакета Go, связанных с тестированием: httptest и iotest.
# 11.7. OWWBA #88: HE IICNOJIb3OBATb IAAKETbI YTIJINT JJIr TECTIPOBAHHA
11.7. OWWBA #88: HE IСПОЛЬЗОВАТЬ ПАКЕТЫ УТИЛИТ ДЛЯ ТЕСТИРОВАНИЯСтандартная библиотека предоставляет пакеты утилит для тестирования. И когда разработчик не знает о них, то может полагаться либо на другие решения, которые не так удобны, либо вообще заниматься велосипедостроением. В этом разделе рассмотрим два таких пакета: один поможет при работе с HTTP, а другой при вводе/выводе и использовании ридеров и райтеров.
# 11.7.1. faker httptest
faker httptest (https://pkg.go.dev/net/http/httptest) предлагает утилиты для тестирования как HTTP- клиентов, так и HTTP- серверов. Pассмотрим оба сценария.
Oбсудим то, как httptest помогает при создании HTTP- сервера. Реализуем обработчик, который выполняет некоторые базовые действия: записывает заголовок и тело, а также возвращает определенный код состояния. Для простоты и большей ясности опустим обработку ошибок:
func Handler(w http.Responsewriter, r \*http.Request){ w.Header().Add("x- API- VERSION","1.0") b,- := io.ReadAll(.Body) Oboeдинение приветствия $\_ ,\_ =$ w.Write(apend([]byte("hello"),b...)) (hello) c tenom запpoca w.WriteHeader(http.StatusCreated) }
HTTP- обработчик принимает два аргумента: запрос и способ написания ответа. Пакет httptest предоставляет утилиты для них обоих. Для запроса мы можем применить httptest. NewRequest, который создает \*http. Request с использованием метода HTTP, URL- адреса и тела. Для ответа возьмем httptest. NewRecorder, чтобы записать изменения, сделанные в обработчике. Напишем iонит-тест для этого обработчика:
func TestHandler(t \*testing.T) { req : $=$ httptest.NewRequest(http.MethodGet, "http://localhost", Cоздanue strings.NewReader("foo")) sanpoca w := httptest.NewReorder() Cоздanue peructpatopa oTbetob Handler(w, req) Bb130B 6pa6ot4uka if got : $=$ w.Result().Header.Get("x- API- VERSION"); got $! =$ "1.0{ t.Errorf("api version: expected 1.0, got %s", got) 1 3aronobka HTTP body, $\coloneqq$ ioutil.ReadAll(wordy) 1 1 1 if got : $=$ string(body); got $! =$ "hello foo" t.Errorf("body: expected hello foo, got %s", got) } if http.Statusok $! =$ w.Result().StatusCode { 1 1 1 t.FailNow() } }
Tecrupobanue o6pa6ot4uka c nomoupbio httptest he npobeprer tpancnopt (часть HTTP). B uentpe bHMahnus tecta haxoautcr nprmoB b33oB o6pa6ot4uka c 3a- npcocm u cncoc6 saHncu otBeta. 3atem, ucnoLb3yA peructpatop otBtob, Mbi 3a- nucbIbаем yTbeprKlenHnIa Jla npobeprk HTTP- 3aronobka, tCJa H Kola cCctorHnIa.
Iocmotpum c JpyroUi croponb: tectupobanue HTTP- Kluhenta. Jlra storo cosJaaum KluheNT, otBtectcbenbHbM 3a sanpc kOHevnOH TovKu HTTP kotopbH bHnucJIeT, cKOLbko BpeMeHn Tpe6yется Jlra nepexOa OT OJHOH KOOPaHHaTbI K JpyroU. OTOT KluheNT bHlJIaJIT TAK:
func (c DurationClient) GetDuration(url string, lat1, lng1, lat2, lng2 float64) { time.Duration, error) { resp, err := c.client.Post( url, "application/json", buildRequestBody(lat1, lng1, lat2, lng2), ) if err != nil { return 0, err } return parseResponseBody(resp.Body) }
KoJ BbInOJIHreT HTTP- 3anpc POST no yKasAnHOMy URL- aJpecy u BosBpaIIaet pasO6paHbHbH otBret (cKaXkEM, kakOH- TO JSON).
A cCJIH aJIO npotectcHpOBaTb sTOT KluheNT? OJHn H3 BapHantOB — ucnoJIb3OBaTb Docker u 3anyctuTb qHnKtUBHbHbI cepep Jlra BosBpaTa heKoropbIX npeJbapHreJIbHO
3aperuCTpupOBaHbIX otBebOB. Ho tAkOu nOJxOJ 3aMeJJIscT BbIIOJIHeHHe TcTcA. ApyrouBapuaHT - uchOJIb3OBaTb httptest.NewServer JJIsc CO3JahHn JOKaJIbHOrO HTTP- cepBepa Ha OcHOBe o6pa6OTYHKa, KoTOpBbI Mbl npeOCTaBIM. Kak ToJIbKO cepBep 3anyuHn H BbIIOJIHeTcTc, Mbl MOxKem nepedaTb ero URL B GetDuration:
func TestDurationClientGet(t \*testing.T){ srv $\equiv$ httptest.NewServer( 3anyck HTTP- cepBepa http Hundazrfunc func(w http.Responsewiter,r \*http.Request){ - - - w.Write([]byte({"duration":314})) PeruCTpaun o6pa6OTYHKa ), JJIa o6cynyKbBaHn otBeta ), ) defer srv.Close() OTKJI04eHHe cepBepa client $\equiv$ NewDurationClient() duration, err $\equiv$ client.GetDuration(srv.URL,51.551261,- 0.1221146,51.57,- 0.13) if err $! =$ nil{ Yka3aHue URL- cepBepa t.Fatal(err) } if duration $! = 314^{*}$ time.Second{ 1pOBePKa otBeta t.Errorf("expected 314 seconds, got %v", duration) } }
3Jecb Mbl co3Jaem cepBep co ctatnueckum o6pa6OTyHKOM, no3BpaIIaIOIIHM 314 ce- KYHJI. Mbl takxke MOxKem JelTats yTBepxKHeHnHa OcHOBe OTnpaBbIeHHoro saIIpoca. Kpome toro, korJa Mbl Mbl3bBaem Get- Duration, to yka3bBaem URL- aJpec saIIyIIeH- horo cepBepa. No cpaBaHeHIO c teCTupOBaHHeM o6pa6OTyHKa sTOT teCT BbIIOJIHeT факTnueckuH HTTP- BbI3OB, HO JelTaet sTO Bcero 3a HecKoJIbKO MHJIncEKyHJI.
MoxHnO takxke saIIyCTHTb HOBbIH cepBep, npuMeHnB TLS c nOMOIIbIO httptest. NewTLSServer, u co3JaTb, HO nOka He saIIyCKaTb cepBep c nOMOIIbIO httptest. NewUnstartedServer, vTObHbI ero MoxHn OblIO saIIyCTHTb JHeHnHO.
IOMHnTe, vTO httptest oyeHb nOIe3eH npu pa6Ote B KoHTeKCTe HTTP- npuJIoxHeHnIH. Co3JIaEM Mbl cepBep HJIH KIIeHT, httptest nOMOxKet pa3pa6OTaTb a3bJeKTHBHHo teCTbI.
# 11.7.2. Flaker iotest
B nakete iotest (https://pkg.go.dev/testing/iotest) peaJIu3OBaHbI yTIIJIHTb JJIa te- cTupOBaHnH pIIePbOH paIItePbOH. 3TO yIO6HbIH nakET, O KOTOpOM pa3pa6OTyHKu HaCTO 3a6bIBaIoT.
Ipu pea/naaunu co6ctbenHoro io.Reader baxko he aabeis npotecunpobatb ero c nomoubno iotest.TestReader. Ta bcnomoratebhaa dyhkqus npoberer, npa- bu/lnho ji bejet ce64 pueep: bO3bpaiaet jiu oh uicJio npouitahhbx Gautob toyho, sanonhret jiu sa/anhnul (pes u t. . Oha tectunpyet takxoe pasnunhbie bapuahtbi nobe/ehnur, eJiu npeJocTabHHeHbHbI MoJyJb YHeHnI pea/nu3yet takne nhtepdeicb, kak io.ReaderAt.
IpeJIOJIOXUM, cCTb NOJIb3OBaTeJIbCKHII LowerCaseReader, kotopbHn nepedaeT nOTOK cTpOyHbIX 6yKB H3 saJahHoro ucTOyHukBa BBOJa io.Reader. Bot kak npotecunpobatb nobe/ehnue puepa H y6cJHTbCra, UTO OHO npaBUNbHoe:
func TestLowerCaseReader(t \*testing.T){ err : $=$ iotest.TestReader( IpeJocTabHHeHue io.Reader &LowerCaseReader{reader: strings.NewReader("aBcDeFghi")}), []byte("acegi"), 4TO OXHJAAeTcA ) if err $! =$ nil{ t.Fatal(err) } }
MbI bH3bIBaEM iotest.TestReader, npeJocTabJIaN IOJIb3OBaTeJIbCKHII LowerCa- seFeaer H saJaBaa To, HTO OXHJaeM Ha BxOJe/niH BBOJe, - cTpOyHbIe 6yKBbI acegi.
EIEe OJINH bapuaHn ucHOJIb3OBaHnI nAketa iotest - y6cJHTbCra, UTO npuJIOXeHnue, ucnoJIb3yJIOHee puepeHn H paHtrepb, ycTOJyHbO K OIIH6KAM:
- iotest.ErrReader cosJaet io.Reader, kotopbHn bO3bpaiaet yka3aHHyIO OIIH6Ky.
- iotest.HalfReader cosJaet io.Reader, kotopbHn HHTaet ToJIbKO HOJIbHnHy 3a- npouHeHnHO rOJIyHCTBa GautOB H3 ApyTOrO io.Reader.
- iotest.OneByteReader cosJaet io.Reader, kotopbHn HHTaet no OJIHOMy GaHry JJIa KaXJIOHO HENyCTOHO YHeHnH H3 ApyTOrO io.Reader.
- iotest.TimeoutReader cosJaet io.Reader, kotopbHn bO3bpaiaet OIIH6Ky HnH BTOpOM YHeHnH 6e3 JAHHbIX. IOcJcJIyJIOHee Bb3OBaH 6yJyT yCneHnHbMn.
- iotest.TruncateWriter cosJaet io.Reader, kotopbHn saHncbIaet B ApyroH io.Writer, HO MOJIua npeKpaiaet pa6Ory nOcJe saHncH n GaHt.
HaIpnHmer, MbI pea/nu3yEM cJeJIyIOIIyIO dyHkHnIO, kotopah aHauHaetcra c YHeHnH bCex GaHTOB H3 puepa:
func foo(r io.Reader) error { b, err := io.ReadAll(r) if err != nil { return err } //... }
Мы хотим убедиться, что функция устойчива, если, например, риглер во время чтения выдаст собой (симуляция сетевой ошибки):
func TestFoo(t *testing.T) { err := foo(iotest.TimeoutReader( Ynakobka предоставленhoro io.Reader strings.NewReader(randomString(1024)), ) ) if err != nil { t.Fatal(err) } }
Мы оборачиваем io.Reader с помощью io.TimeoutReader. Как уже говорилось, второе чтение завершится неудачей. Если мы запустим этот тест, чтобы убедиться, что функция толерантна к ошибкам, то получим провал теста. Действительно, io.ReadAll возвращает все ошибки, которые встречает.
Зная это, реализуем пользовательскую функцию ReadAll, которая терпима к п ошибкам:
func readAll(r io.Reader, retries int) ([]byte, error) { b := make([]byte, 0, 512) for { if len(b) == cap(b) { b = append(b, 0)[:len(b)] } n, err := r.Read(b[len(b):cap(b)]) b = b[:len(b)+n] if err != nil { if err == io.EOF { return b, nil } retries- - if retries < 0 { Допускает повторные попытки return b, err } } } }
Эта реализация похожа на io. ReadAll, но также обрабатывает настраиваемые повторные попытки. Если изменить реализацию исходной функции, чтобы использовать собственный readAll вместо io. ReadAll, тест больше не будет заканчиваться неудачей:
func foo(r io.Reader) error{ b, err := readAll(r, 3) Дотрех повторных попыток if err != nil { return err } //... }
Мы рассмотрели пример того, как проверить, что функция толерантна к ошиб- кам при чтении из io.Reader, выполнив тест с использованием пакета iotest.
При выполнении ввода/вывода и при работе с io.Reader и io.Writer помните, насколько удобен пакет iotest. Как мы видели, он предоставляет утилиты для проверки поведения пользовательского io.Reader и проверки нашего приложения на наличие ошибок, возникающих при чтении или записи данных.
В следующем разделе обсудим, как не попадать в распространенные ловушки, которые могут привести к созданию неточных бенчмарков.
# 11.8. ОШИБКА #89: ПИСАТЬ НЕТОЧНЫЕ БЕНЧМАРКИ
О производительности никогда не стоит гадать. Занимаясь оптимизацией, при- ходится иметь дело с таким множеством факторов, что даже если мы и уверены в неких результатах, нелишне будет их протестировать. Но писать бенчмарки не так-то просто, и написав неточные, легко сделать на их основе неправильные выводы. Цель этого раздела — обсудить типичные ловушки, ведущие к неточным результатам.
Рассмотрим, как работают бенчмарки в Go. Скелет любого бенчмарка выглядит так:
func BenchmarkFoo(b *testing.b) { for i := 0; i < b.N; i++ { foo() } }
Имя функции начинается с предикса Benchmark. Тестируемая функция (foo) вызывается внутри цикла for. Величина b.N представляет собой переменное количество итераций. При запуске теста Go пытается уложиться в требуемое
Bремя, которое по умолчанию задано равным 1 секунде и может быть изменено с помощью флага - benchtime. b.N начинается с 1. Если бенчмарк завершается менее чем за 1 секунду, значение b.N увеличивается и бенчмарк запускается снова, пока b.N не станет примерно равным времени бенчмарка:
$\Updownarrow$ go test - bench $=$ cpu: Intel(R) Core(TM) i5- 7360U CPU @ 2.30GHz BenchmarkFoo- 4 73 16511228 ns/op
3десь бенчмарк занял около 1 секунды, а foo был выполнен 73 раза, при среднем времени выполнения 16 511 228 наносекунд. Изменим время бенчмарка, используя - benchtime:
$\Updownarrow$ go test - bench $=$ - benchtime $= 25$ BenchmarkFoo- 4 150 15832169 ns/op
B этом бенчмарке foo выполнялся 150 раз.
Теперь рассмотрим некоторые типичные ловушки.
# 11.8.1. Не сбрасывать или не ставить на паузу таймер
B некоторых случая перед циклом бенчмарка требуется выполнить какие- либо операции. Они могут занять много времени (например, для создания большого среза данных) и сильно повлиять на результаты тестов:
func BenchmarkFoo(b *testing.B) { expensiveSetup() for i := 0; i < b.N; i++ { functionUnderTest() }}
B этом случае перед входом в цикл используем метод ResetTimer:
func BenchmarkFoo(b *testing.B) { expensiveSetup() b.ResetTimer() for i := 0; i < b.N; i++ { functionUnderTest() }}
Вызов ResetTimer обнуляет счетчики прошедшего с начала бенчмарка времени и выделения памяти. Таким образом, дорогостоящая настройка может быть исключена из результатов теста.
Ho что, если приходится выполнять длительные подготовительные шаги не один раз, а в каждой итерации цикла?
func BenchmarkFoo(b *testing.B) { for i := 0; i < b.N; i++ { expensiveSetup() functionUnderTest() }}
Tyt мы не можем сбрасывать таймер, потому что такой сброс будет выполняться внутри цикла во время каждой итерации. Но можно остановить и возобновить работу таймера бенчмарка, окружив таким образом вызов expensiveSetup:
func BenchmarkFoo(b *testing.B) { for i := 0; i < b.N; i++ { b.StopTimer() expensiveSetup() b.StartTimer() functionUnderTest() }}
Здесь мы приостанавливаем работу бенчмарка, чтобы выполнить дорогостоящую настройку, а затем возобновляем работу таймера.
Примечание С этим подходом связана некая загнода: если тестируемая функция выполняется слишком быстро по сравнению с функцией настройки, общее время выполнения бенчмарка может быть слишком большим. Причина в том, что для достижения значения, равного benchtime, потребуется гораздо больше времени, чем 1 секунда. Вычисление контрольного времени основано исключительно на времени выполнения functionUnderTest. Если в каждой итерации цикла мы будем находиться в состоянии ожидания долгое время, бенчмарк будет намного медленнее, чем 1 секунда. Одним из способов смягчения последствий будет уменьшение benchtime.
Важно убедиться, что мы используем методы таймера, чтобы сохранить точность бенчмарка.
# 11.8.2. Делать неверные предположения о микробенчмарках
Микробенчмарк измеряет крошечную вычислительную единицу, и в связи с этим очень легко сделать неверное предположение. Например, мы изначально не
yверены, следует ли использовать atomic.StoreInt32 или atomic.StoreInt64 (при условии, что обрабатываемые значения всегда умещаются в 32 бита). Например для сравнения обеих функций:
func BenchmarkAtomicStoreInt32(b \*testing.B){ var v int32 for i := 0; i < b.N; i++{ atomic.StoreInt32(&v,1) } } func BenchmarkAtomicStoreInt64(b \*testing.B){ var v int64 for i := 0; i < b.N; i++{ atomic.StoreInt64(&v,1) } }
Допустим, при запуске мы получаем следующее:
cpu: Intel(R) Core(TM) i5- 7360U CPU @ 2.30GHz BenchmarkAtomicStoreInt32 BenchmarkAtomicStoreInt32- 4 197107742 5.682 ns/op BenchmarkAtomicStoreInt64 BenchmarkAtomicStoreInt64- 4 213917528 5.134 ns/op
Мы могли бы легко принять эти результаты как должное и решить использовать atomic.Store- Int64, поскольку кажется, что он быстрее. Но теперь, чтобы провести честный бенчмарк, изменим порядок и сначала запустим atomic.StoreInt64, а затем atomic.StoreInt32. Вот пример результата:
BenchmarkAtomicStoreInt64 BenchmarkAtomicStoreInt64- 4 224900722 5.434 ns/op BenchmarkAtomicStoreInt32 BenchmarkAtomicStoreInt32- 4 230253900 5.159 ns/op
В этот раз atomic.StoreInt32 показал лучшие результаты. Что же произошло?
В случае микробенчмарков на их результаты могут влиять многие факторы: занятость процессора во время выполнения бенчмарков, режимы управления питанием, лучшее выравнивание последовательности инструкций в кэше и многие другие факторы, возможно, даже выходящие за рамки проекта на Go.
Примечание Мы должны убедиться, что машина, на которой выполняется бенчмарк, простаивает. Но в фоновом режиме могут выполняться какие-либо внешние процессы, что может повлиять на результаты. По этой причине такие инструменты, как perflock, могут ограничивать ресурсы CPU, которые
может потреблять бенчмарк. Например, можно запустить бенчмарк, указав, что на него должно расходоваться не более 70 % всех доступных ресурсов CPU, отдав 30 % операционной системе и другим процессам и снизив тем самым влияние фактора занятости процессора на результаты.
Oдин из вариантов — увеличить время бенчмарка с помощью параметра - benchtime. Подобно закону больших чисел в теории вероятностей, если мы запускаем бенчмарк многократно, его результат должен стремиться к ожида- емому значению (при условии, что мы опускаем возможные преимущества от кэширования инструкций и от других техник).
Другой вариант — использовать внешние инструменты поверх классического инструментария бенчмаркинга. Например, инструментарий benchstat, явля- ющийся частью репозитория golang.org/x, позволяет получать и сравнивать статистику выполнения бенчмарков.
Запустим бенчмарк с параметром - count 10 раз и запишем результат в файл:
\(\)$ go test - bench=. - count=10 | tee stats.txt cpu: Intel(R) Core(TM) i5- 7360U CPU @ 2.30GHz BenchmarkAtomicStoreInt32- 4 234935682 5.124 ns/op BenchmarkAtomicStoreInt32- 4 235397294 5.112 ns/op // ... BenchmarkAtomicStoreInt64- 4 235548591 5.107 ns/op BenchmarkAtomicStoreInt64- 4 235210292 5.090 ns/op // ...
Затем запустим benchstat для этого файла:
\(\)$ benchstat stats.txt name time/op AtomicStoreInt32- 4 5.10ns ± 1% AtomicStoreInt64- 4 5.10ns ± 1%
Результаты одинаковы: выполнение обеих функций занимает в среднем 5.10 наносекунды. Мы видим и процентное отклонение в результатах выполнения: ± 1 %. Эта метрика говорит нам, что оба бенчмарка стабильны, что дает большую степень уверенности в рассчитанных средних результатах. Вместо того чтобы делать вывод о том, что atomic.StoreInt32 быстрее или медленнее, следует заключить, что время его выполнения аналогично времени выполнения atomic.StoreInt64 для протестированного нами варианта использования (в конкретной версии Go на конкретной машине).
К микробенчмаркам следует подходить с большой осторожностью. На их результаты могут сильно влиять многие факторы, что может привести к ошибочным
предположениям и выводам. Увеличение времени бенчмарков, повторение отдельных и получение статистики с помощью benchstat — все это поможет ограничить влияние внешних факторов и получать более точные результаты, ведущие к справедливым выводам.
Следует критически относиться к результатам какого-либо микробенчмарка, выполненного на каком-то одном процессоре, если оказывается, что приложение будет запускаться на другой системе, которая может вести себя совершенно иначе, чем та, на которой мы запускали микробенчмарки.
# 11.8.3. Небрежное отношение к оптимизациям компилятора
Оптимизации компилятора также могут приводить к некорректным бенчмаркам, что, в свою очередь, приводит к неверным выводам. В этом разделе рассмотрим проблему Go 14813 (https://github.com/golang/go/issues/14813, также обсуждается членом проекта GO Дейвом Чейни) с функцией, подсчитывающей количество битов, установленных в 1:
const m1 = 0x5555555555555555 const m2 = 0x3333333333333333 const m4 = 0x0f0f0f0f0f0f0f0f const h01 = 0x01010101010101
func popcnt(x uint64) uint64 { x - = (x >> 1) & m1 x = (x & m2) + ((x >> 2) & m2) x = (x + (x >> 4)) & m4 return (x * h01) >> 56 }
Эта функция принимает и возвращает uint64. Для бенчмарки на этой функции сделаем следующее:
func BenchmarkPopcnt1(b *testing.B) { for i := 0; i < b.N; i++ { popcnt(uint64(i)) } }
Но после выполнения бенчмарка мы получим удивительно низкий результат:
cpu: Intel(R) Core(TM) i5- 7360U CPU @ 2.30GHz BenchmarkPopcnt1- 4 1000000000 0.2858 ns/op
Промежуток времени в 0.28 наносекунды примерно равен одному тактовому циклу, поэтому полученное число неправдоподобно низкое. Проблема в том, что разработчик был недостаточно внимателен к оптимизациям компилятора. В этом случае тестируемая функция достаточно проста, чтобы компилятор воспринял ее в качестве кандидата для встраивания (inlining): оптимизации, в результате которой вызов функции заменяется непосредственно ее телом и позволяет предотвратить вызов функции, которая имеет небольшой след. Как только функция оказывается встроена, компилятор замечает, что ее вызов не приводит ни к каким побочным эффектам, и заменяет ее следующим бенчмарком:
func BenchmarkPopcnt1(b \*testing.B){ for i := 0; i < b.N; i++ { // Nyctoe mecto } }
Бенчмарк теперь пуст, поэтому мы получили результат, близкий к продолжи- тельности одного тактового цикла. Чтобы это не произошло, лучшей практикой будет вот что:
- Bo время каждой итерации в цикле присваивайте полученный результат какой-то локальной переменной (локально в контексте функции бенчмарка).- При последнем выполнении цикла результат присваивайте какой-либо глобальной переменной.
B нашем случае мы напишем такой бенчмарк:
var global uint64 Onpepeenee rno6anbnoi nepemehnou func BenchmarkPopcnt2(b \*testing.B){ var v uint64 Onpepeenee noka/bohni nepemehnou for i := 0; i < b.N; i++ { v = popcnt(uint64(i)) 1 Ppucbeene pesyntata noka/bohni nepemehnou } global = v Ppucbeene pesyntata rno6anbnoi nepemehnou }
Примечание A почему бы не назначить результат вызова ровстепепосредственно global, чтобы упростить тест? Запись в глобальную переменную медленнее, чем в локальную. Мы обсуждаем эти вопросы в разделе, посвященном разбору ошибки #95 (не понимать различий между стеком и кучей). Поэтому следует записывать каждый результат в локальную переменную, чтобы ограничить использование памяти на каждой итерации цикла.
global - rno6a1bhar, a v - Jokalbhar nrepemehhar, o61aCTb BuzimocTn kotopou orpanu1eha yhKkueh 6ehmaPKa. Bo BpeMk kaKdou 1tepa11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
Ec111 3anyc111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e3y1b111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111ec3y1b11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e3y11b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e3111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111i111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111311111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111511111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111161111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111117111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111811111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111191111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111114111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111f111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111t111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111s111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111c111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e113111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e114111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e115111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e116111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e117111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e118111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e119111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e11a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e11b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111011a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111011b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111211a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111211b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111113110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111311a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111311b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111116110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111611a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111611b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111117110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111711a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111711b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111118110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111811a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111811b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111119110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111911b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111115110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111511a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111511b1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111114110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111411a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111911a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111411b111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111a110111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111a11a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111a11b111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b11a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111b11b111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111i11a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111i11b111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111f11a111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111f11b111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
global - rno6a1bhar, a v - Jokalbhar nrepemehhar, o61aCTb BuzimocTn kotopou orpanu111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111B111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111Ec11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e
B11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e 11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e e
111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 e 111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e 1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111e 111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e 100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
} } return sum } func calculateSum513(s [][513]int64) int64 { // ToT xe kOд, Что и для calculateSum512 }
Htepruyem no nepbbIM BoCbMn cTOJIaM dna kaxqoI cTpok.
Puc. 11.2. BbivicnHHe CymMbi nepBbix BoCbMn cTOJIaOB
Mbi npobOJIM uTepaHnI no kaxqoI cTpok, a satem no nepBbIM BoCbMn cTOJI- uam u ybeJiuyBaem bivicJreMyIO CymMy, kotopyIO Bo3BpaIIaEM. PeaJn3aIIaB B CaculatesUm513 6yJet ToYHO tAKOI KE.
HyxHO cpaHnIb 3Iu dyHKIIn, UTO6bI peIIHTb, kakaH 33 HIX HaNIOJee 3dOektIbHa, yuHTbIBa, UTO KOJIuEcTBO cTpok OHKCuPOBaHHO:
const rows = 1000
var res int64
func BenchmarkCalculateSum512(b *testing.B) { var sum int64 s := createMatrix512(rows) —— Создание матрицы из 512 колонок b.ResetTimer() for i := 0; i < b.N; i++ { sum = calculateSum512(s) —— BbivicnHHe CymMbI
} res = sum func BenchmarkCalculateSum513(b \*testing.B){ var sum int64 s := createMatrix513(rows) Cоздanue matpulbi us 513 kozHOHOK b.ResetTimer() for i := 0;i<b.Nj++{ sum $=$ calculateSum513(s) BbivcneHne cyMmbi } res $=$ sum }
Мы создаем матрицу только один раз, чтобы ограничить влияние на результаты, и поэтому вызываем CreateMatrix512 и CreateMatrix513 вне цикла. Ожидается, что результаты будут похожи, так как итерации проводятся только по первым восьми столбцам. Но это не так (на моей машине):
cpu: Intel(R) Core(TM) i5- 7360U CPU @ 2.30GHz BenchmarkCalculateSum512- 4 81854 15073 ms/op BenchmarkCalculateSum513- 4 161479 7358 ns/op
Результаты второго бенчмарка с 513 столбцами показывают, что он примерно на 50 % быстрее первого. Но поскольку мы проводим итерации только по первым восьми столбцам, этот результат несколько удивителен.
Чтобы понять эту разницу, нужно понять основы того, как устроен кэш про- цессора. В нем есть разные кэши (обычно L1, L2 и L3). Они снижают среднее время доступа к данным из основной памяти. При некоторых условиях про- цессор может извлекать данные из основной памяти и копировать их в L1. В этом случае CPU пытается получить в L1 подмножество матрицы, которое используется в функции Calculatesum (то есть первые восемь столбцов каждой строки). Но матрица соответствует объему памяти в одном случае (513 столбцов), а в другом (512 столбцов) — нет.
Примечание В этой главе я не буду вдаваться в объяснение причин. Рассмотрим этот вопрос в разделе, посвященном ошибке #91 (не понимать устройство кэша CPU).
Возвращаясь к бенчмарку, можно сказать, что главная проблема заключает- ся в том, что мы продолжаем повторно использовать одну и ту же матрицу в обоих случаях. Поскольку функция выполняется тысячи раз, мы не изме- ряем параметры того, как происходит ее выполнение, когда она получает простую новую матрицу. Вместо этого мы измеряем функцию, которая получает
matрицу, подмножество ячеек которой уже есть в кэше. Поскольку выполнение CalculateSum513 сопровождается меньшим количеством промахов кэша, у нее оказывается лучшее время этого выполнения.
Это пример проявления эффекта наблюдателя. Поскольку мы продолжаем наблюдать за неоднократно вызываемой функцией, которая интенсивно потребляет ресурсы процессора, то в игру может вступить кэширование процессора и повлиять на результаты. Чтобы предотвратить этот эффект, в примере следует создавать матрицу во время каждого теста вместо ее переносользования:
func BenchmarkCalculateSum512(b \*testing.B){ var sum int64 for i := 0; i < b.N; i++ { b.StopTimer() s := createMatrix512(rows) Cоздание новой матрицы во время b.StartTimer() Cоздание новой матрицы во время sum $=$ calculateSum512(s) kaxdouy nterapayu yukna } res $=$ sum }
Новая матрица теперь создается во время каждой итерации. Если снова запустить бенчмарк (и, соответственно, сделать поправку в benchtime, а иначе для выполнения потребуется слишком много времени), то результаты окажутся уже ближе друг к другу:
cpu: Intel(R) Core(TM) i5- 7360U CPU @ 2.30GHz BenchmarkCalculateSum512- 4 1116 33547 ns/op BenchmarkCalculateSum513- 4 998 35507 ns/op
Теперь мы не делаем неправильный вывод, что CalculatesUm513 быстрее, а видим, что при получении новой матрицы оба бенчмарка приводят к близким результатам.
Из-за переносользования одной и той же матрицы кэш CPU значительно повлиял на результаты. Чтобы предотвратить это, пришлось создавать новую матрицу во время каждой итерации. Помните, что наблюдение за тестируемой функцией может привести к значительным различиям в результатах, особенно в контексте микробенчмарков функций, привязанных в CPU, где низкоуровневые оптимизации имеют значение. Заставляя бенчмарк воссоздавать данные во время каждой итерации, можно предотвращать этот эффект.
В последнем разделе главы обсудим несколько общих рекомендаций по тестированию в Go.
# 11.9. OWWKA #90: HE WYATB BCE BO3MOXHOCTI TECTUPOBAHAR B GO
Pa3pa6otyiku 10JXHMH 3HATb 0 KONKpetHbIX BO3MOXHOCTIX, 1yHKHJHx 1 011Hx teCTHPOBANHAR B Go, HHae OHO CTaHET MEHee TOHbIM 1 JAXHe MEHee 3bHKeTHBbIM. B 3TOM pa3JeJe 06cyXbJaiOTcA TeMbI, KoTOpBHe 06JeYaT HaniHcHHe TeCTOB.
# 11.9.1. NokpbITHe TeCTaMn
Ipu pa3pa6otke noJesHO BnJETb, JJIa KaKHX 4acTei koJa cETb COOTBeTCTBYYOIIHue teCTbI. YTO nOJIyHHTb 3Ty HHOpOMaHHO, HcIOJIb3YuTe bJIar - coverprofile:
\(\)$ go test - coverprofile=coverage.out
Ota KOMaHJa CO3Jaet paHJI coverage.out, KOToPbIH MOKHO OTKpbITb C IOMOIIbIO go tool cover:
\(\)$ go tool cover - html=coverage.out
Ota KOMaHJa OTKpbIHcET 6paY3ep H IOKa3bIHcET IOKpbITHe JJIa KaXJOI CTPOKH KOJa.
IO yMOJIyHAnHO TaKOIH aHaJIH3 IPOBOJHTcA ToJIbKO JJIa TeKyIIeTO TeCTHpyeMOrO nAketa. IPeJIOJI0XHM, YTO cETb CTpyKTypa:
/myapp|_ foo|_ foo.go|_ foo_test.go|_ bar|_ bar.go|_ bar_test.go
EcJIu KaKaar- To yaCTb foo.go TeCTHpyeTcA ToJIbKO B bAr_test.go, no yMOJIyHAnHO OHa He 6yJeT OT6paXaKaTbCra b OTyHeTe O IOKpbIHIN KaKHM- JJI6O TeCTOM. YTO6bI nOJIyHHTb 3Ty HHOpOMaHHO, MbI 10JXHbIH aXOJIHTbCra B naiKHe MyapD H cIcIOJIb3OBaTb bJIar - coverpkg:
go test - coverpkg=./... - coverprofile=coverage.out
IOMHHTe O TaKOIH BO3MOXHOCTH, YTO6bI yBuJIeTb TeKyIIeTe IOKpbITHe KOJa H pIIHHTb, KaKHe YaCTH 3aCJIyXbIHaIOT 6OJIbIHHeO YHCJIa TeCTOB.
PruMEyAHHe EyJbTe 6JntelbHbI, korJa peyb uJet o norohe 3a nokpbitueM koJa. CtonpoenentHoe nokpbitue tectamu he oshavaeT, vto npnJioxeHe he coJepxHt onnn6ok. IpaBunbHoe noHmHahue toro, vto nokpHbHaiot tectbI, baxkhee Jn66oro ctaTnueckoro nopo1or0oro 3havени.
# 11.9.2. Tectupobahue us apyroro naketa
Oдин us nOJXOJOB K HaIIucahIIO OHHT- TECTOB COCTOHT B TOM, YTO6bI COcpeJOTovHtbcra Ha nOBeJehHnI, a He Ha BnytpenHeM yCTpOicTBe. IPeJIOJIOXKUM, MbI npeJocTabJIeM KJIueHTaM kakoH- TO API u xOTIM, YTO6bI teCTbI 6bIIn HaTe ero acnEKTbI, kotOpbIe BnJHbI u3bHe, a He Ha JetaJIn ero peaJn3aIIII. TakIM o6pa3OM, ecJIn peaJn3aIIIIa u3MeHHTcra (HaIIpHMEp, ecJIN MbI pa3JeJIM kakyIO- TO OJHy yHKHIIIO Ha JbE), teCTbI OCTaHytcra npeXHmIM. IX JyJET Jere nOHmHaTb, notOMy YTO OHn nOKa3bHbAIOt, Kak uCNIOJIb3yETcra HaII API. YTO6bI pIμmHeHTb TaKOB IJIOXOJ, HyXHO uCNIOJIb3OBaTb JpyroU nakET.
B Go bce paHJIb I HaIke JOLXHbI pIμHaJIeXaTb OJHOMy H TOMy Xe nakety, 3a OJ- HUM uCKJI0eHHeM: TeCTObHbI paHJI MOXeT pIμHaJIeXaTb nakety _test. JOnyCTHM, cJIeJIyIOIIHbI uCXOJIbHbI paHJI counter.go oTHOcHTcra K nakety counter:
package counter
import "sync/atomic"
var count uint64
func Inc() uint64 { atomic.AddUint64(&count, 1) return count}
TectObHbI paHJI MOXeT HaxOJIbTcra B TOM Xe nakete H uMHeb JocTyII K BnytpenHm KOMIOHeHTaM, TaKUM KAK nepeMeHHaJ count. UIJI OH MOXeT 6bIb b nakete counter_test, KAK STOT paHJI counter_test.go:
package counter_test
import ( "testing" "myapp/counter")
func TestCount(t *testing.T) { if counter.Inc() != 1 { t.Errorf("expected 1") }}
Bэтом случае тест реализован во внешнем пакете и не может получить доступ к внутренним компонентам — переменной count. Используя подобный подход, мы гарантируем, что в тесте не будут использоваться неэкспортированные элементы. Следовательно, он сосредоточится на тестировании открытого поведения.
# 11.9.3. Вспомогательные функции
При написании тестов мы можем обрабатывать ошибки иначе, чем в окончательном варианте кода, который используется в продакшене. Например, нужно протестировать функцию, которая принимает в качестве аргумента структуру Customer. Поскольку действия по созданию Customer будут переносользоваться, создадим для целей тестирования специальную функцию createCustomer. Она вернет возможную ошибку вместе с Customer:
func TestCustomer(t \*testing.T){ customer,err $\equiv$ createCustomer("foo") Cоздание customer u npobePKa if err $! =$ nil{ t.Fatal(err) } //... } func createCustomer(someArg string) (Customer, error){ // Cоздание customer if err $! =$ nil{ return Customer{},err } return customer, nil }
Мы создаем клиент с помощью вспомогательной функции createCustomer, а затем выполняем оставшуюся часть теста. Но в контексте функций тестирования можно упростить обработку ошибок, передав переменную *testing. Твспомогательной функции:
func TestCustomer(t \*testing.T){ customer $\equiv$ createCustomer(t,"foo") Bb130B BcHOMORaTeHbHou //... $\updownarrow$ $\updownarrow$ $\updownarrow$ func createCustomer(t \*testing.T, someArg string) Customer { // Cоздание customer if err $! =$ nil{ t.Fatal(err) Tect he npoxoDut hanprmyo, eciu Mbl he moxem cosaats klnueHt } return customer }
Чтобы не возвращать ошибку, createCustomer прямо останавливает тест как проваленный, если не может создать Customer. Это делает TestCustomer короче и легче для чтения.
Помните об этом, чтобы улучшить свои тесты.
# 11.9.4. Настройка и демонтаж
В некоторых случаях может потребоваться подготовить тестовую среду. Например, в интеграционных тестах мы запускаем определенный контейнер Docker, а затем останавливаем его. Мы можем вызывать функции настройки и демонтажа для каждого теста или пакета. К счастью, в Go возможны оба варианта.
Чтобы делать это для каждого теста, вызываем функцию настройки как предварительное действие, а функцию демонтажа с помощью defer:
func TestMySQLIntegration(t *testing.T) { setupMySQL() defer teardownMySQL() // ...}
Также можно зарегистрировать функцию, которая будет выполняться в конце теста. Предположим, что функции TestMySQLIntegration нужно вызвать createConnection для установления подключения к базе данных. Если мы хотим, чтобы эта функция также включала часть, относящуюся к демонтажу, мы можем использовать t.Cleanup для регистрации функции очистки:
func TestMySQLIntegration(t *testing.T) { // ... db := createConnection(t, "tcplocalhost:3306)/db") // ...}func createConnection(t *testing.T, dsn string) *sql.DB { db, err := sql.Open("mysql", dsn) if err != nil { t.FailNow() } t.Cleanup( // —— Регистрация функции, которая будет выполняться в конце тестa func() { _ = db.Close() }) return db}
B конце теста выполняется замыкание из t. Cleanup. Это упрощает написание будущих юнит-тестов, потому что они не будут отвечать за закрытие переменной db.
Обратите внимание, что мы можем зарегистрировать несколько функций очистки. В этом случае они будут выполняться так же, как если бы мы использовали defer: пришедший последним выходит первым.
Чтобы выполнить настройку и демонтаж каждого пакета, используйте функцию TestMain. Самая простая реализация TestMain выглядит так:
func TestMain(m *testing.M) {os.Exit(m.Run())}
Эта функция принимает аргумент *testing.M, который предоставляет единственный метод Run для запуска всех тестов. Поэтому мы можем окружить этот вызов функциями настройки и демонтажа:
func TestMain(m *testing.M) {setupMySQL() ➔ — Установка MySQLcode := m.Run() ➔ — Проведение тестовteardownMySQL() ➔ — Демонтаж MySQLos.Exit(code)}
Этот код запускает MySQL один раз перед всеми тестами, а затем демонтирует его.
Используя эти методы для добавления функций настройки и демонтажа, можно настраивать сложную среду для проведения тестов.
# UTOFU
- Kатегоризация тестов с помощью флагов сборки, переменных среды или короткого режима делает процесс тестирования более эффективным. Создавайте категории тестов, используя флаги сборки или переменные среды (например, категории интеграционных и юнит-тестов), и разграничивайте короткие и длительные тесты, чтобы решить, какие выполнять.
- Включение флага -гасе настоятельно рекомендуется при написании конкурентных приложений. Это позволит выявлять потенциальные гонки данных, которые могут приводить к ошибкам в программах.
- Использование флага
-parallel
- эффективный способ ускорить тесты, особенно длительные.
- Используйте флаг
-shuße, чтобы убедиться, что набор тестов не опирается на неверные предложения, которые скрывают ошибки.
- Таблицные тесты
- эффективный способ сгруппировать набор похожих тестов. Так вы предотвратите дублирование кода и упростите работу с будущими обновлениями.
- Избегайте задержек с помощью синхронизации
- это сделает тест более стабильным и надежным. Если синхронизация невозможна, рассмотрите подход с повторными попытками.
- Понимание того, как работать с функциями с помощью API времени,
- это еще один способ сделать тест более надежным. Используйте стандартные методы: работу со временем как часть скрытой зависимости
- или запрашвайте его у клиентов.
- Пакет httptest поисок для работы с HTTP-приложенными. Он предоставляет набор утилит для тестирования как клиентов, так и серверов.
- Пакет iotest помогает написать io.Reader и проверить, что приложение устойчиво к ошибкам.
- Бенчмарки:
- Используйте методы time, чтобы обеспечить точность бенчмарка.
- Увеличение benchtime или использование таких инструментов, как benchstat, может быть полезным при работе с микробенчмарками.
- Следует с осторожностью подходить к результатам микробенчмарков, если система, где должно работать приложение, отличается от той, на которой выполняются микробенчмарки.
- Убедитесь, что у тестируемой функции нет каких-либо побочных эффектов, чтобы оптимизации компилятора не ввели вас в заблуждение относительно результатов бенчмарка.
- Чтобы предотвратить эффект наблюдателя, сделайте так, чтобы бенчмарк повторно создавал данные, используемые функцией, которая интенсивно потребляет ресурсы процессора.
- Используйте флаг
- coverprofile, чтобы быстро увидеть, какая часть кода требует большего внимания.
- Собрците юнит-тесты в другом пакете, чтобы они фокусировались на анализ-зе и измерении параметров поведения приложения, а не на его внутреннем устройстве.- Обработка ошибок с помощью переменной *testing.Т вместо классического if err!= nil делает код более удобочитаемым.- Для настройки сложной тестовой среды используйте функции настройки и демонтажа, например, в случае интеграционных тестов.
# Bэтой главе:
- Концепция «mechanical sympathy» («любовь к железу»)- Различия между кучей и стеком и сокращение выделения памяти- Использование стандартных инструментов диагностики Go- Работа сборщика мусора- Запуск Go внутри Docker и Kubernetes
Прежде чем начать, я сделаю одно замечание: в большинстве случаев читаемый и понятный код будет лучшим решением, чем оптимизированный, но более сложный и трудный для понимания. Оптимизация, как правило, имеет свою цену, и я рекомендую сделать знаменитой цитате инженера-программиста Уэса Дайера (Wes Dyer):
Сделайте его правильным, сделайте его чистым, сделайте его кратким, сделайте его быстрым — именно в таком порядке.
Это не означает, что оптимизация приложения для повышения скорости и эффективности запрещается. Например, можно определить те пути кода, которые
следует оптимизировать, потому что в этом есть необходимость, например, чтобы удовлетворить запросы наших клиентов или сократить оверхед. В этой главе обсудим общие методы оптимизации: некоторые специфичны для Go, некоторые нет. Обсудим и методы выявления узких мест, чтобы не работать вслепую.
# 12.1. OWWIKA #91: HE NOHIMATb YCTPOICSTBO K3UA CPU
Termin <mechanical sympathy> BBeJI B o6uxoJ Jxeku CTOaprt (Jackie Stewart) - трехкратный yemпионгонок <DopmyJIb 1>:
BaM he hado 6bimb uhxchenpom, umo6bI 6bimb xopouum zonuukom, doctamouho npocmo uyectmobanb maunuy.1
KorДa MbI nonHmaem, как какa- to cистema yctpoeHa u JJIa wTO, 6yJb To abTOMO6uJIb F1, camOJET uJIH komIBiotep, Mb Moxem cJelatb JI3aHb cBOeTO npuJIOxehnIa coJIJacOBaHbIM c 3IIM yCTPOICTBOM u doctuMb ONTMMaJIbHOaI nPOu3BOJutEJIbHOCTu. B 3TOM pa3JeJIe paCCMOTpIM kOHHpetHbIe npuMepbI, KoJIa nonHmAHHe torO, KaK pa6oTaIOT K3IIn CPU, nOMOxet ONTMMu3upOBaTb npuJIOxehnIa Go.
# 12.1.1. Apxutektypa CPU
Pa36epemcr c ochobamu apxutektypbi nENTpaJIbHbIX nPOueccopoB u c tem, noyemy ux K3IInI tak BaxHbI. Bo3bMEm B kavectBe npuMepa CPU Intel Core i5- 7300.
CobpeMehHbIe nPOueccopbI uCnOJIb3yIOT K3IInUpOBaHHe JJIa yCkOpeHnIa doCTyIIa K naMяти. B 6oJIbIIHHCTbE cJIyuaeB yPOBHeIb K3IInUpOBaHHe Tpu: L1, L2 u L3. Y i5- 7300 pa3MepbI K3IInIc JJIeJIyOONHc:
L1: 64 K6aIIT; L2: 256 K6aIIT; L3: 4 M6aIIT.
i5- 7300 имеет два физических и четыре логических ядра; последние также называются виртуальными ядрами, или потоками (threads). В семействе Intel разделение физического ядра на несколько логических ядер называется Hyper- Threading («гиперпотопростъ»).
На рис. 12.1 представлено внутреннее устройство процессора Intel Core i5- 7300 (Тп означает $n$ - ñ nomok). Каждое физическое ядро (ядро 0 и ядро 1) разделено на два логических ядра (поток 0 и поток 1). Кэш L1 разделен на два подкэша: L1D для данных и L1I для инструкций (каждый по 32 Кбайт). Кэширование относится не только к данным: когда CPU выполняет какое-то приложение, он также может кэшировать некоторые инструкции все с той же целью: ускорить выполнение в целом.
Чем ближе какой-то участок памяти к логическому ядру, тем быстрее осуществляется к нему доступ (см. http://mng.bz/o29v):
- L1
- порядка 1 наносекунды.
- L2
- примерно в 4 раза медленнее, чем для L1.
- L3
- примерно в 10 раз медленнее, чем для L1.
Эти различия помогают объяснить также и физическое расположение клеши CPU. L1 и L2 называются on- die — это означает, что они расположены на том же кристалле кремния, что и остальная часть процессора. И наоборот, L3 находится вне кристалла (off- die), что частично объясняет разницу в задержке по сравнению с L1 и L2.

[ImageCaption: Рис. 12.1. Процессор i5-7300 имеет три уровня клеши, два физических и четыре логических ядра]
Для оперативной памяти (основной памяти, или ОЗУ) средняя скорость доступа в 50–100 раз медленнее, чем для L1. За время, необходимое для получения доступа к одной переменной, хранящейся в ОЗУ, мы можем получить доступ к 100 переменным, хранящимся в L1. Поэтому Go- разработчикам так важно заботиться о том, чтобы в приложениях использовались клыш CPU.
# 12.1.2. Кэш-линня
Понимание концепции клш-линний (cache lines) критически важно. Но прежде чем объяснить, что они собой представляют, разберемся, зачем они вообще нужны.
При доступе к какому-либо конкретному месту в памяти (например, при чтении переменной) сразу после этого может произойти одно из следующих событий:
- K этому же месту будет снова сделано какое-то обращение.
- Будет сделано какое-то обращение к близлежащим янейкам памяти.
Первая возможность относится к временной локализации, а вторая — к пространственной. Обе — части так называемого принципа локальности ссылки (locality of reference).
Рассмотрим следующую функцию, которая вычисляет сумму среза int64:
func sum(s []int64) int64 { var total int64 length := len(s) for i := 0; i < length; i++ { total += s[i] } return total }
В этом примере принцип временной локальности применяется к нескольким переменным: i, length и total. На протяжении всех итераций мы продолжаем обращаться к этим переменным. Принцип пространственной локальности применяется к инструкциям кода и срезу s. Поскольку за этим срезом стоит резервный массив, все элементы которого расположены в памяти рядом друг с другом непрерывно, то доступ к s[0] означает также доступ к s[1], s[2] и т. д.
Временная локальность — одна из причин того, зачем нужны клыш CPU: чтобы ускорять повторный доступ к одним и тем же переменным. Но из-за пространственной локальности CPU копирует из ОЗУ в клш не только одну переменную, но и кэш-линию.
Kэш-линия — это непрерывный сегмент памяти фиксированного размера, обыч- но 64 байта (8 переменных int64). Всякий раз, когда CPU решает кэшировать какой-либо блок памяти из ОЗУ, он копирует это блок в строку кэша. Поскольку память иерархична, то когда процессор хочет получить доступ к какой-то конкретной ячейке в памяти, он сначала проверяет ее наличие в L1- кэше, затем в L2- кэше, затем в L3- кэше, и наконец, если в этих кэшах она не найдена, то в основной памяти.
Рассмотрим на конкретном примере получение процессором блока памяти. Мы вызываем функцию sum для среза из 16 элементов типа int64 в первый раз. Когда sum обращается к s[0], этот адрес памяти еще не находится в кэше. Если процессор решит кэшировать эту переменную (мы также обсудим такое решение позже в этой главе), то он скопирует весь блок памяти (рис. 12.2).
Pис. 12.2. Обращение к s[0] заставляет CPU копировать блок памяти 0x000
Сначала обращение к s[0] приводит к промаху кэша, потому что адрес памяти еще не находится в кэше. Это называется принудительным промахом (compulsory miss). Но если CPU получает доступ к блоку памяти 0x000, обращение к элементам с 1-го по 7-й принедет к их чтению уже из кэша (попаданию в кэш). Та же логика применяется, когда sum обращается к s[8] (рис. 12.3).
Pис. 12.3. Обращение к s[8] заставляет CPU копировать блок памяти 0x100
Обращение к s8 приводит к принудительному промаху. Но если блок памяти 0x100 скопировать в строку кэша, то это также ускорит доступ к элементам с 9-го
no 15- ii. Bконце концов перебор 16 элементов приводит к двум принудительным промахам кэша и к 14 попаданиям в кэш.
# Ctpaterinu kshupobahua CPU
BbI Moxkete saJatbcr bonpcOm o touHou ctpaterinu konupobahua npoueccopom kakoro- to Gnoka namatu. Hanpимер, Gynet nu Gnok konupobatbcr ha bce yporbuc? Unu ToJbko ha L1? Uto B StOM cnyuae npoucxOajut c L2 u L3?
Ectb pa3hbie ctpaterinu. MHorda k3uu rBraHOTcr uHkHIO3HbHbIM (HaHpимер, JaHbHbIe, HaxoJdHuecra B L2, taxxke cctb u B L3), a uHorJaa — экckHIO3HbHbIM (HaHpимер, L3 Ha3bIaetcr k3uem xeprmbi, victim cache, nocKoJbky OH coJepxHUT ToJbko JaHbHbIe, BbIeTcHHeHbIe u3 L2).
Kak npabuino, stu ctpaterinu npou3bOJutEnraMn CPU He pasnIaIIaIOCTcr, u 3HaTb ux oco6o He HyxH0. No3tomy He GyJem yrny6JnTbcr B stu BonpcObi.
PaccmOrpum npимер, vtoubi npouJIJIIOCTpHpOBaTb, HacKOLHKO bIbCTbI K3HII CPU. PeaJH3yEM Jbe yHxH1H, KOTOpbIe bH1HcJHIO1 uTOr npH uTepa11rax no cpe3y 3Je- MeHTOB THIa int64. BoJHOM cJyuae uTepa111u 6yJyT AeJIaTbcr nO KaXJOMy BTOpOMy 3JIeMeHTy, a B ApyTOM — Ho KaXJOMy BoCbMOMy:
func sum2(s []int64) int64 { var total int64 for i := 0; i < len(s); i += 2 { // —— Итерации по каждому второму элементу total += s[i] } return total } func sum8(s []int64) int64 { var total int64 for i := 0; i < len(s); i += 8 { // —— Итерации по каждому второму элементу total += s[i] } return total }
O6e yHxH1H110JHHaOBbI, 3a uckJIIOvHHeM uTepa1111. EcJIu MbI uX cpaBHM, To uHtuy1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111 11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111101111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111112111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
Ipu111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111I111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111II1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111113111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111511111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111161111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111117111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111811111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111191111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111114111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111.
onределяется параметрами доступа к памяти, а не инструкцией цикла. В первом случае три из четырех обращений приводят к попаданию в кл. Поэтому раз- ница во времени выполнения этих двух функций несущественна. Этот пример показывает, почему кли- минии важны и то, что интуиция может легко обмануть, если у нас нет «mechanical sympathy» — в данном случае понимания того, как процессор кэширует данные.
Продолжим обсуждать принцип локальности ссылки и рассмотрим пример использования пространственной локальности.
# 12.1.3. Срез структур и структура срезов
В этом разделе рассмотрим пример, где сравним время выполнения двух функций. Первая в качестве своего аргумента принимает фрагмент структур и суммирует все поля а:
type Foo struct { a int64 b int64 } func sumFoo(foos []Foo) int64 { —— Получение среза Foo var total int64 for i := 0; i < len(foos); i++ { —— Циклы по каждому Foo с суммированием всех полей total += foos[i]; a } return total }
sumFoo получает срез Foo и увеличивает итоговую сумму при чтении каждого поля.
Вторая функция также вычисляет сумму. Но на этот раз аргумент представляет собой структуру, содержащую срезы:
type Bar struct { a []int64 a в b [int64 } func sumBar(bar Bar) int64 { —— Получение одной структуры var total int64 for i := 0; i < len(bar.a); i++ { —— Циклы по каждому элементу a total += bar.a[i] —— Увеличение итоговой суммы } return total }
sumBar получает одну структуру Bar, содержащую два среза: a и b. Она выполняет итерацию по каждому элементу a для увеличения total.
Oжидаем ли мы какой-либо разницы в скорости выполнения этих двух функ- ций? Перед запуском бенчмарка визуально посмотрим на различия в памяти, обратившись к рис. 12.4. Оба случая имеют одинаковое количество данных: 16 элементов Foo в срезе и 16 элементов в срезах Bar. Каждая черная полоса пред- ставляет собой int64, который считывается и прибавляется к сумме, а каждая серая полоса обозначает int64, который пропускается.
Puc. 12.4. Ctpyktypa cpe3ob 6onee komnaktha u nostomy tpe6yet mehbiee ksw- nnhiu drr utepaulii
B clyvae c sumFoo mbl no/yyaem cpe3 ctpykyyp, co/epxaaiii dba no. a u b. C/edobate/1bho, y hac b namrtu cctb noc/edobate/1bhoctb us a u b. Ha/6opot, b c/1y/ae sumBar mbl no/yyaem ctpykyyp, co/epxaayyo dba cpe3a, a u b. C/edoba- te/1bho, bce 3/emehtbi a pasmeuiaotcsa b cmexhbx xueikax namrtu.
Ora pasnua he npuroant k kakoi- nioo ontnmu3aunn y/notneneus namrtu. Ho n/be oeux pyhkiuui - nepepartb kaxdbiu 3/emeht a, u drr storo tpe/ye/cta 4 ksn- .ninniu b oJHOM c/1y/ae u bcero 4 ksn- .ninniu b dpyrom.
Ec/nu mbl cpaBhum эти abe dyhkiuu, to sumBar okaxketcra быctpee (oko/0 20 % ha moem komnbiotepe). Ochobnaar npnunha - y/uynaar npoctpaHctbennha/ Joka/nu3a/1r, 6/iaro/apa kotopoi/ CPU u3b/ekaet us n/amrtu mehbiee ksw- .ninnii.
Oto/ t p/mer noka3b/baet, kak npoctpaHctbennha/ Joka/nu3a/1r moxet c/1b/ho nob/nnrts ha npou3b/ou/te/1bHocTb. 1To/6bl ontnmu3uipobatb npn/1oxeHue, hy/ko opraHn3obatb Jahnbie tak, vTo/6bl no/1y/utb makc/ma/1bhy/0 ot/aa/1y ot/ a/eb/ho/6 ksw- .ninniu.
Ho doctatovho jiu nci/ou/3bObHnHr npoctpaHctbenHOn/ Joka/nu3a/1u n/1r n1o1/1u npo/eccopy? Bce e/ue he xba/taet oJHO/1 Kp/tn/ueckO/ xapaKtrepuctnku: np/ekca- 3y/emoctu.
# 12.1.4. PpckasayemocTb
12.1.4. PpckasayemocTbPpckasayemocTb noinumaetcak cnoco6hocTb CPU ppebdudetb, to 6ydet deJatb npuloxehue, tTO6bi yckoputb ero bInOJIehue. PaccmoTpUM npuMEp, korda otcytctbue ppeckasayemocTu heratubho bJirnet ha npou3bOJutelbHocTb npuloxehue.
Eme pa3 BosbMeM Jbe pyHKii, kotopbe cyMmupyot cHucok 3JemeHTob. Hepbaa nepe6upaet cBra3bHii (linked) cHucok u cyMmupyet bce 3Ha4ehu:
type node struct { Crryktypa aahhix cBraHorO cHucka value int64 next \*node } func linkedList(n \*node) int64 { var total int64 for n $! =$ nil{ HrepaHun no kaXdomy y3ny total $+ =$ n.value YbeJnuHue total $\texttt{n} = \texttt{n}$ .next } return total }
Ota pyHKHn noJyuaet cBra3bHii cHucok, utepupyet no hemy u ybeJHuuBaet total.
Bropar pyHKHn sum2 utepupyet no kaXdomy Btopomy 3JemeHTy cpe3a:
func sum2(s []int64) int64 { var total int64 for i $\mathbf{\dot{\rho}} = \mathbf{\Theta}$ ;i<len(s); $\dot{\mathbf{1}} + = \mathbf{2}$ { HrepaHun no kaXdomy Btopomy 3JemeHTy total $+ =$ s[i] } return total }
DOnyctum, vTO nAMrTb JIA cBra3Horo cHucka bblJeJIeTcH nenpepbHHO: hanpuMEp, B OJHOHd pyHKHn. B 64- 6uTHOHd apxutektype cJIOBO HMeet JJIHny 64 6HTa. Ha puc. 12.5 cpaBHbBaTcTc JBE cTpyKtypbi aahhix, kotopbe noJy4aIOT pyHKHn (cBra3bHii cHucok uJIH cpe3). BoJee temHbie noJocbi o6O3HaaIOT 3JemeHTb THIa int64, kotopbe Mbl cHcIOJIb3yEM JIA yBeJHuuHnI o6IIeH cMmbl.
B o6OuX npuMEpaX Mbl cTaJIKbBaemcA c OJHHaKOBO nIOTHbIM pa3MeIIeHueM B nAMrTn. IOccOJIbKy cBra3bHii cHucok ppeJctaBJIeT cO6Oi nocJIeJbOaTeJIbHocTb 3Ha4eHHuH u 64- 6uTHbIX 3JemeHTb- yKa3aTeJIeIH, Mbl yBeJIHuuBaem cyMny, ucIOJIb3yA JIA 3TOro OJHH 3JemeHT H3 TaKOH Hnapb. MeXJy TeM sum2 cHHTbBaET TOJIbKO KaXdblIH BTOpOH 3JemeHT.

[ImageCaption: Puc. 12.5. Cbshbie cHckH H cpe3b yHakobbbaaCTe B HaMHTH aHAnorHHO]
3tu aBec tpyktypbi aahhix meiot oJuhakobyo npoctpanctbenhyno Jokalnusaunio, nostomy Mbl moxem ockudatb oJuhakoboe Bpemr bblnonhenua Jbyx yhKqii. Ho yhKqii, BkJIOvapouia B ce6a utepaun no cpe3y, okasbbaetca shauHteJbH0 6bICTpee (okOIO $70\%$ na moem kombIbIotepe). Iovemy?
HTo6bI noHrTb 3TO, o6cydHm KohIIeIIIO striding (uIarOB), oTHocaIIyIOcA K TOMy, kak npouecccopbi pa6oTaIOT c JAHHbIMu. ECTb три pa3HbIX THIIa IIarOB (puc. 12.6).
IIar eJHHHHHOro pa3mepa (Unit stride) - bce 3haVehHn, K KOTOpbIM Mbl XOTIM nOJIyHbTb JocTyn, paCHOJIaraIOTcA nOcJIeJOBaTeJbHO, HaIIpHMEp cpe3 JJIeMeHTOB THIIa int64. 3TOT HIIar npeJCKa3yEM Jля npouecccopa u HaaIOJee aJbIeKbIH, TAK KAK Tpe6yET MHHHMABHoro KOIJueCTBa K3II- JHHHII Jля o6xoJa JJIeMeHTOB. IIar nOCTOaHHHOro pa3mepa (Constant stride) - no- IpexkHeMv npeJCKa3yEM Jля npoueccopa, HaIIpHMEp cpe3, KOTOpbII npeJ6upaet KaKaJIe JBa JJIeMeHTa. 3TOT HIIar Tpe6yET 6OJIbIeTO KOIJueCTBa K3II- JHHHII Jля o6xoJa JAHHbIX, noSTOMy OH mHee eJbIeKbIHbH, YEM HIIar eJHHHHHOro pa3mepa. IIar c henpeJCKa3yEMbIM pa3mepom, IJIN HeeJHHHHbIH (Non- unit stride), - HIIar, KOTOpbII npoJecccop He moxet npeJCKa3aTb, HaIIpHMEp cB3HbII cHnCOK HJIN cpe3 yKa3aTeJIeII. IocKoJIbKy npoJecccop He 3HaeT, bblJeJIeHbI JIN JAHHbIe nOcJIeJOBaTeJIbHO, OH He 6yJIeT 3arpyKaTb K3II- JHHHII.

[ImageCaption: Puc. 12.6. Tpu THIa WJAROB]
B случае с sum2 мы имеем дело с постоянным шагом. Но для связного списка мы сталкиваемся с неглиничным шагом. Даже если мы сами знаем, что данные расположены непрерывно, но об этом не знает CPU. Следовательно, он не может предсказать, как надо будет проходить по связному списку.
Из- за разного шага и схожей пространственной локализации итерация по связному списку выполняется значительно медленнее, чем по срезу значений. Обычно мы должны отдавать предпочтение единичным шагам, а не постоянным из- за лучшей пространственной локализации. Но CPU не может предсказывать шаг, отличный от единицы, независимо от того, как распределены данные, что негативно влияет на производительность.
До сих пор мы обсудили, что кэши процессора быстрые, но значительно меньше по размеру, чем основная память. Поэтому процессору необходима стратегия для загрузки блока памяти в кэш-линию. Эта стратегия называется политикой размещения кэша и может сильно влиять на производительность.
# 12.1.5. Стратегия размещения кэша
При разборе ошибки #89 (писать неточные бенчмарки) мы обсуждали пример с матрищей, где нужно было вычислить общую сумму содержимого первых восьми ее столбцов. На тот момент мы не объяснили, почему изменение общего количества столбцов повлияло на результаты бенчмарков. Это может показаться нелогичным: нужно прочитать только первые восемь столбцов, почему же изменение общего количества столбцов влияет на время выполнения? Вернемся к этому примеру.
Вспомним, что там обсуждалась следующая реализация:
func calculateSum512(s [][512]int64) int64 { // TOT Xe KoA, YTO A DnA calculateSum512 } func calculateSum513(s [][513]int64) int64 { // TOT Xe KoA, YTO A DnA calculateSum512 }
Мы проводим итерации по каждой строке, каждый раз суммируя первые восемь столбцов. Если мы проводим бенчмаркинг этих двух функций при условии, что
им всякий раз задается в качестве аргумента новая матрица, мы не наблюдаем никакой разницы. Но если продолжить переспользовать одну и ту же матрицу, calculateSum513 на моем компьютере будет выполняться примерно на 50 % быстрее. Причина кроется в кэше CPU и в том, как блок памяти копируется в кэш-линию. Разберемся, откуда появляется разница.
Когда CPU решает скопировать блок памяти и поместить его в кэш, он должен следовать определенной стратегии. Предположим, что кэш L1D имеет размер 32 Kбайт, а кэш-линии — объем 64 байта. Если блок размещается в L1D случай- ным образом, то в худшем случае процессору придется выполнить итерацию по 512 кэш-линиям, чтобы прочитать переменную. Такой вид кэша называется полностью ассоциативным (fully associative).
Для улучшения скорости доступа к адресу из кэша процессора разработаны различные стратегии размещения кэша. Пропустим историю и обсудим наиболее широко используемый на сегодняшний день вариант: наборно-ассоциативный кэш (set- associative cache), принцип действия которого основан на секционировании кэша.
Для простоты изложения на последующих рисунках мы будем работать с несколько урезанной версией этой задачи. Предположим, что:
- кэш L1D имеет размер 512 байт (8 кэш-линий);- матрица состоит из 4 строк и 32 столбцов, и мы будем читать только первые 8 столбцов.
На рис. 12.7 показано, как эта матрица может храниться в памяти. Мы будем использовать двоичное представление адресов блоков памяти. Кроме того, при- мем, что серые блоки представляют первые 8 элементов типа int64, по которым мы хотим провести итерацию. Остальные блоки при этом пропускаются.
Каждый блок памяти содержит 64 байта и, следовательно, 8 элементов int64. Первый блок памяти начинается с адреса 0x00000000000000, второй — с адреса 0001000000000 (512 в двоичном формате) и т. д. Мы также показываем кэш, который может содержать 8 строк/линий (lines).
Примечание В разделе, посвященном разбору ошибки #94 (не знать о выравнивании данных), мы увидим, что срез не обязательно начинается в начале блока.
При использовании политики наборно-ассоциативного кэширования кэш разбивается на сектора. Мы предполагаем, что кэш является двусторонним наборно-ассоциативным, то есть каждый сектор содержит две строки/линии.

[ImageFootnote: Puc. 12.7. XpaHraJaraC 8 naMraTn MaTpNia 1 nycTOI KsN, rOTOBbN 1 paOte]
Bлок namanru moxet rpHnadJexKaTb ToJbko oJhomy pa3JcJy, u ero pa3meIeHne onpeJeeJIeTcra ero aJpecOM b namanru. YTo6bI nOHaTb sto, Mbl oJJIKbI pa3JeJIHTb aJpec 6JJOKa namanru Ha Tpu vactu:
- CmeueHne 6JJOKa (block offset) saBucHr ot pa3mepa 6JJOKa. B JaHHOM CJyVae sJOT pa3MEp paBHe 512 GaHT, a 512 paBHO $2^{9}$ . CJIeJOBaTeJIbHO, neppbIe 9 6HT aJpeca npeJIcTaBJIHOT co66uI cmeIeHne 6JJOKa (bo - block offset).
- UhdeKc cekmopa (set index) yka3bIbaeT Ha cektop, K KOTOPOny OHHOcHTcra aJpec. Tak kak KJIH aJbJIeTcra JByCTOrOpHHUM cekTOpHO-acCOIIaHTHbHbIM u coJIeJPKHT 8 cTpOK/JHHHnI, to HmeeTcra bCero $8 / 2 = 4$ cekTOpa. KpOMe ToJO, 4 paBHO $2^{2}$ , nOeTOMy CJeJIyOHIHe JBa 6HTa npeJIcTaBJIHOT HJIJIeKc Ha66pa (si - set index).
- OctaJIbHaa vactb aJpeca cOCTOHT H3 6HTOB TeTa (tb - tag bIts). Ha puc. 12.7 JJIa nPOCTOTb Mbl yka3bIbaeM aJpec B 13-6HTHOm npeJIcTaBJIeHHu. JJIa bblYHCJIeHHa tb Mbl ucIOJIb3YeM 13 - bo - si = 2. JTO O3HaVaeT, YTO JBa OCTaBIHIXcK 6HTa npeJIcTaBJIHOT co66uI 6HTb1 TeTa.
JOnIyCTHM, Mbl saIIyCKaEM QyHKHIIIO, KOTOpaR nBHTaTeTcA nPOHHTaTb JJIeMEHT s[0][0], KOTOpbIi COOTBeTCTbYeT aJIpecy OO00000000000. IOCKOJIbKy JTOTO JJIeMEHTa eIIe HeT
в кэше, CPU вычисляет индекс его сектора и копирует его в соответствующий раздел кэша (рис. 12.8).

[ImageFootnote: Pa3mer 6noka:64 6aiva = 612 6nt $512 = 2^{\wedge}9$ 9- это cmeuehne 6noka (po)]
Puc. 12.8. Aдрес памяти 0000000000000 копируется в сектор 0
Kak yxe roborpulocb, 9 6nt otboujntcra ha cmeuehne 6noka: oto mihnmaJbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHb HbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHb
Korida pyHKHnH HHTaET 3JIEMeHTbI OT S[0][1] AO S[0][7], JAHHbIe yXe HaxOJIaTcra b kAIE. Kak CPU y3IaET o6 3TOM? CPU bH4HcJIaTcT HaxaJIbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbIHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHBHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHhHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHcHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHchbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbHbE
Далее функция считывает элемент s[0][8], но он еще не кэширован. Поэтому происходит аналогичная операция копирования блока памяти 0100000000000 (рис. 12.9).
Puc. 12.9. Aдрес namяти 0100000000000 koninpyetcrB cektop 0
Tot oлок namяти имеет индекс cektopa, pahbui 00, nostrmy on takxe npunaJ lexut cektopy 0. Kani- yinния koninpyetcrs b cJedyionyio docrynhyno ctpoky b cektope 0. 3atem vtenue xlenemtoB ot s[1][1] ao s[1][7] npubouut k nonadanuo B knn.
JalbIue bce ctahobutcrs uhrepechee. FyHKIur uitaet s[2][0], a storo aJpeca B kIue het. IpOBOJutcrs takar xe onepaIur (puc. 12.10).
Hndek cektopa choba paben 00. OJhako cektop 0 yxe sanolnen - vto tora dеляb npoueccopy? CkouinpoBats oлок namяти B Jpyroii cektop? Het. CPU samenret oJhy us cyuectbYyIouikx kou- yinHn, vTo6bi noJyvHtbs BosmoxHocTs cKouinpoBats oлок namяти 1000000000000.
NoJnIuKa bHitecHenua H3 KJua saBucut ot KOnKpetHoro CPU, Ho o6bIyHO aBJreTcra nceBJo- LRU noJnIuKoBi. HactoarIa LRU (Least Recently Used - bHitecHenue JabHo He ucnoJb3yEMbX) 6bJJa 6bI cJuuIkoM cJIOXHOuI JJIa o6pa6oTKu. JOnyctHM, vTO b HaIem cJyVae samenrecrs nepBaa KJII- yinHn: 0000000000000. 3Ta cunyaIuA nOBtorpReTcA npu uetpaIuu no ctpoke 3: uHdEkc cektopa JJIa aJpeca namяти
110000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 KSWII- JIHINII.

[ImageFootnote: Pa3мер 6noka:64 6aHTa=5126HT $512 = 2^{\wedge}$ 9-3TO CMEuHHeHHe 6noka (bo)]
Puc. 12.10. Aдрес памяти 100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 B cektorpe 0 KSWII- JIHINIO
Tenepb npednoloxkum, vto tect bInonJnhet yhKkHnI0 co cpe3OM, yka3bIBaOIIUM Ha ty xke matpHuy, haunHna c aJpeca 00000000000000. KoTa dyhKHnIa cHunbIaEt s[0][0], to oka3bIBaTeCA, vTO coJepxkMmOrO xueHkH no xTOy aJpecy B KxHHe HET. OTOT 6JIOK yXe BbITeCHeH.
BMeCTO uCnOJIb3OBaHnIa KxHHeI npOIEcCOPa MeXkJy bHInOJIhEHHnMn 3OT OeHyMapk npBueJET K OJbIHIEyM yKOHuIeCTBy npOMaxOB KxHnI. OTOT THH npOMaxa Ha3bIBaTeCA KOHpJIIKmHbM npOMaxOM (conflict miss): npOMax KxHnI, KOtorpHnI He npOHSOIIeJI 6bI, eCJIu 6bI KxHHe 6bIy pa36HnI Ha cektopa. Bce nepeMeHHbIe, no KOtorpHm Mb Hteprupyem, npHnHaJIeXkat 6JIOky nAMHTH, HHJeKc cekTOpa KOtorOPO pABeH OO. HO3TOyM yMb uCnOJIb3yEM ToJIbKO OJIHn cekTOp KxHnI bMeCTO paCnpeJeJIeHnI 10 bCeMv KxHnI.
Ранее мы обсуждали концепцию шагов (striding), которую описали как способ обращения CPU к данным. В этом примере такой шаг называется критическим шагом (critical stride): он приводит к доступу к адресам памяти с тем же индексом набора, которые, таким образом, сохраняются в одном и тем же наборе кэша.
Вернемся к нашему примеру из реального мира — с двумя функциями, calculateSum512 и calculateSum513. Бенчмарк выполнялся с восьмиканальным секторно-ассоциативным кэшем L1D объемом 32 Кбайт — всего 64 сектора. Поскольку длина кэш-линии составляет 64 байта, критический шаг равен $64 \times 64$ байта = 4 Кбайт. Четыре килобайта для типа int64 соответствуют 512 элементам. Таким образом, мы достигаем критического шага при матриче, состоящей из 512 столбцов, поэтому использование кэша неэффективно. Но если матрица содержит 513 столбцов, то это не приводит к критическому шагу. Вот почему мы наблюдали такую огромную разницу между результатами двух бенчмарков.
Помните, что современные кэши разбиты на сектора. В зависимости от шагов в некоторых случаях может оказаться так, что будет использоваться только один сектор. Это может снизить производительность приложения и привести к конфликтным промахам кэша. Шаг такого типа называется критическим. Для приложений, требующих достижения высокой производительности, нужно избегать критических шагов, чтобы получить от использования возможностей кэша CPU максимальную отдачу.
Примечание Наши пример также показывает, почему к результатам микро- бенчмарка нужно подходить с осторожностью, если он выполнялся в системе, отличной от той, где приложение будет реально работать. Если продакшен- система имеет другую архитектуру кэша, то производительность может быть существенно иной.
Продолжим обсуждать влияние кэша CPU. На этот раз увидим его конкретное влияние при написании конкурентного кода.
# 12.2. ОШИБКА #92: ПИСАТЬ КОНКУРЕНТНЫЙ КОД, КОТОРЫЙ ПРИВОДИТ К ЛОЖНОМУ СОВМЕСТНОМУ ИСПОЛЬЗОВАНИЮ
До сих пор мы обсуждали фундаментальные концепции кэширования CPU. Мы видели, что некоторые специфические кэши (обычно L1 и L2) не являются
o6iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii
B 3tom iphmepe Mbi HcnoIb3yem Jbe ctpyktypbi, Input u Result:
type Input struct { a int64 b int64 }
type Result struct { sumA int64 sumB int64 }
IeJb B ToM, YTO6bi peaJbObAaTb yHKHnIO count, Kotopaa HnyyAaer cpe3 Input u bHyHcJrTec TJIyIOHec:
- CymMy Bcex NoJIeU Input.a,3aHnCbIBaB ee B Result.sumA;- CymMy Bcex NoJIeU Input.b,3aHnCbIBaB ee B Result.sumB.
PeanH3yEM KOnKypENTHOe peHHeHue npu nOMOIIu AByX rOpyTHH, OJHa H3 KOTOpBIX bHyHcJIrTec sumA, a JpyTaH sumB:
func count(inputs []Input) Result { wg := sync.WaitGroup{} wg.Add(2) result := Result{} go func() { for i := 0; i < len(inputs); i++ { result.sumA += inputs[i].a BbHycnHHe sumA } wg.Done() } go func() { for i := 0; i < len(inputs); i++ { result.sumB += inputs[i].b BbHycnHHe sumB } wg.Done() }() wg.Wait() return result }
Мы запускаем две горутины: одну, которая проводит итерации по всем полям а, и другую, которая проводит итерации по всем полям b. Этот пример хорош с точки зрения конкурентности. Например, в нем нет гонки данных, потому что в каждой горутине происходит увеличение своих собственных переменных. Но пример иллюстрирует концепцию ложного совместного использования, которая снижает ожидаемую производительность.
Посмотрим, что происходит в основной оперативной памяти (рис. 12.11). Поскольку sumA и sumB выделяются непрерывно, в большинстве случаев (в семи из восьми возможных) обе переменные размещаются в одном и том же блоке памяти.
Pис. 12.11. В этом примере sumA и sumB — это части одного и того же блока памяти
Предположим, что процессор содержит два ядра. В большинстве случаев нужно иметь два потока, выполнение которых происходит на разных ядрах. Таким образом, если CPU решит сконпровать этот блок памяти в квіш-линию, то ко-пирование будет происходить дважды (рис. 12.12).
Pис. 12.12. Каждый блок копируется в квіш-линию как на ядре 0, так и на ядре 1
Обе кэш-линии реплицируются, поскольку L1D (данные L1) относятся к каж-дому ядру. Напомним, что в нашем примере каждая горутина обновляет свою собственную переменную: одна sumA, а другая sumB (рис. 12.13).

[ImageCaption: Puc. 12.13. Kaxdara rorytnha o6hobnret cboo nepemenhyno]
Iockolsky эти kani- jininu penlininpyiotcra, odhoni us netai CPU rbrnecr oecnevehne korepenthocrtu kaiia. Hanpимер, eciu oJha rorytnha o6hobnret sumA, a Jpyraa vtraeet sumA (nocne npobeuehna kakou- to cunxponnusaninu), mi oKudaeM, vto npuloxehne nolynunt caMoe nocJednee 3havenne.
OJhako b hainem npимерe takoro he npoucxodut. O6e rorytnhni o6paunaotcra K cBoUM co6ctbenhbiM nepemenhbiM, a he K o6nuei. Mbi MoZiu 6bi oKudatb, vTO npoueccop y3haet o6 yrom u noUmet, vto sto he kOoHJnkt, ho sto he tak. KorJa Mbi sanucbiaem nepemenhyno, haxoJauyycoc a KsIie, rpahyJapHOcTb, otcJexKubaemar CPU, rbnIaTeCra he nepemenhoni, a KsII- JnHnuei.
KorJa KsII- JnHnus ucnoJb3yetcra heckoJbKumu rJpamu u xotr 6bi oJha rorytnha vto- to sanucbiaeet, to coJepKumoe bcei KsII- JnHnus ctahobutcr heBaJnJHbM. 3to npoucxodut, Jaxe eciu o6hoblenhna Joruyeccku hesaBncumbi (hanpимер, sumA u sumB). 3to u ectb npo6Jema Joxkhoro cobMecthoro ucnoJb3oBahnus, cHUKaIOuIero npou3bOJutTeJbHocTb.
IPUMeVAHNE Bhytrp CPU ucnoJb3yetcr npotokol MESI, rapantupyIouinii korepenthocTb KJIIa. On otcJexKubaet kaxJyio KsII- JnHnIO, nomeyar ee Kac us- mehenhyno, xckJIOBnHyno, cobMectho ucnoJb3yemyno KJII nJedEicTbUTeJbHyIO (MESI).
OJha us hauoJee BaxHbIX oco6ehnocTei BnytpeHHero yctpouCTBa nIamTn u npoJecCOB KJIIupOBahnus, kotopyIO BaxKHO nOHArTb, - cobMecthoe ucnoJb3oBahue nIamTn rJpamu rBbJreTcra he peaJbHocTbIO, a uJJIHO3uei. Oco3HATb sto nOMoraet «mechanical sympathy»: Mbl he paccMaTpubaem npoueccop Kak vepHbui rJnIk, a nBitaemcr «npo- yyBCTbOBaTb MaIIInhy» ha 6a3ObBx ypoBHAx.
Kak peIInTb npo6Jemy Joxkhoro cobMecthoro ucnoJb3oBahnus? Ectb Jba cnoco6a.
Первое решение — использовать тот же подход, который мы только что рассмотрели, но при этом сделать так, чтобы sumA и sumB не были частью одной и той же кэш-линни. Например, мы можем обновить структуру Result, добавив заполнение (padding) между полями. Заполнение — это техника выделения до- полнительной памяти. Поскольку int64 требует выделения 8 байт и кэш-линни длиной 64 байта, нужно 64 - 8 = 56 байт заполнения:
type Result struct { sumA int64 - [56]byte → заполнение sumB int64 }
Haрис. 12.14 показано возможное распределение памяти. Используя заполнения, sumA и sumB всегда будут частями разных блоков памяти и, следовательно, разных кэш-линний.

[ImageCaption: Puc. 12.14. sumA u sumB являются частями разных блоков памяти]
Eсли мы сравним два решения (с заполнением и без него), то увидим, что решение с заполнением значительно быстрее (около 40 % на моем компью- тре). Такое улучшение производительности — это результат добавления заполнения между двумя полями для предотвращения ложного совместного использования.
Другой подход — переработать структуру алгоритма. Например, чтобы не использовать одну и ту же структуру для обеих горутин, можно сделать так, чтобы они коммуницировали свои локальные результаты через каналы. Результат будет примерно таким же, как и при подходе с заполнением.
Помните, что совместное использование памяти разными горутинами на самых низких уровнях памяти иллюзорно. Ложное совместное использование происходит, когда какая-то кэш-линия совместно используется двумя ядрами и при этом хотя бы одна горутина что-то записывает в память. Если нужно оптими- зировать конкурентное приложение, проверьте, нет ли в нем ложного совмест- ного использования, поскольку известно, что это снижает производительность приложения. Можно предотвратить его либо с помощью заполнения (padding), либо с помощью коммуницирования.
В следующем разделе поговорим, как процессоры выполняют инструкции па- раллельно и как использовать эту возможность.
# 12.3. ОШИБКА #93: НЕ УЧИТЫВАТЬ ПАРАЛЛЕЛИЗМ НА УРОВНЕ ИНСТРУКЦИЙ
Параплелизм на уровне инструкций - это еще один фактор, который может значительно повлиять на общую производительность. Прежде чем дать опре- деление этой концепции, обсудим конкретный пример и способы оптимизации, которые здесь применимы.
Создадим функцию, которая получает массив из двух элементов типа int64. Она будет исполняться циклически некоторое (постоянное) число раз. Во время каждой итерации цикла она будет делать следующее:
- увеличивать значение первого элемента массива;- увеличивать значение второго элемента массива, если первый элемент четный.
Bot как эту функцию можно написать на Go:
const n = 1_000_000 func add(s [2]int64) [2]int64 { for i := 0; i < n; i++ { ← Цикл из п итераций s[0]++ ← Yвеличение значения s[0] if s[0]%2 == 0 { ← Yвеличение значения s[1], если s[0] четное s[1]++ } } return s }
Инструкции, выполняемые в цикле, показаны на рис. 12.15 (увеличение значения переменной требует операций как чтения, так и записи). Инструкции
исполняются последовательно: сначала мы увеличиваем s[0], затем перед уве-личением s[1] нужно снова прочитать s[0].
Puc. 12.15. Tpu ochobhux wara: ybeniuenue shavehna, npobepeka, ybeniuenue shavehna
PruMeyAHue 3ta noc.ledobate.leshctpykuni he cootbetctbyet rpa- hylraphoctu hnctrykuni accem6lepa. Ho dria rchoctiu b stom pasdne mei uc- no.ль3yem yipoiuehnyio cxeMy.
Tenepb o6cydum teopuno, lexaanyio b ochobe napal.lesnsma ha ypoBhe hnctrykuni (ILP - instruction- level parallelism). HeckoJbko decratlenutii nasad paspa6otyiku CPU B rонке за повышением производительности процессоров перестали фокусироваться исключительно на увеличении их тактовой частоты. Они разработали несколько схем оптимизации, в том числе ILP, которые позволяют распадале- ливать выполнение отдельных последовательностей инструкций. Процессор, реализующий ILP на одном виртуальном ядре, называется суперскалярным процессором. Например, на рис. 12.16 показано, как процессор выполняет при- ложение, состоящее из трех инструкций: I1, I2 и I3.
BbinoJhение kakoi- Jn6o noc.ledobate.leshctpykuni tpe6yet npoxoxdeHnI pasJirHbixэтапов. CPU Jолжен декодировать инструкции, a notom bblnoJnHtB Hx. 3a noc.ledHniiэтап отвеvает испoJnHtEJbHbii 6Jok, kotopbii bblnoJhreT onepaHnii u bHruCJlenHnI.
Ha puc. 12.16 CPU peHnii bblnoJnHtB три ykasahHbE инctrykIin napalJleJbHbO6patute bHmHahue, vto he bce onu o6J3atJbHbO 3aBepHnIaOTcra 3a OJun takt. HapnImuep, hnctrykIInI, cHHTbBaIOHaJ 3haVehue, kotopoe yXe cEtb b peHCTpe, 3aBepHnIcra 3a OJun HpOueccorpHbii takt, a hnctrykIInI, cHHTbBaIOHaJ aJpec, kotopbii Jолжen 6bIrH H3bJvEeH H3 ochOBHOuI nAMrTnI, MoxEt notpHtHb 3ecrTku TAKTOBbIX IJKJIOB.
Ipu noc.ledobate.leshom bblnoJhneHnI эти инctrykIInI notpe6ObaJn 6bI cJeJyIOHee o6JHee BpeMx (dyHkIInI t(x) o6O3haVaeT BpeMx, heo6xoJbIMoe CPU Jля bblnoJ- HHeHnI aHctrykIInI x):
total time = t(I1) + t(I2) + t(I3)

[ImageCaption: Puc. 12.16, Hecmotpr ha to 4TO STU TPUH HCTPYKUHN 6bJIN HAnucahbI nOcneDOBaTEbHbHO, nCnOJHbICTcA OHn napanJnebHbHO]
Ho 6JiarOJapa ILP 3TO o6IIEe BpeMn 6yIET OnpeJelJIrTbCn KAc:
total time = max(t(I1), t(I2), t(I3))
B teopnn ILP BbIrrJnIT Harnuei, ho Ha npaktnke moxet npnHctn K cJIOXHOCTrM, HAsbIBaEMbIM KoHbJnIcMAnM (hazards).
HaHpMep, VTO 6yIeT, ecJIN I3 yCTaHaBnHbAeT JJIH nepemehnOJI aHaeHnHe 42, a I2 aBJIaTeCTa yCJIOBHONH HnCTPYKIIeI (HaHpMep, if foo == 1)? TeopetHHeCKN 3OT CHeHaPHnI 0JOLxEH npeJOTBpaHnIb HaPaJIJIeJIbHoe BbInOJIHeHHe I2 u I3. 3TO Ha3bIBaTeCTa KoHbJnIcMOM yIpBaBeHeHn (control hazard) IJIH KoHbJnIcMOM BeTBeHeHn (branching hazard.). Ha npaktnke pa3pa6OTnIHKu CPU peHaJIu tAnue np6oJIeMbI c nOMOIIbIb nporHO3HpOBaHnIa BeTbJIeHnI.
HaHpMep, CPU moxeT oTcJIeJIbTb, VTO yCJIOBue [I2] OKa3aJIocb IcTnHbHM B AeBHHoCTO AeBHTn H3 nOcJIeJIHnX CTa pa3, nOcKOJIbKy 6yIET BbInOJIHnTb I2 u I3 napaJIJIeJIbHoe. B CJyVae HeHpBaIBbHoeO npORHO3a (TO cCTb ecJIu I2 OKa3bIBaTeCTa JIOXHbIM) CPU OyIIIIaTe CTbOu TEkyIIIIbI KOHBeTeHp BbInOJIHeHnIa, o6EcneYUBaI TeM cAsbIM OTCyTCTbue HeCOOTBTeCTbIbIb I OIIII6Oc. HO takoIb c6poc npIBOJIUT K nOtePHM OT 10 JIO 2O taktOB, VTO CKa3bIBaTeCTa Ha npOu3BOJIteJIbHOCTnI.
JpyIHe THnIb KoHbJnIcTOB MOYT nOMeHnTb HaPaJIJIeJIbHomy BbInOJIHeHnO HnCTPYKc IIuI. Kak pa3pa6OTnIHKu, Mbl JOLJXHbI 66 3TOM 3HaTb. HaHpMep, aBaaIte paccMO- TpHM eIIe Ae BHe HnCTPYKIIu, KoTOpbie o6HOBJIHOT peHCTpH (O6JIaCTb BpeMeHHOro xpaHeHnIa, nCnOJIb3YeMbIe JJIa BbInOJIHeHnIa ONepaIIuIb):
- I1 J66aBJIeT uICJIa, coJepKaIIuIeCTa B peHCTpax A u B, K C;- I2 CKJIaJIbIBaTe uICJIa, coJepKaIIuIeCTa B peHCTpax C u D, u 3aIIuCbIBaTe pe3yJIb-taT b D.
Поскольку I2 зависит от результата I1 в том плане, что I2 использует значение регистра C, эти две инструкции не могут выполняться одновременно. I1 должен завершиться до начала выполнения I2. Это называется конфликтом данных (data hazard). Чтобы справиться с такими конфликтами, разработчики CPU при- думали трюк, называемый переадресацией (forwarding), суть которого состоит в обходе записи в регистр. Этот метод не решает проблему, а скорее пытается смягчить последствия.
ПРИМЕЧАНИЕ Есть также структурные конфликты (structural hazards), связанные с ситуациями, когда по крайней мере две инструкции в конвей- ере требуют одного и того же ресурса. Как Go- разработчики, мы не можем как- либо повлиять на такого рода конфликты, поэтому и не обсуждаем их здесь.
Теперь, когда у нас есть достаточное понимание теоретических аспектов ILP, вернемся к исходной проблеме и сосредоточимся на том, что происходит при выполнении цикла:
s[0]++ if s[0]%2 == 0 { s[1]++ }
Как мы уже говорили, ситуация конфликта данных препятствует одновременному выполнению инструкций. Посмотрим на последовательность ин- струкций на рис. 12.17. Фокусируемся на различных конфликтах между инструкциями.

[ImageCaption: Рис. 12.17. Типы конфликтов между инструкциями]
Эта последовательность содержит один конфликт ветвления из-за наличия оператора if. Но как уже говорилось, на CPU возлагается задача по опти- мизации выполнения и прогнозу того, какую ветвь выбирать. В нашем при- мере есть несколько конфликтов данных. Как я говорил, они не позволяют
ILP BbInOJIHrTb HHCTpyKIIH IIaPaJIeJIeJIbHO. Ha puc. 12.18 nOKa3aHa nOcJIeIObAteJIbHOCTb HHCTpyKIIH I c ToYKu 3peHnI ILP: eJHHCTbEHHbIe He3aBucUMbIe HHCTpyKIIH - 3TO npOBePKa s[0] II HHKpeMEHT s[1], nOCTOMy 3TH JBa HaO6pa HHCTpyKIIH MoIYT BHHOJIHHrTbC H aPaJIeJIeJIbHO 6JIaRoJIaPbI BO3MOXHOCTH IIPOrHO3HpOBaHHH BETbJIeHHH.
Puc. 12.18. O6e onepaHnI no npu6aBJIeHHIO eJHHHbIb BbInOJIHrHOCTa nOcJIeIObAteJIbHO
A 4TO HacYeT onepaHnI npu6aBJIeHHI eJHHHbIb? MoXeM 3H Mb KaK- 3HO O yJIy4- IIHTb KOJI, 4TO6bI CBeCTH K MHHIMyMY KOJIuYeCTBO KOHbJIHKOb JaHHbIX?
HaHHIIeM ApyryIO BepcuIO 3TOH OyHKIIH (aDD2), KOTOpIa BBOJIHT BpeMeHHyIO nepeMeHHyIO:
func add(s [2]int64) [2]int64 { ← Первая версия for i := 0; i < n; i++ { s[0]++ if s[0]%2 == 0 { s[1]++ } } return s }
func add2(s [2]int64) [2]int64 { ← PropaH BepcH for i := 0; i < n; i++ { v := s[0] ← Введение новой переменной для фиксации значения s[0] s[0] = v + 1 if v%2 != 0 { s[1]++ } } return s }
Bo второй версии мы фиксируем значение s[0] в новой переменной v. Ранее мы увеличивали s[0] и проверяли, четное оно или нет. Чтобы воспроизвести это поведение, то — поскольку v определяется значением s[0] — для увеличения s[1] мы теперь проверяем, является ли нечетным v.
На рис. 12.19 сравниваются две версии с точки зрения конфликтов. Количество шагов остается таким же. Существенная разница касается конфликтов данных: шаг по прибавлению сдиницы к s[0] и шаг проверки v теперь зависят от одной и той же инструкции ( чтение s[0] и запись в v).

[ImageCaption: Puc. 12.19. CuyectBennhar pasnua: konhunkt dahhix tenepb othocится k wary vtenhia v]
Почему это важно? Потому что позволяет центральному процессору увеличить степень параллельности (рис. 12.20).
Xотя число шагов в обеих версиях одинаково, во второй версии увеличивается количество шагов, которые могут быть выполнены параллельно: есть три параллельных маршрута вместо двух. Время выполнения должно стать более оптимальным, так как самый длинный путь был сокращен. Если мы сравним две функции, то увидим значительное улучшение скорости в случае второй версии (около 20 % на моем компьютере), в основном это произошло из-за использования ILP.
Подведем итог этого раздела. Мы узнали, как современные процессоры исполь- зуют параллелизм для оптимизации времени выполнения набора инструкций.

[ImageCaption: Puc. 12.20. Bo Btopoi bepciu o6a wara no npn6abneHnio eJHHHbI MOryT BbInonHrTbCra napanJnenHo]
Dantee paccmOTpeJn npo6Jemy KoHJyHKTOB JAHHbIX, KoTOpblc MoYrT nOMeHATb napaJJIeJIbHOMy BbInOJIHHeHnIO uHCTpyKIIuI. 3atem oIITHMHbIPOBaJIu HaII nIyMEp, yMeHbIIIB KOIyHeCTBO KoHJyHKTOB JAHHbIX, YTO6bI yBeJIyHHTb KoJIyHeCTBO HHCTpyKIIuI, KoTOpblc Mo6KHO BbInOJIHrTb napaJIeJIeJIbHO.
IOnHMaHnue toro, kak Go KoMIIJIInpyet koJ B acceM6Jep u kak ucIOJIb3OBaTb nIy- eMbI oIITHMHbIaIIII nPOu3BOJIteJIbHOcTn CPU (takue, kak ILP), - 3TO eIIe OJIH nOJXOJ K yJIyHHeHnIO pa6OTbI koJa. B paccmOTpeHHOH nIyMEpe BBeJIeHnI BpeMeHHOuI nepeMeHHOuI nIyBHeJIo K 3HaYHTeJIbHOMy yJIyHHeHnIO o6IIeI nPOu3BOJIteJIbHOcTn.
Этоит пример показал, как «mechanical sympathy» поможет оптимизировать Go- приложение.
Не забывайте, что надо с осторожностью подходить к таким микрооптимизациям. Поскольку компилятор Go продолжает развиваться, сгенерированный ассем- блерный код приложения также может измениться при обновлении версии Go.
В следующем разделе обсудим эффекты выравнивания данных.
# 12.4. ОШИБКА #94: НЕ ЗНАТЬ О ВЫРАВНИВАНИИ ДАННЫХ
Выравнивание данных — это способ размещения данных в памяти для ускорения доступа. Незнание этой концепции может привести к лишнему расходу памяти и даже к снижению производительности. В этом разделе обсудим, где это можно применять, а также как предотвратить написание плохо оптимизированного кода.
Чтобы понять, как работает выравнивание данных, обсудим, что было бы без его использования. Допустим, в памяти выделяется место под две переменные: одна типа int32 (32 байта), другая типа int64 (64 байта):
var i int32 var j int64
Вез выравнивания данных в системе с 64-битной архитектурой эти две переменные можно было бы разместить в памяти так, как показано на рис. 12.21. Переменная j может располагаться в ячейках, относящихся к двум словам. Тогда если CPU будет читать j, то ему потребуется два обращения к памяти вместо одного.
Рис. 12.21. Переменная j размещена по ячейкам, относящимся к двум словам
Чтобы этого не произошло, адрес переменной в памяти должен быть кратен ее размеру. Это и есть концепция выравнивания данных. В Go есть следующие варианты выравнивания:
- byte, uint8, int8: 1 байт;- uint16, int16: 2 байтг;- uint32, int32, float32: 4 байтг;- uint64, int64, float64, complex64: 8 байт;- complex128: 16 байт.
Все эти типы гарантированно выровнены: их адреса в памяти кратны их размеру. Например, адрес любой переменной типа int32 кратен 4.
Вернемся в реальный мир. На рис. 12.22 показаны два разных случая размещения i и j в памяти.
Рис. 12.22. В обоих случаях переменная j выровнена по своему размеру
В первом случае некая 32- битная переменная была размещена в памяти непосредственно перед i. Поэтому переменные i и j располагаются в смежных ячейках (непрерывно). Во втором случае перед i никакой 32- битной переменной в памяти нет (например, перед i была 64- битная переменная), поэтому i размещается в начале слова. Чтобы соблюдать принцип выравнивания данных (чтобы адрес был кратен 64), j не может располагаться непосредственно рядом с i, а только в следующей ближайшей ячейке, адрес которой кратен 64. Серый квадрат отображает 32 бита заполнения (padding).
Посмотрим, когда заполнения могут быть проблемой. Рассмотрим структуру, содержащую три поля:
type Foo struct { b1 byte i int64 b2 byte}
Eсть переменные типа byte (1 байт), типа int64 (8 байт) и еще одна типа byte (1 байт). В 64- битной архитектуре эта структура размещается в памяти так, как показано на рис. 12.23. Переменная b1 размещается в памяти первой. Поскольку i типа int64, то ее адрес должен быть кратен 8. Следовательно, ее невозможно разместить рядом с b1 по адресу 0x01. Какой следующий адрес кратен 8? 0x08. Для b2 выделяется следующий доступный адрес, кратный 1: 0x10.
Рис. 12.23. Структура занимает в памяти всего 24 байта
Поскольку размер структуры должен быть кратным размеру слова (8 байт), ее адрес будет занимать не 17 байт, а в общей сложности 24 байта. Во время компиляции Go добавляет заполнение, чтобы обеспечить выравнивание данных:
type Foo struct { b1 byte [7]byte 0 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 064 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 067 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 06 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 066 0
Kаждый раз, когда создается структура Foo, ей требуется 24 байта в памяти, но из них только 10 байт содержат данные, остальные 14 байт используются для заполнения. Поскольку структура является атомарной единицей, она никогда не будет преобразована, даже после сборки мусора она всегда будет занимать 24 байта в памяти. Обратите внимание, что компилятор не переупорядочивает поля — он только добавляет заполнения, чтобы гарантировать выравнивание данных.
Как уменьшить объем резервируемой памяти? Эмпирическое правило: реорганизовать структуру так, чтобы ее поля сортировались по размеру типов в порядке убывания. В нашем случае первой должна идти переменная типа int64, за которой следуют две переменные типа byte:
type Foo struct { i int64
b1 byte b2 byte }
Haрис.12.24nokasano,askpasmeaetcaBnamrtu hobaarbepcractpykrybFoo. Iepemenная i pasmeiaetca nepbou u sanmaet nJoe cJobO. Ochobhoe otJnHue coctoит b tom, vto tenepb b1 u b2 moryt pacnoJiaratbca prJOM Jpyr c Jpyrom b OJ- hom u tom же cJobe.

[ImageCaption: Puc.12.24.Tenepb ctpyktypa sanmaet toJbko 16 6ait namrtn]
Pasmer ctpyktypbi B nasmrtu no- npexhemy JOLxeh 6bItb kprrch pasmerpy cJoba, ho tenepb ctpyktypa sanmaet he 24 6aitra, a bcero 16 6ait. Mbi cJokohomuJiu $33\%$ namяти, npocto nepemectub b koJe nepemenhую i ha nepbую no3nIuio.
Kakue 6bIu 6bi nocJelctbura, ecJu 6bi Mbi ucnoJbSbOaJiu nepbYIO bepcuo ctpyktypbi Foo (24 6aitra) BMeCTO 6oJee komIakTHOH? EcJu 6bi ctpyktypbi Foo coXpaHraJincb (hanpимер, KJII Foo B nasmrtu), npuJIOxение Tpe6ObaJIO 6bi JJra cE6a JOnOJnH- TeJbHbHbI 66bEM nasmrtu. Ho Jaxke ecJu 6bi onu u he coXpaHraJincb, TO BoSHHKaJiu 6bI Jpyrue 3dcketbI. Hanpимер, ecJu 6bI Mbi vacTO cO3JaBaJiu nepemehhie Foo u onu pasmeiaJincb 6bI b Kyve (oc6cyJIM JTy konIeIInIIO b cJeJyIouIem pa3JeJe), pesyJbTaTOM 6bIIO 6bI 6oJee vacTOe BbInOJHeHue c6opku Mycopa, BJnJIOIeE Ha o6uJyIO npou3bOJuteJbHocbTb npuJIOxehua.
Iobopra o npou3bOJuteJbHocTt, cJeJyET yJOMaHytb eIe OJInH 3dcket npOCTpaH- ctBHeHouI JokauJn3aIuI. PaccмотpUM cJeJyIouIyIO qyHKIInIO, sum, kotopaa npHnI- maet B kavectbe apryMeHTa cpe3 ctpyktyp Foo. JTa qyHKInIa nItepupyet no cpe3y u cyMmupyet bce noJra i (int64):
func sum(foos []Foo) int64 { var s int64 for i := 0; i < len(foos); i++ { s += foos[i].i CymMupoBaHue Bcex noneii } return s }
Поскольку за срезом стоит резервный массив, это означает размещение структур. Foo в смежных ячейках памяти непрерывно.
Рассмотрим резервный массив для двух этих версий Foo и проверим две кэш- линии данных (128 байт). На рис. 12.25 каждая серая полоска представляет собой 8 байт данных, а темные полоски показывают, где размещены переменные i (то есть поля, которые мы хотим просуммировать).
Puc. 12.25. Поскольку каждая кэш- линия содержит в себе большое число переменных i, итерация по срезу Foo требует меньшего общего количества кэш- линий
Как мы видим, в последней версии Foo каждая кэш- линия используется более эффективно, поскольку содержит в среднем на 33 % больше переменных i. Следовательно, итерация по срезу Foo для суммирования всех элементов типа int64 более эффективна.
Подтвердим это наблюдение бенчмарком. Если мы запустим два бенчмарка с двумя версиями Foo, взяв срез из 10 000 элементов, то версия, использующая последнюю структуру Foo, будет примерно на 15 % быстрее (на моей машине). Это 15- процентное увеличение скорости достигнуто всего лишь за счет того, что мы изменили положение одного поля в структуре.
Учитывайте принцип выравнивания данных. Как мы видели в этом разделе, реорганизация полей структуры Go таким образом, что они оказываются отсор- тированными по размеру в порядке убывания, предотвращает появление заполнений. А это означает, что структуры оказываются более компактными, что может приводить к различным оптимизациям, например к уменьшению частоты обращения к GC и к лучшей пространственной локализации.
В следующем разделе обсудим фундаментальные различия между стеком и кучей, а также то, почему эти различия важны.
# 12.5. OWWKA #95: HE NOHMATb PA3JNUYИ MEXAY CTEKOM U KYHEY
12.5. OWWKA #95: HE NOHMATb PA3JNUYИ MEXAY CTEKOM U KYHEYB Go nepemenная может быть размещена в стеке или в куче. Эти два типа памяти принципиально различаются, что может сильно повлиять на работу приложений, интенсивно использующих данные. Рассмотрим концепции и правила, которым следует компилятор, чтобы решить, где размещать переменную.
# 12.5.1. CTEK u KYHA
12.5.1. CTEK u KYHAДля начала обсудим различия между стеком и кучей. Стек — это память, используемая по умолчанию. Это структура данных, организованная по принципу «последний пришел — первый ушел» (LIFO — Last- In, First- Out), в которой хранятся все локальные переменные для конкретной горугины. Когда горугина запускается, для нее резервируется 2 Кбайт памяти в качестве пространства стека (этот размер со временем менялся и может измениться снова) и в виде смежных друг с другом ячеек. Но этот размер во время выполнения не фиксирован: он может увеличиваться и уменьшаться по мере необходимости (хотя стек всегда остается в памяти в виде непрерывной последовательности ячеек, тем самым соблюдается принцип локальности данных).
Kогда в Go определяется какая- то функция, то создается фрейм стека, представляющий собой непрерывную область в памяти, доступ к которой может получить только эта функция. Рассмотрим пример, чтобы понять эту концепцию. В примере функция main выводит результат функции sumValue:
func main(){a := 3b := 2c := summValue(a, b) BbIsOB yHKuM sumValueprintln(c) BbIsOB pesybnata}//go:noinline 0TnIovaeM bCTpaBaNHe (inlining)func sumValue(x,y int) int{z := x + yreturn z}
OTmeuy Jbe BeIu. Bo- nepBbix, Mbi ncIOJIb3yEM bCTpOeHnyIO yHKuHIO printIn BMeCTO fmt.PrintIn, kotopar npHuydnteJbHo pa3MeCTUNa 6b1 nepemENHnyO c B KyUE. Bo- BTOpBbIX, OTKJIIOyAEM bCTpaBaNHe B cJIyVae c yHKHuei sumValue; B nPoTUBHOM
c. Jyvaee Bb130B yyHKkHHe npou3ouJLet (o6cyJUM oco6ehnoctu bctpaubahua B pa3- deJee, nocBraueHnOM pa36opy ounioKu #97 (He noJaraTaBcA Ha bctpaubahue)).
Ha puc. 12.26 nokasaHo coctoHnue cTeka Ha nIare pe3epBupobahua B nIamrtu mecta noJ a u b. NockoJbky bma1oJnHJIacb yyHKkHn main, To JJIa Hee 6bJI co3Jah qpeuM cTeka. Jbe nepemeHnHe, a u b, 6bJIu pa3Me1Heb b cTeka bhyTpu qpeuMa. Bcem coxpahenHbM nepemeHbM cootBeteCTbYIOT peaJIbHbIe (Jou1yct1mblIe) aJpeca, JTO O3HaYaeT, YTO Ha HIX MOXHO CCbJIaTbCra I K HHM MOXHO IOJIyHHTb JOCTyI.

[ImageCaption: Puc. 12.26. NepeMeHbHbIe a u b pa3Me1Heb b cTeka]
Ha puc. 12.27 nokasaHo, YTO npoucxoJut bhyTpu yyHKkHn sumValue BIIJOTb J0 onepaTopa retun. Cpeza bH11oJIHeHnH Go co3Jaet HOBbII bpeuM cTeka KaK YaCTb tekY11ero cTeka rOpyTnH. x u y pa3Me11a1oTcA bMeCTe c z B 3r1OM bpeuMe.

[ImageCaption: Puc. 12.27. Bb130B sumValue npubOJUT K CO3JaHnIO B cTeka HOBoro qpeuMa]
B npedblay11em qpeuMe cTeka (cootBeteCTbYIO1em yyHKkHn main) coJepxatcra aJpeca, kotopbIe no- npexhemy cHHTaIOCTcA JO1yCTmblIMH. MbI He MOXEM IOJIyHHTb JOCTyI K a u b Ha1pHmKyIO; Ho eCJIu y HaC 6yJIeT yKa3aTeJIb, Ha1pHmEp, Ha a, TO OH
toke будет действительно. Mbi всkope o6cydim hekotorpie bonpocbi, cbr3анныe c ykasateriами.
Iepedem k noclednei hистpykiiu yhkiin main, to ects k uary, ha kotopom bbi- nonhreterca printin. Ha 3rom uare yke npousoilel bixoJ us qyhkiiu sumValue - - u vto xe npoucxodut c cootbectsbyioiium dpeumom cteka (puc. 12.28)?
Puc. 12.28. Opem ctek a yhkiiu sumValue 6bl ydanen, ha eTo mecto 6bln sanucahb nepemenhbie, othocrauec k main. 3decb nepemenhhar x cterpta u nobepx hee 6blna sanucaha nepemenhhar c, b to bpeMra kak nepemenhbie y u z bce eupe coxparhrotr a hamarin, ho tenepb ctaHn HeJocryhNbl
Ppeim ctek a yhkiiu sumValue he 6bl noJHOCTbIO cTePT H3 namrtn. Korza npo- ucxodut Bosbpat H3 yhkiiu, Go he tpatut bpeMra ha ctupahine nepemenhbx d3r ocb6oxdenua mecta. Ho k 3um ctabiium yke heyxhblm npemenhblm 6o7b1ue he1b3a nonyvutb doctyn, a kora a cteke bbldeJreterca mecto nol hObblie nepemenhbie d3r podutetJbckou yhkiiu, npoucxodut ux sanucb nobepx toro, vto haxoduJocb b cootbectsbyioiux aneikax. B hekotorpom cMbicJe ctek oHnnaecra cam - oh he tpe6yet kakoro- to donoJnhteJbHoro mexanH3ma, hanpumep c6opnuka mycopa.
BheceM he6oJb111oe n3meHne, vTO6bl nonHrbs opranHvHnH, nHcyu1ue cteky. Bme- cTO toro vTO6bl bO3bpa1aTb nepemenhyo Tnna int, nyctb yhkiiu bephet ykasaterb:
func main(){a := 3b := 2c := sumPtr(a, b)println(*c)}//go:noinlinefunc sumPtr(x, y int) *int { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - return &z}
Переменная с в функции main теперь имеет тип *int. Перейдем непосредственно к последней инструкции println, следующей за вызовом sumPtr. Что произойдет, если место под переменную z останется выделенным в стеке (что быть не должно) (рис. 12.29)?

[ImageCaption: Puc. 12.29. Переменная c указывает на адрес namяти, который более не является допустимым]
Eсли бы переменная c ссылалась на адрес переменной z, a z была размещена в стеке, то возникла бы серьезная проблема. Этот адрес был бы недопустим, фрейм стека main продолжал бы расти и стирать переменную z. По этой причине организации памяти по принципу стека недостаточно, и нужен другой тип памяти — куча.
Куча — это пул памяти, совместно используемый всеми горугинами. На рис. 12.30 каждая из трех горугин, G1, G2 и G3, имеет собственный стек. Но они все пользуются одной и той же кучей.
Puc. 12.30. Три горугины имеют собственные стеки, но совместно используют одну общую кучу
В предыдущем примере мы видели, что переменная z не могла быть размещена в стеке, поэтому она была помещена в кучу. Если компилятор не может доказать,
что переменная не используется после возврата из функции, то место для переменной выделяется в куче.
Почему это важно? Занем нужно понимать разницу между стеком и кучей? Потому что это влияет на производительность.
Как мы уже говорили, стек очищается сам, и к нему обращается только одна горутина. И наоборот, куча должна очищаться внешней системой — сборщиком мусора. Чем больше происходит резервирований места в куче, тем большее давление оказывается на сборщик мусора. Когда он задействован, то использует 25 % доступной мощности CPU и может вызывать задержку в миллисекунды в фазе «stop the world» (фаза, когда приложение ставится на паузу).
Важно понимать, что выделение места в стеке происходит в среде выполнения Go быстрее, потому что оно весьма тривиально: указатель ссылается на следующий доступный адрес памяти. И наоборот, выделение места в куче требует больше усилий для поиска нужного места и, следовательно, занимает больше времени.
Чтобы проиллюстрировать эти различия, сравним производительность sumValue и sumPtr:
var globalValue int var globalPtr \*int func BenchmarkSumValue(b \*testing.B){ b.ReportAllocs() Coofiaetca o bbldeninu mecta B kyve var local int for i := 0; i < b.N; i++ { local $=$ sumValue(i, i) Poousodится cymmupobanue shavenni } globalValue $=$ local } func BenchmarkSumPtr(b \*testing.B){ b.ReportAllocs() Coofiaetca o bbldeninu mecta B kyve var local \*int for i := 0; i < b.N; i++ { local $=$ sumPtr(i, i) Poousodится cymmupobanue c unonbsoanuey khasatenel } globalValue $=$ \*local }
Eсли мы запустим эти бенчмарки (и по-прежнему запретим встраивание), то получим следующие результаты: BenchmarkSumValue- 4 992800992 1.261 ns/op 0 B/op 0 allocs/op BenchmarkSumPtr- 4 82829653 14.84 ns/op 8 B/op 1 allocs/op
Функция sumPtr примерно на порядок медленнее, чем sumValue, и это прямое следствие использования кучи вместо стека.
ПРИМЕЧАНИЕ Этот пример показывает, что использование указателей, чтобы избежать копирования, не всегда приводит к более быстрому выполнению; все зависит от контекста. До сих пор в этой книге мы обсуждали значения и указатели только через призму семантики: использование указателя, когда значение [какой-либо сущности] должно быть доступно из разных точек кода. В большинстве случаев этому правилу надо следовать. Также имейте в виду, что современные профессоры чрезвычайно эффективно совершают копирование данных, особенно в пределах одной кэш-линии. Избегайте преждевременной оптимизации и фокусируйтесь на удобочитаемости и семантике.
Следует также отметить, что в предыдущих бенчмарках мы вызывали функцию b.ReportAllocs(), которая отображает резервирование места в куче (резервирования в стеке не учитываются):
- B/op: сколько байтов резервируется при выполнении операции;- allocs/op: сколько актов резервирования памяти происходит за операцию.
Обсудим теперь условия переноса переменной в кучу (размещения ее в куче).
# 12.5.2. Эскейп-анализ
Эскейп-анализ (или escape-анализ) — это работа компилятора, в процессе которой он решает, где должна быть выделена переменная: в стеке или в куче. Обсудим основные правила.
Когда резервирование места не может быть выполнено в стеке, оно выполняется в куче. Несмотря на то что это звучит как некое упрощенное правило, его важно помнить. Например, если у компилятора нет надежных данных о том, что после возврата из функции ссылки на переменную не будет, то эта переменная размещается в куче. В предыдущем разделе именно так было в случае с функцией sumPtr, возвращавшей указатель на переменную, созданную в рамках этой функции. Совместно используемые переменные, определенные в функции, которая вызывается из основного фрагмента кода (sharing up), отправляются в кучу.
Ho как быть в ином случае? Что, если мы принимаем указатель, как в примере:
func main(){ a :=3 b:=2 c := sum(&a,&b) println(c) } //go:noinline func sum(x,y \*int) int{ 0yHKuAa nPHHMAaT B kAeCTBe BOXx apyMeHTOB yKa3aTeN return \*x + \*y }
sum принимает два указателя на переменные, созданные в функции main. Нарис. 12.31 показано текущее состояние стека на шаге выполнения оператора return в функции sum.
Puc. 12.31. Переменные x и y ссылаются на допустимые адреса
Hecмотря ha to vto ohm aBraorca actbIa pyroro ppea cteka, nepemehbie x u y ccsbIaotcr ha onyctumbie apeca. CledobateIbHo, a u b he nyxho onnpaBraTb B kuy, onu moryt octabatbcr a cteke. Kak npaBunio, cobmectho nciObI3vembie nepemehbie, onpeJeJenHbie bo qparmenite ochobHoro koJa (sharing down), octaIotcr a cteke.
Huxke npивeJenbi apyTHe cJyvau, kOrJa nepemehnaa moXet BbIeJIaTbcr a kYye:
- Iro6aJbHha nepemehnaa - notomy vTO k Heu Moryt o6paHnatcra HeckOJbKO ropyTHH.
- Yka3aTeJIb, onnpaBraHemblu B kaHaJI:
type Foo struct{ s string } ch := make(chan \*Foo, 1) foo := &Foo{s: "x"} ch <- foo
3десь foo отправляется в кучу.
- **Переменная, на которую ссылается какое-то значение, отправляемая в канал:
type Foo struct{ s *string } ch := make(chan Foo, 1) s := "x" bar := Foo{s: &s} ch := bar
Поскольку Foo ссылается на переменную s через ее адрес, в таких ситуациях она отправляется в кучу.
- **Если локальная переменная слишком велика для размещения в стеке.**
- **Если размер локальной переменной неизвестен. Например, s := make([] int, 10) может не размещаться в куче, а вот s := make([]int, n) может размещаться, поскольку ее размер зависит от значения переменной.**
- **Если резервный массив среза переопределяется с помощью append.**
Xотя этот список и дает некоторое понимание того, как компилятор принимает решения, он не исчерпывающий и может измениться в будущих версиях Go. Чтобы проверить какое-либо предположение по решению компилятора, используйте флаг - gcflags:
$\Phi$ go build - gcflags "- m=2" ./main.go:12:2: z escapes to heap:
3десь компилятор сообщает, что переменная z будет отправлена в кучу.
Понимание фундаментальных различий между кучей и стеком очень важно для оптимизации Go- приложений. Как мы видели, резервирование места в куче сложнее для среды выполнения Go. Кроме того, при этом для очистки данных нужна внешняя система со сборщиком мусора. Но управление кучей в некоторых приложениях, интенсивно использующих данные, может тратить- ся до 20- 30 % общего времени CPU. С другой стороны, стек очищается сам и он локальный для одной группы, что ускоряет процессы выделения места в памяти. Поэтому затраты на оптимизацию этих процессов могут принести большую отдачу.
Правила эскейп-анализа важны и для написания более эффективного кода. «Sharing down» остается в стеке, тогда как «sharing up» размещается в куче. Это предотвращает некоторые ошибки, например преждевременную оптимизацию, когда мы хотим возвращать указатели, «чтобы избежать операций копирования».
Сначала сфокусируйтесь на удобочитаемости и семантике, а затем на оптими- зации, если это нужно.
В следующем разделе поговорим, как уменьшить выделение памяти.
# 12.6. ОШИБКА #96: НЕ ЗНАТЬ, КАК СОКРАТИТЬ ЧИСЛО ВЫДЕЛЕНИЙ ПАМЯТИ
Сокращение выделений памяти (allocations) - распространенный метод оптими- зации для ускорения приложений Go. В этой книге я уже рассмотрел несколько подходов, сокращающих число выделений памяти в куче:
- Неоптимизированное объединение строк (см. ошибку #39): использование strings. Builder вместо оператора +.- Бесполезные преобразования строк (см. ошибку #40): по возможности избегайте преобразования []byte в строки.- Неэффективная инициализация срезов и карт (см. ошибки #21 и #27): предварительное резервирование места под срезы и карты, если их длина уже известна.- Выравнивание структур данных для уменьшения их размера (см. ошибку #94).
Обсудим три распространенных подхода к сокращению числа выделений:
- изменять API;- рассчитывать на оптимизации компилятора;- использовать такие инструменты, как sync.Pool.
# 12.6.1. Изменения API
Первый вариант - тщательно поработать с API, который мы предоставляем. Возьмем в качестве примера интерфейс io.Reader:
type Reader interface { Read(p []byte) (n int, err error) }
Метод Read принимает срез и возвращает количество прочитанных байтов. Теперь представьте, если бы интерфейс io.Reader делал обратное: передавал
значение int, которое бы задавало, сколько байтов нужно прочитать, и воз- вращал срез:
type Reader interface { Read(n int) (p []byte, err error) }
C точки зрения семантики тут нет ничего плохого. Но тогда возвращенный срез автоматически будет размещен в куче. Это случай sharing up, описанный в предыдущем разделе.
Чтобы предотвратить автоматическое размещение среза в кучу, разработчики среды Go использовали подход sharing down. Таким образом, вызывающая сторона должна предоставлять срез. Это не обязательно означает, что он не будет размещен в куче: компилятор может решить, что этот срез не может оставаться в стеке. Но обрабатывать его должна вызывающая сторона, а не ограничение, обусловленное вызовом метода Read.
Иногда даже небольшое изменение в API может дополнительно повлиять на процесс резервирования места в памяти. При разработке API помните об описанных в предыдущем разделе правилах эскейп-анализа и, при необходимости, используйте - gcflags для понимания решений, принятых компилятором.
# 12.6.2. Приемы оптимизации компилятора
Oдна из целей компилятора Go — по возможности оптимизировать код. Вот пример с картами.
В Go мы не можем определить карту, используя срез в качестве типа ключа. В некоторых случаях, особенно в приложениях, выполняющих ввод/вывод, можно получать данные типа []byte, которые бы хотелось взять в качестве ключа. Но мы должны сначала преобразовать их в строку, поэтому напишем такой код:
type cache struct { m map[string]int Coadprknt карт ctpok } func (c \*cache) get(bytes []byte) (v int, contains bool) { key := string(bytes) Pneo6pa3oBAnHe H3 Tnna []byte B Tn1 string v, contains $=$ c.m[key] 3anpc k kapte no ctpokobomy 3havenuo return }
Поскольку функция get получает срез []byte, мы преобразуем его в строку key для запроса к карте.
Но компилятор Go продолжает определенную оптимизацию в случае запроса к карте с использованием string(bytes):
func (c *cache) get(bytes []byte) (v int, contains bool) { v, contains = c.mstring(bytes)] ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ return}
Несмотря на то что код почти такой же (мы вызываем string(bytes) напрямую вместо вызова переменной), компилятор при этом будет избегать преобразования байтов в строку. Следовательно, вторая версия будет быстрее первой.
Этот пример показывает, что две версии функции, которые выглядят одинаково, могут привести к различным кодам ассемблера, получающимся в результате работы компилятора Go. Для оптимизации приложения следует знать о возможных приемах оптимизации, заложенных в компилятор. Важно следить за будущими релизами Go и проверять, добавляются ли в него новые особенности, направленные на оптимизацию.
# 12.6.3. sync.Pool
Еще один способ повысить производительность приложений, сократив вы- деления памяти, — использовать sync.Pool. Важно понимать, что sync.Pool — это не кэш: для него нет фиксированного размера или максимальной емкости, которые можно устанавливать. Это пул общих объектов, чтобы можно было их переносользовать.
Допустим, мы хотим реализовать функцию write, которая получает io.Writer, вы- зывает какую-то функцию для получения среза []byte, а затем записывает его в io. Writer. Код может выглядеть так (для наглядности опускаем обработку ошибок):
func write(w io.Writer) { b := getResponse() ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ b := getResponse() ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ ➔ 3ались в io.Writer}
Здесь getResponse при каждом вызове возвращает новый срез []byte. Что, если мы хотим уменьшить число выделений памяти, переносользовав этот срез? Предположим, что все ответы имеют максимальный размер 1024 байта. В этой ситуации можно применить sync.Pool.
Для создания sync.Pool требуется фабричная функция func()any (рис. 12.32). sync.Pool предоставляет два метода:
Get()any — для получения объекта из пула; Put(any) — для возвращения объекта в пул.
func factory() any { return }
Puc. 12.32. Onpedejenhe dyhkuin factory, kotorar cosadet hObbii o6bekt npu kaxqom hOBom o6paueHnii k c6e
Ipu bInOJIHeHnI Get nI6o co3Jaetcr HOBbii o6bekt, eCJI nYJI nYCT, nI6o o6bekt ucIOnIb3yetcr IOBtorpHo. IocJIe ucIOnIb3OBaHnIa o6bekta ero MOxHO IOMeCTUTb OO- patHO b nYJI c IOMOIIbIb Put. Ha puc. 12.33 nOKa3aH nIpMEp c paHee OnpeDeJIeHHOu factory BMecTe c Get, kOraa nYJI nYCT, a Takxke c Put u Get, kOraa nYJI He nYCT.
Puc. 12.33. Get nI6o co3Jaet HOBbii o6bekt, nI6o BO3BpaUaet bbl6paHHbii n3 nYna. Put nOMeUaet o6bekt B nYJI
Kогда объекты удаляются из пула? Для этой операции не предусмотрено никак- кого специального метода — она передается сборщику мусора. После каждого обращения к нему объекты из пула уничтожаются.
Допустим, что мы обновим функцию getResponse для записи данных в заданный срез вместо создания их заново. Тогда реализуем другую версию метода write, которая использует пул:
var pool $=$ sync.Pool{ New: func() any{ Cоздanue nyna u задanue apaupuHouy hynKun return make([]byte, 1024) }, func write(w io.Writer) { buffer : $=$ pool.Get().([]byte) Nonyueue []byte us nyna un co3aHue ero buffer $=$ buffer[:0] C6poc 6ypepa defer pool.Put(buffer) 3aHecne6ypepa opratho B nyn getResponse(buffer) 3anucb oTBeta B заданныy 6yep $- ,- =$ w.Write(buffer) }
Мы определяем новый пул с помощью структуры sync. Pool и задаем фабрич- ную функцию для создания нового []byte длиной в 1024 элемента. В функции write делается попытка получить один буфер из пула. Если пул пуст, функция создает новый буфер. В противном случае она выбирает произвольный буфер из пула и возвращает его. Одним из важных шагов является сброс буфера с помощью buffer[:0], так как этот срез, возможно, уже использовался. Затем мы откладываем вызов Рит, чтобы поместить срез обратно в пул.
В этой новой версии вызов write не приводит к созданию нового среза []byte для каждого вызова. Вместо этого можно переносользовать существующие выделенные срезы. В худшем случае, например после сборки мусора, функция создаст новый буфер. При этом амортизированная стоимость выделения снижается.
Если приходится часто выделять место в памяти под большое число объектов одного типа, можно рассмотреть применение sync.Pool. Это набор временных объектов, которые помогут предотвратить многократное пересоздание данных одного и того же типа. Кроме того, sync.Pool безопасен для одновременного использования несколькими горутинами.
Далее обсудим концепцию встраивания.
# 12.7. OWWIYKA #97: HE NOJAFATbCR HA BCTPAUBAHUE
Termin bcmpaueahue (inlining) oshavaeet samehy bbyoba yynkium ee teJom. Bctpaue banue bblnoJInHEeTcA KOMIIJIATropAMH abTOMaTHeCKH. BCTpauebAHue TAKKE MOxEH 6bIb cInOco6OM OITIMMIsaIINH OIPeDEJIeHbIX NYTEH KOJa HPIJIOXeHHA.
PaccMOTpHM npiHep bCTpauebAHHc nOMOIIbIO nPOCTOH DYNKIIH HsM, KOTOPaH cYMMHpyET DBa 3HavEHH H HIIa int:
func main(){ a := 3 b := 2 s := sum(a, b) println(s) } func sum(a int, b int) int{ BCTpauebAHHe yynkH return a + b }
Eclu Mbl 3anyctHm go build c napametpom - gcflags, to y3HaeM peHHeHHe, npiHHToe KOMIIJIATropOM OTHOcHTEJIbHO O6pa6OTKH yynkHIN HsM:
go build - gcflags "- m=2". /main.go:10:6: can inline sum with cost 4 as: func(int, int) int { return a + b } ... /main.go:6:10: inlining call to sum func(int, int) int { return a + b }
KOMIIJIATrop peHIIJI BCTPOHTb Bb3OB sum, H npebblAyHHiK KOI 6bl 3ameHeH CJIe- AYIOHUM:
func main(){ a := 3 b := 2 s := a + b 3ameHa bbl3Oba yynkHIN HsM ee teJOM println(s) }
BCTpauebAHue pa6OTaTc TONbKO JJIa yynkHINH c ONpeDEJIeHbHM yPOBHeM CJIoxHOcTH, TAKKE H3BeCTHbIM KAK 6IOXeH bcmpauebAHHA (inlining budget). YHae KOMIIJIATrop COO6HHT, YTO yynkHINH CJIHIIKOM CJIoxHa JJIa TOrO, YTO6bI OKa3aTbC HbCTPOeHHOH:
./main.go:10:6: cannot inline foo: function too complex: cost 84 exceeds budget 80
Bcropaивание umeet dba npeimyuectba. Bo- nepbBix, oho y3a/ret oBepxeBb Ha bbi30B hyHKiiu (xota эти oBepxeBb 6b/ru ymeHbHbHb, hauHbHac BepcHn Go 1.17, a takxe c nOmoHbIb coTJaiHcHnI O bH30BaX Ha OchObe peHCTPbOB). Bo- BTOpBbIX, 3TO nO3BOJIeT KoMIIJIrTOpy nepeHTH K aJbHHeHIIHm ONTnMHHaHbHbM. HaIIpHMEp, nOcJIe BcTpaивание hyHKiiH KOMIIJIrTOp MOxKet peHHTb, 4TO nepeMeHbHa, KoTOpyIO OH H3HaVaJIbHO JOLxKeH 6b/JI pa3MeCTHTb B KyYE, MOxKet OCTaTHcB b cTxeK.
BosHHKaet bonpoc: eCJI 3Ta ONTmHbHaHn IpuMeHHeTcra KoMIIJIrTOpOM aBTOMaTHu- vecku, noyemy HaM bOo6HHe o6 3TOM HYxHKO 3a6OTHTbCra? OTbTeK KpoeTcra B KoHHeHIIHn BcTpaивание B cepeJHHe cTeka (mid- stack inlining).
Bcropaивание B cepeJHHe cTeka - 3TO bCTpaивание hyHKiiH, KoTOpbE bH3bIBaIOT Jpyrue hyHKiiH. LO BepcHn Go 1.9 3JIa BCTpaивание paCsmATpBbJIHCb TOJIbKO JnCTO3bIe hyHKiiH. TeNePb, 6JIaroJaPra BCTpaивание B cepeJIHe cTeka, MOxHO bCTpOHTb H cJIeJIyIOIIyIO hyHKiiHIO fOo:
func main(){ foo() } func foo(){ x := 1 bar(x) }
IOcKOJIbKy hyHKiiH 6Oo He cJIHIIKOM cJIOxHHa, KoMIIJIrTOp MOxKet ee BCTpOHTb:
func main(){ x := 1 3aMeHn Ha TeIO hyHKiiH foo bar(x) }
BJIaroJaPra BCTpaивание B cepeJIHe cTeka Go- pa3pa6OTyHkH KOrYT ONTmHbHbpoBaTb npuJIoxKeHHe, ucIOnJIb3yA KoHHeHIIHIO BCTpaивание 6bIcTpOro nIyH (fast- path inlining), 4TO6bI pa3JIHHTb, kakHe nIyH 6bIcTpBbIe, a kakHe MeJIcHbHbIe. PaCsmOTpHm npuMEp peaJIH3aIIHn IyNC. MuTeX, 4TO6bI nOHHTb, kak 3TO pa6OtaeT.
LO nO3BJIeHHe BCTpaивание B cepeJIHe cTeka peaJIH3aIIHn MeTOJIa LOck 6b/JIa cJIeJIyIOIIeH:
func (m \*Mutex) Lock(){ if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { // MbHTEKc He 3a6/IOKHpOBaH if race.Enabled { race.Acquire(unsafe.Pointer(m)) } return
}// Mbotekc yxe sa6локupobah var waitStartTime int64 starving := false awoke := false iter := 0 old := m.state for{ // Kaaan- to cnoHuaa noHka } if race.Enabled{ race.Acquire(unsafe.Pointer(m)) } }
Mbi moxem bblJelnItb Jba ochOHHbIX nyTH:
ecJiu Mbotekc he sa6локupobah (atomic.CompareAndSwapInt32 BosBpaIIaet true),sto 6bIcTpbln nyTb; ecJiu Mbotekc yke sa6локupobah (atomic.CompareAndSwapInt32 BosBpaIIaet false), oTO MeJlenHbI1 nyTb.
Ho hesaBucuMo ot bblpaHHoro nyTH, yyHKlIaH he Moxet 6bITb BcTpOeha H3- 3a ee cJIOxHOH cTpyKTyPb. JbIa bCTpaIBaHHaH B cepeJInHe cTeka HyXHO cJelIaTb TakoH peDakTOpHHr MetOJaa Lock, vTO6bi MeJlenHbI1 nyTb HaxOJUICa BHyTpu KOnKpetrHOH bYHKlIuu:
func (m \*Mutex) Lock(){ if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) { if race.Enabled{ race.Acquire(unsafe.Pointer(m)) } return } m.lockSlow() 1yTb,Ha kotopom Mbotekc yke sa6локupobah } func (m \*Mutex) lockSlow(){ var waitStartTime int64 starving := false awoke := false iter := 0 old := m.state for{ // } if race.Enabled{ race.Acquire(unsafe.Pointer(m)) } }
Благодаря этому изменению метод Lock можно встроить. Преимущество заключается в том, что мыотекс, который еще не заблокирован, теперь блокируется без затрат времени на вызов функции (скорость повышается примерно на 5 %). Медленный путь, когда мыотекс уже заблокирован, не изменился. Ранее для выполнения этой логики требовался один вызов функции. Теперь же необходимость только в одном вызове функции остается, но на этот раз для LockSlow.
Эта техника оптимизация заключается в различии между быстрыми и мед-ленными путями выполнения. Если быстрый путь может быть встроен, а мед-ленный путь нет, можно выделить медленный путь в отдельную функцию. Если бюджет встраивания не превышен, функция является кандидатом для встраивания.
Встраивание — это не просто некая невидимая оптимизация кода, которую выполняет компилятор и о которой не нужно думать. Если мы понимаем, как работает встраивание и как получить доступ к решению компилятора на этот счет, то сможем оптимизировать код методом встраивания быстрого пути. Раз- мещение медленного пути внутри отдельной функции предотвращает вызов функции, если выполнение идет по быстрому пути.
В следующем разделе обсудим общие инструменты диагностики, которые помогут понять, что нужно оптимизировать в Go-приложениях.
# 12.8. ОШИБКА #98: НЕ ИСПОЛЬЗОВАТЬ ДИАГНОСТИЧЕСКИЙ ИНСТРУМЕНТАРИЙ GO
В Go есть несколько отличных инструментов диагностики, которые помогут понять, как работает приложение. В этом разделе основное внимание уделяется двум самым важным: профилированию и трассировщику. Они должны входить в набор инструментов любого Go-разработчика, интересующегося оптимизацией. Сначала обсудим профилирование.
# 12.8.1. Профилирование
Профилирование дает возможность проанализировать, как выполняется при- ложение. А это, в свою очередь, позволяет решать проблемы с производительно- стью, обнаруживать конфликты, утечки памяти и многое другое. Информация может быть собрана с помощью нескольких профилей:
CPU - определяет, на выполнение чего приложение затрачивает время. Goroutine - выдает информацию о трассировке стека текущих горутин. Heap - выдает информацию о выделении памяти в куче, чтобы отследить текущее использование памяти и проверить, нет ли возможных утечек памяти. Mutex - выдает информацию о конфликтах между блокировками, чтобы отслеживать поведение используемых в коде мыотексов и узнавать, не тратит ли приложение слишком много времени на блокировку вызовов. Block - показывает места, где горутины блокируются, ожиная синхронизационных примитивов.
Профилирование осуществляется через инструментацию с помощью инструмента, который называется профилировщиком: в Go это pprof. Разберемся, как и когда имеет смысл запускать pprof, и обсудим наиболее важные типы профилей.
# 3anyck pprof
Ectb heckoIbko cInocOoB SanycTUTb pprof. Hanpимер, ncOnIbSOBaTb naket net/ http/pprof JIA nepeJaYH aHbIX npOфиJupOBaHnIa yepe3 HTTP:
package main import( "fmt" "log" "net/http" _,net/http/pprof" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - func main(){ http.HandleFunc("/", func(w http.ResponseWriter, r \*http.Request){ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - fmt.Fprintf(w, "") Предоставление }) koheuHouI Toyku HTTP log.Fatal(http.ListenAndServe(":80",nil)) }
HMIoprr net/http/pprof npHbOJHT K no6OyHOMy 3pDekTy, KoTOpblI nO3BOJIeT nOJIyHHTb URL- aJpec pprof - http://host/debug/pprof. O6paTUTE bHIMaHHe, YTO 3aIyck pprof 6e3onacen Jaxe B npOJaKHe (htps://go.dev/doc/diagnostics#profiling). IppuJiu, bJияIOHHe ha npOu3bOJIteJIbHOcTb, hanpимер npOpHJIupOBaHHe CPU, no yOMJIaHHe He SanyHHeH H He pa6OraIOT nOCTOrHHO: OHI aKTHbHpyIOTcA ToJIbKO Ha onpeJeJIeHHoe BpeMz.
Tenepb, kOrJa Mbl yBuJeJIu, kak npEJocTaBHb KoHeyHyo Toyky pprof, o6cyJIM Hau6OJee pacInpOCTpaHHeHHHe npOpHJIu.
# PpouunpoBAnue CPU
Pa6ota npopunpoBnHka CPU ochobaha ha noJyvenin cnHnJab onepaunonHouCnCnCnCn. KoTa OH aKTHbupOBaH, npuJoxHeHe yKaBaBaHeT OC Ha To, YTO ero HaJIO npepbIBaTb KaKaJbIe 1O MHJIuCeKyHd (sto nHtrepbJI no yMoJIyAnHIO) c nOMOJIbIO cnHnJIa SIgPROF. KoTa npuJIoxHeHe nOJIyvaet SIgPROF, oHO npuOCTaHaBaJIbAeT TeKyIeee BbIIOJIHeHeHe u nepeJaet yInpaBaHeHe npopununpoBnHkY. ToT co6upaeT pa3JIHnHeHe JaHHHe: HHpOpMaIIO O TeKyIHeM BbIIOJIHeHeHnI ropyTnHbI u cTaTnCTHKY BbIIOJIHeHeHnI, K KOTOpOH Mbl MoXeM nOJIyHnTb JoCTYn. 3aTeM OH OCTaHaBaJIbAeTcA, u BbIIOJIHeHeHe BO3O6HOBJIeTcTcA JO CJIeJIyIOIIeTO SIgPROF.
MbI MoXeM nOJIyHnTb JoCTYn K KoHeHeHnI ToYke /debug/pprof/profile, YTO6bl aKTH- bupOBaTb npopununpoBaHHe CPU. При этом по yмолyнанию BbIIOJIHeHeTcя npo- филировaHHe CPU в TeчeHeHe 30 секунд: в это время приJIoxHeHe npepbIBaTcя KaKaJbIe 1O MHJIuCeKyHd. O6paTute bHимaHHe, YTO MoXKO u3MeHeHTb эти JBa 3HaчeHnI no yмолyнанию c nOMOJIbIO napaMeTpa seconds, nepeJaB KoHeHeHnI ToY- ke, KaK JOJITO JOJIXHO npoJOMXaTbcя npopununpoBaHHe (HaIpnMeP, /debug/pprof/ profile?seconds=15), u ИзмHeHnI частоту npepbIBaHnI (cJIeJIaTb сe JaXe MeHee 1O MHJI- JuncKyHd). Ho в бOJIbIInHcTbe cJIyvaeB 1O MHJIuCeKyHd JOJIXHO 6bITb JoCTaTOyHIO, u K yMeHbIIeHnIO этOTO3HaчeHnI (YTO O3HaчaeT yBaJIyHeHe чaCTOTbI npepbIBaHnI) cJIeJIyIeT nOJIXOJIHTb c OCTOpOHeHcTbIe, YTO6bl He nOJIyHnTb HeTaTHbInHe Ha npoXHeo- JHTeJIbHocTb. Чeре3 3O секундMbI 3aгруXaeM JaHHHeHe Из npopununpoBnHka CPU.
# PpouunpoBAnue CPU BO BpeMя 6eHymapKHHra
MoxHO BKJIouHb npopununpoBbIHK CPU, ucnOJIb3Ya флaг - cpuprofIe, hanpИмeр, npu 3aIycke 6eHymapKa:
$\Updownarrow$ go test - bench $=$ .- cpuprofile profile.out
Эта команда создает файл того же типа, который можно загрузить через /debug/ pprof/profile.
От этого файла можно перейти к результатам с помощью go tool:
$\Updownarrow$ go tool pprof - http=:8080 <file>
Эта команда открывает веб-интерфейс, показывающий график вызовов. На рис. 12.34 приведен пример, взятый из некоторого приложения. Жирные стрелки показывают «горячий путь» кода (hot path). Затем мы можем перейти к этому графику и получить данные о выполнении.

[ImageCaption: Puc. 12.34. IpaΦik Bb3oBos npunoxeHnIa 3a 30 cekyHd]
HapnImer, rpaΦik na pnc. 12.35 roBopHT o ToM, HTO a3 30 cekyHd Ha mTOl, decode (npuemHnIk \*FetchResponse) bblI0 nortpaeho 0.06 cekyHdI. H3 Hux 0.02 yllIO Ha Record- Batch.decode H 0.01 Ha makemap (co3dAnHe KapTbI).

[ImageCaption: Puc. 12.35. IpuImer rpaΦikBa3oBos]
Ipu nOmoIIu Be6- uHtrepbeiCa moxHo nOJIyHHTb doCTyII K pa3JIuHbIM npeJIcTaB- JehHnM takoH uHформaIIII. HapnImer, b npeJIcTaBaJIeHnI Top view pyHkIIIIu coprupyIOTcA no bpeMeHn Ix bblIOnJIHeHnI, a BO Flame Graph bH3yJIJI3IIpyeTCa uepapxHn bpeMeHn bblIOnJIHeHnI. IoJIb3OBaTeJIbCKuH uHtrepbeiC moxet dAxe oTO- 6paXaTb cAmbie JoopoIcTOaIIIIe VacTnI uCXOJIHOro koIA nOCTPOHHO.
PРИМЕЧАНИЕ Можно получить данные профилирования с помощью командной строки. Но в этом разделе сосредоточимся на веб-интерфейсе.
Благодаря наличию этих данных мы получаем общее представление о том, как ведет себя приложение:
- Слишком много вызововe runtime, malloc может означать чрезмерное количество небольших выделений памяти в куче, которые можно попытаться свести к минимуму.
- Слишком большое количество времени, потраченное на операции с каналами или блокировку миотексов, может указывать на чрезмерный уровень конку-рентности, что негативно отражается на производительности приложения.
- Слишком большое количество времени, потраченное на syscall.Read или syscall.write, означает, что приложение проводит значительное количество времени в режиме ядра (kernel mode). Тогда при должной работе над будечизацией ввода/вывода можно добиться некоторых улучшений.
Именно такие сведения позволяет получить профилировщик процессора. Он ценен для понимания горячих путей кода и выявления узких мест. Но он не может предоставить более детальную информацию, чем задания частота, поскольку выполняется с фиксированным интервалом (по умолчанию 10 миллисекунд). Чтобы получить более детальную информацию, используйте трассировку, которую мы обсудим дальше.
PРИМЕЧАНИЕ Можно прикреплять к различным функциям ярлыки. Например, представьте себе общую функцию, вызываемую из разных клиентов. Чтобы отслеживать время, потраченное на всех клиентов, используйте prof.Labels.
# Профилирование кучи
Профилирование кучи позволяет получить статистику о ее текущем использо-вании. Как и профилирование CPU, оно основано на принципе сэмплирования. Мы можем менять частоту сэмплирования, но не должны стремиться к слишком большой гранулярности, так как чем сильнее уменьшаем шаг, тем больше усилий требуется для сбора данных. По умолчанию сэмплы профилируются при одном распределении на каждые 512 Кбайт кучи.
Если мы получим /debug/pprof/heap/, то увидим необработанные данные, которые трудно прочитать. Но можно загрузить профиль кучи, используя debug/pprof/
heap/?debug=0, a затем открыть его с помощью go tool (ото та же команда, что упоминалась в предыдущем разделе), чтобы просмотреть данные с помощью веб-интерфейса.
Haрис. 12.36 показан пример графического отображения кучи. Вызов метода MetadataResponse.decode приводит к выделению 1536 K6aunt b kyve (vto co- ставляет $6.32\%$ ot общего объемa kучи). Ho us этих 1536 K6aunt напрямую были выделены 0 K6aunt, nostomy нужно сделать второй вызов. C nomouibio metoza TopicMetadata.decode 6b1no bbldeleho 512 K6aunt us 1536 K6aunt; octaJbHbie 1024 K6aunt 6b11u bbldelehbi dpyruM cInocOOM.
Puc. 12.36. [pa@uueckar cxeMa kyu
Tak mi moxem dbnrtacn no nenoike bblsobob, vto6bi nnohrt, kaka aacts npuJio- xehnus otbevaet sa 6oJbnyto aacts bbldelehni namrtu b kyve. Moxho paccmotperts pasJiuHbie tuHbi bbl6pKu:
alloc_objects - o6uuee koJiuuectbo o6bektoB, nOa kotopbie bbldeleho mecto B nAmATu; alloc_space - o6inuud o6bem pacnpeJeeHnou nAmATu; inuse_objects - koJiuuectbo o6bektoB, nOa kotopbie bbldeleho u eue he ocbo6oxJeho mecto B nAmATu; inuse_space - o6bem pacnpeJeeHnou nAmATu, kotopaa eue he oc6oxJeha.
Еще одна очень полезная возможность при профилировании кучи — отслежи-вание утечек памяти. Для языка, в который встроен сборщик мусора, обычная процедура выглядит так:
1. Запустите GC.
2. Загрузите данные кучи.
3. Подождите несколько секунд/минут.
4. Запустите другой GC.
5. Загрузите другие данные кучи.
6. Сравните.
Принудительная сборка мусора перед загрузкой данных — это способ предот- вратить ложные выводы. Например, если мы увидим максимальное число сохраненных объектов без предварительного запуска GC, то не будем знать, является эта ситуация утечкой памяти или эти объекты будут обработаны при следующем запуске GC.
Используя prof, можно загрузить профиль кучи и тем временем принудительно выполнить GC. Вот как это делается:
1. Перейдите к /debug/pprof/heap?gc=1 (запустите GC и загрузите профиль кучи).
2. Подождите несколько секунд/минут.
3. Перейдите к /debug/pprof/heap?gc=1 еще раз.
4. Используйте go tool для сравнения обоих профилей кучи:
$ go tool pprof - http=:8080 - diff_base <file2> <file1>
Примечание Другой тип профилирования, связанный с кучей, — этоalloc, который сообщает о выделении мест в памяти. Профилирование кучипоказывает текущее состояние памяти кучи. Чтобы получить представление о пронзых распределенных памяти (с момента запуска приложения), используйте профилирование таких распределений. Как я говорил, поскольку выделение места в стеке происходит достаточно быстро, оно не учитывается в профилировании, которое собирает данные только по куче.
На рис. 12.37 показано, к каким данным можно получить доступ. Например, объем памяти в куче, необходимый для метода newTopicProducer (вверху слева), уменьшился (- 513 Кбайт). Напротив, объем для updateMetadata (внизу
справа) увеличился (+512 Кбайт). Медленные увеличения нормальны. Второй профиль кучи мог быть получен, например, в середине сервисного вызова. Можно повторить этот процесс или подождать несколько дольше. Важно от- слеживать, нет ли постоянного увеличения выделенного места в памяти для конкретного объекта.

[ImageCaption: Puc. 12.37. Pasличия между двумя профилями кучи]
# Профилирование порутин
Профиль goroutine выдает отчет о трассировке стека всех текущих гору- тин в приложении. Скачайте соответствующий файл с помощью debug/pprof/ goroutine/?debug=0 и снова используйте go tool. На рис. 12.38 показано, какую информацию можно получить.

[ImageCaption: Puc. 12.38. Графическая схема для порутин]
Мы видим текущее состояние приложения и количество горутин, созданных для каждой функции. В этом случае withRecover создал 296 выполняющихся горутин (63 %), а 29 были связаны с вызовом responseFeeder.
Эта информация также полезна, если подозревается утечка горутин. Можно просмотреть данные профилировщика горутин, чтобы узнать, какая часть системы является подозрительной.
# Профилирование блокировок
Профиль block показывает, где горутины блокируются, ожидая синхронизационных примитивов. Можно отследить:
- отправку или получение по небуферизованному каналу;- отправку в заполненный канал;- прием из пустого канала;- конфликт мыотекса;- состояние ожидания сети или файловой системы.
Профилирование блокировок фиксирует также время ожидания горутины, что доступно в debug/pprot/block. Этот профиль будет чрезвычайно полезен, если мы подозреваем, что производительность оказывается сниженной из-за блокировки вызовов.
# Полный факт стека горутин
Если мы столкнулись с взаимоблокировкой или подозреваем, что горутины находятся в заблокированном состоянии, полный факт стека горутин (debug/pprof/goroutine/?debug=2) создает факт всех текущих трассировок стека горутин. Это может быть полезно в качестве первого шага для анализа ситуации. Например, в следующем этапе показана горутина Sarama, заблокированная на 1420 минут при операции получения из канала:
goroutine 2494290 [chan receive, 1420 minutes]: github.com/Shopify/sarama.(\*syncProducer).SendMessage(0xc00071a090, {0xc0009bb800, 0xfb, 0xfb}) /app/vendor/github.com/Shopify/sarama/sync_producer.go:117 +0x149
По умолчанию профиль block неактивен. Для его запуска вызовите runtime. SetBlockProfileRate. Эта функция контролирует те события блокировок горутин, о которых выдаются сообщения. После запуска профилировщик будет
продолжать собирать данные в фоновом режиме, даже если мы не будем обращаться к конечной точке debug/pprof/block. При высокой частоте выборки будьте осторожны, чтобы сильно не снизить производительность.
# Профилирование мыотексов
Последний тип профилей связан с блокировкой, но только в отношении мыотексов. Если вы подгореваете, что приложение тратит значительное время на ожидание блокировки мыотексов, что негативно сказывается на выполнении, используйте профилирование мыотексов. Оно доступно в /debug/pprof/mutex.
Этот профиль работает аналогично профилю блокировки. По умолчанию он отключен: его нужно запустить с помощью функции runtime.SetMutexProfileFraction, которая определяет долю событий конкуренции мыотексов, которые будут отражены в профиле.
Дополнительные замечания о профилировании:
- Мы не упоминали профиль threadcreate, потому что с 2013 года он перестал работать (https://github.com/golang/go/issues/6104).- В каждый промежуток времени запускайте только один профилировщик, например, не включайте одновременно профилирование CPU и кучи. Это может привести к ошибочным данным наблюдений.- pprof расширяемый, и мы можем создавать собственные профили, используя pprof.Profile.
Мы рассмотрели самые важные профили, которые можем запускать, чтобы понимать, как работает приложение и какие есть пути его оптимизации. Рекомен- дуется включать pro-т даже в продакшене, поскольку в большинстве случаев он предлагает отличный баланс между затратами и объемом получаемой информа- ции. Запуск некоторых профилей, например CPU, приводит к снижению общей производительности, но только в то время, когда он включен. Теперь рассмотрим трассировщик выполнения.
# 12.8.2. Трассировщик выполнения
Трассировщик выполнения — это инструмент, который фиксирует различные события во время выполнения приложения с помощью go tool, чтобы визуа- лизировать их. Это полезно для:
- понимания событий, происходящих во время выполнения приложения, например работы сборщика мусора;
- понимания того, как выполняются горутины;
- выявления плохо распараллеленных процессов.
Попробуем увидеть это на примере, который приводили в описании ошибки #56 (полагать, что конкурентность быстрее). Мы тогда обсудили две параллельные версии алгоритма сортировки слиянием. Проблема с первой версей заключалась в плохом распараллеливании, что приводило к созданию слишком большого количества горутин. Посмотрим, как трассировщик поможет в проверке этого утверждения.
Создадим бенчмарк для первой версии и выполним его с флагом - trace, чтобы запустить трассировщик выполнения:
$ go test - bench=. - v - trace=trace.out
Примечание Можно загрузить удаленный файл трассировки, используя конечную точку /debug/pprof/trace?debug=0 pprof.
Эта команда создает файл trace.out, который открывается с помощью go tool:
$ go tool trace trace.out 2021/11/26 21:36:03 Parsing trace... 2021/11/26 21:36:31 Splitting trace... 2021/11/26 21:37:00 Opening browser. Trace viewer is listening on http://127.0.0.1:54518
Откроется браузер, и мы можем щелкнуть по View Trace, чтобы просмотреть все трассировки за определенный период времени, как показано на рис. 12.39.
Рис. 12.39. Отображение активности горутин и событий во время выполнения приложения (например, фаза сборки мусора)
Ha cкиншоте отражен промежуток примерно в 150 миллисекунд. Мы видим полезные метрики: счетчик горутин и размер кучи. Размер кучи неуклонно расчет, пока не будет запущен сборщик мусора. Можно понаблюдать за активностью приложения на каждом ядре CPU. Временной интервал начинается с кода уровня пользователя, затем выполняется остановка всей системы (stop the world), что занимает все четыре ядра CPU примерно на 40 миллисекунд.
Что касается конкурентности, мы видим, что эта версия использует все доступные ядра CPU. Но посмотрим на рис. 12.40, на котором участок в 1 миллисекунду показан в увеличенном масштабе. Каждый столбик этой диаграммы соответствует одному выполнению горутины. Наличие слишком большого числа маленьких столбиков наводит на мысль о том, что тут что-то не так: это означает, что выполнение плохо распараллелено.
Рис. 12.40. Наличие слишком большого числа маленьких столбиков означает, что выполнение плохо распараллелено
На рис. 12.41 картина показана в большем увеличении, чтобы рассмотреть, как горутины оркестрованы. Примерно $50\%$ процессорного времени тратится не на выполнение кода приложения. Пробелы отображают время, необходимое среде выполнения Go для запуска и оркестрации новых горутин.
Рис. 12.41. Примерно $50\%$ процессорного времени тратится на обработку переключений между горутинами
Сравним это со второй параллельной реализацией, которая была примерно на порядок быстрее. Рисунок 12.42 снова отображает промежуток времени в 1 миллисекунду.

[ImageCaption: Puc. 12.42. Konuuectbo npo6enob shauhtelno mehblue, vto roborput o 6one nonhou3arpy3ke CPU]
BbinoJhneue kaKdouI rOpytnHb3aHmMaeT 6oJbHue bpemehu, a koJnuectbo npo6eJIOB 3haHHTeJbH6o MeHbHue. CJeIObArTeJbH6o, npOueccOp B rOpa3JO 6oJbHuei Mepe 3aHrT bHbInOHHeHueM koJa npOJIOxeHnH, YeM B nepBOH BepcHn. KaKdJa MUNJIneCKyHJa npOueccOpHoro bpemehu paCxoJyETc4 6oJee 3bHkeTUBHO, vTO o6JbArCHreT pa3JHnHnB b 6eHyMapKaX.
O6paHHTe bHmHahue, vTO cTeHeHb rpahyJIrpHocTn TpaCcuPOBOK 3aBucHt OT rOpyTHHbI, a He OT qyHKHnH, KaK npH npObHJIInPOBaHHHn CPU. Ho moXHO OnpeDEJIHTb 3aJaHn yPOBHn IOJIb3OBaTeJIr, vTO6bI IOJIyHnTb npEeCTaBHeHHe O KaKdOuI qyHKHnH nJIu rpyHHe qyHKHnH O 1oMOHHbIO nAketa runtime/trace.
IPeJCTaBHeTe c6e qyHKHnHO, KoTOpaH bHnHcJIreT 1uCJI0 QH6OHaYH, a 3aTeM 3aHnucbHaeT ero B rJI6OaJIbHyIO bpemehnyIO, ucnoJIb3yA atomic. MoXHO OnpeDEJIHTb JBe pa3HbIe 3aJaHn:
var v int64 ctx, fibTask := trace.NewTask(context.Background(), "fibonacci") trace.WithRegion(ctx, "main", func() { v = fibonacci(10) }) fibTask.End() ctx, fibStore := trace.NewTask(ctx, "store") trace.WithRegion(ctx, "main", func() { atomic.StoreInt64(&result, v) }) fibStore.End()
HCnOJIb3yA go tool, Mbl IOJIyHm 6oJee ToHnyIO HHbOpMaHnHO O TOM, KaK bHInOI- HHOIOcA 3TH JBe 3aJaHn. B npeJbHJIyHMe 1oJIb3OBaTeJIbCkOM HHTEpHBeiCe TpaCcu- pOBKH (cM. puc. 12.42) Mbl BnJIHm rpHnHnH kaKdOuI 3aJaHn 3aJaHn KaKdOuI rOpyTHHbI.
В пользовательских задачах (User- Defined Tasks) можем следить за распределением продолжительности (рис. 12.43).

[ImageCaption: Pис. 12.43. Распределение задач пользовательского уровня]
Мы видим, что в большинстве случаев задача fibonacci выполняется менее чем за 15 микросекунд, тогда как задача store требует менее 6309 наносекунд.
В предыдущем разделе мы обсудили виды информации, которую получаем в результате профилирования CPU. Каковы их основные отличия от данных, которые возможно получить из трассировки на уровне пользователя?
- Профилирование CPU:
- основано на сэмплировании;
- делается для каждой функции;
- не опускается ниже частоты сэмплирования (по умолчанию 10 милли-секунд).
- Трассировки на уровне пользователя:
- не основаны на сэмплировании;
- исполняются для каждой горутины (если мы не используем пакет runtime/trace);
- время выполнения не ограничено никакой частотой.
Таким образом, трассировки выполнения — это мощный инструмент, позволяющий понять, как работает приложение. Как было показано на примере сортировки слиянием, мы можем идентифицировать плохо распараллеленное выполнение. Однако транулярность трассировки остается на уровне горутин,
если мы не используем пакет runtime/trace вручную, в отличие, например, от профилирования CPU.
Используйте как профилирование, так и трассировщик выполнения, чтобы получить максимальную отдачу от стандартных инструментов диагностики Go и оптимизировать приложение.
В следующем разделе поговорим, как работает сборщик мусора и как его на- строить.
# 12.9. Ошивка #99: НЕ ПОНИМАТЬ, КАК РАБОТАЕТ СБОРЩИК МУСОРА
Сборщик мусора (GC) — важная часть языка Go, упрощающая жизнь разработчикам. Он позволяет отслеживать и освобождать ресурсы кучи, которые больше не нужны. Поскольку мы не можем заменить каждое резервирование памяти в куче выделением места в стеке, то важно знать, как работает GC, чтобы оптимизировать приложения.
# 12.9.1. Концепции
Сборщик мусора хранит дерево ссылок на объекты. GC в Go основан на алгоритме пометок (mark- and- sweep- algorithm), который состоит из двух этапов:
- Этап пометки (mark)
- просмотр всех объектов в куче и пометка тех, которые все еще используются.- Этап очистки (sweep)
- просмотр дерева ссылок от корня и освобождение места, ранее выделенного под блоки объектов, на которые больше нет никаких ссылок.
Когда запускается сборщик мусора, он сначала выполняет действия, которые приводят к остановке всей системы — stop the world (точнее, две остановки системы на каждый цикл GC). Весь доступный процессорный временной интервал используется для сборки мусора, и выполнение кода приложения приостанавливается. После завершения этих шагов система вновь запускается, приложение продолжает выполнение и одновременно запускается конкурентная фаза. Именно поэтому сборщик мусора Go называется конкурентной пометкой и освобождением (concurrent mark- and- sweep): его целью является уменьшение
KOLINчЕСТВА stop- of- the- world- onepaций на kАждом цикле GC и paбота в основном kонкурентно с прилогжением.
GC tАкже вклпочает в себя способ освобождения памяти после пика потребления. Представьте, что приложение основано на двух фазах:
- фаза инициализации, которая приводит к частым выделениям памяти и к большому размеру кучи;
- фаза выполнения с умеренным количеством выделений памяти и небольшому размеру кучи.
Как среда Go pеагирует на тот факт, что большой размер кучи имеет смысл только при старте приложения, но не далее? Эта ситуация обрабатывается как часть операций по сборке мусора так называемым вспомогательным сборщиком мусора (periodic scavender). Через какое-то время GC обнаруживает, что такая большая куча больше не требуется, поэтому освобождает часть памяти и воз- вращает ее в распоряжение OC.
ПримЕЧАНИЕ Если вспомогательный сборщик мусора недостаточно быстр, можно принудительно вернуть память в распоряжение OC, исполь- зуя debug.FreeOSMemory().
Важный вопрос: когда будет выполняться цикл сборки мусора? По сравнению с другими языками, например Java, конфигурация Go остается достаточно простой. Она задается одной переменной среды: GOGC. Эта переменная определяет тот процент роста кучи с момента последней сборки мусора, достижение которого должно запускать следующий цикл GC; ее значение по умолчанию — 100 %.
Посмотрим пример. Предположим, что сборщик мусора запустился только что, а текущий размер кучи составляет 128 Мбайт. Если GOGC=100, то следующая сбор- ка мусора запустится, когда размер кучи достигает 256 Мбайт. По умолчанию GC выполняется каждый раз, когда размер кучи удваивается. Кроме того, если сборка мусора не выполнялась в течение последних двух минут, то Go запустит ее в принудительном порядке.
Если мы профилируем приложение с теми нагрузками, которые характерны для продакшена, то можем задать значение GOGC очень точно:
- его уменьшение приведет к замедлению роста кучи и увеличит нагрузку на GC;
- и наоборот, его увеличение приведет к ускорению роста кучи и снизит нагрузку на GC.
# Tpaacupobka GC
BbIbeJem Tpaacupobky GC, 3aJab 3ha4ehue nepemehHouI cpeJbI GoDEBUG, hanpu- mer, npu 3anycke 6en4maJpka:
\$ GoDEBUG=gctrace=1 go test - bench=- . - v
Yctanobka qnara gctrace sanhcebIaet Tpaacupobky B stdeIr- kaKqbIb pa3, korJa sanycckaetca GC.
PaccmOrpum heckoJbko npimepob, UTObI noHrTb, kak BeJct cEa c6opHnK mycopa b cJy4ae ybeJHueHnI 1arpy3Ku.
# 12.9.2. TpHmerb
12.9.2. TpHmerbДопустим, мы предоставляем пользователям какие- то публичные сервисы. В пиковое время, в 12:00 к ним подключается миллион пользователей. Но до этого был устойчивый плавный рост числа подключенных пользователей. На рис. 12.44 показан средний размер кучи и размер при запуске сборщика мусора, если значение GOGC оставить равным 100.

[ImageCaption: Puc. 12.44. YctOu4uBbIbI u nIaBbHbI pOcT uCnIa nOJkJIOvHbIX noJb3OBaTeJeV]
Поскольку значение GOGC установлено равным 100, сборщик мусора запускается каждый раз, когда размер кучи удваивается. Поскольку количество пользо- вателей растет более-менее плавно, мы должны столкнуться с приемлемым количеством циклов GC в течение дня (рис. 12.45).

[ImageCaption: Puc. 12.45. Частота запуска GC никогда не достигает уровня выше умеренного]
B начале дня должно быть умеренное количество циклов GC. После 12:00, когда количество пользователей начинает уменьшаться, количество циклов GC также должно плавно уменьшаться. В таком сценарии сохранение значения GOGC на уровне 100 должно быть адекватным решением.
Теперь рассмотрим второй сценарий, когда большинство из миллиона пользо- вателей подключается менее чем за один час (рис. 12.46). В 8:00 средний размер кучи быстро растет и достигает своего пика примерно через час.
Как показано на рис. 12.47, частота запусков GC сильно меняется в течение этого часа. Из-за значительного и резкого увеличения размера кучи мы сталкиваемся с ситуацией, когда циклы сборки мусора запускаются очень часто и в течение короткого периода времени. Несмотря на то что сборка мусора в Go выполняется конкурентно, такая ситуация может привести к значительному числу периодов остановки всей системы и увеличить среднюю задержку, что будет заметно пользователям.

[ImageCaption: Puc. 12.46. Peskun poct 4ucna nodklnoyehhbx no/bsobatenei]

[ImageCaption: Puc. 12.47. B tevение oghoro vaca Mbi ctalnukbaemcr C Bbicokon vactotou uiknob 3anycka GC]
B этом случае нужно рассмотреть возможность увеличения значения GOGC, чтобы уменьшить нагрузку на GC. Обратите внимание, что увеличение GOGC не приводит к линейному увеличению выгоды: ведь чем больше размер кучи, тем больше времени потребуется на ее очистку. Следовательно, в случае тех нагрузок, которые характерны для продакшена, нужно проявлять осторожность в тонкой настройке значения GOGC.
B совершенно исключительных условиях, при еще более резком изменении, подгонки значения GOGC может оказаться недостаточно. Допустим, что вместо перехода от 0 к 1 миллиону пользователей в течение часа, такой скачок происходит за несколько секунд. В течение этих секунд количество запусков сборщика мусора может достичь критического уровня, что приведет к низкой производительности приложения.
Если мы знаем о наличии пика в графике роста размера кучи, можно использовать какой-нибудь срок, который заставляет выделять большое количество памяти для повышения стабильности кучи. Например, принудительно выделить 1 Гбайт с помощью глобальной переменной в main.go:
var min = make([]byte, 1_000_000_000) // 1 Гбайт
В чем смысл? Если GOGC оставить равным 100, то вместо того, чтобы запускать сборщик мусора каждый раз, когда размер кучи удавивается (что происходит очень часто в течение этих нескольких секунд), Go будет его запускать только тогда, когда куча достигает 2 Гбайт. Это должно уменьшить количество циклов сборки мусора, запускаемых в момент подключения всех пользователей, что снизит влияние GC на среднюю задержку.
На это можно возразить: когда размер кучи уменьшается, такой срок приведет к большим и бесполезным затратам памяти. Но на самом деле это не так. В большинстве ОС выделение памяти для переменной min не ведет к тому, что приложение будет потреблять 1 Гбайт памяти. Вызов ваке влечет за собой системный вызов mmaр(), что приводит к ленивому распределению памяти. Например, в Linux память виртуально адресуется и отображается через таблицы cтраниц. Использование mmaр() резервирует 1 Гбайт памяти в виртуальном, а не в физическом адресном пространстве. Только чтение или запись вызовут ошибку страницы, что приведет к фактическому выделению физической памяти. Таким образом, даже если приложение запускается без подключенных клиентов, оно не будет потреблять 1 Гбайт физической памяти.
Примечание Можно проверить такое поведение о помощью инструмен- ta ps.
Для оптимизации сборки мусора важно понимать, как ведет себя GC. Ис-пользуйте GOGC для настройки запуска следующего цикла GC. В большинстве случаев достаточно держать значение этой переменной на уровне 100. Но если приложение может сталкиваться с пиками в числе запросов, приводящими к частому запуску GC и к задержкам, можно это значение увеличивать. Наконец, в случае исключительно резкого пика запросов подумайте о том, чтобы прибегнуть к трюку с сохранением минимального размера виртуальной кучи.
В последнем разделе этой главы поговорим о последствиях запуска Go в Docker и Kubernetes.
# 12.10. ОШИБКА #100: НЕ ПОНИМАТЬ ОСОБЕННОСТЕЙ ЗАПУСКА GO ВНУТРИ DOCKER И KUBERNETES
Опрос Go- разработчиков, проведенный в 2021 году (https://gorev/blog/survey2021- results), выявил, что написание сервисов — это самое распространенное применение Go. A Kubernetes — это наиболее широко используемая платформа для развертывания таких сервисов. Важно понимать последствия запуска Go в Docker и Kubernetes, чтобы предотвращать появление нежелательных ситуаций типа тротлинга CPU.
В разделе, посвященном ошибке #56 (полагать, что конкурентность быстрее), я говорил, что переменная GOMAXPROCS определяет лимит потоков OC, отвечаю- цих за одновременное выполнение фрагментов кода пользовательского уровня. По умолчанию ее значение установлено равным количеству логических ядер CPU, видимых для OC. Что это означает в контексте Docker и Kubernetes?
Предположим, что наш кластер Kubernetes состоит из восемнядерных узлов. Когда контейнер развертывается в Kubernetes, мы можем определить лимит CPU, чтобы гарантировать, что приложение не будет потреблять все ресурсы хоста. Например, следующая конфигурация ограничивает использование CPU до 4000 millicpu (или миллиядер), то есть четырьмя ядрами CPU:
spec: containers: - name: myapp image: myapp resources: limits: cpu: 4000m
Мы можем предположить, что когда приложение будет развернуто, значение GOMAXPROCS будет основано на этих ограничениях и, следовательно, равно 4. Но это не так: оно устанавливается равным количеству логических ядер на хосте, то есть 8. Разберемся с этим.
Kubernetes использует Completely Fair Scheduler (CFS) в качестве планировщика процессов. CFS также используется для соблюдения ограничений CPU для ресурсов пода. При администрировании кластера Kubernetes администратор может настроить два параметра:
- cpu.cfs_period_us (глобальная установка);- cpu.cfs_quota_us (установка для каждого пода).
Первый определяет период, а второй - - квоту. По умолчанию период установлен равным 100 мс. Между тем значение квоты по умолчанию - это то, сколько процессорного времени приложение может потребить за 100 мс. Ограничение установлено на четыре ядра, это означает, что оно равно 400 мс (4 × 100 мс). Таким образом, CFS гарантирует, что наше приложение никогда не потребляет более 400 мс процессорного времени в течение 100 мс.
Представим сценарий, когда в некоторый момент времени несколько порутни выполняются в четырех разных потоках. Выполнение каждого потока запланировано на разных ядрах (1, 3, 4 и 8), как показано на рис. 12.48.

[ImageCaption: Рис. 12.48. На каждые 100 мс приложение потребляет менее 400 мс]
B teчение первого 100-миллисекундного периода заняты четыре потока, поэтому мы потребляем 400 из 400 мс: 100 % квоты. Во второй период мы потребляем 360 мс из 400 мс и т.д. Все нормально, потому что приложение потребляет ресурсы меньше квоты.
Теперь вспомним, что GOMAXPROCS равно 8. В худшем случае может быть восемь потоков, выполнение каждого из которых запланировано на отдельном ядре (рис. 12.49).

[ImageCaption: Puc. 12.49. B teчение каждых 100 mс происходит троттлинг CPU после 50 mс]
Ha каждые 100 mс установлена квота в 400 mс. Если восемь потоков заняты выполнением горутин, через 50 mс мы достигаем этой квоты (8 × 50 mс = 400 mс). Каковы последствия? CFS будет ограничивать ресурсы CPU. Следовательно, ресурсы CPU не будут выделяться до начала следующего периода. Другими словами, приложение будет приостановлено на 50 mс.
Например, время выполнения какого-то сервиса со средней задержкой в 50 mс может вырастать до 150 mс — это увеличение задержки на 300 %.
Как выйти из такой ситуации? Прежде всего, следите за проблемой Go 33803 (https://github.com/golang/go/issues/33803). Возможно, в будущей версии Go GOMAXPROCS будет учитывать CFS.
Ha сегодняшний день решением является библиотека автомахрося, созданная Uber (github.com/uber-go/automaxprocs). Используйте эту библиотеку, добавив пустой импорт в go.uber.org/automaxprocs в main.go: он автоматически установит GOMAXPROCS в соответствии с квотой CPU контейнера Linux. В предыдущем примере для GOMAXPROCS было установлено значение 4 вместо 8, поэтому мы не смогли бы достичь состояния, когда происходит троттлинг CPU.
В настоящее время CFS в Go не поддерживается. GOMAXPROCS зависит от особен- ностей хост-машины, а не от определенных ограничений CPU. Следовательно, можно достичь состояния, когда происходит троттлинг CPU, что приводит к длительным паузам и негативным эффектам, например к значительным увеличениям задержек. До тех пор, пока Go не поддерживает CFS, одним из решений будет использование автомахрося для автоматической установки GOMAXPROCS в соответствии с заданной квотой.
# UTOFU
- Понимать, как использовать кэши CPU, важно для оптимизации приложе-ний, которые интенсивно потребляют ресурсы процессора, поскольку кэш L1 примерно в 50-100 раз быстрее, чем основная память.
- Знание концепции кэш-линии поможет организовать данные в приложе-ниях, интенсивно использующих данные. CPU не извлекает данные из памяти слово за словом, а копирует блок памяти в 64-байтовую кэш-линию. Чтобы получить максимальную отдачу от каждой отдельной кэш-линии, учитывайте принцип пространственной локальности и организуйте память в соответствии с ним.
- Предсказуемость поведения кода для CPU также может быть эффективным способом оптимизации определенных функций. Например, единичный или постоянный шаг предсказуем для CPU, а неединичный шаг (например, связ-ный список) непредсказуем.
- Чтобы избежать критических шагов и, следовательно, использования только кропечной части кэша, имейте в виду, что кэши разбиваются на сектора.
- Знание того, что более низкие уровни кэша CPU не используются совместно всеми ядрами, помогает избежать снижающих производительность паттернов конкурентного кода, например ложного совместного использования. Совместное использование памяти — это иллюзия.
- Используйте параллелизм на уровне инструкций (ILP) для оптимизации определенных частей кода, чтобы CPU мог выполнять как можно больше
инструкций параллельно. Выявление опасностей данных — один из основных моментов, связанных с этим.
- Вы избежите типичных ошибок, если вспомните, что в Go основные типы выравниваются по своему размеру. Имейте в виду, что реорганизация полей структуры по размеру в порядке убывания может привести к более компакт-ным структурам (выделение меньшего объема памяти и, возможно, лучшая пространственная локализация).
- Понимание фундаментальных различий между кучей и стеком поможет оп-тимизировать приложения. Выделение памяти в стеке почти ничего не стоит, в то время как такая же операция в куче выполняется медленнее и зависит от того, как память очищена сборщиком мусора.
- Сокращение числа выделений памяти также важно при оптимизации. Это можно сделать разными способами, например, тщательно проработать дизайн вашего API, чтобы предотвратить совместное использование переменных, определенных в функции, которая вызывается из основного фрагмента кода, применять встроенные в компилятор Go методы оптимизации, а также sync. Pool.
- Применяйте технику быстрого встраивания, чтобы эффективно сократить амортизированное время вызова функции.
- Используйте профилирование и трассировщик выполнения, чтобы понять, как работает приложение и какие его части нужно оптимизировать.
- Знание того, как настраивать сборщик мусора, даст множество преимуществ. Например, вы сможете более эффективно обрабатывать пиковые увеличения нагрузки.
- Чтобы избежать тротлинга CPU при развертывании в средах Docker и Kubernetes, помните, что Go не поддерживает CFS.
# ЗАКЛЮЧЕНИЕ
Поздравляю! Вы дошли до конца книги, и я надеюсь, что она понравилась вам и поможет в ваших личных и/или профессиональных проектах.
Помните, что ошибки — это часть процесса обучения. И как я говорил в предисловии, именно это послужило важным источником вдохновения для написания этой книги. В конце концов, самое главное — это наша способность учиться на ошибках.
Подписывайтесь на меня в Твиттере, чтобы пообсуждать темы этой книги: @teivah.
© Серия «Для профессионалов», 2023 Тейва Харшани
# 100 ошибок Go и как их избежать
Перевел с английского Д. Строганов
Руководитель дивизиона Ю. Сергиенко Ведущий редактор Е. Строганова Литературный редактор К. Тулицева Художественный редактор В. Мостшин Корректоры С. Беляева, Н. Викторова Берегка Л. Егорова
Изветовлено в России. Изготовитель: ООО «Прогресс книга» Место нахождения и фактический адрес: 194044, Россия, г. Санкт-Петербург, Б. Сампсониевский пр., д. 29A, nom. 52. Тел.: +78127037373. Дата изготовления: 09.2023. Наименование: книжная продукция. Срок гостности: не ограничен. Налоговая льгота — общероссийский классификатор продукции ОК 034-2014, 58.11.12 — Книги печатные профессиональные, технические и научные. Импортер в Беларусь: ООО «ПИТЕР М», 220020, РБ, г. Минск, ул. Тимирязева, д. 121/3, к. 214, тел./факс: 208 80 01. Подписано в печать 25.08.23. Формат 70×100/16. Бумага офсетная. Усл. п. л. 38,700. Тираж 1000. Заказ 0000.
Джон Боднер
# GO: ИДИОМЫ И ПАТТЕРНЫ ПРОЕКТИРОВАНИЯ
Go 6bictpo ha6upaet nonyjraHocTb B kavecTbe r3bka dna cos3aHna Be6- cepBucOB. CyuJecTbYet MHoxecTBO yue6HukOB no cHHTaKcucy Go, Ho 3HaTb ero HeJocCTaTOvHO. ABtor Jxон Боднер onисbIbает и o6bясняет natтерHbI npOeKтиpoBaHn, uспoль3yembie onbITHbIMu pa3pa6oTчикamu. B khnre co6paHa ha66one BaxHnar uHформaция, heo6xoДимая dяHa1uncahnЯ чистогo и uДцоматическогo Go-кода. Bbi hayчитесb думать как Go-pa3pa6oTчик, bHe 3aBuCимOCTи OT npедыдущего опbIta npOrpaMмировaHnЯ.
KYNUTb
Бетси Бейер, Крис Джоунс, Дженнифер Петофф, Ричард Мерфи
# SITE RELIABILITY ENGINEERING. НАДЕЖНОСТЬ И БЕЗОТКАЗНОСТЬ КАК В GOOGLE
Вот уже почти 20 лет компания Google обеспечивает работу невообразимо слож- ных и масштабных систем, которые чутко реагируют на запросы пользователей. Поисковик Google находит ответ на любые вопросы за доли секунды, карты Google с высочайшей точностью отражают земной ландшафт, а почта Google доступна в режиме 365/24/7 и, в сущности, стала первым общедоступным облачным хра- нилищем. Неужели эти системы безупречны? Нет, они тоже отказывают, ломаются и устаревают, как любая техника. Просто мы этого не замечаем. Все дело в том, что уже более десяти лет Google нарабатывает уникальную технологию Site Reliability Engineering, обеспечивающую бесперебойную работу и поступательное развитие софтверных систем любой сложности. Эта книга — кладезь опыта, накопленного компанией Google за долгие годы, коллективный труд многих выдающихся специа- листов и незаменимый ресурс для любого инженера, желающего разрабатывать и поддерживать любые продукты максимально качественно и эффективно.
# КУПИТЬ
# BLACK HAT GO: ПРОГРАММИРОВАНИЕ ДЛЯ ХАКЕРОВ И ПЕНТЕСТЕРОВ
Black Hat Go исследует темные стороны Go — популярного языка программирования, который высоко ценится хакерами за его простоту, эффективность и надежность. Эта книга — арсенал практических приемов для специалистов по безопасности и хакеров, она поможет вам в тестировании систем, создании и автоматизации инструментов, а также улучшении навыков противодействия угрозам. Все это реализуется с помощью обширных возможностей Go.
Вы начнете с базового обзора синтаксиса языка и стоящей за ним философии, после чего перейдете к изучению примеров, которые пригодятся для разработки инструментов. Вас ждет знакомство с протоколами HTTP, DNS и SMB. Далее вы перейдете к изучению различных тактик и задач, с которыми сталкиваются пентестеры, рассмотрите такие темы, как кража данных, синффинг сетевых пакетов и разработка эксплойтов. Вы научитесь создавать динамические встраиваемые инструменты, после чего перейдете к изучению криптографии, атаке на Windows и стеганографии.
Готовы расширить арсенал инструментов безопасности? Тогда вперед!
Михалис Цукалос
# GOLANG Для ПРОФИ: СОЗДАЕМ ПРОФЕССИОНАЛЬНЫЕ УТИЛИТЫ, ПАРАЛЛЕЛЬНЫЕ СЕРВЕРЫ И СЕРВИСЫ
3-е издание
Язык Go — это простой и понятный язык для создания высокопроизводительных систем будущего. Используйте Go в реальных производственных системах. В новое издание включены такие темы, как создание серверов и клиентов RESTful, знаком- ство с дженериками Go и разработка серверов и клиентов gRPC.
Третье издание «Golang для профи» исследует практические возможности Go и описывает такие продвинутые темы, как параллелизм и работа сборщика мусо- ра Go, использование Go с Docker, разработка мощных утилит командной строки, обработка данных в формате JSON (JavaScript Object Notation) и взаимодействие с базами данных. Кроме того, книга дает дополнительные сведения о работе внут- ренних механизмов Go, знание которых позволит оптимизировать код на Go и ис- пользовать типы и структуры данных новыми и необычными способами.
Также охватываются некоторые нюансы и идиомы языка Go, предлагаются упраж- нения и приводятся ссылки на ресурсы для закрепления полученных знаний.
Станьте опытным программистом на Go, создавая системы и внедряя передовые методы программирования на Go в свои проекты!