【Networkk】一篇文章完全搞清楚 scoket read/write 返回码、阻塞与非阻塞、异常处理 等让你头疼已久的问题

浅谈TCP/IP网络编程中socket的行为

我认为,想要熟练掌握Linux下的TCP/IP网络编程,至少有三个层面的知识需要熟悉:

1. TCP/IP协议(如连接的建立和终止、重传和确认、滑动窗口和拥塞控制等等)

2. Socket I/O系统调用(重点如read/write),这是TCP/IP协议在应用层表现出来的行为。

3. 编写Performant, Scalable的服务器程序。包括多线程、IO Multiplexing、非阻塞、异步等各种技术。

关于TCP/IP协议,建议参考Richard Stevens的《TCP/IP Illustrated,vol1》(TCP/IP详解卷1)。

关于第二层面,依然建议Richard Stevens的《Unix network proggramming,vol1》(Unix网络编程卷1),这两本书公认是Unix网络编程的圣经。

至于第三个层面,UNP的书中有所提及,也有著名的C10K问题,业界也有各种各样的框架和解决方案,本人才疏学浅,在这里就不一一敷述。

本文的重点在于第二个层面,主要总结一下Linux下TCP/IP网络编程中的read/write系统调用的行为,知识来源于自己网络编程的粗浅经验和对《Unix网络编程卷1》相关章节的总结。由于本人接触Linux下网络编程时间不长,错误和疏漏再所难免,望看官不吝赐教。

一. read/write的语义:为什么会阻塞?

先从write说起:

#include <unistd.h>ssize_t write(int fd, const void *buf, size_t count);

首先,write成功返回,只是buf中的数据被复制到了kernel中的TCP发送缓冲区。至于数据什么时候被发往网络,什么时候被对方主机接收,什么时候被对方进程读取,系统调用层面不会给予任何保证和通知。

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       #126976sysctl net.core.wmem_max        #131071sysctl net.core.wmem_default       #126976sysctl 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行为的区别:

将socket fd设置为nonblock(非阻塞)是在服务器编程中常见的做法,采用blocking IO并为每一个client创建一个线程的模式开销巨大且可扩展性不佳(带来大量的切换开销),更为通用的做法是采用线程池+Nonblock I/O+Multiplexing(select/poll,以及Linux上特有的epoll)。


1

2

3

4

5

6

7

8

// 设置一个文件描述符为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);

}

几个重要的结论:

1. read总是在接收缓冲区有数据时立即返回,而不是等到给定的read buffer填满时返回。

只有当receive buffer为空时,blocking模式才会等待,而nonblock模式下会立即返回-1(errno = EAGAIN或EWOULDBLOCK)

2. 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. 我不知道什么时候能够收到对面的数据

3. 我不知道什么时候通信结束(主动退出或是异常退出、机器故障、网络故障等等)

对于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功能?

cat /proc/sys/net/ipv4/tcp_keepalive_time7200

cat /proc/sys/net/ipv4/tcp_keepalive_intvl75

cat /proc/sys/net/ipv4/tcp_keepalive_probes9

以上参数的大致意思是: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 陈硕

参考资料:

《TCP/IP Illustrated, vol 1》 by Richard Stevens

《Unix Network Programming, vol 1》(3rd Edition) by Richard Stevens

Linux TCP tuning

Using TCP keepalive under Linux

(墙裂推荐)参考资料:

http://blog.163.com/zhang0j_21/blog/static/1941154252014116101843410/

http://www.cnblogs.com/promise6522/archive/2012/03/03/2377935.html

http://www.oschina.net/translate/tcp-keepalive-with-golang

https://www.zhihu.com/question/22925358

https://github.com/3workman/GoServer

https://my.oschina.net/yunfound/blog/141222

http://www.01happy.com/golang-tcp-socket-adhere/

时间: 2024-12-21 07:21:55

【Networkk】一篇文章完全搞清楚 scoket read/write 返回码、阻塞与非阻塞、异常处理 等让你头疼已久的问题的相关文章

一篇文章彻底搞懂es6 Promise

前言 Promise,用于解决回调地狱带来的问题,将异步操作以同步的操作编程表达出来,避免了层层嵌套的回调函数. 既然是用来解决回调地狱的问题,那首先来看下什么是回调地狱 var sayhello = function(callback){ setTimeout(function(){ console.log("hello"); return callback(null); },1000); } sayhello(function(err){ console.log("xia

一篇文章彻底搞懂Java虚拟机

概念 虚拟机:指以软件的方式模拟具有完整硬件系统功能.运行在一个完全隔离环境中的完整计算机系统 ,是物理机的软件实现.常用的虚拟机有VMWare,Visual Box,Java Virtual Machine(Java虚拟机,简称JVM). Java虚拟机阵营:Sun HotSpot VM.BEA JRockit VM.IBM J9 VM.Azul VM.Apache Harmony.Google Dalvik VM.Microsoft JVM- 启动流程 基本架构 Java运行时编译源码(.j

一篇文章彻底搞懂snowflake算法及百度美团的最佳实践

写在前面的话 一提到分布式ID自动生成方案,大家肯定都非常熟悉,并且立即能说出自家拿手的几种方案,确实,ID作为系统数据的重要标识,重要性不言而喻,而各种方案也是历经多代优化,请允许我用这个视角对分布式ID自动生成方案进行分类: 实现方式 完全依赖数据源方式 ID的生成规则,读取控制完全由数据源控制,常见的如数据库的自增长ID,序列号等,或Redis的INCR/INCRBY原子操作产生顺序号等. 半依赖数据源方式 ID的生成规则,有部分生成因子需要由数据源(或配置信息)控制,如snowflake

海明码一篇文章彻底搞懂

海明码学习前提 记住几个要点: 不要用异或套用公式!!!!题目随便变几个变死你! 看完这篇博客不要看别的博客!!!!别的人瞎写的坑死你 学习海明码之前,我们要约定3个原则: 海明码只能检测出2位错,纠1位错(因此不要问如果3位错怎么办等幼稚问题). 海明码默认进行偶校验(除非特殊说明使用奇校验). 海明码是一串由0和1组成的序列(除01外没有其他的值,记住了!这是重点) 如果下面有任何无法理解的问题,反复看上面三个原则,下面再也不赘述. 前提:奇偶校验 奇校验:这串序列1的个数如果为偶数则在前面

那些年搞不懂的多线程、同步异步及阻塞和非阻塞(一)---多线程简介

1.进程和线程的概念 进程:运行中的应用程序称为进程,拥有系统资源(cpu.内存) 线程:进程中的一段代码,一个进程中可以有多段代码.本身不拥有资源(共享所在进程的资源): 在java中,程序入口被自动创建为主线程,在主线程中可以创建多个子线程. 区别: 1.是否占有资源问题 2.创建或撤销一个进程所需要的开销比创建或撤销一个线程所需要的开销大. 3.进程为重量级组件,线程为轻量级组件 多进程: 在操作系统中能同时运行多个任务(程序) 多线程: 在同一应用程序中有多个功能流同时执行 已经有了进程

第11章非阻塞I/O

第11章非阻塞I/O 一个实例客户端 在实现新I/O的客户端时,调用静态工厂方法SocketChannel.open()来创建一个新的java.nio.channels.SocketChannel对象.这个方法的参数是一个java.net.SocketAddress对象,指示要连接的主机和端口. 例如:下面的代码段连接指向rama.poly.edu端口19的通道: SocketAddress rama  = new InetSocketAddress("rama.poly.edu",1

一篇文章搞定Markdown

一篇文章搞定Markdown 原文地址:http://blog.51cto.com/10984621/2113527

一篇文章搞懂python2、3编码

说在前边: 编码问题一直困扰着每一个程序员的编程之路,如果不将它彻底搞清楚,那么你的的这条路一定会走的格外艰辛,尤其是针对使用python的程序员来说,这一问题更加显著, 因为python有两个版本,这两个版本编码格式却完全不同,但我们却经常需要兼顾这两个版本,所以出现各种问题的几率就大了很多. 所以在这里我试图用一篇文章来彻底梳理整个python语言的编码问题,尽量降低以后在这方面举到问题的可能性. ps 此文一定程度上参考和引用了alex的博客:“https://www.cnblogs.co

百度百家号“漏洞”大曝光 一篇文章十几万阅读量 收入上万

越来越发现自媒体的好处,就是现在太多的平台都提供入口,基本是四大门户还有三大巨头互联网公司,百度.腾讯 .阿里都提供!那么接下来我要分享的是中国最大的搜索引擎公司-百度!,相信最近很多人都知道百度百家号,并且是百度大力的推广. 我看到之后毫不犹豫的,就是注册一个账号,这个等审核的过程真是酸甜苦辣一样的煎熬,不过还好.终于审核通过!一开始的话!发布很多文章都没有什么阅读量,都没有什么访问量!经过一番死磕研究之后发现,找文章的素材是最关键的地方,素材找的好,那么阅读量就会上来:再说了,我也不是那么好