环境:
- window10
- .net core 3.1
- vs2019
- centos 7.6
- wireshark 3.4.7
一、网络基础知识
简单描述一下网络传输过程,以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三次握手,过程如下:
用语言描述如下:
A:“B,我请求建立连接,收到回复”
B:“收到,我准备好了,你也准备好,收到回复”
A:“收到,我也准备好了”
之后它们就正常的通信了。
图上的SYN、ACK是TCP报文头中两个二进制位状态。
2.2 TCP连接的释放
当通信完毕,两边的电脑就协商退出了,这个过程称为:4次挥手!
示意图如下:
用语言描述如下:
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如下:
一个网卡是可以设置多个IP地址的,widow下网卡属性-> IPv4->高级弹框里可以添加多个ip。
在TCP报文中,使用两个字节传输端口地址,所以理论上端口的范围是:0 - 2^16 即: 1-65535。即使把常用的端口排除掉,剩余的可用端口依然很多,为了能测试端口的复用情况,我们需要手动将linux自动分配的端口范围缩小:
缩小linux中自动分配的端口范围:
首先,观察linux当前自动分配的端口范围:cat /proc/sys/net/ipv4/ip_local_port_range
然后, 在 /etc/sysctl.conf
文件中添加一行: net.ipv4.ip_local_port_range = 60000 60009,添加完成后如下:
然后,执行sysctl -p /etc/sysctl.conf
,使这个配置生效:
有了上面的设置后,我们就能轻松模拟端口不够用的情况了,也就很容易观察到端口是否重用。
开始试验:
现在来看一下服务端代码:
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
然后运行客户端,命令为:
[root@localhost TcpClient]# dotnet TcpClient.dll
最终服务器的效果为:
客户端的效果为:
从上图中可以看出,linux允许端口重用,即:只要TCP连接的四元组中有一个不相同的即可。
3.2 试验window中目的IP不同的情况下是否可以重用本地端口
有一个window10系统、两个centos系统,它们ip分配如下:
和试验1一样,我们需要先缩小window上自动分配的端口范围。
缩小window中自动分配的端口范围:
首先,观察window当前自动分配的端口范围:netsh int ipv4 show dynamicport tcp
上面的范围是:49152+16384=65536,即:49152-65536
然后,调整范围:netsh int ipv4 set dynamicport tcp start=60000 num=255
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服务端:
[root@localhost TcpServer]# dotnet TcpServer.dll 192.168.0.9:5000
[root@localhost TcpServer]# dotnet TcpServer.dll 192.168.0.21:5000
然后,在window运行客户端:
C:\Users\jackletter\source\repos\Test\TcpClient> dotnet run
直接来看客户端执行后的效果:
从上面可以看到,第一次和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客户端:
可以看到,本地的端口确实重用了,,但是:作为客户机的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
然后,在Socket关闭时,可以使用Socket.Close(0)强制关闭,调用此方法后,TCP的关闭将不会再发生四次挥手,而是关闭的一方直接向对方发送RST信号,发完后自己就直接关闭了。
如果,调用:socket.Close();
那么就会使用标准TCP的四次挥手关闭连接,下面是我的实验截图:
这个实验中关闭的时候卡在了FIN_WAIT_2状态,可能是我自己写的服务端Socket处理的不好,导致第三次挥手的信号都没发出来。
不过更多的时候是卡在了TIME_WAIT状态。
虽然,经过实验后看到Socket是可以解决TCP无法及时断开的问题的,但微软可能有其他的考虑,所以,微软提供的http请求工具中(如:HttpClient)还是遵循TCP的四次挥手断开的。
4.2 端口重用问题
这个问题在第三节已经讲过了,总体来说,在linux服务器上不用担心并发太高导致端口耗尽的问题,但是Window机器上就要头疼了。基于此,建议反向代理服务器都运行在linux上吧。