C++并发编程(八)设计并发代码

本章从算法函数举例,给出其多线程实现。多线程代码需要考虑更多因素,除了封装、耦合、内聚等普通因素,还要分析哪些数据需要共享,如何同步数据访问,线程的先后次序等等。

目录

1.任务切分方法

1.1数据划分

1.1.1以递归方式划分数据

1.1.2依据工作类别划分任务

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

2.1处理器的数量

2.3不经意共享

2.4数据的紧凑度

2.5任务切换与线程饱和

3.设计数据结构以提升多线程程序的性能

3.1针对数组结构数据的划分

3.2其它数据结构的访问模式

4.需要考虑的额外因素

4.1并行算法中的异常安全

4.2引入异常安全

4.3std::async线程安全 

4.4可伸缩性和Amdahl定律

4.5利用多线程“掩藏”等待行为

4.6借并发特性改进响应能力

5.并发代码的设计实践

5.1std::for_each()的并行化实现

5.2std::find()的并行实现 

5.3std::partial_sum()的并行实现

小结


1.任务切分方法

我们需要决定使用多少线程,决定应当让他们执行怎样的任务,还要决定使用执行单一任务的“专家”线程,还是能执行不同任务的“普通”线程。

1.1数据划分

std::for_each()函数对数据集所含的每个元素轮流执行操作,这些元素可以指派给多个线程分别处理,以便实现算法并行化。

最简单的切分方法是,每N个元素分配给一个线程。在完成各自任务前,线程间不会进行任何通信。有时候,数据无法在一开始就划分完毕,要经过适度处理,才可以清晰地对数据实施必要的划分。

1.1.1以递归方式划分数据

回顾快速排序的主要步骤:选定一个元素作为基准,把数据划分为大于和小于基准元素两部分,再对两部分递归划分,最后进行归并。该算法中,数据要经过比较才清楚归入哪个部分。

另外,如果排序操作的数据集非常庞大,每次递归都生成新线程,则线程数过多会影响性能。在第4章中,我们借助std::async()让C++线程库自主决定另起新线程还是在原线程上进行。另一种方法是根据std::hardware_comcurrency()的返回值设定线程数目。

还可以用栈容器管理待排序的数据段(后续章节有标准库提供的并行算法函数):

template<typename T>
struct node
{
	struct chunk_to_sort
	{
		std::list<T> data;
		std::promise<std::list<T>> promise;
	};

	thread_safe_stack<chunk_to_sort> chunks;//通过容器管理待删除的数据段
	std::vector<std::thread> threads;
	const unsigned max_thread_count;
	std::atomic<bool> end_of_data;

	sorter():max_thread_count(std::thread::hardware_concurrency()-1),end_of_data(false)
	{}
	~sorter()
	{
		end_of_data = true;//标志成立则等待所有线程结束
		for (unsigned i = 0; i < threads.size(); ++i)
		{
			threads[i].join();
		}
	}
	void sort_chunk(boost::shared_ptr<chunk_to_sort> const& chunk)
	{
		chunk->promise.set_value(do_sort(chunk->data));//把结果存入promise中,使之准备就绪
	}
	void try_sort_chunk()
	{
		std::shared_ptr<chunk_to_sort> chunk = chunks.pop();//弹出数据
		if (chunk)
		{
			sort_chunk(chunk);//排序
		}
	}
	void sort_thread()
	{
		while (!end_of_data)//只要不成立,各线程反复循环
		{
			try_sort_chunk();
			std::this_thread::yield();
		}
	}

	//主要工作部分
	std::list<T> do_sort(std::list<T>& chunk_data)
	{
		if (chunk_data.empty())
		{
			return chunk_data;
		}

		std::list<T> res;
		res.splice(res.begin(), chunk_data, chunk_data.begin());
		const T& partition = *res.begin();
		std::list<T>::iterator divide_point = std::partition(chunk_data.begin(), chunk_data.end(),
			[&](const& T val) {return val < partition; });
		//创建小于部分的结构体
		chunk_to_sort new_lower_chunk;
		new_lower_chunk.data.splice(new_lower.end(),
			chunk_data,
			chunk_data.begin(), divide_point);
		std::future<std::list<T>> new_lower = new_lower_chunk.promise.get_future();

		chunks.push(std::move(new_lower_chunk));//新划分出来的数据压入栈
		if (threads.size() < max_thread_count)//存在空闲线程时才分配新线程
		{
			threads.push_back(std::thread(&sorter<T>::sort_thread, this));//递归调用排序
		}
		//创建大于部分的结构体
		std::list<T> new_higher(do_sort(chunk_data));//排序被截取后的数据块
		res.splice(res.end(), new_higher);
		while (new_lower.wait_for(std::chrono::second(0)) != std::future_status::ready)//等待进入就绪状态
		{
			try_sort_chunk();//从线程池递归调用排序
		}
		res.splice(res.begin(), new_lower.get());
		return res;
	}
};
template<typename T>
std::list<T> parallel_quick_sort(std::list<T> input)
{
	if (input.empty())
	{
		return input;
	}
	sorter<T> s;
	return s.do_sort(input);
}

上面的方法摆脱了对std::async()的依赖,从而自主选择线程数目(受限于std::thread::hardware_concurrency()的返回值)。但是线程管控令代码更复杂,虽然数据段各自独立,但读写同一个栈容器,即便使用无锁容器,还是会诱发资源争夺。

若数据动态生成,或从外部输入,更合理的方法是依据工作类别划分任务,而不是按数据切分。

1.1.2依据工作类别划分任务

按数据段划分数据基于一项假设:全部线程均对每段数据执行相同的操作。另一种方法是让每个线程分别负责特定的任务,这也契合并发程序设计分离关注点的要求。

1.依据类别划分任务以分离关注点

应用程序需要同时运行多个任务,按照单线程应用的思维:先执行一下甲任务,再执行乙,接着检测按键,检查传入的数据包,又回头执行甲,如此反复循环。令代码复杂化的同时,向循环中加入太多任务也会影响处理速度。

2.多线程相互协助

把每个任务放在独立的线程上运行,操作系统会自动切换动作,我们无需再考虑保存状态和主循环的返回,以及操作的间隔,同时用户的输入也能得到及时响应。

但是线程间的通信需要经过谨慎设计。如:用户界面由界面线程处理,当其它线程提出请求时,才会出现界面,反之,用户操作也要通过界面反馈到后台线程。执行后台任务的线程专注本身操作,但容许被别的线程终止。线程本身不需要关心消息的来源,只在乎该操作应当由自己负责

3.多线程分离关注点需要防范的风险

多线程共享了太多数据,导致线程间发生过量通信。此时需要分析通信发生的缘由,若通信集中在一个因素,则应提取相关功能整合为一个线程也可将两个任务线程合为一个以减少通信

4.按流程划分任务

如果任务处理的是独立的数据项,且数据的操作流程相同,可以采用流水线(pipeline)模式,为流程中的每一步操作创建单独的线程,尽量利用系统中可调配的并发资源。
元素每完成一个操作,就被放入一个队列等待,由下一个线程取出处理。若各项操作之间不存在前后依赖,流水线便能交错运行。

假设我们要在4核处理器上处理20项数据,每项数据需要经四个步骤处理,每步花费3秒。
按数据划分:每12秒处理4项数据,每个线程处理5项数据,总时间1分钟。
按流程划分:每个处理器处理一个步骤,第一个核心处理完一项数据可以立即处理下一项,则总时间需要69秒((4-1+20)X3)。但是处理过程更连贯,在处理视频等延迟敏感类任务时,用户体验更佳。

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

多线程代码性能受诸多因素影响,其中最显著的因素是:目标系统上处理器的数量。

2.1处理器的数量

处理器的数量既是首要因素,又是关键因素。使用std::thread::hardware_concurrency()返回硬件能并发运行线程的数目,可以就此调整线程的数目以充分利用硬件资源,也可采用std::async()让线程库进行适当调度。

随着处理器数量的增加,多个处理器更有可能访问同一份数据,加剧了数据竞争。

假设两个线程在不同处理器上运行,却读取同一份数据,通常情况下,该数据会复制到两个处理器对应的缓存中,让它们运行无碍。但如果其中一个线程改动了数据,该变化会传达到另一个处理器的缓存,这将消耗时间,还可能导致第二个线程半途暂停,等待数据变化。

考虑下面简单的例子:
 

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

counter是原子变量,任何调用process的线程都会改动原子变量,某个处理器执行自增操作时,因为fetch_add()是“读-改-写”操作,必须先确保自身缓存载入了最新更新的变量,再对其进行更改。
即便使用std::memory_order_relaxed内存次序不会与其它数据同步,若另一线程在另一处理器上执行相同的代码,两个处理器的缓冲中将分别形成counter的副本,它们在两个处理器之间来回传递,这种现象称为缓存乒乓,会严重影响应用程序的性能,

如果处理器数量增多,各处理器可能彼此等待共享数据的更新与传递,造成高度争夺(high contention),处理器之间极少相互等待则称为低度争夺(low contention)。

std::mutex m;
m_data data;
void processing_loop_with_mutex()
{
	while (true)
	{
		std::lock_guard<std::mutex> lk(m);
		if (done_processing(data)) break;
	}
}

互斥在操作系统层面起作用,若已启动了足够的处理器,且线程正在等待互斥解锁,则处理器会被切换到另一线程,处理器暂停则令其上的线程寸步难行。随着访问数据的处理器增多,针对互斥本身的争夺也越发激烈。
我们应当尽量降低两个线程争夺同一内存范围的可能性。但是即便只有一个线程访问特定的内存范围,也会出现不经意共享(false sharing)的情况,引发缓存乒乓。

2.3不经意共享

通常,处理器的缓存单元并非独立的小片内范围,而是连续的大块内存,称为缓存块。若多个小型数据项在内存中的位置彼此相邻,它们将被纳入同一缓存块。如果线程访问的数据均位于同一缓存块内,与散布到多个缓存块的情形相比,能提升应用程序的性能。

假设多个线程要访问一个整型数组,各线程由专属的元素,只反复读写自己的元素,假设元素位于同一块缓存内,每当有线程访问0号元素,就需要把整个缓存块传送到相关的处理器,另一处理器上的线程要更新1号元素,则缓存快需再次传递。
虽然上述两个线程没有共享任何数据,但缓存块却被它们共享,因此称为不经意共享。

解决办法是编排数据布局,使得同一线程访问的数据在内存中彼此开进,增加存放在同一缓存块的机会,而不同线程访问的数据在内存中彼此远离,有更多机会存放在独立的缓存块。

C++17标准库在头文件<new>中定义了常量std::hardware_destructive_interference_size,表示一个字节的限度,若当前编译目标内数据所在相邻区域比它小,则可能会在同一个缓存块,只要我们令数据的分布范围超出该值,就不会引发不经意共享。

2.4数据的紧凑度

对单个线程而言,若访问数据在内存中紧凑,则它们可能位于同一块缓存,相比松散布局,处理器从内存读取更少数据块,从而利于性能。

如果系统中的线程数目超过核心数,则每个核心都要运行多个线程,操作系统会选取某种调度编排方式,让同一线程在不同时间片由不同核心运行,此过程就需要将线程数据所在的缓存块在核心间传递,传送的缓存块越多,消耗的时间就越多。而为了避免不经意共享,我们又试图确保各线程分别访问不同数据块,势必会增加缓存压力,且任务切换会加剧该问题(重新载入缓存块)。

C++17标准库在头文件<new>中定义了常量std::hardware_destructive_interference_size,表示同一缓存块的最大连续字节数(数据恰当对齐)。我们将所需数据的尺寸缩减至此限度以内,便能增大数据在同一缓存块内的机会,减少缓存错失次数。

2.5任务切换与线程饱和

除非在大规模并行硬件平台(主流超级计算机)上作业,否则多线程系统中的线程数目往往多于处理器数目。

线程常常会将时间花在等待外部I/O完成、等待条件变量成立、等待互斥上的阻塞等,让应用程序运行超量线程,它才得以完成一些务实的工作,处理器才不会因线程等待而无所事事。但是线程数目过多也会导致频繁的任务切换,引发线程过饱和。

如果是自然划分任务导致线程过饱和,唯一的改善方法就是另选划分方法,仅当原来的性能无法接受且确定改变划分方法会提升性能时,才值得另行选择。

3.设计数据结构以提升多线程程序的性能

从数据结构层面提升性能,主要考虑三个关键点:资源争夺不经意共享数据紧凑度。通常可以通过改变数据内存布局或变换数据元素从属线程的方式,就可以提升多线程程序的性能。

3.1针对数组结构数据的划分

矩阵一般以数组的形式存储,以矩阵乘法为例,有多种方法能针对矩阵乘法在线程间划分任务,各线程可以分别计算某几行或某几列的值,或某个子矩阵的值。

前文说过,如果跳跃地访问前后分散的元素,效果不及集中访问数组中位置连续的元素。假设我们让每个线程分别计算N列的结果,则线程均需按行读入前方矩阵的每个元素,并从后方矩阵读取与自身任务对应列的N个元素。每个线程应当访问连续的列,各行相应的N个元素相邻,令不经意共享发生的概率降至最低,若列的N个元素占据的空间刚好等于缓存块的大小,则各线程所用缓存块完全独立,可杜绝不经意共享。

因为矩阵一般按行连续存储,所以元素相邻的行位于一片连续的内存区域,对比按列计算进行划分的方式,更容易避免不经意共享。

除了按行、列进行线程划分,还有按子矩阵划分的方法。可以视为先按列划分,再按行划分。优点在于仅需读取目标子矩阵包含的行和列。假设两个1000X1000的矩阵相乘,有100个处理器,每个最多能计算10行的结果,即每个处理器计算10X1000个元素,需要访问前方矩阵对应的10行(10X1000)以及后方矩阵的所有元素(1000X1000),共计访问10+1000000个元素。若每个处理器只负责100X100的子矩阵,则需要读取前方矩阵的100行(100X1000)以及后方矩阵的100列(1000X100),共计200000个元素,读取数量缩减为原来的1/5,缓存失效率随之降低,有很大希望能提升性能。

综上,一种方法是让各线程完整地计算出结果矩阵的某几行,更好的做法是把结果矩阵划分成更多小正方形的子矩阵,再逐一分配给各线程,依据矩阵的规模和可用处理器的数目决定子矩阵的规模。

3.2其它数据结构的访问模式

前文中,我们以interference_size的值为参考,令同一线程的数据相互靠近,不同线程的数据相互分离,但一些数据结构难以遵循这种规则。如二叉树,只能固定切分成子树,其它形式都难以进行划分操作,子树划分的有效性由树的平衡度和需要划分的层级决定,且节点可能因动态分配而散布在堆数据段的不同位置。

数据分散在堆上不成问题,虽然处理器需要缓存更多内容,但各节点没有存储真正的数据,而仅包含一个指针,指针指向节点所表示的实质数据,必要时,处理器才会从内存载入该数据,于是,单节点内的实质数据和整体结构之间,不经意共享得以避免。

若利用互斥保护数据,设想一个简单的类,其中包含几项数据和一个互斥,假定互斥和数据在内存中相互靠近,如果仅有单一线程获取互斥,则线程仅需载入一块缓存便能获得数据和互斥。在多线程的情况下,若一个线程已在互斥上持锁,另一线程试图加锁,那么持锁的线程就会遭受性能损失,因为彼此需要访问相同的内存区域。
要确定这种不经意共享是否发生,可以在不同线程并发访问的各项数据之间加入巨大的填充块。

以下例子判断互斥上是否发生争夺行为:

struct protected_data
{
	std::mutex m;
	char padding[std::hardware_destructive_interference_size];
	my_data data;
};

填充块一定要位于互斥和数据之间,这样才能有效隔离。

或用一下方式进行测试:

struct my_data
{
	data_item d1;
	data_item d2;
	char padding[std::hardware_destructive_interference_size];
};
my_data some_array[256];

此处的填充块只能位于最后方(或最前方),否则将d1和d2成员分隔无意义地增加了缓存失败的概率。
如果这些改动提升了性能,表明不经意共享导致了性能问题,遂保留填充块或改变数据编排方式对其进行消除。

4.需要考虑的额外因素

如果增加系统的处理器内核数目,程序性能随之提升,我们称之为代码可伸缩。
无论代码是否可伸缩,都要考虑异常安全。

4.1并行算法中的异常安全

对于单线程串行算法,若程序抛出异常,它可以向上传递,让函数的调用者处理。而并行算法的多项操作会在不同线程上执行,异常不得向上传递,因为有可能位于错误的调用栈中、若在某线程上有函数因发生异常而退出,则整个程序会被终结。

以前面章节的累加函数为例子进行分析:

template<typename Iterator, typename T>
struct accumulate_block
{
	void operator()(Iterator first, Iterator last, T& result)
	{
		result = std::accumulate(first, last, result);//(累加起始位置,累加终止位置,累加初始值)
	}
};
template<typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init)
{
	unsigned long const length = std::distance(first, last);//元素总长度[first, last)
	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);//实际的线程数在2-max_threads之间。
	unsigned long const block_size = length / num_threads;//按照各线程需要分担的元素数量,将数据切分为多块
	std::vector<T> results(num_threads);//用于存放中间结果
	std::vector<std::thread> threads(num_threads - 1);//用于装载线程,-1因为主线程也算一个线程
	Iterator block_start = first;//初始化block_start迭代器
	for (unsigned long i = 0; i < (num_threads - 1); ++i)//循环创建子线程
	{
		Iterator block_end = block_start;//初始化block_end迭代器
		std::advance(block_end, block_size);//block_end迭代器移动至小块末尾
		threads[i] = std::thread(//启动新线程
			accumulate_block<Iterator, T>(),//调用函数计算小块累加结果
			block_start,
			block_end,
			std::ref(results[i])
		);
		block_start = block_end;//因为STL容器左闭右开特性,下一小块的起始位置为本小块的末端
	}
	accumulate_block<Iterator, T>()(//主线程处理最后一个小块,直接处理到last解决余数问题
		block_start, last, results[num_threads - 1]);
	//等待所有线程完成
	for (auto& entry : threads) 
	{
		entry.join();
	}
	return std::accumulate(results.begin(), results.end(), init);//最后返回累加值
}

异常分析:

std::distance()的调用、std::vector容器results与threads的创建在实质工作之前,代码未生成任何线程,故这些都不成问题,容器的析构函数会解决构造函数抛出的异常。而block_start初始化符合线程安全。

接下来我们便进入创建循环。一旦逻辑流程创建出第一个新线程,若抛出任何异常,新创建的对象调用析构函数,进而调用std::terminate()终结整个程序。创建线程调用accumulate_block()可能抛出异常,该处没有任何catch块,代码不会处理异常,会终止程序。

accumulate_block()(处理最后一个小块)的调用有可能抛出异常,线程同样会调用std::terminate()。代码最后调用std::accumulate()也不会引发问题,因为所有线程已经汇合。

4.2引入异常安全

template<typename Iterator, typename T>
struct accumulate_block
{
	T operator()(Iterator first, Iterator last)
	{
		return std::accumulate(first, last, T()); //2.(累加起始位置,累加终止位置,累加初始值)
	}
};
template<typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init)
{
	unsigned long const length = std::distance(first, last);
	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);//实际的线程数在2-max_threads之间。
	unsigned long const block_size = length / num_threads;//按照各线程需要分担的元素数量,将数据切分为多块

	//1.用future代替数据
	std::vector<std::future<T>> futures(num_threads - 1);
	std::vector<std::thread> threads(num_threads - 1);//用于装载线程,-1因为主线程也算一个线程

	Iterator block_start = first;//初始化block_start迭代器
	for (unsigned long i = 0; i < (num_threads - 1); ++i)//循环创建子线程
	{
		Iterator block_end = block_start;//初始化block_end迭代器
		std::advance(block_end, block_size);//block_end迭代器移动至小块末尾

		//3.声明任务,接收两个Iterator对象,并返回一个T对象,与accumulate_block函数参数吻合
		std::packaged_task<T(Iterator, Iterator)> task(accumulate_block<Iterator, T>());
		futures[i] = task.get_future();
		threads[i] = std::thread(//启动新线程
			std::move(task),
			block_start,
			block_end
		);
		block_start = block_end;//因为STL容器左闭右开特性,下一小块的起始位置为本小块的末端
	}
	T last_result = accumulate_block<Iterator, T>()(block_start, last);
	std::for_each(threads.begin(), threads.end(), std::mem_fn(&std::thread::join));//线程汇合

	//4.由于从future获得结果,需要手动累加结果,不使用std::accumulate
	T result = init;
	for (unsigned long i = 0; i < max_threads; ++i)
	{
		result += futures[i].get();
	}
	result += last_result;
	return result;
}

1.首先,accumulate_block的函数调用操作符直接返回结果,而不是接受某个参数的引用,我们使用std::packaged_task类和std::future类,借此函数传递结果。
2.由于取消了result参数,在调用std::accumulate()时,需显式传入一个型别为T的对象,它按默认方式构造,作为累加的原始值。
3.不再用vector容器直接存储结果,而将容器中的元素改为future,为线程分别存储一个std::future<T>对象。在创建线程前,我们先针对accumulate_block对象创建一个任务,任务使用std::packaged_task包装,可以获取与任务相对应的future,然后向线程传入任务。随着任务的运行,结果最终会安置在future内,其中出现的任何异常都会被future捕获。
4.由于使用future存储数据,故需要手动累加结果,若某任务抛出异常,则最后累加结果时,调用get()会再次抛出异常。在结果准备就绪之前,get()的调用会一直阻塞。

若抛出的异常不止一个,只有一个异常能向上传递,可以采用std::nested_exception之类的工具捕获全部异常,在主线程抛出该类型异常。

从线程创建到线程汇合,此期间若有异常抛出,可能导致线程泄漏,先捕获异常,若一个std::thread对象上的joinable()结果为true,则让该线程与主线程汇合,之后再重新抛出异常:

try
{
	for (unsigned long i = 0; i < (num_threads - 1); ++i)
	{
		Iterator block_end = block_start;
		std::advance(block_end, block_size);

		std::packaged_task<T(Iterator, Iterator)> task(accumulate_block<Iterator, T>());
		futures[i] = task.get_future();
		threads[i] = std::thread(
			std::move(task),
			block_start,
			block_end
		);
		block_start = block_end;
	}
	T last_result = accumulate_block<Iterator, T>()(block_start, last);
	std::for_each(threads.begin(), threads.end(), std::mem_fn(&std::thread::join));//线程汇合
}
catch (...)
{
	for (unsigned long i = 0; i < (num_threads - 1); ++i)
	{
		if (threads[i].joinable())
			thread[i].join();
	}
	throw;
}

 这样会有重复的线程汇合代码,对此,我们可以创建一个线程汇合类,用threads容器进行初始化,在函数退出时会自动汇合全部线程。完整的异常安全版本如下:

class join_threads
{
	std::vector<std::thread>& threads;
public:
	explicit join_threads(std::vector<std::thread>& threads) :threads(threads_)
	{}
	~join_threads()
	{
		for (unsigned long i = 0; i < threads.size(); ++i)
		{
			if (threads[i].joinable())
				threads[i].join();
		}
	}
};
template<typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init)
{
	unsigned long const length = std::distance(first, last);
	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);//实际的线程数在2-max_threads之间。
	unsigned long const block_size = length / num_threads;//按照各线程需要分担的元素数量,将数据切分为多块

	//1.用future代替数据
	std::vector<std::future<T>> futures(num_threads - 1);
	std::vector<std::thread> threads(num_threads - 1);//用于装载线程,-1因为主线程也算一个线程
	join_threads joiner(threads);
	Iterator block_start = first;//初始化block_start迭代器
	for (unsigned long i = 0; i < (num_threads - 1); ++i)//循环创建子线程
	{
		Iterator block_end = block_start;//初始化block_end迭代器
		std::advance(block_end, block_size);//block_end迭代器移动至小块末尾

		//3.声明任务,接收两个Iterator对象,并返回一个T对象,与accumulate_block函数参数吻合
		std::packaged_task<T(Iterator, Iterator)> task(accumulate_block<Iterator, T>());
		futures[i] = task.get_future();
		threads[i] = std::thread(//启动新线程
			std::move(task),
			block_start,
			block_end
		);
		block_start = block_end;//因为STL容器左闭右开特性,下一小块的起始位置为本小块的末端
	}
	T last_result = accumulate_block<Iterator, T>()(block_start, last);
	std::for_each(threads.begin(), threads.end(), std::mem_fn(&std::thread::join));//线程汇合

	//4.由于从future获得结果,需要手动累加结果,不使用std::accumulate
	T result = init;
	for (unsigned long i = 0; i < max_threads; ++i)
	{
		result += futures[i].get();
	}
	result += last_result;
	return result;
}

4.3std::async线程安全 

前面章节提到过std::async()会让线程库替我们管控线程。生成的线程一旦完成,对应的future就进入就绪状态。如果不等待future进入就绪状态就将其销毁,future对象的析构函数依然会等待线程运行结束,避免线程资源的遗失:

template<typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init)
{
	const unsigned long length = std::distance(first, last);
	const unsigned long max_chunk_size = 25;
	if (length < max_chunk_size)//若全部数据容纳在一个数据块中
	{
		return std::accumulate(first, last, init);//则直接求和
	}
	else
	{
		Iterator mid_point = first;
		std::advance(mid_point, length / 2);//定位到中间点
		std::future<T> first_half_result = std::async(parallel_accumulate<Iterator, T>, first, mid_point, init);//前半部分
		T second_half_result = parallel_accumulate(mid_point, last, T());//后半部分
		return first_half_result.get() + second_half_result;
	}
}

 前半部分由异步线程处理,后半部分直接采用递归调用处理,std::async()的调用保证了充分利用硬件线程,又能避免线程过量,代码等待异步任务完成,预防悬空线程。若异步调用抛出异常,会向上传递,异常由future捕获,由get()重新抛出。

4.4可伸缩性和Amdahl定律

可伸缩性(本文强调“伸”)的意义在于,若系统增加了处理器,就应确保能充分运用增加的硬件资源。
给定一个多线程应用程序,实际有效执行任务的线程数会发生变化,初始线程可能只有一个,后来根据任务生成线程,且每个线程经常耗费时间在彼此等待,或等待I/O操作完成。一旦线程开始等待,它所在的处理器就会空闲,除非另一线程取而代之占用了处理器。

我们可以把程序划分为单一线程运行的“串行”片段,以及多线程运行的“并行”片段。如果“串行”片段所占总体程序的比例为fs,那么N个处理器所取得的整体性能增益P(加速比)是: 

P=\frac{1}{f_{s}+\frac{1-f_{s}}{N}}

以上是Amdahl定律,描述了加速的整体限度,常常在并发代码性能的讨论中被引用。若每一项操作都能并行化,则“串行”片段的比例为0,加速比是N;若串行片段的比例为1/3,那么加再多的处理器也不会使加速比大于3。

4.5利用多线程“掩藏”等待行为

前文提到,线程不一定总是执行实际工作,有时候需要等待,如果在等待期间为系统指派一些实际工作,即可“掩藏”等待行为。如果线程数刚好等于处理器数目,一旦线程阻塞,处理器便空闲,浪费了CPU时间,如果我们能预知某一线程会花费相当的时间进行等待,就可以多运行一个或几个线程,以充分利用空闲的CPU时间。

考虑一个扫描病毒的应用程序,采用流水线模式进行任务划分:一个线程负责查找检查的目标文件,并将它们放入队列;另一线程从队列中取出文件名,加载文件,并扫描病毒。
我们知道,线程在文件系统中查找目标文件,涉及大量I/O操作,因而可以增加负责扫描的线程,设法利用空闲的CPU时间。

增加线程的数目应恰当,过量会导致系统频繁切换任务,改动前后有必要测量性能,线程最优数目高度依赖执行中任务的性质,还依赖因等待而耗费时间所占的比例。

根据应用程序的性质,即便不增加线程,也有机会改善CPU利用率。譬如:我知道某线程会因等待I/O操作而阻塞,可以采用异步I/O,将I/O操作合理地放到后台执行,同时处理其它工作;若一个线程等待某任务完成,且其它线程尚未开始执行该任务,则该线程可以完整地执行这一任务(无锁实现中的协助)。

4.6借并发特性改进响应能力

图形用户界面框架一般使用事件驱动:按键或鼠标移动构成图形用户界面上的用户行为,并产生一系列时间或消息,由应用程序负责处理,应用程序往往具备如下循环:

while (true)
{
	event_data event = get_event();
	if (event.type == quit)
	{
		break;
	}
	process(event);
}

无论应用程序的用途是什么,必须按照合理的频率调用get_event()和process(),才能确保及时处理用户的输入。 

我们可以用并发代码分离关注点,一个线程负责完整地执行冗长的任务,另一线程负责处理GUI事件:

std::thread task_thread;//任务线程
std::atomic<bool> task_cancelled(false);//任务是否取消

void gui_thread()
{
	while (true)
	{
		event_data event = get_event();
		if (event.type == quit)
		{
			break;
		}
		process(event);
	}
}
void task()//根据任务状态改变执行方式
{
	while (!task_complete && !task_cancelled)
	{
		do_something();
	}
	if (task_cancelled)
	{
		perform_cleanup();
	}
	else
	{
		post_gui_event(task_complete);
	}
}
//根据事件类型调整状态,并与任务线程沟通
void process(const event_data& event)
{
	switch (event.type)
	{
	case start_task:
		task_cancelled = fasle;
		task_thread = std::thread(task);
		break;
	case stop_task:
		task_cancelled = true;
		task_thread.join();
		break;
	case task_complete:
		task_thread.join();
		display_results();
		break;
	default:
		//...
	}
}

设定一个线程专门处理事件,GUI就能专注处理自身相关消息,而不会中断耗时任务的运行。

5.并发代码的设计实践

为具体任务设计并发算法,需要考虑前文所述的事项,深入程度则由任务的性质决定。本节我们将选取一些C++标准库的函数,实现其并行化版本,以展示特定技术的应用。

5.1std::for_each()的并行化实现

概念:在给定的区域内,针对其中的每个元素逐一调用用户提供的函数。

我们需要将数据划分成几组,分配给各线程处理。如果这是系统中唯一的并行任务,线程数目依据std::thread::hardware_concurrency()的结果而定。

为了保证线程安全,我们采用std::future和std::packaged_task配合,在线程间传递异常:

template<typename Iterator, typename Func>
void parallel_for_each(Iterator first, Iterator last, Func f)
{
	const unsigned long length = std::distance(first, last);
	if (!length)
		return;
	const unsigned long min_per_thread = 25;
	const unsigned long max_threads = (length + min_per_thread - 1) / min_per_thread;
	const unsigned long hardware_threads = std::thread::hardware_concurrency();
	const unsigned long num_threads = std::min(hardware_thread != 0 ? hardware_threads : 2, max_threads);
	const unsigned long block_size = length / num_threads;
	std::vector<std::future<void>> futures;
	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(
			[=]()
			{
				std::for_each(block_start, block_end, f);
			}
		);
		futures[i] = task.get_future();
		threads[i] = std::thread(std::move(task));
		block_start = block_end;
	}
	std::for_each(block_start, last, f);
	for (unsigned long i = 0; i < (num_threads - 1); ++i)
	{
		futures[i].get();//仅用于获取抛出的异常
	}
}

上述实现与前文的累加算法类似,其实际任务在lambda中实现,采取该方式不必向线程传递额外参数。 
也可以采用std::async()来简化代码:

template<typename Iterator, typename Func>
void parallel_for_each(Iterator first, Iterator last, Func f)
{
	const unsigned long length = std::distance(first, last);
	if (!length)
		return;
	const unsigned long min_per_thread = 25;

	if (length < (2 * min_per_thread))
	{
		std::for_each(first, last, f);
	}
	else
	{
		const Iterator mid_point = first + length / 2;
		std::future<void> first_half = std::async(&parallel_for_each<Iterator, Func>, first, mid_point, f);
		parallel_for_each(mid_point, last, f);
		first_half.get();
	}
}

与前文类似,我们在任务执行中按递归方式划分数据,因为不知道线程库将使用多少线程,每一层对半划分数据,直到余下数据不值得继续划分。

5.2std::find()的并行实现 

std::find()函数搜索区域内符合条件的元素,只要找到就不必继续查找其它元素,该特点会影响并行算法的设计。

假如我们不中断线程,串行版本的代码性能可能会超过并行版本,因为串行代码只要找到了匹配元素,就立即停止搜索并返回。

我们可以设置一个原子变量作为标志,每处理一个元素就查验一次标志,如果标志被设置为成立,表明某个线程已找到了匹配元素,函数终止处理并返回。由于加载原子变量是缓慢操作,会放缓各线程的处理速度。

返回结果和传播异常的方法:
1.运用std::future和std::packaged_task,此方法会让没抛出异常的线程继续查找,最终抛出其中一个异常。
2.采用std::promise在工作线程中设定最终结果,此方法会在第一个异常发生时中断,因为我们要把异常存储到peomise中,若promise已设定值,再次设定会抛出异常。

本例中使用与std::find()行为更贴近的std::peomise:

template<typename Iterator, typename MatchType>
Iterator parallel_find(Iterator first, Iterator last, MatchType match)
{
	struct find_element
	{
		//函数调用操作符
		void operator()(Iterator begin, Iterator end, MatchType match, std::promise<Iterator>* result, std::atomic<bool>* done_flag)
		{
			try
			{
				for (; (begin != end) && !done_flag->load(); ++begin)
				{
					if (*begin == match)//找到匹配的元素
					{
						result->set_value(begin);//在promise中设置最终结果
						done_flag->store(true);
						return;
					}
				}
			}
			catch (...)
			{
				try
				{
					result->set_exception(std::current_exception());//异常存储到promise中
					done_flag->store(true);
				}
				catch(...)//再次抛出异常则丢弃
				{ }
			}
		}
	};
	const unsigned long length = std::distance(first, last);
	if (!length)
		return last;
	const unsigned long min_per_thread = 25;
	const unsigned long max_threads = (length + min_per_thread - 1) / min_per_thread;
	const unsigned long hardware_threads = std::thread::hardware_concurrency();
	const unsigned long num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);
	const unsigned long block_size = length / num_threads;
	std::promise<Iterator> result;
	std::atomic<bool> done_flag(false);
	std::vector<std::thread> threads(num_threads - 1);
	//join_threads保证此代码块完成时,线程全部汇合,以便进行后续查验
	{
		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);
			threads[i] = std::thread(find_element(), block_start, block_end, match, &result, &done_flag);
			block_start = block_end;
		}
		find_element() (block_start, last, match, &result, &done_flag);
	}
	//先查验标志位
	if (!done_flag.load())
	{
		return last;
	}
	return result.get_future().get();
}

由结构体find_element的函数调用操作符来完成任务。

若给定的区域没有包含查找的元素,用于获取结果的future便无从装填数据,如果仅在future上等待结果则会一直阻塞。所以需要先查验标志位,若没有找到则返回last。

我同样可以使用std::async()借助C++库的自动伸缩功能来简化代码:

template<typename Iterator, typename MatchType>
Iterator parallel_find_impl(Iterator first, Iterator last, MatchType match, std::atomic<bool>& done)
{
	try
	{
		const unsigned long length = std::distance(first, last);
		const unsigned long min_per_thread = 25;
		if (length < (2 * min_per_thread))
		{
			for (; (first != last) && !done.load(); ++first)
			{
				if (*first == match)
				{
					done = true;
					return first;
				}
			}
			return last;
		}
		else
		{
			const Iterator mid_point = first + (length / 2);
			std::future<Iterator> async_res = std::async(&parallel_find_impl<Iterator, MatchType>,
				mid_point, last, match, std::ref(done));//done形参是引用
			const Iterator direct_res = parallel_find_impl(first, mid_point, match, done);
			return (direct_res == mid_point) ? async_res.get() : direct_res;//优先返回前半部分的结果
		}
	}
	catch (...)
	{
		done = true;
		throw;
	}
}
template<typename Iterator, typename MatchType>
Iterator parallel_find(Iterator first, Iterator last, MatchType match)
{
	std::atomic<bool> done(false);
	return parallel_find_impl(first, last, match, done);
}

 实现函数的代码被放在try/catch块内,假设有异常抛出,done的标志设置为成立,保证迅速终结全部线程。若省略了try/catch块,代码依然正确运行,但若出现异常,程序会继续执行查找操作,直至所有线程找到了匹配元素或线程全部结束。

注意,如果处理顺序会影响运行结果,就无法并行处理多个元素,上述的parallel_find可能返回接近查找区域尾部的元素,即便区域头部也存在匹配的元素。

5.3std::partial_sum()的并行实现

std::partial_sum()用于给定区域内计算前缀和:原序列中的各元素分别与其前面的全部元素累加,作为该下标对应的元素。

难点在于无法将一个区域分成多个数据段,再分别独立计算,因为区域内一个元素需要与其前方的每个元素相加。

算法1:先分别计算各数据段的前缀和,得出前方数据段中各项的结果,再将第一段的末项值与第二段个结果分别相加,第二段末项与第三段个结果相加,如此类推。

优点:我们把目标区域分成数段实现并行化,后方各元素与前端末相加也能并行化。如果首先更新每个数据段的末项,则另一线程便可同时更新下一数据段,余下的数据可交由一个线程更新。

缺点:当元素数目远超处理器核心数目,这种方式行之有效,每个核心都有充足的元素需要处理。但若核心数目很多,甚至大于等于元素数目,每个数据段只含有一到两个元素,则会让许多内核空等。

算法2:首先在全局范围内将相邻的元素相加(每个元素与其前一个元素),接着将相隔1个位置的元素相加,再下一轮将相隔3个位置的元素相加,此时前4项已操作完成,如此类推,直到相隔N-1个元素的轮次完成。

优点:这种方法处理步骤比算法1多,但并行化的可能性增大,每个处理器都能在每一步中更新数据。

缺点:每执行一次都以忙等的方式进行一次同步,同步开销与实际工作的比例不理想。

算法复杂度:

算法1:假设总共采用k个线程,每个线程获取一段数据,首先独立计算各段的前缀和,每个线程就需执行N/K项操作,然后再执行N/K项操作,把末尾值传递到下一段,逐项相加。算法复杂度为O(N)。

算法2:整体上执行log2N个步骤,每一步约执行N项操作。算法复杂度为O(Nlog2N)。

算法1方式会因向后传递末项而串行化,如果处理器数目与数据项相当,则算法2要求每个处理器核心执行log2N项操作。处理器较少则算法1,大规模并行则算法2。

算法1的实现:

template<typename Iterator>
void parallel_partial_sum(Iterator first, Iterator last)
{
	typedef typename Iterator::value_type value_type;
	struct process_chunk
	{
		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);//(容器要计算的起始位置,容器要计算的结束位置,结果存放的起始位置)
				if (previous_end_value)//若不是第一个数据段
				{
					value_type& addend = previous_end_value->get();//此处获取末尾值需要等待
					*last += addend;//则需要先累加
					if (end_value)
					{
						end_value->set_value(*last);//先设置数据段的末尾值
					}
					std::for_each(begin, last, [addend](value_type& item)//再对每个元素相加
						{
							item += addend;
						});
				}
				else if (end_value)//若为第一数据段,即上一段的末尾值为0
				{
					end_value->set_value(*last);//则直接设置末尾值
				}
			}
			catch (...)
			{
				if (end_value)
				{
					//将异常存入promise中,会传递到下一数据段,全部异常都会传到最后
					end_value->set_exception(std::current_exception());
				}
				else//最后一段,end_value为0
				{
					throw;//重新抛出
				}
			}
		}
	};
	const unsigned long length = std::distance(first, last);
	if (!length)
		return;
	const unsigned long min_per_thread = 25;
	const unsigend long max_threads = (length + min_per_thread - 1) / min_per_thread;
	const unsigned long hardware_threads = std::thread::hardware_concurrency();
	const unsigend long num_threads = std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);
	const unsigned long block_size = min_per_thread / num_threads;
	typedef typename Iterator::value_type value_type;

	std::vector<std::thread> threads(num_threads - 1);
	std::vector<std::promise<value_type>> end_values(num_threads - 1);//存储各数据段的末尾值
	std::vector<std::future<value_type>> previous_end_values;//用于存储上一数据段的末位置
	previous_end_values.reserve(num_threads - 1);//提前预留空间
	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);//指向数据段的最后一个元素
		第一项的前一段末尾为0(判断处)
		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;
		previous_end_values.push_back(end_values[i].get_future());//数据段末尾的值存储到记录数组
	}
	Iterator final_element = block_start;
	std::advance(final_element, std::distance(block_start, last) - 1);
	process_chunk()(block_start, final_element, (num_threads > 1) ? &previous_end_values.back() : 0, 0);
}

代码结构与前面的基本一致,主要任务由process_chunk()完成,这次迭代器指向各数据段末尾,而不是末尾的下一项,用于将末项值存储到future,future的值会存入promise,用于给下一数据段使用。

第一段的previous_end_value为0,最后一段的end_value为0,异常会存储到promise,传递到最后一个数据段重新抛出。

本例多个线程需要同步(previous_end_value.get()处),后面的数据段需等待前方数据段的末尾元素,故难以用std::async()重写。

第二种算法需要反复令元素配对相加,并逐步增加元素的配对距离。每个处理器需同步执行加法运算。我们使用线程卡std::barrier进行同步:

template<typename Iterator> 
void parallel_partial_sum(Iterator first, Iterator last)
{
	typedef typename Iterator::value_type value_type;
	struct process_element
	{
		void operator()(Iterator first, Iterator last,
			std::vector<value_type>& buffer,
			unsigned i, barrier& b)
		{
			value_type& ith_element = *(first + i);//读出第i个元素
			bool update_source = false;
			for (unsigned step = 0, stride = 1; stride <= i; ++step, stride *= 2)
			{
				const value_type& source = (step % 2) ? buffer[i] : ith_element;//step是偶数则取出元素,奇数则使用buffer元素
				value_type& dest = (step % 2) ? ith_element : buffer[i];//决定存储的位置
				const value_type& addend = (step % 2) ? buffer[i - stride] : *(first+i-stride);//加上上一个元素
				dest = source + addend;
				update_source = !(step % 2);
				b.arrive_and_wait();//线程卡处等待
			}
			if (update_source)
			{
				ith_element = buffer[i];
			}
		}
	};
	const unsigned long length = std::distance(first, last);//根据元素数量确定线程数
	if (length < 1)
		return;
	std::vector<value_type> buffer(length);
	std::barrier b(length);
	std::vector<std::thread> threads(length - 1);
	join_threads joiner(threads);
	Iterator block_start = first;
	for (unsigned long i = 0; i < (length - 1); ++i)
	{
		threads[i] = std::thread(process_element(), first, last,
			std::ref(buffer), i, std::ref(b));
	}
	process_element()(first, last, buffer, length - 1, b);
}

上述代码使用缓存数组,存储偶数下标的结果,待全部线程更新完成再替换元素,使步长指数级增长。

上述代码并非线程安全,如果线程在process_element抛出了异常,会导致整个程序终结。可以利用std::promise存储异常解决问题。

小结

不经意共享问题:不同线程没有显式地访问同一个数据,却因各自数据在同一缓存上,而要访问同一块缓存。

线程划分方法:按数据划分,按任务划分(流水线模式)。

隐藏等待行为:若已知某线程需要耗费相当时间等待,则可考虑增加线程数利用空闲的处理器。

线程划分注意事项

不同线程所需数据的相邻区域尽量大于std::hardware_destructive_interference_size字节限度,以避免不同线程所需数据在同一缓存块上,造成不经意共享。
单个线程所需数据尺寸缩小至常量std::hardware_destructive_interference_size内,减少缓存块的传递次数。

从数据结构层面提升性能,主要考虑三个关键点:资源争夺、不经意共享和数据紧凑度。改善方法:改变数据布局方式,改变数据划分方式。

Amdahl定律

P=\frac{1}{f_{s}+\frac{1-f_{s}}{N}}

其中P是整体性能增益,fs是串行代码占比,N是处理器数量。

线程安全
1.使用std::future和std::packaged_task包装数据和线程,抛出的异常由future捕获,在get()处抛出。

2.使用std::async生成新线程,由系统库灵活调配资源。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 《并发编程实战》是一本经典的并发编程书籍,其中包含了丰富的代码示例。这本书的代码示例非常有实战意义,可以帮助开发者在处理并发编程中的各种问题时提供参考。其中的代码示例主要涉及线程池、CAS、原子操作、锁、并发容器、BlockingQueue、CyclicBarrier和Semaphore等相关知识点。 本书的代码示例分布在各个章节中,开发者可以根据需要选择不同的示例进行学习和实践。例如,在线程池相关章节,作者提供了诸如ThreadPoolExecutor、ExecutorCompletionService等类的实现,并且提供了基于可扩展的ThreadPoolExecutor来实现动态调节线程池大小的代码示例。这些示例可以帮助开发者深入了解线程池的实现方式,以及如何进行线程池的调优。 在锁相关章节,作者提供了诸如ReentrantLock和读写锁ReentrantReadWriteLock等类的实现,并且提供了一些实际应用场景下的代码示例,例如票务系统和登录系统。这些示例可以帮助开发者了解锁的原理及其使用方法。 本书同时也介绍了一些常用的并发容器,例如ConcurrentHashMap、ConcurrentLinkedQueue等,在使用这些容器时需要注意线程安全的问题。作者为这些容器提供了详细的使用方法和代码示例,帮助开发者了解如何高效地使用这些容器。总之,《并发编程实战》的代码示例非常有价值,具有一定参考和借鉴意义,可以帮助开发者更好地掌握并发编程知识。 ### 回答2: 《Java并发编程实战》一书的源码是该书的大部分内容的实现代码。这些代码的使用可以帮助读者更好地理解并发编程的实现方式,同时也可以成为读者自己学习并发编程的参考资料。 该书的源码包括一些经典的并发编程实现,例如线程池、锁、原子变量、阻塞队列等。这些实现具有实用性和普遍性,可以帮助读者在自己的开发中解决并发编程问题。同时,该书的源码还包括一些基于实际场景的例子,让读者可以更好地理解并发编程在实际项目开发中的应用。 在使用该书源码时,读者需要关注一些细节问题,例如多线程环境下的原子性、可见性和有序性等。同时,读者还需要学会如何调试和排查多线程程序的问题,以保证程序的正确性和稳定性。 总之,该书的源码是学习并发编程的重要工具之一,读者需要认真学习源码并结合实际项目开发进行练习。只有这样,才能真正掌握并发编程的技巧和应用。 ### 回答3: 《Java并发编程实战》是一本著名的并发编程领域的经典著作,其中的源代码涵盖了Java并发编程的多个方面,非常有学习和参考的价值。 该书中的源代码主要包括了多线程并发、线程池、ThreadLocal、锁、信号量、条件等一系列并发编程相关的实例和案例,涵盖了从最基础的并发操作到应用场景的实践。 通过学习并实践这些源代码,我们可以更好地理解并发编程的思路和原理,掌握并发编程的技能和方法,提高代码质量和性能。同时,还可以培养我们的编码思维和能力,为我们今后的编程工作和研究打下坚实的基础。 总之,《Java并发编程实战》的源代码是具有非常实用和价值的,并发编程相关领域学习者和从业者都可以将其作为一个良好的学习和实践资源,不断探索和尝试。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值