掌握 Goroutines、通道和同步(一)

230 阅读25分钟

0 理解 Goroutines:高级 Golang 编程中用于并行执行的轻量级线程

Golang,又称 Go,由 Google 开发,因其高效的并发模型而广受好评,该模型主要通过 goroutines 实现。Goroutines 是由 Go 运行时管理的轻量级线程,允许开发人员以最小的开销并发执行函数。此功能对于构建高性能、可扩展的应用程序至关重要。本文深入探讨了 goroutines 的复杂性,提供了高级见解和代码示例,以帮助您掌握它们在 Golang 中的使用。

1 Goroutines 基础知识

goroutine 是与同一地址空间中的其他 goroutine 同时执行的函数或方法。Goroutine 在内存和 CPU 使用率方面比线程更便宜,因为它们需要的管理开销更少。

1.1 基本语法

启动一个 goroutine 很简单:

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    fmt.Println("Hello,World")
}

func main() {
    go sayHello()
    time.Sleep(1 *time.Second) // Ensure main doestn't exit before goroutine finishes.
}

在上例中,sayHello()main 函数同时运行。关键字 go 启动了一个新的 goroutine。time.Sleep()`用于给 goroutine 足够的时间来完成,因为主函数的退出会终止所有正在运行的 goroutine。

Goroutine 生命周期

与传统线程相比,Goroutines 的生命周期更简单:

  1. 创建:由go关键字发起。
  2. 执行:由 Go 运行时调度程序管理。
  3. 阻塞:等待 I/O 操作或同步原语(通道、互斥)时,Goroutines 可能会被阻塞。
  4. 终止:Goroutine在其函数完成或主程序退出时终止。

1.2 同步与通信

Goroutines 通过通道进行通信,通道提供了一种在它们之间安全传递数据的方法,避免了显式锁和竞争条件。

通道底层数据结构包含互斥锁。

1.3 通道

Go 中的通道是一种类型化的管道,你可以通过它发送和接收值。这是一个基本示例:

package main

import "fmt"

func sum(a, b int, resultChan chan int) {
    sum := a + b
    resultChan <- sum // Send the result to the channel
}

func main() {
    resultChan := make(chan int)
    go sum(1, 2, resultChan)
    result := <-resultChan // Receive the result from the channel
    fmt.Println("Sum:", result)
}

resultChan 是同步通道,从空的同步通道接收值会阻塞,因此不会出现主 goroutine 执行完成,所有子 goroutine 都退出的情况。用标准术语来说是偏序关系(happend-before):即一个 goroutine 往同步通道中发送值一定是发生在另一个 goroutine 能够从该通道收到值之前的。

在本例中,创建了一个通道 resultChan 来将两个数字之和传回主函数。

2 高级 Goroutine 模式

2.1 工作池

工作池是一种常见的并发模式,其中固定数量的 goroutine(工作线程)处理可能无限数量的任务。此模式有利于限制并发任务的数量并高效利用资源。

package main

import (
    "fmt"
    "sync"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()
    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        results <- job * 2 // Simulate work by doubling the job number
    }
}

func main() {
    const numJobs = 5
    const numWorkers = 3
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)
    wg.Wait()
    close(results)
    for result := range results {
        fmt.Println("Result:", result)
    }
}

在这个 Worker 池示例中,有固定数量的 Worker 同时处理作业。sync.WaitGroup "用于确保所有 Worker 在主函数退出前完成工作。先启动 numWorker 个 Worker 不断从任务通道中抢任务,然后在创建完任务后关闭任务通道使每个 goroutine 都可以正常退出。等到所有 goroutine 都执行完成后,所有计算结果都已经在 results 通道中了,此时再关闭结果通道打印每个结果并使主函数正常退出。

2.2 Goroutines 中的错误处理

处理 goroutine 中的错误需要谨慎管理,因为错误不能直接从 goroutine 返回给其调用者。一种方法是使用通道进行错误报告。

package main

import (
    "fmt"
    "errors"
)

func workerWithError(id int, jobs <-chan int, results chan<- int, errChan chan<- error) {
    for job := range jobs {
        if job == 2 {
            errChan <- errors.New("job 2 failed")
            continue
        }
        results <- job * 2
    }
}

func main() {
    jobs := make(chan int, 5)
    results := make(chan int, 5)
    errChan := make(chan error)
    for w := 1; w <= 3; w++ {
        go workerWithError(w, jobs, results, errChan)
    }
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)
    for i := 0; i < 5; i++ {
        select {
        case result := <-results:
            fmt.Println("Result:", result)
        case err := <-errChan:
            fmt.Println("Error:", err)
        }
    }
}

在这段代码中,Worker 遇到的错误会被发送到 errChan 中,而主函数会在结果和错误到达时对其进行处理。

2.3 取消上下文

Go 中的 context 包提供了一种在多个 goroutines 之间取消信号的方法,这对于稳健且响应迅速的应用程序来说至关重要。

package main

import (
    "context"
    "fmt"
    "time"
)

func workerWithContext(ctx context.Context, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("Woker %d stopping\n", id)
            return
        default:
            fmt.Printf("Worker %d working\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2 *time.Second)
    defer cancel()
    for i := 1; i <= 3; i++ {
        go workerWithContext(ctx, i)
    }
    time.Sleep(3 * time.Second)
    fmt.Println("Main function completed")
}

在本例中,使用了带超时的上下文,以便在 2 秒后取消 Worker。这种模式可确保 goroutines 在不再需要时能够清理并优雅地退出。

Goroutines 是 Go 的一个强大功能,可以实现低开销的并发执行。理解并有效利用 goroutines 是编写高性能 Go 应用程序的关键。通过掌握通道等同步原语、使用工作池等模式、处理错误以及使用上下文管理 goroutine 生命周期,开发人员可以充分利用 Go 中的并发功能。这些先进的技术可确保您的应用程序既高效又强大,能够轻松处理复杂的并发工作负载。

3 通过 Channels 进行通信:在 Goroutines 之间传递数据

Golang,通常称为 Go,由于其轻量级并发模型,非常适合构建并发系统。该模型的核心是 goroutine 和通道的概念。虽然 goroutine 支持并发执行,但通道促进了它们之间的通信和同步。本文深入探讨了通道的高级用法,展示了如何在 goroutine 之间高效传递数据以构建强大、高性能的应用程序。

3.1 通道基础知识

Go 中的通道是类型化的管道,用于在 goroutines 之间发送和接收值。它们对于避免显式锁和使并发编程更安全、更直观至关重要。

3.2 基本语法

创建和使用通道非常简单:

package main

import (
    "fmt"
)

func main() {
    messages := make(chan string)
    go func() {
        messages <- "Hello, World"
    }()
    msg := <-messages
    fmt.Println(msg)
}

在这个例子中,创建了一个类型为“string”的通道。一个 goroutine 向该通道发送一条消息,主函数接收并打印该消息。

3.3 无缓冲通道与缓冲通道

通道可以是无缓冲的,也可以是缓冲的。无缓冲通道确保同步通信,而缓冲通道允许异步通信。

3.3.1 无缓冲通道

无缓冲通道没有容量来保存值。发送和接收操作会阻塞,直到另一端准备好为止。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    messages := make(chan string)
    
    go func() {
        defer wg.Done()
        // happend-before Rececive value from messages channel
        messages <- "Hello from Groutine"
    }()

    msg := <-messages
    fmt.Println(msg)
    wg.Wait()
}

在此示例中,发送操作一直阻塞,直到主函数准备好接收消息。

3.3.2 缓冲通道

如果缓冲区未满或未空,缓冲通道允许发送和接收操作不受阻塞地进行。

package main

import (
    "fmt"
)

func main() {
    messages := make(chan string, 2)
    messages <- "Buffered"
    messages <- "Channel"
    fmt.Println(<-messages)
    fmt.Println(<-messages)
}

这里,通道最多可以缓冲两条消息,从而允许主函数无需等待即可发送两条消息。

3.4 高级通道模式

3.4.1 工作池

工作池是一种常见模式,其中多个工作 goroutine 同时处理作业。这种方法可以平衡负载并提高资源利用率。

代码实例参见本文 2.1 节代码。

在这个例子中,三个 worker goroutine 处理五个作业。jobs 和 results 通道促进了 main 函数和 worker 之间的通信。

3.4.2 select 语法

select 语句允许一个 goroutine 等待多个通信操作,从而实现对多个通道的响应和灵活处理。

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
    
    go func() {
        time.Sleep(2 * time.Second)
        ch1 <- "Message from ch1"
    }()
    
    go func() {
        time.Sleep(1 * time.Second)
        ch2 <- "Message from ch2"
    }()
    
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("Received:", msg1)
        case msg2 := <-ch2:
            fmt.Println("Received:", msg2)
        }
    }
}

在这个例子中,select语句等待来自ch1ch2的消息,处理先到达的消息。

3.4.3 超时和非阻塞通信

使用 select 语句,可以实现超时和非阻塞通信,从而使应用程序更加健壮。

func main() {
    ch := make(chan string)
    
    go func() {
        time.Sleep(2 * time.Second)
        ch <- "Result"
    }()
    select {
    case res := <-ch:
        fmt.Println("Received:", res)
    case <-time.After(1 * time.Second):
        fmt.Println("Timeout")
    }
}

在本例中,如果在一秒内未收到结果,time.After 通道就会触发超时。

func main() {
    messages := make(chan string)
    signals := make(chan bool)
    
    select {
    case msg := <-messages:
        fmt.Println("Received message:", msg)
    default:
        fmt.Println("No message received")
    }
    
    msg := "Hi"
    select {
    case messages <- msg:
        fmt.Println("Sent message:", msg)
     default:
         fmt.Println("No message sent")
    }
    
    select {
    case msg := <-messages:
        fmt.Println("Received message:", msg)
    case sig := <-signals:
        fmt.Println("Received signal:", sig)
    default:
        fmt.Println("No activity")
    }
}

在这个例子中,select语句在不阻塞的情况下检查通信,如果没有通信发生,则打印一条消息

3.4.4 Fan-Out, Fan-In

扇出 (Fan-Out) 是启动多个 goroutine 来处理单个通道的任务的过程。

扇入 (Fan-In) 将多个通道的结果收集到一个通道中。

2.1 节的 Worker Pool 代码同时涉及 Fan-Out, Fan-In,下面再贴出一次:

func main() {
    const numJobs = 5
    const numWorkers = 3
    
    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup
    
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }
    
    for j := 1; j <= numWorkers; j++ {
        jobs <- j
    }
    close(jobs)

    wg.Wait()
    close(results)
    
    for result := range results {
        fmt.Println("Result:", result)
    }
}

Fan-Out 扇出:在这个实例中,作业通过单个任务通道分发到多个工作 goroutine 进行处理。

Fan-In 扇入:在这个示例中,来自多个 worker 的结果被收集到单个结果通道 results 中。

3.4.5 取消上下文

context 软件包提供了一种在多个程序中发出取消信号的方法,确保它们可以优雅地清理和退出

func worker(ctx context.Context, id int) {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("Worker %d stopping\n", id)
            return
        default:
            fmt.Printf("Worker %d working\n", id)
            time.Sleep(500 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2 * time.Second)
    defer cancel()
    
    for i := 1; i <= 3; i++ {
        go worker(ctx, i)
    }
    
    time.Sleep(3 * time.Second)
    fmt.Println("Main function completed")
}

在此示例中,带有超时的上下文会在两秒后取消 Worker goroutines。

通道是 Go 并发模型的基石,可实现 goroutine 之间的安全高效通信。通过掌握工作池、select 语句、超时、非阻塞通信、扇出、扇入和取消上下文等高级模式,您可以构建健壮、高性能的应用程序。通道不仅简化了同步,还增强了程序的响应能力和可扩展性,使 Go 成为并发编程的绝佳选择。通过理解和利用这些高级模式,您可以充分利用通道的强大功能来创建高效、可维护且可扩展的 Go 应用程序。

3.4.6 流水线(Pipeline)(通道高级模式续章)

流水线是一系列相连的阶段,其中一个阶段的输出成为下一个阶段的输入。此模式对于处理数据流特别有用。

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func generateNumbers(count int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i := 0; i < count; i++ {
            out <- rand.Intn(100)
        }
    }()
}

func squareNumbers(in <-chan int) <-chan int {
    out := make(chan int)
    go func(){
        defer close(out)
        for num := range in {
            out <- num * num
        }
    }()
}

func main() {
    rand.Seed(time.Now().UnixNano())
    numbers := generateNumbers(10)
    squares := squareNumbers(numbers)
    
    for square := range squares {
        fmt.Println(square)
    }
}

在这个例子中,generateNumbers 产生了一个随机数流,然后squareNumbers对其进行处理。这种设置体现了流水线模式,其中流水线的各个阶段由通道连接。

generateNumbers 输出的随机数流作为 squareNumbers 的输入流继续进行处理,而不是由主 goroutine 收集 generateNumbers 输出后再送入 squareNumbers 的输入中。

3.4.7 使用select进行多路复用

多路复用允许单个 goroutine 同时监听多个通道并在消息到达时进行处理。

网络的多路复用指的是单个进程/线程使用一个 epoll 内核对象就可以同时监听多个套接字上是否数据已就绪,包括监听套接字(此时数据指的就是新连接)、已建立连接的套接字等等,都会托管给 epoll 内核对象管理(底层使用红黑树)。

此处的 select 和 select 多路复用重名,应该是想要表达同时监听多个 goroutine 消息是否已到达的语义。(如果多个 goroutine 数据就绪选哪个的问题,只需要知道底层使用的是堆排,排序的对象是地址信息,可以保证以相对公平的概率被选中)。

3.4.8 通道中的错误处理

通道中的错误处理对于构建弹性应用程序至关重要。一种常见的方法是使用专用的错误通道。

参见 2.2 节代码,如果作业无法处理,Worker 函数会向 errChan 发送错误,而 main 函数会在结果和错误到达时进行处理。

3.4.9 使用上下文管理 Goroutine 生命周期

Conext包提供了一种强大的方法来控制Goroutine的生命周期,确保它们可以根据需要被取消或超时。

代码参见 2.3 ,在此示例中,创建了一个具有超时的上下文,工作 goroutine 会检查该上下文以了解何时停止工作。这可确保 goroutine 不会无限期运行,并且可以在不再需要时正确清理资源。

3.4.10 真实世界示例:网络爬虫

在一个简单的网络爬虫中可以看到通道和 goroutine 的实际应用。此示例演示了如何使用通道来管理并发 HTTP 请求和处理响应。

func fetchURL(url string, results chan<- string, wg *sync.WaitGroup) {
    defer wg.Done()
    resp, err := http.Get(url)
    if err != nil {
        results <- fmt.Sprintf("Error fetching %s: %s", url, err)
        return
    }
    defer resp.Body.Close()
    results <- fmt.Sprintf("Fetched %s: %s", url, resp.Status)
}

func main() {
    usls := []string{
        "http://example.com",
        "http://example.org",
        "http://example.net",
    }
    
    results := make(chan string)
    var wg sync.WaitGroup
    
    for _, url := range urls {
        wg.Add(1)
        go fetchURL(url, results, &wg)
    }
    
    go func(){
        wg.Wait()
        close(results)
    }()
    
    for result := range results {
        fmt.Println(result)
    }
}

在本例中,fetchURL 并发地获取 URL,并通过results 通道将结果发送回主函数。sync.WaitGroup "确保在结果通道关闭前完成所有获取操作。

Go 中的通道是管理并发性的通用而强大的工具。通过了解和应用 Worker 池、管道、选择语句、超时和上下文取消等高级模式,您可以构建高性能、可扩展的应用程序。通道不仅简化了并发编程的复杂性,还提供了一种强大的机制,用于在程序之间进行同步和通信。掌握这些技术对于任何希望编写高效、可维护并发代码的 Go 开发人员来说都至关重要。

4 同步原语:确保顺序并避免竞争条件

在并发编程中,同步原语对于协调多个线程或 goroutine 的执行、确保数据一致性和防止竞争条件至关重要。Go 是一种以并发性为设计的语言,它提供了各种同步原语来帮助开发人员有效地管理并发操作。本文将探讨这些原语及其用例,并提供代码示例来说明它们在高级 Go 编程中的应用。

4.1 竞争条件和同步

当程序的行为取决于其线程或 goroutine 的相对时间时,就会发生竞争条件。这可能会导致不可预测的结果和难以重现和修复的错误。同步原语通过控制对共享资源的访问并确保以可预测的方式执行并发操作来帮助避免竞争条件。

4.1.1 互斥锁

Mutex(互斥的缩写)是最基本的同步原语之一。它一次只允许一个 goroutine 访问代码的关键部分,从而确保对共享资源的独占访问。

示例:使用 Mutex 避免竞争条件

type Counter struct {
    mu sync.Mutex
    value int
}

func (c *Counter) increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    var wg sync.WaitGroup
    counter := Counter{}
    
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func(){
            defer wg.Done()
            counter.increment()
        }()
    }
    
    wg.Wait()
    fmt.Println("Final counter value:", counter.Value())
}

在此示例中,Counter 结构使用 Mutex 来保护 value 字段。IncrementValue 方法锁定互斥锁,以确保一次只有一个 goroutine 可以修改或读取 value,从而防止出现竞争条件。

4.1.2 RWMutex 读写互斥锁

RWMutex 是一种读写互斥锁,允许多个读取者但只允许一个写入者。当资源被频繁读取但不频繁写入时,它很有用。

示例:使用 RWMutex 实现高效读写锁定

type SafeMap struct {
    mu sync.RWMutex
    store map[string]string
}

func NewSafeMap() *SafeMap {
    return &SafeMap{
        store: make(map[string]string),
    }
}

func (m *SafeMap) Set(key, value string) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.store[key] = value
}

func (m *SafeMap) Get(key string) (string, bool) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    value, ok := m.store[key]
    return value, ok
}

func main() {
    var wg sync.WaitGroup
    smap := NewSafeMap()
    
    // Writers
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int){
           defer wg.Done()
           smap.Set(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i))
        }(i)
    }

    // Readers
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            if value, ok := smap.Get(fmt.Sprintf("key%d", i); ok {
                fmt.Println("Got:", value)
            }
        }(i)
    }
    wg.Wait()
}

这里,SafeMap 结构使用 RWMutex 保护并发读取,同时仍保护写入。Set方法锁定互斥锁以进行写入,而 Get 方法锁定互斥锁以进行读取。

不同的最多只有一个写者访问 smap,而允许多个读者同时访问 smap。总的来说:读写互斥、读读不互斥、写写互斥。从底层实现上来看,一个写者如果已经持有锁,那么在其主动释放掉锁之前,所有的读者和写者都会阻塞;一个读者如果已经持有锁,那么后续如果有其他读者访问 smap(并且此时没有等待的写者),允许读者直接访问 smap,底层会记录读者的总数,等到这个计数值降为 0 时,最后一个释放锁的读者负责唤醒等待锁的写者。而如果写者目前已经持有锁,同时有读者和写者访问 smap,那么等到前一个写者释放锁后,读者会优先获取锁。

一般说:当写者已经持有锁时,那么后续的读者和写者中,读者优先;当读者已经持有锁,那么后续的读者和写者中,写者优先(因为一旦已经持有锁的那些 reader 释放锁之后,最后一个 reader 会立马唤醒 writer)。

4.1.3 WaitGroup(goroutines 编排器)

WaitGroup 用于等待一组 goroutine 完成。它提供了一种阻塞方法,直到所有 goroutine 都完成其工作。

示例: 使用 WaitGroup 等待 Goroutines

func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    fmt.Printf("Worker %d starting\n", id)
}

func main() {
    var wg sync.WaitGroup
    fmt.Printf("Worker %d starting\n", id)
    // Simulate work
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go worker(i, &wg)
    }
    
    wg.Wait()
    fmt.Println("All worker done")
}

如果你比较细心的话会发现,无论是这个示例代码还是之前的示例代码,出现 WaitGroup 作为参数传递时都是引用传递,这是因为 sync.WaitGroup 底层也是基于互斥锁实现的。在本例中 worker goroutine 在执行完成后会调用 Done 方法将编排器的计数器减 1,等到编排器的计数器值为 0 时,Wait() 阻塞结束继续往下执行,而如果传递的是副本(值传递),修改的并不是预期的计数器值,最终主 goroutine 一直等待 goroutine 结束而导致死锁。

还有一种解释是 golang 中的锁是有状态的。

在此示例中,使用 WaitGroup 等待五个工作 goroutine 完成。Add 方法增加计数器,Done 减少计数器。Wait 方法会阻塞,直到计数器为零。

4.1.4 Cond

Cond(条件变量)允许 goroutine 等待或宣布事件的发生,它必须绑定 Mutex 一起使用。

func main() {
    var mu sync.Mutex
    cond := sync.NewCond(&mu)
    ready := false
    
    go func(){
        cond.L.Lock()
        for !ready {
            cond.Wait()
        }
        fmt.Println("Goroutine proceeding")
        cond.L.Unlock()
    }()

    time.Sleep(1 * time.Second) // Simulate some work
    cond.L.Lock() 
    ready = true
    cond.Signal() // 向单个 goroutine 发送通知
    cond.Unlcok()
    
    time.Sleep(1 * time.Second) // Give time for goroutine to print
}

在此示例中,goroutine 等待 ready 条件变为真。main 函数将 ready 设置为真并发出信号,允许 goroutine 继续执行。

4.1.5 Once

Once "确保一段代码只被执行一次,即使从多个程序中调用也是如此(单例模式的神器)。

示例:使用 Once 执行初始化代码

func main() {
    var once sync.Once
    
    initFunc := func() {
        fmt.Println("initialization done")
    }
    
    var wg sync.WaitGroup
    
    for i := 0; i < 10 ; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            once.Do(initFunc)
        }()
        wg.Wait()
    }
}

在这里,无论有多少个程序调用 once.Do(initFunc)initFunc 都保证只执行一次。

sync.Once 底层数据结构使用一个 bool 值标识是否已经执行过,通过 CAS 保证 bool 值 set 操作的原子性,从而保证 once.Do 注册的 function 只能执行一次。

4.1.6 Semaphore

Go 没有提供内置信号量,但可以使用通道来实现。信号量限制了对资源的并发访问数量。

示例:使用通道实现信号量

type Semaphore struct {
    tokens chan struct{}
}

func NewSemaphore(n int) *Semaphore {
    return &Semaphore{
        token: make(chan struct{}, n),
    }
}

func (s *Semaphore) Acquire() {
    s.tokens <- struct{}{}
}

func (s *Semaphore) Release() {
    <-s.tokens
}

func worker(id int, sema *Semaphore, wg *sync.WaitGroup) {
    defer wg.Done()
    sema.Acquire()
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(1 * time.Second)
    fmt.Printf("Worker %d done\n", id)
    sema.Release()
}

func main() {
    sema := NewSemaphore(3)
    var wg sync.WaitGroup
    
    for i := 1; i <= 10; i++ {
        wg.Add(1)
        go worker(i, sem, &wg)
    }
    
    wg.Wait()
}

token 池,获取到 token 就相当于获取到开启 work goroutine 的权限。

通过设定 tokens 池的容量就相当于限制了同时运行的 worker goroutine 的数量

在这个示例中,创建了一个有三个令牌的 semaphore,只允许三个 Worker 同时运行。如果没有令牌可用,"Acquire "方法就会阻塞,而 "Release "方法则会将一个令牌返回到信号存储空间。

同步原语是 Go 程序员管理并发的重要工具。Mutex"、"RWMutex"、"WaitGroup"、"Cond"、"Once "和 semaphores 都提供了不同的机制来控制对共享资源的访问,确保执行顺序并防止出现竞争条件。通过有效利用这些原语,开发人员可以编写健壮的并发程序,并在并发执行场景下可靠地运行。

5 案例研究:使用高级 Golang 编程中的实际示例构建并发应用程序

并发是现代软件开发的基石,它允许应用程序同时执行多个操作,从而提高性能和响应能力。Go 具有强大的并发模型,提供了一套丰富的工具来构建并发应用程序。本文介绍了几个案例研究,展示了如何使用 Go 的并发特性有效地解决实际问题。

5.1 案例研究 1:使用 Goroutines 和 Channels 进行 Web 抓取

Web 抓取通常需要同时从多个 URL 获取数据。Go 的 goroutine 和通道提供了一种有效的方法来实现这一点。

参见 3.4.10 真实世界示例:网络爬虫部分的代码。在本例中,"fetch" 函数从一个 URL 抓取数据,并将结果发送到一个通道。多个 goroutines 同时获取数据,主函数使用 "WaitGroup" 等待所有 goroutines 完成。结果会被收集并打印出来。

同时还使用了扇入模式、goroutines 编排器。

5.2 案例研究2:并发文件处理

处理大型文件可能非常耗时。并发处理可以显著减少所需时间。

func processLine(line string, wg *sync.WaitGroup, ch chan<- string) {
    defer wg.Done()
    // Simulate line processling
    ch <- fmt.Sprintf("Processed: %s", line)
}

func main() {
    file, err := os.Open("large_log_file.txt")
    if err != nil {
        fmt.Printf("Error opening file: %v\n", err)
        return
    }
    defer file.Close()
    
    var wg sync.WaitGroup
    results := make(chan string)
    scanner := bufio.NewScanner(file)
    
    go func(){
        for scanner.Scan(){
            wg.Add(1)
            go processLine(scanner.Text(), &wg, results)
        }
        wg.Wait()
        close(results)
    }()
    
    for result := range results {
        fmt.Println(result)
    }
    
    if err := scanner.Err(); err != nil {
        fmt.Printf("Error reading file: %v\n", err)
    }
}

在本案例研究中,"processLine"函数同时处理文件的每一行。主函数从文件中读取行数,并为每一行启动一个 goroutine,同时使用一个 WaitGroup 等待所有处理完成。处理结果会发送到一个通道并打印出来。

5.3 案例研究 3:使用工作池进行并发数据处理

工作池是一种常见的并发模式,可以通过限制活动 goroutine 的数量来有效地管理大量任务

示例:图像处理的工作池

type Task struct {
    ID int
    Data string // Placeholder for actual data, e.g., image file path
}

func worker(id int, tasks <-chan Task, results chan<- string, wg *sync.WaitGroup) {
    defer wg.Done()
    for task := range tasks {
        // Simulate processing
        results <- fmt.Sprintf("Worker %d processed task %d", id, task.ID)
    }
}

func main() {
    const numWorkers = 3
    tasks := make(chan Task, 10)
    results := make(chan string, 10)
    var wg sync.WaitGroup
    
    // Start workers
    for i := 1; i <= numWorkers; i++ {
        wg.Add(1)
        go worker(i, tasks, results, &wg)
    }
    
    // Send tasks
    for i := 1; i < 10; i++ {
        tasks <- Task{ID: i, Data: fmt.Sprintf("Image%d", i)}
    }
    
    go func() {
        wg.Wait()
        close(results)
    }()
    
    // Collect results
    for result := range results {
        fmt.Println(result)
    }
}

在此示例中,固定数量的工作 goroutine 处理来自共享通道的任务。任务被发送到通道,然后工作程序会同时处理它们。WaitGroup 确保主函数等待所有工作程序完成。

5.4 案例研究 4:使用 Goroutines 进行实时数据处理

实时应用,比如监控系统,需要持续的数据处理。Go 的 goroutines 和 channels 可以实现高效的实时数据处理。

type SensorData struct {
    Timestamp time.Time
    Value float64
}

func sensor(id int, ch chan<- SensorData, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 10; i++ {
        data := SensorData{
            Timestamp: time.Now(),
            Value: rand.Float64() * 100,
        }
        ch <- data
        time.Sleep(time.Millisecond * 100) // Simulate sensor reading interval
    }
}

func processSensorData(ch <- chan SensorData, done chan<- struct{}) {
    for data := range ch {
        fmt.Printf("Processed sendor data: "%v\n", data)
    }
    done <- struct{}{}
}

func main() {
    rand.Seed(time.Now().UnixNano())
    sensorChannel := make(chan SensorData)
    done := make(chan struct{})
    var wg sync.WaitGroup
    
    // Start sensor goroutines
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go sensor(i, sensorChannel, &wg)
    }
    
    // Start data processing goroutine
    go processSensorData(sensorChannel, done)
    
    // Wait for sensors to finish
    go func(){
        wg.Wait()
        close(sensorChannel)
    }()
    
    <-done
    fmt.Println("All sendor data processed")
}

在此示例中,多个传感器 goroutine 模拟实时数据生成,将数据发送到共享通道。process Sensor Data 函数持续处理传入数据。主函数协调传感器 goroutine,并确保在退出前处理所有数据。

5.5 案例研究 5:具有速率限制的并发 HTTP 服务器

并发 HTTP 服务器经常需要限制传入请求的速率以避免过载。Go 的通道和 goroutines 可以实现有效的速率限制。

func rateLimitedHandler(sem chan struct{}, wg *sync.WaitGroup) http.HandlerFunc {
    return func(w http.ResposnseWriter, r *http.Request) {
        sem <- struct{}{}
        wg.Add(1)
        defer func(){
            <-sem
            wg.Done()
        }()
        // Simulate request handling
        time.Sleep(time.Millisecond * 500)
        fmt.Fprintf(w, "Request handled at %v\n", time.Now())
    }
}

func main() {
    const maxConcurrentRequest = 5
    sem := make(chan struct{}, maxConcurrentRequests)
    var wg sync.WaitGroup
    
    http.HandleFunc("/", rateLimitedHanlder(sem, &wg))
    server := &http.Server{
        Addr: ":8080",
    }
    
    go func(){
        fmt.Println("Start server on :8080")
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            fmt.Printf("Error starting server: %v\n", err)
        }
    }()

    // Graceful shutdown
    stop := make(chan struct{})
    go func(){
        <-stop
        if err := server.Close(); err != nil {
            fmt.Printf("Error shutting down server: %v\n", err)
        }
        wg.Wait()
        fmt.Println("Server shut down gracefully")
    }()
    
    // Simulate stopping the server after 10 seconds
    time.Sleep(10 * time.Second)
    close(stop)
}

使用通道实现的 semaphore(token 池),也可以用来做限流。

在本例中,"rateLimitedHandler"函数限制了使用缓冲通道("sem")的并发请求数。主函数启动 HTTP 服务器,并在 10 秒后模拟优雅关机,确保完成所有正在进行的请求。

这些案例研究展示了 Go 的并发原语--例程、通道、互斥和 "WaitGroup" 是如何用于构建健壮而高效的并发应用程序。无论是网络扫描、文件处理、实时数据处理,还是构建速率受限的服务器,Go 都提供了有效管理并发性所需的工具。通过了解和利用这些基元,开发人员可以创建性能和可靠性都很高的应用程序。

5.6 案例研究6: 带上下文取消的并行任务执行

在许多实际应用中,任务需要并行执行,但能够在满足某些条件时取消它们。Go 的context包提供了一种优雅地处理此类场景的方法。

示例:使用上下文取消的并行任务执行

func performTask(ctx context.Context, id int, wg *sync.WaitGroup) {
    defer wg.Done()
    select {
    case <-time.After(time.Duration(id) * time.Second):
        fmt.Printf("Task %d completed\n", id)
    case <-ctx.Done():
        fmt.Printf("Task %d cancelled\n", id)
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
    defer cancel()
    
    var wg sync.WaitGroup
    numTasks := 10
    
    for i := 1; i <= numTasks; i++ {
        wg.Add(1)
        go performTask(ctx, i, &wg)
    }
    
    wg.Wait()
    fmt.Println("All tasks completed or cancelled")
}

在本例中,performTask 函数执行的任务需要一定时间才能完成。通过使用 context.Context,可以在任务耗时过长时取消任务。context.WithTimeout函数创建了一个 5 秒后自动取消的上下文,确保停止任何在该时间点仍在运行的任务。

5.7 案例研究7:并发数据聚合

同时聚合来自多个来源的数据可以显著加快这一过程,这在从各种服务生成报告等场景中尤其有用。

type Data struct {
    Source string
    Value int
}

func fetchData(source string, wg *sync.WaitGroup, ch chan<- Data) {
    defer wg.Done()
    // Simulate data fetching
    value := len(source) * 10
    ch <- Data{Source: source, Value: value}
}

func main() {
    sources := []string{"ServiceA", "ServiceB", "ServiceC"}
    var wg sync.WaitGroup
    dataChannel := make(chan Data, len(sources))
    
    for _, source := range sources {
        wg.Add(1)
        go fetchData(source, &wg, dataChannel)
    }
    
    go func(){
        wg.Wait()
        close(dataChannel)
    }()
    
    var results []Data
    for data := range dataChannel {
        results := append(results, data)
    }
    
    fmt.Println("Aggregated data:")
    for _, result := range results {
        fmt.Printf("Source: %d, Value: %d\n", result.Source, result.Value)
    }
}

在本例中,"fetchData" 函数并发地从给定数据源获取数据。主函数为每个数据源启动一个 goroutine,而WaitGroup则确保在汇总结果之前完成所有获取操作。然后对收集到的数据进行处理和显示。

5.8 案例研究 8:并发 Map 操作

并发访问共享数据结构(例如映射)需要谨慎同步以防止竞争条件。Go 中的 sync.Map 类型提供了一种处理并发映射操作的安全方法。

示例:并发 Map 操作

func main() {
    var m sync.Map
    var wg sync.WaitGroup
    
    // Concurrent writers
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            m.Store(i, fmt.Sprintf("Value%d", i))
        }(i)
    }
    
    // Concurrent reads
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            if value, ok := m.Load(i); ok {
                fmt.Printf("Key: %d, Value: %s\n", i, value)
            }
        }(i)
    }

    wg.Wait()
}

在这个例子中,sync.Map 类型用于并发存储和检索值。Store 方法用于写入数据,Load 方法用于读取数据。这确保了 map 操作是安全的并且无竞争。

sync.Map 底层使用了两个 map,一个有锁 map 和一个无锁 map,分别对应 dirty map 和 clean map。先访问 clean map,如果没有命中则需要加锁去 dirty map 去找(dirty map 中包含所有 clean map 中已有的 key-value 对)。同时使用一个计数器记录未命中的次数,当这个次数超过一个阈值时,就将 clean map 替换为 dirty map,然后清空 dirty map 继续处理。

这些案例研究说明了 Go 的并发模型在构建高效、健壮的应用程序方面的多功能性和强大功能。从网络抓取和文件处理到实时数据处理和并发 HTTP 服务器,Go 的并发基元--程序、通道、"sync"包组件和 "context"包--提供了应对各种并发编程挑战所需的工具。

6 总结

通过利用这些工具,开发人员可以设计和实现不仅性能良好而且保持高可靠性和可维护性的应用程序。对于任何希望构建可扩展且高效的软件解决方案的 Go 开发人员来说,理解和应用这些并发模式都是必不可少的。