Главная / Курсы / Golang / Горутины и каналы
# Глава 16. Горутины и каналы ## Понятие горутины В Go программа исполняется как одна или несколько горутин. **Горутина** — это легковесный поток, который управляется **средой выполнения** Go. Среда выполнения — runtime в Go — это набор библиотек и компонентов, которые автоматически включаются в каждую скомпилированную программу на Go. Они организуют правильное поведение программы. Функция `main` выполняется в горутине. До сих пор все наши программы выполнялись в единственной горутине. Чтобы запустить из `main` новую горутину, достаточно поставить перед вызовом функции ключевое слово `go`. В следующем примере мы запускаем горутину для вывода сообщения о загрузке. В это время, в функции `main`, параллельно выполняется некоторая работа. Когда эта работа закончится, функция `main` завершится. Вместе с ней остановятся и все горутины, которые были запущены из `main`: ```go {.example_for_playground} package main import ( "fmt" "time" ) func printLoad() { fmt.Print("Loading") for range 10 { time.Sleep(500 * time.Millisecond) fmt.Print(".") } } func main() { go printLoad() // Тяжелая задача time.Sleep(3 * time.Second) fmt.Println("\nDone") } ``` ``` Loading..... Done ``` Обратите внимание, что консольный вывод не всегда будет таким. Количество точек в надписи `Loading.....` может варьироваться. Функция `time.Sleep` приостанавливает выполнение горутины **как минимум** на время, переданной ей в качестве аргумента. В действительности это время может быть не совсем таким. Невозможно гарантировать, что горутина будет ждать строго определенное время. Поэтому _не полагайтесь_ на одинаковое поведение программы при использовании `time.Sleep`. Если `main` закончится раньше горутины, которая была из нее вызвана, то эта горутина не успеет завершиться: ```go {.example_for_playground .example_for_playground_001} func printLoad() { fmt.Print("Loading") for range 10 { time.Sleep(500 * time.Millisecond) fmt.Print(".") } } func main() { // Тяжелая задача теперь в горутине go func() { // Увеличиваем время ожидания, // Чтобы main завершилась раньше горутины time.Sleep(10 * time.Second) fmt.Println("\nDone") }() printLoad() } ``` ``` Loading.......... ``` Если в горутине возникнет паника, то горутина немедленно завершится вместе с программой: ```go {.example_for_playground .example_for_playground_002} func printLoad(seconds time.Duration) { fmt.Print("Loading") for range 10{ time.Sleep(500 * time.Millisecond) fmt.Print(".") // Искусственно создаем панику panic("failed to load next symbol") } } func main() { go printLoad(5) // Тяжелая задача time.Sleep(3 * time.Second) fmt.Println("\nDone") } ``` ``` Loading. panic: failed to load next symbol ``` Не поможет даже восстановление из вызывающей горутины: ```go {.example_for_playground .example_for_playground_003} func printLoad(seconds time.Duration) { fmt.Print("Loading") for range 10{ time.Sleep(500 * time.Millisecond) fmt.Print(".") panic("failed to load next symbol") } } func main() { /* Попытка восстановить ход выполнения программы после того, как возникнет паника внутри горутины */ defer func() { if r := recover(); r != nil { fmt.Printf("\nRecovered: %v\n", r) } }() go printLoad(5) // тяжелая задача time.Sleep(3 * time.Second) fmt.Println("\nDone") } ``` ``` Loading. panic: failed to load next symbol ``` Восстановление работает только в самой горутине, которая вызывает панику: ```go {.example_for_playground .example_for_playground_004} func printLoad(seconds time.Duration) { /* Восстановление после паники внутри горутины */ defer func() { if r := recover(); r != nil { fmt.Printf("\nRecovered: %v\n", r) } }() fmt.Print("Loading") for range 10 { time.Sleep(500 * time.Millisecond) fmt.Print(".") panic("failed to load next symbol") } } func main() { go printLoad(5) // Тяжелая задача time.Sleep(3 * time.Second) fmt.Println("\nDone") } ``` ``` Loading. Recovered: failed to load next symbol Done ``` ## Горутины изнутри Как уже было сказано, горутина — легковесный поток, но все же горутина — это не то же самое, что поток операционной системы. Поток операционной системы содержит стек. Стек хранит локальные переменные вызовов функций. Он имеет фиксированный размер. Как правило, 2 Мбайта. Этот размер оказывается большим для потоков, которые нужны, например, чтобы показывать надпись загрузки. Однако этот размер окажется маленьким для сложных функций. Например, для таких, которые обладают глубокой рекурсией. Горутина имеет стек переменного размера. Это повышает ее эффективность. Она начинает выполняться с небольшим стеком. Как правило, 2 Кбайта. Такой стек, аналогично стеку потока операционной системы, хранит локальные переменные вызовов функций. Он увеличивается, по мере необходимости. Его размер может достигать 1 Гбайта. Потоки операционной системы планируются в ее ядре. Процессор периодически прерывается по таймеру, и запускается планировщик. Планировщик приостанавливает поток, сохраняет значения его регистров в памяти и решает, какой из потоков запустить следующим. Для этого он восстанавливает регистры данного потока и возобновляет его выполнение. Все это происходит медленно, поскольку требуется многократное обращение к памяти, обладающей слабой локальностью. Слабая локальность означает, что вероятность повторного обращения к те же ячейкам памяти невелика. Вероятность обращения к ячейкам памяти, расположенным рядом, также небольшая. У среды выполнения Go есть собственный планировщик. Он распределяет выполнение `M` горутин по `N` потокам операционной системы, которые выполняются на `P` ядрах. В планировщике Go реализован метод распределения задач под названием [M:N планирование.](https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D1%82%D0%BE%D0%BA_%D0%B2%D1%8B%D0%BF%D0%BE%D0%BB%D0%BD%D0%B5%D0%BD%D0%B8%D1%8F#M:N_(%D1%81%D0%BC%D0%B5%D1%88%D0%B0%D0%BD%D0%BD%D0%B0%D1%8F_%D0%BF%D0%BE%D1%82%D0%BE%D0%BA%D0%BE%D0%B2%D0%BE%D1%81%D1%82%D1%8C)) В отличие от планировщика операционной системы, планировщик Go распределяет задания, касающиеся только горутин единственной программы на Go. Он работает с глобальной очередью горутин и локальными очередями, привязанными к конкретным ядрам. Если локальная очередь пуста, то ядро забирает задачу из глобальной. Если и она пуста, то ядро может забрать задачу из очереди другого ядра, чтобы более равномерно распределить нагрузку. Такая стратегия планирования называется [work stealing.](https://en.wikipedia.org/wiki/Work_stealing) ![Планирование выполнения горутин](https://raw.githubusercontent.com/senjun-team/senjun-courses/e75023d07c030e3dd7ac0f4dbaa8d31f535f2300/illustrations/golang/goroutines_scheduling.jpg) {.illustration} Планировщик Go вызывается не по таймеру, а неявными инструкциями языка Go. Он работает быстро, потому что у него нет необходимости обращаться к ядру операционной системы и управлять потоками. Планировщик Go использует параметр с именем `GOMAXPROCS`. Этот параметр содержит максимальное количество потоков операционной системы, которое можно использовать для одновременного запуска горутин. До Go 1.25 этот параметр равнялся количеству логических ядер на машине. Начиная с версии Go 1.25, разработчики приняли во внимание контейнерную среду некоторых программ. Для программ в контейнере теперь [учитываются](https://go.dev/blog/container-aware-gomaxprocs) ограничения этого контейнера. Если программа на Go выполняется внутри контейнера с ограничением ресурсов по ядрам, `GOMAXPROCS` по умолчанию соответствует этому ограничению. Таким образом, `GOMAXPROCS` оказывается меньше числа реальных ядер. Иногда переменную окружения `GOMAXPROCS` устанавливают явно, либо для этого вызывают функцию [runtime.GOMAXPROCS](https://pkg.go.dev/runtime#GOMAXPROCS) из кода. Важно понимать, что `GOMAXPROCS` — это не максимальное число горутин, которое может быть запущено. Если запустить больше горутин, чем `GOMAXPROCS`, то один поток будут использовать несколько горутин. «Лишние» горутины станут ждать, пока поток не освободится. Важной особенностью горутин является то, что они не имеют идентификатора, который может быть получен как обычное значение. Он недоступен программисту. Это было сделано разработчиками Go сознательно. В противном случае могли бы возникнуть зависимости функции не только от ее аргументов. Она могла бы зависеть и от идентификатора потока, в котором функция выполняется. Разработчики Go уверяют, что это не нужно и поощряют простоту в написании программ. ## Каналы Каналы дают возможность горутинам «общаться» между собой. Они позволяют одной горутине передать какое-либо значение другой горутине. Канал работает с данными определенного типа — **типа элементов канала.** Каналы бывают двух типов: - **Буферизованные** каналы накапливают сообщения в буфере из `N` элементов. `N` — это ширина канала, то есть его вместимость (capacity). Она задается при инициализации. При записи в такой канал отправитель не блокируется до тех пор, пока вместимость не превышена. Если же канал заполнен, то чтобы отправитель разблокировался, нужно, чтобы другая горутина прочла из канала сообщение. - **Небуферизованные** каналы не хранят сообщения. Если отправитель что-то записывает в такой канал, он тут же блокируется. Он остается заблокированным до тех пор, пока сообщение не будет прочитано из другого канала. ![Каналы](https://raw.githubusercontent.com/senjun-team/senjun-courses/e75023d07c030e3dd7ac0f4dbaa8d31f535f2300/illustrations/golang/channels.jpg) {.illustration} В этой главе мы рассмотрим небуферизованные каналы. ### Небуферизованные каналы Чтобы создать небуферизованный канал, нужно воспользоваться встроенной функцией `make`. Например, для значений типа `bool`: ```go var ch chan bool ch = make(chan bool) ``` Более лаконичный вариант записи: ```go ch := make(chan bool) ``` Так выглядит запись сообщения в канал: ```go ch <- true ``` А это — чтение из канала в переменную `val`: ```go val := <-ch ``` Если результат чтения не используется, его можно не присваивать переменной: ```go <-ch ``` Закрывается канал через встроенную функцию `close`: ```go close(ch) ``` Канал закрывают, если по этому каналу больше не будут передаваться значения. Закрывать канал каждый раз нет необходимости. Ресурсы недоступного канала освобождаются сборщиком мусора автоматически. Канал закрывают только тогда, когда это нужно по логике программы. Например, чтобы сообщить горутине, что все данные переданы. Попытка закрыть уже закрытый канал приведет к панике: ```go {.example_for_playground} package main func main() { ch := make(chan bool) close(ch) close(ch) } ``` ``` panic: close of closed channel ``` Попытка отправить значение в закрытый канал также приведет к панике: ```go {.example_for_playground} package main func main() { ch := make(chan bool) close(ch) ch <- true } ``` ``` panic: send on closed channel ``` При чтении из закрытого небуферизованного канала вы получите значение типа по умолчанию. Иногда нужно точно знать, получили мы реальное значение или нет. Например, при получении значения из закрытого небуферизованного канала никакого значения прочитано из него не будет. На самом деле, при чтении из канала возвращается два значения. В этом случае используют следующую конструкцию: ```go val, ok := <-ch ``` Если `ok` равен `true`, то значение прочитано. В противном случае — нет. Каналы являются ссылочным типом. Они представляют собой ссылку на некоторую структуру данных. Их можно сравнивать. Если два канала ссылаются на одну и ту же структуру данных, то результат сравнения — `true`. В противном случае — `false`: ```go {.example_for_playground} package main import "fmt" func main() { ch := make(chan bool) ch2 := ch fmt.Println(ch == ch2) } ``` ``` true ``` ```go {.example_for_playground} package main import "fmt" func main() { ch := make(chan bool) ch2 := make(chan bool) fmt.Println(ch == ch2) } ``` ``` false ``` Также допустимо сравнивать каналы с `nil`. Небуферизованные каналы удобно использовать для синхронизации работы горутин. Пока горутина ожидает получения значения из канала, она блокируется. Аналогично блокируется отправитель, пока значение из канала не будет прочитано. Используйте эту идею для решения следующего задания. {.task_text} Некоторая задача `task` выполняется на сервере `server`. Сервер не запущен все время. Он запускается по мере необходимости. На старт сервера затрачивается некоторое время. Для выполнения задачи нужно подготовить данные. Эта операция также выполняется не сразу. {.task_text} Код ниже выполняется последовательно. Модифицируйте его таким образом, чтобы он работал параллельно. Данные должны готовиться во время того, как уже стартует сервер. В отладочных сообщениях вы должны увидеть следующий текст: {.task_text} ``` starting server... task data prepared calculating... stopping server... ``` ```go {.task_source #golang_chapter_0160_task_0010} package main import ( "fmt" "time" ) type deviceConfig struct { id int name string job func() } func main() { server := deviceConfig{1, "server", func() { fmt.Println("starting server...") time.Sleep(1 * time.Second) fmt.Println("calculating...") time.Sleep(1 * time.Second) fmt.Println("stopping srever...") }} task := deviceConfig{2, "task", func() { time.Sleep(2 * time.Second) fmt.Println("task data prepared") }} task.job() server.job() } ``` Используйте небуфиризованные каналы типа `struct{}`. Передача значения в такой канал будет сигнализировать о том, что работа выполнена. Не забудьте, что `main` — это тоже горутина. Она не будет ждать выполнения всех других горутин, если не организовать такое поведение явно. {.task_hint} ```go {.task_answer} package main import ( "fmt" "time" ) type deviceConfig struct { id int name string job func() } func main() { dataReady := make(chan struct{}) done := make(chan struct{}) server := deviceConfig{1, "server", func() { fmt.Println("starting server...") time.Sleep(1 * time.Second) <-dataReady fmt.Println("calculating...") time.Sleep(1 * time.Second) fmt.Println("stopping server...") done <- struct{}{} }} task := deviceConfig{2, "task", func() { time.Sleep(2 * time.Second) fmt.Println("task data prepared") dataReady <- struct{}{} }} go task.job() go server.job() <-done } ``` ## Утечка горутин Важно помнить, что сборщик мусора не занимается тем, чтобы останавливать «зависшие» горутины. Вы должны сами позаботиться о том, чтобы каждая горутина успешно завершилась. В противном случае возникнет утечка памяти. Следующий пример демонстрирует утечку горутин. Найдите и исправьте ошибку в программе. Горутины не должны «зависать». {.task_text} ```go {.task_source #golang_chapter_0160_task_0020} package main import ( "fmt" "runtime" "time" ) func sendMessage(ch chan string, message string) { go func() { message = <-ch }() } func getMessage(ch chan string) { go func() { <-ch }() } func main() { // Число горутин вначале startingGs := runtime.NumGoroutine() ch := make(chan string) sendMessage(ch, "hello, senior!") getMessage(ch) // Ждем некоторое время, чтоб увидеть утечку time.Sleep(time.Second) // Число горутин в конце endingGs := runtime.NumGoroutine() // Пишем результаты fmt.Printf("Begin! Goroutines number: %d\n", startingGs) fmt.Printf("All goroutines started! Goroutines number: %d\n", endingGs) fmt.Println("Number of goroutines leaked:", endingGs-startingGs) } ``` Функция `sendMessage` не отправляет сообщение в канал, а читает из него. {.task_hint} ```go {.task_answer} package main import ( "fmt" "runtime" "time" ) func sendMessage(ch chan string, message string) { go func() { // ПРОБЛЕМА была здесь // функция должна отправить сообщение // в канал, а не читать из него ch <- message }() } func getMessage(ch chan string) { go func() { <-ch }() } func main() { // Число горутин вначале startingGs := runtime.NumGoroutine() ch := make(chan string) sendMessage(ch, "hello, senior!") getMessage(ch) // Ждем некоторое время, чтоб увидеть утечку time.Sleep(time.Second) // Число горутин в конце. endingGs := runtime.NumGoroutine() // Пишем результаты fmt.Printf("Begin! Goroutines number: %d\n", startingGs) fmt.Printf("All goroutines started! Goroutines number: %d\n", endingGs) fmt.Println("Number of goroutines leaked:", endingGs-startingGs) } ``` ## Резюме 1. Горутина — это аналог потока операционной системы. Однако горутина исполняется средой выполнения программы на Go. Горутина легче потока операционной системы. 2. Чтобы запустить новую горутину, достаточно поставить ключевое слово `go` перед вызовом функции. 3. Горутина содержит стек переменного размера. Это позволяет эффективнее использовать ресурсы вычислительной машины. 4. Среда выполнения Go имеет свой планировщик, который мультиплексирует выполнение горутин на потоках операционной системы. 5. Параметр `GOMAXPROCS` отвечает за максимальное число потоков, которые могут быть использованы для одновременного запуска горутин. Есть возможность установить этот параметр вручную, но чаще всего в этом нет необходимости. Он уже равен некоторому разумному значению. 6. Горутины не имеют идентификатора. 7. Каналы позволяют горутинам передавать некоторые значения между собой. 8. Канал закрывают через встроенную функцию `close`. Закрывать канал каждый раз нет необходимости. Это нужно лишь тогда, когда того требует логика программы. Например, чтобы читающая из канала горутина остановилась. 9. Нужно помнить, что, на самом деле, при чтении из канала мы получаем два значения. Первым возвращается само значение, а потом — флаг. Этот флаг равен `true` в случае успешного чтения. Иначе — `false`. При чтении из закрытого канала вернется `false`. 10. Каналы — это ссылочный тип. Ссылка указывает на некоторую структуру. Если два канала указывают на одну структуру, то они равны. В противном случае — нет. Каналы допустимо сравнивать с `nil`. 11. Сборщик мусора не останавливает «зависшие» горутины. Важно всегда самому заботиться о том, чтобы все горутины правильно завершились.

Следующие главы находятся в разработке

Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!