網絡協議 10 - Socket 編程:實踐是檢驗真理的惟一標準

系列文章傳送門:html

  1. 網絡協議 1 - 概述
  2. 網絡協議 2 - IP 是怎麼來,又是怎麼沒的?
  3. 網絡協議 3 - 從物理層到 MAC 層
  4. 網絡協議 4 - 交換機與 VLAN:辦公室太複雜,我要回學校
  5. 網絡協議 5 - ICMP 與 ping:投石問路的偵察兵
  6. 網絡協議 6 - 路由協議:敢問路在何方?
  7. 網絡協議 7 - UDP 協議:性善碰到城會玩
  8. 網絡協議 8 - TCP 協議(上):性惡就要套路深
  9. 網絡協議 9 - TCP協議(下):聰明反被聰明誤

    前面一直在說各類協議,偏理論方面的知識,此次我們就來認識下基於 TCP 和 UDP 協議這些理論知識的 Socket 編程。node

    說 TCP 和 UDP 的時候,咱們是分紅客戶端和服務端來認識的,那在寫 Socket 的時候,咱們也這樣分。linux

    Socket 這個名字頗有意思,能夠做插口或者插槽講。咱們寫程序時,就能夠將 Socket 想象爲,一頭插在客戶端,一頭插在服務端,而後進行通訊。編程

    在創建 Socket 的時候,應該設置什麼參數呢?Socket 編程進行的是端到端的通訊,每每意識不到中間通過多少局域網,多少路由器,於是可以設置的參數,也只能是端到端協議之上網絡層和傳輸層的數組

    對於網絡層和傳輸層,有如下參數須要設置:緩存

  • IP協議:IPv4 對應 AF_INEF,IPv6 對應 AF_INET6;
  • 傳輸層協議:TCP 與 UDP。TCP 協議基於數據流,其對應值是 SOCKET_STREAM,而 UDP 是基於數據報的,其對應值是 SOCKET_DGRAM。

    兩端建立了 Socket 以後,然後面的過程當中,TCP 和 UDP 稍有不一樣,咱們先來看看 TCP。服務器

基於 TCP 協議的 Socket

    對於 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

    基於 UDP 的 Socket 編程過程和 TCP 有些不一樣。UDP 是沒有鏈接狀態的,因此不須要三次握手,也就不須要調用 listen 和 connect。沒有鏈接狀態,也就不須要維護鏈接狀態,於是不須要對每一個鏈接創建一組 Socket,只要創建一組 Socket,就能和多個客戶端通訊。也正是由於沒有鏈接狀態,每次通訊的時候,均可以調用 sendto 和 recvfrom 傳入 IP 地址和端口。

    下圖是基於 UDP 的 Socket 函數調用過程:

clipboard.png

服務器最大併發量

    瞭解了基本的 Socket 函數後,就能夠寫出一個網絡交互的程序了。就像上面的過程同樣,在創建鏈接後,進行一個 while 循環,客戶端發了收,服務端收了發。

    很明顯,這種一臺服務器服務一個客戶的方式和咱們的實際須要相差甚遠。這就至關於老闆成立了一個公司,只有本身一我的,本身親自服務客戶,只能幹完一家再幹下一家。這種方式確定賺不了錢,這時候,就要想,我最多能接多少項目呢?

    咱們能夠先來算下理論最大值,也就是理論最大鏈接數。系統會用一個四元組來標識一個 TCP 鏈接:

{本機 IP,本機端口,對端 IP,對端端口}

    服務器一般固定監聽某個本地端口,等待客戶端鏈接請求。所以,上面四元組中,可變的項只有對端 IP 和對端端口,也就是客戶端 IP 和客戶端端口。不可貴出:

最大 TCP 鏈接數 = 客戶端 IP 數 x 客戶端端口數。

    對於 IPv4:

客戶端最大 IP 數 = 2 的 32 次方

    對於端口數:

客戶端最大端口數 = 2 的 16 次方

    所以:

最大 TCP 鏈接數 = 2 的 48 次方(估算值)

    固然,服務端最大併發 TCP 鏈接數遠不能達到理論最大值。主要有如下緣由:

  1. 文件描述符限制。按照上面的原理,Socket 都是文件,因此首先要經過 ulimit 配置文件描述符的數目;
  2. 內存限制。按上面的數據結構,每一個 TCP 鏈接都要佔用必定的內存,而系統內存是有限的。

    因此,做爲老闆,在資源有限的狀況下,要想接更多的項目,賺更多的錢,就要下降每一個項目消耗的資源數目

    本着這個原則,咱們能夠找到如下幾種方式來最可能的下降消耗項目消耗資源。

1)將項目外包給其餘公司(多進程方式)

    這就至關於你是一個代理,監聽來的請求,一旦創建一個鏈接,就會有一個已鏈接的 Socket,這時候你能夠建立一個紫禁城,而後將基於已鏈接的 Socket 交互交給這個新的子進程來作。就像來了一個新項目,你能夠註冊一家子公司,招人,而後把項目轉包給這就公司作,這樣你就又能夠去接新的項目了。

    這裏有個問題是,如何建立子公司,並將項目移交給子公司?

    在 Linux 下,建立子進程使用 fork 函數。經過名字能夠看出,這是在父進程的基礎上徹底拷貝一個子進程。在 Linux 內核中,會複製文件描述符的列表,也會複製內存空間,還會複製一條記錄當前執行到了哪一行程序的進程。

    這樣,複製完成後,父進程和子進程都會記錄當前剛剛執行完 fork。這兩個進程剛複製完的時候,幾乎如出一轍,只是根據 fork 的返回值來區分是父進程仍是子進程。若是返回值是 0,則是子進程,若是返回值是其餘的整數,就是父進程,這裏返回的整數,就是子進程的 ID

    進程複製過程以下圖:

    由於複製了文件描述符列表,而文件描述符都是指向整個內核統一的打開文件列表的。所以父進程剛纔由於 accept 建立的已鏈接 Socket 也是一個文件描述符,一樣也會被子進程得到。

    接下來,子進程就能夠經過這個已鏈接 Socket 和客戶端進行通訊了。當通訊完成後,就能夠退出進程。那父進程如何知道子進程幹完了項目要退出呢?父進程中 fork 函數返回的整數就是子進程的 ID,父進程能夠經過這個 ID 查看子進程是否完成項目,是否須要退出。

2)將項目轉包給獨立的項目組(多線程方式)

    上面這種方式你應該能發現問題,若是每接一個項目,都申請一個新公司,而後幹完了,就註銷掉,實在是太麻煩了。並且新公司要有新公司的資產、辦公傢俱,每次都買了再賣,不划算。

    這時候,咱們應該已經想到了線程。相比於進程來說,線程更加輕量級。若是建立進程至關於成立新公司,而建立線程,就至關於在同一個公司成立新的項目組。一個項目作完了,就解散項目組,成立新的項目組,辦公傢俱還能夠共用。

    在 Linux 下,經過 pthread_create 建立一個線程,也是調用 do_fork。不一樣的是,雖然新的線程在 task 列表會新建立一項,可是不少資源,例如文件描述符列表、進程空間,這些仍是共享的,只不過多了一個引用而已。

    下圖是線程複製過程:

    新的線程也能夠經過已鏈接 Socket 處理請求,從而達到併發處理的目的。

    上面兩種方式,不管是基於進程仍是線程模型的,其實仍是有問題的。新到來一個 TCP 鏈接,就須要分配一個進程或者線程。一臺機器能建立的進程和線程數是有限的,並不能很好的發揮服務器的性能。著名的C10K問題,就是說一臺機器如何維護 1 萬了鏈接。按咱們上面的方式,系統就要建立 1 萬個進程或者線程,這是操做系統沒法承受的。

    那既然一個線程負責一個 TCP 鏈接不行,能不能一個進程或線程負責多個 TCP 鏈接呢?這就引出了下面兩種方式。

3)一個項目組支撐多個項目(IO 多路複用,一個線程維護多個 Socket)

    當一個項目組負責多個項目時,就要有個項目進度牆來把控每一個項目的進度,除此以外,還得有我的專門盯着進度牆

    上面說過,Socket 是文件描述符,所以某個線程盯的全部的 Socket,都放在一個文件描述符集合 fd_set 中,這就是項目進度牆。而後調用 select 函數來監聽文件描述符集合是否有變化,一旦有變化,就會依次查看每一個文件描述符。那些發生變化的文件描述符在 fd_set 對應的位都設爲 1,表示 Socket 可讀或者可寫,從而能夠進行讀寫操做,而後再調用 select,接着盯着下一輪的變化。

4)一個項目組支撐多個項目(IO 多路複用,從「派人盯着」到「有事通知」)

    上面 select 函數仍是有問題的,由於每次 Socket 所在的文件描述符集合中有發生變化的時候,都須要經過輪詢的方式將全部的 Socket 查看一遍,這大大影響了一個進程或者線程可以支撐的最大鏈接數量。使用 select,可以同時監聽的數量由 FD_SETSIZE 限制。

    若是改爲事件通知的方式,狀況就會好不少。項目組不須要經過輪詢挨個盯着全部項目,而是當項目進度發生變化的時候,主動通知項目組,而後項目組再根據項目進展狀況作相應的操做。

    而 epoll 函數就能完成事件通知。它在內核中的實現不是經過輪詢的方式,而是經過註冊 callback 函數的方式,當某個文件描述符發生變化的時候,主動通知。

clipboard.png

    如上圖所示,假設進程打開了 Socket m、n、x 等多個文件描述符,如今須要經過 epoll 來監聽這些 Socket 是否有事件發生。其中 epoll_create 建立一個 epoll 對象,也是一個文件,對應一個文件描述符,一樣也對應着打開文件列表中的一項。在這項裏面有一個紅黑樹,在紅黑樹裏,要保存這個 epoll 監聽的全部的 Socket。

    當 epoll_ctl 添加一個 Scoket 的時候,其實就是加入這個紅黑樹中。同時,紅黑樹裏面的節點指向一個結構,將這個結構掛在被監聽的 Socket 的事件列表中。當一個 Socket 發生某個事件時,能夠從這個列表中獲得 epoll 對象,並調用 call_back 通知它。

    這種事件通知的方式使得監聽的 Socket 數量增長的同時,效率也不會大幅度下降。所以,可以同時監聽的 Socket 的數量就很是的多了。上限爲系統定義的,進程打開的最大文件描述符個數。於是,epoll 被稱爲解決 C10K 問題的利器

小結

  • 牢記基於 TCP 和 UDP 的 Socket 編程中,客戶端和服務端須要調用的函數;
  • epoll 機制可以解決 C10K 問題。

參考:

  1. The TCP/IP Guide;
  2. 百度百科 - Socket 詞條;
  3. 劉超 - 趣談網絡協議系列課;
相關文章
相關標籤/搜索