C++并发编程 -7. 并发代码设计

一. 线程间划分工作的技术

1.按数量切分

最简单的并行算法,就是并行化的std::for_each,其会对一个数据集中每个元素执行同一个操作。为了并行化该算法,可以为数据集中每个元素分配一个处理线程。如何划分才能获得最佳的性能,很大程度上取决于数据结构实现的细节,在之后有关性能问题的章节会再提及此问题。

最简单的分配方式:第一组N个元素分配一个线程,下一组N个元素再分配一个线程,以此类推,如图8.1所示。不管数据怎么分,每个线程都会对分配给它的元素进行操作,不过并不会和其他线程进行沟通,直到处理完成。

             

虽然这个技术十分强大,但是并不是哪都适用。有时不能像之前那样,对任务进行整齐的划分,因为只有对数据进行处理后,才能进行明确的划分。这里特别适用了递归算法,就像快速排序;下面就来看看这种特别的方式。

2.递归划分

        快速排序有两个最基本的步骤:将数据划分到中枢元素之前或之后,然后对中枢元素之前和之后的两半数组再次进行快速排序。这里不能通过对数据的简单划分达到并行,因为,只有在一次排序结束后,才能知道哪些项在中枢元素之前和之后。当要对这种算法进行并行化,很自然的会想到使用递归。每一级的递归都会多次调用quick_sort函数,因为需要知道哪些元素在中枢元素之前和之后。递归调用是完全独立的,因为其访问的是不同的数据集,并且每次迭代都能并发执行。

代码如下:


#include <iostream>
#include <thread>
#include <memory>
#include <future>
 
template<typename T>
void quick_sort_recursive(T arr[], int start, int end) {
    if (start >= end) return;
    T key = arr[start];
    int left = start, right = end;
    while(left < right) {
        while (arr[right] >= key && left < right) right--;
        while (arr[left] <= key && left < right) left++;
        std::swap(arr[left], arr[right]);
    }
    if (arr[left] < key) {
        std::swap(arr[left], arr[start]);
    }
    quick_sort_recursive(arr, start, left - 1);
    quick_sort_recursive(arr, left + 1, end);
}
template<typename T>
void quick_sort(T arr[], int len) {
    quick_sort_recursive(arr, 0, len - 1);
}
 
int main() {
    int num_arr[] = { 5,3,7,6,4,1,0,2,9,10,8 };
    int length = sizeof(num_arr) / sizeof(int);
     quick_sort(num_arr, length );
    std::cout << "sorted result is ";
    for (int i = 0; i < length; i++) {
        std::cout << " " << num_arr[i];
    }
    std::cout << std::endl;    
}

          

        在并行计算实践中,我们早期实现的递归式快速排序算法采用了动态任务分配策略。该实现的核心机制是:每当递归深入一层,就通过std::async()创建异步任务处理数据分区的前半段,同时由当前线程继续处理后半段数据。这种设计的关键优势在于将线程调度决策权交给C++标准库——标准库会根据当前系统负载智能选择在新线程中执行任务,或直接在调用线程上同步执行。

        这种动态调度策略对处理大规模数据集尤为重要。理论上,如果每次递归都强制创建新线程,在处理超大规模数据时会导致线程数量呈指数级增长。这不仅可能触发线程资源枯竭,实践中还发现当线程数量超过最佳临界值后,程序性能反而会出现显著下降。因此,在采用递归分治策略时,必须对线程创建数量施加有效约束,防止线程资源的无节制消耗。

        虽然std::async()在此场景下表现尚可,但我们通过性能分析发现仍有优化空间。特别是在处理TB级数据集时,频繁创建线程的开销变得不可忽视。为此,我们转而采用线程池方案进行优化,通过复用预创建的线程来降低系统开销。这一改进与《C++并发编程实践》中提出的并行算法设计原则不谋而合——作者在实现并行accumulate算法时,正是通过std::hardware_concurrency()获取硬件支持的并发线程数,并据此建立最优的并行粒度控制机制。       

        当线程无所事事,不是已经完成对自己数据块的梳理,就是在等待一组排序数据的产生;线程可以从栈上获取这组数据,并且对其排序。 

代码如下:

template<typename T>
struct sorter  //1  
{
	struct chunk_to_sort
	{
		std::list<T> data;
		std::promise<std::list<T> > promise;
	};
	thread_safe_stack<chunk_to_sort> chunks;    //⇽-- - 2
	std::vector<std::thread> threads;   // ⇽-- - 3
	unsigned const max_thread_count;
	std::atomic<bool> end_of_data;
	sorter() :
		max_thread_count(std::thread::hardware_concurrency() - 1),
		end_of_data(false)
	{}
	~sorter()    //⇽-- - 4
	{
		end_of_data = true;     //⇽-- - 5
		for (unsigned i = 0; i < threads.size(); ++i)
		{
			threads[i].join();    //⇽-- - 6
		}
	}
	void try_sort_chunk()
	{
		std::shared_ptr<chunk_to_sort> chunk = chunks.try_pop();    //⇽-- - 7
		if (chunk)
		{
			sort_chunk(chunk);    //⇽-- - 8
		}
	}
	 /**
     * @brief 核心的递归排序函数
     * 
     * 这个函数实现了快速排序的递归逻辑。首先检查输入是否为空,
     * 如果为空直接返回。否则,取出第一个元素作为分区值,将剩余元素分为
     * 小于分区值和大于等于分区值的两部分。然后递归对这两部分进行排序,
     * 并将结果合并。
     * 
     * 在递归过程中,会将一部分数据作为新的块压入栈中,并启动新线程进行处理。
     * 通过这种方式实现并行排序。
     */
	std::list<T> do_sort(std::list<T>& chunk_data)    //⇽-- - 9
	{
		if (chunk_data.empty())
		{
			return chunk_data;
		}
		std::list<T> result;
		// 取出第一个元素作为分区值
		result.splice(result.begin(),chunk_data,chunk_data.begin());

		T const& partition_val = *result.begin();
		// 对剩余元素进行分区,将小于分区值的元素放在前面
		typename std::list<T>::iterator divide_point =  //⇽-- - 10
			std::partition(chunk_data.begin(),chunk_data.end(),
						   [&](T const& val) {return val < partition_val; });
		// 创建新的下层块,存储小于分区值的元素
		chunk_to_sort new_lower_chunk;
		new_lower_chunk.data.splice(new_lower_chunk.data.end(),
									chunk_data,chunk_data.begin(),
									divide_point);
		// 获取下层块的 future,用于等待排序结果
		std::future<std::list<T> > new_lower =
			new_lower_chunk.promise.get_future();
		// 将新块推入栈中,等待其他线程处理	
		chunks.push(std::move(new_lower_chunk));   // ⇽-- - 11
		if (threads.size() < max_thread_count)    // ⇽-- - 12
		{
			threads.push_back(std::thread(&sorter<T>::sort_thread,this));
		}
		// 递归排序上层块(大于等于分区值的元素)
		std::list<T> new_higher(do_sort(chunk_data));
		result.splice(result.end(),new_higher);
		// 等待下层块排序完成
		// 由于上层快已经排序完成,且通过new_lower绑定future可以窥测其余线程是否将下层块处理完毕,
		//如果!= ready,泽尝试处理其他快。
		while (new_lower.wait_for(std::chrono::seconds(0)) !=
			  std::future_status::ready)    //⇽-- - 13
		{
			// 尝试处理其他块,充分利用线程资源
			try_sort_chunk();   // ⇽-- - 14
		}
		// 将下层块结果合并到结果列表中
		result.splice(result.begin(),new_lower.get());
		return result;
	}
	/**
     * @brief 对块进行排序并将结果存储在 promise 中
     * 
     * 这个函数接收一个 chunk_to_sort 指针,调用 do_sort 对其 data 进行排序,
     * 然后将排序结果存储到对应的 promise 中。这样,等待这个 promise 的线程
     * 就可以获取到排序后的结果。
     */
	void sort_chunk(std::shared_ptr<chunk_to_sort > const& chunk)
	{
		chunk->promise.set_value(do_sort(chunk->data));    //⇽-- - 15
	}
	void sort_thread()
	{
		while (!end_of_data)    //⇽-- - 16
		{
			try_sort_chunk();    // ⇽-- - 17
			//交出时间片
			std::this_thread::yield();    //⇽-- - 18
		}
	}
};
/**
 * @brief 并行快速排序的入口函数
 * 
 * 这个函数是用户调用的入口。它首先检查输入是否为空,
 * 如果为空直接返回。否则创建 sorter 实例,调用 do_sort 进行排序。
 * 
 * @param input 输入的链表
 * @return std::list<T> 排序后的链表
 */
template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input)    //⇽-- - 19
{
	if (input.empty())
	{
		return input;
	}
	sorter<T> s;
	return s.do_sort(input);    //⇽-- - 20
}

         其核心是通过递归将数据分割为小块,利用多线程异步处理子任务来提升性能。sorter类内部维护一个线程安全的任务栈(chunk_to_sort),存储待排序的数据块和与之关联的promise对象用于异步返回结果;工作线程池(threads)从任务栈中动态获取任务,通过do_sort函数递归地将数据按基准值分割为lowerhigher两部分,将lower部分封装为独立任务推入栈中由其他线程处理,同时当前线程递归处理higher部分,并通过future等待子任务完成,最终合并结果。线程在空闲时通过try_sort_chunk主动尝试处理栈中任务,避免阻塞等待,而析构函数通过原子标志end_of_data通知线程退出并安全回收资源。整体设计通过任务窃取(当前线程处理完毕后查看其余线程future是否已经ready)、动态负载均衡(线程与处理数据绑定)和异步协作(多线程),在控制线程数量的前提下最大化利用多核性能,实现高效并行排序。

3.通过任务类型划分工作

虽然为每个线程分配不同的数据块,但工作的划分(无论是之前就划分好,还是使用递归的方式划分)仍然在理论阶段,因为这里每个线程对每个数据块的操作是相同的。而另一种选择是让线程做专门的工作,也就是每个线程做不同的工作,就像水管工和电工在建造一所屋子的时候所做的不同工作那样。线程可能会对同一段数据进行操作,但它们对数据进行不同的操作。

对分工的排序,也就是从并发分离关注结果;每个线程都有不同的任务,这就意味着真正意义上的线程独立。其他线程偶尔会向特定线程交付数据,或是通过触发事件的方式来进行处理;不过总体而言,每个线程只需要关注自己所要做的事情即可。其本身就是基本良好的设计,每一段代码只对自己的部分负责。

例如:定义定时器使用问题,如果定时器只在特定线程使用,则将定时器与线程进行绑定。这样定时器的创建销毁使用不会与其他线程冲突,也不需要从应用层担心定时器的维护。但是如果定时器和定时器直接有依赖关系,可以借助Actor模式+CRTP(奇异递归模板模式)进行区分。

3.1 Actor模式

        Actor 设计模式是一种并发编程模型,核心思想是通过独立的、封装的计算单元(称为 Actor)之间的消息传递来实现通信。每个 Actor 拥有自己的状态,且不直接与其他 Actor 共享内存,而是通过异步消息进行交互。这种设计避免了锁、竞态条件等传统多线程编程的复杂性。

        简单点说,actor通过消息传递的方式与外界通信。消息传递是异步的。每个actor都有一个邮箱,该邮箱接收并缓存其他actor发过来的消息,actor一次只能同步处理一个消息,处理消息过程中,除了可以接收消息,不能做任何其他操作。Actor在发送消息前是直到接收方是谁,而接受方收到消息后也知道发送方是谁。
        每一个类独立在一个线程里称作Actor,Actor之间通过队列通信,比如Actor1 发消息给Actor2, Actor2 发消息给Actor1都是投递到对方的队列中。好像给对方发邮件,对方从邮箱中取出一样。如下图

3.2 CRTP(奇异递归模板模式)

CRTP(Curiously Recurring Template Pattern) 是一种 C++ 模板元编程技术,核心是派生类继承一个以自身为模板参数的基类,它通过编译期多态实现代码复用,避免虚函数开销。

3.2.1 语法结构
#include <iostream>

template <typename Derived>
class Base {
public:
    void call() {
        static_cast<Derived*>(this)->implementation();
    }

    void common() {
        std::cout << "[Base] Common logic\n";
        call();
    }
};

// 派生类1
class Derived1 : public Base<Derived1> {
public:
    void implementation() {
        std::cout << "[Derived1] Specific logic\n";
    }
};

// 派生类2
class Derived2 : public Base<Derived2> {
public:
    void implementation() {
        std::cout << "[Derived2] Different logic\n";
    }
};

// 派生类3(假设需要额外参数)
class Derived3 : public Base<Derived3> {
public:
    void implementation() {
        std::cout << "[Derived3] Advanced logic with parameter: " << value_ << "\n";
    }

    void set_value(int v) { value_ = v; }

private:
    int value_ = 0;
};

int main() {
    Derived1 d1;
    d1.common(); // 输出:[Base] Common logic → [Derived1] Specific logic

    Derived2 d2;
    d2.common(); // 输出:[Base] Common logic → [Derived2] Different logic

    Derived3 d3;
    d3.set_value(42);
    d3.common(); // 输出:[Base] Common logic → [Derived3] Advanced logic with parameter: 42
}
3.2.2 与虚继承区别
特性CRTP虚函数继承
多态时机编译期多态(静态绑定)运行时多态(动态绑定)
性能

无虚函数表(vtable)开销,效率更高,

适用于高性能场景(如数学库、高频调用逻辑)

编译期确定的类型。

虚函数表查找,有一定运行时开销(两次寻址,访问虚函数表地址,根据虚函数表找虚函数)
虚函数表+虚函数表内容增加内存开销

代码生成模板实例化为具体类型,可能代码膨胀虚函数表统一管理,代码更紧凑
灵活性类型在编译期确定,无法动态替换支持运行时动态绑定(如工厂模式)
接口约束派生类必须实现基类指定的方法派生类必须覆盖纯虚函数
扩展性适合静态代码复用(如运算符重载)适合动态多态(如插件系统),便接口于管理
错误检测编译期检查类型匹配,错误更早暴露运行时可能因未实现虚函数导致崩溃

关于第五点接口约束:

CRTP:基类通过模板的静态类型转换(static_cast<Derived*>(this))调用派生类的方法。若派生类未实现基类预期的方法,编译时会直接报错(如未找到 implementation() 方法)。 

虚函数:若基类声明了纯虚函数(如 virtual void foo() = 0;),则派生类必须覆盖(实现)该函数,否则派生类无法实例化,编译会报错。

3.3 Actor+CCTP 并发任务设计示例
#include <thread>
#include <atomic>
#include <iostream>
#include <mutex>
#include <queue>
#include <atomic>

template<typename ClassType, typename QueType>
class ActorSingle {
public:
	static ClassType& Inst() {
		static ClassType as;
		return as;
	}

	~ ActorSingle(){
		
	}

	void PostMsg(const QueType& data) {
		_que.push(data);
	}

protected:
	
	ActorSingle():_bstop(false){
		
	}

	ActorSingle(const ActorSingle&) = delete;
	ActorSingle(ActorSingle&&) = delete;
	ActorSingle& operator = (const ActorSingle&) = delete;

	std::atomic<bool> _bstop;
	ThreadSafeQue<QueType>  _que;
	std::thread _thread;
};


struct MsgClassA {
	std::string name;
	friend std::ostream& operator << (std::ostream& os, const MsgClassA& ca) {
		os << ca.name;
		return os;
	}
};


class ClassA : public ActorSingle<ClassA, MsgClassA> {
	friend class ActorSingle<ClassA, MsgClassA>;
public:
	~ClassA() {
		_bstop = true;
		_que.NotifyStop();
		_thread.join();
		std::cout << "ClassA destruct " << std::endl;
	}

	void DealMsg(std::shared_ptr<MsgClassA> data) {
		std::cout << "class A deal msg is " << *data << std::endl;

		MsgClassB msga;
		msga.name = "llfc";
		ClassB::Inst().PostMsg(msga);
	}
private:
	ClassA(){
		_thread = std::thread([this]() {
			for (; (_bstop.load() == false);) {
				std::shared_ptr<MsgClassA> data = _que.WaitAndPop();
				if (data == nullptr) {
					continue;
				}

				DealMsg(data);
			}

			std::cout << "ClassA thread exit " << std::endl;
			});
	}
};

struct MsgClassB {
	std::string name;
	friend std::ostream& operator << (std::ostream& os, const MsgClassB& ca) {
		os << ca.name;
		return os;
	}
};


class ClassB : public ActorSingle<ClassB, MsgClassB> {
	friend class ActorSingle<ClassB, MsgClassB>;
public:
	~ClassB() {
		_bstop = true;
		_que.NotifyStop();
		_thread.join();
		std::cout << "ClassB destruct " << std::endl;
	}

	void DealMsg(std::shared_ptr<MsgClassB> data) {
		std::cout << "class B deal msg is " << *data << std::endl;

		MsgClassC msga;
		msga.name = "llfc";
		ClassC::Inst().PostMsg(msga);
	}
private:
	ClassB(){
		_thread = std::thread([this]() {
			for (; (_bstop.load() == false);) {
				std::shared_ptr<MsgClassB> data = _que.WaitAndPop();
				if (data == nullptr) {
					continue;
				}

				DealMsg(data);
			}

			std::cout << "ClassB thread exit " << std::endl;
			});
	}
};

struct MsgClassC {
	std::string name;
	friend std::ostream& operator << (std::ostream& os, const MsgClassC& ca) {
		os << ca.name;
		return os;
	}
};


class ClassC : public ActorSingle<ClassC, MsgClassC> {
	friend class ActorSingle<ClassC, MsgClassC>;
public:
	~ClassC() {
		_bstop = true;
		_que.NotifyStop();
		_thread.join();
		std::cout << "ClassC destruct " << std::endl;
	}

	void DealMsg(std::shared_ptr<MsgClassC> data) {
		std::cout << "class C deal msg is " << *data << std::endl;
	}
private:
	ClassC(){
		_thread = std::thread([this]() {
			for (; (_bstop.load() == false);) {
				std::shared_ptr<MsgClassC> data = _que.WaitAndPop();
				if (data == nullptr) {
					continue;
				}

				DealMsg(data);
			}

			std::cout << "ClassC thread exit " << std::endl;
			});
	}
};

template<typename T>
class ThreadSafeQue
{
private:
	struct node
	{
		std::shared_ptr<T> data;
		std::unique_ptr<node> next;
	};

	std::mutex head_mutex;
	std::unique_ptr<node> head;
	std::mutex tail_mutex;
	node* tail;
	std::condition_variable data_cond;

	std::atomic<bool> _bstop;

	node* get_tail()
	{
		std::lock_guard<std::mutex> tail_lock(tail_mutex);
		return tail;
	}
	std::unique_ptr<node> pop_head()   
	{
		std::unique_ptr<node> old_head = std::move(head);
		head = std::move(old_head->next);
		return old_head;
	}

	std::unique_lock<std::mutex> wait_for_data()   
	{
		std::unique_lock<std::mutex> head_lock(head_mutex);
		data_cond.wait(head_lock,[&] {return (_bstop.load() == true) || (head.get() != get_tail()); });
		return std::move(head_lock);   
	}


		std::unique_ptr<node> wait_pop_head()
		{
			std::unique_lock<std::mutex> head_lock(wait_for_data()); 

			if (_bstop.load()) {
				return nullptr;
			}

			return pop_head();
		}

		std::unique_ptr<node> wait_pop_head(T& value)
		{
			std::unique_lock<std::mutex> head_lock(wait_for_data());  

			if (_bstop.load()) {
				return nullptr;
			}

			value = std::move(*head->data);
			return pop_head();
		}


		std::unique_ptr<node> try_pop_head()
		{
			std::lock_guard<std::mutex> head_lock(head_mutex);
			if (head.get() == get_tail())
			{
				return std::unique_ptr<node>();
			}
			return pop_head();
		}
		std::unique_ptr<node> try_pop_head(T& value)
		{
			std::lock_guard<std::mutex> head_lock(head_mutex);
			if (head.get() == get_tail())
			{
				return std::unique_ptr<node>();
			}
			value = std::move(*head->data);
			return pop_head();
		}
public:

	ThreadSafeQue() :  // ⇽-- - 1
		head(new node), tail(head.get()), _bstop(false)
	{}

	ThreadSafeQue(const ThreadSafeQue& other) = delete;
	ThreadSafeQue& operator=(const ThreadSafeQue& other) = delete;

	void NotifyStop() {
		_bstop.store(true);
		data_cond.notify_one();
	}

	std::shared_ptr<T> WaitAndPop() //  <------3
	{
		std::unique_ptr<node> const old_head = wait_pop_head();
		if (old_head == nullptr) {
			return nullptr;
		}
		return old_head->data;
	}

	void WaitAndPop(T& value)  //  <------4
	{
		std::unique_ptr<node> const old_head = wait_pop_head(value);
	}


	std::shared_ptr<T> Try()
	{
		std::unique_ptr<node> old_head = try_pop_head();
		return old_head ? old_head->data : std::shared_ptr<T>();
	}
	bool try_pop(T& value)
	{
		std::unique_ptr<node> const old_head = try_pop_head(value);
		return old_head;
	}
	bool empty()
	{
		std::lock_guard<std::mutex> head_lock(head_mutex);
		return (head.get() == get_tail());
	}



	void push(T new_value) //<------2
	{
		std::shared_ptr<T> new_data(
			std::make_shared<T>(std::move(new_value)));
		std::unique_ptr<node> p(new node);
		{
			std::lock_guard<std::mutex> tail_lock(tail_mutex);
			tail->data = new_data;
			node* const new_tail = p.get();
			tail->next = std::move(p);
			tail = new_tail;
		}

		data_cond.notify_one();
	}
};

int main()
{
    MsgClassA msga;
    msga.name = "llfc";
    ClassA::Inst().PostMsg(msga);

    std::this_thread::sleep_for(std::chrono::seconds(2));
    std::cout << "main process exited!\n";
}

二. 影响并发代码性能的因素

1.处理器数量对多线程性能的影响

        开发和目标平台的处理器数量及核心数可能不同,导致并发程序行为和性能表现差异,需在不同平台上测试,不可一撮二就。单核16芯、四核双芯或十六核单芯处理器都能运行16个并发线程。线程数少于16时,处理器可能空闲;线程数超过16时,会出现超额认购,浪费处理器时间在线程切换上。

        利用C++标准线程库的std::thread::hardware_concurrency()可获取硬件支持的线程数量,但需谨慎使用,因为它不考虑系统上其他运行线程(同一时刻内其余进程也可能使用cpu),可能导致超额认购。std::async()能合理安排线程数量,避免超额认购;线程池也可谨慎使用来管理线程资源,提高效率。

        随着处理器数量增加,多个处理器访问同一数据时,会出现数据冲突或竞争,影响性能,需通过同步机制等来解决。

2.数据争用与乒乓缓存

乒乓缓存:指的某个数据在多个cpu的缓存中反复更新传递(当多个线程频繁访问同一缓存行(Cache Line)中的数据时,会触发缓存一致性协议(如MESI)的反复失效和同步,导致性能下降。)

        当有线程对数据进行修改的时候,这个修改需要更新到其他核芯的缓存中去,就要耗费一定的时间。根据线程的操作性质,以及使用到的内存序,这样的修改可能会让第二个处理器停下来,等待硬件内存更新缓存中的数据。

看下述代码

std::atomic<unsigned long> counter(0);
void processing_loop()
{
  while(counter.fetch_add(1,std::memory_order_relaxed)<100000000)
  {
    do_something();
  }
}

由于counter是全局变量,当多个线程同时调用processing_loop去修改同一个变量,这就要求counter在缓存内做一份拷贝,再改变自己的值,或其他线程以发布的方式对缓存中的拷贝副本进行更新。fetch_add是一个“读-改-写”操作,因此就要对最新的值进行检索。如果另一个线程在另一个处理器上执行同样的代码,counter的数据需要在两个处理器之间进行传递,那么这两个处理器的缓存中间就存有counter的最新值(当counter的值增加时)。如果do_something()足够短,或有很多处理器来对这段代码进行处理时,处理器将会互相等待;一个处理器准备更新这个值,另一个处理器正在修改这个值,所以该处理器就不得不等待第二个处理器更新完成,并且完成更新传递时,才能执行更新。这种情况被称为高竞争(high contention)。

在这个循环中,counter的数据将在每个缓存中传递若干次。这就叫做乒乓缓存(cache ping-pong)

3.避免伪共享

处理器缓存通常不会用来处理在单个存储位置,但其会用来处理称为缓存行(cache lines)的内存块。内存块通常大小为32或64字节,处理器对缓存行进行数据处理。这样有优势也有劣势:
优势:当线程访问的一组数据是在同一数据行中,对于应用的性能来说就要好于向多个缓存行进行传播。

劣势:当在同一缓存行存储的是无关数据,且需要被不同线程访问,这就会造成性能问题

例如

struct Data {
    int x; // 线程A频繁修改
    int y; // 线程B频繁修改
};

X和Y分别是线程A和线程B关注的两个属性,线程A修改x会导致该缓存行在核心B的缓存中失效,反之亦然(即使y未被修改)。每次修改触发MESI协议的Invalidate消息,其他核心需重新加载缓存行。缓存行频繁失效导致大量缓存同步操作,CPU时间浪费在协调缓存一致性上。高并发下,这种无效化操作成为性能瓶颈。

解决办法

        为了减少伪共享,可以将被不同线程频繁访问的数据项放置在不同的缓存行中。这样,每个线程访问的数据项就不会互相干扰。可以在数据项之间插入足够的填充字节,使得每个数据项都位于不同的缓存行中。

        手动填充:   

struct AlignedData {
    int x;
    char padding[60]; // 填充至64字节(假设缓存行64字节)
    int y;
};

        编译器指令对齐:

struct alignas(64) ThreadData {
    int local_counter;
};

         线程本地存储:

        使用线程局部变量避免共享

thread_local int local_x; // 每个线程独有副本

        通过sysconf获取系统缓存行大小(Linux): 

long cache_line_size = sysconf(_SC_LEVEL1_DCACHE_LINESIZE);

案例

         错误实现

std::atomic<int> counters[4]; // 假设4个线程各自累加自己的计数器

        优化后

struct alignas(64) AlignedCounter {
    std::atomic<int> value;
};
AlignedCounter counters[4]; // 每个计数器独占缓存行

提供工具

  • perf(Linux):通过perf stat -e cache-misses统计缓存未命中次数。

  • Valgrind的Cachegrind:模拟缓存行为,分析缓存行争用。

 4.数据紧凑是否重要?

        数据并非越紧凑越好。对于单线程而言,紧凑布局更好,数据集中在少量缓存行,减少内存访问延迟。对于多线程而言,紧凑布局导致不同线程共享缓存行,频繁失效。

        当线程数 > CPU核心数时,操作系统会在核心上切换线程(时间片轮转)。若线程A在核心1运行后切换到核心2,其使用的数据需从核心1的缓存迁移到核心2的缓存(或从内存重新加载)。若线程A和线程B的数据布局紧凑(共享缓存行),切换时核心需加载更多无关数据,浪费缓存空间。同时频繁切换导致大量缓存行迁移,,进而导致CPU性能下降。

        总的来说我们需要控制几个原则:

  • 单线程:紧凑布局,减少缓存行占用。
  • 多线程:隔离数据,避免共享缓存行。
  • 系统级:控制线程数量,减少切换频率。

三.多线程性能优化数据结构设计关键要点

我们主要从以下角度考量:

        竞争(Contention):减少线程对共享资源的争用。

        伪共享(False Sharing):避免不同线程频繁访问同一缓存行的不同数据。

        数据局部性(Data Proximity):优化数据布局以提升缓存利用率。

1.数组结构优化策略

当线程处理连续行,写入列数据时易引发伪共享(若列连续存储)。

线程处理连续列,写入行数据时内存连续,伪共享风险较低。

分块处理:将矩阵划分为子块(如100x100),减少数据访问量(仅需相邻行/列),显著降低缓存缺失,提升性能。

2.其他数据结构的优化

        对于树状结构,可能存在以下两点原因:

2.1 动态内存分配特性

传统树结构(如二叉树)的节点通常通过newmalloc动态分配,内存地址随机分散。CPU以缓存行为单位进行数据加载。随机节点可能导致:遍历树的时候可能会频繁导致缓存缺失,触发MESI。

解决思路:预先分配连续内存块(如数组)存储所有节点。通过索引(而非指针)管理父子关系。

例如

struct TreeNode {
    int data;
    int left_child_idx;  // 左子节点索引
    int right_child_idx; // 右子节点索引
};
TreeNode node_pool[MAX_NODES]; // 连续内存存储所有节点

        通过一定的内存分配策略,将树按层级或子树划分为块,每块内节点连续存储。节点在内存中连续排列,遍历时触发缓存预取,减少缺失。

        不过这种策略只适用于已经预想到的节点大小,或者后续检测内存不够进行扩容。同时在设计的时候我们去关注以下节点大小,避免超过缓存行。

 2.2 分离节点元数据与实际数据:避免伪共享

在树的结构中一般会姜元数据(指针)与实际数据(键值对)混合存储。多线程场景下:线程A修改节点X的key,线程B修改节点Y的left指针。若X和Y位于同一缓存行,触发伪共享(False Sharing)。

struct TreeNode {
    TreeNode* left;  // 元数据(指针)
    TreeNode* right; // 元数据(指针)
    int key;         // 实际数据
    Value value;     // 实际数据
};

可以将元数据与数据分离 ,或者设置缓存行隔离或者指定64字节对齐等等

struct TreeNode {
    TreeNode* left;
    TreeNode* right;
    char padding1[64 - sizeof(TreeNode*) * 2]; // 填充至64字节
    int key;
    Value value;
    char padding2[64 - sizeof(int) - sizeof(Value)]; // 再次填充
};

3.如何检测数据伪共享

一种测试伪共享问题的方法是:对大量的数据块填充数据,让不同线程并发的进行访问。比如,你可以使用,如果这样能够提高性能,你就能知道伪共享在这里的确存在。

struct protected_data
{
  std::mutex m;
  char padding[65536];  // 65536字节已经超过一个缓存行的数量级
  my_data data_to_protect;
};
3.1 示例

未优化缓存行代码:

// File: false_sharing.cpp
#include <iostream>
#include <thread>
#include <chrono>

struct ContendedData {
    volatile int a;  // 线程1频繁修改
    volatile int b;  // 线程2频繁修改
};

void thread1_task(ContendedData& data) {
    for (int i = 0; i < 100'000'000; ++i) {
        data.a++;
    }
}

void thread2_task(ContendedData& data) {
    for (int i = 0; i < 100'000'000; ++i) {
        data.b++;
    }
}
// g++ -O0 no_dcache_opti.cpp -o no_dcache_opti -lpthread
int main() {
    ContendedData data{0, 0};

    auto start = std::chrono::high_resolution_clock::now();

    std::thread t1(thread1_task, std::ref(data));
    std::thread t2(thread2_task, std::ref(data));

    t1.join();
    t2.join();

    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    std::cout << "Time: " << duration << " ms\n";
    std::cout << "Final values: a=" << data.a << ", b=" << data.b << "\n";

    return 0;
}

优化缓存行代码:

// File: padded_data.cpp
#include <iostream>
#include <thread>
#include <chrono>

struct PaddedData {
    volatile int a;
    char padding[64 - sizeof(int)];  // 填充至64字节(假设缓存行大小为64字节)
    volatile int b;
};

void thread1_task(PaddedData& data) {
    for (int i = 0; i < 100'000'000; ++i) {
        data.a++;
    }
}

void thread2_task(PaddedData& data) {
    for (int i = 0; i < 100'000'000; ++i) {
        data.b++;
    }
}

int main() {
    PaddedData data{0, {}, 0};

    auto start = std::chrono::high_resolution_clock::now();

    std::thread t1(thread1_task, std::ref(data));
    std::thread t2(thread2_task, std::ref(data));

    t1.join();
    t2.join();

    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();

    std::cout << "Time: " << duration << " ms\n";
    std::cout << "Final values: a=" << data.a << ", b=" << data.b << "\n";

    return 0;
}

 我的服务器是:

Linux hcss-ecs-4e5d 5.15.0-113-generic #123-Ubuntu SMP Mon Jun 10 08:16:17 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux

    Architecture:            x86_64
    CPU op-mode(s):        32-bit, 64-bit
    Address sizes:         42 bits physical, 48 bits virtual
    Byte Order:            Little Endian
    CPU(s):                  2
    On-line CPU(s) list:   0,1
    Vendor ID:               AuthenticAMD
    Model name:            General Purpose Processor
    CPU family:          25
    Model:               1
    Thread(s) per core:  2
    Core(s) per socket:  1
    Socket(s):           1
    Stepping:            1
    BogoMIPS:            4899.61

发现性能有大概20倍的优化 ,使用perf观测L1-dcache-load-misses:

使用命令:perf stat -e cache-misses,cache-references,L1-dcache-load-misses  ./app

整理表格如下: 

指标未优化版本优化版本优化效果
执行时间1965 ms115 ms17 倍加速
缓存总访问次数(cache-references)81,724,549 406,177 访问次数锐减
缓存未命中率(cache-misses98.58%47.24%未命中次数减少 99.7%
L1 缓存未命中次数79,281,70968,138L1 命中率提升 99.9%
CPU 用户态时间3.85 秒0.206 秒CPU 利用率提升 18.7 倍

四.设计并发代码的注意事项 

在C++并行算法中确保异常安全需要特别注意以下几点,以下是总结:

1.线程间的异常传播

        并行代码中,若子线程抛出异常且未被捕获,将导致程序调用std::terminate()终止。

解决办法封装任务与异常传递
  • 使用std::packaged_taskstd::future将子线程的任务封装,使异常能通过future传递到主线程。

  • 子线程中的异常会被future捕获,主线程通过future::get()重新抛出异常,确保异常在正确上下文中处理。

2.资源泄露风险

        线程未正确汇入(join)或分离(detach)可能导致资源泄露,尤其是在异常发生时。

解决办法RAII管理线程生命周期

        创建join_threads类,利用RAII在析构函数中自动汇入所有线程,即使发生异常也能保证线程安全汇入。

class join_threads {
    std::vector<std::thread>& threads;
  public:
    explicit join_threads(std::vector<std::thread>& t) : threads(t) {}
    ~join_threads() {
        for (auto& t : threads) {
            if (t.joinable()) t.join();
        }
    }
};

3.示例

在清单8.2 std::accumulate的原始并行版本(源于清单2.8)

template<typename Iterator,typename T>
struct accumulate_block
{
  void operator()(Iterator first,Iterator last,T& result)
  {
    result=std::accumulate(first,last,result);  // 1
  }
};
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
  unsigned long const length=std::distance(first,last);  // 2
  if(!length)
    return init;
  unsigned long const min_per_thread=25;
  unsigned long const max_threads=
    (length+min_per_thread-1)/min_per_thread;
  unsigned long const hardware_threads=
    std::thread::hardware_concurrency();
  unsigned long const num_threads=
    std::min(hardware_threads!=0?hardware_threads:2,max_threads);
  unsigned long const block_size=length/num_threads;
  std::vector<T> results(num_threads);  // 3
  std::vector<std::thread> threads(num_threads-1);  // 4
  Iterator block_start=first;  // 5
  for(unsigned long i=0;i<(num_threads-1);++i)
  {
    Iterator block_end=block_start;  // 6
    std::advance(block_end,block_size);
    threads[i]=std::thread(  // 7
      accumulate_block<Iterator,T>(),
      block_start,block_end,std::ref(results[i]));
    block_start=block_end;  // 8
  }
  accumulate_block()(block_start,last,results[num_threads-1]);  // 9
  std::for_each(threads.begin(),threads.end(),
    std::mem_fn(&std::thread::join));
  return std::accumulate(results.begin(),results.end(),init);  // 10
}

我们可以调整以下几部分:

3.1 任务函数调整
struct accumulate_block {
    T operator()(Iterator first, Iterator last) { 
        return std::accumulate(first, last, T()); // 返回结果而非引用
    }
};

原始任务函数通过引用传递结果void operator()(…, T& result)),若子线程中操作(如std::accumulate)抛出异常。

但是如果将任务函数改为返回结果T operator()(…)),结合std::future:线程调用future.get()时重新抛出,允许在正确上下文中处理。

3.2 使用std::packaged_task分发任务:解决跨线程异常传播问题

在原示例中,子线程抛出的异常无法传递到主线程,直接触发std::terminate()

std::packaged_task<T(Iterator, Iterator)> task(accumulate_block());
futures[i] = task.get_future();
threads[i] = std::thread(std::move(task), block_start, block_end);

通过std::packaged_task封装任务:任务中的异常会被自动捕获并存储到关联的std::future中。主线程通过future.get()获取结果或异常,实现跨线程安全的异常传播。

3.3 异常安全汇入线程:解决资源泄露问题

主线程在创建子线程后,若自身抛出异常(如内存不足)已创建的线程可能未被join(),导致资源泄露或僵尸线程。

通过引入RAII机制:

class join_threads {
    std::vector<std::thread>& threads;
  public:
    ~join_threads() {
        for (auto& t : threads) if (t.joinable()) t.join();
    }
};

在析构函数中确保所有线程join(),无论主线程正常退出还是因异常退出。

五. 在实践中设计并发代码

C++标准库中有三个标准函数,find, for_each以及partial_sum。我们将结合三个函数修改在清单8.2 的实现。

值得注意的是,在c++17中,find for_each函数可以直接指定std::execution::par进行并行操作,本章节通过多线程方式演示for_each并行操作。

1.并行版本for_each和find

1.1 方法一
template<typename Iterator,typename Func>
void parallel_for_each(Iterator first,Iterator last,Func f)
{
  unsigned long const length=std::distance(first,last);
  if(!length)
    return;
  unsigned long const min_per_thread=25;
  unsigned long const max_threads=
    (length+min_per_thread-1)/min_per_thread;
  unsigned long const hardware_threads=
    std::thread::hardware_concurrency();
  unsigned long const num_threads=
    std::min(hardware_threads!=0?hardware_threads:2,max_threads);
  unsigned long const block_size=length/num_threads;
  std::vector<std::future<void> > futures(num_threads-1);  // 1
  std::vector<std::thread> threads(num_threads-1);
  join_threads joiner(threads);
  Iterator block_start=first;
  for(unsigned long i=0;i<(num_threads-1);++i)
  {
    Iterator block_end=block_start;
    std::advance(block_end,block_size);
    std::packaged_task<void(void)> task(  // 2
      [=]()
      {
        std::for_each(block_start,block_end,f);
      });
    futures[i]=task.get_future();
    threads[i]=std::thread(std::move(task));  // 3
    block_start=block_end;
  }
  std::for_each(block_start,last,f);
  for(unsigned long i=0;i<(num_threads-1);++i)
  {
    futures[i].get();  // 4
  }
}

我们将for_each置于lamda表达式中,封装成一个任务传递给thread, numthreads-1个线程并行处理for_each,剩下的主线程处理余下的for_each.最后通过futures.get汇总计算得到总结果。

1.2 方法二

第二种划分方式是我们采取递归的方式,实现并行std::accumulate的时候,使用std::async会简化代码;同样,parallel_for_each也可以使用std::async。实现如下所示。

async可以帮助我们判断是否需要开启线程还是自动串行执行。每次我们将要处理的数据一分为2,前半部分交给一个async开辟线程处理,后半部分在本线程处理。而所谓的本线程不一定是主线程,因为我们通过async递归执行parallel_for_each,也就相当于在一个线程里独立执行了。

template<typename Iterator,typename Func>
void parallel_for_each(Iterator first,Iterator last,Func f)
{
  unsigned long const length=std::distance(first,last);
  if(!length)
    return;
  unsigned long const min_per_thread=25;
  if(length<(2*min_per_thread))
  {
    std::for_each(first,last,f);  // 1
  }
  else
  {
    Iterator const mid_point=first+length/2;
    std::future<void> first_half=  // 2
      std::async(&parallel_for_each<Iterator,Func>,
                 first,mid_point,f);
    parallel_for_each(mid_point,last,f);  // 3
    first_half.get();  // 4
  }
}

同理,find也是如此。可以将要查找的数据分为几个区间,每个区间交给一个线程处理。

当然也可以利用async的特性,让他决定是否需要开启线程

2.并行版本partial_sum

我们可以通过将输入数据划分为多个块,每个块由不同线程处理,并通过promisefuture传递块之间的依赖值,以实现并行计算的同时保证结果的正确性。

例如:

输入数组{1,2,3,4,5,6,7},分块处理:

  1. 线程1处理{1,2,3}:计算前缀和为{1,3,6},传递6给线程2。

  2. 线程2处理{4,5,6}:计算块内前缀和为{4,9,15},加上6得{10,15,21},传递21给主线程。

  3. 主线程处理{7}:加上21得到28。

最终结果:{1,3,6,10,15,21,28}

分块处理逻辑如下:

  1. 划分数据块:根据硬件线程数确定线程数量,将数据划分为多个块,每个线程处理一个块(主线程处理最后一个块)。

  2. 计算块内前缀和:每个线程先独立计算自己块内的前缀和。

  3. 处理跨块依赖:每个块的最后一个元素需要加上前一个块的最后一个元素值。通过promise传递当前块的最后一个值,供后续块使用。

  4. 更新块内元素:在获取前一个块的最后一个值后,将其加到当前块的所有元素(除最后一个外)上

process_chunk类中我们重载operator(),处理单个数据块。然后调用std::partial_sum计算块内前缀和。若存在前一个块的future值(previous_end_value),则获取该值并加到当前块的最后一个元素,随后更新当前块的promise。使用std::for_each将前一个块的累加值(addend)加到当前块除最后一个元素外的所有元素。每个线程处理一个分块,通过promise传递当前块的最后一个值,供后续块使用。主线程处理最后一个分块,并等待前一个块的最后一个值(若存在)。这样通过不断地传输叠加,计算最后的总值。

template<typename Iterator>
void parallel_partial_sum(Iterator first, Iterator last)
{
    typedef typename Iterator::value_type value_type;
    struct process_chunk    //⇽-- - 1
    {
        void operator()(Iterator begin, Iterator last,
            std::future<value_type>* previous_end_value,
            std::promise<value_type>* end_value)
        {
            try
            {
                Iterator end = last;
                ++end;
                std::partial_sum(begin, end, begin);    //⇽-- - 2
                if (previous_end_value)    //⇽-- - 3
                {
                    value_type addend = previous_end_value->get();   // ⇽-- - 4
                    *last += addend;   // ⇽-- - 5
                    if (end_value)
                    {
                        end_value->set_value(*last);    //⇽-- - 6
                    }
                    // ⇽-- - 7
                    std::for_each(begin, last, [addend](value_type& item)
                        {
                            item += addend;
                        });
                }
                else if (end_value)
                {
                    // ⇽-- - 8
                    end_value->set_value(*last);
                }
            }
            catch (...)  // ⇽-- - 9
            {
                if (end_value)
                {
                    end_value->set_exception(std::current_exception());   // ⇽-- - 10
                }
                else
                {
                    throw;   // ⇽-- - 11
                }
            }
        }
    };
        unsigned long const length = std::distance(first, last);
        if (!length) {
            return;
        }
        unsigned long const min_per_thread = 25;     //⇽-- - 12
        unsigned long const max_threads = (length + min_per_thread - 1) / min_per_thread;
        unsigned long const hardware_threads = std::thread::hardware_concurrency();
        unsigned long const num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);
        unsigned long const block_size = length / num_threads;
        typedef typename Iterator::value_type value_type;
        std::vector<std::thread> threads(num_threads - 1);   // ⇽-- - 13
        std::vector<std::promise<value_type> > end_values(num_threads - 1);   // ⇽-- - 14
        std::vector<std::future<value_type> > previous_end_values;   // ⇽-- - 15
        previous_end_values.reserve(num_threads - 1);   // ⇽-- - 16
        join_threads joiner(threads);
        Iterator block_start = first;
        for (unsigned long i = 0; i < (num_threads - 1); ++i)
        {
            Iterator block_last = block_start;
            std::advance(block_last, block_size - 1);   // ⇽-- - 17
            // ⇽-- - 18
            threads[i] = std::thread(process_chunk(), block_start, block_last,
                (i != 0) ? &previous_end_values[i - 1] : 0,
                &end_values[i]);
            block_start = block_last;
            ++block_start;   // ⇽-- - 19
            previous_end_values.push_back(end_values[i].get_future());   // ⇽-- - 20
        }
        Iterator final_element = block_start;
        std::advance(final_element, std::distance(block_start, last) - 1);   // ⇽-- - 21
        // ⇽-- - 22
        process_chunk()(block_start, final_element, (num_threads > 1) ? &previous_end_values.back() : 0,
            0);
}

实验一 C++简单程序设计(2学时) 1.编程计算图形的面积。程序可以计算圆形、长方形、正方形的面积、运行时先提示用户选择图形类型,然后,对圆形要求用户输入半径、对长方形要求用户输入长和宽的值,对正方形要求用户输入边长,计算出面积后将其显示出来。要求使用debug调试功能观察程序运行中变量值的变化情况。 2.定义一个表示时间的结构体,可以精确的表示年、月、日、小时、分、秒;提示用户输入年、月、日、小时、分、秒的值,然后完整地显示出来。 实验二 函数的应用(2学时) 1.编写重载函数Max1,分别求出两个整数,两个双精度数,三个整数,三个双精度数的最大值。 2.使用重载函数模板重新实现上面的函数Max1。 要求:(1)练习重载函数的使用;(2) 练习函数模板的使用。 实验三 类与对象(2学时) 1.声明一个Dog类,包含age、weight等属性,以及对这些属性操作的方法。实现并测试这个类。 2.设计并测试一个名为Rectangle的矩形类,其属性为矩形的左下角和右上角两个点的坐标,有成员函数能计算矩形的面积。 3.定义一个CPU类,包含等级、频率,电压等属性,并编写构造函数、析构函数,以及成员函数run、stop模拟CPU的状态。其中,等级为整型,频率为单位是兆赫兹的整数,电压为浮点型。要求自己设计各个属性的标识。 4.定义一个简单的Computer类,包含数据成员cpu、ram、cdrom等等,有两个成员函数run、stop。其中cpu为CPU类的一个对象,ram为RAM类的一个对象,cdrom为CDROM类的一个对象,定义并实现这个类。 5.(必做)设计一个用于人事管理的People类。考虑到通用性,可以只抽象出所有人员都 具有的属性:number(编号),sex(性别) ,birthday(出生日期),id(身份证号)等等。其中“出生日期”定义为一个“日期”类内嵌子对象。用成员函数实现对人员函数的录入和显示。要求包括:编写构造函数和析构函数、拷贝构造函数、内联成员函数,类的组合。 实验四 C++程序的结构(2学时) 1.编写程序,实现并测试客户机(Client)类。定义字符型静态数据成员ServerName[10],保存其服务器名称;整型静态数据成员ClientNum,记录定义的客户数量;定义静态函数ChangeServerName()改变服务器名称。在头文件client.h中定义类,在文件client.cpp中实现,在文件test.cpp中测试这个类,观察相应的成员变量的取值的变化情况。 2、在实验三题目5编写的人员类中设计适当的方法来实现类中数据的共享性,并采用多文件结构实现程序。 3.(选做)定义类X、Y、Z,函数h(X *),满足:类X有私有成员i,Y的成员函数g(X *)是X的友元函数,实现对X的成员i加1,类Z是类X的友元类,其成员函数f(X *)实现对X的成员i加5,函数h(X *)是X的友元函数,实现对X的成员i加10。在一个文件中声明类,在一个文件中实现类,在另一个文件中测试类。 实验五 继承和派生(2学时) 1.设计并定义一个交通工具类,并通过该类派生出新的类,编写程序定义这些类并测试它; 2.(选做)声明一个基类Shape,在此基础上派生Rectangle和Circle,二者都有GetArea()函数计算对象的面积,编写程序测试。 3.(选做)声明一个哺乳动物类Mammal,再由此派生出狗类Dog,定义一个Dog类的对象,观察基类与派生类的构造函数和析构函数的调用顺序。 4.完善实验四第2题的程序,具体要求如下: 任务1:从people(人员)类派生出student(学生)类,添加属性:学校、学院、专业、班号、入学成绩,设计相应的成员函数(构造函数,拷贝构造函数,录入函数,显示函数)。 任务2:从people类派生出teacher(教师)类,添加属性:职务,部门,职称。并设计相应的成员函数。 任务3:从student类派生出graduate(研究生)类,添加属性:导师,研究方向。并设计相应的成员函数。 任务4:编写程序来测试这个类。 实验六 小型学生管理系统的设计与实现1(2学时) 设计学生类、课程类、成绩类及相应的成员函数。要求能实现学生的信息、课程信息和成绩信息的增加、修改、删除与查询。 实验七 多态性(2学时) 1.定义Point类,有坐标X和Y两个成员变量,对Point类重载++和――运算符,实现对坐标值的改变。 2.定义一个车(Vehicle)类,有run,stop等成员函数,由此派生出自行车(bicycle)类、汽车(car)类,由bicycle类和car类派生出摩托车(motocar)类,它们都包含run,stop等成员函数,编写相应的虚函数并测试
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值