Resumen de datos concurrentes de Coroutine: ¿Hay alguna otra forma además de mutex?

1. Introducción

Este artículo presenta el problema de la agregación de datos en la programación concurrente y analiza el método de usar mutex y canal para garantizar la seguridad de los datos en un entorno concurrente.

En primer lugar, a través de un ejemplo, se describe un caso de obtención y resumen de datos al mismo tiempo, y se utiliza una exclusión mutua para garantizar la seguridad de subprocesos. Luego, se discuten algunas desventajas de los mutex, se presentan los canales como una alternativa y se presentan los usos básicos y las características de los canales. A continuación, un ejemplo demuestra cómo utilizar los canales para implementar la agregación de datos en concurrencia.

Finalmente, cita un ejemplo del uso de canales en etcd para implementar corrutinas para recopilar datos simultáneamente, mostrando la aplicación de canales en proyectos reales.

2. Introducción a la pregunta

Durante el procesamiento de solicitudes, a menudo es necesario extraer datos a través de la interfaz RPC. A veces, debido a la gran cantidad de datos, una sola operación de extracción de datos puede hacer que toda la solicitud tarde más en procesarse. Para acelerar el procesamiento, generalmente consideramos abrir varias corrutinas para extraer datos al mismo tiempo. Una vez que varias corrutinas obtienen datos al mismo tiempo, la corrutina principal debe resumir los datos extraídos por estas corrutinas y luego devolver el resultado. En este proceso, a menudo se involucra el acceso simultáneo a los recursos compartidos Para garantizar la seguridad de los subprocesos, generalmente se utilizan mutexes. A continuación se muestra el proceso a través de un código simple:

package main

import (
        "fmt"
        "sync"
        "time"
)

type Data struct {
        ID   int
        Name string
}

var (
        // 汇总结果
        dataList []Data
        // 互斥锁
        mutex    sync.Mutex
)

func fetchData(page int, wg *sync.WaitGroup) {
        // 模拟RPC接口拉取数据的耗时操作
        time.Sleep(time.Second)

        // 假设从RPC接口获取到了一批数据
        data := Data{
                ID:   page,
                Name: fmt.Sprintf("Data %d", page),
        }

        // 使用互斥锁保护共享数据的并发访问
        mutex.Lock()
        defer mutext.Unlock()
        dataList = append(dataList, data)

        wg.Done()
}

func main() {
        var wg sync.WaitGroup

        // 定义需要拉取的数据页数
        numPages := 10

        // 启动多个协程并发地拉取数据
        for i := 1; i <= numPages; i++ {
            wg.Add(1)
            go fetchData(i, &wg)
        }

        // 等待所有协程完成
        wg.Wait()

        // 打印拉取到的数据
        fmt.Println("Fetched data:")
        for _, data := range dataList {
            fmt.Printf("ID: %d, Name: %s\n", data.ID, data.Name)
        }
}

En el ejemplo anterior, definimos un dataListsegmento compartido para guardar los datos obtenidos. Cada goroutine fetchDatasimula el proceso de extracción de datos llamando a una función y utiliza el acceso simultáneo mutexprotegido dataListpor un mutex. La corrutina principal sync.WaitGroupespera a que todas las corrutinas completen la tarea de obtención de datos y luego imprime los datos obtenidos. Al extraer datos simultáneamente y usar mutexes para garantizar la seguridad de los subprocesos, podemos aumentar significativamente la velocidad de extracción de datos y garantizar la corrección y consistencia de los datos.

回看上述实现,其实是涉及到了多个协程操作同一份数据,有可能导致线程安全的问题,然后这里是通过互斥锁来保证线程安全的。确实,使用互斥锁是可以保证线程安全的,但是也是存在一些缺点的,比如竞争和阻塞,两个协程同时竞争互斥锁时,只有一个协程能够获得锁,而其他协程则会被阻塞,这个就可能导致性能瓶颈,当然在这个场景下问题不大。其次就是代码的复杂性提高了,使用互斥锁需要仔细设计和管理,确保锁的正确获取和释放。这增加了代码的复杂性和维护成本,如果在代码中处理锁的方式不正确,可能会死锁,导致程序无法继续执行。

那我们其实就有疑问,在协程并发下数据汇总的场景,是否存在其他方式,不需要通过使用互斥锁,也能够保证线程安全呢? 其实还真有,Go语言中的channel非常适用于这种情况。通过使用通道,我们可以实现线程安全的数据共享和同步,而无需显式地使用互斥锁。下面我们来了解一下channel

3. channel的使用

3.1 channel的基本介绍

3.1.1 基本说明

channel在Go语言中是一种特殊的数据结构,用于协程之间的通信和同步。它类似于一个先进先出(FIFO)的队列,用于数据的传输和共享。在并发环境中,可以将数据发送到通道,也可以从通道中接收数据,而这两个操作都是线程安全的。

使用channel的优势在于它提供了内置的同步机制,无需显式地使用互斥锁来处理并发访问。

当一个协程向通道发送数据时,如果通道已满,发送操作会被阻塞,直到有其他协程从通道中接收数据释放空间。同样地,当一个协程从通道接收数据时,如果通道为空,接收操作也会被阻塞,直到有其他协程向通道发送数据。

同时,当多个协程同时访问通道时,Go运行时系统会自动处理协程之间的同步和并发访问的细节,保证数据的正确性和一致性。从而可以放心地在多个协程中使用通道进行数据的发送和接收操作,而不需要额外的锁或同步机制来保证线程安全。

因此,使用channel其实是可以避免常见的并发问题,如竞态条件和死锁,简化了并发编程的复杂性。

3.1.2 基本使用

通过上面对channel的基本介绍,我们已经对channel有了基本的了解,其实可以粗略理解其为一个并发安全的队列。下面来了解下channel的基本语法,从而能够开始使用channel

channel基本操作分为创建channel,发送数据到channel,接收channel中的数据,以及关闭channel。下面对其进行简单展示:

创建channel,使用make函数创建通道,通道的类型可以根据需要选择,例如intstring等:

ch := make(chan int)

发送数据到channel:使用<-操作符将数据发送到通道中

ch <- data

接收channel中的数据: 使用<-操作符从通道中接收数据

result := <-ch

关闭channel, 使用close函数关闭通道。关闭通道后,仍然可以从通道接收数据,但无法再向通道发送数据

close(ch)

通过上面channel的四个基本操作,便能够实现在不同协程间线程安全得传递数据。最后通过一个例子,完整得展示channel的基本使用。

package main

import "fmt"

func main() {
        ch := make(chan string) // 创建字符串通道
        defer close(ch)
        go func() {
                ch <- "hello, channel!" // 发送数据到通道
        }()

        result := <-ch // 从通道接收数据
        fmt.Println(result)
}

在这个示例中,我们创建了一个字符串通道ch。然后,在一个单独的协程中,我们向通道发送了字符串"hello, channel!"。最后,主协程从通道中接收数据,并将其打印出来。

通过使用通道,我们可以实现协程之间的数据传输和同步,确保数据的安全共享和线程安全性。通道的使用能够简化并发编程的复杂性,提供一种高效、可靠的方式来处理并发场景下的数据传递。

3.2 使用channel实现汇总数据

下面,我们使用channel来实现并发数据汇总,替换掉之前使用互斥锁来保证线程安全的实现:

package main

import (
        "fmt"
        "sync"
        "time"
)

type Data struct {
        ID   int
        Name string
}

func fetchData(page int, ch chan Data, wg *sync.WaitGroup) {
        // 模拟 RPC 接口拉取数据的耗时操作
        time.Sleep(time.Second)

        // 假设从 RPC 接口获取到了一批数据
        data := Data{
                ID:   page,
                Name: fmt.Sprintf("Data %d", page),
        }

        ch <- data // 将数据发送到通道

        wg.Done()
}

func main() {
        var wg sync.WaitGroup

        // 定义需要拉取的数据页数
        numPages := 10

        dataCh := make(chan Data, 10) // 创建用于接收数据的通道

        // 启动多个协程并发地拉取数据
        for i := 1; i <= numPages; i++ {
                wg.Add(1)
                go fetchData(i, dataCh, &wg)
        }

        go func() {
                wg.Wait()
                close(dataCh) // 关闭通道,表示数据已经全部发送完成
        }()

        // 从通道接收数据并汇总
        var dataList []Data
        for data := range dataCh {
            dataList = append(dataList, data)
        }

        // 打印拉取到的数据
        fmt.Println("Fetched data:")
        for _, data := range dataList {
                fmt.Printf("ID: %d, Name: %s\n", data.ID, data.Name)
        }
}

在修改后的代码中,我们创建了一个用于接收数据的 dataCh。每个协程通过将数据发送到该channel 来完成数据的汇总。主协程通过从channel接收数据,并将其添加到 dataList 中实现数据的汇总过程。这种方式不需要显式地加锁和解锁,并且避免了互斥锁带来的复杂性和性能问题。

通过使用channel,我们能够以一种更直观、更安全的方式实现协程之间的数据传递和同步。channel在并发编程中起到了关键的作用,简化了并发操作的管理和实现。同时,它提供了内置的同步机制,保证了数据的正确性和一致性,避免了死锁和竞态条件的问题。

3.3 总结

协程间的并发下汇总数据可以归类为协程间的数据传递这个场景。在这个场景中,多个协程并发地拉取数据,然后将数据汇总到一个共享的数据结构中。为了保证数据的正确性和一致性,需要使用某种机制来确保多个协程对共享数据的并发访问是安全的。

在原始的实现中,使用了互斥锁来保护共享数据的并发访问。互斥锁提供了互斥访问的机制,确保同一时间只有一个协程可以访问共享数据,从而避免了数据竞争和不一致性。这种方式在保证线程安全的同时,引入了锁的开销和复杂性。

而使用channel来实现协程间的安全数据传递可以更简洁和高效。每个协程可以将拉取到的数据通过channel发送到主协程,主协程通过接收channel中的数据来进行汇总。channel提供了并发安全的数据传递机制,协程之间的数据传输是同步和有序的。由于channel本身就提供了同步机制,不需要额外的锁和同步操作,能够更简洁地实现协程间的安全数据传递。

因此,如果需要在多个协程间实现数据传递,而且由此可能带来线程安全的问题,此时使用channel来实现是相对比较合适的。

4. 开源项目中的使用

假设我们需要对etcd进行性能测试,此时需要模拟大量并发请求,对etcd进行负载测试,并收集每个请求的执行时间、成功/失败状态等结果数据。然后主协程需要收集每一个请求的结果数据,并进行统计计算,生成相应的性能报告。基于此,能够计算出总请求数、请求成功率、平均执行时间、最慢/最快请求等统计信息,以及错误分布情况和慢速请求的详细信息。

从上面的讲述来看,其实我们可以大概想象出这个模型,多个协程并发执行,然后获取每个请求的结果数据。然后主协程需要收集汇总这些数据,基于此来生成性能报告。这个模型其实也就是我们上面所说的协程并发下的数据汇总,因此通过channel来实现协程间的数据传输,是非常合适的。

下面我们来看看etcd中对应的实现。etcd中存在一个report对象的实现,能够接受一系列的请求数据的结果,然后生成性能报告返回回去。结构体定义如下:

type report struct {
   results   chan Result
   stats Stats
}
func (r *report) Results() chan<- Result { return r.results }

// Result describes the timings for an operation.
type Result struct {
   Start  time.Time
   End    time.Time
   Err    error
}

func newReport(precision string) *report {
   r := &report{
      results:   make(chan Result, 16),
   }
   return r
}

Result结构体为单个测试的结果,而 report 结构体则用于整个测试过程的报告和统计信息。通过使用 results 通道,可以将每个测试的结果发送到 report 结构体中,以便进行统计和生成报告。

当进行性能压测时,首先通过newReport生成一个report对象,然后启动多个协程同时进行压测请求,每一个请求处理完成之后,便会生成一个处理结果,存储到Result对象当中。然后基于report对象的Results方法获取到对应的channel,将处理结果传输给主协程。

主协程便通过遍历report对象中的results变量对应的channel,汇总计算所有处理结果,基于此便能够生成压测结果和报告。下面来看其具体流程。

首先是创建一个report对象,然后启动多个协程来处理请求,将结果发送到report对象中的results对应的channel中。

// 这里NewReportSample方法,其实是对上面newReport方法的一个封装
r := NewReportSample("%f")
// 这里假设只有一个协程,模拟执行一系列的测试,并将测试结果发送到 Report 对象的 results 通道中。
go func() {
   start := time.Now()
   for i := 0; i < 5; i++ {
      // 不真实进行请求,只是简单获取执行结果,将测试结果进行传输
      end := start.Add(time.Second)
      r.Results() <- Result{Start: start, End: end}
      start = end
   }
   r.Results() <- Result{Start: start, End: start.Add(time.Second), Err: fmt.Errorf("oops")}
   // 假设所有压测请求都执行完成了
   close(r.Results())
}()
// 主协程 汇总所有的处理结果,然后生成压测报告
stats := <-r.Stats()

以上代码中,r 是通过 NewReportSample("%f") 创建的一个 Report 对象。然后,在一个单独的协程中,执行了一系列的测试,并将测试结果发送到 r.Results() 通道中。

这段代码的作用是模拟执行一系列的测试,并将测试结果发送到 Report 对象的 results 通道中。通过使用 r.Results() 方法返回的通道,可以将测试结果发送到报告对象中进行统计和处理。

接下来,主协程应该不断从 r.Results()方法返回的通道中读取数据,汇总所有的处理结果,从而生成压测报告。这个方法其实是被封装在r.Stas()方法中,具体如下:

func (r *report) Stats() <-chan Stats {
    // 创建一个channel
   donec := make(chan Stats, 1)
   // 启动一个协程来执行
   go func() {
      defer close(donec)
      r.processResults()
      s := r.stats.copy()
      if r.sps != nil {
         s.TimeSeries = r.sps.getTimeSeries()
      }
      // 执行完成的话,将结果返回
      donec <- s
   }()
   // 返回channel
   return donec
}

// Stats方法启动的协程中,实际运行的任务
func (r *report) processResults() {
   st := time.Now()
   // 遍历r.results方法中channel中的数据,然后执行处理流程
   for res := range r.results {
      r.processResult(&res)
   }
   // 后续执行一些具体的计算逻辑
}

上述代码是 report 结构体中的两个方法,其中 Stats() 方法返回一个只读的 Stats 通道。这个方法会在一个单独的协程中执行,并处理 results 通道中的测试结果。事实上就是汇总channel中的数据,然后进行一定的处理,然后返回。

5. 总结

本文通过介绍并发编程中的数据汇总问题,提出了使用互斥锁和通道来保证线程安全的方法。互斥锁适用于临界区保护和共享资源的互斥访问,但可能存在死锁和性能瓶颈的问题。相比之下,通道提供了更直观和安全的协程间通信方式,避免了锁的问题,并提供了更灵活的并发模式。

基于以上内容的介绍,大概能够明确下,在数据传递和汇总的场景下,使用channel来实现可能是更为合适的,能够提高代码的可读性和并发安全性。希望以上内容对你有所帮助。

Supongo que te gusta

Origin juejin.im/post/7235269797427363896
Recomendado
Clasificación