概念说明
- 用户空间与内核空间
- 同步与异步
- 进程的阻塞
- 阻塞与非阻塞
- 缓存IO
1. 用户空间与内核空间
对于32位操作系统而言,它的寻址空间(虚拟地址空间)为4G,操作系统的核心是内核, 他独立于其他的应用程序, 既可以访内存空间,也可以访问外部的硬盘, 为了保证内核的安全, 使用户进程不能直接访问内核对于Linux操作系统而言, 将寻址空间的高1G个字节置为内核空间, 将低3G字节的空间置为用户空间, 供用户进程使用.
2.进程的阻塞
正常运行的进程, 由于等待某些事情未发生, 如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等, 则进程会使自己进入一种阻塞的状态来等待事件的完成 (自由运行中的程序才可能有阻塞状态, 并且进入阻塞状态时, 是不会占用CPU资源的)
3.缓存IO
缓存 IO 又被称作标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
4. 阻塞与非阻塞 : 发起一个调用是否能立即返回
- 阻塞:为了完成功能发起一个调用, 当前不具备完成条件, 则调用一直等待
- 非阻塞: 为了完成功能发起一个调用, 当前不具备完成条件, 则调用直接报错返回
5. 同步与异步 : 功能是否由自身完成
- 同步: 为了完成功能发起一个调用, 当前不具备完成条件, 则调用一直等待,直到功能完成后返回
- 异步: 为了完成功能发起一个调用, 但是功能的具体完成不有自身完成
- 同步与异步的优缺点分析
同步流程控制更加简单, 对资源的利用率不足(CPU的利用率)
异步对资源的利用更加充分,但是流程控制更加复杂一些, 同一时间的组员占的更多
五种IO模型
网络IO的本质在于对socket的读取, socket被Linux操作系统抽象为流, 而IO就是对流的操作. 在Linux的缓存IO中, 操作系统会将IO的数据先拷贝到操作系统内部的缓冲区中, 在有缓冲区拷贝的应用程序的地址空间 ; 以一次read操作为例, 当发生一次read操作的时候,会经历两个步骤, 第一步: 等待数据准备, 第二步: 将数据从缓冲区拷贝到地址空间中.
接下来, 有一个生动的例子来说明,五种IO模型, 例子: 周末我和女友去逛街,中午饿了,我们准备去吃饭。周末人多,吃饭需要排队,我和女友有以下几种方案
同步阻塞IO
1.场景描述
女朋友和我点完餐之后,由于不知道什么时候餐会做好,所以就在这里等着, 不能去逛街, 等饭做好之后,我们吃完着在去逛街这就是典型的阻塞
2.网络模型
在这个IO模型中, 用户空间的应用程序会执行一个系统调用,这会导致应用程序什么也不干,直到数据准备好,并且从缓冲区总拷贝到应用程序之中, 才回去对数据进行处理.
因此应用程序在调用recv()或者recvfrom()接口时,发生在内核中的过程大致如下图
同步非阻塞IO
1.场景描述
女朋友和我点完餐之后, 由于饭还没有及时做出来,女朋友又心急着去逛街, 所以我们就先去逛街, 逛上一会,回来问一下饭做好了没有, 逛上一会,回来问一下饭做好了没有, 来来回回反复的询问。
2.网络模型
在这个IO模型中,当调用的IO操作不能完成时, 会进行一个报错返回.当进程返回之后,可以去干一点其他的事情, 之后,在发起一次IO调用, 若数据还没有准备好,再次报错返回. 如此反复进行,直到有一次IO调用时, 数据已将准备就绪, 则拷贝数据, 之后进程处理数据
在这里插入图片描述
信号驱动IO
1.场景描述
点完餐之后,我们先去逛街, 当餐厅将饭做好之后,打电话通知我们, 饭已经做好了, 此时,我和女朋友返回餐厅吃饭
2.网络模型
添加一个信号处理函数, 之后,进程不进入阻塞,可以执行其他的事情, 当数据准备好之后, 进程会收到一个SIGIO信号,接受到这个信号之后,在信号处理方式中调用IO对数据进行处理.
异步IO
1.场景描述
女友不想逛街,又餐厅太吵了,回家好好休息一下。于是我们叫外卖,打个电话点餐,然后我和女友可以在家好好休息一下,饭好了送货员送到家里来。这就是典型的异步,只需要打个电话说一下,然后可以做自己的事情,饭好了就送来了
2.网络模型
用户进程先定义一个IO信号处理, 之后,发起IO调用, 操作系统进行条件等待, 并且进行数据拷贝,之后通过信号通知进程,让进程考处理数据
多路转接IO
对大量的描述符进行事件监控(可读/可写/异常),能够让用户直接对事件就绪的描述符进行操作. 在网络通信中, 如果仅仅对描述符进行操作,则流程在一个执行流中就不会阻塞, 可以实现在一个执行流中进行多个描述符并发操作
select模型
就绪事件的判断 :
可读事件 : 接受缓冲区中的数据大小大于低水位标记(默认是一个字节)
可写事件 : 发送缓冲区中的空闲空间的大小大于低水位标记(默认一个字节)
fd_set结构
fd_set实际上是一个整数数组, 更严格的说他是一个 “位图” , 他的最大位为1024, 每一个位对应一个描述符, (意思是位图中的第0位,就对应可第0个文件描述符—标准输入), 所以 , select所能监控的描述符最大为1024 ; 当我们要监控某一个描述符的时候, 我们就把fd_set中对应的位置置为1
实现流程
- 用户定义事件集合
- 将集合拷贝到内核进行监控( 对集合中的所有描述符进行遍历判断, 判断事件是否就绪)
- 若某个描述符就绪了用户关心的事件, 就将所有集合中没有就绪的描述符移除, 返回给用户就绪的描述符集合
- 用户拿到就绪描述符集合后,通过遍历判断哪些描述符还在就绪的集合中,进而取到就绪的描述符进行操作
接口
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
//nfds: 集合中最大的描述符+1
//readfds: 可读事件集合
//writefds: 可写事件集合
//exceptfds: 异常事件集合
//timeout 阻塞的时间 NULL表示永远阻塞直到有描述符阻塞
//返回值:>0 表示的就绪的描述符个数
//=0 等待超时(没有描述符就绪)
//<0 监控出错
void FD_CLR(int fd, fd_set *set);//从set集合中移除指定的描述符fd
int FD_ISSET(int fd, fd_set *set);//判断指定的描述符fd是否在集合set中
void FD_SET(int fd, fd_set *set);//将指定的fd描述符添加到set集合中
void FD_ZERO(fd_set *set);//清空set集合内容
//返回值:小于0时, select监控出错
//等待0时, 等待超时
//大于0时返回就绪的文件描述符个数
实现select类用来监控TCP服务端
在前面我们实现TCP服务端的时候, 由于用于监听的套接字与用于通信的套接字是不同的socket, 因此,在代码运行的时候,服务端程序就会阻塞到accpet或者recv接口上, 前面我们的解决方法是通过利用创建线程或者进程的方法,让多个执行流分别执行不同的操作,来完成TCP服务端的程序.
现在我们通过封装select类,让select来监控不同的套接字.
select类的封装需要三个接口
- 向事件集合中添加套接字
- 对添加的集合进行监控,返回一个集合,这个集合中包含的是所有已将就绪的描述符
- 删除集合中的套接字
class Select{
public:
Select(){
FD_ZERO(&_rfds)
}
~Select(){
}
public:
bool Add(TcpSocket &sock){
int fd=sock.Getfd();
FD_SET(fd,&_rfds);
//此处需要对_maxfd进行更改
_maxfd=_maxfd>fd?_maxfd:fd;
return true;
}
bool CLR(TcpSocket &sock){
int fd=sock.Getfd();
FD_CLR(fd,&_rfds);
for(int i=_maxfd;i>=0;i--){\
if(FD_ISSET(fd,&_rfds)){
_maxfd=i;
return true
}
}
_maxfd=-1;
return true;
}
bool Wait(vector<TcpSocket> &list,int sec=3){
struct timeval tv;
tv.tv_sec=sec;
tv.tv_usec=0;
int count=select(_maxfd+1,&_rfds,NULL,NULL,&tv);
if(count<0){
coout<<"select error"<<endl;
return false;
}else if(count==0){
cout<<"wait timeout"<<endl;
return false;
}
for(int i=0;i<=_maxfd;i++){
if(FD_ISSET(i,&_rfds)){
TcpSocket sock;
sock.Setfd(i);
list.push_back(sock);
}
}
return true;
}
private:
fd_set _rfds;//描述符集合
int _maxfd;//集合中的最大描述符
};
实现Select监控的TCP服务端层序
int main(int argc,char* argv){
if(argc!=3){
cout<<"./tcp_select ip port"<<endl;
}
string ip=argv[1];
uint16_t port=atoi(argv[2]);
TcpSocket lis_sock;
CHECK_RET(lis_sock.Sokcet());
CHECK_RET(lis_sock.Bind(ip,port));
CHECK_RET(lis_sock.Listen());
Select s;
s.Add(lis_sock);
while(1){
vector<TcpSocket> list;
if(s.Wait(lise)==false){
continue;
}
for(auto newsock:list){
if(newsock.Getfd()=lis_sock.Getfd()){
//当前就绪的套接字为监听套接字
TcpSocket cli_sock;
bool ret=lis_sock(cli_sock);
if(ret==false){
continue;
}else{
//当前套接字为通讯的套接字
string buf;
bool ret=newsock.Recv(buf);
if(ret==false){
s.CLR(newsock);
newsock.Close();
continue;
}
cout<<"cli say:"<<buf<<endl;
buf.clear();
cin>>buf;
ret=newsock.Send(buf);
if(ret=false){
s.CLR(newsock);
newsock.Close();
continue;
}
}
}
}
lis_sock.Close();
return 0;
}
select的优缺点分析
缺点:
- select所能监控的描述符是有上限控制的, 它由一个宏控制着FD_SETDIZE=1024 最大为1024
- 每一次监控都需要重新将监控集合拷贝到内核中
- 态内核中监控是,是通过轮询遍历监控的, 这种监控会随着集合中的描述符的增多而使性能下降
- 返回的是就绪的一个集合, 还需要用户进行遍历判断,才能对描述符进行操作(无法随就绪的描述符进行操作)
- 因为每次都要清空未就绪的描述符,所以每次监控的时候都需要重新将所有描述符添加进集合中
优点 :
- select遵循posix标准可以跨平台使用
poll模型
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */POLLIN POLLOUT
short revents; /* returned events */
};
在select模型中, 我们要对多种时间进行监控的时候, 就需要多种事件集合分别对每种事件进行监控, 但是在poll模型中, 我们定义了一个pollfd结构体, 这个结构体中包含了三个成员
- fd—文件描述符
- events —对当前描述符我们用户所关心的事件
其中 : POLLIN–可读事件 | POLLOUT–只写事件 - revents : 这次监控返回之后这个描述符就绪的事件
这个成员中只会存放这个描述符中已经就绪的事件, 同时意为着有可能这个成员中没有东西, 也就是这个描述符没有事件就绪
接口中的第二个参数是用来确定范围, 因为我们的事件结构是一个数组, 而当我们监控少量描述符的时候, 我们可以通过这个参数来限制遍历范围的大小, 避免过多的资源损耗
实现流程
poll采用事件结构的方式对描述符进行监控, 当在使用的时候, 用户可以将对该描述符关心的时间添加到事件结构的events中, 当有描述符就绪了用户所关心的事件时, 就将该事假添加到对应的revents中
之后进行遍历查看每一个结点中的revents, 如果revents只不过有就绪的事件, 就开始操作
poll的优缺点分析
缺点:
- poll是Linux下独有的, 无法跨越平台
- 依然需要将监控的描述符事件数组拷贝到内核中
- 在内核中同样也是轮询遍历: 性能随着描述符的增多而下降
- 只是将就绪的时间放在了revents中,需要用户遍历判断
优点:
- 没有描述符数量的上限
- 采用事件结构数组进行事件监控, 简化了select三种事件集合的操作流程
- 不需要每次重新想集合中添加数组
epoll
epoll在监控描述符时,首相通过epoll_create接口创建出一个eventpoll结构, 然后将用户所关心的事件,创建出一个epoll_event事件结构,通过epoll_ctl接口将该描述符的事件结构添加到eventpoll结构中的红黑树中; 当有用户关心的事件的描述符就绪时, 操作系统就将这个描述符所对应的epoll_event结构添加到eventpoll结构中的双向链表中, 最后, 用户程序每隔一段时间查看该双向链表, 如果双向链表不为空, 则就将链表中就绪的时事件结构节点添加到通过epoll_wait接口传入的类型为epoll_event的数组中, 用户就可以直接操作该数组对就绪的描述符进行操作
//在内核中创建eventpoll结构, 返回一个文件描述符,结构中主要包含两个信息:(双向链表, 红黑树)
int epoll_create(int size);
//向内核中的eventpoll结构体中添加事件结构(主要添加到红黑树中)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//epfd: eventpoll的操作句柄
//op : EPOLL_CTL_ADD (添加)/ EPOLL_CTL_MOD(修改) / EPOLL_CTL_DEL(移除)
//描述了向内核中添加事件的方式
//fd : 用户关心的文件描述符
//event : 对于fd描述符所要监控的事件
//开始监控
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
//epfd : eventpoll结构
//events 用于接受已经就绪的事件结构的数组
// maxevents : 数组的最大节点数
//timeout : 超时事件 (以毫秒为单位)
//返回值 : 小于0时出错, 等于0时超时, 大于0时为就绪的时间个数
epoll_ctl中的event参数信息
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
//对用户多关心的事件
uint32_t events; /* Epoll events */
//通过data数据操作fd
epoll_data_t data; /* User data variable */
};