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 四次挥手的流程如下(注意:服务器端也可以主动关闭连接):

- 主机 1(客户端)发送 FIN 报文,进入
FIN_WAIT_1
状态。
- 主机 2(服务器端)接收到 FIN 报文,进入
CLOSE_WAIT
状态,并发送一个 ACK 应答。同时,主机 2 的recv调用将返回 0,表示 EOF。
- 主机 1 接收到 ACK 应答,进入
FIN_WAIT_2
状态,等待主机 2 发送 FIN 报文。
- 当主机 2 这边数据传送完毕后,主机 2 将发送 FIN 报文。主机 2 将进入
LAST_ACK
状态。
- 主机 1 接收到主机 2 发送的 FIN 报文,发送 ACK 应答。主机 1 进入
TIME_WAIT
状态,等待 2MSL 时间后,主机 1 进入CLOSED
状态。
- 主机 2 接收到 ACK 应答后,立即进入
CLOSED
状态。
TCP 的四次挥手有两个需要特别注意的事项,都是有关主动发起关闭连接一端的:
- 当主机处于
FIN_WAIT_1
和FIN_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_RD
和SHUT_WR
各执行了一次。
close(sockfd)
和shutdown(sockfd, SHUT_RDWR)
的区别
1. 当引用计数为 0 的时候,close()
会释放套接字文件;而shutdown()
不会。
2. 如果close()
之后,套接字的引用计数不为 0,那么该套接字还是可以被其它进程使用的;而shutdown()
不 care 引用计数,直接使得该套接字不可用,其它进程将受到影响。
3. 由于引用计数的存在,close()
不一定会发送 FIN 报文;而shutdown()
只要关闭写端,就一定会发送 FIN 报文,这在我们打算关闭连接通知对端的时候,非常重要。6. 经典例题——echo服务器
接下来,请打起精神!我们要用所学过的知识来构建一个经典的服务器—— echo 服务器。
它的流程是这样的:

- 客户端从标准输入读取一行消息,然后将读取的消息发送给服务器端。
- 服务器端接收客户端发送过来的消息,并回显给客户端。
- 客户端接收服务器端回显的消息,并将它输出到标准输出。
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 编程流程图结束这一章。

参考文献
- TCP/IP Illustrated, Volume 1: The Protocols
- UNIX Network Programming, Volume 1: The Sockets Networking API, 3rd Edition
欢迎您在底部评论区留言,我们一起交流学习~
- 作者:Thomas He
- 链接:https://notion-next-lovat-ten.vercel.app/article/networkprogramming/4
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章