C++ 之动手写 Reactor 服务器模型(二):服务器模型概述以及 Reactor 服务器 V1 版本实现

发布于:2024-08-16 ⋅ 阅读:(145) ⋅ 点赞:(0)

五种网络 IO 模型

就是下面五种:

在这里插入图片描述

要注意同步与异步、阻塞与非阻塞的辨析,常见误解就是认为:同步就是阻塞,异步就是非阻塞。

接下来分别给出例子来说明这五种 IO 模型。

基础知识

操作系统分为用户态和内核态。

一个网络数据输入操作包括了两个不同的阶段:

1、等待数据准备好

2、从内核向用户态复制数据

阻塞式 IO 模型

在这里插入图片描述

对于阻塞式 IO 模型,当我们在进行某个系统调用的时候,操作系统会从用户态切换到内核态,这个时候有些系统调用函数会立马有返回值,而有些函数不会立刻有返回值。

这些不会立刻有返回值的函数就会被阻塞住,这些函数会一直等待来自内核的数据,只有当内核的数据准备好了之后内核将数据拷贝到用户态然后用户态返回数据了,这些被阻塞的函数才能返回继续往下执行程序。

非阻塞式 IO 模型

在这里插入图片描述

而非阻塞式 IO 就与阻塞式 IO 相反,程序一旦调用非阻塞式的 IO 系统调用,调用完之后就不管它了,内核有没有数据都会返回,就像上图说的轮询一直查询是否成功。

IO 多路复用模型

在这里插入图片描述

这个之前已经提过了就不再赘述。

信号驱动式 IO 模型

在这里插入图片描述

现在用的比较少,了解一下吧。

异步 IO 模型

在这里插入图片描述

小结

在这里插入图片描述

重点就是前三个:阻塞式 IO 、非阻塞式 IO 以及 IO 多路复用这三个模型是用的最多的,自然就是重点。

常见的并发服务器方案

在这里插入图片描述
在这里插入图片描述

iterative 模型(不重要)

在这里插入图片描述

concurrent 模型(不重要)

在这里插入图片描述

prefork 模型(不重要)

在这里插入图片描述

Basic Reactor 模型(重点)

在这里插入图片描述

从上图可以看见,多个客户端会连接到我们的 Reactor 上面,而在 Reactor 内会有一个 dispatch 分发器,而每一条 read->decode->compute->encode->send 任务链就相当于一个业务逻辑的处理过程。

可以看见通过 dispatch 就可以将客户端连接过来的数据进行分发处理。

还有一个部分是 acceptor 接收器,它就用来负责客户端的连接。

这就是基础版本 Reactor 的一个整体逻辑。

但是其存在缺陷,从逻辑上那个连接请求肯定是顺序处理的,也就是是串行处理的,处理完一条连接请求再处理一条连接请求。如果前面一条业务逻辑过于复杂执行时间很久的话,那么后面的连接请求就只能一直等待,这效率就太低了。

因此为了解决这个问题,我们引进了更高级的 Reactor 版本,也就是加入了线程池的版本。

Reactor+ThreadPool 版本(重点)

在这里插入图片描述

从上图可以看出,我们现在将 IO 操作留给了 Reactor,但是中间的业务逻辑处理交给了线程池中的各个子线程来进行处理,这样并发程度就高了,效率也就提高了。

基于多线程的思想,我们还可以进一步演化成多 Reactor 进程的第三个版本。

Multiple Reactors(了解)

在这里插入图片描述

同样的,我们也可以加入多线程,这样就变成了第四个版本。

Multiple Reactors + ThreadPool(了解)

在这里插入图片描述

Reactor 模型 V1 版本

什么是 Reactor

在这里插入图片描述

补充一个:Proactor

在这里插入图片描述

Proactor 是一个异步模型,一般不会用到也碰不到,了解一下即可。

我们的重点会放在 Reactor 上面。

V1 版本代码思路

对于 V1 版本,我们要做的就是简单的封装 socket 网络编程相关的 C 语言 API 为一个一个的类,然后完成通讯的主要功能,先不考虑效率,也就是不加入 IO 多路复用的技术。

首先要封装的是类 InetAddress,这个是网络地址类,负责所有的地址相关的操作,比如获取 ip 地址啊获取端口号啊等等。

然后是 Socket 类,也就是套接字类,封装所有与套接字相关的操作。

然后是 Acceptor 类,就是连接器类,封装所有连接阶段需要的操作,比如 listen、bind 和 accept。

当我们建立了与客户端的连接之后就需要进行收发数据,收发数据相关的操作我们就封装在 TcpConnection 类里面,该类对象创建完毕就表示三次握手已经建立,该连接就是一个 TCP 连接,该连接就可以进行发送数据与接收数据。

最后对于所有的 IO 相关的操作我们也封装成一个类,这样方便进行 IO 操作,设计为 SocketIO,该类真正进行数据发送与接收。

总结如下:

在这里插入图片描述

V1 版本 UML 类图设计

在这里插入图片描述

截图没截全,补一下 SocketIO 的类:

在这里插入图片描述

InetAddress 类实现

创建我们的项目目录:

在这里插入图片描述

这里面的 NoCopyable.h 文件是用来让子类继承从而删除子类的拷贝构造函数与赋值运算符函数的,在之前的文章里是有实现过的:

#ifndef __NOCOPYABLE_H__
#define __NOCOPYABLE_H__

//继承了本类的成员都将具有对象语义
//即无法实现对象赋值和对象复制的操作
class NoCopyable{
	protected:
		NoCopyable(){

		}

		~NoCopyable(){

		}
		//删除拷贝构造函数
		NoCopyable(const NoCopyable& rhs) = delete;
		//删除赋值运算符函数
		NoCopyable& operator=(const NoCopyable& rhs) = delete;
};

#endif

接下里我们实现 InetAddress.h :

#ifndef __INETADDRESS_H__
#define __INETADDRESS_H__

#include <arpa/inet.h>
#include <string>

using std::string;

class InetAddress{
  public:
    //通过ip和端口构建服务器网络地址结构体
    InetAddress(const string& ip,unsigned short port);
    //直接通过 struct sockaddr_in 构建服务器网络地址结构体
    InetAddress(const struct sockaddr_in& addr);
    //返回 IP 地址
    string ip() const;
    //返回端口号
    unsigned short port() const;
    //返回指向服务器网络地址结构体的指针
    const struct sockaddr_in* getInetAddrPtr() const;

  private:
    //服务器socket套接字网络地址结构体
    struct sockaddr_in _addr;
};

#endif

然后是它的实现文件 InetAddress.cc:

#include "InetAddress.h"
#include <netinet/in.h>
#include <string.h>

//通过ip和端口构建服务器网络地址结构体
InetAddress::InetAddress(const string& ip,unsigned short port){
  ::bzero(&_addr,sizeof(struct sockaddr_in));//使用memset也是一样的
  _addr.sin_family = AF_INET; //AF_INET表示使用IPv4地址族
  _addr.sin_port = htons(port);//端口号要转一下:主机转网络字节序
  _addr.sin_addr.s_addr = inet_addr(ip.c_str());//将点分十进制的IP地址字符串转换为网络字节序的IP地址
}

//直接通过 struct sockaddr_in 构建服务器网络地址结构体
InetAddress::InetAddress(const struct sockaddr_in& addr)
:_addr(addr)
{

}

//返回 IP 地址
string InetAddress::ip() const{
  //inet_ntoa将网络字节序的ip地址转换为点分十进制字符串
  return string(inet_ntoa(_addr.sin_addr));
}

//返回端口号
unsigned short InetAddress::port() const{
  return ntohs(_addr.sin_port);
}

//返回指向服务器网络地址结构体的指针
const struct sockaddr_in* InetAddress::getInetAddrPtr() const{
  return &_addr;
}

Socket 类实现

头文件 Socket.h:

#ifndef __SOCKET_H__
#define __SOCKET_H__

#include "NoCopyable.h"

class Socket: public NoCopyable{
  public:
    Socket();
    //使用explicit防止隐式类型转换
    explicit Socket(int fd);
    ~Socket();
    //返回文件描述符 fd
    int fd() const;

  private:
    int _fd;
};

#endif

实现文件 Socket.cc:

#include "Socket.h"
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>

Socket::Socket(){
  _fd = ::socket(AF_INET,SOCK_STREAM,0);
  if(_fd<0){
    perror("socket()");
    return;
  }
}

//使用explicit防止隐式类型转换
Socket::Socket(int fd): _fd(fd){
  
}

Socket::~Socket(){
  close(_fd);
}

//返回文件描述符 fd
int Socket::fd() const{
  return _fd;
}

Acceptor 类实现

头文件 Acceptor.h :

#ifndef __ACCEPTOR_H__
#define __ACCEPTOR_H__

#include "Socket.h"
#include "InetAddress.h"
#include <string>

using std::string;

class Acceptor{
  public:
    Acceptor(const string& ip,unsigned short port);
    //一键启动连接所需要执行的下面四个函数
    void ready();
    //设置地址重用
    void setRequestAddr();
    //设置端口号重用
    void setRequestPort();
    void bind();
    void listen();
    int accept();

  private:
    Socket _sock;
    InetAddress _servAddr;
};

#endif

实现文件 Acceptor.cc:

#include "Acceptor.h"

Acceptor::Acceptor(const string& ip,unsigned short port)
: _sock(),
  _servAddr(ip,port)
{

}

//一键启动连接所需要执行的下面四个函数
void Acceptor::ready(){
  setRequestAddr();
  setRequestPort();
  bind();
  listen();
}

//设置地址重用
void Acceptor::setRequestAddr(){
  int on = 1;
  int ret = setsockopt(_sock.fd(),SOL_SOCKET,SO_REUSEADDR,&on,sizeof(_servAddr));
  if(ret == -1){
    perror("setsockopt()");
    return;
  }
}

//设置端口号重用
void Acceptor::setRequestPort(){
  int on = 1;
  int ret = setsockopt(_sock.fd(),SOL_SOCKET,SO_REUSEPORT,&on,sizeof(_servAddr));
  if(ret == -1){
    perror("setsockopt()");
    return;
  }
}

void Acceptor::bind(){
  int ret = ::bind(_sock.fd(),(struct sockaddr*)_servAddr.getInetAddrPtr(),
                   sizeof(struct sockaddr));
  if(ret == -1){
    perror("bind()");
    return;
  }
}

void Acceptor::listen(){
  int ret = ::listen(_sock.fd(),128);
  if(ret == -1){
    perror("listen()");
    return;
  }
}

int Acceptor::accept(){
  int connfd = ::accept(_sock.fd(),nullptr,nullptr);
  if(connfd == -1){
    perror("accept()");
    return -1;
  }
  return connfd;
}

TcpConnection 类实现

头文件实现 TcpConnection.h:

#ifndef __TCPCONNECTION_H__
#define __TCPCONNECTION_H__

#include "InetAddress.h"
#include "Socket.h"
#include "SocketIO.h"

class TcpConnection{
  public:
    TcpConnection(int fd);
    //发送消息
    void send(const string& msg);
    //接收消息
    string receive();
    //获取本地地址信息
    InetAddress getLocalAddress();
    //获取客户端地址信息
    InetAddress getPeerAddress();
    //打印所需信息
    string toString();

  private:
    SocketIO _sockIO;
    Socket _sock;
    //获取本地地址信息
    InetAddress _localAddress;
    //获取客户端地址信息
    InetAddress _peerAddress;
};

#endif

实现文件 TcpConnection.cc :

#include "TcpConnection.h"
#include <sstream>

TcpConnection::TcpConnection(int fd)
: _sock(fd),
  _sockIO(fd),
  _localAddress(getLocalAddress()),
  _peerAddress(getPeerAddress())
{
  
}

//发送消息
void TcpConnection::send(const string& msg){
  _sockIO.writen(msg.c_str(),msg.size());
}

//接收消息
string TcpConnection::receive(){
  char buff[65535] = {0};
  _sockIO.readLine(buff,sizeof(buff));
  return string(buff);
}

//获取本地地址信息
InetAddress TcpConnection::getLocalAddress(){
  struct sockaddr_in addr;
  socklen_t len = sizeof(struct sockaddr);
  int ret = getsockname(_sock.fd(),(struct sockaddr*)&addr,
                        &len);
  if(ret == -1){
    perror("getsockname())");
  }
  return InetAddress(addr);
}

//获取客户端地址信息
InetAddress TcpConnection::getPeerAddress(){
  struct sockaddr_in addr;
  socklen_t len = sizeof(struct sockaddr);
  int ret = getpeername(_sock.fd(),(struct sockaddr*)&addr,
                        &len);
  if(ret == -1){
    perror("getpeername())");
  }
  return InetAddress(addr);
}

//打印所需信息
string TcpConnection::toString(){
  std::ostringstream oss;
  oss << _localAddress.ip() << ":"
      << _localAddress.port() << "-->>"
      << _peerAddress.ip() << ":"
      << _peerAddress.port();
  return oss.str();
}

SocketIO 类实现

头文件 SocketIO.h :

#ifndef __SOCKETIO_H__
#define __SOCKETIO_H__

class SocketIO{
  public:
    explicit SocketIO(int fd);
    ~SocketIO();
    int readn(char* buf,int len);
    int readLine(char* buf,int len);
    int writen(const char* buf,int len);

  private:
    int _fd;
};

#endif

实现文件 SocketIO.cc :

#include "SocketIO.h"
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>

SocketIO::SocketIO(int fd)
:_fd(fd)
{

}

SocketIO:: ~SocketIO(){

}

int SocketIO::readn(char* buf,int len){
  int left = len;
  char* pstr = buf;
  int ret = 0;

  while(left > 0){
    ret = read(_fd,pstr,left);
    if(ret == -1 && errno == EINTR){
      continue;
    }
    else if(ret == -1){
      perror("read error -1");
      return len-ret;
    }
    else if(0 == ret){
      break;
    }
    else{
      pstr += ret;
      left -= ret;
    }
  }
  return len-left;
}

/*读取一行的思路:
 1、先从内核取出一部分数据,但不移走
 2、然后再去判断有没有 '\n',如果有,那获取这一行的长度
 3、最后再从内核缓冲区移走就可以了
 */
int SocketIO::readLine(char* buf,int len){
  int left = len - 1;
  char* pstr = buf;
  int ret = 0,total = 0;
  while(left > 0){
    //MSG_PEEK的作用是只将内核态中的数据拷出去但并不移除内核态的数据
    //也就是拷贝和移走的区别
    //因为内核缓冲区中的数据我们下面的代码还要用呢,要是让recv正常读走移走了
    //后面的就没得用了
    ret = recv(_fd,pstr,left,MSG_PEEK);
    if(-1==ret && errno==EINTR){
      continue;
    }
    else if(-1 == ret){
      perror("readLine error -1");
      return len-ret;
    }
    else if(0 == ret){
      break;
    }
    else{
      for(int idx=0;idx<ret;++idx){
        if(pstr[idx] == '\n'){
          int sz = idx + 1;
          readn(pstr,sz);
          pstr += sz;
          *pstr = '\0';

          return total+sz;
        }
      }
      readn(pstr,ret);//从内核态缓冲区将数据移动到用户态,完成数据读取
      total += ret;
      pstr += ret;
      left -= ret;
    }
  }
    *pstr = '\0';
    return total-left;  
}

int SocketIO::writen(const char* buf,int len){
  int left = len;
  const char* pstr = buf;
  int ret = 0;

  while(left > 0){
    ret = write(_fd,pstr,left);
    if(-1 == ret && errno == EINTR){
      continue;
    }
    else if(-1 == ret){
      perror("writen error -1");
      return len-ret;
    }
    else if(0 == ret){
      break;
    }
    else{
      pstr += ret;
      left -= ret;
    }
  }
  return len - left;
}

测试文件 TestTcpConnection.cc

#include "Acceptor.h"
#include "TcpConnection.h"
#include <iostream>
#include <unistd.h>

using std::cout;
using std::endl;

void test(){
  Acceptor acceptor("127.0.0.1",8888);
  acceptor.ready();//此时处于监听状态
  
  //三次握手已经建立,可以创建一条TCP连接
  TcpConnection con(acceptor.accept());
  
  cout << con.toString() << " has connected." << endl;
  
  while(1){
    cout << ">> recv msg from client: " << con.receive() << endl;
    con.send("hello baby!\n");
  }
}

int main(){
  test();
  return 0;
}

测试与 V1 版本缺陷分析

上面的程序编译运行后结果如下:

在这里插入图片描述

此时已经服务器程序已经处于监听状态,我们可以另起一个终端查看端口状态:

查看的命令如下:

netstat -apn | grep a.out

在这里插入图片描述

可以看见此时已经处于监听状态我们的 a.out 执行程序。

为了方便测试,我们将不再编写客户端代码,而是使用 nc 命令进行简单测试:

在这里插入图片描述

因此我们使用 nc 命令来简单的进行测试:

在这里插入图片描述

可以看见已经连上了,那么接下来测试简单的消息通讯:

在这里插入图片描述

测试连接没有问题。

但是 V1 版本存在一个致命的问题,此时已经有一个客户端与我们的服务器连接了,那么我再连接一个是否可以呢?

在这里插入图片描述

最右侧终端是我们刚刚创建的客户端,不难发现连不上,而众所周知服务器不可能只给一个客户端进行服务,因此这就是 V1 版本最大的缺陷,因此接下来我们要升级到 V2 版本了,通过 epoll IO 多路复用技术来提升服务器的服务效率。

再补充一个最后的项目目录文件:

在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到