(十一)Go 并发实战:自增整数生成器+并发消息发送器+定时器等

1,595 阅读12分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

在前面的两节 (九)深入解析 Go 语言 GMP 模型:并发编程的核心机制(十)Go 并发编程详解:锁、WaitGroup、Channel 我们讲完了 Go 并发的理论知识。

接下来我们需要在实战中使用,并体会这些并发原语的精髓,把握其中的细节,从而更加高效而合理的在我们的实际生产中使用他们,达到提升性能,保证并发安全的目的。

实战1 自增整数生成器

场景描述

自增整数生成器是一个常见的并发编程问题。在使用一些具体的数据时,我们可能需要一个能够生成唯一整数 ID 的生成器,并确保它在多线程环境下的线程安全性。也就是说在不同的线程中 ID 数不会重复。

假设我们需要在一个分布式系统中生成全局唯一的任务 ID:

  1. 唯一标识符生成器:在分布式系统中,每个节点需要生成唯一的 ID,可以使用类似的自增整数生成器来保证唯一性。
package main

import (
	"fmt"
	"sync"
)

// Generator 结构体,包含一个互斥锁和一个任务ID计数器
type Generator struct {
	mutex  sync.Mutex
	taskID int
}

// NewGenerator 创建一个新的 Generator
func NewGenerator() *Generator {
	return &Generator{taskID: 0}
}

// Generate 以线程安全的方式生成一个新的唯一任务ID
func (g *Generator) Generate() int {
	g.mutex.Lock()
	defer g.mutex.Unlock()
	g.taskID++
	return g.taskID
}

// worker 函数,模拟一个工作线程,接收一个生成器和一个 WaitGroup
func worker(id int, gen *Generator, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		taskID := gen.Generate()
		fmt.Printf("Worker %d 生成了任务ID: %d\n", id, taskID)
	}
}

func main() {
	gen := NewGenerator()
	var wg sync.WaitGroup

	// 启动3个工作线程,每个线程调用 worker 函数生成任务ID
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go worker(i, gen, &wg)
	}

	// 使用 WaitGroup 确保所有goroutine完成后主程序才退出
	wg.Wait()
}

代码分析

  • Generator结构体: 
    • 包含一个 sync.Mutex 和一个 taskID 计数器。
  • NewGenerator函数: 
    • 创建一个新的 Generator 实例。
  • Generate方法: 
    • Generate 方法生成一个新的唯一任务ID,并通过加锁确保线程安全。
    • 使用 mutex.Lock() 加锁,然后将 taskID 自增,并返回自增后的值。
    • 使用 defer mutex.Unlock() 在函数退出时解锁,保证锁的释放。
  • worker函数: 
    • worker 函数模拟一个工作线程,接收一个生成器和一个 sync.WaitGroup
    • 在循环中调用 Generate 方法生成任务ID,并打印到标准输出。
  • main函数: 
    • 创建一个新的 Generator 实例。
    • 启动3个工作线程,每个线程调用 worker 函数生成任务ID。
    • 使用 sync.WaitGroup 确保所有goroutine完成后主程序才退出。

使用流程

  1. 初始化生成器: 在 main 函数中调用 NewGenerator() 创建一个新的 Generator 实例。
  2. 启动工作线程: 使用 sync.WaitGroup 来管理并发任务,通过循环启动多个 worker goroutine。
  3. 生成唯一任务ID: 每个 worker 调用 Generate 方法生成唯一任务ID,并打印出来。
  4. 等待所有任务完成: 使用 wg.Wait() 确保所有 worker goroutine 完成后主程序才退出。

代码优点

  • 线程安全: 使用 sync.Mutex 确保 taskID 的自增操作是线程安全的,避免了多个 goroutine 同时访问和修改 taskID 时可能出现的数据竞争问题。
  • 并发控制: 使用 sync.WaitGroup 来管理并发任务,确保主程序在所有任务完成后再退出,避免了主程序提前退出导致部分任务未执行完的问题。
  • 代码简洁: 通过封装 Generator 结构体和相关方法,使得代码结构清晰,易于维护和扩展。

场景总结

  1. 唯一标识符生成器:在分布式系统中,每个节点需要生成唯一的 ID,可以使用类似的自增整数生成器来保证唯一性。
  2. 任务分发器:在任务调度系统中,需要为每个任务分配一个唯一的序号或者 ID,可以使用此类生成器来实现。
  3. 数据流处理:在数据流处理中,有时候需要为数据流中的每个数据项分配唯一的标识符或者顺序号,此生成器也可以用来完成这个任务。

通过使用 sync.Mutex 进行加锁操作,我们确保了在并发环境下每次生成的任务 ID 都是唯一且递增的,解决了数据竞争的问题。

实战 2 并发消息发送器

场景描述

在现代的Web应用程序中,用户注册是一个常见的功能。每当新用户注册时,系统通常会发送一封欢迎邮件以增强用户体验或参与度。然而,发送邮件是一个相对耗时的操作,尤其是在高并发的环境下,如果处理不当,可能会导致主流程阻塞,影响系统的响应速度和用户体验。并发消息发送器是一种用于在多线程环境下发送通知的机制。通过使用 channel 和 goroutine来实现一个高效的并发消息发送器,可以实现并发消息的发送,确保主流程不会因为邮件发送而阻塞。

假设我们需要实现一个并发的电子邮件发送系统,在用户注册时发送欢迎邮件:

package main

import (
	"fmt"
	"time"
)

// SendEmail 异步向指定用户发送欢迎电子邮件。
func SendEmail(user string) <-chan string {
	emailChannel := make(chan string, 1)

	go func() {
		defer close(emailChannel)
		time.Sleep(2 * time.Second) // 模拟电子邮件发送延迟
		emailChannel <- fmt.Sprintf("Welcome email sent to %s", user)
	}()

	return emailChannel
}

func main() {
	alice := SendEmail("alice")
	bob := SendEmail("bob")

	fmt.Println(<-alice)
	fmt.Println(<-bob)
}

代码分析

  1. SendEmail 函数

    • SendEmail 函数返回一个只接收数据的 channel(<-chan string)。
    • 创建了一个带缓冲的 channel emailChannel,缓冲区大小为 1。
    • 在一个新的 goroutine 中,通过 time. Sleep 模拟发送电子邮件的延迟,生成欢迎邮件消息并发送到 channel emailChannel,然后关闭 channel。
  2. Main 函数

    • 在 main 函数中,调用 SendEmail 函数分别为用户 "alice" 和 "bob" 生成消息发送器。
    • 通过 <-alice 和 <-bob 从各自的 channel 接收并打印邮件发送结果。

图示解释

  1. Main:主函数,调用 SendEmail 函数。
  2. SendEmail (alice) / SendEmail (bob):为用户 alice 和 bob 创建消息发送器。
  3. Goroutine (alice) / Goroutine (bob):在新 goroutine 中发送消息。
  4. EmailChannel (alice) / emailChannel (bob):通过 channel 接收消息并返回给主函数。

这个图示展示了并发消息发送器的工作流程,通过 goroutine 和 channel 实现异步消息发送,使得主程序可以并发处理多个消息发送任务。

使用流程

  1. 创建消息发送器: 在 main 函数中调用 SendEmail 函数,为每个用户(如 "alice" 和 "bob")创建一个消息发送器。
  2. 启动 goroutine: SendEmail 函数内部启动一个新的 goroutine,模拟发送电子邮件的延迟,并将结果发送到 emailChannel
  3. 接收消息: 在 main 函数中,通过 <-alice 和 <-bob 从各自的 channel 接收并打印邮件发送结果。

代码优点

  • 异步处理: 通过使用 goroutine 和 channel,实现了异步消息发送,使得主程序可以并发处理多个消息发送任务,提高了系统的响应速度和吞吐量。
  • 资源管理: 使用带缓冲的 channel(缓冲区大小为 1),可以避免 goroutine 阻塞,同时通过 defer close(emailChannel) 确保 channel 在使用完毕后被关闭,避免了资源泄漏。提高了系统的响应性和用户体验。

场景总结

这种并发的消息发送器常用于以下场景:

  1. 通知系统:在一个用户注册系统中,可以在用户注册成功后立即发送欢迎通知,确保通知发送过程不会阻塞主流程。
  2. 消息分发系统:在一个消息队列系统中,可以并发地将消息发送给多个订阅者,提高消息发送效率。
  3. 异步任务处理:在一个异步任务处理系统中,可以并发地处理多个任务,并将结果通过 channel 返回给调用方。

这个定时器例子展示了如何使用 Go 语言的 goroutine 和 channel 实现一个简单的定时器。这个定时器在指定的时间间隔后通过 channel 发送一个信号,可以用于在并发环境中处理超时操作。

实战 3 定时器

场景描述

在现代的网络应用中,处理网络请求时经常需要考虑超时问题。如果一个请求在规定时间内没有完成,系统应该能够及时响应并处理超时情况,以避免资源浪费和系统性能下降。通过使用Go语言的并发特性,我们可以实现一个简单的定时器来处理网络请求的超时问题。定时器是一个常见的并发编程模式,用于在一定时间后触发操作。通过使用 channel 和 goroutine,可以实现一个简单的定时器。

假设我们需要实现一个网络请求超时处理系统:

package main

import (
	"fmt"
	"time"
)

// Timer 计时器创建一个在指定持续时间后发送信号的计时器。
func Timer(duration time.Duration) chan bool {
	ch := make(chan bool, 1)

	go func() {
		time.Sleep(duration)
		ch <- true
	}()

	return ch
}

// RequestHandler 处理具有指定超时的网络请求。
func RequestHandler(timeout time.Duration) chan string {
	result := make(chan string, 1)
	// 模拟网络请求延迟 
	go func() {
		// 假设这里是一个耗时的请求
		time.Sleep(3 * time.Second)

		// 判断 result 是否已经关闭
		select {
		case <-result:
			fmt.Println("Result channel is closed")
		default:
			result <- "Request successful"
		}
	}()

	timer := Timer(timeout)
	// 处理超时情况
	go func() {
		select {
		case res := <-result:
			fmt.Println(res)
		case <-timer:
			fmt.Println("Request timed out")
		}
		close(result)
	}()

	return result
}

func main() {
	RequestHandler(1 * time.Second)
	time.Sleep(6 * time.Second) // 等待程序结束,查看打印结果
}

代码分析

  1. Timer 函数

    • Timer 函数返回一个类型为 chan bool 的 channel。
    • 在一个新的 goroutine 中,使用 time.Sleep (duration) 延迟指定的时间,然后向 channel ch 发送 true 信号
  2. RequestHandler 函数

    • RequestHandler 函数返回一个类型为 chan string 的 channel。
    • 在一个新的 goroutine 中模拟网络请求延迟,通过 time.Sleep (3 * time. Second) 延迟 3 秒后向 channel result 发送请求成功的消息。
    • 创建一个定时器,超时时间为 timeout
    • 在另一个新的 goroutine 中使用 select 语句等待 resulttimer 的信号,如果 result 有消息,则打印结果;如果 timer 触发,则打印超时信息。这里的 select 是一个多通道监听器
  3. main 函数

    • 调用 RequestHandler 函数,并设置超时时间为 5 秒,查看输出。
      • 当我们将超时时间设置为 1 s 时,我们再来看一下输出
    • 使用 time. Sleep (6 * time. Second) 延迟主程序退出,确保可以看到输出。

图示解释

  1. Main:主函数,调用 RequestHandler 函数。
  2. RequestHandler:处理网络请求和定时器。
  3. NetworkRequest:模拟网络请求延迟,向 ResultChannel 发送请求结果。
  4. Timer:定时器,向 TimeoutChannel 发送超时信号。
  5. ResultChannel:用于接收网络请求结果的 channel。
  6. TimeoutChannel:用于接收超时信号的 channel。

这个图示展示了并发请求处理系统的工作流程,通过 goroutine 和 channel 实现异步请求处理和超时控制,使得主程序可以并发处理多个请求并处理超时情况。

使用流程

  1. 创建定时器: 在 RequestHandler 函数中调用 Timer 函数创建一个定时器。
  2. 模拟网络请求: 启动一个新的 goroutine 模拟网络请求延迟,并将结果发送到 result channel。
  3. 处理超时: 在另一个新的 goroutine 中使用 select 语句等待 result 或 timer 的信号,处理超时情况。
  4. 主程序等待: 在 main 函数中使用 time.Sleep 等待程序结束,确保可以看到输出。

代码优点

  • 并发处理: 使用 goroutine 和 channel 可以充分利用多核 CPU 的并行处理能力,提高系统的并发处理能力。
  • 非阻塞操作: 通过异步处理网络请求和定时器,避免了主程序在等待请求完成时的阻塞,提高了系统的响应性和用户体验。
  • 错误隔离: 每个 goroutine 独立运行,即使某个 goroutine 出现错误也不会影响其他 goroutine 的执行,提高了系统的健壮性。

场景总结

这种定时器常用于以下场景:

  1. 任务超时处理:在网络请求或者任务处理中,如果某个任务在规定时间内没有完成,则触发超时操作。
  2. 定时任务:在某些情况下,需要定时执行某个任务或操作。
  3. 延迟执行:在某些情况下,需要延迟执行某个操作。

总结

通过以上三个实战例子,我们深入探讨了如何在 Go 语言中实现并发编程,并展示了 goroutine 和 channel 在并发控制中的强大能力。这些例子不仅涵盖了自增整数生成器、并发消息发送器和定时器,还体现了 Go 语言在处理并发任务时的简洁性和高效性。

  1. 自增整数生成器:通过使用 sync.Mutex 确保了在多线程环境下生成唯一且递增的任务 ID,解决了数据竞争问题。
  2. 并发消息发送器:利用 goroutine 和 channel 实现了异步消息发送,提高了系统的响应速度和吞吐量,同时保证了资源管理的有效性。
  3. 定时器:展示了如何使用 goroutine 和 channel 实现一个简单的定时器,用于处理网络请求的超时情况,提高了系统的并发处理能力和响应性。

希望本篇文章能够帮助你更好地理解和掌握 Go 语言的并发编程,并在实际项目中高效而合理地应用这些并发原语,达到提升性能和保证并发安全的目的。