本文迁移并整理自个人之前在博客园的博客。
本文前半部分参考了《UNIX网络编程 卷1》一书,后半部分则摘录自一写得特好的博文浅谈TCP/IP网络编程中socket的行为。
对于内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数。当打开一个现有文件或创建一个新文件时,内核向进程返回一个文件描述符。当读或写一个文件时,使用 open 或 create 返回的文件描述符表示该文件,将其作为参数传给 read 或 write 函数。
read函数
read函数定义如下:
1 |
|
有几种情况可使实际读到的字节数少于要求读的字节数:
1)读普通文件时,在读到要求字节数之前就已经达到了文件末端。例如,若在到达文件末端之前还有 30 个字节,而要求读 100 个字节,则 read 返回 30,下一次再调用 read 时,它将返回 0(文件末端)。
2)当从终端设备读时,通常一次最多读一行。
3)当从网络读时,网络中的缓存机构可能造成返回值小于所要求读的字结束。
4)当从管道或 FIFO 读时,如若管道包含的字节少于所需的数量,那么 read 将只返回实际可用的字节数。
5)当从某些面向记录的设备(例如磁带)读时,一次最多返回一个记录。
6)当某一个信号造成中断,而已经读取了部分数据。
在《UNIX网络编程 卷1》中,作者将该函数进行了封装,以确保数据读取的完整,具体程序如下:
1 | ssize_t /* Read "n" bytes from a descriptor. */ |
write函数
write函数定义如下:
1 |
|
同样,为了保证写入数据的完整性,在《UNIX网络编程 卷1》中,作者将该函数进行了封装,具体程序如下:
1 | ssize_t /* Write "n" bytes to a descriptor. */ |
本文下半部分摘录自博文浅谈TCP/IP网络编程中socket的行为。
read/write的语义:为什么会阻塞?
先从write说起:
1 |
|
首先,write 成功返回,只是 buf 中的数据被复制到了 kernel 中的 TCP 发送缓冲区。至于数据什么时候被发往网络,什么时候被对方主机接收,什么时候被对方进程读取,系统调用层面不会给予任何保证和通知。
write 在什么情况下会阻塞?当 kernel 的该 socket 的发送缓冲区已满时。
对于每个 socket,拥有自己的 send buffer 和 receive buffer。从 Linux 2.6 开始,两个缓冲区大小都由系统来自动调节(autotuning),但一般在 default 和 max 之间浮动。
1 | # 获取 socket 的发送/接受缓冲区的大小:(后面的值是在 Linux 2.6.38 x86_64 上测试的结果) |
已经发送到网络的数据依然需要暂存在 send buffer 中,只有收到对方的 ack 后,kernel 才从 buffer 中清除这一部分数据,为后续发送数据腾出空间。接收端将收到的数据暂存在 receive buffer 中,自动进行确认。但如果 socket 所在的进程不及时将数据从 receive buffer 中取出,最终导致 receive buffer 填满,由于 TCP 的滑动窗口和拥塞控制,接收端会阻止发送端向其发送数据。这些控制皆发生在 TCP/IP 栈中,对应用程序是透明的,应用程序继续发送数据,最终导致 send buffer 填满,write 调用阻塞。
一般来说,由于接收端进程从 socket 读数据的速度跟不上发送端进程向 socket 写数据的速度,最终导致发送端 write 调用阻塞。
而 read 调用的行为相对容易理解,从 socket 的 receive buffer 中拷贝数据到应用程序的 buffer 中。read 调用阻塞,通常是发送端的数据没有到达。
blocking(默认)和 nonblock 模式下 read/write 行为的区别
将 socket fd 设置为 nonblock(非阻塞)是在服务器编程中常见的做法,采用 blocking IO 并为每一个 client 创建一个线程的模式开销巨大且可扩展性不佳(带来大量的切换开销),更为通用的做法是采用“线程池+Nonblock I/O+Multiplexing(select/poll,以及Linux上特有的epoll)”。
1 | // 设置一个文件描述符为 nonblock |
几个重要的结论:
read 总是在接收缓冲区有数据时立即返回,而不是等到给定的 read buffer 填满时返回。
只有当 receive buffer 为空时,blocking 模式才会等待,而 nonblock 模式下会立即返回 -1(errno = EAGAIN或EWOULDBLOCK)
blocking 的 write 只有在缓冲区足以放下整个 buffer 时才返回(与 blocking read 并不相同)
nonblock write 则是返回能够放下的字节数,之后调用则返回 -1(errno = EAGAIN或EWOULDBLOCK)
对于 blocking 的 write 有个特例:当 write 正阻塞等待时对面关闭了 socket,则 write 则会立即将剩余缓冲区填满并返回所写的字节数,再次调用则 write 失败(connection reset by peer),这正是下个小节要提到的:
read/write对连接异常的反馈行为
对应用程序来说,与另一进程的 TCP 通信其实是完全异步的过程:
我并不知道对面什么时候、能否收到我的数据
我不知道什么时候能够收到对面的数据
我不知道什么时候通信结束(主动退出或是异常退出、机器故障、网络故障等等)
对于 1 和 2,采用 write() -> read() -> write() -> read() ->… 的序列,通过 blocking read 或者“nonblock read+轮询”的方式,应用程序基于可以保证正确的处理流程。
对于 3,kernel 将这些事件的“通知”通过 read/write 的结果返回给应用层。
假设 A 机器上的一个进程 a 正在和 B 机器上的进程 b 通信:某一时刻 a 正阻塞在 socket 的 read 调用上(或者在 nonblock 下轮询 socket)
当 b 进程终止时,无论应用程序是否显式关闭了 socket(OS 会负责在进程结束时关闭所有的文件描述符,对于 socket,则会发送一个 FIN 包到对面)。
”同步通知“:进程 a 对已经收到 FIN 的 socket 调用 read,如果已经读完了 receive buffer 的剩余字节,则会返回 EOF:0
”异步通知“:如果进程 a 正阻塞在 read 调用上(前面已经提到,此时 receive buffer 一定为空,因为 read 在 receive buffer 有内容时就会返回),则 read 调用立即返回 EOF,进程 a 被唤醒。
socket 在收到 FIN 后,虽然调用 read 会返回 EOF,但**进程 a 依然可以调用 write,因为根据 TCP 协议,收到对方的 FIN 包只意味着对方不会再发送任何消息。 **在一个双方正常关闭的流程中,收到 FIN 包的一端将剩余数据发送给对面(通过一次或多次 write),然后关闭 socket。
但是事情远远没有想象中简单。优雅地(gracefully)关闭一个 TCP 连接,不仅仅需要双方的应用程序遵守约定,中间还不能出任何差错。
假如 b 进程是异常终止的,发送 FIN 包是 OS 代劳的,b 进程已经不复存在,当机器再次收到该 socket 的消息时,会回应 RST(因为拥有该 socket 的进程已经终止)。a 进程对收到 RST 的 socket 调用 write 时,操作系统会给 a 进程发送 SIGPIPE,默认处理动作是终止进程,知道你的进程为什么毫无征兆地死亡了吧:)
from 《Unix Network programming, vol1》 3rd Edition:
It is okay to write to a socket that has received a FIN, but it is an error to write to a socket that has received an RST.
通过以上的叙述,内核通过 socket 的 read/write 将双方的连接异常通知到应用层,虽然很不直观,似乎也够用。
这里说一句题外话:
不知道有没有同学会和我有一样的感慨:在写 TCP/IP 通信时,似乎没怎么考虑连接的终止或错误,只是在 read/write 错误返回时关闭 socket,程序似乎也能正常运行,但某些情况下总是会出奇怪的问题。想完美处理各种错误,却发现怎么也做不对。
原因之一是:socket(或者说 TCP/IP 栈本身)对错误的反馈能力是有限的。
考虑这样的错误情况:
不同于 b 进程退出(此时 OS 会负责为所有打开的 socket 发送 FIN 包),当 B 机器的** OS 崩溃(注意不同于人为关机,因为关机时所有进程的退出动作依然能够得到保证)/**主机断电/网络不可达时,a 进程根本不会收到 FIN 包作为连接终止的提示。
如果 a 进程阻塞在 read 上,那么结果只能是永远的等待。
如果 a 进程先 write 然后阻塞在 read,由于收不到 B 机器 TCP/IP 栈的 ack,TCP 会持续重传 12 次(时间跨度大约为 9 分钟),然后在阻塞的 read 调用上返回错误:ETIMEDOUT/EHOSTUNREACH/ENETUNREACH
假如 B 机器恰好在某个时候恢复和 A 机器的通路,并收到 a 某个重传的 pack,因为不能识别所以会返回一个 RST,此时 a 进程上阻塞的 read 调用会返回错误 ECONNREST
恩,socket 对这些错误还是有一定的反馈能力的,前提是在对面不可达时你依然做了一次 write 调用,而不是轮询或是阻塞在 read 上,那么总是会在重传的周期内检测出错误。如果没有那次 write 调用,应用层永远不会收到连接错误的通知。
write 的错误最终通过 read 来通知应用层,有点阴差阳错?
还需要做什么?
至此,我们知道了仅仅通过 read/write 来检测异常情况是不靠谱的,还需要一些额外的工作:
1. 使用 TCP 的 KEEPALIVE 功能?
1 | cat /proc/sys/net/ipv4/tcp_keepalive_time |
以上参数的大致意思是:keepalive routine 每 2 小时(7200 秒)启动一次,发送第一个 probe(探测包),如果在 75 秒内没有收到对方应答则重发 probe,当连续 9 个 probe 没有被应答时,认为连接已断。(此时 read 调用应该能够返回错误,待测试)
但在我印象中 keepalive 不太好用,默认的时间间隔太长,又是整个 TCP/IP 栈的全局参数:修改会影响其他进程,Linux 的下似乎可以修改 per socket 的 keepalive 参数?(希望有使用经验的人能够指点一下),但是这些方法不是 portable 的。
2. 进行应用层的心跳
严格的网络程序中,应用层的心跳协议是必不可少的。虽然比 TCP 自带的 keep alive 要麻烦不少,但有其最大的优点:可控。
当然,也可以简单一点,针对连接做 timeout,关闭一段时间没有通信的”空闲“连接。这里可以参考一篇文章:
Muduo 网络编程示例之八:Timing wheel 踢掉空闲连接 by 陈硕