2

这是一个Stackoverflow上的问题但其实我去年就问过这个问题,但是被社区删除了,因为他们觉得引发了数据竞态报告就理应加锁,不需要讨论。但是在一些场景中,性能影响是需要考虑的,实际工作中也不可避免地用到各种奇淫技巧,所以这是值得讨论的。现在我找到了答案。

场景

这个问题其是只适用于少数情况,比如对于一个一写多读的Map,你可以理解为它是“只读”的Map。如果对其进行替换,不管是用锁还是不用锁,期望的行为就是替换前是旧Map,替换后是新Map,替换的瞬间,新旧Map同时存在,这是不可避免也是理所当然的。所以唯一需要讨论的就是,不使用锁是否会产生恐慌或是读取到不存在的错误数据?

太长不看:

从底层角度看,在绝大多数处理器架构上以及已知的Go版本中,对于map的覆盖写入是线程安全的。这很像C/C++中的实现定义行为(IB,Implementation-defined Behaviour)。但GO官方并不鼓励这种行为,他们觉得,对于数据竞态,应该采用宏观的串行措施保证程序安全,所以对任何宏观上并发读写的变量都会产生数据竞态警告。

详细:

首先,很多人觉得maphmap结构体,但是严格来说并不是这样,更准确来说map只是一个指向hmap的指针。因此,map的大小不会超过一个机器字长,unsafe.Sizeof(map1)在32位系统上显示4字节,在64位系统上显示8字节。

其次,你提到了机器字长,所以你应该知道,对于不超过机器字长的类型赋值,在绝大多数CPU中,是可以只通过一条指令完成的。那对map进行覆盖赋值用了多少条指令呢?

我们写一个这样的程序,左边是行号。为了更加严谨,我们对m1赋值两次,原因后面会说。

     1  package main
     2
     3  func main() {
     4          m1 := make(map[int]int, 20)
     5          m2 := make(map[int]int, 20)
     6          m1 = m2
     7          m1 = m2
     8          _ = m1
     9  }

将代码生成Go汇编。

go build -gcflags="-S -N -l" main.go 1> goasm.txt 2>&1

我们可以看到,Go汇编代码显示第6行只用到了一条Go汇编语句。但是第7行用了两条,但是我们发现都只是读写寄存器而已,将数据从内存读到寄存器,从寄存器写入内存,这个过程不存在中间态,因为一个核心的寄存器不存在被其他核心覆盖的可能。所以核心操作只有一条,就是将寄存器赋值给内存MOV REG MEM

    0x0070 00112 (main.go:6)    MOVQ    AX, main.m1+32(SP)
    0x0075 00117 (main.go:7)    MOVQ    main.m2+24(SP), DX
    0x007a 00122 (main.go:7)    MOVQ    DX, main.m1+32(SP)

由于Go汇编语句只是伪汇编,为的是在不同硬件平台上都能编译。所以我我们直接反汇编。

objdump -S -d main > objdump.txt

在amd64 linux机器上,反汇编的结果如下。核心操作是将寄存器的值写入内存。

    m1 = m2
  4576f0:    48 89 44 24 20           mov    %rax,0x20(%rsp)
    m1 = m2
  4576f5:    48 8b 54 24 18           mov    0x18(%rsp),%rdx
  4576fa:    48 89 54 24 20           mov    %rdx,0x20(%rsp)

所以,问题2,MOV操作是原子的吗?

是的,在现今绝大多数的架构上,对于内存对齐的不长于机器字长的MOV操作,都是原子的。

参考Go的内存模型Go内存对齐策略英特尔64架构开发手册

Otherwise, a read r of a memory location x that is not larger than a machine word must observe some write w such that r does not happen before w and there is no write w' such that w happens before w' and w' happens before r. That is, each read must observe a value written by a preceding or concurrent write.

这段话很晦涩,但总的意思是每一次不超过字长的读写都是完整的、原子的,不存在读/写了一半的情况。

The following minimal alignment properties are guaranteed:

1. For a variable x of any type: unsafe.Alignof(x) is at least 1.
2. For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
3. For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array's element type.

A struct or array type has size zero if it contains no fields (or elements, respectively) that have a size greater than zero. Two distinct zero-size variables may have the same address in memory.

Go保证变量在内存以某种方式对齐。

8.1.1 Guaranteed Atomic Operations
The Intel486 processor (and newer processors since) guarantees that the following basic memory operations will
always be carried out atomically:
• Reading or writing a byte
• Reading or writing a word aligned on a 16-bit boundary
• Reading or writing a doubleword aligned on a 32-bit boundary
The Pentium processor (and newer processors since) guarantees that the following additional memory operations
will always be carried out atomically:
• Reading or writing a quadword aligned on a 64-bit boundary
• 16-bit accesses to uncached memory locations that fit within a 32-bit data bus
The P6 family processors (and newer processors since) guarantee that the following additional memory operation
will always be carried out atomically:
• Unaligned 16-, 32-, and 64-bit accesses to cached memory that fit within a cache line

英特尔开发手册保证对于目前绝大多数英特尔处理器,对不超过64位的寄存器读写操作保证原子。

所以答案是,可以,在绝大多数处理器架构上以及已知的Go版本中,对于map的覆盖写入是线程安全的。尽管Go官方并不鼓励这种做法,并警告到

编写无数据竞争的Go程序的程序员,可以依赖这些程序的串行执行,就像其他现代编程语言一样。

对于包含数据竞争的程序来说,无论是程序员还是编译器,都应该记住一个建议:不要过于聪明。


rwxe
91 声望5 粉丝

no tengo trabajo.