【计算机网络】socket编程 --- 实现简易TCP网络程序

news/2024/9/18 8:35:04/ 标签: 网络, 计算机网络, tcp/ip, linux, 自动化, 运维, 网络协议

在这里插入图片描述

👦个人主页:Weraphael
✍🏻作者简介:目前正在学习c++和算法
✈️专栏:Linux
🐋 希望大家多多支持,咱一起进步!😁
如果文章有啥瑕疵,希望大佬指点一二
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注😍


目录

  • 一、 服务端
      • 1.1 前言
      • 1.2 创建套接字
      • 1.3 绑定套接字
      • 1.4 监听
      • 1.5 测试一下
      • 1.6 接受连接
      • 1.7 telnet命令
      • 1.8 处理连接
  • 二、客户端
  • 三、改进TCP网络程序
      • 3.1 单执行流服务器的弊端
      • 3.2 多进程版
      • 3.3 多线程版
      • 3.4 线程池版
  • 四、相关代码

一、 服务端

1.1 前言

这里我们规定将TCP服务器封装成一个类,以下是服务器程序框架

tcpServer.cc

#include <iostream>
#include "tcpServer.hpp"
#include <memory>
using namespace std;int main()
{// 1. 创建TCP服务器端对象unique_ptr<tcpserver> tcpsvr(new tcpserver());// 2. 初始化TCP服务器tcpsvr.Init();// 3. 启动TCP服务器tcpsvr.Run();return 0;
}

tcpServer.hpp

#pragma once
#include <iostream>
#include "log.hpp"log lg;class tcpserver
{
public:tcpserver(){}~tcpserver(){}void Init(){}void Run(){}private:
};

说明:lg是我往期封装的日志类对象,这个在UDP也使用过。具体可以参考一下文章:

  • 👉【计算机网络】socket网络编程 — 实现一些简易UDP网络程序
  • 👉【Linux】模拟实现一个简单的日志系统

1.2 创建套接字

要想进行网络通信,第一步就是要先创建套接字socket

【函数原型】

#include <sys/types.h>           
#include <sys/socket.h>int socket(int domain, int type, int protocol);

说明:

  • domain:指定套接字的协议族(如 AF_INET 表示IPv4AF_INET6表示IPv6网络AF_UNIX / AF_LOCAL用于本地进程间通信)。
  • type:指定套接字的类型(如 SOCK_STREAM 表示TCPSOCK_DGRAM 表示UDP)。
  • protocol:指定具体的协议,通常设置为 0 表示使用默认协议。

综上,TCP服务器在创建套接字时,参数设置如下:

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

  • 参数二:因为我们编写的是TCP服务器,所以选择SOCK_STREAM

  • 参数三:协议类型默认设置为0即可。

tcpServer.hpp

#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <cstring>
#include "log.hpp"log lg;enum
{SOCK_ERR = 1,
};class tcpserver
{
public:tcpserver(): _socketfd(-1){}~tcpserver(){if (_socketfd >= 0){close(_socketfd);}}void Init(){// 创建套接字_socketfd = socket(AF_INET, SOCK_STREAM, 0);if (_socketfd == -1) // 套接字创建失败{lg.logmessage(Fatal, "create socket. errno: %d, describe: %s", errno, strerror(errno));exit(SOCK_ERR);}lg.logmessage(Info, "create socket. errno: %d, describe: %s", errno, strerror(errno));}private:int _socketfd; // 套接字文件描述符};

说明:

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

  • 当析构服务器时,可以将服务器对应的文件描述符进行关闭。也可以选择不关闭。因为操作系统会在进程结束时自动回收文件描述符。然而,为了避免资源泄露和潜在的文件描述符耗尽问题,最好在程序退出前手动关闭文件描述符。这样可以更好地控制资源的释放。

1.3 绑定套接字

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

【函数原型】

#include <sys/types.h>         
#include <sys/socket.h>int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

说明:

  • sockfd:套接字的文件描述符,通常是由 socket 函数返回的。
  • addrsockaddr结构体是一个套接字的通用结构体,实际我们在进行网络通信时,还是要定义sockaddr_insockaddr_un这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr*
    • sockaddr_in结构体:用于跨网络通信
    • sockaddr_un结构体:是用于本地通信
    • 注意:使用以上结构体需要加上头文件<netinet/in.h>
  • addrlenaddr 结构体的大小,以字节为单位。
  • 返回值:成功返回 0;失败返回 -1,并设置errno以指示错误原因。

在这里插入图片描述


首先对于bind函数,第一个参数和第三个参数没的说。主要是第二个参数。因为是跨网络通信,我们需要定义struct sockaddr_in结构体。而该结构体具体有哪些成员呢?我们可以在vscode中查看sockaddr_in结构体的相关成员:

在这里插入图片描述

注意:因为该结构体中还有其他字段,我们可以不用管。因此,可以使用 memset 函数可以确保结构体的所有字段都被初始化为零,从而避免意外数据。

#include <string.h>
void *memset(void *s, int c, size_t len);
// s是指向要填充的内存区域的指针
// c是要填充的值
// len是要填充的字节数// -------------------------
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr)); // 将 addr 结构体的所有字节设置为零

这里再介绍一个函数bzero,它的作用是一个用于将内存区域的字节设置为零的函数。

#include <strings.h> 
void bzero(void *s, size_t len);

初始化之后,我们需要设置struct sockaddr_in成员变量,比如端口号和IP地址之类的

  • sin_family:表示协议家族。必须使用与socket创建时相同的协议家族。例如,如果你使用AF_INET 创建了套接字,bind时也应使用AF_INET
  • sin_port:表示端口号,是一个16位的整数。注意:端口号需要转换为网络字节序。因为只要进行网络通信,端口号一定是双方来回传输的数据,因此为了保证双方能够正常解析数据,需要将其转换为网络字节序。可以使用htons函数
#include <arpa/inet.h>  uint16_t htons(uint16_t hostshort);
  • sin_addr:其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,就是IP地址。我们用户最直观的就是输入类似于192.168.1.1这种字符串形式的IP地址,可是这里sin_addr的类型是32位无符号整数,那么我们要把字符串转化为32位无符号整数。并且在网络通信中,IP地址和端口号一样,也是双方来回传输的数据,也要保证双方能够正常解析数据。由于这些操作在网络中经常会用到,我们并不需要自己手动实现一遍,系统已经我们提供了一些函数:

在这里插入图片描述

对于IP地址,我们可以先将其设置为本地环回127.0.0.1来进行本地通信测试;当然也可以设置为公网IP地址,表示网络通信。

但需要注意的是:

  • 如果你使用的是虚拟机,那么可以设置为公网IP地址;如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显示绑定IP地址,但可以设置成本地环回地址,当然也可以直接设置为INADDR_ANY,这是个宏函数,本质上就是0.0.0.0。此时服务器就可以从本地任何一张网卡当中读取数据。
  • 另外,以上绑定只是在用户层上绑定了,填充完服务器网络相关的属性信息后,需要调用bind函数进行内核绑定。绑定实际就是将文件与网络关联起来,如果绑定失败也没必要进行后续操作了,直接终止程序即可。
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "log.hpp"log lg;enum
{SOCK_ERR = 1,BIND_ERR
};class tcpserver
{
public:tcpserver(const uint16_t &port, const std::string &ip = "0.0.0.0"): _socketfd(-1), _port(port), _ip(ip){}void Init(){// 1. 创建套接字_socketfd = socket(AF_INET, SOCK_STREAM, 0);if (_socketfd == -1) // 套接字创建失败{lg.logmessage(Fatal, "create socket. errno: %d, describe: %s", errno, strerror(errno));exit(SOCK_ERR);}lg.logmessage(Info, "create socket. errno: %d, describe: %s", errno, strerror(errno));// 2. 绑定套接字struct 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)); // 细节// 内核绑定int n = bind(_socketfd, (struct sockaddr *)&local, sizeof(local));if (n < 0) // 绑定失败{lg.logmessage(Fatal, "bind socket. errno: %d, describe: %s", errno, strerror(errno));exit(BIND_ERR);}lg.logmessage(Info, "bind socket. errno: %d, describe: %s", errno, strerror(errno));}private:int _socketfd; // 套接字文件描述符uint16_t _port; // 服务器端口号std::string _ip; // 服务器IP地址
};

代码写到目前为止,虽然细节很多,但其实和UDP前半部分一样!都是套路!

而接下来就和UDP不一样了,因为TCP是一个面向连接的,所以在正式通信之前,一定要客户端和服务器连接上再说,即要将套接字设置为监听状态。因为只有设置为监听状态,才能知道有别人要来和我建立连接,然后基于连接再进行通信。

1.4 监听

listen 函数用于将套接字设置为监听状态,以等待传入的连接请求。它通常用于服务器端,配合 socketbind 函数使用

【函数原型】

#include <sys/types.h>         
#include <sys/socket.h>int listen(int sockfd, int backlog);                                                                                                                                                                     

说明:

  • sockfd: 是之前通过 socket 函数创建的套接字描述符。
  • backlog: 指定套接字的待连接队列的最大长度(数目)。意思就是说,如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度。注意:一般不要设置太大,设置为510即可。
  • 返回值:成功返回0,失败返回-1,并设置 errno 以指示错误类型。
#pragma once#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cstring>
#include <arpa/inet.h>
#include <netinet/in.h>
#include "log.hpp"log lg;enum
{SOCK_ERR = 1,BIND_ERR,LISTEN_ERR
};class tcpserver
{
public:void Init(){// 创建套接字_socketfd = socket(AF_INET, SOCK_STREAM, 0);if (_socketfd == -1) // 套接字创建失败{lg.logmessage(Fatal, "create socket. errno: %d, describe: %s", errno, strerror(errno));exit(SOCK_ERR);}lg.logmessage(Info, "create socket. errno: %d, describe: %s", errno, strerror(errno));// 绑定套接字struct 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)); // 细节// 内核绑定int n = bind(_socketfd, (struct sockaddr *)&local, sizeof(local));if (n < 0) // 绑定失败{lg.logmessage(Fatal, "bind socket. errno: %d, describe: %s", errno, strerror(errno));exit(BIND_ERR);}lg.logmessage(Info, "bind socket. errno: %d, describe: %s", errno, strerror(errno));// 监听(一般配合socket和bind函数使用)int l = listen(_socketfd, 5);if (l == -1) // 监听失败{lg.logmessage(Fatal, "listen socket. errno: %d, describe: %s", errno, strerror(errno));exit(LISTEN_ERR);}lg.logmessage(Info, "listen socket. errno: %d, describe: %s", errno, strerror(errno));}private:int _socketfd; // 套接字文件描述符uint16_t _port; // 服务器端口号std::string _ip; // 服务器IP地址
};

注意:如果TCP服务器无法成功监听(例如,通过调用listen函数时失败),这通常意味着服务器无法接受客户端发来的连接请求。处理监听失败的情况确实需要在程序中适当地终止或处理错误,以确保程序的稳定性和正确性。

至此, 初始化TCP服务器的Init函数已经写完了 ~

1.5 测试一下

写到这里我们可以简单的运行一下,至于服务器启动函数Run,我们可以简单的打印一些消息。

void Run()
{// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){lg.logmessage(Info, "tcp server is running...");sleep(1);}
}

现在我们可以做一下简单的测试,看看当前服务器能否成功接收请求连接。通过命令行参数让用户指定端口号,可以在程序中解析这些参数,然后用指定的端口号配置服务器;另外,IP地址就不需要用户指定传递了,因为IP地址一开始就已经绑定`INADDR_ANY

tcpServer.cc

#include <iostream>
#include "tcpServer.hpp"
#include <memory>
#include <string>
using namespace std;// ./xxx 8888
void Usage(const string &proc)
{cout << "\n\tUsage: " << proc << " port(1024+)" << endl<< endl;
}int main(int argc, char *argv[])
{if (argc != 2){Usage(argv[0]);exit(1);}uint16_t port = stoi(argv[1]);// 1. 创建TCP服务器端对象unique_ptr<tcpserver> tcpsvr(new tcpserver(port));// 2. 初始化TCP服务器tcpsvr->Init();// 3. 启动TCP服务器tcpsvr->Run();return 0;
}

【运行结果】

在这里插入图片描述

另外,我们可以使用以下命令来查看服务器的运行状态

netstat -nltp

说明:

  • -n:显示数字地址,而不是解析成主机名。这意味着你将看到IP地址而不是主机名。
  • -l:仅显示正在监听的套接字。即只显示那些正在等待连接的端口。
  • -t:仅显示TCP连接。这样你就只会看到TCP协议的监听端口。
  • -p:显示与每个套接字相关联的进程PID 和程序名称。这样你可以知道哪个程序在监听哪个端口。

在这里插入图片描述

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

输出字段解释

  • Proto:协议类型(如 tcp)。
  • Recv-Q:接收队列中尚未处理的数据字节数。
  • Send-Q:发送队列中尚未确认的数据字节数。
  • Local Address:本地地址和端口。
  • Foreign Address:远程地址和端口(对监听套接字通常显示为 *)。
  • State:套接字的状态(如 LISTEN 表示监听中)。
  • PID/Program name:进程ID和程序名称。

1.6 接受连接

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

accept 函数用于从一个监听套接字(即已经调用了 listen 函数的套接字)接受一个新的连接请求。它将从等待连接的队列中取出一个连接请求,并返回一个新的套接字,用于与客户端进行数据交换。

【函数原型】

#include <sys/types.h>         
#include <sys/socket.h>int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

说明:

  • sockfd:是之前通过 socket 函数创建的套接字描述符,并且已调用 listen 函数设置为监听状态。
  • addr:指向 sockaddr 结构体的指针,用于存储客户端的地址信息。
  • addrlen:指向 socklen_t 类型的变量的指针,表示地址结构体的大小。函数返回时,它会包含实际填充的地址结构体的大小。
  • 返回值:成功返回新的套接字描述符,这个套接字用于与客户端进行通信;失败返回-1,并设置 errno 以指示错误类型。

socket函数和accept函数的返回值都是文件描述符,往后进行网络通信需要使用哪个文件描述符呢?

假设你和朋友出去吃饭,当你们走到一家名为“开心饭点”的餐馆时,门口有一个叫张三的人在热情地招呼你们进去。由于他的热情好客,你的朋友决定就去这家餐馆吃饭。当张三邀请你们进去时,他并没有一直陪着你们,而是去找了餐馆里的服务员来为你们服务,然后又回到门口继续招呼其他顾客。

在以上故事中,张三就像是socket函数返回的套接字,我们称为监听套接字,他负责接待和招呼客户(即等待连接请求)。当你们决定进入餐馆,张三相当于 accept 函数,它接受了连接请求,并将你们交给餐馆里的服务员(即新的套接字)来进行实际的服务,这个我们称为服务套接字。服务员负责处理你们的需求,就像 accept 函数返回的新套接字负责与客户端进行数据交换一样。

因此,往后进行通信需要使用 accept 返回的文件描述符,而socket 函数返回的文件描述符用于监听和接受连接。

注意:

  • accept 函数在某次调用中失败,服务器程序通常会继续运行,继续监听并接受新的连接请求。你可以把它比作张三虽然有时候吆喝失败,但他不会因为一次失败就放弃继续招呼其他客人。他会继续在门口招呼,直到成功为止。

  • socket网络编程接口默认是阻塞(等待),但你可以将它们设置为非阻塞模式以提高程序的效率和灵活性。你可以使用 fcntl() 函数来修改套接字的属性,将其设置为非阻塞模式(具体自己查)。

void Run()
{// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){// 接受连接struct sockaddr_in client;memset(&client, 0, sizeof(client));socklen_t len = sizeof(client);int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);if (acfd < 0) // 接收失败{lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));// 继续监听并接受新的连接请求continue;}lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));}
}

1.7 telnet命令

现在我们可以做一下简单的测试,看看当前服务器能否成功接收请求连接。虽然现在还没有编写客户端相关的代码,但是我们可以使用telnet命令。

telnet 是一个用于远程访问主机的网络协议和命令行工具。它允许你通过网络连接到远程服务器的特定端口,通常用于测试和诊断TCP/IP网络服务。telnet 底层使用TCP协议,因此可以用来检查服务器是否在特定端口上接受连接。

说明:如果没有telnet命令,可以执行以下命令

sudo yum install -y telnet

在这里插入图片描述

1.8 处理连接

现在TCP服务器已经能够成功接受连接请求了,下面当然就是要对获取到的连接请求进行处理。但需要注意的是:为客户端提供服务的不是监听套接字,监听套接字的任务只是获取到一个连接后会继续获取下一个请求连接;而为对应客户端提供服务的套接字实际是accept函数返回的套接字,也就是服务套接字。

又因为TCP是一个面向连接的协议,它提供了一个可靠的、字节流的传输方式。这意味着它处理的数据是连续的字节流。而readwrite函数可以直接操作这些字节流数据,提供流式读写操作,适合处理TCP数据传输中的流式数据。总之,因为TCP面向字节流,所以不用单独设计接口,直接使用writeread即可

read函数原型】

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

说明:

  • fd:特定的文件描述符,表示从该文件描述符中读取数据。
  • buf:指向用于存储读取数据的缓冲区的指针。
  • count:要读取的字节数。
  • 返回值说明:
    • 如果返回值大于0,该返回值则表示本次实际读取到的字节个数。
    • 如果返回值等于0,表示没有更多数据可读。
    • 如果返回值小于0,表示读取失败。此时,errno 会被设置为具体的错误代码,以说明错误原因。

write函数原型】

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

说明:

  • fd:文件描述符

  • buf:指向包含要写入数据的缓冲区的指针。

  • count:要写入的字节数。

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

为了让通信双方都能看到对应的现象,我们这里就实现一个简单的回响字符串的TCP服务器,当客户端发送消息给服务端,服务端打印客户端的消息后,再把其消息回显给客户端,此时就能确保服务端和客户端能够正常通信了。

// 回显
void v1(int acfd, const std::string &ipbuffer, const uint16_t &client_port)
{char buffer[4096];while (true){ssize_t n = read(acfd, buffer, sizeof(buffer));if (n > 0) // 读取成功{buffer[n] = 0; // 当做字符串// 客户端接收打印cout << "client say# " << buffer << endl;// 回显给客户端std::string echo_string = "tcp-server echo# ";echo_string += buffer;write(acfd, echo_string.c_str(), echo_string.size());}else if (n == 0){// 当客户端退出,意味着服务器没有任何数据可读,那么我们这里规定服务器也退出lg.logmessage(Info, "%s:%d quit, server close...", ipbuffer.c_str(), client_port);break;}else // n < 0{// 读取错误lg.logmessage(Warning, "server read error...");break;}}
}void Run()
{// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){// 获取连接struct sockaddr_in client;socklen_t len = sizeof(client);memset(&client, 0, sizeof(client));int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);if (acfd < 0){lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));continue;}lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));char ipbuffer[32];uint16_t client_port = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));v1(acfd, ipbuffer, client_port);close(acfd); // 关闭}
}

我们可以使用telnet命令来测试一下:

在这里插入图片描述

二、客户端

  • 客户端在调用socket函数创建套接字时,参数设置与服务端创建套接字时是一样的。

  • TCP客户端要绑定bind,但无需要显示编码进行绑定。一个一个来解释:

    • 首先客户端一定需要绑定,这是因为客户端向服务端发送请求,服务端收到请求后,要将结果回响给客户端,那么服务端要回响给唯一的那个客户端,客户端的端口号是什么并不重要。
    • 而不显示绑定是因为操作系统会在客户端建立连接时自动为其分配一个本地地址和端口。这是因为客户端的主要任务是连接到远程服务器,而不需要管理本地的网络接口。系统会处理这些细节,以确保客户端能够正常进行通信。在一些特殊情况下,如需要使用特定的本地端口或者地址时,客户端才需要调用 bind 函数。一般来说,默认行为就足够满足大多数应用的需求。
  • 客户端无需监听listen,服务端需要进行监听是因为服务端需要通过监听来获取新连接,但是不会有人主动连接客户端,因此客户端是不需要进行监听操作的。但客户端是要连接服务器的。

但客户端需要和TCP服务端建立连接,需要用到的是connect函数

【函数原型】

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

说明:

  • sockfd:一个有效的套接字描述符,该套接字通常通过 socket 函数创建。
  • addr:指向 sockaddr 结构体的指针,包含目标服务器的地址和端口信息。
  • addrlenaddr 结构体的大小(以字节为单位)。
  • 返回值:成功返回0,失败返回-1,并设置 errno 以指示错误类型。

注意:如果connect函数调用成功了,客户端本地会随机给该客户端绑定一个端口号发送给对端服务器。

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
#include <string>
using namespace std;void Usage(const string &proc)
{cout << "\n\tUsage: " << proc << " 服务器ip 服务器port" << endl<< endl;
}int main(int argc, char *argv[])
{if (argc != 3){Usage(argv[0]);exit(1);}int socketfd = socket(AF_INET, SOCK_STREAM, 0);// 创建套接字if (socketfd < 0){cerr << "socket error" << endl;return 1;}// 无需显示bind// 客户端中建立与服务器的连接string server_ip = argv[1];uint16_t server_port = stoi(argv[2]);struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(server_port);inet_pton(AF_INET, server_ip.c_str(), &(server.sin_addr));int n = connect(socketfd, (struct sockaddr *)&server, sizeof(server));if (n < 0){cerr << "connect error" << endl;return 2;}// 给服务器发消息string message;while (true){cout << "Please Enter: ";getline(cin, message);write(socketfd, message.c_str(), message.size());char inbuffer[4096];int n = read(socketfd, inbuffer, sizeof(inbuffer));if (n > 0){inbuffer[n] = 0;cout << inbuffer << endl;}}close(socketfd);return 0;
}

【运行结果】

在这里插入图片描述

三、改进TCP网络程序

3.1 单执行流服务器的弊端

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

在这里插入图片描述

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

在这里插入图片描述

通过实验现象可以看到,这服务端只有服务完一个客户端后才会服务另一个客户端。这正是因为我们目前所写的是一个单执行流版的服务器,这个服务器一次只能为一个客户端提供服务。当服务端调用accept函数获取到连接后就只给第一次还没退出的客户端提供服务,但在服务端可以为多个客户端提供服务。

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

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

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

如何解决?

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

3.2 多进程版

服务器既想要为当前客户端进行服务,也想要在服务期间继续处理其他的新连接。因此,当服务端调用accept函数获取到新连接后,不是由当前执行流为该连接提供服务,而是当前执行流调用fork函数创建子进程,然后让子进程为父进程获取到的连接提供服务。

需要注意的是,文件描述符表是隶属于一个进程的,子进程创建后会继承父进程的文件描述符表。比如父进程打开了一个文件,该文件对应的文件描述符是3,此时子进程的3号文件描述符也会指向这个打开的文件。所以,我们针对父子进程来关闭一些不需要的文件描述符。父进程关系的是连接状态,也就是监听套接字,那么可以将服务套接字给关闭;同理,子进程关系的是服务套接字,因此可以将监听套接字给关闭掉。

void Run()
{// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){// 获取连接struct sockaddr_in client;socklen_t len = sizeof(client);memset(&client, 0, sizeof(client));int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);if (acfd < 0){lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));continue;}lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));char ipbuffer[32];uint16_t client_port = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));// v1 - 单进程版// v1(acfd, ipbuffer, client_port);// close(acfd); // 关闭// v2 - 多进程版pid_t pid = fork();if (pid == 0) // child{// 子进程提供服务close(_socketfd);v1(acfd, ipbuffer, client_port);close(acfd); // 关闭exit(0);}else if (pid > 0) // parent{// 父进程继续获取新连接close(acfd);pid_t rid = waitpid(pid, nullptr, 0); // 父进程回收子进程(void)rid;}else // 子进程创建失败{lg.logmessage(Fatal, "fork child. errno: %d, describe: %s", errno, strerror(errno));exit(FORK_ERR);}}
}

以上代码还有一个问题,当父进程创建出子进程后,父进程是需要等待子进程退出的,否则子进程会变成僵尸进程,进而造成内存泄漏。而我们设置的是阻塞等待,那么现在就很矛盾了,服务端还是需要等待服务完当前客户端,才能继续获取下一个连接请求,此时服务端仍然是以一种串行的方式为客户端提供服务。

那么有的人说,可以设置非阻塞轮询等待WNOHANG。虽然在子进程为客户端提供服务期间,父进程可以继续获取新连接,但此时服务端就需要将所有子进程的PID保存下来,并且需要不断花费时间检测询问子进程是否退出。因此,代码还是不够完美。

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

  • 方法一:子进程创建进程(孙子进程),如果孙子进程创建成功,就让子进程退出,最后让孙子进程为客户端提供服务。这样的话,父进程阻塞等待就可以不用等待子进程服务完当前客户端,而是立马回收,进而可以继续获取新连接accept

注意:不需要等待孙子进程退出。由于子进程创建完孙子进程后就立刻退出了,那么孙子进程就变成了孤儿进程,而孤儿进程会被系统领养。👉 点击回炉重造

void Run()
{// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){// 获取连接struct sockaddr_in client;socklen_t len = sizeof(client);memset(&client, 0, sizeof(client));int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);if (acfd < 0){lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));continue;}lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));char ipbuffer[32];uint16_t client_port = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));// v1 - 单进程版// v1(acfd, ipbuffer, client_port);// close(acfd); // 关闭// v2 - 多进程版pid_t pid = fork();if (pid == 0) // child{// 子进程提供服务close(_socketfd);// 子进程再创建孙子进程if (fork() > 0){exit(0);}   v1(acfd, ipbuffer, client_port);close(acfd); // 关闭exit(0);}else if (pid > 0) // parent{// 父进程继续获取新连接close(acfd);// 父进程回收子进程pid_t rid = waitpid(pid, nullptr, 0); (void)rid;}else // 子进程创建失败{lg.logmessage(Fatal, "fork child. errno: %d, describe: %s", errno, strerror(errno));exit(FORK_ERR);}}
}

【运行结果】

在这里插入图片描述

  • 方法二:捕捉SIGCHLD信号,将其处理动作设置为忽略。实际当子进程退出时会给父进程发送SIGCHLD信号,如果父进程将SIGCHLD信号进行捕捉,并将该信号的处理动作设置为忽略,此时父进程就只需专心处理自己的工作,不必关心子进程了。
void Run()
{// 方法二:忽略SIGCHLD信号signal(SIGCHLD, SIG_IGN);// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){// 获取连接struct sockaddr_in client;socklen_t len = sizeof(client);memset(&client, 0, sizeof(client));int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);if (acfd < 0){lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));continue;}lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));char ipbuffer[32];uint16_t client_port = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));// v1 - 单进程版// v1(acfd, ipbuffer, client_port);// close(acfd); // 关闭// v2 - 多进程版pid_t pid = fork();if (pid == 0) // child{// 子进程提供服务close(_socketfd);// 方法一: 子进程再创建孙子进程// if (fork() > 0)// {//     exit(0);// }v1(acfd, ipbuffer, client_port);close(acfd); // 关闭exit(0);}else if (pid > 0) // parent{// 父进程继续获取新连接close(acfd);pid_t rid = waitpid(pid, nullptr, 0); // 父进程回收子进程(void)rid;}else // 子进程创建失败{lg.logmessage(Fatal, "fork child. errno: %d, describe: %s", errno, strerror(errno));exit(FORK_ERR);}}
}

【运行结果】

在这里插入图片描述

3.3 多线程版

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

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

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

另外,主线程创建出来的新线程依旧属于这个进程,因此,创建新线程时并不会为该线程创建独立的文件描述符表,所有的线程看到的都是同一张文件描述符表。

在这里插入图片描述

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

class tcpserver; // 声明class ThreadData
{
public:ThreadData(int fd, const std::string &ip, const uint16_t &port, tcpserver *t): socketfd(fd), client_ip(ip), client_port(port), ts(t){}int socketfd;std::string client_ip;uint16_t client_port;tcpserver *ts;
};class tcpserver
{
public:static void *Routine(void *args){pthread_detach(pthread_self());ThreadData *td = static_cast<ThreadData *>(args);// 新线程提供服务td->ts->v1(td->socketfd, td->client_ip, td->client_port);delete td;return nullptr;}void Run(){// 方法二:忽略SIGCHLD信号signal(SIGCHLD, SIG_IGN);// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){// 获取连接struct sockaddr_in client;socklen_t len = sizeof(client);memset(&client, 0, sizeof(client));int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);if (acfd < 0){lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));continue;}lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));char ipbuffer[32];uint16_t client_port = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));// v1 - 单进程版// v2 - 多进程版// v3 - 多线程版pthread_t tid;ThreadData *td = new ThreadData(acfd, ipbuffer, client_port, this);pthread_create(&tid, nullptr, Routine, (void *)td);}}
};

【运行结果】

在这里插入图片描述

3.4 线程池版

以上多线程版其实还存在一些问题:

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

综上,可以预先创建一组线程来处理所有客户端请求,避免了频繁的线程创建和销毁。这样可以减少线程切换的开销,并提高响应速度。这就不是线程池嘛!!!

这里可以引用往期写过的单例版线程池。在线程池里面有一个任务队列,在线程池当中我们默认创建了5个线程,当有新的任务到来的时候,就可以将任务push到线程池当中,这些线程不断检测任务队列当中是否有任务,如果有任务就拿出任务,然后调用该任务对应的run函数对该任务进行处理,如果线程池当中没有任务那么当前线程就会进入休眠状态。

  • 👉 无单例版线程池
  • 👉 单例版线程池 (3.2.6)

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

ThreadPool.hpp

#pragma once#include <iostream>
#include <pthread.h>
#include <vector>
#include <string>
#include <queue>
#include <unistd.h>#define defaultNum 5 // 线程池默认的线程个数struct threadInfo
{pthread_t tid;std::string threadname;
};template <class T>
class ThreadPool
{
private:// 默认构造函数ThreadPool(int num = defaultNum) // 默认在线程池创建5个线程: _threads(num){pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_cond, nullptr);}// 析构函数~ThreadPool(){pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_cond);}public:// 线程池中线程的执行例程static void *HandlerTask(void *args){// 线程分离pthread_detach(pthread_self());ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);std::string name = tp->GetThreadName(pthread_self());// 不断从任务队列获取任务进行处理while (true){// 线程先检测任务队列有无任务// 而任务队列是临界资源,那么需要加锁pthread_mutex_lock(&(tp->_mutex));while ((tp->_tasks).empty()){// 如果列表为空// 线程直接去休眠, 即去条件变量的线程等待列表等待pthread_cond_wait(&(tp->_cond), &(tp->_mutex));}// 如果有任务,则获取任务T t = tp->pop();pthread_mutex_unlock(&(tp->_mutex));// 处理任务t.run();}}// 启动线程(常见线程)void start(){for (int i = 0; i < _threads.size(); i++){_threads[i].threadname = "thread-" + std::to_string(i + 1);pthread_create(&(_threads[i].tid), nullptr, HandlerTask, this); // 注意参数传入this指针}}// 向任务列表中塞任务 -- 主线程调用void push(const T &t){pthread_mutex_lock(&_mutex);// 向任务队列里塞任务_tasks.push(t);// queue容器会自动扩容,不需要特判任务列表的容量是否够// 接下来唤醒线程池中的线程pthread_mutex_unlock(&_mutex);pthread_cond_signal(&_cond);}// 去掉任务队列中的任务T pop(){// 这个函数不需要对临界资源加锁// 因为pop函数只在HandlerTask函数中被调用// 而在HandlerTask函数中已经对该函数加锁了T t = (_tasks).front();_tasks.pop();return t;}std::string GetThreadName(pthread_t tid){for (const auto &e : _threads){if (e.tid == tid){return e.threadname;}}return "None";}static ThreadPool<T> *getInstance(int num = defaultNum){if (_tp == nullptr){pthread_mutex_lock(&_mtx);if (_tp == nullptr){_tp = new ThreadPool<T>(num);}pthread_mutex_unlock(&_mtx);}return _tp;}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 _mtx;
};template <class T>
ThreadPool<T> *ThreadPool<T>::_tp = nullptr;template <class T>
pthread_mutex_t ThreadPool<T>::_mtx = PTHREAD_MUTEX_INITIALIZER;

tcpServer.hpp

 void Run()
{// 启动线程池ThreadPool<Task>::getInstance()->start();// 服务器启动后是周而复始的在工作的,因此服务器本质就是死循环while (true){// 获取连接struct sockaddr_in client;socklen_t len = sizeof(client);memset(&client, 0, sizeof(client));int acfd = accept(_socketfd, (struct sockaddr *)&client, &len);if (acfd < 0){lg.logmessage(Warning, "accept socket. errno: %d, describe: %s", errno, strerror(errno));continue;}lg.logmessage(Info, "accept socket. errno: %d, describe: %s", errno, strerror(errno));char ipbuffer[32];uint16_t client_port = ntohs(client.sin_port);inet_ntop(AF_INET, &(client.sin_addr), ipbuffer, sizeof(ipbuffer));// v1 - 单进程版// v2 - 多进程版// v3 - 多线程版// v4 - 线程池版Task t(acfd, ipbuffer, client_port);      // 构建任务对象ThreadPool<Task>::getInstance()->push(t); // 发布任务给线程池}
}

任务类的设计Task.hpp

直接将v1函数复制过来即可

#pragma once
#include <iostream>
#include <string>
#include "log.hpp"using std::cout;
using std::endl;extern log lg;class Task
{
public:Task(int acfd, const std::string &ipbuffer, const uint16_t &client_port): _acfd(acfd), _client_ip(ipbuffer), _client_port(client_port){}~Task(){}void run(){char buffer[4096];while (true){ssize_t n = read(_acfd, buffer, sizeof(buffer));if (n > 0) // 读取成功{buffer[n] = 0; // 当做字符串// 客户端接收打印cout << "client say# " << buffer << endl;// 回显给客户端std::string echo_string = "tcp-server echo# ";echo_string += buffer;write(_acfd, echo_string.c_str(), echo_string.size());}else if (n == 0){// 当客户端退出,意味着服务器没有任何数据可读,那么我们这里规定服务器也退出lg.logmessage(Info, "%s:%d quit, server close...", _client_ip.c_str(), _client_port);break;}else // n < 0{// 读取错误lg.logmessage(Warning, "server read error...");break;}}}private:int _acfd;std::string _client_ip;uint16_t _client_port;
};

【运行结果】

在这里插入图片描述

四、相关代码

我的Gitee链接:👉 点击跳转


http://www.ppmy.cn/news/1520533.html

相关文章

【Pytorch实用教程】tqdm的作用:在循环中显示进度条

tqdm 是一个 Python 库,用于在循环中显示进度条。它能够为任何可迭代对象(例如列表、生成器、数据加载器等)添加一个可视化的进度条,使用户可以实时查看程序的执行进度。 在数据科学和机器学习领域,tqdm 经常用于显示训练和验证过程中的进度。例如,在训练神经网络时,由…

编译 ffmpeg 以支持AVS格式视频解码与解码

前言 当前文章介绍如何在Linux下使用FFmpeg转码其他视频格式到AVS格式的指南&#xff0c;包括编译FFmpeg以支持XAVS编码和如何使用FFmpeg进行转码。 AVS (Audio Video Coding Standard) 格式是一种由中国主导制定的视频编码标准&#xff0c;全称为“中国数字音视频编解码技术…

装饰器模式及应用【理论+代码】

装饰器模式&#xff08;Decorator Pattern&#xff09;是一种结构型设计模式&#xff0c;它允许向一个现有的对象添加新的功能&#xff0c;同时又不改变其结构。这种设计模式通过创建一个包装对象&#xff0c;即装饰器&#xff0c;来封装实际对象。 装饰器模式的主要组成&#…

图像处理之透视变换

透视变换 什么是透视变换透视变换有什么用 什么是透视变换 透视变换&#xff08;把斜的图变正&#xff0c;也就是一种坐标系到另外一种坐标系&#xff09;是一种图像处理技术&#xff0c;它利用透视中心、像点、目标点三点共线的条件&#xff0c;按透视旋转定律使承影面&#…

python读取excel数据

在处理Excel数据时&#xff0c;Python 提供了多种强大的库来读取、处理以及分析这些数据。最常用的库之一是 pandas&#xff0c;它建立在 numpy、matplotlib 和 scipy 等库之上&#xff0c;为数据分析和操作提供了高级的、易于使用的数据结构和数据分析工具。另一个流行的库是 …

Java设计模式—策略模式(Strategy)

模式动机 完成一项任务&#xff0c;往往可以有多种不同的方式&#xff0c;每一种方式称为一个策略&#xff0c;我们可以根据环境或者条件的不同选择不同的策略来完成该项任务。在软件开发中也常常遇到类似的情况&#xff0c;实现某一个功能有多个途径&#xff0c;此时可以使用…

【运维监控】prometheus+node exporter+grafana 监控linux机器运行情况(2)

本示例是通过prometheus的node exporter收集主机的信息&#xff0c;然后在grafana的dashborad进行展示。本示例使用到的组件均是最新的&#xff0c;下文中会有具体版本说明&#xff0c;linux环境是centos。本示例分为四个部分&#xff0c;即prometheus、grafana、node exporter…

【MySQL】如何优化 SQL UPDATE 语句以提升性能

如何优化 SQL UPDATE 语句以提升性能 在日常开发中&#xff0c;优化 SQL 查询是非常关键的一项任务&#xff0c;尤其是在处理大量数据时。本文将通过一个 UPDATE 语句的优化过程&#xff0c;探讨如何提升 SQL 性能。 示例场景 假设我们有以下两张表&#xff1a; 表 table_a…

打造一流的研发型企业--- 金发科技研发驱动力初探

2006年3月29日&#xff0c;国家发改委副主任欧新黔亲自为金发科技颁发了“中国改性塑料行业第一位”、“中国合成材料制造业十强”、“中国石油化工全行业百强”三块铜牌证书&#xff0c;金发科技终于成为名符其实的行业“老大”。公司产品销售额增长迅速&#xff0c; 2006年完…

开发基础软件安装地址(持续更新中)

开发基础软件安装地址&#xff08;持续更新中&#xff09; 如果需要新增下载工具可以在评论中留言 欢迎广大开发人员在评论区讨论关于环境安装遇到的问题 正文会持续更新。。。 java常用的jdk1.8版本安装包 链接&#xff1a;jdk-8u421-windows-x64.exe idea java常用的开发工具…

提交保存,要做重复请求拦截,避免出现重复保存的问题

**问题&#xff1a;**前端ajax提交数据的时候&#xff0c;当频繁点击的时候&#xff0c;或者两个账号以相同数据创建的时候&#xff0c;会出现问题。 **处理办法&#xff1a;**前端拦截&#xff0c;防止重复提交数据&#xff0c;在上一次请求返回结果之后才允许提交第二次&…

在 Debian 8 上安装 Nginx 的方法

前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到网站。 介绍 Nginx 是一个流行的 HTTP 服务器&#xff0c;是 Apache2 的一个替代品。它可以用作反向代理、邮件服务器或 Web 服务器。根据 Net…

[某度信息流]SQL164,2021年11月每天新用户的次日留存率

牛客网在线编程 思路&#xff1a; 首先找出用户的注册日期&#xff0c;即date(min(in_time)) 转成date形式 建立两个辅助表&#xff0c;我先放代码&#xff0c;然后进行解释 withuser_reg as (selectuid,date(min(in_time)) as first_datefromtb_user_loggroup by1),…

抖音视频如何下载保存到相册:详细教程

随着抖音的风靡&#xff0c;越来越多的人沉浸在短视频的世界中&#xff0c;观看各种搞笑、有趣、甚至感人的视频。很多用户都希望能够将喜欢的抖音视频保存到自己的手机相册中&#xff0c;方便随时观看或分享给朋友。本文将详细介绍如何下载抖音视频并保存到相册的方法。 一、…

记录Jmeter 通过view result tree配置保存响应信息的方法以及命令行运行时的一个坑

大家在使用Jmeter进行调试时有没有考虑过这个问题&#xff0c;如何查看具体的响应信息&#xff0c;特别是通过命令行执行脚本的时候&#xff0c;如何看到具体请求的响应信息呢&#xff1f; 看到上面这个问题&#xff0c;首先想到的就是我们平时在jmeter中debug问题&#xff0c…

基于FPGA实现SD NAND FLASH的SPI协议读写

基于FPGA&#xff08;现场可编程门阵列&#xff09;实现SD NAND FLASH的SPI&#xff08;串行外设接口&#xff09;协议读写是一个涉及硬件设计与编程的复杂过程。以下将详细介绍该过程的背景、关键步骤、电路设计、SPI协议详解、FPGA实现以及代码示例等方面&#xff0c;内容不少…

Spark-ShuffleManager

一、上下文 《Spark-Task启动流程》中我们讲到了ShuffleMapTask中会对这个Stage的结果进行磁盘的写入&#xff0c;并且从SparkEnv中得到了ShuffleManager&#xff0c;且调用了它的getWriter方法并在这个Stage的入口处&#xff08;也就是RDD的迭代器数据源处&#xff09;调用了…

uniapp 自定义微信小程序 tabBar 导航栏

背景 做了一个校园招聘类小程序&#xff0c;使用 uniapp vue3 uview-plus pinia 构建&#xff0c;这个小程序要实现多角色登录&#xff0c;根据权限动态切换 tab 栏文字、图标。 使用pages.json中配置tabBar无法根据角色动态配置 tabBar&#xff0c;因此自定义tabBar&…

MySQL数据库增删查改(基础)CRUD

CRUD 即增加 (Create) 、查询 (Retrieve) 、更新 (Update) 、删除 (Delete) 四个单词的首字母缩写。 1. 新增&#xff08;Create&#xff09; 1.1单行数据&#xff08;全列插入&#xff09; 比如说&#xff1a;创建一张学生表&#xff0c;有姓名&#xff0c;学号。插入两个学…

新手c语言讲解及题目分享(十)——数组专项练习

C语言中的数组是一个用于存储多个同类型数据的集合。数组在内存中是连续分配的&#xff0c;可以通过索引访问其中的元素。以下是对C语言数组的详细讲解&#xff1a; 1. 数组的定义 数组的定义格式如下&#xff1a; type arrayName[arraySize]; - type&#xff1a;数组中元素…