切換語言為:簡體

Go語言中的context包到底解決了啥問題?

  • 爱糖宝
  • 2024-06-05
  • 2088
  • 0
  • 0

Go語言,自2009年釋出以來,憑藉其簡潔、高效、併發能力強等特點,迅速在開發者社羣獲得了廣泛的關注和應用,特別是在伺服器端開發、雲端計算、容器技術和微服務架構等領域。例如,Docker 和 K8S 等知名的容器技術都是使用Go語言開發的。

為什麼需要context包?

認識 goroutine

首先讓我們來認識下 goroutine。

Go語言的高併發、高效能都來源於它的併發模型:goroutine,就是它,讓開發者可以輕鬆地編寫高吞吐量的應用程式,這在處理大量併發請求的伺服器端開發中尤為重要。

goroutine是Go語言中的輕量級執行緒,或者稱為協程。與作業系統級別的執行緒相比,goroutine的建立和銷燬開銷非常小,排程效率也很高,因此在Go語言中,可以輕鬆地建立成千上萬個goroutine來處理併發任務。

使用goroutine非常簡單,只需在函式呼叫前加上go關鍵字即可。例如:

go func() {
    // 併發執行的程式碼
}()

併發程式設計的挑戰

goroutine 雖然讓併發程式設計變得非常方便,但也帶來了新的挑戰。

  • 超時控制:許多操作(如網路請求、資料庫查詢等)都可能因為各種原因變得緩慢甚至無限期掛起。如果沒有合適的超時控制機制,這些操作可能會導致計算機資源被長時間佔用,影響系統的整體效能和響應速度。

  • 取消操作:某些情況下,某些操作可能需要被取消。例如,當用戶取消了一個正在進行的請求,或者當某個前置條件不再滿足時,我們需要能夠及時地取消正在進行的操作,以避免不必要的資源消耗。

  • 資料傳遞:不同的goroutine之間可能需要共享和傳遞一些上下文資訊。例如,在一個請求的處理過程中,我們可能需要在多個函式呼叫之間傳遞使用者身份、請求ID等。這些資訊需要能夠安全地在多個goroutine之間傳遞和共享。

這些挑戰在其它語言的併發程式設計模型中也是廣泛存在的。

為什麼需要context包

爲了解決併發程式設計中的常見挑戰,Go語言引入了context包。context包提供了一種統一的機制來管理請求的生命週期,傳遞取消訊號,設定超時時間,並在不同的goroutine之間傳遞上下文資訊。

  • 統一管理請求生命週期:context包允許我們為每一個請求建立一個上下文物件(上下文通常就翻譯為context),並在請求的整個生命週期中傳遞這個上下文物件。如此,我們就可以在請求結束時,及時釋放所有相關的資源。

  • 傳遞取消訊號:context包提供了取消訊號的傳遞機制。我們可以建立一個可以取消的上下文物件,並在需要取消操作時呼叫取消函式,通知所有相關的goroutine取消操作。當然這不是自動發生的,還需要我們編寫程式碼進行判斷。

  • 設定超時時間:context包還提供了超時控制的機制。我們可以為操作設定超時時間,並在操作超時後自動取消操作。

  • 傳遞和共享資料:context包還提供了一種安全的方式在不同的goroutine之間傳遞和共享上下文資訊。我們可以將一些關鍵資料儲存在上下文物件中,並在不同的函式呼叫中傳遞這個上下文物件,從而實現資料的安全共享。

context包的使用方法

HTTP請求處理中context應用

讓我們先透過一個例子來感受下 context 包的強大能力。

在Go的net/http包中,每個HTTP請求都會自動攜帶一個context。我們可以透過req.Context()方法獲取這個context,並在處理請求時使用它。以下是一個簡單的示例。

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

// 定義一個key型別,用於在context中儲存和檢索資料
type key string

const (
	userIDKey key = "userID"
)

// 定義一個向控制檯輸出日誌的logger
var logger = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)

func main() {
	http.HandleFunc("/hello", helloHandler)
	http.ListenAndServe(":8080", nil)
}

func helloHandler(w http.ResponseWriter, req *http.Request) {
	// 設定請求的超時為5秒
	ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
	defer cancel()

	// 在context中儲存一些共享資料,例如使用者ID
	ctx = context.WithValue(ctx, userIDKey, "12345")

	// 模擬一些工作,將在goroutine中執行,透過channel通知完成
	done := make(chan struct{})
	go func() {
    // 從context取出使用者ID,記錄到日誌中
		userID := ctx.Value(userIDKey).(string)
		logger.Println("開始處理:", userID)
		time.Sleep(3 * time.Second) // 模擬耗時操作
		close(done)
	}()

  // 透過select跟蹤context超時或者工作完成
	select {
	case <-ctx.Done():
		// 請求被取消或超時
		http.Error(w, "Request canceled or timed out", http.StatusRequestTimeout)
	case <-done:
		// 操作完成,從context中取出使用者ID,返回給呼叫方
		userID := ctx.Value(userIDKey).(string)
		fmt.Fprintf(w, "Hello, User ID: %s!\n", userID)
	}
}

在這個示例中,我們在HTTP處理器中使用 context.WithTimeout 設定了一個5秒的超時。如果請求在5秒內沒有完成,context將自動取消,處理器會返回一個超時錯誤響應。如果操作在5秒內完成,則返回正常的響應。

在這個例子中,我們還使用了 context 來共享資料,在建立超時context之後,我們使用 context.WithValue 在context 中儲存了使用者ID。

ctx = context.WithValue(ctx, userIDKey, "12345")

在處理具體的工作時,我們使用 ctx.Value 從context中檢索共享資料,列印正在處理的使用者:

userID := ctx.Value(userIDKey).(string)
logger.Println("開始處理:", userID)

在完成後,我們還是使用ctx.Value從context中檢索共享資料,並將其包含在響應中:

userID := ctx.Value(userIDKey).(string)
fmt.Fprintf(w, "Hello, User ID: %s!\n", userID)

基本的context用法

建立 context

在Go語言中,建立一個context物件是使用context包的第一步。

在上邊的例子中,我們從http請求中獲取了一個context,其實我們也完全可以自己建立一個新的context,有兩種基本方法:

  • context.Background()

context.Background()返回一個空的context物件,通常用於整個應用程式的頂級context,或者在不確定應該使用哪個context的情況下使用。它是一個常見的根context,所有的派生context都會基於它。

  • context.TODO()

context.TODO()與context.Background()類似,但通常用於你還不確定要使用哪個context,或者程式碼還在開發過程中,未來可能會被替換為更具體的context。

傳遞context

我們可以在內嵌函式中直接使用有效範圍之內的 contex t例項,不過更常見的傳遞方法是透過函式引數。

在Go語言中,context物件通常作為函式的第一個引數進行傳遞。這種方式確保了context在整個呼叫鏈中被正確傳遞和使用。程式碼如下:

func doSomething(ctx context.Context) {
    // 在函式內部使用context
}

func main() {
    ctx := context.Background()
    doSomething(ctx)
}

取消context

context.WithCancel() 函式返回一個派生的context和一個取消函式。呼叫取消函式會取消這個派生的context,並通知所有使用這個context的goroutine進行清理操作。示例程式碼如下:

ctx, cancel := context.WithCancel(context.Background())

go func() {
    // 模擬一些工作
    time.Sleep(2 * time.Second)
    // 取消context
    cancel()
}()

select {
case <-ctx.Done():
    fmt.Println("操作被取消")
}

設定超時

上邊http服務端處理的例子中我們已經提供了一種設定context超時的方法,另外還有一個設定context超時的方法:context.WithDeadline(),這個函式函式類似於context.WithTimeout(),但它允許你指定一個具體的時間點作為截止時間。程式碼示例如下:

deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()  // 確保在不再需要時取消context

select {
case <-ctx.Done():
    fmt.Println("操作在截止時間前未完成")
}

context的最佳實踐

合理設定超時時間

超時時間設定的過長,請求都等著,可能會消耗過多的計算資源;設定的太小,頻繁超時,又會給使用者帶來不好的使用體驗。以下是一些最佳實踐:

  1. 根據業務需求設定超時:不同的業務場景對響應時間的要求不同。根據具體業務需求來設定超時時間,例如使用者請求的超時可以設定得較短,而後臺批次處理任務的超時可以設定得較長。

  2. 逐層縮短超時:在多層級服務呼叫中,通常應該逐層縮短超時時間。比如,頂層請求的超時時間為10秒,呼叫的子服務可以設定為8秒,再呼叫的子服務可以設定為6秒,以確保在超時前有足夠的時間處理和傳遞錯誤。

  3. 考慮網路延遲和重試機制:在分散式系統中,網路延遲和重試機制會影響實際的處理時間。設定超時時應考慮這些因素,避免超時時間過短導致頻繁的重試。

避免context的濫用

context包的主要目的是在請求的生命週期中傳遞取消訊號、超時和共享資料,不要傳遞過多的業務資料,以下是一些建議:

  1. 不將context用於傳遞業務資料:context應該只用於傳遞請求的控制資訊(如取消訊號、超時和trace資訊),不應該用於傳遞業務資料。

  2. 不將context儲存在結構體中:context是臨時性的,不應該儲存在結構體中以避免記憶體洩漏和不必要的複雜性。

  3. 及時取消context:使用context.WithCancel、context.WithTimeout或context.WithDeadline建立的context應該及時呼叫取消函式,以釋放資源。

  4. 避免頻繁建立context:建立和取消context本身的開銷相對較小,但頻繁的建立和取消操作仍然會對效能產生一定影響,特別是在高併發場景下。在設計系統時,儘量減少不必要的context建立操作,可以複用已有的context,避免在每個函式呼叫中都建立新的context。

有的同學可能會有疑問:context.WithTimeout 或 context.WithDeadline 建立的context等著超時或者正常處理完成不就可以了嗎?

其實 context.WithTimeout 和 context.WithDeadline,這兩個函式內部也是透過 WithCancel 實現的,因此也會返回一個 cancel 函式。儘管當超時或截止日期到達時,context會自動“過期”,不過呼叫 cancel 函式仍然是一個好習慣,因為它可以立即停止任何依賴於此上下文的正在進行的操作,而不僅僅等待它們自然發現上下文已過期。

與其他包的結合使用

我們不僅可以在自己編寫的程式碼中使用context,很多標準庫也提供了context的支援,這樣可以更好的管理請求和資源。上邊的示例中已經演示了與net/http包結合,我們再看下database/sql的例子:

package main

import (
    "context"
    "database/sql"
    "log"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

func main() {
    db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/dbname")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 建立一個帶超時的 context
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    // 查詢資料庫時,傳入這個context
    rows, err := db.QueryContext(ctx, "SELECT * FROM users")
    if err != nil {
        log.Println("Query error:", err)
        return
    }
    defer rows.Close()

    for rows.Next() {
        var id int
        var name string
        if err := rows.Scan(&id, &name); err != nil {
            log.Println("Scan error:", err)
            return
        }
        log.Printf("User: %d, Name: %s\n", id, name)
    }

    if err := rows.Err(); err != nil {
        log.Println("Rows error:", err)
    }
}

透過結合使用context包和其他標準庫,我們就可以更好地管理每個請求的生命週期和使用的各種資源,提高整個系統的穩定性和可維護性。


0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.