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

【系统全面】Socket编程——基础知识介绍

套接字通信

DNS

​ 访问网站实际上是与某台服务器通信。为了定位这台服务器,我们需要使用 IP 地址。 然而,IP地址是一串数字,不易读且难以记忆。

​ 域名(Domain Name)是互联网上用于标识网站的易于记忆的名称,代替了难记的IP 地址,使用户能够方便地访问网站。域名由一系列标签组成,这些标签由点(.)分隔,每 个标签代表域名层次结构中的一个级别。如www.atguigu.com。

​ DNS(Domain Name System,域名系统)协议是一种用于将人类易读的域名(如 www.atguigu.com)转换为计算机可以识别的IP地址(如192.0.2.1)的网络协议。它 是互联网的关键组件之一,使用户能够使用友好的域名而不是难记的IP地址来访问网站 和其他互联网资源。

总之,ip地址->DNS->域名

IPC

不用网络进行进程间的数据传输

它允许在同一台主机上运行的进程之间进行高效的数据传输,无需经过网络协议栈,因此具有低延迟和高性能的特点。

man

/* 
地址格式 
UNIX 域套接字地址表示为以下结构: 
*/ 
struct sockaddr_un { sa_family_t sun_family;               /* AF_UNIX */ char        sun_path[108];            /* Pathname */ 
}; 
/* 
sun_family 字段始终包含 AF_UNIX。在 Linux 上,sun_path 的大小为 108 字节;
请参见下面的注意事项。 各种系统调用(例如,bind(2)、connect(2) 和 sendto(2))将一个 sockaddr_un 参
数作为输入。一些其他系统调用(例如,getsockname(2)、getpeername(2)、
recvfrom(2) 和 accept(2))返回此类型的参数。 在 sockaddr_un 结构中区分三种类型的地址: 路径名:可以使用 bind(2) 将 UNIX 域套接字绑定到以空字符结尾的文件系统路径名。
当返回路径名套接字的地址(由上述系统调用之一返回)时,其长度为 
sizeof(sa_family_t) + strlen(sun_path) + 1 未命名:未使用 bind(2) 将流套接字绑定到路径名的套接字没有名称。同样,
socketpair(2) 创建的两个套接字也没有名称。返回未命名套接字的地址时,其长度为 
sizeof(sa_family_t),并且不应检查 sun_path。 抽象:通过 sun_path[0] 是空字节('\0') 来区分抽象套接字地址(与路径名套接字)。
此命名空间中套接字的地址由地址结构指定长度覆盖的 sun_path 中的附加字节给出。
(名称中的空字节没有特殊意义。)名称与文件系统路径名无关。当返回抽象套接字的地
址时,返回的 addrlen 大于 sizeof(sa_family_t)(即大于 2),并且套接字的名称
包含在 sun_path 的前 (addrlen - sizeof(sa_family_t)) 字节中。 
*/ 
/* 
绑定套接字到路径名时,应遵循以下规则以实现最大的可移植性和编码便利性: sun_path 中的路径名应以空字符结尾。 路径名的长度,包括终止的空字节,不应超过 sun_path 的大小。 描述封装 sockaddr_un 结构的 addrlen 参数应指定为 sizeof(struct 
sockaddr_un)。 在 Linux 实现中,路径名套接字遵循它们所在目录的权限。如果进程在创建套接字的目
录中没有写和搜索(执行)权限,则创建新套接字将失败。 在 Linux 上,连接到流套接字对象需要对该套接字具有写权限;同样,向数据报套接字
发送数据报也需要对该套接字具有写权限。POSIX 对套接字文件的权限效果没有做出任何
声明,在某些系统上(例如,旧版 BSD),套接字权限会被忽略。可移植的程序不应依赖
此功能来进行安全性保障。 路径名套接字的所有者、组和权限可以更改(使用 chown(2) 和 chmod(2))。 
*/ 
#include <stdlib.h> 
#include <stdio.h> 
#include <stddef.h> 
#include <sys/socket.h> 
#include <sys/un.h> 
#include <sys/stat.h> 
#include <errno.h> 
#include <string.h> 
#include <unistd.h> #define SOCKET_PATH "unix_domain.socket" 
#define SERVER_MODE 1 
#define CLIENT_MODE 2 
#define BUF_LEN 1024 static struct sockaddr_un socket_addr; 
static char *buf; void handle_error(char *err_msg) 
{ perror(err_msg); unlink(SOCKET_PATH); exit(-1); 
} void server_mode(int sockfd) 
{ int client_fd, msg_len; static struct sockaddr_un client_addr; if (bind(sockfd, (struct sockaddr *)&socket_addr, 
sizeof(socket_addr)) < 0) { handle_error("bind"); } if (listen(sockfd, 128) < 0) { handle_error("listen"); } socklen_t client_addr_len = sizeof(client_addr); if ((client_fd = accept(sockfd, (struct sockaddr *)&client_addr, 
&client_addr_len)) < 0) { handle_error("accept"); } write(STDOUT_FILENO, "Connected to client!\n", 21); do { memset(buf, 0, BUF_LEN); msg_len = recv(client_fd, buf, BUF_LEN, 0); printf("Received msg: %s", buf); if (strncmp(buf, "EOF", 3) != 0) { strcpy(buf, "OK!\n\0"); } send(client_fd, buf, strlen(buf), 0); } while (strncmp(buf, "EOF", 3) != 0); if (shutdown(client_fd, SHUT_RDWR) < 0) { handle_error("shutdown server"); } unlink(SOCKET_PATH); 
} void client_mode(int sockfd) 
{ int msg_len, header_len; if (connect(sockfd, (struct sockaddr *)&socket_addr, 
sizeof(socket_addr))) { handle_error("connect"); } write(STDOUT_FILENO, "Connected to server!\n", 21); strcpy(buf, "Msg received: "); // 计算buf中头的长度 header_len = strlen(buf); do { msg_len = read(STDIN_FILENO, buf + header_len, BUF_LEN - 
header_len); send(sockfd, buf + header_len, msg_len, 0); msg_len = recv(sockfd, buf + header_len, BUF_LEN - header_len, 
0); write(STDOUT_FILENO, buf, msg_len + header_len); } while (strncmp(buf + header_len, "EOF", 3) != 0); 
} int main(int argc, char const *argv[]) 
{ int fd = 0, mode = 0; if (argc == 1 || strncmp(argv[1], "server", 6) == 0) { mode = SERVER_MODE; } else if (strncmp(argv[1], "client", 6) == 0) { mode = CLIENT_MODE; } else { perror("参数错误"); exit(-1); } // address初始化 memset(&socket_addr, 0, sizeof(struct sockaddr_un)); buf = malloc(BUF_LEN); // 给address赋值 socket_addr.sun_family = AF_UNIX; strcpy(socket_addr.sun_path, SOCKET_PATH); if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0) { handle_error("socket"); } // 分服务端和客户端 switch (mode) { case SERVER_MODE: server_mode(fd); break; case CLIENT_MODE: client_mode(fd); break; } if (shutdown(fd, SHUT_RDWR) < 0) { handle_error("shutdown"); } free(buf); exit(0); 
}

UDP

udp是一个面向无连接的,不安全的,报式传输层协议,udp的通信过程默认也是阻塞的。

UDP通信不需要建立连接 ,因此不需要进行connect()操作

UDP通信过程中,每次都需要指定数据接收端的IP和端口,和发快递差不多

UDP不对收到的数据进行排序,在UDP报文的首部中并没有关于数据顺序的信息

UDP对接收到的数据报不回复确认信息,发送端不知道数据是否被正确接收,也不会重发数据。

如果发生了数据丢失,不存在丢一半的情况,如果丢当前这个数据包就全部丢失了

在这里插入图片描述

UDP通讯也使用socket,但是接收和发送的函数与TCP不一样。由于UDP不存在握 手这一步骤,所以在绑定地址之后,服务端不需要listen,客户端也不需要connect, 服务端同样不需要accept。只要服务端绑定以后,就可以相互发消息了,由于没有握手过 程,两端都不能确定对方是否收到消息,这也是UDP协议不如TCP协议可靠的地方。

UDP协议接收和发送数据不再用send和recv方法,这两个方法一般用于TCP通信, UDP通信使用sendto和recvfrom方法,声明如下:

#include <sys/types.h> 
#include <sys/socket.h> 
/** * @brief 将接收到的消息放入缓冲区 buf 中。 *  * @param sockfd 套接字文件描述符 * @param buf 缓冲区指针 * @param len 缓冲区大小 * @param flags 通信标签,详见recv方法说明 * @param src_addr 可以填NULL,如果 src_addr 不是 NULL,并且底层协议提供了
消息的源地址,则该源地址将被放置在 src_addr 指向的缓冲区中。 * @param addrlen 如果src_addr不为NULL,它应初始化为与 src_addr 关联的缓
冲区的大小。返回时,addrlen 被更新为包含实际源地址的大小。如果提供的缓冲区太小,
则返回的地址将被截断;在这种情况下,addrlen 将返回一个大于调用时提供的值。 * @return ssize_t 实际收到消息的大小。如果接收失败,返回-1 */ 
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct 
sockaddr *src_addr, socklen_t *addrlen); /** * @brief 向指定地址发送缓冲区中的数据(一般用于UDP模式) *  * @param sockfd 套接字文件描述符 * @param buf 缓冲区指针 * @param len 缓冲区大小 * @param flags 通信标签,详细减send方法说明 * @param dest_addr 目标地址。如果用于连接模式,该参数会被忽略 * @param addrlen 目标地址长度 * @return ssize_t 发送的消息大小。发送失败返回-1 */ 
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, 
const struct sockaddr *dest_addr, socklen_t addrlen); 

广播

广播的UDP的特性之一,通过广播可以向子网中多台计算机发送消息,并且子网中所有的计算机都可以接收到发送方发送的消息,每个广播消息都包含一个特殊的IP地址,这个IP中子网内主机标志部分的二进制全部为1 (即点分十进制IP的最后一部分是255)。点分十进制的IP地址每一部分是1字节,最大值为255,比如:192.168.1.100

​ 前两部分192.168表示当前网络是局域网
​ 第三部分1表示局域网中的某一个网段,最大值为 255
​ 第四部分100用于标记当前网段中的某一台主机,最大值为255
​ 每个网段都有一个特殊的广播地址,即:192.168.xxx.255
广播分为两端,即数据发送端和数据接收端,通过广播的方式发送数据,发送端和接收端的关系是 1:N

​ 发送广播消息的一端,通过广播地址,可以将消息同时发送到局域网的多台主机上(数据接收端)

​ 在发送广播消息的时候,必须要把数据发送到广播地址上

​ 广播只能在局域网内使用,广域网是无法使用UDP进行广播的

​ 只要发送端在发送广播消息,数据接收端就能收到广播消息,消息的接收是无法拒绝的,除非将接收端的进程关闭,就接收不到了。

UDP的广播和日常生活中的广播是一样的,都是一种快速传播消息的方式,因此广播的开销很小,发送端使用一个广播地址,就可以将数据发送到多个接收数据的终端上,如果不使用广播,就需要进行多次发送才能将数据分别发送到不同的主机上。

在这里插入图片描述

注意事项:发送广播消息一端必须要开启UDP的广播属性,并且发送消息的地址必须是当前发送端所在网段的广播地址,这样才能通过调用一个消息发送函数将消息同时发送N台接收端主机上。

对于接收广播消息的一端,必须要绑定固定的端口,并由广播端将广播消息发送到这个端口上,因此所有接收端都应绑定相同的端口,这样才能同时收到广播数据。

组播(多播)

组播也可以称之为多播这也是UDP的特性之一。组播是主机间一对多的通讯模式,是一种允许一个或多个组播源发送同一报文到多个接收者的技术。组播源将一份报文发送到特定的组播地址,组播地址不同于单播地址,它并不属于特定某个主机,而是属于一组主机。一个组播地址表示一个群组,需要接收组播报文的接收者都加入这个群组。

​ 广播只能在局域网访问内使用,组播既可以在局域网中使用,也可以用于广域网
​ 在发送广播消息的时候,连接到局域网的客户端不管想不想都会接收到广播数据,组播可以控制发送端的消息能够被哪些接收端接收,更灵活和人性化。
​ 广播使用的是广播地址,组播需要使用组播地址。
​ 广播和组播属性默认都是关闭的,如果使用需要通过setsockopt()函数进行设置。
组播需要使用组播地址,在 IPv4 中它的范围从 224.0.0.0 到 239.255.255.255,并被划分为局部链接多播地址、预留多播地址和管理权限多播地址三类:

IP地址 说明
224.0.0.0~224.0.0.255 局部链接多播地址:是为路由协议和其它用途保留的地址,
只能用于局域网中,路由器是不会转发的地址 224.0.0.0不能用,是保留地址
224.0.1.0~224.0.1.255 为用户可用的组播地址(临时组地址),可以用于 Internet 上的。
224.0.2.0~238.255.255.255 用户可用的组播地址(临时组地址),全网范围内有效
239.0.0.0~239.255.255.255 为本地管理组播地址,仅在特定的本地范围内有效

​ 组播地址不属于任何服务器或个人,它有点类似一个微信群号,任何成员(组播源)往微信群(组播IP)发送消息(组播数据),这个群里的成员(组播接收者)都会接收到此消息。

在这里插入图片描述

注意事项:在组播数据的发送端,需要先设置组播属性,发送的数据是通过sendto()函数发送到某一个组播地址上,并且在程序中数据发送到了接收端的9999端口,因此接收端程序必须要绑定这个端口才能收到组播消息。

注意事项:作为组播消息的接收端,必须要先绑定一个固定端口(发送端就可以把数据发送到这个固定的端口上了),然后加入到组播的群组中(一个组播地址可以看做是一个群组),这样就可以接收到组播消息了。

守护进程

传统的进程有一个窗口,可以交互,很便捷操作,但是不稳定,容易被关

有的时候我们不希望他是这样的,比如我们不愿意暴露服务端,这就用到了守护进程

守护进程是在操作系统后台运行的一种特殊类型的进程,它独立于前台用户界面,不 与任何终端设备直接关联。这些进程通常在系统启动时启动,并持续运行直到系统关闭, 或者它们完成其任务并自行终止。守护进程通常用于服务请求、管理系统或执行周期性任务。

创建守护进程的代码(通用)

void my_daemonize() 
{ pid_t pid; // Fork off the parent process pid = fork(); if (pid < 0) exit(EXIT_FAILURE); if (pid > 0) exit(EXIT_SUCCESS); if (setsid() < 0) exit(EXIT_FAILURE); // 处理 SIGHUP、SIGTERM 信号 signal(SIGHUP, signal_handler); signal(SIGTERM, signal_handler); pid = fork(); if (pid < 0) exit(EXIT_FAILURE); if (pid > 0) exit(EXIT_SUCCESS); // 重置umask umask(0); // 将工作目录切换为根目录 chdir("/"); // 关闭所有打开的文件描述符 for (int x = 0; x <= sysconf(_SC_OPEN_MAX); x++) { close(x); } openlog("this is our daemonize process: ", LOG_PID, LOG_DAEMON); 
} 
int main() 
{ my_daemonize(); while (1) { pid = fork(); if (pid > 0) { syslog(LOG_INFO, "守护进程正在监听服务端进程..."); waitpid(-1, NULL, 0); if (is_shutdown) { syslog(LOG_NOTICE, "子进程已被回收,即将关闭syslog连接,守护进程退出"); closelog(); exit(EXIT_SUCCESS); } syslog(LOG_ERR, "服务端进程终止,3s后重启..."); sleep(3); } else if (pid == 0) { syslog(LOG_INFO, "子进程fork成功"); syslog(LOG_INFO, "启动服务端进程"); char *path = "/home/atguigu/daemon_and_multiplex/tcp_server"; char *argv[] = {"my_tcp_server", NULL}; errno = 0; execve(path, argv, NULL); char buf[1024]; sprintf(buf, "errno: %d", errno); syslog(LOG_ERR, "%s", buf); syslog(LOG_ERR, "服务端进程启动失败"); exit(EXIT_FAILURE); }else { syslog(LOG_ERR, "子进程fork失败"); } } return EXIT_SUCCESS; 
}                   

https和http区别

https=http+ssl但是现在ssl都被tsl所取代

sockaddr 数据结构

// 在写数据的时候不好用
struct sockaddr {sa_family_t sa_family;       // 地址族协议, ipv4char        sa_data[14];     // 端口(2字节) + IP地址(4字节) + 填充(8字节)
}typedef unsigned short  uint16_t;
typedef unsigned int    uint32_t;
typedef uint16_t in_port_t;
typedef uint32_t in_addr_t;
typedef unsigned short int sa_family_t;
#define __SOCKADDR_COMMON_SIZE (sizeof (unsigned short int))struct in_addr
{in_addr_t s_addr;
};  // sizeof(struct sockaddr) == sizeof(struct sockaddr_in)
struct sockaddr_in
{sa_family_t sin_family;		/* 地址族协议: AF_INET */in_port_t sin_port;         /* 端口, 2字节-> 大端  */struct in_addr sin_addr;    /* IP地址, 4字节 -> 大端  *//* 填充 8字节 */unsigned char sin_zero[sizeof (struct sockaddr) - sizeof(sin_family) -sizeof (in_port_t) - sizeof (struct in_addr)];
}; 

TCP通信流程与状态转换

TCP是一个面向连接的,安全的,流式传输协议,这个协议是一个传输层协议。

​ 面向连接:是一个双向连接,通过三次握手完成,断开连接需要通过四次挥手完成。
​ 安全:tcp通信过程中,会对发送的每一数据包都会进行校验, 如果发现数据丢失, 会自动重传
​ 流式传输:发送端和接收端处理数据的速度,数据的量都可以不一致

在这里插入图片描述

在这里插入图片描述

只需要看两条主线:红色实线和绿色虚线。关于黑色的实线对应的是一些特殊情况下的状态切换

TIME_WAIT状态

客户端收到了来自服务器的带有FIN、ACK的结束报文段后并没有直接关闭,而是进入了TIME_WAIT状态。

在这个状态中客户端只有等待2MSL(Maximum Segment Life,报文段最大生存时间)才能完全关闭。它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃,RFC文档的建议值是2分钟。为啥有这个,因为TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 MSL 应该要大于等于 TTL 消耗为 0 的时间,以确保报文已被自然消亡。

为什么 TIME_WAIT 等待的时间是 2MSL?以及为什么被动关闭以防不需要?

答:通信要解决的首要问题是同步,即双方处于信息对称的情况下。我们来看最后两次挥手,当服务器端发送FIN给客户端的时候,服务端是不能单方面释放掉连接的,因为他不知道客户端收没收到自己的FIN,所以服务器必须要等待客户端发送的ACK过来,才能安全的释放TCP连接所占用的内存资源,端口号。所以服务器作为被动关闭的一方,是无需任何TIME_WAIT状态的。服务器收到ACK就表明我已经知道客户端知道我要关闭连接了,放心关闭

​ 那么此时看客户端,他发送ACK给服务器端后,客户端并不知道服务端有没有收到这个报文,客户端会这么想:

  1. 服务器没收到ACK,我就等着超时重传
  2. 服务器收到自己的ACK了,也不会发消息

可以发现,不管上面哪一种情况,客户端都必须要等带,而且要取最大值以应对最坏的情况发生,这个最坏情况就是①客户端发送ACK到服务器端的报文最大存活时间+②服务器端从新发送FIN到客户端的报文最大存活时间即2MSL。

为什么需要TIME_WAIT?如果没有TIME_WAIT或者TIME_WAIT很短怎么办?

首先要明白,只有主动发起关闭的一方才有TIME_WAIT状态。

  • 防止具有相同「四元组」的「旧」数据包被收到

    假如在客户端向服务端发送FIN即第一次挥手之前,服务器端发送了一个报文给客户端,但因为网络原因这个报文延迟到达了,那么如果TIME_WAIT没有或者太短,由于这个报文具有相同的四元组的旧报文,可能会和新TCP连接的新报文起冲突。

  • 保证连接正确关闭

    即为什么需要2MSL的原因,上面有不再说了。

    TIME_WAIT过多有什么危害?
    • 占用内存资源

    • 占用端口的资源

      由于客户端使用系统自动分配的临时端口号来建立连接,所以一般不用考虑这个问题。但是服务器可能要考虑,因为服务器的端口号一般是固定的。

      如何优化TIME_WAIT?
    • Linux内核中有一个默认值18000,当系统中的TIME_WAIT一旦超过这个值,系统就会将后面TIME_WAIT连接状态重置。

    服务端出现大量TIME_WAIT的原因

    先来说一说长连接和短连接,在HTTP1.1协议中,有个 Connection 头,Connection有两个值,close和keep-alive,这个头就相当于客户端告诉服务端,服务端你执行完成请求之后,是关闭连接还是保持连接。如果服务器使用的短连接,那么每次客户端请求后,服务器都会主动发送FIN关闭连接。最后进入time_wait状态。可想而知,对于访问量大的Web Server,会存在大量的TIME_WAIT状态。让服务器能够快速回收和重用那些TIME_WAIT的资源,可以修改内核参数。

    解决办法

    1. 客户端:HTTP 请求的头部,connection 设置为 keep-alive,保持存活一段时间:现在的浏览器,一般都这么进行了
    2. 服务端:允许 time_wait 状态的 socket 被重用, 缩减 time_wait 时间,设置为 1 MSL(即,2 mins)

三次握手四次挥手

在这里插入图片描述

# fast sender: 客户端
# slow recerver: 服务器
# win: 滑动窗口大小
# mss: maximum segment size, 单条数据的最大长度

**第1步:**第一次握手,发送连接请求SYN到服务器端

​ 0(0):0表示客户端生成的随机序号,(0)表示客户端没有额外给服务器发送数据, 因此数据的量为0
​ win4096: 客户端告诉服务器, 能接收的数据(缓存)的最大量为4k
​ mss1460: 客户端可以处理的单条最大字节数是1460字节
**第2步:**第二次握手

​ ACK: 服务器同意了客户端的连接请求
​ SYN: 服务器请求和客户端建立连接
​ 8000(0):8000是服务器端生成的随机序号,(0)表示服务器没有额外给客户端发送数据, 因此数据的量为0
​ 1: 发送给客户端的确认序号
​ 确认序号 = 客户端生成的随机序号 + 客户端给服务器发送的数据量(字节数) ===> 1=0+1
​ 表示客户端给服务器发送的1个字节服务器收到了
​ win6144: 服务器告诉客户端我能最多缓存 6k数据
​ mss1024: 服务器能处理的单条数据最大长度是 1k
第3步: 第三次握手

​ ACK: 客户端同意了服务器的连接请求
​ 8001: 发送给服务器的确认序号
​ 确认序号 = 服务器生成的随机序号 + 服务器给客户端发送的数据量 ===> 8001 = 8000 + 1
​ 客户端告诉服务器, 你给我发送的1个字节的数据我收到了
​ win4096: 告诉服务器客户端能缓存的最大数据量是4k
第4~9步: 客户端给服务器发送数据

​ 1(1024):1 (1-0)表示之前一共给服务器发送了1个字节,(1024)表示这次要发送的数据量为 1k
​ 1025(1024):1025(1025-0)表示之前一共给服务器发送了1025个字节,(1024)表示这次要发送的数据量为 1k
​ 2049(1024):2049(2049-0)表示之前一共给服务器发送了2049个字节,(1024)表示这次要发送的数据量为 1k
​ 第9步完成之后,服务器的滑动窗口变为0,接收数据的缓存被写满了,发送端阻塞
第10步:

​ ack6145: 服务器给客户端回复数据,6145是确认序号, 代表实际接收的字节数

​ 服务器实际接收的字节数 = 确认序号 - 客户端生成的随机序号 ===> 6145 = 6145 - 0

win2048:服务器告诉客户端我的缓存还有2k,也就是还有4k还在缓存中没有被读走

**第11步:**win4096表示滑动窗口变为4k,代表还可以接收4k数据,还有2k在缓存中

**第12步:**客户端又给服务器发送了1k数据

第13步: 第一次挥手,FIN表示客户端主动和服务器断开连接,并且发送了1k数据到服务器端

第14步: 第二次挥手,回复ACK, 同意断开连接

第15, 16步: 服务器端从读缓冲区中读数据, 第16步数据读完, 滑动窗口变成最大的6k

第17步:

​ FIN: 服务器请求和客户端断开连接

​ 8001(0): 服务器一共给客户端发送的字节数 8001 - 8000 = 1个字节,携带的数据量为0(FIN不计算在内)

​ ack8194: 服务器收到了客户端的多少个字节: 8194 - 0 = 8194个字节

第18步: 第四次挥手

​ ACK: 客户端同意了服务器断开连接的请求
​ 8002: 确认序号, 可以计算出服务器给客户端发送了多少数据,8002 - 8000 = 2 个字节

为什么是三次握手

从客户端发起FIN同步报文段开始,其实是四段。由于TCP是一个全双工的通信方式

​ 1.可以理解为两条单向通道,客户端发送FIN报文段给服务器端,服务器端发送ACK报文段这一个过程是一个单向通道的建立过程,表明服务器是完好的,可以收发数据。

​ 2.然后服务器到客户端这条通道同理也需要相同的过程,服务器发送FIN同步报文段给客户端,客户端返回一个ACK确认帧给服务器

而服务器发送的ACK确认帧和服务器发送的FIN同步报文段完全可以放在一起,不用分两次发送,所以就造成了三次握手。

为什么是四次挥手

由于TCP是一个全双工的通信方式,四次挥手就是正常的流程。只是看看能不能像三次握手一样合并起来。

​ 1.首先第一次客户端向服务器端发送FIN通知服务器端我这边要断掉了,不给你发数据了,这一次是必然的。

​ 2.还有最后一次,客户端给服务器端恢复一个ACK表示你关吧我知道了。这两次是必须的。

对于第二次和第三次,由于服务器端要处理一些数据然后发送给客户端,所以第二次和第三次是无法合并到一起的。所以当服务器端收到客户端的FIN报文段后,必须马上回一个ACK确认报文表示可以关,但此时可能服务器端有一些数据需要处理,所以说等处理完数据我再发一个FIN关闭报文给客户端告诉客户端我这边也要关闭了。

TCP三次握手与四次挥手的图解与要点理解

在这里插入图片描述
在这里插入图片描述

1.SYN是一个建立连接的标志(同步的报文)FIN断开连接的报文

2.ack是应答位,某一端发送的ack的值是它上一次收到的消息的seq值+1,发送的seq值等于上一次收到的ack值

3.seq是一个序列号,它等于随机生成的序列号+已经发送的字节数(按位编码

4.由3.可知TCP的第三次挥手中seq=V≠seq=W的原因是中间服务端会继续发送数据

5.TCP的第四次挥手后等待2MSL机制的设置是为了确定一下服务器端是否收到了数据,如果超时未应答会重传

断开连接时客户端 FIN 包丢失,服务端的状态是什么?

当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 FIN_WAIT_1 状态。

正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 FIN_WAIT2状态。如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 tcp_orphan_retries 参数控制。

当客户端重传 FIN 报文的次数超过 tcp_orphan_retries 后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么客户端直接进入到 close 状态,而服务端还是ESTABLISHED状态

举个例子,假设 tcp_orphan_retries 参数值为 3,当第一次挥手一直丢失时,发生的过程如下图:

发送窗口(Send Window,swnd)

发送窗口应该是拥塞窗口和接收窗口的最小值

发送窗口是根据内存来设置的

在这里插入图片描述

拥塞窗口是根据网络流量状况动态调整的

在这里插入图片描述

TCP的可靠保障

在这里插入图片描述

**累积确认 :**接收方发送的ACK报文中的 确认号表示的是接收方期望接收的下一个字节的序列号。这意味着所有比这个确认号小的 字节都已经被成功接收。

延时确认:不会立即发送确认报文,而是先等等,看看能不能一起发送

超时重传:没收到再发一遍

服务器并发思路

多进程并发

父进程:
负责监听,处理客户端的连接请求,也就是在父进程中循环调用accept()函数
创建子进程:建立一个新的连接,就创建一个新的子进程,让这个子进程和对应的客户端通信
回收子进程资源:子进程退出回收其内核PCB资源,防止出现僵尸进程
**子进程:**负责通信,基于父进程建立新连接之后得到的文件描述符,和对应的客户端完成数据的接收和发送。
发送数据:send() / write()
接收数据:recv() / read()
在多进程版的服务器端程序中,多个进程是有血缘关系,对应有血缘关系的进程来说,还需要想明白他们有哪些资源是可以被继承的,哪些资源是独占的,以及一些其他细节:

​ 子进程是父进程的拷贝,在子进程的内核区PCB中,文件描述符也是可以被拷贝的,因此在父进程可以使用的文件描述符在子进程中也有一份,并且可以使用它们做和父进程一样的事情。

​ 父子进程有用各自的独立的虚拟地址空间,因此所有的资源都是独占的

​ 为了节省系统资源,对于只有在父进程才能用到的资源,可以在子进程中将其释放掉,父进程亦如此。

​ 由于需要在父进程中做accept()操作,并且要释放子进程资源,如果想要更高效一下可以使用信号的方式处理

在这里插入图片描述

多线程并发

主线程:
负责监听,处理客户端的连接请求,也就是在父进程中循环调用accept()函数
创建子线程:建立一个新的连接,就创建一个新的子进程,让这个子进程和对应的客户端通信
回收子线程资源:由于回收需要调用阻塞函数,这样就会影响accept(),直接做线程分离即可。
**子线程:**负责通信,基于主线程建立新连接之后得到的文件描述符,和对应的客户端完成数据的接收和发送。
发送数据:send() / write()
接收数据:recv() / read()
在多线程版的服务器端程序中,多个线程共用同一个地址空间,有些数据是共享的,有些数据的独占的,下面来分析一些其中的一些细节:

​ 同一地址空间中的多个线程的栈空间是独占的
​ 多个线程共享全局数据区,堆区,以及内核区的文件描述符等资源,因此需要注意数据覆盖问题,并且在多个线程访问共享资源的时候,还需要进行线程同步。

​ 在编写多线程版并发服务器代码的时候,需要注意父子线程共用同一个地址空间中的文件描述符,因此每当在主线程中建立一个新的连接,都需要将得到文件描述符值保存起来,不能在同一变量上进行覆盖,这样做丢失了之前的文件描述符值也就不知道怎么和客户端通信了。

在这里插入图片描述

TCP数据粘包的处理

如果使用TCP进行套接字通信,如果发送的数据包粘连到一起导致接收端无法解析,我们通常使用添加包头的方式轻松地解决掉这个问题。关于数据包的包头大小可以根据自己的实际需求进行设定,这里没有啥特殊需求,因此规定包头的固定大小为4个字节,用于存储当前数据块的总字节数。
在这里插入图片描述

套接字通信类的封装与过程实现(C++改进版)

**改进思路:**将服务器的通信功能去掉,只留下监听并建立新连接一个功能。将客户端类变成一个专门用于套接字通信的类即可。服务器端整个流程使用服务器类+通信类来处理;客户端整个流程通过通信的类来处理。

//通信类
class TcpSocket
{
public:TcpSocket();TcpSocket(int socket);~TcpSocket();int connectToHost(string ip, unsigned short port);int sendMsg(string msg);string recvMsg();private:int readn(char* buf, int size);int writen(const char* msg, int size);private:int m_fd;	// 通信的套接字
};
//通信类定义
TcpSocket::TcpSocket()
{m_fd = socket(AF_INET, SOCK_STREAM, 0);
}
//其中无参构造一般在客户端使用,通过这个套接字对象再和服务器进行连接,之后就可以通信了
//有参构造主要在服务器端使用,当服务器端得到了一个用于通信的套接字对象之后,就可以基于这个套接字直 //接通信,因此不需要再次进行连接操作。
TcpSocket::TcpSocket(int socket)
{m_fd = socket;
}TcpSocket::~TcpSocket()
{if (m_fd > 0){close(m_fd);}
}int TcpSocket::connectToHost(string ip, unsigned short port)
{// 连接服务器IP portstruct sockaddr_in saddr;saddr.sin_family = AF_INET;saddr.sin_port = htons(port);inet_pton(AF_INET, ip.data(), &saddr.sin_addr.s_addr);int ret = connect(m_fd, (struct sockaddr*)&saddr, sizeof(saddr));if (ret == -1){perror("connect");return -1;}cout << "成功和服务器建立连接..." << endl;return ret;
}int TcpSocket::sendMsg(string msg)
{// 申请内存空间: 数据长度 + 包头4字节(存储数据长度)char* data = new char[msg.size() + 4];int bigLen = htonl(msg.size());memcpy(data, &bigLen, 4);memcpy(data + 4, msg.data(), msg.size());// 发送数据int ret = writen(data, msg.size() + 4);delete[]data;return ret;
}string TcpSocket::recvMsg()
{// 接收数据// 1. 读数据头int len = 0;readn((char*)&len, 4);len = ntohl(len);cout << "数据块大小: " << len << endl;// 根据读出的长度分配内存char* buf = new char[len + 1];int ret = readn(buf, len);if (ret != len){return string();}buf[len] = '\0';string retStr(buf);delete[]buf;return retStr;
}int TcpSocket::readn(char* buf, int size)
{int nread = 0;int left = size;char* p = buf;while (left > 0){if ((nread = read(m_fd, p, left)) > 0){p += nread;left -= nread;}else if (nread == -1){return -1;}}return size;
}int TcpSocket::writen(const char* msg, int size)
{int left = size;int nwrite = 0;const char* p = msg;while (left > 0){if ((nwrite = write(m_fd, msg, left)) > 0){p += nwrite;left -= nwrite;}else if (nwrite == -1){return -1;}}return size;
}
//服务器类
class TcpServer
{
public:TcpServer();~TcpServer();int setListen(unsigned short port);TcpSocket* acceptConn(struct sockaddr_in* addr = nullptr);private:int m_fd;	// 监听的套接字
};
//服务器类定义
TcpServer::TcpServer()
{m_fd = socket(AF_INET, SOCK_STREAM, 0);
}TcpServer::~TcpServer()
{close(m_fd);
}int TcpServer::setListen(unsigned short port)
{struct sockaddr_in saddr;saddr.sin_family = AF_INET;saddr.sin_port = htons(port);saddr.sin_addr.s_addr = INADDR_ANY;  // 0 = 0.0.0.0int ret = bind(m_fd, (struct sockaddr*)&saddr, sizeof(saddr));if (ret == -1){perror("bind");return -1;}cout << "套接字绑定成功, ip: "<< inet_ntoa(saddr.sin_addr)<< ", port: " << port << endl;ret = listen(m_fd, 128);if (ret == -1){perror("listen");return -1;}cout << "设置监听成功..." << endl;return ret;
}TcpSocket* TcpServer::acceptConn(sockaddr_in* addr)
{if (addr == NULL){return nullptr;}socklen_t addrlen = sizeof(struct sockaddr_in);int cfd = accept(m_fd, (struct sockaddr*)addr, &addrlen);if (cfd == -1){perror("accept");return nullptr;}printf("成功和客户端建立连接...\n");return new TcpSocket(cfd);
}
//测试代码 client.c
int main()
{// 1. 创建通信的套接字TcpSocket tcp;// 2. 连接服务器IP portint ret = tcp.connectToHost("192.168.237.131", 10000);if (ret == -1){return -1;}// 3. 通信int fd1 = open("english.txt", O_RDONLY);int length = 0;char tmp[100];memset(tmp, 0, sizeof(tmp));while ((length = read(fd1, tmp, sizeof(tmp))) > 0){// 发送数据tcp.sendMsg(string(tmp, length));cout << "send Msg: " << endl;cout << tmp << endl << endl << endl;memset(tmp, 0, sizeof(tmp));// 接收数据usleep(300);}sleep(10);return 0;
}
//测试代码 Server.c
struct SockInfo
{TcpServer* s;TcpSocket* tcp;struct sockaddr_in addr;
};void* working(void* arg)
{struct SockInfo* pinfo = static_cast<struct SockInfo*>(arg);// 连接建立成功, 打印客户端的IP和端口信息char ip[32];printf("客户端的IP: %s, 端口: %d\n",inet_ntop(AF_INET, &pinfo->addr.sin_addr.s_addr, ip, sizeof(ip)),ntohs(pinfo->addr.sin_port));// 5. 通信while (1){printf("接收数据: .....\n");string msg = pinfo->tcp->recvMsg();if (!msg.empty()){cout << msg << endl << endl << endl;}else{break;}}delete pinfo->tcp;delete pinfo;return nullptr;
}int main()
{// 1. 创建监听的套接字TcpServer s;// 2. 绑定本地的IP port并设置监听s.setListen(10000);// 3. 阻塞并等待客户端的连接while (1){SockInfo* info = new SockInfo;TcpSocket* tcp = s.acceptConn(&info->addr);if (tcp == nullptr){cout << "重试...." << endl;continue;}// 创建子线程pthread_t tid;info->s = &s;info->tcp = tcp;pthread_create(&tid, NULL, working, info);pthread_detach(tid);}return 0;
}

IO多路复用

为什么要多路复用

传统的阻塞IO模型中,每个客户端连接都需要一个独立的线程或进程来处理。这会极大地消耗系统资。IO多路复用允许单个线程(或进程)同时监控多个IO流(如socket),只有在某个IO流有数据可读/可写时才进行处理,大大减少了线程/进程的数量,提高了资源利用率。

线性表效率低:轮询

红黑树效率高:

selcet、poll被淘汰

epoll常用,案例如下:

#include <sys/socket.h> 
#include <sys/types.h> 
#include <netinet/in.h> 
#include <stdio.h> 
#include <stdlib.h> 
#include <string.h> 
#include <arpa/inet.h> 
#include <pthread.h> 
#include <unistd.h> 
#include <sys/epoll.h> 
#include <fcntl.h> 
#include <errno.h> #define SEVER_PORT 6666 
#define BUFFER_SIZE 1024 
#define MAX_EVENTS 10 #define handle_error(cmd, result) \ if (result < 0)               \ {                             \ perror(cmd);              \ exit(EXIT_FAILURE);       \ } char *read_buf = NULL; 
char *write_buf = NULL; void init_buf() 
{ read_buf = malloc(sizeof(char) * BUFFER_SIZE); // 判断内存是否分配成功 if (!read_buf) { printf("服务端读缓存创建异常,断开连接\n"); perror("malloc sever read_buf"); exit(EXIT_FAILURE); } // 判断内存是否分配成功 write_buf = malloc(sizeof(char) * BUFFER_SIZE); if (!write_buf) { printf("服务端写缓存创建异常,断开连接\n"); free(read_buf); perror("malloc server write_buf"); exit(EXIT_FAILURE); } memset(read_buf, 0, BUFFER_SIZE); memset(write_buf, 0, BUFFER_SIZE); 
} void clear_buf(char *buf) 
{ memset(buf, 0, BUFFER_SIZE); 
} 
//意思就是
//先获取文件描述符现在的功能再给他加一个功能,再把新功能设置了
void set_nonblocking(int sockfd) 
{ //获取文件的权限模式和状态标记int opts = fcntl(sockfd, F_GETFL); if (opts < 0) { perror("fcntl(F_GETFL)"); exit(EXIT_FAILURE); } //加进去一个功能opts |= O_NONBLOCK; //重新设置文件描述符的功能int res = fcntl(sockfd, F_SETFL, opts); if (res < 0) { perror("fcntl(F_SETFL)"); exit(EXIT_FAILURE); } 
} //从此开始
int main(int argc, char const *argv[]) 
{ //初始化读写变量,init_buf(); // 声明sockfd、clientfd和函数返回状态变量 (临时储存结果)int sockfd, client_fd, temp_result; // 声明服务端和客户端地址 struct sockaddr_in server_addr, client_addr; memset(&server_addr, 0, sizeof(server_addr)); memset(&client_addr, 0, sizeof(client_addr)); // 声明IPV4通信协议 server_addr.sin_family = AF_INET; // 我们需要绑定0.0.0.0地址,转换成网络字节序后完成设置 server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 端口随便用一个,但是不要用特权端口 server_addr.sin_port = htons(SEVER_PORT); // 创建server socket sockfd = socket(AF_INET, SOCK_STREAM, 0); handle_error("socket", sockfd); // 绑定地址 temp_result = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); handle_error("bind", temp_result); // 进入监听模式 temp_result = listen(sockfd, 128); handle_error("listen", temp_result); // 将sockfd设置为非阻塞模式  不然就还的用多线程//既然设置了非阻塞,就要有一个集合来监测他是否有数据读(也就是原来阻塞的时候 忽然有数据来然后他变为非阻塞的一个状态 用这样的一个状态来描述有人连接过来了)set_nonblocking(sockfd); int epollfd, nfds; struct epoll_event ev, events[MAX_EVENTS]; //创建epollepollfd = epoll_create1(0); handle_error("epoll_createl", epollfd); //将socketfd的有数据读    加入EPOLL_CTL_ADD到感兴趣列表&ev//当有客户端连接过来的时候,会被触发ev.data.fd = sockfd; ev.events = EPOLLIN; //感兴趣的事情。读or写?//如果他可以读就说明有客户端连接过来了temp_result = epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev); handle_error("epoll_ctl", temp_result); socklen_t cliaddr_len = sizeof(client_addr); // 接受client连接 while (1) { //第一个循环-> 只等待客户端连接进来-> nfds表示有多少个客户端连接//第n次循环-> 既有新的客户端连接  还有旧的客户端发过来的消息nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1); handle_error("epoll_wait", nfds); for (int i = 0; i < nfds; i++) { //第一次循环-> 只会走这个逻辑//第n次循环-> 有新的连接if (events[i].data.fd == sockfd) { //因为一定有客户端连接了  直接获取连接client_fd = accept(sockfd, (struct sockaddr *)&client_addr, &cliaddr_len); handle_error("accept", client_fd); //修改为非阻塞状态set_nonblocking(client_fd); printf("与客户端 from %s at PORT %d 文件描述符 %d 建立连接\n", inet_ntoa(client_addr.sin_addr), 
ntohs(client_addr.sin_port), client_fd); //将新的和客户端对应上的连接->可以和客户端进行读写操作//修改参数   添加到感兴趣列表ev.data.fd = client_fd; //加了个上升沿触发ev.events = EPOLLIN | EPOLLET; epoll_ctl(epollfd, EPOLL_CTL_ADD, client_fd, &ev); } //第n次循环-> 判断当前有的读否,,else if (events[i].events & EPOLLIN) { int count = 0, send_count = 0; client_fd = events[i].data.fd; while ((count = recv(client_fd, read_buf, BUFFER_SIZE, 
0)) > 0) { printf("reveive message from client_fd: %d: %s\n", client_fd, read_buf); clear_buf(read_buf); strcpy(write_buf, "reveived~\n"); send_count = send(client_fd, write_buf, 
strlen(write_buf), 0); handle_error("send", send_count); clear_buf(write_buf); } if (count == -1 && errno == EAGAIN) { printf("来自客户端client_fd: %d当前批次的数据已读取完毕,继续监听文件描述符集\n", client_fd); } else if (count == 0) { printf("客户端client_fd: %d请求关闭连接......\n", 
client_fd); strcpy(write_buf, "receive your shutdown signal\n"); send_count = send(client_fd, write_buf, 
strlen(write_buf), 0); handle_error("send", send_count); clear_buf(write_buf); // 从epoll文件描述符集中移除client_fd printf("从epoll文件描述符集中移除client_fd: %d\n", 
client_fd); epoll_ctl(epollfd, EPOLL_CTL_DEL, client_fd, NULL); printf("释放client_fd: %d资源\n", client_fd); shutdown(client_fd, SHUT_WR); close(client_fd); } } } } printf("释放资源\n"); close(epollfd); close(sockfd); free(read_buf); free(write_buf); return 0; 
} 

TCP、UDP对比

特性TCP(传输控制协议)UDP(用户数据报协议)
协议类型面向连接(Connection-oriented)无连接(Connectionless)
可靠性可靠,确保数据的顺序、完整性和准确性不可靠,不保证数据的顺序或完整性
流量控制支持流量控制和拥塞控制不支持流量控制和拥塞控制
顺序保证数据按顺序到达,接收端根据顺序接收无顺序保证,数据包可能乱序到达
重传机制自动重传丢失的数据包不支持自动重传丢失的数据包
速度较慢,因为需要建立连接,进行确认和重传较快,由于没有连接和重传机制,传输延迟较低
头部开销较大,20字节(包含源端口、目标端口、序列号、确认号等)较小,8字节(包含源端口、目标端口、长度和校验和)
适用场景适用于要求可靠传输的应用,如网页浏览(HTTP)、文件传输(FTP)等适用于实时传输、不要求过高可靠性的应用,如视频流、在线游戏等
连接建立需要三次握手建立连接不需要建立连接
数据流类型面向字节流(流式传输)面向数据报(离散的数据包)
拥塞控制有拥塞控制,适用于流量较大的网络无拥塞控制,适用于实时、低延迟的场景
应用示例HTTP, FTP, Telnet, SMTP, POP3等DNS, VoIP, 视频流、实时在线游戏等

IO多路复用对比

特性selectpollepollkqueue
实现机制基于轮询,检查文件描述符的状态基于轮询,检查文件描述符的状态基于事件驱动(内核通知),通过事件循环驱动基于事件驱动(内核通知),通过事件循环驱动
支持的操作系统POSIX,Linux、macOS、Unix、BSD等POSIX,Linux、macOS、Unix、BSD等主要支持 Linux主要支持 BSD 系列操作系统(macOS、FreeBSD等)
最大文件描述符限制文件描述符个数有限,通常为 1024(受系统限制)文件描述符个数有限,通常为 1024(受系统限制)没有文件描述符个数的明显限制没有文件描述符个数的明显限制
效率随着文件描述符数量增加,效率下降随着文件描述符数量增加,效率下降高效,特别适合大量连接的情况高效,尤其在大规模事件驱动下表现优秀
内存开销每次调用时都需要复制文件描述符集合每次调用时都需要复制文件描述符集合内核为每个事件分配内存,事件处理较为高效内核为每个事件分配内存,事件处理较为高效
事件通知方式轮询,检查文件描述符的状态轮询,检查文件描述符的状态内核通知(通过事件驱动,应用程序不需要轮询)内核通知(通过事件驱动,应用程序不需要轮询)
适用场景文件描述符数目较少的场景文件描述符数目较少的场景适合高并发场景,特别是大量连接时表现优越适合高并发场景,尤其是支持 macOS 和 FreeBSD 的应用
支持的事件类型只能检查 可读可写异常只能检查 可读可写异常支持 可读可写异常,还可以监听更多事件支持 可读可写异常,还有 EVFILT_TIMER 等事件类型
水平触发 vs 边缘触发仅支持水平触发(Level-triggered)仅支持水平触发(Level-triggered)支持水平触发(Level-triggered)和边缘触发(Edge-triggered)支持水平触发(Level-triggered)和边缘触发(Edge-triggered)
操作接口通过 select() 系统调用来操作通过 poll() 系统调用来操作通过 epoll_create()epoll_wait() 等系统调用操作通过 kqueue()kevent() 等系统调用操作
性能瓶颈文件描述符多时性能低,O(n) 的复杂度文件描述符多时性能低,O(n) 的复杂度高效,O(1) 时间复杂度,特别适合大量连接场景高效,O(1) 时间复杂度,特别适合大量连接场景
http://www.dtcms.com/a/291154.html

相关文章:

  • 2x2矩阵教程
  • AI赋能中医传承:智慧医疗新时代解决方案
  • 如何避免redis分布式锁失效
  • 搭建前端页面,介绍对应标签
  • 前端之学习后端java小白(一)之SDKMAN
  • Typecho目录树插件开发:从后端解析到前端渲染全流程
  • AI革命带来的便利
  • [特殊字符] Java反射从入门到飞升:手撕类结构,动态解析一切![特殊字符]
  • 多线程--线程池
  • 【docker】分享一个好用的docker镜像国内站点
  • dev tools的使用
  • FastMCP全篇教程以及解决400 Bad Request和session termination的问题
  • 理解向量及其运算-AI云计算数值分析和代码验证
  • 微店关键词搜索接口深度开发指南
  • 《探索Go语言:云时代的编程新宠》
  • 【WinMerge】怎么一键查找两个文件的内容不同之处? 用它支持一键批量对比!速度贼快~
  • iOS开发 Swift 速记2:三种集合类型 Array Set Dictionary
  • 关于 Python 的踩坑记录
  • 《使用Qt Quick从零构建AI螺丝瑕疵检测系统》——0. 博客系列大纲
  • 多片RFSoC同步,64T 64R
  • (Python模块)Python 的进阶工具:sys模块、os模块 与 logging 模块
  • 通过TPLink路由器进行用户行为审计实战
  • tcpdump 命令解析(随手记)
  • Vue过度与动画效果
  • 【Linux】重生之从零开始学习运维之Mysql安装
  • GNU Radio多类信号多种参数数据集生成技巧
  • 【Spring AI】Advisors API—顾问(即拦截器)
  • 信号量demo
  • 【华为机试】503. 下一个更大元素 II
  • 【华为机试】85. 最大矩形