文章目录
- 一、IO的理解
- 二、五种IO模型
- 1.阻塞式IO
- 2.非阻塞式IO
- 3.信号驱动式IO
- 4.IO多路转接
- 5.异步IO
- 6.五种IO模型的总结
- 三、非阻塞式IO
- 1.fcntl函数
- 四、IO多路转接之select的介绍
- 五、编写select服务器
- 1.将获取连接时设置为select多路转接
- 2.获取连接成功后的读取数据
- 六、select多路转接的总结
- 1.select的编写代码规律
- 2.select的优点和缺点
一、IO的理解
我们在调用一些读或者写的函数接口时,比如说调用read或者recv函数接口从缓冲区里读取数据,它是有两个阶段的。首先第一个阶段是等待阶段,如果当前缓冲区里没有数据,read函数或者recv函数必须阻塞式的等待缓冲区的数据。第二个阶段是数据拷贝阶段,当缓冲区有数据到来的时候,read函数就可以将缓冲区的数据读取上来了。这个所谓的读取动作,实质上就是将缓冲区的数据拷贝到read函数自己的缓冲区。
所以IO一共有两个阶段组成,分别是等待阶段和数据拷贝阶段。
如果IO操作的过程中,大部分时间都在等待缓冲区的数据,这种IO事实上是非常低效的。所以什么叫高效的IO呢?单位时间内等待时间所占的比重越小,IO的效率越高效。
二、五种IO模型
1.阻塞式IO
阻塞式IO就是指当我们进行IO操作时,比如调用recvfrom函数时,如果缓冲区内没有数据,我们就必须阻塞式地等待,在等待的这段时间里我们什么也不能做就是阻塞在那里,等待数据的到来。当缓冲区有数据到来的时候,我们再将缓冲区的数据拷贝上来,自此也就完成了IO操作。
2.非阻塞式IO
非阻塞式IO指的是当我们在进行IO操作时,如果缓冲区还没有数据,进程也会在等待数据,但不是阻塞式地等待,而是调用recvfrom函数时发现缓冲区没有数据就直接返回,继续去做其它事情,过一会再来调用recvfrom函数检测缓冲区是否有数据,如果有数据到来了就将数据拷贝上来,没有数据的话就继续返回,下一次继续检测,一直不断地轮询检测,直到缓冲区有数据到来。
3.信号驱动式IO
信号驱动式IO指的是先建立SIGIO的信号处理程序,用来检测缓冲区当前是否有数据,当缓冲区没有数据的时候,进程不需要调用recvfrom函数阻塞式等待数据,可以去做其他事情。一旦缓冲区有数据到来了,信号处理程序会发送信号过来,当进程接收到了该信号以后,再调用回调函数执行recvfrom函数,此时由于缓冲区有数据,所以IO操作不需要等待,直接就可以将缓冲区的数据拷贝上来。
4.IO多路转接
因为IO操作一共有两个阶段,分别是等待数据阶段和拷贝数据阶段。多路转接就是将这两个阶段分开,调用select函数来等待缓冲区的数据,当缓冲区有数据时再调用recvfrom函数从缓冲区中拷贝数据。
5.异步IO
Boost库中也为我们提供了一些异步IO的接口,所谓异步IO就是不需要该进程自己等待数据就绪,即使数据就绪了也不需要自己拷贝数据,也就是说IO全程该进程都不参与也不关心,只要拿到IO的结果即可。比如我们调用aio_read函数接口,向操作系统指定缓冲区,操作系统会为我们等待缓冲区的数据就绪,进程可以做其它事情。当缓冲区数据就绪时,操作系统会将缓冲区的数据帮我们拷贝到我们的缓冲区,当操作系统将数据拷贝上来时会告诉进程,进程收到信号之后去处理数据就可以了。
6.五种IO模型的总结
五种IO模型中,效率最高的是多路转接。
其中阻塞式IO、非阻塞式IO、信号驱动式IO、IO多路转接统称为同步IO。同步IO中的同步概念与多进程多线程的同步概念不一样,同步IO是指自己参与到了IO操作当中,异步IO是指自己没有参与到IO操作中,而是让别人帮自己操作。所以同步IO和异步IO的区别就在于自己有没有参与IO操作。
三、非阻塞式IO
1.fcntl函数
一个文件被打开以后都会被分配一个文件描述符,一个文件描述符的默认工作方式都是阻塞式IO。fcntl函数可以设置指定文件描述符的工作方式,函数原型如下:
int fcntl(int fd, int cmd, ...);
其中形参中fd代表的是需要设置哪一个文件描述符,cmd是选项参数,传入的cmd不同,代表的功能也不同。第三个参数是状态参数,这是个可变参数。fcntl函数有5种功能:
- 复制一个现有的文件描述符:
cmd = F_DUPFD
- 获得/设置文件描述符标记:
cmd = F_GETFD 或 cmd = F_SETFD
- 获得/设置文件状态标记:
cmd = F_GETFL 或 cmd = F_SETFL
- 获得/设置异步IO所有权:
cmd = F_GETOWN 或 cmd = F_SETOWN
- 获得/设置记录锁:
cmd = F_GETLK 或 cmd = F_SETLK
利用fcntl函数可以将文件描述符设置成非阻塞式IO,首先我们需要用fcntl函数获得指定描述符的文件状态标记,然后在这个状态标记的基础上再设置文件状态标记,添加上O_NONBLOCK
标记即可将该文件描述符设置为非阻塞式IO。
bool SetNonBlock(int sock)
{
int flag = fcntl(sock, F_GETFL);
if(flag == -1) return false;
int n = fcntl(sock, F_SETFL, flag | O_NONBLOCK);
if(n == -1) return false;
return true;
}
四、IO多路转接之select的介绍
多路转接可以让我们在等待IO资源就绪的时候一次等待多个文件描述符。首先介绍的第一种多路转接方案是select方案。select系统调用是用来让我们的程序监视多个文件描述符的状态变化的。当我们调用select函数时,进程会停在select函数这里等待,直到被监视的文件描述符有一个或多个发生了状态改变。
所以多路转接就是在等待文件描述符的状态变化,所谓的状态变化指的就是比如不可读状态变成可读状态,可写状态变成不可写状态等,这些状态变化它都能检测到。
select函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select函数是帮助我们等待IO事件就绪的,分别可以等待读事件就绪、写事件就绪和异常事件就绪。下面介绍一下select函数的参数:
- int nfds:该参数填的是要等待的文件描述符集合中最大的文件描述符+1,比如文件描述符的集合是3、5、7、8,那么该参数就应该填9。
- fd_set * readfds:这是个输入输出型参数,fd_set是文件描述符集合,这是一个位图结构,对应的比特位被置为1代表该文件描述符在这个集合中。当我们输入这个参数时是告诉select函数哪些文件描述符是需要等待读事件就绪的。当该参数输出时是告诉我们哪些文件描述符的读事件已经就绪了。
- fd_set * writefds:这也是一个输入输出型参数,当我们输入这个参数时是告诉select函数哪些文件描述符是需要等待写事件就绪的。当该参数输出时是告诉我们哪些文件描述符的写事件已经就绪了。
- fd_set * exceptfds:这也是一个输入输出型参数,当我们输入这个参数时是告诉select函数哪些文件描述符是需要等待异常事件就绪的。当该参数输出时是告诉我们哪些文件描述符的异常事件已经就绪了。
- struct timeval * timeout:这也是一个输入输出型参数,这个参数可以用来控制select函数的等待策略。在等待IO的时候等待策略一般有三种,阻塞式等待、非阻塞式等待和设定deadline,deadline时间之内,阻塞式等待,一旦超时了,立马会返回。这个参数输入的时候是填充一个timeval结构体,填充等待的时间。如果在deadline时间之内等待成功了,该参数就会输出剩余时间,并且select函数的返回值会返回一个整数,代表有多少个文件描述符的事件等待成功。如果超时了,select函数会返回0,如果函数出错了会返回-1。另外还需要提到的是,该参数在设置的时候,如果将等待时间全部设置为0,那么select会非阻塞式等待。如果该参数填为nullptr,那么select会永久阻塞式等待,所谓永久阻塞指的就是当select函数在等待的文件描述符集合中,只要有一个文件描述符没有就绪,就都会永久阻塞式地等待。只有文件描述符集合里的所有文件描述符都等待成功了,函数才会成功返回。
五、编写select服务器
下面我们通过编写select服务器来演示一下select多路转接的使用:
1.将获取连接时设置为select多路转接
首先我们利用TCP套接字搭建一个服务器,开始的步骤是获取套接字、bind网络信息、将其设置为listen监听状态。这些步骤完成以后,就可以让服务器循环获取连接了。但是,accept获取连接的时候,如果此时没有客户端连接服务器,服务器是会阻塞在accept函数这里的,原因是accept函数本质上也是IO操作,它也分为两个阶段,即等待连接到来和获取连接两个阶段,所以我们将accept的等待事件就绪行为交给select函数去做,当事件就绪时再让accept去获取连接,这样accept就不会阻塞了。
Sock.hpp:
#pragma once
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <cstdio>
#include <cstring>
#include <signal.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <pthread.h>
#include <cerrno>
#include <cassert>
class Sock
{
public:
static const int gbacklog = 20;
static int Socket()
{
int listenSock = socket(PF_INET, SOCK_STREAM, 0);
if (listenSock < 0)
{
exit(1);
}
int opt = 1;
setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
return listenSock;
}
static void Bind(int socket, uint16_t port)
{
struct sockaddr_in local; // 用户栈
memset(&local, 0, sizeof local);
local.sin_family = PF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
// 2.2 本地socket信息,写入sock_对应的内核区域
if (bind(socket, (const struct sockaddr *)&local, sizeof local) < 0)
{
exit(2);
}
}
static void Listen(int socket)
{
if (listen(socket, gbacklog) < 0)
{
exit(3);
}
}
static int Accept(int socket, std::string *clientip, uint16_t *clientport)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int serviceSock = accept(socket, (struct sockaddr *)&peer, &len);
if (serviceSock < 0)
{
// 获取链接失败
return -1;
}
if(clientport) *clientport = ntohs(peer.sin_port);
if(clientip) *clientip = inet_ntoa(peer.sin_addr);
return serviceSock;
}
};
SelectServer.cc:
#include <iostream>
#include <sys/select.h>
#include "Sock.hpp"
using namespace std;
int main()
{
// 获取套接字
int listen_sock = Sock::Socket();
// bind网络信息
Sock::Bind(listen_sock, 8081);
// 设置监听状态
Sock::Listen(listen_sock);
while (true)
{
// 获取连接的时候,本质上是在等待listen_sock套接字的事件就绪
// 所以需要使用多路转接
int max_fd = listen_sock + 1;
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(listen_sock, &read_fds);
struct timeval time_out;
time_out.tv_sec = 5;
time_out.tv_usec = 0;
int n = select(max_fd + 1, &read_fds, nullptr, nullptr, &time_out);
// select的返回值有以下几种情况:
// 如果在等待时间内正常返回,n返回的是有多少个文件描述符等待成功
// 如果超时了,n返回0
// 如果等待失败,n返回-1
switch (n)
{
case 0:
cout << "time out ... : " << (unsigned long)time(nullptr) << endl;
break;
case -1:
cerr << errno << " : " << strerror(errno) << endl;
break;
default:
cout << "等待listen_sock成功... " << endl;
break;
}
}
return 0;
}
2.获取连接成功后的读取数据
在获取数据成功之后,我们需要读取数据了。之前我们实现的服务器是accept获取连接之后,就要创建多进程或者多线程,让新进程或者新线程去执行读取数据的动作,原来的进程继续accept获取连接。这样做的原因是如果只是单一执行流,在accept获取连接成功之后调用read读取数据,有可能会阻塞在这里,此时如果有其它连接到来服务器就无法获取新连接了,这样是不合理的,所以必须要使用多个进程或者多个线程。
但是今天我们使用多路转接的方案,就不再需要使用多进程或者多线程了,原因是阻塞等待的阶段我们不用自己做了,而是让select函数帮我们等待。当文件描述符的事件就绪时,我们就可以直接调用read函数进行数据拷贝。
但是又会有一个新的问题,select函数的参数readfds读文件描述符集合是一张位图,并且它是输入输出型参数,也就意味着如果我们一开始输入进去的有100个文件描述符需要select帮我们等待读事件就绪,在规定时间内只有一个文件描述符就绪的话,最后输出的位图就只有那一个文件描述符了,其它的文件描述符就因此丢失了。所以我们必须维护一张文件描述符表,用来保存我们需要等待的文件描述符。
维护这个文件描述符表还有另外一个必要之处就是,在获取连接成功之后,会得到一个新的文件描述符,我们就是从这个文件描述符的套接字文件中读取数据的,所以它也需要加入到select函数的文件描述符集合中被等待,同时我们还要继续获取新的连接,所以一开始的listensock套接字也需要继续在select函数的文件描述符集合中被等待,这样就会导致等待成功的文件描述符我们无法确定哪个是用来获取连接的,哪个是用来读取数据的。所以需要维护一张文件描述符表,我们规定好表的第一个元素就是用来获取连接的文件描述符,其它的元素都是用来读取数据的文件描述符。
SelectServer.cc:
#include <iostream>
#include <sys/select.h>
#include "Sock.hpp"
int fdsArray[sizeof(fd_set) * 8] = {0}; // 保存历史上所有的合法fd
int gnum = sizeof(fdsArray) / sizeof(fdsArray[0]);
#define DFL -1
using namespace std;
static void showArray(int arr[], int num)
{
cout << "当前合法sock list# ";
for (int i = 0; i < num; i++)
{
if (arr[i] == DFL)
continue;
else
cout << arr[i] << " ";
}
cout << endl;
}
static void usage(std::string process)
{
cerr << "\nUsage: " << process << " port\n"
<< endl;
}
// readfds: 现在包含就是已经就绪的sock
static void HandlerEvent(int listensock, fd_set &readfds)
{
for (int i = 0; i < gnum; i++)
{
if (fdsArray[i] == DFL)
continue;
if (i == 0 && fdsArray[i] == listensock)
{
// 我们是如何得知哪些fd,上面的事件就绪呢?
if (FD_ISSET(listensock, &readfds))
{
// 具有了一个新链接
cout << "已经有一个新链接到来了,需要进行获取(读取/拷贝)了" << endl;
string clientip;
uint16_t clientport = 0;
int sock = Sock::Accept(listensock, &clientip, &clientport); // 不会阻塞
if (sock < 0)
return;
cout << "获取新连接成功: " << clientip << ":" << clientport << " | sock: " << sock << endl;
// read/write -- 不能,因为你read不知道底层数据是否就绪!!select知道!
// 想办法把新的fd托管给select?如何托管??
int i = 0;
for (; i < gnum; i++)
{
if (fdsArray[i] == DFL)
break;
}
if (i == gnum)
{
cerr << "我的服务器已经到了最大的上限了,无法在承载更多同时保持的连接了" << endl;
close(sock);
}
else
{
fdsArray[i] = sock; // 将sock添加到select中,进行进一步的监听就绪事件了!
// showArray(fdsArray, gnum);
}
}
} // end if (i == 0 && fdsArray[i] == listensock)
else
{
// 处理普通sock的IO事件!
if(FD_ISSET(fdsArray[i], &readfds))
{
// 一定是一个合法的普通的IO类sock就绪了
// read/recv读取即可
// TODO bug
char buffer[1024];
ssize_t s = recv(fdsArray[i], buffer, sizeof(buffer), 0); // 不会阻塞
if(s > 0)
{
buffer[s] = 0;
cout << "client[" << fdsArray[i] << "]# " << buffer << endl;
}
else if(s == 0)
{
cout << "client[" << fdsArray[i] << "] quit, server close " << fdsArray[i] << endl;
close(fdsArray[i]);
fdsArray[i] = DFL; // 去除对该文件描述符的select事件监听
// showArray(fdsArray, gnum);
}
else
{
cout << "client[" << fdsArray[i] << "] error, server close " << fdsArray[i] << endl;
close(fdsArray[i]);
fdsArray[i] = DFL; // 去除对该文件描述符的select事件监听
// showArray(fdsArray, gnum);
}
}
}
}
}
// ./SelectServer 8080
// 只关心读事件
int main(int argc, char *argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(1);
}
// 是一种类型,位图类型,能定义变量,那么就一定有大小,就一定有上限
// fd_set fds; // fd_set是用位图表示多个fd的
// cout << sizeof(fds) * 8 << endl;
int listensock = Sock::Socket();
Sock::Bind(listensock, atoi(argv[1]));
Sock::Listen(listensock);
for (int i = 0; i < gnum; i++)
fdsArray[i] = DFL;
fdsArray[0] = listensock;
while (true)
{
// 在每次进行select的时候进行我们的参数重新设定
int maxFd = DFL;
fd_set readfds;
FD_ZERO(&readfds);
for (int i = 0; i < gnum; i++)
{
if (fdsArray[i] == DFL)
continue; // 1. 过滤不合法的fd
FD_SET(fdsArray[i], &readfds); // 2. 添加所有的合法的fd到readfds中,方便select统一进行就绪监听
cout << "---------------------" << endl;
showArray(fdsArray, gnum);
if (maxFd < fdsArray[i])
maxFd = fdsArray[i]; // 3. 更新出最大值
}
struct timeval timeout = {100, 0};
// 如何看待监听socket,获取新连接的,本质需要先三次握手,前提给我发送syn -> 建立连接的本质,其实也是IO,一个建立好的
// 连接我们称之为:读事件就绪!listensocket 只(也)需要关心读事件就绪!
// accept: 等 + "数据拷贝"
// int sock = Sock::Accept(listensock, );
// 编写多路转接代码的时候,必须先保证条件就绪了,才能调用IO类函数!
int n = select(maxFd + 1, &readfds, nullptr, nullptr, &timeout);
switch (n)
{
case 0:
cout << "time out ... : " << (unsigned long)time(nullptr) << endl;
break;
case -1:
cerr << errno << " : " << strerror(errno) << endl;
break;
default:
HandlerEvent(listensock, readfds);
// 等待成功
// 1. 刚启动的时候,只有一个fd,listensock
// 2. server 运行的时候,sock才会慢慢变多
// 3. select 使用位图,采用输出输出型参数的方式,来进行 内核<->用户 信息的传递, 每一次调用select,都需要对历史数据和sock进行重新设置!!!
// 4. listensock,永远都要被设置进readfds中!
// 5. select 就绪的时候,可能是listen 就绪,也可能是普通的IO sock就绪啦!!
break;
}
}
return 0;
}
六、select多路转接的总结
1.select的编写代码规律
- select之前要进行所有参数的重置,然后将所有需要等待的文件描述符添加到select的参数结构中。
- select需要用户自己维护第三方数组,来保存所有的合法文件描述符,方便select进行批量化处理。
- 一旦特点的文件描述符事件就绪,本次的读取或者写入就不会被阻塞。
2.select的优点和缺点
select函数的优点是:使用select之后不需要像以前一样使用多进程或者多线程,所以与多进程或者多线程相比,select占用资源少,并且高效。
select函数的缺点是:
- 每一次调用select函数之前都要进行大量的重置工作,效率比较低;
- select函数每一次能够检测的文件描述符数量是有上限的,因为fd_set是位图结构,所以它有1024个比特位,select能处理的文件描述符数量上限就是1024,虽然可以采用多进程或者多线程来解决这个问题,但是效率相对就比较低了;
- 每一次调用select函数都需要在用户和内核中互相传递位图参数,当传递较为频繁时,这就是大量的数据拷贝工作,效率也会比较低;
- select的多路转接方案编写代码比较复杂;select底层需要通过遍历的方式,检测所有需要等待的文件描述符,当连接越来越多时,select的效率就会因此下降。