能将三次握手理解到这个深度,面试官拍案叫绝!
在后端相關崗位的入職面試中,三次握手的出場頻率非常的高,甚至說它是必考題也不為過。一般的答案都是說客戶端如何發起 SYN 握手進入 SYN_SENT 狀態,服務器響應 SYN 并回復 SYNACK,然后進入 SYN_RECV,...... , 吧啦吧啦諸如此類。
但我今天想給出一份不一樣的答案。其實三次握手在內核的實現中,并不只是簡單的狀態的流轉,還包括半連接隊列、syncookie、全連接隊列、重傳計時器等關鍵操作。如果能深刻理解這些,你對線上把握和理解將更進一步。如果有面試官問起你三次握手,相信這份答案一定能幫你在面試官面前贏得非常多的加分。
在基于 TCP 的服務開發中,三次握手的主要流程圖如下。
服務器中的核心代碼是創建 socket,綁定端口,listen 監聽,最后 accept 接收客戶端的請求。
客戶端的相關代碼是創建 socket,然后調用 connect 連接 server。
//客戶端核心代碼 int?main(){fd?=?socket(AF_INET,SOCK_STREAM,?0);connect(fd,?...);... }圍繞這個三次握手圖,以及客戶端,服務端的核心代碼,我們來深度探索一下三次握手過程中的內部操作。我們從和三次握手過程關系比較大的 listen 講起!
友情提示:本文中內核源碼會比較多。如果你能理解的了更好,如果覺得理解起來有困難,那直接重點看本文中的描述性的文字,尤其是加粗部分的即可。另外文章最后有一張總結圖歸納和整理了全文內容。
一、服務器的 listen
我們都知道,服務器在開始提供服務之前都需要先 listen 一下。但 listen 內部究竟干了啥,我們平時很少去琢磨。
今天就讓我們詳細來看看,直接上一段 listen 時執行到的內核代碼。
//file:?net/core/request_sock.c int?reqsk_queue_alloc(struct?request_sock_queue?*queue,unsigned?int?nr_table_entries) {size_t?lopt_size?=?sizeof(struct?listen_sock);struct?listen_sock?*lopt;//計算半連接隊列的長度nr_table_entries?=?min_t(u32,?nr_table_entries,?sysctl_max_syn_backlog);nr_table_entries?=?......//為半連接隊列申請內存lopt_size?+=?nr_table_entries?*?sizeof(struct?request_sock?*);if?(lopt_size?>?PAGE_SIZE)lopt?=?vzalloc(lopt_size);elselopt?=?kzalloc(lopt_size,?GFP_KERNEL);//全連接隊列頭初始化queue->rskq_accept_head?=?NULL;//半連接隊列設置lopt->nr_table_entries?=?nr_table_entries;queue->listen_opt?=?lopt;...... }在這段代碼里,內核計算了半連接隊列的長度。然后據此算出半連接隊列所需要的實際內存大小,開始申請用于管理半連接隊列對象的內存(半連接隊列需要快速查找,所以內核是用哈希表來管理半連接隊列的,具體在 listen_sock 下的 syn_table 下)。最后將半連接隊列掛到了接收隊列 queue 上。
另外 queue->rskq_accept_head 代表的是全連接隊列,它是一個鏈表的形式。在 listen 這里因為還沒有連接,所以將全連接隊列頭 queue->rskq_accept_head 設置成 NULL。
當全連接隊列和半連接隊列中有元素的時候,他們在內核中的結構圖大致如下。
在服務器 listen 的時候,主要是進行了全/半連接隊列的長度限制計算,以及相關的內存申請和初始化。全/連接隊列初始化了以后才可以相應來自客戶端的握手請求。
二、客戶端 connect
客戶端通過調用 connect 來發起連接。在 connect 系統調用中會進入到內核源碼的 tcp_v4_connect。
//file:?net/ipv4/tcp_ipv4.c int?tcp_v4_connect(struct?sock?*sk,?struct?sockaddr?*uaddr,?int?addr_len) {//設置?socket?狀態為?TCP_SYN_SENTtcp_set_state(sk,?TCP_SYN_SENT);//動態選擇一個端口err?=?inet_hash_connect(&tcp_death_row,?sk);//函數用來根據 sk 中的信息,構建一個完成的 syn 報文,并將它發送出去。err?=?tcp_connect(sk); }在這里將完成把 socket 狀態設置為 TCP_SYN_SENT。再通過 inet_hash_connect 來動態地選擇一個可用的端口后,進入到 tcp_connect 中。
//file:net/ipv4/tcp_output.c int?tcp_connect(struct?sock?*sk) {tcp_connect_init(sk);//申請?skb?并構造為一個?SYN?包......//添加到發送隊列?sk_write_queue?上tcp_connect_queue_skb(sk,?buff);//實際發出?synerr?=?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 申請和構造 SYN 包,然后將其發出。同時還啟動了一個重傳定時器,該定時器的作用是等到一定時間后收不到服務器的反饋的時候來開啟重傳。在 3.10 版本中首次超時時間是 1 s,一些老版本中是 3 s。
總結一下,客戶端在 connect 的時候,把本地 socket 狀態設置成了 TCP_SYN_SENT,選了一個可用的端口,接著發出 SYN 握手請求并啟動重傳定時器。
三、服務器響應 SYN
在服務器端,所有的 TCP 包(包括客戶端發來的 SYN 握手請求)都經過網卡、軟中斷,進入到 tcp_v4_rcv。在該函數中根據網絡包(skb)TCP 頭信息中的目的 IP 信息查到當前在 listen 的 socket。然后繼續進入 tcp_v4_do_rcv 處理握手過程。
//file:?net/ipv4/tcp_ipv4.c int?tcp_v4_do_rcv(struct?sock?*sk,?struct?sk_buff?*skb) {...//服務器收到第一步握手?SYN?或者第三步?ACK?都會走到這里if?(sk->sk_state?==?TCP_LISTEN)?{struct?sock?*nsk?=?tcp_v4_hnd_req(sk,?skb);}if?(tcp_rcv_state_process(sk,?skb,?tcp_hdr(skb),?skb->len))?{rsk?=?sk;goto?reset;} }在 tcp_v4_do_rcv 中判斷當前 socket 是 listen 狀態后,首先會到 tcp_v4_hnd_req 去查看半連接隊列。服務器第一次響應 SYN 的時候,半連接隊列里必然是空空如也,所以相當于什么也沒干就返回了。
//file:net/ipv4/tcp_ipv4.c static?struct?sock?*tcp_v4_hnd_req(struct?sock?*sk,?struct?sk_buff?*skb) {//?查找?listen?socket?的半連接隊列struct?request_sock?*req?=?inet_csk_search_req(sk,?&prev,?th->source,iph->saddr,?iph->daddr);...return?sk; }在 tcp_rcv_state_process 里根據不同的 socket 狀態進行不同的處理。
//file:net/ipv4/tcp_input.c int?tcp_rcv_state_process(struct?sock?*sk,?struct?sk_buff?*skb,const?struct?tcphdr?*th,?unsigned?int?len) {switch?(sk->sk_state)?{//第一次握手case?TCP_LISTEN:if?(th->syn)?{?//判斷是?SYN?握手包...if?(icsk->icsk_af_ops->conn_request(sk,?skb)?<?0)return?1;...... }??其中 conn_request 是一個函數指針,指向 tcp_v4_conn_request。服務器響應 SYN 的主要處理邏輯都在這個 tcp_v4_conn_request 里。
//file:?net/ipv4/tcp_ipv4.c int?tcp_v4_conn_request(struct?sock?*sk,?struct?sk_buff?*skb) {//看看半連接隊列是否滿了if?(inet_csk_reqsk_queue_is_full(sk)?&&?!isn)?{want_cookie?=?tcp_syn_flood_action(sk,?skb,?"TCP");if?(!want_cookie)goto?drop;}//在全連接隊列滿的情況下,如果有?young_ack,那么直接丟if?(sk_acceptq_is_full(sk)?&&?inet_csk_reqsk_queue_young(sk)?>?1)?{NET_INC_STATS_BH(sock_net(sk),?LINUX_MIB_LISTENOVERFLOWS);goto?drop;}...//分配?request_sock?內核對象req?=?inet_reqsk_alloc(&tcp_request_sock_ops);//構造?syn+ack?包skb_synack?=?tcp_make_synack(sk,?dst,?req,fastopen_cookie_present(&valid_foc)???&valid_foc?:?NULL);if?(likely(!do_fastopen))?{//發送?syn?+?ack?響應err?=?ip_build_and_send_pkt(skb_synack,?sk,?ireq->loc_addr,ireq->rmt_addr,?ireq->opt);//添加到半連接隊列,并開啟計時器inet_csk_reqsk_queue_hash_add(sk,?req,?TCP_TIMEOUT_INIT);}else?... }在這里首先判斷半連接隊列是否滿了,如果滿了的話進入 tcp_syn_flood_action 去判斷是否開啟了 tcp_syncookies 內核參數。如果隊列滿,且未開啟 tcp_syncookies,那么該握手包將直接被丟棄!!
接著還要判斷全連接隊列是否滿。因為全連接隊列滿也會導致握手異常的,那干脆就在第一次握手的時候也判斷了。如果全連接隊列滿了,且有 young_ack 的話,那么同樣也是直接丟棄。
young_ack 是半連接隊列里保持著的一個計數器。記錄的是剛有SYN到達,沒有被SYN_ACK重傳定時器重傳過SYN_ACK,同時也沒有完成過三次握手的sock數量
接下來是構造 synack 包,然后通過 ip_build_and_send_pkt 把它發送出去。
最后把當前握手信息添加到半連接隊列,并開啟計時器。計時器的作用是如果某個時間之內還收不到客戶端的第三次握手的話,服務器會重傳 synack 包。
總結一下,服務器響應 ack 是主要工作是判斷下接收隊列是否滿了,滿的話可能會丟棄該請求,否則發出 synack。申請 request_sock 添加到半連接隊列中,同時啟動定時器。
四、客戶端響應 SYNACK
客戶端收到服務器端發來的 synack 包的時候,也會進入到 tcp_rcv_state_process 函數中來。不過由于自身 socket 的狀態是 TCP_SYN_SENT,所以會進入到另一個不同的分支中去。
//file:net/ipv4/tcp_input.c //除了?ESTABLISHED?和?TIME_WAIT,其他狀態下的?TCP?處理都走這里 int?tcp_rcv_state_process(struct?sock?*sk,?struct?sk_buff?*skb,const?struct?tcphdr?*th,?unsigned?int?len) {switch?(sk->sk_state)?{//服務器收到第一個ACK包case?TCP_LISTEN:...//客戶端第二次握手處理?case?TCP_SYN_SENT://處理?synack?包queued?=?tcp_rcv_synsent_state_process(sk,?skb,?th,?len);...return?0; }tcp_rcv_synsent_state_process 是客戶端響應 synack 的主要邏輯。
//file:net/ipv4/tcp_input.c static?int?tcp_rcv_synsent_state_process(struct?sock?*sk,?struct?sk_buff?*skb,const?struct?tcphdr?*th,?unsigned?int?len) {...tcp_ack(sk,?skb,?FLAG_SLOWPATH);//連接建立完成?tcp_finish_connect(sk,?skb);if?(sk->sk_write_pending?||icsk->icsk_accept_queue.rskq_defer_accept?||icsk->icsk_ack.pingpong)//延遲確認...else?{tcp_send_ack(sk);} }?tcp_ack()->tcp_clean_rtx_queue()
//file:?net/ipv4/tcp_input.c static?int?tcp_clean_rtx_queue(struct?sock?*sk,?int?prior_fackets,u32?prior_snd_una) {//刪除發送隊列...//刪除定時器tcp_rearm_rto(sk); } //file:?net/ipv4/tcp_input.c void?tcp_finish_connect(struct?sock?*sk,?struct?sk_buff?*skb) {//修改?socket?狀態tcp_set_state(sk,?TCP_ESTABLISHED);//初始化擁塞控制tcp_init_congestion_control(sk);...//保活計時器打開if?(sock_flag(sk,?SOCK_KEEPOPEN))inet_csk_reset_keepalive_timer(sk,?keepalive_time_when(tp)); }客戶端修改自己的 socket 狀態為 ESTABLISHED,接著打開 TCP 的保活計時器。
//file:net/ipv4/tcp_output.c void?tcp_send_ack(struct?sock?*sk) {//申請和構造?ack?包buff?=?alloc_skb(MAX_TCP_HEADER,?sk_gfp_atomic(sk,?GFP_ATOMIC));...//發送出去tcp_transmit_skb(sk,?buff,?0,?sk_gfp_atomic(sk,?GFP_ATOMIC)); }在 tcp_send_ack 中構造 ack 包,并把它發送了出去。
客戶端響應來自服務器端的 synack 時清除了 connect 時設置的重傳定時器,把當前 socket 狀態設置為 ESTABLISHED,開啟保活計時器后發出第三次握手的 ack 確認。
五、服務器響應 ACK
服務器響應第三次握手的 ack 時同樣會進入到 tcp_v4_do_rcv
//file:?net/ipv4/tcp_ipv4.c int?tcp_v4_do_rcv(struct?sock?*sk,?struct?sk_buff?*skb) {...if?(sk->sk_state?==?TCP_LISTEN)?{struct?sock?*nsk?=?tcp_v4_hnd_req(sk,?skb);}if?(tcp_rcv_state_process(sk,?skb,?tcp_hdr(skb),?skb->len))?{rsk?=?sk;goto?reset;} }不過由于這已經是第三次握手了,半連接隊列里會存在上次第一次握手時留下的半連接信息。所以 tcp_v4_hnd_req 的執行邏輯會不太一樣。
//file:net/ipv4/tcp_ipv4.c static?struct?sock?*tcp_v4_hnd_req(struct?sock?*sk,?struct?sk_buff?*skb) {...struct?request_sock?*req?=?inet_csk_search_req(sk,?&prev,?th->source,iph->saddr,?iph->daddr);if?(req)return?tcp_check_req(sk,?skb,?req,?prev,?false);... }inet_csk_search_req 負責在半連接隊列里進行查找,找到以后返回一個半連接 request_sock 對象。然后進入到 tcp_check_req 中。
//file:net/ipv4/tcp_minisocks.c struct?sock?*tcp_check_req(struct?sock?*sk,?struct?sk_buff?*skb,struct?request_sock?*req,struct?request_sock?**prev,bool?fastopen) {...//創建子?socketchild?=?inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk,?skb,?req,?NULL);...//清理半連接隊列inet_csk_reqsk_queue_unlink(sk,?req,?prev);inet_csk_reqsk_queue_removed(sk,?req);//添加全連接隊列inet_csk_reqsk_queue_add(sk,?req,?child);return?child; }5.1 創建子 socket
icsk_af_ops->syn_recv_sock 對應的是 tcp_v4_syn_recv_sock 函數。
//file:net/ipv4/tcp_ipv4.c const?struct?inet_connection_sock_af_ops?ipv4_specific?=?{.......conn_request??????=?tcp_v4_conn_request,.syn_recv_sock?????=?tcp_v4_syn_recv_sock,//三次握手接近就算是完畢了,這里創建?sock?內核對象 struct?sock?*tcp_v4_syn_recv_sock(struct?sock?*sk,?struct?sk_buff?*skb,struct?request_sock?*req,struct?dst_entry?*dst) {????//判斷接收隊列是不是滿了if?(sk_acceptq_is_full(sk))goto?exit_overflow;//創建?sock?&&?初始化newsk?=?tcp_create_openreq_child(sk,?req,?skb);**注意,在第三次握手的這里又繼續判斷一次全連接隊列是否滿了,如果滿了修改一下計數器就丟棄了。**如果隊列不滿,那么就申請創建新的 sock 對象。
5.2 刪除半連接隊列
把連接請求塊從半連接隊列中刪除。
//file:?include/net/inet_connection_sock.h? static?inline?void?inet_csk_reqsk_queue_unlink(struct?sock?*sk,?struct?request_sock?*req,struct?request_sock?**prev) {reqsk_queue_unlink(&inet_csk(sk)->icsk_accept_queue,?req,?prev); }reqsk_queue_unlink 中把連接請求塊從半連接隊列中刪除。
5.3 添加全連接隊列
接著添加到全連接隊列里邊來。
//file:net/ipv4/syncookies.c static?inline?void?inet_csk_reqsk_queue_add(struct?sock?*sk,struct?request_sock?*req,struct?sock?*child) {reqsk_queue_add(&inet_csk(sk)->icsk_accept_queue,?req,?sk,?child); }在 reqsk_queue_add 中將握手成功的 request_sock 對象插入到全連接隊列鏈表的尾部。
//file:?include/net/request_sock.h static?inline?void?reqsk_queue_add(...) {req->sk?=?child;sk_acceptq_added(parent);if?(queue->rskq_accept_head?==?NULL)queue->rskq_accept_head?=?req;elsequeue->rskq_accept_tail->dl_next?=?req;queue->rskq_accept_tail?=?req;req->dl_next?=?NULL; }5.4 設置連接為 ESTABLISHED
//file:net/ipv4/tcp_input.c int?tcp_rcv_state_process(struct?sock?*sk,?struct?sk_buff?*skb,const?struct?tcphdr?*th,?unsigned?int?len) {...switch?(sk->sk_state)?{//服務端第三次握手處理case?TCP_SYN_RECV://改變狀態為連接tcp_set_state(sk,?TCP_ESTABLISHED);...} }將連接設置為 TCP_ESTABLISHED 狀態。
服務器響應第三次握手 ack 所做的工作是把當前半連接對象刪除,創建了新的 sock 后加入到全連接隊列中,最后將新連接狀態設置為 ESTABLISHED。
六、服務器 accept
最后 accept 一步咱們長話短說。
//file:?net/ipv4/inet_connection_sock.c struct?sock?*inet_csk_accept(struct?sock?*sk,?int?flags,?int?*err) {//從全連接隊列中獲取struct?request_sock_queue?*queue?=?&icsk->icsk_accept_queue;req?=?reqsk_queue_remove(queue);newsk?=?req->sk;return?newsk; }reqsk_queue_remove 這個操作很簡單,就是從全連接隊列的鏈表里獲取出第一個元素返回就行了。
//file:include/net/request_sock.h static?inline?struct?request_sock?*reqsk_queue_remove(struct?request_sock_queue?*queue) {struct?request_sock?*req?=?queue->rskq_accept_head;queue->rskq_accept_head?=?req->dl_next;if?(queue->rskq_accept_head?==?NULL)queue->rskq_accept_tail?=?NULL;return?req; }所以,accept 的重點工作就是從已經建立好的全連接隊列中取出一個返回給用戶進程。
本文總結
在后端相關崗位的入職面試中,三次握手的出場頻率非常的高。其實在三次握手的過程中,不僅僅是一個握手包的發送 和 TCP 狀態的流轉。還包含了端口選擇,連接隊列創建與處理等很多關鍵技術點。通過今天一篇文章,我們深度去了解了三次握手過程中內核中的這些內部操作。
全文洋洋灑灑上萬字字,其實可以用一幅圖總結起來。
1. 服務器 listen 時,計算了全/半連接隊列的長度,還申請了相關內存并初始化。
2. 客戶端 connect 時,把本地 socket 狀態設置成了 TCP_SYN_SENT,選則一個可用的端口,發出 SYN 握手請求并啟動重傳定時器。
3. 服務器響應 ack 時,會判斷下接收隊列是否滿了,滿的話可能會丟棄該請求。否則發出 synack,申請 request_sock 添加到半連接隊列中,同時啟動定時器。
4. 客戶端響應 synack 時,清除了 connect 時設置的重傳定時器,把當前 socket 狀態設置為 ESTABLISHED,開啟保活計時器后發出第三次握手的 ack 確認。
5. 服務器響應 ack 時,把對應半連接對象刪除,創建了新的 sock 后加入到全連接隊列中,最后將新連接狀態設置為 ESTABLISHED。
6. accept 從已經建立好的全連接隊列中取出一個返回給用戶進程。
另外要注意的是,如果握手過程中發生丟包(網絡問題,或者是連接隊列溢出),內核會等待定時器到期后重試,重試時間間隔在 3.10 版本里分別是 1s 2s 4s ...。在一些老版本里,比如 2.6 里,第一次重試時間是 3 秒。最大重試次數分別由 tcp_syn_retries 和 tcp_synack_retries 控制。
如果你的線上接口正常都是幾十毫秒內返回,但偶爾出現了 1 s、或者 3 s 等這種偶發的響應耗時變長的問題,那么你就要去定位一下看看是不是出現了握手包的超時重傳了。
以上就是三次握手中一些更詳細的內部操作。如果你能在面試官面前講出來內核的這些底層邏輯,我相信面試官一定會對你刮目相看的!
有道無術,術可成;有術無道,止于術
歡迎大家關注Java之道公眾號
好文章,我在看??
總結
以上是生活随笔為你收集整理的能将三次握手理解到这个深度,面试官拍案叫绝!的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: [翻译] ASP.NET Core 2.
- 下一篇: 中国联通官网被发现含木马脚本,可向用户推