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

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

前言

筆者一直以爲若是能知道從應用到框架再到操做系統的每一處代碼,是一件Exciting的事情。
今天筆者就來從Linux源碼的角度看下Client端的Socket在進行Connect的時候到底作了哪些事情。因爲篇幅緣由,關於Server端的Accept源碼講解留給下一篇博客。
(基於Linux 3.10內核)java

一個最簡單的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也就是操做函數。

若是你想知道上圖中的結構是怎麼來的,能夠看下筆者之前的博客:node

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

值得注意的是,因爲socket系統調用操做作了以下兩個代碼的判斷linux

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)

第一個判斷,ulmit超限:安全

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"
cookie

第二個判斷max_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系統調用

咱們再來看一下connect系統調用:socket

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

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

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狀態端口號搜索

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狀態

公衆號

關注筆者公衆號,獲取更多幹貨文章

總結

Client(TCP)端進行Connect的過程真是跋山涉水,從一開始文件描述符的限制到端口號的搜索再到路由表的搜索再到最後的三次握手,任何一個環節有問題就會致使建立鏈接失敗,筆者詳細的描述了這些機制的源碼實現。但願本篇文章能夠對讀者在之後遇到Connect失敗問題時候有所幫助。

相關文章
相關標籤/搜索