Golang-Advance- GO routine


Posted by jerryeml on 2021-04-13

在討論 Go routine之前, 必須先了解 concurrency 和 parllelism的不同以及Coroutine

從中譯的角度解釋:

  • Concurrency:並發性
  • Parallelism:平行性

What is Concurrency

Concurrency is about dealing with lots of things at once. — Rob Pike

Concurrent 則是許多任務在爭搶同一個 CPU 的資源,因此一個時間點只會有一個任務正在執行,只是因為切換非常快,使用者通常不會感覺到任務實際上一直在切換。

What is Parllelism

Parallelism is about doing lots of things at once. — Rob Pike

Parallel 並行是利用多個 CPU 達到同時並行處理任務的需求(也就是同一個時間點有許多任務在同時執行)

Difference

用早餐店的工作解釋:

早餐店有A,B共二位員工以及三種類型的工作內容(1.烤土司、2.煎蛋與火腿、3.組合1~2做成火腿蛋土司)

  • Concurrency:A在烤土司的同時會去煎蛋和火腿,而B會隨時注意土司烤好沒,同時把A煎好的蛋與火腿,一起組合成火腿蛋土司。
  • Parallelism:A、B分別做自己的火腿蛋土司,不共享食材。

以工作的分配情形解釋:

  • Concurrency:相同的工作集合,一起完成同一份工作,互相合作,做團績
  • Parallelism:不同的工作集合,各自完成自己的工作,不互相干擾,有各自的考績

Go is a concurrent language and not a parallel one

所以從字面上來看, GO 是一種 並發 的語言 而不是 平行 的語言

那我們了解 Concurrency 和 Parllelism的差異後 我們來繼續談談 Process / Thread / Coroutine


從以上的圖,可以一眼看出 Process、Thread、Coroutine 的關係
以往我學 OS 的時候,只聽過進程 (Process)、線程 (Thread)

Process

指的就是執行中的程式的一個 Instance,這個實例是 OS 分配資源的基本單位。它會拿到哪些資源呢?有 CPU Time、Memory、I/O Devices 等等,意思就是說這個 Process 執行的時候會需要用到多久的 CPU 時間、花費多少記憶體、甚至可能會需要一些 I/O 設備的資源。

開個 Process Explorer 就會知道你的電腦裡面有超多個Process...

每一個 Instance 對應到都是一個 Process,而 Process 對於 OS 而言就是一個任務的意思。此外,每一個 Process 都有各自對應的獨立地址空間。

Process主要由哪些元件組成

  1. code section
    指的就是程式碼區域,因為每一個進程都是因為執行某段程式碼而開啟的。

  2. data section
    指的就是數據區域,代表進程會用到哪些資源

  3. programming counter
    指的就是數據區域,代表進程會用到哪些資源

  4. cpu registers
    指的是 cpu 的暫存器的內容,專門暫存指令、資料和位址的記憶體空間

  5. stack region
    指的是堆疊區域,負責存放 process 活動過程需要調用的指令及本地變量

Process狀態

一個Process會依據情況而產生以下不同的狀態:

  • new (新產生):該進程正在產生中
  • ready (就緒):該進程正在等待 CPU 分配資源,只要一拿到資源就可以馬上執行
  • running (執行):該進程取得 CPU 資源並且執行中
  • waiting (等待):該進程在等待某個事件的發生,可能是等待 I/O 設備輸入輸出完成或者是接收到一個信號,也可以想成是被 block (阻塞) 住
  • exit (結束):該進程完成工作,將資源釋放掉

Process的優缺點

優點:相對比較穩定安全,因為每一個Process都擁有獨立的系統資源,進程間不容易相互影響,而且因為不會共享 data 的問題,所以不須對進程作互斥存取之機制。

缺點:Process的建立及切換 (context switching) 的開銷都比較大,因為涉及到 OS 資源的切換,彼此進程間要通信也比較複雜及耗時。

小結:程式 (Program)是寫好尚未執行的 code,程式被執行後才會變成進程 (Process)

Thread

又叫做是 light weight process,也就是輕量化的 Process,事實上,Thread 可以想成存在在 Process 裡面,一個Process中至少會有一個Thread,而我們前面說Process會去執行任務,其實就是Process裡面的Thread去做的,所以沒有Process就沒有Thread。而當一個Process裡面有多線程,就代表在一個程式中透過開啟多個Thread的方式來完成不同的任務。而Thread是 OS 分配 CPU 時間 之對象單位。此外,在一個Process裡面的多個Thread會共享進程的系統資源,也就是前面所提Process組成的元件等等。

Thread組成

  • Thread ID
  • Programming Counter
  • CPU registers
  • stack

Multi-Thread

當一個Process裡面有多個線程同時執行,就能執行多個任務,但其實也可以是多個進程,每個進程單一線程達到執行多個任務的效果,但是因為多個進程之間是不會共享資源,所以切換進程會需要很多成本,反之,線程因為會共享,切換的成本較小,提高了 OS 的並發性能。這也是為什麼會有多線程的方式產生。

而 CPU 多核的方式,也可以將一個Process裡面的多個Thread分散到不同核心上變成並行的方式執行任務,達到實質上同時做任務!

而最大的缺點:Multi-Thread在共享資源會有 race condition 也就是互斥存取的問題產生,這也是開發者在開發多線程的程式需要特別注意的一個點。

Coroutine

協程是一種用戶態的輕量級線程,可以想成一個線程裡面可以有多個協程,而協程的調度完全由用戶控制,協程也會有自己的 registers、context、stack 等等,並且由協程的調度器來控制說目前由哪個協程執行,哪個協程要被 block 住。

而相對於 Thread 及 Process 的調度,則是由 CPU 內核去進行調度,因此 OS 那邊其實會有所謂許多的調度算法,並且可以進行搶占式調度,可以主動搶奪執行的控制權。反之,協程是不行的,只能進行非搶佔式的調度。可以理解成,如果 coroutine 被 block 住,則會在用戶態直接切換另外一個 coroutine 給此 thread 繼續執行,這樣其他 coroutine 就不會被 block 住,讓資源能夠有效的被利用,藉此實現 Concurrent 的概念。

Coroutine VS Thread

  • Coroutine只需花幾 KB 就可以被創立,Thread則需要幾 MB 的記憶體才能創立
  • 切換開銷方面,Coroutine遠遠低於Thread,切換的速度也因此大幅提升

Go routine 的誕生

Goroutines are part of making concurrency easy to use. The idea, which has been around for a while, is to multiplex independently executing functions—coroutines—onto a set of threads. When a coroutine blocks, such as by calling a blocking system call, the run-time automatically moves other coroutines on the same operating system thread to a different, runnable thread so they won’t be blocked. The programmer sees none of this, which is the point. The result, which we call goroutines, can be very cheap: they have little overhead beyond the memory for the stack, which is just a few kilobytes.

Golang 語言的 goroutine 其實就是協程,特別的是在語言層面直接原生支持創立協程,並在 runtime、系统調用等多方面對 goroutine 調度進行封裝及處理。相較於 Java 的建立 Thread,OS 是會直接建立一個 Thread 與其對應,而當兩個 Thread,要互相切換需要透過 kernel thread 來進行,會有較大的 context switch 的資源耗費,而 goroutine 是在程式碼上直接實現切換,不需要經過 kernel thread。

Go routine 的優勢:

  • 與 Thread 相比Goroutine非常便宜非常便宜 可以根據應用程序的需求auto scale , 但在 Thread之下 大小是固定的
  • 使用Goroutine訪問share memery的時候 透過 Channel 可以避免race condition的發生

Go routine 使用

package main

import (
    "fmt"
)

func main() {
    /*
        一个goroutine印出数字,另外一个goroutine印出字母,觀察運行結果。。

        并发的程序的运行结果,每次都不一定相同。
        不同计算机设备执行,效果也不相同。


        go语言的并发:go关键字
            系统自动创建并启动主goroutine,执行对应的main()
            用于自己创建并启动子goroutine,执行对应的函数

            go 函数()//go关键创建并启动goroutine,然后执行对应的函数(),该函数执行结束,子goroutine也随之结束。

                子goroutine中执行的函数,往往没有返回值。
                如果有也会被舍弃。
    */

    //1.先创建并启动子goroutine,执行printNum()
    go printNum()

    //2.main中打印字母
    for i := 1; i <= 1000; i++ {
        fmt.Printf("\t主goroutine中打印字母:A %d\n", i)
    }

    //time.Sleep(1 * time.Second)
    fmt.Println("main...over...")

}

func printNum() {
    for i := 1; i <= 1000; i++ {
        fmt.Printf("子goroutine中打印数字:%d\n", i)
    }
}

從這份檔案中我們玩到了甚麼

  • 當新的go routine 開始的時候, 不會等待go routine結束 並且忽略的任何回傳值, 執行下一行程式碼
  • main 的 go routine會執行其他的go routine, 但如果 main的go routine 終止了 那其他的go routine也會一起被終止

啟動多個go routine

package main

import (
    "fmt"
    "time"
)

func mygo(name string) {
    for i := 0; i < 10000; i++ {
        fmt.Printf("In goroutine %s %d\n", name, i)
        // 為了避免第一個協程執行過快,觀察不到并發的效果,加個休眠
        //time.Sleep(1 * time.Millisecond)
    }
}

func main() {
    go mygo("協程1號") // 第一個協程
    go mygo("協程2號") // 第二個協程
    time.Sleep(1 * time.Second)
    fmt.Println("main end...")
}

輸出如下,可以觀察到兩個協程就如兩個執行緒一樣,并發執行

In goroutine 協程2號 9952
In goroutine 協程2號 9953
In goroutine 協程2號 9954
In goroutine 協程2號 9955
In goroutine 協程2號 9956
In goroutine 協程2號 9957
In goroutine 協程2號 9958
In goroutine 協程2號 9959
In goroutine 協程2號 9960
In goroutine 協程2號 9961
In goroutine 協程2號 9962
In goroutine 協程2號 9963
In goroutine 協程2號 9964
In goroutine 協程2號 9965
In goroutine 協程2號 9966
In goroutine 協程2號 9967
In goroutine 協程2號 9968
In goroutine 協程2號 9969
In goroutine 協程2號 9970
In goroutine 協程2號 9971
In goroutine 協程2號 9972
In goroutine 協程2號 9973
In goroutine 協程2號 9974
In goroutine 協程2號 9975
In goroutine 協程2號 9976
In goroutine 協程2號 9977
In goroutine 協程2號 9978
In goroutine 協程2號 9979
In goroutine 協程2號 9980
In goroutine 協程2號 9981
In goroutine 協程2號 9982
In goroutine 協程2號 9983
In goroutine 協程2號 9984
In goroutine 協程2號 9985
In goroutine 協程2號 9986
In goroutine 協程2號 9987
In goroutine 協程2號 9988
In goroutine 協程2號 9989
In goroutine 協程2號 9990
In goroutine 協程2號 9991
In goroutine 協程2號 9992
In goroutine 協程2號 9993
In goroutine 協程2號 9994
In goroutine 協程2號 9995
In goroutine 協程2號 9996
In goroutine 協程2號 9997
In goroutine 協程2號 9998
In goroutine 協程2號 9999
main end...

Go routine 機制原理

理解goroutine機制的原理,關鍵是理解Go语言是如何實現scheduler的

Go语言中支撑整个scheduler实现的主要有4个重要结构,分别是M、G、P、Sched, 前三个定义在runtime.h中,Sched定义在proc.c中。

  • Sched结构就是调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。
  • M结构是Machine,系统Thread,它由操作系统管理的,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息。
  • P结构是Processor,处理器,它的主要用途就是用来执行goroutine的,它维护了一个goroutine队列,即runqueue。Processor是让我们从N:1调度到M:N调度的重要部分。
  • G是goroutine实现的核心结构,它包含了栈,指令指针,以及其他对调度goroutine很重要的信息,例如其阻塞的channel。

我们分别用三角形,矩形和圆形表示Machine Processor和Goroutine。

在单核处理器的场景下,所有goroutine运行在同一个M系统线程中,每一个M系统线程维护一个Processor,任何时刻,一个Processor中只有一个goroutine,其他goroutine在runqueue中等待。一个goroutine运行完自己的时间片后,让出上下文,回到runqueue中。 多核处理器的场景下,为了运行goroutines,每个M系统线程会持有一个Processor。

可以看到Go的并发用起来非常简单,用了一个语法糖将内部复杂的实现结结实实的包装了起来。其内部可以用下面这张图来概述:

GO routine - runtime package

常用函数
  • NumCPU:返回当前系统的 CPU 核数量
  • GOMAXPROCS:设置最大的可同时使用的 CPU 核数(运行时系统的P最大值总会在1~256之间)
  • Goexit:退出当前 goroutine(但是defer语句会照常执行)
  • NumGoroutine:返回正在执行和排队的任务总数
  • GOOS:目标操作系统
  • runtime.GC:会让运行时系统进行一次强制性的垃圾收集
  • GOROOT :获取goroot目录
  • GOOS : 查看目标操作系统 很多时候,我们会根据平台的不同实现不同的操作,就而已用GOOS了
Sample
package main

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

func init() {
    //获取逻辑cpu的数量
    fmt.Println("逻辑CPU的数量-->", runtime.NumCPU())

    //设置go程序执行的最大的cpu的数量:[1,256]
    n := runtime.GOMAXPROCS(runtime.NumCPU())
    fmt.Println(n)
}
func main() {
    //获取goroot目录
    fmt.Println("GOROOT-->", runtime.GOROOT()) //GOROOT--> /usr/local/go
    //获取操作系统
    fmt.Println("os/platform-->", runtime.GOOS) //darwin ,mac系统

    //创建goroutine
    go func() {
        fmt.Println("goroutine开始。。")
        //调用fun
        fun()
        fmt.Println("goroutine结束。。。")
    }()

    //睡一会儿
    time.Sleep(3 * time.Second)

}

func fun() {
    defer fmt.Println("derfer...")
    // return //终止函数
    runtime.Goexit() //终止当前的goroutine
    fmt.Println("fun函数。。。")
}

GO routine - Race condition

共享資源的問題
可能會造成搶資源的狀況造成飢餓或是value錯誤

package main

import (
    "fmt"
    "time"
)

func main() {
    /*
        临界资源:
    */
    a := 1
    go func() {
        a = 2
        fmt.Println("goroutine中。。", a)
    }()

    a = 3
    time.Sleep(1)
    fmt.Println("main goroutine...", a)
}

執行 go run -race demo03_race.go

PS E:\Coding\practice\go_goroutine\l_goroutine> go run -race .\demo03_race.go

==================
WARNING: DATA RACE
Write at 0x00c00012c058 by goroutine 7:
  main.main.func1()
      E:/Coding/practice/go_goroutine/l_goroutine/demo03_race.go:14 +0x44

Previous write at 0x00c00012c058 by main goroutine:
  main.main()
      E:/Coding/practice/go_goroutine/l_goroutine/demo03_race.go:18 +0x92

Goroutine 7 (running) created at:
  main.main()
      E:/Coding/practice/go_goroutine/l_goroutine/demo03_race.go:13 +0x84
==================
goroutine中。。 2
main goroutine... 2
Found 1 data race(s)
exit status 66

並發本身并不複雜,但是因为有了資源競爭的问题,就使得我们开发出好的并发程序变得复杂起来,因为会引起很多莫名其妙的问题。

如果多个goroutine在访问同一个数据资源的时候,其中一个线程修改了数据,那么这个数值就被修改了,对于其他的goroutine来讲,这个数值可能是不对的。

举个例子,我们通过并发来实现火车站售票这个程序。一共有100张票,4个售票口同时出售。

package main

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

//全局变量,表示票
var ticket = 10 //100张票
func main() {
    /*
        4个goroutine,模拟4个售票口,
    */
    go saleTickets("售票口1")
    go saleTickets("售票口2")
    go saleTickets("售票口3")
    go saleTickets("售票口4")

    time.Sleep(5 * time.Second)
}

func saleTickets(name string) {
    rand.Seed(time.Now().UnixNano())
    for {
        if ticket > 0 {
            time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
            fmt.Println(name, "售出:", ticket)
            ticket--
        } else {
            fmt.Println(name, "售罄,没有票了。。")
            break
        }
    }
}

Output

// Output
// 售票口3 售出: 8
// 售票口3 售出: 6
// 售票口1 售出: 5
// 售票口4 售出: 4
// 售票口1 售出: 3
// 售票口2 售出: 2
// 售票口4 售出: 1
// 售票口4 售罄,没有票了。。
// 售票口2 售出: 0
// 售票口2 售罄,没有票了。。
// 售票口3 售出: -1
// 售票口3 售罄,没有票了。。
// 售票口1 售出: -2
// 售票口1 售罄,没有票了。。

分析:

我们的卖票逻辑是先判断票数的编号是否为负数,如果大于0,然后我们就进行卖票,只不过在卖票钱先睡眠,然后再卖,假如说此时已经卖票到只剩最后1张了,某一个goroutine持有了CPU的时间片,那么它再片段是否有票的时候,条件是成立的,所以它可以卖票编号为1的最后一张票。但是因为它在卖之前,先睡眠了,那么其他的goroutine就会持有CPU的时间片,而此时这张票还没有被卖出,那么第二个goroutine再判断是否有票的时候,条件也是成立的,那么它可以卖出这张票,然而它也进入了睡眠。。其他的第三个第四个goroutine都是这样的逻辑,当某个goroutine醒来的时候,不会再判断是否有票,而是直接售出,这样就卖出最后一张票了,然而其他的goroutine醒来的时候,就会陆续卖出了第0张,-1张,-2张。

这就是临界资源的不安全问题。某一个goroutine在访问某个数据资源的时候,按照数值,已经判断好了条件,然后又被其他的goroutine抢占了资源,并修改了数值,等这个goroutine再继续访问这个数据的时候,数值已经不对了。

GO routine - Race condition Solution

要想解决临界资源安全的问题,很多编程语言的解决方案都是同步。通过上锁的方式,某一时间段,只能允许一个goroutine来访问这个共享数据,当前goroutine访问完毕,解锁后,其他的goroutine才能来访问。

package main

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

//全局变量
var ticket = 10 // 100张票

var wg sync.WaitGroup
var matex sync.Mutex // 创建锁头

func main() {
    /*
    4个goroutine,模拟4个售票口,4个子程序操作同一个共享数据。
     */
    wg.Add(4)
    go saleTickets("售票口1") // g1,100
    go saleTickets("售票口2") // g2,100
    go saleTickets("售票口3") //g3,100
    go saleTickets("售票口4") //g4,100
    wg.Wait()              // main要等待。。。

    //time.Sleep(5*time.Second)
}

func saleTickets(name string) {
    rand.Seed(time.Now().UnixNano())
    defer wg.Done()
    //for i:=1;i<=100;i++{
    //  fmt.Println(name,"售出:",i)
    //}
    for { //ticket=1
        matex.Lock()
        if ticket > 0 { //g1,g3,g2,g4
            //睡眠
            time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
            // g1 ,g3, g2,g4
            fmt.Println(name, "售出:", ticket) // 1 , 0, -1 , -2
            ticket--                         //0 , -1 ,-2 , -3
        } else {
            matex.Unlock() //解锁
            fmt.Println(name, "售罄,没有票了。。")
            break
        }
        matex.Unlock() //解锁
    }
}

在Go的并发编程中有一句很经典的话:不要以共享内存的方式去通信,而要以通信的方式去共享内存。

在Go语言中并不鼓励用锁保护共享状态的方式在不同的Goroutine中分享信息(以共享内存的方式去通信)。而是鼓励通过channel将共享状态或共享状态的变化在各个Goroutine之间传递(以通信的方式去共享内存),这样同样能像用锁一样保证在同一的时间只有一个Goroutine访问共享状态。

GO routine - sync package

GO routine - watiGroup

GO routine - mutex

我们知道了在并发程序中,会存在临界资源问题。就是当多个协程来访问共享的数据资源,那么这个共享资源是不安全的。为了解决协程同步的问题我们使用了channel,但是Go语言也提供了传统的同步工具。

什么是锁呢?就是某个协程(线程)在访问某个资源时先锁住,防止其它协程的访问,等访问完毕解锁后其他协程再来加锁进行访问。一般用于处理并发中的临界资源问题。

Go语言包中的 sync 包提供了两种锁类型:sync.Mutex 和 sync.RWMutex。

Mutex 是最简单的一种锁类型,互斥锁,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex。

每个资源都对应于一个可称为 “互斥锁” 的标记,这个标记用来保证在任意时刻,只能有一个协程(线程)访问该资源。其它的协程只能等待。

互斥锁是传统并发编程对共享资源进行访问控制的主要手段,它由标准库sync中的Mutex结构体类型表示。sync.Mutex类型只有两个公开的指针方法,Lock和Unlock。Lock锁定当前的共享资源,Unlock进行解锁。

在使用互斥锁时,一定要注意:对资源操作完成后,一定要解锁,否则会出现流程执行异常,死锁等问题。通常借助defer。锁定后,立即使用defer语句保证互斥锁及时解锁。

但GO語言比較推薦 channel 來實現XD

GO routine - channel

通道可以被认为是Goroutines通信的管道。类似于管道中的水从一端到另一端的流动,数据可以从一端发送到另一端,通过通道接收。

在前面讲Go语言的并发时候,我们就说过,当多个Goroutine想实现共享数据的时候,虽然也提供了传统的同步机制,但是Go语言强烈建议的是使用Channel通道来实现Goroutines之间的通信。

不要通过共享内存来通信,而应该通过通信来共享内存” 这是一句风靡golang社区的经典语

通道是什么,通道就是goroutine之间的通道。它可以让goroutine之间相互通信。

舉個例子來說
兩個go routinne 如果想要溝通字串 可以建立一個string的channel 來進行溝通

package main

import "fmt"

func main() {
    /*
        channel,通道
    */
    var a chan int
    fmt.Printf("%T,%v\n", a, a)

    if a == nil {
        fmt.Println("channel是nil的,不能使用,需要先创建通道。。")
        a = make(chan int)
        fmt.Println(a)
    }
    test1(a)
}

func test1(ch chan int) {
    fmt.Printf("%T,%v\n", ch, ch)
}

再來看到範例二

package main

import "fmt"

func main() {
    var ch1 chan bool
    ch1 = make(chan bool)

    go func() {
        for i := 0; i < 10; i++ {
            fmt.Println("子goroutine中,i:", i)
        }
        //循环结束后,向通道中写数据,表示要结束了。。
        ch1 <- true
        fmt.Println("结束。。")
    }()

    // data := <-ch1
    // fmt.Println("main...data-->", data)
    fmt.Println("main...over...")
}

Notes:

  1. 用于goroutine,传递消息的。
  2. 通道,每个都有相关联的数据类型, nil chan,不能使用,类似于nil map,不能直接存储键值对
  3. 使用通道传递数据:<- chan <- data,发送数据到通道。向通道中写数据 data <- chan,从通道中获取数据。从通道中读数据
  4. 阻塞: 发送数据:chan <- data,阻塞的,直到另一条goroutine,读取数据来解除阻塞 读取数据:data <- chan,也是阻塞的。直到另一条goroutine,写出数据解除阻塞。
  5. 本身channel就是同步的,意味着同一时间,只能有一条goroutine来操作。


#golang #goroutine #advance







Related Posts

Command Line Interface (CLI) 超入門

Command Line Interface (CLI) 超入門

[3] 語法觀念 & 基本資料型別

[3] 語法觀念 & 基本資料型別

npm ERR! enoent ENOENT: no such file or directory, open ... package.json

npm ERR! enoent ENOENT: no such file or directory, open ... package.json


Comments