從Linux源碼看Socket(TCP)Client端的Connect

2022-01-05 OSC開源社區

前言筆者一直覺得如果能知道從應用到框架再到作業系統的每一處代碼,是一件Exciting的事情。今天筆者就來從Linux源碼的角度看下Client端的Socket在進行Connect的時候到底做了哪些事情。由於篇幅原因,關於Server端的Accept源碼講解留給下一篇博客。(基於Linux 3.10內核)一個最簡單的Connect例子

int clientSocket;
if((clientSocket = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
// 創建socket失敗失敗
return -1;
}
.
if(connect(clientSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) < 0) {
// connect 失敗
return -1;
}
..

首先我們通過socket系統調用創建了一個socket,其中指定了SOCK_STREAM,而且最後一個參數為0,也就是建立了一個通常所有的TCP Socket。在這裡,我們直接給出TCP Socket所對應的ops也就是操作函數。 
如果你想知道上圖中的結構是怎麼來的,可以看下筆者以前的博客:

https://my.oschina.net/alchemystar/blog/1791017

值得注意的是,由於socket系統調用操作做了如下兩個代碼的判斷

sock_map_fd
|->get_unused_fd_flags
|->alloc_fd
|->expand_files (ulimit)
|->sock_alloc_file
|->alloc_file
|->get_empty_filp (/proc/sys/fs/max_files)

int expand_files(struct files_struct *files, int nr
{
.
if (nr >= current->signal->rlim[RLIMIT_NOFILE].rlim_cur)
return -EMFILE;
.
}

這邊的判斷即是ulimit的限制!在這裡返回-EMFILE對應的描述就是 "Too many open files" 

struct file *get_empty_filp(void)
{
.
/*
* 由此可見,特權用戶可以無視文件數最大大小的限制!
*/
if (get_nr_files() >= files_stat.max_files && !capable(CAP_SYS_ADMIN)) {
/*
* percpu_counters are inaccurate. Do an expensive check before
* we go and fail.
*/
if (percpu_counter_sum_positive(&nr_files) >= files_stat.max_files)
goto over;
}

.
}

所以在文件描述符超過所有進程能打開的最大文件數量限制(/proc/sys/fs/file-max)的時候會返回-ENFILE,對應的描述就是"Too many open files in system",但是特權用戶確可以無視這一限制,如下圖所示: connect系統調用

int connect(int sockfd,const struct sockaddr *serv_addr,socklen_t addrlen)

這個系統調用有三個參數,那麼依據規則,它肯定在內核中的源碼長下面這個樣子

SYSCALL_DEFINE3(connect, .

socket.c
SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr,
int, addrlen)
{
.
err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen,
sock->file->f_flags);
.
}

前面圖給出了在TCP下的sock->ops == inet_stream_ops,然後再陷入到更進一步的調用棧中,即下面的:

SYSCALL_DEFINE3(connect
|->inet_stream_ops
|->inet_stream_connect
|->tcp_v4_connect
|->tcp_set_state(sk, TCP_SYN_SENT);設置狀態為TCP_SYN_SENT
|->inet_hash_connect
|->tcp_connect

首先,我們來看一下inet_hash_connect這個函數,裡面有一個埠號的搜索過程,搜索不到可用埠號就會導致創建連接失敗!內核能夠建立一個連接也是跋涉了千山萬水的!我們先看一下搜索埠號的邏輯,如下圖所示: 獲取埠號範圍首先,我們從內核中獲取connect能夠使用的埠號範圍,在這裡採用了Linux中的順序鎖(seqlock)

void inet_get_local_port_range(int *low, int *high)
{
unsigned int seq;

do {
// 順序鎖
seq = read_seqbegin(&sysctl_local_ports.lock);

*low = sysctl_local_ports.range[0];
*high = sysctl_local_ports.range[1];
} while (read_seqretry(&sysctl_local_ports.lock, seq));
}

順序鎖事實上就是結合內存屏障等機制的一種樂觀鎖,主要依靠一個序列計數器。在讀取數據之前和之後,序列號都被讀取,如果兩者的序列號相同,說明在讀操作的時候沒有被寫操作打斷過。這也保證了上面的讀取變量都是一致的,也即low和high不會出現low是改前值而high是改後值得情況。low和high要麼都是改之前的,要麼都是改之後的!內核中修改的地方為:

cat /proc/sys/net/ipv4/ip_local_port_range
32768 61000

通過hash決定埠號起始搜索範圍在Linux上進行connect,內核給其分配的埠號並不是線性增長的,但是也符合一定的規律。先來看下代碼:

int __inet_hash_connect(...)
{
// 注意,這邊是static變量
static u32 hint;
// 這邊的port_offset是用對端ip:port hash的一個值
// 也就是說對端ip:port固定,port_offset固定
u32 offset = hint + port_offset;
for (i = 1; i <= remaining; i++) {
port = low + (i + offset) % remaining;
/* port是否佔用check */
....
goto ok;
}
..
ok:
hint += i;
.
}

這裡面有幾個小細節,為了安全原因,Linux本身用對端ip:port做了一次hash作為搜索的初始offset,所以不同遠端ip:port初始搜索範圍可以基本是不同的!但同樣的對端ip:port初始搜索範圍是相同的! 
在筆者機器上,一個完全乾淨的內核裡面,不停的對同一個遠端ip:port,其以2進行穩定增長,也即38742->38744->38746,如果有其它的幹擾,就會打破這個規律。埠號範圍限制由於我們指定了埠號返回ip_local_port_range是不是就意味著我們最多創建high-low+1個連接呢?當然不是,由於檢查埠號是否重複是將(網絡命名空間,對端ip,對端port,本端port,Socket綁定的dev)當做唯一鍵進行重複校驗,所以限制僅僅是在同一個網絡命名空間下,連接同一個對端ip:port的最大可用埠號數為high-low+1,當然可能還要減去ip_local_reserved_ports。如下圖所示:檢查埠號是否被佔用埠號的佔用搜索分為兩個階段,一個是處於TIME_WAIT狀態的埠號搜索,另一個是其它狀態埠號搜索。TIME_WAIT狀態埠號搜索眾所周知,TIME_WAIT階段是TCP主動close必經的一個階段。如果Client採用短連接的方式和Server端進行交互,就會產生大量的TIME_WAIT狀態的Socket。而這些Socket由佔用埠號,所以當TIME_WAIT過多,打爆上面的埠號範圍之後,新的connect就會返回錯誤碼:

C語言connect返回錯誤碼為
-EADDRNOTAVAIL,對應描述為Cannot assign requested address
對應Java的異常為
java.net.NoRouteToHostException: Cannot assign requested address (Address not available)

ip_local_reserved_ports。如下圖所示: 由於TIME_WAIT大概一分鐘左右才能消失,如果在一分鐘內Client端和Server建立大量的短連接請求就容易導致埠號耗盡。而這個一分鐘(TIME_WAIT的最大存活時間)是在內核(3.10)編譯階段就確定了的,無法通過內核參數調整。如下代碼所示:

#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT
* state, about 60 seconds */

Linux自然也考慮到了這種情況,所以提供了一個tcp_tw_reuse參數使得在搜索埠號時可以在某些情況下重用TIME_WAIT。代碼如下:

__inet_hash_connect
|->__inet_check_established
static int __inet_check_established(.)
{
.
/* Check TIME-WAIT sockets first. */
sk_nulls_for_each(sk2, node, &head->twchain) {
tw = inet_twsk(sk2);
// 如果在time_wait中找到一個match的port,就判斷是否可重用
if (INET_TW_MATCH(sk2, net, hash, acookie,
saddr, daddr, ports, dif)) {
if (twsk_unique(sk, sk2, twp))
goto unique;
else
goto not_unique;
}
}
.
}

如上面代碼中寫的那樣,如果在一堆TIME-WAIT狀態的Socket裡面能夠有當前要搜索的port,則判斷是否這個port可以重複利用。如果是TCP的話這個twsk_unique的實現函數是:

int tcp_twsk_unique(.)
{
.
if (tcptw->tw_ts_recent_stamp &&
(twp == NULL || (sysctl_tcp_tw_reuse &&
get_seconds() - tcptw->tw_ts_recent_stamp > 1))) {
tp->write_seq = tcptw->tw_snd_nxt + 65535 + 2
.
return 1;
}
return 0;
}

在開啟了tcp_timestamp以及tcp_tw_reuse的情況下,在Connect搜索port時只要比之前用這個port的TIME_WAIT狀態的Socket記錄的最近時間戳>1s,就可以重用此port,即將之前的1分鐘縮短到1s。同時為了防止潛在的序列號衝突,直接將write_seq加上在65537,這樣,在單Socket傳輸速率小於80Mbit/s的情況下,不會造成序列號衝突。
同時這個tw_ts_recent_stamp設置的時機如下圖所示: 所以如果Socket進入TIME_WAIT狀態後,如果一直有對應的包發過來,那麼會影響此TIME_WAIT對應的port是否可用的時間。我們可以通過下面命令開始tcp_tw_reuse:

echo '1' > /proc/sys/net/ipv4/tcp_tw_reuse

ESTABLISHED狀態埠號搜索

/* And established part... */
sk_nulls_for_each(sk2, node, &head->chain) {
if (INET_MATCH(sk2, net, hash, acookie,
saddr, daddr, ports, dif))
goto not_unique;
}

以(網絡命名空間,對端ip,對端port,本端port,Socket綁定的dev)當做唯一鍵進行匹配,如果匹配成功,表明此埠無法重用。埠號迭代搜索Linux內核在[low,high]範圍按照上述邏輯進行port的搜索,如果沒有搜索到port,即port耗盡,就會返回-EADDRNOTAVAIL,也即Cannot assign requested address。但還有一個細節,如果是重用TIME_WAIT狀態的Socket的埠的話,就會將對應的TIME_WAIT狀態的Socket給銷毀。

__inet_hash_connect(.)
{
.
if (tw) {
inet_twsk_deschedule(tw, death_row);
inet_twsk_put(tw);
}
.
}

尋找路由表在我們找到一個可用埠號port後,就會進入搜尋路由階段:

ip_route_newports
|->ip_route_output_flow
|->__ip_route_output_key
|->ip_route_output_slow
|->fib_lookup

這也是一個非常複雜的過程,限於篇幅,就不做詳細闡述了。如果搜索不到路由信息的話,會返回。

-ENETUNREACH,對應描述為Network is unreachable

Client端的三次握手在前面一大堆前置條件就緒後,才進入到真正的三次握手階段。

tcp_connect
|->tcp_connect_init 初始化tcp socket
|->tcp_transmit_skb 發送SYN包
|->inet_csk_reset_xmit_timer 設置SYN重傳定時器

tcp_connect_init初始化了一大堆TCP相關的設置,例如mss_cache/rcv_mss等一大堆。而且如果開啟了TCP窗口擴大選項的話,其窗口擴大因子也在此函數裡進行計算:

tcp_connect_init
|->tcp_select_initial_window
int tcp_select_initial_window(...)
{
.
(*rcv_wscale) = 0;
if (wscale_ok) {
/* Set window scaling on max possible window
* See RFC1323 for an explanation of the limit to 14
*/
space = max_t(u32, sysctl_tcp_rmem[2], sysctl_rmem_max);
space = min_t(u32, space, *window_clamp);
while (space > 65535 && (*rcv_wscale) < 14) {
space >>= 1;
(*rcv_wscale)++;
}
}
.
}

如上面代碼所示,窗口擴大因子取決於Socket最大可允許的讀緩衝大小和window_clamp(最大允許滑動窗口大小,動態調整)。搞完了一票初始信息設置後,才開始真正的三次握手。在tcp_transmit_skb中才真正發送SYN包,同時在緊接著的inet_csk_reset_xmit_timer裡設置了SYN超時定時器。如果對端一直不發送SYN_ACK,將會返回-ETIMEDOUT。

/proc/sys/net/ipv4/tcp_syn_retries

息息相關,Linux默認設置為5,建議設置成3,下面是不同設置的超時時間參照圖。在設置了SYN超時重傳定時器後,tcp_connnect就返回,並一路返回到最初始的inet_stream_connect。在這裡我們就等待對端返回SYN_ACK或者SYN定時器超時。

int __inet_stream_connect(struct socket *sock,...,)
{
// 如果設置了O_NONBLOCK則timeo為0
timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);
.
// 如果timeo=0即O_NONBLOCK會立刻返回
// 否則等待timeo時間
if (!timeo || !inet_wait_for_connect(sk, timeo, writebias))
goto out;
}

Linux本身提供一個SO_SNDTIMEO來控制對connect的超時,不過Java並沒有採用這個選項。而是採用別的方式進行connect的超時控制。僅僅就C語言的connect系統調用而言,不設置SO_SNDTIMEO,就會將對應用戶進程進行睡眠,直到SYN_ACK到達或者超時定時器超時才將次用戶進程喚醒。如果是NON_BLOCK的話,則是通過select/epoll等多路復用機制去捕獲超時或者連接成功事件。對端SYN_ACK到達在Server端SYN_ACK到達之後會按照下面的代碼路徑傳遞,並喚醒用戶態進程:

tcp_v4_rcv
|->tcp_v4_do_rcv
|->tcp_rcv_state_process
|->tcp_rcv_synsent_state_process
|->tcp_finish_connect
|->tcp_init_metrics 初始化度量統計
|->tcp_init_congestion_control 初始化擁塞控制
|->tcp_init_buffer_space 初始化buffer空間
|->inet_csk_reset_keepalive_timer 開啟包活定時器
|->sk_state_change(sock_def_wakeup) 喚醒用戶態進程
|->tcp_send_ack 發送三次握手的最後一次握手給Server端
|->tcp_set_state(sk, TCP_ESTABLISHED) 設置為ESTABLISHED狀態

覺得不錯,請點個在看呀

相關焦點

  • 從linux源碼看epoll
    從linux源碼看epoll前言在linux的高性能網絡編程中,繞不開的就是epoll。
  • 從源碼看tcp三次握手(上)
    Linux系統中間件的巧妙實現--以用戶空間的tcp協議棧為例請根據上述文章中的指引獲取leve-ip的全部源碼,並且嘗試在任意Linux發行版本上編譯運行。下面是具體的過程:客戶端的協議棧向伺服器端發送了 SYN 包,並告訴伺服器端當前發送序列號 j,客戶端進入 SYNC_SENT 狀態;伺服器端的協議棧收到這個包之後,和客戶端進行 ACK 應答,應答的值為 j+1,表示對 SYN 包 j 的確認,同時伺服器也發送一個 SYN 包,告訴客戶端當前我的發送序列號為 k,伺服器端進入 SYNC_RCVD
  • ZooKeeper源碼學習筆記--client端解析
    ZooKeeper 3.4.9在bin/zkCli.sh中,我們看到client端的真實入口其實是一個org.apache.zookeeper.ZooKeeperMain的Java類通過源碼走讀,看到在ZooKeeperMain中主要由兩部分構成
  • python筆記28(TCP,UDP,socket協議)
    4、代碼部分:①介紹socket;②使用socket完成tcp協議的web通信;③使用socket完成udp協議的web通信。socket歷史 同一臺機器上的兩個服務之間的通信 基於文件 基於網絡的多態機器之間的多個服務通信TCP協議:###########server端:import socketsk=socket.socket()sk.bind(('127.0.0.1',9000))sk.listen()print('*'*20)conn,addr=sk.accept()while True:
  • 20-python高級篇-如何實現client和server間的通信
    我們通過昨天的文章19-python高級篇-http,socket和tcp的關係認識了http,socket和tcp三者之間的區別和聯繫,今天我們將學習如何實現client和server間的通信。(一)socket編程流程    每一個應用程式佔用一個埠,埠號唯一。socket編程中需要首先綁定協議,地址和埠。
  • 詳解HTTP 與TCP中Keep-Alive機制的區別
    tomcat會根據http響應的狀態碼,判斷是否需要丟棄連接(筆者這裡看的是tomcat 9.0.19的源碼)。org.apache.coyote.http11.Http11Processor#statusDropsConnection
  • Linux TCP 狀態 TIME_WAIT 過多的處理
    讓我們一起看下下面的流程圖:首先,是三次握手:首先Client端發送連接請求報文,Server段接受連接後回復ACK報文,並為這次連接分配資源。Client端接收到ACK報文後也向Server段發生ACK報文,並分配資源,這樣TCP連接就建立了。
  • 啟明雲端分享|ESP32-S3如何實現tcp_client和tcp_server
    配置完成後保存退出(伺服器的IP及埠)4.編譯、燒錄編譯:idf.py build燒錄:idf.py -p PORT [-b BAUD] flash註:只有一個串口時可以直接 idf.py flash5.電腦端或手機端打開
  • C語言:使用socket寫一個tcp客戶端
    #include <stdlib.h>#include <stdio.h>#include <string.h>#include <netdb.h>#include <sys/types.h>#include <netinet/in.h>#include <sys/socket.h
  • C語言Socket編程,實現兩個程序間的通信
    實現兩個程序間的通信1.服務端server2.客戶端connect連接請求時,發來的套接字clientSocket按流程圖來看, server服務端主要就是實現下面幾個步驟:0.WSAStartup初始化
  • 基於Socket.IO的Client封裝
    有了WebSocket的經驗,這次寫Socket.IO的Client順利了很多,參考之前的文章:socket接口開發和測試初探、IntelliJ中基於文本的HTTP客戶端、基於WebSocket的client封裝。
  • 懶人入門網絡編程(四):實現一個socket長連接沒那麼簡單!
    這樣一來,這個 socket 就成了所謂的 listening socket,它開始監聽客戶的連接。接下來,我們的客戶端創建一個 Socket,同樣的,內核也創建一個 socket 實例。內核創建的這個 socket 跟 ServerSocket 一開始創建的那個沒有什麼區別。不同的是,接下來 Socket 會對它執行 connect,發起對服務端的連接。
  • C語言Socket TCP極簡示例
    >#include <arpa/inet.h>#include <unistd.h>#define PORT 8080int main() {   int socket_desc, client_sock;  struct sockaddr_in server, client;  int addrlen = sizeof(client);  int read_size
  • 用Socket編程之TCP/IP通信,你會了嗎?
    1、socket即為套接字,在TCP/IP協議中,「IP位址+TCP或UDP埠號」唯一的標識網絡通訊中的一個進程,「IP位址+TCP或UDP埠號」就為socket。 2、在TCP協議中,建立連接的兩個進程(客戶端和伺服器)各自有一個socket來標識,則這兩個socket組成的socket pair就唯一標識一個連接。
  • Linux C Socket Api詳解
    注意:linux的man命令可以查看api的詳細說明,而且還有例子,也挺不錯的。4. 建立連接1> connect如果處理的是面向連接的網絡服務(SOCK_STREAM或SOCK_SEQPACKET),在開始交換數據前,需要在請求服務的進程套接字(客戶端)和提供服務的進程套接字(伺服器)之間建立一個連接。使用connect.
  • Linux 內核 TCP MSS 機制詳細分析
    所以本文將通過Linux內核源碼對TCP的MSS機制進行詳細分析。作業系統版本:Ubuntu 18.04 內核版本:4.15.0-20-generic地址:192.168.11.112內核源碼:$ sudo apt install linux-source-4.15.0$ ls /usr/src/linux-source-4.15.0.tar.bz2
  • Linux C網絡編程[Socket]
    5.Linux C Socket簡單實例與詳細注釋 程序為簡單的「回射」,客戶端將控制臺輸入的信息發送給伺服器端,伺服器原樣返回信息。 伺服器端:1#include <sys/types.h> 2 #include <sys/socket.h> 3 #include <stdio.h> 4 #include <netinet/in.h> 5 #include <arpa/inet.h>
  • Linux 編程之 Socket
    ;    socklen_t length = sizeof(client_addr);     // 監聽並返回客戶端fd    int conn = accept(server_sockfd, (struct sockaddr*)&client_addr, &length);    if (conn <
  • TCP和UDP區別
    看一下TCP和UDP 協議頭的源碼文件頭在include/uapi/linux/tcp.h(unsigned short ==__be16 == __u16 == __sum16)(unsigned int == __be32)TCP頭UDP頭字節數差別點__be16 source