文章目录
8.1 线程间划分工作的技术
需要决定使用多少个线程,并且这些线程应该去做什么。还需要决定是使用“全能”的线程去完成所有的任务,还是使用“专业”线程只去完成一件事情,或将两种方法混合。
8.1.1 在线程处理前对数据进行划分
- 最简单的并行算法:并行化的
std::for_each
,其会对一个数据集中每个元素执行同一个操作 - 最简单的分配方式:第一组N个元素分配一个线程,下一组N个元素再分配一个线程,以此类推,如图所示,不管数据怎么分,每个线程都会对分配给它的元素进行操作,不过并不会和其他线程进行沟通,直到处理完成
- 一项任务被分割成多个,放入一个并行任务集中,执行线程独立的执行这些任务,结果在会有主线程中合并
- 虽然这个技术十分强大,但是并不是哪都适用。有时不能像之前那样,对任务进行整齐的划分,因为只有对数据进行处理后,才能进行明确的划分
8.1.2 递归划分
-
快速排序有两个最基本的步骤:将数据划分到中枢元素之前或之后,然后对中枢元素之前和之后的两半数组再次进行快速排序
-
递归调用是完全独立的,因为其访问的是不同的数据集,并且每次迭代都能并发执行
-
具体过程如图所示:
-
使用
std::async()
可以为每一级生成小于数据块的异步任务 -
重要的是:对一个很大的数据集进行排序时,当每层递归都产生一个新线程,最后就会产生大量的线程
-
如果有太多的线程存在,那么你的应用将会运行的很慢
-
如果数据集过于庞大,会将线程耗尽
-
解决办法:你只需要将一定数量的数据打包后,交给线程即可
-
使用栈的并行快速排序算法——等待数据块排序
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()
{
boost::shared_ptr<chunk_to_sort > chunk=chunks.pop(); // 7 取出数据块
if(chunk)
{
sort_chunk(chunk); // 8 对数据块进行排序
}
}
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); //左闭右开
std::future<std::list<T> > new_lower=
new_lower_chunk.promise.get_future();
chunks.push(std::move(new_lower_chunk)); // 11 将小于部分的数据的promise移入chunks中
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);
while(new_lower.wait_for(std::chrono::seconds(0)) !=
std::future_status::ready)
// 13 如果该线程没有完成(future new_lower没有处于ready状态),则等待该线程完成;
{
try_sort_chunk(); // 14 让当前线程去处理try_sort_chunk
}
result.splice(result.begin(),new_lower.get());
return result;
}
void sort_chunk(boost::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 在循环检查中给其它线程机会
//(调用线程放弃执行,回到准备状态,重新分配cpu资源。
// 所以调用该方法后,可能执行其他线程,也可能还是执行该线程)
}
}
};
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
}
- parallel_quick_sort函数⑲代表了sorter类①的功能,其支持在栈上简单的存储无序数据块②,并且对线程进行设置③
- do_sort成员函数⑨主要做的就是对数据进行划分⑩
- 相较于对每一个数据块产生一个新的线程,这次会将这些数据块推到栈上⑪;并在有备用处理器⑫的时候,产生新线程
- 因为小于部分的数据块可能由其他线程进行处理,那么就得等待这个线程完成⑬
- 为了让所有事情顺利进行(只有一个线程和其他所有线程都忙碌时),当线程处于等待状态时⑭,就让当前线程尝试处理栈上的数据
- try_sort_chunk只是从栈上弹出一个数据块⑦,并且对其进行排序⑧,将结果存在promise中,让线程对已经存在于栈上的数据块进行提取⑮
- 当end_of_data没有被设置时⑯,新生成的线程还在尝试从栈上获取需要排序的数据块⑰
- 在循环检查中,也要给其他线程机会⑱,可以从栈上取下数据块进行更多的操作
- 这里的实现依赖于sorter类④对线程的清理
- 当所有数据都已经排序完成,do_sort将会返回(即使还有工作线程在运行),所以主线程将会从parallel_quick_sort⑳中返回,在这之后会销毁sorter对象
- 析构函数会设置end_of_data标志⑤,以及等待所有线程完成工作⑥
- 标志的设置将终止线程函数内部的循环⑯
- 该方案制约线程数量的值就是
std::thread::hardware_concurrency()
的值,这样就能避免任务过于频繁的切换 - 不过,这里还有两个问题:线程管理和线程通讯
- 这个方案使用到了一个特殊的线程池——所有线程的任务都来源于一个等待链表,然后线程会去完成任务,完成任务后会再来链表提取任务
- 这个线程池很有问题(包括对工作链表的竞争),这个问题的解决方案将在第9章提到
- 不足:
- 几种划分方法:1,处理前划分;2,递归划分(都需要事先知道数据的长度固定)
- 当数据是动态生成,或是通过外部输入,那么这里的办法就不适用了
- 在这种情况下,基于任务类型的划分方式,就要好于基于数据的划分方式
8.1.3 通过任务类型划分工作
- 另一种选择是让线程做专门的工作,也就是每个线程做不同的工作,就像水管工和电工在建造一所屋子的时候所做的不同工作那样
- 线程可能会对同一段数据进行操作,但它们对数据进行不同的操作
分离关注
- 单线程的世界中,代码会执行任务A(部分)后,再去执行任务B(部分),再检查按钮事件,再检查传入的网络包,然后在循环回去,执行任务A
- 这将会使得任务A复杂化,因为需要存储完成状态,以及定期从主循环中返回
- 当使用独立线程执行任务时,操作系统会帮你处理接口问题
- 在执行任务A时,线程可以专注于执行任务,而不用为保存状态从主循环中返回;
操作系统会自动保存状态,当需要的时候,将线程切换到任务B或任务C - 如果目标系统是带有多核或多个处理器,任务A和任务B可很可能真正的并发执行
- 如果每件事都是独立的,那么线程间就不需要交互,这样的话一切都很简单了
- 多线程下有两个危险需要分离关注:
对错误担忧的分离,主要表现为线程间共享着很多的数据,或者不同的线程要相互等待
当这种情况发生,就需要看一下为什么需要这么多交互。当所有交互都有关于同样的问题,就应该使用单线程来解决,并将引用同一原因的线程提取出来。或者,当有两个线程需要频繁的交流,且没有其他线程时,那么就可以将这两个线程合为一个线程
当多个输入数据集需要使用同样的操作序列,可以将序列中的操作分成多个阶段,来让每个线程执行
划分任务序列
当任务会应用到相同操作序列,去处理独立的数据项时,就可以使用流水线(pipeline)系统进行并发;
以为流水线中的每一阶段操作创建一个独立线程。当一个操作完成,数据元素会放在队列中,以供下一阶段的线程提取使用。这就允许第一个线程在完成对于第一个数据块的操作,并要对第二个数据块进行操作时,第二个线程可以对第一个数据块执行管线中的第二个操作。
8.2 影响并发代码性能的因素
之后你会看到,在多线程代码中有很多因素会影响性能——对线程处理的数据做一些简单的改动(其他不变),都可能对性能产生戏剧性的效果
8.2.1 有多少个处理器?
- 1)处理器个数是影响多线程应用的首要因素
- 可能在一个类似的平台上进行开发,不过你所使用的平台与目标平台的差异很大
- 一个单核16芯的处理器和四核双芯或十六核单芯的处理器相同:在任何系统上,都能运行16个并发线程
- 当线程数量少于16个时,会有处理器处于空闲状态(除非系统同时需要运行其他应用,不过我们暂时忽略这种可能性)
- 另一方面,当多于16个线程在运行的时候(都没有阻塞或等待),应用将会浪费处理器的运算时间在线程间进行切换 —— 称为 超额认购(oversubscription)
- 检测得到硬件上可运行的线程数:
std::thread::hardware_concurrency()
- 需要谨慎使用
std::thread::hardware_concurrency()
,因为代码不会考虑有其他运行在系统上的线程(除非已经将系统信息进行共享)。最坏的情况就是,多线程同时调用std::thread::hardware_concurrency()
函数来对线程数量进行扩展,这样将导致庞大的超额认购 - 2)即使你已经考虑到所有在应用中运行的线程,程序还要被同时运行的其他程序所影响 —— 解决办法:限制每个应用使用的处理芯个数
- 随着处理器数量的增加,另一个问题就会来影响性能:多个处理器尝试访问同一个数据
8.2.2 数据争用与乒乓缓存
- 当两个线程并发的在不同处理器上执行,并且对同一数据进行读取,通常不会出现问题;因为数据将会拷贝到每个线程的缓存中,并且可以让两个处理器同时进行处理
- 不过,当有线程对数据进行修改的时候,这个修改需要更新到其他核芯的缓存中去,就要耗费一定的时间
- 如果处理器经常需要互相等待,那么这种情况就是高竞争(high contention)
- 如果处理器很少需要互相等待,那么这种情况就是低竞争(low contention)
- 数据将在每个缓存中传递若干次,就叫做乒乓缓存(cache ping-pong)
- 当系统中的核心数和处理器数量增加时,很可能看到高竞争,以及一个处理器等待其他处理器的情况;这里有很多这样的情况,很多线程会同时尝试对互斥量进行获取,或者同时访问变量,等等
- 解决思想:减少两个线程对同一个内存位置的竞争
8.2.3 伪共享
- 伪共享时造成乒乓效用的另一个原因
- 处理器缓存通常不会用来处理在单个存储位置,但其会用来处理称为缓存行(cache lines)的内存块;内存块通常大小为32或64字节,实际大小需要由正在使用着的处理器模型来决定;
- 当线程访问的一组数据是在同一数据行中,对于应用的性能来说就要好于向多个缓存行进行传播
- 当在同一缓存行存储的是无关数据,且需要被不同线程访问,这就会造成性能问题
- 例子:
假设你有一个int类型的数组,并且有一组线程可以访问数组中的元素,且对数组的访问很频繁(包括更新)。通常int类型的大小要小于一个缓存行,同一个缓存行中可以存储多个数据项
~~~~~~
即使每个线程都能对数据中的成员进行访问,硬件缓存还是会产生乒乓缓存;
~~~~~~
每当线程访问0号数据项,并对其值进行更新时,缓存行的所有权就需要转移给执行该线程的处
~~~~~~
理器,这仅是为了让更新0号数据项的线程获取0号线程的所有权(意味着其它线程不能获得该所
~~~~~~
有权了);缓存行是共享的(即使没有数据存在),因此使用伪共享来称呼这种方式
~~~~~~ 解决办法就是对数据进行构造,让同一线程访问的数据项存在临近的内存中(就像是放在同一缓存 ~~~~~~ 行中),这样那些能被独立线程访问的数据将分布在相距很远的地方,并且可能是存储在不同的缓 ~~~~~~ 存行中
8.2.4 如何让数据紧凑?
如果多线程访问同一内存行是一种糟糕的情况,那么在单线程下的内存布局将会如何带来哪些影响呢?
- 伪共享发生的原因:某个线程所要访问的数据过于接近另一线程的数据,另一个是与数据布局相关的陷阱会直接影响单线程的性能
- 对于单线程而言,若该线程的数据是spread out(铺开)的,那么其处于不同的缓冲行;若数据相互靠近,则属于同一缓冲行;当数据属于不同缓冲行,该线程需要调入多个内存块,开销大;且该内存块中很可能包含该线程不关心的数据;极端情况下,该线程将导入巨大的无用数据,开销大且造成内存空间的浪费;
- 任务切换(task switching):如果系统中的线程数量要比核芯多,每个核上都要运行多个线程。这就会增加缓存的压力,为了避免伪共享,努力让不同线程访问不同缓存行;因此,当处理器切换线程的时候,就要对重新加载不同内存行来获得数据(当不同线程使用的数据跨越了多个缓存行时),而非将不同线程数据保存在同一缓冲行;
8.3 为多线程性能设计数据结构
- 当为多线程性能而设计数据结构的时候,需要考虑竞争(contention),伪共享(false sharing)和数据距离(data proximity)
8.3.1 为复杂操作划分数组元素
- 假设你有一些偏数学计算任务,比如,需要将两个很大的矩阵进行相乘。对于矩阵相乘来说,将第一个矩阵中的首行每个元素和第二个矩阵中首列每个元素相乘后,再相加,从而产生新矩阵中左上角的第一个元素。,以此类推。如图8.3所示,高亮展示的就是在新矩阵中第二行-第三列中的元素产生的过程
- 使用多线程来优化矩阵乘法:
非稀疏矩阵可以用一个大数组来代表,也就是第二行的元素紧随着第一行的,以此类推;
为了完成矩阵乘法,这里就需要三个大数组;假设矩阵的行或列数量大于处理器的数量,可以让每个线程计算出结果矩阵列上的元素,或是行上的元素,亦或计算一个子矩阵;
- 每个线程处理结果矩阵列上的元素:需要读入第一个矩阵的所有元素和第二个矩阵相应列的元素;只需要写入列的值;给定的两个矩阵是以行连续的方式存储,这就意味着当你访问第一个矩阵的第一行的前N个元素,然后是第二行的前N个元素,以此类推(N是列的数量);其他线程会访问每行的的其他元素;很明显的,应该访问相邻的列,所以从行上读取的N个元素也是连续的,这将最大程度的降低伪共享的几率;
- 每个线程处理结果矩阵行上的元素:需要读入第二个矩阵的所有元素和第一个矩阵相应行的元素;只需要写入行的值;因为矩阵是以行连续的方式存储,那么现在可以以N行的方式访问所有的元素,接着读入邻接的N行;优于第一种方法,因为伪共享只可能有在一个内存块的最后几个元素和下一个元素的开始几个上发生;
- 每个线程计算一个子矩阵:这可以看作先对列进行划分,再对行进行划分;因此,划分列的时候,同样有伪共享的问题存在;将大矩阵划分为小块,对于读取来说是有好处的:就不再需要读取整个源矩阵了;只需要读取目标矩形里面相关行列的值就可以了
8.3.2 其他数据结构中的数据访问模式
- 同样的考虑适用于想要优化数据结构的数据访问模式
- 1)尝试调整数据在线程间的分布,就能让同一线程中的数据紧密联系在一起
- 2)尝试减少线程上所需的数据量
- 3)尝试让不同线程访问不同的存储位置,以避免伪共享
8.4 设计并发代码的注意事项
- 随着系统中核数的增加,性能越来越高(无论是在减少执行时间,还是增加吞吐率),这样的代码称为“可扩展”代码
- 理想状态下,性能随着核数的增加线性增长
8.4.1 并行算法中的异常安全
- 当一个操作在串行算法中抛出一个异常,算法只需要考虑对其本身进行处理,以避免资源泄露和损坏不变量;
- 在并行算法中很多操作要运行在独立的线程上,异常就不再允许被传播,因为这将会使调用堆栈出现问题
- 如果一个函数在创建一个新线程后带着异常退出,那么这个应用将会终止
- 异常要在哪抛出:基本上就是在调用函数的地方抛出异常,或在用户定义类型上执行某个操作时可能抛出异常
例子:并行计算求和
template<typename Iterator,typename T>
struct accumulate_block
{
T operator()(Iterator first,Iterator last) // 1
{
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);
unsigned long const block_size=length/num_threads;
std::vector<std::future<T> > futures(num_threads-1);
// 3 生成一个vector 用于存储每个线程的future
std::vector<std::thread> threads(num_threads-1); // 存储线程的vector
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<T(Iterator,Iterator)> task( // 4 生成一个任务,该任务需要两个iterator,获取一个T类型数据
accumulate_block<Iterator,T>());
futures[i]=task.get_future(); // 5 将该任务的future存入
threads[i]=std::thread(std::move(task),block_start,block_end); // 6 创建线程,并move控制权
block_start=block_end;
}
T last_result=accumulate_block()(block_start,last); // 7
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join));
T result=init; // 8
for(unsigned long i=0;i<(num_threads-1);++i)
{
result+=futures[i].get(); // 9
}
result += last_result; // 10 加上该主线程的所得的计算结果
return result;
}
- 第一个修改就是调用accumulate_block的操作现在就是直接将结果返回,而非使用引用将结果存储在某个地方①。使用
std::packaged_task
和std::future
是线程安全的,所以你可以使用它们来对结果进行转移。当调用std::accumulate
②时,需要你显示传入T的默认构造函数,而非复用result的值,不过这只是一个小改动 - 下一个改动就是,不用向量来存储结果,而使用futures向量为每个新生线程存储
std::future<T>
③。在新线程生成循环中,首先要为accumulate_block创建一个任务④。std::packaged_task<T(Iterator,Iterator)>
声明,需要操作的两个Iterators和一个想要获取的T。然后,从任务中获取future⑤,再将需要处理的数据块的开始和结束信息传入⑥,让新线程去执行这个任务。当任务执行时,future将会获取对应的结果,以及任何抛出的异常。 - 使用future,就不能获得到一组结果数组,所以需要将最终数据块的结果赋给一个变量进行保存⑦,而非对一个数组进行填槽。同样,因为需要从future中获取结果,使用简单的for循环,就要比使用
std::accumulate
好的多;循环从提供的初始值开始⑧,并且将每个future上的值进行累加⑨。如果相关任务抛出一个异常,那么异常就会被future捕捉到,并且使用get()的时候获取数据时,这个异常会再次抛出。最后,在返回结果给调用者之前,将最后一个数据块上的结果添加入结果中⑩。 - 一个问题就已经解决:在工作线程上抛出的异常,可以在主线程上抛出
- 剩下的问题就是,当生成第一个新线程和当所有线程都汇入主线程时,抛出异常;这样会让线程产生泄露
- 最简单的方法就是捕获所有抛出的线程,汇入的线程依旧是joinable()的,并且会再次抛出异常
try
{
for(unsigned long i=0;i<(num_threads-1);++i)
{
// ... as before
}
T last_result=accumulate_block()(block_start,last);
std::for_each(threads.begin(),threads.end(),
std::mem_fn(&std::thread::join));
}
catch(...)
{
for(unsigned long i=0;i<(num_thread-1);++i)
{
if(threads[i].joinable())
thread[i].join();
}
throw;
}
重复代码是没有必要的,因为这就意味着更多的地方需要改变;
现在让我们来提取一个对象的析构函数,毕竟,析构函数是C++中处理资源的惯用方式;
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);
unsigned long const block_size=length/num_threads;
std::vector<std::future<T> > futures(num_threads-1);
std::vector<std::thread> threads(num_threads-1);
join_threads joiner(threads); // 1
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<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()(block_start,last);
T result=init;
for(unsigned long i=0;i<(num_threads-1);++i)
{
result+=futures[i].get(); // 2
}
result += last_result;
return result;
}
当创建了线程容器,就对新类型创建了一个实例①,当退出时可让所有线程进行汇入。然后,可以再显式的汇入循环中将线程删除,在原理上来说是安全的:因为线程,无论怎么样退出,都会汇入主线程。注意这里对futures[i].get()②的调用,将会阻塞线程,直到结果准备就绪,所以这里不需要显式的将线程进行汇入。和清单8.2中的原始代码不同:原始代码中,你需要将线程汇入,以确保results向量被正确填充。不仅需要异常安全的代码,还需要较短的函数实现,因为这里已经将汇入部分的代码放到新(可复用)类型中去了
- std::async()的异常安全
- 异常安全并行版std::accumulate——使用
std::async()
template<typename Iterator,typename T>
T parallel_accumulate(Iterator first,Iterator last,T init)
{
unsigned long const length=std::distance(first,last); // 1
unsigned long const max_chunk_size=25;
if(length<=max_chunk_size)
{
return std::accumulate(first,last,init); // 2
}
else
{
Iterator mid_point=first;
std::advance(mid_point,length/2); // 3
std::future<T> first_half_result=
std::async(parallel_accumulate<Iterator,T>, // 4
first,mid_point,init);
T second_half_result=parallel_accumulate(mid_point,last,T()); // 5
return first_half_result.get()+second_half_result; // 6
}
}
- 这个版本对数据进行递归划分,而非在预计算后对数据进行分块;因此,这个版本要比之前的版本简单很多,并且这个版本也是异常安全的。和之前一样,一开始要确定序列的长度①,如果其长度小于数据块包含数据的最大数量,那么可以直接调用std::accumulate②。如果元素的数量超出了数据块包含数据的最大数量,那么就需要找到数量中点③,将这个数据块分成两部分,然后再生成一个异步任务对另一半数据进行处理④。第二半的数据是通过直接的递归调用来处理的⑤,之后将两个块的结果加和到一起⑥。标准库能保证std::async的调用能够充分的利用硬件线程,并且不会产生线程的超额认购,一些“异步”调用是在调用get()⑥后同步执行的。
- 优雅的地方,不仅在于利用硬件并发的优势,并且还能保证异常安全。如果有异常在递归调用⑤中抛出,通过调用std::async④所产生的“期望”,将会在异常传播时被销毁。这就需要依次等待异步任务的完成,因此也能避免悬空线程的出现。另外,当异步任务抛出异常,且被future所捕获,在对get()⑥调用的时候,future中存储的异常,会再次抛出。
8.4.2 可扩展性和Amdahl定律
- 扩展性代表了应用利用系统中处理器执行任务的能力。一种极端就是将应用写死为单线程运行,这种应用就是完全不可扩展的
- 这就是Amdahl定律,在讨论并发程序性能的时候都会引用到的公式。如果每行代码都能并行化,串行部分就为0,那么性能增益就为N。或者,当串行部分为1/3时,当处理器数量无限增长,你都无法获得超过3的性能增益。
8.4.3 使用并发提高响应能力
通过使用并发分离关注,可以将一个很长的任务交给一个全新的线程,并且留下一个专用的GUI线程来处理这些事件。线程可以通过简单的机制进行通讯,而不是将事件处理代码和任务代码混在一起。下面的例子就是展示了这样的分离:
将GUI线程和任务线程进行分离
8.5 在实践中设计并发代码
8.5.1 并行实现:std::find
- 当你和妻子或者搭档,在一个纪念盒中找寻一张老照片,当找到这张照片时,就不会再看另外的照片了。不过,你得让其他人知道你已经找到照片了(比如,大喊一声“找到了!”),这样其他人就会停止搜索了
- 如果不中断其他线程,那么串行版本的性能可能会超越并行版,因为串行算法可以在找到匹配元素的时候,停止搜索并返回。
- 一种办法,中断其他线程的一个办法就是使用一个原子变量作为一个标识,在处理过每一个元素后就对这个标识进行检查。如果标识被设置,那么就有线程找到了匹配元素,所以算法就可以停止并返回了。用这种方式来中断线程,就可以将那些没有处理的数据保持原样,并且在更多的情况下,相较于串行方式,性能能提升很多。缺点就是,加载原子变量是一个很慢的操作,会阻碍每个线程的运行
- 如何返回值和传播异常呢?
1)两种方法:你可以使用一个future数组,使用std::packaged_task
来转移值和异常,在主线程上对返回值和异常进行处理;或者使用std::promise
对工作线程上的最终结果直接进行设置
2)这完全依赖于你想怎么样处理工作线程上的异常。如果想停止第一个异常(即使还没有对所有元素进行处理),就可以使用std::promise
对异常和最终值进行设置。另外,如果想要让其他工作线程继续查找,可以使用std::packaged_task
来存储所有的异常,当线程没有找到匹配元素时,异常将再次抛出。
3)选择std::promise
,因为其行为和std::find更为接近
4)需要注意一下搜索的元素是不是在提供的搜索范围内,在所有线程结束前,获取future上的结果。如果被future阻塞住,所要查找的值不在范围内,就会持续的等待下去;
代码如下:
template<typename Iterator,typename MatchType>
Iterator parallel_find(Iterator first,Iterator last,MatchType match)
{
struct find_element // 1
{
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) // 2
{
if(*begin==match)
{
result->set_value(begin); // 3
done_flag->store(true); // 4
return;
}
}
}
catch(...) // 5
{
try
{
result->set_exception(std::current_exception()); // 6
done_flag->store(true);
}
catch(...) // 7
{}
}
}
};
unsigned long const length=std::distance(first,last);
if(!length)
return last;
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::promise<Iterator> result; // 8
std::atomic<bool> done_flag(false); // 9
std::vector<std::thread> threads(num_threads-1);
{ // 10
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(), // 11
block_start,block_end,match,
&result,&done_flag);
block_start=block_end;
}
find_element()(block_start,last,match,&result,&done_flag); // 12
}
if(!done_flag.load()) //13
{
return last;
}
return result.get_future().get(); // 14
}
- 由find_element类①的函数调用操作实现,来完成查找工作的。循环通过在给定数据块中的元素,检查每一步上的标识②。如果匹配的元素被找到,就将最终的结果设置到promise③当中,并且在返回前对done_flag④进行设置。
- 如果有一个异常被抛出,那么它就会被通用处理代码⑤捕获,并且在promise⑥尝中试存储前,对done_flag进行设置。如果对应promise已经被设置,设置在promise上的值可能会抛出一个异常,所以这里⑦发生的任何异常,都可以捕获并丢弃
- 这意味着,当线程调用find_element查询一个值,或者抛出一个异常时,如果其他线程看到done_flag被设置,那么其他线程将会终止。如果多线程同时找到匹配值或抛出异常,它们将会对promise产生竞争。不过,这是良性的条件竞争;因为,成功的竞争者会作为“第一个”返回线程,因此这个结果可以接受
- 回到parallel_find函数本身,其拥有用来停止搜索的promise⑧和标识⑨;随着对范围内的元素的查找⑪,promise和标识会传递到新线程中。主线程也使用find_element来对剩下的元素进行查找⑫。像之前提到的,需要在全部线程结束前,对结果进行检查,因为结果可能是任意位置上的匹配元素。这里将“启动-汇入”代码放在一个块中⑩,所以所有线程都会在找到匹配元素时⑬进行汇入。如果找到匹配元素,就可以调用std::future(来自promise⑭)的成员函数get()来获取返回值或异常