從linux原始碼看socket的close
筆者一直覺得如果能知道從應用到框架再到作業系統的每一處程式碼,是一件Exciting的事情。上篇部落格講了socket的阻塞和非阻塞,這篇就開始談一談socket的close(以tcp為例且基於linux-2.6.24核心版本)
TCP關閉狀態轉移圖:
眾所周知,TCP的close過程是四次揮手,狀態機的變遷也逃不出TCP狀態轉移圖,如下圖所示:
tcp的關閉主要分主動關閉、被動關閉以及同時關閉(特殊情況,不做描述)
主動關閉
close(fd)的過程
以C語言為例,在我們關閉socket的時候,會使用close(fd)函式:
1 2 3 4 5 6 |
int socket_fd; socket_fd = socket(AF_INET, SOCK_STREAM, 0); ... // 此處通過檔案描述符關閉對應的socket close(socket_fd) |
而close(int fd)又是通過系統呼叫sys_close來執行的:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
asmlinkage long sys_close(unsigned int fd) { // 清除(close_on_exec即退出程式時)的點陣圖標記 FD_CLR(fd, fdt->close_on_exec); // 釋放檔案描述符 // 將fdt->open_fds即開啟的fd點陣圖中對應的位清除 // 再將fd掛入下一個可使用的fd以便複用 __put_unused_fd(files, fd); // 呼叫file_pointer的close方法真正清除 retval = filp_close(filp, files); } |
我們看到最終是呼叫的filp_close方法:
1 2 3 4 5 6 7 8 9 10 |
int filp_close(struct file *filp, fl_owner_t id) { // 如果存在flush方法則flush if (filp->f_op && filp->f_op->flush) filp->f_op->flush(filp, id); // 呼叫fput fput(filp); ...... } |
緊接著我們進入fput:
1 2 3 4 5 6 7 8 |
void fastcall fput(struct file *file) { // 對應file->count--,同時檢查是否還有關於此file的引用 // 如果沒有,則呼叫_fput進行釋放 if (atomic_dec_and_test(&file->f_count)) __fput(file); } |
同一個file(socket)有多個引用的情況很常見,例如下面的例子:
所以在多程式的socket伺服器編寫過程中,父程式也需要close(fd)一次,以免socket無法最終關閉
然後就是_fput函式了:
1 2 3 4 5 6 7 8 9 |
void fastcall __fput(struct file *file) { // 從eventpoll中釋放file eventpoll_release(file); // 如果是release方法,則呼叫release if (file->f_op && file->f_op->release) file->f_op->release(inode, file); } |
由於我們討論的是socket的close,所以,我們現在探查下file->f_op->release在socket情況下的實現:
f_op->release的賦值
我們跟蹤建立socket的程式碼,即
1 2 3 4 5 6 7 |
socket(AF_INET, SOCK_STREAM, 0); |-sock_create // 建立sock |-sock_map_fd // 將sock和fd關聯 |-sock_attach_fd |-init_file(file,...,&socket_file_ops); |-file->f_op = fop; //fop賦值為socket_file_ops |
socket_file_ops的實現為:
1 2 3 4 5 6 7 8 |
static const struct file_operations socket_file_ops = { .owner = THIS_MODULE, ...... // 我們在這裡只考慮sock_close .release = sock_close, ...... }; |
繼續跟蹤:
1 2 3 4 |
sock_close |-sock_release |-sock->ops->release(sock); |
在上一篇部落格中,我們知道sock->ops為下圖所示:
即(在這裡我們僅考慮tcp,即sk_prot=tcp_prot):
1 2 3 4 5 6 |
inet_stream_ops->release |-inet_release |-sk->sk_prot->close(sk, timeout); |-tcp_prot->close(sk, timeout); |->tcp_prot.tcp_close |
關於fd與socket的關係如下圖所示:
上圖中紅色線標註的是close(fd)的呼叫鏈
tcp_close
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
void tcp_close(struct sock *sk, long timeout) { if (sk->sk_state == TCP_LISTEN) { // 如果是listen狀態,則直接設為close狀態 tcp_set_state(sk, TCP_CLOSE); } // 清空掉recv.buffer ...... // SOCK_LINGER選項的處理 ...... else if (tcp_close_state(sk)){ // tcp_close_state會將sk從established狀態變為fin_wait1 // 傳送fin包 tcp_send_fin(sk); } ...... } |
四次揮手
現在就是我們的四次揮手環節了,其中上半段的兩次揮手下圖所示:
首先,在tcp_close_state(sk)中已經將狀態設定為fin_wait1,並呼叫tcp_send_fin
1 2 3 4 5 6 7 8 9 10 |
void tcp_send_fin(struct sock *sk) { ...... // 這邊設定flags為ack和fin TCP_SKB_CB(skb)->flags = (TCPCB_FLAG_ACK | TCPCB_FLAG_FIN); ...... // 傳送fin包,同時關閉nagle __tcp_push_pending_frames(sk, mss_now, TCP_NAGLE_OFF); } |
如上圖Step1所示。 接著,主動關閉的這一端等待對端的ACK,如果ACK回來了,就設定TCP狀態為FIN_WAIT2,如上圖Step2所示,具體程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
tcp_v4_do_rcv |-tcp_rcv_state_process int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb, struct tcphdr *th, unsigned len) { ...... /* step 5: check the ACK field */ if (th->ack) { ... case TCP_FIN_WAIT1: // 這處判斷是確認此ack是傳送Fin包對應的那個ack if (tp->snd_una == tp->write_seq) { // 設定為FIN_WAIT2狀態 tcp_set_state(sk, TCP_FIN_WAIT2); ...... // 設定TCP_FIN_WAIT2定時器,將在tmo時間到期後將狀態變遷為TIME_WAIT // 不過是這時候改的已經是inet_timewait_sock了 tcp_time_wait(sk, TCP_FIN_WAIT2, tmo); ...... } } /* step 7: process the segment text */ switch(sk->sk_state) { case TCP_FIN_WAIT1: case TCP_FIN_WAIT2: ...... case TCP_ESTABLISHED: tcp_data_queue(sk, skb); queued = 1; break; } ..... } |
值的注意的是,從TCP_FIN_WAIT1變遷到TCP_FIN_WAIT2之後,還呼叫tcp_time_wait設定一個TCP_FIN_WAIT2定時器,在tmo+(2MSL或者基於RTO計算超時)超時後會直接變遷到closed狀態(不過此時已經是inet_timewait_sock了)。這個超時時間可以配置,如果是ipv4的話,則可以按照下列配置:
1 2 3 |
net.ipv4.tcp_fin_timeout /sbin/sysctl -w net.ipv4.tcp_fin_timeout=30 |
如下圖所示:
有這樣一步的原因是防止對端由於種種原因始終沒有傳送fin,防止一直處於FIN_WAIT2狀態。
接著在FIN_WAIT2狀態等待對端的FIN,完成後面兩次揮手:
由Step1和Step2將狀態置為了FIN_WAIT_2,然後接收到對端傳送的FIN之後,將會將狀態設定為time_wait,如下程式碼所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
tcp_v4_do_rcv |-tcp_rcv_state_process |-tcp_data_queue |-tcp_fin static void tcp_fin(struct sk_buff *skb, struct sock *sk, struct tcphdr *th) { switch (sk->sk_state) { ...... case TCP_FIN_WAIT1: // 這邊是處理同時關閉的情況 tcp_send_ack(sk); tcp_set_state(sk, TCP_CLOSING); break; case TCP_FIN_WAIT2: /* Received a FIN -- send ACK and enter TIME_WAIT. */ // 收到FIN之後,傳送ACK同時將狀態進入TIME_WAIT tcp_send_ack(sk); tcp_time_wait(sk, TCP_TIME_WAIT, 0); } } |
time_wait狀態時,原socket會被destroy,然後新建立一個inet_timewait_sock,這樣就能及時的將原socket使用的資源回收。而inet_timewait_sock被掛入一個bucket中,由 inet_twdr_twcal_tick定時從bucket中將超過(2MSL或者基於RTO計算的時間)的time_wait的例項刪除。 我們來看下tcp_time_wait函式
1 2 3 4 5 6 7 8 9 10 |
void tcp_time_wait(struct sock *sk, int state, int timeo) { // 建立inet_timewait_sock tw = inet_twsk_alloc(sk, state); // 放到bucket的具體位置等待定時器刪除 inet_twsk_schedule(tw, &tcp_death_row, time,TCP_TIMEWAIT_LEN); // 設定sk狀態為TCP_CLOSE,然後回收sk資源 tcp_done(sk); } |
具體的定時器操作函式為inet_twdr_twcal_tick,這邊就不做描述了
被動關閉
close_wait
在tcp的socket時候,如果是established狀態,接收到了對端的FIN,則是被動關閉狀態,會進入close_wait狀態,如下圖Step1所示:
具體程式碼如下所示:
1 2 3 4 5 6 7 8 9 10 |
tcp_rcv_state_process |-tcp_data_queue static void tcp_data_queue(struct sock *sk, struct sk_buff *skb) { ... if (th->fin) tcp_fin(skb, sk, th); ... } |
我們再看下tcp_fin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
static void tcp_fin(struct sk_buff *skb, struct sock *sk, struct tcphdr *th) { ...... // 這一句表明當前socket有ack需要傳送 inet_csk_schedule_ack(sk); ...... switch (sk->sk_state) { case TCP_SYN_RECV: case TCP_ESTABLISHED: /* Move to CLOSE_WAIT */ // 狀態設定程close_wait狀態 tcp_set_state(sk, TCP_CLOSE_WAIT); // 這一句表明,當前fin可以延遲傳送 // 即和後面的資料一起傳送或者定時器到時後傳送 inet_csk(sk)->icsk_ack.pingpong = 1; break; } ...... } |
這邊有意思的點是,收到對端的fin之後並不會立即傳送ack告知對端收到了,而是等有資料攜帶一塊傳送,或者等攜帶重傳定時器到期後傳送ack。
如果對端關閉了,應用端在read的時候得到的返回值是0,此時就應該手動呼叫close去關閉連線
1 2 3 4 |
if(recv(sockfd, buf, MAXLINE,0) == 0){ close(sockfd) } |
我們看下recv是怎麼處理fin包,從而返回0的,上一篇部落格可知,recv最後呼叫tcp_rcvmsg,由於比較複雜,我們分兩段來看:
tcp_recvmsg第一段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
...... // 從接收佇列裡面獲取一個sk_buffer skb = skb_peek(&sk->sk_receive_queue); do { // 如果已經沒有資料,直接跳出讀取迴圈,返回0 if (!skb) break; ...... // *seq表示已經讀到多少seq // TCP_SKB_CB(skb)->seq表示當前sk_buffer的起始seq // offset即是在當前sk_buffer中已經讀取的長度 offset = *seq - TCP_SKB_CB(skb)->seq; // syn處理 if (tcp_hdr(skb)->syn) offset--; // 此處判斷表示,當前skb還有資料可讀,跳轉found_ok_skb if (offset < skb->len) goto found_ok_skb; // 處理fin包的情況 // offset == skb->len,跳轉到found_fin_ok然後跳出外面的大迴圈 // 並返回0 if (tcp_hdr(skb)->fin) goto found_fin_ok; BUG_TRAP(flags & MSG_PEEK); skb = skb->next; } while (skb != (struct sk_buff *)&sk->sk_receive_queue); ...... |
上面程式碼的處理過程如下圖所示:
我們看下tcp_recmsg的第二段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
found_ok_skb: // tcp已讀seq更新 *seq += used; // 這次讀取的數量更新 copied += used; // 如果還沒有讀到當前sk_buffer的盡頭,則不檢測fin標識 if (used + offset < skb->len) continue; // 如果發現當前skb有fin標識,去found_fin_ok if (tcp_hdr(skb)->fin) goto found_fin_ok; ...... found_fin_ok: /* Process the FIN. */ // tcp已讀seq++ ++*seq; ... break; } while(len > 0); |
由上面程式碼可知,一旦當前skb讀完了而且攜帶有fin標識,則不管有沒有讀到使用者期望的位元組數量都會返回已讀到的位元組數。下一次再讀取的時候則在剛才描述的tcp_rcvmsg上半段直接不讀取任何資料再跳轉到found_fin_ok並返回0。這樣應用就能感知到對端已經關閉了。 如下圖所示:
last_ack
應用層在發現對端關閉之後已經是close_wait狀態,這時候再呼叫close的話,會將狀態改為last_ack狀態,併傳送本端的fin,如下程式碼所示:
1 2 3 4 5 6 7 8 9 10 |
void tcp_close(struct sock *sk, long timeout) { ...... else if (tcp_close_state(sk)){ // tcp_close_state會將sk從close_wait狀態變為last_ack // 傳送fin包 tcp_send_fin(sk); } } |
在接收到主動關閉端的last_ack之後,則呼叫tcp_done(sk)設定sk為tcp_closed狀態,並回收sk的資源,如下程式碼所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
tcp_v4_do_rcv |-tcp_rcv_state_process int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb, struct tcphdr *th, unsigned len) { ...... /* step 5: check the ACK field */ if (th->ack) { ... case TCP_LAST_ACK: // 這處判斷是確認此ack是傳送Fin包對應的那個ack if (tp->snd_una == tp->write_seq) { tcp_update_metrics(sk); // 設定socket為closed,並回收socket的資源 tcp_done(sk); goto discard; } ... } } |
上述程式碼就是被動關閉端的後兩次揮手了,如下圖所示:
出現大量close_wait的情況
linux中出現大量close_wait的情況一般是應用在檢測到對端fin時沒有及時close當前連線。有一種可能如下圖所示:
當出現這種情況,通常是minIdle之類引數的配置不對(如果連線池有定時收縮連線功能的話)。給連線池加上心跳也可以解決這種問題。
如果應用close的時間過晚,對端已經將連線給銷燬。則應用傳送給fin給對端,對端會由於找不到對應的連線而傳送一個RST(Reset)報文。
作業系統何時回收close_wait
如果應用遲遲沒有呼叫close_wait,那麼作業系統有沒有一個回收機制呢,答案是有的。 tcp本身有一個包活(keep alive)定時器,在(keep alive)定時器超時之後,會強行將此連線關閉。可以設定tcp keep alive的時間
1 2 3 4 5 |
/etc/sysctl.conf net.ipv4.tcp_keepalive_intvl = 75 net.ipv4.tcp_keepalive_probes = 9 net.ipv4.tcp_keepalive_time = 7200 |
預設值如上面所示,設定的很大,7200s後超時,如果想快速回收close_wait可以設定小一點。但最終解決方案還是得從應用程式著手。
關於tcp keepalive包活定時器可見筆者另一篇部落格:
https://my.oschina.net/alchemystar/blog/833981
程式關閉時清理socket資源
程式在退出時候(無論kill,kill -9 或是正常退出)都會關閉當前程式中所有的fd(檔案描述符)
1 2 3 4 5 6 |
do_exit |-exit_files |-__exit_files |-close_files |-filp_close |
這樣我們又回到了部落格伊始的filp_close函式,對每一個是socket的fd傳送send_fin
Java GC時清理socket資源
Java的socket最終關聯到AbstractPlainSocketImpl,且其重寫了object的finalize方法
1 2 3 4 5 6 7 8 9 10 11 12 |
abstract class AbstractPlainSocketImpl extends SocketImpl { ...... /** * Cleans up if the user forgets to close it. */ protected void finalize() throws IOException { close() } ...... } |
所以Java會在GC時刻會關閉沒有被引用的socket,但是切記不要寄希望於Java的GC,因為GC時刻並不是以未引用的socket數量來判斷的,所以有可能洩露了一堆socket,但仍舊沒有觸發GC。
總結
linux核心原始碼博大精深,閱讀其程式碼很費周折。之前讀《TCP/IP詳解卷二》的時候由於有先輩引導和梳理,所以看書中所使用的BSD原始碼並不覺得十分費勁。直到現在自己帶著問題獨立看linux原始碼的時候,儘管有之前的基礎,仍舊被其中的各種細節所迷惑。希望筆者這篇文章能幫助到閱讀linux網路協議棧程式碼的人。