Лабораторная работа
Основы конкурентности в Go: горутины, каналы, буферизация и синхронизация
Часть 1. Горутины и ожидание их завершения (sync.WaitGroup)
Объяснение
Горутина — это легковесная конкурентная функция в Go. Она запускается с помощью ключевого слова go.
Проблема в том, что функция main не ждет завершения других горутин автоматически. Если main завершится раньше, программа остановится, даже если фоновые задачи еще работают.
Для ожидания завершения нескольких горутин используется sync.WaitGroup:
-
wg.Add(1)сообщает, что нужно дождаться еще одной горутины; -
wg.Done()уменьшает счетчик; -
wg.Wait()блокирует выполнение, пока счетчик не станет равен нулю.
Код
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Воркер %d начал работу\n", id)
time.Sleep(1 * time.Second)
fmt.Printf("Воркер %d закончил работу\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
fmt.Println("main: Жду завершения всех воркеров...")
wg.Wait()
fmt.Println("main: Все воркеры закончили работу!")
}
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Воркер %d начал работу\n", id)
time.Sleep(1 * time.Second)
fmt.Printf("Воркер %d закончил работу\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
fmt.Println("main: Жду завершения всех воркеров...")
wg.Wait()
fmt.Println("main: Все воркеры закончили работу!")
}
Задание
Запустите программу и посмотрите порядок вывода. Затем закомментируйте строку wg.Wait() и запустите код снова. Сравните результат и объясните, почему программа ведет себя по-разному.
Часть 2. Небуферизированные каналы
Объяснение
Канал в Go используется для обмена данными между горутинами.
Небуферизированный канал работает синхронно: отправка значения и его получение должны встретиться. Если получателя нет, отправитель ждет. Если отправителя нет, получатель тоже ждет.
Это удобно, когда нужно не только передать данные, но и синхронизировать выполнение двух горутин.
Код
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
fmt.Println("Горутина: выполняю сложную работу...")
time.Sleep(2 * time.Second)
ch <- "Привет из фоновой горутины!"
}()
fmt.Println("main: Жду сообщение из канала...")
msg := <-ch
fmt.Println("main: Получено сообщение:", msg)
}
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
fmt.Println("Горутина: выполняю сложную работу...")
time.Sleep(2 * time.Second)
ch <- "Привет из фоновой горутины!"
}()
fmt.Println("main: Жду сообщение из канала...")
msg := <-ch
fmt.Println("main: Получено сообщение:", msg)
}
Задание
Запустите программу и обратите внимание, где именно основная горутина ждет. Затем попробуйте убрать чтение из канала или отправку в канал и посмотрите, что изменится.
Часть 3. Буферизированные каналы и закрытие канала
Объяснение
Буферизированный канал имеет внутреннюю очередь. Это значит, что в него можно отправить несколько значений подряд без немедленного чтения, если в буфере есть место.
После окончания отправки канал можно закрыть с помощью close(ch).
После закрытия:
- новые значения отправлять нельзя;
- читать из канала можно;
-
цикл
for rangeзавершится, когда канал станет пустым.
Закрывать канал должен тот, кто отправляет в него данные.
Код
package main
import (
"fmt"
)
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
fmt.Println("В канал записано 3 значения.")
close(ch)
for val := range ch {
fmt.Printf("Прочитано из канала: %d\n", val)
}
fmt.Println("Канал пуст и закрыт.")
}
package main
import (
"fmt"
)
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
fmt.Println("В канал записано 3 значения.")
close(ch)
for val := range ch {
fmt.Printf("Прочитано из канала: %d\n", val)
}
fmt.Println("Канал пуст и закрыт.")
}
Дополнительный пример
package main
import (
"fmt"
)
func main() {
ch := make(chan int, 1)
ch <- 42
close(ch)
v1, ok1 := <-ch
fmt.Println(v1, ok1)
v2, ok2 := <-ch
fmt.Println(v2, ok2)
}
package main
import (
"fmt"
)
func main() {
ch := make(chan int, 1)
ch <- 42
close(ch)
v1, ok1 := <-ch
fmt.Println(v1, ok1)
v2, ok2 := <-ch
fmt.Println(v2, ok2)
}
Задание
Сначала запустите основной пример и разберите его вывод. Затем добавьте строку ch <- 40 перед close(ch) и запустите код снова. После этого измените размер буфера на 4 и повторите запуск. Отдельно запустите дополнительный пример и посмотрите, что происходит при чтении из уже закрытого канала.
Часть 4. Оператор select
Объяснение
Иногда программа должна ждать данные не из одного, а сразу из нескольких каналов. Для этого используется оператор select.
select выбирает тот case, который готов выполниться первым. Это позволяет удобно обрабатывать несколько источников данных и таймауты ожидания.
Код
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch1 <- "Ответ от сервиса 1"
}()
go func() {
time.Sleep(1 * time.Second)
ch2 <- "Ответ от сервиса 2"
}()
fmt.Println("main: Ожидаю первый пришедший ответ...")
select {
case msg1 := <-ch1:
fmt.Println("Получено:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено:", msg2)
}
fmt.Println("main: Программа завершена.")
}
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch1 <- "Ответ от сервиса 1"
}()
go func() {
time.Sleep(1 * time.Second)
ch2 <- "Ответ от сервиса 2"
}()
fmt.Println("main: Ожидаю первый пришедший ответ...")
select {
case msg1 := <-ch1:
fmt.Println("Получено:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено:", msg2)
}
fmt.Println("main: Программа завершена.")
}
Вариант с таймаутом
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch1 <- "Ответ от сервиса 1"
}()
go func() {
time.Sleep(3 * time.Second)
ch2 <- "Ответ от сервиса 2"
}()
fmt.Println("main: Ожидаю ответ не дольше 500 мс...")
select {
case msg1 := <-ch1:
fmt.Println("Получено:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено:", msg2)
case <-time.After(500 * time.Millisecond):
fmt.Println("Таймаут! Сервисы слишком долго отвечают")
}
}
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(2 * time.Second)
ch1 <- "Ответ от сервиса 1"
}()
go func() {
time.Sleep(3 * time.Second)
ch2 <- "Ответ от сервиса 2"
}()
fmt.Println("main: Ожидаю ответ не дольше 500 мс...")
select {
case msg1 := <-ch1:
fmt.Println("Получено:", msg1)
case msg2 := <-ch2:
fmt.Println("Получено:", msg2)
case <-time.After(500 * time.Millisecond):
fmt.Println("Таймаут! Сервисы слишком долго отвечают")
}
}
Задание
Запустите первый пример и определите, какой канал срабатывает раньше. Затем измените задержки и проверьте, как это влияет на результат. После этого запустите пример с таймаутом и посмотрите, в каком случае срабатывает ветка time.After.
Часть 5. Состояние гонки и sync.Mutex
Объяснение
Если несколько горутин одновременно изменяют одну и ту же переменную, возникает состояние гонки. Это приводит к некорректному результату.
Операция counter++ не является атомарной: она состоит из нескольких шагов. Если две горутины выполняют ее одновременно, одна запись может перезаписать другую.
Для защиты критической секции используется sync.Mutex. Он позволяет одной горутине временно «запереть» участок кода.
Код с ошибкой гонки
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("Итоговое значение счетчика:", counter)
}
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++
}()
}
wg.Wait()
fmt.Println("Итоговое значение счетчика:", counter)
}
Исправленный код
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Итоговое значение счетчика:", counter)
}
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var mu sync.Mutex
counter := 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("Итоговое значение счетчика:", counter)
}
Задание
Запустите сначала первый пример несколько раз. Затем выполните его с флагом -race. После этого запустите исправленный вариант с Mutex и сравните результат.
Часть 6. Пул воркеров (Worker Pool)
Объяснение
Если задач много, не всегда разумно создавать отдельную горутину на каждую. Часто лучше запустить несколько постоянных воркеров, которые будут брать задачи из общего канала.
Такой подход называется пулом воркеров. Он помогает ограничивать нагрузку и делать выполнение более управляемым.
Код
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("Воркер #%d начал задачу %d\n", id, j)
time.Sleep(500 * time.Millisecond)
fmt.Printf("Воркер #%d закончил задачу %d\n", id, j)
}
}
func main() {
var wg sync.WaitGroup
jobs := make(chan int, 5)
for w := 1; w <= 2; w++ {
wg.Add(1)
go worker(w, jobs, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
fmt.Println("main: Жду завершения всех задач...")
wg.Wait()
fmt.Println("main: Вся работа выполнена.")
}
package main
import (
"fmt"
"sync"
"time"
)
func worker(id int, jobs <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("Воркер #%d начал задачу %d\n", id, j)
time.Sleep(500 * time.Millisecond)
fmt.Printf("Воркер #%d закончил задачу %d\n", id, j)
}
}
func main() {
var wg sync.WaitGroup
jobs := make(chan int, 5)
for w := 1; w <= 2; w++ {
wg.Add(1)
go worker(w, jobs, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
fmt.Println("main: Жду завершения всех задач...")
wg.Wait()
fmt.Println("main: Вся работа выполнена.")
}
Задание
Запустите программу и проследите, как задачи распределяются между воркерами. Затем измените число воркеров, например с 2 на 5, и сравните скорость выполнения и порядок вывода.
Часть 7. Атомарные операции (sync/atomic)
Объяснение
Для простых операций со счетчиками и числами вместо мьютекса можно использовать атомарные функции из пакета sync/atomic.
Атомарная операция выполняется безопасно для нескольких горутин и не требует явного Lock/Unlock.
Код
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var counter int64 = 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println("Итоговое значение счетчика:", counter)
}
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var wg sync.WaitGroup
var counter int64 = 0
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
fmt.Println("Итоговое значение счетчика:", counter)
}
Задание
Запустите программу обычным способом, затем с флагом -race. После этого замените атомарное увеличение на обычное counter++ и снова выполните запуск. Сравните поведение.
Часть 8. Однократное выполнение (sync.Once)
Объяснение
Иногда некоторый код должен выполниться только один раз за время работы всей программы. Например, инициализация соединения, загрузка конфигурации или подготовка ресурса.
Для этого используется sync.Once. Даже если несколько горутин одновременно вызывают once.Do(...), нужная функция будет выполнена ровно один раз.
Код
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var once sync.Once
initializeDB := func() {
fmt.Println(">>> База данных успешно инициализирована! <<<")
}
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
fmt.Printf("Воркер %d пытается выполнить инициализацию...\n", workerID)
once.Do(initializeDB)
}(i)
}
wg.Wait()
}
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var once sync.Once
initializeDB := func() {
fmt.Println(">>> База данных успешно инициализирована! <<<")
}
for i := 1; i <= 5; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
fmt.Printf("Воркер %d пытается выполнить инициализацию...\n", workerID)
once.Do(initializeDB)
}(i)
}
wg.Wait()
}
Задание
Запустите программу и убедитесь, что инициализация выполняется только один раз. Затем замените once.Do(initializeDB) на прямой вызов initializeDB() и сравните результат.
Часть 9. Таймауты и отмена операций (context)
Объяснение
В реальных программах некоторые операции могут выполняться слишком долго. Чтобы не ждать бесконечно, в Go используется пакет context.
Контекст с таймаутом позволяет ограничить максимальное время ожидания. Когда время выходит, срабатывает ctx.Done(), и программа может корректно прекратить ожидание.
Код
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
fmt.Println("Горутина: начинаю долгую задачу (5 секунд)...")
time.Sleep(5 * time.Second)
select {
case done <- struct{}{}:
case <-ctx.Done():
}
}()
fmt.Println("main: Жду завершения задачи...")
select {
case <-done:
fmt.Println("main: Успех! Задача завершена до таймаута.")
case <-ctx.Done():
fmt.Println("main: Время вышло! Прекращаем ожидание.")
}
}
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
done := make(chan struct{})
go func() {
fmt.Println("Горутина: начинаю долгую задачу (5 секунд)...")
time.Sleep(5 * time.Second)
select {
case done <- struct{}{}:
case <-ctx.Done():
}
}()
fmt.Println("main: Жду завершения задачи...")
select {
case <-done:
fmt.Println("main: Успех! Задача завершена до таймаута.")
case <-ctx.Done():
fmt.Println("main: Время вышло! Прекращаем ожидание.")
}
}
Задание
Запустите программу и посмотрите, что произойдет при таймауте в 2 секунды. Затем увеличьте время ожидания до 6 секунд и снова выполните код. Сравните результат.
Часть 10. Неблокирующий select
Объяснение
Иногда нужно проверить, есть ли данные в канале прямо сейчас, не останавливая выполнение программы. Для этого в select добавляют ветку default.
Если ни один канал не готов, выполняется default, и программа не блокируется.
Код
package main
import "fmt"
func main() {
ch := make(chan string)
select {
case msg := <-ch:
fmt.Println("Получено:", msg)
default:
fmt.Println("Сообщений пока нет")
}
}
package main
import "fmt"
func main() {
ch := make(chan string)
select {
case msg := <-ch:
fmt.Println("Получено:", msg)
default:
fmt.Println("Сообщений пока нет")
}
}
Вариант для эксперимента
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch <- "Привет!"
}()
select {
case msg := <-ch:
fmt.Println("Получено:", msg)
default:
fmt.Println("Сообщений пока нет")
}
time.Sleep(200 * time.Millisecond)
select {
case msg := <-ch:
fmt.Println("Получено позже:", msg)
default:
fmt.Println("Сообщений все еще нет")
}
}
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch <- "Привет!"
}()
select {
case msg := <-ch:
fmt.Println("Получено:", msg)
default:
fmt.Println("Сообщений пока нет")
}
time.Sleep(200 * time.Millisecond)
select {
case msg := <-ch:
fmt.Println("Получено позже:", msg)
default:
fmt.Println("Сообщений все еще нет")
}
}
Задание
Сначала запустите короткий пример и посмотрите, почему программа не блокируется. Затем выполните второй вариант с горутиной и сравните результат первого и второго select.

