目录
8.3 设计数据结构以提升多线程程序的性能
8.3.1 针对复杂操作的数据划分
8.3.2 其他数据结构的访问模式
8.4 设计并发代码时要额外考虑的因素
8.4.1 并行算法代码中的异常安全
8.4.2 可扩展性和Amdahl定律
8.4.3 利用多线程隐藏等待行为
8.4.4 借并发特性改进响应能力
8.5 并发代码的设计实践
参考:https://github.com/xiaoweiChen/CPP-Concurrency-In-Action-2ed-2019/blob/master/content/chapter8/8.2-chinese.md
8.1节了解了各种划分方法,8.2节中了解了影响性能的各种因素。如何在设计数据结构时,使用这些信息提高多线程代码的性能?与第6、7章中的问题不同,之前是关于如何设计安全、并发访问的数据结构。
当为多线程性能设计数据结构时,需要考虑竞争(contention),伪共享(false sharing)和邻近数据(data proximity),这三个对于性能都有着重大的影响的因素,改善数据布局,或者将数据进行修改。
线程间划分工作的方式有很多,假设矩阵的行或列数量大于处理器的数量,可以让每个线程计算出结果矩阵列上的元素,或是行上的元素,亦或计算一个子矩阵。
回顾一下8.2.3和8.2.4节,对于数组来说访问连续元素是最好的方式,因为这会减少缓存的刷新,降低伪共享的概率。如果要让每个线程处理几行,线程需要读取第一个矩阵中的每一个元素,并且读取第二个矩阵上的相关数据,不过这里只需要对列进行写入。给定的两个矩阵是以行连续的方式存储,这就意味着当访问第一个矩阵的第一行的前N个元素,而后是第二行的前N个元素,以此类推(N是列的数量)。其他线程会访问每行的的其他元素,访问相邻的列,所以从行上读取的N个元素也是连续的,这将最大程度降低伪共享的几率。当然,如果N个元素已占有相应的空间,且N个元素也就是每个缓存行上具体的存储元素数量,就会让伪共享的情况消失,因为线程将会对独立缓存行上的数据进行操作。
另一方面,当每个线程处理一组行数据,就需要读取第二个矩阵上的数据,还要读取第一个矩阵中的相关行上的值,不过只需要对行上的值进行写入即可。因为矩阵是以行连续的方式存储,那么可以以N行的方式访问所有的元素。如果再次选择相邻行,这就意味着线程现在只能写入N行,就有不能被其他线程所访问的连续内存块。让线程对每组列进行处理就是一种改进,因为伪共享只可能有在一个内存块的最后几个元素和下一个元素的开始几个上发生,不过具体的时间还要根据目标架构来决定。
第三个选择——将矩阵分成小矩阵块?这可以看作先对列进行划分,再对行进行划分。因此,划分列的时候,同样有伪共享的问题存在。如果可以选择内存块所拥有行的数量,就可以有效的避免伪共享。将大矩阵划分为小块,对于读取来说是有好处的:就不再需要读取整个源矩阵了。只需要读取目标矩形里面相关行列的值就可以了。具体的来看,考虑1,000行和1,000列的两个矩阵相乘,就会有1百万个元素。如果有100个处理器,这样就可以每次处理10行的数据,也就是10,000个元素。不过,为了计算着10,000个元素,就需要对第二个矩阵中的全部内容进行访问(1百万个元素),再加上10,000个相关行(第一个矩阵)上的元素,大概就要访问1,010,000个元素。另外,硬件能处理100x100的数据块(总共10,000个元素),这就需要对第一个矩阵中的100行进行访问(100x1,000=100,000个元素),还有第二个矩阵中的100列(另外100,000个)。这才只有200,000个元素,就需要5轮读取才能完成。如果读取的元素少一些,缓存缺失的情况就会少一些。
因此,将矩阵分成小块或正方形的块,要比使用单线程来处理少量的列好的多。当然,可以根据源矩阵的大小和处理器的数量,在运行时对块的大小进行调整。性能是很重要的指标时,就需要对目标架构上的各项指标进行测量,并且查阅相关领域的文献——如果只是做矩阵乘法,我认为这并不是最好的选择。
同样的原理可以应用于任何情况,这种情况就是有很大的数据块需要在线程间进行划分。仔细观察所有数据访问的各个方面,以及确定性能问题产生的原因。各种领域中,出现问题的情况都很相似:改变划分方式就能够提高性能,不需要对基本算法进行任何修改。
OK,我们已经了解了访问数组是如何对性能产生影响的
同样的考虑适用于数据结构的数据访问模式,如同优化对数组的访问:
尝试调整数据在线程间的分布,让同一线程中的数据紧密联系在一起。
尝试减少线程上所需的数据量。
尝试让不同线程访问不同的存储位置,以避免伪共享。
和用互斥量来保护数据类似。假设有一个类,包含一些数据项和一个用于保护数据的互斥量(多线程环境下)。如果互斥量和数据项在内存中很接近,对于一个需要获取互斥量的线程来说是很理想的情况。因为在之前为了对互斥量进行修改,已经加载了需要的数据,所以需要的数据可能早已存入处理器的缓存中。不过,还有一个缺点:当其他线程尝试锁住互斥量时(第一个线程还没有是释放),线程就能对数据项进行访问。互斥锁是作为“读-改-写”原子操作实现的,对于相同位置的操作都需要先获取互斥量,如果互斥量已锁,就会调用系统内核。这种“读-改-写”操作可能会让数据存储在缓存中,让线程获取的互斥量变得毫无作用。从目前互斥量的发展来看,这并不是个问题,因为线程不会直到互斥量解锁才接触互斥量。不过,当互斥量共享同一缓存行时,其中存储的是线程已使用的数据,这时拥有互斥量的线程将会遭受到性能打击,因为其他线程也在尝试锁住互斥量。
一种测试伪共享问题的方法:填充大量的数据块,让不同线程并发访问。
struct protected_data
{
std::mutex m;
char padding[65536]; // 如果你的编译器不支持std::hardware_destructive_interference_size,可以使用类似65536字节,这个数字肯定超过一个缓存行
my_data data_to_protect;
};
用来测试互斥量竞争或
struct my_data
{
data_item1 d1;
data_item2 d2;
char padding[65536];
};
my_data some_array[256];
用来测试数组数据中的伪共享。
如果这样能够提高性能,就能知道伪共享在这里的确存在。
当然,设计并发的时候有更多的数据访问模式需要考虑。
虽然,有很多设计并发代码的内容,但还需要考虑的更多,比如异常安全和可扩展性。随着核数的增加,性能越来越高(无论是在减少执行时间,还是增加吞吐率),这样的代码称为“可扩展”代码。理想状态下,性能随着核数的增加线性增长,也就是当系统有100个处理器时,其性能是系统只有1核时的100倍。
虽然,非扩展性代码依旧可以正常工作——单线程应用就无法扩展,例如:异常安全是一个正确性问题,如果代码不是异常安全的,最终会破坏不变量,或是造成条件竞争,亦或是操作抛出异常意外终止应用。我们就先来看一下异常安全的问题。
代码8.2 std::accumulate
的原始并行版本(源于代码2.8)
template
struct accumulate_block
{
void operator()(Iterator first,Iterator last,T& result)
{
result=std::accumulate(first,last,result); // 1
}
};
template
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 results(num_threads); // 3
std::vector 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(),
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
}
代码8.3 使用std::packaged_task
的并行std::accumulate
template
struct accumulate_block
{
T operator()(Iterator first,Iterator last) // 1
{
return std::accumulate(first,last,T()); // 2
}
};
template
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 > futures(num_threads-1); // 3
std::vector threads(num_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 task( // 4
accumulate_block());
futures[i]=task.get_future(); // 5
threads[i]=std::thread(std::move(task),block_start,block_end); // 6
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;
}
这样问题就解决了:工作线程上抛出的异常,可以在主线程上抛出。如果不止一个工作线程抛出异常,那么只有一个异常能在主线程中抛出。如果这个问题很重要,可以使用类似std::nested_exception
对所有抛出的异常进行捕捉。
剩下的问题:当第一个新线程和当所有线程都汇入主线程时抛出异常时,就会让线程产生泄露。最简单的方法就是捕获所有抛出的线程,汇入的线程依旧是joinable()的,并且会再次抛出异常:
代码8.4 异常安全版std::accumulate
template
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 > futures(num_threads-1);
std::vector 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 task(
accumulate_block());
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;
}
std::async()的异常安全
当需要管理线程时,需要代码是异常安全的。那现在来看一下使用std::async()
是怎样完成异常安全的。本例中标准库对线程进行了较好的管理,并且当future处以就绪状态时,就能生成新的线程。对于异常安全,还需要注意一件事,如果没有等待的情况下对future实例进行销毁,析构函数会等待对应线程执行完毕后才执行。这就能体现线程泄露的问题,因为线程还在执行,且持有数据引用。下面将展示使用std::async()
完成异常安全的实现。
代码8.5 异常安全并行版std::accumulate
——使用std::async()
template
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 first_half_result=
std::async(parallel_accumulate, // 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,将异常在传播时销毁。这就需要依次等待异步任务的完成,因此也能避免悬空线程的出现。另外,当异步任务抛出异常,且被future所捕获后,在对get()⑥调用的时候,future中存储的异常会再次抛出。
扩展性代表了应用利用系统中处理器执行任务的能力。一种极端的方式就是将应用写死为单线程运行,这种应用就是完全不可扩展的。即使添加了100个处理器到你的系统中,应用的性能都不会有任何改变。另一种就是像SETI@Home[3]项目一样,让应用使用系统中成千上万的处理器(以个人电脑的形式加入网络的用户)成为可能。
对于任意的多线程程序,运行时的工作线程数量会有所不同。应用初始阶段只有一个线程,之后会在这个线程上衍生出新的线程。理想状态:每个线程都做着有用的工作,不过这种情况几乎是不可能发生的。线程通常会花时间进行互相等待,或等待I/O操作的完成。
一种简化的方式就是就是将程序划分成“串行”和“并行”部分。串行部分:只能由单线程执行一些工作的地方。并行部分:可以让所有可用的处理器一起工作的部分。当在多处理系统上运行应用时,“并行”部分理论上会完成的相当快,因为其工作被划分为多份,放在不同的处理器上执行。“串行”部分则不同,只能一个处理器执行所有工作。这样的(简化)假设下,就可以随着处理数量的增加,估计一下性能的增益:当程序“串行”部分的时间用fs来表示,那么性能增益(P)就可以通过处理器数量(N)进行估计:
这就是Amdahl定律,讨论并发程序性能的时候都会引用到的公式。如果每行代码都能并行化,串行部分就为0,性能增益就为N。或者,当串行部分为1/3时,当处理器数量无限增长,都无法获得超过3的性能增益。
Amdahl定律明确了,对代码最大化并发可以保证所有处理器都能用来做有用的工作。如果将“串行”部分的减小,或者减少线程的等待,就可以在多处理器的系统中获取更多的性能收益。或者,当能提供更多的数据让系统进行处理,并且让并行部分做最重要的工作,就可以减少“串行”部分,以获取更高的性能增益。
扩展性:当有更多的处理器加入时,减少单个动作的执行时间,或在给定时间内做更多工作。有时这两个指标是等价的(如果处理器的速度相当快,就可以处理更多的数据)。选择线程间的工作划分的技术前,需要辨别哪些方面是能够扩展的。
代码8.6 将GUI线程和任务线程进行分离
std::thread task_thread;
std::atomic 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_next_operation();
}
if(task_cancelled)
{
perform_cleanup();
}
else
{
post_gui_event(task_complete);
}
}
void process(event_data const& event)
{
switch(event.type)
{
case start_task:
task_cancelled=false;
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:
//...
}
}
略
本章我们讨论了很多东西。从划分线程间的工作开始(比如,数据提前划分或让线程形成流水线),以低层视角来看多线程下的性能问题,顺带了解了伪共享和数据通讯,了解访问数据的模式对性能的影响。再后,了解了异常安全和可扩展性是如何影响并发代码设计的。最后,用一些并行算法实现结束本章。
设计这些并行算法实现时碰到的问题,在设计其他并行代码的时候也会遇到。
本章关于线程池的部分移除了,线程池——一个预先设定的线程组,会将任务指定给池中的线程。很多不错的想法可以用来设计一个不错的线程池,所以我们将在下一章中来了解一些有关线程池的问题,以及线程的高级管理方式。