I/O多路复用
作者:知乎用户
链接:https://www.zhihu.com/question/28594409/answer/52835876
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
下面举一个例子,模拟一个tcp服务器处理30个客户socket。假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:
- 第一种选择:
按顺序逐个检查
,先检查A,然后是B,之后是C、D。。。这中间如果有一个学生卡主,全班都会被耽误。
这种模式就好比,你用循环挨个处理socket,根本不具有并发能力。
- 第二种选择:你
用时间片给30个同学分
,每个分身检查一个学生的答案是否正确。 这种类似于为每一个用户创建一个进程或者线程处理连接。
- 第三种选择,你
站在讲台上等,谁解答完谁举手
。这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等。此时E、A又举手,然后去处理E和A。。。
这种就是IO复用模型,Linux下的select、poll和epoll就是干这个的。将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作。此时的socket应该采用
非阻塞模式
。
这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是
事件驱动
,所谓的reactor模式
select 、poll、epoll只在一个线程里就能完成。
select的数据结构是数组,poll的数据结构是链表。它们都是文件描述符拷贝到内核里再拷出来。而epoll创建的有关文件描述符的数据结构本身就在内核中。
select、poll采用轮询的方式检查文件描述符是否处于就绪态。epoll采用回调机制。
一、普通的多线程网络模型
1.socket
2.bind
3.listen
4.accept
每来一个socket连接,就开一个线程去处理。在这个线程里做socket的读和写。
5.create_thread
6.read/write
二、select模型
一般用于嵌入式
将一组socket数组投递给系统,然后去系统里去查询socket是否有信号,它是通过一个select()函数进行的,会返回有操作的select数组。
select的大致工作流程:
(1)采用数组组织文件描述符(fd 一个id,可以通过这个fd查到对应的socket)
(2)通过遍历数组的方式,监视文件描述符的状态(可读,可写,异常)
(3)如果没有可读/可写的文件描述符,进程会阻塞等待一段事件,超时就返回
(4)当有一个可读/可写的文件描述符存在时,进程会从阻塞状态醒来
(5)进行无差别轮询,找出能够操作的I/O流,若处理后,会移除对应的文件描述符
select的缺点:
(1)每次调用select,都需要把文件描述符集合从用户空间贝到内核空间,这个开销在I/O流很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所文件描述符数组,这个开销在I/O流很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024
代码
fd_set allsockets;
//清零
FD_ZERO(&allSockets);
//服务器装进去
FD_SET(socketServer, &allSockets);
while (1)
{
fd_set readSockets = allSockets;
fd_set writeSockets = allSockets;
fd_set errorSockets = allSockets;
//时间段
struct timeval st;
st.tv_sec = 3;
st.tv_usec = 0;
//select
int nRes = select(0, &readSockets, &writeSockets, &errorSockets, &st);
if (0 == nRes) //没有响应的socket
{
continue;
}
else if (nRes > 0)
{
//处理错误
for (u_int i = 0; i < errorSockets.fd_count; i++)
{
char str[100] = { 0 };
int len = 99;
if (SOCKET_ERROR == getsockopt(errorSockets.fd_array[i], SOL_SOCKET, SO_ERROR, str, &len))
{
printf("无法得到错误信息\n");
}
printf("%s\n", str);
}
for (u_int i = 0; i < writeSockets.fd_count; i++)
{
//printf("服务器%d,%d:可写\n", socketServer, writeSockets.fd_array[i]);
if (SOCKET_ERROR == send(writeSockets.fd_array[i], "ok", 2, 0))
{
int a = WSAGetLastError();
}
}
//有响应
for (u_int i = 0; i < readSockets.fd_count; i++)
{
if (readSockets.fd_array[i] == socketServer)
{
//accept
SOCKET socketClient = accept(socketServer, NULL, NULL);
if (INVALID_SOCKET == socketClient)
{
//链接出错
continue;
}
FD_SET(socketClient, &allSockets);
//send
}
else
{
char strBuf[1500] = { 0 };
//客户端吧
int nRecv = recv(readSockets.fd_array[i], strBuf, 1500, 0);
//send
if (0 == nRecv)
{
//客户端下线了
//从集合中拿掉
SOCKET socketTemp = readSockets.fd_array[i];
FD_CLR(readSockets.fd_array[i], &allSockets);
//释放
closesocket(socketTemp);
}
else if (0 < nRecv)
{
//接收到了消息
printf(strBuf);
}
else //SOCK_ERROR
{
//强制下线也叫出错 10054
int a = WSAGetLastError();
switch (a)
{
case 10054:
{
SOCKET socketTemp = readSockets.fd_array[i];
FD_CLR(readSockets.fd_array[i], &allSockets);
//释放
closesocket(socketTemp);
}
}
}
}
}
}
图示
对于CS模型,它的accept()会阻塞,如果有连接,做了recv()和accept()后,又继续回到accept()阻塞。select模型是把这个socket加到数组里去,然后用select函数把有信号的socket发回来。传给应用程序里定义好的socket数组。
URL
三、poll模型
1、它主要解决的是select模型中对文件描述符数量的限制,它用链表来组织文件描述符。
2、原理跟select一致
3、只是解决了文件描述符容量受限的问题(用链表存储)
4、select和poll都是水平触发(意思是我找到了要处理的socket,然后通知你,让你处理,比如read,然后你没有read,它一直未处理的状态,然后下次轮询的时候还是会收到。
四、epoll模型
1)内核里维护一个红黑树和一个就绪队列(就绪链表)。红黑树用于管理所有的文件描述符(这样就不用每次都拷贝到内核里去),就绪队列用于保存所有存在事件的文件描述符。
2)接收到I/O请求,会先在红黑树中查找,如果存在把文件描述符放到就绪队列中,如果不存在就添加到红黑树中。
3)如果就绪队列为空,进程阻塞,不为空就遍历就绪队列,并通知应用程序处理文件描述符对应的I/O
工作模式
1.LT模式(水平触发)检测到可处理的文件描述符时,通知应用程序,应用程序可以不立即处理该事件,后续会再次通知。
2.ET模式(边缘触发)检测到可处理的文件描述符时,通知应用程序,应用程序必须立即处理该事件,如果本次不处理,后续不通知。