教你使用 SO_REUSEPORT 套接字選項(xiàng)提升服務(wù)性能
前言
Linux 網(wǎng)絡(luò)棧中有一個(gè)相對(duì)較新的特性——SO_REUSEPORT 套接字選項(xiàng),可以使用它來提升你的服務(wù)性能。

圖 1: 上面的服務(wù)是使用并行監(jiān)聽器來避免請(qǐng)求連接瓶頸,而下面的服務(wù)只使用一個(gè)監(jiān)聽器來接收連接
概要
HAProxy 和 NGINX 是少數(shù)幾個(gè)使用 Linux 網(wǎng)絡(luò)棧中 TCP 的 SO_REUSEPORT 套接字選項(xiàng)[1]的應(yīng)用程序。這個(gè)選項(xiàng)最初是在 4.4 BSD 中引入的,幫助在現(xiàn)在大型多核系統(tǒng)中實(shí)現(xiàn)高性能服務(wù)。本文的前幾節(jié)將解釋 TCP/IP 套接字的一些基本概念,其余部分將使用這些知識(shí)描述 SO_REUSEPORT 套接字選項(xiàng)的基本原理、用法和實(shí)現(xiàn)。
問題陳述
當(dāng)運(yùn)行在多核系統(tǒng)上時(shí),高性能服務(wù)采用的傳統(tǒng)方法是使用單個(gè)監(jiān)聽器進(jìn)程接受連接,并將這些連接傳遞給工作進(jìn)程進(jìn)行處理。但在高連接負(fù)載下,監(jiān)聽過程成為瓶頸。服務(wù)經(jīng)常使用的另一種方法是打開一個(gè)監(jiān)聽套接字,然后分多個(gè)進(jìn)程,每個(gè)進(jìn)程調(diào)用 accept() 來處理套接字上的接入的連接,同時(shí)自己執(zhí)行工作。這種方法的問題是,開始拾取連接的過程往往會(huì)獲得高度傾斜的連接。在本文中,我們將討論第三種替代方法——打開多個(gè)監(jiān)聽套接字,使用SO_REUSEPORT 處理傳入的連接,這既解決了單個(gè)進(jìn)程瓶頸問題,也解決了進(jìn)程之間的連接傾斜問題。
TCP 連接基礎(chǔ)
一個(gè) TCP 連接是由唯一的一個(gè) 5 元組來定義描述 [2]:
[ Protocol, Source IP address, Source Port, Destination IP address, Destination Port ]
客戶端和服務(wù)端以不同的方式指定各個(gè)元組內(nèi)元素。下面一起來了解應(yīng)用程序是如何初始化每個(gè)元組元素的。
客戶端應(yīng)用
Protocol:該字段在根據(jù)應(yīng)用程序提供的參數(shù)在創(chuàng)建套接字時(shí)初始化。在本文中,協(xié)議始終是 TCP。例如, socket(AF_INET SOCK_STREAM 0); /* 創(chuàng)建TCP套接字 */
源 IP 地址和端口:這些通常在應(yīng)用程序調(diào)用 connect() 時(shí)由內(nèi)核設(shè)置,而無需事先調(diào)用 bind()。內(nèi)核為會(huì)選擇一個(gè)合適的IP地址與目標(biāo)服務(wù)通信,并從臨時(shí)端口范圍 (sysctl net.ipv4.ip_local_port_range) 中選擇一個(gè)源端口。
目的 IP 地址和端口:由應(yīng)用程序通過調(diào)用 connect() 設(shè)置。例如:
服務(wù)端應(yīng)用
協(xié)議:初始化方式與客戶端應(yīng)用相同。
源 IP 地址和端口:由應(yīng)用程序調(diào)用 bind() 時(shí)設(shè)置,例如:
目的 IP 地址及端口:客戶端通過 TCP 三次握手連接服務(wù)端[3]。服務(wù)端的 TCP/IP 協(xié)議棧創(chuàng)建一個(gè)新的套接字來跟蹤管理客戶端連接,并從傳入的客戶端連接參數(shù)設(shè)置它的源 IP:port 和目的 IP:port。新的套接字的狀態(tài)被轉(zhuǎn)換為 ESTABLISHED 狀態(tài),而服務(wù)端的 LISTEN 套接字則保持不變。此時(shí),服務(wù)端應(yīng)用程序?qū)?LISTEN 套接字上的 accept() 的調(diào)用返回對(duì)新建立的套接字的引用。有關(guān)客戶端和服務(wù)端應(yīng)用程序的示例實(shí)現(xiàn),請(qǐng)參閱本文末尾的源代碼清單。
【文章福利】小編推薦自己的Linux內(nèi)核技術(shù)交流群:【891587639】整理了一些個(gè)人覺得比較好的學(xué)習(xí)書籍、視頻資料共享在群文件里面,有需要的可以自行添加哦!?。。ê曨l教程、電子書、實(shí)戰(zhàn)項(xiàng)目及代碼)? ? ?


TIME-WAIT 套接字
一個(gè) TIME-WAIT [4]套接字是在應(yīng)用程序首先關(guān)閉它的 TCP 連接時(shí)創(chuàng)建的。這導(dǎo)致 TCP 4 次握手的啟動(dòng),在此過程中,套接字狀態(tài)從 ESTABLISHED 變?yōu)?FIN-WAIT1、FIN-WAIT2 到 TIME-WAIT,然后套接字被關(guān)閉。由于協(xié)議原因,TIME-WAIT 狀態(tài)是一種延遲狀態(tài)。應(yīng)用程序可以通過發(fā)送 TCP RST 包來指示 TCP/IP 棧不讓連接延遲。這樣一來,連接就會(huì)立即終止,而不需要經(jīng)過TCP 4 次握手。下面的代碼片段通過指定套接字逗留時(shí)間為 0 秒來實(shí)現(xiàn)連接的重置:
理解服務(wù)器套接字的不同狀態(tài)
服務(wù)端通常在啟動(dòng)時(shí)執(zhí)行以下系統(tǒng)調(diào)用:
任何通過 socket() 或 accept() 系統(tǒng)調(diào)用創(chuàng)建的新套接字,都會(huì)在內(nèi)核中使用 struct sock 結(jié)構(gòu)[5]進(jìn)行跟蹤管理。在上面的代碼片段中,在步驟 1 中創(chuàng)建了一個(gè)套接字,并在步驟 2 中綁定了一個(gè)明確的地址。這個(gè)套接字在步驟 3 中被轉(zhuǎn)換為 LISTEN 狀態(tài)。步驟 4 中調(diào)用 accept(),阻塞直到有客戶端連接到這個(gè) IP:port??蛻舳送瓿蒚CP 3 次握手后,內(nèi)核創(chuàng)建一個(gè)套接字,并返回對(duì)該套接字的引用。新套接字的狀態(tài)設(shè)置為 ESTABLISHED,而 server_fd 套接字保持 LISTEN 狀態(tài)。
SO_REUSEADDR 套接字選項(xiàng)
TCP 套接字的 SO_REUSEADDR 選項(xiàng)可以從以下兩個(gè)用例中更好地理解:
案例 #1. 服務(wù)端應(yīng)用程序重新啟動(dòng)分為兩個(gè)步驟—退出之后再啟動(dòng)。在退出期間,服務(wù)端的 LISTEN 套接字立即關(guān)閉。讓我們看看因?yàn)榉?wù)端的存在連接而可能出現(xiàn)的兩種情況。
所有已建立的連接都被這個(gè)瀕死的服務(wù)端進(jìn)程關(guān)閉,并且那些套接字轉(zhuǎn)換到 TIME-WAIT 狀態(tài)。
所有已建立連接將被移交給子進(jìn)程,并繼續(xù)保持 ESTABLISHED 狀態(tài)。
當(dāng)服務(wù)端隨后啟動(dòng)時(shí),它嘗試使用 EADDRINUSE 參數(shù)綁定到它監(jiān)聽端口時(shí)會(huì)失敗,因?yàn)橄到y(tǒng)上的一些套接字已經(jīng)綁定到這個(gè) IP:port 組合(例如,處于 TIME-WAIT 或 ESTABLISHED 狀態(tài)的套接字)。這個(gè)問題的演示如下:
此清單顯示前面已經(jīng)是 ESTABLISHED 狀態(tài)的套接字與現(xiàn)在在 TIME-WAIT 狀態(tài)下的套接字相同。由于這個(gè)綁定到本地地址- 10.20.1.1:45000 的套接字的存在,阻止了接下來服務(wù)為它的 LISTEN 套接字 bind() 到相同的 IP:port 組合。
用例 # 2 如果兩個(gè)進(jìn)程試圖 bind() 到相同的 IP:port 組合,先執(zhí)行 bind() 的進(jìn)程會(huì)成功,而后執(zhí)行 bind() 的進(jìn)程會(huì)由于EADDRINUSE 而失敗。此用例的另一個(gè)實(shí)例涉及到一個(gè)綁定到特定 IP:port (例如,192.168.100.1:80)的應(yīng)用程序,以及另一個(gè)試圖綁定到具有相同端口號(hào)的通配符 IP 地址的應(yīng)用程序(例如,0.0.0.0:80);同樣,后一個(gè) bind() 調(diào)用失敗,因?yàn)樗噲D綁定到使用與第一個(gè)進(jìn)程使用的相同端口號(hào)的所有地址。如果兩個(gè)進(jìn)程都在它們的套接字上設(shè)置了 SO_REUSEADDR 選項(xiàng),那么兩個(gè)套接字都可以成功綁定。但是,請(qǐng)注意一點(diǎn)——如果第一個(gè)進(jìn)程調(diào)用了 bind() 和 listen() ,第二個(gè)進(jìn)程仍然無法成功執(zhí)行 bind() ,因?yàn)榈谝粋€(gè)套接字處于 LISTEN 狀態(tài)。因此,這個(gè)用例的實(shí)現(xiàn)通常用于那些想在連接到不同服務(wù)之前綁定到特定 IP:port 的客戶端。
SO_REUSEADDR 如何幫助解決這個(gè)問題的呢?當(dāng)服務(wù)重新啟動(dòng)并在設(shè)置了 SO_REUSEADDR 的套接字上調(diào)用 bind() 時(shí),內(nèi)核忽略所有綁定到相同 IP:port 組合的非 LISTEN 套接字。Richard Stevens 在他的**《Unix網(wǎng)絡(luò)編程[6]》** 一書 中這樣描述這個(gè)特性:“ SO_REUSEADDR 允許監(jiān)聽服務(wù)啟動(dòng)并綁定它的已知端口,即使創(chuàng)建的這個(gè)鏈接之前已經(jīng)把這個(gè)端口作為它的本地端口”。
但是,我們需要 SO_REUSEPORT 選項(xiàng)來讓兩個(gè)或多個(gè)進(jìn)程成功地在同一個(gè)端口上調(diào)用 listen() 。這個(gè)選項(xiàng)將在后面的部分中進(jìn)行更詳細(xì)的說明。
SO_REUSEPORT 套接字選項(xiàng)
當(dāng)現(xiàn)有的套接字在 ESTABLISHED 或 TIME-WAIT 狀態(tài)時(shí),SO_REUSEADDR 選項(xiàng)允許套接字 bind() 到相同的 IP:port 組合,而當(dāng)現(xiàn)有的套接字在 LISTEN 狀態(tài)時(shí) SO_REUSEPORT 選項(xiàng)允許綁定到相同的 IP:port 。當(dāng)應(yīng)用程序在啟用SO_REUSEPORT 的套接字上調(diào)用 bind() 或 listen() 時(shí),內(nèi)核會(huì)忽略所有套接字,包括處于 LISTEN 狀態(tài)的套接字。這允許多次調(diào)用服務(wù)進(jìn)程,允許多個(gè)進(jìn)程監(jiān)聽連接。下一節(jié)我們來研究一下內(nèi)核怎么實(shí)現(xiàn) SO_REUSEPORT 的。
如何在多個(gè)監(jiān)聽器之間分配連接?
當(dāng)多個(gè)套接字處于 LISTEN 狀態(tài)時(shí),內(nèi)核如何決定哪個(gè)套接字——以及哪個(gè)應(yīng)用程序進(jìn)程——接收傳入連接?還是使用了輪訓(xùn)、最少連接、隨機(jī)或者其他方法決定的?我們來更深入地研究一下 TCP/IP 代碼,以理解套接字選擇是如何執(zhí)行的。
注意:
為了清晰起見,本節(jié)中的數(shù)據(jù)結(jié)構(gòu)和代碼片段進(jìn)行了大量簡(jiǎn)化——?jiǎng)h除了一些結(jié)構(gòu)體元素、函數(shù)參數(shù)、變量和不必要的代碼——但又不失正確性。為了更好地理解,清單的某些部分是偽代碼。
sk 表示 “struct sock” 類型的內(nèi)核套接字?jǐn)?shù)據(jù)結(jié)構(gòu)。
skb,即套接字緩沖區(qū),表示 “struct sk_buff” 類型的網(wǎng)絡(luò)包。
src_addr、src_port 和 dst_addr, dst_port 分別表示:源IP:端口和目的IP:端口。
如果需要,讀者可以將代碼片段與實(shí)際內(nèi)核源代碼[5]關(guān)聯(lián)起來一起看。
當(dāng)傳入網(wǎng)絡(luò)數(shù)據(jù)包 skb 在提交到 TCP/IP 協(xié)議棧中時(shí),IP 子系統(tǒng)就會(huì)調(diào)用 TCP 的數(shù)據(jù)包接收處理函數(shù) tcp_v4_rcv(),并提供 skb 作為參數(shù)。tcp_v4_rcv() 會(huì)嘗試尋找與此 skb 相關(guān)的套接字:
tcp_hashinfo 是一個(gè)類型為 “struct inet_hashinfo” 的全局變量,其中包含了 ESTABLISHED 和 LISTEN 套接字的兩個(gè)哈希表。LISTEN 哈希表的大小為 32 個(gè)桶,如下所示:
__inet_lookup_skb() 從傳入的 skb 中提取源和目的 IP 地址,并將這些地址與源和目的端口一起傳遞給__inet_lookup() 以查找相關(guān)的 ESTABLISHED 或 LISTEN 狀態(tài)的套接字,如下所示:
__inet_lookup()_ looks in tcp_hashinfo->ehash hash-table for an already established socket matching the client 4-tuple parameters. In the absence of an established socket, it looks in tcp_hashinfo->listening_hash hash-table for a LISTEN socket. __inet_lookup() 在 tcp_hashinfo->ehash 哈希表中查找已經(jīng)建立成功的套接字,匹配客戶端4元組參數(shù)。如果沒有找到,它將在 tcp_hashinfo->listening_hash 哈希表中查找 LISTEN 套接字。
__inet_lookup_listener() 函數(shù)進(jìn)行已經(jīng)存在的 LISTEN 套接字的選擇:
由 reuseport_select_sock() 負(fù)責(zé)從 SO_REUSEPORT 組中選擇套接字:
我們需要退一步來理解這是如何實(shí)現(xiàn)的。當(dāng)?shù)谝粋€(gè)進(jìn)程在啟用了 SO_REUSEPORT 的套接字上調(diào)用 listen() 時(shí),會(huì)分配它的 “struct sock” 結(jié)構(gòu)中的指針- sk_reuseport_cb。該結(jié)構(gòu)定義為:
該結(jié)構(gòu)的最后一個(gè)元素是“靈活數(shù)組成員”[7]。整個(gè)結(jié)構(gòu)是這樣分配的:socks[] 數(shù)組有128個(gè)類型為“struct sock *”的元素。注意,當(dāng)監(jiān)聽器的數(shù)量超過 128 時(shí),這個(gè)結(jié)構(gòu)會(huì)被重新分配,這個(gè) socks[] 數(shù)組的大小就會(huì)翻倍。
調(diào)用 listen() 的第一個(gè)套接字 sk1 會(huì)被緩存在它自己的 socks[] 數(shù)組的第一個(gè)槽位中,例如: sk1->sk_reuseport_cb->socks[0] = sk1;
當(dāng)隨后在綁定到相同 IP:port 的其他套接字(sk2,…)上調(diào)用 listen() 時(shí),會(huì)執(zhí)行兩個(gè)操作:
新套接字(sk2,…)的地址被附加到第一個(gè)套接字(sk1)的 sk_reuseport_cb->socks[] 。
新套接字的 sk_reuseport_cb 指針指向第一個(gè)套接字的 sk_reuseport_cb 指針。這確保同一組的所有 LISTEN 套接字引用相同的 sk_reuseport_cb 指針。
這兩個(gè)步驟的執(zhí)行如下圖所示

圖 2: LISTEN 套接字的 SO_REUSEPORT組
在此圖中,sk1 是第一個(gè) LISTEN 套接字,而 sk2 和sk3 是隨后調(diào)用 listen() 的套接字。上面描述的兩個(gè)步驟在下面的代碼片段中執(zhí)行,并通過 listen() 調(diào)用鏈執(zhí)行:
現(xiàn)在讓我們了解 reuseport_select_sock() 如何選擇 LISTEN 套接字。reuseport_select_sock() 通過調(diào)用_reciprocal_scale()_ 簡(jiǎn)單地索引到 ' socks[] ' 數(shù)組中,如下所示:
reciprocal_scale() [8] 是一個(gè)優(yōu)化的函數(shù),它使用乘法和移位操作實(shí)現(xiàn)偽模運(yùn)算
如前面所看到的, ‘phash’ 是在 __inet_lookup_listener() 函數(shù)中計(jì)算,
' num_socks ‘ 是 socks[] 數(shù)組中的套接字個(gè)數(shù)。函數(shù) reciprocal_scale(phash, num_socks) 計(jì)算一個(gè)索引,索引>= 0,但是 < num_socks。該索引用于從 SO_REUSEPOR T套接字組中獲取套接字。因此,我們看到內(nèi)核通過對(duì)客戶 IP:port 和服務(wù) IP:port 計(jì)算哈希值來選擇套接字。該方法對(duì)不同的 LISTEN 套接字上的連接可以做到較好的分配。
來看如何實(shí)際使用 SO_REUSEPORT 選項(xiàng)
讓我們通過兩個(gè)測(cè)試來看看 SO_REUSEPORT 的影響
一個(gè)應(yīng)用程序打開一個(gè)套接字用于監(jiān)聽,并創(chuàng)建兩個(gè)進(jìn)程。應(yīng)用程序代碼路徑: socket(); bind (); listen(); fork ();
一個(gè)應(yīng)用程序創(chuàng)建兩個(gè)進(jìn)程,每個(gè)進(jìn)程在設(shè)置 SO_REUSEPORT 后創(chuàng)建一個(gè) LISTEN 套接字。應(yīng)用程序代碼路徑:fork();socket();setsockopt (SO_REUSEPORT);bind ();listen();
先看看沒有 SO_REUSEPORT 的套接字狀態(tài):
字符串 “ino:3854904087 sk:37d5a0” 就描述一個(gè)內(nèi)核套接字。
再來看看有 SO_REUSEPORT 的套接字狀態(tài):
現(xiàn)在我們看到了兩個(gè)不同的內(nèi)核套接字——注意不同的 inode 號(hào)。
使用多個(gè)進(jìn)程接受單個(gè) LISTEN 套接字上的連接的應(yīng)用程序可能會(huì)遇到嚴(yán)重的性能問題,因?yàn)槊總€(gè)進(jìn)程在 accept() 中爭(zhēng)奪相同的套接字鎖,如下面的簡(jiǎn)化偽代碼所示:
ock_sock() 和 release_sock() 都在內(nèi)部獲取并釋放嵌入在’ sk ‘中的自旋鎖。參見本文后面的圖4觀察自旋鎖競(jìng)爭(zhēng)用造成的開銷。
Benchmarking SO_REUSEPORT
以下設(shè)置用于測(cè)量 SO_REUSEPORT 性能:
內(nèi)核版本:4.17.13。
客戶端和服務(wù)端系統(tǒng)都有 48 個(gè)超線程核心,并通過交換機(jī)使用一個(gè) 40g NIC 相互連接。
服務(wù)端有以下兩種啟動(dòng)方式:
創(chuàng)建一個(gè) LISTEN 套接字和 fork 48 次;或
Fork 48 次,每個(gè)子進(jìn)程在啟用 SO_REUSEPORT 后創(chuàng)建一個(gè) LISTEN 套接字。
客戶端創(chuàng)建 48 個(gè)進(jìn)程。每個(gè)進(jìn)程依次連接和斷開與服務(wù)器的連接 100 萬次。
客戶端和服務(wù)端應(yīng)用程序的源代碼在本文的末尾。
SO_REUSEPORT 的性能分析
讓我們使用 perf [9] 工具查看以上兩個(gè)測(cè)試的性能數(shù)據(jù)。圖 3 和圖 4 顯示了在不使用 SO_REUSEPORT 的情況下進(jìn)行上述測(cè)試的硬件性能統(tǒng)計(jì)和內(nèi)核性能。

圖 3. 沒有設(shè)置 SO_REUSEPORT 時(shí)硬件性能統(tǒng)計(jì)

圖 4. 沒有設(shè)置 SO_REUSEPORT 時(shí) top 25 個(gè)函數(shù)的性能數(shù)據(jù)
圖 5 和圖 6 顯示了使用 SO_REUSEPORT 進(jìn)行上述測(cè)試的硬件性能統(tǒng)計(jì)和內(nèi)核性能。

圖 5. 設(shè)置了 SO_REUSEPORT 時(shí)硬件性能統(tǒng)計(jì)

圖 6. 設(shè)置了 SO_REUSEPORT 時(shí)的 top 25 函數(shù)性能
客戶端和服務(wù)端應(yīng)用程序的源代碼:
下面實(shí)現(xiàn)了一個(gè)用于 SO_REUSEPORT 性能測(cè)試的服務(wù)端和客戶端應(yīng)用程序。
服務(wù)端程序:
客戶端程序
原文作者:黑光信息
