从不同线程中的繁重操作同步写入文件

时间:2018-06-08 08:46:12

标签: multithreading file go synchronization

我需要一次一个地编写一个文件(可能是一个大文件)并将结果写入一个新文件。 简而言之,我有一个基本功能来详细说明一个块:

func elaborateBlock(block []byte) []byte { ... }

每个块都需要详细说明,然后按顺序写入输出文件(保留原始顺序)。

单线程实现很简单:

for {
        buffer := make([]byte, BlockSize)
        _, err := inputFile.Read(buffer)

        if err == io.EOF {
            break
        }
        processedData := elaborateBlock(buffer)
        outputFile.Write(processedData)
}

但是详细说明可能很重,每个块都可以单独处理,因此多线程实现是自然的演变。

我提出的解决方案是创建一个通道数组,计算不同线程中的每个块,并通过循环通道数组来同步最终写入:

效用函数:

func blockThread(channel chan []byte, block []byte) {
    channel <- elaborateBlock(block)
}

在主程序中:

chans = []chan []byte {}

for {
    buffer := make([]byte, BlockSize)
    _, err := inputFile.Read(buffer)

    if err == io.EOF {
        break
    }

    channel := make(chan []byte)
    chans = append(chans, channel)

    go blockThread(channel, buffer)
}

for i := range chans {
    data := <- chans[i]
    outputFile.Write(data)
}

这种方法有效,但对于大文件可能会有问题,因为它需要在开始编写输出之前将整个文件加载到内存中。

您认为可以有更好的解决方案,整体性能也会更好吗?

2 个答案:

答案 0 :(得分:1)

如果需要按顺序写出块

如果你想同时处理多个块,显然你需要同时在内存中保存多个块。

您可以决定要同时处理多少个块,并且足以同时读入尽可能多的块。例如。你可能会说你想同时处理5个块。这将限制内存使用量,并且仍可能最大限度地利用CPU资源。建议根据可用的CPU内核选择一个数字(如果处理块尚未使用多核)。可以使用runtime.GOMAXPROCS(0)查询。

你应该有一个goroutine按顺序读取输入文件,并且prodocue包含在Jobs中的块(也包含块索引)。

你应该拥有多个工作器goroutine,最好是你拥有的核心数量(但也可以尝试更小和更高的值)。每个工作人员都只接收工作,并在数据上调用elaborateBlock(),并在结果渠道上发送。

应该有一个指定的消费者接收已完成的作业,并将它们写入输出文件。由于goroutine并发运行,并且我们无法控制块的完成顺序,因此消费者应该跟踪要写入输出的下一个块的索引。只应存储无序到达的块,并且只有在后续块到达时才进行写入。

这是一个(不完整的)示例,如何做所有这些:

const BlockSize = 1 << 20 // 1 MB

func elaborateBlock(in []byte) []byte { return in }

type Job struct {
    Index int
    Block []byte
}

func producer(jobsCh chan<- *Job) {
    // Init input file:
    var inputFile *os.File

    for index := 0; ; index++ {
        job := &Job{
            Index: index,
            Block: make([]byte, BlockSize),
        }

        _, err := inputFile.Read(job.Block)
        if err != nil {
            break
        }

        jobsCh <- job
    }
}

func worker(jobsCh <-chan *Job, resultCh chan<- *Job) {
    for job := range jobsCh {
        job.Block = elaborateBlock(job.Block)
        resultCh <- job
    }
}

func consumer(resultCh <-chan *Job) {
    // Init output file:
    var outputFile *os.File

    nextIdx := 0
    jobMap := map[int]*Job{}

    for job := range resultCh {
        jobMap[job.Index] = job

        // Write out all blocks we have in contiguous index range:
        for {
            j := jobMap[nextIdx]
            if j == nil {
                break
            }
            if _, err := outputFile.Write(j.Block); err != nil {
                // handle error, maybe terminate?
            }
            delete(nextIdx) // This job is written out
            nextIdx++
        }
    }
}

func main() {
    jobsCh := make(chan *Job)
    resultCh := make(chan *Job)

    for i := 0; i < 5; i++ {
        go worker(jobsCh, resultCh)
    }

    wg := sync.WaitGroup{}
    wg.Add(1)
    go func() {
        defer wg.Done()
        consumer(resultCh)
    }()

    // Start producing jobs:
    producer(jobsCh)
    // No more jobs:
    close(jobsCh)

    // Wait for consumer to complete:
    wg.Wait()
}

有一点需要注意:仅此一项并不能保证限制已用内存。想象一下,第一个块需要大量时间来计算,而后续块则不需要。会发生什么?第一个区块将占用一名工人,其他工人将“快速”完成后续区块。消费者将所有内容存储在内存中,等待第一个块完成(因为必须首先写出)。这可能会增加内存使用量。

我们怎么能避免这种情况?

引入一个工作池。新工作不能随意创建,而是从池中获取。如果池为空,则生产者必须等待。因此,当生产者需要一个新的Job时,从池中取一个。当消费者写出Job时,将其放回池中。就那么简单。这也可以减少垃圾收集器的压力,因为不会创建和丢弃作业(以及大[]byte个缓冲区),它们可以重复使用。

对于简单的Job池实现,您可以使用缓冲通道。有关详细信息,请参阅How to implement Memory Pooling in Golang

如果可以按任何顺序写入块

另一种选择可能是提前分配输出文件。如果输出块的大小也是确定性的,则可以这样做(例如outsize := (insize / blocksize) * outblockSize)。

到底是什么?

如果您预先分配了输出文件,则使用者无需按顺序等待输入块。计算输入块后,您可以计算输出块的位置,寻找该位置并写入。为此,您可以使用File.Seek()

此解决方案仍然需要将块索引从生产者发送到消费者,但消费者不需要存储无序到达的块,因此消费者可以更简单,并且不需要存储已完成阻止,直到后续的到达为止继续写入输出文件。

请注意,此解决方案自然不会造成内存威胁,因为已完成的作业永远不会累积/缓存,而是按完成顺序写出来。

有关详细信息和技术,请参阅相关问题:

Is this an idiomatic worker thread pool in Go?

How to collect values from N goroutines executed in a specific order?

答案 1 :(得分:0)

这是一个应该工作的工作示例,并且尽可能接近原始代码。

这个想法是将您的数组转换为字节通道的通道。那么

  • 首先启动将读取此通道通道的消费者,获取字节通道,从中读取并写入结果。

  • 回到主线程,你创建一个字节通道,把它写到通道的通道(现在消费者从它们顺序读取将按顺序读取结果),然后启动将执行在分配的渠道(生产者)上工作和写作。

现在会发生的事情是会有一场比赛&#34;在销售者和消费者之间,一旦从消费者那里读出产生的块并写入与其相关的资源将被解除分配。这可能是对原始设计的改进。

这是代码和游乐场链接:

package main

import (
    "bytes"
    "fmt"
    "io"
    "sync"
)

func elaborateBlock(b []byte) []byte {
    return []byte("werkwerkwerk")
}

func blockThread(channel chan []byte, block []byte, wg *sync.WaitGroup) {
    channel <- elaborateBlock(block)
    wg.Done()
}

func main() {
    chans := make(chan chan []byte)
    BlockSize := 3
    inputBytes := bytes.NewBuffer([]byte("transmutemetowerkwerkwerk"))

    producewg := sync.WaitGroup{}
    consumewg := sync.WaitGroup{}
    consumewg.Add(1)
    go func() {
        chancount := 0
        for ch := range chans {
            data := <-ch
            fmt.Printf("got %d block, result:%s\n", chancount, data)
            chancount++
        }
        fmt.Printf("done receiving\n")
        consumewg.Done()
    }()
    for {
        buffer := make([]byte, BlockSize)
        _, err := inputBytes.Read(buffer)

        if err == io.EOF {
            go func() {
                //wait for all the procuders to finish
                producewg.Wait()
                //then close the main channel to notify the consumer
                close(chans)
            }()
            break
        }

        channel := make(chan []byte)
        chans <- channel //give the channel that we return the result to the receiver

        producewg.Add(1)
        go blockThread(channel, buffer, &producewg)
    }

    consumewg.Wait()
    fmt.Printf("main exiting")
}

playground link

作为一个小问题,我对#34;将整个文件读入内存&#34;感觉不对。声明因为你每次只是从阅读器中读取一个块,可能&#34;将整个计算的结果保存在内存中#34;更合适吗?