【Golang】快速複習指南QuickReview(八)——goroutine

goroutineGolang特有,類似於線程,但是線程是由操作系統進行調度管理,而goroutine是由Golang運行時進行調度管理的用戶態的線程。

1.C#的線程操作

1.1 創建線程

 static void Main(string[] args)
 {
     Thread thread = new Thread(Count);
     thread.IsBackground = true;
     thread.Start();
     for (int i = 0; i < 10; i++)
         Console.Write("x\n");
 }
 static void Count()
 {
     for (int i = 0; i < 100; i++)
     {
         Console.WriteLine(i); ;
     }
 }

1.2 向線程傳參

Thread構造函數有兩個參數ParameterizedThreadStartThreadStart

public delegate void ParameterizedThreadStart(object? obj);
public delegate void ThreadStart();

沒錯,一個是無參委託,,一個是有參委託且參數類型爲object,因此我們用以創建線程的方法參數需爲object類型

static void Main(string[] args)
{
    Thread thread = new Thread(Count);
    thread.IsBackground = true;
    thread.Start(100);
    for (int i = 0; i < 10; i++)
        Console.Write("x\n");
}


static void Count(object times)
{
    int count = (int)times;
    for (int i = 0; i < 100; i++)
    {
        Console.WriteLine(i); ;
    }
}

當然簡單的還是直接使用lambda表達式

static void Main(string[] args)
{
    Thread thread = new Thread(()=>{
        Count(100);
    });
    thread.IsBackground = true;
    thread.Start();
    for (int i = 0; i < 10; i++)
        Console.Write("x\n");
}

static void Count(object times)
{
    int count = (int)times;
    for (int i = 0; i < 100; i++)
    {
        Console.WriteLine(i); ;
    }
}

注意:使用Lambda表達式可以很簡單的給Thread傳遞參數,但是線程開始後,可能會不小心修改了被捕獲的變量,這要多加註意。比如循環體中,最好創建一個臨時變量。

1.3 線程安全與鎖

從單例模式來看線程安全:

class Student
{
    private static Student _instance =new Student();
    private Student()
    {
    }
    static Student GetInstance()
    {
        return _instance;
    }
}
  • 單例模式,我們的本意是始終保持類實例化一次然後保證內存中只有一個實例。上述代碼中在類被加載時,就完成靜態私有變量的初始化,不管需要與否,都會實例化,這個被稱爲餓漢模式的單例模式。這樣雖然沒有線程安全問題,但是這個類如果不使用,就不需要實例化。然後便有了下面的寫法:需要時實例化
class Student
{
    private static Student _instance;
    private Student()
    {
    }
    static Student GetInstance()
    {
        if (_instance == null) 
                        _instance = new Student();
        return _instance;
    }
}
  • 調用時判斷靜態私有變量是否爲空,然後再給。這個其實就有一個線程安全的問題:多線程調用GetInstance(),當同時多個線程執行時,條件_instance == null可能會同時都滿足。這樣_instance就完成了多次實例化賦值操作,就引出了我們的鎖Lock
private static Student _instance;
private static readonly object locker = new object();
private Student()
{

}
static Student GetInstance()
{
    lock (locker)
    {
        if (_instance == null)
            _instance = new Student();
    }

    return _instance;
}
  • 第一個線程運行,就會加鎖Lock
  • 第二個線程運行,首先檢測到locker對象爲"加鎖"狀態(是否還有線程在lock內,未執行完成),該線程就會阻塞等待第一個線程解鎖
  • 第一個線程執行完lock體內代碼,解鎖,第二個線程纔會繼續執行

上面看似已經完美了,但是多線程情況下,每次都要經歷,檢測(是否阻塞),上鎖,解鎖,其實是很影響性能,我們本來的目的是返回單一實例即可。我們在檢測locker對象是否加鎖之前,如果實例已經存在,那麼後續工作是沒必要做的。所以就有了下面的 雙重檢驗

private static Student _instance;
private static readonly object locker = new object();
private Student()
{

}
static Student GetInstance()
{
    if (_instance == null)
    {
        lock (locker)
        {
            if (_instance == null)
                _instance = new Student();
        }
    }

    return _instance;
}

1.4 Task

線程有兩種工作類型:

  • CPU-Bound計算密集型,花費大部分時間執行CPU密集型工作的操作,這種工作類型永遠也不會讓線程處在等待狀態。

  • IO-BoundI/O密集型,花費大部分時間等待某事發生的操作,一直等待着,導致線程進入等待狀態的工作類型。比如通過http請求對資源的訪問。

對於IO-Bound的操作,時間花費主要是在I/O上,而在CPU上幾乎是沒有花費時間。對於此,更推薦使用Task編寫異步代碼,而對於CPU-BoundIO-Bound的異步代碼不是我們本篇的重點,博主將大概介紹一下Task的優勢:

  • 不再幹等:: 以HttpClient使用異步代碼請求GetStringAsync爲例,這顯然是一個I/O-Bound,最終會對操作系統本地網絡庫進行調用,系統API調用,比如發起請求的socket,但是這個時間長短並不是由代碼決定,他取決硬件,操作系統,網絡情況。控制權會返回給調用者,我們可以做其他操作,這讓系統能處理更多的工作而不是等待 I/O 調用結束。直到await去獲得請求結果。

  • 讓調用者不再幹等: 對於CPU-Bound,沒有辦法避免將一個線程用於計算,因爲這畢竟是一個計算密集型的工作。但是使用Task的異步代碼(asyncawait)不僅可以與後臺線程交互,還可以讓調用者繼續響應(可以併發執行其他操作)。同上,直到遇到await時,異步方法都會讓步於調用方。

2.Golang的goroutine

2.1 啓動goroutine

Golang中啓動一個goroutine沒有C#的線程那麼麻煩,只需要在調用方法的前面加上關鍵字go.

func main(){
    go Count(100)
}
func Count(times int) {
	for i := 0; i < times; i++ {
		fmt.Printf("%v\n", i)
	}
}

2.2 goroutine的同步

在C#中的任務(Task)可以使用Task.WhenAll來等待Task對象完成。Golang中比較原始,像個花名冊一樣登記造冊:

  • 啓動 - 計數+1
  • 執行完畢 - 計數-1
  • 完成

要使用sync包:

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func main() {
    
    //登記造冊
	wg.Add(2)
	go Count(100)
	go Count(100)
    
    //等待所有登記的goroutine都結束
	wg.Wait()
	fmt.Println("執行完成")
}

func Count(times int) {
    
    //執行完成
	defer wg.Done()
	for i := 0; i < times; i++ {
		fmt.Printf("%v\n", i)
	}
}

2.3 channel通道

一個goroutine發送特定值到另一個goroutine的通信機制就是channel

2.3.1 channel聲明

channel是引用類型

var 變量 chan 元素類型

chan 元素類型structinterface一樣,就是一種類型。後面的元素類型限定了通道具體存儲類型。

2.3.2 channel初始化

聲明後的channel是空值nil,需要初始化才能使用。

var ch chan int
ch=make(chan int,5) //5爲設定的緩衝大小,可選參數

2.3.3 channel操作

操作三板斧:

  • send - 發送 ch<-100

值指向通道

  • receive - 接收 value:=<-ch
i, ok := <-ch1 // 通道關閉後再取值ok=false

//或者
for i := range ch1 { // 通道關閉後會退出for range循環
    fmt.Println(i)
}

通道指向變量

  • close - 關閉 close(ch)

2.3.3 緩衝與無緩衝

ch1:=make(chan int,5) //5爲設定的緩衝大小,可選參數
ch2:=make(chan int)

無緩衝的通道,無緩衝的通道只有在接收值的時候才能發送值。

  • 只往通道傳值,不從通道接收,就會出現deadlock
  • 只從通道接收,不往淘到發送,也會發生阻塞

使用無緩衝通道進行通信將導致發送和接收的goroutine同步化。因此,無緩衝通道也被稱爲同步通道

有緩衝的通道,可以有效緩解無緩衝通道的尷尬,但是通道裝滿,上面的尷尬依然存在。

2.3.4 單向通道

限制通道在函數中只能發送或只能接收,單向通道粉墨登場,單向通道的使用是在函數的參數中,也沒有引入新的關鍵字,只是簡單的改變的箭頭的位置:

chan<- int 只寫不讀
<-chan int 只讀不寫

函數傳參及任何賦值操作中可以將雙向通道轉換爲單向通道,反之,不行。

2.3.5 多路複用

package main

import "fmt"

func main() {
	ch := make(chan int, 1)
	for i := 0; i < 10; i++ {
		select {
		case x := <-ch:
			fmt.Printf("第%v次,x := <-ch,從通道中讀取值%v", i+1, x)
			fmt.Println()
		case ch <- i:
			fmt.Printf("第%v次,執行ch<-i", i+1)
			fmt.Println()
		}
	}
}

第1次,執行ch<-i
第2次,x := <-ch,從通道中讀取值0
第3次,執行ch<-i
第4次,x := <-ch,從通道中讀取值2
第5次,執行ch<-i
第6次,x := <-ch,從通道中讀取值4
第7次,執行ch<-i
第8次,x := <-ch,從通道中讀取值6
第9次,執行ch<-i
第10次,x := <-ch,從通道中讀取值8

Select多路複用的規則:

  • 可處理一個或多個channel的發送/接收操作。
  • 如果多個case同時滿足,select會隨機選擇一個。
  • 對於沒有caseselect{}會一直等待,可用於阻塞main函數。

2.5 併發安全與鎖

goroutine是通過channel通道進行通信。不會出現併發安全問題。但是,實際上還是不能完全避免操作公共資源的情況,如果多個goroutine同時操作這些公共資源,可能就會發生併發安全問題,跟C#的線程一樣,鎖的出現就是爲了解決這個問題:

2.5.1 互斥鎖

互斥鎖,這個就跟C#的鎖是一樣,一個goroutine訪問,另外一個就這能等待互斥鎖的釋放。同樣需要sync包:

sync.Mutex

var lock sync.Mutex

lock.Lock()//加鎖

//操作公共資源

lock.Unlock()//解鎖

2.5.2 讀寫互斥鎖

互斥鎖是完全互斥的,如果是讀多寫少,大部分goroutine都在讀,少量的goroutine在寫,這時併發讀是沒必要加鎖的。使用時,依然需要sync包:

sync.RWMutex

讀寫鎖分爲兩種:

  • 讀鎖
  • 寫鎖。
import (
	"fmt"
	"sync"
)

var (
	lock   sync.Mutex
	rwlock sync.RWMutex
)

rwlock.Lock() // 加寫鎖
//效果等同於互斥鎖
rwlock.Unlock() // 解寫鎖

rwlock.RLock()  //加讀鎖
//可讀不可寫
rwlock.RUnlock() //解讀鎖

2.6* sync.Once

goroutine的同步我們使用過sync.WaitGroup

  • Add(count int) 計數器累加,在調用goroutine外部執行,由開發人員指定
  • Done() 計數器-1,在goroutine內部執行
  • Wait() 阻塞 直至計數器爲0

除此之外還有一個sync.Once,顧名思義,一次,只執行一次。

func (o *Once) Do(f func()) {}

var handleOnce sync.Once
handleOnce.Do(函數)

sync.Once其實內部包含一個互斥鎖和一個布爾值,互斥鎖保證布爾值和數據的安全,而布爾值用來記錄初始化是否完成。這樣設計就能保證初始化操作的時候是併發安全的並且初始化操作也不會被執行多次。

type Once struct {
	// done indicates whether the action has been performed.
	// It is first in the struct because it is used in the hot path.
	// The hot path is inlined at every call site.
	// Placing done first allows more compact instructions on some architectures (amd64/x86),
	// and fewer instructions (to calculate offset) on other architectures.
	done uint32
	m    Mutex
}

2.7* sync.Map

Golang的map不是併發安全的。sync包中提供了一個開箱即用的併發安全版map–sync.Map

開箱即用表示不用像內置的map一樣使用make函數初始化就能直接使用。同時sync.Map內置了諸如StoreLoadLoadOrStoreDeleteRange等操作方法。

var m = sync.Map{}
m.Store("四川", "成都")
m.Store("成都", "高新區")
m.Store("高新區", "應龍南一路")
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v\n", key, value)
    return true
})
fmt.Println()
v, ok := m.Load("成都")
if ok {
    fmt.Println(v)
}
fmt.Println()
value, loaded := m.LoadOrStore("陝西", "西安")
fmt.Println(value)
fmt.Println(loaded)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v\n", key, value)
    return true
})
fmt.Println()

//存在就加載,不存在就添加
value1, loaded1 := m.LoadOrStore("四川", "成都")
fmt.Println(value1)
fmt.Println(loaded1)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v\n", key, value)
    return true
})
fmt.Println()

//加載並刪除 key存在
value2, loaded2 := m.LoadAndDelete("四川")
fmt.Println(value2)
fmt.Println(loaded2)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v\n", key, value)
    return true
})
fmt.Println()

//加載並刪除 key 不存在
value3, loaded3 := m.LoadAndDelete("北京")
fmt.Println(value3)
fmt.Println(loaded3)
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v\n", key, value)
    return true
})
fmt.Println()

m.Delete("成都")  //內部是調用的LoadAndDelete
m.Range(func(key interface{}, value interface{}) bool {
    fmt.Printf("k=:%v,v:=%v\n", key, value)
    return true
})
fmt.Println()
k=:四川,v:=成都
k=:成都,v:=高新區
k=:高新區,v:=應龍南一路

高新區

西安
false
k=:四川,v:=成都
k=:成都,v:=高新區
k=:高新區,v:=應龍南一路
k=:陝西,v:=西安

成都
true
k=:四川,v:=成都
k=:成都,v:=高新區
k=:高新區,v:=應龍南一路
k=:陝西,v:=西安

成都
true
k=:陝西,v:=西安
k=:成都,v:=高新區
k=:高新區,v:=應龍南一路

<nil>
false
k=:成都,v:=高新區
k=:高新區,v:=應龍南一路
k=:陝西,v:=西安

k=:高新區,v:=應龍南一路
k=:陝西,v:=西安

2.8 單例模式

綜上,sync.Once其實內部包含一個互斥鎖和一個布爾值,這個布爾值就相當於C#單例模式下的雙重檢驗的第一個判斷。所以在golang中可以利用sync.Once實現單例模式:

package singleton

import (
    "sync"
)

type singleton struct {}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

2.9* 原子操作

代碼中的加鎖操作因爲涉及內核態的上下文切換會比較耗時、代價比較高。針對基本數據類型我們還可以使用原子操作來保證併發安全。Golang語言中原子操作由內置的標準庫sync/atomic提供。由於場景較少,就不做介紹,詳細操作請自行查閱學習。

再次強調:這個系列並不是教程,如果想系統的學習,博主可推薦學習資源。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章