Linux网络编程
网络基础
网卡
网络适配器:
作用:收发数据
每一块网卡有唯一的mac地址 。在网络上的每一个计算机都必须拥有一个独一无二的MAC地址。
作用: 用来标识一块网卡,6个字节。
ip
ip用来标识一台主机,逻辑地址。
iPv4 :ip地址是4字节,32位。
ipv6: ipv6地址是16字节,128位。
子网id: ip中被子网掩码中连续1覆盖的位.
主机id:ip中被子网掩码中连续0覆盖的位。
子网掩码 netmask: 用来区分子网id 和主机id。
192.168.1.2/24
192.168.1.2/255.255.255.0
其中:
网段地址: 192.168.1.0
广播地址: 192.168.1.255
剩下254个地址为可选ip地址。
端口
作用: 用来标识应用程序(进程)。
port: 2个字节,范围 0-65535。
其中:
知名端口:0-1023
自定义端口:1024 - 65535
OSI 七层模型 与 TCP/IP 四层模型
物理层: 主要定义物理设备标准,如网线的接口类型、光纤的接口类型、各种传输介质的传输速率等。它的主要作用是传输比特流(就是由1、0转化为电流强弱来进行传输,到达目的地后再转化为1、0,也就是我们常说的数模转换与模数转换)。这一层的数据叫做比特。
数据链路层: mac 负责收发数据。定义了如何让格式化数据以帧为单位进行传输,以及如何让控制对物理介质的访问。这一层通常还提供错误检测和纠正,以确保数据的可靠传输。
网络层: 在位于不同地理位置的网络中的两个主机系统之间提供连接和路径选择。Internet的发展使得从世界各站点访问信息的用户数大大增加,而网络层正是管理这种连接的层。
传输层: 端口区分数据递送到哪一个应用程序。定义了一些传输数据的协议和端口号。如:TCP(传输控制协议,传输效率低,可靠性强,用于传输可靠性要求高,数据量大的数据),UDP(用户数据报协议,与TCP特性恰恰相反,用于传输可靠性要求不高,数据量小的数据,如QQ聊天数据就是通过这种方式传输的)。 主要是将从下层接收的数据进行分段和传输,到达目的地址后再进行重组。常常把这一层数据叫做段。
会话层: 建立连接。通过传输层(端口号:传输端口与接收端口)建立数据传输的通路。主要在你的系统之间发起会话或者接受会话请求(设备之间需要互相认识,可以是IP也可以是MAC或者是主机名)。
表示层: 解码。 可确保一个系统的应用层所发送的信息可以被另一个系统的应用层读取。例如,PC程序与另一台计算机进行通信,其中一台计算机使用扩展二一十进制交换码(EBCDIC),而另一台则使用美国信息交换标准码(ASCII)来表示相同的字符。如有必要,表示层会通过使用一种通用格式来实现多种数据格式之间的转换。
应用层:应用程序。是最靠近用户的OSI层。这一层为用户的应用程序(例如电子邮件、文件传输和终端仿真)提供网络服务。
协议
规定了数据传输的方式和格式。从应用的角度出发,协议可理解为“规则”,是数据传输和数据的解释的规则。
假设,A、B双方欲传输文件。规定:
第一次,传输文件名,接收方接收到文件名,应答OK给传输方;
第二次,发送文件的尺寸,接收方接收到该数据再次应答一个OK;
第三次,传输文件内容。同样,接收方接收数据完成后应答OK表示文件内容接收成功。
由此,无论A、B之间传递何种文件,都是通过三次数据传输来完成。A、B之间形成了一个最简单的数据传输规则。双方都按此规则发送、接收数据。A、B之间达成的这个相互遵守的规则即为协议。
这种仅在A、B之间被遵守的协议称之为原始协议。当此协议被更多的人采用,不断的增加、改进、维护、完善。最终形成一个稳定的、完整的文件传输协议,被广泛应用于各种文件传输过程中。该协议就成为一个标准协议。最早的ftp协议就是由此衍生而来。
TCP协议注重数据的传输。http协议着重于数据的解释。
应用层协议:
FTP: 文件传输协议
HTTP: 超文本传输协议
NFS: 网络文件系统
传输层协议:
TCP: 传输控制协议
UDP: 用户数据报协议
网络层:
IP:因特网互联协议
ICMP: 因特网控制报文协议 ping
IGMP: 因特网组管理协议
链路层协议:
ARP: 地址解析协议 通过ip找mac地址
RARP: 反向地址解析协议 通过mac找ip
报头介绍
UDP
TCP
1.源端口号:发送方端口号
2.目的端口号:接收方端口号
3.序列号:本报文段的数据的第一个字节的序号
4.确认序号:期望收到对方下一个报文段的第一个数据字节的序号
5.首部长度(数据偏移):TCP报文段的数据起始处距离TCP报文段的起始处有多远,即首部长度。单位:32位,即以4字节为计算单位。
6.保留:占6位,保留为今后使用,目前应置为0
7.紧急URG: 此位置1,表明紧急指针字段有效,它告诉系统此报文段中有紧急数据,应尽快传送
8.确认ACK: 仅当ACK=1时确认号字段才有效,TCP规定,在连接建立后所有传达的报文段都必须把ACK置1
9.推送PSH:当两个应用进程进行交互式的通信时,有时在一端的应用进程希望在键入一个命令后立即就能够收到对方的响应。在这种情况下,TCP就可以使用推送(push)操作,这时,发送方TCP把PSH置1,并立即创建一个报文段发送出去,接收方收到PSH=1的报文段,就尽快地(即“推送”向前)交付给接收应用进程,而不再等到整个缓存都填满后再向上交付
10.复位RST: 用于复位相应的TCP连接
11.同步SYN: 仅在三次握手建立TCP连接时有效。当SYN=1而ACK=0时,表明这是一个连接请求报文段,对方若同意建立连接,则应在相应的报文段中使用SYN=1和ACK=1.因此,SYN置1就表示这是一个连接请求或连接接受报文
12.终止FIN:用来释放一个连接。当FIN=1时,表明此报文段的发送方的数据已经发送完毕,并要求释放运输连接。
13.窗口:指发送本报文段的一方的接收窗口(而不是自己的发送窗口)
14.校验和:校验和字段检验的范围包括首部和数据两部分,在计算校验和时需要加上12字节的伪头部
15.紧急指针:仅在URG=1时才有意义,它指出本报文段中的紧急数据的字节数(紧急数据结束后就是普通数据),即指出了紧急数据的末尾在报文中的位置,注意:即使窗口为零时也可发送紧急数据
16.选项:长度可变,最长可达40字节,当没有使用选项时,TCP首部长度是20字节
IP
1.版本:IP协议的版本。通信双方使用过的IP协议的版本必须一致,目前最广泛使用的IP协议版本号为4(即IPv4 )
2.首部长度:单位是32位(4字节)
3.服务类型:一般不适用,取值为0
4.总长度:指首部加上数据的总长度,单位为字节
5.标识(identification):IP软件在存储器中维持一个计数器,每产生一个数据报,计数器就加1,并将此值赋给标识字段
6.标志(flag):目前只有两位有意义。
标志字段中的最低位记为MF。MF=1即表示后面“还有分片”的数据报。MF=0表示这已是若干数据报片中的最后一个。
标志字段中间的一位记为DF,意思是“不能分片”,只有当DF=0时才允许分片
7.片偏移:指出较长的分组在分片后,某片在源分组中的相对位置,也就是说,相对于用户数据段的起点,该片从何处开始。片偏移以8字节为偏移单位。
8.生存时间:TTL,表明是数据报在网络中的寿命,即为“跳数限制”,由发出数据报的源点设置这个字段。路由器在转发数据之前就把TTL值减一,当TTL值减为零时,就丢弃这个数据报。
9.协议:指出此数据报携带的数据时使用何种协议,以便使目的主机的IP层知道应将数据部分上交给哪个处理过程,常用的ICMP(1),IGMP(2),TCP(6),UDP(17),IPv6(41)
10.首部校验和:只校验数据报的首部,不包括数据部分。
11.源地址:发送方IP地址
12.目的地址:接收方IP地址
MAC
ARP
1.Dest MAC:目的MAC地址
2.Src MAC:源MAC地址
3.帧类型:0x0806
4.硬件类型:1(以太网)
5.协议类型:0x0800(IP地址)
6.硬件地址长度:6
7.协议地址长度:4
8.OP:1(ARP请求),2(ARP应答),3(RARP请求),4(RARP应答)
网络通信过程
arp通信过程
地址解析协议: 通过ip找目的mac地址。
主机A想要向主机192.168.1.3发送数据包,但是不知道主机B的mac地址,因此向局域网内的所有主机发送arp请求包。
主机B发现该请求包请求的是自己的mac地址,因此arp应答。主机C发现不是自己,丢弃。
如果发现该ip地址不是局域网内的,是外网ip,就将请求包发送给网关192.168.1.1,目的mac地址就是网关mac地址。
arp请求包:
不知道主机B的mac地址,因此需要广播,目标mac地址是ff:ff:ff:ff:ff:ff。
源mac地址是主机A的mac地址。
网络模式
C/S模式:传统的网络应用设计模式,客户机(client)/服务器(server)模式。需要在通讯两端各自部署客户机和服务器来完成数据通信。
B/S模式:浏览器(browser)/服务器(server)模式。只需在一端部署服务器,而另外一端使用每台PC都默认配置的浏览器即可完成数据的传输。
优缺点
对于C/S模式来说,其优点明显。客户端位于目标主机上可以保证性能,将数据缓存至客户端本地,从而提高数据传输效率。且,一般来说客户端和服务器程序由一个开发团队创作,所以他们之间所采用的协议相对灵活。可以在标准协议的基础上根据需求裁剪及定制。例如,腾讯公司所采用的通信协议,即为ftp协议的修改剪裁版。因此,传统的网络应用程序及较大型的网络应用程序都首选C/S模式进行开发。
C/S模式的缺点也较突出。由于客户端和服务器都需要有一个开发团队来完成开发。工作量将成倍提升,开发周期较长。另外,从用户角度出发,需要将客户端安插至用户主机上,对用户主机的安全性构成威胁。这也是很多用户不愿使用C/S模式应用程序的重要原因。
B/S模式相比C/S模式而言,由于它没有独立的客户端,使用标准浏览器作为客户端,其工作开发量较小。只需开发服务器端即可。另外由于其采用浏览器显示数据,因此移植性非常好,不受平台限制。如早期的偷菜游戏,在各个平台上都可以完美运行。
B/S模式的缺点也较明显。由于使用第三方浏览器,因此网络应用支持受限。另外,没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制。应用的观感大打折扣。第三,必须与浏览器一样,采用标准http协议进行通信,协议选择不灵活。
TCP和socket
socket
socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。
在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用socket来描述网络连接的一对一关系。
套接字通信原理如下图所示:
在网络通信中,套接字一定是成对出现的。一端的发送缓冲区对应另一端的接收缓冲区。我们使用同一个文件描述符对应发送缓冲区和接收缓冲区。
网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端和小端之分。网络数据流同样有大端和小端之分,那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址存高字节,高地址存低字节。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong); //4个字节,转ip地址
uint16_t htons(uint16_t hostshort);//2个字节,转端口
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
//h表示host,n表示network,l表示32位长整数,s表示16位短整数。
//如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
点分十进制转换(常用)
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
功能: 将点分十进制串 转成32位网络大端的数据。
参数:
af:
AF_INET 表示 IPV4
AF_INET6 表示 IPV6
src: 点分十进制串的首地址
dst: 32位网络数据的地址
返回值:成功返回1
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
功能: 将32位大端的网络数据转成点分十进制串
参数:
af : AF_INET
src : 32位大端的网络数的地址
dst : 存储点分十进制串的地址
size : 存储点分十进制串数组的大小,最大是16(255.255.255.255)
返回值: 存储点分十进制串数组首地址
支持IPv4和IPv6
可重入函数
其中inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr。
因此函数接口是void*。
网络通信解决三大问题
协议
ip
端口
为了方便,这三个东西封装为了一个结构体sockaddr。
很多网络编程函数诞生早于IPv4协议,那时候都使用的是sockaddr结构体,为了向前兼容,现在sockaddr退化成了(void *)的作用,传递一个地址给函数,至于这个函数是sockaddr_in还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。
ipv4套接字结构体
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
sin_family:协议 AF_INET
sin_portL:端口
sin_addr:ip地址
ipv6套接字结构体
struct sockaddr_in6 {
unsigned short int sin6_family; /* AF_INET6 */
__be16 sin6_port; /* Transport layer port # */
__be32 sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
__u32 sin6_scope_id; /* scope id (new in RFC2553) */
};
struct in6_addr {
union {
__u8 u6_addr8[16];
__be16 u6_addr16[8];
__be32 u6_addr32[4];
} in6_u;
#define s6_addr in6_u.u6_addr8
#define s6_addr16 in6_u.u6_addr16
#define s6_addr32 in6_u.u6_addr32
};
#define UNIX_PATH_MAX 108
struct sockaddr_un {
__kernel_sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
通用套接字结构体
struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};
socket API可以接受各种类型的sockaddr结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下,例如:
struct sockaddr_in servaddr;
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));/* initialize servaddr */
TCP通信流程
特点: 出错重传,每次发送数据对方都会回ACK, 可靠 。
创建套接字API
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数:
domain:
AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
AF_INET6 与上面类似,不过是来用IPv6的地址
AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用
type:
SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,这个socket是使用TCP来进行传输。
SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用UDP来进行它的连接。
SOCK_SEQPACKET该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
SOCK_RAW socket类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)
SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序
protocol: 0 表示使用默认协议
返回值:
成功:返回指向新创建的socket的文件描述符,失败:返回-1,设置errno
socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1。对于IPv4,domain参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可。
连接服务器API
#include <sys/socket.h>
int connect(intsockfd , const struct sockaddr *addr,
socklen_t addrlen);
参数:
sockfd:socket文件描述符
addr:传入参数,指定服务器端地址信息,含IP地址和端口号
addrlen:传入参数,传入sizeof(addr)大小
返回值:
成功返回0,失败返回-1,设置errno
创建TCP客户端
关于TCP服务器(重点)
TCP服务器通信步骤:
1.创建套接字
2.绑定
3.监听
4.提取
5.读写
6.关闭
为什么会提取新的连接,使用已连接套接字通信?
因为监听套接字只有一个,如果使用监听套接字通信,当客户端有多个的时候,就会产生冲突。所以每一个新的连接,都会生成一个已连接套接字。
监听套接字只负责监听是否有新的连接,并放入未完成连接队列中。
bind函数
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:
socket文件描述符
addr:
构造出IP地址加端口号
addrlen:
sizeof(addr)长度
返回值:
成功返回0,失败返回-1, 设置errno
服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。
bind()的作用是将参数sockfd和addr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号。
例如:
struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);
首先将整个结构体清零,然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为6666。
listen函数
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
sockfd:
socket文件描述符
backlog:
排队建立3次握手队列和刚刚建立3次握手队列的链接数和
已完成连接队列和未完成连接队列个数之和的最大值,一般128
典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于待连接状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。
accept函数
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockdf:
socket文件描述符
addr:
传出参数,返回链接的客户端地址信息,含IP地址和端口号
addrlen:
传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小
返回值:
成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno
三次握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。addr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区addr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给addr参数传NULL,表示不关心客户端的地址。
创建TCP服务器
总结
服务器调用socket()、bind()、listen()完成初始化后,调用accept()阻塞等待,处于监听端口的状态,客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。
数据传输的过程:
建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。
如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。
包裹函数
上面的例子不仅功能简单,而且简单到几乎没有什么错误处理,我们知道,系统调用不能保证每次都成功,必须进行出错处理,这样一方面可以保证程序逻辑正常,另一方面可以迅速得到故障信息。
为使错误处理的代码不影响主程序的可读性,我们把与socket相关的一些系统函数加上错误处理代码包装成新的函数,做成一个模块wrap.c:
功能:输入端口号和ip地址字符串,自动绑定,包含错误处理,返回监听套接字。
int tcp4bind(short port,const char *IP)
{
struct sockaddr_in serv_addr;
int lfd = Socket(AF_INET,SOCK_STREAM,0);
bzero(&serv_addr,sizeof(serv_addr));
if(IP == NULL){
//如果这样使用 0.0.0.0,任意ip将可以连接
serv_addr.sin_addr.s_addr = INADDR_ANY;
}else{
if(inet_pton(AF_INET,IP,&serv_addr.sin_addr.s_addr) <= 0){
perror(IP);//转换失败
exit(1);
}
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(port);
// int opt = 1;
//setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
Bind(lfd,(struct sockaddr *)&serv_addr,sizeof(serv_addr));
return lfd;
}
功能:快捷打印errno,并且退出程序
void perr_exit(const char *s)
{
perror(s);
exit(-1);
}
功能:bind加入错误处理
int Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
int n;
if ((n = bind(fd, sa, salen)) < 0)
perr_exit("bind error");
return n;
}
功能:listen加入错误处理
int Listen(int fd, int backlog)
{
int n;
if ((n = listen(fd, backlog)) < 0)
perr_exit("listen error");
return n;
}
功能:accept加入错误处理
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
int n;
again:
if ((n = accept(fd, sa, salenptr)) < 0) {
if ((errno == ECONNABORTED) || (errno == EINTR))//如果是被信号中断和软件层次中断,不能退出
goto again;
else
perr_exit("accept error");
}
return n;
}
功能:connet加入错误处理
int Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
int n;
if ((n = connect(fd, sa, salen)) < 0)
perr_exit("connect error");
return n;
}
功能:socket加入错误处理
int Socket(int family, int type, int protocol)
{
int n;
if ((n = socket(family, type, protocol)) < 0)
perr_exit("socket error");
return n;
}
功能:read加入错误处理
ssize_t Read(int fd, void *ptr, size_t nbytes)
{
ssize_t n;
again:
if ( (n = read(fd, ptr, nbytes)) == -1) {
if (errno == EINTR)//如果是被信号中断,不应该退出
goto again;
else
return -1;
}
return n;
}
功能:write加入错误处理
ssize_t Write(int fd, const void *ptr, size_t nbytes)
{
ssize_t n;
again:
if ( (n = write(fd, ptr, nbytes)) == -1) {
if (errno == EINTR)
goto again;
else
return -1;
}
return n;
}
功能:close加入错误处理
int Close(int fd)
{
int n;
if ((n = close(fd)) == -1)
perr_exit("close error");
return n;
}
功能:读取固定的字节数数据
ssize_t Readn(int fd, void *vptr, size_t n)
{
size_t nleft; //usigned int 剩余未读取的字节数
ssize_t nread; //int 实际读到的字节数
char *ptr;
ptr = vptr;
nleft = n;
while (nleft > 0) {
if ((nread = read(fd, ptr, nleft)) < 0) {
if (errno == EINTR)
nread = 0;
else
return -1;
} else if (nread == 0)
break;
nleft -= nread;
ptr += nread;
}
return n - nleft;
}
功能:写入固定的字节数数据
ssize_t Writen(int fd, const void *vptr, size_t n)
{
size_t nleft;
ssize_t nwritten;
const char *ptr;
ptr = vptr;
nleft = n;
while (nleft > 0) {
if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
if (nwritten < 0 && errno == EINTR)
nwritten = 0;
else
return -1;
}
nleft -= nwritten;
ptr += nwritten;
}
return n;
}
功能:读取一行数据
static ssize_t my_read(int fd, char *ptr)
{
static int read_cnt;
static char *read_ptr;
static char read_buf[100];
if (read_cnt <= 0) {
again:
if ( (read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
if (errno == EINTR)
goto again;
return -1;
} else if (read_cnt == 0)
return 0;
read_ptr = read_buf;
}
read_cnt--;
*ptr = *read_ptr++;
return 1;
}
ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
ssize_t n, rc;
char c, *ptr;
ptr = vptr;
for (n = 1; n < maxlen; n++) {
if ( (rc = my_read(fd, &c)) == 1) {
*ptr++ = c;
if (c == '\n')
break;
} else if (rc == 0) {
*ptr = 0;
return n - 1;
} else
return -1;
}
*ptr = 0;
return n;
}
TCP通信时序
三次握手
四次挥手
总结
在这个例子中,首先客户端主动发起连接、发送请求,然后服务器端响应请求,然后客户端主动关闭连接。两条竖线表示通讯的两端,从上到下表示时间的先后顺序,注意,数据从一端传到网络的另一端也需要时间,所以图中的箭头都是斜的。双方发送的段按时间顺序编号为1-10,各段中的主要信息在箭头上标出,例如段2的箭头上标着SYN, 8000(0), ACK1001,表示该段中的SYN位置1,32位序号是8000,该段不携带有效载荷(数据字节数为0),ACK位置1,32位确认序号是1001,带有一个mss(Maximum Segment Size,最大报文长度)选项值为1024。
建立连接(三次握手)的过程
1.客户端发送一个带SYN标志的TCP报文到服务器。这是三次握手过程中的段1。
客户端发出段1,SYN位表示连接请求。序号是1000,这个序号在网络通讯中用作临时的地址,每发一个数据字节,这个序号要加1,这样在接收端可以根据序号排出数据包的正确顺序,也可以发现丢包的情况,另外,规定SYN位和FIN位也要占一个序号,这次虽然没发数据,但是由于发了SYN位,因此下次再发送应该用序号1001。mss表示最大段尺寸,如果一个段太大,封装成帧后超过了链路层的最大帧长度,就必须在IP层分片,为了避免这种情况,客户端声明自己的最大段尺寸,建议服务器端发来的段不要超过这个长度。
2.服务器端回应客户端,是三次握手中的第2个报文段,同时带ACK标志和SYN标志。它表示对刚才客户端SYN的回应;同时又发送SYN给客户端,询问客户端是否准备好进行数据通讯。
服务器发出段2,也带有SYN位,同时置ACK位表示确认,确认序号是1001,表示“我接收到序号1000及其以前所有的段,请你下次发送序号为1001的段”,也就是应答了客户端的连接请求,同时也给客户端发出一个连接请求,同时声明最大尺寸为1024。
3.客户必须再次回应服务器端一个ACK报文,这是报文段3。
客户端发出段3,对服务器的连接请求进行应答,确认序号是8001。在这个过程中,客户端和服务器分别给对方发了连接请求,也应答了对方的连接请求,其中服务器的请求和应答在一个段中发出,因此一共有三个段用于建立连接,称为“三方握手(three-way-handshake)”。在建立连接的同时,双方协商了一些信息,例如双方发送序号的初始值、最大段尺寸等。
在TCP通讯中,如果一方收到另一方发来的段,读出其中的目的端口号,发现本机并没有任何进程使用这个端口,就会应答一个包含RST位的段给另一方。例如,服务器并没有任何进程使用8080端口,我们却用telnet客户端去连接它,服务器收到客户端发来的SYN段就会应答一个RST段,客户端的telnet程序收到RST段后报告错误。
数据传输的过程
1.客户端发出段4,包含从序号1001开始的20个字节数据。
2.服务器发出段5,确认序号为1021,对序号为1001-1020的数据表示确认收到,同时请求发送序号1021开始的数据,服务器在应答的同时也向客户端发送从序号8001开始的10个字节数据,这称为piggyback。
3.客户端发出段6,对服务器发来的序号为8001-8010的数据表示确认收到,请求发送序号8011开始的数据。
在数据传输过程中,ACK和确认序号是非常重要的,应用程序交给TCP协议发送的数据会暂存在TCP层的发送缓冲区中,发出数据包给对方之后,只有收到对方应答的ACK段才知道该数据包确实发到了对方,可以从发送缓冲区中释放掉了,如果因为网络故障丢失了数据包或者丢失了对方发回的ACK段,经过等待超时后TCP协议自动将发送缓冲区中的数据包重发。
关闭连接(四次握手)的过程
由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。当任意一方完成它的数据发送任务后,就能发送一个FIN来终止这个方向的连接。收到一个 FIN意味着这一方向上没有数据流动。一个TCP连接在收到一个FIN后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭。
1.客户端发出段7,FIN位表示关闭连接的请求。
2.服务器发出段8,应答客户端的关闭连接请求。
3.服务器发出段9,其中也包含FIN位,向客户端发送关闭连接请求。
4.客户端发出段10,应答服务器的关闭连接请求。
建立连接的过程是三次握手,而关闭连接通常需要4个段,服务器的应答和关闭连接请求通常不合并在一个段中,因为有连接半关闭的情况,这种情况下客户端关闭连接之后就不能再发送数据给服务器了,但是服务器还可以发送数据给客户端,直到服务器也关闭连接为止。
滑动窗口
如果发送端发送的速度较快,接收端接收到数据后处理的速度较慢,而接收缓冲区的大小是固定的,就会丢失数据。TCP协议通过“滑动窗口(Sliding Window)”机制解决这一问题。
1.发送端发起连接,声明最大段尺寸是1460,初始序号是0,窗口大小是4K,表示“我的接收缓冲区还有4K字节空闲,你发的数据不要超过4K”。接收端应答连接请求,声明最大段尺寸是1024,初始序号是8000,窗口大小是6K。发送端应答,三方握手结束。
2.发送端发出段4-9,每个段带1K的数据,发送端根据窗口大小知道接收端的缓冲区满了,因此停止发送数据。
3.接收端的应用程序提走2K数据,接收缓冲区又有了2K空闲,接收端发出段10,在应答已收到6K数据的同时声明窗口大小为2K。
4.接收端的应用程序又提走2K数据,接收缓冲区有4K空闲,接收端发出段11,重新声明窗口大小为4K。
5.发送端发出段12-13,每个段带2K数据,段13同时还包含FIN位。
6.接收端应答接收到的2K数据(6145-8192),再加上FIN位占一个序号8193,因此应答序号是8194,连接处于半关闭状态,接收端同时声明窗口大小为2K。
7.接收端的应用程序提走2K数据,接收端重新声明窗口大小为4K。
8.接收端的应用程序提走剩下的2K数据,接收缓冲区全空,接收端重新声明窗口大小为6K。
9.接收端的应用程序在提走全部数据后,决定关闭连接,发出段17包含FIN位,发送端应答,连接完全关闭。
从这个例子还可以看出,发送端是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据。也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),在底层通讯中这些数据可能被拆成很多数据包来发送,但是一个数据包有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。
多进程实现并发服务器
多线程实现并发服务器
TCP状态转移和IO多路复用
TCP状态转换图
- CLOSED:表示初始状态。
- LISTEN:该状态表示服务器端的某个SOCKET处于监听状态,可以接受连接。
- SYN_SENT:这个状态与SYN_RCVD遥相呼应,当客户端SOCKET执行CONNECT连接时,它首先发送SYN报文,随即进入到了SYN_SENT状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT状态表示客户端已发送SYN报文。
- SYN_RCVD:该状态表示接收到SYN报文,在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂。此种状态时,当收到客户端的ACK报文后,会进入到ESTABLISHED状态。
- ESTABLISHED:表示连接已经建立。
- FIN_WAIT_1: FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等待对方的FIN报文。
区别是:
FIN_WAIT_1状态是当socket在ESTABLISHED状态时,想主动关闭连接,向对方发送了FIN报文,此时该socket进入到FIN_WAIT_1状态。
FIN_WAIT_2状态是当对方回应ACK后,该socket进入到FIN_WAIT_2状态,正常情况下,对方应马上回应ACK报文,所以FIN_WAIT_1状态一般较难见到,而FIN_WAIT_2状态可用netstat看到。 - FIN_WAIT_2:主动关闭链接的一方,发出FIN收到ACK以后进入该状态。称之为半连接或半关闭状态。该状态下的socket只能接收数据,不能发。
- TIME_WAIT:表示收到了对方的FIN报文,并发送出了ACK报文,等2MSL后即可回到CLOSED可用状态。如果FIN_WAIT_1状态下,收到对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。
- CLOSING: 这种状态较特殊,属于一种较罕见的状态。正常情况下,当你发送FIN报文后,按理来说是应该先收到(或同时收到)对方的ACK报文,再收到对方的FIN报文。但是CLOSING状态表示你发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?如果双方几乎在同时close一个SOCKET的话,那么就出现了双方同时发送FIN报文的情况,也即会出现CLOSING状态,表示双方都正在关闭SOCKET连接。
- CLOSE_WAIT:此种状态表示在等待关闭。当对方关闭一个SOCKET后发送FIN报文给自己,系统会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来呢,查看是否还有数据发送给对方,如果没有则close这个SOCKET,发送FIN报文给对方,即关闭连接。所以在CLOSE_WAIT状态下,需要关闭连接。
- LAST_ACK:该状态是被动关闭一方在发送FIN报文后,最后等待对方的ACK报文。当收到ACK报文后,即可以进入到CLOSED可用状态。
半关闭
当TCP链接中A发送FIN请求关闭,B端回应ACK后(A端进入FIN_WAIT_2状态),B没有立即发送FIN给A时,A方处在半链接状态,此时A可以接收B发送的数据,但是A已不能再向B发送数据。
从程序的角度,可以使用API来控制实现半连接状态。
#include <sys/socket.h>
int shutdown(int sockfd, int how);
sockfd:需要关闭的socket的描述符
how:允许为shutdown操作选择以下几种方式:
SHUT_RD(0): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。
该套接字不再接收数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
SHUT_RDWR(2): 关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR。
使用close中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为0时才关闭连接。
而shutdown不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。
注意:
1.如果有多个进程共享一个套接字,close每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了close,套接字将被释放。
2.在多进程中如果一个进程调用了shutdown(sfd, SHUT_RDWR)后,其它的进程将无法进行通信。但,如果一个进程close(sfd)将不会影响到其它进程。
2MSL
2MSL (Maximum Segment Lifetime) TIME_WAIT状态的存在有两个理由:
(1)让4次握手关闭流程更加可靠;4次握手的最后一个ACK是是由主动关闭方发送出去的,若这个ACK丢失,被动关闭方会再次发一个FIN过来。若主动关闭方能够保持一个2MSL的TIME_WAIT状态,则有更大的机会让丢失的ACK被再次发送出去。
(2)防止lost duplicate对后续新建正常链接的传输造成破坏。lost duplicate在实际的网络中非常常见,经常是由于路由器产生故障,路径无法收敛,导致一个packet在路由器A,B,C之间做类似死循环的跳转。IP头部有个TTL,限制了一个包在网络中的最大跳数,因此这个包有两种命运,要么最后TTL变为0,在网络中消失;要么TTL在变为0之前路由器路径收敛,它凭借剩余的TTL跳数终于到达目的地。但非常可惜的是TCP通过超时重传机制在早些时候发送了一个跟它一模一样的包,并先于它达到了目的地,因此它的命运也就注定被TCP协议栈抛弃。
心跳包
在TCP网络通信中,经常会出现客户端和服务器之间的非正常断开,需要实时检测查询链接状态。常用的解决方法就是在程序中加入心跳机制。
Heart-Beat线程
这个是最常用的简单方法。在接收和发送数据时个人设计一个守护进程(线程),定时发送Heart-Beat包,客户端/服务器收到该小包后,立刻返回相应的包即可检测对方是否实时在线。
该方法的好处是通用,但缺点就是会改变现有的通讯协议!大家一般都是使用业务层心跳来处理,主要是灵活可控。
UNIX网络编程不推荐使用SO_KEEPALIVE来做心跳检测,还是在业务层以心跳包做检测比较好,也方便控制。
设置TCP属性
SO_KEEPALIVE 保持连接检测对方主机是否崩溃,避免(服务器)永远阻塞于TCP连接的输入。设置该选项后,如果2小时内在此套接口的任一方向都没有数据交换,TCP就自动给对方发一个保持存活探测分节(keepalive probe)。这是一个对方必须响应的TCP分节。
它会导致以下三种情况:
对方接收一切正常:以期望的ACK响应。2小时后,TCP将发出另一个探测分节。
对方已崩溃且已重新启动:以RST响应。套接口的待处理错误被置为ECONNRESET,套接口本身则被关闭。对方无任何响应:源自berkeley的TCP发送另外8个探测分节,相隔75秒一个,试图得到一个响应。在发出第一个探测分节11分钟 15秒后若仍无响应就放弃。套接口的待处理错误被置为ETIMEOUT,套接口本身则被关闭。如ICMP错误是“host unreachable(主机不可达)”,说明对方主机并没有崩溃,但是不可达,这种情况下待处理错误被置为EHOSTUNREACH。
根据上面的介绍我们可以知道对端以一种非优雅的方式断开连接的时候,我们可以设置SO_KEEPALIVE属性使得我们在2小时以后发现对方的TCP连接是否依然存在。
int keepAlive = 1;
setsockopt(listenfd, SOL_SOCKET, SO_KEEPALIVE, (void*)&keepAlive, sizeof(keepAlive));
如果我们不能接受如此之长的等待时间,从TCP-Keepalive-HOWTO上可以知道一共有两种方式可以设置,一种是修改内核关于网络方面的 配置参数,另外一种就是SOL_TCP字段的TCP_KEEPIDLE,TCP_KEEPINTVL, TCP_KEEPCNT三个选项。
高并发服务器
端口复用
在server的TCP连接没有完全断开之前不允许重新监听是不合理的。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。
在server代码的socket()和bind()调用之间插入如下代码:
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
多路IO转接技术
多路IO转接服务器也叫做多任务IO服务器。该类服务器实现的主旨思想是,不再由应用程序自己监视客户端连接,取而代之由内核替应用程序监视文件。
select
1.select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数。
2.解决1024以下客户端时使用select是很合适的,但如果链接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力。
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds: 监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
readfds: 监控有读数据到达文件描述符集合,传入传出参数
writefds: 监控写数据到达文件描述符集合,传入传出参数
exceptfds: 监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
timeout: 定时阻塞监控时间,3种情况
1.NULL,永远等下去
2.设置timeval,等待固定时间
3.设置timeval里时间均为0,检查描述字后立即返回,轮询
struct timeval {
long tv_sec; /* seconds */
long tv_usec; /* microseconds */
};
void FD_CLR(int fd, fd_set *set); //把文件描述符集合里fd位清0
int FD_ISSET(int fd, fd_set *set); //测试文件描述符集合里fd是否置1
void FD_SET(int fd, fd_set *set); //把文件描述符集合里fd位置1
void FD_ZERO(fd_set *set); //把文件描述符集合里所有位清0
返回值: 返回的是变化的文件描述符的个数。
注意: 变化的文件描述符会存在监听的集合中,未变化的文件描述符会从集合中删除。
select用法
为什么需要 listen 步骤,尽管用的是 select 来监听?
listen 函数是 TCP 服务器端用来将一个未连接的套接字转换为被动套接字,表明服务器已准备好接受来自客户端的连接请求。select 用于同时监控多个文件描述符的状态,包括这个被动套接字(用于接受新连接)和其他活动的连接套接字(用于数据通信)。
为什么在处理文件描述符时,不使用 while 循环持续读取,而是只读取一次?
这个设计是出于非阻塞和效率的考虑。如果对于每个文件描述符使用 while 循环持续读取直到没有数据可读,这可能会导致服务器在一个客户端上阻塞,从而影响对其他客户端的响应。通过一次性读取,服务器可以更公平地轮询处理每个客户端的请求。
这种方式可能不会立即处理客户端连续发送的大量数据。但是,这可以通过增加缓冲区的大小或在客户端和服务器之间设计更有效的通信协议来解决。
select 的优缺点
优点:跨平台
缺点:
文件描述符1024的限制(FD_SETSIZE的限制)。
只是返回变化的文件描述符的个数,具体哪个发生变化需要遍历。
每次都需要将需要监听的文件描述集合由应用层符拷贝到内核。
select效率低。
数组版select
poll
相较于select而言,poll的优势:
1. 传入、传出事件分离。无需每次调用时,重新设定监听事件。
2. 文件描述符上限,可突破1024限制。能监控的最大上限数可使用配置文件调整。
poll API
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监控的事件 */
short revents; /* 监控事件中满足条件返回的事件 */
};
功能: 监听多个文件描述符的属性变化
参数:
fds : 监听的数组的首元素地址
nfds: 监控数组中有多少文件描述符需要被监控。数组有效元素的最大下标+1
timeout : 超时时间 -1是永久监听 >=0 限时等待
struct pollfd {
int fd; /* file descriptor */ 需要监听的文件描述符
short events;/* requested events */需要监听文件描述符什么事件 POLLIN 读事件 POLLOUT写事件
short revents;/* returned events */ 返回监听到的事件
};
POLLIN 普通或带外优先数据可读,即POLLRDNORM | POLLRDBAND
POLLRDNORM 数据可读
POLLRDBAND 优先级带数据可读
POLLPRI 高优先级可读数据
POLLOUT 普通或带外数据可写
POLLWRNORM 数据可写
POLLWRBAND 优先级带数据可写
POLLERR 发生错误
POLLHUP 发生挂起
POLLNVAL 描述字不是一个打开的文件
如果不再监控某个文件描述符时,可以把pollfd中,fd设置为-1,poll不再监控此pollfd,下次返回时,把revents设置为0。
epoll
epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
目前epoll是linux大规模并发网络程序中的热门首选模型。
epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。
epollAPI
1.创建红黑树
#include <sys/epoll.h>
int epoll_create(int size);
参数:
size : 监听的文件描述符的上限, 2.6版本之后写1即可。
返回: 成功:返回树的句柄 失败:-1,设置相应的errno
2.上树 下树 修改节点
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
epfd : 树的句柄
op : EPOLL_CTL_ADD 上树 EPOLL_CTL_DEL 下树 EPOLL_CTL_MOD 修改
fd : 上树,下树的文件描述符
event : 告诉内核需要监听的事件
返回值:成功:0;失败:-1,设置相应的errno
struct epoll_event {
uint32_t events; /* Epoll events */ 需要监听的事件
epoll_data_t data; /* User data variable */ 需要监听的文件描述符
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
EPOLLIN : 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLOUT: 表示对应的文件描述符可以写
EPOLLPRI: 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR: 表示对应的文件描述符发生错误
EPOLLHUP: 表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
将cfd上树
int epfd = epoll_create(1);
struct epoll_event ev;
ev. data.fd = cfd;
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD,cfd, &ev);
3.监听
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
功能: 等待所监控文件描述符上有事件的产生,类似于select()调用。
epfd : 树的句柄
events : 接收变化的节点的数组的首地址
maxevents : 数组元素的个数
timeout : -1 永久监听 大于等于0 限时等待
返回值: 成功返回的是变化的文件描述符个数,时间到时返回0,出错返回-1。
epoll示例
注意:
在 epoll 中,一个可读事件并不仅仅意味着数据已经准备好被读取,它还可以表示有新的连接请求。
在 TCP 网络编程中,当客户端尝试建立连接时,它会发送一个 SYN 包给服务器端。服务器端的监听套接字(lfd)接收到这个包后,操作系统内核会将这个连接请求放入相应的监听队列中。这个事件会被视为一个可读事件,因为它表示有新的连接请求需要被接受(通过 accept 函数)。
因此,当我们在使用 epoll 处理网络事件时,EPOLLIN 事件在监听套接字上的意义是“有新的连接请求到来”,而在已连接的套接字上则通常表示“有新的数据可读”。
epoll的工作方式
EPOLL事件有两种模型:
Edge Triggered (ET) 边缘触发:只有数据到来才触发,不管缓存区中是否还有数据。
Level Triggered (LT) 水平触发:只要有数据都会触发。
在水平触发模式下,只要满足条件(例如,对于读操作,缓冲区中有数据未读;对于写操作,缓冲区有空间可写),epoll_wait就会通知应用程序。
这意味着:
如果应用程序没有一次性处理完所有数据(如读取或写入),那么epoll_wait将会再次返回相同的事件,直到数据被完全处理。
应用程序可以在任何时候处理数据,不必担心错过事件。
使用起来相对简单,适用于大多数场景。
在边缘触发模式下,只有当状态发生变化时(例如,从无数据到有数据,或从无可用写入空间到有可用空间),epoll_wait才会通知应用程序。
这意味着:
事件只有在状态变化时才会被触发一次,即使缓冲区中仍有未处理的数据,epoll_wait也不会再次通知应用程序。
应用程序需要一次性处理完所有可用的数据,否则可能错过事件。
更加高效,因为它减少了事件的数量,但编程难度更大,需要更加小心地管理缓冲区。
边沿触发 + 非阻塞 = 高速模式
如果设置为水平触发,只要缓存区有数据,epoll_wait就会被触发,epoll_wait是一个系统调用,尽量少调用。
这里,每次epoll_wait监听到事件变化,我们就打印epoll_wait。然后设置缓冲区为4个字节,但是我们一次性发送10个字节。
客户端发送1234567890结果如下。我们可以看见,在一个读事件响应时,epoll_wait触发了三次。
注意:在这里我把printf换成了write,将信息打印到终端,为什么这么做?
问题在于我使用了固定大小的缓冲区char buf[4] = "";
来接收可能超过4个字符的数据。这里的关键是理解read函数的行为和printf函数与write函数处理字符串的方式。
read函数:当调用read(evs[i].data.fd, buf, 4);,它会从文件描述符读取最多4个字节的数据并存放到buf中。如果读取的数据小于4个字节,read不会在buf的末尾添加空字符\0来表示字符串的结束。
printf函数:printf函数处理以\0结尾的字符串。如果buf没有以\0结尾,printf会继续读取内存,直到遇到\0,这可能会打印出预期之外的字符,从而导致乱码。
所以尽量使用边缘触发。这个时候要求一次性将数据读完。那么我们就需要使用while循环读,但是读到最后两个字节的时候read函数默认带阻塞,其他文件描述符到来则不能监听。所以我门不能让read阻塞,因此设置cfd为非阻塞,read读到最后一次返回值为-1,我们只需要判断errno的值为EAGAIN,代表数据读干净。
这里只触发了一次epoll_wait,但是只打印了1234,说明没有一次性讲数据读完。
设置cfd为非阻塞,并且循环读取缓冲区的值。
关于边缘触发+非阻塞的几个问题
1.为什么只有 cfd(客户端文件描述符)需要设置为边缘触发(EPOLLET)?
在 epoll 的边缘触发(ET)模式下,事件只在状态变化时通知一次。对于监听套接字(lfd),一旦有新的连接请求,它的状态就会改变,所以通常不需要设置为 ET。但是对于客户端套接字(cfd),数据可以在任何时候到达,而且可能是分多次接收。使用 ET 模式可以提高效率,因为只有数据状态发生变化时,才会触发通知。
2.为什么 cfd 要设置为非阻塞?
非阻塞 I/O 意味着 I/O 操作将立即返回,不会等待。在 ET 模式下,一旦 epoll_wait 通知有数据可读,就需要一次性读取所有可用数据,因为不会再次通知我们相同的数据。如果 cfd 是阻塞的,当尝试读取超出当前可用数据量的数据时,read 调用将会阻塞。这可能导致程序卡在一个套接字上,无法处理其他套接字。设置为非阻塞可以确保即使尝试读取的数据量超过了当前可用的量,read 也会立即返回。
3.默认的阻塞是什么情况?read 读取默认的文件描述符会循环等待缓冲区增加数据吗?
默认情况下,套接字是阻塞的。这意味着当我们对这样的套接字执行 read 操作时,如果没有足够的数据满足 read 请求,read 将会阻塞执行,直到有足够的数据可读。它不是“循环等待”,而是处于挂起状态,直到条件满足(即有足够数据到达)或发生错误。
总结来说,使用非阻塞 I/O 和边缘触发模式是为了提高服务器处理多个客户端连接的效率,特别是在高负载情况下。这种方式允许服务器在有数据处理时立即进行处理,而在没有数据时可以进行其他任务,从而提高整体的吞吐量和响应性。
反应堆模型和线程池模型
epoll反应堆设计思想
反应堆模型(Reactor Pattern):这是一种事件处理模式,用于处理并分发输入或输出事件。在这个模式中,程序非阻塞地等待事件发生(例如,数据可读、连接就绪等),当事件发生时,调用预先注册的回调函数来处理这些事件。
反应堆简单版代码
总的流程如下:
服务器启动并创建 epoll 实例。
服务器将监听套接字(lfd)添加到 epoll 实例,初始关注读事件。
服务器循环调用 epoll_wait 等待事件发生。
当有新客户端连接时,服务器接受连接并将客户端套接字(cfd)添加到 epoll 实例,关注读事件。
当 epoll_wait 检测到 cfd 上有可读数据时,调用 readData 函数处理读事件。
readData 读取数据,处理完毕后,将 cfd 的关注事件改为写事件,准备向客户端发送数据。
当 epoll_wait 检测到 cfd 可写时,调用相应的写数据处理函数(如 senddata)。
先看main函数:
前四步是创建套接字并绑定,然后改主动为被动监听。第五步创建一个epoll树根以及存放接收变化的节点的数组。
接下来首先将监听套接字lfd上树,然后开始循环处理。当监听的事件到来时,调用相应的回调函数。
eventadd(lfd, EPOLLIN, initAccept, &myevents[_EVENT_SIZE_], &myevents[_EVENT_SIZE_]);
这行代码\将 lfd 添加到 epoll 实例中,并注册 initAccept 作为其回调函数。这意味着,当 lfd 上有新的连接请求时(即可读事件 EPOLLIN 发生),epoll_wait 会返回,而且 initAccept 会被调用。
在 epoll_wait 循环内,当 lfd 上的读事件发生时,initAccept 被调用。initAccept 函数接受新的连接请求,并为新的连接创建一个文件描述符 cfd。
然后,initAccept 为 cfd 注册一个新的事件处理器 readData,用于处理来自该客户端的读事件。
事件结构体 xevent:定义了事件相关的数据和回调函数,用于处理不同的事件。
关于 eventadd 的工作原理
eventadd 将 fd 和相关事件以及回调函数绑定,然后使用 epoll_ctl 将这个事件添加到 epoll 实例中。
当 epoll_wait 返回时,它提供了一个包含就绪事件的列表。程序遍历这个列表,对于每个就绪事件,根据绑定的回调函数进行处理。
接下来就是把cfd上树,并且关联对应的回调函数。
让我们看看cfd的回调函数内容。
在 epoll 中,文件描述符(fd)与 epoll 事件结构体(struct epoll_event)是关联的。当在 epoll 实例中添加、修改或删除一个事件时,是基于文件描述符来操作的。
在删除事件时(EPOLL_CTL_DEL),通常不需要指定事件类型,因为删除操作是针对文件描述符本身,而不是特定的事件类型。所以,即使 epoll_event epv 中的其他字段没有被设置或使用,删除操作也能正确执行。在 eventdel 函数中,即使重新定义了一个新的 epoll_event epv 结构体,但最重要的是它和要操作的文件描述符(fd)一起被传递给 epoll_ctl 函数。epoll_ctl 根据文件描述符(fd)来识别需要操作的事件。因此,即使 epoll_event 结构是新的,只要文件描述符(fd)是正确的,epoll_ctl 就能找到并正确地处理对应的事件。
关于epoll 的核心工作原理,特别是如何使用 epoll 的 data.ptr 字段来关联事件和回调函数。
epoll 的工作原理
注册事件:当调用 epoll_ctl 并使用 EPOLL_CTL_ADD 操作时,实际上告诉 epoll 实例监视指定的文件描述符(fd)上的某些事件(如 EPOLLIN,表示可读事件)。
epoll_event 结构中的 data 字段允许我们存储一个用户定义的数据,这通常是一个指向某种结构的指针。
epoll_event 结构体:epoll_event 结构体有一个名为 data 的联合体,其中有一个 void* 类型的 ptr 成员。这意味着您可以将任何类型的指针存储在这里,epoll 不会尝试解释它,仅仅是在事件发生时返回它。
关联事件和回调函数:我们创建了一个 xevent 结构体的实例,这个结构体包含了文件描述符、事件类型和回调函数。我们将这个结构体的指针存储在 epoll_event 的 data.ptr 中。
触发事件和调用回调
当我们调用 epoll_wait 并且某个事件发生时,epoll 将填充我们传递给它的 epoll_event 数组。每个数组元素包含了发生事件的文件描述符的相关信息,以及我们最初存储在 data.ptr 中的数据 xevent。
这时,程序会检查这个数组,获取每个 epoll_event 的 data.ptr,并将其转换回 xevent 结构体的指针。由于这个结构体包含了回调函数的指针,我们的程序就可以直接调用它。
调用回调:由于 xevent 结构体包含了回调函数的指针,当我们从 epoll_wait 获取 xevent 指针时,可以直接调用其中的回调函数,就像这样:xe->call_back(xe->fd, xe->events, xe);
线程池思想
线程池是一个抽象概念,可以简单的认为若干线程在一起运行,线程不退出,等待有任务处理。
为什么要有线程池?
1.以网络编程服务器端为例。作为服务器端支持高并发,可以有多个客户端连接、发出请求。对于多个请求我们每次都去建立线程,这样线程会创建很多,而且线程执行完销毁也会有很大的系统开销,使用上效率很低。
2.之前在线程篇章中,我们也知道创建线程并非多多益善,所以我们的思路是提前创建好若干个线程,不退出,等待任务的产生,去接收任务处理后等待下一个任务。
线程池如何实现?需要思考2个问题?
1.假设线程池创建了,线程们如何去协调接收任务并且处理?
2.线程池上的线程如何能够执行不同的请求任务?
上述问题1就很像我们之前学过的生产者和消费者模型,客户端对应生产者,服务器端这边的线程池对应消费者,需要借助互斥锁和条件变量来搞定。
问题2解决思路就是利用回调机制,我们同样可以借助结构体的方式,对任务进行封装,比如任务的数据和任务处理回调都封装在结构体上,这样线程池的工作线程拿到任务的同时,也知道该如何执行了。
线程池代码解读
任务和线程池结构
PoolTask:代表线程池中的一个任务。它包含:
tasknum: 任务编号,用于识别任务。
arg: 指向任务函数的参数。
task_func: 任务函数的指针,这是实际执行的函数。
ThreadPool: 代表线程池本身。它包含:
max_job_num: 最大任务数量。
job_num: 当前任务数量。
tasks: 任务队列数组。
job_push 和 job_pop: 分别用于标记任务的入队和出队位置。
thr_num: 线程池中的线程数量。
threads: 线程数组。
shutdown: 标记是否关闭线程池。
pool_lock: 线程池的互斥锁。
empty_task 和 not_empty_task: 条件变量,用于控制任务队列的状态。
主要函数
main
当main函数运行时,首先创建一个线程池并添加一系列任务。
每个任务被添加到线程池的任务队列中,线程池中的线程会取出并执行这些任务。
当所有任务完成后,线程池被销毁,释放所有资源。
void create_threadpool(int thrnum,int maxtasknum);
void addtask(ThreadPool *pool);
int taskpos = (pool->job_push++)%pool->max_job_num;
是入队位置对最大任务数量取模,为了可以在0-max_job_num-1之间循环(数组模拟循环队列)。
其中tasks是PoolTask *tasks;//任务队列数组
void *thrRun(void *arg);
这里,线程从线程池的任务队列中取出一个任务。memcpy 用于复制任务队列中的任务到一个局部变量 task,这样做的原因是为了避免在任务执行过程中任务被其他线程更改。
线程池和epoll
接下来就是epoll循环监听。
添加任务主要改动就是填充任务节点的结构体。
任务回调函数
tcp和udp的优缺点和使用场景
TCP: 传输控制协议,安全可靠,丢包重传,面向连接(电话模型) 。
UDP: 用户数据报协议,不安全不可靠,丢包不重传,快,不面向连接(邮件模型)。
传输层主要应用的协议模型有两种,一种是TCP协议,另外一种则是UDP协议。
TCP协议在网络通信中占主导地位,绝大多数的网络通信借助TCP协议完成数据传输。但UDP也是网络通信中不可或缺的重要通信手段。
相较于TCP而言,UDP通信的形式更像是发短信。不需要在数据传输之前建立、维护连接。只专心获取数据就好。省去了三次握手的过程,通信速度可以大大提高,但与之伴随的通信的稳定性和正确率便得不到保证。因此,我们称UDP为“无连接的不可靠报文传递”。
那么与我们熟知的TCP相比,UDP有哪些优点和不足呢?由于无需创建连接,所以UDP开销较小,数据传输速度快,实时性较强。多用于对实时性要求较高的通信场合,如视频会议、电话会议等。但随之也伴随着数据传输不可靠,传输数据的正确率、传输顺序和流量都得不到控制和保证。所以,通常情况下,使用UDP协议进行数据传输,为保证数据的正确性,我们需要在应用层添加辅助校验协议来弥补UDP的不足,以达到数据可靠传输的目的。
通信流程
tcp
服务器: 创建流式套接字 绑定 监听 提取 读写 关闭
客户端: 创建流式套接字 连接 读写 关闭
收发数据:
read recv
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
write send
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
udp
服务器: 创建报式套接字 绑定 读写 关闭
客户端: 创建报式套接字 读写 关闭
发数据:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
dest_addr: 目的地的地址信息
addrlen: 结构体大小
收数据:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
src_addr: 对方的地址信息
addrlen: 结构体大小的地址
udp服务器通信流程
创建报式套接字
int socket(int domain, int type, int protocol);
参数:
domain:AF_INET
type:SOCK_DGRAM
protocol:0
udp客户端通信流程
通过本地套接字进行本地进程通信
socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。
虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。
这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。
UNIX Domain Socket是全双工的,API接口语义丰富,相比其它IPC机制有明显的优越性,目前已成为使用最广泛的IPC机制,比如X Window服务器和GUI程序之间就是通过UNIXDomain Socket通讯的。
.
本地套接字实现TCP服务器和客户端
使用UNIX Domain Socket的过程和网络socket十分相似,也要先调用socket()创建一个socket文件描述符,address family指定为AF_UNIX,type可以选择SOCK_DGRAM或SOCK_STREAM,protocol参数仍然指定为0即可。
UNIX Domain Socket与网络socket编程最明显的不同在于地址格式不同,用结构体sockaddr_un表示,网络编程的socket地址是IP地址加端口号,而UNIX Domain Socket的地址是一个socket类型的文件在文件系统中的路径,这个socket文件由bind()调用创建,如果调用bind()时该文件已存在,则bind()错误返回。
struct sockaddr_un {
__kernel_sa_family_t sun_family; /* AF_UNIX */ 地址结构类型
char sun_path[UNIX_PATH_MAX]; /* pathname */ socket文件名(含路径)
};
创建本地套接字用于tcp通信
int socket(int domain, int type, int protocol);
参数:
domain:AF_UNIX
type:SOCK_STREAM
protocol:0
绑定
int bind(int sockfd, const struct sockaddr *addr,
socklen_t addrlen);
sockfd:本地套接字
addr:本地套接字结构体地址
addrlen:sockaddr_un大小
unixtcp_server
unixtcp_client
libevent
Libevent 是一个用C语言编写的、轻量级的开源高性能事件通知库,主要有以下几个亮点:事件驱动(event-driven),高性能;轻量级,专注于网络,不如 ACE 那么臃肿庞大;源代码相当精炼、易读;跨平台,支持 Windows、Linux、 *BSD 和 Mac Os;支持多种 I/O 多路复用技术, epoll、 poll、 dev/poll、 select 和kqueue 等;支持 I/O,定时器和信号等事件;注册事件优先级。
libevent包括事件管理、缓存管理、DNS、HTTP、缓存事件几大部分。
事件管理包括各种IO(socket)、定时器、信号等事件;
缓存管理是指evbuffer功能;
DNS是libevent提供的一个异步DNS查询功能;
HTTP是libevent的一个轻量级http实现,包括服务器和客户端。
libevent也支持ssl,这对于有安全需求的网络程序非常的重要,但是其支持不是很完善,比如http server的实现就不支持ssl。
Reactor(反应堆)模式是libevent的核心框架,libevent以事件驱动,自动触发回调功能。
安装libevent
官网下载源码压缩包
解压源码包
进入源码目录
执行配置./configure,生成makefile
编译源码
编译后安装
头文件目录:
库目录:
安装后验证
如果执行失败
终端输入
这条命令的意思是:“使用管理员权限创建一个符号链接(软链接),这个链接的源文件是/usr/local/lib/libevent-2.0.so.5,链接(即快捷方式)将被创建在/usr/lib/libevent-2.0.so.5。
然后再执行一次
Libevent的入门级使用
Libevent的地基-event_base
可以理解为EPOLL的树根。
在使用libevent的函数之前,需要先申请一个或多个event_base结构,相当于盖房子时的地基。在event_base基础上会有一个事件集合,可以检测哪个事件是激活的(就绪)。
通常情况下可以通过event_base_new函数获得event_base结构。返回值就是event_base根节点地址。
struct event_base* event_base_new(void);
申请到event_base结构指针可以通过event_base_free进行释放。
void event_base_free(struct event_base *);
如果fork出子进程,想在子进程继续使用event_base,那么子进程需要对event_base重新初始化,函数如下:
int event_reinit(struct event_base *base);
对于不同系统而言,event_base就是调用不同的多路IO接口去判断事件是否已经被激活,对于linux系统而言,核心调用的就是epoll,同时支持poll和select。
等待事件产生-循环等待event_loop
Libevent在地基打好之后,需要等待事件的产生,也就是等待想要等待的事件的激活,那么程序不能退出,对于epoll来说,我们需要自己控制循环,而在libevent中也给我们提供了API接口,类似while(1){epoll_wait}循环监听的功能。函数如下:
int event_base_loop(struct event_base *base, int flags);
flags的取值:
#define EVLOOP_ONCE 0x01
只触发一次,如果事件没有被触发,阻塞等待。#define EVLOOP_NONBLOCK 0x02
非阻塞方式检测事件是否被触发,不管事件触发与否,都会立即返回。
而大多数我们都调用libevent给我们提供的另外一个API:
int event_base_dispatch(struct event_base *base);
调用该函数,相当于没有设置标志位的event_base_loop。程序将会一直运行,直到没有需要检测的事件了,或者被结束循环的API终止。
退出循环监听:
int event_base_loopexit(struct event_base *base, const struct timeval *tv);
int event_base_loopbreak(struct event_base *base);
struct timeval
{
long tv_sec;
long tv_usec;
};
这两个函数的区别是如果正在执行激活事件的回调函数,那么event_base_loopexit将在事件回调执行结束后终止循环(如果tv时间非NULL,那么将等待tv设置的时间后立即结束循环),而event_base_loopbreak会立即终止循环。
事件驱动-event
事件驱动实际上是libevent的核心思想,本小节主要介绍基本的事件event。
主要的状态转化:
主要几个状态:
无效的指针 此时仅仅是定义了 struct event *ptr;
非未决:相当于创建了事件,但是事件还没有处于被监听状态,类似于我们使用epoll的时候定义了struct epoll_event ev并且对ev的两个字段进行了赋值,但是此时尚未调用epoll_ctl。
未决:就是对事件开始监听,暂时未有事件产生。相当于调用epoll_ctl。
激活:代表监听的事件已经产生,这时需要处理,相当于我们epoll所说的事件就绪。
初始化上树节点
struct event *event_new(struct event_base *base, evutil_socket_t fd, short events, event_callback_fn cb, void *arg);
参数:
base: event_base根节点
fd: 上树的文件描述符
events: 监听的事件
cb: 回调函数
arg: 传给回调函数的参数
返回值: 初始化好的节点的地址
#define EV_TIMEOUT 0x01 //超时事件
#define EV_READ 0x02 //读事件
#define EV_WRITE 0x04 //写事件
#define EV_SIGNAL 0x08 //信号事件
#define EV_PERSIST 0x10 //周期性触发
#define EV_ET
回调函数结构:
typedef void (*event_callback_fn)(evutil_socket_t fd, short events, void *arg);
参数:
fd:文件描述符
events:监听的事件
arg:传给回调函数的参数
节点上树
@function:将非未决态事件转为未决态,相当于调用epoll_ctl函数,开始监听事件是否产生。
int event_add(struct event *ev, const struct timeval *timeout);
参数:
ev:上树节点的地址
timeout:限时等待事件的产生,也可以设置为NULL,没有限时。
节点下树
int event_del(struct event *ev);
参数:
ev:下树节点的地址
使用libevent编写tcp服务器
-
创建套接字
-
绑定
-
监听
-
创建event_base根节点
-
初始化上树节点
这里将event_base节点地址当作回调函数参数传入lfd的回调函数中,以便后续监听到读事件上树。 -
上树
-
循环监听
-
收尾
lfd的回调函数
cfd的回调函数
注意:
因为每连接一个客户端,在lfd的回调函数中都会把ev指针的指向赋给新的客户端,因此如果将ev当作回调函数参数传递给cfd的回调函数的话,会发生cfd和ev不对应的情况,并不能正确的下树。
因此用一个数组来存放cfd和对应的struct event*类型的ev,可以保证一一对应,能够正确的下树。
数组的定义如下:
初始化:
然后在lfd监听到客户端连接,提取到cfd后将cfd和对应的ev插入数组。
因为数组是全局变量,不需要当作参数传递给cfd回调函数,因此监听到客户端断开时候,可以直接去数组里找到对应的ev,并下树。
bufferevent事件
普通的event事件:一个文件描述符,事件(底层缓冲区的读事件或者写事件) 触发->回调
高级的event事件:bufferevent事件
Bufferevent实际上也是一个event,只不过比普通的event高级一些,它的内部有两个缓冲区,以及一个文件描述符(网络套接字)。我们都知道一个网络套接字有读和写两个缓冲区,bufferevent同样也带有两个缓冲区,还有就是libevent事件驱动的核心回调函数,那么四个缓冲区以及触发回调的关系如下:
bufferevent有三个回调函数:
- 读回调:当bufferevent将底层读缓冲区的数据读到自身的读缓冲区时触发读事件回调。
- 写回调:当bufferevent将自身写缓冲的数据写到底层写缓冲区的时候出发写事件回调。
- 事件回调:当bufferevent绑定的socket连接,断开或者异常的时候触发事件回调。
bufferveent事件的监听流程
bufferevent事件的API
创建新的节点
struct bufferevent *bufferevent_socket_new(struct event_base *base, evutil_socket_t fd, int options);
参数:
base:event_base 根节点
fd:要初始化上树的文件描述符
options:
BEV_OPT_CLOSE_ON_FREE:释放bufferevent自动关闭底层接口(fd)
BEV_OPT_THREADSAFE:使bufferevent能够在多线程下是安全的
返回值:新建节点的地址
设置节点回调
bufferevent_setcb用于设置bufferevent的回调函数。
void bufferevent_setcb(struct bufferevent *bufev , bufferevent_data_cb readcb,
bufferevent_data_cb writecb,bufferevent_event_cb eventcb, void *cbarg);
参数:
bufev : 新建的节点的地址
readcb : 读回调
writecb : 写回调
eventcb : 事件回调(断开连接等)
cbarg : 传给回调函数的参数
回调函数的原型:
typedef void (*bufferevent_data_cb)(struct bufferevent *bev, void *ctx); //读写回调
typedef void (*bufferevent_event_cb)(struct bufferevent *bev, short what, void *ctx); //事件回调
What 代表对应的事件:
BEV_EVENT_EOF :对方关闭连接
BEV_EVENT_ERROR :出错
BEV_EVENT_TIMEOUT :超时
BEV_EVENT_CONNECTED :建立连接成功
bufferevent_socket_connect封装了底层的socket与connect接口,通过调用此函数,可以将bufferevent事件与通信的socket进行绑定
让事件使能
bufferevent_enable与bufferevent_disable是设置事件是否生效,如果设置为disable,事件回调将不会被触发。
int bufferevent_enable(struct bufferevent *bufev, short event);//EV_READ EV_WRITE
int bufferevent_disable(struct bufferevent *bufev, short event);//EV_READ EV_WRITE
写数据
bufferevent_write是将data的数据写到bufferevent的写缓冲区。
bufferevent_write_buffer 是将数据写到写缓冲区另外一个写法,实际上bufferevent的内部的两个缓冲区结构就是struct evbuffer。
int bufferevent_write(struct bufferevent *bufev, const void *data, size_t size);
int bufferevent_write_buffer(struct bufferevent *bufev, struct evbuffer *buf);
读数据
bufferevent_read 是将bufferevent的读缓冲区数据读到data中,同时将读到的数据从bufferevent的读缓冲清除。
bufferevent_read_buffer 将bufferevent读缓冲数据读到buf中,接口的另外一种。
size_t bufferevent_read(struct bufferevent *bufev, void *data, size_t size);
int bufferevent_read_buffer(struct bufferevent *bufev, struct evbuffer *buf);
客户端使用的建立连接函数
int bufferevent_socket_connect(struct bufferevent *bev, struct sockaddr *serv, int socklen);
参数:
bev:需要提前初始化的bufferevent事件
serv:服务器的地址信息,ip地址,端口,协议的结构指针
socklen:描述serv的长度
链接监听器
链接监听器封装了底层的socket通信相关函数,比如socket,bind,listen,accept这几个函数。链接监听器创建后实际上相当于调用了socket,bind,listen,此时等待新的客户端连接到来,如果有新的客户端连接,那么内部先进行accept处理,然后调用用户指定的回调函数。
初始化
struct evconnlistener * evconnlistener_new_bind(struct event_base *base,
evconnlistener_cb cb, void *ptr, unsigned flags, int backlog,
const struct sockaddr *sa, int socklen);
参数:
base:根节点
cb:提取套接字(cfd)后调用的回调
ptr:传给回调函数的参数
Flags:
LEV_OPT_LEAVE_SOCKETS_BLOCKING:文件描述符为阻塞的
LEV_OPT_CLOSE_ON_FREE:关闭时自动释放
LEV_OPT_REUSEABLE:端口复用
LEV_OPT_THREADSAFE:分配锁,线程安全
backlog:-1,自动填充
sa:绑定的地址信息
socklen:sa的大小
返回值:链接监听器的地址
evconnlistener_new_bind是在当前没有套接字的情况下对链接监听器进行初始化,最后2个参数实际上就是bind使用的关键参数,backlog是listen函数的关键参数(略有不同的是,如果backlog是-1,那么监听器会自动选择一个合适的值,如果填0,那么监听器会认为listen函数已经被调用过了),ptr是回调函数的参数,cb是有新连接之后的回调函数,但是注意这个回调函数触发的时候,链接器已经处理好新连接了,并将与新连接通信的描述符交给回调函数。
struct evconnlistener *evconnlistener_new(struct event_base *base,
evconnlistener_cb cb, void *ptr, unsigned flags, int backlog,
evutil_socket_t fd);
evconnlistener_new函数与前一个函数不同的地方在与后2个参数,使用本函数时,认为socket已经初始化好,并且bind完成,甚至也可以做完listen,所以大多数时候,我们都使用第一个函数。
回调函数
typedef void (*evconnlistener_cb)(struct evconnlistener *evl, evutil_socket_t fd,
struct sockaddr *cliaddr, int socklen, void *ptr);
参数:
evl:链接监听器的地址
fd:cfd
cliaddr:客户端的地址信息
socklen:地址信息的长度
ptr:传给回调函数的参数
释放链接监听器
void evconnlistener_free(struct evconnlistener *lev);
int evconnlistener_enable(struct evconnlistener *lev);
使链接监听器生效
int evconnlistener_disable(struct evconnlistener *lev);
使链接监听器失效
bufferevent 实现tcp服务器与客户端
tcp服务器
-
创建event_base根节点
-
创建链接侦听器
-
创建信号节点并上树
-
链接侦听器的回调函数(有新的连接到来自动调用)
-
读回调:讲读到的信息发送回去
-
写回调
获取缓冲区类型,然后查看缓冲区是否有值,判断是否发过去message。如果需要服务器发送完helloworld就断开连接,打开注释即可。 -
事件回调
-
信号回调(信号处理函数)
收到ctrl+c打断信号,延迟两秒退出循环监听。
tcp客户端
- 首先传入服务器的ip地址和端口:
- 创建event_base根节点
- 创建套接字 连接服务器
-1表示目前还不知道fd。 - 新建一个普通event节点,上树
这里监听的是终端输入,如果用户输入并且按下回车就会触发回调函数cmd_msg_cb。 - 连接服务器
- 设置buffer的回调函数
- 终端输入回调函数
- 读回调
- 事件回调
webserver项目
原理
html
html简介
Html(Hyper Texture Markup Language)是超文本标记语言,在计算机中以 .html或者.htm作为扩展名,可以被浏览器识别,就是经常见到的网页。
html语法
html的语法非常简洁,以相应的英语单词关键字进行组合。
html标签不区分大小写,标签大多数成对出现,有开始,有结束。例如<html></html>
,但是并没有要求必须成对出现.同时也有固定的短标签,例如<br/>
,<hr/>
。
html的组成可以分为如下部分:
<html>
开始 和</html>
结束,属于html的根标签。<head>
</head>
头部标签,头部标签内一般有<title>
</title>
。<body>
</body>
主体标签,一般用于显示内容。
学习html基本可以认为就是学习各种标签。标签也可以设置属性,例如<font color="red">hello, world</font>
,示例中color代表标签的颜色属性,red代表标签是红色字体,hello,world为实际显示的内容。
也可以指定页面类型和字符编码,下面设置页面类型为html,并且字符编码为utf8
<meta http-equiv="content-Type" content="text/html; charset=utf8">
Html标签属性,可以双引号,单引号,或者不写
html标签介绍
题目标签
共有6种,<h1>
,<h2>
,…<h6>
,其中<h1>
最大,<h6>
最小。
文本标签
标签,可以设置颜色和字体大小属性。
字体大小可以使用size属性,大小范围为1-7,其中7最大,1最小。
有时候需要使用换行标签 ,这是一个短标签 <br/>
与之对应另外还有一个水平线也是短标签, <hr/>
,水平线也可以设置颜色和大小。
列表标签
列表标签分无序列表和有序列表,分别对应<ul>
和<ol>
。
无序列表的格式如下:
<ul>
<li>列表内容1</li>
<li>列表内容2</li>
…
</ul>
无序列表可以设置type属性:
- 实心圆圈:type=disc
- 空心圆圈:type=circle
- 小方块: type=square
有序列表的格式如下:
<ol>
<li>列表内容1</li>
<li>列表内容2</li>
…
</ol>
有序列表同样可以设置type属性:
- 数字:type=1,也是默认方式
- 英文字母:type=a或type=A
- 罗马数字:type=i或type=I
图片标签
图片标签使用<img>
,内部需要设置若干属性,可以不必写结束标签。
属性:
1.src=”3.gif” 图片来源,必写。
2.alt=”图片不存在” 图片不显示时,显示的内容。
3.title=”我的天呐” 鼠标移动到图片上时显示的文字。
4.width=”600” 图片显示的宽度。
5.height=”400” 图片显示的高度。
超链接标签
超链接标签使用<a>
,同样需要设置属性表明要链接到哪里。
属性:
1.href=”http://www.baidu.com”
。前往地址,必填,注意要写http://
2.title=”百度”
。鼠标移动到链接上时显示的文字。
3.target=”_self”或者”_blank”
,_self是默认值,在自身页面打开,_blank是新开页面前往连接地址。
当我们访问某个网站的时候,当请求的资源不存在,经常会给我们报告一个错误,显示为404错误,一般会给请求用户返回一个错误页,可以自行尝试一下编写一个我们自己的错误页。
http超文本传输协议
http协议和html前面的ht都是超文本的意思,所以http与html是配合非常紧密的一对,我们可以认为http就是为了传输html这样的文件。
http位于应用层,侧重于解释。
http协议对消息区分可以分为请求消息和响应消息。
http请求消息
请求消息分为四部分内容:
- 请求行:说明请求类型,请求的内容,以及使用的http版本。
- 请求头:说明服务器使用的附加信息,都是键值对。比如表明浏览器类型。
- 空行:不能省略,而且是\r\n,包括请求行和请求头都是以\r\n结尾。
- 请求数据:表明请求的特定数据内容,可以省略。如登陆时会将用户名和密码内容作为请求数据。
请求行:GET /demo.html HTTP/1.1\r\n
请求的方式 请求的内容 版本 \r\n
请求头:
空行:\r\n
数据:
请求类型
http协议有很多种请求类型,对我们来说常见的用的最多的是get和post请求。常见的请求类型如下:
- Get:请求指定的页面信息,并返回实体主体。
- Post:向指定资源提交数据进行处理请求(例如提交表单或者上传文件)。数据被包含在请求体中。POST请求可能会导致新的资源的建立或已有资源的修改。
- Head:类似于get请求,但是响应消息没有内容,只是获得报头。
- Put:从客户端向浏览器传送的数据取代指定的文档内容。
- Delete:请求服务器删除指定的页面。
- Connect:HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器。
- Options:允许客户端查看浏览器的性能。
- Trace:回显服务器收到的请求,主要用于测试和诊断。
get 和 post 请求都是请求资源,而且都会提交数据,如果提交密码信息用get请求,就会明文显示,而post则不会显示出涉密信息。
http响应消息
响应消息是代表服务器收到请求消息后,给浏览器做的反馈,所以响应消息是服务器发送给浏览器的。
响应消息也分为四部分:
- 状态行:包括http版本号,状态码,状态信息。
常见的状态码如下:
- 200 OK 客户端请求成功
- 301 Moved Permanently 重定向
- 400 Bad Request 客户端请求有语法错误,不能被服务器所理解
- 401 Unauthorized 请求未经授权,这个状态代码必须和WWW-Authenticate报头域一起使用
- 403 Forbidden 服务器收到请求,但是拒绝提供服务
- 404 Not Found 请求资源不存在,eg:输入了错误的URL
- 500 Internal Server Error 服务器发生不可预期的错误
- 503 Server Unavailable 服务器当前不能处理客户端的请求,一段时间后可能恢复正常
-
消息报头:说明客户端要使用的一些附加信息,也是键值对。
文件的类型(必填的):
文件的长度(可填可不填,填一定填对):
-
空行:\r\n 同样不能省略。
-
响应正文:服务器返回给客户端的文本信息。
http常见状态码
http状态码由三位数字组成,第一个数字代表响应的类别,有五种分类:
1.1xx 指示信息:表示请求已接收,继续处理。
2.2xx 成功:表示请求已被成功接收、理解、接受
3.3xx 重定向–要完成请求必须进行更进一步的操作
4.4xx 客户端错误–请求有语法错误或请求无法实现
5.5xx 服务器端错误–服务器未能实现合法的请求
http常见文件类型分类
http与浏览器交互时,为使浏览器能够识别文件信息,所以需要传递文件类型,这也是响应消息必填项,常见的类型如下:
- 普通文件: text/plain; charset=utf-8
- *.html: text/html; charset=utf-8
- *.jpg: image/jpeg
- *.gif: image/gif
- *.png: image/png
- *.wav: audio/wav
- *.avi: video/x-msvideo
- *.mov: video/quicktime
- *.mp3: audio/mpeg
特别说明
- charset=iso-8859-1 西欧的编码,说明网站采用的编码是英文;
- charset=gb2312 说明网站采用的编码是简体中文;
- charset=utf-8 代表世界通用的语言编码;可以用到中文、韩文、日文等世界上所有语言编码上
- charset=euc-kr 说明网站采用的编码是韩文;
- charset=big5 说明网站采用的编码是繁体中文;