Если вы когда-либо писали программу, которая, казалось, делала что-то одно за раз, в то время как мир требует от неё делать семнадцать вещей одновременно, добро пожаловать в доconcurrent-эру. К счастью для вас, Go был буквально разработан, чтобы избавить вас от этой боли. На самом деле, если вы слышали фразу «Go идеально подходит для concurrent-систем», это не маркетинг — это просто разработчики, которые испытали альтернативу и всё ещё приходят в себя.
Последовательный узкое место: почему мы не можем иметь хорошие вещи (пока)
Большинство языков программирования относятся к параллелизму как к нежелательному родственнику на семейном ужине — они технически признают, что он существует, но предпочли бы, чтобы вы справились с этим с помощью потоков, блокировок и сложности, которая заставляет опытных разработчиков хвататься за свои антистрессовые шарики.
Рассмотрим этот классический сценарий: вашему веб-серверу необходимо обработать несколько клиентских запросов. Наивный подход? Обрабатывать их по одному. Клиент А закончил? Отлично, теперь внимание получает Клиент Б. Тем временем Клиенты с С по Z смотрят на свои экраны, гадая, не сломался ли интернет. Это последовательное выполнение, и оно настолько же эффективно, как однополосное шоссе в час пик.
Go посмотрел на эту проблему и подумал: «Что, если мы сделаем параллелизм настолько тривиальным, что вы бы на самом деле его использовали?» Так появились goroutines.
Goroutines: Лёгкая магия параллелизма
Думайте о goroutines как о ответе Go на потоки, но значительно дешевле. Мы говорим о создании тысяч из них без того, чтобы ваша система устроила истерику. Вот фундаментальное различие: в то время как потоки ОС являются тяжеловесными зверями, которые потребляют значительный объём памяти и накладных расходов на переключение контекста, goroutines управляются средой выполнения Go и могут быть мультиплексированы на меньшее количество потоков ОС.
Синтаксис красиво прост. Чтобы запустить goroutine, вы буквально просто пишете go перед вызовом функции. Вот и всё. Вы только что разблокировали параллельное выполнение.
package main
import (
"fmt"
"time"
)
func printNumbers(label string) {
for i := 1; i <= 5; i++ {
fmt.Printf("%s: %d\n", label, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// Последовательное выполнение — скучно
// printNumbers("Последовательный")
// Параллельное выполнение — увлекательно!
go printNumbers("Задача A")
go printNumbers("Задача B")
// Дать goroutines время на завершение
time.Sleep(1 * time.Second)
fmt.Println("Main finished")
}
Когда вы запустите это, вы увидите, что задачи A и B перемежают свой вывод. Они выполняются параллельно, каждая получает время процессора по мере того, как среда выполнения Go планирует их.
Но вот в чём загвоздка: time.Sleep(1 * time.Second) — это костыль. Что если задачи займут больше времени? Что если они займут меньше времени? Мы действуем вслепую. Здесь возникает наша первая проблема: синхронизация goroutines.
Проблема синхронизации: не оставляйте свои goroutines в подвешенном состоянии
Представьте, что вы создаёте goroutine, а затем ваша основная функция завершается до того, как эта goroutine закончит. Goroutine умирает вместе с ней — без вопросов, без очистки, без пощады. Среда выполнения Go завершает всю программу.
Здесь на сцену выходит sync.WaitGroup, как ответственный сопровождающий на экскурсии, который подсчитывает головы перед посадкой в автобус.
package main
import (
"fmt"
"sync"
"time"
)
func processOrder(orderID int, wg *sync.WaitGroup) {
defer wg.Done() // Уменьшить счётчик при выходе из функции
fmt.Printf("Processing order %d\n", orderID)
time.Sleep(500 * time.Millisecond)
fmt.Printf("Order %d completed\n", orderID)
}
func main() {
var wg sync.WaitGroup
orders := []int{101, 102, 103, 104, 105}
for _, orderID := range orders {
wg.Add(1) // Увеличить счётчик
go processOrder(orderID, &wg)
}
wg.Wait() // Блокировать до тех пор, пока счётчик не достигнет нуля
fmt.Println("Все заказы обработаны")
}
Шаблон WaitGroup прост:
- Add(n) увеличивает внутренний счётчик на n
- Done() уменьшает счётчик (вызывайте это, когда goroutine заканчивает)
- Wait() блокирует до тех пор, пока счётчик не достигнет нуля Это ваш первый настоящий инструмент в наборе инструментов для параллелизма, и он удивительно эффективен для простых случаев.
Каналы: заставляем goroutines общаться друг с другом
Здесь Go становится по-настоящему элегантным. Вместо того чтобы делиться памятью и защищать её с помощью блокировок (традиционный подход, который приводит к взаимоблокировкам и потере волос), Go придерживается другой философии: делиться памятью посредством общения.
Каналы — это типизированные каналы для передачи данных между goroutines. Думайте о них как о почтовых ящиках, где одна goroutine может отправлять данные, а другая — получать их.
package main
import (
"fmt"
"time"
)
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Worker %d started job %d\n", id, job)
time.Sleep(time.Second)
result := job * 2
fmt.Printf("Worker %d finished job %d\n", id, job)
results <- result
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int)
// Запуск 3 рабочих goroutines
for i := 1; i <= 3; i++ {
go worker(i, jobs, results)
}
// Отправка заданий
go func() {
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs)
}()
// Сбор результатов
for i := 0; i < 9; i++ {
fmt.Printf("Result: %d\n", <-results)
}
}
Обратите внимание на типы каналов: <-chan int (только для приёма) и chan<- int (только для отправки). Эта направленная типизация предотвращает ошибки, когда функция может ошибочно отправить данные по каналу, предназначенному только для приёма. Компилятор буквально не позволит вам этого сделать.
Магия заключается в том, что каналы управляют синхронизацией за вас. Когда вы отправляете данные по каналу, отправитель блокируется до тех пор, пока кто-нибудь не получит их. Когда вы получаете данные, получатель блокируется до тех пор, пока кто-нибудь не отправит их. Это естественное блокирующее поведение — то, что делает рабочий шаблон таким элегантным.
Буферизованные каналы: небольшая очередь имеет большое значение
По умолчанию каналы не буферизуются (ёмкость 0). В тот момент, когда вы отправляете данные, вы блокируетесь до тех пор, пока кто-нибудь их не получит. Иногда вам нужна небольшая гибкость — небольшая очередь, где отправители могут сбросить данные и уйти, не дожидаясь.
messages := make(chan string, 2)
messages <- "first"
messages <- "second"
// Эти две отправки происходят немедленно, потому что канал имеет ёмкость 2
fmt.Println(<-messages) // "first"
fmt.Println(<-messages) // "second"
Буферизованные каналы полезны для паттернов «производитель-потребитель», где скорость производства может кратковременно превышать скорость потребления.
Взаимное исключение: защита общего состояния
Не всё можно решить с помощью каналов. Иногда у вас есть общее состояние, которое несколько goroutines должны читать и изменять. Для таких случаев sync.Mutex (блокировка взаимного исключения) — ваш друг.
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
func (sc *SafeCounter) Increment() {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.count++
}
func (sc *SafeCounter) Value() int {
sc.mu.Lock()
defer sc.mu.Unlock()
return sc.count
}
func main() {
counter := SafeCounter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment
