Go線程模型&異步編程的能力

1.文章目錄

  • Go概述
  • Go語言線程模型
  • goroutine與channel初探實踐
  • Go實現異步編程與JDK的對比

2.Go概述

  • 傳統的編程模型,JAVA,C++,Python實現併發編程時,多線程之間需要通過共享內存(JAVA堆上的共享變量)來通信;爲了保證線程安全,多線程共享的數據結構需要使用鎖來保護,多線程訪問共享數據需要鎖競爭,獲得鎖纔可以獲取共享數據;
  • Go提供了低級併發支持鎖,互斥鎖,讀寫鎖,條件變量等;Go推薦使用channel和goroutine獨特的結構化併發方式。
  • JAVA中的線程模型是一個操作系統內核線程對應一個new Thread創建的線程,由於操作系統的線程是有限的,所以限制了創建線程的個數;另外,線程阻塞時,線程要用用戶態切換到內核態執行,開銷很大;
  • Go線程模型時一個操作系統線程對應多個goroutine,用戶可以創建的goroutine只受內存大小的限制,上下文切換都在用戶態,很快,一臺機器可以創建百萬個goroutine;

3.Go線程模型

  • 線程的併發執行是操作系統來進行調度的,操作系統一般在內核提供堆線程的支持;我們用高級語言編寫時創建的線程時用戶線程,用戶線程和內核線程的關係是什麼呢?

一對一模型

  • 這種線程模型用戶線程和內核線程一對一,程序從程序入口啓動後,操作系統就創建了一個進程。這個main函數所在的線程就是主線程
  • main函數中創建一個線程其實就對應操作系統中創建一個內核線程;
  • 優點:線程可以實現真正意義上的並行,一個線程阻塞其他線程不會有影響
  • 缺點:一般操作系統會限制內核的線程個數,所以用戶線程也會限制;用戶線程執行系統調用時會涉及線程用戶態與內核態的切換;

          

多對一模型

  • 多個用戶線程對應一個內核線程,同時同一個用戶線程只能對應一個內核線程,這個時候統一內核的多個用戶線程上下文切換是由用戶態運行時線程庫做的;
  • 優點:上下文切換在用戶態,速度很快,開銷很小;可以創建的用戶線程數量很多,隻手內存大小的限制;
  • 缺點:多個用戶線程對應一個內核線程,有一個用戶線程阻塞,該內核線程的其他用戶線程也不能運行;不能很好的利用CPU進行併發;

           

多對多模型

  • 結合一對一和一對多的優點,讓大量的用戶線程對應少數幾個內核線程;
  • 同時每個內核線程對應多個用戶線程,每個用戶線程對應多個內核線程,當一個用戶線程阻塞時,其對應的其他用戶線程切換到其他內核線程運行;所以可以充分使用CPU的效能;對用戶線程沒有個數限制;

                   

Go線程模型

     

  • Go的goroutine可以認爲是輕量級的用戶線程。Go的線程模型包含3個概念:內核線程,goroutine和邏輯處理器。
  • Go中每個邏輯處理器會綁定到一個內核線程上,每個邏輯處理器有一個本地隊列,用來存放Go運行時分配的goroutine,操作系統調度線程在CPU上運行,在Go中是運行時goroutine在邏輯處理器上運行;
  • Go的兩級調度:一級是操作系統的調度系統,該調度系統調度邏輯處理器佔用CPU的運行時間;一級是Go的運行時調度系統調度某個goroutine在邏輯器上的運行;
  • 使用Go語言創建的goroutine會被放進Go的運行時調度器全局運行隊列中,然後Go運行時調度器把全局隊列中的goroutine分配給不同的邏輯處理器,然後被分配的goroutine進入邏輯處理器的運行隊列,待分配到時間片就可以運行;
  • 防止goroutine出現飢餓現象,邏輯處理器會分時處理多個goroutine,不是獨佔到結束;
  • 一個goroutine阻塞OS線程,列如等待輸入,OS線程對應的邏輯處理器會把其他的goroutine遷移到其他的OS線程;
  • 其他:可以使用debug.SetMaxThreads(num)設置最大內核線程數,默認10000個;也可以runtime.GOMAXPROCS設置邏輯處理器個數;

4.goroutine與channel實踐

    在Go中,使用go 跟上一個函數就創建一個goroutine,每個goroutine可以任務是一個輕量級線程,佔用更少的堆棧內存,並且可以自己在運行時動態增加/回收;

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func main() {
	// 函數退出時會執行
	defer fmt.Println("---main goroutine over---")
    
	wg.Add(1)
	go func() {
		fmt.Println("goroutine hi")
		wg.Done()
	}()

	fmt.Println("--wait sub goroutine over--")
    // 阻塞等待goroutine結束
	wg.Wait()
	fmt.Println("---sub goroutine over---")
}
  • 上述代碼,go func()運行一個匿名函數,我們也可以定義一個函數然後go 函數名,實現和上述代碼一樣的功能;
package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func printMsg()  {
	fmt.Println("goroutine hi")
	wg.Done()
}
func main() {
	// 函數退出時會執行
	defer fmt.Println("---main goroutine over---")

	wg.Add(1)
	go printMsg()
	fmt.Println("--wait sub goroutine over--")
	wg.Wait()
	fmt.Println("---sub goroutine over---")
}

注意點:

  • Go整個進程的生命週期與main函數所在的goroutine一致,main函數所在的goroutine結束,整個進程就結束了,不管是否還有其他goroutine在運行;
  • JDK中我們知道線程退出的條件是沒有非Deamon線程,因此會等待非Deamon線程運行完;
package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func printMsg() {
	fmt.Println("goroutine hi")
	wg.Done()
	// 循環打印
	for {
		fmt.Println("yeyeye")
	}
}
func main() {
	// 函數退出時會執行
	defer fmt.Println("---main goroutine over---")

	wg.Add(1)
	go printMsg()
	fmt.Println("--wait sub goroutine over--")
	wg.Wait()
	fmt.Println("---sub goroutine over---")
}
  • 上述代碼,儘管循環打印沒有結束,還是會退出線程;

channel通知goroutine退出

package main

import (
	"fmt"
	"time"
)

func main() {
	// 函數退出時會執行
	defer fmt.Println("---main goroutine over---")

	quit := make(chan struct{})
	go func() {
        // 監聽機制
		select {
        監聽quit有數據
		case <-quit:
			fmt.Println("sub goroutine over")
			return
		default:
			for {
				time.Sleep(1 * time.Second)
				fmt.Println("hihi")
			}
		}
	}()
	// do something
	time.Sleep(5 * time.Second)
	fmt.Println("--stop sub goroutine over--")
    // 本質quit中寫入零值,子goroutine監聽到就會退出
	close(quit)

	time.Sleep(10 * time.Second)
}

Channel概述

  • channel通道,可以任務是一個併發安全的隊列,生產者放入元素,消費者獲取元素;
  • 從大小來看,通道分爲有緩衝通道,無緩衝通道(最多隻有一個);從方向來看,如果通道只允許寫元素聲明爲var ch chan <- int,只允許取聲明爲var ch <- chan int接受通道;既可以取也可以收var ch chan int;
package main

func main() {
	// 創建一個無緩衝通道,兩種方式
	ch1 := make(chan int)
	/*var ch1 chan int = make(chan int)
	// 創建有緩衝通道
	ch2 := make(chan int, 10) // 緩衝數爲10
	var ch2 chan int  = make(chan int, 10)*/

	// 通道寫入數據
	ch1 <- 1 // 寫入

	ch1 <- 2     // 此時因爲ch1內的數據沒有被消費,會阻塞*/
	<-ch1        // 讀取元素
	num := <-ch1 // 讀取元素到變量num,此時會因爲沒有元素被阻塞

}
  • 到通道滿了,添加元素會阻塞;當通道沒有數據,獲取數據也會阻塞;

生產者消費者模型

package main

import "fmt"

// ch只讀取,wg只寫入
func printer(ch <-chan int, wg chan<- int) {
	// 打印通道元素,沒有則阻塞
	for i := range ch {
		fmt.Println(i)
	}
	// 寫入元素
	wg <- 1
	// 關閉通道,只可以讀通道元素不能再寫
	close(wg)
}
func main() {
	// 創建緩衝通道
	ch := make(chan int, 10)
	// 創建同步用的無緩衝通道
	wg := make(chan int)

	go printer(ch, wg)
    // 寫入元素,激活激活的goroutine
	for i := 1; i < 100; i++ {
		ch <- i
	}
	// 關閉協程,不能寫,只能讀
	close(ch)

	fmt.Println("wait sub goroutine over")
    // 當printer goroutine沒有運行完,會一直阻塞,等待wg中有元素
	<-wg
	fmt.Println("main goroutine over")
}

5.Go實現異步編程&JDK對比

  • 我們使用管道實現異步編程以及回壓;
  • 我們在JDK-CompletableFuture中,一個異步任務稱爲計算節點;基於Go實現類似功能;

計算節點一:異步將整數列表發送到通道,關閉通道返回;

// gen函數,nums表示多個int類型數據,多參數  返回一個只讀取的通道, 不會阻塞
func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		// 寫入所有數據,然後關閉通道,寫入一個之後等待消費者消費後,纔可以再次寫入
		for _, n := range nums {
			out <- n
		}
		// close 後不能寫入,其他goroutine讀取out的數據完之後就會退出不在阻塞
		close(out)
	}()
	return out
}

計算節點2:將通道數據讀取,異步返回保存讀取數據的平方數通道

func sq(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		//異步的從in獲取數據,開始是阻塞的,遍歷寫入out,當gen調用close(out)後就會不在阻塞等待in的數據
		for n := range in {
			out <- n * n
		}
		// 通知main goroutine的for 通道數據寫入完,讀取完就可以了,不用阻塞了
		close(out)
	}()
	return out
}

測試:

package main

import (
	"fmt"
)

// gen函數,nums表示多個int類型數據,多參數  返回一個只讀取的通道, 不會阻塞
func gen(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		// 寫入所有數據,然後關閉通道,寫入一個之後等待消費者消費後,纔可以再次寫入
		for _, n := range nums {
			out <- n
		}
		// close 後不能寫入,其他goroutine讀取out的數據完之後就會退出不在阻塞
		close(out)
	}()
	return out
}

func sq(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		//異步的從in獲取數據,開始是阻塞的,遍歷寫入out,當gen調用close(out)後就會不在阻塞等待in的數據
		for n := range in {
			out <- n * n
		}
		// 通知main goroutine的for 通道數據寫入完,讀取完就可以了,不用阻塞了
		close(out)
	}()
	return out
}
func main() {
	c := gen(2, 3)
	out := sq(c)
    
	// 類似於JAVA CompleteFuture.thenApply ..等
	for i := range out{
		fmt.Println(i)
	}
}

         

同樣的sq函數輸入,輸出類似,可以多次調用該節點

func main() {
	c := gen(2, 3)
	out := sq(c)
	out = sq(out)
	// 類似於JAVA CompleteFuture.thenApply ..等
	for i := range out{
		fmt.Println(i)
	}
}

異步回壓操作:類似CompletableFuture.thenCombine:將兩個節點的結果,作爲參數作爲下一個節點的入參

func merge(cs ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	// 輸出通道
	out := make(chan int)

	// 函數:將輸入通道元素寫入元素的輸入通道
	outPut := func(c <-chan int) {
		for n := range c {
			out <- n
		}
		wg.Done()
	}
	// cs 數量的信號量
	wg.Add(len(cs))

	for _, c := range cs {
		go outPut(c)
	}

	// 等待輸入通道所有數據全部寫入輸出通道
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

測試:

func main() {
	c := gen(2, 3)
	// 由於兩個sq是併發的,可能是4先寫入meger通道也可能是9
	out := sq(c)
	out1 := sq(c)

	// 4,9 / 9,4
	for i := range merge(out, out1) {
		fmt.Println(i)
	}
}
  • 藉助Go的併發原語與goroutine通信,完成異步常見模型

6.總結

  • 通過Go的線程模型的優勢,與對併發的支持,以及異步模型的實踐,Go爲什麼會被推舉爲併發世界的寵兒,簡單,易用;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章