优化已有Go 程序,协程助力下的性能争夺战|青训营

251 阅读7分钟

线程与GO协程

基本概念

线程(Thread)是操作系统提供的一种执行单元,用于执行程序中的代码。每个线程都有自己的程序计数器寄存器等资源,它们可以独立地执行代码片段,并且能够与其他线程并发地执行。

  • 堆栈:函数在被执行的时候产生的数据包括 函数参数、 局部变量、 返回地址等信息,这些信息是保存在栈中的,线程相当于进程中的一个执行流,为了保存执行流的信息,我们需要给线程创建独属堆栈

  • 寄存器:函数运行需要额外的寄存器来保留一些信息,所以线程的寄存器也是私有的。

  • 程序计数器:CPU 执行指令的信息保存在一个叫做程序计数器的寄存器中,通过这个寄存器我们就知道接下来要执行哪一条指令。所以线程也有自己的计数器用于告诉我们线程执行的工作顺序。

image.png

Go协程(Goroutine)是Go语言中并发编程的概念,它是一种轻量级的执行单位。与传统的线程相比,Go协程的创建成本非常低,并且可以高效地并发执行大量的协程。Go语言提供了内置的调度器(Goroutine Scheduler),它负责在有限的系统线程集上进行协程的调度和管理。

go协程由go语言运行时的调度器进行调度,操作系统内核感知不到协程的存在。在多核处理场景中,线程是并发与并行同时存在的,而go协程依托于线程,因此多核处理场景下,go协程也是并发与并行同时存在的。因为go协程从属于某一个线程,所以即便在单核处理器上某一时刻运行一个线程,在线程内go语言调度器也会切换多个协程执行,这时协程是并发的。在多核心处理器上,如果多个协程被分配给了不同的线程,而这些线程同时被不同的CPU核心所处理,这时协程就是并行处理的。

线程与GO协程的区别

1. 创建和销毁的成本

创建和销毁线程通常需要操作系统的介入,这涉及到分配和回收内存、线程上下文切换等开销。相比之下,Go协程的创建和销毁非常轻量级,几乎可以忽略不计,因为Go运行时环境(Goroutine Scheduler)负责管理和调度协程。

2. 并发数量限制

线程的数量通常受操作系统的限制,不同的操作系统可能有不同的限制。而Go协程可创建数量非常大,可以达到数十万甚至更多,并且它们可以在较小的线程集上高效地执行,因为Goroutine Scheduler会将多个协程映射到一组系统线程上进行调度。

3. 内存占用

每个线程都需要分配一定的栈空间来存储函数调用堆栈,这些堆栈有时会相当大,因此线程的内存占用会比较大。而Go协程使用可按需增长和缩小的动态栈,其内存占用较小。

4. 通信和同步

线程之间的通信和同步通常需要使用锁、条件变量等机制,这涉及到线程间的竞争和互斥操作。Go协程使用了更高级别的通信原语,如信道(Channel),可以通过发送和接收操作实现协程之间的数据交换和同步,避免了显式的锁操作,简化了并发编程的复杂性。

优化已有GO程序

示例

线程
package main  
  
import (  
"fmt"  
"sync"  
"time"  
)  
  
func process(wg *sync.WaitGroup, id int) {  
defer wg.Done()  
  
// 模拟一些计算密集型的任务  
time.Sleep(2 * time.Second)  
  
fmt.Println("Goroutine", id, "完成")  
}  
  
func main() {  
start := time.Now() // 记录开始时间  
  
var wg sync.WaitGroup  
  
// 启动 4 个线程并发执行任务  
for i := 0; i < 10; i++ {  
wg.Add(1)  
go process(&wg, i+1)  
}  
  
// 等待所有线程完成  
wg.Wait()  
  
end := time.Now() // 记录结束时间  
elapsed := end.Sub(start)  
  
fmt.Println("所有任务完成")  
fmt.Println("总耗时:", elapsed)  
}

结果如下:

image.png

协程
package main  
  
import (  
"fmt"  
"sync"  
"time"  
)  
  
func process(wg *sync.WaitGroup, ch chan int, id int) {  
defer wg.Done()  
  
// 模拟一些计算密集型的任务  
time.Sleep(2 * time.Second)  
  
ch <- id // 将任务完成的信号发送到通道  
}  
  
func main() {  
start := time.Now() // 记录开始时间  
  
var wg sync.WaitGroup  
ch := make(chan int)  
  
// 启动 4 个协程并发执行任务  
for i := 0; i < 10; i++ {  
wg.Add(1)  
go process(&wg, ch, i+1)  
}  
  
// 使用匿名函数来等待所有协程完成  
go func() {  
wg.Wait()  
close(ch) // 关闭通道  
}()  
  
// 遍历通道以接收任务完成的信号  
for id := range ch {  
fmt.Println("Goroutine", id, "完成")  
}  
  
end := time.Now() // 记录结束时间  
elapsed := end.Sub(start)  
  
fmt.Println("所有任务完成")  
fmt.Println("总耗时:", elapsed)  
}

结果如下:

image.png

注意!并非所有情况都适合并行化

计算斐波那契数列第n项的Go程序

计算斐波那契数列第n项

斐波那契数列(Fibonacci sequence),又称黄金分割数列、因数学家列昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波纳契数列以如下被以递归的方法定义:F(0)=1,F(1)=1,F(n)=F(n−1)+F(n−2)(n>=2,n∈N∗)

不使用线程与协程

package main

import (
	"fmt"
	"time"
)

func fibonacci(n int) int {
	if n <= 1 {
		return n
	}

	prev, current := 0, 1
	for i := 2; i <= n; i++ {
		prev, current = current, prev+current
	}

	return current
}

func main() {
	start := time.Now()
	n := 40
	result := fibonacci(n)
	fmt.Printf("fibonacci(%d) = %d\n", n, result)
	elapsed := time.Since(start)
	fmt.Printf("Time taken: %s\n", elapsed)
}

最后的耗时为0S

使用协程

但是如果你去当计算fibonacci(n)时,我们将其拆分成两个子任务:计算fibonacci(n-1)和fibonacci(n-2)。利用Go协程的特性,我们可以并发地执行这两个子任务,以提高程序的性能。使用通道(Channel)来进行协程间的数据交换。通过两个匿名函数分别启动两个协程,每个协程计算其中一个子任务的结果,并将结果发送到通道ch中。然后,主协程通过从通道中接收数据来获取两个子任务的结果,并返回它们的和作为最终结果。 你会发现,程序直接因为CPU占用过多直接崩溃。

即使你使用线程,耗时也会增加到2S。

总结

线程和协程是并发编程中常见的概念,它们在处理任务时具有不同的特点和用途。我们通过实例比较了线程和协程的耗时,更好地理解了它们的区别。

线程是操作系统层面的调度单位,每个线程都拥有自己的执行上下文和资源。多线程编程可以充分利用多核处理器的优势,提高程序的并发性。然而,线程的创建、销毁和切换都需要操作系统的介入,因此会带来一定的开销。在示例代码里展示了使用线程进行并发执行的情况,通过 sync.WaitGroup 等待所有线程完成,并统计总耗时。线程适合于执行计算密集型任务,但在大量线程并发的情况下,线程切换的开销可能会导致性能下降。

协程是一种轻量级的用户态线程,由编程语言或框架提供协程调度的支持。与线程不同,协程的创建、销毁和切换都由程序自身控制,无需操作系统的介入,因此具有更低的开销。示例代码展示了使用协程进行并发执行的情况,通过通道和 sync.WaitGroup 实现任务的并发执行,统计总耗时。协程适合于执行 IO 密集型任务,如网络请求、文件读写等。由于协程的切换成本较低,可以轻松实现上千甚至上万个协程并发执行,而线程则受限于系统资源。

总结而言,线程和协程都是实现并发编程的重要工具,但在选择使用时需要考虑任务的特性和需求。线程适用于计算密集型任务,能够充分利用多核处理器的优势,但在大量线程并发时会带来开销。协程适用于 IO 密集型任务,可以高效地处理大量的并发请求,但无法利用多核处理器的优势。因此,在实际应用中,可以根据任务类型、系统资源和性能需求来选择合适的并发模型。

需要注意的是,并发并不是所有程序都必须追求的目标。在某些场景下,串行执行可能更加简单和高效。并发编程往往增加了代码的复杂性,并引入了新的问题,如竞态条件和死锁。因此,在决定是否使用并发编程时,需要权衡所需的性能提升与额外的复杂性和风险。