在 TCP 連接中,客戶端在發起連接請求前會先確定一個客戶端端口,然后用這個端口去和服務器端進行握手建立連接。那么在 Linux 上,客戶端的端口到底是如何被確定下來的呢?
事實上很多我們平時遇到的問題都和這個端口選擇過程相關,如果能深度理解這個過程,將有助于我們對這些問題的深刻理解。
- Cannot assign requested address 報錯是怎么回事?
- 一個客戶端端口可以同時用在兩條 TCP 連接上嗎?
還是讓我們借助一段簡單到只有兩句的代碼,從這個來講起!
int?main(){
?fd?=?socket(AF_INET,SOCK_STREAM,?0);
?connect(fd,?...);
?...
}
一、創建 socket
客戶端在發起連接的時候,需要事先創建一個 socket。在 c 語言中,就是調用 socket 函數,例如 socket(AF_INET,SOCK_STREAM, 0)
這句。
socket 函數執行完畢后,在用戶層視角我們是看到返回了一個文件描述符 fd。但在內核中其實是一套內核對象組合,大體結構如下。
從上圖我們看到,socket 在內核里并不是一個內核對象。而是包含 file、socket、sock 等多個相關內核對象構成,每個內核對象還定義了 ops 操作函數集合。在后面的內核源碼執行過程中,我們需要時不時回頭來看這些內核對象,這里先簡單了解一下就行。
這些內核對象都是在 socket 系統調用執行過程中創建出來的。為了避免喧賓奪主,這里只列出入口代碼,詳細過程就不展開介紹了。
//file:?net/socket.c
SYSCALL_DEFINE3(socket,?int,?family,?int,?type,?int,?protocol)
{
?//創建?socket、sock?等內核對象,并初始化
?sock_create(family,?type,?protocol,?&sock);
?//創建?file?內核對象,申請?fd
?sock_map_fd(sock,?flags?&?(O_CLOEXEC?|?O_NONBLOCK));
?......
}
二、connect 發起連接
接下來我們就進入到 connect 函數的執行過程中來。由于這個過程比較長,所以我們分成幾個小節來進行討論。
2.1 connect 調用鏈展開
當我們在客戶端機上調用 connect 函數的時候,事實上會進入到內核的系統調用源碼中進行執行。
//file:?net/socket.c
SYSCALL_DEFINE3(connect,?int,?fd,?struct?sockaddr?__user?*,?uservaddr,
??int,?addrlen)
{
?struct?socket?*sock;
?//根據用戶?fd?查找內核中的?socket?對象
?sock?=?sockfd_lookup_light(fd,?&err,?&fput_needed);
?//進行?connect
?err?=?sock->ops->connect(sock,?(struct?sockaddr?*)&address,?addrlen,
?????sock->file->f_flags);
?...
}
這段代碼首先根據用戶傳入的 fd(文件描述符)來查詢對應的 socket 內核對象。在第一節中我們看了 socket 內核對象結構,據此可以知道接下來 sock->ops->connect 其實調用的是 inet_stream_connect 函數。
//file:?ipv4/af_inet.c
int?inet_stream_connect(struct?socket?*sock,?...){?
?...
?__inet_stream_connect(sock,?uaddr,?addr_len,?flags);
}
int?__inet_stream_connect(struct?socket?*sock,?...)
{
?struct?sock?*sk?=?sock->sk;
?switch?(sock->state)?{
??case?SS_UNCONNECTED:
???err?=?sk->sk_prot->connect(sk,?uaddr,?addr_len);
???sock->state?=?SS_CONNECTING;
???break;
?}
?...
}
剛創建完畢的 socket 的狀態就是 SS_UNCONNECTED,所以在 __inet_stream_connect 中的 switch 判斷會進入到 case SS_UNCONNECTED 的處理邏輯中。
上述代碼中 sk 取的是 sock 對象。繼續回顧第一節中 socket 的內核數據結構圖,可以得知 sk->sk_prot->connect 實際上對應的是 tcp_v4_connect 方法。
我們來看 tcp_v4_connect 的代碼,它位于 net/ipv4/tcp_ipv4.c。
//file:?net/ipv4/tcp_ipv4.c
int?tcp_v4_connect(struct?sock?*sk,?struct?sockaddr?*uaddr,?int?addr_len){
?//設置?socket?狀態為?TCP_SYN_SENT
?tcp_set_state(sk,?TCP_SYN_SENT);
?//動態選擇一個端口
?err?=?inet_hash_connect(&tcp_death_row,?sk);
?//函數用來根據 sk 中的信息,構建一個完成的 syn 報文,并將它發送出去。
?err?=?tcp_connect(sk);
}
在 tcp_v4_connect 中我們終于看到了選擇端口的函數,那就是 inet_hash_connect。
2.2 選擇可用端口
我們找到 inet_hash_connect 的源碼,我們來看看到底端口是如何選擇出來的。
//file:net/ipv4/inet_hashtables.c
int?inet_hash_connect(struct?inet_timewait_death_row?*death_row,????????struct?sock?*sk){
?return?__inet_hash_connect(death_row,?sk,?inet_sk_port_offset(sk),
???__inet_check_established,?__inet_hash_nolisten);
}
這里需要提一下在調用 __inet_hash_connect 時傳入的兩個重要參數。
- inet_sk_port_offset(sk):這個函數是根據要連接的目的 IP 和端口等信息生成一個隨機數。
- __inet_check_established:檢查是否和現有 ESTABLISH 的連接是否沖突的時候用的函數
了解了這兩個參數后,讓我們進入 __inet_hash_connect。這個函數比較長,為了方便理解,我們先看前面這一段。
//file:net/ipv4/inet_hashtables.c
int?__inet_hash_connect(...)
{
?//是否綁定過端口
?const?unsigned?short?snum?=?inet_sk(sk)->inet_num;
?//獲取本地端口配置
?inet_get_local_port_range(&low,?&high);
??remaining?=?(high?-?low)?+?1;
?if?(!snum)?{
??//遍歷查找
??for?(i?=?1;?i?<=?remaining;?i++)?{
???port?=?low?+?(i?+?offset)?%?remaining;
???...
??}
?}
}
在這個函數中首先判斷了 inet_sk(sk)->inet_num,如果我們調用過 bind,那么這個函數會選擇好端口并設置在 inet_num 上。這個我們后面專門分一小節介紹。這里我們假設沒有調用過 bind,所以 snum 為 0。
接著調用 inet_get_local_port_range,這個函數讀取的是 net.ipv4.ip_local_port_range 這個內核參數。來讀取管理員配置的可用的端口范圍。
該參數的默認值是 32768 61000,意味著端口總可用的數量是 61000 - 32768 = 28232 個。如果你覺得這個數字不夠用,那就修改你的 net.ipv4.ip_local_port_range 內核參數。
接下來進入到了 for 循環中。其中offset 是我們前面說的,通過 inet_sk_port_offset(sk) 計算出的隨機數。那這段循環的作用就是從某個隨機數開始,把整個可用端口范圍來遍歷一遍。直到找到可用的端口后停止。
那么我們接著來看,如何來確定一個端口是否可以使用呢?
//file:net/ipv4/inet_hashtables.c
int?__inet_hash_connect(...)
{
?for?(i?=?1;?i?<=?remaining;?i++)?{
??port?=?low?+?(i?+?offset)?%?remaining;
??//查看是否是保留端口,是則跳過
??if?(inet_is_reserved_local_port(port))
???continue;
??//?查找和遍歷已經使用的端口的哈希鏈表
??head?=?&hinfo->bhash[inet_bhashfn(net,?port,
????hinfo->bhash_size)];
??inet_bind_bucket_for_each(tb,?&head->chain)?{
???//如果端口已經被使用
???if?(net_eq(ib_net(tb),?net)?&&
???????tb->port?==?port)?{
????????????????//通過?check_established?繼續檢查是否可用
????if?(!check_established(death_row,?sk,
???????port,?&tw))
?????goto?ok;
???}
??}
??//未使用的話,直接?ok
??goto?ok;
?}
?return?-EADDRNOTAVAIL;
ok:?
?...??
}
首先判斷的是 inet_is_reserved_local_port,這個很簡單就是判斷要選擇的端口是否在 net.ipv4.ip_local_reserved_ports 中,在的話就不能用。
如果你因為某種原因不希望某些端口被內核使用,那么就把它們寫到 ip_local_reserved_ports 這個內核參數中就行了。
整個系統中會維護一個所有使用過的端口的哈希表,它就是 hinfo->bhash。接下來的代碼就會在這里進行查找。如果在哈希表中沒有找到,那么說明這個端口是可用的。至此端口就算是找到了。
遍歷完所有端口都沒找到合適的,就返回 -EADDRNOTAVAIL,你在用戶程序上看到的就是 Cannot assign requested address 這個錯誤。怎么樣,是不是很眼熟,你見過它的對吧,哈哈!
/*?Cannot?assign?requested?address?*/
#define?EADDRNOTAVAIL?99?
以后當你再遇到 Cannot assign requested address 錯誤,你應該想到去查一下 net.ipv4.ip_local_port_range 中設置的可用端口的范圍是不是太小了。
2.3 端口被使用過怎么辦
回顧剛才的 __inet_hash_connect, 為了描述簡單我們之前跳過了已經在 bhash 中存在時候的判斷。這是由于其一這個過程比較長,其二這段邏輯很有價值,所以飛哥單獨拉一小節出來。
//file:net/ipv4/inet_hashtables.c
int?__inet_hash_connect(...)
{
?for?(i?=?1;?i?<=?remaining;?i++)?{
??port?=?low?+?(i?+?offset)?%?remaining;
??...
??//如果端口已經被使用
??if?(net_eq(ib_net(tb),?net)?&&
???????tb->port?==?port)?{
???//通過?check_established?繼續檢查是否可用
???if?(!check_established(death_row,?sk,?port,?&tw))
????goto?ok;
??}
?}
}
port 已經在 bhash 中如果已經存在,就表示有其它的連接使用過該端口了。請注意,如果 check_established 返回 0,該端口仍然可以接著使用!。
這里可能會讓很多同學困惑了,一個端口怎么可以被用多次呢?
回憶下四元組的概念,兩對兒四元組中只要任意一個元素不同,都算是兩條不同的連接。以下的兩條 TCP 連接完全可以同時存在(假設 192.168.1.101 是客戶端,192.168.1.100 是服務端)
- 連接1:192.168.1.101 5000 192.168.1.100 8090
- 連接2:192.168.1.101 5000 192.168.1.100 8091
check_established 作用就是檢測現有的 TCP 連接中是否四元組和要建立的連接四元素完全一致。如果不完全一致,那么該端口仍然可用!!!
這個 check_established 是由調用方傳入的,實際上使用的是 __inet_check_established。我們來看它的源碼。
//file:?net/ipv4/inet_hashtables.c
static?int?__inet_check_established(struct?inet_timewait_death_row?*death_row,
????????struct?sock?*sk,?__u16?lport,
????????struct?inet_timewait_sock?**twp)
{
?//找到hash桶
?struct?inet_ehash_bucket?*head?=?inet_ehash_bucket(hinfo,?hash);
?//遍歷看看有沒有四元組一樣的,一樣的話就報錯
?sk_nulls_for_each(sk2,?node,?&head->chain)?{
??if?(sk2->sk_hash?!=?hash)
???continue;
??if?(likely(INET_MATCH(sk2,?net,?acookie,
??????????saddr,?daddr,?ports,?dif)))
???goto?not_unique;
?}
unique:
?//要用了,記錄,返回?0?(成功)
?return?0;
not_unique:
?return?-EADDRNOTAVAIL;?
}
該函數首先找到 inet_ehash_bucket,這個和 bhash 類似,只不過是所有 ESTABLISH 狀態的 socket 組成的哈希表。然后遍歷這個哈希表,使用 INET_MATCH 來判斷是否可用。
這里 INET_MATCH 源碼如下:
//?include/net/inet_hashtables.h
#define?INET_MATCH(__sk,?__net,?__cookie,?__saddr,?__daddr,?__ports,?__dif)?\
?((inet_sk(__sk)->inet_portpair?==?(__ports))?&&??\
??(inet_sk(__sk)->inet_daddr?==?(__saddr))?&&??\
??(inet_sk(__sk)->inet_rcv_saddr?==?(__daddr))?&&??\
??(!(__sk)->sk_bound_dev_if?||????\
????((__sk)->sk_bound_dev_if?==?(__dif)))??&&??\??net_eq(sock_net(__sk),?(__net)))
在 INET_MATCH 中將 __saddr、__daddr、__ports 都進行了比較。當然除了 ip 和端口,INET_MATCH還比較了其它一些東東,所以 TCP 連接還有五元組、七元組之類的說法。為了統一,咱們還沿用四元組的說法。
如果 MATCH,就是說就四元組完全一致的連接,所以這個端口不可用。也返回 -EADDRNOTAVAIL。
如果不 MATCH,哪怕四元組中有一個元素不一樣,例如服務器的端口號不一樣,那么就 return 0,表示該端口仍然可用于建立新連接。
所以一臺客戶端機最大能建立的連接數并不是 65535。只要 server 足夠多,單機發出百萬條連接沒有任何問題。
2.4 發起 syn 請求
再回到 tcp_v4_connect,這時我們的 inet_hash_connect 已經返回了一個可用端口了。接下來就進入到 tcp_connect,如下源碼所示。
//file:?net/ipv4/tcp_ipv4.c
int?tcp_v4_connect(struct?sock?*sk,?struct?sockaddr?*uaddr,?int?addr_len){
?......
?//動態選擇一個端口
?err?=?inet_hash_connect(&tcp_death_row,?sk);
?//函數用來根據 sk 中的信息,構建一個完成的 syn 報文,并將它發送出去。
?err?=?tcp_connect(sk);
}
到這里其實就和本文要討論的主題沒有關系了,所以我們只是簡單看一下。
//file:net/ipv4/tcp_output.c
int?tcp_connect(struct?sock?*sk){
?//申請并設置?skb
?buff?=?alloc_skb_fclone(MAX_TCP_HEADER?+?15,?sk->sk_allocation);
?tcp_init_nondata_skb(buff,?tp->write_seq++,?TCPHDR_SYN);
?//添加到發送隊列?sk_write_queue?上
?tcp_connect_queue_skb(sk,?buff);
?//實際發出?syn
?err?=?tp->fastopen_req???tcp_send_syn_data(sk,?buff)?:
???????tcp_transmit_skb(sk,?buff,?1,?sk->sk_allocation);
?//啟動重傳定時器
?inet_csk_reset_xmit_timer(sk,?ICSK_TIME_RETRANS,
??????inet_csk(sk)->icsk_rto,?TCP_RTO_MAX);
}
tcp_connect 一口氣做了這么幾件事
- 申請一個 skb,并將其設置為 SYN 包
- 添加到發送隊列上
- 調用 tcp_transmit_skb 將該包發出
- 啟動一個重傳定時器,超時會重發
三、bind 時端口如何選擇
在 2.2 小節中,我們看到 connect 選擇端口之前先判斷了 inet_sk(sk)->inet_num 有沒有值。如果有的話就直接用這個,而會跳過端口選擇過程。
那么這個值是從哪兒來的呢?不賣關子,它就是在對 socket 使用 bind 時設置的。
不只是服務器端,哪怕是對于客戶端,也可以對 socket 使用 bind 來綁定 IP 或者端口。如果使用了 bind,那么在 bind 的時候就會確定好端口,并設置到 inet_num 變量中。
一般非常不推薦在客戶端角色下使用 bind。因為這會打亂 connect 里的端口選擇過程。
bind 的時候,如果傳了端口,那么 bind 就會嘗試使用該端口。如果端口號傳的是 0 ,那么 bind 有一套獨立的選擇端口號的邏輯。
//file:?net/ipv4/af_inet.c
int?inet_bind(struct?socket?*sock,?struct?sockaddr?*uaddr,?int?addr_len){
?struct?sock?*sk?=?sock->sk;
?...
?//用戶傳入的端口號
?snum?=?ntohs(addr->sin_port);
?//不允許綁定?1024?以下的端口
?if?(snum?&&?snum?<?PROT_SOCK?&&
?????!ns_capable(net->user_ns,?CAP_NET_BIND_SERVICE))
??goto?out;
?//嘗試確定端口號
?if?(sk->sk_prot->get_port(sk,?snum))?{
??inet->inet_saddr?=?inet->inet_rcv_saddr?=?0;
??err?=?-EADDRINUSE;
??goto?out_release_sock;
?}
根據第一節中的 socket 內核對象,能找到 sk->sk_prot->get_port 實際調用的是 inet_csk_get_port。該函數來嘗試確定端口號,如果嘗試失敗,返回 EADDRINUSE。你的應用程序將會顯示一條錯誤信息 “Address already in use”。
#define?EADDRINUSE?226?/*?Address?already?in?use?*/
我們簡單看一下如果用戶沒有傳入端口(傳入的為 0),bind 是怎么選擇端口的。
//file:?net/ipv4/inet_connection_sock.c
int?inet_csk_get_port(struct?sock?*sk,?unsigned?short?snum){
?...
?if?(!snum)?{
??inet_get_local_port_range(&low,?&high);
??remaining?=?(high?-?low)?+?1;
??smallest_rover?=?rover?=?net_random()?%?remaining?+?low;
??do?{
???if?(inet_is_reserved_local_port(rover))
????goto?next_nolock;
???head?=?&hashinfo->bhash[inet_bhashfn(net,?rover,
??????hashinfo->bhash_size)];
???inet_bind_bucket_for_each(tb,?&head->chain)
????//?沖突檢測
????if?(!inet_csk(sk)->icsk_af_ops->bind_conflict(sk,?tb,?false))?{
?????snum?=?rover;
?????goto?tb_found;
????}
??}?while?(--remaining?>?0);
?}
}
這段邏輯和 connect 很像,通過 net_random 來從 net.ipv4.ip_local_port_range 指定的端口范圍內一個隨機位置開始遍歷。也會跳開 ip_local_reserved_ports 保留端口配置。通過 inet_csk(sk)->icsk_af_ops->bind_conflict 進行沖突檢測。
inet_csk_bind_conflict 這個函數整體比較復雜,不過我們只需要了解一點就好,該函數和 connect 中端口選擇邏輯不同的是,并不會到 ESTABLISH 的哈希表進行可用檢測,只在 bind 狀態的 socket 里查。所以默認情況下,只要端口用過一次就不會再次使用。
四、結論
客戶端建立連接前需要確定一個端口,該端口會在兩個位置進行確定。
第一個位置,也是最主要的確定時機是 connect 系統調用執行過程。在 connect 的時候,會隨機地從 ip_local_port_range 選擇一個位置開始循環判斷。找到可用端口后,發出 syn 握手包。如果端口查找失敗,會報錯 “Cannot assign requested address”。這個時候你應該首先想到去檢查一下服務器上的 net.ipv4.ip_local_port_range 參數,是不是可以再放的多一些。
如果你因為某種原因不希望某些端口被使用到,那么就把它們寫到 ip_local_reserved_ports 這個內核參數中就行了,內核在選擇的時候會跳過這些端口。
另外注意即使是一個端口是可以被用于多條 TCP 連接的。所以一臺客戶端機最大能建立的連接數并不是 65535。只要 server 足夠多,單機發出百萬條連接沒有任何問題。
我給大伙兒貼一下我實驗時候在客戶機上實驗時的實際截圖,來實際看一下一個端口號確實是被用在了多條連接上了。
截圖中左邊的 192 是客戶端,右邊的 119 是服務器的 ip。可以看到客戶端的 10000 這個端口號是用在了多條連接上了的。
第二個位置,如果在 connect 之前使用了 bind,將會使得 connect 時的端口選擇方式無效。轉而使用 bind 時確定的端口。bind 時如果傳入了端口號,會嘗試首先使用該端口號,如果傳入了 0 ,也會自動選擇一個。但默認情況下一個端口只會被使用一次。所以對于客戶端角色的 socket,不建議使用 bind !
最后我再想多說一句,上面的選擇端口的都是從 ip_local_port_range 范圍中的某一個隨機位置開始循環的。如果可用端口很充足,則能快一點找到可用端口,那循環很快就能退出。假設實際中 ip_local_port_range 中的端口快被用光了,這時候內核就大概率得把循環多執行很多輪才能找到,這會導致 connect 系統調用的 CPU 開銷的上漲。
所以,最好不要等到端口不夠用了才加大 ip_local_port_range 的范圍,而是事先就應該保持一個充足的范圍。
喜歡的話別忘了?分享、點贊、收藏?三連~
歡迎關注公眾號?前端進階體驗?收獲更多優質文章~
本文使用 文章同步助手 同步