socket编程TCP程序

目录

一.使用函数详解

1.listen函数

2.accept函数 

3.connect函数

4.read函数

5.write函数  

二.测试代码

1.单进程的TCP网络程序

2.多进程的TCP网络程序

3.多线程的TCP网络程序

4.线程池的TCP网络程序


 

一.使用函数详解

1.listen函数

函数:  int listen(int sockfd, int backlog);

参数:

  • sockfd:   需要设置为监听状态的套接字对应的文件描述符。
  • backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。

返回值: 监听成功返回0,监听失败返回-1,同时错误码会被设置。

补充:
典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接待状态,如果接收到更多的连接请求就忽略。

                 

2.accept函数 

函数:      int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数:

  • sockfd:特定的监听套接字,表示从该监听套接字中获取连接。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。

返回值: 成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno

(1)accept函数返回的套接字是什么?

调用accept函数获取连接时,是从监听套接字当中获取的。如果accept函数获取连接成功,此时会返回接收到的套接字对应的文件描述符。

(2)监听套接字与accept函数返回的套接字的作用:

  • 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。
  • accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字。
     

                 

3.connect函数

函数:int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数:

  • sockfd:特定的套接字,表示通过该套接字发起连接请求。
  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入的addr结构体的长度。

返回值说明:连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。

                

4.read函数

函数:  ssize_t read(int fd, void *buf, size_t count);

参数:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。
  • buf:数据的存储位置,表示将读取到的数据存储到该位置。
  • count:数据的个数,表示从该文件描述符中读取数据的字节数。

返回值说明:

  • 如果返回值大于0,则表示本次实际读取到的字节个数。
  • 如果返回值等于0,则表示对端已经把连接关闭了。
  • 如果返回值小于0,则表示读取时遇到了错误。

                         

5.write函数

函数:  ssize_t write(int fd, const void *buf, size_t count);

参数:

  • fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
  • buf:需要写入的数据。
  • count:需要写入数据的字节个数。

返回值: 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。

        

                

                

二.测试代码

1.单进程的TCP网络程序

(1)代码

①Makefile

CC=g++

.PHONY:all
all:server client

server:tcp_server.cc 
		$(CC) -o $@ $^ -std=c++11

client:tcp_client.cc
		$(CC) -o $@ $^ -std=c++11

.PHONY:clean
clean:
		rm -f server client

②tcp_server.hpp

#pragma once 


#include<iostream>
#include<sys/socket.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/fcntl.h>

#define DEFALUT 8080
#define BACKLOG 5   //全连接的最大数量

class TcpServer{
   private:
     int port;
     int lsock; //listen socket

   public:
     TcpServer(int _port = DEFALUT):port(_port),lsock(-1)
     {}
     ~TcpServer()
     {
       if(lsock >=0 )
         close(lsock);
     }

   public:
     void InitTcpServer()
     {
        lsock = socket(AF_INET,SOCK_STREAM,0);
        if(lsock < 0){
          std::cerr << "socket error!" << std::endl;
          exit(1);
        }

        struct sockaddr_in local;
        memset(&local,0,sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if(bind(lsock,(struct sockaddr*)&local , sizeof(local)) < 0 ){
          std::cerr << "bind error!" << std::endl;
          exit(2);
        }


        //监听,等待链接到来
        if(listen(lsock,BACKLOG) < 0){
          std::cerr << "listen error!" << std::endl;
          exit(3);
        }
     }
      
     void Start()
     {
       //获取链接处理链接
       struct sockaddr_in peer;
       for(;;){
         socklen_t len = sizeof(peer);
         int sock = accept(lsock , (struct sockaddr*)&peer ,&len);
         if(sock < 0){
           std::cout << "accept error ,continue next" <<std::endl;
           continue;
         }

         std::string _ip = inet_ntoa(peer.sin_addr);
         int _port = ntohs(peer.sin_port); 

         std::cout << "get a new sock [" << _ip  <<"]:" << _port << std::endl;

         Service(sock,_ip,_port); //调用函数处理任务
       }
     }

     void Service(int sock,std::string ip,int port)
     {
       char buffer[1024];
       while(true){
          ssize_t size = read(sock,buffer,sizeof(buffer)-1);
          if(size > 0){
             buffer[size] = 0;
             std::cout << ip << ":" << port << "# " << buffer << std::endl;

             write(sock,buffer,size);
          }
          else if(size == 0){
             std::cout << ip << ":" << port << "close!" << std::endl;
             break;
          }
          else{
             std::cerr << sock << " read error " << std::endl;
             break;
          }
       }

       close(sock); //fd也是一种资源是有限的
       std::cout << "service done!" << std::endl;
     }


};

1.TCP服务器在调用socket函数创建套接字时,参数设置如下:

  • 协议家族选择AF_INET,因为我们要进行的是网络通信。
  • 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
  • 协议类型默认设置为0即可。

                                 

2.TCP服务调用band函数

(1)套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用bind函数进行绑定操作。

(2)绑定的步骤如下:

  • 定义一个struct sockaddr_in结构体,将服务器网络相关的属性信息填充到该结构体当中,比如协议家族、IP地址、端口号等。
  • 填充服务器网络相关的属性信息时,协议家族对应就是AF_INET,端口号就是当前TCP服务器程序的端口号。在设置端口号时,需要调用htons函数将端口号由主机序列转为网络序列。
  • 在设置服务器的IP地址时,我们可以设置为本地环回127.0.0.1,表示本地通信。也可以设置为公网IP地址,表示网络通信。
  • 如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序的转换。
  • 填充完服务器网络相关的属性信息后,需要调用bind函数进行绑定。绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可。
     

 (3)定义好struct sockaddr_in结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空;bzero函数也可以对特定的一块内存区域进行清空.

void bzero(void *s, size_t n);

                         

3.TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态.

补充:

  • 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字 , 即listen_sock。
  • 在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。

                         

4.服务端在获取连接时调用accept函数

  • accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。(就像在餐厅门口有一个吆喝拉客的只管把客人拉进店里(listen),接下来的服务是由服务员做的(accept))
  • 如果要将获取到的连接对应客户端的IP地址和端口号信息进行输出,需要调用inet_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
  • inet_ntoa函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP。

                 

5.TCP服务器已经能够获取连接请求了,下面就是要对获取到的连接进行处理。

(1)此时为客户端提供服务的不是监听套接字,因为监听套接字获取到一个连接后会继续获取下一个请求连接,为对应客户端提供服务的套接字实际是accept函数返回的套接字,将其称为“服务套接字”。

(2)read返回值为0表示对端连接关闭

这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:

  • 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
  • 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
  • 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
  • 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。

这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。

(3)当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。

(4)服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。

(5)从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
 

③tcp_server.cc

#include"tcp_server.hpp"



int main(int argc,char* argv[])
{
  if(argc != 2){
    std::cout << "Usage: " << argv[0] << " port" << std::endl;
    return 1;
  }

  int port = atoi(argv[1]);

  TcpServer* tsv = new TcpServer(port);
  tsv->InitTcpServer();
  tsv->Start();

  delete tsv;
  return 0;
}

                         

④tcp_client.hpp

#pragma once

#include <iostream>
#include <cstring>
#include <string>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/types.h>

class TcpClient{
   private:
     std::string svr_ip;
     int svr_port;
     int sock;

   public:
     TcpClient(std::string ip , int port):svr_ip(ip),svr_port(port),sock(-1)
     {}

     TcpClient()
     {
       if(sock >= 0)
         close(sock);
     }

   public:
     void InitTcpClient()
     {
       sock = socket(AF_INET,SOCK_STREAM,0);
       if(sock < 0){
         std::cerr << "socket error" << std::endl;
         exit(2);
       }  

     }

     void Start()
     {
       struct sockaddr_in peer;
       memset(&peer,0,sizeof(peer));

       peer.sin_family = AF_INET;
       peer.sin_port = htons(svr_port);
       peer.sin_addr.s_addr = inet_addr(svr_ip.c_str());

       //发送链接请求
       if(connect(sock,(struct sockaddr*)&peer,sizeof(peer)) == 0){
         //success
         std::cout << "connect success ..." << std::endl;
         Request(sock);
       }
       else{
         //faild
         std::cout << "connect failed ..." <<  std::endl;
       }
     }


     void Request(int sock)
     {
        std::string message;
        char buffer[1024];
        while(true){
          std::cout << "Please Enter# ";
          std::cin >> message;

          write(sock,message.c_str(),message.size());

          ssize_t size = read(sock,buffer,sizeof(buffer)-1);
          if(size > 0){
            buffer[size] = 0;
          }

          std::cout << "server echo# " << buffer << std::endl;

        }
     }
};

1.client不需要进行绑定和监听:

  • 服务端要进行绑定是因为服务端的IP地址和端口号必须要众所周知,不能随意改变。而客户端虽然也需要IP地址和端口号,但是客户端并不需要我们进行绑定操作,客户端连接服务端时系统会自动指定一个端口号给客户端。
  • 服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。 

2.客户端不是不需要进行绑定,而是不需要我们自己进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。也就是说,如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。connect的时候底层自动进行bind().
 

⑤tcp_client.cc

#include"tcp_client.hpp"


int main(int argc ,char* argv[])
{ 
  if(argc != 3){
    std::cerr << "Usage: " << argv[0] <<"ip  port " << std::endl;
    exit(1);
  }

  std::string ip = argv[1];
  int port = atoi(argv[2]);

  TcpClient* tcl = new TcpClient(ip,port);
  tcl->InitTcpClient();

  tcl->Start();

  delete tcl;

  return 0;
}

                         

(2)结果

①运行server,使用netstat命令查看网络详情.

由于服务器绑定的是INADDR_ANY,因此该服务器的本地IP地址是0.0.0.0,这就意味着该TCP服务器可以读取本地任何一张网卡里面端口为8081的数据。此外,最重要的是当前该服务器所处的状态是LISTEN状态,表明当前服务器可以接收外部的请求连接。

                         

②使用telnet指令连接server , 只是进行链接,并没有调用函数处理任务.

  • 我们可以使用telnet命令远程登录到该服务器,因为telnet底层实际采用的就是TCP协议。
  • 使用telnet命令连接当前TCP服务器后可以看到,此时服务器接收到了一个连接,为该连接提供服务的套接字对应的文件描述符就是4。因为0、1、2是默认打开的,其分别对应标准输入流、标准输出流和标准错误流,而3号文件描述符在初始化服务器时分配给了监听套接字,因此当第一个客户端发起连接请求时,为该客户端提供服务的套接字对应的文件描述符就是4。

使用其他窗口继续连接,sock值会增加 

使用Windows连接,使用浏览器连接. 因为浏览器常见的应用层协议是http或https,其底层对应的也是TCP协议,因此浏览器也可以向当前这个TCP服务器发起请求连接。

 这里为什么浏览器一次会向我们的TCP服务器发起两次请求这个问题,这里就不作讨论了,我们只是要证明当前TCP服务器能够正常接收外部的请求连接。

                

③正常进行链接处理任务 

  • 通过命令行参数 ./client IP PORT ,此时客户端就会向服务端发起连接请求,服务端获取到请求后就会为该客户端提供服务。 
  • 当客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息。
  • 如果此时客户端退出了,那么服务端在调用read函数时得到的返回值就是0,此时服务端也就知道客户端退出了,进而会终止对该客户端的服务。
  • 服务端对该客户端的服务终止了,此时服务器依旧在运行,它在等待下一个客户端的连接请求。

                

(3) server单进程处理任务的弊端

①仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。

 

②我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。         

                 

③当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。

                

(4) 测试现象解释

①单执行流server

  • 通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。
  • 当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。

②client为什么会显示连接成功?

  • 当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。
  • 实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。
  • 服务器是单进程的,所有的获取套接字以及处理数据再包括后面提供服务lO相关的,都是由一个任务来处理,这样势必会导致倘若有任何一个任务发生了阻塞问题,其他任务都不可能执行,这样的服务器基本上是不可能被使用的。
     

③如何解决

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

2.多进程的TCP网络程序

将当前的单执行流服务器改为多进程版的服务器

  • 当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
  • 由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。

                 

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

  • 文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件。

 

  • 但当父进程创建子进程后,父子进程之间会保持独立性,此时父进程文件描述符表的变化不会影响子进程。最典型的代表就是匿名管道,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了。
  • 对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。
     

等待子进程问题 

①当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。

②阻塞式等待与非阻塞式等待:

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

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

④不等待子进程退出的方式有两种

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

         

(1)通过SIGCHLD回收子进程

只需要改变tcp_server.hpp中的Start函数,对处理任务进行调整

 void Start()
     {
       signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号

       struct sockaddr_in peer;
       for(;;){
         socklen_t len = sizeof(peer);
         int sock = accept(lsock , (struct sockaddr*)&peer ,&len);
         if(sock < 0){
           std::cout << "accept error ,continue next" <<std::endl;
           continue;
         }

         std::string _ip = inet_ntoa(peer.sin_addr);
	     int _port = ntohs(peer.sin_port);
		 std::cout << "get a new link->" << sock << " [" << _ip << "]:" << _port << std::endl;
			
			pid_t id = fork();
			if (id == 0){ //child
				//处理请求
                close(lsock);
				Service(sock, _ip, _port);
				exit(0); //子进程提供完服务退出
			}
         
            close(sock);
        }
     }

当父进程打开多个文件描述特时,fork之后子进程会继承父进程的文件描述符表以及打开的文件信息。创建子进程时是要创建PCB,地址空间以及文件描述符表,基本上所有数据结构都要以父进程为模板,所以父子进程可能指向同—个文件。

  • ①站在子进程的角度,子进程最关心sock,不关心lsock, 关闭close(lsock).   大家虽然指向的是同一个文件,文件描述符值虽然一样,但是大家用的是不同的表。当然可以不关闭,但是作为服务器或代码的编写者不相信任何的使用者,万一不小心把lsock用了。
  • ②父进程关心lsock,不关心sock、必须关闭close(sock)。父进程的主要职责是不断只获取链接,当accept获取链接的时候一定会带来问题,父进程获取上来的套接字被子进程继承下去之后sock对父进程没用了,文件描述符还占着的话,来的链接越多只会导致父进程可用的文件描述符少。

                

 测试结果

  •  两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。
  • 因为我们分派任务给子进程之后就close(sock) , 所以当任务来的慢的时候每次申请链接都是分配的4号fd , 3号fd是被lsock占用. 

                         

(2)孙子进程提供服务

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

①代码

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

void Start()
	{
		for (;;){
			//获取连接
			struct sockaddr_in peer;
			memset(&peer, '\0', 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 _ip = inet_ntoa(peer.sin_addr);
			int _port = ntohs(peer.sin_port);
			std::cout << "get a new link->" << sock << " [" << _ip << "]:" << _port << std::endl;
			
			pid_t id = fork();
			if (id == 0){ //father
				close(lsock); 
				if (fork() > 0){
					exit(0); //father进程直接退出
				}

				//son处理请求
				Service(sock, _ip, _port); //son进程提供服务
				exit(0); //son进程提供完服务退出
			}

			close(sock); //grandfather关闭为连接提供服务的套接字
			waitpid(id, nullptr, 0); //等待father进程(会立刻等待成功)
		}

                

②不需要等待son进程退出

而由于father进程创建完son进程后就立刻退出了,因此实际为客户端提供服务的son进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收son进程,所以服务进程(grandfather进程)是不需要等待son进程退出的。

                 

③关闭对应的文件描述符

  • 服务进程(grandfather进程)调用accept函数获取到新连接后,会让son进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了father进程,而father进程又会调用fork函数创建出son进程,然后再将文件描述符表继承给son进程。
  • 而父子进程创建后,它们各自的文件描述符表是独立的,不会相互影响。因此服务进程在调用fork函数后,服务进程就不需要再关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭。
  • 同样的,对于father进程和son进程来说,它们是不需要关心从服务进程(grandfather进程)继承下来的监听套接字的,因此father进程可以将监听套接字关掉。

                

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

  • 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
  • 而对于father进程和son进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为son进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。
     

⑤son进程处理任务这种方式不太推荐,因为创建进程是要消耗很大资源的,况且你两次fork().创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现。

        

⑥两个客户端是由两个不同的孤儿进程提供服务的,因此它们也是能够同时享受到服务的,可以看到这两个客户端发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。

                 

⑦当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程。
 

        

3.多线程的TCP网络程序

  • 当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。
  • 主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。
  • 文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。
  • 当服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。
  • 虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作

         

(1)只修改 tcp_server.hpp的代码

#pragma once 

//多线程

#include<iostream>
#include<sys/socket.h>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/fcntl.h>
#include<sys/wait.h>
#include<pthread.h>

#define DEFALUT 8080
#define BACKLOG 5

class Prama{ //存储参数
  public:
    std::string ip;
    int port;
    int sock;

  public:
    Prama(std::string _ip,int _port,int _sock)
      :ip(_ip),port(_port),sock(_sock)
    {}

    ~Prama()
    {}
};

class TcpServer{
   private:
     int port;
     int lsock; //listen socket

   public:
     TcpServer(int _port = DEFALUT):port(_port),lsock(-1)
     {}
     ~TcpServer()
     {
       if(lsock >=0 )
         close(lsock);
     }

   public:
     void InitTcpServer()
     {
        lsock = socket(AF_INET,SOCK_STREAM,0);
        if(lsock < 0){
          std::cerr << "socket error!" << std::endl;
          exit(1);
        }

        struct sockaddr_in local;
        memset(&local,0,sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        local.sin_addr.s_addr = INADDR_ANY;

        if(bind(lsock,(struct sockaddr*)&local , sizeof(local)) < 0 ){
          std::cerr << "bind error!" << std::endl;
          exit(2);
        }


        if(listen(lsock,BACKLOG) < 0){
          std::cerr << "listen error!" << std::endl;
          exit(3);
        }
     }
      
    static void*  routine(void* arg)
    {
      Prama* p = (Prama*)arg;
      pthread_detach(pthread_self());

      Service(p->sock,p->ip,p->port);
      close(p->port);
      delete p;
   
      return nullptr;
    }

     void Start()
     {
       struct sockaddr_in peer;
       for(;;){
         socklen_t len = sizeof(peer);
         int sock = accept(lsock , (struct sockaddr*)&peer ,&len);
         if(sock < 0){
           std::cout << "accept error ,continue next" <<std::endl;
           continue;
         }

         pthread_t tid;
         std::string _ip = inet_ntoa(peer.sin_addr);
         int _port = ntohs(peer.sin_port);

         Prama* p = new Prama(_ip,_port,sock);
         pthread_create(&tid,nullptr,routine,(void*)p);
       }
     }

    static  void Service(int sock,std::string ip,int port)
     {
       char buffer[1024];
       while(true){
          ssize_t size = read(sock,buffer,sizeof(buffer)-1);
          if(size > 0){
             buffer[size] = 0;
             std::cout << ip << ":" << port << "# " << buffer << std::endl;

             write(sock,buffer,size);
          }
          else if(size == 0){
             std::cout << ip << ":" << port << " close!" << std::endl;
             break;
          }
          else{
             std::cerr << sock << " read error " << std::endl;
             break;
          }
       }

       close(sock); //fd也是一种资源是有限的
       std::cout << "service done!" << std::endl;
     }


};

①参数结构体

  • 实际新线程在为客户端提供服务时就是调用Service函数,而调用Service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数。
  • 这时我们可以设计一个参数结构体Param,此时这三个参数就可以放到Param结构体当中,当主线程创建新线程时就可以定义一个Param对象,将客户端对应的套接字、IP地址和端口号设计进这个Param对象当中,然后将Param对象的地址作为新线程执行例程的参数进行传入。
  • 此时新线程在执行例程当中再将这个void*类型的参数强转为Param*类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用Service函数为对应客户端提供服务。

②文件描述符关闭的问题

  • 由于所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
  • 对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。
  • 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了。

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

  • 调用pthread_create函数创建线程时,新线程的执行例程是一个参数为void*,返回值为void*的函数。如果我们要将这个执行例程定义到类内,就需要将其定义为静态成员函数,否则这个执行例程的第一个参数是隐藏的this指针。
  • 在线程的执行例程当中会调用Service函数,由于执行例程是静态成员函数,静态成员函数无法调用非静态成员函数,因此我们需要将Service函数定义为静态成员函数。恰好Service函数内部进行的操作都是与类无关的,因此我们直接在Service函数前面加上一个static即可。

                 

(2)结果

  • 由于代码当中用到了多线程,因此编译时需要携带上 -l pthread 选项。此外,由于我们现在要监测的是一个个的线程,因此在监控时使用的不再是ps -axj 命令,而是 ps -aL 命令。
  • 当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用Service函数为该客户端提供服务

 

 

4.线程池的TCP网络程序

单纯多线程存在的问题

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

解决方法 : 引入线程池

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

                 

(1)代码

①threadpool.hpp  线程池

#pragma once 

#include<iostream>
#include<queue>
#include<pthread.h>

#define NUM 5

template<class T>
class ThreadPool{
  private:
    int thread_num;
    std::queue<T> task_q;

    pthread_mutex_t lock;
    pthread_cond_t cond;

  public:
    ThreadPool(int num = NUM):thread_num(num)
    { 
      pthread_mutex_init(&lock,nullptr);
      pthread_cond_init(&cond,nullptr);
    }

    ~ThreadPool()
    {
      pthread_mutex_destroy(&lock);
      pthread_cond_destroy(&cond);
    }

  public:
    void InitTheadPool()
    {
      pthread_t tid;
      for(int i = 0 ; i < thread_num ;++i){
        pthread_create(&tid , nullptr , Routine ,this); // !!
      }
    }

   static void* Routine(void* arg)
   {
     pthread_detach(pthread_self());
     ThreadPool* self = (ThreadPool*)arg;
     while(true){
       self->Lock();
       while(self->IsEmpty()){
         self->Wait();
       }

       //走到这里一定有任务
       T task;
       self->Pop(task); 
       self->Unlock();

       //有些任务处理时间可能较长,解锁之后再处理,否则就成串行了
       task.Run();
       
     }
   }


  public:
    void Push(const T& in)
    {
       Lock();
       task_q.push(in);
       Unlock();

       Wakeup();
    }

    void Pop(T& out)
    {
       out = task_q.front();
       task_q.pop();
    }

    void Lock()
    {
      pthread_mutex_lock(&lock);
    }

    void Unlock()
    {
      pthread_mutex_unlock(&lock);
    }


    void Wait()
    {
      pthread_cond_wait(&cond,&lock);
    }

    void Wakeup()
    {
      pthread_cond_signal(&cond); 
    }
     
    bool IsEmpty()
    {
      return task_q.size() == 0 ? true:false ;
    }

};

②Task.hpp

#pragma once

#include <iostream>
#include <string>
#include <unistd.h>
#include <unordered_map>

class Handler{
    public:
        Handler(){}
        void operator()(int sock, std::string ip, int port)
        {
            //for test
            std::unordered_map<std::string, std::string> dict;
            dict.insert({"apple", "苹果"});
            dict.insert({"banana", "香蕉"});
            dict.insert({"boy", "男孩"});
            dict.insert({"MIUI", "小米"});

            char buffer[1024];
            std::string value;
            while(true){
                ssize_t size = read(sock, buffer, sizeof(buffer)-1);
                if(size > 0){
                    buffer[size] = 0;
                    std::cout << ip << ":" << port << "# " << buffer << std::endl;

                    std::string key = buffer;
                    auto iter = dict.find(key);
                    if(iter != dict.end()){
                        value = iter->second;
                    }
                    else{
                        value = buffer;
                    }

                    write(sock, value.c_str(), value.size());
                }
                else if(size == 0){
                    std::cout << ip << ":" << port << " close!" << std::endl;
                    break;
                }
                else{
                    std::cerr << sock << " read error" << std::endl;
                    break;
                }
            }
            std::cout << "service done" << std::endl;
            close(sock);
        }
        ~Handler(){}
};

class Task{
    private:
        int sock;
        std::string ip;
        int port;
        Handler handler;
    public:
        Task(){}
        Task(int _sock, std::string _ip, int _port)
            :sock(_sock), ip(_ip), port(_port)
        {}
        void Run()
        {
            handler(sock, ip, port);
        }

        ~Task()
        {}
};

 

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

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

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

  • 在Handler类当中对()操作符进行重载,将()操作符的执行动作重载为执行Service函数的代码
  • 我们可以让服务器处理不同的任务,当前服务器只是在进行字符串的回显处理,而实际要怎么处理这个任务完全是由任务类当中的handler成员来决定的。
  • 如果想要让服务器处理其他任务,只需要修改Handler类当中对()的重载函数就行了,而服务器的初始化、启动服务器以及线程池的代码都是不需要更改的,这就叫做把通信功能和业务逻辑在软件上做解耦。

③tcp_server.hpp

#pragma once

#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/fcntl.h>
#include <sys/wait.h>
#include <pthread.h>
#include "Task.hpp"
#include "threadPool.hpp"

#define DFL_PORT 8080
#define BACK_LOG 5


class TcpServer{
    private:
        int port;
        int listen_sock;
        ThreadPool<Task> *tp;

    public:
        TcpServer(int _port = DFL_PORT):port(_port), listen_sock(-1),tp(nullptr)
        {}
        ~TcpServer()
        {
            if(listen_sock >= 0) close(listen_sock);
            delete tp;
        }
    
    public:
        
        void InitTcpServer()
        {
            listen_sock = socket(AF_INET, SOCK_STREAM, 0);
            if(listen_sock < 0){
                std::cerr << "socket error" << std::endl;
                exit(2);
            }

            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));
            local.sin_family = AF_INET;
            local.sin_port = htons(port);
            local.sin_addr.s_addr = INADDR_ANY; 

            if(bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0){
                std::cerr << "bind error" << std::endl;
                exit(3);
            }

            if(listen(listen_sock, BACK_LOG) < 0){
                std::cerr << "listen error" << std::endl;
                exit(4);
            }

            tp = new ThreadPool<Task>();
        }


       void Start()
        {
            tp->InitTheadPool();
            struct sockaddr_in peer;
            for( ; ; ){
                socklen_t len = sizeof(peer);
                int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);
                if(sock < 0){
                    std::cout << "accept error, continue next" << std::endl;
                    continue;
                }
                std::string ip = inet_ntoa(peer.sin_addr);
                int port = ntohs(peer.sin_port);
                Task t(sock, ip, port);
                tp->Push(t);

            }
        }

};

服务端引入了线程池,因此在服务类当中需要新增一个指向线程池的指针成员:

  • 当实例化服务器对象时,先将这个线程池指针先初始化为空。
  • 当服务器初始化完毕后,再实际构造这个线程池对象,在构造线程池对象时可以指定线程池当中线程的个数,也可以不指定,此时默认线程的个数为5。
  • 在启动服务器之前对线程池进行初始化,此时就会将线程池当中的若干线程创建出来,而这些线程创建出来后就会不断检测任务队列,从任务队列当中拿出任务进行处理。
  • 现在当服务进程调用accept函数获取到一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号构建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列。
  • 这实际也是一个生产者消费者模型,其中服务进程就作为了任务的生产者,而后端线程池当中的若干线程就不断从任务队列当中获取任务进行处理,它们承担的就是消费者的角色,其中生产者和消费者的交易场所就是线程池当中的任务队列。
     

(2)结果 

  •  运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了6个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池当中为客户端提供服务的线程。
  •  此时当客户端连接服务器后,服务端的主线程就会获取该客户端的连接请求,并将其封装为一个任务对象后塞入任务队列,此时线程池中的5个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务。
  • server端的Task中的run函数对数据进行了处理,能够简单进行中英互译

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值