9.优雅的关闭

216 阅读10分钟

上一讲我们讲到了 TCP 的四次挥手,其中发起连接关闭的一方会有一段时间处于 TIME_WAIT 状态。那么究竟如何来发起连接关闭呢?这一讲我们就来讨论一下。

TCP 连接需要经过三次握手进入数据传输阶段,最后来到连接关闭阶段。在该阶段我们需要重点关注的是“半连接”状态。

因为 TCP 是双向的,这里说的方向,指的是数据流的写入 - 读出的方向。

比如客户端到服务器的方向,指的是客户端通过套接字接口,向服务器端发送 TCP 报文;而服务器端到客户端方向则是另一个传输方向。在绝大数情况下,TCP 连接都是先关闭一个方向,此时另外一个方向还是可以正常进行数据传输。

举个例子,客户端主动发起连接的中断,将自己到服务器的数据流方向关闭,此时,客户端不再往服务器端写入数据,服务器读完客户端数据后就不会再有新的报文到达。但这并不意味着,TCP 连接已经完全关闭,很有可能的是,服务器正在对客户端的最后报文进行处理,比如去访问数据库,存入一些数据;或者是计算出某个客户端需要的值,当完成这些操作之后,服务器把结果通过套接字写给客户端,我们说这个套接字的状态此时是“半关闭”的。最后,服务器才关闭剩下的半个连接。

当然这里描述的是服务器“优雅”地关闭了连接。如果处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果,很可能是服务器处理完的信息没办法正常传送给客户端,破坏了用户侧的使用场景。

接下来我们就来看看关闭连接时,都有哪些方式呢?

close 函数

首先,我们来看最常见的 close 函数:

int close(int sockfd)

对已连接的套接字执行 close 操作,若成功则为 0,若出错则为 -1。

这个函数会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并且会关闭TCP 两个方向的数据流

套接字引用计数是什么意思呢?因为套接字可以被多个进程共享,所以通过计数的方式确保断开连接时没有进程在使用

close 函数具体是如何关闭两个方向的数据流呢?

在输入方向,内核会将该套接字设置为不可读,任何读操作都会返回异常。

在输出方向,内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个 FIN 报文,接下来如果再对该套接字进行写操作会返回异常。

如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个 RST 报文,告诉对端:“Hi, 我已经关闭了,别再给我发数据了。”

close 函数并不能帮助我们关闭连接的单个方向,那么如何在需要时关闭单方向呢?幸运的是,设计 TCP 协议的人帮我们想好了解决方案,这就是 shutdown 函数。

shutdown 函数

shutdown 函数的原型是这样的:

int shutdown(int sockfd, int howto)

对已连接的套接字执行 shutdown 操作,若成功则为 0,若出错则为 -1。

howto 有三个主要选项:

  • SHUT_RD(0):关闭连接的“读”方向,对该套接字进行读操作直接返回 EOF。套接字上接收缓冲区已有的数据将被丢弃,如果再有新的数据流到达,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。
  • SHUT_WR(1):关闭连接的“写”方向,即常被称为”半关闭“的连接。不管套接字引用计数的值是多少,都会直接关闭连接的写方向。套接字上发送缓冲区已有的数据将被立即发送出去,并发送一个 FIN 报文给对端。应用程序如果对该套接字进行写操作会报错。
  • SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,关闭套接字的读和写两个方向。

不知道你有没有困惑,使用 SHUT_RDWR 来调用 shutdown 不是和 close 基本一样吗,都是关闭连接的读和写两个方向。

其实,这两个还是有差别的。

  1. close 会关闭连接,并释放所有连接对应的资源,而 shutdown 并不会释放掉套接字和所有的资源。
  2. close 存在引用计数的概念,并不一定导致该套接字不可用;shutdown 则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。
  3. close 的引用计数导致不一定会发出 FIN 结束报文,而 shutdown 则总是会发出 FIN 结束报文

体会 close 和 shutdown 的差别

下面,我们通过构建一组客户端和服务器程序,来进行 close 和 shutdown 的实验。

客户端程序,从标准输入不断接收用户输入,把输入的字符串通过套接字发送给服务器,同时,将服务器的应答显示到标准输出上。

如果用户输入了“close”,则会调用 close 函数关闭连接,休眠一段时间,等待服务器处理后退出;如果用户输入了“shutdown”,调用 shutdown 函数关闭连接的写方向,注意我们不会直接退出,而是会继续等待服务器的应答,直到服务器端完成自己的操作,在另一个方向上完成关闭。

在这里,我们会第一次接触到 select 多路复用,不展开讲,你只需要记住,使用 select 使得我们可以同时完成对连接套接字和标准输入两个 I/O 对象的处理。

先在common.h里加入# include<sys/select.h>

# include "common.h"

# define    MAXLINE     4096

int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: graceclient <IPaddress>");

    }
    // 创建TCP套接字
    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);
    // 设置连接的目标服务器 IPv4 地址,绑定到了指定的 IP 和端口;
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);
    // 发起连接请求
    socklen_t server_len = sizeof(server_addr);
    int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }

    char send_line[MAXLINE], recv_line[MAXLINE + 1];
    int n;

    fd_set readmask;
    fd_set allreads;
    // 为使用 select 做准备,初始化描述字集合,这部分后面详细解释,这里就不再深入。
    FD_ZERO(&allreads);
    FD_SET(0, &allreads);
    FD_SET(socket_fd, &allreads);
    for (;;) { // 使用 select 多路复用观测在连接套接字和标准输入上的 I/O 事件 
        readmask = allreads;
        int rc = select(socket_fd + 1, &readmask, NULL, NULL, NULL);
        if (rc <= 0)
            error(1, errno, "select failed");
        if (FD_ISSET(socket_fd, &readmask)) {//当连接套接字上有数据可读,将数据读入到程序缓冲区中
            n = read(socket_fd, recv_line, MAXLINE);
            if (n < 0) {//如果有异常则报错退出
                error(1, errno, "read error");
            } else if (n == 0) {//如果读到服务器端发送的 EOF 则正常退出。
                error(1, 0, "server terminated \n");
            }
            recv_line[n] = 0;
            fputs(recv_line, stdout);
            fputs("\n", stdout);
        }
        if (FD_ISSET(0, &readmask)) {// 当标准输入上有数据可读,读入后进行判断。
            if (fgets(send_line, MAXLINE, stdin) != NULL) {
                if (strncmp(send_line, "shutdown", 8) == 0) {// 如果输入的是“shutdown”,则关闭标准输入的 I/O 事件感知
                    FD_CLR(0, &allreads);
                    if (shutdown(socket_fd, 1)) {//并调用 shutdown 函数关闭写方向
                        error(1, errno, "shutdown failed");
                    }
                } else if (strncmp(send_line, "close", 5) == 0) { // ;如果输入的是”close“
                    FD_CLR(0, &allreads);
                    if (close(socket_fd)) {//调用 close 函数关闭连接;
                        error(1, errno, "close failed");
                    }
                    sleep(6);
                    exit(0);
                } else {
                // 处理正常的输入,将回车符截掉,调用 write 函数,通过套接字将数据发送给服务器端。
                    int i = strlen(send_line);
                    if (send_line[i - 1] == '\n') {
                        send_line[i - 1] = 0;
                    }

                    printf("now sending %s\n", send_line);
                    size_t rt = write(socket_fd, send_line, strlen(send_line));
                    if (rt < 0) {
                        error(1, errno, "write failed ");
                    }
                    printf("send bytes: %zu \n", rt);
                }
            }
        }
    }
}

服务器程序稍微简单一点,连接建立之后,打印出接收的字节,并重新格式化后,发送给客户端。

服务器程序有一点需要注意,那就是对 SIGPIPE 这个信号的处理。后面我会结合程序的结果展开说明。

#include "common.h"

static int count;

static void sig_int(int signo) {
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}


int main(int argc, char **argv) {
    //创建TCP套接字
    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);
    //设置了本地服务器 IPv4 地址,绑定到了 ANY 地址和指定的端口;
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);
    //设置了本地服务器 IPv4 地址,绑定到了 ANY 地址和指定的端口;
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }

    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }

    signal(SIGINT, sig_int);
    signal(SIGPIPE, SIG_DFL);

    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);

    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "bind failed ");
    }

    char message[MAXLINE];
    count = 0;
    //显示收到的字符串,重新格式化,调用 send 函数将数据发送给客户端。在发送前让服务器休眠 5 秒模拟服务器处理的时间。
    for (;;) {
        int n = read(connfd, message, MAXLINE);
        if (n < 0) {
            error(1, errno, "error read");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
        message[n] = 0;
        printf("received %d bytes: %s\n", n, message);
        count++;

        char send_line[MAXLINE];
        sprintf(send_line, "Hi, %s", message);

        sleep(5);

        int write_nc = send(connfd, send_line, strlen(send_line), 0);
        printf("send bytes: %zu \n", write_nc);
        if (write_nc < 0) {
            error(1, errno, "error write");
        }
    }
}

我们启动服务器,再启动客户端,依次在标准输入上输入 data1、data2 和 close,观察一段时间后我们看到:

$./graceclient 127.0.0.1
data1
now sending data1
send bytes:5
data2
now sending data2
send bytes:5
Hi,data1
close
$./graceserver
received 5 bytes: data1
send bytes: 9
received 5 bytes: data2
send bytes: 9
client closed

客户端依次发送了 data1 和 data2,服务器也正常接收到。在客户端 close 掉整个连接之后,服务器端接收到 SIGPIPE 信号,直接退出。客户端并没有收到服务器端的应答数据。

因为客户端调用 close 函数关闭了整个连接,当服务器端发送的“Hi, data1”分组到达时,客户端给回送一个 RST 分组,发送方套接字接收到 RST 分组后,会在内核中记录该连接的状态为“失败”或“异常”,无法再通过该连接发送任何数据;服务器端再次尝试发送“Hi, data2”第二个应答分组时,内核通知 SIGPIPE 信号。这是因为当应用程序尝试通过该连接发送数据时,操作系统会返回一个错误码,通常是 EPIPESIGPIPE 错误。这表明应用程序正在尝试使用一个已经关闭的连接进行通信。

image.png

我们可以注册一个信号处理函数,对 SIGPIPE 信号进行处理,避免程序莫名退出:

static void sig_pipe(int signo){
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}

signal(SIGPIPE, sig_pipe);

这样在该连接再次发送数据后会调用该函数后退出。

接下来,再次启动服务器和客户端,依次在标准输入上输入 data1、data2 和 shutdown 函数,观察一段时间后我们看到:

$./graceclient 127.0.0.1
data1
now sending data1
send bytes:5
data2
now sending data2
send bytes:5
shutdown
Hi, data1
Hi,data2
server terminated
$./graceserver
received 5 bytes: data1
send bytes: 9
received 5 bytes: data2
send bytes: 9
client closed

和前面的结果不同,服务器输出了 data1、data2;客户端也输出了“Hi,data1”和“Hi,data2”,客户端和服务器端各自完成了自己的工作后,正常退出。

我们再看下客户端和服务器交互的时序图。因为客户端调用 shutdown 函数只是关闭连接的一个方向,服务器端到客户端的这个方向还可以继续进行数据的发送和接收,所以“Hi,data1”和“Hi,data2”都可以正常传送;当服务器端读到 EOF 时,立即向客户端发送了 FIN 报文,客户端在 read 函数中感知了 EOF,也进行了正常退出。