《Linux高性能服务器编程》 游双著
IP协议在网络层,是传输层的TCP/IP协议族的动力。
特点:无状态、无连接、不可靠
IP通信双方不同步传输数据的状态信息。
缺点:乱序、重复
所有IP数据报之间相互独立,因此可能是乱序的,也可能重复收到同一个IP数据报。(TCP协议面向连接,有状态,能够处理乱序、重复)
优点:简单、高效
不需要为了保持通信状态而携带状态信息。
UDP、HTTP也是无状态协议
IP通信双方不保持对方的任何信息,因此,上层协议每次发送数据,都需要明确指定对方的IP地址。
IP协议不保证IP数据包准确到达
接收端,比如发生超时、数据报错误(通过校验机制)。
谁检测错误?发送端的IP模块。
发生错误时做什么?发送端的IP模块通知上层协议发送失败,但不试图重传。
4位版本号,IPv4值是4,其他IPv4协议的扩展版本则是其他值。
4位头部长度,表示该IP头部有多少个4字节,由于4位最多表示15,因此IP头部最长是60字节
。
8位服务类型,其中有4个TOS字段(Type Of Service),可以通过置其中的唯一一位为1
来选择最小延时、最大吞吐量、最高可靠性、最小费用。
16位总长度,标记了整个IP数据报的长度,最大长度为65535(2^16 - 1),注意,由于MTU限制,超过MTU长度的数据报都将被分片。
16位标识,唯一地标识主机发送的每一个数据报。该标识初始值由系统随机生成,每发送一个数据报,值加一。该值在分片时被复制到每个分片里,因此同一数据报的不同分片具有相同标识
。
3位标志字段,第一位保留,第二位(Don’t Fragment, DF)表示禁止分片
,如果设置了这个位,IP模块将不再对数据报分片,如果IP数据报长度超出MTU,会被丢弃,并返回一个ICMP差错报文
。第三位(More Fragment, MF)表示更多分片
,除了数据报的最后一个分片,其他分片都要将它置为1。
以上3位标志字段的第三位,说明数据报中的分片是存在先后顺序的,如何实现?看下一段。
13位分片偏移,记录分片对于原始IP数据报开始处的偏移。实际偏移值是该值左移3位(乘8)得到的,因此,除了最后一个IP分片外,每个IP分片的数据部分的长度必须是8的整数倍(这样才能保证后面的IP分片拥有一个合适的偏移值)。
8位生存时间(Time TO Live,TTL),是数据报到达目的地之前允许经过的最大路由跳数
。TTL由发送端设置
,每经过一个路由,TTL 减一,TTL为零时,路由器丢弃数据报,并向源端发送一个ICMP差错报文
。TTL可以防止数据报陷入路由循环
。
8位协议,用于区分上层协议。ICMP是1,TCP是6,UDP是17。
16位头部校验和,接收端使用CRC算法以检验IP数据报头部(只检验头部)。
32位源IP地址、32位目的IP地址,无论中间经过多少个路由器,这两个值都不变。
前面所有这些字段有20字节长
,而IP头部最长为60字节,因此最后还有一个选项字段
(最长40字节
)。
可用的IP选项:
记录路由,通知途中的所有路由器把IP填入选项部分。
时间戳,告诉每个路由器,将数据报被转发的事件填入选项部分。
松散源路由选择,指定一个路由器IP地址列表,要求数据报在发送过程中必须经过其中所有的路由器。
严格源路由选择,指定一个路由器IP地址列表,要求数据报在发送过程中必须且只能经过其中所有的路由器。
IP分片发生在哪?在哪重组?
发送端或中断路由器上,可能发生多次分片,并且只有在最终目标机器上,这些分片才会被内核中的IP模块重新组装。IP层传递给数据链路层的数据可能是一个完整的IP数据报,也可能是一个IP分片,它们统称为IP 分组 。
值得注意的是,每个分片都包含IP头部(20字节)
。
如何保证分片和重组的可靠性?
上面所说的,数据报标识、3位标志字段、片偏移保证了分片和重组的可靠性。
IP转发包括:应该发送至哪个下一跳路由(或者目标机器)、经过哪个网卡发送。
在转发之前需要经过以下操作:
1、IP模块接收到来自数据链路层的IP数据报
2、处理IP头部选项
3、检查是否是发给本机的数据报,是的话进行分用(传递给上层应用),不是的话进入下一步
4、检测是否允许转发,不允许则丢弃数据报,允许则由路由表计算下一跳路由
静态路由更新:可以用route命令或其他工具
手动修改路由表
动态路由更新:BGP/RIP/OSPF
ICMP重定向报文
网关
相比IPv4增加了多播、流
,为网络上多媒体内容的质量提供精细控制;引入自动配置
功能,使得局域网管理更方便;增加了专门的网络安全
功能。IPv6不是IPv4的简单扩展,而是完全独立的协议。在以太网帧里,IPv4数据报的以太网帧封装类型值是0X800,IPv6数据报的以太网帧封装类型值是0X86dd(详见RFC 2464)。
https://my.oschina.net/piorcn/blog/806989
https://blog.csdn.net/dog250/article/details/52962727
由于传输层的协议主要有两个:TCP和UDP。因此后续讨论TCP,会经常与UDP作对比。
1、TCP服务面向连接,一对一,不可为基于广播、多播的应用程序提供服务
,而UDP则适合。
2、TCP报文段个数与应用程序读写操作次数之间无明确数量关系,而UDP有。
发送端TCP模块会将上层应用传递的数据放入TCP发送缓存区
,后续真正发送数据时,这些数据封装成一个或多个
TCP报文段发出。因此TCP报文段与应用程序的写操作次数之间没有固定的数量关系。
接收端,应用程序有一个读缓存区
,可以一次性将TCP接收缓存区
中的数据全部读出,也可以分次读出,这取决于读缓存区的大小。
UDP则不一样,一次写操作对应一个UDP数据报的封装和发送,一次读操作对应一次应用程序缓存区的读取。
形式上,TCP字节流服务中,应用程序的写入函数是send(),读取函数是recv()。UDP数据报服务中,应用程序的写入函数是sendto(),读取函数是recvfrom()。
16位端口号, 存着源和目的端口。进行TCP通信时,客户端使用系统自动选择的临时端口号,服务器使用知名服务端口号(所有知名服务使用的端口号都定义在/etc/services中)。
32位序号,一个传输方向
上的字节流的每个字节
的编号,初始序号是由系统初始化的随机值ISN(Initial Sequence Number)。注意,一个TCP报文段包含一段数据,它的序号值是第一个字节
的序号值。
32位确认号,如果A和B在进行TCP通信,A发送出的TCP报文段不仅携带自身序号,还包含对B送来的TCP报文段的确认号!确认号的值是收到的TCP报文段的序号值加一
。
4位头部长度,标识着该TCP头部有多少个4字节,TCP头部最多15 * 4字节。
6位标志位包含如下几项:
URG标志, 表示紧急指针是否有效。
ACK标志,表示确认号是否有效。称携带ACK标志的TCP报文段为确认报文段。
PSH标志,表示接收端应用程序应该立即从TCP接收缓存区读走数据
,为后续数据腾出空间(如果不读走,会一直停留
在TCP接收缓存区)
RST标志,表示要求对方重新建立连接。称携带RST标志的TCP报文段为复位报文段。
SYN标志,表示请求建立一个连接。称为同步报文段。
FIN标志,表示通知对方本端要关闭连接了。称为结束报文段。
16位窗口大小,用于流量控制
,这里的窗口是接收窗口(Receiver Window,RWND)。用于告诉对方,本端的接收缓存区还能容纳多少字节的数据,这样对方可以控制发送数据的速度。
16位校验和,发送端填充,接收端用CRC算法进行检验(不仅检验头部,还检验数据)。
16位紧急指针,是一个偏移量,该报文段序号值加上这个偏移量表示一个紧急数据的序号,用于发送端向接收端发送紧急数据。
以上是固定字段,占20字节,后面40字节是选项字段
,具体可以参考第34页、第35页。
可以参考我的这篇博客
https://blog.csdn.net/jojozym/article/details/105101795
值得补充的有:
1、服务器和客户端应用程序判断对方已经关闭连接的方法是:read系统调用返回0(收到FIN报文段)。
2、2MSL(Maximum Segment Life, 报文段最大生存时间),TIME_WAIT状态时长为2MSL,MSL是TCP报文段在网络中的最大生存时间,标准文档RFC 1122的建议值是2 min
。
粗虚线表示服务器端连接的状态转移,粗实现表示客户端连接的状态转移。
客户端的connect系统调用首先给服务器发送一个SYN报文段,使连接转移到SYN_SENT状态。connect系统调用可能因为以下原因失败:
1、目标端口不存在(未被任何进程监听),或者该端口仍然被处于TIME_WAIT状态的连接所占用,此时,服务器将给客户端发送一个RST报文段
,connect调用失败。
2、目标端口存在,但是connect在超时时间内未收到服务器的确认报文段,则connect调用失败。
调用失败则回到CLOSED状态,调用成功则ESTABLISHED。
何时会发送RST报文段?
1、一端请求访问另一端的某个不存在或被占用
的端口时,另一端会发送RST报文段。
2、应用程序可以使用socket选项SO_LINGER来发送复位报文段,以异常终止一个连接。一旦发送了RST报文段,发送端所有排队等待发送的数据都将被丢弃。
3、假如A是服务器(客户端),B是客户端(服务器),当A关闭或异常终止了连接,B没有接收到结束报文段(比如发生了网络故障),那么B仍然维持着连接,而A哪怕重启,也已经没有该连接的任何信息了。这种状态是半打开状态
,处于这种状态的连接叫半打开连接
。如果B往半打开连接写入数据,A将回应一个RST报文段。
交互数据仅包含很少的字节,使用交互数据的应用程序(或协议)对实时性要求高,比如telnet、ssh等。
延时确认:客户端针对服务器返回的数据所发送的确认报文段不携带任何应用程序数据,而服务器每次发送的确认报文段都包含它需要发送的应用程序数据。服务器不马上确认收到的数据,而是在一段延迟时间后查看本端是否有数据需要发送,如果有则和确认信息一起发出。延时确认的好处是可以减少发送TCP报文段的数量
。
Nagle算法:携带交互数据的微小TCP报文段数量很多(一个按键输入就导致一个TCP报文段),这些因素都可能导致拥塞发生。Nagle算法指的是:在一个TCP连接中,通信双方
在任意时刻最多只能发送一个未被确认的TCP报文段,在该报文段的确认到达之前不能发送其他TCP报文段,在等待确认时
,收集本端需要发送的微量数据,并在确认到来时以一个TCP报文段将它们全部发出,好处是极大减少了网络上TCP报文段的数量
。
Nagle算法:
*1. 若发送应用进程要把发送的数据逐个字节地送到TCP的发送缓存,则发送方就把第一个数据字节先发送出去,把后面的字节先缓存起来;
*2. 当发送方收到第一个字节的确认后(也得到了网络情况和对方的接收窗口大小),再把缓冲区的剩余字节组成合适大小的报文发送出去;
*3. 当到达的数据已达到发送窗口大小的一半或以达到报文段的最大长度时,就立即发送一个报文段;
发送大量大块数据的时候,发送方会连续发送多个TCP报文段,接收方可以一次性确认所有这些报文段。
TCP模块为每个TCP报文段都维护一个重传定时器
,该定时器在该报文段第一次被发送时启动
。
TCP重传策略
:超时时间如何选择,最多执行多少次重传。
重传可以发生在超时之前,即快速重传
。
标准文档是 RFC 5681。
拥塞控制控制的是什么变量?发送窗口,发送端一次连续写入的数据量,称为SWND(Send Window)。
拥塞控制有哪几个部分?慢启动、拥塞避免、快速重传、快速恢复。
为什么需要拥塞控制?
SWND如果太小,会引起明显的网络延迟,太大会导致网络拥塞。
接收方可以通过接收窗口(RWND)来控制发送端的(SWND),但是这显然不够。因此,在发送端引入了一个称为拥塞窗口(Congestion Window,CWND)的状态变量。
那么拥塞控制在网络传输中扮演了什么角色?
从上图可以看出,拥塞控制的输入有:
1、网络状况,比如RTT(Round Trip Time)、是否丢包。
2、拥塞窗口 CWND。
3、发送窗口 SWND。
如何具体的进行拥塞控制?
TCP连接建立好之后,CWND被设置为初始值
IW(Initial Window),其大小为2 - 4 个SMSS(Sender Maximum Segment Size, 发送者最大数据段大小,其值一般等于MSS,但新的Linux内核提高了该初始值,以减少传输滞后)。
CWND怎么改变
? 慢启动和拥塞避免。
什么是慢启动?发送端收到确认时改变CWND: CWND += min(N,SMSS),其中N指的是此次确认的字节数
。TCP模块不了解网络情况,因此用这种试探的方式平滑的增加CWND的大小。但慢启动不慢,它呈指数增长
,如果没有其他手段,CWND会很快膨胀并导致网络拥塞。因此需要拥塞避免。如果CWND的大小超过了慢启动门限
(slow start threshold size,ssthresh),会进入拥塞避免阶段。
什么是拥塞避免?拥塞避免算法使得CWND按照线性方式增长
。实现方式有以下两种:
1、每个RTT时间内按照 CWND += min(N,SMSS)计算新的CWND,而不论该RTT时间内发送端收到多少确认。
2、每次收到一个确认报文段,CWND += SMSS * SMSS / CWND 。
以上讨论的都是未检测到拥塞时所采用的积极避免拥塞的方法
。
如何检测拥塞,或者说拥塞有哪几种?:
1、传输超时,即重传定时器溢出。
2、接收到重复的确认报文段。
如何应对这两种拥塞:
1、依然使用慢启动和拥塞避免。执行重传并做如下调整:
ssthresh = max(FlightSize / 2,2 * SMSS)
CWND <= SMSS
执行重传
其中FlightSize是已经发送但未收到确认的字节数。拥塞控制再次进入慢启动阶段
。
2、使用快速重传和快速恢复。
快重传的机制是:
-1. 接收方建立这样的机制,如果一个包丢失,则对后续的包继续发送针对该包的重传请求;
-2. 一旦发送方接收到三个一样的确认,就知道该包之后出现了错误,立刻重传该包;
-3. 此时发送方开始执行“快恢复”算法:
*1. 慢开始门限减半;
*2. cwnd设为慢开始门限减半后的数值;
*3. 执行拥塞避免算法(高起点,线性增长);
快速重传和快速恢复:
连续收到3个相同的确认报文段,则认为发生了拥塞,启用快速重传和快速恢复。
快速重传和快速恢复过程如下:
1、收到第3个重复的确认报文段时,改ssthresh,ssthresh = max(FlightSize / 2,2 * SMSS),立即重传丢失报文段,CWND = ssthresh + 3 * SMSS
2、每次收到1个重复的确认报文段时,CWND = CWND + SMSS。此时发送端可以发送新的TCP报文段(如果新的CWND允许的话)。
3、收到新数据的确认时,设置CWND = ssthresh(ssthresh是第一步计算得到的新的慢启动门限)。
快速重传和快速恢复后,拥塞控制将恢复到拥塞避免阶段
。
最后,本文提到的拥塞控制算法已经过时,现在普遍用Google的BBR算法。
BBR之前的算法有两个特点:
1、反馈性差,比如Cubic搞了一个高大上的以三次方程凸凹曲线来抉择的增窗机制,但这个锯齿降的太猛,避免阻塞的策略过于保守
(激烈的保守)。
2、拥塞算法被接管
在TCP拥塞控制机制发现丢包时(即RTO或者N次重复的ACK等),TCP会完全接管拥塞控制算法,自己控制拥塞窗口。然而问题是,这种所谓的丢包可能并不是真的丢包,这只是TCP认为丢包而已,这是30年前的丢包判断机制了...
真的丢包了吗?不一定啊!
总的来讲,bbr之前的拥塞控制逻辑在执行过程中会分为两种阶段,即正常阶段和异常阶段。在正常阶段中,TCP模块化的拥塞控制算法
主导窗口的调整,在异常阶段中,TCP核心的拥塞控制状态机
从拥塞控制算法那里接管窗口的计算,逻辑如下:
static void tcp_cong_control(struct sock *sk, u32 ack, u32 acked_sacked,
int flag)
{
if (tcp_in_cwnd_reduction(sk)) { // 异常模式
/* Reduce cwnd if state mandates */
// 在进入窗口下降逻辑之前,还需要tcp_fastretrans_alert来搜集异常信息并处理异常过程。
tcp_cwnd_reduction(sk, acked_sacked, flag);
} else if (tcp_may_raise_cwnd(sk, flag)) { // 正常模式或者安全的异常模式!
/* Advance cwnd if state allows */
tcp_cong_avoid(sk, ack, acked_sacked);
}
tcp_update_pacing_rate(sk);
}
BBR做了哪些改动呢?其实就是在异常模式不再让拥塞控制状态机进行接管:
static void tcp_cong_control(struct sock *sk, u32 ack, u32 acked_sacked,
int flag, const struct rate_sample *rs)
{
const struct inet_connection_sock *icsk = inet_csk(sk);
// 这里是新逻辑,如果回调中宣称自己有能力解决任何拥塞问题,那么交给它!
if (icsk->icsk_ca_ops->cong_control) {
icsk->icsk_ca_ops->cong_control(sk, rs);
// 直接return!TCP核心不再过问。
return;
}
// 这是老的逻辑。
if (tcp_in_cwnd_reduction(sk)) {
/* Reduce cwnd if state mandates */
// 如果不是Open状态...记住,tcp_cwnd_reduction并不受拥塞控制算法控制!!
tcp_cwnd_reduction(sk, acked_sacked, flag);
} else if (tcp_may_raise_cwnd(sk, flag)) {
/* Advance cwnd if state allows */
tcp_cong_avoid(sk, ack, acked_sacked);
}
tcp_update_pacing_rate(sk);
}
bbr不断采集连接内时间窗口内的最大带宽max-bw和最小RTT min-rtt,并以此计算发送速率和拥塞窗口,依据反馈的实际带宽bw和max-rtt调节增益系数
。
bbr算法消除了不必要的锯齿。这种锯齿在bbr之前简直就是TCP的动力源,各种算法盲目地增窗,一旦TCP认为丢包发生(虽然可能并不是真的丢包。所以才有了各种越来越复杂的机制,比如DSACK之类的…),在留下一个几乎拍脑袋拍出来的ssthresh
之后,所有逻辑均被接管,而这里就是锯齿的齿尖之所在。事实上,锯齿是由于TCP拥塞状态机控制逻辑和TCP拥塞控制算法之间在拥塞事件发生时“工作交接
”而形成的,bbr算法中取消了这种不必要的交接
,因此锯齿也自然变钝甚至磨平了。
不是Vegas,CUBIC等无法发现拥塞,是TCP并不将权力全权交给它们从而导致的Vegas,CUBIC等如此眼瞎如此盲目。这事实上可能是最初的TCP实现中的做法,比如ssthresh这个概念,事实上很多算法中并不需要这个东西,只是为了迎合“大师的标准”罢了。bbr没有使用ssthresh
(ssthresh体现了拥塞算法与TCP拥塞状态机之间的耦合,bbr没有这种耦合,所以不需要ssthresh)。
大端字节序(big endian)指的是正数的高位字节存储在内存的低地址处,低位字节存储在内存的高地址处。大端字节序又称为网络字节序
。
小端字节序(little endian)则相反。现代PC主要采用小端字节序,小端字节序又称为主机字节序
。
如果数据在两个使用不同字节序的主机间(或者同一台机器的两个进程间)
直接传递,接收端必然错误解释。因此,统一用大端字节序进行传递
,即,发送方总是把要发送的数据转化成大端字节序数据后再发送,接收端根据自身字节序决定是否转化。
表示socket地址的是结构体sockaddr,定义如下:
#include
struct sockaddr
{
sa_family_t sa_family; //地址族变量
char sa_data[14]
}
以上的通用socket结构体并不好用。在设置和获取IP地址和端口号的时候都需要执行繁琐的位操作。因此Linux为各个协议族
提供了专用的socket结构体。
值得注意的是:
1、这些专用socket结构体里的端口号都是用网络字节序
表示的。
2、所有专用socket地址(以及sockaddr_storage)类型的变量都需要在实际使用时转化
为通用socket地址类型sockaddr(直接强制转换),因为所有socket编程API用的地址参数的类型都是sockaddr。
完成整数表示的IP地址(int)与点分十进制字符串(in_addr_t)之间的转换。
一个socket有哪些属性?协议族(UNIX还是IPv4还是IPv6)、传输层协议(TCP还是UDP)。额外还可以设置非阻塞、CLOEXEC。
#include
#include
int socket (int domain, int type, int protocol);
domain参数
告诉系统使用哪个底层协议族,可选项有PF_UNIX/PF_INET/PF_INET6等等,可以参考man手册。
type参数
指定服务类型。SOCK_STREAM服务是流服务,SOCK_UGRAM服务是数据报服务。前者表示传输层使用TCP协议,后者表示传输层使用UDP协议。
值得注意的是,在Linux内核版本2.6.17之前的Linux中,SOCK_NONBLOCK和SOCK_CLOEXEC这两个文件描述符属性需要使用额外的系统调用(比如fcntl)来设置。在2.6.17之后的Linux中,这两个属性可以直接与服务类型相与并作为type参数
。SOCK_NONBLOCK和SOCK_CLOEXEC分别指的是将新创建的socket设置为非阻塞、用fork调用创建子进程时在子进程中关闭该socket。
protocol表示在前两个参数构成的协议集合下,再选择一个具体的协议。这个值通常是唯一的,因为前两个参数已经决定了它的值。几乎在所有情况下,我们应该将它设置为0,表示使用默认协议。
创建socket成功返回0,失败返回-1并设置errno。
在创建socket时,我们只是指定了socket用的协议族,但是并未指定使用该地址族里的哪个具体socket地址(IP和port),因此,我们将新创建的socket与sockaddr结构体绑定
称为命名socket。
服务器通常要命名socket,否则客户端不知道如何连接它。
客户端通常不需要命名socket,而是采用匿名方式
,即使用操作系统自动分配的socket地址。(随机端口
)
#include
#include
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);
bind成功返回0,失败返回-1并设置errno。
常见errno:
1、 EACCES 被绑定的地址是受保护的地址。(比如知名服务端口0-1023,这些端口仅有超级用户能访问)。
2、EADDRINUSE 被绑定的地址正在使用,比如正处于TIME_WAIT状态的socket地址。
#include
int listen (int sockfd, int backlog);
sockfd就是socket文件描述符,backlog是内核监听队列的最大长度(典型值是5)。backlog溢出,则不再受理新的客户连接(listen和accept之间进行connect,即三次握手),客户端将收到ECONNREFUSED
错误信息。
PS
:在内核版本2.2之前,backlog是指半连接状态(SYN_RCVD)和完全连接状态(ESTABLISHED)的socket的上限。2.2之后只表示完全连接状态的socket的上限。
监听队列中完整连接的上限通常比backlog的值略大
。
#include
#include
int accept (int sockfd, struct sockaddr *addr, socklen_t *addrlen);
第二个参数是客户端的socket地址,第三个参数是该socket地址的长度(类型是socklen_t *)。
accept只是从监听队列里取出连接,而不论连接处于何种状态(比如客户端网络断开后,仍然处于ESTABLISHED状态,或者建立连接后退出客户端程序,处于TIME_WAIT状态),更不关心任何网络状况的变化。accept依然调用成功。
#include
#include
int connect (int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
可以看到,connect的API和accept的API中的参数类型是一致的。
connect调用失败的errno有:
1、ECONNREFUSED 目标端口不存在(或者被占用,比如TIME_WAIT),连接被拒绝。
2、ETIMEDOUT 连接超时。
有两种关闭方式
,close和shutdown。
第一种:close
#include
int close (int fd);
fd就是待关闭的socket,在多进程程序中,只有父进程和通过fork系统调用产生的子进程都对该socket执行close调用才能将连接关闭。每次fork,fd的引用计数+1(这里类似于硬链接),只有引用计数为0才能真正关闭连接
。
#include
int shutdown(int sockfd, int howto);
不考虑引用计数,无论如何都要立即终止连接时,使用shutdown。第二个参数howto可以控制关闭该socket的读还是写(或者都关)。
read和write同样适用于socket(毕竟LINUX中万物皆是文件)。但是还有几个专门用于socket读写的系统调用,它们增加了对数据读写的控制。
用于TCP的读写API是recv和send
#include
#include
ssize_t recv(int sockfd, void * buf, size_t len, int flags);
ssize_t send(int sockfd, const void * buf, size_t len, int flags);
第二、三个参数是读写缓存区的位置、大小。
recv成功时返回实际读取到的数据的长度,可能小于期望读到的长度len。因此往往需要多次调用recv
。
recv返回0意味着通信双方关闭连接,返回-1说明出错并设置了errno。
sned类似。
flag的使用
可以参考 82页。flag参数只是对send和recv的当前调用生效,setsockopt系统调用则永久性地修改socket的某些属性。
#include
#include
ssize_t recvfrom(int sockfd, void * buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);
ssize_t sendto(int sockfd, const void * buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t* addrlen);
与recv和send的含义类似。值得一提的是,recvfrom和sendto也可以用于面向连接(STREAM)的socket的数据读写,只要将最后两项设置为NULL即可。
#include
ssize_t recvmsg(int sockfd, void * buf, struct msghdr* msg, int flags);
ssize_t sendto(int sockfd, void * buf, struct msghdr* msg, int flags);
具体可以参考86页。
略。
可以用于获取一个连接socket
的本端socket地址和远端socket地址。注意,连接socket只能由accept系统调用返回。
setsockopt可以设置socket文件描述符属性,getsockopt可以读取socket文件描述符属性。
值得一提的是,有的socket选项应该在TCP同步报文段中设置(比如TCP最大报文段选项), 这种选项必须在connect成功之前设置(因此三次握手里已经设置好了TCP最大报文段),Linux给出的解决方案是,对监听socket设置这些socket选项,那么accept返回的连接socket将自动继承这些选项。
选项的例子可以在书中找到
。
socket地址的两个属性:port和ip地址。都是用数值表示的,不便于扩展(比如从IPv4到IPv6)。
telnet 127.0.0.1 80
telnet localhost www
上面这两行语句等价,原因是什么?中间调用了网络信息API。
可以通过主机名或者主机IP地址得到主机的完整信息(存储在hostent结构体里)
。
需要一个const char* 类型的名字字符串,该函数通常先在本地的/etc/hosts配置文件中查找,如果没找到再去访问DNS服务器。
gethostbyaddr需要目标主机的IP地址(const void*)、所指IP地址的长度(size_t)、IP地址的类型(int型,可以选择AF_INET和AF_INET6,分别表示IPv4和IPv6)。
可以通过主机名或者端口号 与服务名得到主机的完整信息(存储在servent结构体里)
。
这两个函数都是用于获取服务的完整信息。比如可以获取tcp、udp的完整信息。
getaddrinfo函数可以通过主机名获得IP地址,也可以通过服务名获得端口号。
内部用的是gethostbyname和getservbyname
。
getnameinfo函数类似于getaddrinfo的反向
,可以通过socket信息获得主机名和服务名。
内部用的是gethostbyaddr和getservbyport
。
Linux的基础I/O函数(比如open和read)十分常用,但一些高级的I/O函数在特定条件下可以表现出优秀的性能。主要讲讲和网络编程相关的几个。这些函数大致分为三类:
1、创建文件描述符的函数。pipe、dup/dup2
2、读写数据的函数。readv/writev、sendfile、mmap/munmap、splice、tee
3、控制I/O行为和属性的函数。fcntl
下面逐一讲讲上面这些函数。
#include
int pipe(int fd[2]);
fd[2]存放着两个文件描述符,fd[1]是读端文件描述符,fd[0]是写端文件描述符。
管道是单向的,如果需要双向读写,必须创建两个管道。
管道内部传输的是数据量,容量大小默认是65536字节。可以用fcntl函数修改管道容量。
socket的基础API中有一个socketpair函数,可以方便地创建双向管道
#include
#include
int socketpair(int domain, int type, int protocol, int fd[2])
前三个参数与socket函数的参数一致,最后一个参数对应的两个文件描述符都是可读可写
的。
可以将标准输入重定向到一个文件,或者把标准输出重定向到一个网络连接(比如CGI编程)。
readv函数将数据从文件描述符读到分散的内存块中,即分散读。writev函数则将多块分散的内存数据一并写入文件描述符中,即集中写。
#include
ssize_t readv(int fd, const struct iovec* vector, int count);
ssize_t writev(int fd, const struct iovec* vector, int count);
其中iovec结构体描述一块内存区。
#include
struct iovec {
ptr_t iov_base; /* Starting address */
size_t iov_len; /* Length in bytes */
};
struct iovec定义了一个向量元素。通常,这个结构用作一个多元素的数组。对于每一个传输的元素,指针成员iov_base指向一个缓冲区,这个缓冲区存放的是readv所接收的数据或是writev将要发送的数据。成员iov_len在各种情况下分别确定了接收的最大长度以及实际写入的长度。
比如HTTP应答时,服务器需要发送状态行、多个头部字段、一个空行和文档的内容。其中,往往前三个部分在同一块内存中,而文档内容通常读入到另外一块内存中,这里就可以用writev函数将它们同时写出。
struct iovec iv[2];
iv[0].iov_base = header_buf;
iv[0].iov_len = strlen(header_buf);
iv[1].iov_base = file_buf;
iv[1].iov_len = file_stat.st_size;
其中header_buf和file_buf都是char数组,具体实现可以参考105页。
在两个文件描述符间直接传递数据(完全在内核操作),避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,这被称为零拷贝(zero copy)。
#include
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count);
out_fd表示输出描述符,in_fd表示输入描述符,offset表示输入文件流的起始位置(为空则从文件的默认起始位置开始),count表示在两个描述符之间传输的字节数。
sendfile成功则返回传输的字节数,失败返回-1并设置errno。
in_fd必须是一个支持类似mmap函数的文件描述符,即它必须指向真实文件,不能是socket或者管道。而out_fd则必须是一个socket。由此可见,sendfile几乎是专门为在网络上传输文件而设计的
。
用法:
const char* file_name = argv[3];//当客户机telnet到服务器上,服务器即可获得该文件
int filefd = open(file_name , O_RDONLY);
struct stat stat_buf;
fstat(filefd , &stat_buf);
...
//socket
//bind
//listen
//connect
...
if(connfd >= 0)
{
sendfile(connfd , filefd , NULL , stat_buf.st_size);
}
其中有一个stat结构体用于保存文件状态
,而fstat用于将filefd的文件状态保存到stat结构体里
:
#include
#include
int stat(
const char *filename //文件或者文件夹的路径
, struct stat *buf //获取的信息保存在内存中
); //! prototype,原型
正确——返回0
错误——返回-1,具体错误码保存在errno中
struct stat
{
dev_t st_dev; /* ID of device containing file -文件所在设备的ID*/
ino_t st_ino; /* inode number -inode节点号*/
mode_t st_mode; /* protection -保护模式?*/
nlink_t st_nlink; /* number of hard links -链向此文件的连接数(硬连接)*/
uid_t st_uid; /* user ID of owner -user id*/
gid_t st_gid; /* group ID of owner - group id*/
dev_t st_rdev; /* device ID (if special file) -设备号,针对设备文件*/
off_t st_size; /* total size, in bytes -文件大小,字节为单位*/
blksize_t st_blksize; /* blocksize for filesystem I/O -系统块的大小*/
blkcnt_t st_blocks; /* number of blocks allocated -文件所占块数*/
time_t st_atime; /* time of last access -最近存取时间*/
time_t st_mtime; /* time of last modification -最近修改时间*/
time_t st_ctime; /* time of last status change - */
};
mmap函数可以指定一段内存,将这段内存作为IPC的共享内存,也可以将文件直接映射到其中。munmap则释放这段内存空间。
普通文件映射到进程地址空间后,进程可以像访问内存的方式一样对文件进行访问,不需要其他系统调用(read,write)去操作。
mmap函数的API如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
mmap函数成功返回指向内存区域的指针,图上的进程的地址空间的开始地址就是mmap函数的返回值,失败返回MAP_FAILED。
addr,某个特定的地址作为起始地址,当被设置为NULL,系统会在地址空间选择一块合适的内存区域。
length说的是内存段的长度。
prot是用来设定内存段的访问权限。
flags参数控制内存段内容被修改以后程序的行为。
mmap可以映射内存,也可以映射文件。
1、当映射内存时,可以用于IPC共享内存,作用与shm类似,且调用更简单。
2、用于映射文件时,将不必再对文件执行I/O操作,这意味着在对文件进行处理时将不必再为文件申请并分配缓存,所有的文件缓存操作均由系统直接管理,由于取消了将文件数据加载到内存、数据从内存到文件的回写以及释放内存块等步骤,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。
mmap的共享内存方式:
1、进程间通信。使用普通文件提供的内存映射:适用于任何进程之间
;此时,需要打开或创建一个文件,然后再调用mmap();典型调用代码如下:
fd=open(name, flag, mode);
if(fd<0)
...
ptr=mmap(NULL, len , PROT_READ|PROT_WRITE, MAP_SHARED , fd , 0);
2、父子进程之间通信。使用特殊文件提供匿名内存映射:适用于具有亲缘关系的进程之间;由于父子进程特殊的亲缘关系,在父进程中先调用mmap(),然后调用fork()。那么在调用fork()之后,子进程继承父进程匿名映射后的地址空间,同样也继承mmap()返回的地址
,这样,父子进程就可以通过映射区域进行通信了。注意,这里不是一般的继承关系。一般来说,子进程单独维护从父进程继承下来的一些变量。而mmap()返回的地址,却由父子进程共同维护。
零拷贝地在两个文件描述符之间移动数据。
#include
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参数是待输入描述符。如果它是一个管道文件描述符,则off_in必须设置为NULL;否则off_in表示从输入数据流的何处开始读取,此时若为NULL,则从输入数据流的当前偏移位置读入。
fd_out/off_out与上述相同,不过是用于输出。
len参数指定移动数据的长度。
flags参数则控制数据如何移动:
使用splice时, fd_in和fd_out中必须至少有一个是管道文件描述符
。
调用成功时返回移动的字节数量;它可能返回0,表示没有数据需要移动,这通常发生在从管道中读数据时而该管道没有被写入的时候。
失败时返回-1,并设置errno
int pipefd[2];
ret = pipe(pipefd); //创建管道
assert(ret != -1);
//将connfd上的客户端数据定向到管道中
ret = splice(connfd, NULL, pipefd[1], NULL,32768, SPLICE_F_MORE | SPLICE_F_MOVE);
assert(ret != -1);
//将管道的输出定向到connfd上
ret = splice(pipefd[0], NULL, connfd, NULL,32768, SPLICE_F_MORE | SPLICE_F_MOVE);
assert(ret != -1);
close(connfd);
}
与splice函数类似,不同点是tee函数的描述符都必须是管道描述符。
控制文件描述符属性和行为。与ioctl作用相同,ioctl比fcntl能够执行更多的控制。
但是,对于控制文件描述符常用的属性和行为
,fcntl是由POSIX规范指定的首选方法。
将服务器解构为如下三个主要模块:
1、I/O处理单元。介绍I/O处理单元的四种I/O模型和两种高效事件处理模式。
2、逻辑单元。介绍逻辑单元的两种高效并发模式,以及高效的逻辑处理方式——有限状态机。
3、存储单元。它是服务器程序的可选模块,其内容与网络编程本身无关,因此暂不讨论。
由于资源都被数据提供者垄断,因此几乎所有的网络应用程序都很自然地使用了C/S(客户端/服务器)模型。
C/S模型的逻辑:
1、socket,创建一个或多个监听socket
2、bind,绑定到服务器感兴趣的端口上(比如80是HTTP,443是HTTPS)
3、listen,等待客户连接
4、客户端调用connect发起连接(三次握手)
由于客户连接请求是随机到达的异步事件
,因此需要一种I/O模型来监听这一事件。
监听到事件并建立连接后,分配一个逻辑单元(可以是子进程、子线程或者其他)
为新的连接服务。
监听和处理可以并行,同时监听多个客户请求是通过select、poll、epoll系统调用实现的。
C/S模型适用于资源相对集中的场景
。
Peer ot Peer,点对点。每台机器的地位是对等的,消耗服务的同时也给别人提供服务。
优点:资源能够充分、自由地共享。
缺点:用户之间的请求过多时,网络的负载将加重。
P2P模型存在一个显著的问题:主机之间很难互相发现。所以实际使用的P2P模型通常带有一个专门的发现服务器。这个发现服务器通常还提供查找服务(甚至还提供内容服务),使每个客户都能尽快找到自己需要的资源。
P2P模型可以看作C/S模型的扩展:每台主机既是客户端,又是服务器。
阻塞I/O
客户端通过connect向服务器发起连接时,connect将首先发送同步报文给服务器,等待服务器返回确认报文段。如果服务器的确认报文段没有立即到达客户端,则connect调用将被挂起,直到客户端收到确认报文段并唤醒connect调用。
非阻塞I/O
无论事件是否发生,都立即返回 -1 并设置 errno、如何区分出错和正常返回?对accept、send和recv,事件未发生时,errno被设置为EAGAIN(”再来一次“)或者EWOULDBLOCK(”期望阻塞“);对connect而言,errno则被设置为EINPROGRESS(”在处理中“)。
非阻塞I/O 通常要与其他I/O通知机制一起使用,比如I/O复用和SIGIO信号
。
1、I/O复用
应用程序向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序。Linux常用的I/0复用函数是select、poll和epoll_wait。值得一提的是!
I/O复用本身是阻塞的,但由于能同时监听多个I/O事件,因此可以提高程序效率。
2、SIGIO信号
为一个文件描述符指定宿主进程,该文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发,在信号处理函数里对目标文件描述符执行非阻塞I/O操作。由于是信号触发,没有阻塞阶段。
同步I/O VS 异步I/O
1、同步I/O要求用户代码自行执行I/O操作,异步I/O则由内核执行I/O操作(用户缓冲区与内核缓冲区之间数据的移动是由用户还是内核执行的区别)。
2、同步I/O向用户程序通知的是I/O就绪事件,异步I/O向用户程序通知的是I/O完成事件。
服务器程序通常需要处理三类事件:I/O事件、信号及定时事件。
有主线程和工作线程。主线程只负责监听文件描述符上是否有事件发生,有的话立即将该事件通知工作线程。工作线程负责读写数据、接受新的连接、处理客户请求。
使用同步I/O模型(以epoll_wait为例)实现的Reactor模式的工作流程是:
1、主线程往epoll内核事件表中注册socket上的读就绪事件。
2、主线程调用epoll_wait等待数据可读写。
3、有数据可读写时,epoll_wait通知主线程。主线程将socket可读写事件放入请求队列
4、睡眠在请求队列上的某个工作线程被唤醒,如果是读事件,则从socket读取数据并处理客户请求;如果是写事件,则往socket上写入服务器处理客户请求的结果。
与Reactor模式不同,Proactor将所有I/O操作交给主线程和内核来处理。工作线程仅仅负责业务逻辑。
使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程是:
1、调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读(写)缓存区的位置,以及读操作完成时如何通知应用程序(这里以信号为例
)。
2、主线程继续处理其他逻辑。
3、当socket上的数据被读入用户缓存区(或者用户缓存区的数据被写入socket)之后,内核向应用程序发送一个信号,以通知应用程序数据可用(或者通知应用程序数据已经发送完毕)。
4、应用程序预先定义好的信号处理函数选择一个工作线程做善后处理,比如决定是否关闭socket。
模拟Proactor模式是用同步I/O模拟Proactor模式。与Reactor的主要区别在第三步第四步:
3、有数据可读写时,epoll_wait通知主线程。如果是读事件,主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。如果是写事件,主线程往socket上写入服务器处理客户端请求的结果。
以上是模拟Proactor模式的最后一步,I/O操作由主线程来完成
。
程序可以分为计算密集型和I/O密集型
。由于I/O操作远远没有CPU的计算速度快,如果程序阻塞于I/O操作将耗费大量的CPU时间,因此需要并发编程。如果被I/O阻塞的执行线程主动放弃CPU(或者由操作系统进行调度),CPU就可以用来做更有意义的事情,显著提高利用率。
并发编程主要有多进程和多线程两种方式
。
这里的同步指的是程序完全按照代码序列的顺序执行。异步指的是程序的执行需要由系统事件来驱动。
前面I/O模型讲的同步异步区分的是内核向应用程序通知的是何种I/O事件(就绪事件还是完成事件),以及谁来完成I/O读写(应用程序还是内核)。
同步线程:按照同步方式运行的线程。
异步线程:按照异步方式运行的线程。
1、一般来说,同步线程就是工作线程,异步线程是主进程。
2、异步线程用于监听
客户请求,将客户请求封装
成请求对象并插入
请求队列。请求队列通知某个工作在同步模式的工作线程来读取并处理
该请求。
以下是半同步/半异步模式的几种变体:
在高效的半同步/半异步模式中,每个线程都维持自己的事件循环,各自独立地监听不同的事件,并且都工作在异步模式,因此并非严格的半同步/半异步模式。
暂略
有限状态机可以用于逻辑单元内部
(比如HTTP请求的读取和分析)的高效编程。
p136-p143 以 HTTP请求的读取和分析 为例子。
由于系统硬件资源充裕,以空间换时间,即用服务器的硬件资源换取其运行效率。因为动态分配资源是非常耗费时间
的。因此,在服务器启动之初就创建并初始化一组资源,这组资源就叫做池
。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用来释放资源
。
初始化时分配足够多,如果后续还是不够,再动态分配
。
根据资源类型,可以分为:内存池、进程池、线程池、连接池。
在web server里,
内存池用于socket的接收缓存和发送缓存。比如HTTP请求放在一个预先分配大小的接收缓存区里(比如5000字节),不够再分配。
线程池和进程池的好处是无需用fork或者pthread_create来创建进程或线程,我们可以直接从池里取得一个执行实体。
连接池是服务器预先与数据库程序建立的一组连接的集合。某个逻辑单元需要访问数据库时,直接从池里取得连接实体即可。
在客户端和服务器之间尽量避免数据复制,比如使用sendfile这样的零拷贝函数。
代码内部尽量避免数据复制,比如进程间使用共享内存而不是管道、消息队列,比如HTTP请求中使用指针来指出行在buffer中的起始位置,而不是复制。
即使是I/O密集型的服务器,也不应该使用过多的工作线程(或工作进程),否则上下文切换将占用大量的CPU时间。因此,上文的高效半同步/半异步模式是一种比较合理的解决方案,它允许一个线程(进程)同时处理多个客户连接
。
锁需要访问内存资源,会导致服务器效率低下。可以考虑减小锁的粒度,比如使用读写锁。