标题:Go 语言并发实战:素数统计性能优化对比(“传统串行实现”与“多协程并发实现”)
在编程学习中,判断素数是一个经典的算法问题。当数据量较小时,传统的串行循环足以应付;但当数据量扩大到几十万甚至更多时,如何提升计算效率就成了关键。
本文将基于 Go 语言,对比“传统串行实现”与“多协程并发实现”在统计 1-200,000 范围内素数时的性能差异,并深入分析背后的原理。
一、 需求与分析思路
任务目标:
统计 1 到 200,000 之间所有的素数,并对比不同实现方式的耗时。
1. 传统方法(串行):
使用一个 for 循环,从 1 遍历到 200,000。对每一个数字调用 isPrime 函数进行判断。这种方法所有计算都在主线程中按顺序执行,无法利用多核 CPU 的优势。
2. 并发方法(并行 – 任务队列模式):
核心思想是“动态负载均衡”。我们不再给每个协程分配固定的数字区间,而是建立一个“任务通道”。
架构设计:
– 任务生产者:启动一个协程,负责将 1 到 200,000 的数字依次放入 intChan(任务通道)。
– 任务消费者(多个 Goroutine):启动 2 个(或根据 CPU 核心数调整)Goroutine。这些协程不关心自己处理哪一段数字,它们只负责从 intChan 中“抢”任务。谁空闲,谁就从通道里取下一个数字进行判断。
– 结果收集(primeChan):如果某个协程判断当前数字是素数,就将其写入 primeChan。
– 同步控制(exitChan):当所有计算协程处理完通道中的所有任务后,通知主线程统计结束。
这种模式的好处是:如果某个数字判断特别耗时,不会阻塞其他协程,其他协程会继续从通道领取新任务,实现了真正的动态负载平衡。
二、 代码实现对比
1. 传统串行实现 (goroutine-apply02/main.go)
这种实现方式非常直观:
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now().Unix()
for num := 1; num <= 200000; num++ {
flag := true //假设是素数
//判断num是不是素数
for i := 2; i < num; i++ {
if num%i == 0 { //说明该num不是素数
flag = false
break
}
}
if flag {
//将这个数就放入到primeChan
//primeChan<- num
//将结果输出
//fmt.Printf("素数=%d\n", num)
}
}
end := time.Now().Unix()
fmt.Println("普通的方法耗时=", end-start)
}
2. 多协程并发实现 (goroutine-apply/main.go)
并发实现利用了 Channel 进行任务分发:
package main
import (
"fmt"
"time"
)
// 向 intChan 放入 1-200000 个数
func putNum(intChan chan int) {
for i := 1; i <= 200000; i++ {
intChan <- i
}
//关闭intChan
close(intChan)
}
// 从 intChan取出数据,并判断是否为素数,如果是,就放入到 primeChan
func primeNum(intChan chan int, primeChan chan int, exitChan chan bool) {
// 使用 for 循环
// var num int
var flag bool //
for {
//time.Sleep(time.Millisecond * 10)
num, ok := <-intChan //intChan 取不到..
if !ok {
break
}
flag = true //假设是素数
//判断num是不是素数
for i := 2; i < num; i++ {
if num%i == 0 { //说明该num不是素数
flag = false
break
}
}
if flag {
//将这个数就放入到primeChan
primeChan <- num
}
}
fmt.Println("有一个primeNum 协程因为取不到数据,退出")
//这里我们还不能关闭 primeChan
//向 exitChan 写入true
exitChan <- true
}
func main() {
intChan := make(chan int, 1000)
primeChan := make(chan int, 20000) //放入结果
//标识退出的管道
exitChan := make(chan bool, 2) // 2个
start := time.Now().Unix()
//开启一个协程,向 intChan 放入 1-200000 个数
go putNum(intChan)
//开启4个协程,从 intChan 取出数据,并判断是否为素数,如果是,就
//放入到 primeChan
for i := 0; i < 2; i++ {
go primeNum(intChan, primeChan, exitChan)
}
//这里我们主线程,进行处理
//直接
go func() {
for i := 0; i < 2; i++ {
<-exitChan
}
end := time.Now().Unix()
fmt.Println("使用协程耗时=", end-start)
//当我们从 exitChan 取出了4个结果,就可以放心的关闭 prprimeChan
close(primeChan)
}()
//遍历我们的 primeChan ,把结果取出
for {
_, ok := <-primeChan
if !ok {
break
}
//将结果输出
//fmt.Printf("素数=%d\n", res)
}
fmt.Println("main线程退出")
}
三、 性能测试结果
在我的测试环境(2 核 CPU)下,统计 1-200,000 的素数,结果如下:如图1

– 传统串行实现:耗时约 25 秒。
– 多协程并发实现:耗时约 12 秒。
可以看到,并发实现的效率提升了 50% 左右。理论上来说,如果是 4 核 CPU,预计耗时大约为 6 秒左右。
四、 深度解析:为什么并发更快?
1. 动态任务分发:
正如你所指出的,primeNum 相关的协程是同时进行的。它们通过 for num := range intChan 共同竞争任务。这意味着,如果 CPU 有两个核心,那么同一时刻有两个协程在并行计算。当一个协程算完一个数字,它会立即去通道里拿下一个,不需要等待特定的“区间”结束。
2. CPU 核心利用率:
在串行模式下,只有一个核心在全速运转。而在并发模式下,Go 运行时调度器会将这 2 个 Goroutine 映射到可用的操作系统线程上,从而让两个核心同时参与素数判断的计算,显著减少了总等待时间。
3. 通道(Channel)的同步作用:
intChan 在这里扮演了“任务池”的角色。它解耦了任务的产生和消费。无论生产者是快是慢,消费者都能以最快的速度处理手头可用的任务。而 exitChan 则确保了主线程只有在所有子任务真正完成后才进行统计,避免了数据遗漏。
五、 总结与建议
通过这个素数统计的案例,我们可以得出以下结论:
1. 并发适合 CPU 密集型任务的拆分:对于大量的独立计算任务,利用多核优势可以显著缩短总耗时。
2. “任务队列”比“静态分片”更灵活:通过 Channel 分发任务,可以自动处理负载不均的问题,代码扩展性更好。
3. 通道是并发安全的基石:合理使用 Channel 可以避免复杂的锁操作,让并发代码更加简洁、易读。
在实际开发中,如果遇到类似的大规模数据处理场景,不妨尝试一下 Go 的并发模型,它往往能给你带来意想不到的性能惊喜。