天天看點

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題

環境:

  • window10
  • .net core 3.1
  • vs2019
  • centos 7.6
  • wireshark 3.4.7
參考:《圖解 | 原來這就是TCP》

一、網絡基礎知識

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題

簡單描述一下網絡傳輸過程,以web伺服器傳回http封包到浏覽器為例:

  • web伺服器将http響應封包裝進TCP包裹中,然後填寫上目的端口和源端口,比如:80 => 12345。
  • 作業系統将TCP包裹裝進IP包裹中并寫上目的IP位址和自己的IP位址,比如:11.193.0.12 => 23.12.41.23
  • 作業系統再将IP包裝裝進MAC包裹中,并協商目的MAC位址和自己使用網卡的MAC位址,然後将它發送給路由器;
  • 網際網路上的路由器接到MAC包裹就拆封并根據IP包裹上訓示的目的IP位址進行轉發(轉發時再重新打包MAC包裹并寫上目的MAC位址和自己的MAC位址);
  • 經網際網路上路由器層層轉發,包裹不知被重新打包了多少次,最外層的MAC位址不知被改了多少次,但IP包裹一直都沒動過;
  • 最終MAC包裹到達了客戶機網卡,作業系統層層打開包裹,最終根據指定的IP位址和TCP端口找到了目的應用程式-浏覽器。

上面是http封包在網絡中流轉的基礎模型。

二、TCP概念

IP協定的特點是:盡最大努力傳遞。 即:我們通過郵局發送了一封信,它八成是能送達的,但不免遇上天災人禍,信件可能丢失。

UDP是基于IP協定的,而且設計的比較簡單,它也是最大努力傳遞。

TCP

同樣是基于IP協定的,但它要複雜的多,因為它要

保證傳遞!

它就像是我們打電話一樣,專門建立了一個連接配接通道。

2.1 TCP連接配接的建立

要想通過TCP傳輸封包,就必須先建立TCP連接配接通道,建立通道就要經過TCP三向交握,過程如下:

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題

用語言描述如下:

A:“B,我請求建立連接配接,收到回複”

B:“收到,我準備好了,你也準備好,收到回複”

A:“收到,我也準備好了”

之後它們就正常的通信了。

圖上的SYN、ACK是TCP封包頭中兩個二進制位狀态。

2.2 TCP連接配接的釋放

當通信完畢,兩邊的電腦就協商退出了,這個過程稱為:4次揮手!

示意圖如下:

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題

用語言描述如下:

A:“請求斷開,收到回複”

B:“已收到,請稍等,我在做清理工作”

B:“我已清理完畢,可以斷開”

A:“收到,我早就準備好了,你斷開吧”

雖然他們揮手完畢了,但故事到這裡并沒有結束,A在想:“B會不會沒收到消息啊,我再等一會吧,如果沒有意外,我再關閉”。這一等,可能是2分鐘,也可能是4分鐘。

我們知道,1分鐘對軟體來說都已經很長了,這裡竟然要等這麼久,是以:

天下苦TCP久已!

我們在并發高的機器上會遇到提示Socket耗盡的情況,當我們排查問題時,可能會發現很多處于

TIME_WAIT

FIN_WAIT_2

狀态的連接配接,這些TCP連接配接将斷未斷還占着端口,就導緻了Socket不夠用的情況。

2.3 TCP連接配接中的其他細節

2.3.1 丢包問題

我們知道IP是盡最大努力傳遞,即不可靠,那麼TCP是怎樣保證可靠的呢?

原來,TCP在發了一個包後就等待對方的回複,收到對方确認後再發送第二個包,如果沒收到回複就再重發,這樣就保證了包一定能正确的發出去,不會是發出去之後對方說不收到都不知道。這種模式叫做

停止等待協定

停止等待協定

的好處是,TCP發包時心裡有數,不會丢包、漏包,但它的缺點是:

效率太低了!

不能發一個包就等一次回複啊。。。

于是,就又發明了

連續等待協定

,這個協定是這樣的:将要發送的包按照順序編号,然後一批一批的發出去(注意:不是全部發出去,比如:共100個包,可以一批就發20個),也不用等待對方回複,對方收每個包後都回複一下,當收到對方回複的包序号時,就任務這個序号之前的都發送成功了。

2.3.2 流量問題

在上面說連續等待協定的時候,100個包分批發送,一批可能發20個,那麼怎麼确定每一批發多少呢,總不能固定吧。一批發的多的話,對方确認不了,長時間收不到确認的話還要重發,發的少的話,效率還是提不上。

解決這個問題的最好的辦法就是讓對方告訴你它還能接受多少個包。比如,對方在某個回複包中說自己還可以接受10個,那麼自己就控制發送出去的包維持在10個左右,如果對方說它還等處理30個,那麼自己就控制發出去的包在30個左右,而這個數量是一直在調整的。這個數量就稱為

滑動視窗

。它能有效的解決:

根據對方處理包的速度合理選擇發送的速度。

2.3.4 擁塞問題

但有的時候,不是 對方 的接受能力不夠,而是網絡不太好,造成了網絡擁塞。如果出現了這種情況,自己也要控制不能發包發的太快,這種情況可以根據收到的回複情況自行判斷,是以上面說的滑動視窗的大小還要受這個的影響。

好了,以上就是TCP的連接配接的主要内容了,大部分參考了《圖解 | 原來這就是TCP》。

三、程式設計中的Socket

Socket是網絡程式設計中的概念,封裝者TCP、UDP這種協定, 由作業系統提供,最終交給程式代碼操作。

這裡僅讨論操作TCP的Socket。

先來看一個問題:

作業系統如何在本機上标記唯一的TCP連接配接?四元組的概念:

四元組 :=(源IP,源端口,目的IP,目的端口)。

根據TCP的設計,作業系統可以用四元組進行辨別,即:多個程式可以共用一個IP位址+端口号,隻要它們連接配接的目的IP或端口不相同即可。

道理是這樣的,但實際卻有差别。

3.1 實驗linux中在目的ip不同的情況下是否可以重用本地端口

有一個window10系統、一個centos7.6系統,它們的IP如下:

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題
一個網卡是可以設定多個IP位址的,widow下網卡屬性-> IPv4->進階彈框裡可以添加多個ip。

在TCP封包中,使用兩個位元組傳輸端口位址,是以理論上端口的範圍是:0 - 2^16 即: 1-65535。即使把常用的端口排除掉,剩餘的可用端口依然很多,為了能測試端口的複用情況,我們需要手動将linux自動配置設定的端口範圍縮小:

縮小linux中自動配置設定的端口範圍:

首先,觀察linux目前自動配置設定的端口範圍:

cat /proc/sys/net/ipv4/ip_local_port_range

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題

然後, 在

/etc/sysctl.conf

檔案中添加一行: net.ipv4.ip_local_port_range = 60000 60009,添加完成後如下:

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題

然後,執行

sysctl -p /etc/sysctl.conf

,使這個配置生效:

c#:TCP網絡基礎知識 & 初識Socket程式設計 & Socket程式設計中的問題

有了上面的設定後,我們就能輕松模拟端口不夠用的情況了,也就很容易觀察到端口是否重用。

開始試驗:

現在來看一下服務端代碼:

class ProgramServer
{
    static void Main(string[] args)
    {
        var endpointStr = args[0];
        var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        var endPoint = IPEndPoint.Parse(endpointStr);
        socket.Bind(endPoint);
        socket.Listen(70000);
        Console.WriteLine($"伺服器啟動: {endpointStr},接受最大連接配接數: {70000} ...");
        var dic = new Dictionary<string, Socket>();
        var task = Task.Run(() =>
          {
              long count = 0;
              while (true)
              {
                  var acceptSocket = socket.Accept();
                  count++;
                  var lcoalEndPoint = acceptSocket.LocalEndPoint as IPEndPoint;
                  var remoteEndPoint = acceptSocket.RemoteEndPoint as IPEndPoint;
                  dic.Add($"{remoteEndPoint.Address}:{remoteEndPoint.Port}", acceptSocket);
                  Console.WriteLine($"{count.ToString().PadRight(7)} 接受連接配接 {lcoalEndPoint.Address}:{lcoalEndPoint.Port} <= {remoteEndPoint.Address}:{remoteEndPoint.Port}");
              }
          });
        Console.ReadLine();
    }
}
           

用戶端代碼:

class ProgramClient
{
    static void Main(string[] args)
    {
        var count = 0;
        var endpoint = IPEndPoint.Parse("192.168.0.6:5000");
        try
        {
            for (var i = 0; i < 100; i++)
            {
                var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                socket.Connect(endpoint);
                var localEndPoint = socket.LocalEndPoint as IPEndPoint;
                var remoteEndPoint = socket.RemoteEndPoint as IPEndPoint;
                count++;
                Console.WriteLine($"{count.ToString().PadLeft(2)} 連接配接成功:  {localEndPoint.Address}:{localEndPoint.Port} -> {remoteEndPoint.Address}:{remoteEndPoint.Port}");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"exception {count.ToString().PadLeft(2)} 192.168.0.6:5000 {ex?.Message}\r\n {ex?.StackTrace}");
        }

        endpoint = IPEndPoint.Parse("192.168.0.123:5000");
        try
        {
            for (var i = 0; i < 100; i++)
            {
                var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                socket.Connect(endpoint);
                var localEndPoint = socket.LocalEndPoint as IPEndPoint;
                var remoteEndPoint = socket.RemoteEndPoint as IPEndPoint;
                count++;
                Console.WriteLine($"{count.ToString().PadLeft(2)} 連接配接成功:  {localEndPoint.Address}:{localEndPoint.Port} -> {remoteEndPoint.Address}:{remoteEndPoint.Port}");
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine($"exception {count.ToString().PadLeft(2)} 192.168.0.123:5000 {ex?.Message}\r\n {ex?.StackTrace}");
        }

        Console.ReadLine();
    }
}
           

服務端運作兩次,指令分别為:

dotnet run 192.168.0.6:5000

dotnet run 192.168.0.123:5000

然後運作用戶端,指令為:

[[email protected] TcpClient]# dotnet TcpClient.dll

最終伺服器的效果為:

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

用戶端的效果為:

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

從上圖中可以看出,linux允許端口重用,即:隻要TCP連接配接的四元組中有一個不相同的即可。

3.2 試驗window中目的IP不同的情況下是否可以重用本地端口

有一個window10系統、兩個centos系統,它們ip配置設定如下:

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

和試驗1一樣,我們需要先縮小window上自動配置設定的端口範圍。

縮小window中自動配置設定的端口範圍:

首先,觀察window目前自動配置設定的端口範圍:

netsh int ipv4 show dynamicport tcp

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

上面的範圍是:49152+16384=65536,即:49152-65536

然後,調整範圍:

netsh int ipv4 set dynamicport tcp start=60000 num=255

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題
window下調整動态端口範圍,參照:《Windows修改動态端口範圍》

因為window下動态端口最少也要255個,是以需要調整下用戶端代碼,如下:

// 用戶端代碼
class ProgramClient
 {
     static void Main(string[] args)
     {
         var count = 0;
         var list = new List<string>();
         var endpoint = IPEndPoint.Parse("192.168.0.9:5000");
         try
         {
             for (var i = 0; i < 300; i++)
             {
                 var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                 socket.Connect(endpoint);
                 var localEndPoint = socket.LocalEndPoint as IPEndPoint;
                 var remoteEndPoint = socket.RemoteEndPoint as IPEndPoint;
                 count++;
                 Console.WriteLine($"{count.ToString().PadLeft(3)} 連接配接成功:  {localEndPoint.Address}:{localEndPoint.Port} -> {remoteEndPoint.Address}:{remoteEndPoint.Port}");
                 list.Add($"{localEndPoint.Address}:{localEndPoint.Port}");
             }
         }
         catch (Exception ex)
         {
             Console.WriteLine($"exception 已建立連接配接: {count.ToString().PadLeft(3)}個 目的:192.168.0.9:5000 {ex?.Message}\r\n {ex?.StackTrace}");
             list = list.OrderBy(i => i).ToList();
             Console.WriteLine($"\t\t 已建立連接配接使用的本地位址({list.FirstOrDefault()} - {list.LastOrDefault()})");
         }

         list = new List<string>();
         endpoint = IPEndPoint.Parse("192.168.0.21:5000");
         try
         {
             for (var i = 0; i < 300; i++)
             {
                 var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                 socket.Connect(endpoint);
                 var localEndPoint = socket.LocalEndPoint as IPEndPoint;
                 var remoteEndPoint = socket.RemoteEndPoint as IPEndPoint;
                 count++;
                 Console.WriteLine($"{count.ToString().PadLeft(3)} 連接配接成功:  {localEndPoint.Address}:{localEndPoint.Port} -> {remoteEndPoint.Address}:{remoteEndPoint.Port}");
                 list.Add($"{localEndPoint.Address}:{localEndPoint.Port}");
             }
         }
         catch (Exception ex)
         {
             Console.WriteLine($"exception 已建立連接配接: {count.ToString().PadLeft(3)}個 目的:192.168.0.123:5000 {ex?.Message}\r\n {ex?.StackTrace}");
             list = list.OrderBy(i => i).ToList();
             Console.WriteLine($"\t\t 已建立連接配接使用的本地位址({list.FirstOrDefault()} - {list.LastOrDefault()})");
         }

         Console.ReadLine();
     }
 }
           

服務端代碼不變。

開始試驗:

首先,分别在兩台linux伺服器上運作socket服務端:

[[email protected] TcpServer]# dotnet TcpServer.dll 192.168.0.9:5000

[r[email protected] TcpServer]# dotnet TcpServer.dll 192.168.0.21:5000

然後,在window運作用戶端:

C:\Users\jackletter\source\repos\Test\TcpClient> dotnet run

直接來看用戶端執行後的效果:

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

從上面可以看到,第一次和

192.168.0.9:5000

共建立了238個連接配接,後續由于無可用端口失敗。

第二次和

192.168.0.21:5000

一個連接配接也沒建立成功,說明上一次開啟TCP連接配接占用的端口并不能重用。

這就說明,linux和window在對待TCP四元組的時候又重大的差異!

注意:測試的時候由于window機器就是我現在使用的機器,是以端口耗盡後導緻浏覽器網頁都打不開了。。。

那麼,有沒有辦法通過設定在window上重用端口呢?

有,也沒有!

當window作為客戶機的時候,一般建立Socket連接配接使用的本地端口都是作業系統配置設定的,但是也可以自己配置設定,當自己配置設定的時候就能實作重用了,如下面代碼所示:

class ProgramClient
{
    static void Main(string[] args)
    {
        var list = new List<string>();
        var endpoint = IPEndPoint.Parse("192.168.0.9:5000");

        var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        //設定重用位址
        socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
        //需要手動綁定本地的IP和端口
        socket.Bind(IPEndPoint.Parse("192.168.0.6:60005"));
        socket.Connect(endpoint);
        var localEndPoint = socket.LocalEndPoint as IPEndPoint;
        var remoteEndPoint = socket.RemoteEndPoint as IPEndPoint;
        list.Add($"{localEndPoint.Address}:{localEndPoint.Port}");
        Console.WriteLine($"{list.Count.ToString().PadLeft(3)} 連接配接成功:  {localEndPoint.Address}:{localEndPoint.Port} -> {remoteEndPoint.Address}:{remoteEndPoint.Port}");

        endpoint = IPEndPoint.Parse("192.168.0.21:5000");
        socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        //設定重用位址
        socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
        //需要手動綁定本地的IP和端口
        socket.Bind(IPEndPoint.Parse("192.168.0.6:60005"));
        socket.Connect(endpoint);
        localEndPoint = socket.LocalEndPoint as IPEndPoint;
        remoteEndPoint = socket.RemoteEndPoint as IPEndPoint;
        list.Add($"{localEndPoint.Address}:{localEndPoint.Port}");
        Console.WriteLine($"{list.Count.ToString().PadLeft(3)} 連接配接成功:  {localEndPoint.Address}:{localEndPoint.Port} -> {remoteEndPoint.Address}:{remoteEndPoint.Port}");

        Console.ReadLine();
    }
}
           

經過這樣設定後,運作window用戶端:

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

可以看到,本地的端口确實重用了,,但是:作為客戶機的Socket怎麼會手動綁定自己的IP和端口呢?一般都是作業系統配置設定的好嘛? 是以隻能說

有也沒有

可以自行測試一下,如果

socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);

後面不跟上

socket.Bind(IPEndPoint.Parse("192.168.0.6:60005"));

會不會發生端口重用。

四、Socket程式設計中兩個問題

4.1 連接配接無法及時斷開的問題

其實,從上面講TCP協定的時候就能看出來,者并不是Socket本身的問題,而是TCP協定的問題。

那麼,能不能從Socket本身解決呢?

首先,在window下可以配置TIME_WAIT的時間,操作步驟在MSDN上有介紹:

https://docs.microsoft.com/zh-CN/troubleshoot/windows-client/networking/tcpip-and-nbt-configuration-parameters

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

然後,在Socket關閉時,可以使用Socket.Close(0)強制關閉,調用此方法後,TCP的關閉将不會再發生四次揮手,而是關閉的一方直接向對方發送RST信号,發完後自己就直接關閉了。

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題
c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

如果,調用:

socket.Close();

那麼就會使用标準TCP的四次揮手關閉連接配接,下面是我的實驗截圖:

c#:TCP網絡基礎知識 &amp; 初識Socket程式設計 &amp; Socket程式設計中的問題

這個實驗中關閉的時候卡在了FIN_WAIT_2狀态,可能是我自己寫的服務端Socket處理的不好,導緻第三次揮手的信号都沒發出來。

不過更多的時候是卡在了TIME_WAIT狀态。

雖然,經過實驗後看到Socket是可以解決TCP無法及時斷開的問題的,但微軟可能有其他的考慮,是以,微軟提供的http請求工具中(如:HttpClient)還是遵循TCP的四次揮手斷開的。

4.2 端口重用問題

這個問題在第三節已經講過了,總體來說,在linux伺服器上不用擔心并發太高導緻端口耗盡的問題,但是Window機器上就要頭疼了。基于此,建議反向代理伺服器都運作在linux上吧。

繼續閱讀