总览

  最近发现了一本不错的书,叫《Linux高性能服务器编程》,我从豆瓣阅读上买了一本电子书,大体初略的看了一遍,从TCP/IP协议,讲到socket基础,再到服务器网络编程的几种模式,最后聊到了libevent (一种高性能的服务端网络编程框架,c语言实现),等同于Java中的netty,我打算以这本书为核心,再次学习一遍网络知识,TCP的核心内容需要学习《TCP/IP详解-协议》一书中的TCP相关章节, 可以结合《图解TCP_IP》一书(非常浅显),从常规的三次握手,到复杂一点的拥塞控制,再到socket内核相关的参数,结合着陶辉(就是写Nginx书籍的)的博客,结合收藏过的所有博客文章(包括刘超的趣谈网络协议 中的TCP部分),必须彻底搞懂。

  上面的内容结束以后,自然的过度到epoll、netty,争取记下来netty的相关知识点,最好是能够对照libevent相关的文章,学习一下其源码,最高的要求是,学习陈硕的那本muduo库的网络编程书籍。

一、TCP/IP协议族

  TCP/IP协议族是一个四层的协议系统,分别是数据链路层、网络层、传输层和应用层,也有一种说法是七层(还有物理层、会话层和表示层)。这里我们通常按照 下面四层协议来解读

1、数据链路层

  数据链路层实现的是网卡接口的网络驱动程序,处理数据在物理媒介(比如以太网、令牌环等)上的传输。这一层最常用的协议是ARP(Address Resolve Protocol ,地址解析协议)和RARP(Reverse Address Resolve Protocol, 逆地址解析协议),它们实现的是IP地址和机器物理地址(通常是MAC地址)之间的互相转换。

  网络层使用IP地址寻址一台机器,数据链路层使用物理地址寻址一台机器,因此网络层在将数据包传递给数据链路层的时候,需要利用ARP协议将目标机器的IP地址转换 为物理地址。

2、网络层

  网络上通信的两台主机一般都不是直接相连的,而是通过多个中间节点(路由器)连接的,网络层就是找到这些中间的节点,以确定两台主机的通信路径。同时网络层对 上层协议隐藏了这些网络拓扑细则,使得传输层和更上层的应用层看来,通信的双方是直接相连的。

  网络层最核心的协议是IP协议(Internet Protocol),IP协议根据数据包的目的IP地址来决定如何投递这个数据包,如果不能直接发送给目的主机,就利用某些 算法策略为其寻找一个合适的下一跳路由器,将数据包交给该路由器来转发,重复这个过程直到数据包到达目的主机或者由于发送失败被丢弃。

  网络层还有一个ICMP协议(Internet Control Message Protocol),主要用于网络检测,这里有一个比较有意思的点是,应用层直接ping某个IP地址时,是 直接使用网络层的ICMP协议,这里中间是不经过传输层的,也就是没有TCP/UDP的参与。

3、传输层

  传输层为两台主机的应用程序提供端到端的通信,网络层是使用的逐跳通信方式,传输层只关注通信的起始端和终点端,并不在乎数据包的中间过程。这里其实又是一个 封装的思想。

  传输层的协议主要有三个:TCP(Transmission Control Protocol,传输控制协议)、UDP(User Datagram Protocol, 用户数据报协议)、SCTP( Stream Control Transmission Protocol, 流控制传输协议),其中SCTP是一种比较新的传输层协议,是为了在因特网上传输电话信号而设计的,可以先忽略。

  • TCP:TCP为应用层提供可靠的、面向连接的、基于流的服务。TCP使用数据确认和超时重传机制来保证数据包正确的发送到接收端,这是可靠;使用TCP协议进行通 信的双方必须先建立TCP连接,并且在内核中为这个连接维护一些数据结构(连接的状态,读写缓冲区,一些定时器等),通信结束时,双方必须关闭连接以释放这些内核数据 结构,这是面向连接;基于流的数据没有边界限制,它源源不断的从一端流向另一端,发送端可以逐个字节的向数据流中写入数据,接收端也可以逐个字节的从流中读取数据。

  • UDP:UDP提供的服务不可靠、无连接,面向数据报。不可靠意味着UDP协议并不保证数据从发送端正确的传输到目的端,如果数据中途丢失,UDP协议只是简单的 通知上层应用程序发送失败,因此使用UDP协议的应用程序往往需要自己处理数据确认、超时重传等逻辑。UDP协议通信的双方并不需要保持一个连接,因此应用程序每次发送 的时候都需要明确指定接收端的地址;每个UDP数据报都有一个长度,接收端必须以该长度为最小单位将其内容一次读取出。

4、应用层

  应用层负责处理应用程序的逻辑,在用户空间实现(其实也可以在内核空间);数据链路层、网络层和传输层负责处理网络通信细则,必须高效稳定,因此在内核空间实现。 常用的应用层协议包括:HTTP、DNS、telnet、OSPF。

  应用层协议通常使用传输层协议提供的服务,比如DNS就使用的UDP,HTTP常使用TCP,也可以跳过传输层,直接使用网络层提供的服务,比如ping程序和OSPF协议。

封装

  每层协议都将在上层数据的基础上加上自己的头部信息(有时还包括尾部信息,以太网就还需要加尾部),以实现该层的功能,这个过程就称为封装

  经过TCP封装后的数据就称为TCP报文段,因为TCP协议为通信的双方在内核维护了一个连接,并在内核存储了一些当前这个连接相关的数据;这部分数据中的TCP头部信息和TCP内核缓冲区(发送缓冲区或者接收缓冲区) 的数据一起构成了一个TCP报文段。发送端应用程序调用send(或者write)函数向一个TCP连接写入数据时,内核中的TCP模块首先把数据复制到该连接对应的TCP内核发送缓冲区中,然后TCP模块调用IP模块提供的服务,TCP 报文段就是传递的参数。

  UDP数据报不同之处在于,当一个UDP数据报成功发送以后,UDP内核缓冲区中的该数据就被丢弃了(没有ACK,这就是不可靠)。如果应用程序检测到接收端没有接收到,并且打算重新发送,那么应用程序就需要重新从用户空间将 该数据拷贝到内核缓冲区中。

  IP数据报分为头部和数据两部分,数据部分就是一个TCP/UDP/ICMP报文段。

  经过数据链路层封装后的数据称为帧(frame)。传输媒介不同则帧的类型也不一样,以太网上传输的是以太网帧,令牌环网上传输的是令牌环帧。以太网帧在IP数据报的基础上,前面增加6个字节的目的物理地址和6字节的源物理地址, 来表示通信的双方,再增加2字节的类型字段,然后是中间的IP数据报(46-1500字节),尾部增加4字节的CRC字段,提供循环冗余校验。

  帧的最大传输单元(Max Transmit Unit, MTU),表示帧最多能够携带多少的上层协议数据(比如IP数据报),通常受到网络类型的限制,比如以太网帧的MTU是1500字节,也就是说,过长的IP数据报可能需要被分片(fragment)传输。

二、IP协议

  IP协议提供的是无状态、无连接、不可靠的服务。无状态指的是所有IP数据报的发送、传输和接受都是相互独立的,并没有上下文关系(这一点有点类似HTTP和UDP),这样导致的一个问题就是数据报可能是乱序的,也可能有重复,先发送的 数据报不一定就先到达接收端,接收端也无法检测到乱序和重复,只要收到了完整的IP数据报(如果是IP分片,IP模块会先执行重组),就将其数据部分(也就是一个TCP、UDP报文)交付给上层协议,比如TCP就会处理乱序重复问题。

IP头部

  IP数据报的头部提供了一个字段来唯一标识一个IP数据报,但它是用于IP分片和重组的,并不指示IP数据报的接受顺序。无连接指的是IP通信的双方并不记录对方的任何信息。不可靠指的是IP协议并不保证IP数据报能够准确到达接收端, 它只是承诺best effort。发送端的IP模块一旦检测到发送失败,就通知上层协议,不会自己重传,使用IP协议的上层协议(比如TCP),自己实现重传逻辑。

  1、IPV4的数据报头部长度通常是20字节(固定部分),也可以包含最大长度为40字节的选项部分(可选部分),因此IP头部最长为60字节。

  2、整个IP数据报的总长度最多为65535字节,但是由于MTU的限制,长度超过MTU的IP数据报都会被分片传输,因此实际传输的IP数据报(单个分片)的长度远远达不到65535的限制。

  3、IP数据报头部还有一个16位的标识字段,唯一标识每一个IP数据报,这个值在分片的时候会复制到每一个IP分片中,因此属于同一个IP数据报的所有分片都有同一个标识(id)。

  4、8位生存时间(Time To Live,TTL)是数据报到达目的地之前允许经过的路由器跳数。TTL值被发送端设置(常见的值是64)。数据报在转发过程中每经过一个路由,该值就被路由器减1。当TTL值减为0时,路由器将丢弃数据报, 并向源端发送一个ICMP差错报文。TTL值可以防止数据报陷入路由循环。

  5、8位协议(protocol)用来区分上层协议,也就是区分IP数据报的数据部分,是TCP报文还是UDP报文等。

  6、16位头部校验和(header checksum)由发送端填充,接收端对其使用CRC算法以检验IP数据报头部(注意,仅检验头部)在传输过程中是否损坏。

  7、32位的源端IP地址和目的端IP地址用来标识数据报的发送端和接收端。一般情况下,这两个地址在整个数据报的传递过程中保持不变,而不论它中间经过多少个中转路由器。

IP分片

  当IP数据报大小超过MTU时,IP数据报会被分片,分片可能发生在发送端,也可能发生在中间的路由器上,甚至也可能多次分片;但是只有在最终的目的机器上,这些分片才会被重组,合成为一个完整的IP数据报。

  一个IP数据报的每一个分片都具有自己的IP头部,它们拥有相同的标识(id),但是各自的片偏移(13位)不一样,并且除了最后一个分片以外,前面的每个分片都将设置MF(More Fragment)标志,每个分片的IP数据报总长度字段 被设置为各自分片的长度。如果按照以太网的MTU为1500字节计算,那么IP数据报的数据部分(也就是TCP报文段)的大小最多为1480字节。

IP路由和转发

  这一部分设计到路由表,以及各类转发策略,路由表的更新维护等,并不作为重点学习。

三、TCP协议详解

  TCP面向字节流而UDP面向数据报,这一点需要明白,TCP发送的时候也是以一个个的TCP报文发送出去的,那么这两种的区别对应在实际编程中,则表现为通信的双方是否需要执行相同次数的读写操作。当发送端应用程序连续执行多次写操作 时,TCP模块先将这些数据放入TCP发送缓冲区中。当TCP模块真正开始发送数据时,发送缓冲区中这些等待发送的数据可能被封装成一个或多个TCP报文段发出。因此,TCP模块发送出的TCP报文段的个数和应用程序执行的写操作次数之间没有固定的 数量关系。

  当接收端接收到一个或者多个TCP报文段的时候,TCP模块将这些报文段所携带的应用程序数据按照TCP报文段的序号(位于TCP首部)依次放入TCP接收缓冲区,并通知应用程序读取数据(这里就是epoll,通知应用程序某个事件发生,也就是 应用程序收到通知时,TCP接收缓冲区(socket接收缓冲区)已经收到数据了,应用程序可以异步去读了)。接收端应用程序可以一次性将TCP接收缓冲区中的数据全部读出,也可以分多次读取,这取决于用户指定的应用程序读缓冲区的大小。 因此,应用程序执行的读操作次数和TCP模块接收到的TCP报文段个数之间也没有固定的数量关系。

  也就是说,发送端执行的写操作次数和接收端执行的读操作次数没有任何数量关系,这就是字节流。而UDP不一样,发送端应用程序每执行一次写操作,UDP模块就将其封装成一个UDP数据报并发送之,接收端必须及时针对每一个UDP数据报执行 读操作(通过recvfrom系统调用),否则就会丢包。并且,如果用户没有指定足够大的应用程序缓冲区来读取UDP数据,则UDP数据还会被截断。

  TCP传输是可靠的。首先,TCP协议采用发送应答机制,即发送端发送的每个TCP报文段都必须得到接收方的应答,才认为这个TCP报文段传输成功。其次,TCP协议采用超时重传机制,发送端在发送出一个TCP报文段之后启动定时器,如果在 定时时间内未收到应答,它将重发该报文段。最后,因为TCP报文段最终是以IP数据报发送的,而IP数据报到达接收端可能乱序、重复,所以TCP协议还会对接收到的TCP报文段重排、整理,再交付给应用层。

  UDP协议则和IP协议一样,提供不可靠服务。它们都需要上层协议来处理数据确认和超时重传。

TCP头部

  和IP头部一样,TCP头部的固定长度也是20字节,可选部分的长度为40字节,因此最大为60字节。

  1、16位的源端口和目的端口号,通常客户端使用随机选择的临时端口号,服务端则使用知名端口号。如http这种就是80。

  2、32位序号,一次TCP通信过程中某一个传输流方向上的字节流的 每个字节的编号。假设A和主机B进行通信,A发送给B的第一个TCP报文段中,序号被初始化为某个值(Initial Sequence Number, ISN),那么在该传输方向上, 后续的每个TCP报文段都序号值就是ISN + 该报文段携带的数据的第一个字节在整个字节流中的偏移。例如,某个TCP报文段传送的数据是字节流中的第1025~2048字节,那么该报文段的序号值就是ISN+1025。另外一个传输方向(从B到A) 的TCP报文段的序号值也具有相同的含义。

  3、32位确认号(ACK),用作对对方发过来的TCP报文段的响应,它的值是收到的TCP报文段的序号值 + 1,主机A和B进行通信,那么A发送给B的TCP报文段中,不仅要包含自己的序号,也要包含对B发送过来的报文段的确认号。

  4、6位标志位分别如下:

  • URG标志:用于通知接收方,数据段内的某些数据是紧急数据,需要优先处理,不必进入缓冲区直接交付上层应用,配合着TCP的报头的16位紧急指针字段使用,但此标志位已经逐渐被淘汰,它需要配合Linux内核代码一起理解。
  • ACK标志:表示确认号是否有效,携带了ACK标志的报文叫做 确认报文段。连接建立后,这个字段一般都是1。
  • PSH标志:提示接收端应用程序应该立即从TCP接收缓冲区中读取走数据,为后面的数据腾出空间。正常情况下,发送方从应用层接收数据到缓冲区直到达到某个大小开始发送;接收方从缓冲区接收数据, 直到达到某个大小开始上报给用户层。在大多数情况下,这种工作模式是很有效率的。试想在要求实时交互通信的场景(比如启动一个 Telnet 连接):一端的应用进程希望在键入一个命令后立即就能收到对方的响应。如果还是上述的工作模式, 有可能你输入的数据太少,未满足发送缓冲大小导致无法发送亦或者接收到的数据太小还没有投递到应用层,这样都会导致响应的不及时。引入PSH标志,一是提醒发送方立即发送这段数据,二是提示接收方立即将数据传给应用程序,不必等到缓冲 区满。
  • RST标志:表示要求对方重新连接,携带了RST标志的报文叫做 复位报文段
  • SYN标志:表示请求建立一个连接,携带SYN标志的报文叫做 同步报文段
  • FIN标志:表示通知对方本端要断开连接了,携带FIN标志的报文叫做 结束报文段

  5、16位窗口大小(window size),这里的窗口指的是 接收窗口大小(Receiver Window, RWND),它告诉对方本端的TCP接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送的速度,用于流量控制。

  6、16位校验和(TCP checksum):由发送端填充,接收端对TCP报文段执行CRC算法以检验TCP报文段在传输过程中是否损坏。注意,这个校验不仅包括TCP头部,也包括数据部分(作为对比,IP的校验和只校验头部)。这也是TCP可靠传输 的一个重要保障。

  7、16位紧急指针(urgent pointer):是一个正的偏移量。它和序号字段的值相加表示最后一个紧急数据的下一字节的序号。因此,确切地说,这个字段是紧急指针相对当前序号的偏移,不妨称之为紧急偏移。TCP的紧急指针是发送端向接收 端发送紧急数据的方法。

  上面是20字节的固定头部,其实还有最多40字节的可选部分,这里面的含义还比较多,挑几个重点的梳理一下:

  1、最大报文长度选项(2字节)。TCP连接初始化时,通信双方使用这个选项来商议最大的报文段长度(Max Segment Size, MSS),TCP模块通常将MSS设置为(MTU-40)字节(减掉的这40字节包括20字节的TCP头部和20字节的IP头部 )。这样携带TCP报文段的IP数据报的长度就不会超过MTU(假设TCP头部和IP头部都不包含选项字段,并且这也是一般情况),从而避免本机发生IP分片。对以太网而言,MSS值是1460(1500-40)字节。

  参考: TCP/IP协议如何拆分数据

  注意,这里说明了一点,在建立连接的TCP报文段里,TCP首部是包含了可选部分的(因为要确定MSS),但是在后续的发送数据过程中,往往是不包含选项部分的,就不用再次确立了。

  2、窗口扩大因子选项(1字节)。也是在TCP连接初始化的时候,通信双方用来协商 接收窗口的扩大因子。在固定首部中,接收窗口大小是16位,最大是65535字节,但实际上TCP模块允许的接收窗口大小远远不止这个数字,这就是窗口 扩大因子的作用。假设TCP头部中的接收窗口大小是M,窗口扩大因子是N,则实际的接收窗口大小等于 M * 2^N(即M左移N位),扩大因子的取值为0~14,这个数字位于/proc/sys/net/ipv4/tcp_window_scaling,这个内核变量中, 可以动态修改之。

  和上面的MSS一样,窗口扩大因子选项也只能出现在同步报文段中(只是在建立连接的报文),否则将被忽略。但是同步报文段本身并不执行窗口扩大操作,即同步报文段头部的接收通告窗口大小就是该TCP报文段的实际接收通告窗口大小。当 连接建立好之后,每个数据传输方向的窗口扩大因子就固定不变了。

  3、选择性确认选项(Selective Acknowledgment)。TCP通信时,如果某个TCP报文段丢失,则TCP模块会重传最后被确认的TCP报文段后续的所有报文段,这样原先已经正确传输的TCP报文段也可能重复发送,从而降低了TCP性能。 SACK技术正是为改善这种情况而产生的,它使TCP模块只重新发送丢失的TCP报文段,不用发送所有未被确认的TCP报文段。选择性确认选项用在连接初始化时,表示是否支持SACK技术。可以通过修改/proc/sys/net/ipv4/tcp_sack内核变量来 启用或关闭选择性确认选项。一般都设置为1了。

TCP连接建立与关闭

  三次握手的连接建立流程和四次挥手的连接断开流程本身没有太多说的,关于三次握手有一篇比较好的参考文章 为什么TCP建立连接是三次握手。 这篇文章写得非常好,因为它剖析了三次握手的根本原因是将是否建立连接的最终控制权交给了发送方,因为只有发送方有足够的上下文来判断当前连接是否是错误的或者过期的。

  为什么需要三次握手才能建立连接,首先需要理解并且明确的就是,这个连接到底是什么含义?RFC793关于TCP的介绍里有这样一段话:

The reliability and flow control mechanisms described above require that TCPs initialize 
 and maintain certain status information for each data stream. The combination of 
 this information, including sockets, sequence numbers, and window sizes, 
 is called a connection.

  这里对连接的解释其实就是一些状态信息,即通信的双方需要维护的状态信息,包括socket、序列号以及窗口大小。连接的作用是什么呢?用来保证可靠性以及流量控制。所以建立TCP连接就是通信的双方需要对这几条信息达成共识。socket 就是IP地址+端口号,标识一个应用进程;窗口大小用于进行流量控制;序列号用来唯一标识和追踪发送方发送的数据报,接收方可以通过将收到的数据报序号 + 1作为ack号发回对方,表示已接收到。

  现在的问题就转化为,为什么需要三次握手才可以确认socket、序列号以及窗口大小?这里有两个非常重要的原因:当然其实socket(端口号)和窗口大小(接收窗口大小)都是直接在TCP报文段的固定首部中,所以在握手的过程中就商量 好了。

  • 1) 通过三次握手才能阻止历史重复连接的初始化(最主要原因),还是先来看RFC793官方文档里怎么说的:

    The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion

  这里就指出了使用三次握手的首要原因是——为了阻止历史重复连接造成的混乱问题,防止通信双方建立了错误的连接。假设有这样一个场景:在网络情况较差时,发送方连续发起了建立连接的请求,如果 TCP 建立连接只能通信两次,那么接收 方只能选择接受或者拒绝发送方发起的请求,它并不清楚这一次请求是不是由于网络拥堵而早早过期的连接。所以TCP引入了三次握手并在在TCP头部弄了一个RST标志位来辅助,接收方收到第一次握手请求时,会将收到的SEQ + 1作为ack发送给 对方,这样就由发送方来判断当前连接是否是历史连接。

  a. 如果当前连接是历史连接,比如发送方连续发送了SEQ为90和100的两个请求,因为网络环境比较差,我认为90那个已经无效了,我现在期望收到的ack是101,但是接收端发过来的ack是91,那么发送方就认为收到的这个91报文是已经 过期的了,就直接发送一个RST报文表示让对方重新发。   b. 如果当前连接不是历史连接,发送方就直接发送ack回去,这样双方就建立好了连接。

  使用三次握手和 RST 控制消息将是否建立连接的最终控制权交给了发送方,因为只有发送方有足够的上下文来判断当前连接是否是错误的或者过期的,这也是 TCP 使用三次握手建立连接的最主要原因。

  • 2) 通信双方都需要获得一个用于发送信息的初始化序列号,这就是TCP可靠的基石。因为在复杂的网络环境中,加之IP协议并不可靠,可能会导致很多问题。比如: 数据包被多次发送导致接收端收到重复的数据报,数据包丢失,数据包的接收顺序可能和发送顺序不一致(即顺序混乱)。

  为了解决上面这几个问题,TCO头部有一个序列号字段SEQ,每一个数据包都有一个对应的序列号,接收方就可以通过序列号进行去重;发送方在没有收到相关数据包 的ack时,会进行重传;接收方可以利用数据包的序列号对它们进行重排序。

  两次握手至少让一端无法确定对端是否了解了你的起始序列号。即,假设我是服务端。对端syn给我发了序列号,我也给对端回了我的序列号,但是如果我给对方发的这个数据包丢了怎么办?于是我没法确认对端是否收到, 所以需要对端再跟我确认一下他确实收到了。

  除此之外,网络作为一个分布式的系统,其中并不存在一个用于计数的全局时钟,而 TCP 可以通过不同的机制来初始化序列号,作为 TCP 连接的接收方我们无法 判断对方传来的初始化序列号是否过期,所以我们需要交由对方来判断,TCP 连接的发起方可以通过保存发出的序列号判断连接是否过期,如果让接收方来保存并判断序列 号却是不现实的,这也再一次强化了我们在上一节中提出的观点 —— 避免历史错连接的初始化。所以本质上,这个序列号也是在解决第一个问题,非常有意义。

(1) 半关闭状态

  由于TCP是一个全双工协议,因此它允许只有一个方向的数据传输,即两个方向的数据传输可以各自分别关闭,发送端可以发送结束报文(FIN)给对方,告诉对方我已经完成了数据的发送,但是此时我仍然可以接收数据,直到对方也发送了 结束报文段来关闭连接,这种状态就是半关闭状态。

(2) 连接超时

  假设由于网络情况不佳,客户端发送的第一个同步报文段(SYN)丢失了,或者接收端对这个报文段的响应(ACK、SYN,同时也是一个同步报文段)丢失了;那么客户 端首先要进行重连(多次),多次后仍然无效则通知应用程序连接超时。连接超时次数是由内核变量/proc/sys/net/ipv4/tcp_syn_retries定义的,每次重新连接的 超时时间都会增加一倍。注意,这个是连接超时次数,即syn_retries,不是后面的数据报超时重传次数,两者是不太一样的。

TCP状态转移

  TCP状态转移图这里不再画出,各类经典书籍中都会有。TCP连接的任意一端在任意一个时刻都一定处于某种状态,从连接建立到关闭的整个过程中状态不断变化, 下面会分别针对服务端和客户端描述一下这个过程。

  1、首先是服务端,服务端通过listen系统调用进入LISTEN状态,被动等待客户端的连接;

  2、服务端一旦监听到某个连接请求(收到同步报文段,即第一次握手SYN),就将该连接(对应内核中的结构体 inet_request_sock)放入内核等待队列 (也叫SYN队列,或者叫半连接队列),并向客户端发送带SYN标志、ACK标志的确认报文段(即第二次握手的ack),此时服务端的连接进入SYN_RCVD状态;

  3、服务端接收到了客户端的确认报文段(第三次握手的ack),首先找到对应的SYN队列,再在队列中检查相关的数据是否匹配(因为这个ack包可能是其他的一个 客户端发过来的),如果匹配,内核就将SYN队列中的该连接(以及相关数据)移除,创建一个完整的连接(对应内核结构体 inet_sock),并将这个完整的连接 放入Accept队列(也叫全连接队列),此时服务端的连接进入ESTABLISHED状态。这个状态是连接双方能够进行双向数据传输的状态。

  4、当客户端主动发起关闭连接的请求时(通过close或者shutdown系统调用向服务端发送结束报文段),服务器返回确认报文段ack后,服务端连接进入 CLOSE_WAIT状态。含义是:我已经知道你要关闭连接了,我也等待自己服务端应用程序关闭连接。一般情况下,服务端收到客户端的FIN包时,也会立即发送给 客户端一个FIN包来关闭连接,发送FIN包后,服务端连接进入LAST_ACK状态,以等待客户端的最后一次确认。一旦收到后,连接就彻底关闭了。

  接下来讨论一下客户端的连接转移过程:

  1、客户端通过connect系统调用主动与服务端建立连接,connect系统调用首先发送给服务器发送一个同步报文段(对应前面服务端收到的第一个握手请求),此时 客户端;连接进入SYN_SENT状态,然后,connect系统调用可能会由于下面两个原因而失败返回:

  • 1) 如果connect的目标端口不存在(未被任何服务端应用进程监听),或者该端口仍被处于TIME_WAIT状态的连接所占用,则服务端给客户端发送一个复位 报文段(RST标志),connect调用失败;
  • 2) 如果目标端口存在,但是connect在超时时间内没有收到服务端发送的确认报文段,则调用失败。

  2、connect调用失败则客户端连接进入初始的CLOSE状态,如果客户端收到了服务端发送的SYN+ACK报文段,则connect系统调用返回成功,客户端连接进入 ESTABLISHED状态,并给服务端发回ack(第三次握手)。

  3、当客户端主动发起关闭连接请求时,它向服务端发一个FIN结束报文段,同时客户端连接进入FIN_WAIT_1状态;如果此时客户端收到了服务端对于这个关闭 连接请求的ack,那么客户端连接转移到FIN_WAIT_2状态,此时服务端是处于CLOSE_WAIT状态的。这一对状态就是发生在前面提到的半关闭时刻。如果服务端也 关闭连接(发送FIN报文段),则客户端收到后将发回最后一个ACK确认,并进入TIME_WAIT状态。

  值得注意的是,客户端是有可能直接从FIN_WAIT_1状态进入TIME_WAIT状态的(即不经过FIN_WAIT_2状态),也就是服务端不是先发送一个ack,再发送一个 FIN,而是把这两个合并到一个报文段中进行发送(即ACK + FIN报文),

问题一:现在一个问题,服务端还会经过CLOSE_WAIT状态吗?理论上应该也没有了吧。

  前面提到了,客户端由FIN_WAIT_2进入TIME_WAIT状态的条件是 收到了服务端发起的主动关闭连接请求(服务端的FIN报文),否则客户端将一直停留在这个状态;然而,如果不是为了在半关闭状态下继续接收数据,连接长时间的停留在 FIN_WAIT_2状态没有什么好处。连接停留在这个状态的原因之一可能是:客户端主动关闭连接后,也收到了服务端的ack,但是还没有等到服务端执行关闭连接,客户端就强行退出了,此时客户端的连接由内核接管,也叫作孤儿连接。Linux 为了防止孤儿连接长时间的留存在内核中,定义了两个内核变量: /proc/sys/net/ipv4/tcp_max_orphans和/proc/sys/net/ipv4/tcp_fin_timeout。前者指定内核能接管的孤儿连接个数,后者指定孤儿连接的存活时间。

  这里并没有讨论一些特殊情况,比如连接的同时打开和同时关闭。

TIME_WAIT状态

  客户端在收到服务端的结束报文段时(FIN),发回ack,并没有直接进入CLOSE状态,而是到了一个TIME_WAIT状态。客户端连接会在TIME_WAIT状态等待一段2 * MSL(Maximum Segment Life,TCP报文段在网络中的最大生存时间) 时间,然后才完全关闭。TIME_WAIT状态存在主要有两个原因:

  • 1) 保证让迟到的TCP报文段有足够时间被接收或者被丢弃。假设在半关闭状态下,服务端还想最后发送给客户端一个数据报,然后接着发送了FIN关闭连接,但是由于网络环境,客户端先收到了FIN包,如果直接关闭,那么就无法处理数据报了, 这样就不符合可靠的定义了,所以需要等一会,等等那些迟到的TCP报文段。

  • 2) 可靠地终止TCP连接。假设 客户端发回的最后一个ack(对于服务端关闭连接的确认)包丢失了,那么服务端由于没有收到自己关闭连接请求的ack,那么服务端必然会重新发送自己的关闭连接请求(重新发送FIN包),那么客户端必须要 停留在某个状态以处理这种重新发发送的FIN请求。否则,客户端收到一个不存在的连接的包,将发送一个复位报文RST给服务端,服务端收到RST则明显是错误的,因为它期望收到的是一个正常的ACK。

  在Linux系统上,同一个端口不能被同时打开,当一个TCP连接处于TIME_WAIT状态时,是不能使用该连接占用的端口来新建一个新的连接的。假设不存在这个状态,那么当旧的TCP连接发回ack以后,立即进入CLOSE,然后应用程序马上就 可以使用同一个端口新建一个相似的连接(准确来说,就是IP + 端口号一致),这个新的连接是仍然有可能接收到本应该属于旧连接的、携带数据的、迟到的TCP报文段(在半关闭状态下),和1)极其相似的情况,这样显然是不合理 的,即必须要存在TIME_WAIT状态的另外一个原因,其实还是一个道理,必须等待一段时间处理那些迟到的TCP报文数据。

  有时候我们希望避免TIME_WAIT状态,因为当程序退出后,我们希望能够立即重启它。但由于处在TIME_WAIT状态的连接还占用着端口,程序将无法启动(直到2MSL超时时间结束)。对客户端程序来说,我们通常不用担心上面描述的重启问题。 因为客户端一般使用系统自动分配的临时端口号来建立连接,而由于随机性,临时端口号一般和程序上一次使用的端口号(还处于TIME_WAIT状态的那个连接使用的端口号)不同,所以客户端程序一般可以立即重启。

  但如果是服务器主动关闭连接后异常终止,则因为它总是使用同一个知名服务端口号,所以连接的TIME_WAIT状态将导致它不能立即重启。不过,我们可以通过socket选项SO_REUSEADDR来强制进程立即使用处于TIME_WAIT状态的连接占用的 端口,后面还会梳理这个问题。

  你必须要意识到的一个问题是:只有主动关闭连接的一方,才会出现TIME_WAIT状态,而这个主动关闭,服务端和客户端均有可能。

问题二:那么你可以思考这样一个问题,我们目前的RPC服务方,就是一台机器上的一个进行,仅使用一个端口,那么为了保证端口可重用,服务方是不是必须使用socket的SO_REUSEADDR选项,或者就是服务端永远不会主动关闭连接,主动关闭连接的操作都由客户端来进行?是这样吗?
复位报文段

  在一些特殊情况下,TCP连接的一端会向另一端发送带RST标识的复位报文段,主要有以下几种情况:

  • (1) 访问不存在的端口(未被监听的),则服务端会返回一个RST报文段,RST报文段中首部的接收窗口大小是0,因此收到RST报文段的一方应该关闭连接,或者重新尝试连接,而不能回复这个RST报文段。实际上,前面客户端connect系统 调用时提到了,除了访问不存在的端口以外,如果该端口仍然被处于TIME_WAIT状态的连接所占用的时候,客户端也会收到RST报文段。这里可以思考RPC服务方是怎么避免端口被TIME_WAIT占用的。

  • (2) 异常终止连接:TCP提供了一个异常终止一个连接的方法——给对方发送一个复位报文段,一旦发送了RST,发送端所有等待发送的数据都会被丢弃。应用程序 可以使用socket选项SO_LINGER来发送复位报文段,以异常终止一个连接。

  • (3) 处理半打开连接:有这样一种情况,服务端主动关闭或者异常的终止了连接,而此时客户端没有收到FIN报文段(网络故障),所以客户端还是维持着原来的连接, 正常的往服务端发送数据,服务端即使重启也没用原来的连接信息了,这种状态就是半打开状态,处于这种状态的连接就是半打开连接,客户端相当于往一个半打开的连接中 写入数据,此时服务端正常它是希望接收到ack的(即对于我那次FIN包的ack),所以它不理解这些数据,回复一个RST报文段。

TCP交互数据流

  TCP报文段携带的应用程序数据,按照长度可以分为两种:交互数据流和成块数据流,交互数据流包含的字节一般较少,使用交互数据流的应用程序对实时性一般要求 较高;成块数据流的数据长度一般就是TCP报文段允许的最大长度,这种应用程序一般对吞吐量要求较高(比如ftp)。

  服务端的延迟确认:服务端每次发送的确认报文段都包含它需要发送的应用程序数据,即它不马上确认收到的数据,而是在一段时间延迟后,查看本端是否有需要发送 的数据,如果有就和确认信息一起发出。延迟确认可以减少发送的TCP报文段数目。

  这里在广域网中往往也会引入新的问题,即广域网上的微小TCP报文段数目非常多,数据流延迟也飘忽不定,这些因素会导致拥塞发生,解决拥塞的一个简单 算法就是Nagle算法

  Nagle算法要求,TCP连接的双方在任意时刻都只能发送一个未被确认的TCP报文段,在这个报文段的ack收到之前,不能发送其他的报文段;另一方面,发送方在 等待ack的同时,也收集本端需要发送的微小数据,等到ack到达时以一个TCP报文段将它们全部发出,这样极大的减少网络上的微小TCP报文段数量。

TCP成块数据流
带外数据流

  TCP和UDP都没有带外数据,带外数据用于告诉对方我这边有一些重要事件,优先级比普通的数据要高,TCP就是利用头部的URG标识和紧急指针两个字段来实现的, TCP的紧急方式利用传输普通数据的连接来传输紧急数据。这种紧急数据的含义和带外数据类似。

TCP超时重传

  重传指的是,在一定超时时间内,发送方没有收到已经发送的数据报的确认,那就需要重传。因此,TCP模块为每一个TCP报文段都维护了一个重传定时器,这个定时器 在TCP报文段被第一次发送时启动,如果超时时间内仍然没有收到这个报文段的确认(当然服务端是可以一次确认多个的),那么就重传报文段并重启定时器,至于后续每 依次重传的超时时间以及重试次数,是由重传策略决定的。事实上这里说的并不准确,一般是为一个发送窗口内的一组TCP报文段设置一个定时器,这个窗口里的一组报文段被同时发送。

  虽然超时会导致TCP报文段的重传,但是TCP报文段的重传是可能发生在超时之前的,即快速重传

TCP拥塞控制

  TCP模块除了超时重传保证可靠性以外,还需要提高网络带宽利用率,降低丢包率,并保证网络资源对每条数据流的公平性,这就是拥塞控制。拥塞控制的四个部分 分别是:慢启动(slow start)、拥塞避免(congestion avoidance)、快速重传(fast retransmit)和快速恢复(fast recovery)。拥塞控制算法 在Linux下有多重实现,比如reno算法、vegas算法和cubic算法等。它们或者部分或者全部实现了上述四个部分。 /proc/sys/net/ipv4/tcp_congestion_control文件指示机器当前所使用的拥塞控制算法。现在一般的实现都是cubic算法。

  拥塞控制的最终受控变量是发送端向网络一次连续写入的数据量(这里是应用程序向TCP发送缓冲区,还是发送缓冲区向网卡?),称之为SWND(Send Window, 发送窗口)。发送端最终是以TCP报文段来发送数据的,所以SWND限定了发送端能够连续发送的报文段数量,这些TCP报文段的最大长度(仅数据部分)称为SMSS( Sender Maximum Segment Size,发送者最大段大小),一般就等于MSS。

  实际的SWND等于 发送端的拥塞窗口(Congestion Window, CWND)和 接收端的 接收窗口(RWND)之间的较小者。

慢启动和拥塞避免

  TCP连接建立好以后(三次握手结束),CWND被设置成初始值IW(Initial Window),一般是2~4个SMSS,现在新的Linux内核一般不止这个数字,此时发送端 最多能发送IW个字节的数据,此后发送端每收到一个接收端的确认,就调整其拥塞窗口大小:

CWND += min(N, SMSS)

其中,N是此次确认中包含的之前未被确认的字节数,这样的过程就是慢启动,因为TCP模块刚开始发送数据时不知道网络的实际情况,因此用一种比较平滑的方式增加 CWND的大小。

  但是很快CWND就会变得非常大,并最终导致网络拥塞,因此TCP拥塞控制中定义了一个慢启动门限变量(slow start threshold size),当CWND大小超过 这个值时,拥塞控制将会进入拥塞避免阶段。

  拥塞避免算法主要是减缓CWND的增长速度,RFC中提到了两种实现方式:

  • (1)每个RTT时间内按照上面的公式计算新的CWND,而不论该RTT时间内发送端收到多少个确认;

  • (2)每收到一个对新数据的确认报文段,就按照下面新的公式来计算CWND。

    CWND += SMSS * SMSS/CWND

  上面是发送端在未检测到拥塞时所采用的的 积极避免拥塞的办法,下面两个部分是 发送端检测到拥塞发生时所采用的的方法,检测到拥塞可能是在慢启动阶段,也可 能是在拥塞避免阶段,发送端判断拥塞发生的依据有下面两个:

  • (1) 传输超时,或者说TCP重传定时器溢出;如果是这种情况,发送端仍然使用慢启动和拥塞避免来处理,这里涉及到调整慢启动门限ssthreshold值。
  • (2) 接收到重复的确认报文段。如果是这种情况,发送端使用快速重传和快速恢复来解决拥塞。
快速重传和快速恢复

  前面提到,接收到重复的确认报文段时,拥塞控制算法还需要判断是否是真正的发生了拥塞,更具体来说就是判断TCP报文段是否真的丢失了,具体做法是: 发送端如果连续收到了3个重复的确认报文段,就认为发生了拥塞,然后它启用快速重传和快速恢复来处理拥塞。

四、Linux网络编程基础API

  这一章主要讲的都是一些socket相关的API,对C语言稍微熟悉一些就可以理解本章的内容。

  大端字节序(big endian)和小端字节序(little endian):大端指的是一个整数的高位字节存储在内存的低地址处,而小端正好相反。现代CPU的累加器一次都能装载至少4字节,即一个int。那么这个4字节在内存中的排列顺序将会 影响到它被CPU装载后的值,这就是字节序的问题。

  现代PC一般都使用小端字节序(但是Java虚拟机采用大端字节序),因此小端字节序有时候也被称为主机字节序。现在两台主机进行网络通信时,发送端都是将要发送的数据转化为大端字节序然后再发送,接收端根据自己采用的字节序决定 是否对收到的数据进行转化,大端字节序一般也称为网络字节序,它给所有接收端的网络主机提供了一个保证。

监听连接

  socket被创建后,bind到某一个地址上(IP地址 + 端口号),就可以调用下面这个系统调用来监听连接了,本质上这个系统调用创建了一个监听队列(这是前面SYN队列和ACCEPT队列的统称),来存放待处理的客户端连接。

#include<sys/socket.h>
 int listen(int sockfd,int backlog);

  socketfd指定被监听的socket,backlog参数指定监听队列的长度,监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也将收到ECONNREFUSED错误信息。在2.2的内核版本以前,backlog参数指的是所有处于半连 接状态(SYN_RCVD)和完全连接状态(ESTABLISHED)的socket之和,即SYN队列和Accept队列的大小之和;在后面的版本中,backlog参数仅包含处于完全连接状态的socket的上限,即ACCEPT队列的大小,处于半连接状态的socket的 上限则由 /proc/sys/net/ipv4/tcp_max_syn_backlog 内核参数定义,这个值在目前的线上服务器我们一般是4096或者8192。

  这里,我使用书中的测试backlog参数的程序,在一台Linux服务器上执行了这个程序,然后本地使用命令telnet 10.126.85.71 12345来尝试和服务端监听的端口建立连接,然后在服务端使用netstat -nt | grep 12345命令 来查看连接的情况,程序设置backlog为5,不停的尝试telnet命令建立新的连接,可以看到情况如下:

tcp        0      0 10.126.85.71:12345          10.252.155.118:54158        SYN_RECV    
 tcp        0      0 10.126.85.71:12345          10.252.155.118:54124        SYN_RECV    
 tcp        0      0 10.126.85.71:12345          10.252.155.118:53923        ESTABLISHED 
 tcp        0      0 10.126.85.71:12345          10.252.155.118:54050        ESTABLISHED 
 tcp        3      0 10.126.85.71:12345          10.252.155.118:53871        ESTABLISHED 
 tcp        0      0 10.126.85.71:12345          10.252.155.118:54064        ESTABLISHED 
 tcp        0      0 10.126.85.71:12345          10.252.155.118:54019        ESTABLISHED 
 tcp        0      0 10.126.85.71:12345          10.252.155.118:54091        ESTABLISHED

  可以看到,服务端监听的socket(端口)中,处于ESTABLISHED状态的连接最多只能是6个,其他的连接都处于SYN_RCVD状态,完整的连接数最多只能是backlog + 1个,但实际上这个数字会随着系统而改变,据说在Mac系统上就只能是 5个,不过我并没有尝试,大体规律是这样的即可,一般来说监听队列中的完整连接的上限比backlog值略大。

接受连接

accept系统调用从监听队列中接受一个连接,如下:

#include<sys/types.h>
 #include<sys/socket.h>
 int accept(int sockfd,struct sockaddr*addr,socklen_t*addrlen);

  这里socketfd指的是服务端已经进行过listen系统调用的socket,处于监听状态了,addr参数用来获取被接受连接的远端socket地址,该地址的长度由addrlen指出,accept成功则返回一个新的连接socket,该socket 唯一标识了被接受的这个连接,通过读写这个socket,服务端就可以和 被接受连接的客户端进行通信了,accept失败则返回-1,并设置errno。

  现在考虑这样一个情况:监听队列中处于ESTABLISHED状态的连接对应的客户端出现网络异常了(或者提前退出),此时,你思考一下,根据前面的状态转移图,再使用netstat命令去查看连接状态,服务端连接应该是处于CLOSE_WAIT状 态(因为它收到了客户端的FIN报文,自己回了一个ack,然后进入CLOSE_WAIT状态,但是自己并没有发送FIN报文,所以没有进入LAST_ACK状态)。那么服务端对这个连接调用accept是否会成功?

  我还是在10.126.85.71机器上测试了书中提供的代码,答案是 会成功。这里其实本质上是因为,accept是从全连接队列中取出连接,但是一个连接一旦成为ESTABLISHED状态后,客户端再断开连接,这个连接的状态理论上会变为 CLOSE_WAIT,但是它并未从ACCEPT队列中移出?因此accept系统调用还能获取到。

发起连接(客户端)

前面提到的监听和接受连接API都是针对服务端的,发起连接往往针对客户端而言,connect系统调用如下:

#include<sys/types.h>
#include<sys/socket.h>
int connect(int sockfd,const struct sockaddr*serv_addr,socklen_t addrlen);

  socketfd参数由socket系统调用,创建的一个新的socket,serv_addr是服务器监听的socket地址,addrlen指定这个地址的长度。connect成功时返回0,表示成功建立连接,socketfd就唯一标识这个连接,客户端就 可以通过读写sockfd来与服务器通信。connect失败则返回-1并设置errno。其中两种常见的errno是ECONNREFUSED和ETIMEDOUT,它们的含义如下:

  • (1)ECONNREFUSED,目标端口不存在,连接被拒绝。
  • (2)ETIMEDOUT,连接超时。
关闭连接

  关闭一个连接其实就是关闭连接对应的socket,可以通过int close(int fd)系统调用完成,但是close系统调用并不总是立即关闭一个连接,而是将fd的引用计数减1,只有当fd的引用计数减为0时,连接才真正的关闭。 这个的主要问题发生在多进程程序中,一次fork系统调用默认将父进程中打开的socket的引用计数+1,因此当关闭时需要将父子进程中都对该socket执行close才行。

  后面引出的shutdown系统调用也可以用于关闭socket,这个系统调用是真正的关闭连接。

socket选项
  • (1) SO_REUSEADDR选项:服务器程序可以强制设置该选项,使得即使处于TIME_WAIT状态的连接所占用的socket地址也可以被使用,也就是客户端connect这样一个端口也不会收到异常。
  • (2) SO_RCVBUF和SO_SNDBUF:分别表示TCP的发送缓冲区和接收缓冲区的大小。不过,当我们用setsockopt来设置TCP的接收缓冲区和发送缓冲区的大小时,系统都会将其值加倍,并且不得小于某个最小值。这里需要思考一下的是: TCP发送缓冲区和发送窗口的区别是什么?或者说接收缓冲区和接收窗口的区别?我的理解是:外部应用程序调用send系统调用时,将应用数据是写入到了发送缓冲区(位于内核)的,不管你的send是阻塞还是非阻塞,返回后只是意味着数据写入 到了发送缓冲区,剩下的发送过程是由内核接管的(也就是TCP模块),这时候才会有发送窗口的概念。接收缓冲区是一样的。

  此外,也可以直接修改内核参数/proc/sys/net/ipv4/tcp_rmem和/proc/sys/net/ipv4/tcp_wmem来强制TCP接收缓冲区和发送缓冲区的大小没有最小值限制。

  • (3) SO_RCVLOWAT和SO_SNDLOWAT:这两个选项分别表示TCP接收缓冲区和发送缓冲区的低水位标记,它们一般被IO复用系统调用(select或者epoll)用来判断socket是否可读或者可写。当TCP接收缓冲区中可读数据的总数大于 其低水位标记时,I/O复用系统调用将通知应用程序可以从对应的socket上读取数据;当TCP发送缓冲区中的空闲空间(可以写入数据的空间)大于其低水位标记时,I/O复用系统调用将通知应用程序可以往对应的socke上写入数据。默认情况下, TCP接收缓冲区的低水位标记和TCP发送缓冲区的低水位标记均为1字节。

  • (4) SO_LINGER选项:SO_LINGER选项用于控制close系统调用在关闭TCP连接时的行为。默认情况下,当我们使用close系统调用来关闭一个socket时,close将立即返回,TCP模块负责把该socket对应的TCP发送缓冲区中残留的数据 发送给对方。SO_LINGER选项可以定义一些行为,这个理论上不是重点。

五、高级I/O函数

readv和writev函数

  readv函数将数据从文件描述符读到分散的内存块中,即分散读;writev函数则将多块分散的内存数据一并写入文件描述符中,即集中写。它们的定义如下:

#include<sys/uio.h>
ssize_t readv(int fd,const struct iovec*vector,int count)
ssize_t writev(int fd,const struct iovec*vector,int count);

  fd参数是被操作的目标文件描述符。vector参数的类型是iovec结构数组,该结构体描述一块内存区。count参数是vector数组的长度,即有多少块内存数据需要从fd读出或写到fd。readv和writev在成功时返回读出/写入fd的字节数, 失败则返回-1并设置errno。它们相当于简化版的recvmsg和sendmsg函数。

sendfile函数

  sendfile函数用来在两个文件描述符之间直接传输数据(完全在内核操作),避免了数据在内核缓冲区和用户缓冲区之间的拷贝操作,效率非常高,被称为零拷贝,这个函数非常重要。定义如下:

#include<sys/sendfile.h>
ssize_t sendfile(int out_fd,int in_fd,off_t*offset,size_t count);

  in_fd是待读出数据的文件描述符,out_fd是待写入的文件描述符,offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位置。count参数指定在文件描述符in_fd和out_fd之间传输的字节数。函数调 用成功时返回传输的字节数,失败则返回-1并设置errno。

  需要注意的是,in_fd必须是一个支持类似mmap函数的文件描述符,它必须指向真实的文件,不能是socket或者管道;而out_fd必须是一个socket。其实也就是,sendfile是专门用于在网络上传输文件而设计的

mmap函数和munmap函数

  mmap函数用于申请一段内存空间,这段内存可以作为进程间通信的共享内存,也可以将一个文件映射到其中;映射文件的场景用的可能更多一点,munmap函数则用来释放创建的内存空间,它们的函数定义如下:

#include<sys/mman.h>
void*mmap(void*start,size_t length,int prot,int flags,int fd,off_t offset);
int munmap(void*start,size_t length);

  start参数可以由用户指定,使用某个特定的地址作为这段内存空间的起始地址;如果设置为NULL,则系统自动分配一个;length指定内存段的长度,prot参数用来设置内存段的访问权限(可以是读/写/不可访问/可执行等)。

  fd参数是被映射文件对应的文件描述符。它一般通过open系统调用获得。offset参数设置从文件的何处开始映射(对于不需要读入整个文件的情况)。mmap函数成功时返回指向目标内存区域的指针,失败则返回MAP_FAILED((void*)-1) 并设置errno。munmap函数成功时返回0,失败则返回-1并设置errno。

splice函数

  splice函数用于在两个文件描述符之间移动数据,也是零拷贝操作。定义如下:

#include<fcntl.h>
ssize_t splice(int fd_in,loff_t*off_in,int fd_out,loff_t*off_out,size_t len,unsigned int flags);

  fd_in和fd_out必须至少有一个是管道文件描述符。fd_in参数是待输入数据的文件描述符。如果fd_in是一个管道文件描述符,那么off_in参数必须被设置为NULL。如果fd_in不是一个管道文件描述符(比如socket),那么off_in表示 从输入数据流的何处开始读取数据。

tee函数

  tee函数被用来在两个管道文件描述符之间传输数据,也是零拷贝操作,不作为重点学习。

fcntl函数

  fcntl函数(file control)提供了对文件描述符的各种控制操作。其中在网络编程中,fcntl函数通常用来将一个文件描述符设置为非阻塞的。

  此外,SIGIO和SIGURG这两个信号与其他Linux信号不同,它们必须与某个文件描述符相关联方可使用:当被关联的文件描述符可读或可写时,系统将触发SIGIO信号;当被关联的文件描述符(而且必须是一个socket)上有带外数据可读时, 系统将触发SIGURG信号。将信号和文件描述符关联的方法,就是使用fcntl函数为目标文件描述符指定宿主进程或进程组,那么被指定的宿主进程或进程组将捕获这两个信号。使用SIGIO时,还需要利用fcntl设置其O_ASYNC标志(异步I/O标志, 不过SIGIO信号模型并非真正意义上的异步I/O模型,见第8章)。

六、高性能服务器程序框架

  这一章,会进入I/O模型、select、epoll、reactor模式等的学习和处理,这些都是基础知识了。这些也是学习netty的基础,看完这本书以后,配合陶辉的文章, 再过一遍整体的流程,争取掌握80%内容。

服务器编程框架

  大体上,服务端框架主要分为I/O处理单元和逻辑单元两大部分,I/O处理单元是服务器管理客户连接的模块。它的主要工作是:等待并接受客户端的连接,接收客户端 数据,将服务端的响应数据返回给客户端等。但是,数据的收发不一定在I/O处理单元中执行,也可能在逻辑单元中执行,具体在哪里执行取决于事件处理模式。      逻辑单元通常是一个进程或者一个线程。它处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端(具体取决于事件处理模式),服务器通常有 多个逻辑单元,因为需要同时处理多个用户的请求。 

  请求队列是I/O处理单元和逻辑单元之间的通信方式的抽象。I/O处理单元接收到客户端的请求以后,需要以某种方式通知逻辑单元来处理请求,因此这里需要注意的是, 请求队列的实现需要考虑同步问题,比如多个逻辑单元同时操作一个请求队列需要考虑锁。

I/O模型

  socket在创建时默认是阻塞的,可以给socket系统调用的第2个参数传递SOCK_NONBLOCK标志,或者通过fcntl系统调用的F_SETFL命令,将其设置为非阻塞的。 阻塞和非阻塞的概念能应用于所有文件描述符,而不仅仅是socket。我们称阻塞的文件描述符为阻塞I/O,称非阻塞的文件描述符为非阻塞I/O。

  socket的基础API中,可能被阻塞的系统调用包括accept、send、recv和connect。这里关于阻塞和非阻塞就看另一篇关于NIO的文章吧。针对非阻塞I/O执行的 系统调用则总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1,和出错的情况一样。此时我们必须根据errno来区分这两种情况。 对accept、send和recv而言,事件未发生时errno通常被设置成EAGAIN(意为“再来一次”)或者EWOULDBLOCK(意为“期望阻塞”);对connect而言,errno则被设置 成EINPROGRESS(意为“在处理中”)。

  很显然,我们只有在事件已经发生的情况下操作非阻塞I/O(读、写等),才能提高程序的效率(否则你就只能轮询了)。因此,非阻塞I/O通常要和其他I/O通知机制 一起使用,比如I/O复用和SIGIO信号。I/O复用是最常用的IO通知机制,应用程序通过I/O复用函数(select、epoll_wait等)向内核注册一组事件,内核通过I/O 复用函数将将其中就绪的事件通知给应用程序,需要注意的是I/O复用函数本身是阻塞的,它为什么可以提高效率?是因为它可以同时监听多个I/O事件。

  前面其实提到了,SIGIO信号也可以用来通知I/O事件,可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程就可以捕获到SIGIO信号。当文件描述符上 有事件发生时,SIGIO信号的信号处理函数将被触发,这样就可以在信号处理函数中自定义相关的行为,对目标文件描述符进行非阻塞I/O操作了,这就是信号驱动IO

  阻塞I/O、非阻塞I/O、信号驱动I/O都是同步I/O,因为在这三种I/O模型中,I/O的读写操作,都是在I/O事件发生之后,由应用程序来完成的。而POSIX规范所定义 的异步I/O模型则不同。对异步I/O而言,用户可以直接对I/O执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及I/O操作完成之后内核通知应用程序的方式。 异步I/O的读写操作总是立即返回,而不论I/O是否是阻塞的,因为真正的读写操作已经由内核接管。也就是说,同步I/O模型要求用户代码自行执行I/O操作(将数据从内 核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区),而异步I/O机制则由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台” 完成的)。

事件处理模式

  服务器通常需要处理三种事件:I/O事件、信号以及定时事件。这一小部分先介绍两种比较高效的事件处理模式:Reactor和Proactor,其中同步I/O模型通常用来实现Reactor模式,异步I/O模型通常用来实现Proactor模式; 但是,也可以用同步I/O方式模拟出Proactor模式

  Reactor模式:它要求主线程(I/O处理单元)只负责监听socket上有没有事件发生,有的话就立即将该事件通知给工作线程(逻辑单元)。除此之外,主线程不做其他工作,读写数据、接收新的连接(?)以及处理客户端请求都在工作线程 中完成。使用同步I/O方式(以epoll为例)来实现Reactor模式的工作流程是:

  • (1) 主线程往epoll内核事件表中注册socket上的读就绪事件;
  • (2) 主线程调用epoll_wait等待socket上有数据可读(阻塞);
  • (3) 当socket上有数据可读时,epoll_wait返回,主线程将socket可读事件放入请求队列;
  • (4) 睡眠在请求队列上的某个工作线程被唤醒(这一点本质上是生产者消费者模式),它从socket上读取数据,并处理客户请求(业务逻辑),然后向epoll内核事件表中注册本端socket上的写就绪事件
  • (5) 主线程继续调用epoll_wait等待socket可写(实际上主线程是在一个永久的循环中)。
  • (6) 当socket可写时,epoll_wait返回,主线程将socket可写事件放入请求队列;
  • (7) 同(4),它将服务器的响应数据写入socket。

  Proactor模式:和Reactor不太一样,它将所有的I/O操作都交给主线程和内核来处理,工作线程只负责业务逻辑,不再去收发数据了。使用异步I/O模型(以 aio_read和aio_write为例)来实现Proactor模式的流程主要是:

  • (1) 主线程调用aio_read函数向内核注册socket上的 读完成事件(注意同步I/O中注册的都是可读事件),并告诉内核用户读缓冲区的位置,以及读操作 完成后如何通知应用程序;
  • (2) 主线程继续处理其他逻辑;
  • (3) 当socket中的数据被内核读取到用户缓冲区后,内核向应用程序发一个信号,以通知应用程序数据已经可用了;
  • (4) 应用程序预先定义好的信号处理函数选择一个工作线程来处理请求,处理完以后,调用aio_write函数向内核注册socket上的 写完成事件,并告诉内核 用户写缓冲区的位置,以及写操作完成以后如何通知应用程序;
  • (5) 同(2);
  • (6) 当用户缓冲区的数据被内核写入到socket以后,同(3);
  • (7) 应用程序预先定义好的信号处理函数选择一个工作线程来做一些善后操作,比如是否关闭socket等。

  使用同步I/O模拟Proactor模式:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接 获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。

七、I/O复用

select系统调用

  select系统调用的用途是:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常等事件,select系统调用的API如下:

#include<sys/select.h>
int select(int nfds,fd_set*readfds,fd_set*writefds,fd_set*exceptfds,struct timeval*timeout);

  nfds指定被监听的文件描述符的总数,通常被设置为select监听的文件描述符的最大值 + 1。readfds、writefds、exceptfds分别指向可读、可写和异常 事件对应的文件描述符集合,应用程序调用select时,传入自己感兴趣的文件描述符。调用返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪。timeout 参数用来指定超时时间,并且在调用成功后,这个时间会被内核修改,以告知应用程序等待了多久。

  如果给timeout变量的tv_sec成员和tv_usec成员都传递0,则select将立即返回。如果给timeout传递NULL,则select将一直阻塞,直到某个文件描述符就绪。

  select成功时返回就绪(可读、可写和异常)文件描述符的总数。如果在超时时间内没有任何文件描述符就绪,select将返回0。select失败时返回-1并设置 errno。如果在select等待期间,程序接收到信号,则select立即返回-1,并设置errno为EINTR。

  各类文件描述符就绪的条件比较重要,哪些情况下文件描述符被认为是可读、可写或者出现了异常,这一点很关键。这一部分最好还是阅读《Unix网络编程 卷1》书,里面介绍socket可读可写的各种情况。

  一、在网络编程中,下列的情况socket可读:

  select的man手册里也提到了这一点:

 Under Linux, select() may report a socket file descriptor as "ready for reading", 
  while nevertheless a subsequent read blocks.  This could for example happen when
  data has arrived but upon examination has wrong checksum and is discarded.  
  There may be other circumstances in which a file descriptor is spuriously reported  as
  ready.  Thus it may be safer to use O_NONBLOCK on sockets that should not block.
  • (2) socket通信的对方关闭连接了,此时对该socket的读操作将返回0;
  • (3) 监听socket上有新的连接请求(即Accept队列已经有三次握手完成的连接);这一点尤其需要注意,这时监听socket是可读的
  • (4) socket上有未处理的错误。

  二、下列情况下socket可写:

  • (1) socket内核发送缓冲区的空闲空间大于等于其低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且写操作返回的字节数大于0。
  • (2) socket的写操作被关闭,对写操作被关闭的socket执行写操作将触发一个SIGPIPE信号(根据Unix网络编程卷一说明,这时候收到这个信号的进程将会被终止)。
  • (3) socket使用非阻塞的connect连接成功或者超时失败以后。
  • (4) 还是看《Unix网络编程 卷1》比较好

  socket上接收到普通数据和带外数据都将使select返回,但socket处于不同的就绪状态:前者处于可读状态,后者处于异常状态。

poll系统调用

  poll本身没有太多说的,也不是重点(其实重点是select、epoll和kqueue),它和select类似,也是在指定时间内去轮询一定数量的文件描述符,以测试其中 是否有就绪的文件描述符。

epoll系统调用

  epoll是Linux特有的I/O复用函数,像kqueue是FreeBSD特有的;epoll使用的一组函数来完成任务,它将用户关心的文件描述符上的事件放在内核的一个事件 表中,就不用像select或者epoll每次调用的时候都要重复传入一堆文件描述符集合,偶尔需要的就是对这个内核事件表的增删改(内核使用红黑树实现事件表)。 但是epoll需要使用一个额外的文件描述符来标识这个内核事件表,这个文件描述符(事件表)用下面的函数来创建:

#include<sys/epoll.h>
int epoll_create(int size)

  epoll_create函数返回的就是事件表对应的文件描述符,它将作为其他epoll系统调用的第一个参数,以指定要访问的内核事件表,size本来用于指定要创建的 事件表的大小,现在已经没用了。下面的函数epoll_ctl用来操作epoll的内核事件表:

#include<sys/epoll.h>
int epoll_ctl(int epfd,int op,int fd,struct epoll_event*event)

  第一个参数epfd是前面create出的事件表,fd是要操作的文件描述符,op是操作类型,主要有:

  • EPOLL_CTL_ADD,往事件表中注册fd上的事件。
  • EPOLL_CTL_MOD,修改fd上的注册事件。
  • EPOLL_CTL_DEL,删除fd上的注册事件。

  event参数指定事件,它是epoll_event结构体指针,epoll有两个额外的事件类型——EPOLLET和EPOLLONESHOT。它们对于epoll的高效运作非常关键。 epoll_ctl成功时返回0,失败则返回-1并设置errno。

  epoll这一组系统调用中最重要的是epoll_wait函数,它就是在超时时间内等待一组文件描述符上的事件发生,其接口如下:

#include<sys/epoll.h>
int epoll_wait(int epfd,struct epoll_event*events,int maxevents,int timeout);

  epoll_wait调用成功时返回就绪的文件描述符个数,失败时返回-1并设置errno。其中timeout参数指定超时时间,maxevents指定最多监听多少个事件,它必须大于0。这个函数如果检测到事件,就将所有就绪的事件从内核事件表 (由epfd指定)中复制到events指向的数组中。值得注意的是,这里是两步拷贝操作(红黑树—>就绪链表,就绪链表—>用户空间数组)。 这个数组只用于输出epoll_wait检测到的就绪事件,而不像select和poll的数组参数那样既用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这就极大地提高了应用程序索引就绪 文件描述符的效率。因为你用select或者epoll最后还是遍历所有的socketfd,然后判断其状态是否是就绪的。

epoll的一些解释

  epoll比较重要的两个数据结构是红黑树和就绪链表,红黑树用于管理所有的文件描述符,也就是你调用epoll_ctl添加删除修改fd,就是在操作这棵树; 当向系统中添加一个fd时,就创建一个epitem结构体。就绪链表用于保存有事件发生的文件描述符,也就是调用epoll_wait返回的结果。

  在内核实现代码中,ep_poll_callback函数的主要功能是:当文件描述符上注册的事件就绪时,将文件描述符对应的epitem实例添加到就绪链表(rdlist)中, 导致rdlist不为空,从而进程被唤醒,epoll_wait得以继续执行,内核将就绪链表中的事件拷贝到用户空间(也就是上面events指向的数组中)。

  如果是ET模式, epitem是不会再进入到就绪链表,除非fd再次发生了状态改变, ep_poll_callback被调用。如果是LT模式,不但会将对应的数据返回给用户, 并且会将当前的epitem再次加入到rdllist中。这样如果下次再次被唤醒就会给用户空间再次返回事件。当然如果这个被监听的fd确实没事件也没数据了, epoll_wait会返回一个0。

  调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个 红黑树 用于存储以后epoll_ctl传来的socket外,还会 再建立一个list链表,用于存储准备就绪的事件.

  当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以, epoll_wait非常高效。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量 的句柄到用户态而已。 那么,这个准备就绪list链表是怎么维护的呢?

当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个 句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

LT模式和ET模式

  epoll对文件描述符的操作有两种模式:LT(Level Trigger,水平触发)模式和ET(Edge Trigger,边沿触发)模式。LT模式是默认的工作模式,这种模式下epoll相当于一个效率较高的poll。如果当往epoll内核事件表中注册 一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符。ET模式是epoll的高效工作模式。      对于采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到 该事件被处理。而对于采用ET工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。可见,ET模式在很大程度 上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。

  这里关于LT和ET模式理解的并不深入,希望未来可以更加深入的理解它们。其中Redis使用LT模式,Nginx使用ET模式,所以LT模式也足够快了。

  二者的差异在于Level Triggered模式下只要某个socket处于readable/writable状态, 无论什么时候进行epoll_wait都会返回该socket;而 Edge Triggered模式下只有某个socket从unreadable变为readable或 从unwritable变为writable时,epoll_wait才会返回该socket。具体在内核代码中, 就是LT模式下,当前epitem被返回给用户后,还会被加入到就绪链表rdlist中,也就是下次返回的事件列表中还有这个fd对应的事件(除非确实没有数据可读了),但是 对于ET模式下,则不会把这个epitem再加入到就绪链表的,这就是ET模式的只触发一次,只在状态变化的那一次触发。

EPOLLONESHOT事件

  一个socket上的某个事件被触发多次的情况:比如一个线程读取完socket上的数据,正在处理相关数据的时候,这个socket上又有新的数据到来,socket可读(EPOLLIN再次被触发),此时另一个线程被唤醒来读取这些新的数据,那么就 出现了同一个socket被两个线程同时操作的情况,这样并不合理,我们往往期望的是一个socket连接在任意时刻都只能被一个线程处理,这一点就可以用epoll的EPOLLONESHOT事件来实现。  

  对于注册了EPOLLONESHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程在处理某个 socket时,其他线程是不可能有机会操作该socket的。但反过来思考,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置这个socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其 EPOLLIN事件能被触发,进而让其他工作线程有机会继续处理这个socket。

三组I/O复用函数对比

  select和poll都只能工作在相对低效的LT模式,而epoll则可以工作在ET高效模式。并且epoll还支持EPOLLONESHOT事件。该事件能进一步减少可读、可写和异常等事件被触发的次数。

  从实现原理上来说,select和poll采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。epoll_wait则不同,它采用的 是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此epoll_wait无须轮询整个文件描述符集合 来检测哪些事件已经就绪,其算法时间复杂度是O(1)。但是,当活动连接比较多的时候,epoll_wait的效率未必比select和poll高,因为此时回调函数被触发得过于频繁。所以epoll_wait适用于连接数量多,但活动连接较少的情况。

八、信号

  信号是由用户、系统或者进程发送给目标进程的信息,以通知目标进程某个状态的改变或者系统异常,Linux的信号有:输入Ctrl+C通常会给进程发送一个终端信号,运行kill命令或者调用kill函数,还有一些系统异常(非法内存段访问)。

九、定时器

  定时器是网络程序需要处理的第三类事件,前两类分别是I/O事件和信号,应用程序怎么收到信号并进行处理的呢?其实在libevent的实现中也是使用I/O复用函数,监听某个管道上的读事件,信号发到管道上即可。下面学习一下定时器事件, 后面会体会到libevent代码是怎么同时处理这三类事件的(这也叫统一事件源),实际上后面netty的eventloop也是同时处理I/O事件和定时任务的,这些知识都是相通的。

  libevent的高明之处还在于,它把fd读写、信号、DNS、定时器甚至idle(空闲) 都抽象化成了event(事件)。

十、多进程编程

  管道是父子进程通信的常用手段,

  共享内存是最高效的IPC(进程间通信)机制,因为它不涉及进程之间的任何数据传输。这种高效率带来的问题是,我们必须用其他辅助手段来同步进程对共享内存的访问,否则会产生竞态条件。

libevent源码分析