【网络编程】高级IO

 

文章目录

  • 一、五种IO模型的基本理念
  • 二、IO重要概念
    • 1.同步通信与异步通信的对比
    • 2.阻塞VS非阻塞
  • 三丶非阻塞IO的代码演示
  • 四丶IO多路转接select
  • 总结


一、五种IO模型的基本理念

首先IO就是 等 + 数据拷贝,还记得我们之前实现服务器用的read/recv接口的,当时我们就说过,这个接口如果有数据,那么read/recv会拷贝完成之后进行返回,如果没有数据,则会阻塞式等待,等待的目的就是等待资源就绪一旦有资源就进行数据拷贝。

1.阻塞IO

在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式.
【网络编程】高级IO_第1张图片

 当进程调用recvfrom进行系统调用来读取内核中的数据,如果数据还没有准备好,recv就会直接阻塞等待数据就绪,一旦数据准备好就将数据从内核拷贝到用户空间,拷贝完成会返回成功的指示。

2.非阻塞IO

如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
非阻塞 IO 往往需要程序员循环的方式反复尝试读写文件描述符 , 这个过程称为 轮询 . 这对 CPU 来说是较大的浪费 , 一般只有特定场景下才使用。
【网络编程】高级IO_第2张图片

 当进程调用recvfrom进行系统调用来读取内核中的数据,如果数据没有准备好那么recv就会返回错误码,因为是非阻塞的所以需要过一段时间就来询问内核数据是否准备好,在其他时间可以让这个进程干一些其他的事情比如打印日志什么的,只需要隔一段时间去询问数据是否准备好,如果没准备好还是发送错误码,准备好就把数据从内核拷贝到用户空间并且返回成功指示。

3.信号驱动IO

内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作

【网络编程】高级IO_第3张图片

 当数据还没有准备好时,我们可以让进程对sigaction做捕捉,一旦准备好了我们就捕捉到这个信号去拷贝数据。在没有准备好期间依旧可以干一些其他的事情。

4.IO多路转接

在于IO多路转接能够同时等待多个文件描述符的就绪状态

【网络编程】高级IO_第4张图片

 注意:多路转接的原理是一次可以等待多个文件描述符,所以以前的接口不可以使用了,必须使用新的select系统调用。而select以及poll和epoll都是IO中等的那一步,一旦等成功了那么还是调用recvfrom进行数据拷贝即可。并且多路转接中recvfrom不会再阻塞,只要select等待成功recvfrom会直接进行数据的拷贝。

5.异步IO

由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).

【网络编程】高级IO_第5张图片

 异步IO的原理就是让系统去等待数据,有数据了就给我拷贝到我指定的缓冲区当中,我只负责在缓冲区拿数据。这就相当于前面几个IO都是关注如何做饭,而异步IO只关注如何吃饭,对饭是怎么来的并不关心。

任何IO过程中, 都包含两个步骤. 第一是等待, 第二是拷贝. 而且在实际的应用场景中, 等待消耗的时间往 往都远远高于拷贝的时间. 让IO更高效, 最核心的办法就是让等待的时间尽量少。

二、IO重要概念

1.同步通信 vs 异步通(synchronouscommunication/asynchronous communication)

同步和异步关注的是消息通信机制 .
所谓同步,就是在发出一个 调用 时,在没有得到结果之前,该 调用 就不返回 . 但是一旦调用返回,就得到返回值了; 换句话说,就是由 调用者 主动等待这个 调用 的结果 ;
异步则是相反, 调用 在发出之后,这个调用就直接返回了,所以没有返回结果 ; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在 调用 发出后, 被调用者 通过状态、通知来通知调用者,或通过回调函数处理这个调用.
另外 , 我们回忆在讲多进程多线程的时候 , 也提到同步和互斥 . 这里的同步通信和进程之间的同步是完全不想干的概念.
进程 / 线程同步也是进程 / 线程之间直接的制约关系,是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系. 尤其是在访问临界资源的时候 .
在看到 " 同步 " 这个词 , 一定要先搞清楚大背景是什么 . 这个同步 , 是同步通信异步通信的同步 , 还是同步与互斥的同步.

2.阻塞 vs 非阻塞

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态 .
阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回.
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

三.非阻塞IO的代码演示

首先我们认识一下fcntl接口:

#include 
#include 
int fcntl(int fd, int cmd, ... /* arg */ );
传入的 cmd 的值不同 , 后面追加的参数也不相同 .
fcntl 函数有 5 种功能 :
复制一个现有的描述符(cmd=F_DUPFD).
获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
获得/设置记录锁(cmd=F_GETLK,F_SETLK或F_SETLKW).
我们此处只是用第三种功能 , 获取 / 设置文件状态标记 , 就可以将一个文件描述符设置为非阻塞
void setNonBlock(int fd)
{
    int n = fcntl(fd,F_GETFL);
    if (n<0)
    {
        std::cerr<<"fcntl: "<

F_GETFD获取文件描述符的状态标记位,函数返回-1表示设置失败

F_SETFL可以设置文件描述符的状态标记位,比如设置读或者设置写,如下图:

最后的O_NONBLOCK就是设置为非阻塞的选项。 当我们将设置文件描述符为非阻塞的函数写好后,先演示阻塞状态的结果,在演示非阻塞状态的结果:

int main()
{
    char buffer[1024];
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0,buffer,sizeof(buffer)-1);
        if (s>0)
        {
            buffer[s] = 0;
            std::cout<<"echo# "<

【网络编程】高级IO_第6张图片

 我们直接死循环式的读取,首先创建一个缓冲区,然后将0号标准输入文件描述符内的数据读到我们自己的缓冲区,读取成功时在文件结尾放上\0然后打印即可。看到结果我们可知这是阻塞式读取,因为一旦我们不向标准输入文件描述符内打印内容就会阻塞在read函数,下面我们看看非阻塞的结果:

int main()
{
    char buffer[1024];
    setNonBlock(0);
    while (true)
    {
        printf(">>> ");
        fflush(stdout);
        ssize_t s = read(0,buffer,sizeof(buffer)-1);
        if (s>0)
        {
            buffer[s] = 0;
            std::cout<<"echo# "<

首先将0号描述符设置为非阻塞,因为测试的时候打印>>>太快了为了演示我们sleep1秒:

【网络编程】高级IO_第7张图片

 可以看到即使我们不向0号文件描述符输入函数依旧会死循环的执行,体现的结果就是如果不输入就会持续打印>>>符号,并且我们输入的过程中也会打印>>>符号,这就是非阻塞!我们不用再阻塞到read接口等待数据输入了。

注意:我们完全可以写一些简单的函数比如打印日志什么的在循环内运行,效果与上图是一样的,如下图所示:
【网络编程】高级IO_第8张图片

还记得刚开始我们说非阻塞IO如果数据没有准备好就返回错误码吗,我们知道read接口读取失败返回-1,下面我们验证一下:

【网络编程】高级IO_第9张图片

【网络编程】高级IO_第10张图片

 从结果上我们可以看到确实返回了错误码-1,下面我们把报错原因打印出来:

 ​​​​​​

 可以看到虽然返回了-1,但是并不是错误而是说资源没有就绪。实际上操作系统是给我们准备了一些错误码的:

【网络编程】高级IO_第11张图片

 比如EAGAIN就是资源未就绪,EINTR是数据没读完被中断了,并不算错误:

【网络编程】高级IO_第12张图片

【网络编程】高级IO_第13张图片

 所以说实际上正确的写法是上面这样,因为这样我们才能知道此时没有出错只是资源没就绪。

以上就是非阻塞IO的代码演示了,下面我们介绍IO多路转接的select接口。

 四.IO多路转接select

系统提供 select 函数来实现多路复用输入 / 输出模型 .
select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变;
int select(int nfds, fd_set *readfds, fd_set *writefds,
 fd_set *exceptfds, struct timeval *timeout);

因为select可以一次等待多个文件描述符,而文件描述符的本质就是数组下标,所以第一个参数就是需要检视的最大的文件描述符+1,+1是因为底层会遍历文件描述符。

readfds和writefds和exceptfds分别是读文件描述符集合,写文件描述符集合,异常文件描述符集合。

timeout是一个结构体,是用来设置select的等待时间的。下面我们看看timeval是什么:

【网络编程】高级IO_第14张图片

什么意思呢。比如说我们传timeout={0,0}表示非阻塞的监视文件描述符,timeout=nullptr表示阻塞式的监视文件描述符,timeout={5,0}表示5s内阻塞式监视文件描述符,超过5秒非阻塞返回,并且后续timeout{5,0}变成{0,0}。 

就比如刚开始演示的阻塞式读取代码中,如果用select设置5,0就会是5s内只显示>>>等待用户输入,5s后返回错误码,返回后就和非阻塞一样的持续打印>>>

select返回值如果大于0表示有几个文件描述符就绪了,如果返回值等于0表示超时返回,如果返回值小于0说明select调用出现错误。

实际上我们的fd_set类型就是一个位图,当某个文件描述符读事件就绪,那么位图中的这个文件描述符的位置被置为1,写事件和异常事件同理,如下图:

【网络编程】高级IO_第15张图片

 当我们调用的时候传入表示用户告诉内核哪些文件描述符需要被关心。

【网络编程】高级IO_第16张图片

 当函数执行完,位图中哪个比特位被置为1就代表哪个文件描述符的事件已经就绪了。

下面是操作位图的接口:

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的全部位

认识了以上接口后我们就实现一下select服务器:

首先我们将创建套接字,绑定,监听,获取新链接四步分别封装成函数:

enum
{
    SOCKET_ERR = 2,
    USE_ERR,
    BIND_ERR,
    LISTEN_ERR
};
const uint16_t gport = 8080;
class Sock
{
private:

public:
    const static int gbacklog = 32;
    static int createSock()
    {
        // 1.创建文件套接字对象
        int sock = socket(AF_INET, SOCK_STREAM, 0);
        if (sock == -1)
        {
            logMessage(FATAL, "create socket error");
            exit(SOCKET_ERR);
        }
        logMessage(NORMAL, "socket success %d",sock);

        int opt = 1;
        setsockopt(sock,SOL_SOCKET,SO_REUSEADDR | SO_REUSEPORT,&opt,sizeof(opt));
        return sock;
    }
    static void Bind(int sock,uint16_t port)
    {
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY绑定任意地址IP
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            logMessage(FATAL, "bind socket error");
            exit(BIND_ERR);
        }
        logMessage(NORMAL, "bind socket success");
    }
    static void Listen(int sock)
    {
        if (listen(sock, gbacklog) < 0)
        {
            logMessage(FATAL, "listen socket error");
            exit(LISTEN_ERR);
        }
        logMessage(NORMAL, "listen socket success");
    }
    static int Accept(int listensock,std::string *clientip,uint16_t& clientport)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        //  sock是和client通信的fd
        int sock = accept(listensock, (struct sockaddr *)&peer, &len);
        // accept失败也无所谓,继续让accept去获取新链接
        if (sock < 0)
        {
            logMessage(ERROR, "accept error,next");
        }
        else 
        {
            logMessage(NORMAL, "accept a new link success");
            *clientip = inet_ntoa(peer.sin_addr);
            clientport = ntohs(peer.sin_port);
        }
        return sock;
    }
};

上面所有关于服务器的函数接口我们在实现TCP服务器的时候都讲过,不懂得可以去看看:

namespace select_ns
{
    static const int defaultport = 8080;
    class SelectServer
    {
    private:
        int _port;
        int _listensock;
    public:
        SelectServer(int port = defaultport)
        :_port(port)
        ,_listensock(-1)
        {

        }
        void initServer()
        {
            _listensock = Sock::createSock();
            Sock::Bind(_listensock,_port);
            Sock::Listen(_listensock);
        }
        void start()
        {
            for (;;)
            {
                fd_set rfds;
                FD_ZERO(&rfds);
                // 把lsock添加到读文件描述符集中
                FD_SET(_listensock, &rfds);
                struct timeval timeout = {1, 0};
                int n = select(_listensock+1,&rfds,nullptr,nullptr,&timeout);
                switch (n)
                {
                    case 0:
                        logMessage(NORMAL,"time out.....");
                        break;
                    case -1:
                        logMessage(WARNING,"select error,code: %d,err string: %s",errno,strerror(errno));
                        break;
                    default:
                        //说明有事件就绪了
                        logMessage(NORMAL,"get a new link");
                        break;
                }
                sleep(1);
                /* std::string clientip;
                uint16_t clientport = 0;
                int sock = Sock::Accept(_listensock,&clientip,clientport);
                if (sock<0)
                {
                    continue;
                }
                //开始进行服务器的处理逻辑 */
            }
        }
        ~SelectServer()
        {
            if (_listensock != -1)
            {
                close(_listensock);
            }
        }
    };
}

上面是我们利用封装好的接口实现一个select服务器的框架,在服务器启动的函数中,我们需要创建文件描述符位图读对象,然后用FD_ZERO初始化为0,注意我们作为演示只演示如何读取,实际上写和异常都是和读一样的。设置1秒内阻塞式读取,我们通过select的返回值分为3种情况,1.select超时2.select错误3.检测到有事件就绪,一旦有事件就绪我们就打印一下。下面我们运行起来:

【网络编程】高级IO_第17张图片

 没连接的时候肯定是打印time_out,当有连接时就打印get new:

【网络编程】高级IO_第18张图片

 那么为什么会打印这么多get a new 呢?这是因为我们没有处理这个select获取到的文件描述符,导致位图中这个文件描述符的值一直为1所以一直打印,下面我们写一个处理函数专门处理已经就绪的文件描述符:

void HanderEvent(fd_set &rfds)
        {
            if (FD_ISSET(_listensock, &rfds))
            {
                //listensock必然就绪
                std::string clientip;
                uint16_t clientport = 0;
                int sock = Sock::Accept(_listensock, &clientip, clientport);
                if (sock < 0)
                {
                    return;
                }
                logMessage(NORMAL,"accept success [%s:%d]",clientip.c_str(),clientport);
            }
        }

当listen文件描述符读事件就绪,我们就获取新连接并且打印客户端的ip和端口号:

【网络编程】高级IO_第19张图片

下面我们运行起来:

【网络编程】高级IO_第20张图片 

 我们可以看到一旦获取新连接成功这次就不像之前那样重复打印获取到新连接,而是继续等待新连接,这是因为读事件就绪我们处理了这个事件。

处理了这一点后,我们思考一下如何让select处理其他的文件描述符呢,比如我们现在要用accept返回的文件描述符通信,当客户端发送数据我们服务器显示这个数据即可,实际上一般要使用select,是需要程序员自己维护一个保存所有合法fd的数组,下面我们就实现一下:

首先我们创建一个数组和一个默认值,这个默认值用来初始化数组内的所有元素:

【网络编程】高级IO_第21张图片

 fd_num代表这个数组所能存放的最大文件描述符个数,这个个数就是fd_set*8这么大。

void initServer()
        {
            _listensock = Sock::createSock();
            if (_listensock == -1)
            {
                logMessage(NORMAL,"createSock error");
                return;
            }
            Sock::Bind(_listensock,_port);
            Sock::Listen(_listensock);
            fdarray = new int[fd_num];
            for (int i = 0;i

我们在初始化的时候需要开空间并且初始化所有值为-1(为什么是负数呢?因为文件描述符从0开始,如果是正数有可能影响某个文件描述符),既然开了空间那么不用了肯定是要析构的,所以还有析构函数,当然我们的监听套接字一定要在初始化的时候放在数组中管理起来:

 ~SelectServer()
        {
            if (_listensock != -1)
            {
                close(_listensock);
            }
            if (fdarray)
            {
                delete[] fdarray;
                fdarray = nullptr;
            }
        }

在start函数中当某个事件就绪了我们就执行hander函数,因为我们现在是用一个数组管理所有的文件描述符,所以hander方法变成了下面这样:

void HanderEvent(fd_set &rfds)
        {
            if (FD_ISSET(_listensock, &rfds))
            {
                //listensock必然就绪
                std::string clientip;
                uint16_t clientport = 0;
                int sock = Sock::Accept(_listensock, &clientip, clientport);
                if (sock < 0)
                {
                    return;
                }
                logMessage(NORMAL,"accept success [%s:%d]",clientip.c_str(),clientport);
                // 开始进行服务器的处理逻辑
                // 将accept返回的文件描述符放到自己管理的数组中,本质就是放到了select管理的位图中
                int i = 0;
                for (i = 0; i < fd_num; i++)
                {
                    if (fdarray[i] != defaultfd)
                    {
                        continue;
                    }
                    else
                    {
                        break;
                    }
                }
                if (i == fd_num)
                {
                    logMessage(WARNING, "server is full ,please wait");
                    close(sock);
                }
                else
                {
                    fdarray[i] = sock;
                }
                print();
            }
        }

 第一步首先判断监听套接字的读事件是否就绪,只有就绪了我们才做下面的操作。再得到新连接返回的用于通信套接字时,我们要将这个套接字放到select中的位图管理起来,所以首先遍历数组找到合法的文件描述符(如果使用的是默认值那么说明是非法的),找到合法描述符后我们首先判断刚刚遍历的过程中是否到数组结尾,如果到数组结尾说明数组中所有文件描述符都是合法的,这个时候需要记录日志数组已满,需要等待。如果没有到数组结尾则把刚刚accept返回的新文件描述符放到数组指定位置即可。后面我们加了一个打印函数为了方便看到结果:

void print()
        {
            std::cout << "fd list: ";
            for (int i = 0; i < fd_num; i++)
            {
                if (fdarray[i] != defaultfd)
                {
                    std::cout << fdarray[i] << " ";
                }
            }
            std::cout << std::endl;
        }

这个函数只会打印合法的文件描述符,当然还有一处地方没有修改,还记得select的第一个参数吗,这个参数是最大文件描述符+1,所以修改如下:

【网络编程】高级IO_第22张图片

 首先假设最大文件描述符是监听套接字,然后去遍历数组,找到合法文件描述符把这个合法的文件描述符添加到读位图中,然后判断是否大于maxfd.下面我们看看效果吧:

【网络编程】高级IO_第23张图片

 可以看到是没有问题的,每次新连接到来都会给我们把新连接的文件描述符添加到数组中,最后数组会将这些合法的文件描述符放到select中监视。

下面我们继续修改代码让我们的select服务器支持正常的IO通信:

因为我们需要处理所有文件描述符,所以我们在hander函数中将accept部分封装起来,然后根据不同的文件描述符实现对应的功能:

void HanderEvent(fd_set &rfds)
        {
            for (int i = 0;i

当是listensock文件描述符就绪时,我们就调用accept函数去处理监听新连接,如果是普通文件描述符就绪那么就执行读数据函数:

void Accepter(int listensock)
        {
            // listensock必然就绪
            std::string clientip;
            uint16_t clientport = 0;
            int sock = Sock::Accept(listensock, &clientip, clientport);
            if (sock < 0)
            {
                return;
            }
            logMessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);
            // 开始进行服务器的处理逻辑
            // 将accept返回的文件描述符放到自己管理的数组中,本质就是放到了select管理的位图中
            int i = 0;
            for (i = 0; i < fd_num; i++)
            {
                if (fdarray[i] != defaultfd)
                {
                    continue;
                }
                else
                {
                    break;
                }
            }
            if (i == fd_num)
            {
                logMessage(WARNING, "server is full ,please wait");
                close(sock);
            }
            else
            {
                fdarray[i] = sock;
            }
            print();
        }

accept就是刚刚hander函数内的代码,我们直接讲解如何处理数据:

 void Recver(int sock,int pos)
        {
            //注意:这样的读取有问题,由于没有定协议所以我们不能确定是否能读取一个完整的报文,并且还有序列化反序列化操作...
            //由于我们只做演示所以不再定协议,在TCP服务器定制的协议大家可以看看
            char buffer[1024];
            ssize_t s = recv(sock,buffer,sizeof(buffer)-1,0);
            if (s>0)
            {
                buffer[s] = 0;
                logMessage(NORMAL,"client# %s",buffer);
            }
            else if (s == 0)
            {
                //对方关闭文件描述符,我们也要关闭并且下次不让select关心这个文件描述符了
                close(sock);
                fdarray[pos] = defaultfd;
                logMessage(NORMAL,"client quit");
            }
            else 
            {
                //读取失败,关闭文件描述符
                close(sock);
                fdarray[pos] = defaultfd;
                logMessage(ERROR,"client quit: %s",strerror(errno));
            }
            //2.处理 request
            std::string response = func(buffer);

            //3.返回response
            write(sock,response.c_str(),response.size());
        }

首先我们对数据的处理是有问题的,因为正常情况下需要定制协议保证读到的是一个完整的报文,并且还要序列化和反序列化,今天为了演示我们就不再做这些工作。读取到数据后我们在服务端进行一个回显打印,如果读取失败或者客户端关闭文件描述符,这个时候我们服务器也应该关闭对应的文件描述符,并且我们要将数组中的这个文件描述符设置为非法状态,这样的话下次select就不会再监视这个文件描述符了。拿到客户端的消息后我们直接调用一个func函数去处理,func是我们新加的一个用于演示的函数,如下图:

【网络编程】高级IO_第24张图片

 【网络编程】高级IO_第25张图片

 可以看到我们就只是简单的将客户端的消息进行返回,而实际上这个函数的作用是处理客户端请求并且有一个响应经过序列化和反序列化后发送给客户端。

拿到响应后我们直接write写回到用于通信的文件描述符中。这样我们就将代码修改完毕,下面运行起来看看:

【网络编程】高级IO_第26张图片

【网络编程】高级IO_第27张图片

 可以看到程序运行起来也是没有问题的。


总结

下面我们总结一下select服务器的特点:

1.select能同时等待的文件描述符是有上限的,改内核只能提高一点上限,并不能完全解决。

2.select服务器必须借助第三方数组来维护合法的文件描述符。

3.select的大部分参数是输入输出型的,调用select前,要重新设置所有的文件描述符,调用之后,我们还要检查更新所有的文件描述符,这带来的就是遍历的成本。

4.select的第一个参数为什么是最大文件描述符+1呢?这是因为在内核层面也需要遍历文件描述符

5.select采用位图,所以会频繁的从内核态切换为用户态,再从用户态切换为内核态来回的进行数据拷贝,是有拷贝成本的问题的。

那么如何解决上面的问题呢?后面的poll和epoll服务器会解决这个问题。

你可能感兴趣的:(linux,网络,服务器,select,linux,网络协议,c++,后端)