Go, практика асинхронного взаимодействия

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



    Каналы и пустое значение


    Каналы — это инструмент для асинхронной разработки. Но зачастую не важно что переслать по каналу — важен лишь факт пересылки. Порой встречается
    done := make(chan bool)
    /// [...]
    done <- true

    Размер bool зависит от платформы, да, обычно, это не тот случай, когда следует беспокоиться о размере. Но всё же существует способ ничего не отправлять, а если точнее — то отправлять ничего (если быть ещё точнее, то речь о пустой структуре).
    done := make(chan struct{})
    // [...]
    done <- struct{}{}

    Вот собственно и всё.

    Односторонние каналы


    Есть ещё один момент, который хотелось бы явно осветить. Пример:
    func main() {
        done := make(chan struct{})
        go func() {
            // stuff
            done <- struct{}{} // перед завершением сообщаем об этом
        }()
        <- done // ожидание завершения горутины
    }

    Всё просто — done в горутине нужен только для записи. В принципе, в горутине его можно и прочитать (получить значение из канала done). Во избежании неприятностей, если код путаный, выручают параметры. Параметры функции, что передаётся горутине. Теперь так
    func main() {
        done := make(chan struct{})
        go func(done chan<- struct{}) {
            // stuff
            done <- struct{}{} // перед завершением сообщаем об этом
        } (done)
        <- done // ожидание завершения горутины
    }
    Теперь, при передаче канала так, он будет преобразован в канал только для записи. Но вот внизу, канал по прежнему останется двунаправленным. В принципе, канал можно преобразовать в односторонний и не передавая его аргументом:
    done := make(chan struct{})
    writingChan := (chan<- struct{})(done) // первые скобки не важны
    readingChan := (<-chan struct{})(done) // первые скобки обязательны
    При частой необходимости, можно сделать функцию, которая будет всем этим заниматься. Вот пример на play.golang.org. Всё это позволяет отловить некоторые ошибки на этапе компиляции.

    Выполнение в основном треде ОС


    Например такие библиотеки как — OpenGL, libSDL, Cocoa — используют локальные для процесса структуры данных (thread local storage). Это значит, что они должны выполняться в основном треде ОС (main OS thread), иначе — ошибка. Функция runtime.LockOSThread() позволяет приморозить текущую горутину к текущему треду (thread) ОС. Если вызвать её при инициализации (в функции init), то это и будет основной тред ОС (main OS thread). При этом другие горутины спокойно могут выполняться в параллельных тредах ОС.

    Для того, чтобы вынести вычисления в отдельный тред (в данном случае речь о горутине, не факт что она будет в отдельном треде ОС) достаточно просто пересылать функции в основной. Вот и всё.
    Простыня
    На play.golang.org
    package main
     
    import (
            "fmt"
            "runtime"
    )
     
    func init() {
            runtime.LockOSThread() // примораживаем текущую горутину к текущему треду
    }
     
    func main() {
            /*
                коммуникации
            */

            done := make(chan struct{})    // <- остановка и выход
            stuff := make(chan func()) // <- отправка функций в основной тред
     
            /*
                создадим второй тред (в данном случае - вторую горутину, но  это не важно)
                и начнём отправлять "работу" в первый
            */

            go func(done chan<- struct{}, stuff chan<- func()) { // параллельная работа
                    stuff <- func() { // первый пошёл
                            fmt.Println("1")
                    }
                    stuff <- func() { // второй пошёл
                            fmt.Println("2")
                    }
                    stuff <- func() { // третий пошёл
                            fmt.Println("3")
                    }
                    done <- struct{}{}
            }(done, stuff)
    Loop:
            for {
                    select {
                    case do := <-stuff: // получение "работы"
                            do()        // и выполнение
                    case <-done:
                            break Loop
                    }
            }
    }



    Вынос блокирующих операций


    Куда чаще встречаются блокирующие IO-операции, но они побеждаются аналогично.
    Простыня
    На play.golang.org
    package main
     
    import "os"
     
    func main() {
            /*
                    коммуникации
            */

            stop := make(chan struct{}) // нужен для остановки "пишущей" горутины
            done := make(chan struct{}) // ожидание её завершения
            write := make(chan []byte) // данные для записи
     
            /*
                    параллельный поток для IO-операций
            */

            go func(write <-chan []byte, stop <-chan struct{}, done chan<- struct{}) {
            Loop:
                    for {
                            select {
                            case msg := <-write: // получения сообщения для записи
                                    os.Stdout.Write(msg) // асинхронная запись
                            case <-stop:
                                    break Loop
                            }
                    }
                    done <- struct{}{}
            }(write, stop, done)
            write <- []byte("Hello ")    // отправка сообщений
            write <- []byte("World!\n")  // на запись
            stop <- struct{}{} // остановка
            <-done // ожидание завершения
    }

    Если несколько горутин будут отправлять свои сообщения к одной «пишущей», то они всё равно будут блокироваться. В этом случае выручит канал с буфером. Учитывая, что slice — это референсный тип, по каналу будет пересылаться только указатель.




    Референс



    1. Разъяснение LockOSThread (англ.)
    2. Пустые структуры на blog.golang.org (англ.)
    3. Ещё про пустые структуры (англ.)

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    Материал
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 32
      0
      Можно ли в Go расшарить состояние между двумя горутинами? Или обмениваться данными можно только строго через каналы?
        +2
        Есть библиотека sync.Mutex
          0
          В Go есть и низкоуровневые примитивы синхронизации (не в той мере как… в C, POSIX, но основные). Можно пользоваться ними. Но горутины и каналы — это высокоровневый механизм… идущий от мониторов Хоара, он гораздо больше свободен от ошибок.
          Если некторые структуры данных объявлены в области видимости нескольких функций-горутин, они вполне могут совместно использовать такие данные. Не зря синтаксис Go расширен (от C) вложенными опредделенияи функций многих уровней.
            0
            Вот пример без использования каналов, а на чистом sync.WaitGroup play.golang.org/p/_y6NU9tVjt
            Этот подход позволяет обойтись без каналов, в некоторых случаях.
            0
            Но вот внизу, канал по прежнему останется двунаправленным.
            Мне понятно что вы здесь показываете, но непонятно зачем. Зачем вам принципиально однонаправленный канал?
              0
              это была цитата из текста… но оформленная неправльно…
                0
                Всё это позволяет отловить некоторые ошибки на этапе компиляции.

                Только это. Каналов может быть тьма, ненароком можно и записать куда не стоит. По ссылке на play.golang.org вообще нет двунаправленного канала — один для записи, другой для чтения, вот и всё. Разумеется для простеньких задач (как эти примеры) — это слишком (а может — хорошая практика, приучать себя к этому). Например, в пакете time, такие функции как After и Tick возвращают строго канал только для чтения. Достаточно трудно намудрить так, что бы потом записать в этот канал. Но если да — то выхлоп компилятора чётко укажет на ошибку.
                  0
                  Например при реализации геттера, который возвращает приватное поле-канал (может быть полезно для использования с select). Если просто передать канал, то есть опасность, что туда могут записать то, что не нужно. Могу кинуть реальный пример, сегодня пришлось писать свою реализацию WaitGroup из-за того, что тот нельзя использовать с select для ожидания.
                  0
                  Еще, имхо, весьма важный момент, о котором стоит сказать явно.
                  В коде вида

                  func main() {
                      done := make(chan struct{})
                      go func() {
                          // stuff
                          done <- struct{}
                      }()
                      <- done
                  }
                  


                  выполнение главной горутины будет блокироваться на чтении из канала до тех пор, пока мы туда не запишем что-то через done < — (либо не закроем канал).
                  Канал в таком виде — синхронный и блокирующий.
                    0
                    Вероятно Вы правы — добавил поясняющие комментарии. Кстати, я там накосячил в статье. Вместо
                    done <- struct{} // тип
                    
                    Нужно
                    done <- struct{}{} // экземпляр
                    
                    Статью исправил.
                    Вообще множество аспектов касающихся каналов не освещены в статье, ибо тогда она получилась бы чересчур большой громоздкой.
                    0
                    Размер bool зависит от платформы, да, обычно, это не тот случай, когда следует беспокоиться о размере. Но всё же существует способ ничего не отправлять, а если точнее — то отправлять ничего (если быть ещё точнее, то речь о пустой структуре).

                    done := make(chan struct{})
                    // [...]
                    done < — struct{}{}


                    Можно сделать ещё «легче»:
                        done := make(chan interface{})
                        //
                        done <- nil
                    


                    Или не посылать ничего:
                     done := make(chan interface{})
                        //
                        close(done)
                        // в родителе
                        select {
                          case _,ok := <- done:
                          if !ok {
                            // канал закрыт
                          }
                        }
                    


                    А для неблокирующего чтения также можно использовать select:
                    ch := make(chan int)
                    // ...
                    select {
                      val, ok := <- ch:
                        // обработка значения
                        // если канал пуст - управление возвращается коду
                        // если канал закрыт - val == nil, ok == false
                    }
                    

                      0
                      Неблокирующее — это default в select. А тут скорее проверка на закрытость канала.
                      +2
                      А вот и нет, и в первом и во втором случае у Вас создаётся канал предназначенный для конкретного значения. Даже если не пересылать ничего, а использовать закрытие канала — нет нужды делать его типа interface{} или bool. В любом случае — это какие-то значения. Вот например размеры play.golang.org, и nil — это тоже значение, в данном случае. Суть в том, что любое количество struct{} будет занимать 0 памяти, а указатель на переменную содержащую struct{}{} будет всегда один и тот же, для всех таких переменных. Иными словами — это ничего в классическом понимании. С таким же успехом, вместо inteface{} можно пулять любой референсный тип, но опять же зачем?
                        0
                        nil — это не нулевой указатель?
                        Который всегда будет занимать ровно столько же, сколько и указатель на пустую структуру? (4 или 8 байт в зависимости от архитектуры). Но по нулевому указателю сразу понятно, что он нулевой.
                        А указатель на struct{}{} получается еще раскрыть надо.
                        Или я ошибаюсь?
                          0
                          nil — это нулевой указатель, всё верно. И он занимает uintptr места. Указатель на struct{}{} будет занимать столько же места, столько же места будет занимать []byte или &struct{ A, B, C int }. Я не призываю использовать указатель на struct{}{}, ибо это вряд ли когда пригодиться. Но хочу подчеркнуть — что struct{}{} — всегда один и тот же экземпляр. Вот например
                          var a, b struct{}
                          a == b // true
                          &a == &b // true
                          
                          На play.golang.org с раскрытием значения &struct{}{}.
                          interface{}, кстати — это что-то вроде указателя на структуру вида struct{ typePtr, valPrt }. Вот референс (англ.). Разумеется на несуществующий interface{} будет указывать nil ровно как и на любой другой референсный тип.
                            0
                            В любом случае я не думаю, что если использовать НЕ struct{}{} — то приложение выжрет всю память, или будет жёстко тормозить. Вот есть такая фишка как struct{}{} — это основной посыл. И эта struct{}{} применима только для пересылки по каналу, когда по сути не важно, что пересылать. В коде такие пересылки будут явно означать (при просмотре), что речь не о передаче данных, а о каком-то сигнале/событии.
                        +1
                        > runtime.LockOSThread()
                        > к текущему _процессу_ ОС

                        Я надеюсь, это опечатка?
                          0
                          Спасибо, исправил. О, кто-то смотрел спойлеры, а я уж думал что зря их добавил.
                            +1
                            > При этом другие горутины спокойно могут выполняться в параллельных процессах.
                            Ну где ж вы процессы там увидели.

                            Ну и да, эту функцию можно использовать исключительно понимая, что делаешь.
                              0
                              Всё время мысленно провожу аналогию с POSIX threads — а это отдельные процессы (по крайней мере в Linux). Отсюда и такие досадные ошибки. Я хотел подчеркнуть, что LockOSThread не заставляет всю программу выполняться в одном треде, а только текущую горутину. Ща исправлю, на более корректное высказывание. Спасибо, Вы очень внимательны.
                                0
                                BTW, в Linux уже 10 лет как нормальные треды.
                                  0
                                  Всё время мысленно провожу аналогию с POSIX threads — а это отдельные процессы (по крайней мере в Linux).


                                  Да ничего подобного! Оттого, что pthread_t создаются тем же вызовом clone(), что и процессы, они не становятся процессами.

                                  Но более того, есть публикации, которые утверждают, что горутины даже не являются потоками ядра, а являются ещ более легковесными механизмами пространства пользователя.
                                    0
                                    Но более того, есть публикации, которые утверждают, что горутины даже не являются потоками ядра, а являются ещ более легковесными механизмами пространства пользователя.
                                    И это так. Но это не значит, что горутины не используют треды ОС.
                                    If a goroutine is blocking, the runtime will start a new OS thread to handle the other goroutines until the blocking one stops blocking.
                                    Например для
                                    package main
                                    
                                    import "time"
                                    
                                    func comm() (chan<- struct{}, <-chan struct{}) {
                                            c := make(chan struct{})
                                            return c, c
                                    }
                                    
                                    func main() {
                                            in, out := comm()
                                            go func(done chan<- struct{}) {
                                                    time.Sleep(30*time.Second)
                                                    done <- struct{}{}
                                            }(in)
                                            time.Sleep(30*time.Second)
                                            <-out
                                    }
                                    

                                    ps -o nlwp $PID будет 4.

                                    Примечание: для go < 1.5 могут быть отличия. Для GOMAXPROCS=1 число станет 3.

                                    Ну ладно, один тред забирает сборщик мусора. Один, может, ещё для чего или просто про-запас. В итоге получается — горутина = тред. Но, только при условии блокирующих операций в горутинах. Вот и всё.

                                    Про то что POSIX thread не процесс — Ваша правда. Я всегда думал иначе. Странно.
                                      0
                                      Вы опять путаете. Горутины выполняются на тредах ОС как N:M. В go 1.5 стартовое количество тредов, GOMAXPROCS равно количеству логических ядер в системе. В вашем примере на моем рабочем компе 8 тредов, по количеству ядер. В go 1.3.3 тредов 4, чем это обусловлено мне сейчас все же некогда разбираться.

                                      Блокировка горутины не обязательно вызывает блокировку треда: network I/O, time.Sleep(), ожидание на канале и ожидание на примитивах из sync не вызывают блокировки треда, а только снимают горутину с выполнения до какого-то события.

                                      Блокировка треда совпадает с блокировкой горутины при выполнении системного вызова(syscall). Например, все file I/O, включая, вроде бы, консоль. Так же тред условно блокируется уже упомянутой runtime.LockOSThread(), на нем выполняется только горутина вызвавшая LockOSThread() и никакие другие до завершения этой горутины.
                                      Я сейчас уже не вдамся в подробности, когда именно создается новый тред вместо заблокированного, но в какой-то момент создается точно.

                                      Кстати, все вызовы через cgo считаются syscall-ами и в неблагоприятных условиях могут жрать треды только так. Если глянуть в https://github.com/golang/go/blob/master/src/runtime/cgocall.go#L86, желание использовать cgo из более чем одной-двух горутин полностью исчезнет.

                                      Go Scheduler Design Doc — https://golang.org/s/go11sched
                                      Исходники рантайма тоже рекомендуются к чтению.

                                      Кстати, плевок в сторону подхода авторов Go — почти ничего из написанного выше не описано в официальной документации, в т.ч. и откровенно неприятные особенности cgo.
                                        0
                                        Go 1.5 сразу формирует столько тредов ОС, сколько ядер у процессора. У ранних версий со старта выделялся только один. Вот golang.org/doc/go1.5#introduction третий пункт. Одна строчка. Но их количество можно ограничить переменной окружения GOMAXPROCS или функцией runtime.GOMAXPROCS()

                                        Если вы запустите пример выше c GOMAXPROCS=1 то тредов ОС будет три. Не два, не один, не четыре и не восемь, а три. При этом, приложению нужно максимум два, учитывая, что time.Sleep() не блокирует тред — то один.

                                        Да, Вы правы, вместо time.Sleep следовало использовать, например
                                        buf := make([]byte, 1024)
                                         _, _ = syscall.Read(0, buf) // чтение STDIN
                                        


                                        Но это всё не важно — ибо «юзер-спейс треды» не мешают использовать thread local storage. И сколько бы их не было — если все они в основном треде ОС, то из любого из них можно вызывать функции того же OpenGL. Это касается и горутин, но опять же — проще LockOSThread и не разбираться, что и где выполняется. Ведь библиотек, которые требуют выполнение строго во втором или третьем треде ОС нет.
                                          0
                                          > «юзер-спейс треды» не мешают использовать thread local storage.

                                          В общем случае именно мешают, поскольку никто не гарантирует, что они выполняются на одном OS-треде(да и вообще, на кой они такие нужны, если на одном треде), и что они выполняются на одном и том же треде.
                                            0
                                            Можно вызывать runtime.LockOSThread в каждой горутине — и это гарантирует: горутина=OS тред.
                                        +1
                                        Вот, кстати, отличный пост по теме http://morsmachine.dk/go-scheduler
                                          0
                                          Спс
                                            0
                                            Я бы на месте тех, кто интересуется Go, а Go наращивается очень динамично, несравнимо ни с одним инструметом программирования, создал бы где-то тему, где собирал бы URL всё новых и новых публикаций и книг по Go.

                                            Вот, кстати, очень активный руссоязычный ресурс Язык программирования Go — на нём чуть ли не ежедневно выкладываются свежие статьи, переводы, ссылки.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое