天天看点

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上吧。

继续阅读