11 | 优雅地关闭还是粗暴地关闭 ?
下载APP
关闭
渠道合作
推荐作者
11 | 优雅地关闭还是粗暴地关闭 ?
2019-08-26 盛延敏 来自北京
《网络编程实战》
课程介绍
讲述:冯永吉
时长12:20大小16.91M
你好,我是盛延敏,这里是网络编程实战第 11 讲,欢迎回来。
上一讲我们讲到了 TCP 的四次挥手,其中发起连接关闭的一方会有一段时间处于 TIME_WAIT 状态。那么究竟如何来发起连接关闭呢?这一讲我们就来讨论一下。
我们知道,一个 TCP 连接需要经过三次握手进入数据传输阶段,最后来到连接关闭阶段。在最后的连接关闭阶段,我们需要重点关注的是“半连接”状态。
因为 TCP 是双向的,这里说的方向,指的是数据流的写入 - 读出的方向。
比如客户端到服务器端的方向,指的是客户端通过套接字接口,向服务器端发送 TCP 报文;而服务器端到客户端方向则是另一个传输方向。在绝大多数情况下,TCP 连接都是先关闭一个方向,此时另外一个方向还是可以正常进行数据传输。
举个例子,客户端主动发起连接的中断,将自己到服务器端的数据流方向关闭,此时,客户端不再往服务器端写入数据,服务器端读完客户端数据后就不会再有新的报文到达。但这并不意味着,TCP 连接已经完全关闭,很有可能的是,服务器端正在对客户端的最后报文进行处理,比如去访问数据库,存入一些数据;或者是计算出某个客户端需要的值,当完成这些操作之后,服务器端把结果通过套接字写给客户端,我们说这个套接字的状态此时是“半关闭”的。最后,服务器端才有条不紊地关闭剩下的半个连接,结束这一段 TCP 连接的使命。
当然,我这里描述的是服务器端“优雅”地关闭了连接。如果服务器端处理不好,就会导致最后的关闭过程是“粗暴”的,达不到我们上面描述的“优雅”关闭的目标,形成的后果,很可能是服务器端处理完的信息没办法正常传送给客户端,破坏了用户侧的使用场景。
接下来我们就来看看关闭连接时,都有哪些方式呢?
close 函数
首先,我们来看最常见的 close 函数:
这个函数很简单,对已连接的套接字执行 close 操作就可以,若成功则为 0,若出错则为 -1。
这个函数会对套接字引用计数减一,一旦发现套接字引用计数到 0,就会对套接字进行彻底释放,并且会关闭 TCP 两个方向的数据流。
套接字引用计数是什么意思呢?因为套接字可以被多个进程共享,你可以理解为我们给每个套接字都设置了一个积分,如果我们通过 fork 的方式产生子进程,套接字就会积分 +1, 如果我们调用一次 close 函数,套接字积分就会 -1。这就是套接字引用计数的含义。
close 函数具体是如何关闭两个方向的数据流呢?
在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,并最后向对端发送一个 FIN 报文,接下来如果再对该套接字进行写操作会返回异常。
如果对端没有检测到套接字已关闭,还继续发送报文,就会收到一个 RST 报文,告诉对端:“Hi, 我已经关闭了,别再给我发数据了。”
我们会发现,close 函数并不能帮助我们关闭连接的一个方向,那么如何在需要的时候关闭一个方向呢?幸运的是,设计 TCP 协议的人帮我们想好了解决方案,这就是 shutdown 函数。
shutdown 函数
shutdown 函数的原型是这样的:
对已连接的套接字执行 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 基本一样吗,都是关闭连接的读和写两个方向。
其实,这两个还是有差别的。
第一个差别:close 会关闭连接,并释放所有连接对应的资源,而 shutdown 并不会释放掉套接字和所有的资源。
第二个差别:close 存在引用计数的概念,并不一定导致该套接字不可用;shutdown 则不管引用计数,直接使得该套接字不可用,如果有别的进程企图使用该套接字,将会受到影响。
第三个差别:close 的引用计数导致不一定会发出 FIN 结束报文,而 shutdown 则总是会发出 FIN 结束报文,这在我们打算关闭连接通知对端的时候,是非常重要的。
体会 close 和 shutdown 的差别
下面,我们通过构建一组客户端和服务器程序,来进行 close 和 shutdown 的实验。
客户端程序,从标准输入不断接收用户输入,把输入的字符串通过套接字发送给服务器端,同时,将服务器端的应答显示到标准输出上。
如果用户输入了“close”,则会调用 close 函数关闭连接,休眠一段时间,等待服务器端处理后退出;如果用户输入了“shutdown”,调用 shutdown 函数关闭连接的写方向,注意我们不会直接退出,而是会继续等待服务器端的应答,直到服务器端完成自己的操作,在另一个方向上完成关闭。
在这里,我们会第一次接触到 select 多路复用,这里不展开讲,你只需要记住,使用 select 使得我们可以同时完成对连接套接字和标准输入两个 I/O 对象的处理。
我对这个程序的细节展开解释一下:
第一部分是套接字的创建和 select 初始化工作:
9-10 行创建了一个 TCP 套接字;
12-16 行设置了连接的目标服务器 IPv4 地址,绑定到了指定的 IP 和端口;
18-22 行使用创建的套接字,向目标 IPv4 地址发起连接请求;
30-32 行为使用 select 做准备,初始化描述字集合,这部分我会在后面详细解释,这里就不再深入。
第二部分是程序的主体部分,从 33-80 行, 使用 select 多路复用观测在连接套接字和标准输入上的 I/O 事件,其中:
38-48 行:当连接套接字上有数据可读,将数据读入到程序缓冲区中。40-41 行,如果有异常则报错退出;42-43 行如果读到服务器端发送的 EOF 则正常退出。
49-77 行:当标准输入上有数据可读,读入后进行判断。如果输入的是“shutdown”,则关闭标准输入的 I/O 事件感知,并调用 shutdown 函数关闭写方向;如果输入的是“close”,则调用 close 函数关闭连接;64-74 行处理正常的输入,将回车符截掉,调用 write 函数,通过套接字将数据发送给服务器端。
服务器端程序稍微简单一点,连接建立之后,打印出接收的字节,并重新格式化后,发送给客户端。
服务器端程序有一点需要注意,那就是对 SIGPIPE 这个信号的处理。后面我会结合程序的结果展开说明。
服务器端程序的细节也展开解释一下:
第一部分是套接字和连接创建过程:
11-12 行创建了一个 TCP 套接字;
14-18 行设置了本地服务器 IPv4 地址,绑定到了 ANY 地址和指定的端口;
20-40 行使用创建的套接字,依次执行 bind、listen 和 accept 操作,完成连接建立。
第二部分是程序的主体,通过 read 函数获取客户端传送来的数据流,并回送给客户端:
51-52 行显示收到的字符串,在 56 行对原字符串进行重新格式化,之后调用 send 函数将数据发送给客户端。注意,在发送之前,让服务器端程序休眠了 5 秒,以模拟服务器端处理的时间。
我们启动服务器,再启动客户端,依次在标准输入上输入 data1、data2 和 close,观察一段时间后我们看到:
客户端依次发送了 data1 和 data2,服务器端也正常接收到 data1 和 data2。在客户端 close 掉整个连接之后,服务器端接收到 SIGPIPE 信号,直接退出。客户端并没有收到服务器端的应答数据。
我在下面放了一张图,这张图详细解释了客户端和服务器端交互的时序图。因为客户端调用 close 函数关闭了整个连接,当服务器端发送的“Hi, data1”分组到底时,客户端给回送一个 RST 分组;服务器端再次尝试发送“Hi, data2”第二个应答分组时,系统内核通知 SIGPIPE 信号。这是因为,在 RST 的套接字进行写操作,会直接触发 SIGPIPE 信号。
这回知道你的程序莫名其妙终止的原因了吧。
我们可以像这样注册一个信号处理函数,对 SIGPIPE 信号进行处理,避免程序莫名退出:
接下来,再次启动服务器,再启动客户端,依次在标准输入上输入 data1、data2 和 shutdown 函数,观察一段时间后我们看到:
和前面的结果不同,服务器端输出了 data1、data2;客户端也输出了“Hi,data1”和“Hi,data2”,客户端和服务器端各自完成了自己的工作后,正常退出。
我们再看下客户端和服务器端交互的时序图。因为客户端调用 shutdown 函数只是关闭连接的一个方向,服务器端到客户端的这个方向还可以继续进行数据的发送和接收,所以“Hi,data1”和“Hi,data2”都可以正常传送;当服务器端读到 EOF 时,立即向客户端发送了 FIN 报文,客户端在 read 函数中感知了 EOF,也进行了正常退出。
总结
在这一讲中,我们讲述了 close 函数关闭连接的方法,使用 close 函数关闭连接有两个需要明确的地方。
close 函数只是把套接字引用计数减 1,未必会立即关闭连接;
close 函数如果在套接字引用计数达到 0 时,立即终止读和写两个方向的数据传送。
基于这两点,在期望关闭连接其中一个方向时,应该使用 shutdown 函数。
思考题
和往常一样,给你留两道思考题。
第一道题,你可以看到在今天的服务器端程序中,直接调用exit(0)完成了 FIN 报文的发送,这是为什么呢?为什么不调用 close 函数或 shutdown 函数呢?
第二道题关于信号量处理,今天的程序中,使用的是SIG_IGN默认处理,你知道默认处理和自定义函数处理的区别吗?不妨查查资料,了解一下。
欢迎你在评论区写下你的思考,也欢迎把这篇文章分享给你的朋友或者同事,一起来交流。
分享给需要的人,Ta购买本课程,你将得18元
生成海报并分享
赞 19
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
10 | TIME_WAIT:隐藏在细节下的魔鬼
下一篇
12 | 连接无效:使用Keep-Alive还是应用心跳来检测?
精选留言(76)
- 卫江2019-09-03问题一,为什么调用exit以后不需要调用close,shutdown?因为在调用exit之后进程会退出,而进程相关的所有的资源,文件,内存,信号等内核分配的资源都会被释放,在linux中,一切皆文件,本身socket就是一种文件类型,内核会为每一个打开的文件创建file结构并维护指向改结构的引用计数,每一个进程结构中都会维护本进程打开的文件数组,数组下标就是fd,内容就指向上面的file结构,close本身就可以用来操作所有的文件,做的事就是,删除本进程打开的文件数组中指定的fd项,并把指向的file结构中的引用计数减一,等引用计数为0的时候,就会调用内部包含的文件操作close,针对于socket,它内部的实现应该就是调用shutdown,只是参数是关闭读写端,从而比较粗暴的关闭连接。 第二个问题,信号的处理有三种,默认处理,忽略处理,自定义处理。默认处理就是采用系统自定义的操作,大部分信号的默认处理都是杀死进程,忽略处理就是当做什么都没有发生。展开
作者回复: 赞赞赞。
共 7 条评论116 - zhchnchn2019-09-03请问老师一个问题,像FIN,ACK等报文并不属于“数据”是么?比如对于4次挥手中,发起关闭的客户端在发送FIN报文后,表明客户端不再发送“数据”了(可以读数据),之后在服务端也发送FIN报文后,客户端还是会发送一个ACK报文给服务端。这里的最后一个ACK报文,并不属于“数据”是吗?T或者CP中这些所有的控制报文包都不属于所谓的“数据”吗?
作者回复: 这是控制报文,是脱离应用程序掌握之外的控制协议部分,层这个层面来说,他们不被认为是"应用层数据"。
共 2 条评论30 - Guchen2020-10-21老师我这个理解有问题么? 客户端调用close并发送了FIN包,服务端接收到FIN包会将连接状态设置为对端已关闭写端,服务端在向客户端回送FIN包之前可以向对端发送数据也不可不发送,实例程序就是发送了数据,第一次发送数据对端会返回个RST包(因为客户端close已经关闭了读端),服务端调用read会返回-1,设置errno,此时服务端了解到客户端也不会再读数据了,如果服务端还是强行再次向客户端发送数据包,调用write后,协议栈会发现用户进程向一个没有读用户的管道做写操作,那么会触发SIGPIPE信号执行默认操作关闭用户进程。展开
作者回复: 正解~
共 4 条评论17 - skye2019-12-29那请问老师,什么情况下用close,什么情况下用shutdown?谢谢!
作者回复: 你想彻底关闭双向连接的时候用close,你想只关闭自己这端到对端的连接时用shutdown。
10 - 传说中的成大大2019-08-27然后又引申了fin包和read返回0是怎样的关系呢?
作者回复: 读到FIN包在read看来就是返回0.
8 - W.jyao2019-08-27SUG_IGN是忽略信号吧,老师是不是代码有问题啊,SIG_DFL才是默认处理,示例中那样写不是应该是忽略了SIGPIPE信号么。
作者回复: 嗯,你试一下改改是不是可以的,我应该是写错了。
共 3 条评论9 - 忆水寒2020-03-08close关闭比较粗暴,如果想要优雅的关闭,等处理完数据再关闭可以使用shutdown设置参数。 实际上在tcp协议中有个参数so_linger也可以设置比较优雅(规定时间处理缓冲区的字节,超时则清空缓冲区)的关闭tcp的socket。
作者回复: 学习了,后面应该会讲到SO_LINGER。
6 - 传说中的成大大2019-08-27思考题 1. 调用exit(0) 是系统内核自动回收了一切资源,关闭了一切相关的文件 2. SIG_IGN是忽略该信号 如果是采用SIG_DEF默认处理方式则是由系统进行一些处理,而自定义函数的话 是捕捉到该信号并进入回调 自己处理相关事宜5
- W.jyao2019-08-27老师,调用shutdown不会释放套接字资源,那么应该怎么处理呢
作者回复: 不会立即释放,最终还是会被系统内核回收的。
4 - 一周思进2019-09-02根据所学内容解决了服务器退出问题 https://mp.weixin.qq.com/s/n2rCkJtPnMrxOPsPcwR89w3
- 传说中的成大大2019-08-28今天我也写了代码测试上面的例子同时也想起了网上一句话 close是关闭掉socket 并且回收了socket相关资源,而shutdown一般只是关闭连接并不会关闭socket 这也是为啥调用close服务器端在进行写数据的时候会触发sigpipe信号,而shutdown却不会触发,那么调用shutdown关闭的套接字怎么回收呢?是由内核回收还是? 服务器端和客户端又是怎样回收的呢?
作者回复: 我理解还是内核会回收的,只不过是等待双向连接都关闭掉才会回收。
共 2 条评论3 - Sun2020-04-25close 关闭两个方向的数据流时。输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。在输出方向,系统内核尝试将发送缓冲区的数据发送给对端,那对端已经不可读了,这些数据该如何处理?
作者回复: 有顺序的,系统会在所有待发送的有效数据之后,插入一个EOF标识发送给对端,对端也是在处理完所有发送给自己的有效数据之后,才会读到EOF标识。换句话说,这是有一个过程的,并不是瞬间就达到的。
共 2 条评论2 - 雷刚2020-04-14老师在文中提到的一个问题:close 函数只是把套接字引用计数减 1,未必会立即关闭连接。有可能进程 A 在关闭 socket,而进程 B 却仍然在进行 socket I/O 操作,如何才能保证一旦调用 socket.close 方法,所有的进程(线程)都不能进行 I/O 操作呢? Java nio 对 close 进行了优化,采用经典的 two-step 处理方案,即 dup close 来关闭连接。比如 Java GC 中的标记清除算法,CHL 无锁队列中的标记删除算法,都是这种思想:先标记元素不可能用,再进行删除。dup close 方案同样如此,具体参考:<https://www.oschina.net/question/138146_26027>。 1. socket 在初始化时就会生成一个半关闭的连接,这个半关闭的 socket 连接相当于一个清除标记。 2. 当调用 close 时,先会调用 nd.preClose() 方法将原先的 socket 替换为这个已经半关闭的连接。这样其它进程在进行 socket I/O 操作时就会报错(EOF 或者 Pipe Error),read 或 write 方法里会检测这些状态做相应处理。 3. 当所有的进程都调用 close 后,就没有进行再使用这个 socket,最后一个使用的线程调用 nd.close() 真正关闭 socket。展开
作者回复: 我记得Java这么做是有一个兼容性的原因,为了保持windows和linux跨平台的兼容性的。
2 - 胡波 allenhu2019-08-26老师你好, 上面的例子中有两个地方不是很清楚: (1) close: client调用close后, 不是会马上关闭两个方向的连接吗, 那为啥"Hi, data1"还能被收到并显示? (2) shutdown: client调用shutdown只是关闭写方向的连接, 是不是client不能发了只能收?那为啥server能读到EOF并发送"FIN"给client?
作者回复: 1.那是之前发送的; 2.那是shutdown的结果,shutdown之后,就是往队列里扔了一个FIN报文,这个发送队列还是要被发送完成的。
共 2 条评论2 - lwenbin2021-10-15这里服务器signal 里 SIG_PIPE 应该是 SIG_DFL, github 里是对的,不然会 ignore,然后print "error write". 对于close的情况下,server 发送第一个write 是可以的,不会返回-1,应该成功写入了缓冲区。但是感觉发送后client会通知server协议栈RST。第二次server再 write 的时候就收到了 SIGPIPE 了。 这里不管 close 还是 shutdown, client 都会发送 FIN 到客户端,在 shutdown 情况下 server 还是能够 read 和 write,只有 read 完缓冲区后才会收到 0,这时候 server 才会去exit/close/shutdown。 所以感觉对于server 收到 FIN 后,write 除非收到了 RST然后 SIG_PIPE,不然还是可以写。当然如果server read 到了 EOF,应该关闭当前socket。展开
作者回复: 确实如此。对于读和写,是要区分不同的逻辑。
1 - 晚风·和煦2020-04-03close那里的输入和输出方向没太理解😂
作者回复: 再仔细揣摩一下哈
共 2 条评论1 - burner2020-03-25关于文中EOF的含义不是特别理解,意思是说表示发送数据结束标记? 最近看请求公司内部服务的日志,发现客户端会由于收到EOF而请求服务失败,但是不是经常出现,也不是在业务高峰期出现,咨询下老师,这种情况意味着什么?
作者回复: EOF就是End OF File的缩写,是为了表明通信结束。 说明服务端试图关闭这个连接。
共 2 条评论1 - JJj2020-01-15第一个差别:close 会关闭连接,并释放所有连接对应的资源,而 shutdown 并不会释放掉套接字和所有的资源。--------------------请问下,shutdown两个方向也不会释放套接字资源吗?那什么时候释放的
作者回复: 分别调用shutdown两个方向,可以释放掉两个方向的套接字资源。
1 - lixin2022-08-20 来自广东请问一下,如果一端shutdown()关闭读,对端发送数据会有什么现象出现;一端关闭shutdown()关闭读,对端调用什么接口会感应到?
- 云会宾2022-07-18看了其他人的评论后,补充了下不懂的地方。对于老师画的图也能猜出意思了,不知道对不对?就是close的图:发送data1和data2到close每个都有ack这应该是TCP协议层面的吧?不是说只有read一下才ack。应该是客户端把data1和data2和close很快就发到服务端了,在服务端接受缓存里。服务端是睡5秒醒了去接受缓存里去取,取完一个就要去发送,这个就和老师讲的接上了。也就是那个图的客户端发送到服务端的数据发完了。这个时候可能服务器端才刚开始执行read代码,这图和代码是有时空差的。不知道是不是这样?展开