深刻理解socket编程中的read&&write

前记

本文内容基本复制粘贴来自互联网博客,由于实在写的太好,所以特此再总结下。先放个图,下图来自以下链接: cnblogs.com/junneyang/p,在这里感谢博主总结的这个图!

该图基本总结了wirite和read的各种情况,下面再详细进行说明吧!

以下内容很多摘自

cnblogs.com/promise6522

read/write 回溯

首先,write成功返回,只是buf中的数据被复制到了kernel中的TCP发送缓冲区。至于数据什么时候被发往网络,什么时候被对方主机接收,什么时候被对方进程读取,系统调用层面不会给予任何保证和通知。其实这里面涉及传输层以下的协议了,之前我一直以为只要write进去就是发过去了,其实这个是错误的认知,发不发是系统决定的。涉及到发送有个算法决定,Nagle算法是被TCP默认开启的,这里涉及一个字段选项设置,TCP_NODELAY,启用这个字段就把Nalge算法禁止掉了。如果读者有兴趣可以自行百度这两个关键字。write在什么情况下会阻塞?当kernel的该socket的发送缓冲区已满时。对于每个socket,拥有自己的send buffer和receive buffer。从Linux 2.6开始,两个缓冲区大小都由系统来自动调节(autotuning),但一般在default和max之间浮动。

# 获取socket的发送/接受缓冲区的大小:(后面的值是在我在Linux 2.6.38 x86_64上测试的结果)
sysctl net.core.wmem_default       #126976
sysctl net.core.wmem_max        #131071
sysctl net.core.wmem_default       #126976
sysctl net.core.wmem_max           #131071

已经发送到网络的数据依然需要暂存在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行为的区别:

很多情况可以设置非阻塞,对于高并发或者应用要求特定情况下一般是将read会设置为超时非阻塞,当然也可以直接设置该套接字为非阻塞:

// 设置一个文件描述符为nonblock
int set_nonblocking(int fd)
{
    int flags;
    if ((flags = fcntl(fd, F_GETFL, 0)) == -1)
        flags = 0;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

read总是在接收缓冲区有数据时立即返回,而不是等到给定的read buffer填满时返回。返回值就是读取的字节数,同时内核receivebuffer空间会增加相应的大小。只有当receive buffer为空时,blocking模式才会等待,而nonblock模式下会立即返回-1(errno = EAGAIN或EWOULDBLOCK)

阻塞与非阻塞write返回值没有区分,都是 <0:出错,=0:连接关闭,>0发送数据大小,特别:返回值 <0时并且(errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN)的情况下认为连接是正常的,继续发送。只是阻塞模式下write会阻塞着发送数据,非阻塞模式下如果暂时无法发送数据会返回,不会阻塞着 write,因此需要循环发送。

read/write对连接异常的反馈行为:

对应用程序来说,与另一进程的TCP通信其实是完全异步的过程:

  1. 我并不知道对面什么时候、能否收到我的数据
  2. 我不知道什么时候能够收到对面的数据
  3. 我不知道什么时候通信结束(主动退出或是异常退出、机器故障、网络故障等等)

对于1和2,采用write() -> read() -> write() -> read() ->...的序列,通过blocking read或者nonblock read+轮询的方式,应用程序基于可以保证正确的处理流程。而且对于3,kernel将这些事件的“通知”通过read/write的结果返回给应用层。这个也是TCP通信的难点。

假设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调用,应用层永远不会收到连接错误的通知。

发布于 2019-07-02 01:15