【Linux】网络编程套接字(中)
🎇Linux:
- 博客主页:一起去看日落吗
- 分享博主的在Linux中学习到的知识和遇到的问题
博主的能力有限,出现错误希望大家不吝赐教
- 分享给大家一句我很喜欢的话: 看似不起波澜的日复一日,一定会在某一天让你看见坚持的意义,祝我们都能在鸡零狗碎里找到闪闪的快乐🌿🌞🐾。
✨ ⭐️ 🌟 💫
目录
- 🌟1. 简单的TCP网络程序
- ⭐️1.1 服务端创建套接字
- ⭐️1.2 服务端绑定
- ⭐️1.3 服务端监听
- ⭐️1.4 服务端获取连接
- ⭐️1.5 服务端处理请求
- ⭐️1.6 客户端创建套接字
- ⭐️1.7 客户端连接服务器
- ⭐️1.8 客户端发起请求
- ⭐️1.9 单执行流服务器的弊端
- 🌟2. 多进程版的TCP网络程序
- ⭐️2.1 捕捉SIGCHLD信号
- ⭐️2.2 让孙子进程提供服务
- 🌟3. 多线程版的TCP网络程序
- 🌟4. 线程池版的TCP网络程序
🌟1. 简单的TCP网络程序
⭐️1.1 服务端创建套接字
我们将TCP服务器封装成一个类,当我们定义出一个服务器对象后需要马上对服务器进行初始化,而初始化TCP服务器要做的第一件事就是创建套接字。
TCP服务器在调用socket函数创建套接字时:
- 协议家族选择AF_INET,因为我们要进行的是网络通信。
- 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器,SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
- 协议类型默认设置为0即可。
如果创建套接字后获得的文件描述符是小于0的,说明套接字创建失败,此时也就没必要进行后续操作了,直接终止程序即可。
class TcpServer
{
public:void InitServer(){//创建套接字_sock = socket(AF_INET, SOCK_STREAM, 0);if (_sock < 0){std::cerr << "socket error" << std::endl;exit(2);}}~TcpServer(){if (_sock >= 0){close(_sock);}}
private:int _sock; //套接字
};
说明一下:
- 实际TCP服务器创建套接字的做法与UDP服务器是一样的,只不过创建套接字时TCP需要的是流式服务,而UDP需要的是用户数据报服务。
- 当析构服务器时,可以将服务器对应的文件描述符进行关闭。
⭐️1.2 服务端绑定
套接字创建完毕后我们实际只是在系统层面上打开了一个文件,该文件还没有与网络关联起来,因此创建完套接字后我们还需要调用bind函数进行绑定操作。
绑定的步骤如下:
- 定义一个struct sockaddr_in结构体,将服务器网络相关的属性信息填充到该结构体当中,比如协议家族、IP地址、端口号等。
- 填充服务器网络相关的属性信息时,协议家族对应就是AF_INET,端口号就是当前TCP服务器程序的端口号。在设置端口号时,需要调用htons函数将端口号由主机序列转为网络序列。
- 在设置服务器的IP地址时,我们可以设置为本地环回127.0.0.1,表示本地通信。也可以设置为公网IP地址,表示网络通信。
- 如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序的转换。
- 填充完服务器网络相关的属性信息后,需要调用bind函数进行绑定。绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可。
由于TCP服务器初始化时需要服务器的端口号,因此在服务器类当中需要引入端口号,当实例化服务器对象时就需要给传入一个端口号。而由于我当前使用的是云服务器,因此在绑定TCP服务器的IP地址时不需要绑定公网IP地址,直接绑定INADDR_ANY即可,因此我这里没有在服务器类当中引入IP地址。
class TcpServer
{
public:TcpServer(int port): _sock(-1), _port(port){}void InitServer(){//创建套接字_sock = socket(AF_INET, SOCK_STREAM, 0);if (_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(_sock, (struct sockaddr*)&local, sizeof(local)) < 0){std::cerr << "bind error" << std::endl;exit(3);}}~TcpServer(){if (_sock >= 0){close(_sock);}}
private:int _sock; //监听套接字int _port; //端口号
};
当定义好struct sockaddr_in结构体后,最好先用memset函数对该结构体进行清空,也可以用bzero函数进行清空。bzero函数也可以对特定的一块内存区域进行清空。
void bzero(void *s, size_t n);
TCP服务器绑定时的步骤与UDP服务器是完全一样的,没有任何区别。
⭐️1.3 服务端监听
UDP服务器的初始化操作只有两步,第一步就是创建套接字,第二步就是绑定。而TCP服务器是面向连接的,客户端在正式向TCP服务器发送数据之前,需要先与TCP服务器建立连接,然后才能与服务器进行通信。
因此TCP服务器需要时刻注意是否有客户端发来连接请求,此时就需要将TCP服务器创建的套接字设置为监听状态。
- listen函数
int listen(int sockfd, int backlog);
参数说明:
- sockfd:需要设置为监听状态的套接字对应的文件描述符。
- backlog:全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度,一般不要设置太大,设置为5或10即可。
返回值说明:
-
监听成功返回0,监听失败返回-1,同时错误码会被设置。
-
服务器监听
TCP服务器在创建完套接字和绑定后,需要再进一步将套接字设置为监听状态,监听是否有新的连接到来。如果监听失败也没必要进行后续操作了,因为监听失败也就意味着TCP服务器无法接收客户端发来的连接请求,因此监听失败我们直接终止程序即可
#define BACKLOG 5class TcpServer
{
public:void InitServer(){//创建套接字_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, BACKLOG) < 0){std::cerr << "listen error" << std::endl;exit(4);}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
说明一下:
- 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock改为listensocket。
- 在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。
⭐️1.4 服务端获取连接
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_ntoa函数将整数IP转换成字符串IP,调用ntohs函数将端口号由网络序列转换成主机序列。
-
inet_ntoa函数在底层实际做了两个工作,一是将网络序列转换成主机序列,二是将主机序列的整数IP转换成字符串风格的点分十进制的IP。
class TcpServer
{
public: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 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;}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
服务端运行后,通过netstat命令可以查看到一个程序名为tcp_server的服务程序,它绑定的端口就是8081,而由于服务器绑定的是INADDR_ANY,因此该服务器的本地IP地址是0.0.0.0,这就意味着该TCP服务器可以读取本地任何一张网卡里面的数据。此外,最重要的是当前该服务器所处的状态是LISTEN状态,表明当前服务器可以接收外部的请求连接。
虽然现在还没有编写客户端相关的代码,但是我们可以使用telnet命令远程登录到该服务器,因为telnet底层实际采用的就是TCP协议。
使用telnet命令连接当前TCP服务器后可以看到,此时服务器接收到了一个连接,为该连接提供服务的套接字对应的文件描述符就是4。因为0、1、2是默认打开的,其分别对应标准输入流、标准输出流和标准错误流,而3号文件描述符在初始化服务器时分配给了监听套接字,因此当第一个客户端发起连接请求时,为该客户端提供服务的套接字对应的文件描述符就是4。
如果此时我们再用其他窗口继续使用telnet命令,向该TCP服务器发起请求连接,此时为该客户端提供服务的套接字对应的文件描述符就是5。
⭐️1.5 服务端处理请求
现在TCP服务器已经能够获取连接请求了,下面当然就是要对获取到的连接进行处理。但此时为客户端提供服务的不是监听套接字,因为监听套接字获取到一个连接后会继续获取下一个请求连接,为对应客户端提供服务的套接字实际是accept函数返回的套接字,下面就将其称为“服务套接字”。
为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回声TCP服务器,服务端在为客户端提供服务时就简单的将客户端发来的数据进行输出,并且将客户端发来的数据重新发回给客户端即可。当客户端拿到服务端的响应数据后再将该数据进行打印输出,此时就能确保服务端和客户端能够正常通信了。
- read函数
ssize_t read(int fd, void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示从该文件描述符中读取数据。
- buf:数据的存储位置,表示将读取到的数据存储到该位置。
- count:数据的个数,表示从该文件描述符中读取数据的字节数。
返回值说明:
- 如果返回值大于0,则表示本次实际读取到的字节个数。
- 如果返回值等于0,则表示对端已经把连接关闭了。
- 如果返回值小于0,则表示读取时遇到了错误。
- read返回值为0表示对端连接关闭
这实际和本地进程间通信中的管道通信是类似的,当使用管道进行通信时,可能会出现如下情况:
- 写端进程不写,读端进程一直读,此时读端进程就会被挂起,因为此时数据没有就绪。
- 读端进程不读,写端进程一直写,此时当管道被写满后写端进程就会被挂起,因为此时空间没有就绪。
- 写端进程将数据写完后将写端关闭,此时当读端进程将管道当中的数据读完后就会读到0。
- 读端进程将读端关闭,此时写端进程就会被操作系统杀掉,因为此时写端进程写入的数据不会被读取。
这里的写端就对应客户端,如果客户端将连接关闭了,那么此时服务端将套接字当中的信息读完后就会读取到0,因此如果服务端调用read函数后得到的返回值为0,此时服务端就不必再为该客户端提供服务了。
- write函数
ssize_t write(int fd, const void *buf, size_t count);
参数说明:
- fd:特定的文件描述符,表示将数据写入该文件描述符对应的套接字。
- buf:需要写入的数据。
- count:需要写入数据的字节个数。
返回值说明:
- 写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。
当服务端调用read函数收到客户端的数据后,就可以再调用write函数将该数据再响应给客户端。
- 服务端处理请求
需要注意的是,服务端读取数据是服务套接字中读取的,而写入数据的时候也是写入进服务套接字的。也就是说这里为客户端提供服务的套接字,既可以读取数据也可以写入数据,这就是TCP全双工的通信的体现。
在从服务套接字中读取客户端发来的数据时,如果调用read函数后得到的返回值为0,或者读取出错了,此时就应该直接将服务套接字对应的文件描述符关闭。因为文件描述符本质就是数组的下标,因此文件描述符的资源是有限的,如果我们一直占用,那么可用的文件描述符就会越来越少,因此服务完客户端后要及时关闭对应的文件描述符,否则会导致文件描述符泄漏。
class TcpServer
{
public:void Service(int sock, std::string client_ip, int client_port){char buffer[1024];while (true){ssize_t size = read(sock, buffer, sizeof(buffer)-1);if (size > 0){ //读取成功buffer[size] = '\0';std::cout << "get a new link->" << sock << " [" << client_ip << "]:" << client_port << std::endl;write(sock, buffer, size);}else if (size == 0){ //对端关闭连接std::cout << client_ip << ":" << client_port << " close!" << std::endl;break;}else{ //读取失败std::cerr << sock << " read error!" << std::endl;break;}}close(sock); //归还文件描述符std::cout << client_ip << ":" << client_port << " service done!" << std::endl;}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 client_ip = inet_ntoa(peer.sin_addr);int client_port = ntohs(peer.sin_port);std::cout << "get a new link [" << client_ip << "]:" << client_port << std::endl;//处理请求Service(sock, client_ip, client_port);}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
⭐️1.6 客户端创建套接字
同样的,我们将客户端也封装成一个类,当我们定义出一个客户端对象后也需要对其进行初始化,而初始化客户端唯一需要做的就是创建套接字。而客户端在调用socket函数创建套接字时,参数设置与服务端创建套接字时是一样的。
客户端不需要进行绑定和监听:
- 服务端要进行绑定是因为服务端的IP地址和端口号必须要众所周知,不能随意改变。而客户端虽然也需要IP地址和端口号,但是客户端并不需要我们进行绑定操作,客户端连接服务端时系统会自动指定一个端口号给客户端。
- 服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。
此外,客户端必须要知道它要连接的服务端的IP地址和端口号,因此客户端除了要有自己的套接字之外,还需要知道服务端的IP地址和端口号,这样客户端才能够通过套接字向指定服务器进行通信。
class TcpClient
{
public:TcpClient(std::string server_ip, int server_port): _sock(-1), _server_ip(server_ip), _server_port(server_port){}void InitClient(){//创建套接字_sock = socket(AF_INET, SOCK_STREAM, 0);if (_sock < 0){std::cerr << "socket error" << std::endl;exit(2);}}~TcpClient(){if (_sock >= 0){close(_sock);}}
private:int _sock; //套接字std::string _server_ip; //服务端IP地址int _server_port; //服务端端口号
};
⭐️1.7 客户端连接服务器
由于客户端不需要绑定,也不需要监听,因此当客户端创建完套接字后就可以向服务端发起连接请求。
- connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:特定的套接字,表示通过该套接字发起连接请求。
- addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 连接或绑定成功返回0,连接失败返回-1,同时错误码会被设置。
- 客户端连接服务器
需要注意的是,客户端不是不需要进行绑定,而是不需要我们自己进行绑定操作,当客户端向服务端发起连接请求时,系统会给客户端随机指定一个端口号进行绑定。因为通信双方都必须要有IP地址和端口号,否则无法唯一标识通信双方。也就是说,如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。
此外,调用connect函数向服务端发起连接请求时,需要传入服务端对应的网络信息,否则connect函数也不知道该客户端到底是要向哪一个服务端发起连接请求。
class TcpClient
{
public:void Start(){struct sockaddr_in peer;memset(&peer, '\0', sizeof(peer));peer.sin_family = AF_INET;peer.sin_port = htons(_server_port);peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());if (connect(_sock, (struct sockaddr*)&peer, sizeof(peer)) == 0){ //connect successstd::cout << "connect success..." << std::endl;Request(); //发起请求}else{ //connect errorstd::cerr << "connect failed..." << std::endl;exit(3);}}
private:int _sock; //套接字std::string _server_ip; //服务端IP地址int _server_port; //服务端端口号
};
⭐️1.8 客户端发起请求
由于我们实现的是一个简单的回声服务器,因此当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可。
当客户端将数据发送给服务端后,由于服务端读取到数据后还会进行回显,因此客户端在发送数据后还需要调用read函数读取服务端的响应数据,然后将该响应数据进行打印,以确定双方通信无误。
class TcpClient
{
public:void Request(){std::string msg;char buffer[1024];while (true){std::cout << "Please Enter# ";getline(std::cin, msg);write(_sock, msg.c_str(), msg.size());ssize_t size = read(_sock, buffer, sizeof(buffer)-1);if (size > 0){buffer[size] = '\0';std::cout << "server echo# " << buffer << std::endl;}else if (size == 0){std::cout << "server close!" << std::endl;break;}else{std::cerr << "read error!" << std::endl;break;}}}void Start(){struct sockaddr_in peer;memset(&peer, '\0', sizeof(peer));peer.sin_family = AF_INET;peer.sin_port = htons(_server_port);peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());if (connect(_sock, (struct sockaddr*)&peer, sizeof(peer)) == 0){ //connect successstd::cout << "connect success..." << std::endl;Request(); //发起请求}else{ //connect errorstd::cerr << "connect failed..." << std::endl;exit(3);}}
private:int _sock; //套接字std::string _server_ip; //服务端IP地址int _server_port; //服务端端口号
};
在运行客户端程序时我们就需要携带上服务端对应的IP地址和端口号,然后我们就可以通过服务端的IP地址和端口号构造出一个客户端对象,对客户端进行初始后启动客户端即可。
void Usage(std::string proc)
{std::cout << "Usage: " << proc << "server_ip server_port" << std::endl;
}
int main(int argc, char* argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}std::string server_ip = argv[1];int server_port = atoi(argv[2]);TcpClient* clt = new TcpClient(server_ip, server_port);clt->InitClient();clt->Start();return 0;
}
⭐️1.9 单执行流服务器的弊端
当我们仅用一个客户端连接服务端时,这一个客户端能够正常享受到服务端的服务。
但在这个客户端正在享受服务端的服务时,我们让另一个客户端也连接服务器,此时虽然在客户端显示连接是成功的,但这个客户端发送给服务端的消息既没有在服务端进行打印,服务端也没有将该数据回显给该客户端。
只有当第一个客户端退出后,服务端才会将第二个客户端发来是数据进行打印,并回显该第二个客户端。
- 单执行流的服务器
通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。
当服务端调用accept函数获取到连接后就给该客户端提供服务,但在服务端提供服务期间可能会有其他客户端发起连接请求,但由于当前服务器是单执行流的,只能服务完当前客户端后才能继续服务下一个客户端。
- 客户端为什么会显示连接成功?
当服务端在给第一个客户端提供服务期间,第二个客户端向服务端发起的连接请求时是成功的,只不过服务端没有调用accept函数将该连接获取上来罢了。
实际在底层会为我们维护一个连接队列,服务端没有accept的新连接就会放到这个连接队列当中,而这个连接队列的最大长度就是通过listen函数的第二个参数来指定的,因此服务端虽然没有获取第二个客户端发来的连接请求,但是在第二个客户端那里显示是连接成功的。
- 解决方法
单执行流的服务器一次只能给一个客户端提供服务,此时服务器的资源并没有得到充分利用,因此服务器一般是不会写成单执行流的。要解决这个问题就需要将服务器改为多执行流的,此时就要引入多进程或多线程。
🌟2. 多进程版的TCP网络程序
我们可以将当前的单执行流服务器改为多进程版的服务器。
当服务端调用accept函数获取到新连接后不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。
由于父子进程是两个不同的执行流,当父进程调用fork创建出子进程后,父进程就可以继续从监听套接字当中获取新连接,而不用关心获取上来的连接是否服务完毕。
子进程继承父进程的文件描述符表
需要注意的是,文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时父进程创建的子进程的3号文件描述符也会指向这个打开的文件,而如果子进程再创建一个子进程,那么子进程创建的子进程的3号文件描述符也同样会指向这个打开的文件。
但当父进程创建子进程后,父子进程之间会保持独立性,此时父进程文件描述符表的变化不会影响子进程。最典型的代表就是匿名管道,父子进程在使用匿名管道进行通信时,父进程先调用pipe函数得到两个文件描述符,一个是管道读端的文件描述符,一个是管道写端的文件描述符,此时父进程创建出来的子进程就会继承这两个文件描述符,之后父子进程一个关闭管道的读端,另一个关闭管道的写端,这时父子进程文件描述符表的变化是不会相互影响的,此后父子进程就可以通过这个管道进行单向通信了。
对于套接字文件也是一样的,父进程创建的子进程也会继承父进程的套接字文件,此时子进程就能够对特定的套接字文件进行读写操作,进而完成对对应客户端的服务。
- 等待子进程问题
当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。因此服务端创建子进程后需要调用wait或waitpid函数对子进程进行等待。
阻塞式等待与非阻塞式等待:
- 如果服务端采用阻塞的方式等待子进程,那么服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。
- 如果服务端采用非阻塞的方式等待子进程,虽然在子进程为客户端提供服务期间服务端可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测子进程是否退出。
总之,服务端要等待子进程退出,无论采用阻塞式等待还是非阻塞式等待,都不尽人意。此时我们可以考虑让服务端不等待子进程退出。
- 不等待子进程退出的方式
让父进程不等待子进程退出,常见的方式有两种:
- 捕捉SIGCHLD信号,将其处理动作设置为忽略。
- 让父进程创建子进程,子进程再创建孙子进程,最后让孙子进程为客户端提供服务。
⭐️2.1 捕捉SIGCHLD信号
实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
该方式实现起来非常简单,也是比较推荐的一种做法。
class TcpServer
{
public:void Start(){signal(SIGCHLD, SIG_IGN); //忽略SIGCHLD信号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 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; //端口号
};
- 代码测试
重新编译程序运行服务端后,可以通过以下监控脚本对服务进程进行监控。
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
此时可以看到,一开始没有客户端连接该服务器,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的。
此时我们运行一个客户端,让该客户端连接服务器,此时服务进程就会调用fork函数创建出一个子进程,由该子进程为这个客户端提供服务。
如果再有一个客户端连接服务器,此时服务进程会再创建出一个子进程,让该子进程为这个客户端提供服务。
最重要的是,由于这两个客户端分别由两个不同的执行流提供服务,因此这两个客户端可以同时享受到服务,它们发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。
当客户端一个个退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接。
⭐️2.2 让孙子进程提供服务
我们也可以让服务端创建出来的子进程再次进行fork,让孙子进程为客户端提供服务, 此时我们就不用等待孙子进程退出了。
命名说明:
- 爷爷进程:在服务端调用accept函数获取客户端连接请求的进程。
- 爸爸进程:由爷爷进程调用fork函数创建出来的进程。
- 孙子进程:由爸爸进程调用fork函数创建出来的进程,该进程调用Service函数为客户端提供服务。
我们让爸爸进程创建完孙子进程后立刻退出,此时服务进程(爷爷进程)调用wait/waitpid函数等待爸爸进程就能立刻等待成功,此后服务进程就能继续调用accept函数获取其他客户端的连接请求。
- 不需要等待孙子进程退出
而由于爸爸进程创建完孙子进程后就立刻退出了,因此实际为客户端提供服务的孙子进程就变成了孤儿进程,该进程就会被系统领养,当孙子进程为客户端提供完服务退出后系统会回收孙子进程,所以服务进程(爷爷进程)是不需要等待孙子进程退出的。
- 关闭对应的文件描述符
服务进程(爷爷进程)调用accept函数获取到新连接后,会让孙子进程为该连接提供服务,此时服务进程已经将文件描述符表继承给了爸爸进程,而爸爸进程又会调用fork函数创建出孙子进程,然后再将文件描述符表继承给孙子进程。
而父子进程创建后,它们各自的文件描述符表是独立的,不会相互影响。因此服务进程在调用fork函数后,服务进程就不需要再关心刚才从accept函数获取到的文件描述符了,此时服务进程就可以调用close函数将该文件描述符进行关闭。
同样的,对于爸爸进程和孙子进程来说,它们是不需要关心从服务进程(爷爷进程)继承下来的监听套接字的,因此爸爸进程可以将监听套接字关掉。
关闭文件描述符的必要性:
- 对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。
- 而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。
class TcpServer
{
public: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 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){ //childclose(_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; //端口号
};
- 服务器测试
重新编译程序运行客户端后,继续使用监控脚本对服务进程进行实时监控。
while :; do ps axj | head -1 && ps axj | grep tcp_server | grep -v grep;echo "######################";sleep 1;done
此时没有客户端连接服务器,因此也是只监控到了一个服务进程,该服务进程正在等待客户端的请求连接。
此时我们运行一个客户端,让该客户端连接当前这个服务器,此时服务进程会创建出爸爸进程,爸爸进程再创建出孙子进程,之后爸爸进程就会立刻退出,而由孙子进程为客户端提供服务。因此这时我们只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程。
当我们运行第二个客户端连接服务器时,此时就又会创建出一个孤儿进程为该客户端提供服务。
此时这两个客户端是由两个不同的孤儿进程提供服务的,因此它们也是能够同时享受到服务的,可以看到这两个客户端发送给服务端的数据都能够在服务端输出,并且服务端也会对它们的数据进行响应。
当客户端全部退出后,对应为客户端提供服务的孤儿进程也会跟着退出,这时这些孤儿进程会被系统回收,而最终剩下那个获取连接的服务进程。
🌟3. 多线程版的TCP网络程序
创建进程的成本是很高的,创建进程时需要创建该进程对应的进程控制块(task_struct)、进程地址空间(mm_struct)、页表等数据结构。而创建线程的成本比创建进程的成本会小得多,因为线程本质是在进程地址空间内运行,创建出来的线程会共享该进程的大部分资源,因此在实现多执行流的服务器时最好采用多线程进行实现。
当服务进程调用accept函数获取到一个新连接后,就可以直接创建一个线程,让该线程为对应客户端提供服务。
当然,主线程(服务进程)创建出新线程后,也是需要等待新线程退出的,否则也会造成类似于僵尸进程这样的问题。但对于线程来说,如果不想让主线程等待新线程退出,可以让创建出来的新线程调用pthread_detach函数进行线程分离,当这个线程退出时系统会自动回收该线程所对应的资源。此时主线程(服务进程)就可以继续调用accept函数获取新连接,而让新线程去服务对应的客户端。
- 各个线程共享同一张文件描述符表
文件描述符表维护的是进程与文件之间的对应关系,因此一个进程对应一张文件描述符表。而主线程创建出来的新线程依旧属于这个进程,因此创建线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。
因此当服务进程(主线程)调用accept函数获取到一个文件描述符后,其他创建的新线程是能够直接访问这个文件描述符的。
需要注意的是,虽然新线程能够直接访问主线程accept上来的文件描述符,但此时新线程并不知道它所服务的客户端对应的是哪一个文件描述符,因此主线程创建新线程后需要告诉新线程对应应该访问的文件描述符的值,也就是告诉每个新线程在服务客户端时,应该对哪一个套接字进行操作。
- 参数结构体
实际新线程在为客户端提供服务时就是调用Service函数,而调用Service函数时是需要传入三个参数的,分别是客户端对应的套接字、IP地址和端口号。因此主线程创建新线程时需要给新线程传入三个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数。
这时我们可以设计一个参数结构体Param,此时这三个参数就可以放到Param结构体当中,当主线程创建新线程时就可以定义一个Param对象,将客户端对应的套接字、IP地址和端口号设计进这个Param对象当中,然后将Param对象的地址作为新线程执行例程的参数进行传入。
此时新线程在执行例程当中再将这个void类型的参数强转为Param类型,然后就能够拿到客户端对应的套接字,IP地址和端口号,进而调用Service函数为对应客户端提供服务。
class Param
{
public:Param(int sock, std::string ip, int port): _sock(sock), _ip(ip), _port(port){}~Param(){}
public:int _sock;std::string _ip;int _port;
};
- 文件描述符关闭的问题
由于此时所有线程看到的都是同一张文件描述符表,因此当某个线程要对这张文件描述符表做某种操作时,不仅要考虑当前线程,还要考虑其他线程。
-
对于主线程accept上来的文件描述符,主线程不能对其进行关闭操作,该文件描述符的关闭操作应该又新线程来执行。因为是新线程为客户端提供服务的,只有当新线程为客户端提供的服务结束后才能将该文件描述符关闭。
-
对于监听套接字,虽然创建出来的新线程不必关心监听套接字,但新线程不能将监听套接字对应的文件描述符关闭,否则主线程就无法从监听套接字当中获取新连接了。
-
Service函数定义为静态成员函数
由于调用pthread_create函数创建线程时,新线程的执行例程是一个参数为void*,返回值为void*的函数。如果我们要将这个执行例程定义到类内,就需要将其定义为静态成员函数,否则这个执行例程的第一个参数是隐藏的this指针。
在线程的执行例程当中会调用Service函数,由于执行例程是静态成员函数,静态成员函数无法调用非静态成员函数,因此我们需要将Service函数定义为静态成员函数。恰好Service函数内部进行的操作都是与类无关的,因此我们直接在Service函数前面加上一个static即可。
class TcpServer
{
public:static void* HandlerRequest(void* arg){pthread_detach(pthread_self()); //分离线程//int sock = *(int*)arg;Param* p = (Param*)arg;Service(p->_sock, p->_ip, p->_port); //线程为客户端提供服务delete p; //释放参数占用的堆空间return nullptr;}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 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;Param* p = new Param(sock, client_ip, client_port);pthread_t tid;pthread_create(&tid, nullptr, HandlerRequest, p);}}
private:int _listen_sock; //监听套接字int _port; //端口号
};
- 代码测试
此时我们再重新编译服务端代码,由于代码当中用到了多线程,因此编译时需要携带上-pthread选项。此外,由于我们现在要监测的是一个个的线程,因此在监控时使用的不再是ps -axj命令,而是ps -aL命令。
while :; do ps -aL|head -1&&ps -aL|grep tcp_server;echo "####################";sleep 1;done
运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来。
当一个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用Service函数为该客户端提供服务,因此在监控当中显示了两个线程。
当第二个客户端发来连接请求时,主线程会进行相同的操作,最终再创建出一个新线程为该客户端提供服务,此时服务端当中就有了三个线程。
由于为这两个客户端提供服务的也是两个不同的执行流,因此这两个客户端可以同时享受服务端提供的服务,它们发送给服务端的消息也都能够在服务端进行打印,并且这两个客户端也都能够收到服务端的回显数据。
此时无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来。
🌟4. 线程池版的TCP网络程序
- 单纯多线程存在的问题
当前多线程版的服务器存在的问题:
-
每当有新连接到来时,服务端的主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁。这样做不仅麻烦,而且效率低下,每当连接到来的时候服务端才创建对应提供服务的线程。
-
如果有大量的客户端连接请求,此时服务端要为每一个客户端创建对应的服务线程。计算机当中的线程越多,CPU的压力就越大,因为CPU要不断在这些线程之间来回切换,此时CPU在调度线程的时候,线程和线程之间切换的成本就会变得很高。此外,一旦线程太多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也迟迟得不到应答。
-
解决思路
针对这两个问题,对应的解决思路如下:
- 可以在服务端预先创建一批线程,当有客户端请求连接时就让这些线程为客户端提供服务,此时客户端一来就有线程为其提供服务,而不是当客户端来了才创建对应的服务线程。
当某个线程为客户端提供完服务后,不要让该线程退出,而是让该线程继续为下一个客户端提供服务,如果当前没有客户端连接请求,则可以让该线程先进入休眠状态,当有客户端连接到来时再将该线程唤醒。 - 服务端创建的这一批线程的数量不能太多,此时CPU的压力也就不会太大。此外,如果有客户端连接到来,但此时这一批线程都在给其他客户端提供服务,这时服务端不应该再创建线程,而应该让这个新来的连接请求在全连接队列进行排队,等服务端这一批线程中有空闲线程后,再将该连接请求获取上来并为其提供服务。
- 引入线程池
实际要解决这里的问题我们就需要在服务端引入线程池,因为线程池的存在就是为了避免处理短时间任务时创建与销毁线程的代价,此外,线程池还能够保证内核充分利用,防止过分调度。
其中在线程池里面有一个任务队列,当有新的任务到来的时候,就可以将任务Push到线程池当中,在线程池当中我们默认创建了5个线程,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的Run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠状态。
在博主的另一篇博客当中详细介绍并实现了线程池,这里就直接将线程池的代码接入到当前的TCP服务器
#define NUM 5//线程池
template<class T>
class ThreadPool
{
private:bool IsEmpty(){return _task_queue.size() == 0;}void LockQueue(){pthread_mutex_lock(&_mutex);}void UnLockQueue(){pthread_mutex_unlock(&_mutex);}void Wait(){pthread_cond_wait(&_cond, &_mutex);}void WakeUp(){pthread_cond_signal(&_cond);}
public:ThreadPool(int num = NUM): _thread_num(num){pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_cond, nullptr);}~ThreadPool(){pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_cond);}//线程池中线程的执行例程static void* Routine(void* arg){pthread_detach(pthread_self());ThreadPool* self = (ThreadPool*)arg;//不断从任务队列获取任务进行处理while (true){self->LockQueue();while (self->IsEmpty()){self->Wait();}T task;self->Pop(task);self->UnLockQueue();task.Run(); //处理任务}}void ThreadPoolInit(){pthread_t tid;for (int i = 0; i < _thread_num; i++){pthread_create(&tid, nullptr, Routine, this); //注意参数传入this指针}}//往任务队列塞任务(主线程调用)void Push(const T& task){LockQueue();_task_queue.push(task);UnLockQueue();WakeUp();}//从任务队列获取任务(线程池中的线程调用)void Pop(T& task){task = _task_queue.front();_task_queue.pop();}private:std::queue<T> _task_queue; //任务队列int _thread_num; //线程池中线程的数量pthread_mutex_t _mutex;pthread_cond_t _cond;
};
- 服务类新增线程池成员
现在服务端引入了线程池,因此在服务类当中需要新增一个指向线程池的指针成员:
- 当实例化服务器对象时,先将这个线程池指针先初始化为空。
- 当服务器初始化完毕后,再实际构造这个线程池对象,在构造线程池对象时可以指定线程池当中线程的个数,也可以不指定,此时默认线程的个数为5。
- 在启动服务器之前对线程池进行初始化,此时就会将线程池当中的若干线程创建出来,而这些线程创建出来后就会不断检测任务队列,从任务队列当中拿出任务进行处理。
现在当服务进程调用accept函数获取到一个连接请求后,就会根据该客户端的套接字、IP地址以及端口号构建出一个任务,然后调用线程池提供的Push接口将该任务塞入任务队列。
这实际也是一个生产者消费者模型,其中服务进程就作为了任务的生产者,而后端线程池当中的若干线程就不断从任务队列当中获取任务进行处理,它们承担的就是消费者的角色,其中生产者和消费者的交易场所就是线程池当中的任务队列。
class TcpServer
{
public:TcpServer(int port): _listen_sock(-1), _port(port), _tp(nullptr){}void InitServer(){//创建套接字_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, BACKLOG) < 0){std::cerr << "listen error" << std::endl;exit(4);}_tp = new ThreadPool<Task>(); //构造线程池对象}void Start(){_tp->ThreadPoolInit(); //初始化线程池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 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;Task task(sock, client_ip, client_port); //构造任务_tp->Push(task); //将任务Push进任务队列}}
private:int _listen_sock; //监听套接字int _port; //端口号ThreadPool<Task>* _tp; //线程池
};
- 设计任务类
现在我们要做的就是设计一个任务类,该任务类当中需要包含客户端对应的套接字、IP地址、端口号,表示该任务是为哪一个客户端提供服务,对应操作的套接字是哪一个。
此外,任务类当中需要包含一个Run方法,当线程池中的线程拿到任务后就会直接调用这个Run方法对该任务进行处理,而实际处理这个任务的方法就是服务类当中的Service函数,服务端就是通过调用Service函数为客户端提供服务的。
我们可以直接拿出服务类当中的Service函数,将其放到任务类当中作为任务类当中的Run方法,但这实际不利于软件分层。我们可以给任务类新增一个仿函数成员,当执行任务类当中的Run方法处理任务时就可以以回调的方式处理该任务。
class Task
{
public:Task(){}Task(int sock, std::string client_ip, int client_port): _sock(sock), _client_ip(client_ip), _client_port(client_port){}~Task(){}//任务处理函数void Run(){_handler(_sock, _client_ip, _client_port); //调用仿函数}
private:int _sock; //套接字std::string _client_ip; //IP地址int _client_port; //端口号Handler _handler; //处理方法
};
注意: 当任务队列当中有任务时,线程池当中的线程会先定义出一个Task对象,然后将这个Task对象作为输出型参数调用任务队列的Pop函数,从任务队列当中获取任务,因此Task类除了提供带参的构造函数以外,还需要提供一个无参的构造函数,方便我们可以定义无参对象。
- 设计Handler类
此时需要再设计一个Handler类,在Handler类当中对()操作符进行重载,将()操作符的执行动作重载为执行Service函数的代码。
class Handler
{
public:Handler(){}~Handler(){}void operator()(int sock, std::string client_ip, int client_port){char buffer[1024];while (true){ssize_t size = read(sock, buffer, sizeof(buffer)-1);if (size > 0){ //读取成功buffer[size] = '\0';std::cout << client_ip << ":" << client_port << "# " << buffer << std::endl;write(sock, buffer, size);}else if (size == 0){ //对端关闭连接std::cout << client_ip << ":" << client_port << " close!" << std::endl;break;}else{ //读取失败std::cerr << sock << " read error!" << std::endl;break;}}close(sock); //归还文件描述符std::cout << client_ip << ":" << client_port << " service done!" << std::endl;}
};
实际我们可以让服务器处理不同的任务,当前服务器只是在进行字符串的回显处理,而实际要怎么处理这个任务完全是由任务类当中的handler成员来决定的。
如果想要让服务器处理其他任务,只需要修改Handler类当中对()的重载函数就行了,而服务器的初始化、启动服务器以及线程池的代码都是不需要更改的,这就叫做把通信功能和业务逻辑在软件上做解耦。
相关文章:

【Linux】网络编程套接字(中)
🎇Linux: 博客主页:一起去看日落吗分享博主的在Linux中学习到的知识和遇到的问题博主的能力有限,出现错误希望大家不吝赐教分享给大家一句我很喜欢的话: 看似不起波澜的日复一日,一定会在某一天让你看见坚持…...

手撕数据结构—队列
队列队列的话只允许在一端插入,在另外一端删除。插入数据的那一段叫做队尾,出数据的那一段叫做队头(从尾巴插入)。因此的话队列是先进先出的。入的顺序与出的顺序的话是一样的。这个与栈是不一样的,因为栈的话就是说如…...

gdb调试工具和makemakefile工具
gdb调试工具和make/makefile工具 文章目录gdb调试工具和make/makefile工具一、gdb调试工具1.debug/release2.使用二、make/makefile1.什么是make/makefile2.编写一、gdb调试工具 1.debug/release 程序有两种默认的发布方式debug和release。release是无法进行调试的。Linux中g…...

【进阶数据结构】平衡搜索二叉树 —— AVL树
🌈感谢阅读East-sunrise学习分享——[进阶数据结构]AVL树 博主水平有限,如有差错,欢迎斧正🙏感谢有你 码字不易,若有收获,期待你的点赞关注💙我们一起进步🚀 🌈我们上一篇…...

ROS使用(5)action学习
action消息的构建 首先进行功能包的创建 mkdir -p ros2_ws/src cd ros2_ws/src ros2 pkg create action_tutorials_interfaces action消息的类型 # Request --- # Result --- # Feedback 动作定义由三个消息定义组成,以---分隔。 从动作客户机向动作服务器发送…...

2023前端面试题集(含答案)之HTML+CSS篇(一)
在又到了金三银四的招聘季,不管你是刚入行的小白,亦或是混迹职场的老鸟,还在为面试前端工程师时不知道面试官要问什么怎么回答而苦恼吗?为了帮助你获得面试官的青睐,顺利通过面试,跳槽进入大厂,…...

设计模式2 - 观察者模式
定义: 观察者模式又叫发布订阅模式,它定义了对象之间的一对多依赖,这样一来,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新。 组成: Subject(通知者/被观察者)&#…...

ini配置文件
ini配置文件 ini文件是initialization file的缩写,即初始化文件,是widows系统配置文件所采用的存储格式。 文件扩展名: .ini ini配置文件的后缀名也不一定必须是.ini, 也可以是.cfg, .conf或者是.txt ini文件格式 ini配置文件由参数, 节, 注解组成 参…...

蓝桥杯备赛经验 pythonA组(非科班选手)
个人2022 CA组江苏省一等奖,决赛成绩不理想,没有拿到一二等奖,但是因为自己是非科班的学生,所以能拿到这样的成绩自己其实也应该知足了 题外话: 很多ACMer嘲笑蓝桥杯非常水,但是据我观察CA组决赛一等奖获奖…...

C++实现通讯录管理系统
通讯录是一个可以记录亲人、好友信息的工具,本博客借助黑马程序员的项目进行修改,利用C实现一个通讯录管理系统,旨在复习C的语法。 一、系统需求 系统需要实现的功能如下: 添加联系人∶向通讯录中添加新人,信息包括…...

开关电源Y电容放置的位置
Y电容,是我们工程师做开关电源设计时都要接触到的一个非常关键的元器件,它对EMI的贡献是相当的大的,但是它是一个较难把控的元器件,原理上并没有那么直观易懂,在EMI传播路径中需要联系到很多的寄生参数才能够去分析。 …...

二叉树的最小深度——递归法、迭代法
1题目给定一个二叉树,找出其最小深度。最小深度是从根节点到最近叶子节点的最短路径上的节点数量。说明:叶子节点是指没有子节点的节点。示例 1:输入:root [3,9,20,null,null,15,7]输出:2示例 2:输入&…...

Vue中常使用的三种刷新页面的方式
一、通过js原始方法刷新 缺点: 出现闪白 目录 一、通过js原始方法刷新 二、通过Vue自带的路由进行跳转 三、通过在APP页面进行demo进行刷新(推荐) 1.vue2写法 2. vue3.2写法 <template><div><div class"header"><button clic…...

【Shell】脚本
Shell脚本脚本格式第一个Shell脚本:hello.sh脚本常用执行方式1. bash或sh脚本的相对路径或绝对路径2. 输入脚本的绝对路径或相对路径3. 在脚本的路径前加上.或者source脚本格式 脚本以#!/bin/bash开头(指定解析器) #! 是一个约定的标记&…...

Mybatis的多表操作
1.Mybatis多表查询 1.1一对一查询 1.一对一查询的模型 用户表和订单表的关系为,一个用户有多个订单,一个订单只从属于一个用户 一对一查询的需求:查询一个订单,与此同时查询出该订单所属的用户2.创建Order和User实体public class…...

【JVM】字节码指令全解
文章目录 入门案例原始 java 代码编译后的字节码文件常量池载入运行时常量池方法字节码载入方法区main 线程开始运行,分配栈帧内存执行引擎开始执行字节码bipush 10istore_1ldc #3istore_2iload_1iload_2iaddistore_3getstatic #4iload_3invokevirtual #5return条件判断指令循…...

【精品】华为认证数通HCIA+HCIP题库分享(含答案解析)
嗨~大家好久不见,我是薄荷学姐,随着华为业务也全球领域的迅猛发展,越来越多人开始重视华为认证的重要性。今天给大家分享一下去年8月份的题库,基本都是一样,希望可以帮助到大家哈想要通过华为认证,除了进行…...

Qt cmake 资源文件的加载
Qt cmake 资源文件的加载概述qt_add_resourcesqt5_add_resourcesqt6_add_resources是否需要加载qrc文件需要加载qrc的情况不需要加载qrc的情况C 代码加载示例加载PNG加载CSS文件加载qrc文件Qt6相对于Qt5的一些变化Qt6和Qt5在加载资源文件方面的区别主要集中在两个方面ÿ…...

【链表OJ题(九)】环形链表延伸问题以及相关OJ题
环形链表OJ题 1. 环形链表 链接:141. 环形链表 描述: 给你一个链表的头节点 head ,判断链表中是否有环。 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环&…...

【C++初阶】四、类和对象(下)
文章目录一、再谈构造函数构造函数体赋值初始化列表explicit关键字二、Static成员引入- 计算类中创建了多少个类对象概念特性静态成员函数的访问三、友元友元函数友元类四、内部类五、匿名对象六、拷贝对象时的一些编译器优化一、再谈构造函数 构造函数体赋值 在创建对象时&a…...

IDEA maven没有Import Maven projects automatically解决办法
去年装了个 IDEA2022版本 配置maven时我才发现是个大坑 他没有import Maven projects automatically配置项 当时看到我人都麻了 然后项目呢 用了依赖 这东西还不会自动下依赖 整的我那是相当难受 还在最后还是找到了解决办法 我们在配置文件上点击右键 然后鼠标选择如下图选项…...

Java实习生------MySQL10道面试题打卡
今日语录:“没有执行力,就没有竞争力 ”🌹 参考资料:图解MySQL、MySQL面试题 1、事务有哪些特性? 原子性: 一个事务中的所有操作,要么全部完成,要么全部不完成,不会出现…...

帆软报表设计器 数据集之数据库查询
当点击数据库查询时,调用TableDataTreePane的 public void actionPerformed(ActionEvent var1) {TableDataTreePane.this.dgEdit(this.getTableDataInstance().creatTableDataPane(), TableDataTreePane.this.createDsName(this.getNamePrefix()), false);} 然后调用TableDat…...

CSDN 第三十七期竞赛题解
很少有时间来玩玩题目,上一次因为环境极为嘈杂的原因在时间上没有进入前十,挺遗憾的。 在 CSDN 参加的第一次没出锅的比赛。 大概只有最后一题值得好好讲讲。 T1:幼稚班作业 幼稚园终于又有新的作业了。 老师安排同学用发给同学的4根木棒拼接…...

Vue实战【常用的Vue小魔法】
目录🌟前言🌟能让你首次加载更快的路由懒加载,怎么能忘?🌟你是否还记得有一个叫Object.freeze的方法?🌟异步组件那么强,你是不是没用过?🌟你是不是还在comput…...

用C跑爬虫
爬虫自指定的URL地址开始下载网络资源,直到该地址和所有子地址的指定资源都下载完毕为止。 下面开始逐步分析爬虫的实现。 待下载集合与已下载集合 为了保存需要下载的URL,同时防止重复下载,我们需要分别用了两个集合来存放将要下载的URL和…...

【C语言】你真的了解结构体吗
引言✨我们知道C语言中存在着整形(int、short...),字符型(char),浮点型(float、double)等等内置类型,但是有时候,这些内置类型并不能解决我们的需求,因为我们无法用这些单一的内置类型来描述一些复杂的对象,…...

血氧仪是如何得出血氧饱和度值的?
目录 一、血氧饱和度概念 二、血氧饱和度监测意义 三、血氧饱和度的监测方式 四、容积脉搏波计算血氧饱和度原理 五、容积脉搏波波形的测量电路方案 1)光源和光电探测器的集成测量模块:SFH7050—反射式 2)模拟前端 六、市面上血氧仪类型…...

Java全栈知识(3)接口和抽象类
1、抽象类 抽象类就是由abstract修饰的类,其中没有只声明没有实现的方法就是抽象方法,抽象类中可以有0个或者多个抽象方法。 1.1、抽象类的语法 抽象类不能被final修饰 因为抽象类是一种类似于工程中未完成的中间件。需要有子类进行继承完善其功能,所…...

JavaScript == === Object.is()
文章目录JavaScript & & Object.is() 相等运算符 全等运算符Object.is() 值比较JavaScript & & Object.is() 相等运算符 相等运算符,会先进行类型转换,将2个操作数转为相同的类型,再比较2个值。 console.log("10&…...