您现在的位置是:首页 >学无止境 >【Hello Network】网络编程套接字(三)网站首页学无止境

【Hello Network】网络编程套接字(三)

学习同学 2023-06-03 00:00:03
简介【Hello Network】网络编程套接字(三)

作者:@小萌新
专栏:@网络
作者简介:大二学生 希望能和大家一起进步
本篇博客简介:简单介绍下各种类型的Tcp协议

我们在前面的网络编程套接字(二)中写出了一个单执行流的服务器

我们再来回顾下它的运行

在这里插入图片描述
我们首先启动服务器 之后启动客户端1 最后启动客户端2

我们发现启动客户端1之后向服务器发送数据服务器很快的就回显了一个数据并且打印了得到一个新连接

可是在客户端2连接的时候却没有发生任何情况

在这里插入图片描述

当我们的客户端1退出的时候 服务器接受到了客户端2的连接并且回显了数据

单执行流服务器

这是因为我们的服务器是单执行流的 所以在同一时间只能有一个客户端接受服务

当服务端调用accept函数获取到连接后就给该客户端提供服务 但在服务端提供服务期间可能会有其他客户端发起连接请求 但由于当前服务器是单执行流的 只能服务完当前客户端后才能继续服务下一个客户端

客户端为什么会显示连接成功

服务器是处于监听状态的 在我们的客户端2发送连接请求的时候实际上已经被监听到了 只不过服务端没有调用accept函数将该连接获取上来

实际在底层会为我们维护一个连接队列 服务端没有accept的新连接就会放到这个连接队列当中 而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的 因此服务端虽然没有获取第二个客户端发来的连接请求 但是在第二个客户端那里显示是连接成功的

如何解决?

单执行流的服务器一次只能给一个客户端提供服务 此时服务器的资源并没有得到充分利用 因此服务器一般是不会写成单执行流的 要解决这个问题就需要将服务器改为多执行流的 此时就要引入多进程或多线程

多进程版的TCP网络程序

我们将之前的单执行流服务器改为多进程服务器

当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务 而是当前执行流调用fork函数创建子进程 然后让子进程为父进程获取到的连接提供服务

由于父子进程是两个不同的执行流 当父进程调用fork创建出子进程后 父进程就可以继续从监听套接字当中获取新连接 而不用关心获取上来的连接是否服务完毕

子进程继承父进程的文件描述符表

需要注意的是 文件描述符表是隶属于一个进程的 子进程创建后会继承父进程的文件描述符表

比如父进程打开了一个文件 该文件对应的文件描述符是3 此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件 而如果子进程再创建一个子进程 那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件

在这里插入图片描述

但是当父进程创建出子进程之后 父子进程就会保持独立性了 此时父进程文件描述符表的变化不会影响子进程的文件描述符表

在我们之前学习的匿名管道通信时 我们就是使用的这个原理

父进程首先使用pipe函数得到两个文件描述符 一个是文件读端一个是文件的写端 此时父进程创建的子进程会继承这两个文件描述符

之后父子进程一个关闭管道的读端 另一个关闭管道的写端 这时父子进程文件描述符表的变化是不会相互影响的 此后父子进程就可以通过这个管道进行单向通信了

对于套接字文件也是一样的 父进程创建的子进程也会继承父进程的套接字文件 此时子进程就能够对特定的套接字文件进行读写操作 进而完成对对应客户端的服务

等待子进程问题

当父进程创建出子进程后 父进程是需要等待子进程退出的 否则子进程会变成僵尸进程 进而造成内存泄漏

因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待

此时我们就有两种等待方式 阻塞式等待和非阻塞式等待:

  • 如果服务端采用阻塞的方式等待子进程 那么服务端还是需要等待服务完当前客户端 才能继续获取下一个连接请求此时服务端仍然是以一种串行的方式为客户端提供服务
  • 如果服务端采用非阻塞的方式等待子进程 虽然在子进程为客户端提供服务期间服务端可以继续获取新连接 但此时服务端就需要将所有子进程的PID保存下来 并且需要不断花费时间检测子进程是否退出

总之 服务端要等待子进程退出 无论采用阻塞式等待还是非阻塞式等待 都不尽人意 此时我们可以考虑让服务端不等待子进程退出

不等待子进程退出的方式

让父进程不等待子进程退出 常见的方式有两种:

  • 捕捉SIGCHLD信号 将其处理动作设置为忽略
  • 让父进程创建子进程 子进程再创建孙子进程 最后让孙子进程为客户端提供服务

捕捉SIGCHLD信号

实际当子进程退出时会给父进程发送SIGCHLD信号 如果父进程将SIGCHLD信号进行捕捉 并将该信号的处理动作设置为忽略 此时父进程就只需专心处理自己的工作 不必关心子进程了

下面是我们的处理代码 其中比较核心的代码是这一行

signal(SIGCHLD, SIG_IGN);
class TcpServer
{
public:
	void Start()
	{
		signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号
		for (;;){
			//获取连接
			struct sockaddr_in peer;
			memset(&peer, '', sizeof(peer));
			socklen_t len = sizeof(peer);
			int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
			if (sock < 0){
				std::cerr << "accept error, continue next" << std::endl;
				continue;
			}
			std::string client_ip = inet_ntoa(peer.sin_addr);
			int client_port = ntohs(peer.sin_port);
			std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
			
			pid_t id = fork();
			if (id == 0){ //child
				//处理请求
				Service(sock, client_ip, client_port);
				exit(0); //子进程提供完服务退出
			}
		}
	}
private:
	int _listen_sock; //监听套接字
	int _port; //端口号
};

下面是在此运行的结果

在这里插入图片描述

我们可以发现 加上这几行代码之后我们就可以让服务器服务多个客户端了

让孙子进程执行任务

我们也可以让服务端创建出来的子进程再次进行fork 让孙子进程为客户端提供服务 此时我们就不用等待孙子进程退出了

命名:

  • 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程
  • 爸爸进程:由爷爷进程调用fork函数创建出来的进程
  • 孙子进程:由爸爸进程调用fork函数创建出来的进程 该进程调用Service函数为客户端提供服务

我们让爸爸进程创建完孙子进程后立刻退出 此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功 此后服务进程就能继续调用accept函数获取其他客户端的连接请求

不需要等待孙子进程退出

这里主要是利用了孤儿进程的原理 当孙子进程的父进程死亡后它就会被1号进程也就是init进程领养 当孙子进程运行完毕之后它的资源会由1号进程进行回收 我们也就不需要担心僵尸进程的问题了

关闭对应的文件描述符

服务进程(爷爷进程)调用accept函数获取到新连接后 会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了爸爸进程 而爸爸进程又会调用fork函数创建出孙子进程 然后再将文件描述符表继承给孙子进程。

而父子进程创建后 它们各自的文件描述符表是独立的 不会相互影响

因此服务进程在调用fork函数后 服务进程就不需要再关心刚才从accept函数获取到的文件描述符了 此时服务进程就可以调用close函数将该文件描述符进行关闭

同样的 对于爸爸进程和孙子进程来说 它们是不需要关心从服务进程(爷爷进程)继承下来的监听套接字的 因此爸爸进程可以将监听套接字关掉

关闭文件描述符的必要性:

  • 对于服务进程来说 当它调用fork函数后就必须将从accept函数获取的文件描述符关掉 因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字) 如果服务进程不及时关掉不用的文件描述符 最终服务进程中可用的文件描述符就会越来越少
  • 而对于爸爸进程和孙子进程来说 还是建议关闭从服务进程继承下来的监听套接字 实际就算它们不关闭监听套接字 最终也只会导致这一个文件描述符泄漏 但一般还是建议关上 因为孙子进程在提供服务时可能会对监听套接字进行某种误操作 此时就会对监听套接字当中的数据造成影响
class TcpServer
{
public:
	void Start()
	{
		for (;;){
			//获取连接
			struct sockaddr_in peer;
			memset(&peer, '', sizeof(peer));
			socklen_t len = sizeof(peer);
			int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
			if (sock < 0){
				std::cerr << "accept error, continue next" << std::endl;
				continue;
			}
			std::string client_ip = inet_ntoa(peer.sin_addr);
			int client_port = ntohs(peer.sin_port);
			std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;
			
			pid_t id = fork();
			if (id == 0){ //child
				close(_listen_sock); //child关闭监听套接字
				if (fork() > 0){
					exit(0); //爸爸进程直接退出
				}
				//处理请求
				Service(sock, client_ip, client_port); //孙子进程提供服务
				exit(0); //孙子进程提供完服务退出
			}
			close(sock); //father关闭为连接提供服务的套接字
			waitpid(id, nullptr, 0); //等待爸爸进程(会立刻等待成功)
		}
	}
private:
	int _listen_sock; //监听套接字
	int _port; //端口号
};

运行结果如下

在这里插入图片描述
我们可以发现当前服务器可以支持多个客户端访问并且得到的文件描述符都是4

多线程TCP网络程序

创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现

当服务进程调用accept函数获取到一个新连接后 就可以直接创建一个线程 让该线程为对应客户端提供服务

当然 主线程(服务进程)创建出新线程后 也是需要等待新线程退出的 否则也会造成类似于僵尸进程这样的问题 但对于线程来说 如果不想让主线程等待新线程退出 可以让创建出来的新线程调用pthread_detach函数进行线程分离 当这个线程退出时系统会自动回收该线程所对应的资源 此时主线程(服务进程)就可以继续调用accept函数获取新连接 而让新线程去服务对应的客户端

各个线程共享同一张文件描述符表

文件描述符表维护的是进程与文件之间的对应关系 因此一个进程对应一张文件描述符表

而主线程创建出来的新线程依旧属于这个进程 因此创建线程时并不会为该线程创建独立的文件描述符表 所有的线程看到的都是同一张文件描述符表

在这里插入图片描述

因此当服务进程(主线程)调用accept函数获取到一个文件描述符后 其他创建的新线程是能够直接访问这个文件描述符的

需要注意的是 虽然新线程能够直接访问主线程accept上来的文件描述符 但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符

因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值 也就是告诉每个新线程在服务客户端时 应该对哪一个套接字进行操作

文件描述符关闭的问题

由于此时所有线程看到的都是同一张文件描述符表 因此当某个线程要对这张文件描述符表做某种操作时 不仅要考虑当前线程 还要考虑其他线程

  • 对于主线程accept上来的文件描述符 主线程不能对其进行关闭操作 该文件描述符的关闭操作应该由新线程来执行 因为是新线程为客户端提供服务的 只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭
  • 对于监听套接字 虽然创建出来的新线程不必关心监听套接字 但新线程不能将监听套接字对应的文件描述符关闭 否则主线程就无法从监听套接字当中获取新连接了

Service函数定义为静态成员函数

由于调用pthread_create函数创建线程时 新线程的执行例程是一个参数为void* 返回值为void*的函数 如果我们要将这个执行例程定义到类内 就需要将其定义为静态成员函数 否则这个执行例程的第一个参数是隐藏的this指针

在线程的执行例程当中会调用Service函数 由于执行例程是静态成员函数 静态成员函数无法调用非静态成员函数 因此我们需要将Service函数定义为静态成员函数 恰好Service函数内部进行的操作都是与类无关的 因此我们直接在Service函数前面加上一个static即可

Rontine函数

  static void* Rontine(void* arg)
  {
    pthread_detach(pthread_self());
    int* p = (int*)arg;

    int sock = *p;
    Service(sock);
    return nullptr;
  }

Start函数

  void Start()
  {
    while(true)
    {
      // accept 
      struct sockaddr_in peer; 
      memset(&peer , '' , sizeof(peer));
      socklen_t len = sizeof(peer);

      int sock = accept(_sockfd , (struct sockaddr*)&peer , &len);
      if (sock < 0)
      {
        cout << "accept error" << endl; 
        continue;
      }

      int* p = &sock; 
      pthread_t tid;
      pthread_create(&tid , nullptr , Rontine , (void*)p);
    }
  }

运行结果如下
在这里插入图片描述

线程池版多线程TCP网络程序

当前多线程版的服务器存在的问题:

  • 每当有新连接到来时 服务端的主线程都会重新为该客户端创建为其提供服务的新线程 而当服务结束后又会将该新线程销毁 这样做不仅麻烦 而且效率低下 每当连接到来的时候服务端才创建对应提供服务的线程
  • 如果有大量的客户端连接请求 此时服务端要为每一个客户端创建对应的服务线程 计算机当中的线程越多 CPU的压力就越大 因为CPU要不断在这些线程之间来回切换 此时CPU在调度线程的时候 线程和线程之间切换的成本就会变得很高
  • 一旦线程太多 每一个线程再次被调度的周期就变长了 而线程是为客户端提供服务的 线程被调度的周期变长 客户端也迟迟得不到应答

解决思路

  • 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程。
  • 当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。
  • 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大。此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务。

我们可以发现 我们前面做的线程池可以完美解决上面的问题

线程池

服务类新增线程池成员

服务类新增线程池成员

  • 当实例化服务器对象时,先将这个线程池指针先初始化为空。
  • 当服务器初始化完毕后,再实际构造这个线程池对象,在构造线程池对象时可以指定线程池当中线程的个数,也可以不指定,此时默认线程的个数为5。
  • 在启动服务器之前对线程池进行初始化,此时就会将线程池当中的若干线程创建出来,而这些线程创建出来后就会不断检测任务队列,从任务队列当中拿出任务进行处理。

现在当服务进程调用accept函数获取到一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号构建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列

这实际也是一个生产者消费者模型,其中服务进程就作为了任务的生产者,而后端线程池当中的若干线程就不断从任务队列当中获取任务进行处理,它们承担的就是消费者的角色,其中生产者和消费者的交易场所就是线程池当中的任务队列。

    void Start()                                                           
    {                                                                      
      _tp->ThreadPoolInit();                                               
      while(true)                                                          
      {                                                                    
        // accept                                                          
        struct sockaddr_in peer;                                           
        memset(&peer , '' , sizeof(peer));                               
        socklen_t len = sizeof(peer);                                      
                                                                           
        int sock = accept(_sockfd , (struct sockaddr*)&peer , &len);       
        if (sock < 0)                                                      
        {                                                                  
          cout << "accept error" << endl;                                  
          continue;                                                        
        }                                                                                                                                           
                                                                           
E>      Task task(port);                                                   
        _tp->Push(task);    
      }                                                                   
    }  

设计任务类

现在我们要做的就是设计一个任务类,该任务类当中需要包含客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个。

此外,任务类当中需要包含一个Run方法,当线程池中的线程拿到任务后就会直接调用这个Run方法对该任务进行处理,而实际处理这个任务的方法就是服务类当中的Service函数,服务端就是通过调用Service函数为客户端提供服务的。

我们可以直接拿出服务类当中的Service函数,将其放到任务类当中作为任务类当中的Run方法,但这实际不利于软件分层。我们可以给任务类新增一个仿函数成员,当执行任务类当中的Run方法处理任务时就可以以回调的方式处理该任务。

Handler类

  class Handler
  {
    Handler() = default;
    void operator()(int sock)
    {
        cout << "get a new linl :  " << sock  << endl;
        char buff[1024];                                                                                                                                                   
        while(true)     
        {                          
          ssize_t size = read(sock , buff , sizeof(buff) - 1);
          if (size > 0)               
          {
            buff[size] = 0; // ''
            cout << buff << endl;
            write(sock , buff , size);   
          }
          else if (size == 0)
          {       
            cout << "read close" << endl;
            break;                                                                                                                                  
          }
          else
          {         
            cout << "unknown error" << endl;      
          }
       }
        close(sock);
        cout << "Service end sock closed" << endl;
    }
  };

Task类

#pragma once     
#include "sever.cc"    
#include <iostream>    
using namespace std;    
class Task    
{    
  private:    
    int _sock;    
    Handler _handler;    
  public:    
    Task(int sock)    
      :_sock(sock)                                                                                                                                  
    {}    
    Task() = default;   
    void run()    
    {    
      _handler(_sock);    
    }    
};  

这样子我们线程池版本的TCP网络程序就基本完成了

下面是运行结果

在这里插入图片描述

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。