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

网络套接字(二)

目录

简单的TCP网络程序

搭建TCP服务器

创建套接字

绑定套接字

监听套接字

服务端获取连接

服务端处理请求

客户端创建套接字

客户端发起请求

服务器测试

单执行流服务器的弊端

多进程版的TCP网络程序

捕捉SIGCHLD信号

让孙子进程提供服务

多线程版的TCP网络程序

线程池版的TCP网络程序


简单的TCP网络程序

搭建TCP服务器

与UDP相比,TCP是流式的,多了一个监听

创建套接字

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

  • socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符;
  • 应用程序可以像读写文件一样用read/write在网络上收发数据;
  • 如果socket()调用出错则返回-1;
  • 对于IPv4, family参数指定为AF_INET;
  • 创建套接字时所需的服务类型应该是SOCK_STREAM,因为我们编写的是TCP服务器SOCK_STREAM提供的就是一个有序的、可靠的、全双工的、基于连接的流式服务。
  • protocol参数的介绍从略,指定为0即可。

绑定套接字

TCP服务器在调用bind进行绑定时,参数设置如下:

  • 服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接;
  • 服务器需要调用bind绑定一个固定的网络地址和端口号;
  • bind()成功返回0,失败返回-1。 bind()的作用是将参数sockfd和myaddr绑定在一起, 使sockfd这个用于网络通讯的文件描述符监听myaddr所描述的地址和端口号;
  • 前面讲过,struct sockaddr *是一个通用指针类型,myaddr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度;

监听套接字

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

listen函数

将套接字设置监听状态函数原型如下:

int listen(int sockfd, int backlog);

参数说明:

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

返回值说明:

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

以上就是初始化服务端流程:

class TcpServer
{public:TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip) : _ip(ip), _port(port){}void tverinit(){//创建套接字_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){lg(FATAL, "listensocket create fail, sockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));exit(SOCKETERR);}lg(INFO, "listensocket create success, sockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));struct sockaddr_in local;// bzero(&local, sizeof(local));memset(&local, 0, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(_port);local.sin_addr.s_addr = INADDR_ANY;// local.sin_addr.s_addr = inet_addr(_ip.c_str());socklen_t len = sizeof(local);// 绑定套接字if (bind(_listensockfd, (struct sockaddr *)&local, len) < 0){lg(FATAL, "listensockfd bind fail, listensockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));exit(BINDERR);}lg(INFO, "listensockfd bind success, listensockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));// 将套接字设置为监听状态if (listen(_listensockfd, 5) < 0){lg(FATAL, "listen fail, errno: %d, strerror: %s\n", errno, strerror(errno));exit(LISTENERR);}lg(INFO, "listen success, errno: %d, strerror: %s\n", errno, strerror(errno));}~TcpServer(){};
private:int _listensockfd;uint16_t _port;std::string _ip;
};

说明一下

  • 初始化TCP服务器时创建的套接字并不是普通的套接字,而应该叫做监听套接字。为了表明寓意,我们将代码中套接字的名字由sock改为listen
    socket。
  • 在初始化TCP服务器时,只有创建套接字成功、绑定成功、监听成功,此时TCP服务器的初始化才算完成。

服务端获取连接

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

accept函数

获取连接的函数叫做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。
void start(){std::cout << "threadpool start" << std::endl;SingletonThreadPool<Task>::GetInStance()->ThreadStart();for (;;){struct sockaddr_in client;socklen_t len = sizeof(client);//获取新链接, 失败不影响获取下一个int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){lg(WARNING, "_sockfd create fail, sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));continue;}uint16_t clientport = ntohs(client.sin_port);char clientip[32]; // 维护ip只需要16字节的大小,以防万一我们开辟32字节inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); //将字符串维护在clientip数组中lg(INFO, "create a new link ..., sockfd: %d, clientip: %s, clientport: %d\n", sockfd, clientip, clientport);// TCP是面向字节流的,单线程版本close(_sockfd);}}

服务端接收连接测试

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

#include <iostream>
#include "TcpServer.hpp"void Usage(const std::string &proc)
{	std::cout << "\r\nUsage: " << proc << "Serverport Serverip" << std::endl;
}int main(int argc, char *argv[])
{// ./TcpServer portif(argc != 2){Usage(argv[0]);exit(0);}uint16_t Serverport = atoi(argv[1]);std::unique_ptr<TcpServer> ts(new TcpServer(Serverport));ts->tverinit();ts->start();return 0;
}

编译代码后,以./tcp_server 端口号的方式运行服务端。


服务端运行后,通过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。

服务端处理请求

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

read函数

TCP服务器读取数据的函数叫做read与UDP中recvfrom函数作用类似,该函数的函数原型如下:

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

函数参数说明:

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

返回值说明:

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

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

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

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

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

write函数

TCP服务器写入数据的函数叫做write与UDP中sendto函数作用类似,该函数的函数原型如下:

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

参数说明:

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

返回值说明:

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

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

服务端处理请求

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

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

class TcpServer
{public:TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip) : _ip(ip), _port(port), _running(false){}void start(){_running = true;for (;;){struct sockaddr_in client;socklen_t len = sizeof(client);//获取新链接, 失败不影响获取下一个int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){lg(WARNING, "_sockfd create fail, sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));continue;}uint16_t clientport = ntohs(client.sin_port);char clientip[32]; // 维护ip只需要16字节的大小,以防万一我们开辟32字节inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); //将字符串维护在clientip数组中lg(INFO, "create a new link ..., sockfd: %d, clientip: %s, clientport: %d\n", sockfd, clientip, clientport);//TCP是面向字节流的,单线程版本Service(sockfd, clientip, clientport);close(sockfd);}}void Service(int sockfd, const std::string &clientip, const uint16_t &clientport){char buffer[4096];while (true){ssize_t n = read(sockfd, buffer, sizeof(buffer));if (n > 0){buffer[n] = {0};std::cout << "client say@ ";std::string message = buffer;std::cout << message << std::endl;write(sockfd, message.c_str(), sizeof(message));}else if (n == 0){lg(INFO, "Did not get it! [sockfd: %d, clientip: %s : clientport: %d] quit...\n", sockfd, clientip, clientport);break;}else{lg(WARNING, "Did not get it! [sockfd: %d, clientip: %s : clientport: %d] quit...\n", sockfd, clientip, clientport);break;}}}~TcpServer(){};
private:int _listensockfd;uint16_t _port;std::string _ip;
};

客户端创建套接字

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

connect函数

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

参数说明:

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

返回值说明:

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

客户端连接服务器

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

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

enum
{SOCKERR = 1	
};void Usage(const std::string &proc)
{	std::cout << "\r\nUsage: " << proc << "Serverport Serverip" << std::endl;
}int main(int argc, char *argv[])
{// ./TcpServer Serverip Serverportif(argc != 3){Usage(argv[0]);exit(0);}std::string Serverip = argv[1];uint16_t Serverport = std::stoi(argv[2]);int sockfd = 0;struct sockaddr_in Server;Server.sin_family = AF_INET;Server.sin_addr.s_addr = inet_addr(Serverip.c_str());Server.sin_port = htons(Serverport);socklen_t len = sizeof(Server);bool isreconnect = false;int cnt = 5;while(true){sockfd = socket(AF_INET, SOCK_STREAM, 0);if(sockfd < 0){lg(FATAL, "socket create fail... sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));exit(SOCKERR);}lg(INFO, "socket create success... sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));do{//连接int n = connect(sockfd, (struct sockaddr *)&Server, len);if(n > 0){isreconnect = true;std::cout << "connect error... isreconnect: " << cnt--  << std::endl;}else{break;}}while(cnt && isreconnect);if(cnt == 0){std::cerr <<  "user offline..." << std::endl;break;}}close(sockfd);return 0;
}

客户端发起请求

当客户端连接到服务端后,客户端就可以向服务端发送数据了,这里我们可以让客户端将用户输入的数据发送给服务端,发送时调用write函数向套接字当中写入数据即可。

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

enum
{SOCKERR = 1	
};void Usage(const std::string &proc)
{	std::cout << "\r\nUsage: " << proc << "Serverport Serverip" << std::endl;
}int main(int argc, char *argv[])
{// ./TcpServer Serverip Serverportif(argc != 3){Usage(argv[0]);exit(0);}std::string Serverip = argv[1];uint16_t Serverport = std::stoi(argv[2]);int sockfd = 0;struct sockaddr_in Server;Server.sin_family = AF_INET;Server.sin_addr.s_addr = inet_addr(Serverip.c_str());Server.sin_port = htons(Serverport);socklen_t len = sizeof(Server);bool isreconnect = false;int cnt = 5;while(true){sockfd = socket(AF_INET, SOCK_STREAM, 0);if(sockfd < 0){lg(FATAL, "socket create fail... sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));exit(SOCKERR);}lg(INFO, "socket create success... sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));do{//连接int n = connect(sockfd, (struct sockaddr *)&Server, len);if(n > 0){isreconnect = true;std::cout << "connect error... isreconnect: " << cnt--  << std::endl;}else{break;}}while(cnt && isreconnect);if(cnt == 0){std::cerr <<  "user offline..." << std::endl;break;}while(true){//连接完成进行收发std::string message;std::cout << "plase Enter# ";getline(std::cin, message);ssize_t n = write(sockfd, message.c_str(), message.size());if(n <= 0){std::cout << "write fail..." << std::endl;}char inbuffer[4096];n = read(sockfd, inbuffer, sizeof(inbuffer));if(n > 0){inbuffer[n] = 0;std::cout << inbuffer << std::endl;}else{std::cout << "read fail..." << std::endl;break;}std::cout << "detail complete..." << std::endl;break;}break;}close(sockfd);return 0;
}

在运行客户端程序时我们就需要携带上服务端对应的IP地址和端口号

服务器测试

现在服务端和客户端均已编写完毕,下面我们进行测试。测试时我们先启动服务端,然后通过netstat命令进行查看,此时我们就能看到一个名为tcp_server的服务进程,该进程当前处于监听状态。

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

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

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

注意: 此时是服务端对该客户端的服务终止了,而不是服务器终止了,此时服务器依旧在运行,它在等待下一个客户端的连接请求。

单执行流服务器的弊端

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

单执行流的服务器

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

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

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

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

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

如何解决?

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

多进程版的TCP网络程序

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

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

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

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

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

等待子进程问题

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

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

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

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

不等待子进程退出的方式

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

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

捕捉SIGCHLD信号

该方式实现起来非常简单,也是比较推荐的一种做法。

class TcpServer
{
public:void start(){signal(SIGCLID, SIG_IGN); //将子进程退出信号修改为忽略for (;;){struct sockaddr_in client;socklen_t len = sizeof(client);//获取新链接, 失败不影响获取下一个int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){lg(WARNING, "_sockfd create fail, sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));continue;}uint16_t clientport = ntohs(client.sin_port);char clientip[32]; // 维护ip只需要16字节的大小,以防万一我们开辟32字节inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); //将字符串维护在clientip数组中lg(INFO, "create a new link ..., sockfd: %d, clientip: %s, clientport: %d\n", sockfd, clientip, clientport);// TCP是面向字节流的,单线程版本Service(sockfd, clientip, clientport);close(sockfd);//多进程版本 缺点:创建一个进程成本太高,进程地址空间 页表 部分物理空间pid_t id = fork();if(id == 0){//因为子进程会继承父进程的文件描述符,listensockfd和sockfd都会被子进程继承//其中listensockfd监听描述符可关可不关,sockfd进行服务的描述符建议还是关掉//不关的话,父进程文件描述符表的位置会越用越少close(_listensockfd);Service(sockfd, clientip, clientport); //服务结束之后关闭对应的描述符->子进程close(sockfd);exit(0);} }}
private:int _listensockfd;uint16_t _port;std::string _ip;
};

代码测试

重新编译程序运行服务端后,可以通过以下监控脚本对服务进程进行监控。

while :; do ps ajx | head -1 && ps ajx | grep -v grep | grep TcpServer ; echo "#####################" ; sleep 1; done

此时可以看到,一开始没有客户端连接该服务器,此时服务进程只有一个,该服务进程就是不断获取新连接的进程,而获取到新连接后也是由该进程创建子进程为对应客户端提供服务的。

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

当客户端一个个退出后,在服务端对应为之提供服务的子进程也会相继退出,但无论如何服务端都至少会有一个服务进程,这个服务进程的任务就是不断获取新连接。

让孙子进程提供服务

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

关闭对应的文件描述符

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

对于服务进程来说,当它调用fork函数后就必须将从accept函数获取的文件描述符关掉。因为服务进程会不断调用accept函数获取新的文件描述符(服务套接字),如果服务进程不及时关掉不用的文件描述符,最终服务进程中可用的文件描述符就会越来越少。

而对于爸爸进程和孙子进程来说,还是建议关闭从服务进程继承下来的监听套接字。实际就算它们不关闭监听套接字,最终也只会导致这一个文件描述符泄漏,但一般还是建议关上。因为孙子进程在提供服务时可能会对监听套接字进行某种误操作,此时就会对监听套接字当中的数据造成影响。

	void start(){// std::cout << "threadpool start" << std::endl;// SingletonThreadPool<Task>::GetInStance()->ThreadStart();signal(SIGCHLD, SIG_IGN);_running = true;for (;;){struct sockaddr_in client;socklen_t len = sizeof(client);//获取新链接, 失败不影响获取下一个int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){lg(WARNING, "_sockfd create fail, sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));continue;}uint16_t clientport = ntohs(client.sin_port);char clientip[32]; // 维护ip只需要16字节的大小,以防万一我们开辟32字节inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); //将字符串维护在clientip数组中lg(INFO, "create a new link ..., sockfd: %d, clientip: %s, clientport: %d\n", sockfd, clientip, clientport);// TCP是面向字节流的,单线程版本// Service(sockfd, clientip, clientport);// close(sockfd);//多进程版本 缺点:创建一个进程成本太高,进程地址空间 页表 部分物理空间pid_t id = fork();if(id == 0){// std::cout << sockfd << std::endl;//因为爸爸进程会继承爷爷进程的文件描述符,listensockfd和sockfd都会被爸爸进程继承//其中listensockfd监听描述符可关可不关,因为最后只会泄露一个文件描述符影响不打,sockfd进行服务的描述符建议还是关掉//不关的话,父进程文件描述符表的位置会越用越少close(_listensockfd);if(fork() > 0) exit(0); //爸爸进程退出,让爷爷进程回收Service(sockfd, clientip, clientport); //正真执行服务的是孙子进程//服务结束之后关闭对应的描述符->孙子进程close(sockfd);exit(0);} //服务完之后,爷爷进程也关闭对应的描述符,不会影响其他进程close(sockfd); //打印的是爷爷进程的文件描述符,关闭的话会一直用文件描述符表的一个位置pid_t rid = waitpid(id, nullptr, 0);//由于g++编译器比较严格,对rid变量进行使用(void)rid;}}

 代码测试

重新编译程序运行服务端后,可以通过以下监控脚本对服务进程进行监控。

while :; do ps ajx | head -1 && ps ajx | grep -v grep | grep TcpServer ; echo "#####################" ; sleep 1; done

此时我们运行一个客户端,让该客户端连接当前这个服务器,此时服务进程会创建出爸爸进程,爸爸进程再创建出孙子进程,之后爸爸进程就会立刻退出,而由孙子进程为客户端提供服务。因此这时我们只看到了两个服务进程,其中一个是一开始用于获取连接的服务进程,还有一个就是孙子进程,该进程为当前客户端提供服务,它的PPID为1,表明这是一个孤儿进程,最后会被系统进行回收。

多线程版的TCP网络程序

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

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

所以线程共享一个进程地址空间,所以看到的是同一张文件描述符表

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

参数结构体

实际新线程在为客户端提供服务时就是调用Service函数,而调用Service函数时是需要传入四个参数的,分别是客户端对应的套接字、IP地址和端口号,和this指针(因为Service是类类成员,而线程函数是静态的,所以我们需要this来调用Service函数)。因此主线程创建新线程时需要给新线程传入四个参数,而实际在调用pthread_create函数创建新线程时,只能传入一个类型为void*的参数。

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

其实也可以直接将Service放在类外面,这样就可以直接调用

struct ThreadData
{ThreadData(int sockfd, const uint16_t &clientport, const std::string &clientip, TcpServer *tver): _sockfd(sockfd), _clientport(clientport), _clientip(clientip) ,_tver(tver) {}int _sockfd;uint16_t _clientport;std::string _clientip;TcpServer* _tver;
};

文件描述符关闭的问题

class TcpServer
{public:TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip) : _ip(ip), _port(port), _running(false){}void tverinit(){_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){lg(FATAL, "listensocket create fail, sockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));exit(SOCKETERR);}lg(INFO, "listensocket create success, sockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));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;socklen_t len = sizeof(local);// bindif (bind(_listensockfd, (struct sockaddr *)&local, len) < 0){lg(FATAL, "listensockfd bind fail, listensockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));exit(BINDERR);}lg(INFO, "listensockfd bind success, listensockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));// 将套接字修改为监听状态if (listen(_listensockfd, 5) < 0){lg(FATAL, "listen fail, errno: %d, strerror: %s\n", errno, strerror(errno));exit(LISTENERR);}lg(INFO, "listen success, errno: %d, strerror: %s\n", errno, strerror(errno));}static void *Routine(void* args){pthread_detach(pthread_self());ThreadData* td = static_cast<ThreadData*>(args);td->_tver->Service(td->_sockfd, td->_clientip, td->_clientport);delete td;return nullptr;}void start(){signal(SIGCHLD, SIG_IGN);_running = true;for (;;){struct sockaddr_in client;socklen_t len = sizeof(client);//获取新链接, 失败不影响获取下一个int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){lg(WARNING, "_sockfd create fail, sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));continue;}uint16_t clientport = ntohs(client.sin_port);char clientip[32]; // 维护ip只需要16字节的大小,以防万一我们开辟32字节inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); //将字符串维护在clientip数组中lg(INFO, "create a new link ..., sockfd: %d, clientip: %s, clientport: %d\n", sockfd, clientip, clientport);ThreadData* td = new ThreadData(sockfd, clientport, clientip, this); //不要使用join进行线程等待,会造成主进程卡住,不会往后继续进行了pthread_t tid;pthread_create(&tid, nullptr, Routine, td);}}void Service(int sockfd, const std::string &clientip, const uint16_t &clientport){char buffer[4096];while (true){ssize_t n = read(sockfd, buffer, sizeof(buffer));if (n > 0){buffer[n] = {0};std::cout << "client say@ ";std::string message = buffer;std::cout << message << std::endl;write(sockfd, message.c_str(), sizeof(message));}else if (n == 0){lg(INFO, "Did not get it! [sockfd: %d, clientip: %s : clientport: %d] quit...\n", sockfd, clientip, clientport);break;}else{lg(WARNING, "Did not get it! [sockfd: %d, clientip: %s : clientport: %d] quit...\n", sockfd, clientip, clientport);break;}}}~TcpServer(){};
private:int _listensockfd;uint16_t _port;std::string _ip;
};

代码测试

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

运行服务端,通过监控可以看到,此时只有一个服务线程,该服务线程就是主线程,它现在在等待客户端的连接到来。

当两个客户端连接到服务端后,此时主线程就会为该客户端构建一个参数结构体,然后创建一个新线程,将该参数结构体的地址作为参数传递给这个新线程,此时该新线程就能够从这个参数结构体当中提取出对应的参数,然后调用Service函数为该客户端提供服务,因此在监控当中显示了三个线程。

由于为这两个客户端提供服务的也是两个不同的执行流,因此这两个客户端可以同时享受服务端提供的服务,它们发送给服务端的消息也都能够在服务端进行打印,并且这两个客户端也都能够收到服务端的回显数据。

无论有多少个客户端发来连接请求,在服务端都会创建出相应数量的新线程为对应客户端提供服务,而当客户端一个个退出后,为其提供服务的新线程也就会相继退出,最终就只剩下最初的主线程仍在等待新连接的到来。

线程池版的TCP网络程序

多线程存在的问题

客户不退线程会越来越多,线程越多就会导致CPU的压力变大,因为CPU需要不断的在这些线程之间进行来回的切换,此时CPU在调度执行线程的时候,线程和线程之间的切换成本就会变得很高

解决思路

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

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

使用线程池

线程池代码:

#pragma once#include <iostream>
#include <pthread.h>
#include <queue>
#include <vector>
#include <string>static const int defaultnum = 5;class ThreadInfo
{
public:pthread_t tid;std::string threadname;
};template <class T>
class SingletonThreadPool
{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 IsEmptyThreadPool(){return _tasksqueue.size() == 0;}public:static void* RoutineTasks(void* args){SingletonThreadPool<T> *TP = static_cast<SingletonThreadPool<T>*>(args);while(true){std::string name = TP->GetThreadName(pthread_self());TP->Lock();if(TP->IsEmptyThreadPool()){TP->Wait();}T t = TP->pop();TP->UnLock();t();}}public:void ThreadStart(){std::cout << "threadstart" << std::endl;int num = _threads.size();for(int i = 0; i < num; i++){pthread_create(&_threads[i].tid, nullptr, RoutineTasks, this);_threads[i].threadname = "thread-" + std::to_string(i);}}T pop(){T task = _tasksqueue.front();_tasksqueue.pop();return task;}std::string GetThreadName(pthread_t tid){for(const auto& e : _threads){if(tid == e.tid)return e.threadname;}return "none";}void push(const T& task){Lock();_tasksqueue.push(task);WakeUp();UnLock();}static SingletonThreadPool<T>* GetInStance(){//避免占用CPU和操作系统资源,做无意义的事,提高效率if(inst == nullptr){//避免多线程模式下,同时申请多个instpthread_mutex_lock(&slock);if(inst == nullptr){inst = new SingletonThreadPool<T>;}pthread_mutex_unlock(&slock);}return inst;}private:SingletonThreadPool(int num = defaultnum):_threads(num){pthread_mutex_init(&lock, nullptr);pthread_cond_init(&cond, nullptr);}~SingletonThreadPool(){pthread_mutex_destroy(&lock);pthread_cond_destroy(&cond);}//防拷贝SingletonThreadPool(const SingletonThreadPool<T>& STP) = delete;SingletonThreadPool<T>& operator=(const SingletonThreadPool<T>& STP) = delete;private:std::vector<ThreadInfo> _threads;std::queue<T> _tasksqueue;pthread_mutex_t lock;pthread_cond_t cond;static SingletonThreadPool<T> *inst;static pthread_mutex_t slock;
}; template<class T>
SingletonThreadPool<T>* SingletonThreadPool<T>::inst = nullptr;template<class T>
pthread_mutex_t SingletonThreadPool<T>::slock = PTHREAD_MUTEX_INITIALIZER;

服务端代码:

#include <iostream>
#include <string>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <memory>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <unordered_map>
#include <signal.h>
#include "SingletonThreadPool.hpp"
#include "log.hpp"
#include "Task.hpp"enum
{SOCKETERR = 1,BINDERR,LISTENERR
};const std::string defaultip = "0.0.0.0";
const uint16_t defaultport = 8080;class TcpServer;struct ThreadData
{ThreadData(int sockfd, const uint16_t &clientport, const std::string &clientip, TcpServer *tver): _sockfd(sockfd), _clientport(clientport), _clientip(clientip) ,_tver(tver) {}int _sockfd;uint16_t _clientport;std::string _clientip;TcpServer* _tver;
};class TcpServer
{public:TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip) : _ip(ip), _port(port), _running(false){}void tverinit(){_listensockfd = socket(AF_INET, SOCK_STREAM, 0);if (_listensockfd < 0){lg(FATAL, "listensocket create fail, sockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));exit(SOCKETERR);}lg(INFO, "listensocket create success, sockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));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;socklen_t len = sizeof(local);// bindif (bind(_listensockfd, (struct sockaddr *)&local, len) < 0){lg(FATAL, "listensockfd bind fail, listensockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));exit(BINDERR);}lg(INFO, "listensockfd bind success, listensockfd: %d, errno: %d, strerror: %s\n", _listensockfd, errno, strerror(errno));// 将套接字修改为监听状态if (listen(_listensockfd, 5) < 0){lg(FATAL, "listen fail, errno: %d, strerror: %s\n", errno, strerror(errno));exit(LISTENERR);}lg(INFO, "listen success, errno: %d, strerror: %s\n", errno, strerror(errno));}void start(){//线程池开始SingletonThreadPool<Task>::GetInStance()->ThreadStart();signal(SIGCHLD, SIG_IGN);_running = true;for (;;){struct sockaddr_in client;socklen_t len = sizeof(client);//获取新链接, 失败不影响获取下一个int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &len);if (sockfd < 0){lg(WARNING, "_sockfd create fail, sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));continue;}uint16_t clientport = ntohs(client.sin_port);char clientip[32]; // 维护ip只需要16字节的大小,以防万一我们开辟32字节inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); //将字符串维护在clientip数组中lg(INFO, "create a new link ..., sockfd: %d, clientip: %s, clientport: %d\n", sockfd, clientip, clientport);//线程池版本Task t(sockfd, clientport, clientip);SingletonThreadPool<Task>::GetInStance()->push(t);}}~TcpServer(){};
private:int _listensockfd;uint16_t _port;std::string _ip;
};

设计任务类

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

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

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

任务类代码:

#pragma once#include "SingletonThreadPool.hpp"
#include "init.hpp"
#include "log.hpp"Init init;enum
{EXITCODE = 0,DIVZERO,MODZERO
};class Task
{
public:Task(int sockfd, const uint16_t &port, const std::string &ip): _sockfd(sockfd), _port(port), _ip(ip) {}void operator()(){run();}void run(){char buffer[4096];// std::cout << "开始接收消息"<< std::endl;ssize_t n = read(_sockfd, buffer, sizeof(buffer) - 1);if (n > 0){buffer[n] = {0};std::cout << "client say@ ";// std::string message = buffer;std::string message = init.translation(buffer);std::cout << message << std::endl;write(_sockfd, message.c_str(), sizeof(message));}else if (n == 0){lg(INFO, "Did not get it! [sockfd: %d, clientip: %s : clientport: %d] quit...\n", _sockfd, _clientip, _clientport);}else{lg(WARNING, "Did not get it! [sockfd: %d, clientip: %s : clientport: %d] quit...\n", _sockfd, _clientip, _clientport);}close(_sockfd);}~Task(){}private:int _sockfd;uint16_t _port;std::string _ip;
};

客户端代码:

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <string>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include "log.hpp"enum
{SOCKERR = 1
};void Usage(const std::string &proc)
{std::cout << "\r\nUsage: " << proc << "Serverport Serverip" << std::endl;
}int main(int argc, char *argv[])
{// ./TcpServer Serverip Serverportif (argc != 3){Usage(argv[0]);exit(0);}std::string Serverip = argv[1];uint16_t Serverport = std::stoi(argv[2]);int sockfd = 0;struct sockaddr_in Server;Server.sin_family = AF_INET;Server.sin_addr.s_addr = inet_addr(Serverip.c_str());Server.sin_port = htons(Serverport);socklen_t len = sizeof(Server);bool isreconnect = false;int cnt = 5;while (true){sockfd = socket(AF_INET, SOCK_STREAM, 0);std::cout << sockfd << std::endl;if (sockfd < 0){lg(FATAL, "socket create fail... sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));exit(SOCKERR);}lg(INFO, "socket create success... sockfd: %d, errno: %d, strerror: %s\n", sockfd, errno, strerror(errno));do{// 连接int n = connect(sockfd, (struct sockaddr *)&Server, len);if (n > 0){isreconnect = true;std::cout << "connect error... isreconnect: " << cnt-- << std::endl;}else{break;}} while (cnt && isreconnect);if (cnt == 0){std::cerr << "user offline..." << std::endl;break;}while (true){// 连接完成进行收发std::string message;std::cout << "plase Enter# ";getline(std::cin, message);ssize_t n = write(sockfd, message.c_str(), message.size());if (n <= 0){std::cout << "write fail..." << std::endl;}char inbuffer[4096];n = read(sockfd, inbuffer, sizeof(inbuffer));if (n > 0){inbuffer[n] = 0;std::cout << inbuffer << std::endl;}else{std::cout << "read fail..." << std::endl;break;}std::cout << "detail complete..." << std::endl;break;}break;}close(sockfd);return 0;
}

测试代码

监控脚本:

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

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

此时当客户端连接服务器后,服务端的主线程就会获取该客户端的连接请求,并将其封装为一个任务对象后塞入任务队列,此时线程池中的5个线程就会有一个线程从任务队列当中获取到该任务,并执行该任务的处理函数为客户端提供服务。

当第二个客户端发起连接请求时,服务端也会将其封装为一个任务类塞到任务队列,然后线程池当中的线程再从任务队列当中获取到该任务进行处理,此时也是不同的执行流为这两个客户端提供的服务,因此这两个客户端也是能够同时享受服务的。

与之前不同的是,现在提供的是短服务,不会因为客户端长时间占用线程,而导致其他客户短长时间在连接队列汇总等待的问题,而且无论现在有多少客户端发来请求,在服务端都只会有线程池当中的5个线程为之提供服务,线程池当中的线程个数不会随着客户端连接的增多而增多,这些线程也不会因为客户端的退出而退出。

相关文章:

  • vue2+ThinkPHP5实现简单大文件切片上传
  • 软件逆向基础-扫雷篇
  • copy_paste
  • 力扣-98.验证二叉搜索树
  • OA 系统办公自动化包含哪些内容,关键功能模块与操作要点说明
  • CodeBuddy 接入 MCP,一键生成网站!
  • 操作系统期末复习笔记
  • HCIP-Datacom Core Technology V1.0_1认识网络设备
  • 计算机网络:移动通信蜂窝网络指的是什么?
  • AI编程:使用Trae + Claude生成原型图,提示词分享
  • 集星云推碰一碰源码搭建的核心模块
  • 2005-2022年各省绿色信贷水平测算数据(含原始数据+计算过程+计算结果)
  • 【CSS】使用 CSS 绘制三角形
  • 【Alist+RaiDrive挂载网盘到本地磁盘】
  • 673SJBH基于ASP的公交系统
  • 电脑内存智能监控清理,优化性能的实用软件
  • UPS是什么?UPS 不间断电源有哪些适配的升压芯片?
  • ET ProcessInnerSender类(实体) 分析
  • 场景以及八股复习篇
  • 图像采集卡的核心功能功与应用详解
  • 澎湃·镜相第二届非虚构写作大赛初选入围名单公示
  • 黄仕忠丨戏曲文献研究之回顾与展望
  • 人民日报评外卖平台被约谈:摒弃恶性竞争,实现行业健康发展
  • 落实中美经贸高层会谈重要共识,中方调整对美加征关税措施
  • 国家林业和草原局原党组成员、副局长李春良接受审查调查
  • 5年建成强化城市核心功能新引擎,上海北外滩“风景文化都是顶流”