Как предотвратить утечки горутин

Go

эта статьяНачалось вмой блогЕсли вы найдете его полезным, приветствуйте большие пальцы на больших пальцах, чтобы увидеть больше друзей.

Сегодня давайте кратко поговорим о том, как Go предотвращает утечки горутин.

Обзор

Модель параллелизма в Go отличается от других языков: хотя она и упрощает разработку параллельных программ, если вы не понимаете, как ее использовать, вы часто сталкиваетесь с проблемой утечки горутин. Хотя горутина является легковесным потоком и занимает очень мало ресурсов, если она не была выпущена и постоянно создает новые сопрограммы, нет никаких сомнений в том, что проблема есть, и программа будет работать несколько дней или даже дольше. проблема.

Для проблемы, описанной выше, я думаю, что ее можно решить с двух сторон, а именно:

Во-первых, это предотвращение.Чтобы предотвратить, нам нужно понять, какой код будет иметь утечку, и как написать правильный код;

Во-вторых, мониторинг.Хотя предотвращение снижает вероятность утечки, никто не осмеливается сказать, что они не делают ошибок.Поэтому нам обычно нужны некоторые методы мониторинга для дальнейшего обеспечения надежности программы;

Далее я представлю эти две точки зрения в двух статьях Сегодня я расскажу о первом пункте.

Как контролировать утечки

В этой статье основное внимание уделяется первому пункту, но для лучшего демонстрационного эффекта мы можем сначала представить самый простой метод мониторинга. Получите количество горутин, запущенных в данный момент через runtime.NumGoroutine(), и используйте его для подтверждения наличия утечки. Его использование очень простое, поэтому я не буду писать для него пример.

простой пример

Поддержка параллелизма на уровне языка — одна из сильных сторон Go, но ею также легко злоупотреблять. Обычно, когда мы начинаем одновременно изучать Go, мы часто слышим от других, что параллелизм в Go очень прост.Добавление ключевого слова go перед вызовом функции может запустить горутину, то есть модуль параллелизма, но многие люди могут услышать только это предложение: Затем появляется код, подобный следующему:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func sayHello() {
    for {
        fmt.Println("Hello gorotine")
        time.Sleep(time.Second)
    }
}

func main() {
    defer func() {
        fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
    }()

    go sayHello()
    fmt.Println("Hello main")
}

Если вы знакомы с Go, то легко найдете проблему этого кода: sayHello — это бесконечный цикл, и нет механизма выхода, поэтому нет возможности освободить созданную горутину. Мы печатаем количество горутин, запущенных в данный момент, когда функция завершает работу через отсрочку в начале основной функции.Неудивительно, его вывод выглядит следующим образом:

the number of goroutines: 2

Однако, поскольку указанная выше программа не является резидентной, проблема с утечкой незначительна.После выхода программы система автоматически перезапустит ресурсы времени выполнения. Но если этот код выполнять в резидентном сервисе, например http-сервере, каждый раз при получении запроса будет запускаться sayHello.Проходит время, и горутины, которые каждый раз запускаются, не будут выпущены, и ваш сервис рухнет. Становимся ближе.

Этот пример относительно прост, и я думаю, что друзья, которые хоть немного разбираются в параллелизме в Go, не допустят этой ошибки.

Классификация утечек

Пример, представленный ранее, представляет собой утечку, вызванную запуском бесконечного цикла в горутине. Далее я проанализирую различные ситуации утечки по методу параллельной синхронизации данных. Простоту можно разделить на две категории, а именно:

  • Утечка из-за канала
  • Утечка, вызванная традиционными механизмами синхронизации

Традиционные механизмы синхронизации в основном относятся к механизмам синхронизации для разделяемой памяти, таким как эксклюзивные блокировки и разделяемые блокировки. Утечки, вызванные этими двумя ситуациями, относительно распространены. Из-за наличия отсрочки второго типа ситуаций обычно легче избежать.

Утечка из-за Chanel

Давайте сначала поговорим о канале, если вы уже читали официальную параллельную статью.статья,переведенная версияВы найдете применение Каналу, и если случайно слили. Мы пришли к выводу, что они могут быть вызваны.

Отправить не получить

Мы знаем, что у отправителей обычно есть соответствующие получатели. В идеале мы хотим, чтобы получатель всегда получал все отправленные данные, чтобы не было проблем. Но реальность такова, что как только получатель выйдет из строя и перестанет получать исходящие данные, отправитель будет заблокирован. Эта ситуацияпредыдущая статьяТам очень подробное введение.

Образец кода:

package main

import "time"

func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        for _, n := range nums {
            out <- n
        }
        close(out)
    }()
    return out
}

func main() {
    defer func() {
        fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
    }()

    // Set up the pipeline.
    out := gen(2, 3)

    for n := range out {
        fmt.Println(n)               // 2
        time.Sleep(5 * time.Second) // done thing, 可能异常中断接收
        if true { // if err != nil 
            break
        }
    }
}

В примере отправитель отправляет данные в нисходящий поток через внешний канал, основная функция получает данные, а получатель обычно выполняет определенную обработку в соответствии с полученными данными, которые здесь заменяются Sleep. Если в течение этого периода происходит исключение, обработка прерывается и цикл завершается. Горутина, запущенная в функции gen, не завершается.

Как решить?

Основная проблема здесь в том, что когда получатель перестает работать, отправитель этого не знает и тупо отправляет данные вниз по течению. Поэтому нам нужен механизм уведомления отправителя. Отвечу сразу, а не шаг за шагом. Go может отправлять широковещательные сообщения всем получателям, закрывая канал.

Модифицированный код:

package main

import "time"

func gen(done chan struct{}, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for _, n := range nums {
            select {
            case out <- n:
            case <-done:
                return
            }
        }
    }()
    return out
}

func main() {
    defer func() {
        time.Sleep(time.Second)
        fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
    }()

    // Set up the pipeline.
    done := make(chan struct{})
    defer close(done)

    out := gen(done, 2, 3)

    for n := range out {
        fmt.Println(n) // 2
        time.Sleep(5 * time.Second) // done thing, 可能异常中断接收
        if true { // if err != nil 
            break
        }
    }
}

В функции gen одновременная обработка 2-х каналов реализована через select. Когда возникает исключение, будет введена ветвь

Вывод после выполнения следующий:

the number of goroutines:  1

Сейчас существует только основная горутина.

получить не отправить

Отправка без получения приведет к блокировке отправителя, и наоборот, получение без отправки также приведет к блокировке получателя. Посмотрите непосредственно на пример кода, как показано ниже:

package main

func main() {
    defer func() {
        time.Sleep(time.Second)
        fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
    }()

    var ch chan struct{}
    go func() {
        ch <- struct{}{}
    }()
}

Результат выполнения показывает:

the number of goroutines:  2

Конечно, мы обычно не сталкиваемся с такой глупой ситуацией, реальная работа больше случаев может быть отправлена ​​была завершена, но отправитель не закрыл канал, и, естественно, не может знать, что получатель был отправлен, таким образом, что блокировка происходит Отказ

Каково решение? То есть, конечно, не забудьте закрыть канал после отправки.

nil channel

Отправка и получение данных по нулевому каналу вызовет блокировку. Это может произойти, когда мы забываем инициализировать канал при его определении.

Образец кода:

func main() {
    defer func() {
        time.Sleep(time.Second)
        fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
    }()

    var ch chan int
    go func() {
        <-ch
        // ch<-
    }()
}

Два способа записи:

func main() {
	defer func() {
		time.Sleep(time.Second)
		fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
	}()

	done := make(chan struct{})

	var ch chan int
	go func() {
		defer close(done)
	}()

	select {
	case <-ch:
	case <-done:
		return
	}
}

После того, как выполнение горутины завершено, обнаруживается, что done закрыто, и основная функция завершает работу.

реальная сцена

Реальный сценарий определенно не так прост, он может включать взаимодействие между многоступенчатыми горутинами, и определенная горутина может быть отправителем, даже если получатель. Но, в конце концов, независимо от того, какова схема использования. Все они представляют собой рациональное использование базовых знаний, организованных вместе.

традиционный механизм синхронизации

Хотя обычно рекомендуется передавать параллельные данные Go, в некоторых сценариях, очевидно, более целесообразно использовать традиционный механизм синхронизации. Традиционный механизм синхронизации, предоставляемый в Go, в основном представлен в пакетах sync и atomic. Далее я расскажу, что блокировки и группы ожидания могут привести к утечке горутин.

Mutex

Как и в других языках, в Go есть два вида блокировок: эксклюзивные блокировки и общие блокировки, и их использование не будет представлено. В качестве примера для анализа возьмем монопольные блокировки.

Пример выглядит следующим образом:

func main() {
    total := 0

    defer func() {
        time.Sleep(time.Second)
        fmt.Println("total: ", total)
        fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
    }()

    var mutex sync.Mutex
    for i := 0; i < 2; i++ {
        go func() {
            mutex.Lock()
            total += 1
        }()
    }
}

Результат выполнения следующий:

total: 1
the number of goroutines: 2

Этот код запускает две горутины для суммирования суммы.Чтобы предотвратить конкуренцию данных, вычислительная часть заблокирована и защищена, но не разблокирована вовремя, в результате чего горутина i = 1 блокирует горутину, ожидающую i = 0 все время. Снимите блокировку. Как видите, при выходе существуют 2 горутины, есть утечка, а значение total равно 1.

Как с этим бороться? Поскольку в Go есть отсрочка, эту проблему по-прежнему очень легко решить, просто не забывайте помнить об отсрочке разблокировки при блокировке.

Пример выглядит следующим образом:

mutex.Lock()
defer mutext.Unlock()

Другие замки на самом деле похожи здесь.

WaitGroup

WaitGroup отличается от блокировки, она похожа на семафор в Linux и может ожидать группу операций goroutine. При его использовании, если установлено неправильное количество задач, это также может вызвать блокировку и привести к утечке.

Например, когда мы разрабатываем внутренний интерфейс, нам нужно получить доступ к нескольким таблицам данных. Поскольку между данными нет зависимости, мы можем обращаться к ним одновременно. Пример выглядит следующим образом:

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

func handle() {
    var wg sync.WaitGroup

    wg.Add(4)

    go func() {
        fmt.Println("访问表1")
        wg.Done()
    }()

    go func() {
        fmt.Println("访问表2")
        wg.Done()
    }()

    go func() {
        fmt.Println("访问表3")
        wg.Done()
    }()

    wg.Wait()
}

func main() {
    defer func() {
        time.Sleep(time.Second)
        fmt.Println("the number of goroutines: ", runtime.NumGoroutine())
    }()

    go handle()
    time.Sleep(time.Second)
}

Результат выполнения следующий:

the number of goroutines: 2

Произошла утечка. Взглянув еще раз на код, он определяет переменную wg типа sync.WaitGroup в начале и устанавливает количество одновременных задач равным 4, но из примера видно, что есть только 3 одновременных задачи. Следовательно, финальное ожидание условия выхода wg.Wait() никогда не будет выполнено, и дескриптор всегда будет блокироваться.

Как предотвратить это?

Мой личный совет — старайтесь не ставить сразу все количество задач, даже если оно очень конкретное. Потому что он также может быть заблокирован между запуском нескольких одновременных задач. Лучше всего добавить как можно больше через wg.Add(1) при запуске задачи.

Пример выглядит следующим образом:

    ...
    wg.Add(1)
    go func() {
        fmt.Println("访问表1")
        wg.Done()
    }()

    wg.Add(1)
    go func() {
        fmt.Println("访问表2")
        wg.Done()
    }()

    wg.Add(1)
    go func() {
        fmt.Println("访问表3")
        wg.Done()
    }()
    ...

Суммировать

Я примерно рассмотрел все ситуации, которые, по моему мнению, могут привести к утечкам горутин. Подводя итог, фактически, является ли это бесконечным циклом, блокировкой канала, ожиданием блокировки, до тех пор, пока метод записи, который вызовет блокировку, может вызвать утечку. Следовательно, как предотвратить утечку goroutine, становится как предотвратить блокировку. Для дальнейшего предотвращения утечек в некоторых реализациях будет добавлена ​​обработка времени ожидания для активного выпуска горутин, время обработки которых слишком велико.

В этой статье в основном рассказывается, как предотвратить утечку горутин с точки зрения того, как писать правильный код.Следующий, расскажет, как реализовать лучший мониторинг и обнаружение, чтобы помочь нам найти утечки, которые уже существуют в текущем коде.

использованная литература

Concurrency In Go
Goroutine leak
Leaking-Goroutines
Go Concurrency Patterns: Context
Go Concurrency Patterns: Pipelines and cancellation
make goroutine stay running after returning from function
Never start a goroutine without knowing how it will stop


波罗学的微信公众号

Категории