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

Linux网络socket套接字(中)

前言

  本篇是socket套接字的第二篇,上一篇中我们讲述了socket编程UDP协议的内容,那么这一篇主要讲的是socket编程TCP协议的内容,TCP协议肯定是比UDP要更加复杂的,大家加油!!

3.Socket编程TCP

1.服务端创建套接字和绑定

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

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

  • 协议家族选择 AF_INET ,因为我们要进行的是网络通信。

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

  • 如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可

image-20250730222419788

实际TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务

关于绑定的内容和UDP是一样的,这里不过多赘述

image-20250730222709319

同样的我们给sockaddr_in结构体中sin_addr的s_addr传INADDR_ANY就行

image-20250730223256292

2.服务器监听

UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信;因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态

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

image-20250731191315238

参数说明:

  • sockfd:需要设置为监听状态的套接字对应的文件描述符。

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

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

TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可

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

 void Init(){// 1.创建套接字_sockfd = socket(AF_INET, SOCK_STREAM, 0);if (_sockfd < 0){LOG(LogLevel::FATAL) << "socket error";exit(SOCKET_ERR);}LOG(LogLevel::INFO) << "socket success: " << _sockfd; // 3
​// 2. bind绑定InetAddr local(_port);int n = bind(_sockfd, local.NetAddrPtr(), local.NetAddrLen());if (n < 0){LOG(LogLevel::FATAL) << "bind error";exit(BIND_ERR);}LOG(LogLevel::INFO) << "bind success" << _sockfd; // 3
​// 3.设置socket状态为listenn = listen(_sockfd, backlog);if (n < 0){LOG(LogLevel::FATAL) << "listen error";exit(LISTEN_ERR);}LOG(LogLevel::INFO) << "listen success" << _sockfd; // 3}

image-20250731200103477

如果我们要查询系统当中的tcp服务可用:netstat -tnlp命令

image-20250731231304283

3.服务器获取连接

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

我们使用accept函数来获取连接

image-20250801215635834

参数说明:

  • sockfd:特定的监听套接字,表示从该监听套接字中获取连接。

  • addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等,是一个输入输出型参数

  • addrlen:调用时传入期望读取的addr结构体的长度,返回时代表实际读取到的addr结构体的长度,这是一个输入输出型参数。

(通过我们的addr和addrlen这两个输出型参数可以清楚的知道是哪个客户端连接的我们的这个服务器)

我们获取的连接在哪里?答:从内核中直接获取的,建立的过程和accept无关

返回值说明:

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

image-20250801221532698

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

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

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

  • 监听套接字:用于获取客户端发来的连接请求。accept函数会不断从监听套接字当中获取新连接。

  • accept函数返回的套接字:用于为本次accept获取到的连接提供服务。监听套接字的任务只是不断获取新连接,而真正为这些连接提供服务的套接字是accept函数返回的套接字,而不是监听套接字

这个accept函数新返回的套接字fd就像是某一天你跟朋友走在大街上,突然有一个揽客的把你招呼进一家饭店,这个揽客去招揽新的客人前叫了个店内的服务员来接待你的情景中的这个服务员是一样的意思

image-20250801225418933

服务端在获取连接时需要注意:accept函数获取连接时可能会失败,但TCP服务器不会因为获取某个连接失败而退出,因此服务端获取连接失败后应该继续获取连接

void Run(){_isrunning = true;while (_isrunning){// a. 获取连接struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);int sockfd = accept(_listensockfd, (struct sockaddr *)&peer, &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept error,continue next";continue;}
​InetAddr addr(peer);
​LOG(LogLevel::INFO) << "accept success,peer addr: " << addr.StringAddr();}
​_isrunning = false;}

尽管现在还没有编写客户端相关的代码,但是我们可以使用 telnet命令 远程登录到该服务器,因为telnet底层实际采用的就是TCP协议(只要tcp服务器处于listen状态,那么它就已经可以被连接了)

image-20250731232954419

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

image-20250801233947594

4.服务端处理请求

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

  1. TCP服务器读取数据的函数叫做read

image-20250802111148726

参数说明:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。

  • buf:数据的存储位置,表示将读取到的数据存储到该位置。

  • count:数据的个数,表示从该文件描述符中读取数据的字节数

返回值说明:

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

  • 如果返回值等于0,则表示对端已经把连接关闭了。

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

image-20250802114949967

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

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

  1. 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。

  2. 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。

  3. 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。

  4. 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取

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

  1. TCP服务器写入数据的函数叫做write

image-20250802164154138

参数说明:

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

  • buf:需要写入的数据。

  • count:需要写入数据的字节个数

返回值说明:

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

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

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

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

image-20250802213143911

处理请求的方法代码:

void Service(int sockfd, InetAddr &peer){char buffer[1024];while (true){// 1. 先读取数据//  a. n>0:读取成功//  b. n<0:读取失败//  c. n==0:对端把连接关了,读到了文件的结尾\0// 这和管道很像ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);if (n > 0){buffer[n] = 0; // 设置为c风格的字符串,n<=sizeof(buffer)-1LOG(LogLevel::DEBUG) << peer.StringAddr() << " say#" << buffer;
​// 2. 写回数据std::string echo_string = "echo# ";echo_string += buffer;write(sockfd, echo_string.c_str(), echo_string.size());}else if (n == 0){LOG(LogLevel::DEBUG) << peer.StringAddr() << "退出了...";// 对端退出了,我也退出close(sockfd);break;}else{// 客户端读取异常了LOG(LogLevel::DEBUG) << peer.StringAddr() << "异常...";// 退出close(sockfd);break;}}close(sockfd);}

ok,这个时候我们再用telnet连接,就可以实现消息的发送和接受啦

image-20250802165227783

image-20250802165211974

5.客户端创建套接字

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

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

服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的

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

#include <iostream>
#include "Common.hpp"
​
void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}
​
// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(USAGE_ERR);}
​std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);
​// 1. 创建套接字socketint sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}return 0;
}

6.客户端连接服务器

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

发起连接请求的函数叫做connect:

image-20250802200834204

参数说明:

  • sockfd:特定的套接字,表示通过该套接字发起连接请求。

  • addr:对端网络(目标服务器)相关的属性信息,包括协议家族、IP地址、端口号等。

  • addrlen:传入的addr结构体的长度

返回值说明:

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

client会在connect成功之后,在底层自动进行bind

ok,到这我们也就完成了对客户端代码的编写:

#include <iostream>
#include "Common.hpp"
#include "InetAddr.hpp"
​
void Usage(std::string proc)
{std::cerr << "Usage: " << proc << " server_ip server_port" << std::endl;
}
​
// ./tcpclient server_ip server_port
int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(USAGE_ERR);}
​std::string serverip = argv[1];uint16_t serverport = std::stoi(argv[2]);
​// 1. 创建套接字socketint sockfd = socket(AF_INET, SOCK_STREAM, 0);if (sockfd < 0){std::cerr << "socket error" << std::endl;exit(SOCKET_ERR);}
​// 2.不需要显式的绑定bind// 那么客户端应该做什么呢? listen? accept?都不需要!!// 要做的就是直接向目标服务器建立连接的请求InetAddr serveraddr(serverip, serverport);int n = connect(sockfd, serveraddr.NetAddrPtr(), serveraddr.NetAddrLen());if (n < 0){std::cerr << "connect error" << std::endl;exit(CONNECT_ERR);}
​// 3. echo clientwhile (true){std::string line;std::cout << "Please Enter@ ";std::getline(std::cin, line);
​// 写入数据write(sockfd, line.c_str(), line.size());
​// 读数据char buffer[1024];ssize_t size = read(sockfd, buffer, sizeof(buffer) - 1);if (size > 0){buffer[size] = 0;std::cout << "server echo# " << buffer << std::endl;}}
​return 0;
}

image-20250802231952755

image-20250802232004110

7.单执行流服务器的弊端

从上边的测试代码就可以看出来,我们此时的代码是基于单进程的;当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务

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

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

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

那么我们又有问题啦:客户端为什么会显示连接成功?

当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了

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

所以这个时候我们就要把我们的代码修改成多执行流的,也就是多进程的

8.多进程版的Tcp网络程序

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

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

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

image-20250803115953810

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

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

对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务,而后我们的子进程是不需要进行监听的,也就是需要关闭listensock,父进程则是不需要普通的套接字了,只负责监听,所以得关闭sockfd

等待子进程问题

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

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

  • 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。

  • 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出

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

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

  • 捕捉SIGCHLD信号,将其处理动作设置为忽略。

  • 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务

我们可以采用捕捉SIGCHLD信号得做法(简单),这样实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了

在启动方法第一行加:

// 捕捉SIGCHLD信号,进行该信号的忽略,这样父进程就不需要进行等待了
signal(SIGCHLD, SIG_IGN);

我们也可以采用第二种方式,创建孙子进程,然后让这个孙子进程来处理任务

pid_t id = fork();
if (id < 0)
{LOG(LogLevel::FATAL) << "fork error";exit(FOCK_ERR);
}
else if (id == 0)
{// 子进程,子进程除了看到sockfd,能看到listensockfd吗?// 答案是可以的,但是我们不想让子进程访问listensock// 所以得让子进程关闭listensockclose(_listensockfd);if (fork() > 0) // 让子进程创建孙子进程,然后自己退出{exit(OK);}// 让孙子进程处理IO请求Service(sockfd, addr);// 然后让子进程处理完任务后直接退出exit(OK);
}
else
{// 父进程close(sockfd);
​// 父进程进行等待,这个时候由于我们让子进程退出了,所以不会阻塞pid_t rid = waitpid(id, nullptr, 0);
}
}

那么此时的服务器运行方法的代码:

 void Run(){_isrunning = true;while (_isrunning){// a. 获取连接struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);// 如果没有连接到来,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr *)&peer, &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept error,continue next";continue;}
​InetAddr addr(peer);
​LOG(LogLevel::INFO) << "accept success,fd peer addr: " << sockfd << " " << addr.StringAddr();
​// version0 ---test version——单进程程序——不会存在// Service(sockfd, addr);
​// version1 ---多进程版本pid_t id = fork();if (id < 0){LOG(LogLevel::FATAL) << "fork error";exit(FOCK_ERR);}else if (id == 0){// 子进程,子进程除了看到sockfd,能看到listensockfd吗?// 答案是可以的,但是我们不想让子进程访问listensock// 所以得让子进程关闭listensockclose(_listensockfd);if (fork() > 0) // 让子进程创建孙子进程,然后自己退出{exit(OK);}// 让孙子进程处理IO请求// 孙子进程由于子进程退出了,变成了孤儿进程,由系统回收Service(sockfd, addr);// 然后让子进程处理完任务后直接退出exit(OK);}else{// 父进程close(sockfd);
​// 父进程进行等待,这个时候由于我们让子进程退出了,所以不会阻塞pid_t rid = waitpid(id, nullptr, 0);(void)rid;}}
​_isrunning = false;}

此时完全不需要去等一个客户端退了,另一个客户端才能登录

image-20250803225632439

image-20250803225644367

image-20250803225711342

我们的多进程版Tcp网络聊天室完整代码在:Tcp版多进程聊天室

9.多线程版的Tcp网络程序

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

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

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

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

参数结构体

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

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

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

// 线程小类class ThreadData{public:ThreadData(int fd, InetAddr &ar, TcpServer *s): sockfd(fd),addr(ar),tsvr(s){}
​public:int sockfd;InetAddr addr;TcpServer *tsvr;};

关于文件描述符关闭的问题

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

  • 对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该由新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。

  • 对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了

那么我们就可以进一步把我们的多线程版本的Tcp网络程序的启动方法和线程要执行的任务方法写出来了

// routine类内方法,由this指针,所以在这设计成静态的static void *Routine(void *args){// 先进行线程的分离,使得线程不需要等待pthread_detach(pthread_self());ThreadData *td = static_cast<ThreadData *>(args);td->tsvr->Service(td->sockfd, td->addr);delete td;return nullptr;}
​void Run(){_isrunning = true;while (_isrunning){// a. 获取连接struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);// 如果没有连接到来,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr *)&peer, &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept error,continue next";continue;}
​InetAddr addr(peer);
​LOG(LogLevel::INFO) << "accept success,fd peer addr: " << sockfd << " " << addr.StringAddr();
​ThreadData *td = new ThreadData(sockfd, addr, this);// version2:多线程版本pthread_t tid;pthread_create(&tid, nullptr, Routine, td);}
​_isrunning = false;}

这里我们需要关注一下Service函数定义为静态成员函数问题

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

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

我们的多线程版Tcp网络聊天室完整代码在:Tcp版多线程聊天室

10.线程池版的Tcp网络程序

先来看下多线程版本的服务器存在哪些问题:

  • 每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程。

  • 如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机当中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答

针对这两个问题,对应的解决思路如下:

  • 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程。

  • 当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。

  • 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大。此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务

当然我们的解决方法其实就是引入线程池(我们在系统部分和UDP部分都是用过的),不过线程池比较适合的是处理短服务,上面的多线程适合处理长服务

我们的线程池版本只需要在多线程的基础上加入线程池类以及修改一下Run方法的代码就行啦

线程池类:

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <queue>
#include "Log.hpp"
#include "Thread.hpp"
#include "Cond.hpp"
#include "Mutex.hpp"
using namespace std;namespace ThreadPoolModule
{using namespace ThreadModlue;using namespace LogModule;using namespace CondModule;using namespace MutexModule;static const int gnum = 5;template <class T>class ThreadPool{private:// 唤醒所有的线程void WakeUpAllThread(){LockGuard lockguard(_mutex);if (_sleepernum) // 休眠线程个数大于0时才唤醒{_cond.Broadcast();LOG(LogLevel::INFO) << "唤醒所有的休眠线程";}}// 唤醒一个线程void WakeUpOne(){_cond.Signal();LOG(LogLevel::INFO) << "唤醒一个线程";}// 运用懒汉模式,构造函数得私有化ThreadPool(int num = gnum): _num(num),_isrunning(false),_sleepernum(0){for (int i = 0; i < num; i++){_threads.emplace_back(//[this]表示lambda内部可以访问当前ThreadPool对象的所有成员// 使得所有线程一旦被启动就可以执行HandlerTask方法// 为每个工作线程绑定任务处理函数[this](){HandlerTask();});// 上面等价于thread t([this]() { HandlerTask(); });//_threads.push_back(std::move(t));}}// 启动线程void Start(){if (_isrunning){// 已经启动了return;}// 没有启动_isrunning = true;for (auto &thread : _threads){thread.Start();// 在调用Start创建线程后线程执行Routine方法,然后遇到 self->_func();// 碰到回调函数回到lambda表达式中执行HandlerTask方法LOG(LogLevel::INFO) << "start new thread success" << thread.Name();}}// 拷贝构造和operator赋值都要禁掉ThreadPool(const ThreadPool<T> &) = delete;ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;public:// 获取当前单例// 但是如果直接这样写就会导致该方法属于类内方法,需要依赖对象来调用// 而对象是在这个方法中创建的,就会导致这个方法不可被调用,所以得加上static// 使之能够属于这个类,不依赖对象,可以在外面不创建对象的情况下直接突破类域使用// hreadPool<T> *GetInstance()static ThreadPool<T> *GetInstance(){// 双重判空,提高获取单例的效率同时保护获取单例的安全if (inc == nullptr) // 只会有一次判空,其他线程直接往下返回指针就行了,提高效率{LockGuard lockguard(_lock); // 加锁保护在多线程获取单例的情况LOG(LogLevel::DEBUG) << "获取线程池单例";if (inc == nullptr){LOG(LogLevel::DEBUG) << "首次使用单例,创建之...";inc = new ThreadPool<T>();// 把对象构建出来后直接启动inc->Start();return inc;}}return inc;}// 停止线程void Stop(){if (!_isrunning){return;}_isrunning = false;// 停止线程还需要把所有线程唤醒这样才任务处理逻辑时休眠的线程才能醒来WakeUpAllThread();}void Join(){for (auto &thread : _threads){thread.Join();}}// 负责从任务队列中获取并执行任务void HandlerTask(){char name[128];// 第一个参数获取线程的名称,然后写入到name中pthread_getname_np(pthread_self(), name, sizeof(name));while (true){T t;{LockGuard lockguard(_mutex);// 判断任务队列当中是否有任务// 要进行正常休眠除了要判断是否有任务还需要判断线程池是否要退出// a.队列为空,b.线程池没有退出while (_taskq.empty() && _isrunning){// 一个线程需要休眠_sleepernum++;// 如果队列为空等进行等待(条件变量)_cond.Wait(_mutex);// 醒来时--_sleepernum--;}// 2.内部的线程被唤醒if (!_isrunning && _taskq.empty()){// 启动标志位为false表示线程池要退&&任务队列为空才可以退LOG(LogLevel::INFO) << name << "退出,线程池退出&&任务队列为空";break;// break之后HandlerTask执行结束后回到Routine方法中// Routine方法往下走完return之后底层线程自动被释放}// 到这说明一定是有任务的// 取出队列中的任务t = _taskq.front();_taskq.pop();}// 从q中获取了任务之后,任务已经是该线程所有的了t(); // 所以处理任务在临界区外进行,为并行过程,这样效率才高}}// 入任务队列bool Enqueue(const T &in){// 如果是启动状态才给任务入队列if (_isrunning){LockGuard lockguard(_mutex);_taskq.push(in);// 如果全部线程都在休眠,我们就唤醒一个来处理新增的任务if (_threads.size() == _sleepernum){WakeUpOne();}return true;}return false;}~ThreadPool() {}private:vector<Thread> _threads;int _num;        // 线程池中线程的个数queue<T> _taskq; // 任务队列Mutex _mutex;    // 锁Cond _cond;      // 条件变量bool _isrunning; // 启动标志位int _sleepernum; // 表示当前有多少个休眠线程static ThreadPool<T> *inc; // 单例模式的指针static Mutex _lock;        // 单例模式要用的锁(因为上面的锁依赖于对象)};// 初始化指针和锁template <class T>ThreadPool<T> *ThreadPool<T>::inc = nullptr;template <class T>Mutex ThreadPool<T>::_lock; // Mutex对象自动会调它的构造函数
}

线程池类所需条件变量类代码的补充:

#pragma once
#include <iostream>
#include <pthread.h>
#include "Mutex.hpp"using namespace MutexModule;namespace CondModule
{class Cond{public:Cond(){pthread_cond_init(&_cond, nullptr);}void Wait(Mutex &mutex){int n = pthread_cond_wait(&_cond, mutex.Get());(void)n;}void Signal(){// 唤醒在条件变量下等待的一个线程int n = pthread_cond_signal(&_cond);(void)n;}void Broadcast(){// 唤醒所有在条件变量下等待的线程int n = pthread_cond_broadcast(&_cond);(void)n;}~Cond(){pthread_cond_destroy(&_cond);}private:pthread_cond_t _cond;};
};

那么我们的Run()方法修改如下:

#include<ThreadPool>using namespace ThreadPoolModule;
​
using task_t = std::function<void()>;
​
void Run(){_isrunning = true;while (_isrunning){// a. 获取连接struct sockaddr_in peer;memset(&peer, 0, sizeof(peer));socklen_t len = sizeof(peer);// 如果没有连接到来,accept就会阻塞int sockfd = accept(_listensockfd, (struct sockaddr *)&peer, &len);if (sockfd < 0){LOG(LogLevel::WARNING) << "accept error,continue next";continue;}
​InetAddr addr(peer);
​LOG(LogLevel::INFO) << "accept success,fd peer addr: " << sockfd << " " << addr.StringAddr();
​// version3:线程池版本,线程池一般比较适合处理短服务// 将新连接和客户端构建一个新的任务,push到线程池中ThreadPool<task_t>::GetInstance()->Enqueue([this, sockfd, &addr](){ this->Service(sockfd, addr); });}
​_isrunning = false;}

无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务(因为在我们的线程池代码中默认线程就是5个),线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出

image-20250805191203886

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

相关文章:

  • AI人工智能大模型应用如何落地
  • DriveDreamer-2
  • C++ 模板全览:从“非特化”到“全特化 / 偏特化”的完整原理与区别
  • CUDA与图形API的深度互操作:解锁GPU硬件接口的真正潜力
  • Linux 系统都有哪些
  • Playwright Python 教程:实战篇
  • docker中的命令(四)
  • Coze源码分析-工作空间-项目开发-前端源码
  • 如何重置SVN被保存的用户名和密码
  • 【pve】
  • 轻量化注意力+脉冲机制,Transformer在低功耗AI中再度进化
  • 吴恩达机器学习作业十 PCA主成分分析
  • 基于单片机智能大棚/温室大棚/智慧农业/智能栽培种植系统/温湿度控制
  • LeetCode 37.解数独
  • k8s三阶段项目
  • 狂神说--Nginx--通俗易懂
  • 线程池八股文
  • 从零开始写个deer-flow-mvp-第一天
  • 拆分TypeScript项目的学习收获:处理编译缓存和包缓存,引用本地项目,使用相对路径
  • 粗糙表面接触模型MATLAB代码
  • 多租户配额与预算:限额、配额周期与突发桶的结算模型(Final)
  • 【机械故障】使用扭矩计算物体重量
  • web墨卡托的纬度范围为什么是85°S~85°N?
  • 为何重定义库函数会减少flash体积(从prinf讲解)
  • 为什么计算机使用补码存储整数:补码的本质
  • 【秋招笔试】2025.08.29阿里云秋招笔试题
  • 【Linux】动静态库的制作与原理
  • 第三十二天:数组
  • 刷算法题-数组-02
  • 关于Ctrl+a不能全选的问题