高并发内存池

一、引言

高并发内存池是一种针对多线程环境优化的动态内存分配器,旨在解决传统内存分配(如malloc)在多线程下因频繁加锁、竞争和内存碎片导致的性能瓶颈。其核心思想是通过层次化缓存结构减少锁竞争,提升效率。

内存池与池化技术

内存池是一种基于池化思想的内存管理技术,其核心是预先向操作系统申请一大块连续内存,然后由内存池自身进行切割、分配和回收管理,从而替代传统的直接通过malloc/free或new/delete向操作系统申请小块内存的方式。池化技术本质上是一种资源复用的策略,通过将频繁创建销毁的资源提前缓存起来,避免重复的分配开销和系统调用,提升效率。

二、基础:定长内存池的设计

定长内存池是分配固定大小的内存块,通常用于管理同一类型的对象,其实现通常更为简洁:预先申请一大块连续内存,按固定大小切割成多个空闲槽位,并通过链表或数组索引将它们串联起来。分配时直接取出链表头部的一个槽位,释放时将该槽位重新挂入链表,操作均为O(1)且无需考虑内存碎片问题。由于分配大小恒定,定长内存池可以完全避免内部碎片,并支持极低延迟的分配与释放,在高性能计算或实时系统中应用广泛。

定长内存池在设计时有两个主要的成员,一个是指向开辟内存的指针,一个是存储释放后回收内存的自由链表。当要申请内存时,先检查自由链表中有没有空闲的内存,如果有直接返回给用户,如果没有,再找申请的大块内存切分。

但要注意的的细节是自由链表链接前后节点时是直接使用切分的小块内存的前4个或8个字节(32位或64位)存储下一个节点的地址。因此,要注意如果申请的内存小于8字节,要在申请内存时就进行判断,直接申请8字节,避免越界问题。

而释放时就直接将内存头插至自由链表中即可。

cpp 复制代码
template<class T>
class ObjectPool
{
public:
	T* New()
	{
		T* obj = nullptr;

		// 优先把还回来内存块对象,再次重复利用
		if (_freeList)
		{
			void* next = *((void**)_freeList);
			obj = (T*)_freeList;
			_freeList = next;
		}
		else
		{
			// 扩容
			if (_remainBytes < sizeof(T))
			{
				_remainBytes = 128 * 1024;
				_memory = (char*)malloc(_remainBytes);
				if (_memory == nullptr)
				{
					throw std::bad_alloc();
				}
			}

			obj = (T*)_memory;
			size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
			_memory += objSize;
			_remainBytes -= objSize;
		}

		// 定位new,显示调用T的构造函数初始化
		new(obj)T;

		return obj;
	}

	void Delete(T* obj)
	{
		// 显示调用析构函数清理对象
		obj->~T();

		// 头插
		*(void**)obj = _freeList;
		_freeList = obj;
	}

private:
	char* _memory = nullptr; // 指向大块内存的指针
	size_t _remainBytes = 0; // 大块内存在切分过程中剩余字节数

	void* _freeList = nullptr; // 还回来过程中链接的自由链表的头指针
};

三、高并发内存池整体架构

在高并发场景下,申请内存时,必然存在激烈的锁竞争问题。因此设计了三层结构,用于提高效率,解决多线程环境下的锁竞争问题。

三层结构总览


Thread Cache:线程本地缓存,无锁访问,处理小对象

线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这⾥申请内存不需要加锁,每个线程独享⼀个thread cache。


Central Cache:中央缓存,所有线程共享,加锁保护

中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache合适的时机回收thread cache中的对象,避免⼀个线程占⽤了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,所以从这里取内存对象是需要加锁,但这里用到的锁并不是全局锁,而是桶锁,只用当两个thread cache访问同一个位置时,才需要加锁。而且只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。


Page Cache:页级缓存,管理大块内存,与系统交互

页缓存是在central cache缓存上面的⼀层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出⼀定数量的page,并切割成定长大小的小块内存,分配给central cache。当⼀个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。


为什么设计为三层结构呢?

如果是单层,需要加全局锁,在高并发下性能极差。

如果是两层(Thread Cache + Central Cache):虽然解决了锁竞争,但存在大量的申请的小内存碎片,外碎片严重,且无法回收。其与系统直接交互,频繁进行系统调用。

所以设计为三层:Thread Cache实现无锁访问;Central Cache均衡调度;Page Cache解决内存碎片,并系统交互,实现内存的申请和回收释放。


此外还有一点就是,threadcache解决的是小于256KB的内存块申请,如果申请的内存大于256KB,就直接通过Page Cache进行申请和释放,无需经过其余两层。

四、Thread Cache(线程缓存)详解

用于处理小对象。线程缓存是每个线程独有的,用于小于256KB的内存的分配,线程从这里申请内存不需要加锁,因为每个线程独享⼀个thread cache。

thread cache的实现采用的是哈希桶,即哈希映射+自由链表。数组每个元素是一个自由链表头,管理固定大小的内存块。进行字节分段对齐,平衡内碎片与桶的数量。其覆盖1~256KB,将桶的数量控制在几百个内。

内存的分段对齐

要对申请的内存进行分段对齐(通过以下操作将内部碎片控制在最多10%左右,实现了208个桶就可以存储不同字节大小):

1到128字节按8字节对齐,对应freelist索引0至15;

129到1024字节按16字节对齐,对应freelist索引16至71;

1025到8KB(8192字节)按128字节对齐,对应freelist索引72至127;

8KB+1到64KB按1KB(1024字节)对齐,对应freelist索引128至183;

64KB+1到256KB按8KB(8192字节),对齐对应freelist索引184至207。

线程局部存储(TLS)实现无锁访问

Thread Cache的无锁访问的实现,其核心技术就是线程局部存储(TLS):是一种多线程编程中的内存管理机制,用于实现线程内部全局可访问但其他线程独立的变量。

主要解决多线程编程中共享变量带来的同步问题。传统的全局变量在线程间共享,需要用锁来避免数据竞争;而TLS通过数据隔离,从根本上消除了竞争,提升了并发性能。传统全局变量或静态变量在多线程中共享,而TLS通过为每个线程分配独立存储空间,确保变量仅限当前线程访问。

在Thread Cache上层封装一层,每个线程第一次访问时创建自己的 ThreadCache,调用ThreadCache的成员函数。

主要成员函数的实现

其主要操作就是申请和释放内存:

申请内存:通过申请内存大小,计算哈希索引,从自由链表取块。如果若链表空,就从 Central Cache 中批量获取一批(慢启动策略)

释放内存:将块插回自由链表。如果链表过长(超过阈值,按照一定规则计算),批量归还给 Central Cache,再交与 Central Cache操作。

慢启动策略

慢启动批量获取机制是自适应调整批量大小,减少 Central Cache 访问。其如果访问频繁,那其一次申请批量时数量会增加,减少对Central Cache的访问。但其不是无限次增长的,而是有一个上限值,如果超过这个上限值,就按该上限值进行申请,不会超过该上限值。其上限max按内存大小设定,小块上限大,大块上限小。

cpp 复制代码
class ThreadCache
{
public:
	// 申请和释放内存对象
	void* Allocate(size_t size);
	void Deallocate(void* ptr, size_t size);

	// 从中心缓存获取对象
	void* FetchFromCentralCache(size_t index, size_t size);

	// 释放对象时,链表过长时,回收内存回到中心缓存
	void ListTooLong(FreeList& list, size_t size);
private:
	FreeList _freeLists[NFREELIST];
};

// TLS: thread local storage
static _declspec(thread) ThreadCache* pTLSThreadCache = nullptr;
cpp 复制代码
void* ThreadCache::FetchFromCentralCache(size_t index, size_t size)
{
	size_t batchNum = min(_freeLists[index].MaxSize(), SizeClass::NumMoveSize(size));
	if (_freeLists[index].MaxSize() == batchNum)
	{
		_freeLists[index].MaxSize() += 1;
	}

	void* start = nullptr;
	void* end = nullptr;
	size_t actualNum = CentralCache::GetInstance()->FetchRangeObj(start, end, batchNum, size);
	assert(actualNum > 0);

	if (actualNum == 1)
	{
		assert(start == end);
		return start;
	}
	else
	{
		_freeLists[index].PushRange(NextObj(start), end, actualNum-1);
		return start;
	}
}

void* ThreadCache::Allocate(size_t size)
{
	assert(size <= MAX_BYTES);
	size_t alignSize = SizeClass::RoundUp(size);
	size_t index = SizeClass::Index(size);

	if (!_freeLists[index].Empty())
	{
		return _freeLists[index].Pop();
	}
	else
	{
		return FetchFromCentralCache(index, alignSize);
	}
}

void ThreadCache::Deallocate(void* ptr, size_t size)
{
	assert(ptr);
	assert(size <= MAX_BYTES);

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

	// 当链表长度大于一次批量申请的内存时就开始还一段list给central cache
	if (_freeLists[index].Size() >= _freeLists[index].MaxSize())
	{
		ListTooLong(_freeLists[index], size);
	}
}

void ThreadCache::ListTooLong(FreeList& list, size_t size)
{
	void* start = nullptr;
	void* end = nullptr;
	list.PopRange(start, end, list.MaxSize());

	CentralCache::GetInstance()->ReleaseListToSpans(start, size);
}

五、Central Cache(中央缓存)详解

central cache的结构和thread cache一样,但不同的是central cache存储的是一个个Span(双向链表),其是一段连续内存页,需要时切分成小块供 Thread Cache 使用。每个桶对应一组 span,每个 span 管理一种固定大小的块。中心缓存是所有线程所共享,thread cache是按需从central cache中获取的对象。central cache合适的时机回收thread cache中的对象,避免⼀个线程占⽤了太多的内存,而其他线程的内存吃紧,达到内存分配在多个线程中更均衡的按需调度的目的。central cache是存在竞争的,**所以从这里取内存对象是需要加锁,但这里用到的锁并不是全局锁,而是桶锁,只用当两个thread cache访问同一个位置时,才需要加锁。**而且只有thread cache的没有内存对象时才会找central cache,所以这里竞争不会很激烈。

cpp 复制代码
struct Span
{
	PAGE_ID _pageId = 0; // 大块内存起始页的页号
	size_t  _n = 0;      // 页的数量

	Span* _next = nullptr;	// 双向链表的结构
	Span* _prev = nullptr;

	size_t _objSize = 0;  // 切好的小对象的大小
	size_t _useCount = 0; // 切好小块内存,被分配给thread cache的计数
	void* _freeList = nullptr;  // 切好的小块内存的自由链表

	bool _isUse = false;          // 是否在被使用
};

单例模式的设计

由于被多线程共享,所以要保证其是线程安全的,所以在设计时要设计为单例模式,一个类只有一个实例,并提供一个全局访问点来获取这个实例。其核心目标是控制实例数目,节省系统资源,避免因创建多个实例导致的状态不一致或资源冲突。

主要成员函数的实现

其核心操作为分配内存给thread cache和回收内存挂回span。

分配 :找到对应桶,从 span 链表中取一个非空 span,从 span 的 _freeList 中取出若干块,返回给 Thread Cache,若桶里所有 span 都满或者该位置没有Span,则向 Page Cache 申请新 span。
回收:Thread Cache 归还一批块,重新挂入 span 的 _freeList,然后更新 _useCount。若 _useCount == 0,则该span完全空闲,就可以归还给 Page Cache。

cpp 复制代码
// 单例模式
class CentralCache
{
public:
	static CentralCache* GetInstance()
	{
		return &_sInst;
	}

	// 获取一个非空的span
	Span* GetOneSpan(SpanList& list, size_t byte_size);

	// 从中心缓存获取一定数量的对象给thread cache
	size_t FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size);

	// 将一定数量的对象释放到span跨度
	void ReleaseListToSpans(void* start, size_t byte_size);
private:
	SpanList _spanLists[NFREELIST];

private:
	CentralCache()
	{}

	CentralCache(const CentralCache&) = delete;

	static CentralCache _sInst;
};
cpp 复制代码
CentralCache CentralCache::_sInst;

// 获取一个非空的span
Span* CentralCache::GetOneSpan(SpanList& list, size_t size)
{
	Span* it = list.Begin();
	while (it != list.End())
	{
		if (it->_freeList != nullptr)
		{
			return it;
		}
		else
		{
			it = it->_next;
		}
	}

	list._mtx.unlock();

	PageCache::GetInstance()->_pageMtx.lock();
	Span* span = PageCache::GetInstance()->NewSpan(SizeClass::NumMovePage(size));
	span->_isUse = true;
	span->_objSize = size;
	PageCache::GetInstance()->_pageMtx.unlock();

	// 计算span的大块内存的起始地址和大块内存的大小(字节数)
	char* start = (char*)(span->_pageId << PAGE_SHIFT);
	size_t bytes = span->_n << PAGE_SHIFT;
	char* end = start + bytes;

	span->_freeList = start;
	start += size;
	void* tail = span->_freeList;
	int i = 1;
	while (start < end)
	{
		++i;
		NextObj(tail) = start;
		tail = NextObj(tail);
		start += size;
	}

	NextObj(tail) = nullptr;

	list._mtx.lock();
	list.PushFront(span);

	return span;
}

// 从中心缓存获取一定数量的对象给thread cache
size_t CentralCache::FetchRangeObj(void*& start, void*& end, size_t batchNum, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();

	Span* span = GetOneSpan(_spanLists[index], size);
	assert(span);
	assert(span->_freeList);

	start = span->_freeList;
	end = start;
	size_t i = 0;
	size_t actualNum = 1;
	while ( i < batchNum - 1 && NextObj(end) != nullptr)
	{
		end = NextObj(end);
		++i;
		++actualNum;
	}
	span->_freeList = NextObj(end);
	NextObj(end) = nullptr;
	span->_useCount += actualNum;

	_spanLists[index]._mtx.unlock();

	return actualNum;
}

void CentralCache::ReleaseListToSpans(void* start, size_t size)
{
	size_t index = SizeClass::Index(size);
	_spanLists[index]._mtx.lock();
	while (start)
	{
		void* next = NextObj(start);

		Span* span = PageCache::GetInstance()->MapObjectToSpan(start);
		NextObj(start) = span->_freeList;
		span->_freeList = start;
		span->_useCount--;

		if (span->_useCount == 0)
		{
			_spanLists[index].Erase(span);
			span->_freeList = nullptr;
			span->_next = nullptr;
			span->_prev = nullptr;

			_spanLists[index]._mtx.unlock();

			PageCache::GetInstance()->_pageMtx.lock();
			PageCache::GetInstance()->ReleaseSpanToPageCache(span);
			PageCache::GetInstance()->_pageMtx.unlock();

			_spanLists[index]._mtx.lock();
		}

		start = next;
	}

	_spanLists[index]._mtx.unlock();
}

六、Page Cache(页缓存)详解

page cache的结构依然是哈希桶,但其是按页数组织的,是⼀个以页为单位的span自由链表。它也要设计为单例模式,确保线程安全。由于多线程会同时访问,所以Page Cache要加全局锁,一次只允许一个线程访问。

页缓存是在central cache缓存上面的⼀层缓存,存储的内存是以页为单位存储及分配的,central cache没有内存对象时,从page cache分配出⼀定数量的page,并切割成定长大小的小块内存,分配给central cache。当⼀个span的几个跨度页的对象都回收以后,page cache会回收central cache满足条件的span对象,并且合并相邻的页,组成更大的页,缓解内存碎片的问题。

其负责与操作系统交互,合并物理相邻的空闲页,减少外碎片。

回收逻辑详解(基数树实现页号到span的映射)

要实现相邻页的回收,就要实现页号到 span 的映射,通过空闲页的页号,找到前后相邻的页的页号,通过映射找到对应的Span,如果是空闲的,就合并两个Span。将其合并成更大的空闲页。

而页号到 span 的映射简单一点的话可以采用map映射(O(logN)查找),但在多线程环境下需要加锁来保护共享数据,因为多个线程可能同时查询或修改映射。加锁会导致竞争,降低性能。

因此,更好的方法是采用基数树(Radix Tree)实现 O(1) 查找,且其不用加锁,由于不同页号映射到不同的位置,因此多个线程更新不同页号可以完全并发,互不干扰。

什么是基数树?

又称压缩前缀树,是一种节省空间的前缀树结构。它以二进制位串为关键字,采用多叉树形结构设计,中间节点包含指向子节点的指针数组,叶子节点存储对象指针。树的每一层对应一个片段,每个节点是一个指针数组,数组大小等于片段可能的取值个数。查找时,依次用键的各个片段作为索引,从根节点向下遍历,直至叶子节点(或任意节点)存储目标值。

而在当前场景下可以实现通过下标直接访问的O(1)操作,实现无锁并发查询。

主要成员函数的实现

其主要操作是分配和回收。

分配 span:根据请求页数查找空闲 span,从对应桶开始,找不到则找更大桶。若都没有,向系统申请一大块内存128 页的大块内存,切分后返回,剩余部分插入空闲链表。

回收 span:将 span 插入空闲链表,合并相邻空闲页:检查前后页是否空闲,若是则合并成更大 span,合并后重新插入空闲链表。如果超过阈值,就回收一部分内存。

cpp 复制代码
class PageCache
{
public:
	static PageCache* GetInstance()
	{
		return &_sInst;
	}

	// 获取从对象到span的映射
	Span* MapObjectToSpan(void* obj);

	// 释放空闲span回到Pagecache,并合并相邻的span
	void ReleaseSpanToPageCache(Span* span);

	// 获取一个K页的span
	Span* NewSpan(size_t k);

	std::mutex _pageMtx;
private:
	SpanList _spanLists[NPAGES];
	ObjectPool<Span> _spanPool;

	TCMalloc_PageMap1<32 - PAGE_SHIFT> _idSpanMap;

	PageCache()
	{}
	PageCache(const PageCache&) = delete;


	static PageCache _sInst;
};
cpp 复制代码
// 获取一个K页的span
Span* PageCache::NewSpan(size_t k)
{
	assert(k > 0);

	// 大于128 page的直接向堆申请
	if (k > NPAGES-1)
	{
		void* ptr = SystemAlloc(k);
		Span* span = _spanPool.New();

		span->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
		span->_n = k;

		_idSpanMap.set(span->_pageId, span);

		return span;
	}

	// 先检查第k个桶里面有没有span
	if (!_spanLists[k].Empty())
	{
		Span* kSpan = _spanLists[k].PopFront();

		// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
		for (PAGE_ID i = 0; i < kSpan->_n; ++i)
		{
			_idSpanMap.set(kSpan->_pageId + i, kSpan);
		}

		return kSpan;
	}

	for (size_t i = k+1; i < NPAGES; ++i)
	{
		if (!_spanLists[i].Empty())
		{
			Span* nSpan = _spanLists[i].PopFront();
			Span* kSpan = _spanPool.New();

			kSpan->_pageId = nSpan->_pageId;
			kSpan->_n = k;

			nSpan->_pageId += k;
			nSpan->_n -= k;

			_spanLists[nSpan->_n].PushFront(nSpan);
			_idSpanMap.set(nSpan->_pageId, nSpan);
			_idSpanMap.set(nSpan->_pageId + nSpan->_n - 1, nSpan);

			// 建立id和span的映射,方便central cache回收小块内存时,查找对应的span
			for (PAGE_ID i = 0; i < kSpan->_n; ++i)
			{
				_idSpanMap.set(kSpan->_pageId + i, kSpan);
			}

			return kSpan;
		}
	}

	Span* bigSpan = _spanPool.New();
	void* ptr = SystemAlloc(NPAGES - 1);
	bigSpan->_pageId = (PAGE_ID)ptr >> PAGE_SHIFT;
	bigSpan->_n = NPAGES - 1;

	_spanLists[bigSpan->_n].PushFront(bigSpan);

	return NewSpan(k);
}

Span* PageCache::MapObjectToSpan(void* obj)
{
	PAGE_ID id = ((PAGE_ID)obj >> PAGE_SHIFT);

	auto ret = (Span*)_idSpanMap.get(id);
	assert(ret != nullptr);
	return ret;
}

void PageCache::ReleaseSpanToPageCache(Span* span)
{
	// 大于128 page的直接还给堆
	if (span->_n > NPAGES-1)
	{
		void* ptr = (void*)(span->_pageId << PAGE_SHIFT);
		SystemFree(ptr);
		_spanPool.Delete(span);

		return;
	}

	// 对span前后的页,尝试进行合并,缓解内存碎片问题
	while (1)
	{
		PAGE_ID prevId = span->_pageId - 1;

		auto ret = (Span*)_idSpanMap.get(prevId);
		if (ret == nullptr)
		{
			break;
		}

		Span* prevSpan = ret;
		if (prevSpan->_isUse == true)
		{
			break;
		}

		if (prevSpan->_n + span->_n > NPAGES-1)
		{
			break;
		}

		span->_pageId = prevSpan->_pageId;
		span->_n += prevSpan->_n;

		_spanLists[prevSpan->_n].Erase(prevSpan);
		_spanPool.Delete(prevSpan);
	}

	// 向后合并
	while (1)
	{
		PAGE_ID nextId = span->_pageId + span->_n;

		auto ret = (Span*)_idSpanMap.get(nextId);
		if (ret == nullptr)
		{
			break;
		}

		Span* nextSpan = ret;
		if (nextSpan->_isUse == true)
		{
			break;
		}

		if (nextSpan->_n + span->_n > NPAGES-1)
		{
			break;
		}

		span->_n += nextSpan->_n;

		_spanLists[nextSpan->_n].Erase(nextSpan);
		_spanPool.Delete(nextSpan);
	}

	_spanLists[span->_n].PushFront(span);
	span->_isUse = false;

	_idSpanMap.set(span->_pageId, span);
	_idSpanMap.set(span->_pageId + span->_n - 1, span);
}

七、内存申请与释放完整流程

申请流程(以 16 字节为例)

1、用户调用 allocate(16),通过 TLS 找到当前线程的 ThreadCache。

2、ThreadCache 计算对齐后大小和桶下标,发现自由链表为空。

3、向 Central Cache 批量申请(慢启动决定本次拿 batch_num 个)。

4、Central Cache 找到对应桶,从 span 中取出 batch_num 个块;若桶里无可用 span,向 Page Cache 申请新 span。

5、Page Cache 分配 span(可能触发系统调用),返回给 Central Cache。

6、Central Cache 切分 span,返回块给 ThreadCache。

7、ThreadCache 将块链入自由链表,取第一个返回给用户。

释放流程

1、用户调用 deallocate(ptr),通过页号找到所属 span。

2、ThreadCache 检查自由链表长度,若超过阈值,批量归还给 Central Cache。

3、Central Cache 将块挂回 span 的 _freeList,更新 _useCount。

4、若 _useCount == 0,span 完全空闲,归还给 Page Cache。

5、Page Cache 尝试合并相邻空闲页,重新插入空闲链表,若超过阈值,批量释放。

大内存的申请与释放(>256KB)

分配时直接调用 Page Cache,不经过 Thread Cache 和 Central Cache。

释放时直接归还给 Page Cache。

八、总结

使用三层架构实现高并发场景下的内存申请。综合考虑了效率问题、内存碎片问题和锁竞争的问题。实现内存的回收利用、thread cache的无锁申请与释放、大块内存的申请与释放、使用基数树优化查询效率等。

但有些场景下会有问题。比如跨线程的释放等,并没有考虑在内;当前的高并发内存池是采用的被动回收内存的机制,并没有引入自适应内存回收策略,自动回收内存,等等。

相关推荐
汉克老师2 小时前
GESP2024年9月认证C++二级( 第三部分编程题(1) 数位之和 )
c++·循环结构·分支结构·gesp二级·gesp2级·求余数·拆数字
lxl13072 小时前
C++算法(3)二分算法
数据结构·c++·算法
星火开发设计3 小时前
C++ 异常处理:try-catch-throw 的基本用法
java·开发语言·jvm·c++·学习·知识·对象
白太岁3 小时前
C++:(3) 线程的关联、条件变量、锁和线程池
开发语言·c++
仰泳的熊猫3 小时前
题目1474:蓝桥杯基础练习VIP-阶乘计算
数据结构·c++·算法·蓝桥杯
WBluuue4 小时前
数据结构与算法:dp优化——树状数组/线段树优化
数据结构·c++·算法·leetcode·动态规划
华科大胡子4 小时前
《Effective C++》学习笔记:条款02
c++·编程语言·inline·const·enum·define
tankeven4 小时前
HJ84 统计大写字母个数
c++·算法
㓗冽4 小时前
阵列(二维数组)-基础题79th + 饲料调配(二维数组)-基础题80th + 求小数位数个数(字符串)-基础题81th
数据结构·c++·算法