環境:
- window10
- .net core 3.1
- vs2019
- centos 7.6
- wireshark 3.4.7
參考:《圖解 | 原來這就是TCP》
一、網絡基礎知識
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAzNfRHLGZkRGZkRfJ3bs92YsYTMfVmepNHL9smeNNTW65EMBRVT3V1MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnL0ADOjNTMlhTMlFDZyEzMihTZzQzM4QzNjRTMxImZiZzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
簡單描述一下網絡傳輸過程,以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
然後運作用戶端,指令為:
[[email protected] 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服務端:
[[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
直接來看用戶端執行後的效果:
從上面可以看到,第一次和
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上吧。