多线程性能逊于单线程?原因及优化方案探讨
问题背景
应用的核心性能热点是Panorama函数,其代码如下:
function Panorama(var Solutions: TSolutionDynArray): Integer; var I, J: Integer; begin Result := 0; for I := Low(Solutions) to High(Solutions) do with Solutions[I] do begin Panorama := Level; for J := Low(FPertinentPanoramas[I, Level]) to High(FPertinentPanoramas[I, Level]) do if Level > Solutions[FPertinentPanoramas[I, Level, J]].Level then Panorama := Panorama + 1 else Panorama := Panorama - 1; Result := Result + Panorama; end; end;
相关类型定义:
TSolution = record Level, Panorama: Integer; end; TSolutionDynArray = array of TSolution;
其中FPertinentPanoramas是初始化后只读的整数多维数组。理论上,循环中每个I的计算逻辑完全独立,可通过拆分I的区间实现并行化,但自行实现的复用线程版本(代码如下)性能反而远低于单线程:
type TAnonymousThread = class(TThread) private FEvent: TEvent; FProc: TProc; protected procedure Execute; override; procedure SetProc(const Proc: TProc); public constructor Create(const Proc: TProc = nil); property Proc: TProc write SetProc; end; var AnonymousThreads: array of TAnonymousThread; Event: TEvent; procedure InitializeThreads(const N: Integer); procedure ExecuteAnonymousThreads; implementation var FCriticalSection: TCriticalSection; FCounter: Integer; procedure Counter; begin with FCriticalSection do begin Acquire; FCounter := FCounter - 1; if FCounter = 0 then Event.SetEvent; Release; end; end; constructor TAnonymousThread.Create(const Proc: TProc = nil); begin inherited Create; FreeOnTerminate := True; FEvent := TEvent.Create; SetProc(Proc); end; procedure TAnonymousThread.Execute; begin while not Terminated do begin FEvent.WaitFor; FEvent.ResetEvent; if Assigned(FProc) then FProc; Counter; end; end; procedure TAnonymousThread.SetProc(const Proc: TProc); begin FProc := Proc; end; procedure InitializeThreads(const N: Integer); var I: Integer; begin Event := TEvent.Create; FCriticalSection := TCriticalSection.Create; SetLength(AnonymousThreads, N); for I := Low(AnonymousThreads) to High(AnonymousThreads) do AnonymousThreads[I] := TAnonymousThread.Create; end; procedure ExecuteAnonymousThreads; var I: Integer; begin FCounter := Length(AnonymousThreads); Event.ResetEvent; for I := Low(AnonymousThreads) to High(AnonymousThreads) do AnonymousThreads[I].FEvent.SetEvent; end;
补充测试情况:
- 尝试硬编码非匿名线程,性能无明显提升;
- 在
Panorama函数内部使用TParallel.For性能极差,但在函数外层调用(每个任务处理整个SolutionsPool[I])效果良好; - OTL的
Parallel.ForEach比TParallel.For性能高约10%; - 自行实现的
TParallelFor类性能比TParallel.For高100%以上,但依赖任务拆分的均匀性(理想情况为(HighA - LowA + 1) mod N = 0),且任务计算量越大,越适合高并行度。
性能劣于单线程的原因
同步与调度开销过高
自行实现的线程模型中,每个线程依赖Event等待任务触发,且每次任务完成后都要进入临界区更新计数器,这些同步操作的开销远高于单个I循环的计算开销,直接抵消了并行带来的收益。任务粒度不合理
如果拆分的任务粒度太小(比如每个I单独作为一个任务),线程切换、任务调度的开销会远超过并行计算节省的时间,尤其是当每个I对应的J循环计算量不大时,这种情况会更加明显。线程池实现低效
该匿名线程池的设计存在冗余:线程长期处于等待Event的状态,每次任务需要手动设置Proc并触发Event,额外增加了调度复杂度;若线程数量超过CPU核心数,会导致频繁的上下文切换,进一步降低性能。缓存一致性问题
多个线程同时访问共享的Solutions数组时,若不同线程修改的元素位于同一缓存行,会触发伪共享,导致缓存频繁失效并同步,增加内存访问开销。
优化方案
调整任务粒度
避免细粒度任务,将I的循环区间按CPU核心数拆分为大块连续区间(比如4核拆为4段),每个线程处理一整段区间,减少任务调度和同步的次数。同时,优先让线程处理连续的内存区域,提升缓存命中率。优化结果汇总方式
原函数中Result是全局累加,并行时需要同步。改为让每个线程先计算局部结果,最后再将所有线程的局部结果汇总到全局,彻底避免频繁的临界区操作。使用成熟并行库
优先选择OTL的Parallel.ForEach或自行实现的TParallelFor类(已验证性能更优),这类库已经优化了线程调度、任务拆分和同步机制,比自行实现的简单线程池效率高得多。优化内存访问
- 将
TSolution结构体设置为缓存行对齐(比如使用[Align(64)]属性),避免伪共享; - 由于
FPertinentPanoramas是只读的,可在并行计算前将其预加载到CPU缓存,减少内存访问延迟。
- 将
合理设置并行度
根据任务计算量调整线程数:计算量小的任务减少并行度(比如等于CPU核心数),计算量大的任务可适当增加,但不宜超过核心数的2倍,避免过多上下文切换。避免不必要的线程复用
如果任务不是频繁执行,复用线程的收益可能不如直接使用轻量级任务(如TTask),因为复用线程的等待、触发机制存在额外开销,而轻量级任务由系统线程池调度,效率更高。
内容的提问来源于stack exchange,提问作者hundreAd

