Knihovny libmill a libdill: implementace korutin a kanálů pro jazyk C

19. 3. 2020
Doba čtení: 27 minut

Sdílet

Dnes si popíšeme knihovnu libmill. Jedná se o čistě céčkovou knihovnu (použitelnou i v C++), která do tohoto programovacího jazyka přináší technologie známé z jazyka Go – především korutiny a kanály určené pro komunikaci mezi nimi.

Obsah

1. Knihovny libmill a libdill: implementace korutin a kanálů pro jazyk C

2. Několik poznámek a autorovi knihoven libmill a libdill

3. Souběžné provádění operací

4. Communicating Sequential Processes

5. Konstrukce používané v moderních implementacích odvozených od CSP

6. Kanály a gorutiny v programovacím jazyce Go

7. Implementace kanálů a go-bloků v jazyce Clojure

8. Knihovna libmill se představuje

9. Překlad knihovny libmill

10. Vytvoření a zavolání korutin

11. Vytvoření většího množství korutin bez vzájemné synchronizace

12. Koncept deadline

13. Přepnutí běhu na jinou korutinu

14. Použití kanálů pro komunikaci mezi korutinami

15. Nejjednodušší použití kanálu

16. Obdoba konstrukce select

17. Použití konstrukce typu select

18. Obsah následujícího článku

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

1. Knihovny libmill a libdill: implementace korutin a kanálů pro jazyk C

Jedním z důvodů, proč se poměrně často setkáme s použitím programovacího jazyka Go, je velmi dobrá podpora pro práci s takzvanými gorutinami a taktéž s kanály, které tvoří programové prostředky určené pro komunikaci mezi nimi. Díky existenci těchto prostředků lze relativně snadno vytvářet programy využívající souběžnost a přitom netrpících problémy typu deadlock, souběžný přístup ke sdílené proměnné/paměti bez zajištění výlučnosti či atomičnosti přístupu atd. Ovšem gorutiny (i když toto je název používaný výhradně v Go) a kanály nejsou v žádném případě výsadou tohoto jediného programovacího jazyka. Můžeme se s nimi setkat například i v jazyku Clojure, částečně dokonce i v Pythonu (jedná se o knihovnu goless, která je ovšem prozatím spíše ve stadiu vývoje, což je škoda) a – což může být překvapivé – i ve starém dobrém programovacím jazyku C.

Pro tento postarší, ale stále velmi často používaný programovací jazyk totiž existuje knihovna nazvaná libmill a taktéž poněkud odlišně postavená knihovna libdill. V knihovně libmill se její autor snaží napodobit sémantiku používanou programovacím jazykem Go, knihovna libdill (pocházející ovšem od stejného autora) je naopak orientována spíše přímo na céčkové programátory a nabízí sémantiku v mnoha ohledech odlišnou od jazyka Go. Již na úvod je nutné říci, že výkonnost knihovny libmill je přitom úctyhodná – ostatně sám její autor uvádí, že je možné spustit až 20 milionů korutin a provést více než 50 milionů přepnutí kontextů za sekundu (zde pochopitelně za předpokladu, že procesor nebude provádět žádné další výpočetně náročné činnosti). Knihovna libdill je ovšem realizována odlišným způsobem a tím pádem se liší i její výkonnost. Přepnutí kontextu lze provést za cca 6 ns, korutina se vytvoří za 26 ns a poslání zprávy přes kanál může trvat okolo 40 ns.

V první části článku si ukážeme některé společné vlastnosti již existujících knihoven a jazyků založených na konceptu popsaného ve slavném článku CSP. V části druhé si pak řekneme, jaké možnosti nám nabízí právě knihovna libmill.

Poznámka: ve skutečnosti existuje mnohem větší množství knihoven pro podporu korutin pro jazyk C. Kromě libmill a libdill můžeme jmenovat například libpcl, coro, libconcurrency, libcoro, ribs, libco a další. Pro programovací jazyk C++ se zmiňme především o coroutine a coroutine2 z Boostu. Autorem obou knihoven je Oliver Kowalke.

2. Několik poznámek a autorovi knihoven libmill a libdill

Autorem obou výše zmíněných knihoven libmill a libdill je Martin Sustrik (http://250bpm.com/), s nímž jsme se již na stránkách Rootu několikrát setkali, protože je mj. i autorem či spoluautorem knihoven ØMQ a nanomsg. Viz též následující články ze seriálu o message brokerech, v nichž jsme se těmito zajímavými a užitečnými nástroji zabývali:

  1. ØMQ: knihovna pro asynchronní předávání zpráv
    https://www.root.cz/clanky/0mq-knihovna-pro-asynchronni-predavani-zprav/
  2. Další možnosti poskytované knihovnou ØMQ
    https://www.root.cz/clanky/dalsi-moznosti-poskytovane-knihovnou-mq/
  3. Využití zařízení v knihovně ØMQ při tvorbě systému se složitější architekturou
    https://www.root.cz/clanky/vyuziti-zarizeni-v-knihovne-mq-pri-tvorbe-systemu-se-slozitejsi-architekturou/
  4. Další možnosti nabízené knihovnou ØMQ, implementace protokolů ØMQ v čisté Javě
    https://www.root.cz/clanky/dalsi-moznosti-nabizene-knihovnou-mq-implementace-protokolu-mq-v-ciste-jave/
  5. Implementace různých komunikačních strategií s využitím knihovny nanomsg
    https://www.root.cz/clanky/im­plementace-ruznych-komunikacnich-strategii-s-vyuzitim-knihovny-nanomsg/
  6. Dokončení popisu komunikačních strategií poskytovaných knihovnou nanomsg
    https://www.root.cz/clanky/dokonceni-popisu-komunikacnich-strategii-poskytovanych-knihovnou-nanomsg/

3. Souběžné provádění operací

Knihovny či nové jazykové konstrukce umožňující používání kanálů (či front) pro synchronní, popř. asynchronní komunikaci mezi různými částmi vyvíjených aplikací, se v posledních několika letech těší poměrně velké popularitě. Ta je způsobena dvěma faktory. První důvod spočívá ve snaze o zjednodušení návrhu (či porozumění) vyvíjené aplikace, zejména ve chvíli, kdy se v rámci jednoho programu předávají data (resp. objekty) mezi částmi, jejichž funkce může být dobře izolována od částí ostatních a která již z principu mají běžet souběžně (UI, hry atd.). Druhý důvod je poněkud prozaičtější – v některých situacích je nutné dosáhnout zvýšení efektivity aplikace (například zvýšit počet odpovědí, které může server vygenerovat za určitou časovou jednotku) a přitom není možné či vhodné využívat přímočaré ale dosti nízkoúrovňové řešení založené na použití většího množství vláken spravovaných systémem. Naprosto typickým příkladem jsou některé virtuální stroje JavaScriptu, které povětšinou umožňují běh aplikace v jediném vláknu (což je ovšem s ohledem na „kvalitu“ některých programových kódů spíše výhodou…).

Poznámka: ve skutečnosti používá node.js více vláken, ovšem pouze jediné vlákno se smyčkou událostí.

Některé programovací jazyky, zejména pak již v první kapitole zmíněný jazyk Go, obsahují prostředky sloužící pro zajištění synchronní a asynchronní komunikace přímo v syntaxi (a samozřejmě též v sémantice) jazyka. Konkrétně v případě jazyka Go se jedná o takzvané „gorutiny“ představované klíčovým slovem go a taktéž o operace sloužící pro zápis či čtení dat z kanálů, které jsou představovány operátorem <- (ten má dva významy v závislosti na tom, zda je před operátorem uveden identifikátor představující kanál či nikoli). V programovacím jazyku Clojure však naproti tomu nebylo nutné zavádět speciální syntaxi a vlastně ani nebylo nutné zasahovat do překladače. Celá knihovna core.async, která nabízí podobný koncept, jako jazyk Go, je založena na několika běžných funkcích a hlavně pak na makrech, které manipulací s předaným programovým kódem dokážou z běžného příkazového bloku vytvořit blok zpracovávaný asynchronně (opět se zde ukazuje přednost homoikonických jazyků). Použít ji lze dokonce i v ClojureScriptu, tedy v implementaci jazyka Clojure postavené nad mnohdy „jednovláknovým“ JavaScriptem.

Poznámka: v tomto kontextu je dobré rozlišovat mezi pojmy concurrency a parallelism, které se mnohdy překládají stejně – souběžnost. Pěkně je rozdíl mezi těmito pojmy popsán na tomto videu. Slajdy k videu jsou dostupné na adrese Concurrency is not Parallelism; důležitý je především osmý slajd.

4. Communicating Sequential Processes

Použití asynchronních kanálů a z nich odvozených asynchronních vstupně-výstupních operací (async I/O), se mezi širší programátorskou komunitu rozšířilo pravděpodobně až s vývojem známého systému Node.js. Ve skutečnosti je však realizovaný princip mnohem starší, protože sahá až do šedesátých a sedmdesátých let minulého století, kdy se postupně vyvíjely metody umožňující lepší využití strojového času tehdejších počítačů (lepším využitím se zde nutně nemyslí škálovatelnost!). Za strojový čas se tehdy samozřejmě platilo, což přeneseně platí (sic) i dnes. Poznatky vypracovávané v šedesátých a sedmdesátých letech byly formalizovány a následně shrnuty do teorie popsané ve známém článku Communicating Sequential Processes, jejímž autorem je C.A.R Hoare. Zmíněný článek byl vydán již v roce 1978 a kvůli jeho oblibě a známosti se mnohdy setkáme pouze se zkratkou CSP. CSP měl velký vliv na design několika programovacích jazyků, mezi jinými i jazyka occam a nepřímo i jazyků Limbo, Go a Crystal.

Poznámka: elektronickou a rozšířenou verzi CSP (nikoli původní článek) lze získat ze stránky http://www.usingcsp.com/.

5. Konstrukce používané v moderních implementacích odvozených od CSP

Moderní implementace CSP je většinou založena na dvou základních konstrukcích podporovaných buď přímo v programovacím jazyku nebo v jeho knihovně (popř. s využitím makrosystému daného jazyka). Jedná se o již zmíněné kanály, které lze v některých případech nakonfigurovat takovým způsobem, že fungují jako fronty či buffery (nejjednodušší kanál vlastnosti fronty nemá, proto jsou všechny operace nad ním blokující, někdy se přeneseně a poněkud nepřesně používá označení mailbox). Pro operace s kanály je typicky deklarováno několik funkcí či jazykových konstrukcí: vytvoření kanálu, zavření kanálu, zápis dat, čtení dat, čtení dat z libovolného kanálu, čtení dat s určením priority jednotlivých kanálů atd. Kanál lze použít i jako takzvané synchronizační primitivum a jak uvidíme dále, některé speciální kanály lze použít například i pro pouhé pozastavení výpočtu. Druhou konstrukcí jsou většinou takzvané go bloky (ostatně stejně se jmenují i v knihovně libmill).

Kanály umožňují snadno realizovat například konstrukci typu producent–konzument:

+-----------+                              +-----------+
| producent |           +-----+            | konzument |
|           |... >! ... |kanál} ... <! ... |           |
| go block  |           +-----+            | go block  |
+-----------+                              +-----------+
Poznámka: to ovšem nemusí nutně znamenat, že producent a konzument musí za všech okolností skutečně běžet zcela paralelně. Souběh (concurrency) totiž lze zajistit i na jednovláknovém mikroprocesoru.

Kanál může být využíván více producenty (i konzumenty), takže se původní schéma změní následovně:

+-----------+
| producent |
|     #1    |... >!.........
| go block  |              :
+-----------+              :
                           :
+-----------+              :               +-----------+
| producent |           +-----+            | konzument |
|     #2    |... >! ... |kanál} ... <! ... |           |
| go block  |           +-----+            | go block  |
+-----------+              :               +-----------+
                           :
+-----------+              :
| producent |              :
|     #3    |... >!........:
| go block  |
+-----------+

Náhodný výběr je zde důležitý, protože pokud by se kanály neustále vybíraly podle uvedeného pořadí, mohlo by docházet k efektu, který je znám pod termínem „vyhladovění“ (starvation) u těch kanálů, které se ve vektoru nachází na posledních pozicích. Kromě dvou již uvedených schémat tedy ještě přibývá třetí schéma + všechny jeho varianty:

+-----------+
| producent |           +------+
|     #1    |... >!.....|kanál1}.......
| go block  |           +------+      :
+-----------+                         :
                                      :
+-----------+                         :        +-----------+
| producent |           +------+      :        | konzument |
|     #2    |... >! ... |kanál2} ... alts! ... |           |
| go block  |           +------+      :        | go block  |
+-----------+                         :        +-----------+
                                      :
+-----------+                         :
| producent |           +------+      :
|     #3    |... >!.....|kanál3}......:
| go block  |           +------+
+-----------+

6. Kanály a gorutiny v programovacím jazyce Go

Nejprve se před popisem možností nabízených knihovnou libmill podívejme na to, jak je problematika kanálů a gorutin řešena v programovacím jazyce Go. V Go se gorutiny vytváří a volají velmi snadno – s použitím klíčového slova go. V následujícím příkladu je ukázáno, jak lze dvakrát zavolat funkci message, a to pokaždé v samostatné gorutině běžící nezávisle na gorutině hlavní (main). Ve skutečnosti je však velmi pravděpodobné, že se tato funkce v praxi nestihne zavolat ani jednou, protože mezitím dojde k ukončení hlavní gorutiny a tím pádem i k ukončení činnosti celé aplikace:

package main
 
import "fmt"
 
func message(id int) {
        fmt.Printf("gorutina %d\n", id)
}
 
func main() {
        fmt.Println("main begin")
        go message(1)
        go message(2)
        fmt.Println("main end")
}

Ve druhém příkladu je použit kanál sloužící jako komunikační médium mezi dvěma gorutinami, konkrétně mezi gorutinou hlavní a gorutinou představovanou funkcí message. Hlavní gorutina kanál vytvoří, otevře a následně čeká, až do něj druhá gorutina provede zápis. Pro zápis i čtení z kanálu, což jsou potenciálně blokující operace, slouží v programovacím jazyce Go specializované operátory:

package main
 
import "fmt"
 
func message(id int, channel chan int) {
        fmt.Printf("gorutina %d\n", id)
        channel <- 1
}
 
func main() {
        channel := make(chan int)
 
        fmt.Println("main begin")
        go message(1, channel)
 
        fmt.Println("waiting...")
 
        code, status := <-channel
 
        fmt.Printf("received code: %d and status: %t\n", code, status)
        fmt.Println("main end")
}

Ve třetím příkladu je ukázáno řešení klasické úlohy producent-konzument, v níž konzument (nebo taktéž worker) běží ve vlastní gorutině, zpracovává data posílaná do kanálu taskChannel a ve chvíli, kdy je tento kanál zavřený, ukončí se. Ovšem ještě předtím o svém ukončení informuje producenta, a to zápisem zprávy do kanálu nazvaného workerDone:

package main
 
import "fmt"
 
func worker(taskChannel chan int, workerDone chan bool) {
        fmt.Println("worker started")
        for {
                value, more := <-taskChannel
                if more {
                        fmt.Printf("worker received task with parameter %d\n", value)
                } else {
                        fmt.Println("finishing worker")
                        workerDone <- true
                        fmt.Println("worker finished")
                        return
                }
        }
}
 
func main() {
        taskChannel := make(chan int)
        workerDone := make(chan bool)
 
        fmt.Println("main begin")
 
        go worker(taskChannel, workerDone)
 
        for i := 1; i <= 10; i++ {
                fmt.Printf("sending task with parameter %d\n", i)
                taskChannel <- i
        }
        close(taskChannel)
 
        fmt.Println("waiting for workers...")
 
        code, status := <-workerDone
 
        fmt.Printf("received code: %t and status: %t\n", code, status)
        fmt.Println("main end")
}

V příkladu následujícím je ukázáno jednoduché použití jazykové konstrukce select-case při čekání na data, která mohou být poslána do jednoho ze dvou kanálů ch1 a ch2. V tomto programu jsou nejdříve vytvořeny dva kanály. Následně jsou spuštěny dvě gorutiny, z nichž první pošle data do prvního kanálu ch1 a druhá do druhého kanálu ch2. Nakonec v programové konstrukci select-case počkáme na to, až jsou data dostupná v libovolném z těchto kanálů (teoreticky ani nemusíme jeden z workerů-gorutin spouštět):

package main
 
import (
        "fmt"
        "time"
)
 
func worker(channel chan int, worker int) {
        fmt.Printf("Worker %d spuštěn\n", worker)
        time.Sleep(2 * time.Second)
        channel <- 1
        fmt.Printf("Worker %d ukončen\n", worker)
}
 
func main() {
        ch1 := make(chan int)
        ch2 := make(chan int)
 
        go worker(ch1, 1)
        go worker(ch2, 2)
 
        for true {
                select {
                case <-ch1:
                        fmt.Println("Data z kanálu 1")
                case <-ch2:
                        fmt.Println("Data z kanálu 2")
                default:
                        fmt.Println("Žádná data nejsou k dispozici")
                }
                time.Sleep(1 * time.Second)
        }
}

V posledním demonstračním příkladu, v němž použijeme konstrukci select-case, zkombinujeme jak čtení, tak i zápis do různých kanálů. I tuto kombinaci je možné v programovacím jazyku Go bez větších problémů použít. V konstrukci select-case se vybere vždy pouze jediná větev v závislosti na tom, zda jsou v prvních dvou kanálech data, popř. zda jsou druhé dva kanály prázdné či obsazené.

package main
 
import (
        "fmt"
        "time"
)
 
func receiver(channel chan int, receiver int) {
        for true {
                value, ok := <-channel
                if ok {
                        fmt.Printf("Příjemce %d přijal hodnotu %d\n", receiver, value)
                } else {
                        fmt.Printf("Kanál je pro příjemce %d uzavřen\n", receiver)
                }
                time.Sleep(2 * time.Second)
        }
}
 
func sender(channel chan int, sender int) {
        fmt.Printf("Odesílatel %d byl spuštěn\n", sender)
        for i := 1; i <= 5; i++ {
                time.Sleep(1 * time.Second)
                channel <- i
        }
        fmt.Printf("Odesílatel %d byl ukončen\n", sender)
}
 
func main() {
        ch1 := make(chan int)
        ch2 := make(chan int)
        ch3 := make(chan int)
        // ch1 := make(chan int, 20)
 
        go receiver(ch1, 1)
        go receiver(ch1, 2)
        go sender(ch2, 1)
        go sender(ch3, 2)
 
        for i := 0; i < 20; i++ {
                select {
                case ch1 <- 0:
                        fmt.Println("Poslána nula")
                case ch1 <- 1:
                        fmt.Println("Poslána jednička")
                case data, ok := <-ch2:
                        if ok {
                                fmt.Printf("Přijata data %d z kanálu 2\n", data)
                        }
                case data, ok := <-ch3:
                        if ok {
                                fmt.Printf("Přijata data %d z kanálu 3\n", data)
                        }
                }
        }
}

7. Implementace kanálů a go-bloků v jazyce Clojure

Nyní se podívejme na způsob implementace kanálů a go-bloku v programovacím jazyce Clojure. Zde se namísto nových klíčových slov a nových jazykových konstrukcí používají převážně makra.

V prvním demonstračním příkladu je vytvořen producent, který do kanálu postupně posílá sekvenci čísel 0 až 9, přičemž mezi čísly je určitý čekací interval simulující nějaký výpočet či I/O operaci. Konzument je naproti tomu realizován nekonečnou smyčkou čekající na data z kanálu. Pro go bloky je typické právě použití smyček, ovšem lze použít i go-loop atd.:

(ns async1.core
    (:gen-class))
 
; nacteme vsechny potrebne funkce, makra a symboly z knihovny
; (schvalne se nenacitaji vsechny funkce, protoze by jejich jmena
;  prepsala takove zakladni funkce, jako je map apod.)
(require '[clojure.core.async :refer (go chan >! <!])
 
(defn wait
    "Pozastaveni hlavniho vlakna - simulace interaktivni prace."
    []
    (Thread/sleep 15000))
 
(defn -main
    "Tato funkce se spusti automaticky nastrojem Leiningen."
    [& args]
    (println "Start")
    ; vytvorime kanal
    (let [channel (chan)]
        ; kontinualni posilani zprav do kanalu v asynchronnim bloku
        (go
            (dotimes [i 10]
                (Thread/sleep 1000)
                (>! channel i)))
 
        (println "1st go block started")
 
        ; kontinualni cteni zprav z kanalu v asynchronnim bloku
        (go
            (while true
                (println (<! channel))))
 
        (println "2nd go block started"))
    ; chvili pockame, az se vypise cela sekvence 0 az 9
    (wait)
    (println "Finish"))

Ve skutečnosti není použití (Thread/sleep 1000) příliš idiomatické. Mnohem lepší je použití speciálního kanálu vytvořeného zavoláním (timeout x). Tento kanál je po uplynutí určeného časového intervalu automaticky uzavřen. Co to znamená? Pokud z kanálu pouze čteme, jedná se o blokující operaci (to již víme), ovšem po uplynutí určeného času je kanál uzavřen a vrátí se hodnota nil. Čtením z kanálu typu timeout tedy můžeme elegantně realizovat efektivně implementované čekání. Upravený producent vypadá takto:

; kontinualni posilani zprav do kanalu v asynchronnim bloku
(go
    (dotimes [i 10]
        ;(Thread/sleep 1000)
        (<! (timeout 1000))
        (>! channel i)))

Kanál může být pochopitelně využíván více producenty (i konzumenty). Toto uspořádání je samozřejmě v jazyce Clojure plně podporováno, o čemž nás přesvědčí další programový kód s trojicí producentů a jediným konzumentem, který vypadá následovně:

(ns async2.core
    (:gen-class))
 
; nacteme vsechny potrebne funkce, makra a symboly z knihovny
; (schvalne se nenacitaji vsechny funkce, protoze by jejich jmena
;  prepsala takove zakladni funkce, jako je map apod.)
(require '[clojure.core.async :refer (go chan >! <! timeout)])
 
(defn wait
    "Pozastaveni hlavniho vlakna - simulace interaktivni prace."
    []
    (Thread/sleep 10000))
 
(defn -main
    "Tato funkce se spusti automaticky nastrojem Leiningen."
    [& args]
    (println "Start")
    ; vytvorime kanal
    (let [channel (chan)]
        ; kontinualni posilani zprav do kanalu v trojici asynchronnich bloku
        (go
            (while true
                (<! (timeout 500))
                (>! channel "first")))
 
        (go
            (while true
                (<! (timeout 1000))
                (>! channel "second")))
 
        (go
            (while true
                (<! (timeout 2000))
                (>! channel "third")))
 
        (println "producers started")
 
        ; kontinualni cteni zprav z kanalu v asynchronnim bloku
        (go
            (while true
                (println (<! channel))))
 
        (println "consumer started"))
    ; chvili pockame, az se vypise cela sekvence 0 az 9
    (wait)
    (println "Finish")
    (System/exit 0))

Použití většího množství producentů jsme si již ukázali v předchozím příkladu. Nyní si řekněme, jak může jediný konzument načítat data z většího množství kanálů. To je možné, ovšem namísto operace <! se použije funkce alts!, které se předá vektor kanálů. Funkce alts! ve výchozím nastavení náhodně vybere kanál, ze kterého bude číst. Náhodný výběr je zde důležitý, protože pokud by se kanály neustále vybíraly podle uvedeného pořadí, mohlo by docházet k efektu, který je znám pod termínem „vyhladovění“ (starvation) u těch kanálů, které se ve vektoru nachází na posledních pozicích.

Funkce alts! je použita ve třetím demonstračním příkladu. Povšimněte si, že návratovou hodnotou této funkce je dvojice, protože je nutné nějakým způsobem vrátit jak referenci na kanál, ze kterého se čte, tak i vlastní přečtenou hodnotu. My referenci na kanál použijeme ve výstupu pro určení tisknutého prefixu:

(ns async3.core
    (:gen-class))
 
; nacteme vsechny potrebne funkce, makra a symboly z knihovny
; (schvalne se nenacitaji vsechny funkce, protoze by jejich jmena
;  prepsala takove zakladni funkce, jako je map apod.)
(require '[clojure.core.async :refer (go chan >! <! timeout alts!)])
 
(defn wait
    "Pozastaveni hlavniho vlakna - simulace interaktivni prace."
    []
    (Thread/sleep 10000))
 
(defn -main
    "Tato funkce se spusti automaticky nastrojem Leiningen."
    [& args]
    (println "Start")
    ; vytvorime kanaly
    (let [channel1 (chan)
          channel2 (chan)
          channel3 (chan)]
 
        ; kontinualni posilani zprav do trech kanalu v trojici asynchronnich bloku
        (go
            (doseq [i (range)]
                (<! (timeout 500))
                (>! channel1 i)))
 
        (go
            (doseq [i (range)]
                (<! (timeout 1000))
                (>! channel2 i)))
 
        (go
            (doseq [i (range)]
                (<! (timeout 2000))
                (>! channel3 i)))
 
        (println "producers started")
 
        ; kontinualni cteni zprav z kanalu v asynchronnim bloku
        (go
            (while true
                (let [[item channel] (alts! [channel1 channel2 channel3])]
                    (condp = channel
                        channel1 (println "channel #1: " item)
                        channel2 (println "channel #2: " item)
                        channel3 (println "channel #3: " item)))))
 
        (println "consumer started"))
    ; chvili pockame, az se vypise cela sekvence 0 az 9
    (wait)
    (println "Finish")
    (System/exit 0))

8. Knihovna libmill se představuje

Nejprve malé shrnutí: viděli jsme, že jak v jazyce Go, tak i ve zcela odlišně pojatém programovacím jazyce Clojure, existuje několik užitečných technologií:

  1. Koncept korutin či gorutin
  2. Kanály jako komunikační médium mezi gorutinami
  3. Možnost číst či zapisovat do jednoho kanálu z nabízeného seznamu/vektoru na základě toho, který kanál je připravený

Všechny tyto tři technologie nalezneme i v knihovně libmill. Způsob implementace se však nutně odlišuje. V jazyce Go je práce s gorutinami součástí vlastního jazyka, v Clojure se jedná o systém funkcí a maker a v případě knihovny libmill je použit stejně znějící, ovšem interně zcela odlišný mechanismus založený taktéž na specializovaných funkcích a značně složitých makrech preprocesoru programovacího jazyka C. Ovšem z hlediska programátora se zejména Go a libmill značně podobá: stačí se podívat na porovnání uvedené přímo na stránce http://libmill.org/.

9. Překlad knihovny libmill

Nejprve je pochopitelně nutné knihovnu libmill nainstalovat. To je snadné, budete přitom potřebovat jen základní sadu nástrojů, které má většinou k dispozici prakticky každý céčkový vývojář – překladač, linker, program make, nechvalně známé autotools (autoconf) atd.

V prvním kroku stáhneme zdrojové kódy knihovny:

$ curl http://libmill.org/libmill-1.18.tar.gz

V kroku druhém rozbalíme získaný tarball:

$ tar xvfz libmill-1.18.tar.gz

Provedeme konfiguraci programu:

$ cd libmill-1.18
 
$ ./configure
 
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... /bin/mkdir -p
checking for gawk... gawk
checking whether make sets $(MAKE)... yes
...
...
...
config.status: creating Makefile
config.status: creating libmill.pc
config.status: executing depfiles commands
config.status: executing libtool commands

Následuje překlad s využitím utility make:

$ make
 
  CC       libmill_la-chan.lo
  CC       libmill_la-cr.lo
  CC       libmill_la-debug.lo
  CC       libmill_la-ip.lo
  CC       libmill_la-list.lo
  CC       libmill_la-mfork.lo
  ...
  ...
  ...
  CC       tutorial/step6.o
  CCLD     tutorial/step6
  CC       tutorial/step7.o
  CCLD     tutorial/step7
  CC       tutorial/step8.o
  CCLD     tutorial/step8

Výsledkem by měl být (mj.) i podadresář .libs obsahující statickou i dynamickou variantu knihovny libmill.

Můžeme provést i instalaci knihovny a jejích hlavičkových soubor, nyní ovšem s právy superuživatele:

$ sudo make install
Poznámka: kromě vlastní knihovny se přeloží i sada testovacích a demonstračních příkladů, které si pochopitelně můžete odzkoušet. Naleznete je v podadresáři tutorial.

10. Vytvoření a zavolání korutin

Základním úkolem, v němž knihovnu libmill využijeme, bude přepsání následujícího prográmku takovým způsobem, aby se funkce foo a bar zavolaly každá ve své vlastní korutině:

#include <stdio.h>
 
void foo() {
    puts("foo");
}
 
void bar() {
    puts("bar");
}
 
int main(void) {
    foo();
    bar();
    return 0;
}

Prvním přiblížením může být použití makra go, které má podobný význam, jako stejně pojmenované klíčové slovo go v programovacím jazyce Go. Makro go se zapisuje podobně jako volání funkce, ovšem samotné volání je provedeno (v obecném případě) souběžně:

#include <stdio.h>
#include "libmill.h"
 
void foo() {
    puts("foo");
}
 
void bar() {
    puts("bar");
}
 
int main(void) {
    go(foo());
    go(bar());
    return 0;
}

Ve skutečnosti však není výše uvedený kód za všech okolností korektní, protože korutiny pracují s vlastními daty atd. Pro zcela korektní chování je nutné před funkce volané v korutinách přidat slovo coroutine:

#include <stdio.h>
#include "libmill.h"
 
coroutine void foo() {
    puts("foo");
}
 
coroutine void bar() {
    puts("bar");
}
 
int main(void) {
    go(foo());
    go(bar());
    return 0;
}

Překlad tohoto příkladu se provede způsobem:

$ gcc test1.c -lmill -L{cesta_k_přeložené_knihovně_libmill}

Před spuštěním je nutné zajistit, aby se našla dynamicky linkovaná knihovna libmill:

$ export LD_LIBRARY_PATH={cesta_k_přeložené_knihovně_libmill}
$ ./a.out 

Pro zajímavost se můžete podívat na to, jak vlastně program vypadá po zpracování preprocesorem:

$ cpp test1.c
Poznámka: výsledek zde ukazovat nebudu, neboť je obrovský a prakticky nečitelný. Důležité je si jen uvědomit, že samotné použití makra go není interně zcela triviální.

11. Vytvoření většího množství korutin bez vzájemné synchronizace

Nic nám pochopitelně nebrání použít jednu a tu samou funkci ve více korutinách, což je ukázáno v následujícím demonstračním příkladu:

#include <stdio.h>
#include "libmill.h"
 
coroutine void print(char what) {
    putchar(what);
}
 
int main(void) {
    setvbuf(stdout, (char*)NULL, _IONBF, 0);
    char ch;
    for (ch='a'; ch<='z'; ch++) {
        go(print(ch));
    }
    return 0;
}
Poznámka: tento demonstrační příklad nebude po překladu a spuštění fungovat tak, jak očekáváte, protože se v korutinách používají I/O operace a navíc se vlastní způsob práce s korutinami v knihovně libmill odlišuje od jazyka Go. Obecně platí, že I/O operace jsou nějakým způsobem serializovány, a to na úrovni, která je mimo dosah knihovny libmill.
Poznámka2: volání setvbuf zajistí, že se nebude používat buffer pro optimalizace tisku na standardní výstup. Podobné volání bude použito i ve všech dalších demonstračních příkladech.

12. Koncept deadline

V knihovně libmill se setkáme i s konceptem deadline. Jedná se o absolutní časový údaj určující, v jakém okamžiku by měla být nějaká činnost ukončena. V jiných knihovnách se většinou setkáme spíše s timeoutem, což je relativní časový údaj, tedy za jak dlouho by měla být činnost ukončena. Rozdíly mezi oběma koncepty jsou většinou z pohledu konstrukce programu minimální (jen si to nesmíme poplést). Příkladem použití timeoutu může být standardní funkce:

unsigned int sleep(unsigned int seconds);

V této funkci se parametrem specifikuje, na jak dlouho má být aktuální vlákno uspáno, což znamená, že pokud budeme vždy volat tuto funkci s parametrem 1, bude vlákno uspáno na přibližně jednu sekundu, nezávisle na tom, zda byla funkce zavolána nyní, či před hodinou.

Naproti tomu v knihovně libmill existuje funkce:

void msleep(int64_t deadline);

V níž se určuje absolutní čas zadaný v tomto případě pro větší přesnost v milisekundách. Pokud budeme chtít (v tomto případě korutinu) uspat na jednu sekundu, je následující volání špatně, protože 1000 znamená absolutní čas, konkrétně jednu sekundu na samotném počátku epochy:

msleep(1000);

Korektní volání by mělo používat funkci now() vracející aktuální čas:

msleep(now() + 1000);

Použití deadline si můžeme ukázat na následujícím příkladu, který většinou skončí dříve, než stačí korutiny vypsat všechny znaky:

#include <stdio.h>
#include "libmill.h"
 
coroutine void print(char what) {
    msleep(now() + 1000);
    putchar(what);
}
 
int main(void) {
    setvbuf(stdout, (char*)NULL, _IONBF, 0);
    char ch;
    for (ch='a'; ch<='z'; ch++) {
        go(print(ch));
    }
    return 0;
}

Další, tentokrát nepatrně složitější příklad, v němž je čekání vloženo i do hlavní korutiny:

#include <stdio.h>
#include "libmill.h"
 
coroutine void print(char what) {
    int i;
 
    for (i=0; i<10; i++) {
        msleep(now() + 100);
        putchar(what);
    }
}
 
int main(void) {
    setvbuf(stdout, (char*)NULL, _IONBF, 0);
    char ch;
    for (ch='a'; ch<='z'; ch++) {
        go(print(ch));
    }
    msleep(now() + 200);
    return 0;
}

13. Přepnutí běhu na jinou korutinu

V knihovně libmill nalezneme i funkci:

void yield(void);

V případě, že je tato funkce zavolána v nějaké korutině, provede se přepnutí kontextu na jinou korutinu a stávající korutina je pozastavena:

#include <stdio.h>
#include "libmill.h"
 
coroutine void print(char what) {
    int i;
 
    for (i=0; i<10; i++) {
        putchar(what);
        yield();
        msleep(now() + 100);
    }
}
 
int main(void) {
    setvbuf(stdout, (char*)NULL, _IONBF, 0);
    char ch;
    for (ch='a'; ch<='z'; ch++) {
        go(print(ch));
    }
    msleep(now() + 200);
    return 0;
}
Poznámka: chování funkce yield se poněkud odlišuje například od klíčového slova yield, které je použito v Pythonu. Základní myšlenka je však podobná – pozastavení vykonávání aktuálního kódu a přepnutí kontextu do kódu jiného.

14. Použití kanálů pro komunikaci mezi korutinami

V knihovně libmill se pro komunikaci mezi korutinami používají kanály, a to podobným způsobem, jako v již zmíněném Go či Clojure (sémantika je prakticky shodná, liší se pouze syntaxe). Musíme ovšem brát ohledy na to, že se v céčku musíme sami postarat o manuální uvolňování všech prostředků, což se týká i kanálů. Podporovány jsou tyto funkce a makra:

Funkce/makro Stručný popis V Go
chmake vytvoření kanálu s uvedením typu hodnot, které se mohou přenášet v Go je to řešeno přes make
chs poslání hodnoty do kanálu (send) v Go existuje operátor
chr přečtení hodnoty z kanálu (receive) v Go existuje operátor
chdone ukončení zápisu do kanálu  
chclose dealokace a uzavření kanálu close()
chdup duplikace reference na kanál (nemáme GC) řešeno v runtime
Poznámka: o kanálech s kapacitou si povíme více údajů příště.

15. Nejjednodušší použití kanálu

Následuje velmi jednoduchý příklad, v němž je použit jeden kanál pro komunikaci mezi dvojicí korutin. Korutina představovaná funkcí sender pošle do kanálu celočíselnou hodnotu odpovídající znaku ‚*‘ a následně kanál uzavře. Hlavní korutina na tuto hodnotu čeká a teprve po jejím příjmu přijatou hodnotu vypíše a ukončí se:

#include <stdio.h>
#include "libmill.h"
 
coroutine void sender(chan channel) {
    msleep(now() + 1000);
    chs(channel, int, '*');
    chclose(channel);
}
 
int main() {
    chan channel = chmake(int, 0);
 
    puts("vytvoreni korutiny");
    go(sender(chdup(channel)));
 
    puts("cekani na zpravu z korutiny");
    char c = chr(channel, int);
 
    puts("prijato");
    putchar(c);
    chclose(channel);
    return 0;
}
Poznámka: podobně jako je tomu například v již mnohokrát zmíněném jazyce Go, je toto jeden z mála korektních způsobů čekání na dokončení běhu korutiny, samozřejmě za předpokladu, že se po poslání zprávy do kanálu již nebudou provádět žádné další kritické operace.

16. Obdoba konstrukce select

V kapitole o Go jsme si mj. ukázali i programovou konstrukci typu select, která dokáže vybrat/poslat data do právě volného kanálu:

select {
case <-ch1:
        fmt.Println("Data z kanálu 1")
case <-ch2:
        fmt.Println("Data z kanálu 2")
default:
        fmt.Println("Žádná data nejsou k dispozici")
}

Tato konstrukce sice pochopitelně v programovacím jazyku C neexistuje, ale pomocí makrosystému ji lze do jisté míry nasimulovat. Pro kanály, ze kterých se provádí čtení by vypadala následovně:

choose {
    in(ch1, int, val):
        puts("Data z kanálu 1");
    in(ch2, int, val):
        puts("Data z kanálu 2");
    otherwise:
        puts("Žádná data nejsou k dispozici");
    end
}

Rozdíly jsou skutečně jen syntaktické, protože v C musíme explicitně určovat typ čtených dat a nepoužívat klíčová slova case a default, která jsou v Go „přetížena“.

Lze pochopitelně použít jak vstupní, tak i výstupní kanály:

choose {
    in(ch1, int, val):
        puts("Data z kanálu 1");
    out(ch2, int, val):
        puts("Data poslána do kanálu 2");
        ...
        ...
        ...
    otherwise:
        puts("Žádná data nejsou k dispozici");
    end
}
Poznámka: demonstrační příklad kombinující vstupní a výstupní kanály si ukážeme příště.

17. Použití konstrukce typu select

V dnešním posledním demonstračním příkladu je ukázáno použití dvou korutin posílajících (nezávisle na sobě) data do hlavní korutiny (první korutina je nepatrně pomalejší než korutina druhá). Každá korutina přitom pro komunikaci používá vlastní kanál, což znamená, že hlavní korutina musí využít programovou konstrukci typu select. Příklad ovšem není zcela korektní – bylo by vhodné, aby se v hlavní korutině nepoužívala počítaná smyčka, ale aby se zjišťovalo, kdy jsou ostatní korutiny ukončeny. Nápravu provedeme příště:

#include <stdio.h>
#include "libmill.h"
 
coroutine void sender1(chan channel) {
    int i;
    for (i=0; i<10; i++) {
        msleep(now() + 1000);
        chs(channel, int, '*');
    }
    chclose(channel);
}
 
coroutine void sender2(chan channel) {
    int i;
    for (i=0; i<10; i++) {
        msleep(now() + 800);
        chs(channel, int, '?');
    }
    chclose(channel);
}
 
int main() {
    chan channel1 = chmake(int, 0);
    chan channel2 = chmake(int, 0);
 
    puts("vytvoreni korutin");
    go(sender1(chdup(channel1)));
    go(sender2(chdup(channel2)));
 
    setvbuf(stdout, (char*)NULL, _IONBF, 0);
 
    puts("cekani na zpravy z korutin");
    int i;
    for (i=0; i<100; i++) {
        choose {
            in(channel1, int, val):
                putchar(val);
            in(channel2, int, val):
                putchar(val);
            otherwise:
                putchar('-');
            end
        }
        msleep(now() + 50);
    }
    chclose(channel1);
    chclose(channel2);
    return 0;
}

Příklad výstupu získaného po spuštění tohoto demonstračního příkladu:

bitcoin_skoleni

vytvoreni korutin
cekani na zpravy z korutin
----------------?---*-----------?-------*-------?-----------*---?---------------?*--------------?---
Poznámka: konkrétní výstup se samozřejmě bude prakticky s každým spuštěním nepatrně odlišovat.

18. Obsah následujícího článku

Knihovna libmill umožňuje provádět i mnohé další triky, například při práci se síťovými sockety apod. S těmito triky a na nich postavenými koncepty se seznámíme v navazujícím článku. Taktéž se pokusíme vizualizovat souběžnost práce korutin zápisem do framebufferu.

19. Repositář s demonstračními příklady

Zdrojové kódy všech dnes použitých demonstračních příkladů byly uloženy do nového Git repositáře, který je dostupný na adrese https://github.com/tisnik/concurrency-demos (stále na GitHubu :-). V případě, že nebudete chtít klonovat celý repositář (ten je ovšem – alespoň prozatím – velmi malý, dnes má přibližně jednotky kilobajtů), můžete namísto toho použít odkazy na jednotlivé demonstrační příklady, které naleznete v následující tabulce:

20. Odkazy na Internetu

  1. Libmill: Go-style concurrency in C
    http://libmill.org/
  2. Libmill na GitHubu
    https://github.com/sustrik/libmill
  3. libdill: Structured Concurrency for C
    http://libdill.org/
  4. libdill na GitHubu
    https://github.com/sustrik/libdill
  5. Libdill: Structured Concurrency for C (2016)
    https://news.ycombinator.com/i­tem?id=15977983
  6. Asynchronní programování v Clojure s využitím knihovny core.async
    https://www.root.cz/clanky/asynchronni-programovani-v-clojure-s-vyuzitim-knihovny-core-async/
  7. Asynchronní programování v Clojure s využitím knihovny core.async (pokračování)
    https://www.root.cz/clanky/asynchronni-programovani-v-clojure-s-vyuzitim-knihovny-core-async-pokracovani/
  8. Asynchronní programování v Clojure s využitím knihovny core.async (dokončení)
    https://www.root.cz/clanky/asynchronni-programovani-v-clojure-s-vyuzitim-knihovny-core-async-dokonceni/
  9. Communicating sequential processes (Wikipedia)
    https://en.wikipedia.org/wi­ki/Communicating_sequenti­al_processes
  10. Go Block Best Practices
    https://www.clojure.org/gu­ides/core_async_go
  11. Clojure core.async and Go: A Code Comparison
    https://blog.drewolson.org/clojure-go-comparison
  12. core.async API Reference
    https://clojure.github.io/core.async/
  13. Clojure core.async Channels
    http://clojure.com/blog/2013/06/28/clo­jure-core-async-channels.html
  14. FLOSS Weekly 358: Libmill
    https://www.youtube.com/watch?v=zUDhcN-8oQo
  15. Structured Concurrency Finding our way out of callback hell
    https://www.youtube.com/wat­ch?v=llYv1AitDH8
  16. Go part 21: Goroutines
    https://golangbot.com/goroutines/
  17. Go part 22: Channels
    https://golangbot.com/channels/
  18. [Go] Lightweight eventbus with async compatibility for Go
    https://github.com/asaske­vich/EventBus
  19. Goroutine IDs
    https://blog.sgmansfield.com/2015/12/go­routine-ids/
  20. Different ways to pass channels as arguments in function in go (golang)
    https://stackoverflow.com/qu­estions/24868859/different-ways-to-pass-channels-as-arguments-in-function-in-go-golang
  21. Select waits on a group of channels
    https://yourbasic.org/golang/select-explained/
  22. Communicating Sequential Processes. The First 25 Years
    https://www.springer.com/gp/bo­ok/9783540258131
  23. Dokumentace k Pythonovské knihovně goless
    https://goless.readthedoc­s.io/en/latest/
  24. Coroutine
    https://en.wikipedia.org/wi­ki/Coroutine
  25. Coroutine z Boostu
    https://www.boost.org/doc/lib­s/1_57_0/libs/coroutine/doc/html/in­dex.html
  26. Coroutine2 z Boostu
    https://www.boost.org/doc/lib­s/1_61_0/libs/coroutine2/doc/html/in­dex.html
  27. Concurrency is not parallelism
    https://blog.golang.org/concurrency-is-not-parallelism
  28. Why is node.js asynchronous?
    https://stackoverflow.com/qu­estions/17607280/why-is-node-js-asynchronous
  29. Stránka projektu node.js
    https://nodejs.org/en/
  30. CSP (elektronická podoba)
    http://www.usingcsp.com/

Autor článku

Vystudoval VUT FIT a v současné době pracuje na projektech vytvářených v jazycích Python a Go.