【问题标题】:Go: Thread-Safe Concurrency Issue with Sparse Array Read & WriteGo:稀疏数组读写的线程安全并发问题
【发布时间】:2015-04-05 00:50:27
【问题描述】:

我正在用 Go 编写一个搜索引擎,其中我对每个单词的相应结果都有一个单词的倒排索引。有一组单词字典,因此单词已经转换为 StemID,它是从 0 开始的整数。这允许我使用指针切片(即 sparse array)来映射每个 StemID到包含该查询结果的结构。例如。 var StemID_to_Index []*resultStruct。如果aardvark0,则指向aardvark 的resultStruct 的指针位于StemID_to_Index[0],如果当前未加载该单词的结果,则该指针将是nil

服务器上没有足够的内存将所有这些存储在内存中,因此每个StemID 的结构将保存为单独的文件,并且可以将这些文件加载​​到StemID_to_Index 切片中。如果StemID_to_Index对于这个StemID当前是nil,那么结果没有缓存,需要加载,否则已经加载(缓存)了,所以可以直接使用。每次加载新结果时,都会检查内存使用情况,如果超过阈值,则丢弃 2/3 的加载结果(对于这些 StemID,StemID_to_Index 设置为 nil,并且强制进行垃圾收集。)

我的问题是并发性。我可以让多个线程同时搜索而不会遇到不同线程同时尝试读取和写入同一个地方的问题的最快和最有效的方法是什么?我试图避免在所有内容上使用互斥锁,因为这会减慢每次访问尝试。

你认为我会在工作线程中从磁盘加载结果,然后使用通道将指向该结构的指针传递给“更新程序”线程,然后更新StemID_to_Index 中的nil 值吗?切片到加载结果的指针?这意味着两个线程永远不会尝试同时写入,但是如果另一个线程尝试从 StemID_to_Index 的确切索引中读取,而“更新程序”线程正在更新指针,会发生什么?对于当前正在加载的结果,是否为线程提供nil 指针并不重要,因为它只会被加载两次,虽然这会浪费资源,但它仍然会提供相同的结果,因为那是不太可能经常发生,这是可以原谅的。

另外,将要更新的指针发送到“updater”线程的工作线程如何知道“updater”线程何时完成了对切片中指针的更新?它应该只是休眠并继续检查,还是有一种简单的方法让更新程序将消息发送回推送到频道的特定线程?

更新

我制作了一个小测试脚本,看看如果在修改指针的同时尝试访问指针会发生什么......它似乎总是可以的。没有错误。我错过了什么吗?

package main

import (
    "fmt"
    "sync"
)

type tester struct {
 a uint
}

var things *tester

func updater() {
    var a uint
    for {
        what := new(tester)
        what.a = a
        things = what
        a++
    }
}

func test() {
    var t *tester
    for {
        t = things
        if t != nil {
            if t.a < 0 {
                fmt.Println(`Error1`)
            }
        } else {
            fmt.Println(`Error2`)
        }
    }
}

func main() {
    var wg sync.WaitGroup
    things = new(tester)
    go test()
    go test()
    go test()
    go test()
    go test()
    go test()
    go updater()
    go test()
    go test()
    go test()
    go test()
    go test()
    wg.Add(1)
    wg.Wait()
}

更新 2

更进一步,即使我同时从多个线程读取和写入同一个变量......它没有区别,仍然没有错误:

从上面:

func test() {
    var a uint
    var t *tester
    for {
        t = things
        if t != nil {
            if t.a < 0 {
                fmt.Println(`Error1`)
            }
        } else {
            fmt.Println(`Error2`)
        }
        what := new(tester)
        what.a = a
        things = what
        a++
    }
}

这意味着我根本不必担心并发性......再次:我在这里遗漏了什么吗?

【问题讨论】:

  • There are no benign data races!使用竞赛检测器运行您的最后一个示例。仅仅因为您无法引发错误或观察到未定义的行为,并不意味着它不会发生。你要么有比赛,要么没有。
  • 感谢您的链接,这很有趣!但问题是,可能只有两个结果,一个指向结构的指针,它总是包含相同的数据……或者为零。就我而言,它是哪一个没有区别。因此,指针是否过时并不重要,只有在可能发生实际运行时错误的情况下,例如“nil 指针取消引用”,仅当指针实际损坏时才会发生。只要它始终是一个有效的指针(或 nil),我就没有问题,即使它已经过时了。

标签: multithreading go


【解决方案1】:

这听起来像是 memory mapped file 的完美用例:

package main

import (
    "log"
    "os"
    "unsafe"

    "github.com/edsrzf/mmap-go"
)

func main() {
    // Open the backing file
    f, err := os.OpenFile("example.txt", os.O_RDWR|os.O_CREATE, 0644)
    if err != nil {
        log.Fatalln(err)
    }
    defer f.Close()

    // Set it's size
    f.Truncate(1024)

    // Memory map it
    m, err := mmap.Map(f, mmap.RDWR, 0)
    if err != nil {
        log.Fatalln(err)
    }
    defer m.Unmap()

    // m is a byte slice
    copy(m, "Hello World")
    m.Flush()

    // here's how to use it with a pointer
    type Coordinate struct{ X, Y int }
    // first get the memory address as a *byte pointer and convert it to an unsafe
    // pointer
    ptr := unsafe.Pointer(&m[20])
    // next convert it into a different pointer type
    coord := (*Coordinate)(ptr)
    // now you can use it directly
    *coord = Coordinate{1, 2}
    m.Flush()
    // and vice-versa
    log.Println(*(*Coordinate)(unsafe.Pointer(&m[20])))
}

内存映射可以大于实际内存,操作系统会为您处理所有混乱的细节。

您仍然需要确保不同的 goroutine 不会同时读取/写入同一段内存。

【讨论】:

    【解决方案2】:

    我的最佳答案是在 elastigo 这样的客户端上使用 elasticsearch。

    如果这不是一个选项,那么了解您对种族行为的关注程度真的会有所帮助。如果您不在乎,可能会在读取完成后立即发生写入,完成读取的用户将获得陈旧的数据。您可以只拥有一个写入和读取操作队列,并让多个线程馈入该队列,并且一个调度程序在映射出现时一次一个地向映射发出操作。在所有其他情况下,如果有多个读取器和写入器,您将需要一个互斥锁。地图不是thread safe in go

    不过,老实说,我现在只想添加一个互斥锁以使事情变得简单,并通过分析瓶颈的实际位置进行优化。似乎您检查阈值然后清除 2/3 的缓存有点武断,如果您通过这样做来降低性能,我不会感到惊讶。以下是可能发生故障的情况:

    请求者 1、2、3 和 4 经常访问文件 A 和 B 上的许多相同单词。 请求者 5、6、7 和 8 经常访问存储在文件 C 和 D 中的许多相同单词。

    现在,当这些请求者和文件之间交错的请求快速连续发生时,您最终可能会一遍又一遍地清除 2/3 的缓存,以便在不久之后请求结果。还有其他几种方法:

    1. 将经常同时访问的词缓存在同一个盒子上,并有多个缓存盒子。
    2. 缓存基于每个单词,并对该单词的流行程度进行某种排名。如果在缓存已满时从文件中访问了一个新词,请查看该文件中是否存在其他更流行的词,并清除缓存中不太流行的条目,以希望这些词具有更高的命中率。
    3. 方法 1 和 2。

    【讨论】:

    • 对于清除,我打算按上次访问排序,然后清除最长时间前访问的 2/3。这些很可能都是晦涩难懂的术语,很少被搜索 - 在这种情况下,只要人们继续搜索,就永远不会清除常用术语。
    • 关于更新...它将始终是相同的数据,因为索引在开始时仅创建一次。因此,每个结果要么存在,要么为零,然后加载,从用户的角度来看,这两种方式都无关紧要。唯一的问题是由于读取一半写入而导致的 nil 指针取消引用,但这似乎没有发生,有可能吗?
    • 我已经更新了并发的答案。你是对的,让读者和作家读/写相同的数据不是线程安全的。您必须使用互斥锁。 stackoverflow.com/questions/11063473/map-with-concurrent-access
    • 那为什么在我的测试中没关系呢?没有错误,一切都很好。请参阅更新 2。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-12-31
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2019-05-28
    相关资源
    最近更新 更多