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

系列文章傳送門:

  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 編程。

    説 TCP 和 UDP 的時候,我們是分成客户端和服務端來認識的,那在寫 Socket 的時候,我們也這樣分。

    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 地址和端口號,然後開始發起三次握手。內核會給客户端分配一個臨時的端口,一旦握手成功,服務端的 accep 就會返回另一個 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 函數調用過程:

服務器最大併發量

    瞭解了基本的 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 函數的方式,當某個文檔描述符發生變化的時候,主動通知。

    如上圖所示,假設進程打開了 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. 劉超 - 趣談網絡協議系列課;

關鍵詞:一個 socket 進程 連接 項目 文檔 tcp 協議 描述符 可以

相關推薦:

網絡協議 8 - TCP協議(下):聰明反被聰明誤

網絡協議 7 - UDP 協議:性善碰到城會玩

網絡協議 3 - 從物理層到 MAC 層

網絡協議 1 - 概述

網絡編程技術(未完待續)

詳解Java Socket的工作機制

一文告訴你java NIO底層用到的那些connect、bind、listen、accept、close

網絡,網絡編程