在IO模型中,一般将IO操作分为两步,第一步就是等待数据准备好(记为“等”),第二步就是将数据从内核拷贝到用户区(记为“拷贝”)。
钓鱼例子:钓鱼我们认为有两步,第一步就是等鱼上钩(记为“等”),第二步就是钓鱼(记为“掉”)。
阻塞IO:在内核将数据准备好之前,系统调用会一直等待。所有的套接字默认都是阻塞方式的。
钓鱼例子之张三钓鱼:张三是一个非常认真的人,他在钓鱼时只顾钓鱼,完全不受外界任何事情的影响。在等鱼上钩的过程中,他就静静的坐在凳子上看着鱼鳔,即使有人找他他也不会搭理。当鱼上钩时,将鱼钓上来再继续下一的等。
在阻塞IO中,内核如果没有准备好数据,系统调用就会像张三一样,静静的等待数据准备。数据准备好后,系统调用将数据从内核拷贝到用户缓冲区,完成一次IO操作。
非阻塞IO:如果内核没有将数据准备好,系统调用仍然会直接返回,并且返回EWOULDBLOCK错误码。
钓鱼例子之李四钓鱼:李四是一个活泼好动的人,他在钓鱼时不同于张三。李四在钓鱼时并不会一直在等鱼上钩,鱼没上钩他就去找张三说话,但是张三完全不会搭理他的话,李四感到自讨无趣就继续回去看他的鱼竿是否有鱼上钩,如果有鱼上钩天涯就 钓鱼,如果没有他就继续取自讨无趣找张三说话(实际上是在自言自语,张三并不会搭理他)。就这样轮询的取找张三说话,在过去看是否有鱼上钩。
在非阻塞IO中,系统调用并不会因为内核中数据为准备好一直等待,如果内核中数据没有准备好他就直接返回。需要注意的是:在非阻塞IO往往需要程序员循环的方式反复读写文件描述符,这个过程称为轮询,对CPU来说是一个较大的浪费,只有特定场景下才会使用。
信号驱动IO:内核将数据准备好时,使用SIGIO通知应用进程拷贝数据。
钓鱼例子之王五钓鱼:王五是一个爱学习的人,他在钓鱼时都会带一本书,同时,它给鱼竿上放一个铃铛,当鱼上钩时铃铛就会响。他在钓鱼时,将鱼竿放入水中之后他就开始专心看书,当听到铃铛响他就知道有鱼上钩了,就会放下手中的书将鱼钓上来。
在信号驱动IO中,应用进程发现内核数据并未准备好,会继续执行。当内核数据准备好后,给进城发送SIGIO信号,进程再去读取数据。这样,解决了阻塞IO和非阻塞IO中的效率问题。
IO多路转接:虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态.
钓鱼例子之赵六钓鱼:赵六是一个土豪,别人每次钓鱼拿一个鱼竿钓鱼,它每次直接拉一车鱼竿钓鱼。将所有鱼竿放进水中,他就开始在拿转悠看那个鱼竿有鱼上钩了,将上钩的鱼钓上来。他虽然和张三一样,都是静静的在哪等鱼上钩,但是不同的是赵六等的是一车鱼竿而张三只等一个鱼竿。
在IO多路转接中,应用进程一次可以阻塞的等待多个文件描述符,那个文件描述符的数据准备好了应用进程就将内核数据拷贝到该进程的用户区中。
异步IO:由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据)。
钓鱼例子之田七钓鱼:田七一个名副其实的土豪,他和他的司机开车拿着一个鱼竿去钓鱼,到河边后田七告诉司机,你去拿着鱼竿钓鱼,我去打会麻将,等鱼掉好了你给我打电话我来接你,晚上一起烤鱼。
异步IO他最主要的特点就是,用户进程只需要告诉内核需要进行IO操作,等内核将数据准备好并拷贝到用户区后给用户进程发送信号,告诉用户进程数据准备好了。用户进程在处理数据。
总结:任何IO过程中,都包含两个步骤:等和拷贝,而在实际的应用中,等待消耗的时间往往都远远高于拷贝消耗的时间,让IO更高效最核心的办法就是让等待的时间尽量减少。
同步通信和异步通信
需要注意的是,这里的同步和进程间通信的同步是完全不同的概念。
阻塞和非阻塞
阻塞和非阻塞关注的是程序在在等待调用结果时的状态。
fcntl
int fcntl(int fd, int cmd, ... /* arg */ );
根据传入的cmd参数的不同,fcntl有以下五种功能(一般我们使用第三个):
实现setNonBlock函数
基于fcntl函数,实现一个将文件描述符设置为非阻塞的函数。
void SetNoBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0)
{
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
前边说过高效IO最主要的就是减少等的时间,因此下面要讲的几种IO多路转接模型,最主要的就是减少等的时间(提高等的效率,同一时间等更多的文件描述符)。
1)select概念
select是一个系统调用,用来实现多路复用输入输出模型。
函数原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);nfds:表示需要监视的最大的文件描述符+1(select需要等待多个文件描述符的,文件描述符是被保存在fd_array数组中,那么select要监视多个肯定需要遍历fd_array数组,因此只需要给出最大文件描述符+1就可以进行遍历。)
readfds writefds exceptfds:输出型参数,分别表示可读文件描述符的集合、可写文件描述符的集合、异常文件描述符的集合。
timeout:是一个结构体,用来设置select的等待时间。当timeout为NULL时select会一直被阻塞,直到某个文件描述符上发生了时间;为0时仅检测文件描述符集合的状态,然后立即返回,并不等待外部事件的发生;指定时间,如果在该时间内没有发生时间select将超时返回。
fd_set结构:
其实fd_set就是一个位图,用对应的比特位表示要监视的文件描述符。
操作系统提供了一些系统调用接口,来操作位图:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
struct timeval结构:
timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。
select函数返回值:
2)select执行过程
3)socket就绪条件
读就绪:
为了保证效率socket并不是说缓冲区有数据就立刻通知应用进程拷贝数据,而是有一个低水位线,接收缓冲区的字节数大于等于低水位线时,可以无阻塞的读并且select函数的返回值大于0
socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
写就绪:
socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记
SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号
3)select的特点和缺点
特点:
缺点:
#include "Sock.hpp"
#include
#define DFL_PORT 8080
#define MAX_FD_SET sizeof(fd_set)
class Select
{
private:
int lsock;
int port;
int fd_arry[MAX_FD_SET] = {-1};
public:
Select(int _port = DFL_PORT):port(_port)
{}
void InitServer()
{
lsock = Sock::Socket();
Sock::SetSockOpt(lsock);
Sock::Bind(lsock,port);
Sock::Listen(lsock);
//一开始只有一个文件描述符:将lsock加入到fd_array中
fd_arry[0] = lsock;
}
void AddFd2Array(int sock)
{
size_t i = 0;
for( ; i < MAX_FD_SET; i++ )
{
if(fd_arry[i] == -1)
{
break;
}
}
if(i >= MAX_FD_SET)
{
//fd_Set设置满了
cerr << "fd array is full, close sock" << endl;
close(sock);
}
else
{
fd_arry[i] = sock;
cout << "fd: " << sock << " add to select ..." << endl;
}
}
void DefFdFromArray(size_t index)
{
if(index >=0 && index < MAX_FD_SET)
{
fd_arry[index] = -1;
}
}
void HandlerEvent(fd_set* rfd)
{
//遍历rfd
for(size_t i = 0;i < MAX_FD_SET;i++)
{
if(fd_arry[i] == -1)
continue;
if(FD_ISSET(fd_arry[i],rfd))
{
if(fd_arry[i] == lsock)
{
int sk = Sock::Accept(lsock);
if(sk > 0)
{
cout<<"get a new link..."<0)
{
buf[s] = 0;
cout << "client# " << buf << endl;
}
else if(s == 0)
{
cout << "clien quit" << endl;
close(fd_arry[i]);
DefFdFromArray(i);
}
}
}
}
}
void SatrtServer()
{
int maxfd = -1;
for(;;)
{
fd_set rfd;
FD_ZERO(&rfd);
//将fd_srray设置仅rfd
for(size_t i = 0;i < MAX_FD_SET;i++)
{
if(fd_arry[i] > maxfd)
maxfd = fd_arry[i];
FD_SET(fd_arry[i],&rfd);
}
switch(select(maxfd+1,&rfd,nullptr,nullptr,nullptr))
{
case 0:
cout<<"timeout ..."<
1)相关接口
#include
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
参数说明:
events和revents的取值
POLLIN 读数据
POLLPRI There is urgent data to read (e.g., out-of-band data on TCP socket; pseudoterminal master in packet mode has seen state change in slave).
POLLOUT Writing now will not block.
POLLRDHUP (since Linux 2.6.17) Stream socket peer closed connection, or shut down writing half of connection. The _GNU_SOURCE feature test macro must be defined (before
including any header files) in order to obtain this definition.
POLLERR Error condition (output only).
POLLHUP Hang up (output only).
POLLNVAL Invalid request: fd not open (output only).When compiling with _XOPEN_SOURCE defined, one also has the following, which convey no further information beyond the bits listed above:
POLLRDNORM Equivalent to POLLIN.
POLLRDBAND Priority band data can be read (generally unused on Linux).
POLLWRNORM Equivalent to POLLOUT.
POLLWRBAND Priority data may be written.
返回值:
2)poll的优点和缺点
优点
缺点
int main()
{
struct pollfd rfds[1];
rfds[0].fd = 1;
rfds[0].events = POLLOUT;
rfds[0].revents = 0;
char buf[1024] = {0};
cout << "poll begin..." << endl;
while(true){
switch(poll(rfds, 1, 1000)){
case 0:
cout << "time out ..." << endl;
break;
case -1:
cout << "poll error" << endl;
break;
default:
cout << "events happen!" << endl;
//HandlerEvents(rfds);
if(rfds[0].fd == 1 && (rfds[0].revents & POLLOUT)){
printf("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
//cout << "hello world";
// ssize_t s = read(0, buf, sizeof(buf));
// buf[s] = 0;
// cout <<"echo# "<< buf <
1)相关接口
创建句柄:int epoll_create(int size);
事件注册:int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
收集epoll监控的事件中已经发生的事件:int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
2)工作原理
3)select、poll和epoll的对比
select最主要的缺点就是,每次调用select需要手动设置fd_set、当fd_set中保存的文件描述符过多时必然会产生较大的系统到内核内核到系统的数据拷贝开销并且在内核内部需要阻塞的关注这些文件描述符的状态,因此操作系统就需要遍历这些文件描述符,这个开销在文件描述符过多时也是给常大的,还有一个问题就是fd_set的大小是有限(我电脑上是128字节)的,也就是说select只能关注有限个文件描述符的状态;poll模型使用一个结构体保存文件描述符,不同于select使用三个位图表示,其次最主要的就是poll可以设置的文件描述符的大小并没有限制,但是使用poll每次也需要将数据从内核拷贝到用户从用户拷贝到内核,其次也需要操作系统轮询检测这些文件描述符,同时select和poll都需要使用一个数组将设置的文件描述符保存,一是为了方便对比(跟fd_Set上返回的就绪时间对比,查看哪些事件就绪),二是select会将没有就绪的文件描述符置为0.只是poll每次将就绪的文件描述符保存在一个数组中,每次只需要遍历数组就可以取到就绪的文件描述符。
epoll解决了select和poll的所有缺点,epoll使用三个接口进行控制。用户调用epoll_sreate时操作系统底层会创建红黑树、创建回调机制、创建就绪队列,当用户调用epoll_ctl时,会将文件描述符保存在红黑树中,并未文件描述符创建回调函数、当文件描述符上的事件就绪就会调用回调函数将对应的红黑树节点插入到就绪队列,当调用epoll_wait时会会从就绪队列中将就绪的节点返回给用户。epoll不需要操作系统轮询遍历所有文件描述符,epoll不需要每次将所有文件描述符都拷贝给用户(select每调用陪你过一次,就需要将fd_set中的所有文件描述符拷贝一次),其次每次从红黑树中取就绪节点,时间复杂度为O(1)同时epoll支持大量的文件描述符。
4)epoll工作方式
水平触发LT
epoll默认状态下是LT工作模式:
边缘出发ET
如果我们在第1步将socket添加到epoll描述符的时候使用了EPOLLET标志, epoll进入ET工作模式.
注意:select和poll其实也是在LT模式下工作的
LT是 epoll 的默认行为. 使用 ET 能够减少 epoll 触发的次数. 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.相当于一个文件描述符就绪之后, 不会反复被提示就绪, 看起来就比 LT 更高效一些. 但是在 LT 情况下如果也能做到每次就绪的文件描述符都立刻处理, 不让这个就绪被重复提示的话, 其实性能也是一样的.另一方面, ET 的代码复杂程度更高了。
理解ET模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求.假设这样的场景: 服务器接受到一个10k的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个10k请求.如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来,参考 man 手册的说明, 可能被信号打断), 剩下的9k数据就会待在缓冲区中,此时由于 epoll 是ET模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据. epoll_wait 才能返回,但是问题来了.服务器只读到1k个数据, 要10k读完才会给客户端返回响应数据.客户端要读到服务器的响应, 才会发送下一个请求客户端发送了下一个请求, epoll_wait 才会返回, 才能去读缓冲区中剩余的数据.所以, 为了解决上述问题(阻塞read不一定能一下把完整的请求读完), 于是就可以使用非阻塞轮训的方式来读缓冲区,保证一定能把完整的请求都读出来.而如果是LT没这个问题. 只要缓冲区中的数据没读完, 就能够让 epoll_wait 返回文件描述符读就绪。
epoll的惊群问题
在多线程或者多进程环境下,有些人为了提高程序的稳定性(当有多个请求时,程序的效率可以得到保障),往往会让多个线程或者多个进程同时在epoll_wait监听的socket描述符。当一个新的链接请求进来时,操作系统不知道选派那个线程或者进程处理此事件,则干脆将其中几个线程或者进程给唤醒,而实际上只有其中一个进程或者线程能够成功处理accept事件,其他线程都将失败,且errno错误码为EAGAIN。这种现象称为惊群效应,结果是肯定的,惊群效应肯定会带来资源的消耗和性能的影响。
这种情况,不建议让多个线程同时在epoll_wait监听的socket,而是让其中一个线程epoll_wait监听的socket,当有新的链接请求进来之后,由epoll_wait的线程调用accept,建立新的连接,然后交给其他工作线程处理后续的数据读写请求,这样就可以避免了由于多线程环境下的epoll_wait惊群效应问题。