网络编程-套接字篇
Posted lovejune
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了网络编程-套接字篇相关的知识,希望对你有一定的参考价值。
本博来源:《linux高性能服务器编程》
TCP/IP协议族是一个四层协议系统。
可以看到该协议族是由多个协议组成的。其中重要的是IP协议、TCP协议、UDP协议。
同时可以清楚的看到,socket套接字处于应用层和传输层的中间,充当一个调度者的角色。
1.数据链路层:实现了网卡接口的网络驱动程序。以处理数据在物理媒介上传输。网络驱动程序隐藏了不同物理网络的电气特性,为上层协议提供了统一的接口。
常用到两个互逆的协议:ARP和RARP协议。它们实现了IP地址和机器物理地址的相互转换。
2.网络层:实现数据包的选路和转发。通信的两台主机不是直接相连的,而是通过多个中间节点(路由器)连接的。网络层来选择这些中间节点,确定主机之间的通信路线。同时,网联层对上层协议隐藏了网络拓扑连接的细节。
最核心的协议是IP协议,一个重要的协议是ICMP协议。
ICMP协议主要用于检测网络连接。
3.传输层:传输层为两台主机上的应用程序进行端到端的通信。只关心通信的起始端和目的端。不在乎数据包的中转过程。
传输层主要有三个协议:TCP协议、UDP协议、SCTP协议。
SCTP协议是为在因特网上传输电话信号而设计的。
4.应用层:负责处理应用程序的逻辑,在用户空间实现,负责处理众多逻辑,比如文件传输、名称查询、网络管理等。
ping是应用程序,不是协议。它利用ICMP报文检测网络连接。
telnet协议是一种远程登录协议。在本地完成远程任务。
OSPF协议(开放最短路径优先)是一种动态路由更新协议。用于路由器之间的通信,告知对方各自的路由信息。
DNS协议提供机器域名到IP地址的转换。
以上就是介绍的TCO/IP的四层协议。
而上层协议是如何使用下层协议提供的服务?是通过封装实现的。应用程序数据在发送到物理网络之前,沿着协议栈从上往下依次传递。每层协议在上层数据的基础上加上自己的头部信息。
当以太网帧到达目的主机时,将沿着协议栈自底向上依次传递,各层协议依次处理帧中本层负责的头部数据。获取所需数据。最终交给目的应用程序。这个过程称为分用。
在顶层目标服务来看,封装和分用好像没有发生过。
ARP协议:实现任意网络层地址到任意物理地址的转换。(IP地址->mac地址)
工作原理:主机向自己所在的网络广播一个ARP请求,该请求包含目标机器的网络地址,而所在的网络上的机器都会收到这个请求,但只有目标机器会回应一个应答。应答里包含了目标机器的物理地址。
通常,ARP会维护一个高速缓存。包含经常访问或者最近访问的机器地址映射。可以避免重复的ARP请求,提高发送数据包的速度。
DNS协议:DNS是一套分布式的域名服务系统。DNS服务器存放着大量的机器名和IP地址的映射。网络客户端程序是使用DNS协议来向DNS服务器查询目标主机的IP地址。
命令:host www.baidu,com 可以找到其主机ip
IP协议:是socket网络编程的基础之一。主要是两方面:IP头部信息、IP数据报的路由和转发。
IP协议服务的特点:无状态、无连接、不可靠。
无状态:IP通信双方不同步传输数据的状态信息。所有IP数据报的发送、传输和接收都是相互独立的。最大的缺点是无法处理乱序和重复的IP数据报。但可以将数据报交给上层协议来处理。优点是简单。高效。
无连接:IP通信双方都不长久的维持对方的任何信息。上层协议每次发送数据,都必须明确指定对方的IP地址。
不可靠:IP通信不能保证数据报准确的到达接收端。只是承诺尽最大努力。
IPv4的头部结构:
4位版本号:IPv4的值为4,其他版本的值不同。
4位头部长度:标识IP头部有多少个32bit字。最长为60个字节。
8位服务类型:表示最小延时、最大吞吐量、最高可靠性、最小费用,其中最多能置为1。
16位总长度:整个IP数据报的长度。
16位标识:唯一的标识主机发送的每一个数据报。初始值随机生成,每发送一个就加1,该值在数据报分片的时候,被复制到每一个分片中。即同一个数据报的所有分片都具有相同的标识值。
3位标志字段:第二位表示禁止分片。第三位表示更多分片。
13位分片偏移:分片相对原始IP数据报开始处的偏移。
8位生存时间:数据报到达目的地之前允许经过的路由器跳数。TTL被发送端设置。每经过一个路由,值减一。当值为0时,数据报被路由器丢弃,向源端发送ICMP差错报文。
8位协议:用来区分上层协议,ICMP为1,TCP为6,UDP为7
16位头部校验和:由发送端填充,接收端对其使用CRC算法以检验IP数据报头部在传输过程中是否损坏。
选项:可变长的可选信息,最多包含40个字节。作用:记录路由、时间戳、松散源路由选择、严格源路由选择。
IP分片:当IP数据报的长度超过帧的MTU时,会被分片传输。分片发生在发送端,也可以发生在中转路由器上。也可以被多次分片,但最终在目标机器上才会被重组。
IP路由:IP协议的核心任务是数据报的路由,决定发送数据报到目标机器的路径。
从上图可以知道,首先IP模块接收来自数据链路层的IP数据报,对IP数据报的头部进行处理,校验。确认无误。
如果IP数据报设置了源站选路选项,则IP模块调用数据报转发子模块来处理该数据报;如果该数据报的目标地址是本机,那么IP模块会根据头部中的协议字段来决定派发给哪个上层应用;如果发现该数据报不是发送给本机的,也会调用数据报转发子模块来处理。
数据报转发模块首先检测系统是否允许转发,不允许,则直接丢弃。如果允许,那么就会操作计算下一跳路由的子模块,其核心是路由表,该表按照数据报的目标IP地址来分类,同一类型的IP数据报将被发送到相同的下一跳路由器。
在IP输出队列中,存放的是所有等待发送的IP数据报。
IP的路由机制:1.查找路由表中和数据报目标IP地址完全匹配的主机IP地址,如果找到,就使用该路由项;2.如果没有找到,就查找路由表中,和数据报的目标IP地址具有相同网路ID的网络IP地址。如果找到,就使用该路由;3.如果没有找到,选择默认路由项,下一跳路由是网关。
IP转发:路由器来执行数据报的转发,事实上通过修改主机内核参数,使主机也具有数据报转发功能。
重定向:是ICMP的重定向,由上图可以知道,ICMP的重定向可以用于更新路由表。
TCP协议:四个方面,TCP头部信息、TCP状态转移过程、TCP数据流、TCP数据流的控制。
TCP协议的特点就是:面向连接、字节流、可靠传输。
面向连接:TCP协议双方必须先建立连接,然后开始数据的读写。双方都要为该连接分配必要的内核资源,来管理连接的状态的连接上数据的传输。连接是全双工的,双方的数据读写可以通过一个连接来进行。这种连接是一对一的。
字节流:当发送端连续执行多次写操作的时候,TCP模块先将这些数据放入TCP发送缓存区,TCP模块真正发送数据的时候,发送缓存区中这些等待的数据可能被封装为一个或者多个TCP报文段发出。当接收端收到一个或者多个TCP报文段的时候,TCP模块会将它们携带的数据按照TCP报文段的序号依次放入TCP接收缓存区中,并通知应用程序读取数据。接收端可以一次性的将TCP接收缓存区的数据全部读出,也可以分多次读取。取决于用户指定的应用程序读缓存区的大小。总结来说,发送端执行的写操作和接收端执行的读操作之间没有任何数量关系。即字节流的概念:应用程序对数据的发送和接收是没有边界限制的。
可靠传输:TCP协议采用发送应答的机制,发送端发送的每一个TCP报文段都必须得到接收方的应答。TCP协议采用超时重传机制,发送端发送一个TCP报文段之后启动定时器,如果在定时时间内未收到应答,它将重传报文段。TCP协议会对接收到的TCP报文段重排、整理,再交付给应用层。
TCP头部结构:用于指定通信的源端端口、目的端端口、管理TCP连接。
16位端口号:告知主机该报文段来自哪个端口,传给哪个上层协议或者应用程序(端口)。
32位序号:一次TCP通信过程中,某一个传输方向上的字节流的每个字节编号。A发送给B的第一个TCP报文端中,序号值会被系统初始化为某个随机值,该传输方向上后续的TCP报文段中的序号值将被系统设置为初始值加上其相对第一个字节的偏移。
32位确认号:用作对另外一方发送的TCP报文段的响应。其值是收到TCP报文段的序号+1。
4位头部长度:标识该TCP头部有几个32bit字。最长是60个字节。
6个标志位:URG:紧急指针是否有效。ACK:确认号是否有效。PSH:提示接收端应用程序立即从TCP接收端缓存区读走数据。RST:要求对方重新建立连接。SYN:请求建立一个连接。携带SYN的报文段为同步报文段。FIN:通知对方本端要关闭连接。
16位窗口大小:TCP流量控制的手段,窗口指接收通告窗口。告诉对方,本端的接收缓存区还可以容纳多少字节的数据。
16位校验和:由发送端填充,接收端对TCP报文段执行CRC算法检验传输过程中是否损坏。校验不止头部还有数据,保证可靠传输。
16位紧急指针:正的偏移量。发送端向接收端发送紧急数据的方法。
选项:包括,选项表结束选项、空操作选项、最大报文段长度选项、窗口扩大因子选项、选择性确认选项、时间戳选项。
TCP连接的建立和关闭
TCP三次握手:
TCP四次挥手过程:
半关闭状态:TCP是全双工的,允许两个方向的数据传输被独立关闭。即通信的一方可以发送结束报文段给对方,告诉它本端完成了数据的发送。但允许继续接受来自对方的数据,直到对方也发送结束报文段关闭连接。这种纸关闭了一端的情况,被称为半关闭状态。
TCP状态转移:TCP连接的任意一端在任一时刻都处于某种状态,当前状态可以通过netstat命令来查看。
服务器的连接状态:服务器通过listen系统调用进入LISTEN状态,被动等待客户端连接(被动打开)。服务器一旦监听到某个连接请求(收到同步报文段),将该连接放入内核等待队列。向客户端发送带SYN标志的确认报文段。此时,该连接处于SYN_RCVD状态。如果服务器收到客户端发送回的确认报文段,那么连接转移到ESTABLISTEN状态。(ESTABLISTEN状态是连接双方能够进行双方数据传输的状态)。当客户端主动关闭连接时(通过close或者shutdown系统调用向服务器发送结束报文段),服务器通过返回确认报文段使连接进入CLOSE_WAIT状态(CLOSE_WAIT状态表示等待服务器关闭)。服务器检测到客户端关闭连接后,立即给客户端发送一个结束报文段来关闭连接,此时状态转移到LAST_ACK状态。以等待客户端对结束报文段的最后一次确认。一旦确认完成,连接会彻底关闭。
客户端的连接状态:客户端通过connect系统调用,主动与服务器建立连接。connect系统调用首先给服务器发送一个同步报文段,使连接转移到SYN_SENT状态(若目标端口不存在或者端口仍然被TIME_WAIT状态的连接所占用,或者目标端口存在,但connect在超时时间内未收到服务器的确认报文段,这些情况都会导致connect调用失败)。connect调用失败会返回初始状态CLOSE,若客户端收到服务器的同步报文段确认,则调用成功,连接转移到ESTABLISTEN状态。当客户端主动执行关闭时,向服务器发送一个结束报文段,同时进入FIN_WAIT1状态。此时,客户端收到服务器专门用来确认目的的确认报文段,连接将转移到FIN_WAIT2状态。此时服务器会处于CLOSE_WAIT状态,这一对状态可能发生半关闭连接。若服务器也关闭连接(发送结束报文段),那么客户端将给与确认并进入TIME_WAIT状态。(处于FIN_WAIT2状态的客户端需要等待服务器发送结束报文段,才能转入TIME_WAIT状态。否则将长时间处于FIN_WAIT2状态。比如客户端执行半关闭状态后,没等服务器连接关闭就强行退出了,此时的客户端连接会被内核接管,称之为孤儿连接)
TIME_WAIT状态:客户端连接在收到服务器的结束报文后,并没有直接进入CLOSED状态,而是转移到TIME_WAIT状态。客户端需要等待一段长为2MSL(报文最大生存时间)的时间,才能完全关闭。
TIME_WAIT状态存在的原因:1.可靠的终止TCP连接。当用于确认服务器结束报文段的报文被丢失,那么服务器会重新发送结束报文段,因此客户端需要在该状态去处理重复收到的结束报文。否则客户端将以复位报文段来回应服务器。服务器认为这是一个错误。2.保证让迟来的TCP报文段有足够的时间被识别并丢弃。因为一个TCP端口不能被同时打开多次。当一个TCP连接处于TIME_WAIT状态时,我们无法立即使用该连接占用的端口来建立连接。反过来,如果没有这个状态,那么应用程序会利用迟来的报文建立一个和刚关闭的连接相似的连接。而使用2MSL是为了确保网络上两个传输方向上迟到的报文段都消失了。
复位报文段:在特殊情况下,TCP连接的一端会向另一端发送携带RST标志的报文段,即复位报文段,以通知对方关闭连接或重新建立连接。
生成复位报文段的三种情况:1.访问不存在的端口或者请求端口处于TIME_WAIT状态、2.异常终止连接,即给对方发送一个复位报文段,则发送端所有等待发送的排队数据都将被丢弃、3.处理半打开连接,某一端关闭或者异常终止连接,而另一端还维持着原来状态,这种状态就是半打开,如果此时该端向另一端发送写入数据,那么对方将回应一个复位报文段。
TCP数据流:交互数据流、成块数据流
交互数据流:仅包含很少的字节,使用交互数据的应用程序对实时性要求比较高,比如telnet或者shell等。其工作原理是,客户端针对服务器返回的数据所发送的确认报文段不会携带数据,而服务器发送的确认报文段包含所需的应用数据。即服务器采用延迟确认的方式,服务器不马上确认上次收到的数据,而是在一段延迟之后,看本端有没有数据需要发送,如果有,就和确认信息一起发出。
成块数据流:长度通常是TCP报文段允许的最大数据长度,使用成块数据对传输效率要求比较高,比如ftp。其工作原理是,当传输大量大块数据时,发送方会连续发送多个TCP报文段,接收方可以一次确认所有报文段,而发送方收到确认后,下次可发送的报文段数量由接收窗口来决定。而且服务器每发送4个TCP报文段就会传送一个PSH标志,通知客户端尽快读数据。
TCP数据流的控制:超时重传、拥塞控制
超时重传:TCP服务必须能够重传超过时间内未收到确认的TCP报文段,TCP模块为每个报文段都维护一个重传定时器,该定时器在TCP报文段第一次被发送时启动,若超时时间内没有收到接收方的应答,TCP模块会重传报文段并重置定时器。
拥塞控制:提高网络利用率,降低丢包率,并保证网络资源对每条数据流的公平性。拥塞控制有四个部分:慢启动、拥塞避免、快速重传、快速恢复。拥塞控制的最终受控变量是发送端向网络一次连续的写入数据量。(SWND发送窗口),SWND限制了发送端能连续发送的TCP报文段。接收端可通过其接收窗口来控制SWND,但显然不够,因此发送端引入了拥塞窗口的概念(CWND),而SWND是RWND和CWND中的较小者。
慢启动和拥塞避免:TCP连接建立好之后,CWND被设置为初始值(大小为2-4SMSS(报文最大长度)),此后发送端每收到接收端一个确认,cwnd就会增加,增加的算法是指数的。慢启动算法的理由是:TCP模块一开始不知道网络的实际情况,需要去试探。慢启动必然会使cwnd很快膨胀,导致网络拥塞,定义一个慢启动门限,当cwnd大小超过该值后,进入拥塞避免阶段。拥塞避免的算法是线性增加的。
快重传和快恢复:发送端判断拥塞的依据,1.传输超时 2.接收到重复的确认报文段。对于第一种情况,使用慢启动和拥塞避免。第二种情况,算法需要判断当收到重复的确认报文段时,网络是否发生了拥塞。一般来说,发送端如果连续收到3个重复的确认报文段,就认为是拥塞发生了。首先,当收到第三个重复的确认报文段时,计算门限,然后立即重传丢失的报文段,设置cwnd=门限+3SMSS。每次收到1个重复的确认,设置cwnd=cwnd+SMSS。此时发送端可以发送新的TCP报文段。
socketAPI:socket最开始的含义是一个IP地址和端口对。唯一的表示了使用TCP通信的一端。包括socket地址API、socket基础API、网络信息API
socket地址API:
主机字节序和网络字节序
PC大多采用小端字节序,又称为主机字节序。当格式化的数据在两台使用不同字节序的主机之间直接传递时,接收端会发生错误。而大端字节序也称为网络字节序,给所有接收数据的主机提供了一个正确解释收到的格式化数据的保证(发送端把数据转化为大端字节序来发送,接收端根据自己的字节序转换)。同一个机器的两个进程也需要考虑字节序的问题。
通用socket地址:使用结构体定义,提供了足够大的空间来存放地址值。(socketaddr)
专用socket地址:各个协议族提供的专门的socket地址结构体。ipv4的地址结构体为socketaddr_in、ipv6的结构体为socketaddr_in6。所有专用的地址类型变量在使用时都需要转换为通用的socket地址类型socketaddr。
ip地址转换函数:inet_addr函数将将用点分十进制字符串表示的ipv4地址转换为网络字节序整数表示的ipv4地址。inet_aton和inet_addr一样。inet_ntoa和上面两个相反。逆操作。
创建socket:在linux中,所有东西都是文件,socket也是文件,是可读、可写、可控制、可关闭的文件描述符。使用socket系统调用可以创建一个socket
int socket(int domain,int type,int protocol)
命名socket:将一个socket与socket地址绑定称为给socket命名。服务器程序一般要命名socket,这样客户端才知道如何连接它,客户端一般不用命名,采用匿名方式。系统自动分配。命名socket是调用系统bind。
int bind ( int sockfd , const struct socketaddr* my_addr , ocklen_t addrlen)
bind将my_addr所指向的socket地址分配给未命名的sockfd文件描述符。而addrlen是指地址长度。
监听socket:使用系统调用来创建一个监听队列以存放待处理的客户连接。
int listen ( int sockfd , int backlog )
sockfd参数指定被监听的socket。backlog参数提示内核监听队列的最大长度。如果现在监听队列的长度如果超过backlog,那么服务器将不受新的客户连接。
接受连接:系统调用从listen监听队列里面接受一个连接。
int accept ( int sockfd , struct sockaddr *addr , socklen_t *addrlen )
sockfd参数是执行过listen调用的监听socket(服务器),而addr参数是获取被接受连接的远程socket地址(客户端)。accept成功后返回一个新的连接socket,唯一的标识了被接受的这个连接。服务器可通过读写该socket来与被接受连接的客户端通信。accept只是从监听队列中取出连接,而不论连接处于哪种状态,更不关心任何的网络状态变化。
发起连接:客户端系统调用主动与服务器建立连接。
int connect ( int sockfd , const struct sockaddr *serv_addr , socklen_t addrlen )
sockfd是客户端系统调用创建的socket,addr地址是服务器监听的socket地址。一旦建立了连接,客户端就可以通过读写sockfd来与服务器通信。
关闭连接:关闭一个连接实际上就是关闭该连接对应的socket
int close(int fd)
fd参数是待关闭的socket,在系统中close调用不是立即关闭一个连接,而是将fd的引用计数域减1,当引用计数为0时,才会真正关闭。
如果要立即终止连接,可以使用shutdown系统调用。
int shutsown(int sockfd,int howto)
sockfd是待关闭的socket,howto参数决定了shutdown的行为。
数据读写:socket的读写操作和文件的读写类似。socket接口提供了几个专门的socket数据读写的系统调用、
ssize_t recv(int sockfd , void *buf , size_t len , int flags);
recv读取sockfd上的数据,buf和len指定读缓存区的位置的大小,成功时返回实际读取到的数据长度。
ssize_t send(int sockfd , const void *buf , size_t len , int flags);
send往sockfd上写入数据,buf和len参数分别指定写缓存区的位置和大小。send成功时返回实际写入的数据长度。
两个调用中的flags参数为数据的收发提供了额外的控制。
通用数据读写:不仅可以用于TCP数据流也可以用于UDP数据报。
以上就是常用API,不常用的先不介绍
高级IO函数:1.用于创建文件描述符的函数、2.用于读写数据的函数、3.用于控制IO行为和属性的函数
pipe函数:创建一个管道,实现进程间的通信。
int pipe(int fd[2])
参数包含了两个包含int型整数的数组指针,成功时,将一对打开的文件描述符填入其参数指向的数组。通过两个文件描述符,分别构成管道的两端,往fd[1]写入的数据可以从fd[0]读出,并且fd[0]只能读数据,而fd[1]只能写数据。要实现双向的数据传输,应该使用两个管道。默认条件下,这对文件描述符是阻塞的。空管道的read是被阻塞的。满管道的write也是阻塞的。
dup函数和dup2函数:用于复制文件描述符,把标准输入或者输出重定向到一个文件或者网络连接上。
int dup(int file_descriptor); int dup2(int file_descriptor_one ,int file_descriptor_two)
dup函数创建一个新的文件描述符,该描述符和原描述符指向相同的文件、管道或者网络连接。
readv函数和writev函数:readv将数据从文件描述符读到分散的内存块,分散读。writev函数将多块分散的内存块数据一并写入文件描述符,集中写。
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是数组长度。
sendfile函数:两个文件描述符之间直接传递数据。避免内核缓存区和用户缓存区之间的数据拷贝,效率很高。为零拷贝。
ssize_t sendfile(int out_fd,int in_fd,off_t* offset,size_t count);
in_fd参数是待读出内容的文件描述符,out_fd是待写入内容的文件描述符。offset参数指定从读入文件流的哪个位置开始读,count是传输的字节数。sendfile是专门为网络上传输文件而设计的。
高性能服务器程序框架
服务器可以分为三个主要模块:IO处理单元、逻辑单元、存储单元
服务器模型:
C/S模型:TCP/IP协议在设计和实现上没有客户端和服务端区别,但多数网络应用程序都采用了C/S的模型,
原理:服务器启动之后,首先创建一个(多个)监听socket,并调用bind函数将其绑定在服务器感兴趣的端口上,然后调用listen函数等待客户连接。服务器稳定运行之后,客户端可以调用connect函数向服务器发起连接请求。由于客户连接请求是随机到达的异步事件,服务器需要使用某种IO模型来监听这个事件。IO模型有很多种。比如IO复用技术之一的select系统调用。
原理:当监听到连接请求之后,服务器调用accept函数接收它,并分配一个逻辑单元为该连接服务。逻辑单元可以是一个新创建的子进程,子线程或者其他。在select里面,逻辑单元是由fork系统调用的子进程。逻辑单元读取客户请求,处理该请求。然后将处理结果返回给客户端。客户端收到服务器反馈后,可以继续发送请求,也可以主动关闭请求。而服务器在处理一个客户请求的同时会继续监听其他客户请求。
C/S模型适合资源相对集中的场合,但缺点是服务器是通信的中心,当访问量很大时,可能所有客户都将得到很慢的响应。
P2P模型:抛弃了以服务器为中心的格局,让网络上所有主机重新回归对等地位。每台服务器在消耗服务的同时也给别人提供服务。资源能充分自由的共享。缺点是,当用户之间的传输请求过多时,网络负载将加重。
服务器编程框架:
IO处理模块:服务器管理客户端连接的模块。等待并接受新的客户连接,接受客户数据,将服务器响应数据返回客户端。(但数据的收发不一定在IO处理单元中执行,也有可能在逻辑单元中执行。这取决于事件处理模式)
逻辑单元:通常是一个进程或者线程。分析并处理客户数据,然后将结果传递给IO处理单元或者直接发给客户端。
网络存储单元:可以是数据库,缓存和文件。不是必须的。
请求队列:各单元之间通信方式的抽象。IO处理单元接收客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。
IO模型:阻塞IO、非阻塞IO、IO复用、SIGIO信号、异步IO
阻塞IO:socket创建时是默认阻塞的,针对阻塞IO执行的系统调用可能因为无法立即完成而被操作系统挂起。知道等待的事件发生为止。比如,客户端通过connect向服务器发起连接时,connect将首先发送同步报文段给服务器,然后等待服务器返回确认报文段。如果服务器的确认报文段没有立即到达客户端,则connect调用将被挂起,直到客户端收到确认报文段并唤醒connect调用。可能被阻塞的系统调用包括:accept、send、recv、connect。
非阻塞IO:这怒地非阻塞IO执行的系统调用则总是立即返回。而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1。显然,我们只有在事件已经发生的情况下操作非阻塞IO,才能提高系统的效率。因此,非阻塞IO通常要和IO复用和SIGIO信号一起使用。
IO复用:最常用的IO通知机制,应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数把其中就绪的事件通知给应用程序。linux中常用的IO复用函数是select、poll和epoll_wait。IO复用本身是阻塞的,能提高程序效率的原因是他们具有同时监听多个IO事件的能力。
SIGIO信号也可以用来报告IO事件。可以为一个目标文件描述符指定宿主进程,那么被指定的宿主进程将捕获到SIGIO信号,当目标文件描述符上有事件发生时,SIGIO信号的信号处理函数将被触发。因此我们可以在该信号处理函数中对目标文件描述符进行非阻塞的IO操作了。
从理论上来说,无论是阻塞IO、IO复用和信号驱动IO都是同步的IO模型,IO的读写操作都要IO事件发生以后,由应用程序来完成。
异步IO:对于异步IO而言,用户可以直接对IO执行读写操作,这些操作告诉内核,用户读写缓冲区的位置,以及IO操作完成后内核通知应用程序的方式。异步的读写操作总是立即返回的,不论IO是否阻塞,真正的读写操作被内核接管。即同步IO模型要求用户代码自行执行IO操作(将数据从内核缓存区读到用户缓存区或从用户缓存区写入内核缓存区),而异步IO机制是由内核来执行IO操作。可以理解为,同步IO向应用程序通知的是IO就绪事件,而异步IO向应用程序通知的是IO完成事件。
两种高效的事件处理模式:Reactor和Proactor
服务器程序通常需要处理三类事件,IO事件、信号事件、定时事件。同步IO模型通常用于实现Reactor模式,异步IO模型则用于实现Proactor模式。
Reactor模式:要求主线程(IO处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。除此之外,主线程不做任何实际性工作。读写数据、接收新的连接以及处理客户端请求这些都在工作线程中完成。
使用同步IO模型(epoll_wait为例)实现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.睡眠在请求队列上的某个工作线程被唤醒,往socket上写入服务器处理客户请求的结果。
Proactor模式:
Proactor模式将所有的IO操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
使用异步IO模型实现的Proactor模式的工作流程:
1.主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核,用户读缓存区的位置,以及读操作完成后如何通知应用程序(信号)
2.主线程继续处理其他逻辑。
3.当socket上的数据被读入用户缓存区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
4.应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件。,并告诉内核用户写缓存区的位置,以及写操作完成时如何通知应用程序。(信号)
5.主线程继续处理其他逻辑。
6.当用户缓存区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
7.应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。
模拟Proactor模式:使用同步IO方式模拟Proactor模式
主线程执行数据读写操作,读写完成后,主线程向工作线程通知这一完成事件。那么从工作线程的角度来看,他们直接获取了数据读写的结果,接下来要做的只是对读写结果的逻辑处理。
使用同步IO模型(epoll_wait)模拟出的Proactor模式的工作流程:
1.主线程往epoll内核事件表中注册socket上的读就绪事件
2.主线程调用epoll_wait等待socket上有数据可读。
3.当socke上有数据可读时,epoll_wait通知主线程,主线程从socket循环读取数据,直到没有更多的数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
4.睡眠在请求队列上的某个工作线程被唤醒,它获取请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件。
5.主线程调用epoll_wait等待socket可写。
6.当socket可写时,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。
两种高效的并发模式:对于IO密集型的程序,经常读写文件,访问数据库,由于IO操作的速度远远没有cpu的计算速度快,所以让程序阻塞IO操作将浪费大量的CPU时间。如果程序有多个执行线程,则当前被IO阻塞的执行线程可主动放弃cpu,并将执行权转移到其他线程上,那么cpu就可以用来做更多有意义的事情,而不是等待IO操作完成。而服务器的两种并发编程模式:半同步/半异步(half-sync/half-async)、领导者/追随者(Leader/Followers)模式。
半同步/半异步模式:这里的同步异步和IO模型里面的概念不同,在IO模型中,同步,异步的区分是内核向应用程序通知的是何种IO事件(就绪事件还是完成事件),以及该由谁来完成IO读写(应用程序还是内核)。而在并发模式中,同步指程序完全按照代码序列的顺序执行,异步指程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。
按照同步方式来运行的线程称为同步线程,按照异步方式来运行的线程称为异步线程。同步线程效率低、实时性差、但逻辑简单;而异步线程的执行效率高、实时性强,但编写程序比较复杂而且不适用于大量并发。因此采用半同步、半异步。
半同步/半异步模式中,同步线程用于处理客户逻辑,异步线程用于处理IO事件。异步线程监听到客户请求之后,就将其封装成请求对象并插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程来为新的客户请求服务,则取决于请求队列的设计。
由于存在两种事件处理模式以及几种IO模型,则半同步/半异步模式存在多种变体,其中一种叫半同步/半反应堆模式
异步线程由主线程充当,负责监听所有的socket上的事件。如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之,得到新的socket连接,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送到客户端,主线程就将该连接socket插入请求队列中。所有的工作线程都睡眠在请求队列中,当任务到来后,它们将通过竞争(申请互斥锁)获取任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会处理新任务。
上图表示的半同步/半反应堆采用的事件处理模式是Reactor模式,当然也可以采用模拟的Procator事件处理模式。由主线程来完成数据的读写。
半同步/半反应堆模式的缺点:
1.主线程和工作线程共享请求队列,主线程往请求队列里添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁,浪费cpu的时间。
2.每个工作线程在同一时间只能处理一个客户请求,如果客户数量较多,而工作线程较少,则请求队列中将堆积很多的任务对象,客户端的响应速度会越来越慢,而如果增加工作线程,那么工作线程的切换也会耗费大量cpu时间。
上图是一个高效的半同步/半异步模式,它的每一个工作线程都能同时处理多个客户端连接。主线程只管监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受,并将新返回的连接派发给某个工作线程。此后该新socket上的任何IO操作都由被选中的工作线程来处理,直到客户端关闭连接。而主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来,如果是,就把该新socket上的读写事件注册到自己的epoll内核事件表中。
领导者/追随者模式:多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任意时间点,程序都仅有一个领导者线程,它负责监听IO事件。而其他线程都是追随者,他们休眠在线程池中推选出新的领导者线程,然后处理IO事件,而原来的领导者则处理IO事件。
领导者/追随者模式包含的组件:句柄集(HandleSet)、线程集(ThreadSet)、事件处理器(EventHandle)和具体的事件处理器(ConcreteEventHandle)
句柄集:句柄用于表示IO资源,在linux下就是一个文件描述符。句柄集管理众多的句柄,它使用wait_for_event方法来监听这些句柄的IO事件,并将其中的就绪事件通知给领导者线程。领导者则调用绑定在Handle上的事件处理器来处理事件。领导者将Handle和事件处理器绑定是通过调用句柄集中的register_handle方法来实现的。
线程集:是所有工作线程(包括领导者和追随者线程)的管理者。负责各线程之间的同步,以及新领导线程的推选。线程集中的线程在任一时间必处于如下三种状态之一:
1.Leader:处于领导者身份,负责等待句柄集上的IO事件。
2.Processing:线程正在处理事件。领导者检测到IO事件之后,可以转移到Processing状态来处理该事件。并调用promote_new_leader方法来推选新的领导者;也可以指定其中的追随者来处理事件。此时的领导者地位不变。当处于Processing状态的线程处理完事件之后,如果当前线程集中没有领导者,它将成为新的领导者,否则它直接转变为追随者。
3.Follower:线程当前处于追随者的身份,通过调用线程集的jion方法等待成为新的领导者,也可能被当前的领导者指定来处理新的任务。
领导者线程推选新的领导和追随者等待成为新的领导者这两个操作都将改变线程集,因此,线程集提供了一个成员Synchronized来同步操作,避免竞态条件。
事件处理器和具体的事件处理器:事件处理器通常包括一个或者多个回调函数handle_event。这些回调函数用于处理事件对应的业务逻辑。事件处理器在使用前需要被绑定在某个句柄上,当该句柄上有事件发生时,领导者就执行与之绑定的事件处理器中的回调函数。而具体的事件处理函数是事件处理器的派生类。必须重新实现基类的handle_event方法,处理特定的任务。
由于领导者线程自己监听IO事件并处理客户请求,因而领导者/追随者模式不需要在线程之间传递任何额外的数据,也无需像半同步/半反应堆模式那样在线程之间同步对请求队列的访问。但领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法让每个工作线程独立的管理多个客户连接。
有限状态机:逻辑单元内部的高效编程方法。有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。
提高服务器性能的意见:池、数据复制、上下文切换、锁
池:以空间换时间。浪费服务器的硬件资源,换取其运行效率。池就是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,称为静态资源分配。当服务器进入正式运行阶段,开始处理客户请求的时候,如果需要相关的资源,就可以直接从池中获取,无需动态分配。当服务器处理完一个客户连接之后,可以把相关的资源放回池中,无需执行系统调用释放资源。从最终的效果看,池相当于服务器管理系统资源的应用层设施,避免了服务器对内核的频繁访问。如果无法预知应该在池中分配多少资源,最简单的方法是分配足够多的资源。针对每个可能的客户连接都分配必要的资源。但这种会造成浪费。还有一种解决方案就是预先分配一定的资源,如果发现资源不够,就动态分配加入池中。
池的类型:内存池、进程池、线程池、连接池。
内存池常用于socket的接收缓存和发送缓存。对于某些长度有限的客户请求,比如http请求,预先分配一个大小足够的接收缓存区是合理的,当请求长度超过接收缓存大小时,可以选择丢弃请求或者动态扩大接收缓存区。
进程池和线程池是并发编程常用方法,当我们需要一个工作进程或工作线程来处理新到来的客户请求时,我们可以直接从进程池或者线程池中取得一个执行实体,而无需动态的调用fork或pthread_create等函数来创建进程和线程。
连接池用于服务器或者服务器集群的内部永久连接。每个逻辑单元可能都需要频繁的访问本地的某个数据库,简单的做法是:逻辑单元每次需要访问数据库的时候,就向数据库程序发起连接,而访问完毕后,释放连接。这种做法效率太低。使用连接池,当某个逻辑单元需要访问数据库的时候,它可以直接从连接池中取得一个连接实体并使用,完成数据库访问之后,逻辑单元再将该连接返还给连接池。
数据复制:高性能服务器要避免不必要的数据复制,尤其是数据复制发生在用户代码和内核之间的时候,如果内核可以直接处理从socket或者文件读入的数据,则应用程序没必要将这些数据复制到应用程序缓存区。
上下文切换和锁:并发程序必须考虑上下文切换问题。即进程或者线程切换导致的开销。另外一个要考虑的问题是共享资源的加锁保护。
IO复用:程序能同时监听多个文件描述符。
需要用到IO复用技术的情况:
1.客户端程序需要同时处理多个socket。比如非阻塞的connect技术。
2.客户端程序要同时处理用户输入和网络连接。比如聊天室的程序。
3.TCP服务器要同时监听socket和连接socket。
4.服务器要同时处理TCP请求和UDP请求 ,比如回射服务器。
5.服务器要同时监听多个端口,或者处理多种服务。
IO复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,程序只能按顺序依次处理其中的每个文件描述符。IO复用的系统调用主要有select、poll、epoll
select系统调用:在一定时间内,监听用户感兴趣的文件描述符上可读、可写和异常事件。
int select (i nt nfds ,f d_set* readfds , fd_set* writefds , fd_set* exceptfds , struct timeval* timeout);
nfds参数指定被监听文件描述符的总数。通常被设置为select监听文件描述符中最大值+1。
readfds、writefds和exceptfds参数分别指向可读、可写和异常等事件对应的文件描述符集合。应用程序调用select函数,通过这三个参数传入自己感兴趣的文件描述符。select调用返回的时候,内核将修改它们通知应用程序哪些文件描述符已经就绪。fd_set结构体仅包含了一个整形的数组,该数组的每个元素的每个bit位标记一个文件描述符。
timeout参数用来设置select函数的超时时间,它是一个timeval结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久,调用失败时的timeout值是不确定的。
select成功时,返回就绪文件描述符的总数,如果在超时时间内没有任何文件描述符就绪,select将返回0,select失败将返回-1,并设置error
文件描述符就绪条件:哪些条件下文件描述符可以被认为是可读、可写或者出现异常。下列情况下socket可读
处理带外数据:socket上接收到的普通数据和带外数据将使select返回,但socket处于不同的就绪状态。前者是可读状态,后者处于异常状态。
总结:使用select系统调用,可以知道哪些文件描述符就绪了,然后可读的文件描述符进行读操作,可写的进行写操作。。。
poll系统调用:在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。
int poll ( struct pollfd* fds, nfds_t nfds,int timeout)
fds参数是一个pollfd结构类型的数组,它指定了所有我们感兴趣的文件描述符上发生的可读、可写和异常事件。其中pollfd结构体定义如下:
struct pollfd{ int fd;//文件描述符 short events;//注册的事件 short revents;//实际发生的事件,由内核填充 }
fd成员指定文件描述符,events成员告诉poll监听fd上的哪些事件,是一系列事件的按位与。revents成员则由内核修改,通知应用程序fd上实际发生了哪些事件。
poll支持的事件:
通常应用程序需要根据recv调用的返回值来区分socket接收到的是有效数据还是对方关闭连接的请求,并做相应处理。
nfds参数指定被监听事件集合fds的大小。
timeout参数指定poll的超时值,单位是毫秒。timeout为-1时,poll调用将永远阻塞,直到某个事件发生;当timeout为0时,poll调用将立即返回。
epoll系列系统调用:
内核事件表:epoll是linux特有的IO复用函数,它在实现和使用上与select、poll有很大差异。首先,epoll使用一组函数来完成任务,而不是单个函数。其次,epoll把用户关心的文件描述符上的事件放在一个内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或者事件集,但epoll需要使用一个额外的文件描述符,来唯一标识内核中的事件表。
int epoll_create(int size)
size只是给内核一个提示,告诉它事件需要多大。该函数的返回值是文件描述符,该文件描述符将作用于其他所有epoll系统调用的第一个参数,指定要访问的内核事件表。
操作epoll的内核事件表
int epoll_ctl (int epfd, int op, int fd, struct epoll_event* event)
epfd参数是访问内核事件表的参数。
fd参数是要操作的文件描述符,op参数则是指定的操作类型。操作类型有三种:EPOLL_CTL_ADD(往事件表里注册fd上的事件)、EPOLL_CTL_MOD(修改fd上的注册事件)、EPOLL_CTL_DEL(删除fd上的注册事件)
event参数指定事件,是epoll_event结构指针类型。
struct epoll_event{ _uint32_t event; /*epoll事件 */ epoll_data_t data; /*用户数据*/ }
events成员描述事件类型,epoll支持的事件类型和poll基本相同,表示epoll事件类型的宏是在poll对应的宏上加e,epoll有两个额外的事件类型epollet和epolloneshot。data成员用于存储用户数据,其类型epoll_data_t的定义如下:
typedef union epoll_data{ void* ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t
epoll_data_t是一个联合体,fd指定事件所从属目标的文件描述符,ptr成员可用来指定与fd相关的用户数据。fd和ptr不能同时使用。
epoll_ctl成功时返回0。
epoll_wait函数:在一段超时时间内等待一组文件描述符上的事件。
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout)
该函数成功时,返回就绪的文件描述符的个数。
timeout参数含义和poll接口中timeout参数相同。maxevents参数指定最多监听的事件个数。
epoll_wait函数如果检测到事件,就会将所有就绪的事件从内核事件表中复制到它第二个参数event指向的数组中,这个数组只用于输出epoll_wait检测到的就绪事件,而不是像select和poll的数组参数既可以用于传入用户注册事件,又用于输出内核检测到的事件,极大的提高了效率。
LE和ET模式:电平触发和边沿触发。
LT模式是默认的工作模式:epoll相当于一个效率较高的poll,当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式来操作该文件描述符,ET模式是epoll的高效工作模式。
对于采用LT工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知给应用程序后,应用程序可以不立即处理该事件,当应用程序下一次调用epoll_wait时,epoll_wait还会向应用程序通告此事。直到该事件被处理。
对于采用ET工作模式的文件描述符,当epoll_wait检测到其上有事件发生并将此事通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。可见ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,效率要比LT模式高。
EPOLLONSHOT事件:即使使用ET模式,一个socket上的某个事件还是可能被触发多次。在并发程序中,会引起问题,比如,一个线程在读取完某个socket上的数据后开始处理这些数据,而在数据的处理该socket上又有新的数据可读(EPOLLLIN再次被触发),此时另一个线程被唤醒来读取这些新的数据。于是出现了两个线程同时操作一个socket的局面。但我们期望的是,一个socket连接在任一时刻都只能被一个线程处理。这种功能可以使用EPOLLONSHOT事件实现。即注册了EPOLLONSHOT事件的文件描述符,操作系统最多触发其上注册的一个可读、可写、或者异常的事件,且只触发一次。这样,当一个线程在处理某个socket时,其他线程是不可能有机会操作该socket。反过来说,注册了EPOLLONSHOT事件的socket一旦被某个线程处理完毕,该线程应该立即重置这个socket上的EPOLLONSHOT事件,以确保这个socket再下次可读的时候,其EPOLLONSHOT能被触发。
三组IO复用函数的比较:
select、poll、epoll三组IO复用系统调用,都可以同时监听多个文件描述符,将等待由timeout参数指定的超时时间,直到一个或者多个文件描述符上有事件发生时返回。返回值就是就绪的文件描述符数量。
事件集:三组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理结果。
1.select的参数类型fd_set没有文件描述符和事件的绑定。它仅仅是一个文件描述符的集合,因此select需要三个这种类型的参数来分别输入和输出可读、可写、异常等事件。这使得select不能处理更多类型的事件。另一方面,由于内核对fd_set集合的在线修改,应用程序下次调用的时候select不得不重置这三个fd_set集合。
2.poll的参数类型相对聪明。它把文件描述符和事件都定义其中。任何事件都可以被统一的处理,从而使得编程接口简洁的多。并且内核每次修改的是pollfd结构体的revents成员,而events成员保持不变。因此下次调用的时候,应用程序无需重置pollfd类型的事件集参数。
每次select和poll调用都返回整个用户注册的事件集合(包括就绪和未就绪的),所以应用程序索引就绪文件描述符的时间复杂度为O(n)。
3.epoll在内核中维护一个事件表,并提供了一个独立的系统调用epoll_ctl来控制往其中添加、删除、修改事件。每次epoll_wait调用都直接从该内核事件表中取得用户注册的事件。而无需从用户空间读入这些事件。epoll_wait系统调用的events参数仅用来返回就绪的事件,使得应用程序索引就绪文件描述符的事件复杂度达到O(1)。
最大支持文件描述符数:poll和epoll_wait分别用nfds和maxevents参数指定最多监听多少个文件描述符和事件。可以达到系统允许打开的最大文件描述符数目。即65535。而select允许监听的最大文件描述符数量通常有限制。如果修改了这个限制,可能导致不可预期的后果。
工作模式:select和poll都只能工作在相对低效的LT模式(边沿触发),而epoll则可以工作在ET高效模式(电平触发)。并且epoll还支持EPOLLONESHOT事件。该事件能进一步减少可读,可写和异常等事件被触发的次数。
具体实现原理:select和poll采用的都是轮询的方式,每次调用都要扫描整个注册文件描述符的集合。并将其中就绪的文件描述符返回给用户程序。而epoll_wait不同,它采用回调的方式,内核检测到就绪的文件描述符时,触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间,因此epoll_wait无需轮询整个文件描述符集合来检测哪些事件已经就绪。但当活动连接比较多时,epoll_wait的效率未必比其他的高。因为回调函数被触发的过于频繁了。所以epoll_wait适用于连接数多,但活动连接较少的情况。
以上是关于网络编程-套接字篇的主要内容,如果未能解决你的问题,请参考以下文章