在网络环境下,通俗的讲,将IO分为两步:
1.等;
2.数据搬迁。
如果要想提高IO效率,需要将等的时间降低。
五种IO模型包括:阻塞IO、非阻塞IO、信号驱动IO、IO多路转接、异步IO。其中,前四个被称为同步IO。
在介绍五种IO模型时,我会举生活中钓鱼的例子,加深理解。
1.阻塞IO(blocking I/O)
A拿着一支鱼竿在河边钓鱼,并且一直在鱼竿前等,在等的时候不做其他的事情,十分专心。只有鱼上钩的时,才结束掉等的动作,把鱼钓上来。
在内核将数据准备好之前,系统调用会一直等待所有的套接字,默认的是阻塞方式。
其实,我们例子中所说的鱼竿就是这一个文件描述符。这个模型是我们最常见的,程序调用和我们编写的基本程序是一致的。
fd=connect();
write(fd);
read(fd);
close(fd);
程序的read必须在write之后执行,当write阻塞住了,read就不能执行下去,一直处于等待状态。
2.非阻塞IO(noblocking I/O)
B也在河边钓鱼,但是B不想将自己的所有时间都花费在钓鱼上,在等鱼上钩这个时间段中,B也在做其他的事情(一会看看书,一会读读报纸,一会又去看其他人的钓鱼等),但B在做这些事情的时候,每隔一个固定的时间检查鱼是否上钩。一旦检查到有鱼上钩,就停下手中的事情,把鱼钓上来。
其实,B在检查鱼竿是否有鱼,是一个轮询的过程。
每次客户询问内核是否有数据准备好,即文件描述符缓冲区是否就绪。当有数据报准备好时,就进行拷贝数据报的操作。当没有数据报准备好时,也不阻塞程序,内核直接返回未准备就绪的信号,等待用户程序的下一个轮寻。
但是,轮寻对于CPU来说是较大的浪费,一般只有在特定的场景下才使用。
3.信号驱动IO(signal blocking I/O)
C也在河边钓鱼,但与A、B不同的是,C比较聪明,他给鱼竿上挂一个铃铛,当有鱼上钩的时候,这个铃铛就会被碰响,C就会将鱼钓上来。
信号驱动IO模型,应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号处理函数来获取数据报。
4.IO多路转接(I/O multiplexing)
D同样也在河边钓鱼,但是D生活水平比较好,D拿了很多的鱼竿,一次性有很多鱼竿在等,D不断的查看每个鱼竿是否有鱼上钩。增加了效率,减少了等待的时间。
IO多路转接是多了一个select函数,select函数有一个参数是文件描述符集合,对这些文件描述符进行循环监听,当某个文件描述符就绪时,就对这个文件描述符进行处理。
其中,select只负责等,recvfrom只负责拷贝。
IO多路转接是属于阻塞IO,但可以对多个文件描述符进行阻塞监听,所以效率较阻塞IO的高。
5.异步IO(asynchronous I/O)
E也想钓鱼,但E有事情,于是他雇来了F,让F帮他等待鱼上钩,一旦有鱼上钩,F就打电话给E,E就会将鱼钓上去。
当应用程序调用aio_read时,内核一方面去取数据报内容返回,另一方面将程序控制权还给应用进程,应用进程继续处理其他事情,是一种非阻塞的状态。
当内核中有数据报就绪时,由内核将数据报拷贝到应用程序中,返回aio_read中定义好的函数处理程序。
很少有Linux系统支持,Windows的IOCP就是该模型。
可以看出,阻塞程度:阻塞IO>非阻塞IO>多路转接IO>信号驱动IO>异步IO,效率是由低到高的。
关于I/O多路复用:
I/O多路复用(又被称为“事件驱动”),首先要理解的是,操作系统为你提供了一个功能,当你的某个socket可读或者可写的时候,它可以给你一 个通知。这样当配合非阻塞的socket使用时,只有当系统通知我哪个描述符可读了,我才去执行read操作,可以保证每次read都能读到有效数据而不 做纯返回-1和EAGAIN的无用功。写操作类似。操作系统的这个功能通过select/poll/epoll之类的系统调用来实现,这些函数都可以同时 监视多个描述符的读写就绪状况,这样,**多个描述符的I/O操作都能在一个线程内并发交替地顺序完成,这就叫I/O多路复用,这里的“复用”指的是复用 同一个线程。
Select模型原理
在Windows中所有的socket函数都是阻塞类型的,也就是说只有网络中有特定的事件发生时才会返回,在没有发生事件时会一直等待,虽说我们将它们设置为非阻塞状态,但是在对于服务器段而言,肯定会一直等待客户端的消息,也就是说即使设置为非阻塞状态,时间到了函数返回,但是程序不能结束,需要一个循环不断的侦听,特别是对于有多个客户端需要管理的时候,每一个与客户端通信的socket都需要一个侦听,这样管理起来非常麻烦,我们希望系统帮助我们管理,告诉我们有哪些socket现在可以操作。为了实现这个,我们可以使用select模型
Select模型原理
select模型中需要一个结构体fd_set,该结构体是一个socket的集合,我们可以看到该结构体的定义:
typedef struct fd_set {
u_int fd_count; /* how many are SET? */
SOCKET fd_array[FD_SETSIZE]; /* an array of SOCKETs */
} fd_set;
从这个定义中可以看到,结构体中主要保存了一个socket的数组和一个保存数组的大小变量;
使用select模型主要使用函数select,该函数原型如下:
int select (
int nfds, //系统保留,无意义
fd_set FAR * readfds,//可读的socket集合
fd_set FAR * writefds,//可写的socket集合
fd_set FAR * exceptfds,//外带socket集合
const struct timeval FAR * timeout//该函数的超时值
);
在程序中使用该函数前需要在特定的集合中放入需要检测的socket值,当发生某一时间导致该函数返回时,函数会将特定集合中未待决的socket全部剔除出去,保留待决套接字,比如在readfds集合中放入几个套接字并执行完成函数,那么留下的套接字都是可以从系统的相应缓冲区读数据的。通过遍历相应的集合我们知道如何对套接字做相应的操作;
select模型最多支持64个套接字,这个值由FD_SETSIZE宏定义的,我们可以修改这个宏的值,以便支持更多的套接字,修改时尽量不要在系统文件中修改,在我们的工程文件中修改,可用使用如下方式:
#ifdef FD_SETSIZE
#undef FD_SETSIZE
#endif
#define FD_SETSIZE 200
这段代码使得select模型支持200个套接字;虽然可以修改,但是这个数组太大,会消耗过多的系统资源,每次在遍历数组时总会从头到尾遍历,数组太大效率必然底下,所以最好不要修改这个值,处理大于64个套接字的情况下可以使用多线程的方式,多定义几个集合处理;
为了操作这个集合,Windows专门定义了一组宏,他们分别是:
FD_SET(fd, &set) //将fd套接字压入集合set中
FD_ISSET(fd, &set)//判断fd是否在set中
FD_ZERO(&set)//将集合set清零
FD_CLR(fd, &set)//将fd从集合set中删除
下面说一下服务端一个简单的select模型的编写
1)创建套接字,绑定、侦听;
2)等待客户端链接
3)将连接返回的套接字压入一个数组中保存
4)将数组的套接字填入集合中
5)调用select函数
6)检测特定集合中的套接字
7)进行读写操作
8)返回到第四步,等待客户端下一步请求
在编写时需要注意以下几点:
1)为了与多个客户端保持连接,需要一个数组保存与客户端连接的所有的socket,由于select函数只会执行一次,每次返回后需要再次将徐监控的套接字压入集合,调用select,以便进行下一次检测;所以一般将这一步写在一个死循环中
2)注意select是一个阻塞函数,所以为了可以支持多个客户端可以采用一些方法:第一种就是采用多线程的方式,每有一个客户端连接都需要将新开一个线程处理并调用select监控;另一种就是调用select对侦听套接字以及与客户端通信的套接字;为什么可以这样呢,这就要说到TCP/IP中的三次握手,首先一般由客户端发起链接,发送一条数据包到服务器,服务器接收到数据,发送一条确认信息给客户端,然后客户端再发送一条数据,这样就正式建立连接,所以在客户端与服务器建立连接时必然会发送数据,而服务器一定会收到数据,所以将侦听套接字放入到read集合中,当有客户端需要连接时自然会收到一条数据,这个时候select会返回,我们需要校验集合中的套接字是否是侦听套接字,如果是则表明有客户端需要连接;这样当客户端有请求select会返回,可以进行下一次的侦听,没有请求,会死锁在select函数上,但是对于所有客户端并没有太大的影响;
3)我们用数组存储所有的套接字时,每当有客户端链接,我们需要添加,而有客户端断开链接我们需要在数组中删除,并将下一个套接字添加进该位置,为了管理套接字数组,我们另外需要一个队列用来记录退出客户端的socket在数组中的位置,下一次有新的链接进来就将相应的套接字放到这个位置。
下面是一个简单的例子:
SOCKET g_sockArray[FD_SETSIZE] = { 0 };
int g_nCount = 0;
int _tmain(int argc, _TCHAR* argv[])
{
WSAData wd;
WSAStartup(MAKEWORD(2, 2), &wd);
SOCKET sockListen = socket(AF_INET, SOCK_STREAM, IPPROTO_IP);
if (INVALID_SOCKET == sockListen)
{
cout << "创建套接字失败,错误码为:" << WSAGetLastError() << endl;
closesocket(sockListen);
WSACleanup();
return 0;
}
SOCKADDR_IN SrvAddr = { AF_INET };
SrvAddr.sin_port = htons(6666);
SrvAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (SOCKET_ERROR == bind(sockListen, (SOCKADDR*)&SrvAddr, sizeof(SOCKADDR)))
{
cout << "绑定失败,错误码:" << WSAGetLastError() << endl;
closesocket(sockListen);
WSACleanup();
return 0;
}
if (SOCKET_ERROR == listen(sockListen, 5))
{
cout << "侦听失败,错误码:" << WSAGetLastError() << endl;
closesocket(sockListen);
WSACleanup();
}
g_sockArray[g_nCount] = sockListen;
g_nCount++;
fd_set fdRead = { 0 };
while (true)
{
FD_ZERO(&fdRead);
for (int i = 0; i < g_nCount; i++)
{
FD_SET(g_sockArray[i], &fdRead);
}
select(0, &fdRead, NULL, NULL, NULL);
for (int i = 0; i < g_nCount; i++)
{
if (FD_ISSET(g_sockArray[i], &fdRead))
{
if (sockListen == g_sockArray[i])
{
SOCKET sockClient = accept(sockListen, NULL, NULL);
if (INVALID_SOCKET == sockClient)
{
cout << "连接失败, 错误码为:" << WSAGetLastError() << endl;
break;
}
cout << "有客户端链接进来" << endl;
if (g_nCount == FD_SETSIZE)
{
cout << "以达到服务器管理上限" << endl;
break;
}
if (NULL == g_pFirst)
{
g_sockArray[g_nCount] = sockClient;
}
else
{
int n = Pop();
g_sockArray[n] = sockClient;
}
g_nCount++;
}
else
{
char szBuf[255] = "";
recv(g_sockArray[i], szBuf, 255, 0);
cout << "客户端发送消息:"<< szBuf << endl;
if (0 == strcmp(szBuf, "exit"))
{
Push(i);
closesocket(g_sockArray[i]);
cout << "与客户端链接断开" << endl;
g_nCount--;
}
break;
}
}
}
}
WSACleanup();
return 0;
}
上述代码中,每当检测到有待决套接字就处理,处理完一个后就不在继续检测了,我们知道在理论上select执行完成后,保留的是所有待决套接字,那么待决套接字可不可能有多个呢,我觉得这个基本上不可能,因为服务器端判定在某一时刻该套接字是否处于待决状态是在毫秒级别的,就算有几个客户端在某时刻毫秒不差的向服务器发送数据,那么我们还要考虑双方之间的距离(虽说光速很快可以忽略不计但是当单位是毫秒是应该还是有影响)以及网络状况,虽说在大规模的情况可能出现在毫秒级别上同时响应,但是我们的select只支持64个(超过64时需要另外开线程再创建一个相应的集合),在64个客户端中找到这样的两个客户端是不可能的,所以我们就假定每次只有一个待决套接字,使用break为了让其跳出循环,避免做无用功;
从用户空间拷贝fd_set到内核空间;
注册回调函数__pollwait;
遍历所有fd,对全部指定设备做一次poll(这里的poll是一个文件操作,它有两个参数,一个是文件fd本身,一个是当设备尚未就绪时调用的回调函数__pollwait,这个函数把设备自己特有的等待队列传给内核,让内核把当前的进程挂载到其中);
当设备就绪时,设备就会唤醒在自己特有等待队列中的【所有】节点,于是当前进程就获取到了完成的信号。poll文件操作返回的是一组标准的掩码,其中的各个位指示当前的不同的就绪状态(全0为没有任何事件触发),根据mask可对fd_set赋值;
如果所有设备返回的掩码都没有显示任何的事件触发,就去掉回调函数的函数指针,进入有限时的睡眠状态,再恢复和不断做poll,再作有限时的睡眠,直到其中一个设备有事件触发为止。
只要有事件触发,系统调用返回,将fd_set从内核空间拷贝到用户空间,回到用户态,用户就可以对相关的fd作进一步的读或者写操作了。
内核帮我们在epoll文件系统里建了个file结点;
在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket;
建立一个list链表,用于存储准备就绪的事件。
调用epoll_ctl时,做了以下事情:
把socket放到epoll文件系统里file对象对应的红黑树上;
给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。
调用epoll_wait时,做了以下事情:
观察list链表里有没有数据。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已。
附:
在传统的网络服务设计模式中,有两种比较经典的模式:
一种是多线程,一种是线程池。
对于多线程模式,也就说来了client,服务器就会新建一个线程来处理该client的读写事件,如下图所示:
这种模式虽然处理起来简单方便,但是由于服务器为每个client的连接都采用一个线程去处理,使得资源占用非常大。因此,当连接数量达到上限时,再有用户请求连接,直接会导致资源瓶颈,严重的可能会直接导致服务器崩溃。
因此,为了解决这种一个线程对应一个客户端模式带来的问题,提出了采用线程池的方式,也就说创建一个固定大小的线程池,来一个客户端,就从线程池取一个空闲线程来处理,当客户端处理完读写操作之后,就交出对线程的占用。因此这样就避免为每一个客户端都要创建线程带来的资源浪费,使得线程可以重用。
但是线程池也有它的弊端,如果连接大多是长连接,因此可能会导致在一段时间内,线程池中的线程都被占用,那么当再有用户请求连接时,由于没有可用的空闲线程来处理,就会导致客户端连接失败,从而影响用户体验。因此,线程池比较适合大量的短连接应用。
因此便出现了下面的两种高性能IO设计模式:Reactor和Proactor。
在Reactor模式中,会先对每个client注册感兴趣的事件,然后有一个线程专门去轮询每个client是否有事件发生,当有事件发生时,便顺序处理每个事件,当所有事件处理完之后,便再转去继续轮询,如下图所示:
从这里可以看出,上面的五种IO模型中的多路复用IO就是采用Reactor模式。注意,上面的图中展示的 是顺序处理每个事件,当然为了提高事件处理速度,可以通过多线程或者线程池的方式来处理事件。Java NIO使用的就是这种
在Proactor模式中,当检测到有事件发生时,会新起一个异步操作,然后交由内核线程去处理,当内核线程完成IO操作之后,发送一个通知告知操作已完成,可以得知,异步IO模型采用的就是Proactor模式。Java AIO使用的这种。