有趣的 Go HttpClient 超時機制

来源:https://www.cnblogs.com/zhuochongdashi/archive/2022/11/15/16893627.html
-Advertisement-
Play Games

hello,大家好呀,我是既寫 Java 又寫 Go 的小樓,在寫 Go 的過程中經常對比這兩種語言的特性,踩了不少坑,也發現了不少有意思的地方,今天就來聊聊 Go 自帶的 HttpClient 的超時機制。 Java HttpClient 超時底層原理 在介紹 Go 的 HttpClient 超時 ...


hello,大家好呀,我是既寫 Java 又寫 Go 的小樓,在寫 Go 的過程中經常對比這兩種語言的特性,踩了不少坑,也發現了不少有意思的地方,今天就來聊聊 Go 自帶的 HttpClient 的超時機制。

Java HttpClient 超時底層原理

在介紹 Go 的 HttpClient 超時機制之前,我們先看看 Java 是如何實現超時的。

寫一個 Java 原生的 HttpClient,設置連接超時、讀取超時時間分別對應到底層的方法分別是:

image

再追溯到 JVM 源碼,發現是對系統調用的封裝,其實不光是 Java,大部分的編程語言都藉助了操作系統提供的超時能力。

然而 Go 的 HttpClient 卻提供了另一種超時機制,挺有意思,我們來盤一盤。但在開始之前,我們先瞭解一下 Go 的 Context。

Go Context 簡介

Context 是什麼?

根據 Go 源碼的註釋:

// A Context carries a deadline, a cancellation signal, and other values across
// API boundaries.
// Context's methods may be called by multiple goroutines simultaneously.

Context 簡單來說是一個可以攜帶超時時間、取消信號和其他數據的介面,Context 的方法會被多個協程同時調用。

Context 有點類似 Java 的ThreadLocal,可以線上程中傳遞數據,但又不完全相同,它是顯示傳遞,ThreadLocal 是隱式傳遞,除了傳遞數據之外,Context 還能攜帶超時時間、取消信號。

Context 只是定義了介面,具體的實現在 Go 中提供了幾個:

  • Background :空的實現,啥也沒做
  • TODO:還不知道用什麼 Context,先用 TODO 代替,也是啥也沒做的空 Context
  • cancelCtx:可以取消的 Context
  • timerCtx:主動超時的 Context

針對 Context 的三個特性,可以通過 Go 提供的 Context 實現以及源碼中的例子來進一步瞭解下。

Context 三個特性例子

這部分的例子來源於 Go 的源碼,位於 src/context/example_test.go

攜帶數據

使用 context.WithValue 來攜帶,使用 Value 來取值,源碼中的例子如下:

// 來自 src/context/example_test.go
func ExampleWithValue() {
	type favContextKey string

	f := func(ctx context.Context, k favContextKey) {
		if v := ctx.Value(k); v != nil {
			fmt.Println("found value:", v)
			return
		}
		fmt.Println("key not found:", k)
	}

	k := favContextKey("language")
	ctx := context.WithValue(context.Background(), k, "Go")

	f(ctx, k)
	f(ctx, favContextKey("color"))

	// Output:
	// found value: Go
	// key not found: color
}

取消

先起一個協程執行一個死迴圈,不停地往 channel 中寫數據,同時監聽 ctx.Done() 的事件

// 來自 src/context/example_test.go
gen := func(ctx context.Context) <-chan int {
		dst := make(chan int)
		n := 1
		go func() {
			for {
				select {
				case <-ctx.Done():
					return // returning not to leak the goroutine
				case dst <- n:
					n++
				}
			}
		}()
		return dst
	}

然後通過 context.WithCancel 生成一個可取消的 Context,傳入 gen 方法,直到 gen 返回 5 時,調用 cancel 取消 gen 方法的執行。

// 來自 src/context/example_test.go
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // cancel when we are finished consuming integers

for n := range gen(ctx) {
	fmt.Println(n)
	if n == 5 {
		break
	}
}
// Output:
// 1
// 2
// 3
// 4
// 5

這麼看起來,可以簡單理解為在一個協程的迴圈中埋入結束標誌,另一個協程去設置這個結束標誌。

超時

有了 cancel 的鋪墊,超時就好理解了,cancel 是手動取消,超時是自動取消,只要起一個定時的協程,到時間後執行 cancel 即可。

設置超時時間有2種方式:context.WithTimeoutcontext.WithDeadline,WithTimeout 是設置一段時間後,WithDeadline 是設置一個截止時間點,WithTimeout 最終也會轉換為 WithDeadline。

// 來自 src/context/example_test.go
func ExampleWithTimeout() {
	// Pass a context with a timeout to tell a blocking function that it
	// should abandon its work after the timeout elapses.
	ctx, cancel := context.WithTimeout(context.Background(), shortDuration)
	defer cancel()

	select {
	case <-time.After(1 * time.Second):
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err()) // prints "context deadline exceeded"
	}

	// Output:
	// context deadline exceeded
}

Go HttpClient 的另一種超時機制

基於 Context 可以設置任意代碼段執行的超時機制,就可以設計一種脫離操作系統能力的請求超時能力。

超時機制簡介

看一下 Go 的 HttpClient 超時配置說明:

	client := http.Client{
		Timeout: 10 * time.Second,
	}
	
	// 來自 src/net/http/client.go
	type Client struct {
	// ... 省略其他欄位
	// Timeout specifies a time limit for requests made by this
	// Client. The timeout includes connection time, any
	// redirects, and reading the response body. The timer remains
	// running after Get, Head, Post, or Do return and will
	// interrupt reading of the Response.Body.
	//
	// A Timeout of zero means no timeout.
	//
	// The Client cancels requests to the underlying Transport
	// as if the Request's Context ended.
	//
	// For compatibility, the Client will also use the deprecated
	// CancelRequest method on Transport if found. New
	// RoundTripper implementations should use the Request's Context
	// for cancellation instead of implementing CancelRequest.
	Timeout time.Duration
}

翻譯一下註釋:Timeout 包括了連接、redirect、讀取數據的時間,定時器會在 Timeout 時間後打斷數據的讀取,設為0則沒有超時限制。

也就是說這個超時是一個請求的總體超時時間,而不必再分別去設置連接超時、讀取超時等等。

這對於使用者來說可能是一個更好的選擇,大部分場景,使用者不必關心到底是哪部分導致的超時,而只是想這個 HTTP 請求整體什麼時候能返回。

超時機制底層原理

以一個最簡單的例子來闡述超時機制的底層原理。

這裡我起了一個本地服務,用 Go HttpClient 去請求,超時時間設置為 10 分鐘,建議使 Debug 時設置長一點,否則可能超時導致無法走完全流程。

	client := http.Client{
		Timeout: 10 * time.Minute,
	}
	resp, err := client.Get("http://127.0.0.1:81/hello")

1. 根據 timeout 計算出超時的時間點

// 來自 src/net/http/client.go
deadline = c.deadline()

2. 設置請求的 cancel

// 來自 src/net/http/client.go
stopTimer, didTimeout := setRequestCancel(req, rt, deadline)

這裡返回的 stopTimer 就是可以手動 cancel 的方法,didTimeout 是判斷是否超時的方法。這兩個可以理解為回調方法,調用 stopTimer() 可以手動 cancel,調用 didTimeout() 可以返回是否超時。

設置的主要代碼其實就是將請求的 Context 替換為 cancelCtx,後續所有的操作都將攜帶這個 cancelCtx:

// 來自 src/net/http/client.go
var cancelCtx func()
if oldCtx := req.Context(); timeBeforeContextDeadline(deadline, oldCtx) {
	req.ctx, cancelCtx = context.WithDeadline(oldCtx, deadline)
}

同時,再起一個定時器,當超時時間到了之後,將 timedOut 設置為 true,再調用 doCancel(),doCancel() 是調用真正 RoundTripper (代表一個 HTTP 請求事務)的 CancelRequest,也就是取消請求,這個跟實現有關。

// 來自 src/net/http/client.go
timer := time.NewTimer(time.Until(deadline))
var timedOut atomicBool

go func() {
	select {
	case <-initialReqCancel:
		doCancel()
		timer.Stop()
	case <-timer.C:
		timedOut.setTrue()
		doCancel()
	case <-stopTimerCh:
		timer.Stop()
	}
}()

Go 預設 RoundTripper CancelRequest 實現是關閉這個連接

// 位於 src/net/http/transport.go
// CancelRequest cancels an in-flight request by closing its connection.
// CancelRequest should only be called after RoundTrip has returned.
func (t *Transport) CancelRequest(req *Request) {
	t.cancelRequest(cancelKey{req}, errRequestCanceled)
}

3. 獲取連接

// 位於 src/net/http/transport.go
for {
	select {
	case <-ctx.Done():
		req.closeBody()
		return nil, ctx.Err()
	default:
	}

	// ...
	pconn, err := t.getConn(treq, cm)
	// ...
}

代碼的開頭監聽 ctx.Done,如果超時則直接返回,使用 for 迴圈主要是為了請求的重試。

後續的 getConn 是阻塞的,代碼比較長,挑重點說,先看看有沒有空閑連接,如果有則直接返回

// 位於 src/net/http/transport.go
// Queue for idle connection.
if delivered := t.queueForIdleConn(w); delivered {
	// ...
	return pc, nil
}

如果沒有空閑連接,起個協程去非同步建立,建立成功再通知主協程

// 位於 src/net/http/transport.go
// Queue for permission to dial.
t.queueForDial(w)

再接著是一個 select 等待連接建立成功、超時或者主動取消,這就實現了在連接過程中的超時

// 位於 src/net/http/transport.go
// Wait for completion or cancellation.
select {
case <-w.ready:
	// ...
	return w.pc, w.err
case <-req.Cancel:
	return nil, errRequestCanceledConn
case <-req.Context().Done():
	return nil, req.Context().Err()
case err := <-cancelc:
	if err == errRequestCanceled {
		err = errRequestCanceledConn
	}
	return nil, err
}

4. 讀寫數據

在上一條連接建立的時候,每個鏈接還偷偷起了兩個協程,一個負責往連接中寫入數據,另一個負責讀數據,他們都監聽了相應的 channel。

// 位於 src/net/http/transport.go
go pconn.readLoop()
go pconn.writeLoop()

其中 wirteLoop 監聽來自主協程的數據,並往連接中寫入

// 位於 src/net/http/transport.go
func (pc *persistConn) writeLoop() {
	defer close(pc.writeLoopDone)
	for {
		select {
		case wr := <-pc.writech:
			startBytesWritten := pc.nwrite
			err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
			// ... 
			if err != nil {
				pc.close(err)
				return
			}
		case <-pc.closech:
			return
		}
	}
}

同理,readLoop 讀取響應數據,並寫回主協程。讀與寫的過程中如果超時了,連接將被關閉,報錯退出。

超時機制小結

Go 的這種請求超時機制,可隨時終止請求,可設置整個請求的超時時間。其實現主要依賴協程、channel、select 機制的配合。總結出套路是:

  • 主協程生成 cancelCtx,傳遞給子協程,主協程與子協程之間用 channel 通信
  • 主協程 select channel 和 cancelCtx.Done,子協程完成或取消則 return
  • 迴圈任務:子協程起一個迴圈處理,每次迴圈開始都 select cancelCtx.Done,如果完成或取消則退出
  • 阻塞任務:子協程 select 阻塞任務與 cancelCtx.Done,阻塞任務處理完或取消則退出

以迴圈任務為例

image

Java 能實現這種超時機制嗎

直接說結論:暫時不行。

首先 Java 的線程太重,像 Go 這樣一次請求開了這麼多協程,換成線程性能會大打折扣。

其次 Go 的 channel 雖然和 Java 的阻塞隊列類似,但 Go 的 select 是多路復用機制,Java 暫時無法實現,即無法監聽多個隊列是否有數據到達。所以綜合來看 Java 暫時無法實現類似機制。

總結

本文介紹了 Go 另類且有趣的 HTTP 超時機制,並且分析了底層實現原理,歸納出了這種機制的套路,如果我們寫 Go 代碼,也可以如此模仿,讓代碼更 Go。這期是我寫的 Go 底層原理第一期,求個 在看分享,我們下期再見。


  • 搜索關註微信公眾號"捉蟲大師",後端技術分享,架構設計、性能優化、源碼閱讀、問題排查、踩坑實踐。

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • vue中的ajax vue腳手架配置代理 方法一 ​ 在vue.config.js中添加如下配置: devServer:{ proxy:"http://localhost:5000" } 說明: 優點:配置簡單,請求資源時直接發給前端(8080)即可。 缺點:不能配置多個代理,不能靈活的控制請求是否 ...
  • 獲取微信昵稱、頭像、OpenID 微信公眾號服務號的網頁授權功能開發,主要是通過js跳轉到一個微信提供的url 然後微信會彈出獲取昵稱頭像的按鈕 允許獲取後,會回跳到我們的網址上,並且帶著一個code參數 我們拿到code參數,調用介面獲取到獲取到昵稱頭像、以及openid。這樣就拿到了微信客戶的主 ...
  • 函數模板 模板的意義:對類型也參數化 int sum1(int a,int b){return a+b;} double sum2(double a ,double b){return a+b;} 幾個概念 函數模板 模板的實例化 模板函數 模板類型參數 模板非類型參數 模板實參推演 模板的特例化 ...
  • # 1.公共操作 # del 刪除 刪除變數或指定容器內數據 變數,容器裡面的值 # + 將兩個相同類型序列進行連接 字元串,列表,元組 print('1.公共操作') print('a' + 'b') print(['a'] + ['b']) print(('a',)+('b',)) # 元組,要 ...
  • 小伙伴們曾經可能都經歷過整天寫著CURD的業務,都沒寫過一些組件相關的東西,這篇文章記錄一下SpringBoot如何自定義一個Starter。 原理和理論就不用多說了,可以在網上找到很多關於該方面的資料,這裡主要分享如何自定義。 原文鏈接:SpringBoot怎麼自定義一個Starter ?一隻小C ...
  • 使用 .editorconfig 統一規範 Visual Studio 編碼格式,使用 /utf-8 編譯選項指定源碼文件解碼格式,使得整個團隊文件編碼、代碼格式保持一致。 ...
  • 類的生命周期 首先我們先看類的生命周期 類的載入過程包含了載入、驗證、準備、解析、初始這五個階段,其中除瞭解析階段其他四個階段的發生順序都是確定的,因為解析階段在某些情況下會在初始階段之後開始,同時這些階段都是按順序開始的不是按順序進行或結束,因為這些階段通常都是互相交叉的混合進行。以下為類的生命周 ...
  • 引入課程和Maven 1.Maven maven中央倉庫:Maven Repository: Search/Browse/Explore (mvnrepository.com) maven倉庫是國外的一個網站,由於網路問題,我們也常使用maven倉庫的鏡像 maven的原理和java程式操作資料庫, ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...