type
status
date
slug
summary
tags
category
icon
password
 
上一章,我们介绍了服务器端和客户端的建立连接的主要流程。这一章,我们来讲一下,服务器端和客户端如何通过已创建的套接字(通信端点)通信,以及如何正确地断开连接。

1. send()

顾名思义,send()是用来发送数据的。
参数
sockfd: 要发送数据的套接字。
buf: 缓冲区,用来存放要发送的数据。
len: 要发送数据的字节长度。
flags: 控制位。
💬
我们也可以用常规的read()write()来进行网络通信,但send()recv()可以提供更强大的控制,这种控制能力是通过设置 flags 的值来完成的。如果 flags 的值为 0,那么send()write()没有任何区别,i.e.: send(sockfd, buf, len, 0)等价于write(sockfd, buf, len)
注意,send()实际发送的数据可能比len要少。内核协议栈会竭尽所能发送所有的数据,但如果实在发送不了那么多,它就会能发多少是多少,并且相信程序员能够正确处理未发送的数据。
💬
好消息是:如果数据包比较小(比如小于 1KB),那么内核协议栈很可能一次性就能够发送完。

2. recv()

recv()和send()很像,它是用来接收数据的。
参数
sockfd: 要接收数据的套接字。
buf: 缓冲区,用来存放要接收的数据。
len: 缓冲区的长度。
flags: 控制位。
💬
如果 flags 的值为 0,那么recv()read()没有任何区别,i.e.: recv(sockfd, buf, len, 0)等价于read(sockfd, buf, len)
Okok,我们现在已经学会了建立连接和收发数据了。接下来就该学习如何断开连接了。不过,在学习如何断开连接之前,我们先来看以下 TCP 的四次挥手过程。

3. TCP 的四次挥手

TCP 四次挥手的流程如下(注意:服务器端也可以主动关闭连接):
notion image
  1. 主机 1(客户端)发送 FIN 报文,进入FIN_WAIT_1状态。
  1. 主机 2(服务器端)接收到 FIN 报文,进入CLOSE_WAIT状态,并发送一个 ACK 应答。同时,主机 2 的recv调用将返回 0,表示 EOF。
  1. 主机 1 接收到 ACK 应答,进入FIN_WAIT_2状态,等待主机 2 发送 FIN 报文。
  1. 当主机 2 这边数据传送完毕后,主机 2 将发送 FIN 报文。主机 2 将进入LAST_ACK状态。
  1. 主机 1 接收到主机 2 发送的 FIN 报文,发送 ACK 应答。主机 1 进入TIME_WAIT状态,等待 2MSL 时间后,主机 1 进入CLOSED状态。
  1. 主机 2 接收到 ACK 应答后,立即进入CLOSED状态。
TCP 的四次挥手有两个需要特别注意的事项,都是有关主动发起关闭连接一端的:
  • 当主机处于FIN_WAIT_1FIN_WAIT_2状态时,主机是可以接收对端发送的数据的,这就是所谓的“半连接状态”。
  • 主动发起关闭连接的一端会有一个TIME_WAIT状态,持续的时间为 2MSL(maximum segment lifetime)。在 Linux 中,这个值是固定的,其值为 60 秒;也就是说,在 Linux 系统中,TIME_WAIT持续的时间为 60 秒。
TIME_WAIT的作用 Q: 好奇的你肯定有很多疑问,为什么不直接进入CLOSED状态,而非要停留在TIME_WAIT状态呢? A: 原因主要有两个方面: 1) 为了容错。假如主机 1 最后的 ACK 报文没有传输成功,那么主机 2 就会重新发送 FIN 报文。如果主机 1 没有TIME_WAIT状态,而是直接进入CLOSED状态,那么主机 1 将回复一个 RST 操作,从而导致主机 2 出现错误(引发SIGPIPE信号)。相反,如果有TIME_WAIT状态,主机 1 在TIME_WAIT状态收到 FIN 报文之后,会重新发送一个 ACK 报文,从而使主机 2 进入正常的CLOSED状态。需要特别注意的是,在TIME_WAIT状态每接收到一个 FIN 报文,发送一个 ACK 报文,2MSL 是需要重新计时的。 为什么需要重新计时呢?以及为什么TIME_WAIT的持续时间为 2MSL 呢?这就和我们第二个原因有关了。 2) 为了让旧连接的迷走报文在网络中自然消失。 首先,我们解释一下什么是旧连接。在通信的一端,我们是用四元组 (源 IP,源端口,目的地 IP,目的地端口) 来唯一标识一个 TCP 连接的。现在,我们来考虑这样一个场景:原连接断开后,重新创建了一个新连接,这个新连接的四元组和原连接一模一样。那么原连接,我们称之为“旧连接”;新连接,我们称之为旧连接的“化身”。 其次,我们再解释一下什么是迷走报文。网络是很复杂的,所以有些报文会“迷路”,也就是在网络中瞎逛,这些迷路的报文就是迷走报文。为了避免迷走报文造成网络拥挤和阻塞,我们会限制迷走报文的最长存活时间,也就是 2MSL。 所以,TIME_WAIT的持续时间设计为 2MSL 是有原因的。假设,我们只设置为 1MSL。那么,当旧连接关闭,创建的新连接恰好是旧连接的“化身”。那么在开始的一段时间内,新连接接收到的报文可能会是旧连接的迷走报文。

4. close()

首先,我们来看一下close()这个函数:
它的作用是关闭文件描述符fd。什么意思呢?
如果文件描述符fd被关闭了的话,那么fd就不再指向一个打开文件,该进程不能够再去读写fd,并且fd可以被进程重新利用。
close(fd)会导致fd关联的打开文件的引用计数 -1。当引用计数为 0 的时候,内核就会释放该打开文件;具体到SOCK_STREAM套接字文件,就是释放该套接字文件,并且关闭 TCP 两个方向的数据流
  • 在输入方向,系统内核会将该套接字设置为不可读,任何读操作都会返回异常。
  • 在输出方向,系统内核会尝试将发送缓冲区中的数据发送给对端,并最后发送一个 FIN 报文。之后,如果再对该套接字进行写操作,则会返回异常。
  • 对端如果继续发送报文,则会收到一个 RST 报文。意思是说:”Buddy,我已经关闭了,别再给我发送数据了“。
close()会关闭两个方向的数据流,这在实际应用中是不太好用的。我们经常需要先关闭 TCP 连接的一个方向,此时另一个方向还是可以传输数据的。这个状态我们称之为”半连接状态“。
举个例子,客户端主动断开连接,将自己到服务器端方向的数据流关闭。此时,客户端不再往服务器端写数据,但这并不意味着客户端不需要读服务器端的数据。很有可能,服务器端正在处理客户端的最后一个请求,比如,正在访问数据库,或者进行某个复杂的计算。当完成这些操作后,服务器还需要把结果发送给客户端。最后,当所有请求处理完毕后,服务器端再关闭剩下的半个连接,结束这个 TCP 连接的一生。
那如何让连接处于”半连接状态“呢?有请shutdown()登场!
(观众欢呼雷动!)

5. shutdown()

shutdown()可以关闭全双工连接的某一端,或者两端。
参数
sockfd: 套接字文件描述符
how: 关闭方式。
  • SHUT_RD: 关闭读端,对该套接字进行读操作将返回EOF。套接字接收缓冲区上的数据将被丢弃;如果有新的数据到达,会对数据进行 ACK,然后悄悄丢弃。也就是说,对端还是会收到 ACK,压根儿不知道数据已经被丢弃。
  • SHUT_WR: 关闭写端,对该套接字进行写操作会报错,这就是常说的”半连接状态”。套接字发送缓冲区中的数据会被立即发送出去,最后还会发送一个 FIN 报文。
  • SHUT_RDWR: 既关闭读端,也关闭写端,相当于SHUT_RDSHUT_WR各执行了一次。
📢
close(sockfd)shutdown(sockfd, SHUT_RDWR)的区别 1. 当引用计数为 0 的时候,close()会释放套接字文件;而shutdown()不会。 2. 如果close()之后,套接字的引用计数不为 0,那么该套接字还是可以被其它进程使用的;而shutdown()不 care 引用计数,直接使得该套接字不可用,其它进程将受到影响。 3. 由于引用计数的存在,close()不一定会发送 FIN 报文;而shutdown()只要关闭写端,就一定会发送 FIN 报文,这在我们打算关闭连接通知对端的时候,非常重要。

6. 经典例题——echo服务器

接下来,请打起精神!我们要用所学过的知识来构建一个经典的服务器—— echo 服务器。
它的流程是这样的:
notion image
  1. 客户端从标准输入读取一行消息,然后将读取的消息发送给服务器端。
  1. 服务器端接收客户端发送过来的消息,并回显给客户端。
  1. 客户端接收服务器端回显的消息,并将它输出到标准输出。
echo 服务器虽然比较简单,但是它五脏俱全。并且服务器端采用了最传统的网络编程模型,阻塞 I/O + 多进程。每有一个连接,服务器端就会 fork 一个子进程来独立服务这个连接。因为连接上所有的 I/O 都由这个子进程负责处理,所以即便是阻塞 I/O,各个连接直接也不会相互影响。
阻塞 I/O + 多进程的方式虽然简单,但效率不高,扩展性差,资源的占用率高(进程比较耗资源)。但对 echo 这样简单的服务器,是够用的了。阻塞 I/O + 多进程方式的好处是,隔离性强,一条连接上的错误不会导致这个服务器崩溃(进程是隔离的)。
💬
echo 服务器的变种 echo 服务器是典型的一问一答的方式。只要响应的内容不和请求的内容一样,我们就可以有各种各样的变种。不过这会涉及请求的解析,请求的处理以及响应的生成。HTTP 服务器就是一个典型 echo 服务器的变种。

归纳总结

这一章,我们主要讲了以下内容:
  • 如何接收和发送数据:recv()send()
  • 如何关闭连接:close()shutdown()。重点是理解半连接状态,以及close()shutdown()的区别。
  • 手写了一个经典的案例——echo 服务器。大家一定要熟练掌握这个案例,好好体会 TCP 编程的流程,以及阻塞 I/O + 多进程的模型。
最后,我们以一张 TCP 编程流程图结束这一章。
notion image

参考文献

  • TCP/IP Illustrated, Volume 1: The Protocols
  • UNIX Network Programming, Volume 1: The Sockets Networking API, 3rd Edition
 
💡
欢迎您在底部评论区留言,我们一起交流学习~
高性能网络编程(5)——UDP 编程高性能网络编程(3)——TCP编程(一)