系列文章傳送門:html
前面一直在說各類協議,偏理論方面的知識,此次我們就來認識下基於 TCP 和 UDP 協議這些理論知識的 Socket 編程。node
說 TCP 和 UDP 的時候,咱們是分紅客戶端和服務端來認識的,那在寫 Socket 的時候,咱們也這樣分。linux
Socket 這個名字頗有意思,能夠做插口或者插槽講。咱們寫程序時,就能夠將 Socket 想象爲,一頭插在客戶端,一頭插在服務端,而後進行通訊。編程
在創建 Socket 的時候,應該設置什麼參數呢?Socket 編程進行的是端到端的通訊,每每意識不到中間通過多少局域網,多少路由器,於是可以設置的參數,也只能是端到端協議之上網絡層和傳輸層的。數組
對於網絡層和傳輸層,有如下參數須要設置:緩存
兩端建立了 Socket 以後,然後面的過程當中,TCP 和 UDP 稍有不一樣,咱們先來看看 TCP。服務器
對於 TCP 建立 Socket 的過程,有如下幾步走:網絡
1)TCP 調用 bind 函數賦予 Socket IP 地址和端口。數據結構
爲何須要 IP 地址?還記得嗎?我們以前瞭解過,一臺機器會有多個網卡,而每一個網卡就有一個 IP 地址,咱們能夠選擇監聽全部的網卡,也能夠選擇監聽一個網卡,只有,發給指定網卡的包纔會發給你。多線程
爲何須要端口?要知道,我們寫的是一個應用程序,當一個網絡包來的時候,內核就是要經過 TCP 裏面的端口號來找到對應的應用程序,把包給你。
2)調用 listen 函數監聽端口。 在 TCP 的狀態圖了,有一個 listen 狀態,當調用這個函數以後,服務端就進入了這個狀態,這個時候客戶端就能夠發起鏈接了。
在內核中,爲每一個 Socket 維護兩個隊列。一個是已經創建了鏈接的隊列,這裏面的鏈接已經完成三次握手,處於 established 狀態;另外一個是尚未徹底創建鏈接的隊列,這裏面的鏈接尚未完成三次握手,處於 syn_rcvd 狀態。
3)服務端調用 accept 函數。 這時候服務端會拿出一個已經完成的鏈接進行處理,若是尚未已經完成的鏈接,就要等着。
在服務端等待的時候,客戶端能夠經過 connect 函數發起鏈接。客戶端先在參數中指明要鏈接的 IP 地址和端口號,而後開始發起三次握手。內核會給客戶端分配一個臨時的端口,一旦握手成功,服務端的 accept 就會返回另外一個 Socket。
注意,從上面的過程當中能夠看出,監聽的 Socket 和真正用來傳數據的 Socket 是不一樣的兩個。 一個叫作監聽 Socket,一個叫作已鏈接 Socket。
下圖就是基於 TCP 協議的 Socket 函數調用過程:
鏈接創建成功以後,雙方開始經過 read 和write 函數來讀寫數據,就像往一個文件流裏寫東西同樣。
這裏說 TCP 的 Socket 是一個文件流,是很是準確的。由於 Socket 在 linux 中就是以文件的形式存在的。除此以外,還存在文件描述符。寫入和讀出,也是經過文件描述符。
每個進程都有一個數據結構 task_struct,裏面指向一個文件描述符數組,來列出這個進程打開的全部文件的文件描述符。文件描述符是一個整數索引值,是這個數組的下標。
這個數組中的內容是一個指針,指向內核中全部打開的文件列表。而每一個文件也會有一個 inode(索引節點)。
對於 Socke 而言,它是一個文件,也就有對於的文件描述符。與真正的文件系統不同的是,Socket 對於的 inode 並非保存在硬盤上,而是在內存中。在這個 inode 中,指向了 Socket 在內核中的 Socket 結構。
在這個機構裏面,主要有兩個隊列。一個發送隊列,一個接收隊列。這兩個隊列裏面,保存的是一個緩存 sk_buff。這個緩存裏可以看到完整的包結構。說到這裏,你應該就會發現,數據結構以及和前面瞭解的收發包的場景聯繫起來了。
上面整個過程提及來稍顯混亂,可對比下圖加深理解。
基於 UDP 的 Socket 編程過程和 TCP 有些不一樣。UDP 是沒有鏈接狀態的,因此不須要三次握手,也就不須要調用 listen 和 connect。沒有鏈接狀態,也就不須要維護鏈接狀態,於是不須要對每一個鏈接創建一組 Socket,只要創建一組 Socket,就能和多個客戶端通訊。也正是由於沒有鏈接狀態,每次通訊的時候,均可以調用 sendto 和 recvfrom 傳入 IP 地址和端口。
下圖是基於 UDP 的 Socket 函數調用過程:
瞭解了基本的 Socket 函數後,就能夠寫出一個網絡交互的程序了。就像上面的過程同樣,在創建鏈接後,進行一個 while 循環,客戶端發了收,服務端收了發。
很明顯,這種一臺服務器服務一個客戶的方式和咱們的實際須要相差甚遠。這就至關於老闆成立了一個公司,只有本身一我的,本身親自服務客戶,只能幹完一家再幹下一家。這種方式確定賺不了錢,這時候,就要想,我最多能接多少項目呢?
咱們能夠先來算下理論最大值,也就是理論最大鏈接數。系統會用一個四元組來標識一個 TCP 鏈接:
{本機 IP,本機端口,對端 IP,對端端口}
服務器一般固定監聽某個本地端口,等待客戶端鏈接請求。所以,上面四元組中,可變的項只有對端 IP 和對端端口,也就是客戶端 IP 和客戶端端口。不可貴出:
最大 TCP 鏈接數 = 客戶端 IP 數 x 客戶端端口數。
對於 IPv4:
客戶端最大 IP 數 = 2 的 32 次方
對於端口數:
客戶端最大端口數 = 2 的 16 次方
所以:
最大 TCP 鏈接數 = 2 的 48 次方(估算值)
固然,服務端最大併發 TCP 鏈接數遠不能達到理論最大值。主要有如下緣由:
因此,做爲老闆,在資源有限的狀況下,要想接更多的項目,賺更多的錢,就要下降每一個項目消耗的資源數目。
本着這個原則,咱們能夠找到如下幾種方式來最可能的下降消耗項目消耗資源。
這就至關於你是一個代理,監聽來的請求,一旦創建一個鏈接,就會有一個已鏈接的 Socket,這時候你能夠建立一個紫禁城,而後將基於已鏈接的 Socket 交互交給這個新的子進程來作。就像來了一個新項目,你能夠註冊一家子公司,招人,而後把項目轉包給這就公司作,這樣你就又能夠去接新的項目了。
這裏有個問題是,如何建立子公司,並將項目移交給子公司?
在 Linux 下,建立子進程使用 fork 函數。經過名字能夠看出,這是在父進程的基礎上徹底拷貝一個子進程。在 Linux 內核中,會複製文件描述符的列表,也會複製內存空間,還會複製一條記錄當前執行到了哪一行程序的進程。
這樣,複製完成後,父進程和子進程都會記錄當前剛剛執行完 fork。這兩個進程剛複製完的時候,幾乎如出一轍,只是根據 fork 的返回值來區分是父進程仍是子進程。若是返回值是 0,則是子進程,若是返回值是其餘的整數,就是父進程,這裏返回的整數,就是子進程的 ID。
進程複製過程以下圖:
由於複製了文件描述符列表,而文件描述符都是指向整個內核統一的打開文件列表的。所以父進程剛纔由於 accept 建立的已鏈接 Socket 也是一個文件描述符,一樣也會被子進程得到。
接下來,子進程就能夠經過這個已鏈接 Socket 和客戶端進行通訊了。當通訊完成後,就能夠退出進程。那父進程如何知道子進程幹完了項目要退出呢?父進程中 fork 函數返回的整數就是子進程的 ID,父進程能夠經過這個 ID 查看子進程是否完成項目,是否須要退出。
上面這種方式你應該能發現問題,若是每接一個項目,都申請一個新公司,而後幹完了,就註銷掉,實在是太麻煩了。並且新公司要有新公司的資產、辦公傢俱,每次都買了再賣,不划算。
這時候,咱們應該已經想到了線程。相比於進程來說,線程更加輕量級。若是建立進程至關於成立新公司,而建立線程,就至關於在同一個公司成立新的項目組。一個項目作完了,就解散項目組,成立新的項目組,辦公傢俱還能夠共用。
在 Linux 下,經過 pthread_create 建立一個線程,也是調用 do_fork。不一樣的是,雖然新的線程在 task 列表會新建立一項,可是不少資源,例如文件描述符列表、進程空間,這些仍是共享的,只不過多了一個引用而已。
下圖是線程複製過程:
新的線程也能夠經過已鏈接 Socket 處理請求,從而達到併發處理的目的。
上面兩種方式,不管是基於進程仍是線程模型的,其實仍是有問題的。新到來一個 TCP 鏈接,就須要分配一個進程或者線程。一臺機器能建立的進程和線程數是有限的,並不能很好的發揮服務器的性能。著名的C10K問題,就是說一臺機器如何維護 1 萬了鏈接。按咱們上面的方式,系統就要建立 1 萬個進程或者線程,這是操做系統沒法承受的。
那既然一個線程負責一個 TCP 鏈接不行,能不能一個進程或線程負責多個 TCP 鏈接呢?這就引出了下面兩種方式。
當一個項目組負責多個項目時,就要有個項目進度牆來把控每一個項目的進度,除此以外,還得有我的專門盯着進度牆。
上面說過,Socket 是文件描述符,所以某個線程盯的全部的 Socket,都放在一個文件描述符集合 fd_set 中,這就是項目進度牆。而後調用 select 函數來監聽文件描述符集合是否有變化,一旦有變化,就會依次查看每一個文件描述符。那些發生變化的文件描述符在 fd_set 對應的位都設爲 1,表示 Socket 可讀或者可寫,從而能夠進行讀寫操做,而後再調用 select,接着盯着下一輪的變化。
上面 select 函數仍是有問題的,由於每次 Socket 所在的文件描述符集合中有發生變化的時候,都須要經過輪詢的方式將全部的 Socket 查看一遍,這大大影響了一個進程或者線程可以支撐的最大鏈接數量。使用 select,可以同時監聽的數量由 FD_SETSIZE 限制。
若是改爲事件通知的方式,狀況就會好不少。項目組不須要經過輪詢挨個盯着全部項目,而是當項目進度發生變化的時候,主動通知項目組,而後項目組再根據項目進展狀況作相應的操做。
而 epoll 函數就能完成事件通知。它在內核中的實現不是經過輪詢的方式,而是經過註冊 callback 函數的方式,當某個文件描述符發生變化的時候,主動通知。
如上圖所示,假設進程打開了 Socket m、n、x 等多個文件描述符,如今須要經過 epoll 來監聽這些 Socket 是否有事件發生。其中 epoll_create 建立一個 epoll 對象,也是一個文件,對應一個文件描述符,一樣也對應着打開文件列表中的一項。在這項裏面有一個紅黑樹,在紅黑樹裏,要保存這個 epoll 監聽的全部的 Socket。
當 epoll_ctl 添加一個 Scoket 的時候,其實就是加入這個紅黑樹中。同時,紅黑樹裏面的節點指向一個結構,將這個結構掛在被監聽的 Socket 的事件列表中。當一個 Socket 發生某個事件時,能夠從這個列表中獲得 epoll 對象,並調用 call_back 通知它。
這種事件通知的方式使得監聽的 Socket 數量增長的同時,效率也不會大幅度下降。所以,可以同時監聽的 Socket 的數量就很是的多了。上限爲系統定義的,進程打開的最大文件描述符個數。於是,epoll 被稱爲解決 C10K 問題的利器。
參考: