网络编程(TCP通信)

目录

一、服务器和客户端(单对单)

1、TCP服务器创建流程

1)socket(创建服务器套接字)

2)定义struct sockaddr_in类型结构体

3)使用bind函数命名套接字

4)listen(设置服务器监听列表)

5)accept(接受监听列表中客户端的连接)

6)recv | read(接收数据)

7)服务器代码

2、TCP客户端创建流程

1)socket(创建服务器套接字)

2)定义struct sockaddr_in类型结构体

3)connect(通过套接字申请连接服务器)

4)send | write(发送数据)

5)客户端代码

3、TCP存在的问题

1)TCP协议的执行逻辑

2)粘包问题和截断问题

3)解决粘包问题

1. 接收端

2. 发送端

二、服务器和多客户端(单对多)

1、多线程服务器

2、多进程服务器

3、多路文件IO

1)监视列表:

2)激活列表:

3)多路文件IO的操作流程

4、select模型

1)创建 select 模型的监视列表

2)操作 监视列表

3)select 模型的监视函数

4)select 模型代码

5)select 模型的优缺点

5、poll 模型

1)创建 poll 的监视列表

2)操作监视列表

3)poll 模型监视函数

4)poll 模型代码

5)poll 模型的优缺点

6、epoll 模型

1)epoll_create1(创建 epoll 的监视列表)

2)epoll_ctl(操作监视列表)

3)epoll_wait(监视列表并加入激活列表)

4)epoll 模型代码

5)epoll 模型的两个模式

1. 水平模式

2. 边缘模式

3. 使用函数 epoll_ctl 将 epoll 列表设置为边缘模式

6)epoll 模型的优缺点


一、服务器和客户端(单对单)

1、TCP服务器创建流程

1)socket(创建服务器套接字)

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
功能:创建不同类型的套接字用文件描述符
参数 domain:套接字所依赖的网络介质
    如果是 ipv4 就填入 AF_INET
    如果是 ipv6 就填入 AF_INET6
    如果是 域套接字 就填入 AF_LOCAL / AF_UNIX
参数 type:选择套接字的类型
    SCOK_STREAM:字节流套接字传输数据,连续,可靠,双向,数据量大
    SOCL_DGRAM:数据包套接字传输数据,不连续,不可靠,有长度要求,双向
参数 protocol:选择套接字所依赖的通信协议
    0:自动匹配,会根据参数 type 和参数 protocol 自动选择合适的通信协议
返回值:
    返回创建套接字文件描述符

一般来说:
    AF_INET + SOCK_STREAM + 0 ,最终创建的是 TCP 协议的套接字
    AF_INET + SOCK_DGRAM  + 0 ,最终创建的是 UDP 协议的套接字 

2)定义struct sockaddr_in类型结构体

若为 ipv6 型的 ip地址 则使用结构体 struct sockaddr_in6

struct sockaddr_in {
  __kernel_sa_family_t  sin_family; /* 依赖的网络介质       */
  __be16        sin_port;           /* port端口号           */
  struct in_addr    sin_addr;       /* ip地址               */
};

3)使用bind函数命名套接字

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
功能:往套接字中写入 ip地址 和 port端口号 (该操作被称为为套接字命名)
参数 sockfd:填写 套接字用文件描述法
参数 addr:通用套接字结构体指针,需要提前准备 struct sockaddr_in 类型的结构体
参数 addrlen:参数 addr 的字节长度

4)listen(设置服务器监听列表)

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
功能:将想要连接服务器的客户端加入监听列表,等待客服务器与其连接
参数 sockfd:套接字文件描述符
参数 backlog:监听列表的长度

监听列表:
    所有等待服务器连接的客户端,都会在监听列表等待
    服务器连接客户端后,会将连接的客户端从监听列表移除
    若服务器只监听不连接,监听列表被塞满,则不会添加新的客户端到列表

5)accept(接受监听列表中客户端的连接)

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能:连接监听列表第一个客户端(阻塞函数)
参数 sockfd:接受客户端连接的服务器的套接字
参数 addr:结构体接收已连接的客户端套接字文件中的ip地址和port端口号,若填NULL则不接受连接
参数 addrlen:结构体addr的字节长度
返回值:返回连接的客户端的套接字,没有客户端可连接就阻塞

6)recv | read(接收数据)

#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
功能:通过套接字,读取套接字中ip地址所发送的数据
参数 sockfd:由socket创建的套接字文件描述符(要读取的目标)
参数 buf:将读取到的数据存入buf所指向的连续地址(所指向的数组)
参数 len:所读取数据的字节长度
参数 flags:设置函数的状态 阻塞 / 非阻塞
    0    :默认阻塞,没有接收到数据就阻塞
    MSG_DONTWAIT:非阻塞,没有读取到数据直接返回 0 
返回值:
    阻塞模式:
        返回接收到数据的字节数,若套接字损坏 返回 -1
        若服务器与客户端连接断开,则有阻塞函数变为非阻塞函数,并返回 0
    非阻塞模式:
        返回接收到数据的字节数,若服务器与客户端间发生错误,返回 -1
        未接收到参数 以及 客户端断开连接,返回 0
=====================================================================
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
用法基本一致,IO篇也有详细介绍

7)服务器代码

2、TCP客户端创建流程

1)socket(创建服务器套接字)

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
功能:创建不同类型的套接字用文件描述符
参数 domain:套接字所依赖的网络介质
    如果是 ipv4 就填入 AF_INET
    如果是 ipv6 就填入 AF_INET6
    如果是 域套接字 就填入 AF_LOCAL / AF_UNIX
参数 type:选择套接字的类型
    SCOK_STREAM:字节流套接字传输数据,连续,可靠,双向,数据量大
    SOCL_DGRAM:数据包套接字传输数据,不连续,不可靠,有长度要求,双向
参数 protocol:选择套接字所依赖的通信协议
    0:自动匹配,会根据参数 type 和参数 protocol 自动选择合适的通信协议
返回值:
    返回创建套接字文件描述符

一般来说:
    AF_INET + SOCK_STREAM + 0 ,最终创建的是 TCP 协议的套接字
    AF_INET + SOCK_DGRAM  + 0 ,最终创建的是 UDP 协议的套接字 

2)定义struct sockaddr_in类型结构体

struct sockaddr_in {
  __kernel_sa_family_t  sin_family; /* 依赖的网络介质       */
  __be16        sin_port;           /* port端口号           */
  struct in_addr    sin_addr;       /* ip地址               */
};

3)connect(通过套接字申请连接服务器)

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
功能:连接套接字文件描述符指向的服务器
参数 sockfd:要连接的服务器的套接字文件描述符
参数 addr:结构体存储要连接的服务器的ip地址和port端口号
参数 addrlen:结构体addr的长度
返回值:
    成功连接返回 0 ,失败返回 -1

4)send | write(发送数据)

#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
功能:通过套接字,向套接字中指向的ip地址发送数据
参数 sockfd:填入 socket创建的套接字文件描述符(发送的目标)
参数 buf:填入 要发送的数据的地址
参数 len:填入 要发送的字节的长度
参数 flag:设置函数的状态 阻塞 / 非阻塞
    0    :默认阻塞,发送数据给目标,目标的接收区满了,就会发送阻塞
    MSG_DONTWAIT:非阻塞,发送数据给目标,接收区满了,丢弃新发送的数据
=======================================================================
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
用法基本一致,IO篇也有详细介绍

5)客户端代码

3、TCP存在的问题

1)TCP协议的执行逻辑

将短时间内连续发送的数据存储在一个1500字节的缓存区,每次发送数据,其实时固定发送了1500个字节,这样的好处时大大的提高了发送数据的效率。这也出现了两个问题

2)粘包问题和截断问题

1. 短时间内连续发送的数据(小于1500字节),会被粘连在一起发送(接收端要拆分)

2. 如果要发送的数据总大小超过1500字节,那么超出1500字节的部分,会被截断,留着下次发送

3)解决粘包问题

1. 接收端
#include <my_head.h>

enum Type{
    TYPE_REGIST,
    TYPE_LOGIN
};

typedef struct Pack{
	int size;
    enum Type type;
	char buf[4096];
	int used;// 记录一下当前pack包的buf用了几个字节
}pack_t;


// 写一个函数,读取pack包里面的数据
// 参数 1:等待读取的pack包
char** read_data_from_pack(pack_t* pack){
	// packsize 是整个包的总大小
	char* buf = pack->buf;
	
	// 创建一个堆空间数组,用于向外返回数据
	//char** list = malloc(sizeof(char*)*20);
	char** list = calloc(20,sizeof(char*));// 申请堆空间变初始化
	int i = 0;
	
	int readed_size = 0;// 用来记录buf已经读取了多少个字节

	while(1){
		short size = *(short*)(buf+readed_size);
		//printf("size = %d\n",size);
		if(size == 0){
			break;
		}

		readed_size += 2;

		char temp[size+1];
		memset(temp,0,size+1);
		strncpy(temp,buf+readed_size,size);

		readed_size += size;

		list[i] = calloc(1,size+1);
		strcpy(list[i],temp);
		i++;
	}
	return list;
}

int main(int argc, const char *argv[])
{
	if(argc < 2){
		printf("请输入端口号\n");
		return 1;
	}
	short port = atoi(argv[1]);
	// "abc123" -> 0
	int server = socket(AF_INET,SOCK_STREAM,0);

	struct sockaddr_in addr = {0};
	addr.sin_family = AF_INET;	
	addr.sin_port = htons(port);
	addr.sin_addr.s_addr = inet_addr("0.0.0.0");

	if(bind(server,(struct sockaddr*)&addr,sizeof(addr)) == -1){
		perror("bind");
		return 1;
	}

	listen(server,10);

	struct sockaddr_in client_addr;
    int client_len = sizeof(client_addr);

    int client = accept(server,(struct sockaddr*)&client_addr,&client_len);
	printf("有客户端连接\n");

	while(1){
	
		int size = 0;
		int res = read(client,&size,4);
		// 先读取前4个字节的目的在于:知道一下客户端发来的包到底多大
		if(res == 0){
			printf("客户端断开连接\n");
			return 0;
		}

		// 到此为止,我们已经知道了客户端发来的数据包,总共size个字节
		pack_t pack = {0};
		pack.size = size;
		res = read(client,(char*)&pack+4,size-4);

		//printf("packsize = %d\n",size);
		//printf("type = %d\n",pack.type);
		// (char*)&pack+4 含义为:将读取的数据,码放在pack首地址偏移4个字节的位置处
		// size - 4含义为:整个数据包只有size个字节,前面74行已经读取了4个字节,现在只剩 size - 4个字节可读
		
		// 到目前为止,我们只是将客户端发来的数据,读取到了pack数据包里面而已
		// 还没有解包
		
		// 开始解包
		char** list = read_data_from_pack(&pack);
		printf("账号 = %s\n",list[0]);
		printf("密码 = %s\n",list[1]);
	}
	return 0;
}
2. 发送端
#include <my_head.h>

enum Type{
	TYPE_REGIST,
	TYPE_LOGIN
};

typedef struct Pack{
	int size; // 用来记录打算发送的协议包有多大
	enum Type type;
	char buf[4096];
	int used;
}pack_t;

// 写一个函数,功能为 向pack包中,写入一组数据
// 参数1: & pack包 
// 参数2:想要写入的数据本身,暂定为字符串
void pack_append_data(pack_t* pack,const char* val){
	char* buf = pack->buf;

	// 计算准备写入的数据的长度
	short len = strlen(val);

	// 从buf里面,取2个字节,用来存放val的长度
	*(short*)(buf+pack->used) = len;
	// buf + pack->used
	// 含义为:偏移掉已经使用的部分,从buf未使用的部分开始存放数据

	pack->used += 2;

	// 将字符串数据拷贝到紧挨着的内存里面
	strcpy(buf+pack->used,val);
	pack->used += len;

	pack->size = 8 + pack->used;
	// 8个字节是什么? size 自己的大小 + type 的大小
	// pack->used是什么? pack.buf 实际使用的大小
}

int main(int argc, const char *argv[])
{
	if(argc < 2){
		printf("请输入端口号\n");
		return 1;
	}
	short port = atoi(argv[1]);// atoi 函数,将字符串类型转换成整形

	int client = socket(AF_INET,SOCK_STREAM,0);

	struct sockaddr_in addr = {0};
	addr.sin_family = AF_INET;	
	addr.sin_port = htons(port);
	addr.sin_addr.s_addr = inet_addr("127.0.0.1");

	connect(client,(struct sockaddr*)&addr,sizeof(addr));

// 	客户端发送账号密码给服务器,实现注册/登录操作

	while(1){
		char name[16] = "";
		char pswd[16] = "";
		printf("请输入账号:");
		scanf("%s",name);
		getchar();

		printf("请输入密码:");
		scanf("%s",pswd);
		getchar();

		pack_t pack = {0};
		pack_append_data(&pack,name);// 将账号name存入pack包中
		pack_append_data(&pack,pswd);// 将密码pswd存入pack包中

		printf("packsize = %d\n",pack.size);
		write(client,&pack,pack.size);
	}
	return 0;
}

二、服务器和多客户端(单对多)

普通的cs架构是无法实现单服务器连接多客户端的,准确来说是,可以实现但是功能十分受限
因为 accept 函数和read 函数是阻塞函数,而这两个函数在一起会互相阻塞,互相影响。

多线程和多进程仅作为一种方法,主要的还是使用多路文件IO,因为线程进程十分占用内核 

1、多线程服务器

2、多进程服务器

3、多路文件IO

功能:在单线程中,可以顺畅的调用多个阻塞函数,但是不会互相影响

该功能通过监视列表激活列表实现

1)监视列表:

用户需要通过套接字文件描述符手动的将想要监视的对象,和可能发送信息的对象,加入监视列表多路文件IO会监视列表中的所有套接字文件描述符,看哪个套接字发送了信息,就激活哪个。

2)激活列表:

套接字发送了信息,变为可读,自动激活,自动将套接字文件描述符加入激活列表,进入激活列表
说名这个套接字是可读的,或者待连接的,使用阻塞函数 accept 或 recv 等,是不会被阻塞的。

3)多路文件IO的操作流程

1. 创建监视列表

2. 将想要监视的套接字的文件描述符加入监视列表

3. 使用多路IO模型的函数监视监视列表

4. 从激活列表中取出已激活的描述符使用

4、select模型

select 模型是最早的多路IO模型

头文件
/* According to POSIX.1-2001, POSIX.1-2008 */
#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

1)创建 select 模型的监视列表

fd_set list
    fd_set 是select监视列表的数据类型
    list 是监视列表

2)操作 监视列表

void FD_CLR(int fd, fd_set *set);
功能:将套接字文件描述符 fd 从监视列表 set 中删除
int  FD_ISSET(int fd, fd_set *set);
功能:判断 套接字文件描述符fd 是否在 监视列表set 中
void FD_SET(int fd, fd_set *set);
功能:将套接字文件描述符 fd 加入监视列表 set 中
void FD_ZERO(fd_set *set);
功能:清空监视列表 set

3)select 模型的监视函数

 int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds,
            struct timeval *timeout);
功能:监视3个监视列表,将激活的列表加入激活列表
参数 nfds:传入监视的3个列表中最大的文件描述符+1
参数 readfds:仅监视监视列表 readfds列表 中的描述符是否变得可读
参数 writefds:仅监视监视列表 writefds列表 中的描述符是否变得可写
参数 exceptfds:仅监视监视列表 execeptfds列表 中的描述符是是否异常
参数 wimeout:设置函数的最大阻塞时长

        struct timeval {
            long    tv_sec;         /* seconds 秒*/
            long    tv_usec;        /* microseconds 微秒*/
        };
        如果传入的结构体值是 {1,1000000},select 做多阻塞 2秒
        如果传入 NULL,则表示 select 函数会一直阻塞,直到发现某些描述符激活为止
返回值:成功返回同一个时间段内,激活的描述符的数量,select函数基本用不到这个返回值

    注意:
    select 函数监视列表和激活列表是同一张
    select会将激活的描述符,以覆盖的形式的,重新写入监视列表中
#include <sys/select.h>

int pselect(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, 
            const struct timespec *timeout,
            const sigset_t *sigmask);
功能和参数和select函数一致,唯独多一个参数 sigmask
参数 sigmask:用于临时替换掩码值

4)select 模型代码

只写发送端代码,接收端代码和单对单的一模一样




5)select 模型的优缺点

优点:

        简单好用,直观

缺点:

        1:监视列表 fd_set 类型 其本质是一个 栈空间数组,因此存在上限且无法动态扩容

        2:select 还将数组包装成结构体,并且提供操作数组的数组的函数
              这就导致,用户本身无法利用该数组来记录一些必要描述符
              所以,用户需要自己定义一个数组去记录一些必要的描述符
              然后判断这些必要的描述符是否激活的时候,就会造成双重循环,程序运行效率低

        3:select的激活列表会覆盖监视列表,所以用户还需要额外准备一个激活列表,
              并且每次重新使用监视列表去赋值,导致更多的多余操作以及降低程序的运行效率

5、poll 模型

1)创建 poll 的监视列表

struct pollfd list
    struct pollfd 是 poll模型监视列表的类型
    list 监视列表变量名

    struct pollfd {
        int   fd;         /* file descriptor */
        short events;     /* requested events */
        short revents;    /* returned events */
    };

2)操作监视列表

    struct pollfd {
        int   fd;         /* file descriptor */
            想要监视的文件描述符
        short events;     /* requested events */
            以何种形式监视 fd,例如
                POLLIN:监视 fd 是否变得可读
                POLLOUT:监视 fd 是否变得可写
                
        short revents;    /* returned events */
            默认值是 0 
    };


关于 revents:
    poll 模型里面,监视列表和激活列表也是同一张,但是操作方式和select不一样
    在poll模型里面,poll 会监视 监视列表,并寻找列表哪些描述符激活了
        如果发现某个描述符激活了
        poll 就会找到这个描述符所在的结构体,并更改其revent是值
        
    所以 revents 功能就是:
        标记 fd 描述符是否被激活,以及被如何激活了
        0:表示fd描述符没有被激活
        POLLIN:表示 fd 描述变得可读,即可读激活
        POLLOUT:表示 fd 描述符变得可写了,即可写激活

3)poll 模型监视函数

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能:监视 fds 列表中,是否存在激活的描述符
参数 fds :监视列表,是一个 struct pollfd 结构体数组,同时也是 激活列表
参数 nfds:fds 数组的长度,用户自己管理
参数 timeout:用来指定 poll 函数最大阻塞时长,单位为"毫秒"
        即,该参数传 1000,poll函数最多阻塞1秒
        如果传0,则表示 poll 函数不阻塞
        如果传 < 0 ,一般传-1,则表示 poll 函数会一直阻塞,直到有描述符激活为止
返回值:成功返回激活的描述符的数量,一般用不到

4)poll 模型代码

5)poll 模型的优缺点

优点:改进了select的几个缺点

        1:虽然监视列表和激活列表还是同一张
              但是不会覆盖了。所以用户无需额外操作,也无需反复大量赋值,浪费系统资源

        2:select监视列表是一个无法扩容的栈空间数组,poll模型的监视列表很灵活,
              不想扩容,就写成栈空间数组。想扩容,就写成堆空间的动态数组

缺点:

poll模型的监视列表,虽然能扩容了,但是由于是一个数组,每次监视到有描述符激活的时候,需要从头遍历,修改监视列表里面的revents

在查询激活的描述符的效率方面,poll模型还是比较低的,epoll模型就是效率最高的

6、epoll 模型

仅当Liunx内核版本为 2.6.8 及以上的时候才能使用,windows是使用不了的

1)epoll_create1(创建 epoll 的监视列表)

epoll 的监视列表和激活列表不是普通的列表,而是一个文件。

#include <sys/epoll.h>

int epoll_create(int size);
功能:创建epoll列表,并监视监视列表,但由于数量固定不可动态扩容已弃用
============================================================================
int epoll_create1(int flags);
功能:创建并监视 epoll 列表,且监视列表的数量自动扩容
参数 flags:现阶段只能填写 EPOLL_CLOEXEC
返回值:成功 返回epoll监视列表的文件描述符,失败 返回 -1

一般来说在产生子进程前,打开的文件,子进程会复制打开的状态,
但是由于 flags 参数为EPOLL_CLOEXEC,
所以在子进程产生前打开的监视列表,在子进程中默认是关闭的。

其实这一步直接写 int epfd = epoll_create1(EPOLL_CLOEXEC); 即可

2)epoll_ctl(操作监视列表)

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:操作监视列表,共有 3 种操作方式
参数 epfd:要操作的监视列表
参数 op:设置 epoll_ctl 函数的操作方式
    EPOLL_CTL_ADD :将文件描述符 fd 指向的套接字加入监视列表,并将监视事件,与其关联
    EPOLL_CTL_MOD :更新监视列表种 fd 指向的套接字的监视事件,到原有的结构体中
    EPOLL_CTL_DEL :将套接字文件描述符从监视列表中移除,此时event可以为NULL
参数 fd:要操作的套接字的文件描述符
参数 event:是针对 监控 套接字文件描述符使用的结构体,结构体如下
    typedef union epoll_data {
        void        *ptr;
        int          fd;         用来标记套接字文件描述符
        uint32_t     u32;
        uint64_t     u64;
    } epoll_data_t;

    struct epoll_event {
        uint32_t     events;     EPOLLIN:监视可读    EPOLLOUT:监视可写
        epoll_data_t data;       
    };
返回值:成功 返回0 失败 返回-1,更新errno

注意:虽然监视列表和激活列表不是同一个,但是该模型只会把结构体存入激活列表
    不会把结构体对应的文件描述符一起存入激活列表,因此我们需要将文件描述符存入结构体

3)epoll_wait(监视列表并加入激活列表)

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
功能:监视epoll监视列表并将,并且将激活的文件描述符对应的结构体存入激活列表
参数 epfd:监视列表的文件描述符,监视该列表
参数 events:加入该激活列表,激活列表是 struct epoll_event * 类型的结构体数组
参数 maxevents:想要监视的套接字的数量
参数 timeout:设定epoll_wait 的最长阻塞时间,单位是毫秒,
                    特殊值 0 表示不阻塞,-1表示一直阻塞
返回值:成功 返回激活的数量,失败 返回 -1 ,阻塞超时 返回 0 

4)epoll 模型代码



5)epoll 模型的两个模式

1. 水平模式

        只要目标套接字文件描述符,对应的可读缓存区中,存在未读的数据,则该套接字文件描述符会一直处于激活状态。select、poll、epoll 的默认模式都是水平模式

2. 边缘模式

        无论目标套接字文件描述符,对应的可读缓存区中,是否存在未读数据,都不会激活,只有当目标套接字文件描述符对应的缓存区,写入新的数据时,会激活目标套接字文件描述符。

3. 使用函数 epoll_ctl 将 epoll 列表设置为边缘模式
int epoll_ctl(int epfd,int op,struct epoll_event* event);

可以通过设置该函数的 event 参数,设置epoll列表的模式
常用(水平模式) :EPOLLIN
设置成边缘模式 :EPOLLIN | EPOLLET

6)epoll 模型的优缺点

缺点:模型复杂,学习成本高

优点:查询效率高(监视列表是二叉树),可以自动动态扩容,由于本质是文件,理论上无上限,监视列表和激活列表不是同一个,额外存在边缘模式

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值