select的缺陷
(1)fd,set的本质是一个位图,容量是固定的1024,因此最大只能监听1024个连接 (可以扩容)
(2)监听和就绪用的是同一个数据结构,使用困难
(3)存在多次大量的从用户态到内核态的拷贝,因为我们设置fd_set 都是在用户态,但是要实现监听必须要将fd_set从用户态拷贝到内核态
(4)采用轮询找到就绪的fd,在海量连接少量就绪的情况下,会浪费了大量的时间进行轮询
高并发服务器的基石epoll(最重要的)
epoll
是一种高性能的IO多路复用
epoll由监听集合和就绪队列组成
(1)将所有的监听数据放在内核态,看成一个文件对象(避免拷贝问题),epoll是一个文件对象,文件对象中有监听集合监听所有连接,查找的时候使用二分查找,使用红黑树进行存储,因此epoll可以建立大量的连接,实现快速查找
(2)把监听和就绪分离,就绪事件用队列保存,有任何事件处于就绪的时候会把就绪事件队列拷贝到用户态
只有Linux
操作系统才可以使用epoll
使用epoll的步骤
(1)创建一个文件对象
(2)设置监听
(3)陷入阻塞,直到任一监听就绪
(4)遍历就绪事件队列处理事件(和select有区别)
(1)创建文件对象
epoll_create
返回值为一个非负的文件描述符
(2)设置监听
epfd
:epoll对象的文件描述符,上一个函数的返回值
op
的操作
EPOLL_CTL_ADD
增加 监听
EPOLL_CTL_MOD
修改 监听
EPOLL_CTL_DEL
删除 监听
fd
:我们要监听的文件对象的文件描述符
event
:描述监听的类型
epoll_data
是一个联合体,里面的数据四选一,一般选择int fd
;
其中epoll_event
中的events
是用来指定是读就绪还是写就绪或者异常就绪,我们一般填写的是读就绪EPOLLIN
epoll_event
中的data
就是上一个结构体
(3)陷入阻塞
events
:用户给就绪事件队列分配的地址,本质上是一个数组,数组中有一个重要成员data.fd
,这个成员用来说明是谁就绪了
maxevents
:数组的长度,因为数组在传递的过程中会丢失长度信息
timeout
:表示等待的毫秒数,精度不高,-1表示无线等待
示例
不能使用break,只能使用goto
断线重连
#include <43func.h>
//服务端
int main(int argc,char * argv[]) {
ARGS_CHECK(argc,3);
int sockfd = socket(AF_INET,SOCK_STREAM,0);//创建你IPv4的TCP连接
ERROR_CHECK(sockfd,-1,"socket");
int optval = 1;
int ret = setsockopt(sockfd,SOL_SOCKET,SO_REUSEADDR,&optval,sizeof(int));
ERROR_CHECK(ret,-1,"setsockopt");
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(atoi(argv[2]));
addr.sin_addr.s_addr = inet_addr(argv[1]);
ret = bind(sockfd,(struct sockaddr*)&addr,sizeof(addr));
ERROR_CHECK(ret,-1,"bind");
ret = listen(sockfd,10);//更改server端socket的结构,更改半连接队列长度为10
ERROR_CHECK(ret,-1,"listen");
int netfd = -1;
int epofd = epoll_create(1);//创建epoll文件对象
ERROR_CHECK(epofd,-1,"epoll_create");
struct epoll_event event;//创建监听描述符
event.data.fd = STDIN_FILENO;//将文件输入流加入监听
event.events = EPOLLIN;//将监听设置为读就绪监听
epoll_ctl(epofd,EPOLL_CTL_ADD,STDIN_FILENO,&event);//将读事件stdin加入监听集合
event.data.fd = sockfd;//将网络输入流加入监听
event.events = EPOLLIN;//将监听设置为读就绪监听
epoll_ctl(epofd,EPOLL_CTL_ADD,sockfd,&event);//将读事件stdin加入监听集合
char buf[4097] = {0};
struct epoll_event readyArr[3];//就绪队列
while(1) {
int readNum = epoll_wait(epofd,readyArr,3,-1);//开启阻塞,等待就绪队列中的连接
puts("epoll_wait returns");
for(int i = 0;i < readNum;i++) {
if(readyArr[i].data.fd == STDIN_FILENO){//输入流就绪
bzero(buf,sizeof(buf));//清空buf
ret = read(STDIN_FILENO,buf,sizeof(buf));
if(ret == 0) {//读取到关闭的消息
goto end;
}
send(netfd,buf,sizeof(buf),0);//往网络文件复制数据
}
else if(readyArr[i].data.fd == sockfd) {//数据来源于网络文件
netfd = accept(sockfd,NULL,NULL);//创建新的套接字
ERROR_CHECK(netfd,-1,"accept");
event.data.fd = netfd;
event.events = EPOLLIN;
epoll_ctl(epofd,EPOLL_CTL_ADD,netfd,&event);//将网络文件描述符添加监听
}
else if(readyArr[i].data.fd == netfd) {//读取网络文件数据
bzero(buf,sizeof(buf));
ret = recv(netfd,buf,sizeof(buf),0);
if(ret == 0) {//将netfd移除监听队列
close(netfd);
event.data.fd = netfd;
event.events = EPOLLIN;
epoll_ctl(epofd,EPOLL_CTL_DEL,netfd,&event);
}
puts(buf);
}
}
}
end:
close(netfd);
}
recv和read的非阻塞
当缓冲区中有数据,正常读取
当缓冲区中没有数据,直接返回-1.好处进行不会切换状态
如果我们能够预期这个数据很快就能到达,那么我们使用非阻塞效果会更好
非阻塞适用于持续但不连续(网络)
的数据传输
异步非阻塞(io_wring
–Linux;icop
–windows):当线程读取缓冲区的时候没有数据就会立刻返回,去执行其他操作,当缓冲区就绪的某一个时刻在通知线程进行读取
同步阻塞:假如线程执行read操作读取缓冲区,如果缓冲区没有数据线程会陷入阻塞等到缓冲区中有数据读取,并且读取数据之后才会返回
同步非阻塞:线程读取缓冲区,如果缓冲区没有数据,那么线程会立刻返回,然后立刻再调用read区查看缓冲区,直到读取到数据,才执行之后的事件,这样不会导致线程阻塞,会让线程一直运行,但是相比阻塞这样的方式一直在消耗CPU资源
IO多路复用:将全部需要进行等待阻塞的操作统一到一起,既可配阻塞,也可配合非阻塞
给已经打开的文件描述符加上非阻塞属性
cmd
的取值,表示他是获取属性还是修改属性
epoll的触发方式
水平触发:只要缓冲区有数据,就会触发读取操作
如果某个用户有大量数据,epoll会重复就绪,好处是这个用户的数据会在有限时间取完,代价就是要循环好多次epoll
边缘触发:只有当新数据进来,数据量增加的时候才会触发读取操作
哪个用户来使用读取操作,就读取哪个用户的数据,但是不会循环的去读取,保证用户使用epoll的公平性,代价是如果有一个数据量大的用户,可能数据永远取不完
采用epoll的边缘触发,配合while+非阻塞recv
水平触发
边缘触发
使用while+边缘触发读取数据
设置socket属性
修改缓冲区大小
SO_SNDBUF
:设置发送缓冲区大小SO_RCVBUF
:设置接收缓冲区大小
缓冲区下限
读取缓冲区和发送缓冲区的下限,要达到下限才能发送或者读取数据
recv 的MSG_PEEK属性
复制缓冲区的数据,但是不取出bzero(buf,sizeof(buf)); ret = recv(netfd,buf,sizeof(buf),MSG_PEEK);//打印管道中的数据,将管道中的数据复制一份出来,但是不读取 puts(buf);//打印读取到的管道数据
进程池和线程池
设计架构考量方面
(1)高新能,充分利用操作系统提供的资源,能不浪费就不浪费
(2)可维护性,抽象
设计方案:
(1)基于线程【很高的可维护性{Apache},但是性能差:浪费大量的资源创建和销毁线程】,每当有一个客户端连接时,创建一个线程,断开时销毁
(2)池化的思路:申请的资源用完之后不要马上回归,可以交给另外的事情复用
进程池/线程池设计思路
(1)提前创建好若干个进程
(2)每当有任务到来分配一个进程
(3)任务完成后归还进程
(4)整个进程池关闭的时候在销毁
使用进程池/线程池出现的问题
事件驱动模型(性能更高)---- event-driven
任务太多,使用任务队列解决
事件太多,原本是采用进程来统一某些事件,但是现在需要每个事件拆分开来,每个事件使用一个进程完成,因此会有多个事件,使用IO多路复用机制管理事件
进程池(实现下载服务器)
首先我们会创建多个进程(工作进程),其中有一个是主进程(Master进程)
工作进程就是执行while(1){取任务;完成任务;恢复空闲;}的循环操作,被称为事件循环 — event loop
主线程:
(1)创建子进程
(2)监听子进程
(3)实现TCP,bind/listen,监听sockfd等操作
给工作线程分配工作,并且知道哪些线程是空闲线程,每当有客户端发起连接请求总是和主线程连接在一起,因此主线程需要使用epoll
来管理连接
创建子进程
实现tcp连接/初始化tcp连接
启动之后使用netstat -an|grep 1234
命令可以看到网络端口1234处于监听状态
父进程移交连接
“惊群问题
”:在前面我们创建子线程的时候都是先使用fork创建子进程,然后再用socket创建网络文件对象,但是父进程没有子进程的网络文件对象。假如我们将socket和fork顺序调换,那么又会出现父子进程共享网络文件对象,当客户端来一个连接时,就会有多个进程同时连接。这种多个进程同时连接的问题就叫做惊群问题
选项SO_REWSEPORT
可以解决惊群问题,但是对操作系统内核版本有限制,Nginx就是这样实现的
我们实现先fork再socket
如果已经fork过,怎么样让父子进程共享文件对象
在两个进程之间传递文件对象
除了可以传递文本内容,还可以传递控制信息,可以在两个进程之间传递信息
sockfd:不能是一个管道,必须时socket,因此父子进程传递信息必须要使用socket才能传递
msg:消息头部
msg_iov
:io vector(数组),数组里面存储着不连续数据的指针
不连续数据结构体
msg_iovlen
:数组的长度,如果其数值为1,那么说明msg_iov就是连续数据,
msg_control
:控制信息
msg_controllen
:
控制信息的处理
(1)在堆区为struct cmaghdr
申请内存
使用sendmsg
将数据发送给对方,收取使用recvmsg
去收
socketpair
和pipe
使用方法一致
使用socketpair
会在进程中产生一个文件对象sv[0]
和sv[1]
,我们使用fork
的时候会产生新的进程,此时进程中也会有一个sv[0]
和sv[1]
,并且这两个进程中sv[0]
是相互连通的,sv[1]
是相互连通的
发送的文件描述符的数值和收到的文件描述符的数值可能是不同的,但是其返回的文件对象是同一个 发送/接收实现
示例:
使用进程池实现服务端服务
epoll和select的使用对比
使用epoll实现聊天室
(1)获取readyNum
(2)遍历readyArr
(3)更局readyArr[i].data.fd数值找到分类,在处理对应事件
进程池
池:预先申请好所有资源,有任务到来时可以使用资源,任务完成之后可以留存资源,以供后续任务使用
(1)创建很多个进程分为主进程和工作进程,
主进程负责创建工作进程,一开始时创建,任务完成时不销毁
工作进程:完成具体的工作
(2)主进程的职责
a.创建子进程(使用fork,返回0就是工作进程),管理子进程(有任务到来,就分配给子进程,子进程完成任务通知主进程)主进程用epoll监听子进程的管道,子进昵称完成任务后用管道通知主线程
b.初始化TCP连接,有连接来就要将连接交给子进程,
代码的组织
主进程的工作
(1)TCP的初始化
(2)用epoll去管理多个文件描述符(sockfd和每个子进程的管道),封装epoll的操作(增加监听,删除监听)
开始工作
sendmsg和recvmsg
发送文本数据
使用sendmsg传递控制信息
给子进程增加下载的任务
TCP是一种字节流协议,消息之间没有边界,因此会导致接收的文件内容也会当作文件名
发送文件的方法------进程池进程调用下载方法-------客户端接收下载文件
应用层私有协议实现小文件传输
解决了TCP流中数据没有消息边界的问题,消息边界问题也称为”粘包“问题
解决的具体操作方法就是,我们下载文件之前,服务端将文件的文件名以及文件内容封装在一个结构体里面,这个结构体里面有Length
表示数据长度,还有char buf[]
来存储数据,客户端每次收到数据会先读取结构体中的Length
然后在根据Length
的长度来确定后面需要读取多少数据,这样就可以实现消息边界
typedef struct train_s{
int length;//记录数据长度
char buf[1000];//记录发送的数据,但是客户端只会读取Length长度的数据,同时1000也指定发送的文件大小不能超过1000个字节
} train_t;
大文件的传输
如果只是按照下面简单的使用while(1)
循环实现大文件的下载,当文件大小过大的时候就会出现下载报错,客户端收到的数据长度过大
出现错误的原因:由于recv
中len
的长度我们虽然填的是1000个字节,但是不一定能够读到1000个字节的数据,,这个len
只是规定接收端最大能接收多少,并不代表他一定能拿到多少,由于网络是不稳定的,并且TCP
是流式的可靠协议,假如我们在读到一个struct结构体
的头获取长度为1000,但是其数据只是到达900,那么接收端就会读取900个字节的数据,但是剩下的数据不会被丢弃,而且由于我们使用while(1)
进行循环,此时就会把剩下的100个字节的头4个字节作为下一个struct结构体
的头,这样就会导致读取错误。
如果我们把服务端的ERROR_CHECK
错误检测,主进程就会陷入死循环,并且我们发现会有一个僵尸进程。这是由于客户端先出错,就关闭了读操作,然而服务端不知道客户端已经断开读操作,服务器就会继续写入数据,此时就会触发SIGPIPE
信号,导致子进程终止,然而父进程在执行epoll_wait
,无法为子进程清空数据,因此就编程僵尸进程
死循环的原因:父子进程之间的管道关闭了,父进程的epoll_wait就一直就绪
生成指定大小的空文件,文件中的内容都为0
truncate -s 100M file1
使用gdb调试多进程
set follow -fork -mode child/parent
追踪子进程或父进程
临时屏蔽SIGPIPE信号----保证客户端可以死,但是服务端不能跟着客户端一起死
send
的flag
参数
在使用send
时操作参数使用MSG_NOSIGNAL
确保客户端死,不会导致服务端死
让每一次接收一定能收完
recv
函数操作添加MSG_WAITALL
,如果管道没有关闭,recv
确保能收到len
字节的数据
如果文件比较大的时候MSG_WAITALL
可能会出错,他可能不会等到对应len长度数据就已经存储了
自己实现MSG_WAITALL
int recvn(int sockFd,void *pstart,int len){
int total = 0;//记录获取到的数据总数
int ret;
char *p = (char *)pstart;//强转为char*
while(total < len) {//循环获取数据
ret = recv(sockFd,p+total,len-total,0);
total += ret;
}
return 0;
}
文件校验
摘要哈希:把任何文本内容经过一i写算法,将其生成一段摘要,如果文件内容一致,则摘要一致,文件内容不一致,则摘要极大可能不一致
MD5(常用):效率比SHA高
SHA:有1—64—128数字越大,效率越低,碰撞越小
CRC:效率高,碰撞几率大,
计算MD5码
使用命令sudo apt install openssl
下载相应的包
可以使用sha1sum file1
计算出文件file1
的SHA1
的摘要
使用md5sum file1
计算出文件file1
的MD5
码
显示进度条
函数fstat
可以根据文件描述符获取文件大小
服务端先向客户端发送文件大小,客户端在根据文件大小显示下载百分比
服务端
//服务端
struct stat statbuf;
ret = fstat(fd,&statbuf);//保存文件信息
ERROR_CHECK(ret,-1,"fstat");
train.length = 4;
int fileSize = statbuf.st_size;//长度转换成int
memcpy(train.buf,&fileSize,sizeof(int));//int存入train.buf中
send(netFd,&traiin,train.length + sizeof(train),MSG_NOSIGNAL);//将文件长度发送给客户端
零拷贝
我们之前实现文件的下载,是首先在内核态创建对应物理内存的映射,然后在用read将数据拷贝到用户态的train.buf,再通过send将数据从用户态的train.buf拷贝到内核态的socket,这样的来回拷贝就会消耗大量的资源和时间,因此我们下面要使用零拷贝技术来减少这样的内核于用户态之间频繁拷贝的开销。
mmap(文件映射)
(1)使用mmap实现片段发送(效率没有提升)
让用户态空间的一部分和内核态空间的一部分对应同一份物理内存,用户对物理内存更改数据,其实际就是对内核态更改数据,但是这没有对数据进行拷贝,只是使用映射机制将其对应同一个区域。
(2)使用mmap实现整个文件发送
首先使用struct发送文件名称和名称大小
在通过结构体发送文件内容,因为TCP是可靠连接,因此只需要确定文件内容大小就可以实现一次性传输