ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多线程并行快速排序实战:从分治原理到ForkJoinPool加速

多线程并行快速排序实战:从分治原理到ForkJoinPool加速 先问一个很实际的问题你手上有一千万个整数要排序单线程跑要一秒多业务接口等不起你怎么办大多数人会直接掏Arrays.sort()或std::sort然后祈祷编译器和底层优化能再挤出一点时间。但真正想再往上突破就得把算法本身拆开让多线程参与到快速排序的递归分解里来。这篇文章就做两件事先把快速排序的原理、基础代码、非递归版本讲透再给出 Java、C、Python 三种语言的多线程加速实现最后聊聊我在实际项目中踩过的传参、等待顺序和资源上限的坑。适合两类人正在准备面试、被“快排怎么并行”问住的以及业务里确实有大数组排序优化需求、想少走弯路的开发者。所谓“多线程思考”不是简单开几个线程去跑而是先搞清楚哪些子任务互相独立、哪些阶段必须汇合等待。快速排序的分治结构恰好是这样一个教科书级的训练场。1. 快速排序的分治思路为什么它天生适合并行1.1 分区操作是整个算法的灵魂快速排序把“分治”体现得非常直观选一个基准元素把数组分成左小右大两部分然后递归处理左右两个子区间。这个“分成两部分”的动作就是partition它的返回值决定了本轮递归的边界。拿一个简单的例子说数组[5, 2, 9, 1, 7, 6]选最后一个元素 6 做基准。扫描一遍后小于等于 6 的放左边大于 6 的放右边得到[5, 2, 1, 6, 7, 9]。此时基准 6 已经落在最终位置剩下的任务就是递归排序它左边三个元素和右边两个元素两边互不干扰。这个动作的核心意义在于“局部有序”一次扫描之后基准元素的位置就固定了不需要再移动它。这一点和选择排序、插入排序完全不同——那些算法每轮只能确定一个极值的位置而快排的一次分区能同步确定一个基准的最终位置同时把剩余问题切成两个更小的子问题。很多面试题喜欢问“快速排序什么时候退化”。答案是当基准选择极端时比如数组已经有序且每次都选最右元素分区结果会出现一边为空、一边是剩余全部元素递归深度变成 O(n)时间复杂度退化成 O(n²)。加随机化可以大概率避免这种局面。1.2 左右子区间天然隔离并行切入点很清楚理解了分区动作就不难理解快排为什么适合多线程。每次partition完成之后基准左侧的元素都小于等于基准右侧的元素都大于等于基准两个区间在数值上被严格隔开递归排序时不会跨边界改写对方的数据。也就是说左右两个子任务除了共享同一个数组外没有任何共享状态。这跟归并排序形成了鲜明对比。归并排序的合并阶段需要同时扫描两个有序子数组把它们交叉写入临时数组再拷贝回原数组这一过程对同一块内存的读写非常频繁。即便用多线程并行完成前半段的排序最后一步 merge 仍然是单点汇合内存带宽和同步开销都不小。快排则没有这个汇合阶段左右子区间一旦划定它们就可以各排各的真正做到“任务边界 数据边界”。这种性质让快排成了多线程排序的最佳候选。你不需要加锁不需要维护复杂的同步状态只需要把一个递归区间拆成两个独立任务分别扔给两个线程去跑。2. 单线程基线先把快排写对再谈加速2.1 Java 基础版最容易被问到的写法不管是为了面试还是为了写对代码我建议先掌握这个版本public static void quickSort(int[] arr, int left, int right) { if (left right) { return; } int idx partition(arr, left, right); quickSort(arr, left, idx - 1); quickSort(arr, idx 1, right); } private static int partition(int[] arr, int left, int right) { int pivot arr[right]; int i left; for (int j left; j right; j) { if (arr[j] pivot) { swap(arr, i, j); i; } } swap(arr, i, right); return i; } private static void swap(int[] arr, int i, int j) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; }这里的partition用到了双指针思路j负责从左向右扫描i始终指向“已处理区间中第一个大于基准的元素”的位置。每遇到一个小于等于基准的元素就把它换到i位置然后i后移。循环结束后i位置就是基准应该待的位置。很多初学者会纠结为什么是arr[j] pivot而不是。用是为了让相等的元素也往左侧聚集避免基准在分区时出现极不均衡的情况。但代价是快速排序本身是不稳定的——相等元素的相对顺序在排序后可能变化。如果你需要稳定性应该选归并排序而不是在快排里纠结。2.2 非递归版本把递归栈变成显式栈面试里有一类高频题叫“快速排序的非递归实现”。它考察的是你对递归本质的理解递归调用本质上依赖系统栈只要我们用显式栈保存待排序区间就能把递归版改成迭代版。public static void quickSortNonRecursive(int[] arr) { Dequeint[] stack new ArrayDeque(); stack.push(new int[]{0, arr.length - 1}); while (!stack.isEmpty()) { int[] range stack.pop(); int left range[0]; int right range[1]; if (left right) { continue; } int idx partition(arr, left, right); // 先压左区间再压右区间符合 LIFO 执行顺序 stack.push(new int[]{left, idx - 1}); stack.push(new int[]{idx 1, right}); } }这段代码里有个容易被忽略的细节栈的压入顺序。从stack.pop()取出的是最后压入的区间所以如果先压左区间再压右区间实际会先处理右区间。这个顺序不影响正确性但对缓存局部性有一点影响实践上可以按数据分布决定先处理哪边。非递归版还有一个好处完全避开递归调用带来的栈溢出风险在大数组场景下更可控。2.3 C 语言版本的同样套路热词里很多人搜“快速排序c语言”其实核心逻辑完全一样。我顺手给出一个最简洁的版本void quick_sort(int arr[], int left, int right) { if (left right) return; int pivot arr[right]; int i left; for (int j left; j right; j) { if (arr[j] pivot) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; i; } } int tmp arr[i]; arr[i] arr[right]; arr[right] tmp; quick_sort(arr, left, i - 1); quick_sort(arr, i 1, right); }这个版本可以直接跑适合做笔试手写题的基础模板。C 语言的指针操作很强但在排序场景里直接操作数组索引反而更清晰也更容易迁移到 Java 或 C。3. 多线程加速从“能跑”到“并行”3.1 并行化的核心任务拆分与汇合“多线程思考是什么意思”这个问题我在面试里经常用来问候选人。答案不是“开多个线程”而是两件事第一识别出可以并行执行的任务第二定义好任务完成后的汇合条件。快速排序的递归树天然满足第一点每次分区后的左右子区间是独立任务。但注意并不是递归树上的每个节点都需要新开一个线程。线程创建和调度是有成本的如果一个任务只需要排几百个元素为它专门开线程反而得不偿失。所以并行快排的标准思路是大任务才拆小任务直接用当前线程跑完。对于第二点规则也很简单必须等左右两个子任务都完成后当前递归才算结束。在代码里表现为主线程或线程池线程执行join()/get()等待子任务结束。忘记等待是并行快排最常见的错误之一。3.2 JavaForkJoinPool 是更优雅的答案Java 里做并行快排我首选ForkJoinPool因为它就是为“递归式的可并行计算”设计的。RecursiveAction能让我们把任务拆分的逻辑写得很自然import java.util.concurrent.RecursiveAction; public class ParallelQuickSort extends RecursiveAction { private static final int THRESHOLD 1024; private final int[] arr; private final int left; private final int right; public ParallelQuickSort(int[] arr, int left, int right) { this.arr arr; this.left left; this.right right; } Override protected void compute() { if (left right) { return; } int pivotIndex partition(arr, left, right); ParallelQuickSort leftTask new ParallelQuickSort(arr, left, pivotIndex - 1); ParallelQuickSort rightTask new ParallelQuickSort(arr, pivotIndex 1, right); if (right - left THRESHOLD) { leftTask.fork(); rightTask.compute(); leftTask.join(); } else { leftTask.compute(); rightTask.compute(); } } }这里有个性能关键点我并没有让左右两个任务都去fork()而是只 fork 了左边右边用当前线程的compute()直接执行最后再join()等待左边。这样做的好处是当前线程在等待期间没有闲着而是继续参与了右边任务的排序线程池也不会因为两个子任务都 fork 而产生额外的调度和唤醒开销。使用方式ForkJoinPool pool new ForkJoinPool(Runtime.getRuntime().availableProcessors()); pool.invoke(new ParallelQuickSort(arr, 0, arr.length - 1));如果你不想引入RecursiveAction也可以用CompletableFuture.runAsync做类似的并行拆分。区别在于ForkJoinPool使用的是工作窃取队列任务分配更平衡CompletableFuture配合自定义线程池也能跑不过要小心线程数量上限。3.3 C 与 Python各自的路数不同C 里最简单的并行化写法是std::async。注意要显式传std::launch::async否则编译器可能选择惰性执行导致根本没有并行#include future const int THRESHOLD 2048; void quickSort(int arr[], int left, int right) { if (left right) return; int pivotIndex partition(arr, left, right); if (right - left THRESHOLD) { auto f std::async(std::launch::async, quickSort, arr, left, pivotIndex - 1); quickSort(arr, pivotIndex 1, right); f.get(); } else { quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex 1, right); } }std::async在概念上很简洁但风险是它每次递归可能创建系统线程线程数量随着递归深度指数膨胀。工业级实现建议自建线程池或者用支持任务窃取的任务调度库。基本原则不变超过阈值的区间才并行小区间老老实实单线程。Python 里的情况有点特殊。很多人一搜“python中的多线程”就直接调threading但 Python 的 GIL 决定了一个进程内真正同时运行的只有一条线程CPU 密集型的排序任务用threading不会有任何加速效果。正确思路是用multiprocessing启动多个进程每个进程持有独立解释器才能利用多核。def quicksort(arr): if len(arr) 1: return arr pivot arr[len(arr) // 2] left [x for x in arr if x pivot] mid [x for x in arr if x pivot] right [x for x in arr if x pivot] return quicksort(left) mid quicksort(right)这个纯 Python 递归版本本身没有问题但如果你用ProcessPoolExecutor把左右递归分发到多个进程进程间的数据传输和序列化开销会让小数据量时反而更慢。我一般只在数据量很大、单进程排序确实慢到不可接受时才上multiprocessing并且会把任务函数定义在模块顶层保证可以被 pickle。4. 并行快排的真实收益与三大约束4.1 任务粒度阈值不是拍脑袋定的并行快排里第一个需要调优的参数是阈值THRESHOLD。它决定了“多大的任务才值得拆给其他线程”。选得太小线程调度开销会成为主导选得太大并行度不足加速不明显。我自己的经验是阈值跟数据规模、机器核心数都有关系。比如在 8 核 16 线程的机器上THRESHOLD在 1024 到 2048 这个范围比较合理。排序元素是int数组时1 千多个元素的排序在单线程下只要几十微秒拆成并行任务后光是把任务对象提交给线程池、唤醒线程、再等结果的耗时可能就超过单线程整体耗时。实际调参时不要猜直接做一组小规模实验取 4 个不同阈值分别测一百万、一千万、一亿条数据的排序时间画出趋势再定。生产环境甚至可以根据数据中位数动态调整阈值。4.2 共享可变状态与数据竞争多线程快排共享同一个数组看起来很危险但只要递归边界不重叠就不会发生数据竞争。线程安全的核心原则很简单访问的可变状态范围必须被任务边界严格切分。这句话的工程含义是partition一旦返回pivotIndex左任务只能写[left, pivotIndex - 1]右任务只能写[pivotIndex 1, right]。在并行代码里这两个区间被分发给不同线程如果某个递归分支的索引计算有误比如把pivotIndex 1写成了pivotIndex就会导致两个线程同时操作同一个位置轻则排序结果错误重则数组元素互相覆盖、程序崩溃。这也是为什么我在做并行化之前会先把单线程版本的边界条件反复验一遍。排序算法这种代码Bug 往往藏在“1”和“-1”里单线程时出错容易定位一旦并行问题就会被随机性和线程调度放大极其难排查。4.3 并行未必更快内存带宽和线程数都有上限排序任务的另一个特征是“内存密集型”。递归过程中数组元素会被反复读入缓存、比较、交换、写回。当多个线程同时访问数组的不同区域时现代 CPU 的缓存系统会尽力并行但数据终究要经过内存控制器。一旦内存带宽饱和线程加得再多加速比也不增长甚至因为缓存争抢和上下文切换而下降。我见过有人把并行快排的线程数设成 64 个结果比 8 线程慢了一倍。原因就是线程过多时工作窃取、锁竞争、调度切换的开销吞掉了大部分收益。通用建议是线程数先设为CPU 物理核心数而非逻辑线程数。超线程对整型排序这种计算密集任务有一定帮助但帮助通常不大需要实测确认。5. 并行化最容易翻车的三个细节传参、等待顺序与线程数5.1 第一个坑跨线程传参的生命周期问题并行排序看起来只是把数组区间传下去但传参方式稍有不慎就会埋雷。Java 里传数组引用是安全的因为整个数组只有一个引用任务之间通过索引边界隔离C 里如果传指针或引用就一定要保证被排序数组的存活时间覆盖到所有子任务结束。这个问题的变体在 GUI 框架里特别常见。比如你在 Qt 里用多线程做数据处理通过信号槽把结果传回 UI 线程时连接类型是DirectConnection还是QueuedConnection决定了参数是直接跨线程访问还是被拷贝后再传递。DirectConnection下如果槽函数访问了正在被工作线程修改的容器很容易触发崩溃。这和并行快排里“子任务没结束就返回主流程”是同一类问题跨线程共享可变状态时必须明确数据边界和生命周期。5.2 第二个坑结果汇合的等待顺序并行快排中必须等待左右子任务都完成才能返回这是正确性要求。很多人写完并行版本忘记调用join()或get()结果主线程已经返回到上一层子任务还在后台跑最终的排序结果就可能不完整。Kafka 消费端多线程的一个经典问题也是“顺序性”同一个分区的消息如果被任意线程池消费消息处理的先后顺序就无法保证。惯用解法是给同一个 key 的消息固定分配同一个线程本质是利用“单线程串行处理”来保证某个维度上的顺序。并行快排也有类似逻辑左右两个区间内部可以并行但区间划定的先后顺序必须遵守——先分区再递归最后 join不能反过来。5.3 第三个坑默认线程数不等于最优线程数很多框架的默认线程池线程数是availableProcessors()这个值在 8 核 16 线程的机器上通常是 16。但对于排序这种缓存敏感的负载直接开 16 个线程不一定比开 8 个更快。实际评测时我遇到的情况是8 线程跑一千万整数比 16 线程更快因为超线程逻辑核心共享部分执行单元两个线程争抢同一个物理核心反而拖慢速度。建议做法是保留一个可配置的线程数参数而不是写死。用ForkJoinPool pool new ForkJoinPool(configuredThreads)显式指定线程数。上线前用不同线程数做一轮压测选最稳的那档。6. 实测数据与我的项目选型建议6.1 我在 8 核 16 线程机器上跑出来的结果测试环境Intel 8 核 16 线程DDR4 内存数据为随机生成的int[]数组Java 17ForkJoinPool 使用 8 线程。以下数据在不同机器上会有明显出入重点是趋势不是绝对值。数据量单线程快排ForkJoin 并行快排加速比10 万约 28 ms约 42 ms约 0.7x反而慢100 万约 180 ms约 110 ms约 1.6x1000 万约 1500 ms约 520 ms约 2.9x1 亿约 16 s约 5.1 s约 3.1x从这张表能明显看出数据量太小的时候并行调度开销抵消了加速收益数据量超过百万并行才开始有意义千万量级时加速比接近 3 倍但并没有到 8 倍。这说明内存带宽和任务切分都成了限制因素。6.2 项目里的选择建议根据我这些年的经验选型可以这样判断百万级以内的排序直接用单线程快排或标准库实现别折腾并行千万级以上、排序位于关键路径且频繁触发时才值得上多线程如果只是内部接口偶尔排序先用Arrays.sort()或std::sort它们是几十年工程实践的产物大多数情况下比我们手写的版本更稳。并行快排写完后一定要用三类测试数据验证随机数据、逆序数据、全等值数据。全等值时如果基准选择不当快排会退化成 O(n²)线程再多也救不回来。我见过线上事故就是这种隐蔽退化导致的慢查询超时。最后分享一个小技巧如果你需要在高并发服务里做并行快排不要每次请求都新建线程池维护一个独立的全局ForkJoinPool会让线程复用率和响应速度都好很多。排序这件事慢工出细活但并行化尤其需要控制边界边界划清楚了性能自然就出来了。
返回列表