排序算法这话题,代码谁都能敲出来,但排序背后的并行模型、坑点和适用边界,往往是真正拉开差距的地方。我最近在梳理Go的并发编程模型时,特意把奇偶转置排序(odd-even transposition sort)从原理到并行实现完整过了一遍,串行版、goroutine逐对并行版、分块worker池版全部写了一遍,还跑了对比测试。这篇文章就把整个过程的源码和踩坑记录整理出来,适合正在学Go并发、或想理解"排序网络"这类并行算法的人参考。
1. 奇偶转置排序是什么:冒泡排序的"并行亲戚"
1.1 从冒泡到奇偶:把相邻对拆成两批
先回顾冒泡排序为什么难并行。看一组逆序数据[5,4,3,2,1],一轮冒泡的过程是:先比较(0,1)交换得到[4,5,3,2,1],接着比较(1,2)发现5>3又交换得到[4,3,5,2,1]。问题在于第二个比较依赖第一个比较的结果——5是从位置0被交换到位置1之后,才被位置2的比较"撞见"的。所以一轮里相邻比较操作有严格的先后关系,没法直接拆给多个线程。
奇偶转置排序的做法很粗暴:不要在一轮里处理所有相邻对,而是把相邻对按照起点位置分成两类。第0轮只处理起点为偶数的对:(0,1)、(2,3)、(4,5)...;第1轮只处理起点为奇数的对:(1,2)、(3,4)、(5,6)...。这两种对在各自的那一轮里完全互不重叠,因此可以同时比较、同时交换。第2轮再回到偶数对,第3轮再回到奇数对,如此交替进行,直到整个数组有序。
这里有个容易绕的点:处理"偶数对"和"奇数对"的叫法是相对的。下面的代码统一约定第0轮从偶数对开始,你完全可以从奇数对开始,只要保证后续交替就行。结论不变,但代码里如果混着写,很容易把自己绕进去。这也是为什么后面并行版要严格遵守"先偶后奇、交替扫描"的原因。
1.2 用[5,1,4,2,8]手动推演一遍
假设要排序的数组是[5,1,4,2,8],按"先偶后奇"的方式推演:
| 轮次 | 相位 | 比较的对 | 本轮结束后的数组 |
|---|---|---|---|
| 0 | 偶数对 | (0,1),(2,3) | [1,5,2,4,8] |
| 1 | 奇数对 | (1,2),(3,4) | [1,2,5,4,8] |
| 2 | 偶数对 | (0,1),(2,3) | [1,2,4,5,8] |
| 3 | 奇数对 | (1,2),(3,4) | [1,2,4,5,8] |
推导细节:第0轮里(0,1)是5>1交换,(2,3)是4>2交换,所以数组变成[1,5,2,4,8]。第1轮里(1,2)是5>2交换,把5继续往右推,(3,4)是4>8不交换。第2轮(2,3)发现5>4,交换后数组已经有序。到第3轮没有任何可交换的位置。如果用的是带flag提前终止的版本,第3轮结束就能判定"整轮无交换"并退出;如果固定n轮,还要再空跑第4轮。
这里出现一个很容易误判的地方:只跑了3轮(0、1、2)时,数组虽然已经有序,但程序并不知道。所以"提前终止"必须依赖"一整轮无交换"这个条件,而不是"某一相位无交换"。单独看偶数对没交换就退出,很可能下一轮奇数对又发现要交换。
1.3 为什么n轮之后一定有序:排序网络的直觉
要严格证明n轮足够,直觉上可以这样理解:这个算法每一轮和冒泡一样,最多让一个元素往右移动一个位置,同时最多让一个元素往左移动一个位置。最坏情况下,一个元素要从最左边移动到最右边,要走n-1步,所以n轮一定够。这个结论对逆序数组是最紧的:例如[8,7,6,5,4,3,2,1],1要跨过7个位置才能到队首,需要跑满n轮。
更本质的视角是排序网络。奇偶转置排序对应一个宽度为n、深度为n的排序网络:每一层里,所有比较交换单元要么全部连接奇偶对,要么全部连接偶奇对,层与层之间交替。因为每一层的比较单元互不重叠,所有比较单元可以并行执行,这也是它在GPU、FPGA这类并行硬件上一直有应用的原因。我们在Go里做的并行化,本质上就是把排序网络的每一层"摊"到多个goroutine上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 串行版Go源码:两种写法与边界条件
2.1 固定n轮的经典实现
先给最基础的串行版本。它严格对应"转置"两个字的来源:第0轮扫偶数对、第1轮扫奇数对、反复交替n轮。
go复制package main
// OddEvenSortClassic 固定 n 轮迭代的经典版本
func OddEvenSortClassic(arr []int) {
n := len(arr)
if n <= 1 {
return
}
for k := 0; k < n; k++ {
if k%2 == 0 {
// 偶数对:(0,1) (2,3) ...
for i := 0; i < n-1; i += 2 {
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}
} else {
// 奇数对:(1,2) (3,4) ...
for i := 1; i < n-1; i += 2 {
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}
}
}
}
这段代码的逻辑很直白:k%2==0处理偶数对,k%2==1处理奇数对。注意两个for循环的上界都是i < n-1,保证arr[i+1]不会越界。n为1或0时直接返回。
关于为什么固定n轮就够:前面说过,最坏情况n轮就一定有序。所以这个版本不需要任何"是否发生过交换"的标记,实现最简单,也最容易改成并行版本。缺点是即使数组第2轮就已经有序,它也要傻乎乎地跑完n轮。
2.2 带flag提前终止的版本
如果数据接近有序,固定n轮会浪费大量比较。串行场景下加一个flag判断"这一整轮有没有发生过交换",没有就提前退出,几乎不增加什么成本。
go复制// OddEvenSortFlag 串行版:带提前终止
func OddEvenSortFlag(arr []int) {
n := len(arr)
if n <= 1 {
return
}
sorted := false
for !sorted {
sorted = true
// 偶数对
for i := 0; i < n-1; i += 2 {
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
sorted = false
}
}
// 奇数对
for i := 1; i < n-1; i += 2 {
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
sorted = false
}
}
}
}
第一次看这段代码的人可能会问:为什么把偶数对和奇数对放在同一个for !sorted循环里?因为"一整轮"的定义就是"先处理偶对再处理奇对";只有当这两种相位都没发生交换,整个数组才确定有序。如果只把偶数对或奇数对单独作为一轮,那很有可能偶数对没交换、奇数对还需要交换,单独看某一个相位是无法判定结束的。
这里也提醒一个细节:先偶后奇的顺序不是硬性要求,但一旦选定顺序,整个算法里要一直保持,不能一会儿先偶后奇一会儿先奇后偶。
2.3 三个容易写错的地方
第一,循环上界。很多人写冒泡写习惯了,for循环写出i < n,然后arr[i+1]在最后一个元素处越界。必须是i < n-1。这个错误在Go里会直接panic,报index out of range,定位很快,但写的时候能避免最省事。
第二,边界元素。当n为奇数时,比如n=5,偶数对只有(0,1)(2,3)两对,元素4在偶相位里没有对;奇数对有(1,2)(3,4)两对,元素0在奇相位里没有对。这是正常的,不用特殊处理,只要按i < n-1步进2就不会漏对也不会重复对。
第三,相等元素。比较符号必须用>而不是>=。奇偶转置排序只交换严格逆序的相邻对,这样排序是稳定的;一旦用>=,相等元素的相对顺序就会被打乱,而且没有任何性能收益。这个坑在排序网络类算法里很常见,因为硬件实现里为了省事经常直接给"小于等于就交换",CPU软件实现没必要学它。
3. 并行版Go源码:goroutine、WaitGroup与worker池
3.1 同一轮内的比较对为什么可以并发执行
串行版本跑通之后,才是奇偶转置排序真正有意思的地方。先看数据依赖:第0轮里要处理(0,1)和(2,3)这两个对,它们没有公共元素,所以比较(0,1)的结果不会影响比较(2,3)的输入。第1轮的(1,2)和(3,4)同样如此。也就是说,每一轮内部的比较对是完全独立的,可以把它们交给不同的goroutine并发执行。
但轮与轮之间不行。第1轮的(1,2)比较,很需要第0轮(0,1)的交换结果——如果第0轮把5从位置0换到了位置1,第1轮才能决定是否再把5从位置1换到位置2。如果不等第0轮全部结束就开跑第1轮,就会读到旧值,产生错误排序。这种"本阶段内部可并行、阶段之间必须同步"的模式,在多线程编程里非常经典,术语叫barrier(屏障)。
3.2 goroutine-per-pair版本:最直白的并发写法
直接利用"每个比较对独立"这个性质,每一轮里为每个比较对开一个goroutine,用WaitGroup等待本轮全部完成后再进入下一轮。
go复制import "sync"
// OddEvenSortParallel 并行版:同一轮内每个比较对开一个 goroutine
func OddEvenSortParallel(arr []int) {
n := len(arr)
if n <= 1 {
return
}
for k := 0; k < n; k++ {
var wg sync.WaitGroup
if k%2 == 0 {
// 偶数对
for i := 0; i < n-1; i += 2 {
wg.Add(1)
go func(i int) {
defer wg.Done()
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}(i)
}
} else {
// 奇数对
for i := 1; i < n-1; i += 2 {
wg.Add(1)
go func(i int) {
defer wg.Done()
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}(i)
}
}
// 等待本轮所有比较对执行完
wg.Wait()
}
}
有个关键点必须单独讲:go func(i int) 这里显式传参捕获当前索引,而不是写go func() { ... arr[i] ... }()。如果直接捕获外层循环变量i,闭包读到的是运行时i的当前值,很可能所有goroutine都在处理同一个索引,结果就是一堆乱套的比较对。这是Go闭包最经典的坑,面试也常考。Go 1.22之后循环变量有新的语义,但为了兼容老版本,我不建议依赖它。
另一个看起来不起眼的点是WaitGroup的Add、Done必须和goroutine配对好。这里每开一个goroutine前先Add(1),goroutine启动后defer Done(),wg.Wait()会在本轮全部goroutine跑完后才返回。如果Add放在循环外却开了多个goroutine,计数就对不上,会出现Wait提前返回,下一轮读取可能还没完成的写入结果,排序就会悄悄出错。
3.3 用go run -race验证一下数据竞争
并行代码写完,第一件事就是跑race检测器。
bash复制go run -race main.go
我实测的结果是:这版代码不会报任何data race。原因可以拆成两层看。
第一层,同一相位内的所有goroutine访问的是不同数组下标。比如偶数对里,(0,1)和(2,3)两个goroutine一个读写arr[0]、arr[1],另一个读写arr[2]、arr[3],内存地址不重叠,Go的race detector基于真实内存访问地址判断,不会把它们判定为竞争。
第二层,轮与轮之间有wg.Wait()。WaitGroup的Done和Wait之间正好构成"happens-before"关系:第0轮里所有goroutine的写入,一定在wg.Wait()返回前完成;主goroutine在Wait之后进入第1轮,再读到arr里的值时,能保证看到的是第0轮写入后的结果。
不过要泼一盆冷水:race检测器不报,只代表没有数据竞争,不代表排序一定正确。算法逻辑错误(比如闭包捕获循环变量、比如轮次没有正确同步)可能不会立刻暴露,或者延迟到很晚才出现。所以写完还是要靠正确性测试兜底,至少跑几类输入:随机数组、逆序数组、全部相同、只有一个元素不同。
3.4 进阶:分块worker池比逐对开goroutine快在哪
goroutine-per-pair版正确性没问题,但性能非常差。原因很简单:goroutine虽然轻量,但创建、调度、销毁都是有成本的,而一个完整的比较交换才几条指令。排序10000个元素要跑10000轮,每轮约5000个goroutine,总共会创建5000万个goroutine,这个开销直接把算法本身的计算量全都吃光。
工程里更常见的做法是开固定数量的worker,每个worker连续处理一段区间的比较对。下面是我调整后的分块版本,每轮先算好比较对总数,然后按区间均匀分给workers个goroutine:
go复制// OddEvenSortParallelPool 并行版:固定 worker 数量,每个 worker 处理一段连续的比较对
func OddEvenSortParallelPool(arr []int, workers int) {
n := len(arr)
if n <= 1 {
return
}
if workers < 1 {
workers = 1
}
for k := 0; k < n; k++ {
// 本轮处理的比较对总数
start := 0
if k%2 == 1 {
start = 1
}
total := 0
for i := start; i < n-1; i += 2 {
total++
}
var wg sync.WaitGroup
chunk := (total + workers - 1) / workers
for begin := 0; begin < total; begin += chunk {
end := begin + chunk
if end > total {
end = total
}
wg.Add(1)
go func(begin, end int) {
defer wg.Done()
for p := begin; p < end; p++ {
// 第 p 个比较对对应的数组起始下标
i := start + 2*p
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}
}(begin, end)
}
wg.Wait()
}
}
这里的技巧是:偶数相位时,第p个比较对对应的数组下标是2p;奇数相位时,是2p+1。所以把"比较对编号"换算成下标只需要一个公式。total其实可以直接算出来,我特意保留循环是为了让读者一眼看清"本轮到底有几个比较对",追求性能的话可以用算术表达式替代,但可读性会差不少。
这个分块版本的goroutine总数从n²/2降到了n × workers,基本上就是几万个量级,机器完全扛得住。在实际调用时传runtime.GOMAXPROCS(0),让worker数量尽量贴合CPU核数。
4. 实测结论:它到底适不适合做工程排序
4.1 我跑的几组对比测试
测试代码我用time命令加一个小的main分别调用三种排序,为了公平,每个函数跑之前都用完全相同的随机种子生成数组。数据量我选了1000、10000、50000三档,初始分布包括随机、逆序、已有序三种。
下表是趋势性结论,具体数值取决于机器和Go版本,但量级不会有太大出入:
| 排序方式 | 1000随机 | 10000随机 | 50000随机 | 10000逆序 | 10000已有序 |
|---|---|---|---|---|---|
| OddEvenSortFlag | 微秒级 | 几十毫秒级 | 秒级 | 几百毫秒级 | 微秒级 |
| OddEvenSortParallel(逐对开goroutine) | 毫秒级往上 | 秒级 | 不可接受 | 更慢 | 明显慢 |
| OddEvenSortParallelPool(分块worker) | 略慢于flag | 比flag好一些 | 有一定加速 | 接近flag | 明显慢于flag |
| sort.Slice(标准库) | 最快 | 最快 | 最快 | 最快 | 最快 |
几个典型现象:第一,逐对开goroutine在任何规模下几乎都垫底,数据量越大越惨,因为它造的goroutine数量是n²/2量级,属于性能自爆。第二,分块worker池版本在50000随机数据上能跑出一些并行优势,但绝对时间依然远远落后于sort.Slice。第三,对已有序数组,flag版最快——第一轮发现没有任何交换就退出,只比较了n-1次;而固定n轮的并行版本必须傻跑n轮,这个差距非常明显。
4.2 为什么并行版在已排序数组上反而吃亏
这里有个很不直观的点:按理说数据已经有序,排序应该瞬间结束,但固定n轮的并行版却要老老实实跑完n轮,每一轮还要做barrier同步。原因是它没有像串行flag版那样全局检查"本轮是否发生过交换",因为那需要额外引入一个原子计数器或锁,在每一轮结束后再做一次全局聚合,成本比"多跑几轮"还高。大部分并行实现为了简单,干脆放弃提前终止。
所以如果你想用并行奇偶转置跑近乎有序的数据,最实际的优化是:先用一个O(n)的检查判断数组是否已经有序,如果有序就直接返回;否则再进入并行排序循环。这属于"外套一层快速路径"的思路,实现成本很低,收益却很大。这个技巧不仅适用于奇偶转置排序,很多分治排序的递归入口也会做类似判断。
4.3 更适合奇偶转置排序的真实场景
综上,奇偶转置排序在通用CPU上打不过标准库的快速排序,这个不用怀疑。它真正的价值在三个方向。
第一是教学:它用最少的代码把"阶段内并行、阶段间同步"这个并行编程核心模型讲清楚了,比上来就写生产者消费者、任务队列直观得多。我用这版代码讲过一个并发小课,同学对WaitGroup的理解明显比直接解释sync包更牢。
第二是GPU排序:在CUDA里,每轮让一个block处理一批比较对,或者像许多开源实现那样把数组分块后先在块内做奇偶转置、再跨块处理,思路和这篇文章一模一样。你理解了Go里的barrier模型,再看GPU版本会顺畅很多。
第三是硬件排序网络:FPGA/ASIC里固定深度、固定结构的比较网络实现,奇偶转置网络是少数"深度=宽度"的规则网络,布线友好,面积可控。学数字电路的人对这个东西应该不陌生。
5. 完整源码:存成main.go直接跑
5.1 完整main.go文件
下面把串行flag版和逐对goroutine并行版放在同一个文件里,另外加一个简单的正确性检查函数和几个main用例。把这段代码保存成main.go,直接go run main.go就能看到输出。
go复制package main
import (
"fmt"
"math/rand"
"sync"
)
// OddEvenSortFlag 串行版:带提前终止的奇偶转置排序
func OddEvenSortFlag(arr []int) {
n := len(arr)
if n <= 1 {
return
}
sorted := false
for !sorted {
sorted = true
for i := 0; i < n-1; i += 2 {
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
sorted = false
}
}
for i := 1; i < n-1; i += 2 {
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
sorted = false
}
}
}
}
// OddEvenSortParallel 并行版:同一轮内每个比较对开一个 goroutine
func OddEvenSortParallel(arr []int) {
n := len(arr)
if n <= 1 {
return
}
for k := 0; k < n; k++ {
var wg sync.WaitGroup
if k%2 == 0 {
for i := 0; i < n-1; i += 2 {
wg.Add(1)
go func(i int) {
defer wg.Done()
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}(i)
}
} else {
for i := 1; i < n-1; i += 2 {
wg.Add(1)
go func(i int) {
defer wg.Done()
if arr[i] > arr[i+1] {
arr[i], arr[i+1] = arr[i+1], arr[i]
}
}(i)
}
}
wg.Wait()
}
}
// isSorted 检查数组是否从小到大排好序
func isSorted(arr []int) bool {
for i := 0; i < len(arr)-1; i++ {
if arr[i] > arr[i+1] {
return false
}
}
return true
}
func main() {
testCases := [][]int{
{5, 1, 4, 2, 8},
{9, 8, 7, 6, 5, 4, 3, 2, 1},
{1, 2, 3, 4, 5},
{3, 3, 3, 3},
{2},
{},
}
for idx, tc := range testCases {
arr1 := make([]int, len(tc))
copy(arr1, tc)
OddEvenSortFlag(arr1)
fmt.Printf("串行 flag 版 case %d: %v, sorted=%v\n", idx, arr1, isSorted(arr1))
arr2 := make([]int, len(tc))
copy(arr2, tc)
OddEvenSortParallel(arr2)
fmt.Printf("并行版 case %d: %v, sorted=%v\n", idx, arr2, isSorted(arr2))
}
// 一个稍大的随机数据冒烟测试
big := make([]int, 100)
for i := range big {
big[i] = rand.Intn(1000)
}
OddEvenSortFlag(big)
fmt.Println("big random sorted:", isSorted(big))
}
5.2 运行方式与实测注意事项
用go run main.go会依次打印每个case的排序结果和isSorted检查结果。想验证并行版是否触发数据竞争,把命令换成go run -race main.go,正常情况下不会有任何race warning。
如果你要看分块worker池版的性能,把3.4节的OddEvenSortParallelPool函数复制进main.go,然后在main里像这样调用:
go复制arr := make([]int, 10000)
// 填充随机数据...
OddEvenSortParallelPool(arr, runtime.GOMAXPROCS(0))
fmt.Println(isSorted(arr))
建议在跑性能对比前先关掉其他占用CPU的程序,不然结果噪音很大。我在第一次对比时开着浏览器还挂着IDE,数据看起来很诡异,关掉之后才稳定下来。另外,比较不同的排序函数时,最好每个函数都单独跑一个进程或用testing.B写benchmark,避免GC和调度互相干扰。奇偶转置排序的代码本身很短,错就错在并发同步和循环边界上,多花十分钟写几个边界case,比事后在数据里找bug值多了。
