圖解 | 深入理解高性能網(wǎng)絡(luò)開發(fā)路上的絆腳石 - 同步阻塞網(wǎng)絡(luò) IO
在網(wǎng)絡(luò)開發(fā)模型中,有一種非常易于開發(fā)同學(xué)使用的方式,那就是同步阻塞的網(wǎng)絡(luò) IO(在 Java 中習(xí)慣叫 BIO)。
例如我們想請求服務(wù)器上的一段數(shù)據(jù),那么 C 語言的一段代碼 demo 大概是下面這樣:
但是在高并發(fā)的服務(wù)器開發(fā)中,這種網(wǎng)絡(luò) IO 的性能奇差。因為
1.進(jìn)程在 recv 的時候大概率會被阻塞掉,導(dǎo)致一次進(jìn)程切換
2.當(dāng)連接上數(shù)據(jù)就緒的時候進(jìn)程又會被喚醒,又是一次進(jìn)程切換
3.一個進(jìn)程同時只能等待一條連接,如果有很多并發(fā),則需要很多進(jìn)程
如果用一句話來概括,那就是:同步阻塞網(wǎng)絡(luò) IO 是高性能網(wǎng)絡(luò)開發(fā)路上的絆腳石! 俗話說得好,知己知彼方能百戰(zhàn)百勝。所以我們今天先不講優(yōu)化,只深入分析同步阻塞網(wǎng)絡(luò) IO 的內(nèi)部實現(xiàn)。
在上面的 demo 中雖然只是簡單的兩三行代碼,但實際上用戶進(jìn)程和內(nèi)核配合做了非常多的工作。先是用戶進(jìn)程發(fā)起創(chuàng)建 socket 的指令,然后切換到內(nèi)核態(tài)完成了內(nèi)核對象的初始化。接下來 Linux 在數(shù)據(jù)包的接收上,是硬中斷和 ksoftirqd 進(jìn)程在進(jìn)行處理。當(dāng) ksoftirqd 進(jìn)程處理完以后,再通知到相關(guān)的用戶進(jìn)程。
從用戶進(jìn)程創(chuàng)建 socket,到一個網(wǎng)絡(luò)包抵達(dá)網(wǎng)卡到被用戶進(jìn)程接收到,總體上的流程圖如下:

我們今天用圖解加源碼分析的方式來詳細(xì)拆解一下上面的每一個步驟,來看一下在內(nèi)核里是它們是怎么實現(xiàn)的。閱讀完本文,你將深刻地理解在同步阻塞的網(wǎng)絡(luò) IO 性能低下的原因!
【文章福利】小編推薦自己的Linux內(nèi)核技術(shù)交流群:【891587639】整理了一些個人覺得比較好的學(xué)習(xí)書籍、視頻資料共享在群文件里面,有需要的可以自行添加哦?。。。ê曨l教程、電子書、實戰(zhàn)項目及代碼)? ? ??


開篇源碼中的 socket 函數(shù)調(diào)用執(zhí)行完以后,內(nèi)核在內(nèi)部創(chuàng)建了一系列的 socket 相關(guān)的內(nèi)核對象(是的,不是只有一個)。它們互相之間的關(guān)系如圖。當(dāng)然了,這個對象比圖示的還要更復(fù)雜。我只在圖中把和今天的主題相關(guān)的內(nèi)容展現(xiàn)了出來。

我們來翻翻源碼,看下上面的結(jié)構(gòu)是如何被創(chuàng)造出來的。
sock_create 是創(chuàng)建 socket 的主要位置。其中 sock_create 又調(diào)用了 __sock_create。
在 __sock_create 里,首先調(diào)用 sock_alloc 來分配一個 struct sock 對象。接著在獲取協(xié)議族的操作函數(shù)表,并調(diào)用其 create 方法。對于 AF_INET 協(xié)議族來說,執(zhí)行到的是 inet_create 方法。
在 inet_create 中,根據(jù)類型 SOCK_STREAM 查找到對于 tcp 定義的操作方法實現(xiàn)集合 inet_stream_ops 和 tcp_prot。并把它們分別設(shè)置到 socket->ops 和 sock->sk_prot 上。

我們再往下看到了 sock_init_data。在這個方法中將 sock 中的 sk_data_ready 函數(shù)指針進(jìn)行了初始化,設(shè)置為默認(rèn) sock_def_readable()。

當(dāng)軟中斷上收到數(shù)據(jù)包時會通過調(diào)用 sk_data_ready 函數(shù)指針(實際被設(shè)置成了 sock_def_readable()) 來喚醒在 sock 上等待的進(jìn)程。這個咱們后面介紹軟中斷的時候再說,這里記住這個就行了。
至此,一個 tcp對象,確切地說是 AF_INET 協(xié)議族下 SOCK_STREAM對象就算是創(chuàng)建完成了。這里花費了一次 socket 系統(tǒng)調(diào)用的開銷
二、等待接收消息
接著我們來看 recv 函數(shù)依賴的底層實現(xiàn)。首先通過 strace 命令跟蹤,可以看到 clib 庫函數(shù) recv 會執(zhí)行到 recvfrom 系統(tǒng)調(diào)用。
進(jìn)入系統(tǒng)調(diào)用后,用戶進(jìn)程就進(jìn)入到了內(nèi)核態(tài),通過執(zhí)行一系列的內(nèi)核協(xié)議層函數(shù),然后到 socket 對象的接收隊列中查看是否有數(shù)據(jù),沒有的話就把自己添加到 socket 對應(yīng)的等待隊列里。最后讓出CPU,操作系統(tǒng)會選擇下一個就緒狀態(tài)的進(jìn)程來執(zhí)行。整個流程圖如下:

看完了整個流程圖,接下來讓我們根據(jù)源碼來看更詳細(xì)的細(xì)節(jié)。其中我們今天要關(guān)注的重點是 recvfrom 最后是怎么把自己的進(jìn)程給阻塞掉的(假如我們沒有使用 O_NONBLOCK 標(biāo)記)。
sock_recvmsg ==> __sock_recvmsg => __sock_recvmsg_nosec
調(diào)用 socket 對象 ops 里的 recvmsg, 回憶我們上面的 socket 對象圖,從圖中可以看到 recvmsg 指向的是 inet_recvmsg 方法。

這里又遇到一個函數(shù)指針,這次調(diào)用的是 socket 對象里的 sk_prot 下面的 recvmsg方法。同上,得出這個 recvmsg 方法對應(yīng)的是 tcp_recvmsg 方法。
終于看到了我們想要看的東西,skb_queue_walk 是在訪問 sock 對象下面的接收隊列了。

如果沒有收到數(shù)據(jù),或者收到不足夠多,則調(diào)用 sk_wait_data 把當(dāng)前進(jìn)程阻塞掉。
我們再來詳細(xì)看下 sk_wait_data 是怎么把當(dāng)前進(jìn)程給阻塞掉的。

首先在 DEFINE_WAIT 宏下,定義了一個等待隊列項 wait。在這個新的等待隊列項上,注冊了回調(diào)函數(shù) autoremove_wake_function,并把當(dāng)前進(jìn)程描述符 current 關(guān)聯(lián)到其 .private成員上。
緊接著在 sk_wait_data 中 調(diào)用 sk_sleep 獲取 sock 對象下的等待隊列列表頭 wait_queue_head_t。sk_sleep 源代碼如下:
接著調(diào)用 prepare_to_wait 來把新定義的等待隊列項 wait 插入到 sock 對象的等待隊列下。
這樣后面當(dāng)內(nèi)核收完數(shù)據(jù)產(chǎn)生就緒時間的時候,就可以查找 socket 等待隊列上的等待項,進(jìn)而就可以找到回調(diào)函數(shù)和在等待該 socket 就緒事件的進(jìn)程了。
最后再調(diào)用 sk_wait_event 讓出 CPU,進(jìn)程將進(jìn)入睡眠狀態(tài),這會導(dǎo)致一次進(jìn)程上下文的開銷。
接下來的小節(jié)里我們將能看到進(jìn)程是如何被喚醒的了。
三、軟中斷模塊
接著我們再轉(zhuǎn)換一下視角,來看負(fù)責(zé)接收和處理數(shù)據(jù)包的軟中斷這邊。關(guān)于網(wǎng)絡(luò)包到網(wǎng)卡后是怎么被網(wǎng)卡接收,最后在交由軟中斷處理的,這里就不多贅述了。

軟中斷(也就是 Linux 里的 ksoftirqd 進(jìn)程)里收到數(shù)據(jù)包以后,發(fā)現(xiàn)是 tcp 的包的話就會執(zhí)行到 tcp_v4_rcv 函數(shù)。接著走,如果是 ESTABLISH 狀態(tài)下的數(shù)據(jù)包,則最終會把數(shù)據(jù)拆出來放到對應(yīng) socket 的接收隊列中。然后調(diào)用 sk_data_ready 來喚醒用戶進(jìn)程。
我們看更詳細(xì)一點的代碼:
在 tcp_v4_rcv 中首先根據(jù)收到的網(wǎng)絡(luò)包的 header 里的 source 和 dest 信息來在本機(jī)上查詢對應(yīng)的 socket。找到以后,我們直接進(jìn)入接收的主體函數(shù) tcp_v4_do_rcv 來看。
我們假設(shè)處理的是 ESTABLISH 狀態(tài)下的包,這樣就又進(jìn)入 tcp_rcv_established 函數(shù)中進(jìn)行處理。
在 tcp_rcv_established 中通過調(diào)用 ?tcp_queue_rcv 函數(shù)中完成了將接收數(shù)據(jù)放到 socket 的接收隊列上。

如下源碼所示
調(diào)用 tcp_queue_rcv 接收完成之后,接著再調(diào)用 sk_data_ready 來喚醒在socket上等待的用戶進(jìn)程。 ?這又是一個函數(shù)指針?;叵肷厦嫖覀冊?創(chuàng)建 socket 流程里執(zhí)行到的 sock_init_data 函數(shù),在這個函數(shù)里已經(jīng)把 sk_data_ready 設(shè)置成 sock_def_readable 函數(shù)了(可以ctrl + f 搜索前文)。它是默認(rèn)的數(shù)據(jù)就緒處理函數(shù)。
在 sock_def_readable 中再一次訪問到了 sock->sk_wq 下的wait?;貞浵挛覀兦懊嬲{(diào)用 recvfrom 執(zhí)行的最后,通過 DEFINE_WAIT(wait) 將當(dāng)前進(jìn)程關(guān)聯(lián)的等待隊列添加到 sock->sk_wq 下的 wait 里了。
那接下來就是調(diào)用 wake_up_interruptible_sync_poll 來喚醒在 socket 上因為等待數(shù)據(jù)而被阻塞掉的進(jìn)程了。

__wake_up_common 實現(xiàn)喚醒。這里注意下, 該函數(shù)調(diào)用是參數(shù) nr_exclusive 傳入的是 1,這里指的是即使是有多個進(jìn)程都阻塞在同一個 socket 上,也只喚醒 1 個進(jìn)程。其作用是為了避免驚群。
在 __wake_up_common 中找出一個等待隊列項 curr,然后調(diào)用其 curr->func。回憶我們前面在 recv 函數(shù)執(zhí)行的時候,使用 DEFINE_WAIT() 定義等待隊列項的細(xì)節(jié),內(nèi)核把 curr->func 設(shè)置成了 autoremove_wake_function。
在 autoremove_wake_function 中,調(diào)用了 default_wake_function。
調(diào)用 try_to_wake_up 時傳入的 task_struct 是 curr->private。這個就是當(dāng)時因為等待而被阻塞的進(jìn)程項。當(dāng)這個函數(shù)執(zhí)行完的時候,在 socket 上等待而被阻塞的進(jìn)程就被推入到可運(yùn)行隊列里了,這又將是一次進(jìn)程上下文切換的開銷。
小結(jié)
好了,我們把上面的流程總結(jié)一下。內(nèi)核在通知網(wǎng)絡(luò)包的運(yùn)行環(huán)境分兩部分:
第一部分是我們自己代碼所在的進(jìn)程,我們調(diào)用的 socket() 函數(shù)會進(jìn)入內(nèi)核態(tài)創(chuàng)建必要內(nèi)核對象。recv() 函數(shù)在進(jìn)入內(nèi)核態(tài)以后負(fù)責(zé)查看接收隊列,以及在沒有數(shù)據(jù)可處理的時候把當(dāng)前進(jìn)程阻塞掉,讓出 CPU。
第二部分是硬中斷、軟中斷上下文(系統(tǒng)進(jìn)程 ksoftirqd)。在這些組件中,將包處理完后會放到 socket 的接收隊列中。然后再根據(jù) socket 內(nèi)核對象找到其等待隊列中正在因為等待而被阻塞掉的進(jìn)程,然后把它喚醒。

每次一個進(jìn)程專門為了等一個 socket 上的數(shù)據(jù)就得被從 CPU 上拿下來。然后再換上另一個進(jìn)程。等到數(shù)據(jù) ready 了,睡眠的進(jìn)程又會被喚醒??偣矁纱芜M(jìn)程上下文切換開銷,根據(jù)之前的測試來看,每一次切換大約是 3-5 us(微秒)左右。如果是網(wǎng)絡(luò) IO 密集型的應(yīng)用的話,CPU 就不停地做進(jìn)程切換這種無用功。
在服務(wù)端角色上,這種模式完全沒辦法使用。因為這種簡單模型里的 socket 和進(jìn)程是一對一的。我們現(xiàn)在要在單臺機(jī)器上承載成千上萬,甚至十幾、上百萬的用戶連接請求。如果用上面的方式,那就得為每個用戶請求都創(chuàng)建一個進(jìn)程。相信你在無論多原始的服務(wù)器網(wǎng)絡(luò)編程里,都沒見過有人這么干吧。
如果讓我給它起一個名字的話,它就叫單路不復(fù)用(飛哥自創(chuàng)名詞)。那么有沒有更高效的網(wǎng)絡(luò) IO 模型呢?當(dāng)然有,那就是你所熟知的 select、poll 和 epoll了。下次飛哥再開始拆解 epoll 的實現(xiàn)源碼,敬請期待!
這種模式在客戶端角色上,現(xiàn)在還存在使用的情形。因為你的進(jìn)程可能確實得等 Mysql 的數(shù)據(jù)返回成功之后,才能渲染頁面返回給用戶,否則啥也干不了。
注意一下,我說的是角色,不是具體的機(jī)器。例如對于你的 php/java/golang 接口機(jī),你接收用戶請求的時候,你是服務(wù)端角色。但當(dāng)你再請求 redis 的時候,就變?yōu)榭蛻舳私巧恕?/p>
不過現(xiàn)在有一些封裝的很好的網(wǎng)絡(luò)框架例如 Sogou Workflow,Golang 的 net 包等在網(wǎng)絡(luò)客戶端角色上也早已摒棄了這種低效的模式!
