Как бы вы определили пул goroutines, которые будут выполнены сразу в Голанге?

TL; TR: Пожалуйста, просто перейдите к последней части и скажите мне, как вы решите эту проблему.

Я начал использовать Голанг сегодня утром из Питона. Я хочу вызывать исполняемый файл с закрытым исходным кодом из Go несколько раз, с небольшим количеством concurrency, с разными аргументами командной строки. Мой итоговый код работает хорошо, но я хотел бы получить ваш вклад, чтобы улучшить его. Поскольку я нахожусь на раннем этапе обучения, я также объясню свой рабочий процесс.

Для простоты предположим, что эта "внешняя программа с закрытым исходным кодом" - это zenity, инструмент командной строки Linux, который может отображать графические окна сообщений из командной строки.

Вызов исполняемого файла из Go

Итак, в Go, я бы сделал следующее:

package main
import "os/exec"
func main() {
    cmd := exec.Command("zenity", "--info", "--text='Hello World'")
    cmd.Run()
}

Это должно работать правильно. Заметим, что .Run() является функциональным эквивалентом .Start(), за которым следует .Wait(). Это здорово, но если бы я хотел выполнить эту программу только один раз, весь материал программирования не стоил бы этого. Поэтому давайте просто делать это несколько раз.

Вызов исполняемого файла несколько раз

Теперь, когда я работал, я хотел бы несколько раз вызывать свою программу с помощью аргументов командной строки (здесь просто i для простоты).

package main    
import (
    "os/exec"
    "strconv"
)

func main() {
    NumEl := 8 // Number of times the external program is called
    for i:=0; i<NumEl; i++ {
        cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
        cmd.Run()
    }
}

Хорошо, мы сделали это! Но я до сих пор не вижу преимущества Go over Python... Этот кусок кода фактически выполняется в серийном режиме. У меня многоядерный процессор, и я бы хотел его использовать. Поэтому добавьте несколько concurrency с goroutines.

Goroutines, или способ сделать мою параллельную программу

a) Первая попытка: просто добавьте "go" s везде

Перепишите наш код, чтобы упростить вызов и повторное использование и добавить знаменитое ключевое слово go:

package main
import (
    "os/exec"
    "strconv"
)

func main() {
    NumEl := 8 
    for i:=0; i<NumEl; i++ {
        go callProg(i)  // <--- There!
    }
}

func callProg(i int) {
    cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
    cmd.Run()
}

Ничего! В чем проблема? Все горуты выполняются сразу. Я не знаю, почему zenity не выполняется, но AFAIK, программа Go вышла, прежде чем внешняя программа zenity могла даже быть инициализирована. Это было подтверждено использованием time.Sleep: ожидания на пару секунд было достаточно, чтобы 8 экземпляров zenity запустили себя. Я не знаю, можно ли это считать ошибкой.

Чтобы усугубить ситуацию, реальная программа, на которую я действительно звонил, требует времени, чтобы выполнить ее. Если я буду запускать 8 экземпляров этой программы параллельно на моем 4-ядерном процессоре, он потратит некоторое время на много переключение контекста... Я не знаю, как ведут себя обычные горуты Go, но exec.Command будет запустить zenity 8 раз в 8 различных потоков. Чтобы сделать это еще хуже, я хочу выполнить эту программу более 100 000 раз. Выполнение всего этого сразу в гортанах не будет эффективным вообще. Тем не менее, я бы хотел использовать свой 4-ядерный процессор!

b) Вторая попытка: использовать пулы goroutines

Интернет-ресурсы обычно рекомендуют использовать sync.WaitGroup для такого рода работ. Проблема с этим подходом заключается в том, что вы в основном работаете с партиями goroutines: если я создаю WaitGroup из 4 членов, программа Go будет ждать завершения всех 4 внешних программ, прежде чем вызывать новую партию из 4-х программ. Это неэффективно: процессор снова теряется впустую.

Некоторые другие ресурсы рекомендовали использовать буферный канал для выполнения работы:

package main
import (
    "os/exec"
    "strconv"
)

func main() {
    NumEl := 8               // Number of times the external program is called
    NumCore := 4             // Number of available cores
    c := make(chan bool, NumCore - 1) 
    for i:=0; i<NumEl; i++ {
        go callProg(i, c)
        c <- true            // At the NumCoreth iteration, c is blocking   
    }
}

func callProg(i int, c chan bool) {
    defer func () {<- c}()
    cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
    cmd.Run()
}

Это кажется уродливым. Каналы не предназначены для этой цели: я использую побочный эффект. Мне нравится концепция defer, но мне не нравится объявлять функцию (даже лямбду), чтобы вывести значение из созданного мной фиктивного канала. О, и, конечно, использование фиктивного канала само по себе является уродливым.

c) Третья попытка: умереть, когда все дети мертвы

Теперь мы почти закончили. Я должен только принять во внимание еще один побочный эффект: программа Go закрывается до того, как все всплывающие окна zenity закрыты. Это связано с тем, что когда цикл завершен (на восьмой итерации), ничто не мешает программе завершить. На этот раз полезно использовать sync.WaitGroup.

package main
import (
    "os/exec"
    "strconv"
    "sync"
)

func main() {
    NumEl := 8               // Number of times the external program is called
    NumCore := 4             // Number of available cores
    c := make(chan bool, NumCore - 1) 
    wg := new(sync.WaitGroup)
    wg.Add(NumEl)            // Set the number of goroutines to (0 + NumEl)
    for i:=0; i<NumEl; i++ {
        go callProg(i, c, wg)
        c <- true            // At the NumCoreth iteration, c is blocking   
    }
    wg.Wait() // Wait for all the children to die
    close(c)
}

func callProg(i int, c chan bool, wg *sync.WaitGroup) {
    defer func () {
        <- c
        wg.Done() // Decrease the number of alive goroutines
    }()
    cmd := exec.Command("zenity", "--info", "--text='Hello from iteration n." + strconv.Itoa(i) + "'")
    cmd.Run()
}

Готово.

Мои вопросы

  • Знаете ли вы какой-либо другой правильный способ ограничить количество запущенных одновременно goroutines?

Я не имею в виду потоки; как Go управляет goroutines внутри, не имеет значения. Я действительно хочу ограничить количество запущенных goroutines сразу: exec.Command создает новый поток каждый раз, когда он вызывается, поэтому я должен контролировать количество времени, которое он вызывается.

  • Отличается ли этот код к вам?
  • Знаете ли вы, как избежать использования фиктивного канала в этом случае?

Я не могу убедить себя, что такие фиктивные каналы - это путь.

Ответ 1

Я бы породил 4 рабочих goroutines, которые читали задания из общего канала. Горутины, которые быстрее других (потому что они запланированы по-другому или имеют простые задачи), получат больше заданий с этого канала, чем другие. В дополнение к этому, я бы использовал sync.WaitGroup, чтобы дождаться завершения всех работников. Оставшаяся часть - это просто создание задач. Вы можете увидеть пример реализации этого подхода здесь:

package main

import (
    "os/exec"
    "strconv"
    "sync"
)

func main() {
    tasks := make(chan *exec.Cmd, 64)

    // spawn four worker goroutines
    var wg sync.WaitGroup
    for i := 0; i < 4; i++ {
        wg.Add(1)
        go func() {
            for cmd := range tasks {
                cmd.Run()
            }
            wg.Done()
        }()
    }

    // generate some tasks
    for i := 0; i < 10; i++ {
        tasks <- exec.Command("zenity", "--info", "--text='Hello from iteration n."+strconv.Itoa(i)+"'")
    }
    close(tasks)

    // wait for the workers to finish
    wg.Wait()
}

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

Ответ 2

Простой подход к дросселированию (выполнить f() N раз, но максимум maxConcurrency одновременно), просто схема:

package main

import (
        "sync"
)

const maxConcurrency = 4 // for example

var throttle = make(chan int, maxConcurrency)

func main() {
        const N = 100 // for example
        var wg sync.WaitGroup
        for i := 0; i < N; i++ {
                throttle <- 1 // whatever number
                wg.Add(1)
                go f(i, &wg, throttle)
        }
        wg.Wait()
}

func f(i int, wg *sync.WaitGroup, throttle chan int) {
        defer wg.Done()
        // whatever processing
        println(i)
        <-throttle
}

Игровая площадка

Я бы не назвал throttle канал "dummy". ИМХО это изящный способ (это не мое изобретение, конечно), как ограничить concurrency.

BTW: Обратите внимание, что вы игнорируете возвращенную ошибку от cmd.Run().

Ответ 3

попробуйте следующее: https://github.com/korovkin/limiter

 limiter := NewConcurrencyLimiter(10)
 limiter.Execute(func() {
        zenity(...) 
 })
 limiter.Wait()