一、前言
首先来分析一下进程版通信和线程版通信
- 进程版通信:一个客户端一个进程
- 线程版通信:一个客户单一个线程
进程是资源分配的基本单位,如果客户端数量剧增,每一个进程都会有代码段、数据段、堆栈段,所以使用进程开销大, 且进程间数据还不能共享,也不可能每个进程都写一个IPC实现通信。
如果使用线程,一个进程如果开出N个线程,一旦这个进程被杀死,就会导致N个线程全部销毁。而且在一些计算机上一个进程最多开300个进程(现实中肯定不够用,就像QQ平均每天在线人数达到7亿多),但这时候操作系统也被卡死了。
线程虽然解决了进程的开销问题,但同时也存在弊端。所以就要使用网络通道的复用技术。
二、五个I/O模型
- 阻塞I/O
- 非阻塞I/O
- I/O复用(select和poll)
- 信号驱动I/O
- 异步I/O
1、阻塞I/O
当系统read不到数据,就会一直阻塞在等待数据这个阶段,直到read到数据或者发生错误,才会继续往下走,即在系统调用read函数到read结束这个期间都是阻塞的,在阻塞期间CPU可以去做其他事。如下图
2、非阻塞I/O
read在没有读取到数据的时候会返回-1,然后继续读,读到数据也立即返回(没有阻塞操作,系统会一直处在判断read是否成功(数据是否准备好)的循环(轮询)中,直到read成功才会继续往下走),这样导致cpu非常的繁忙,造成cpu时间上极大的浪费,一般只在专门提供某种功能的系统中才会用到。如下图
阻塞I/O与非阻塞I/O的区别
阻塞I/Oread只调用一次,非阻塞I/O中read一直循环调用(cpu没有休息)
示例:
非阻塞IO采用轮询机制,就好像我们不断给快递小哥打电话询问,占用了大量时间
三、多路复用IO
首先以一个收快递的例子,来说说多进程IO和多路复用IO
多个客户端上线(connect)存在多个socket通道(代码中就是调用accept函数可以看到返回值是4、5、6......)
1、执行过程及原理
事件队列(等待状态)
socket——>发送数据等操作——>到就绪队列
就绪队列(准备状态)
socket唤醒——>到主进程
socket做完操作——>回到事件队列
主进程——>(线程池可解决队列先进先出)
详细过程说明:
无论对接了多少socket,在没有传递消息的时候,socket全部存放在事件队列中(还没有发生业务,如客户端发送数据或者掉线,socket全部处于等待状态),如果其中一个socket需要发送数据,就会移出到就绪队列。就绪队列就会唤醒进程来操作socket。就好像快递小哥把快递暂时存储在就绪队列,通过给我们发取件码或者消息告诉我们快递到了。
如果就绪队列中的第一个socket操作完了不是销毁,而是还回去到事件等待队列中(如下图),主进程接着唤醒第二个socket进行操作,即这个进程是为所有socket服务的(同一个read、write针对多个客户端)。客户端可能先注册后登录再聊天,他们先后产生事件,事件发生送到就绪队列执行,执行完再返回事件队列(因为socket表示一个客户端,必须存在)。
2、复用技术
目前支持I/O多路复用的系统调用有 select,pselect,poll,epoll,I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。他们的目的都一样,都是为了实现IO复用。与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小 。
1、select、poll、epoll区别:
①从时间复杂度和IO效率来看:
select、poll都需要轮询事件队列的fd(客户端),时间复杂度为o(n),CPU不会闲置,客户端越多,处理时间越长;epoll不需要轮询,只需要等待即可,时间复杂度为O(1),CPU会闲置,与活跃的客户端数有关,不会随着描述符数目的增加而下降。②从结构来看:
select一般采用整数数组(存描述符fd); poll采用链表,epoll采用红黑树和双向链表③从连接数来看:
select在32位系统下,最多连接3232,同理,64位系统最多连接6464;poll采用链表,理论上是无限的;epoll有上限,1G内存能连接10万左右客户端④从消息传递方式来看:
select和poll:将消息从内核空间传递到用户空间,都需要内核拷贝动作;epoll:可以通过内核和用户空间共享一块内存来实现。2、
select、poll的缺点如下
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 内核需要遍历队列中存放的每个fd,这个开销在fd很多时也会很大
- 其中select最大连接数有限制,默认是1024
epoll解决了select和poll存在的3个缺点。
- 每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
- 不是轮询的方式,不会随着fd数目的增加导致效率下降。只有活跃可用的fd才会调用callback函数。
- 虽然连接数有上限,但是很大,能打开的fd的上限远大于1024 (1G的内存上能监听约10万个端口)。
四、epoll使用流程
头文件#include <sys/epoll.h>
- 准备好事件结构体
- 结构体初始化——bzero
- 绑定当前准备好的socketfd (可用网络对象)
- 绑定事件为客户端接入事件
- 创建epoll——epoll_create
- 将已经准备好的网络描述符添加到epoll事件队列中——epoll_ctl(EPOLL_CTL_ADD)
- 等待客户端上线:循环判断事件队列里是否有客户端上线(epolEventArray[i].data.fd == socketfd),有上线就把客户端acceptfd 绑定事件并添加到epoll
- 客户端产生了事件以后,就进行read,返回值>0表示接收到数据,返回值<=0表示客户端掉线,那么就从epoll中删除客户端描述符——epoll_ctl(EPOLL_CTL_DEL)。
五、epoll示例
服务器核心代码如下
#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <iostream>//cout
#include <map>
#include <sys/epoll.h>
using namespace std;
int main() {
struct epoll_event epolEvent;
struct epoll_event epolEventArray[5];//看起来是数组,内部是队列的形式
int epollfd = 0;
int epolfd;
int epol_waitfd;
int opt_val = 1;
struct sockaddr_in s_addr;
int socketfd = 0;
int length = 0;
int acceptfd = 0;//客户端的文件描述符
char ser_buf[66] = { 0 };
int pid = 0;
//初始化网络
socketfd = socket(AF_INET, SOCK_STREAM, 0);
if (socketfd == -1)
{
perror(" socket error");
}
else
{
//确定使用那个协议族 ipv4
s_addr.sin_family = AF_INET;
//系统自动获取本机ip地址
s_addr.sin_addr.s_addr = INADDR_ANY;
//端口65535,10000以下是操作系统使用,自己定义需要10000以后
s_addr.sin_port = htons(10086);
length = sizeof(s_addr);
//端口复用,解决 地址重用问题
setsockopt(socketfd,SOL_SOCKET,SO_REUSEADDR,(const void *)opt_val,sizeof(opt_val));
//绑定ip地址和端口号
if (bind(socketfd, (struct sockaddr*)&s_addr, length) == -1)
{
perror(" bind error");
}
//监听这个地址和端口有没有客户端连接
if (listen(socketfd, 10) == -1)
{
perror(" listen error");
}
cout << "服务器网络通道准备好了" << endl;
//事件结构体初始化
bzero(&epolEvent,sizeof(epolEvent));
//绑定当前准备好的socketfd(可用网络对象)
epolEvent.data.fd = socketfd;
//绑定事件为客户端接入事件
epolEvent.events = EPOLLIN;
//创建epoll
epolfd = epoll_create(5);
//将已经准备好的网络描述符添加到epoll事件队列中
epoll_ctl(epolfd,EPOLL_CTL_ADD,socketfd,&epolEvent);
while (true)
{
cout << "epoll wait client。。。"<<endl;
epol_waitfd = epoll_wait(epolfd,epolEventArray,5,-1);//5和前面要对应
if (epol_waitfd < 0)
{
perror("epoll_wait error");
}
for (int i = 0; i < epol_waitfd; i++)
{
//判断是否有客户端上线
if (epolEventArray[i].data.fd == socketfd)
{
cout << "网络开始工作 等待客户端上线" << endl;
acceptfd = accept(socketfd,NULL,NULL);
cout << "acceptfd = " << acceptfd << endl;
//上线的客户端描述符是acceptfd 绑定事件添加到epoll
epolEvent.data.fd = acceptfd;
epolEvent.events = EPOLLIN;
epoll_ctl(epolfd,EPOLL_CTL_ADD, acceptfd,&epolEvent);
}
//客户端产生了事件以后
else if (epolEventArray[i].events & EPOLLIN)
{
bzero(ser_buf,sizeof(ser_buf));
int res = read(epolEventArray[i].data.fd, ser_buf,sizeof(ser_buf));
if (res > 0)
{
cout << "服务器接收到数据 buf = " << ser_buf <<endl;
}
else if (res <= 0)
{
cout << "客户端掉线。。。 " << ser_buf << endl;
close(epolEventArray[i].data.fd);
//从epoll中删除客户端描述符
epolEvent.data.fd = epolEvent.data.fd;
epolEvent.events = EPOLLIN;
epoll_ctl(epollfd,EPOLL_CTL_DEL, epolEventArray[i].data.fd,&epolEvent);
}
}
}
}
}
return 0;
}
注意:
1、前面的socketfd表示的是服务器打通的网络通道,这个时候没有上线的客户端(还没有客户端对接),只代表服务器本身自己的通道入口
而下面的acceptfd,表示有客户端上线进行连接。
所以,epoll中存放的所有的socketfd通道包含两种,一种是服务器本身的通道,用来做等待(accept),另一种是接入的客户端,即调用accept的返回值(acceptfd)
2、客户端上线之后要进行read,之前都是用acceptfd,但是这里acceptfd给了事件结构体中的fd,所以这里read要使用产生事件后的fd,即epolEventArray[i].data.fd
3、 客户端主动下线,发出数据告诉服务器,可以读到数据。如果是掉线, 要么是网络断开连接了,要么是客户端程序奔溃(异常下线) ,这时候要关闭fd,从epoll中删除客户端描述符
4、排错方法
接收read和write返回值,并打印出来,打印大小和发送、接收的大小一致,表示接收、发送成功5、解决地址重用问题(address already use)
进行端口复用——绑定端口(bind)之前设置
setsockopt(socketfd,SOL_SOCKET,SO_REUSEADDR,(const void *)opt_val,sizeof(opt_val));
六、运行结果
ref: