《GO并发编程实战》—— Concurrent Map

声明:本文是《Go并发编程实战》的样章,感谢图灵授权并发编程网站发布样章,禁止以任何形式转载此文。

我们在本章前面的部分中对Go语言提供的各种传统同步工具和方法进行了逐一的介绍。在本节,我们将运用它们来构造一个并发安全的字典(Map)类型。

我们已经知道,Go语言提供的字典类型并不是并发安全的。因此,我们需要使用一些同步方法对它进行扩展。这看起来并不困难。我们只要使用读写锁将针对一个字典类型值的读操作和写操作保护起来就可以了。确实,读写锁应该是我们首先想到的同步工具。不过,我们还不能确定只使用它是否就足够了。不管怎样,让我们先来编写并发安全的字典类型的第一个版本。

我们先来确定并发安全的字典类型的行为。还记得吗?依然,这需要声明一个接口类型。我们在第4章带领读者编写过OrderedMap接口类型及其实现类型。我们可以借鉴OrderedMap接口类型的声明并编写出需要在这里声明的接口类型ConcurrentMap。实际上,ConcurrentMap接口类型的方法集合应该是OrderedMap接口类型的方法集合的一个子集。我们只需从OrderedMap中去除那些代表有序Map特有行为的方法声明即可。既然是这样,我何不从这两个自定义的字典接口类型中抽出一个公共接口呢?

这个公共的字典接口类型可以是这样的:

// 泛化的Map的接口类型

type GenericMap interface {

// 获取给定键值对应的元素值。若没有对应元素值则返回nil。

Get(key interface{}) interface{}

// 添加键值对,并返回与给定键值对应的旧的元素值。若没有旧元素值则返回(nil, true)。

Put(key interface{}, elem interface{}) (interface{}, bool)

// 删除与给定键值对应的键值对,并返回旧的元素值。若没有旧元素值则返回nil。

Remove(key interface{}) interface{}

// 清除所有的键值对。

Clear()

// 获取键值对的数量。

Len() int

// 判断是否包含给定的键值。

Contains(key interface{}) bool

// 获取已排序的键值所组成的切片值。

Keys() []interface{}

// 获取已排序的元素值所组成的切片值。

Elems() []interface{}

// 获取已包含的键值对所组成的字典值。

ToMap() map[interface{}]interface{}

// 获取键的类型。

KeyType() reflect.Type

// 获取元素的类型。

ElemType() reflect.Type

}

然后,我们把这个名为GenericMap的字典接口类型嵌入到OrderedMap接口类型中,并去掉后者中的已在前者内声明的那些方法。修改后的OrderedMap接口类型如下:

// 有序的Map的接口类型。

type OrderedMap interface {

GenericMap // 泛化的Map接口

// 获取第一个键值。若无任何键值对则返回nil。

FirstKey() interface{}

// 获取最后一个键值。若无任何键值对则返回nil。

LastKey() interface{}

// 获取由小于键值toKey的键值所对应的键值对组成的OrderedMap类型值。

HeadMap(toKey interface{}) OrderedMap

// 获取由小于键值toKey且大于等于键值fromKey的键值所对应的键值对组成的OrderedMap类型值。

SubMap(fromKey interface{}, toKey interface{}) OrderedMap

// 获取由大于等于键值fromKey的键值所对应的键值对组成的OrderedMap类型值。

TailMap(fromKey interface{}) OrderedMap

}

我们要记得在修改完成后立即使用go test命令重新运行相关的功能测试,并以此确保这样的重构没有破坏任何现有的功能。

有了GenericMap接口类型之后,我们的ConcurrentMap接口类型的声明就相当简单了。由于后者没有任何特殊的行为,所以我们只要简单地将前者嵌入到后者的声明中即可,就像这样:

type ConcurrentMap interface {

GenericMap

}

下面我们来编写该接口类型的实现类型。我们依然使用一个结构体类型来充当,并把它命名为myConcurrentMap。myConcurrentMap类型的基本结构如下:

type myConcurrentMap struct {

m       map[interface{}]interface{}

keyType reflect.Type

elemType reflect.Type

rwmutex sync.RWMutex

}

有了编写myOrderedMap类型(还记得吗?它的指针类型是OrderedMap的实现类型)的经验,写出myConcurrentMap类型的基本结构也是一件比较容易的事情。可以看到,在基本需要之外,我们只为myConcurrentMap类型加入了一个代表了读写锁的rwmutex字段。此外,我们需要为myConcurrentMap类型添加的那些指针方法的实现代码实际上也可以以myOrderedMap类型中的相应方法为蓝本。不过,在实现前者的过程中要注意合理运用同步方法以保证它们的并发安全性。下面,我们就开始编写它们。

首先,我们来看Put、Remove和Clear这几个方法。它们都属于写操作,都会改变myConcurrentMap类型的m字段的值。

方法Put的功能是向myConcurrentMap类型值添加一个键值对。那么,我们在这个操作的前后一定要分别锁定和解锁rwmutex的写锁。Put方法的实现如下:

func (cmap *myConcurrentMap) Put(key interface{}, elem interface{}) (interface{}, bool) {

if !cmap.isAcceptablePair(key, elem) {

return nil, false

}

cmap.rwmutex.Lock()

defer cmap.rwmutex.Unlock()

oldElem := cmap.m[key]

cmap.m[key] = elem

return oldElem, true

}

该实现中的isAcceptablePair方法的功能是检查参数值key和elem是否均不为nil且它们的类型是否均与当前值允许的键类型和元素类型一致。在通过该检查之后,我们就需要对rwmutex进行锁定了。相应的,我们使用defer语句来保证对它的及时解锁。与此类似,我们在Remove和Clear方法的实现中也应该加入相同的操作。

与这些代表着写操作的方法相对应的,是代表读操作的方法。在ConcurrentMap接口类型中,此类方法有Get、Len、Contains、Keys、Elems和ToMap。我们需要分别在这些方法的实现中加入对rwmutex的读锁的锁定和解锁操作。以Get方法为例,我们应该这样来实现它:

func (cmap *myConcurrentMap) Get(key interface{}) interface{} {

cmap.rwmutex.RLock()

defer cmap.rwmutex.RUnlock()

return cmap.m[key]

}

这里有两点需要特别注意。

  • 我们在使用写锁的时候,要注意方法间的调用关系。比如,一个代表写操作的方法中调用了另一个代表写操作的方法。显然,我们在这两个方法中都会用到读写锁中的写锁。但如果使用不当,我们就会使前者被永远锁住。当然,对于代表写操作的方法调用代表读操作的方法的这种情况来说,也会是这样。请看下面的示例:
func (cmap *myConcurrentMap) Remove(key interface{}) interface{} { cmap.rwmutex.Lock() defer cmap.rwmutex.Unlock() oldElem := cmap.Get() delete(cmap.m, key) return oldElem }

可以看到,我们在Remove方法中调用了Get方法。并且,在这个调用之前,我们已经锁定了rwmutex的写锁。然而,由前面的展示可知,我们在Get方法的开始处对rwmutex的读锁进行了锁定。由于这两个锁定操作之间的互斥性,所以我们一旦调用这个Remove方法就会使当前Goroutine永远陷入阻塞。更严重的是,在这之后,其他Goroutine在调用该*myConcurrentMap类型值的一些方法(涉及到其中的rwmutex字段的读锁或写锁)的时候也会立即被阻塞住。

我们应该避免这种情况的方式。这里有两种解决方案。第一种解决方案是,把Remove方法中的oldElem := cmap.Get()语句与在它前面的那两条语句的位置互换,即变为:

oldElem := cmap.Get() cmap.rwmutex.Lock() defer cmap.rwmutex.Unlock()

这样可以保证在解锁读锁之后才会去锁定写锁。相比之下,第二种解决方案更加彻底一些,即:消除掉方法间的调用。也就是说,我们需要把oldElem := cmap.Get()语句替换掉。在Get方法中,体现其功能的语句是oldElem := cmap.m[key]。因此,我们把后者作为前者的替代品。若如此,那么我们必须保证该语句出现在对写锁的锁定操作之后。这样,我们才能依然确保其在锁的保护之下。实际上,通过这样的修改,我们升级了Remove方法中的被用来保护从m字段中获取对应元素值的这一操作的锁(由读锁升级至写锁)。

  • 对于rwmutex字段的读锁来说,虽然锁定它的操作之间不是互斥的,但是这些操作与相应的写锁的锁定操作之间却是互斥的。我们在上一条注意事项中已经说明了这一点。因此,为了最小化对写操作的性能的影响,我们应该在锁定读锁之后尽快的对其进行解锁。也就是说,我们要在相关的方法中尽量减少持有读锁的时间。这需要我们综合的考量。

依据前面的示例和注意事项说明,读者可以试着实现Remove、Clear、Len、Contains、Keys、Elems和ToMap方法。它们实现起来并不困难。注意,我们想让*myConcurrentMap类型成为ConcurrentMap接口类型的实现类型。因此,这些方法都必须是myConcurrentMap类型的指针方法。这包括马上要提及的那两个方法。

方法KeyType和ElemType的实现极其简单。我们可以直接分别返回myConcurrentMap类型的keyType字段和elemType字段的值。这两个字段的值应该是在myConcurrentMap类型值的使用方初始化它的时候给出的。

按照惯例,我们理应提供一个可以方便的创建和初始化并发安全的字典值的函数。我们把它命名为NewConcurrentMap,其实现如下:

func NewConcurrentMap(keyType, elemType reflect.Type) ConcurrentMap {

return &myConcurrentMap{

keyType: keyType,

elemType: elemType,

m:       make(map[interface{}]interface{})}

}

这个函数并没有什么特别之处。由于myConcurrentMap类型的rwmutex字段并不需要额外的初始化,所以它并没有出现在该函数中的那个复合字面量中。此外,为了遵循面向接口编程的原则,我们把该函数的结果的类型声明为了ConcurrentMap,而不是它的实现类型*myConcurrentMap。如果将来我们编写出了另一个ConcurrentMap接口类型的实现类型,那么就应该考虑调整该函数的名称。比如变更为NewDefaultConcurrentMap,或者其他。

待读者把还未实现的*myConcurrentMap类型的那几个方法都补全之后(可以利用NewConcurrentMap函数来检验这个类型是否是一个合格的ConcurrentMap接口的实现类型),我们就开始一起为该类型编写功能和性能测试了。

参照我们之前为*myOrderedMap类型编写的功能测试,我们可以很快的照猫画虎的创建出*myConcurrentMap类型的功能测试函数。这些函数和本小节前面讲到的所有代码都被放到了goc2p项目的basic/map1代码包中。其中,接口类型ConcurrentMap的声明和myConcurrentMap类型的基本结构及其所有的指针方法均在库源码文件cmap.go中。因此,我们应该把对应的测试代码放到cmap_test.go文件中。

既然有了很好的参照,作者并不想再赘述*myConcurrentMap类型的功能测试函数了。我希望读者能够先独立的编写出来并通过go test命令的检验,然后再去与cmap_test.go文件中的代码对照。

另外,在myConcurrentMap类型及其指针方法的实现中,我们多处用到了读写锁和反射API(声明在reflect代码包中的那些公开的程序实体)。它们执行的都是可能会对程序性能造成一定影响的操作。因此,针对*myConcurrentMap类型的性能测试(或称基准测试)是很有必要的。这样我们才能知道它的值在性能上到底与官方的字典类型有怎样的差别。

我们在测试源码文件cmap_test.go文件中声明两个基准测试函数——BenchmarkConcurrentMap和BenchmarkMap。顾名思义,这两个函数是分别被用来测试*myConcurrentMap类型和Go语言官方的字典类型的值的性能的。

在BenchmarkConcurrentMap函数中,我们执行这样一个流程。

(1) 初始化一个*myConcurrentMap类型的值,同时设定键类型和元素类型均为int32类型。

(2) 执行迭代次数预先给定(即该函数的*testing.B类型的参数b的字段N的值)的循环。在单次迭代中,我们向字典类型值添加一个键值对,然后再试图从该值中获取与当前键值对应的元素值。

(3) 打印出一行提示信息,包含该值的键类型、元素类型以及长度等内容。

下面是该函数的实现:

func BenchmarkConcurrentMap(b *testing.B) {

keyType := reflect.TypeOf(int32(2))

elemType := keyType

cmap := NewConcurrentMap(keyType, elemType)

var key, elem int32

fmt.Printf("N=%d.\n", b.N)

b.ResetTimer()

for i := 0; i < b.N; i++ {

b.StopTimer()

seed := int32(i)

key = seed

elem = seed << 10

b.StartTimer()

cmap.Put(key, elem)

_ = cmap.Get(key)

b.StopTimer()

b.SetBytes(8)

b.StartTimer()

}

ml := cmap.Len()

b.StopTimer()

mapType := fmt.Sprintf("ConcurrentMap<%s, %s>",

keyType.Kind().String(), elemType.Kind().String())

b.Logf("The length of % value is %d.\n", mapType, ml)

b.StartTimer()

}

在这段代码中,我们用到了参数b的几个方法。我们在第5章讲基准测试的时候说明过它们的功用。这里再简单回顾一下。b.ResetTimer方法的功能是将针对该函数的本次执行的计时器归零。而b.StartTimer方法和b.StopTimer方法的功能则分别是启动和停止这个计时器。在该函数体中,我们使用这三个方法忽略掉一些无关紧要的语句的执行时间。更具体的讲,我们只对for语句的for子句及其代码块中的cmap.Put(key, elem)语句和_ = cmap.Get(key)语句,以及ml := cmap.Len()语句的执行时间进行计时。注意,只要它们的耗时不超过1秒或由go test命令的benchtime标记给定的时间,那么测试运行程序就会尝试着多次执行该函数并在每次执行前增加b.N的值。所以,我们去掉无关语句的执行耗时也意味着会让BenchmarkConcurrentMap函数被执行更多次。

除此之外,我们还用到了b.SetBytes方法。它的作用是记录在单次操作中被处理的字节的数量。在这里,我们每次记录一个键值对所用的字节数量。由于键和元素的类型都是int32类型的,所以它们共会用掉8个字节。

在编写完成BenchmarkConcurrentMap函数之后,我们便可以如法炮制针对Go官方的字典类型的基准测试函数BenchmarkMap了。请注意,为了公平起见,我们在初始化这个字典类型值的时候也要把它的键类型和元素类型都设定为interface{},就像这样:

imap := make(map[interface{}]interface{})

但是,在为其添加键值对的时候要让键和元素值的类型均为int32类型。

在一切准备妥当之后,我们在相应目录下使用命令

go test -bench=”.” -run=”^$” -benchtime=1s -v

运行goc2p项目的basic/map1代码包中的基准测试。

稍等片刻,标准输出上会出现如下内容:

PASS

BenchmarkConcurrentMap N=1.

N=100.

N=10000.

N=1000000.

1000000             1612 ns/op           4.96 MB/s

--- BENCH: BenchmarkConcurrentMap

cmap_test.go:240: The length of ConcurrentMap<int32, int32>alue is 1.

cmap_test.go:240: The length of ConcurrentMap<int32, int32>alue is 100.

cmap_test.go:240: The length of ConcurrentMap<int32, int32>alue is 10000.

cmap_test.go:240: The length of ConcurrentMap<int32, int32>alue is 1000000.

BenchmarkMap   N=1.

N=100.

N=10000.

N=1000000.

N=2000000.

2000000               856 ns/op           9.35 MB/s

--- BENCH: BenchmarkMap

cmap_test.go:268: The length of Map<int32, int32> value is 1.

cmap_test.go:268: The length of Map<int32, int32> value is 100.

cmap_test.go:268: The length of Map<int32, int32> value is 10000.

cmap_test.go:268: The length of Map<int32, int32> value is 1000000.

cmap_test.go:268: The length of Map<int32, int32> value is 2000000.

ok     basic/map1     258.327s

我们看到,测试运行程序执行BenchmarkConcurrentMap函数的次数是4,而执行BenchmarkMap函数的次数是5。这从以“N=”为起始的输出内容和测试日志的行数上都可以看得出来。由我们前面提到的测试运行程序多次执行基准测试函数的前提条件已经可知,Go语言提供的字典类型的值的性能要比我们自行扩展的并发安全的*myConcurrentMap类型的值的性能好。具体的性能差距可以参看测试输出中的那两行代表了测试细节的内容,即:

1000000             1612 ns/op           4.96 MB/s

2000000               856 ns/op           9.35 MB/s

前者代表针对*myConcurrentMap类型值的测试细节。测试运行程序在1秒钟之内最多可以执行相关操作(包括添加键值对、根据键值获取元素值和获取字典类型值的长度)的次数为一百万,平均每次执行的耗时为1612纳秒。并且,根据我们在BenchmarkConcurrentMap函数中的设置,它每秒可以处理4.86兆字节的数据。

另一方面,Go语言方法的字典类型的值的测试细节是这样的:测试运行程序在1秒钟之内最多可以执行相关操作的次数为两百万,平均每次执行的耗时为856纳秒,根据BenchmarkMap函数中的设置,它每秒可以处理9.35兆字节的数据。

从上述测试细节可以看出,前者在性能上要比后者差,且差距将近一倍。这样的差距几乎都是由*myConcurrentMap类型及其方法中使用的读写锁造成的。

由此,我们也印证了,同步工具在为程序的并发安全提供支持的同时也会对其性能造成了不可忽视的损耗。这也使我们认识到:在使用同步工具的时候应该仔细斟酌并尽量平衡各个方面的指标,以使其无论是在功能上还是在性能上都能达到我们的要求。

顺便提一句,Go语言未对自定义泛型提供支持,以至于我们在编写此类扩展的时候并不是那么方便。有时候,我们不得不使用反射API。但是,众所周知,它们对程序性能的负面影响也是不可小觑的。因此,我们应该尽量减少对它们的使用。

原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: 《GO并发编程实战》—— Concurrent Map


郭蕾

并发编程网站长,InfoQ技术编辑。个人博客:http://guolei.us
FavoriteLoading添加本文到我的收藏
  • Trackback 关闭
  • 评论 (0)
  1. 暂无评论

您必须 登陆 后才能发表评论

return top