本章会介绍epoll,标准I/O等其他知识(epoll的具体底层实现可以参考其他的优秀文章,如底层红黑树的储存,操作系统的拷贝等等,本文具体介绍如何使用)
标准I/O函数(比如fdopen等 )比系统函数传输更快等等(比如read colse等),但是标准I/O也有缺点。
首先简单介绍fopen函数 (CSDN上此部分很详细,fputs,fputc,fread,fwrite等操作函数自行搜索):fopen返回一个新打开文件的文件指针 ,可以根据fopen的设置函数中的模式来进行相关的操作,"r":打开文件仅供读取操作(前提是必须存在),“w":创建文件仅供写入,如果存在则会清空在写入,“a"打开文件附加写入。
介绍fdopen函数:
注意将文件描述符转换为文件指针,下面是代码演示:
#include<iostream>
#include<fcntl.h>
int main(){
FILE* fp;
int fd=open("new.txt",O_WRONLY|O_CREAT|O_TRUNC);
if(fd==-1){
fputs("file open error",stdout);
return -1;
}
fp=fdopen(fd,"w");//文件套接字转换为文件指针 且文件指针为w类
fputs("netword, skskssk\n",fp);
fclose(fp);//关闭文件指针 则文件描述符毫无意义
return 0;
}
此函数和fileno相反,此函数是将文件指针转换为文件描述符。
#include<iostream>
#include<fcntl.h>
int main(){
FILE* fp;
int fd=open("new.txt",O_WRONLY|O_CREAT|O_TRUNC);
if(fd==-1){
fputs("file open error",stdout);
return -1;
}
fp=fdopen(fd,"w");//文件套接字转换为文件指针 且文件指针为w类
std::cout<<" "<<fp<<std::endl;
std::cout<<fileno(fp)<<std::endl;
fclose(fp);//关闭文件指针 则文件描述符毫无意义
return 0;
}
注意 用文件指针操作时,向文件内读写内容要用f开头的函数,这个不要忘了。文件指针对应文件函数操作。
下面我们给出基于瞄准I/O的回声客户端和服务端的代码:
服务端代码如下:
#include<iostream>
#include <sys/socket.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<string.h>
const int MAX_SIZE=1024;
int main(int argc,char* argv[]){
int serv_sock,cil_sock;
char message[MAX_SIZE];
struct sockaddr_in ser,cil;
socklen_t size;
FILE* fp1,*fp2;
if(argc!=2){
std::cout<<"error( )"<<std::endl;
exit(1);
}
serv_sock=socket(PF_INET,SOCK_STREAM,0);
memset(&ser,0,sizeof(ser));
ser.sin_family=AF_INET;
ser.sin_port=htons(atoi(argv[1]));
ser.sin_addr.s_addr=htonl(INADDR_ANY);
if(bind(serv_sock,(struct sockaddr*)&ser,sizeof(ser))==-1){
std::cout<<" bind error()"<<std::endl;
}
if(listen(serv_sock,5)==-1){
std::cout<<"listen error()"<<std::endl;
}
for(int i=0;i<5;i++){
size=sizeof(cil);
cil_sock=accept(serv_sock,(struct sockaddr*)&cil,&size);
if(cil_sock==-1){
std::cout<<"accept()error"<<std::endl;
}else{
std::cout<<i+1<<std::endl;
}
fp1=fdopen(cil_sock,"r");
fp2=fdopen(cil_sock,"w");//套接字转换为文件指针
while(!feof(fp1)){//遇到文件未结束 返回零
fgets(message,MAX_SIZE,fp1);//读取客户端传来信息存入到message 读到/n停止
fputs(message,fp2);//同样 标准输出到/n
fflush(fp2);//提供额外的缓冲I/O 让服务端的消息能立马传给客户端 提高性能
}
fclose(fp1);//关闭文件指针即可
fclose(fp2);
}
close(serv_sock);
return 0;
}
客服端代码:
#include<iostream>
#include <sys/socket.h>
#include<arpa/inet.h>
#include<unistd.h>
#include<string.h>
const int MAX_SIZE=1024;
int main(int argc,char* argv[]){
int sock;
char buf[MAX_SIZE];
struct sockaddr_in serv_adr;
if(argc!=3){
std::cout<<"error"<<std::endl;
exit(1);
}
sock=socket(PF_INET,SOCK_STREAM,0);
memset(&serv_adr,0,sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
serv_adr.sin_port=htons(atoi(argv[2]));
if(connect(sock,(struct sockaddr*)&serv_adr,sizeof(serv_adr))==-1){
std::cout<<" connect()error"<<std::endl;
}
FILE* read1,*write1;
read1=fdopen(sock,"r");//专用于读
write1=fdopen(sock,"w");//用于写
while(1){
fputs("place :",stdout);//拿进去
fgets(buf,MAX_SIZE,stdin);//取出来放进缓冲区中
if(!strcmp(buf,"Q\n")||(strcmp(buf,"q\n")))
break;
fputs(buf,write1);//往服务端传送数据 传就是写
fflush(write1);
fgets(buf,MAX_SIZE,read1);//存放在BUF中
std::cout<<buf<<std::endl;
}
fclose(read1);
fclose(write1);
return 0;
}
对应文件指针,fput函数:fputs(const char* str,FILE* x) ,将str指向内存空间的数据发送指定流(本质是写,对应的文件描述符/套接字是w)。fgets函数:从指定输入流中,读取数据存入指定位置(本质是读,对应的套接字/文件描述符就是r)
对于描述select函数(I/O复用),其无法拓展到同时连接上百个客户端,因为首先针对的是所有文件描述符的循环操作(不是将所有发生变化的文件描述符集中在一起,而是通过轮询的方式查找),第二个是每次调用SELECT函数都需要向其传递监视对象消息(向操作系统传递监视对象消息,这样的开销是巨大的
如上 EPOLL支持仅向操作系统传递一次监听对象,当监听范围或者内容发生变化时只通知变化事项,而不是传递监听对象所有信息。
如上 epoll_create函数中的形参仅仅是向操作系统提供的建议大小,其创建的例程大小属于操作系统,和套接字类似。所有创建成功也会返回文件描述符,要终止时,不要忘了close,关闭文件描述符。
注意epoll_ctl函数第二个参数调用EPOLL_CTL_DEL,由于其不需要监听任何事件,所以是NULL。
下面介绍第二个参数的选项:
EPOLL_EVENT 结构体不仅仅可以储存发生事件变化的文件描述符,还可以用其在注册文件描述符的同时注册关注的事件。
接下来是epoll_wait函数。
注意第二个参数类似于结构体数组,用于储存发生变化的文件描述符集合的结构体地址,一般要动态开辟。
下面是服务端代码:
include<sys/epoll.h>
#include<iostream>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<sys/socket.h>
const int MAX_SIZE=100;
const int epoll_size=50;
int main(int argc,char* argv[]){
socklen_t size;
struct sockaddr_in serv,cil;
int ser_sock,cil_sock;
char buf[MAX_SIZE];
struct epoll_event *ep_event;
struct epoll_event event;
int epfd,event_cnt,str_len;
if(argc!=2){
std::cout<<"error"<<std::endl;
exit(1);
}
ser_sock=socket(PF_INET,SOCK_STREAM,0);
memset(&serv,0,sizeof(serv));
serv.sin_family=AF_INET;
serv.sin_port=htons(atoi(argv[1]));
serv.sin_addr.s_addr=htonl(INADDR_ANY);
if(bind(ser_sock,(struct sockaddr*)&serv,sizeof(serv))==-1){
std::cout<<"bind()error"<<std::endl;
}
if(listen(ser_sock,5)==-1){
std::cout<<"listen()error"<<std::endl;
}
epfd=epoll_create(epoll_size);//创造epoll例程
ep_event=new struct epoll_event [epoll_size];//创造收集集合
event.events=EPOLLIN;
epoll_ctl(epfd,EPOLL_CTL_ADD,ser_sock,&event);//将服务端套接字注册到epoll例程中 监听事件event发生 此时ser_sock已经和该事件进行绑定了
while(1){
event_cnt=epoll_wait(epfd,ep_event,MAX_SIZE,1000);
if(event_cnt==-1){
std::cout<<"epoll_wait() error"<<std::endl;
break;
}else if(event_cnt==0){
std::cout<<"time-out"<<std::endl;
}
else
{
//已经准备好的事件了
for(int i=0;i<event_cnt;i++){
if(ep_event[i].data.fd==ser_sock){
size=sizeof(cil);
cil_sock=accept(ser_sock,(struct sockaddr*)&cil,&size);
event.events=EPOLLIN;
event.data.fd=cil_sock;
epoll_ctl(epfd,EPOLL_CTL_ADD,cil_sock,&event);//对连接客户端的套接字检测读取操作
}else{
str_len=read(ep_event[i].data.fd,buf,MAX_SIZE);
if(str_len==0){
epoll_ctl(epfd,EPOLL_CTL_DEL,ep_event[i].data.fd,nullptr);
close(ep_event[i].data.fd);
std::cout<<"disconnect :"<<ep_event[i].data.fd<<std::endl;
}else{
write(ep_event[i].data.fd,buf,str_len);
}
}
}
}
}
close(ser_sock);
close(epfd);
return 0;
}
上面代码中,接受方是直接一次性从输入传冲中全部读取完数据 你不要管是请求连接还是发送数据 反正变化的都加到一起了(不论是客户端还是服务端),分类套论就行。
还有一点要注意是,到了超时后,会自动断开阻塞,服务端会一直提醒time-wait,不会一直等待请求。将阻塞I/O转变为非阻塞I/O的方法1.设置超时,2.调用fctnl函数。
下面讲述条件触发和边界触发
条件触发:只要输入缓冲有数据时就会一直通知该事件(一直注册发生变化的文件描述符,每次循环加入ep_event中,知道读取完客户端的数据。)
边界触发:即收到数据时只会注册一次该事件,即使输入缓冲还有数据,也不会进行注册。
epoll默认的触发方式是条件触发,只有是条件触发才能连续地回复。
上述EPOLL服务端将MAX_SIZE取为4 ,(防止一次数据都接受),当客户端传输给服务端数据时,服务端每次只能取出4字节,这时输入缓冲中还有数据,因此会一直注册新的事件。比如客户端每次向服务端发送16字节,此时16字节在输入缓冲区,服务端要读取四次,即要循环总体四次,每次都会调用eopll_wait函数,连接客户端的套接字发生读事件,每次都将其加入ep_event中 然后再把读到的四个字节传输回去。如下就会调用四次puts函数
select函数就是边缘触发。
下面介绍边缘触发:
边缘触发若发现输入缓冲有数据,其只会注册一次该事件发生,所以向运用边缘触发,就要实现1.数据一次性读取完,2.读取完后一定要有相应的操作
由上述可知,可以通过ERRNO变量查找是否发生I/O阻塞,这样就可以完成非阻塞的服务器。
如上就可以变为非阻塞套接字,不变为非阻塞套接字,服务端套接字会一直停顿,但改为非阻塞套接字,客户端没传入消息,服务端套接字不会等待,而是关闭,这样就不会浪费资源。
即要实现边缘触发必须将套接字改为非阻塞,下面是代码
#include<sys/epoll.h>
#include<iostream>
#include<unistd.h>
#include<string.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<fcntl.h>
#include<errno.h>
const int MAX_SIZE=4;
void setsock(int);
int main(int argc,char* argv[]){
socklen_t size;
int serv_sock,cil_sock,strlen,opn,event_si;
char buf[MAX_SIZE];
struct sockaddr_in serv,cil;
if(argc!=2){
std::cout<<"error()"<<std::endl;
exit(1);
}
struct epoll_event* ed;
struct epoll_event event;
serv_sock=socket(PF_INET,SOCK_STREAM,0);
memset(&serv,0,sizeof(serv));
serv.sin_addr.s_addr=htonl(INADDR_ANY);
serv.sin_family=AF_INET;
serv.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock,(struct sockaddr*)&serv,sizeof(serv))==-1){
std::cout<<"bind()error"<<std::endl;
}
if(listen(serv_sock,5)==-1){
std::cout<<"listen()error"<<std::endl;
}
ed=new struct epoll_event [50];
event.data.fd=serv_sock;
event.events=EPOLLIN;
opn=epoll_create(50);//创造EPOLL池
epoll_ctl(opn,EPOLL_CTL_ADD,serv_sock,&event);
setsock(serv_sock);//服务端套接字改为非阻塞
while(1){
event_si=epoll_wait(opn,ed,50,-1);
if(event_si==-1){
std::cout<<"epoll_wait() error"<<std::endl;
break;
}
puts("epoll_wait()");
for(int i=0;i<event_si;i++){
if(ed[i].data.fd==serv_sock){
size=sizeof(cil);
cil_sock=accept(serv_sock,(struct sockaddr*)&cil,&size);
setsock(cil_sock);//客户端套接字改为非阻塞
event.data.fd=cil_sock;
event.events=EPOLLIN|EPOLLET;//事件加上边缘触发
epoll_ctl(opn,EPOLL_CTL_ADD,cil_sock,&event);
std::cout<<"connect cil"<<cil_sock<<std::endl;
}else{//读 由于是边缘触发 在下面的while循环中 将输入传冲区都读完 再进行下一次的主体循环。 即把当前的
while(1){//客户端的任务做完才能做下一个任务
strlen=read(ed[i].data.fd,buf,MAX_SIZE);
if(strlen==0){//读取完数据或者关闭套接字
epoll_ctl(opn,EPOLL_CTL_DEL,ed[i].data.fd,nullptr);//epoll池中删除
close(ed[i].data.fd);//关闭对应的套接字
std::cout<<"disconnect cil"<<ed[i].data.fd<<std::endl;
break;
}
else if(strlen<0){
if(errno==EAGAIN)//无数据可以接受 防止read write引发阻塞
break;
}
else{
write(ed[i].data.fd,buf,MAX_SIZE);
}
}
}
}
}
close(serv_sock);
close(opn);
return 0;
}
void setsock(int serv_sock){
int flag=fcntl(serv_sock,F_GETFL,0);
fcntl(serv_sock,F_SETFL,flag|O_NONBLOCK);
}
不同于条件触发,比如客户端每次传输16个字节到输入缓冲区中,服务端方必须将其读取完才能服务其他的客户端 ,即里面的while(1),循环四次,直到把缓冲区域数据读取完,这时就会返回-1,无数据可接受,这样就跳出循环。所以客户端每调用一次不会像条件触发调用四次PUTS,而是调用一次PUTS函数。
对于条件触发,边缘触发的好处可以分离接受数据和处理数据的事件点。
下面介绍多线程服务器的实现。
回忆多进程服务器,其复制和创造多进程往往需要一定开销而且进程之间的通信需要管道,并且要防止产生僵尸进程等等。但线程的上下文切换的开辟要比进程的上下文切换和开辟更加容易而且消耗少一些的资源,所以线程更有优势。
相信大家都学过操作系统,这里就不多做介绍了。下面介绍C++中多线程编程,因为是完成多线程服务器,所以只介绍部分,等TCP/IP本书结束后再根据《C++并发编程》和游双的《LINUX高性能服务器》,更加深入学习网络编程,再次之前EPOLL,SELECT,多进程服务器模板要尽量记住。