卡塔尔世界杯_中国进过几次世界杯 - 210rc.com
首页世界杯波兰正文

如何优雅的关闭socket

2025-11-06 03:30:17

26 Sep 2022 5977字 20分 CC BY 4.0 (除特别声明或转载文章外) 在网络编程中,一定会涉及到 socket 的关闭操作,那如何安全、优雅的关闭 socket 呢?是仅仅调用 close 就可以了吗? 带着这样的疑问,我们开始发车了。

close 与 shutdown 的区别关于 socket 的关闭,内核会提供两个相关的 api:close(fd) 和 shutdown(fd, how)。在捋清这两个的接口之前,先简单说明下进程是如何持有套接字的。

当在一个进程 A 中创建了一个套接字,这个套接字的引用次数为 1,当另一个进程 B 也持有该套接字,此时套接字的引用次数为 2,以此类推,你可以认为套接字对象是属于内核的资源,进程只持有该套接字对象的一个引用。进程在调用 close() 后引用次数 -1,当引用次数为 0 后,内核才会真正关闭连接,即发送 FIN 包,走四次挥手流程。

close 系统调用在深入 close(fd) 的执行流程之前,我们最好要对套接字的有一个大概的了解,一个套接字在内核是如何创建出来的呢?我们可能经常听说“linux 一切皆文件”,那么理所当然的套接字 socket 本质也是文件。当我们在上层使用 socket() 创建了一个套接字对象,该对象内部会持有一个文件的指针,这点我们可以从内核代码中看出:

// net/socket.c

// 创建一个套接字对象

int sock_create(int family, int type, int protocol, struct socket **res)

{

// 真正创建socket的函数

// 一个socket会与一个inode关联,而 inode 则是通过 sock_alloc_inode() 分配

// 内部使用了一个 sock_inode_cachep 分配器,内核会缓存一些inode,不够则调用 ctor 构造inode,可以参见:init_inodecache

return __sock_create(current->nsproxy->net_ns, family, type, protocol, res, 0);

}

// 给套接字创建文件对象

struct file *sock_alloc_file(struct socket *sock, int flags, const char *dname)

{

struct file *file;

if (!dname)

dname = sock->sk ? sock->sk->sk_prot_creator->name : "";

// 创建一个文件

file = alloc_file_pseudo(SOCK_INODE(sock), sock_mnt, dname,

O_RDWR | (flags & O_NONBLOCK),

&socket_file_ops);

if (IS_ERR(file)) {

sock_release(sock);

return file;

}

// 将文件指针赋值给 sock->file

sock->file = file; // 注意:在 sock_close 执行完毕后,sock 将不再持有file,即 sock->file = NULL

file->private_data = sock; // sock指针赋值给 file->private_data,这样 sock 和 file 就相互引用了

stream_open(SOCK_INODE(sock), file);

return file;

}

// socket() 系统调用

int __sys_socket(int family, int type, int protocol)

{

int retval;

struct socket *sock;

int flags;

// balabala...

retval = sock_create(family, type, protocol, &sock);

if (retval < 0)

return retval;

return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));

}

接下来,我们再来通过内核源码来深入分析 close 的流程:

内核将套接字所持有的文件从进程的文件列表中删除,然后将文件引用计数 -1;若套接字的文件引用计数为0,则将文件释放流程加入到任务队列,待到进程从内核态返回用户态的时调用;调用设置在文件上的文件操作回调函数 release,开始进入到套接字的 close 流程;内核判断接收缓冲区是否还有数据,若有则直接关闭套接字,并向对端发送 RST;若接收缓冲区为空,则走正常的关闭流程,即设置套接字 FIN_WAIT1,并发送 FIN 包;套接字与进程上下文分离,并与inode解绑,但是套接字不会在此释放,因为内核可能还需要使用到它;以下是 linux 内核 close(fd) 调用流程:

close_fd(fd) @fs/file.c

├─>pick_file(files, fd) @fs/open.c

└─>filp_close(file, files) @fs/open.c

└─>fput(file) @fs/file_table.c

└─>__fput(file) @fs/file_table.c

└─>sock_close(inode, file) @net/socket.c #本质是调用 file->f_op->release,即 socket_file_ops 结构体对象的 release 方法

└─>inet_release(sock) @net/ipv4/af_inet.c #本质是调用 sock->ops->release,即 inet_stream_ops 结构体对象的 release 方法

└─>tcp_close(sk, timeout) @net/ipv4/tcp.c #本质是调用 sock->sk->sk_prot->close,即 tcp_prot 结构体对象的 close 方法

上面一堆源码的调用分析也许让人很懵,我直接放出以下几点结论:

调用 close 并不直接操控套接字,而是通过修改它的引用计数来触发真正的关闭动作;当接收缓冲区没有数据时,调用 close 会走正常的四次挥手流程,对端收到FIN后,会给套接字设置 RCV_SHUTDOWN,epoll 则会抛出 EPOLLIN 和 EPOLLRDHUP ;

在这种情况下有几点说明:

内核会先设置套接字的状态为 FIN_WAIT_1,然后才尽力将发送缓冲区的数据发送出去,并在最后发出 FIN 包;接上,close(fd) 调用返回成功只能表明 FIN 包已经被放入发送缓冲区,至于对方是否收到是不知道的,需要等待对方的 ACK 包;当套接字进入 FIN_WAIT1 或者 FIN_WAIT2 后,在 FIN 包收到确认后,再收到其他数据包,则会给对端 RST;对端收到的 FIN,只能表明对方关闭了发送通道,至于是使用 close(fd) 还是 shutdown(fd, SHUT_WR) 对端是不知道的,且 epoll 在水平模式下会不断抛出 EPOLLIN 和 EPOLLRDHUP;接上,对端再继续调用 write(fd),依然能返回写入的数据长度,但是接着 epoll 会抛出 EPOLLERR(Broken pipe)、EPOLLHUP,因为已经对对方的 FIN 进行了确认回复,对方已经处于 FIN_WAIT2 状态,当对方再次收到数据包时,会返回 RST。对端再次调用 write(fd),内核将触发 SIGPIPE 信号,默认处理该信号的方式是退出进程,因此网络编程中一般都会手动处理该信号;当接收缓冲区还有数据时,调用 close 会导致给对端发送 RST,直接重置了链接,没有走正常的四次挥手流程,对端会抛出 (Connection reset by peer)、EPOLLHUP、EPOLLRDHUP、EPOLLIN(一次性抛出);以上三个结论我准备了三个小实验,代码放在github仓库,可自行编译,并结合抓包来验证。

实验1(close1),演示 close() 的引用计数,当 epollclient 进程 close 退出后,shardclient 进程依然能够收发数据。实验2(close2),演示接收缓冲区为空时调用 close(),服务端收到的 FIN ,但是让服务端再发送数据时,就会产生异常。实验3(close3),演示接收缓冲区不为空时调用 close(),客户端直接发送了 RST,重置了链接。注意:以上的结论是没有考虑 so_linger 。

顺便我们再思考一个问题,如果对一个已经成功调用 close() 的 fd 再次调用诸如 close、read、write 会发生什么情况呢?可以写一个简单例子验证,这里我就不再贴代码,直接把结果放出来:这几个系统调用的返回值都会返回 -1,且错误均为 “Bad file descriptor”。我们可以从内核代码中看出,以 read 为例:

// fs/read_write.c

ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)

{

struct fd f = fdget_pos(fd);

ssize_t ret = -EBADF;

if (f.file) {

// close() 后的 f.file == NULL,因此这个 if 是进不来的

// 原因是 close 调用链中的 pick_file 函数将 fd 从进程的文件列表中删除了

loff_t pos, *ppos = file_ppos(f.file);

if (ppos) {

pos = *ppos;

ppos = &pos;

}

ret = vfs_read(f.file, buf, count, ppos);

if (ret >= 0 && ppos)

f.file->f_pos = pos;

fdput_pos(f);

}

return ret;

}

// fs/file.c

static struct file *pick_file(struct files_struct *files, unsigned fd)

{

struct file *file;

struct fdtable *fdt;

spin_lock(&files->file_lock);

fdt = files_fdtable(files);

if (fd >= fdt->max_fds) {

file = ERR_PTR(-EINVAL);

goto out_unlock;

}

file = fdt->fd[fd];

if (!file) {

file = ERR_PTR(-EBADF);

goto out_unlock;

}

rcu_assign_pointer(fdt->fd[fd], NULL); // 将fd从fdt->fd中移除

__put_unused_fd(files, fd); // 放入 unused_fd ,以便复用

out_unlock:

spin_unlock(&files->file_lock);

return file;

}

shutdown 系统调用与 close(fd) 不同的是,shutdown(fd,how) 会直接修改套接字,通过 how 参数可以控制 socket 的发送和接收通道,可以只关闭接收通道,也可以只关闭发送通道,也可以同时关闭接收和发送通道。how 参数可以传的值如下所示:

SHUT_RD(0),关闭接收通道;SHUT_WR(1),关闭发送通道;SHUT_RDWR(2),先关闭接收通道,然后再关闭发送通道;我们先说下 SHUT_WR,它与 close(fd) 的处理流程类似,内核会将 socket 设置为 FIN_WAIT1,然后将发送缓冲区的数据发送出去,并在最后发送 FIN,对方收到 FIN 后,epoll 会抛出 EPOLLRDHUP(该事件需要注册,表示读通道挂断),且对方调用 read 会返回 0,但是若接收缓冲区不为空时,SHUT_WR 不会像 close 那样引起 RST。

对于 SHUT_RD,当我们只 SHUT_RD 时,对套接字是没有影响的(可以查看内核代码 tcp_shutdown 函数),依然可以 read 到数据,不过当缓冲区没有数据时, read 会立即返回0,即使套接字设置为阻塞,具体可以查看内核的 tcp_recvmsg_locked 函数;当 SHUT_WR 后再 SHUT_RD,套接字将完全关闭,对方再发送数据会返回 RST,这与 SHUT_RDWR 的效果是一样的。

以下是 linux 内核 shutdown(fd, how) 调用流程:

# shutdown 系统调用流程

__sys_shutdown(fd,how) @net/socket.c # 系统调用 shutdown(fd, how)

└─>__sys_shutdown_sock(sock,how) @net/socket.c

└─>inet_shutdown(sock,how) @net/ipv4/af_inet.c #通过调用 sock->ops->shutdown 这个回调函数

└─>tcp_shutdown(sk, how) @net/ipv4/tcp_ipv4.c # 通过调用 sock->sk->sk_prot->shutdown 这个回调函数

# tcp 收到数据的处理流程

tcp_v4_rcv(skb) @net/ipv4/tcp_ipv4.c

└─>tcp_v4_do_rcv(sk, skb) @net/ipv4/tcp_ipv4.c

└─>tcp_rcv_state_process(sk, skb) @net/ipv4/tcp_input.c

为了验证不同 how 参数的效果,我同样准备了三个小实验,实验代码链接见上。

实验1(shutdown1),演示了只关闭读通道后的效果。服务端收到链接后每隔 500ms 发送一次数据,客户端则关闭读通道,结果表明客户端依然能收到数据,但read 会立即返回。实验2(shutdown2),演示了先关闭写通道再关闭读通道的效果。服务器依然间隔 500ms 发送一次数据,客户端延迟 2s 后关闭写通道,然后再等待一段时间关闭写通道,最后服务端因为收到 RST 重置链接,且再次写入数据时触发 SIGPIPE 信号。实验3(shutdown3),演示了同时关闭读写通道的效果,最后结果与实验2类似。so_linger 选项的作用优雅的关闭socket参考TCP: When is EPOLLHUP generated?SO_LINGER on Non-Blocking SocketsClosing a non-blocking socket with linger enabled may cause leak

碧蓝航线茗任务流程 芙怎么读
相关内容