高并发内存池(二):​整体框架的介绍与ThreadCache的实现

news2024/11/13 9:05:11

目录

整体框架介绍

ThreadCache的主体框架

自由链表-FreeList

内存对齐-RoundUp

计算桶位置-Index

基础版

进阶版

线程局部存储

__declspec(thread) 关键字

实现线程无锁

申请内存-Allocate

释放内存-Deallocate

从中心缓存中申请内存


整体框架介绍

高并发内存池(concurrent  memory pool)主要由以下三个部分构成:

  1. 线程缓存(Thread Cache)是哈希桶结构,每个桶下都挂有一个自由链表,每个线程独享,用于分配单次申请的内存小于256KB的情况(而不是说它一共可分配的内存为256KB),每个线程从这里申请内存不需要加锁,更加高效
  2. 中心缓存(Central Cache)所有线程共享,故使用桶锁来解决各个线程在申请内存时存在的竞争关系(因为只有某个线程缓存没有足够的内存时才会向中心缓存申请,所以这里的竞争没有那么激烈),每个线程的线程缓存会按需求从中心缓存中获取内存,中心缓存再在合适的时机回收线程缓存中的内存,从而达到内存分配在多线程中更加的均衡,中心缓存没对象时会去页缓存申请页
  3. 页缓存(Page Cache)以页为单位进行存储和分配,中心缓存没有足够的内存对象时,页缓存会分配一定数量的页,并切割成小块内存,分配给中心缓存,当一个span管理的几个页对象都回收后,页缓存会回收中心缓存中满足条件的span对象,并且合并相邻的页,组成更大的页,从而缓解内存碎片问题

哈希桶:具有相同映射关系的对象归于同一子集合,每一个子集合称为一个哈希桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中 

ThreadCache的主体框架

//定义在common.h中
static const size_t NFREELIST = 208;//提前计算出需要208个桶(后面会有解释)
static const size_t NFREELIST = 208;//规定好有208个桶(后面会有解释)

//定义在ThreadCache.h中
class ThreadCache
{
public:
	void* Allocate(size_t bytes);//申请内存
	void Deallocate(void* ptr, size_t size);//释放内存
	//从中心缓存中获取内存
	void* FetchFromCentralCache(size_t index, size_t size);
private:
	FreeList _freeLists[NFREELIST];//208个桶(自由链表)
};

//TLS thread local storage(后续会说明)
//static保证该指针只在当前文件可见防止因为多个头文件包含导致的链接时出现多个相同名称的指针
static _declspec(thread)ThreadCache* pTLSThreadCache = nullptr;

//_declspec(thread)关键字指定 pTLSThreadCache 变量是一个线程局部存储(TLS)变量.
//这意味着 pTLSThreadCache 指针是每个线程独有的,当一个线程使用 pTLSThreadCache 时,它访问的是与其他线程完全独立的内存空间。

自由链表-FreeList

基本概念:链表中的各个结点都是归还回来的小块内存

//static修饰防止重名,同时传引用返回防止拷贝
static void*& NextObj(void* obj)
{
	return *(void**)obj;
}

//管理小块内存的自由链表
class FreeList
{
public:
	//头插
	void Push(void* obj)
	{
		assert(obj);//要插入的对象不能为空
		NextObj(obj) = _freeList;
		_freeList = obj;
	}

	//头删
	void* Pop()
	{
		assert(_freeList);//自由链表不能为空
		void* obj = _freeList;
		_freeList = NextObj(obj);
		return obj;
	}

	//判空
	bool Empty()
	{
		return  _freeList == nullptr;
	}

	//最大结点个数
	size_t& MaxSize()
	{
		return _maxSize;
	}

private:
	void* _freeList = nullptr;
	size_t _maxSize = 1;//用于控制自由链表中结点的最大个数
};
  • NextObj(void* obj):用每个链表结点的前4/8个字节(取决于位环境)存放下一个结点的地址,但如果

  • void* _freelist::void*类型的指针是通用指针类型,可以指向任何类型的数据。这意味着自由链表的每个节点都可以容纳任何类型的数据结构
int a = 2;
int* ptr = &a;
void* vptr = ptr; 
  • size_t& MaxSize():用于文章末尾的慢开始调节算法

内存对齐-RoundUp

需求原因:thread cache支持单次内存小于等于256KB的申请,如果我们将每种字节数的内存块都用自由链表来管理,就需要256 * 1024 = 262,144个桶,而存储桶中的自由链表的头指针就需要消耗大量的内存(一个桶下就有一个自由链表)

解决办法:将每次申请的内存大小size按照某种规则进行内存对齐

新问题:对齐数应该大于4字节,因为自由链表中的每个结点都需要存放下一个结点的地址,如果对齐数设为4,则在32位环境下size为3时对齐后为4,可以放下一个指针,但在64位环境下size为3对齐后为4,不能放下一个指针,所以最小对齐数应该为8(惯例,取4或8或16等2的倍数便于OS进行内存管理),但若我们将对齐数均取为8,仍然需要32767个桶

假设有一块8字节的内存,初始时它处于空闲状态:

//32位环境
[ 指向下一个块的地址 | 未使用 ] 
[ 4字节指针 | 4字节未使用 ]

//64位环境
[ 指向下一个块的地址 ] 
[ 8字节指针 ]

当这个块被分配给一个变量后,整个8字节就可以用来存储变量的数据:

//32位环境下:
[ 变量数据 | 变量数据 ]
[ 4字节数据 | 4字节数据 ]

//64位环境下:
[ 变量数据 | 变量数据 ]
[ 4字节数据 | 4字节数据 ]

空闲时的结点中不会存放数据只有下一个结点的地址,所以不用担心64位环境下size = 3,对齐后为8但是放不下一个指针的问题(之前我一直在纠结这里🤡)

最终解决办法:按照size所属的字节范围选用不同的对齐数,即一段范围的值对应一个桶

[1,128]:对齐数为8,一共16个自由链表:
8->8->
16->16->
...
128->128->


[128+1,1024]:对齐数为16,一共56个自由链表:
129->129->
145->145->
...
1024->1024->

...

[64*1024+1,256*1024]:对齐数为8*1024,一共56个自由链表:
64*1024+1->64*1024+1->
64*1024+8*1024->64*1024+8*1024->
...
256*1024->256*1024->

优点:减少高并发内存池(一):项目介绍与定长内存池的实现中提到的内碎片,提高资源利用率,每次分配出去的内存中最多有10%左右的内碎片浪费(size = 15,在[1,128]范围内,按8对齐后的内碎片为1,1 / 16 * 100% = 6.25% ≈ 10%)

对齐规则:

申请的size大小             对齐数              桶/自由链表的个数     
[1,128]                  按8byte对齐			freelist[0,16)           
[128+1.1024]		     按16byte对齐	    freelist[16,72)          
[1024+1,8*1024]			 按128byte对齐		freelist[72,128)	      
[8*1024+1,64*1024]		 按1024byte对齐		freelist[128,184)        
[64*1024+1,256*1024]     按8*1024byte对齐	freelist[184,208)	      
//计算对象大小的对齐映射规则 
class SizeClass
{
public:
	//_函数名:表示一个子/辅助函数
	//bytes:申请的内存大小
    //alignNum:规定的对齐数
	 static inline size_t _RoundUp(size_t bytes, size_t alignNum)
	{
		size_t alignSize;//对齐后大小
		if (bytes % 8 != 0)//不满足初始的以8byte对齐就按照
		{
			alignSize = (bytes / alignSize + 1) * alignNum;
		}
		else
		{
			alignSize = bytes;
		}
		return alignSize;
	}

    //对齐函数
	static inline size_t RoundUp(size_t size)
	{
		if (size <= 128)
		{
			return _RoundUp(size, 8);
		}
		else if (size <= 1024)
		{
			return _RoundUp(size, 16);
		}
		else if(size <= 8*1024)
		{
			return _RoundUp(size, 128);
		}
		else if(size <= 64*1024)
		{
			return _RoundUp(size, 1024);
		}
		else if (size <= 256 * 1024)
		{
			return _RoundUp(size, 8*1024);
		}
		else
		{
			//到这里表示必然出错,直接assert退出即可
			assert(false);
			return -1;
		}
	}
};
  • 内联函数:减少函数调用的开销,提高程序的运行效率 
  • 通过位运算实现的计算对齐规则的子函数,虽然这样进行位运算更快,但很难想到:
static inline size_t _RoundUp(size_t bytes, size_t align)
 {
     return (((bytes)+align - 1) & ~(align - 1));
 }

注意事项:这只是为了减少桶的个数而设计的对齐方案,如何找到对应的桶在下面的内容中 

计算桶位置-Index

基础版

//基础版寻找桶位置
static inline size_t Index(size_t bytes,size_t alignnum)//申请内存,对齐数
{
	
	if (bytes % alignnum == 0)//刚刚好和对齐数一样
	{
		return bytes / alignnum - 1;//第一个桶的下标为0,故后续桶计算出的位置要-1
	}
	else
	{
		return bytes / alignnum;
	}
}

//传递参数的函数与进阶版中的类似,这里不再写
  • bytes = 8:alignnum = 8,8 % 8 = 0,8 / 8 - 1 = 0,应该位于第一个桶下的自由链表
  • bytes = 9:alignnum = 8,9 / 8  = 1,应该位于第二个桶下的自由链表

进阶版

//进阶版寻找桶位置:
static inline size_t _Index(size_t bytes, size_t align_shift)//申请内存,对齐数的次方
{
	return ((bytes + (1 << align_shift) - 1) >> align_shift) - 1;
}

static inline size_t Index(size_t bytes)
{
	assert(bytes <= MAX_BYTES);//确保传入申请内存的最大大小不超过256KB,MAX_BYTES会额外定义
	static int group_array[4] = { 16,56,56,56 };//提前写出计算好的每个链表的个数
	if (bytes <= 128)
	{
		return _Index(bytes,3);
	}
	else if (bytes <= 1024)
	{
		return _Index(bytes - 128, 4) + group_array[0];//加上上一个范围内的桶的个数
	}
	else if (bytes <= 8 * 1024)
	{
		return _Index(bytes - 1024, 7) + group_array[0] + group_array[1];
	}
	else if (bytes <= 64 * 1024)
	{
		return _Index(bytes - 8 * 1024, 10) + group_array[0] + group_array[1] + group_array[2];
	}
	else if (bytes <= 256 * 1024)
	{
		return _Index(bytes - 64 * 1024, 13) + group_array[0] + group_array[1] + group_array[2] + group_array[3];
	}
	else
	{
		assert(false);
		return -1;
	}
}
  • bytes = 56,align_shift = 3:(bytes + (1 << align_shift) - 1),56+7=63
  • >> align_shift-1:63 >> - 1 = 6,应该位于第七个桶下的自由链表
  • 依旧是位运算更快所以才会有进阶版,但实际有点难想到

线程局部存储

基本概念: 允许每个线程有自己的一份数据拷贝,这样多个线程可以同时运行相同的代码,而不必担心会干扰其他线程的数据

__declspec(thread) 关键字

基本概念:是一个用于在Windows平台上声明线程局部存储变量的关键字,它会为每个线程创建一个独立的数据副本,每个线程对这些数据的读写操作都是独立的

~下面的三种用法是补充内容,了解即可~

基本用法:被__declspec(thread) 修饰的变量会为每个线程创建一个独立的副本。每个线程对这些变量的读写操作都是线程独立的。

__declspec(thread) int tlsVar = 0;

void SomeFunction() {
    tlsVar++;
    std::cout << "Thread " << GetCurrentThreadId() << ": tlsVar = " << tlsVar << std::endl;
}

        在本例子中,tlsVar 是一个线程局部变量,每个线程都有自己的 tlsVar 实例SomeFunction 函数可以在多个线程中并发执行,每个线程都会修改自己的 tlsVar,而不会影响其他线程的 tlsVar

在类中使用:__declspec(thread) 也可以用于修饰类的成员变量,只要这些成员是静态的

class MyClass {
public:
    static __declspec(thread) int tlsMember;
};

__declspec(thread) int MyClass::tlsMember = 0;

void SomeFunction() {
    MyClass::tlsMember++;
    std::cout << "Thread " << GetCurrentThreadId() << ": tlsMember = " << MyClass::tlsMember << std::endl;
}

MyClass::tlsMember 是一个静态的线程局部变量,每个线程都有自己的 tlsMember 实例

 在多文件中使用:被__declspec(thread) 修饰的变量可以在多个编译单元(即多个源文件)中使用,但需要确保在每个源文件中正确声明和定义变量

// header.h
#ifndef HEADER_H
#define HEADER_H

extern __declspec(thread) int tlsVar;

void SomeFunction();

#endif

// source1.cpp
#include "header.h"

__declspec(thread) int tlsVar = 0;

void SomeFunction() {
    tlsVar++;
    std::cout << "Thread " << GetCurrentThreadId() << ": tlsVar = " << tlsVar << std::endl;
}

// source2.cpp
#include "header.h"

void AnotherFunction() {
    tlsVar += 10;
    std::cout << "Thread " << GetCurrentThreadId() << ": tlsVar = " << tlsVar << std::endl;
}

  tlsVarsource1.cpp 文件中定义,但它可以在 source2.cpp 文件中使用。每个线程都有自己独立的 tlsVar 副本

注意事项:__declspec(thread) 是Microsoft的扩展,主要用于Windows平台和支持它的编译器(如Microsoft Visual C++)。在跨平台开发中使用时需要小心,如果目标平台不支持这个关键字,代码将无法编译 

实现线程无锁

//处理并发执行的函数
static void* ConcurrentAlloc(size_t size)
{

	//通过TLS方法,每个线程可以无锁的获取自己专属的ThreadCache对象
	if (pTLSThreadCache == nullptr)
	{
		pTLSThreadCache = new ThreadCache;
	}

	//获取线程id(检测两个线程是否分到两个不同的pTLSThreadCache)
	//cout << std::this_thread::get_id() << ":" << pTLSThreadCache << endl;
	return pTLSThreadCache->Allocate(size);
}

//释放ThreadCache
static void ConcurrentAlloc(void* ptr,size_t size)
{
	//理论上释放时pTLSThreadCache不会为空
	assert(pTLSThreadCache);
	pTLSThreadCache->Deallocate(ptr,size);//调用Deallocate函数
}

下面是测试代码及过程(完整代码过多不再展示理解意思即可):

申请内存-Allocate

//调用ThreadCache中的申请内存对象
void* ThreadCache::Allocate(size_t size)
{
	//范围
	assert(size <= MAX_BYTES);
	size_t allignSize = SizeClass::RoundUp(size);//获取对齐后的大小
	size_t index = SizeClass::Index(size);//确认桶的位置
	if (!_freeLists[index].Empty())//桶中的自由链表是否为空
	{
		return _freeLists[index].Pop();//头删相应位置的自由链表
	}
	else
	{
		return FetchFromCentralCache(index, allignSize);//向中心缓存处获取内容
	}
}

释放内存-Deallocate

void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);//大于256KB的内存不应该在这里归还

	//找对映射的自由链表桶,并将用完的对象插入进去
	size_t index = SizeClass::Index(size);
	_freeLists[index].Push(ptr);
}

从中心缓存中申请内存

注意事项:为了方便申请和释放内存,所以ThreadCache、CentralCache、PageCache三者的内存size与桶位置的映射关系是一样的

//向中心缓存申请
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	//不断有size大小的内存需求,那么batchNum会不断增长直到上限,size越小上限越高,最高是512
	size_t batchNum = std::min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));

	if (_freeLists[index].MaxSize() == batchNum)
	{
		_freeLists[index].MaxSize() += 1;
	}

	return nullptr; 
}
  • batchNum:一次性批发的某自由链表中结点的个数 

慢开始调节算法

基本概念:源自于TCP拥塞控制算法中的一种机制,用于在连接刚开始时逐渐增加发送窗口大小,在这里是为了实现小块内存多申请,大块内存少申请的目标,避免最开始一次性向central cache申请过多的内存,因为要太多可能用不完

//thread cache一次可以从central cache中获取的span的个数
static size_t NumMoveSize(size_t size)//size表示要申请的对象的大小
{
	if (size == 0)
	{
		return 0;
	}
		
	int num = MAX_BYTES / size;//计算需要可能的span个数
	if (num < 2)
	{
		num = 2;
	}
	if (num > 512)
	{
		num = 512;
	}
	//num的取值范围是[2,512]
	return num;
}

~over~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2116098.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

变量数据类型 Day3

1. 变量 1.1 变量的概念 变量是计算机内存中的一块存储单元&#xff0c;是存储数据的基本单元变量的组成包括&#xff1a;数据类型、变量名、值&#xff0c;后文会具体描述变量的本质作用就是去记录数据的&#xff0c;比如说记录一个人的身高、体重、年龄&#xff0c;就需要去…

【微处理器系统原理和应用设计第十讲】外部中断之开发键控灯亮灭功能

一、基础知识 外部设备所产生的信号通过EXIT触发中断。 1、与中断相关的主要寄存器 EXTI共设有6个寄存器&#xff0c;分别为中断屏蔽寄存器&#xff08;IMR&#xff09;&#xff0c;事件屏蔽寄存器&#xff08;EMR&#xff09;&#xff0c;上升沿触发选择寄存器&#xff08;…

Ubuntu | 安装 Truffle 框架(安装缓慢)

目录 预备工作具体步骤Step1&#xff1a;安装 nvma. 官方方式&#xff08;可能失败&#xff09;b. 压缩包安装方式 Step2&#xff1a;安装 node.js 和 npmStep3&#xff1a;安装 Truffle 参考博客 前言&#xff1a;昨天安装 Truffle 框架&#xff0c;结果缓冲条转了一晚上都没安…

利士策分享,如何平衡物质追求与心理健康?

利士策分享&#xff0c;如何平衡物质追求与心理健康? 在快节奏的现代社会&#xff0c;物质追求与心理健康仿佛成了人们生活中不可或缺的两极。 一方面&#xff0c;科技的飞速发展和经济的繁荣让我们拥有了前所未有的物质享受&#xff1b; 另一方面&#xff0c;高压的工作环…

前端基础 | HTML基础:HTML结构,HTML常见标签

文章目录 HTML1、HTML结构1.1HTML标签1.1.1标签1.1.2标签含义 1.2HTML文件基本结构1.3标签层次结构1.4 快速生成代码框架 2、HTML常见标签2.1注释标签2.2标题标签&#xff1a;h1–h62.3段落标签&#xff1a;p2.4 换行标签&#xff1a;br2.5格式化标签2.6 图片标签&#xff1a;i…

细致刨析JDBC ② 进阶篇

目录 一、JDBC拓展 1.实体类和ORM Ⅰ、ORM思想封装单个对象 Ⅱ、ORM思想封装集合 2.主键回显 3.批量操作 ① 循环逐条数据进行添加 ② 批量进行添加 二、连接池 1.现有问题 2.连接池 3.常见连接池 4.Druid连接池使用 使用步骤&#xff1a; 硬编码 软编码 5.HikariCP连接池使用 …

(详细文档)javaswing学生成绩管理系统(mysql)+详细报告

摘要 在现今信息时代&#xff0c;生活速度的加快&#xff0c;使得人们越来越向信息化、数字化发展。 随着学校的规模不断扩大&#xff0c;学生数量急剧增加&#xff0c;有关学生的各种信息量也成倍增 长&#xff0c;尤其是学生的考试成绩数据。面对庞大的学生的成绩&#xff0…

@Value读取properties中文乱码解决方案

前几天碰到使用Value中文乱码的问题&#xff0c;英文字符则不会出现问题 原因&#xff1a;SpringBoot在加载properties配置文件时&#xff0c;使用的默认编码是&#xff1a;ISO_88599_1 解决方式&#xff1a;将properties改成yml就可以读取成功了 Data Component PropertySou…

数据结构(邓俊辉)学习笔记】排序 1——快速排序:算法A

文章目录 1. 分而治之2. 轴点3. 构造轴点4. 单调性 不变性5. 实例 1. 分而治之 主题就是排序。实际上我们对于排序问题并不陌生。你应该记得在最开始的几章&#xff0c;我们就分别介绍过起泡排序、插入排序、选择排序以及归并排序&#xff0c;而在介绍散列技术时&#xff0c;我…

Tableau 2023下载安装教程最新教学附软件包百度网盘分享链接地址

Tableau 2023介绍 Tableau 2023下载安装教程最新教学附软件包百度网盘分享链接地址&#xff0c;Tableau 是一款强大的数据可视化软件。它能连接多种数据源并整合&#xff0c;操作简单&#xff0c;通过拖放即可创建可视化报表和仪表盘。具有高效的分析处理能力&#xff0c;支持…

windows下安装并使用nvm

目录 一.准备工作&#xff1a;卸载node 卸载步骤 二.下载nvm 三.安装nvm 三.配置下载源【重要】 四.使用nvm安装node.js 五.nvm常用命令 六.卸载nvm 一.准备工作&#xff1a;卸载node 如果电脑上已经有node&#xff0c;那么我们需要先完全卸载node&#xff0c;再安装…

LeetCode 热题 100 回顾15

干货分享&#xff0c;感谢您的阅读&#xff01;原文见&#xff1a;LeetCode 热题 100 回顾_力code热题100-CSDN博客 一、哈希部分 1.两数之和 &#xff08;简单&#xff09; 题目描述 给定一个整数数组 nums 和一个整数目标值 target&#xff0c;请你在该数组中找出 和为目标…

FastAPI模块化:为复杂应用程序提供清晰的结构

开题描述&#xff1a; 在现代软件开发中&#xff0c;随着应用程序规模的扩大和功能的增加&#xff0c;传统的单体架构逐渐暴露出其局限性。FastAPI&#xff0c;作为一款高性能的现代Web框架&#xff0c;通过其模块化设计提供了一种解决方案。本文将探讨FastAPI模块化如何为构建…

顶刊中的树状图如何绘制?|科研绘图·24-09-07

小罗碎碎念 本期推文主题&#xff1a;树状图 本期推文主要介绍如何绘制树状图以及它的一些变体形式&#xff0c;看完本篇推文&#xff0c;你最终能够实现的效果如下。 一、组织结构图 Dendrogram是一种网络结构&#xff0c;由一个根节点开始&#xff0c;该节点通过边或分支连接…

找商业网字体加密(TTFont方法)

网点地址&#xff1a;公司介绍-泰州名列新材料有限公司 (zhaosw.com) 问题如下&#xff1a; 在网站中看到的电话号码在页面源码中无法查看 破解步骤&#xff1a; 1.找到woff文件 查找字体的class属性&#xff0c;全文查找font-face-encrypted找到如下内容&#xff0c;可以看到…

在 Linux 上部署javaWeb项目+图文详解_java web项目部署到linux服务器

-f : force强制的意思&#xff0c;如果目标文件已经存在&#xff0c;不会询问直接覆盖 -i : 若目标已经存在&#xff0c;就会询问是否覆盖 -u : 若目标文件已经存在&#xff0c;且比目标文件新&#xff0c;才会更新 # 该命令可以把多个文件一次移动到一个文件夹中&#xff0c;但…

github删除自己创建的仓库

1.进入仓库&#xff0c;点击Settings 2.下拉至Danger Zone区域&#xff0c;点击Delete this repository 3.点击 I want to delete this repository 4.点击i have read ... 5.按提示输入&#xff0c;点击Delete this repository 总结 1.进入仓库&#xff0c;点击Settings 2.下…

推荐9个不同风格的音频频谱波形 听音乐怎么能少了它

9个不同风格的音频频谱波形 听音乐怎么能少了它。在我们沉静在听音乐的过程中&#xff0c;桌面上的频谱跳动&#xff0c;会让音乐更有动感&#xff0c;视觉化把音频表现出来。在桌面上跳动的音乐&#xff0c;更有氛围。小小编给大家带来了9种非常有特殊的音频频谱&#xff0c;看…

做一个最简单的CPU -- 计算机组成原理(六)

在上一个章节中&#xff0c;我们已经了解了一个存储是如何制作出来的&#xff0c;利用这个存储我们就可以做一个最简单的CPU 指令 我们知道CPU负责执行计算机的程序&#xff0c;而程序其实是一个个的操作指令 比如可能是计算指令&#xff0c;cpu会指示ALU进行加减运算 也可…

【web网页制作】html+css旅游家乡河南主题网页制作(5页面)【附源码】

一、&#x1f468;‍&#x1f393;网站题目 旅游&#xff0c;当地特色&#xff0c;历史文化&#xff0c;特色小吃等网站的设计与制作。 二、✍️网站描述 &#x1f468;‍&#x1f393;静态网站的编写主要是用HTML DIVCSS 等来完成页面的排版设计&#x1f469;‍&#x1f393;…