当前位置: 首页 > news >正文

网络编程套接字(二)

目录

  • 一、服务端创建套接字
    • 服务器绑定
    • 服务端监听
    • 服务端获取连接
    • 服务端处理请求
    • 客户端创建套接字
    • 客户端连接服务器
    • 客户端发送数据
    • 服务器测试
  • 二、场景
    • 单执行流服务器的弊端
    • 多进程版的TCP网络程序
      • 孙子进程提供服务
    • 多线程版TCP网络程序
    • 线程池版TCP网络程序
  • 三、流程


一、服务端创建套接字

将TCP服务器封装成一个类,当定义出一个服务器对象后需要马上对服务器进行初始化,而初始化TCP服务器要做的第一件事就是创建套接字。

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 检查返回值 listen_fd != -1

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

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

创建套接字后的文件描述符是小于0的,说明套接字创建失败,终止程序。

const int defaultfd=-1;
const std::string defaultip="0.0.0.0";class tcpServer
{
public:tcpServer(const uint16_t &port,const std::string &ip=defaultip):_listensockfd(defaultfd),_ip(ip),_port(port){}void Init(){_listensockfd=socket(AF_INET,SOCK_STREAM,0);if(_listensockfd<0){printf("create socket errno:%d ,errstring: %s\n",errno,strerror(errno));exit(0);}printf("create socket success,sockfd: %d\n",_listensockfd);}~tcpServer(){if (_listensockfd >= 0){close(_listensockfd);}}
private:int _listensockfd;uint16_t _port;std::string _ip;
};

TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。当析构服务器时,可以将服务器对应的文件描述符进行关闭,一般不用析构因为服务器是一直运行的。

服务器绑定

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

用struct sockaddr_in结构体填充协议家族、IP地址、端口号信息。

由于TCP服务器初始化时需要服务器的端口号,因此在服务器类当中需要引入端口号,当实例化服务器对象时就需要给传入一个端口号。而由于我当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不需要绑定公网IP地址,直接绑定INADDR_ANY即可,因此我这里没有在服务器类当中引入IP地址。

const int defaultfd=-1;
const std::string defaultip="0.0.0.0";class tcpServer
{
public:tcpServer(const uint16_t &port,const std::string &ip=defaultip):_listensockfd(defaultfd),_ip(ip),_port(port){}void Init(){_listensockfd=socket(AF_INET,SOCK_STREAM,0);if(_listensockfd<0){printf("create socket errno:%d ,errstring: %s\n",errno,strerror(errno));exit(0);}printf("create socket success,sockfd: %d\n",_listensockfd);//填充信息,该结构体(ip+port),struct in_addr纯ipstruct sockaddr_in local;memset(&local,0,sizeof(local));local.sin_family=AF_INET;local.sin_port=htons(_port);inet_aton(_ip.c_str(),&(local.sin_addr));if(bind(_listensockfd,(struct sockaddr*)&local,sizeof(local))<0){printf("bind error,errno: %d,errstring :%s\n",errno,strerror(errno));exit(1);}printf("bind success sockfd: %d\n",_listensockfd);}~tcpServer(){}
privateint _listensockfd;uint16_t _port;std::string _ip;
};

TCP服务器绑定时的步骤与UDP服务器是完全一样的。

服务端监听

UDP服务器的初始化操作只有两步,第一步创建套接字,第二步绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。

因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。

设置套接字为监听状态的函数叫做listen,该函数的函数原型:

int listen(int sockfd, int backlog);

参数说明:

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

返回值说明:

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

服务器监听

TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来,监听失败则终止程序。

const int defaultfd=-1;
const std::string defaultip="0.0.0.0";
const int backlog=10;class tcpServer
{
public:tcpServer(const uint16_t &port,const std::string &ip=defaultip):_listensockfd(defaultfd),_ip(ip),_port(port){}void Init(){_listensockfd=socket(AF_INET,SOCK_STREAM,0);if(_listensockfd<0){printf("create socket errno:%d ,errstring: %s\n",errno,strerror(errno));exit(0);}printf("create socket success,sockfd: %d\n",_listensockfd);//填充信息,该结构体(ip+port),struct in_addr纯ipstruct sockaddr_in local;memset(&local,0,sizeof(local));local.sin_family=AF_INET;local.sin_port=htons(_port);inet_aton(_ip.c_str(),&(local.sin_addr));if(bind(_listensockfd,(struct sockaddr*)&local,sizeof(local))<0){printf("bind error,errno: %d,errstring :%s\n",errno,strerror(errno));exit(1);}printf("bind success sockfd: %d\n",_listensockfd);if(listen(_listensockfd,backlog)<0){printf("listen error,errno: %d,errstring :%s\n",errno,strerror(errno));exit(2);}printf("listen success sockfd: %d\n",_listensockfd);}~tcpServer(){}private:int _listensockfd;uint16_t _port;std::string _ip;
};

初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。

服务端获取连接

TCP服务器初始化后就可以开始运行了,但TCP服务器在与客户端进行网络通信之前,服务器需要先获取到客户端的连接请求。

获取连接的函数叫做accept,该函数的函数原型:

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

参数说明:

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

返回值说明:

  • 获取连接成功返回接收到的套接字的文件描述符,获取连接失败返回-1,同时错误码会被设置。

accept函数返回的套接字

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

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

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

服务端获取连接

服务端在获取连接时需要注意:

  • accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接。
  • 如果要将获取到的连接对应客户端的IP地址和端口号信息进行输出,需要调用inet_ntop函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
  • inet_ntop函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP。
class tcpServer
{
public:void Start(){std::cout<<"tcpServer is running"<<std::endl;for(;;){//获取新连接struct sockaddr_in client;socklen_t len=sizeof(client);int sockfd=accept(_listensockfd,(struct sockaddr*)&client,&len);if(sockfd<0){printf("accept error errno: %d,errstring: %s\n",errno,strerror(errno));continue;}char clientip[32];uint16_t clientport=ntohs(client.sin_port);inet_ntop(AF_INET,&(client.sin_addr),clientip,sizeof(clientip));printf("get a new link...,sockfd: %d,client ip: %s,client port: %d\n",sockfd,clientip,clientport);}}
private:int _listen_sock; //监听套接字int _port; //端口号
};

服务端接收连接测试

测试当前服务器能否成功接收请求连接。在运行服务端时需要传入一个端口号作为服务端的端口号,然后我们用该端口号构造一个服务端对象,对服务端进行初始化后启动服务端。

void Usage(std::string proc)
{std::cout<<"\n\rUsage: "<<proc<<" port[1024+]\n"<<std::endl;
}int main(int argc,char *argv[])
{if(argc!=2){Usage(argv[0]);exit(10);}uint16_t port=std::stoi(argv[1]);std::unique_ptr<tcpServer> svr(new tcpServer(port));svr->Init();svr->Start();return 0;   
}

编译代码,运行服务端。
在这里插入图片描述
服务端运行后,通过netstat命令可以查看到一个程序名为testtcp的服务程序,它绑定的端口8080,而由于服务器绑定的是INADDR_ANY,因此该服务器的本地IP地址是0.0.0.0,这就意味着该TCP服务器可以读取本地任何一张网卡里面的数据。此外,最重要的是当前该服务器所处的状态是LISTEN状态,表明当前服务器可以接收外部的请求连接。

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

在这里插入图片描述

服务端处理请求

现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。但此时为客户端提供服务的不是监听套接字,因为监听套接字获取到一个连接后会继续获取下一个请求连接,为对应客户端提供服务的套接字实际是accept函数返回的套接字,下面就将其称为“服务套接字”。

为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。

TCP服务器读取数据的函数叫做read,该函数的原型

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

参数说明:

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

返回值说明:

  • 如果返回值大于0,则表示本次实际读取到的字节个数。

  • 如果返回值小于0,则表示读取时遇到了错误。

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

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

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

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

write函数

TCP服务器写入数据的函数叫做write,该函数的原型

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

参数说明:

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

返回值说明:

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

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

服务端处理请求

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

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

客户端创建套接字

客户端创建套接字和服务器的操作是一样的。

客户端不需要进行绑定和监听:

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

此外,客户端必须要知道它要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信。

//若没有正确输入命令行参数则提示用户输入参数
void Usage(const std::string& proc)
{std::cout<<"\n\rUsage: "<<proc<<" server ip,server port\n"<<std::endl;    
}int main(int argc,char* argv[])
{if(argc!=3)                 //需传入三个命令行参数{Usage(argv[0]);exit(1);}//从命令行参数获取ip和port信息,都是字符串信息std::string serverip=argv[1];uint16_t serverport=std::stoi(argv[2]);    int sockfd=socket(AF_INET,SOCK_STREAM,0);if(sockfd<0){std::cerr<<"socket error"<<std::endl;return 1;}close(sockfd);  return 0;
} 

客户端连接服务器

客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。

connect函数

发起连接请求的函数叫做connect,该函数的函数原型:

//服务端的填充信息
int connect(int sockfd, struct sockaddr *addr, socklen_t addrlen);

参数说明:

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

返回值说明:

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

客户端连接服务器

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

此外,调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求。

//若没有正确输入命令行参数则提示用户输入参数
void Usage(const std::string& proc)
{std::cout<<"\n\rUsage: "<<proc<<" server ip,server port\n"<<std::endl;    
}int main(int argc,char* argv[])
{if(argc!=3)                 //需传入三个命令行参数{Usage(argv[0]);exit(1);}//获取ip和端口std::string serverip=argv[1];uint16_t serverport=std::stoi(argv[2]);    int sockfd=socket(AF_INET,SOCK_STREAM,0);if(sockfd<0){std::cerr<<"socket error"<<std::endl;return 1;}//连接目标服务端,填充服务端信息struct sockaddr_in server;memset(&server,0,sizeof(server));server.sin_family=AF_INET;server.sin_port=htons(serverport);inet_pton(AF_INET,serverip.c_str(),&(server.sin_addr));         //主机序列转网络序列//客户端发起connect,进行自动随机绑定int n=connect(sockfd,(struct sockaddr*)&server,sizeof(server));if(n<0){std::cerr<<"connect error"<<std::endl;return 2;}close(sockfd);  return 0;
} 

客户端发送数据

由于我们实现的是一个简单的回声服务器,因此当客户端连接到服务端后,客户端就可以向服务端发送数据了,客户端将用户输入的数据用write函数像套接字发送给服务端。

当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误。

//若没有正确输入命令行参数则提示用户输入参数
void Usage(const std::string& proc)
{std::cout<<"\n\rUsage: "<<proc<<" server ip,server port\n"<<std::endl;    
}int main(int argc,char* argv[])
{if(argc!=3)                 //需传入三个命令行参数{Usage(argv[0]);exit(1);}//获取ip和端口std::string serverip=argv[1];uint16_t serverport=std::stoi(argv[2]);    int sockfd=socket(AF_INET,SOCK_STREAM,0);if(sockfd<0){std::cerr<<"socket error"<<std::endl;return 1;}//连接目标服务端,填充服务端信息struct sockaddr_in server;memset(&server,0,sizeof(server));server.sin_family=AF_INET;server.sin_port=htons(serverport);inet_pton(AF_INET,serverip.c_str(),&(server.sin_addr));         //主机序列转网络序列//客户端发起connect,进行自动随机绑定int n=connect(sockfd,(struct sockaddr*)&server,sizeof(server));if(n<0){std::cerr<<"connect error"<<std::endl;return 2;}//待发信息std::string message;while(1){std::cout<<"please Enter# "<<std::endl;std::getline(std::cin,message);write(sockfd,message.c_str(),message.size());       //写(发)给服务端char buf[4096];int n=read(sockfd,buf,sizeof(buf));                 //读(收)服务端返回的信息if(n>0){buf[n]=0;std::cout<<buf<<std::endl;                      //打印服务端返回的信息}}close(sockfd);  return 0;
} 

服务器测试

测试时先启动服务端,通过netstat命令进行查看,看到一个名为testtcp的服务进程,该进程当前处于监听状态。
在这里插入图片描述

通过./tcp_client IP地址 端口号的形式运行客户端,此时客户端就会向服务端发起连接请求,服务端获取到请求后就会为该客户端提供服务。

在这里插入图片描述

客户端向服务端发送消息后,服务端可以通过打印的IP地址和端口号识别出对应的客户端,而客户端也可以通过服务端响应回来的消息来判断服务端是否收到了自己发送的消息。

在这里插入图片描述

当客户端退出了,那么服务端在调用read函数时得到的返回值就是0,此时服务端也就知道客户端退出了,进而会终止对该客户端的服务。

在这里插入图片描述

二、场景

单执行流服务器的弊端

单进程版就是直接调用服务功能的函数,当仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。

在这里插入图片描述

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

在这里插入图片描述
通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。

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

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

当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来。
实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。

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

多进程版的TCP网络程序

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

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

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

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

需要注意创建子进程时,文件描述符也是会被子进程继承下去的。
在这里插入图片描述
但当父进程创建子进程后,父子进程之间会保持独立性,此时父进程文件描述符表的变化不会影响子进程。最典型的代表就是匿名管道,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了。

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

等待子进程问题

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

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

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

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

不等待子进程退出的方式

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

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

演示一下第二钟方式。

孙子进程提供服务

所有的几个版本只改动Start函数获取到新连接部分

三个进程:

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

第一次fork():

  • 父进程创建子进程(pid==0的分支)
  • 父进程继续执行,等待这个子进程结束(waitpid)

子进程中:

  • 关闭监听套接字(_listensockfd),因为子进程不需要它

进行第二次fork():

  • 如果fork()返回值>0(即父进程,也就是第一个子进程),直接exit(0)
  • 如果fork()返回值==0(即孙子进程),继续执行Service()

孙子进程中:

  • 执行实际的Service()处理
  • 完成后关闭客户端套接字(sockfd)并退出,由操作系统回收

原始父进程中:

  • 关闭客户端套接字(sockfd)
  • 等待第一个子进程结束
void Start()
{std::cout << "tcpServer is running" << std::endl;for (;;){// 获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){printf("accept error errno: %d,errstring: %s\n", errno, strerror(errno));continue;}char clientip[32];uint16_t clientport = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));// 根据新连接进行通信服务printf("get a new link...,sockfd: %d,client ip: %s,client port: %d\n", sockfd, clientip, clientport);// 多进程版pid_t pid=fork();if(pid==0)                          //子进程只管创建孙子进程{close(_listensockfd);if(fork()>0)  exit(0);Service(sockfd,clientip,clientport);        //孙子进程负责执行任务,不用等待,最后由系统领养close(sockfd);exit(0);}close(sockfd);pid_t rid=waitpid(pid,nullptr,0);(void)rid;}
}

没有客户端连接服务器,因此也是只监控到了一个服务进程,该服务进程正在等待客户端的请求连接。
在这里插入图片描述
此时运行一个客户端,让该客户端连接当前这个服务器,此时服务进程会创建子进程,子进程再创建出孙子进程,之后子进程就会立刻退出,而由孙子进程为客户端提供服务。因此这时我们只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程(父进程),还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程。

在这里插入图片描述
当我们运行第二个客户端连接服务器时,此时就又会创建出一个孤儿进程为该客户端提供服务。
在这里插入图片描述
此时这两个客户端是由两个不同的孤儿进程提供服务的,因此它们也是能够同时享受到服务的,这两个客户端发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。

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

多线程版TCP网络程序

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

从每一个进程为每一个客户端提供服务变成在一个进程内每个线程为每个客户端服务。
多线程不需要关闭文件描述符,定义一个存放线程信息的结构体,封装线程所需的全部数据,作为参数传递给线程函数。

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

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

文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。

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

需要注意的是,虽然新线程能够直接访问主线程accept上的文件描述符,但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作。

参数结构体

实际新线程在为客户端提供服务时就是调用Service函数,而调用Service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数。

这时可以设计一个参数结构体threadData,这三个参数放到该结构体中,当主线程创建新线程时就可以定义一个threadData对象,将客户端对应的套接字、IP地址和端口号信息设计进该对象当中,然后将PthreadData对象的地址作为新线程执行例程的参数进行传入。

此时新线程在执行例程当中再将这个void类型的参数强转为threadData类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用Service函数为对应客户端提供服务。

//设置线程信息
class threadData
{
public:threadData(int sockfd,const std:: string& ip,const uint16_t& port,tcpServer* t):sockfd(sockfd),clientip(ip),clientport(port),tsvr(t){}
public:int sockfd;std::string clientip;uint16_t clientport;tcpServer *tsvr;
};// 线程函数
static void *Routine(void *args) {pthread_detach(pthread_self());  // 将线程设置为分离状态threadData *td = static_cast<threadData *>(args);  // 转换参数类型td->tsvr->Service(td->sockfd, td->clientip, td->clientport);  // 执行业务逻辑delete td;  // 释放线程数据return nullptr;
}void Start()
{std::cout << "tcpServer is running" << std::endl;for (;;){// 获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){printf("accept error errno: %d,errstring: %s\n", errno, strerror(errno));continue;}char clientip[32];uint16_t clientport = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));// 根据新连接进行通信服务printf("get a new link...,sockfd: %d,client ip: %s,client port: %d\n", sockfd, clientip, clientport);// 多线程版,主线程获取新链接,新线程执行任务threadData *td = new threadData(sockfd, clientip, clientport, this);pthread_t tid;pthread_create(&tid, nullptr, Routine, td);}
}

再重新编译服务端代码,由于代码当中用到了多线程,因此编译时需要携带上-pthread选项。此外,由于要监测的是一个个的线程,因此在监控时使用的不再是ps -axj命令,而是ps -aL命令。

while :; do ps -aL|head -1 && ps -aL|grep testtcp;echo "-------------------";sleep 1;done

运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来。
在这里插入图片描述
当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用Service函数为该客户端提供服务,因此在监控当中显示了两个线程。
在这里插入图片描述
当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时服务端当中就有了三个线程。
在这里插入图片描述
由于为这两个客户端提供服务的也是两个不同的执行流,因此这两个客户端可以同时享受服务端提供的服务,它们发送给服务端的消息也都能够在服务端进行打印,并且这两个客户端也都能够收到服务端的回显数据。
在这里插入图片描述
此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来。
在这里插入图片描述

线程函数的关键点

  • 必须是static成员函数,因为普通成员函数隐含this指针
  • 使用pthread_detach使线程成为分离状态,线程结束后自动回收资源
  • 通过threadData传递所有必要参数
  • 调用Service方法处理客户端请求

多线程函数工作流程:

  • 主线程在accept获取新连接后
  • 创建threadData对象保存连接信息
  • 调用pthread_create创建新线程
  • 新线程执行Routine函数处理该连接

其它特点:

  • 每个线程处理独立的客户端连接
  • 线程间不共享数据(每个连接有独立的sockfd)
  • Service方法只操作自己的连接数据
  • 文件描述符管理:主线程不关闭工作线程的sockfd,工作线程自行管理
  • 资源释放:线程结束时自动删除ThreadData对象

多线程模型特点

这是一个典型的每连接每线程(thread-per-connection)模型:

  • 主线程:负责接受新连接

  • 工作线程:每个线程处理一个完整连接的生命周期

  • 优点:编程模型简单,逻辑清晰

  • 缺点:连接数多时线程数量爆炸

线程池版TCP网络程序

线程池的5大核心优势

降低资源消耗

  • 复用线程避免频繁创建/销毁开销
  • 减少内存占用

提高响应速度

  • 任务到达时直接由空闲线程处理
  • 避免线程初始化延迟(尤其适合短连接场景)

避免连接风暴

  • 通过任务队列缓冲突发请求
  • 可设置最大线程数防止资源耗尽

提升CPU利用率

  • 智能调度策略(如工作窃取)减少线程空闲
  • 适配多核CPU的并行处理能力

简化并发管理

  • 统一线程生命周期管理
  • 内置优雅退出机制(避免服务终止时连接丢失)

线程池的代码

#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <string>
#include <unistd.h>
#include <pthread.h>struct threadInfo
{pthread_t tid;std::string name;
};static const int defaultnum = 5; // 线程池个数template <class T>
class ThreadPool
{
public:void lock(){pthread_mutex_lock(&_mutex);}void unlock(){pthread_mutex_unlock(&_mutex);}void Wakeup(){pthread_cond_signal(&_cond);}void ThreadSleep(){pthread_cond_wait(&_cond, &_mutex);}bool QueueEmpry(){return _tasks.empty();}std::string GetThreadName(pthread_t tid){for (const auto &t : _threads){if (t.tid == tid)return t.name;}return "None";}public:// 类内函数默认有this指针,参数个数不一致,添加static或放到类外// 静态成员函数没有 this 指针,不能直接访问类的非静态成员,用创建线程函数主动传入this指针static void *HanderTask(void *args){ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);std::string name = tp->GetThreadName(pthread_self());while (1){tp->lock();while (tp->QueueEmpry()) // 若没有任务则休眠{tp->ThreadSleep();}T t = tp->pop(); // 有任务则消费一个任务tp->unlock();t(); // 锁外处理任务}}void Start(){int num = _threads.size();for (int i = 0; i < num; i++){_threads[i].name = "thread-" + std::to_string(i + 1);pthread_create(&(_threads[i].tid), nullptr, HanderTask, this); // this传给静态函数}}T pop(){T t = _tasks.front();_tasks.pop();return t;}void push(const T &t){lock();_tasks.push(t); // 添加任务到队列Wakeup();       // 唤醒一个线程执行unlock();}// 获取单例的接口static ThreadPool<T> *GetInstance() // 只能通过该单例执行私有化成员函数{if (nullptr == _tp)             //当单例已创建后,直接返回 _tp,完全跳过锁操作{pthread_mutex_lock(&_lock); // 预防多线程并发访问if (nullptr == _tp)         // 若还没有创建对象则指向语句{std::cout << "log: singleton create done first" << std::endl;_tp = new ThreadPool<T>();}pthread_mutex_unlock(&_lock);}return _tp;}// 单例:可能形成第二个对象的都私有化成员函数
private:ThreadPool(int num = defaultnum): _threads(num) // 开辟空间{pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_cond, nullptr);}ThreadPool(const ThreadPool<T> &) = delete;                     // 拷贝构造const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // 赋值语句~ThreadPool(){pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_cond);}private:std::vector<threadInfo> _threads; // 一批线程std::queue<T> _tasks;             // 一批任务pthread_mutex_t _mutex;pthread_cond_t _cond;static ThreadPool<T> *_tp; // 指向线程池的单例static pthread_mutex_t _lock;
};template <class T>
ThreadPool<T> *ThreadPool<T>::_tp = nullptr; // 静态成员类外初始化
template <class T>
pthread_mutex_t ThreadPool<T>::_lock = PTHREAD_MUTEX_INITIALIZER;

设计任务类

设计一个包含套接字、IP和port的类,用套接字来对应为哪一个客户端提供服务。
任务类中用Run方法来提供给客户端的服务,实际处理这个任务的方法就是服务类当中的Service函数。

#pragma once
#include <iostream>
#include <string>class Task
{
public:Task(int sockfd,const std::string& ip,const uint16_t& port): _sockfd(sockfd), _clientip(ip), _clientport(port){}void Run(){char buf[4096];while (1){ssize_t n = read(_sockfd, buf, sizeof(buf));if (n > 0){buf[n] = 0;std::cout << "client say# " << buf << std::endl;std::string echo_string = "tcpserver say#";echo_string += buf;write(_sockfd, echo_string.c_str(), echo_string.size());}else if (n == 0) // 客户端退出{printf("%s:%d quit,server close sockfd: %d\n", _clientip.c_str(), _clientport, _sockfd);break;}else{printf("read error,sockfd: %d,client ip: %s,client port: %d", _sockfd, _clientip.c_str(), _clientport);break;}}}void operator()()       //仿函数{Run();}std::string GetTask(){}~Task(){} 
private:int _sockfd;std::string _clientip;uint16_t _clientport;
};

服务类新增线程池成员

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

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

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

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

#include <iostream>
#include <string>
#include <unistd.h>
#include <sys/wait.h>
#include <strings.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "ThreadPool.hpp"
#include "Task.hpp"const int defaultfd = -1;
const std::string defaultip = "0.0.0.0";class tcpServer;class threadData
{
public:threadData(int sockfd, const std::string &ip, const uint16_t &port, tcpServer *t): sockfd(sockfd), clientip(ip), clientport(port), tsvr(t){}public:int sockfd;std::string clientip;uint16_t clientport;tcpServer *tsvr;
};class tcpServer
{
public:tcpServer(const uint16_t &port, const std::string &ip = defaultip): _listensockfd(defaultfd), _ip(ip), _port(port){}void Init(){_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){printf("create socket errno:%d ,errstring: %s\n", errno, strerror(errno));exit(0);}printf("create socket success,sockfd: %d\n", _listensockfd);// 填充信息,该结构体(ip+port),struct in_addr纯ipstruct sockaddr_in local;memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);inet_aton(_ip.c_str(), &(local.sin_addr));if (bind(_listensockfd, (struct sockaddr *)&local, sizeof(local)) < 0){printf("bind error,errno: %d,errstring :%s\n", errno, strerror(errno));exit(1);}printf("bind success sockfd: %d\n", _listensockfd);if (listen(_listensockfd, 5) < 0){printf("listen error,errno: %d,errstring :%s\n", errno, strerror(errno));exit(2);}printf("listen success sockfd: %d\n", _listensockfd);}static void *Routine(void *args){pthread_detach(pthread_self()); // 将线程设置为分离状态threadData *td = static_cast<threadData *>(args);            // 转换参数类型td->tsvr->Service(td->sockfd, td->clientip, td->clientport); // 执行业务逻辑delete td; // 释放线程数据return nullptr;}void Start(){ThreadPool<Task>::GetInstance()->Start();               //启动线程池std::cout << "tcpServer is running" << std::endl;for (;;){// 获取新连接struct sockaddr_in client;socklen_t len = sizeof(client);int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){printf("accept error errno: %d,errstring: %s\n", errno, strerror(errno));continue;}char clientip[32];uint16_t clientport = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip));// 根据新连接进行通信服务printf("get a new link...,sockfd: %d,client ip: %s,client port: %d\n", sockfd, clientip, clientport);// 线程池版Task t(sockfd,clientip,clientport);          //构造任务ThreadPool<Task>::GetInstance()->push(t);   }}~tcpServer(){}private:int _listensockfd;uint16_t _port;std::string _ip;
};

运行服务端后,就算没有客户端发来连接请求,此时在服务端就已经有了5个线程,其中有一个是接收新连接的服务线程,而其余的5个是线程池当中为客户端提供服务的线程。

在这里插入图片描述

当客户端连接服务器后,服务端的主线程就会获取该客户端的连接请求,并将其封装为一个任务对象后塞入任务队列,此时线程池中的5个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务。
在这里插入图片描述
第二个客户端发起连接请求时,服务端也会将其封装为一个任务类塞到任务队列,然后线程池当中的线程再从任务队列当中获取到该任务进行处理,此时也是不同的执行流为这两个客户端提供的服务,因此这两个客户端也是能够同时享受服务的。
在这里插入图片描述
与之前不同的是,无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出。

三、流程

在这里插入图片描述

http://www.xdnf.cn/news/520597.html

相关文章:

  • 17 C 语言数据类型转换与数据溢出回绕详解:隐式转换、显式转换、VS Code 警告配置、溢出回绕机制
  • 并发编程(4)
  • 中山市东区信息学竞赛2025 题目解析
  • CMake调试与详细输出选项解析
  • 基于区块链技术的智能汽车诊断与性能分析
  • 运行vscode编辑器源码
  • 课外活动:再次理解页面实例化PO对象的魔法方法__getattr__
  • 【免杀】C2免杀技术(五)动态API
  • C2S-Scale方法解读
  • [Android] 青木扫描全能文档3.0,支持自动扫描功能
  • 机器学习入门之朴素叶贝斯和决策树分类(四)
  • 【VMware】开启「共享文件夹」
  • 计算机系统的工作原理
  • 2.2.5
  • 进程间通信--信号量【Linux操作系统】
  • leetcode解题思路分析(一百六十四)1418 - 1424 题
  • [论文品鉴] DeepSeek V3 最新论文 之 MHA、MQA、GQA、MLA
  • 进程状态并详解S和D状态
  • C++学习:六个月从基础到就业——C++17:结构化绑定
  • 什么是dom?作用是什么
  • 产品周围的几面墙
  • C++高级用法--绑定器和函数对象
  • 垂直智能体:企业AI落地的正确打开方式
  • [人月神话_6] 另外一面 | 一页流程图 | 没有银弹
  • 三:操作系统线程管理之用户级线程与内核级线程
  • 大模型应用开发工程师
  • 从逻辑学视角探析证据学的理论框架与应用体系;《证据学》大纲参考
  • Java学习手册:服务熔断与降级
  • 朴素贝叶斯
  • 做什么, what to do?