没有不值得去解决的问题,也没有不值得去学习的技术!

Concurrent Prime Counting in Go: Comparing Serial and Goroutine Implementations

In my test environment (2-core CPU), calculating prime numbers from 1 to 200,000 yielded the following results (as shown in Figure 1): - Traditional serial implementation: Approximately 25 seconds. - Concurrent implementation using multiple coroutines: Approximately 12 seconds.

In programming, checking for prime numbers is a classic algorithmic problem. For small datasets, a traditional serial loop is sufficient; however, when the dataset scales to hundreds of thousands or more, improving computational efficiency becomes critical.

This article uses Go to compare the performance differences between a “traditional serial implementation” and a “multi-goroutine concurrent implementation” when counting prime numbers in the range of 1 to 200,000, and provides an in-depth analysis of the underlying principles.

1. Requirements and Analysis

Objective:
Count all prime numbers between 1 and 200,000, and compare the execution time of different implementation approaches.

1. Traditional approach (serial):
Use a for loop to iterate from 1 to 200,000. Call the isPrime function to check each number. In this approach, all calculations execute sequentially in the main thread, unable to leverage the advantages of a multi-core CPU.

2. Concurrent approach (parallel – task queue pattern):
The core idea is “dynamic load balancing.” Instead of assigning a fixed range of numbers to each goroutine, we set up a “task channel.”

Architecture design:
– Task producer: Start a goroutine responsible for sequentially putting numbers from 1 to 200,000 into intChan (the task channel).
– Task consumers (multiple Goroutines): Start 2 Goroutines (or adjust based on the number of CPU cores). These goroutines do not care about which segment of numbers they process; they only “grab” tasks from intChan. Whichever goroutine is idle takes the next number from the channel for evaluation.
– Result collection (primeChan): If a goroutine determines that the current number is prime, it writes it to primeChan.
– Synchronization control (exitChan): When all computing goroutines finish processing all tasks in the channel, they notify the main thread that the counting is complete.

The benefit of this pattern is that if checking a particular number is especially time-consuming, it will not block other goroutines. Other goroutines continue to take new tasks from the channel, achieving true dynamic load balancing.

2. Code Implementation Comparison

1. Traditional serial implementation (goroutine-apply02/main.go)

This implementation is very straightforward:

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. Multi-goroutine concurrent implementation (goroutine-apply/main.go)

The concurrent implementation uses Channels for task distribution:

Go
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线程退出")

}

3. Performance Test Results

In my test environment (2-core CPU), the results for counting primes from 1 to 200,000 are as follows: see Figure 1.

In my test environment (2-core CPU), the results for counting primes from 1 to 200,000 are as follows: see Figure 1 - Traditional serial implementation: takes about 25 seconds. - Multi-goroutine concurrent implementation: takes about 12 seconds.

– Traditional serial implementation: takes about 25 seconds.
– Multi-goroutine concurrent implementation: takes about 12 seconds.

As you can see, the concurrent implementation improves efficiency by about 50%. Theoretically, on a 4-core CPU, the estimated time would be around 6 seconds.

4. In-Depth Analysis: Why is Concurrency Faster?

1. Dynamic task distribution:

As you noted, the goroutines related to primeNum run concurrently. They compete for tasks collectively through for num := range intChan. This means that if the CPU has two cores, two goroutines are computing in parallel at the same time. Once a goroutine finishes checking a number, it immediately fetches the next one from the channel without waiting for a specific “range” to finish.

2. CPU core utilization:

In serial mode, only one core runs at full speed. In concurrent mode, the Go runtime scheduler maps these 2 Goroutines onto available operating system threads, allowing both cores to participate in the prime-checking computation simultaneously and significantly reducing total wait time.

3. Synchronization role of Channels:

Here, intChan acts as a “task pool.” It decouples task production from consumption. Regardless of whether the producer is fast or slow, consumers process available tasks as quickly as possible. Meanwhile, exitChan ensures that the main thread only performs the final count after all subtasks are truly complete, preventing data omission.

5. Summary and Recommendations

From this prime-counting case study, we can draw the following conclusions:

1. Concurrency is suitable for splitting CPU-intensive tasks: For a large number of independent computational tasks, leveraging multi-core advantages can significantly reduce total execution time.
2. “Task queues” are more flexible than “static sharding”: Distributing tasks via Channels can automatically handle uneven loads, resulting in better code scalability.
3. Channels are the cornerstone of concurrency safety: Proper use of Channels can avoid complex lock operations, making concurrent code more concise and readable.

In actual development, when encountering similar large-scale data processing scenarios, consider trying Go’s concurrency model—it often delivers unexpected performance gains.

系列导航

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系