從 linux 源碼看 socket 的 close

筆者一直覺得如果能知道從應用到框架再到操作系統的每一處代碼,是一件 Exciting 的事情。上篇博客講了 socket 的阻塞和非阻塞,這篇就開始談一談 socket 的 close(以 tcp 爲例且基於 linux-2.6.24 內核版本)

TCP 關閉狀態轉移圖:

衆所周知,TCP 的 close 過程是四次揮手,狀態機的變遷也逃不出 TCP 狀態轉移圖,如下圖所示:

tcp 的關閉主要分主動關閉、被動關閉以及同時關閉 (特殊情況, 不做描述)

主動關閉

close(fd) 的過程

以 C 語言爲例,在我們關閉 socket 的時候,會使用 close(fd) 函數:

int    socket_fd;
socket_fd = socket(AF_INET, SOCK_STREAM, 0);
...
// 此處通過文件描述符關閉對應的socket
close(socket_fd)

而 close(int fd) 又是通過系統調用 sys_close 來執行的:

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 方法:

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:

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 函數了:

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 的代碼,即

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 的實現爲:

static const struct file_operations socket_file_ops = {
    .owner =    THIS_MODULE,
    ......
    // 我們在這裏只考慮sock_close
    .release =    sock_close,
    ......
};

繼續跟蹤:

sock_close
    |-sock_release
        |-sock->ops->release(sock);

在上一篇博客中,我們知道 sock->ops 爲下圖所示:

即 (在這裏我們僅考慮 tcp, 即 sk_prot=tcp_prot):

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

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

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 所示, 具體代碼如下:

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 的話, 則可以按照下列配置:

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, 如下代碼所示:

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 函數

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 所示:

具體代碼如下所示:

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

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 去關閉連接

if(recv(sockfd, buf, MAXLINE,0) == 0){
    close(sockfd)
}

我們看下 recv 是怎麼處理 fin 包,從而返回 0 的, 上一篇博客可知,recv 最後調用 tcp_rcvmsg, 由於比較複雜,我們分兩段來看:
tcp_recvmsg 第一段

        ......
        // 從接收隊列裏面獲取一個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 的第二段:

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, 如下代碼所示:

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 的資源, 如下代碼所示:

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 的時間

/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(文件描述符)

do_exit
    |-exit_files
        |-__exit_files
            |-close_files
                    |-filp_close

這樣我們又回到了博客伊始的 filp_close 函數,對每一個是 socket 的 fd 發送 send_fin

Java GC 時清理 socket 資源

Java 的 socket 最終關聯到 AbstractPlainSocketImpl, 且其重寫了 object 的 finalize 方法

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 內核源代碼博大精深,閱讀其代碼很費周折。之前讀 <> 的時候由於有先輩引導和梳理,所以看書中所使用的 BSD 源碼並不覺得十分費勁。直到現在自己帶着問題獨立看 linux 源碼的時候,儘管有之前的基礎,仍舊被其中的各種細節所迷惑。希望筆者這篇文章能幫助到閱讀 linux 網絡協議棧代碼的人。

原文鏈接

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ze1DLuxIdDqMxG7d_rIiVQ