Kihagyás

Go

A gyakorlat anyaga

Az eddigi gyakorlatokon a közös memóriás párhuzamos programozási modellel foglalkoztunk a Linux IPC és a Java segítségével. Ennek során a folyamatok ugyanazokat a változókat használhatták, amelyeket akár egyidőben is megkísérelhettek olvasni és/vagy írni.

Most áttérünk az osztott memóriás párhuzamos programozásra. Általánosságban ez azt jelenti, hogy minden folyamat saját változókészlettel rendelkezik, ezeket kizárólagosan használják, azaz nem kell kezelnünk az egyidejű hozzáférést. Ebben az esetben a párhuzamosan futó folyamatok közötti kommunikáción, illetve a közöttük történő szinkronizáción (információcserén!) van a hangsúly.

A folyamatok együttműködése üzenetküldéssel történik, így folyamatok kezelése mellett szükség van olyan utasításokra, amelyek az üzenetek kezelését teszik lehetővé. Ehhez kapcsolódónak a következő fogalmak:

Az első a közvetlen és a közvetett címzés. Közvetlen címzés esetén szükség van arra, hogy a folyamatok létrehozáskor egyértelmű folyamatazonosítót kapjanak a rendszerben, ez alapján tudjuk megmondani hogy honnan hova küldjük az üzenetet. Közvetett címzés esetén nincs szükség folyamatazonosítókra, ehelyett csatornán keresztül történik a kommunikáció, a folyamatnak pedig csak a csatorna egyik végét kell látnia.

A második pedig a szinkron és aszinkron kommunikáció. Szinkron kommunikációnál a küldő bevárja a fogadót, annak megérkezésekor megtörténik az adatátvitel, majd mindkét folyamat tovább folytatja futását. Értelemszerűen aszinkron kommumnikációnál küldő folyamat nem várja be a fogadót, hanem folytatja futását. De van lehetőségünk szinkron kommunikációt aszinkron kommunikációva alakítani és fordítva is.

Golang

A Google által tervezett és karbantartott Go programozási nyelv leginkább a C/C++ nyelvre hasonlító szintaxissal rendelkezik, így például a kommentezési lehetőségek megegyeznek (// egysoros, /* */ többsoros komment), de eltérések mutatkoznak például a for ciklus vagy a feltételes vezérlés szintaxisában. Erősen típusos, de nem objektumorientált nyelv, azonban struct és interface segítségével hasonló működés előidézhető.

A go program futtatását egy szokásos Hello World!-jellegű példa segítségével mutatjuk be. Hozzunk létre egy könyvtárat, majd ebben a könyvtárban hozzunk létre egy .go kiterjesztésű állományt és másoljuk bele a következő kódot. A nyelvben a package-ek csak logikai csoportosítást jelöl, viszont a main package megkülönböztetett csomag, mivel a program belépési pontját jelöli.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

import (
    "fmt"
    "math/rand"
)

func main() {
    fmt.Println("Hello, my number is", rand.Intn(10))
}

Navigáljunk a létrehozott könyvtárba és adjuk ki a következő utasítást (amennyiben a fájl neve hello.go):

  • go run hello.go: az első futtatás esetenként lassabb lehet, mivel az utasítás hatására a go egy ideiglenes bináris fájlt hoz létre, amit aztán végrehajt

Egy másik megoldás, ha direkt módon build-eljük a bináris állományt, majd ezt követően futtatjuk:

  • go build hello.go

  • ./hello

Továbbá fontos, hogy a A Go fordító alapértelmezetten hibát dob, ha egy csomagot importálsz vagy egy változót hozol létre, de nem használod azt a kódban.

Függvények, változók, tömbök és vezérlési szerkezetek

Go-ban lehetőség van "hagyományos" függvények létrehozására, de létrehozhatunk több visszatérési értékkel rendelkező függvényeket is, amelyeket tipikusan hibakezelés során használnak. Ezenkívül van lehetőség nevesített visszatérési értékekkel rendelkező függvények létrehozására is.

A nyelvben a változók láthatósága korlátozódik package szintre (globális változók), illetve egy adott függvényen belül érvényes változókra (lokális változók). Névegyezőség esetében a lokális írja felül a globális változót!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
package main

import "fmt"

func sum(x int) string {
    switch x {
        case 1:
            return "A"
        case 2:
            return "B"
        default:
            return "C"
    }
}

func multi(a int, b int) (int, error) {
    if ( a > b) {
        return 0, fmt.Errorf("Wrong interval.")
    } else {
        var sum int = 0
        for i := a; i < b; i++ {
            sum += i
        }
        return sum, nil
    }
}

func maxpos(numbers []int) (max int, position int) {
    max = numbers[0]
    position = 0    

    for index, value := range numbers {
        if (value > max) {
            max = value
            position = index
        }
    }
    return
}

var pac int = 10 

func main() {
    var mypi float32 = 3.14 
    str := "hello" 
    const con = 3

    fmt.Println(pac, mypi, str, con)

    fmt.Println(sum(1))

    res, err := multi(5,1) 

    if(err != nil){
        fmt.Println(err)
    }else{
        fmt.Println(res)
    }

    primes := []int{2, 11, 5, 13, 3, 7}

    m, p := maxpos(primes)
    fmt.Println(primes, "maximális eleme:", m, "pozíciója:", p, "tömb hossza:", len(primes), "résztömbje:", primes[p:])

    func() {
        fmt.Println("anonym")
    } ()
}

Defer

A defer egy speciális funkció (és kulcssszó), amely egyfajta késleltetett végrehajtásra alkalmas. A defer kulcsszóval ellátott függvényhívás argumentumai azonnal kiértékelésre kerülnek, de a függvényhívás nem hajtódik azonnal végre. Több egymást követő, defer kulcsszóval meghívott utasítások egy LIFO végrehajtást eredményeznek a defer függvényeket hívó függvény visszatérése előtt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package main

import (
    "fmt"
    "time"
)

func printer(str string) {
    for i := 0; i < 10; i++{
        fmt.Println(str, i)
        time.Sleep(200 * time.Millisecond)
    }
}

func main() {
    fmt.Println("counting")
    defer printer("A");
    fmt.Println("done")
}

A defer hasznos kis konstrukció komplexebb függvények esetén, hiszen segítségével biztosítani tudunk egy függvényhívást pontosan azelőtt, amikor a defer utasítást tartalmazó függvény visszatérne. Így tehát érdemes lehet kiszervezni a defer függvénybe például az erőforrásokat (pl. I/O) lezáró hívásokat.

Go szálkezelés

A Go szálkezelését az ún. goroutine valósítja meg, új goroutine-t a go kulcsszó segítségével tudunk létrehozni úgy, hogy egy adott függvényhívás elé kiírjuk a kulcsszót. A hivatalos dokumentáció úgy hivatkozik rá, mint egy lightweight thread. Ezek a függvények nem blokkolják az őt meghívó függvény végrehajtását, a függvény visszatérési értéke pedig nem kerül tárolásra. Fontos még megjegyezni, hogy a goroutine-t használó programok ún. concurrent programok, tehát nem feltétlenül futnak párhuzamosan. A goroutine-oknak nincs saját szála, tehát valójában sok goroutine futhat egyetlen szálon időosztás segítségével. A Go futtatókörnyezet szükség szerint dinamikusan multiplexeli a gorutinokat a szálakra, hogy az egyes gorutinok folyamatosan fussanak.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

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


var wg sync.WaitGroup

func f(x string) {
    for i := 0; i < 10; i++ {
        fmt.Println(x, i)
        time.Sleep(200 * time.Millisecond)
    }
}

func main() {
    go f("A")
    f("B")
}

A goroutine-ok esetén figyelnünk kell még arra, hogy a main függvény a meghívott goroutine-ok bejeződését nem várja meg, a programunk terminálni fog. Anonim függvény is futtatható goroutine-ként. Elsőre egy nem túl szép, de hatásos megoldás lehet egyszerűbb programoknál, ha valamilyen felhasználói interakcióhoz kötjük a main terminálását, pl. beolvasunk egy értéket:

1
2
    var input string
    fmt.Scanln(&input)

Egy másik lehetséges eszközt a sync package biztosítja. WaitGroup használata esetén lehetőségünk van goroutine-ok befejeződését megvárni egy adott pontján a programnak. 3 függvénye van:

  • Add(int): a WaitGroup számlálója, tipikusan a bevárandó goroutine-ok számával hívjuk meg

  • Wait(): várakozik, amíg a számláló 0 nem lesz

  • Done(): csökkenti a számlálót 1-gyel

Csatorna

A Go a közvetett címzést használja, tehát ahhoz, hogy a goroutine-ok kommunikálni (és szinkronizálni) tudjanak, szükség van csatornákra. Ez egy típusos konstrukció, amelyen értékeket tudunk küldeni és fogadni a csatornaoperátorral. Nincs külön operátor a küldésre és a fogadásra, hanem a csatornaazonosító és a csatornaopárator egymáshoz viszonyított sorrendje adja meg az adatfolyam irányát; c csatorna és x érték alapján:

  • c <- x : x-et ráküldjük a csatornára
  • x := <- c : c csatornáról fogadunk egy értéket

Használatához még kettő kulcsszóra lesz szükségünk: make és a chan.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
    "fmt"
    "sync"
)


var wg sync.WaitGroup

func ping(c chan string) {
    c<- "ping"
    msg := <-c
    fmt.Println(msg)
    wg.Done()
}

func pong(c chan string) {
    msg := <-c
    fmt.Println(msg)
    c<- "pong" // idézzünk elő holtpontot az utasítás kikommentezésével
    wg.Done()
}

func main() {
    var c chan string = make(chan string)

    wg.Add(2)
    go ping(c)
    go pong(c)
    wg.Wait()
}

A csatornán való küldés szinkronküldés, azaz a fogadó blokkolódik, amíg a másik oldal, azaz a küldő nem jut el a saját szinkronizációs pontjáig, ahol leolvassa a csatornán található értékeket. A csatorna megőrzi a csatornára küldött adatok sorrendjét.

Egyirányú és pufferelt csatorna

Go-ban lehetőségünk van meghatározni, hogy egy létrehozott csatornát az egyik folyamat mindig csak adat fogadására, míg egy másik folyamat mindig csak adat küldésére használja. Tehát lehetőségünk van a csatornát egyirányúsítani, amellyel így szigorúbb megkötéseket tudunk tenni, összetett program esetén segíti a kód struktúrálását, fordítási időben már megjelenő tervezési hibák felderítését:

  • func a(c chan<- string): a függvény agrumentuma egy olyan c csatorna, amelyre csak adatot küldünk

  • func b(c <-chan string): a függvény argumentuma egy olyan c csatorna, amelyről csak adatot fogadunk

Egy gyakori jelenség, hogy a küldő már elküldte az adatot, de a fogadó még nem olvasta le azt a csatornáról azt, mivel nem jutott el a leolvasást vezérlő kódrészletig. Ilyen esetben a programunk futása megáll, hiszen a csatornán történő kommunikáció alapértelmezetten szinkron kommunikáció. Szerencsés esetben csupán rövid ideig tart a blokkolás, de akár holtpontba is kerülhet a programunk (pl. egy érték csatornára küldése miatt, ami sosem kerül leolvasásra).

Ennek a problémának - azaz mikor a szinkron kommunikáció blokkolásra késztet egy folyamatot - egy lehetséges megoldása lehet az ún. pufferelt csatorna. Egy pufferelt csatorna létrehozása esetén annak méretét is meg kell mondanunk. Adatküldés egy pufferelt csatornára csak akkor blokkolódik, ha a puffer tele van. Ha a puffer nem üres, akkor a fogadó nem vár egy újabb adat küldésére, hanem a buffer-ból olvassa ki a soronkövetkező értéket.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package main

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


var wg sync.WaitGroup

func ping(c chan<- int) {
    for i := 0; i < 5; i++{
        fmt.Println(i, "elküldve")
        c<- i
        time.Sleep(1000 * time.Millisecond)
    }
    close(c) // a csatornára nem küldünk több adatot
    wg.Done()
}

func pong(c <-chan int) {
    for value := range c {
        fmt.Println(value, "fogadva")
        time.Sleep(3000 * time.Millisecond)
    }
    wg.Done()
}

func main() {
    var c chan int = make(chan int, 1) // még 1 üzenetet képes tárolni a csatorna pluszban

    wg.Add(2)
    go ping(c)
    go pong(c)
    wg.Wait()
}

A fogadó folyamatok tesztelhetik is, hogy egy csatorna bezárt-e (value lesz a csatornáról fogadott érték, az ok pedig hamis, ha a csatorna már korábban bezárásra került):

value, ok := <-ch

Select

A select az egyik legösszetettebb Go folyamat, az ún. "gyors várakozást" valósítja meg azáltal, hogy több bejövő csatornát figyel és a legelső kommunikációs kísérletre reagál, ehhez egy switch-szerű formát használ. Mivel a program írásakor nem feltétlen tudhatjuk, hogy melyik csatornáról fog adat érkezni, ezért az select-et tartalmazó programok nemdeterminisztikusak. A holtpont veszélye sincs kizárva, mert azt sem tudhatjuk előre, hogy a felsorolt csatornákra egyáltalán fog-e érkezni adat. Ennek kiküszöbölésére külön figyelmet kell fordítanunk.

A select blokkol, amíg adatot nem küldünk vagy nem fogadunk. Ha több csatorna is "kész" (azaz van adat küldésére vagy fogadására), akkor a select véletlenszerűen választ egyet, és azt végrehajtja. A select tartalmazhat default ágat is.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package main

import (  
    "fmt"
    "time"
    "math/rand"
)

func example1(ch chan<- string) {
    ch<- "from client1 (1x)"
    time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
    ch<- "from client1 (2x)"
}

func example2(ch chan<- string) {  
    time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
    ch<- "from client2 (2x)"
}

func example3(ch <-chan string) {  
    fmt.Println(<-ch)
}

func main() {  
    channel1 := make(chan string)
    channel2 := make(chan string)
    channel3 := make(chan string)

    go example1(channel1)
    go example2(channel2)
    go example3(channel3)

    for { 
        select { 
            case ans := <-channel1:
                fmt.Println(ans)
            case ans := <-channel2:
                fmt.Println(ans)
            case channel3<- "to client3": // adatküldésre is használható
            default:
                fmt.Println("no activity..")
        }
        time.Sleep(1 * time.Second)
    }
}

További érdekesség, hogy a select adatküldésre is használható, például véletlen 0-1 sorozat küldése a csatornára (hiszen ha több is végrehajtható, akkor a végrehajthatók közül véletlenszerűen kerül kiválasztásra az ág, amelyik lefut):

1
2
3
4
5
6
for { 
    select {
        case c <- 0:  
        case c <- 1:
    }
}

Csővezeték

Csővezeték esetén egy láncban helyezzük el a folyamatokat, amelyek egy feldolgozás munkafázisait végzik el a rajtuk áthaladó adatokon. Egyidőben több adatelem van feldolgozás alatt, mindegyik másik munkafázisban. Egy adatelemnek a teljes feldolgozáshoz végig kell járnia sorrendben az összes munkafázist:

kolcsonos-kizaras

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
    "sync"
    "bufio"
    "os"
)


var wg sync.WaitGroup

// szűrés
func first(in *bufio.Reader, out chan<- int) {
    reading := true

    for reading {
        value, _ := in.ReadByte()

        if 97 <= value && value <= 122 {
            out <- int(value)
        } else if value == 48 {
            reading = false
            close(out)
        }
    }
}

// transzformálás
func second(in <-chan int, out *bufio.Writer) {

    for value := range in {
        out.WriteByte(byte(value - 32))
        out.Flush() // kiürítjük a a writer buffer-ét
    }
    wg.Done();
}

func main() {
    var c chan int = make(chan int)
    in := bufio.NewReader(os.Stdin)
    out := bufio.NewWriter(os.Stderr)

    wg.Add(1)
    go first(in, c)
    go second(c, out)
    wg.Wait()
}

Gyakorló feladat

(1) Implementáljunk egy statisztika készítő programot, ami egy tömb segítségével összegzi, hogy mennyi kis- és nagybetű, illetve szám karakter került lenyomásra, minden mást eldob. A program a TAB billentyű segítségével termináljon. A program a következő goroutine-okból álljon, amelyek egyirányú, kétszeresen pufferelt, BYTE csatorna segítségével kommunikáljon:

  • A: a standard input-ról olvas és szűri a megadott feltételek alapján a karaktereket,

  • B: minden egyes fogadott érték alapján a tömb megfelelő indexével jelölt értéket növeli 1-gyel

Kapcsolódó linkek

Go dokumentáció

Go standard könyvtárak


Utolsó frissítés: 2025-03-31 10:47:37